From 1a7ff64fe93278d51de1790293f06fb24ae1b47b Mon Sep 17 00:00:00 2001 From: Nicolas Brousse Date: Sat, 31 Oct 2026 22:10:32 -0700 Subject: [PATCH 1/2] chore(discipline): upgrade to v0.29.0 Re-pin all orieg/discipline action usages and regenerate agent hooks with discipline 0.29.0 (observe mode). allow-agent-instructions: .agents/hooks.json regenerated by discipline hook install --agent agy --upgrade to v0.29.0 hook format allow-agent-instructions: .claude/hooks/discipline-bootstrap.sh regenerated by discipline hook install --agent claude-code --upgrade to v0.29.0 (version v0.18.0 -> v0.29.0) allow-agent-instructions: .claude/settings.json regenerated by discipline hook install --agent claude-code --upgrade to v0.29.0 hook format allow-agent-instructions: .github/hooks/discipline.json regenerated by discipline hook install --agent copilot --upgrade to v0.29.0 hook format allow-agent-instructions: .opencode/plugins/discipline.js regenerated by discipline hook install --agent opencode --upgrade to v0.29.0 allow-swallow: .opencode/plugins/discipline.js observe-mode plugin contract: every empty catch is deliberate --- .agents/hooks.json | 12 ++ .claude/hooks/discipline-bootstrap.sh | 3 +- .claude/settings.json | 9 ++ .github/hooks/discipline.json | 6 + .github/workflows/ci.yml | 10 +- .github/workflows/copilot-setup-steps.yml | 2 +- .opencode/plugins/discipline.js | 163 +++++++++++++++++++--- 7 files changed, 176 insertions(+), 29 deletions(-) diff --git a/.agents/hooks.json b/.agents/hooks.json index 1b53f41..e437bd5 100644 --- a/.agents/hooks.json +++ b/.agents/hooks.json @@ -1,5 +1,17 @@ { "discipline": { + "PostToolUse": [ + { + "hooks": [ + { + "command": "command -v discipline >/dev/null 2>&1 || { echo 'discipline is not on PATH; the discipline hook did not run (https://orieg.github.io/discipline/)' >&2; exit 0; }; discipline hook run --help 2>/dev/null | grep -q post-tool || { echo 'discipline on PATH predates v0.22.0 (no --event post-tool); the post-tool hook did not run (https://orieg.github.io/discipline/)' >&2; exit 0; }; discipline hook run --agent agy --event post-tool", + "timeout": 30, + "type": "command" + } + ], + "matcher": "run_command" + } + ], "PreToolUse": [ { "hooks": [ diff --git a/.claude/hooks/discipline-bootstrap.sh b/.claude/hooks/discipline-bootstrap.sh index 448ee8b..64d1076 100755 --- a/.claude/hooks/discipline-bootstrap.sh +++ b/.claude/hooks/discipline-bootstrap.sh @@ -1,5 +1,6 @@ #!/bin/bash # Written by `discipline hook install --agent claude-code`. +# discipline-hook-file: sums=release sha256=78c33496ba2f18958b18a8bb15a23610b7f40f84b66f23bdb1ceb3bd10d83e38 # A Claude Code cloud session starts on a fresh VM without discipline. This installs # the pinned release, checksum-verified, so the hooks in .claude/settings.json can # check the change. Locally it does nothing: install discipline yourself. @@ -9,7 +10,7 @@ set -u [ "${CLAUDE_CODE_REMOTE:-}" = "true" ] || exit 0 command -v discipline >/dev/null 2>&1 && exit 0 -version="v0.18.0" +version="v0.29.0" say() { echo "discipline bootstrap: $*" >&2; } case "$(uname -m)" in x86_64|amd64) arch="x86_64" ;; diff --git a/.claude/settings.json b/.claude/settings.json index d0dbf7d..9643a49 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -9,6 +9,15 @@ } ], "matcher": "Edit|Write|MultiEdit|NotebookEdit" + }, + { + "hooks": [ + { + "command": "command -v discipline >/dev/null 2>&1 || { echo 'discipline is not on PATH; the discipline hook did not run (https://orieg.github.io/discipline/)' >&2; exit 0; }; discipline hook run --help 2>/dev/null | grep -q post-tool || { echo 'discipline on PATH predates v0.22.0 (no --event post-tool); the post-tool hook did not run (https://orieg.github.io/discipline/)' >&2; exit 0; }; discipline hook run --agent claude-code --event post-tool", + "type": "command" + } + ], + "matcher": "Bash" } ], "PreToolUse": [ diff --git a/.github/hooks/discipline.json b/.github/hooks/discipline.json index 18adf25..fd9cf4b 100644 --- a/.github/hooks/discipline.json +++ b/.github/hooks/discipline.json @@ -13,6 +13,12 @@ "matcher": "create|edit|str_replace_editor|apply_patch", "timeoutSec": 120, "type": "command" + }, + { + "bash": "command -v discipline >/dev/null 2>&1 || { echo 'discipline is not on PATH; the discipline hook did not run (https://orieg.github.io/discipline/)' >&2; exit 0; }; discipline hook run --help 2>/dev/null | grep -q post-tool || { echo 'discipline on PATH predates v0.22.0 (no --event post-tool); the post-tool hook did not run (https://orieg.github.io/discipline/)' >&2; exit 0; }; discipline hook run --agent copilot --event post-tool", + "matcher": "bash", + "timeoutSec": 30, + "type": "command" } ], "preToolUse": [ diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6afd888..ca9aca0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -283,7 +283,7 @@ jobs: # that this job can write pull-request comments. persist-credentials: false - name: Run Discipline Diff Sentinel - uses: orieg/discipline@1edfbb883459be2364ef932eb5f438dae8b7cafa # v0.18.0 + uses: orieg/discipline@49f0d47e774244615e084e207784a9a0e6082cd5 # v0.29.0 with: fail_on_warnings: true # Judge the change with main's discipline.toml, so a PR cannot loosen @@ -1157,7 +1157,7 @@ jobs: fetch-depth: 0 - name: Verify package manifest and version lockstep - uses: orieg/discipline@1edfbb883459be2364ef932eb5f438dae8b7cafa # v0.18.0 + uses: orieg/discipline@49f0d47e774244615e084e207784a9a0e6082cd5 # v0.29.0 id: discipline-manifest with: enable: manifest-sync,version-lockstep @@ -1177,7 +1177,7 @@ jobs: # libjudy/ ships. This asserts the other direction: tools/ and # research/ must not. Both were absent from package.xml only by # happenstance until this gate existed. - uses: orieg/discipline@1edfbb883459be2364ef932eb5f438dae8b7cafa # v0.18.0 + uses: orieg/discipline@49f0d47e774244615e084e207784a9a0e6082cd5 # v0.29.0 with: enable: archive-contents fail_on_warnings: true @@ -1658,7 +1658,7 @@ jobs: - name: Run test-floor identity ratchet # Fork PRs run with a read-only GITHUB_TOKEN; skip API writes there. if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.fork == false }} - uses: orieg/discipline@1edfbb883459be2364ef932eb5f438dae8b7cafa # v0.18.0 + uses: orieg/discipline@49f0d47e774244615e084e207784a9a0e6082cd5 # v0.29.0 with: enable: test-floor test_report: reports/junit.xml @@ -2912,7 +2912,7 @@ jobs: fetch-depth: 0 - name: Skip set matches each job's if (ci-skip-set) if: ${{ !cancelled() }} - uses: orieg/discipline@1edfbb883459be2364ef932eb5f438dae8b7cafa # v0.18.0 + uses: orieg/discipline@49f0d47e774244615e084e207784a9a0e6082cd5 # v0.29.0 with: suite: integrity fail_on_warnings: true diff --git a/.github/workflows/copilot-setup-steps.yml b/.github/workflows/copilot-setup-steps.yml index 2637973..0cc4097 100644 --- a/.github/workflows/copilot-setup-steps.yml +++ b/.github/workflows/copilot-setup-steps.yml @@ -24,6 +24,6 @@ jobs: contents: read steps: - name: Install discipline for Copilot's hooks - uses: orieg/discipline@1edfbb883459be2364ef932eb5f438dae8b7cafa # v0.18.0 + uses: orieg/discipline@49f0d47e774244615e084e207784a9a0e6082cd5 # v0.29.0 with: install_only: 'true' diff --git a/.opencode/plugins/discipline.js b/.opencode/plugins/discipline.js index dc12d1e..29bf614 100644 --- a/.opencode/plugins/discipline.js +++ b/.opencode/plugins/discipline.js @@ -1,12 +1,31 @@ // Written by `discipline hook install --agent opencode`. +// discipline-hook-file: mode=observe sha256=7455debabe1fd05598196ab6a956e551713965a4483d70a16d45a30fca073b48 // When a session is created, takes this worktree's lease for it. Before an edit tool, refuses an edit outside this session's worktree (the tool call // is sent on stdin; a refusal throws, and the model reads the reason). After it, runs // the discipline check and, when it fails, appends the report to the tool's output so -// the model reads it and repairs the change. +// the model reads it and repairs the change. On OpenCode 2.x, at the end of a turn, sends +// the final message to the end-of-turn checks; a refusal goes back to the model. // Observe mode: no hook below refuses a tool call or throws, whatever the command does (a non-zero exit, a command that is not found, any other error). import { $ } from "bun" const EDIT_TOOLS = ["edit", "write", "apply_patch"] +// OpenCode 2.x names its shell tool `shell`; 1.x named it `bash`. +const SHELL_TOOLS = ["bash", "shell"] +// 1.x: a shell call's arguments, kept from `tool.execute.before` for `tool.execute.after`. +const SHELL_ARGS = new Map() +const HOOK_TIMEOUT_MS = 30_000 +const CHECK_TIMEOUT_MS = 120_000 + +// Stops waiting for `p` after `ms`. It cannot kill the command: Bun's `$` gives no handle +// to the child process, so a command that outlives the timeout runs on until it exits. +const withTimeout = (p, ms) => { + let timer + const limit = new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(`discipline hook timed out after ${ms / 1000}s`)), ms) + timer?.unref?.() + }) + return Promise.race([Promise.resolve(p), limit]).finally(() => clearTimeout(timer)) +} export const Discipline = async ({ $, directory }) => ({ // A new session takes this worktree's lease; it never blocks the session. @@ -14,20 +33,37 @@ export const Discipline = async ({ $, directory }) => ({ try { if (event.type !== "session.created") return const start = new Response(JSON.stringify({ input: { sessionID: event.properties?.sessionID }, cwd: event.properties?.info?.directory ?? directory })) - await $`discipline hook run --agent opencode --event session-start < ${start}`.nothrow().quiet() + await withTimeout($`discipline hook run --agent opencode --event session-start < ${start}`.nothrow().quiet(), HOOK_TIMEOUT_MS) } catch {} }, "tool.execute.before": async (input, output) => { try { - if (!EDIT_TOOLS.includes(input.tool) && input.tool !== "bash") return + if (!EDIT_TOOLS.includes(input.tool) && !SHELL_TOOLS.includes(input.tool)) return + if (input.callID && SHELL_TOOLS.includes(input.tool)) { + if (SHELL_ARGS.size > 256) SHELL_ARGS.clear() + SHELL_ARGS.set(input.callID, output?.args) + } const call = new Response(JSON.stringify({ input, output, cwd: directory })) - await $`discipline hook run --agent opencode --event pre-tool --observe < ${call}`.cwd(directory).nothrow().quiet() + await withTimeout($`discipline hook run --agent opencode --event pre-tool --observe < ${call}`.cwd(directory).nothrow().quiet(), HOOK_TIMEOUT_MS) } catch {} }, "tool.execute.after": async (input, output) => { + if (SHELL_TOOLS.includes(input.tool)) { + try { + const args = input.args ?? SHELL_ARGS.get(input.callID) ?? {} + SHELL_ARGS.delete(input.callID) + const ran = new Response(JSON.stringify({ + input: { tool: input.tool, sessionID: input.sessionID }, + output: { args, metadata: output?.metadata, output: output?.output }, + cwd: directory + })) + await withTimeout($`discipline hook run --agent opencode --event post-tool < ${ran}`.cwd(directory).nothrow().quiet(), HOOK_TIMEOUT_MS) + } catch {} + return + } try { if (!EDIT_TOOLS.includes(input.tool)) return - const r = await $`discipline hook run --agent opencode --observe`.cwd(directory).nothrow().quiet() + const r = await withTimeout($`discipline hook run --agent opencode --observe`.cwd(directory).nothrow().quiet(), CHECK_TIMEOUT_MS) if (r.exitCode !== 0) { output.output += "\n\n" + r.stdout.toString() + r.stderr.toString() } @@ -37,41 +73,124 @@ export const Discipline = async ({ $, directory }) => ({ export default { id: "discipline", - setup({ tool, event, location }) { + setup({ tool, event, location, session }) { const directory = location?.directory ?? process.cwd() + // `opencode run --standalone` serves this plugin over stdio (`serve --stdio`; the + // background service is `serve --service`), and exits before a continuation runs. + const STANDALONE = process.argv.includes("--stdio") + // Each session's turn so far, by session id. + const TURNS = new Map() + const TURN_ERROR = () => {} - event?.subscribe?.(async (e) => { - try { - if (e?.type !== "session.created") return - const start = new Response(JSON.stringify({ - input: { sessionID: e.properties?.sessionID }, - cwd: e.properties?.info?.directory ?? directory - })) - await $`discipline hook run --agent opencode --event session-start < ${start}`.nothrow().quiet() - } catch {} - }) + // OpenCode 2.x has no stop hook: at the end of a turn the final message and whether + // a tool call followed it are sent to `hook run --event stop`, which judges them + // (`[hooks.premature-stop]`, `[hooks.verified-stop]`). It never holds the session: + // nothing waits for it. + const endOfTurn = async (sessionID, turn, cwd) => { + const payload = new Response(JSON.stringify({ + hook_event_name: "Stop", + session_id: sessionID, + last_assistant_message: turn.text, + tool_call_followed: turn.toolAfter, + standalone: STANDALONE, + cwd + })) + await withTimeout($`discipline hook run --agent opencode --event stop --observe < ${payload}`.cwd(cwd).nothrow().quiet(), CHECK_TIMEOUT_MS) + } + + // OpenCode 2.x registers no event callback: `event.subscribe()` returns the events + // as an async iterable. A session new to this plugin takes the lease on the first of + // `session.created` (not delivered to a plugin loaded after the session was made, + // as under `opencode run --standalone`) and `session.execution.started`. + const events = event?.subscribe?.() + if (typeof events?.[Symbol.asyncIterator] === "function") { + const leased = new Set() + ;(async () => { + for await (const e of events) { + try { + const turnID = e?.data?.sessionID + if (typeof turnID === "string") { + if (e.type === "session.execution.started") { + if (TURNS.size > 256) TURNS.clear() + TURNS.set(turnID, { run: (TURNS.get(turnID)?.run ?? 0) + 1, text: undefined, message: undefined, toolAfter: false, cwd: undefined }) + } else if (TURNS.has(turnID)) { + const turn = TURNS.get(turnID) + // `session.execution.succeeded` names no directory (RUN, 2.0.28); the turn's other events do. + if (typeof e.location?.directory === "string") turn.cwd = e.location.directory + if (e.type === "session.text.ended" && typeof e.data.text === "string") { + const same = turn.message === e.data.assistantMessageID && !turn.toolAfter && turn.text !== undefined + turn.text = same ? turn.text + "\n\n" + e.data.text : e.data.text + turn.message = e.data.assistantMessageID + turn.toolAfter = false + } else if (e.type === "session.tool.called" || (e.type === "session.step.ended" && e.data.finish === "tool-calls")) { + turn.toolAfter = true + } else if (e.type === "session.execution.succeeded") { + endOfTurn(turnID, { ...turn }, turn.cwd ?? directory).catch(TURN_ERROR) + } + } + } + } catch {} + try { + if (e?.type !== "session.created" && e?.type !== "session.execution.started") continue + const sessionID = e.data?.sessionID + if (typeof sessionID !== "string" || leased.has(sessionID)) continue + leased.add(sessionID) + const start = new Response(JSON.stringify({ + input: { sessionID }, + cwd: e.location?.directory ?? directory + })) + await withTimeout($`discipline hook run --agent opencode --event session-start < ${start}`.nothrow().quiet(), HOOK_TIMEOUT_MS) + } catch {} + } + })().catch(() => {}) + } tool?.hook?.("execute.before", async (call) => { try { const toolName = call.tool ?? call.input?.tool - if (!EDIT_TOOLS.includes(toolName) && toolName !== "bash") return + if (!EDIT_TOOLS.includes(toolName) && !SHELL_TOOLS.includes(toolName)) return const payload = new Response(JSON.stringify({ input: { tool: toolName, sessionID: call.sessionID }, output: { args: call.input ?? {} }, cwd: directory })) - await $`discipline hook run --agent opencode --event pre-tool --observe < ${payload}`.cwd(directory).nothrow().quiet() + await withTimeout($`discipline hook run --agent opencode --event pre-tool --observe < ${payload}`.cwd(directory).nothrow().quiet(), HOOK_TIMEOUT_MS) } catch {} }) tool?.hook?.("execute.after", async (call) => { + const shellName = call.tool ?? call.input?.tool + if (SHELL_TOOLS.includes(shellName)) { + try { + const text = Array.isArray(call.result?.content) + ? call.result.content.filter((c) => typeof c?.text === "string").map((c) => c.text).join("\n") + : call.result?.output + const ran = new Response(JSON.stringify({ + input: { tool: shellName, sessionID: call.sessionID }, + output: { + args: call.input ?? {}, + metadata: call.result?.metadata, + output: typeof text === "string" ? text : undefined + }, + cwd: directory + })) + await withTimeout($`discipline hook run --agent opencode --event post-tool < ${ran}`.cwd(directory).nothrow().quiet(), HOOK_TIMEOUT_MS) + } catch {} + return + } try { const toolName = call.tool ?? call.input?.tool if (!EDIT_TOOLS.includes(toolName)) return - const r = await $`discipline hook run --agent opencode --observe`.cwd(directory).nothrow().quiet() - if (r.exitCode !== 0) { - if (call.result) { - call.result.output = (call.result.output ?? "") + "\n\n" + r.stdout.toString() + r.stderr.toString() + const none = new Response("") + const r = await withTimeout($`discipline hook run --agent opencode --observe < ${none}`.cwd(directory).nothrow().quiet(), CHECK_TIMEOUT_MS) + if (r.exitCode !== 0 && call.result) { + const report = "\n\n" + r.stdout.toString() + r.stderr.toString() + if (Array.isArray(call.result.content)) { + const part = call.result.content.find((c) => c?.type === "text" && typeof c.text === "string") + if (part) part.text += report + else call.result.content.push({ type: "text", text: report.trimStart() }) + } else if (typeof call.result.output === "string") { + call.result.output += report } } } catch {} From 1d9c32a5f807420c5d255be3a38496331b3687f2 Mon Sep 17 00:00:00 2001 From: Nicolas Brousse Date: Sat, 31 Oct 2026 23:04:04 -0700 Subject: [PATCH 2/2] chore(discipline): enable provenance-tags (diff-scoped) Enable provenance-tags for benchmark/doc numerics, scoped to changed paragraphs to avoid churning legacy figures. allow-gate-weakening: provenance-tags limit checks to changed paragraphs to avoid flagging untouched legacy numbers --- discipline.toml | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/discipline.toml b/discipline.toml index 4ed8077..501ee54 100644 --- a/discipline.toml +++ b/discipline.toml @@ -47,6 +47,11 @@ diff_only = true [gates.agent-scratch] enabled = true +[gates.provenance-tags] +enabled = true +diff_only = true +# BENCHMARK.md and examples carry measured numbers; require (measured|target|projected) tags. + # ── CI & Packaging Integrity ───────────────────────────────────────────── [gates.ci-integrity] enabled = true @@ -125,3 +130,23 @@ paths = [ "**/*bench*.txt", "baselines/latest.json", ] + +# ── Verification (intentionally off for this project) ──────────────────── +# sanitizers: discipline's gate is Rust/Cargo-oriented (nightly toolchain) and +# has no command hook for a C/PHP extension build. Memory safety for the C side +# is covered by a dedicated CI job instead (see issue/PR notes). +# [gates.sanitizers] +# enabled = false + +# command: would duplicate the make test run on every discipline check and slow +# local/PR checks; test-floor already ratchets test counts. Revisit if a cheap +# smoke command is wanted. +# [gates.command] +# enabled = false + +# citation-metadata: no CITATION.cff/.zenodo.json; enable only if a Zenodo DOI +# or academic citation record is desired for releases. +# [gates.citation-metadata] +# enabled = false + +# agents-md: CLAUDE.md is a short quick reference, AGENTS.md is the full guide.