Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .agents/hooks.json
Original file line number Diff line number Diff line change
@@ -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": [
Expand Down
3 changes: 2 additions & 1 deletion .claude/hooks/discipline-bootstrap.sh
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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" ;;
Expand Down
9 changes: 9 additions & 0 deletions .claude/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down
6 changes: 6 additions & 0 deletions .github/hooks/discipline.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down
10 changes: 5 additions & 5 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/copilot-setup-steps.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
163 changes: 141 additions & 22 deletions .opencode/plugins/discipline.js
Original file line number Diff line number Diff line change
@@ -1,33 +1,69 @@
// 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.
event: async ({ event }) => {
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()
}
Expand All @@ -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 {}
Expand Down
25 changes: 25 additions & 0 deletions discipline.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Loading