Hooks¶
Eleven engine hooks are wired by install.sh into each harness it finds —
settings.json on Claude Code, config.toml on Codex CLI (eight of the eleven scripts
land there, as ten declarations: an API error, a tool failure, a task, a file change and a
config change are not events Codex has). They
live in <asset home>/hooks/ and are no-ops unless a run is active — several ride
more than one event through one script (the compaction checkpoint, the subagent ledger,
the receipts, the evidence gate), PreToolUse carries three different hooks (the git lock,
the subagent cap and the evidence gate), and the second-writer watch takes four
FileChanged entries for two files because that event's matcher is a literal file name.
The prompt enhancer lives in <asset home>/enhance/ and is a no-op until you toggle it
on — so every one of them is safe to leave installed in every session. The asset home is
~/.claude/leopold whenever Claude Code is present and ~/.codex/leopold on a
Codex-only machine (Asset Home); the paths below use the Claude
Code layout.
The engine hooks are the same unmodified scripts on both harnesses — Codex
reimplemented Claude Code's hook contract nearly field for field, so no portability
layer exists to go wrong. What differs is never the script, only what a harness does
with the reply: Codex honors the permission policy's deny and ignores its allow,
which hooks/hook-matrix.tsv records as substitute and leopold doctor prints on
that row. See Claude Code and Codex.
stop-continuity.sh — the Stop hook¶
Runs when the agent finishes a turn. Contract: read JSON on stdin; print
{"decision":"block","reason":"..."} to keep going, or exit 0 to allow the stop.
flowchart TD
In["Stop event (JSON on stdin)"] --> Active{run active?}
Active -- no --> Allow([exit 0 · allow stop])
Active -- yes --> Owner{"stopping session<br/>is the run's owner?"}
Owner -- no --> Foreign(["exit 0 · allow stop ·<br/>foreign_stop + notice"])
Owner -- yes --> Kill{STOP file?}
Kill -- yes --> Allow
Kill -- no --> Budget{budget / failures hit?}
Budget -- yes --> Allow
Budget -- no --> Plan{open plan items?}
Plan -- no --> Allow
Plan -- yes --> Human{"next open item<br/>is a @human node?"}
Human -- "yes · autonomy: ask" --> Ask(["exit 0 · allow stop ·<br/>awaiting_human"])
Human -- "yes · autonomy: full" --> Persona["block · re-inject:<br/>synthesize the role, decide it,<br/>log it with a Reversal"]
Human -- no --> Block["increment iteration ·<br/>block · re-inject continue"]
Fail-open: any unexpected error allows the stop. Continuity is best-effort; halting is always safe.
How an allowed stop reaches you¶
A Stop hook has two output channels, and they are not interchangeable. On the
block path ({"decision":"block","reason":…} on stdout) the reason goes to the
model. On the allow path — exit 0, which is every stop above — stderr is
discarded: the harness surfaces a hook's stderr on exit 2, not exit 0.
So every allowed stop that a person has to act on carries its notice as
systemMessage on stdout, which the harness renders as a Stop says: … notice.
That covers the window roll, the max_windows ceiling, the livelock verdict, the
awaiting_human pause, and the state_invalid fail-safe. The same text still goes
to stderr, for anyone running the hook by hand.
This matters more than it sounds: the roll notice used to be written only to
stderr on the allow path, so a run that rolled its window looked, from the outside,
like /leopold-run had quit on its own after one plan item. Nothing was broken —
but nobody was told.
Both harnesses carry the field on the same wire: Claude Code documents
systemMessage for all hooks, and Codex deserializes reason / stopReason /
suppressOutput / systemMessage on its StopCommandOutputWire.
Session ownership — one session conducts a run¶
A run is conducted by one session. state.json records it as owner
(session_id, harness, engine, claimed_at, pid, transcript_path), written
once by whichever engine activated the run: /leopold-run Step 1 (engine skill; the
session id is what the harness exports into every shell it runs — CLAUDE_CODE_SESSION_ID
on Claude Code, CODEX_THREAD_ID on Codex) or the driver's initState (engine driver,
no session id). Every Stop payload names the stopping session as session_id — the same
string — so the hook makes one comparison before it counts anything:
Owner in state.json |
Stopping session | The hook |
|---|---|---|
matches the payload's session_id |
the owner | continues and counts, exactly as before |
| another session | not the owner | allows the stop, writes nothing to state.json, logs one foreign_stop event naming both sessions, and tells the person through systemMessage who owns the run and how to take the seat (/leopold-run) |
engine driver |
a session the driver spawned (LEOPOLD_SDK_WORKER=1 in its environment) |
allows the stop silently — the conductor decides what happens next |
engine driver |
any other session | allows the stop with a notice naming the driver run and its pid |
present, but the payload has no session_id |
unscopable | continues as before and logs owner_unknown once (no_session_in_payload) |
| absent (a state older than the record) | anyone | continues as before and logs owner_unknown once (no_owner_in_state) — re-activate with /leopold-run to bind the run |
A state written by an older /leopold-run carries the session in the top-level
session_id; the hook reads that as the owner, so those runs are scoped too. A state an
older driver wrote has an orchestrator_pid and no session: that is a driver run.
Why it exists: on 2026-09-02 a second Claude Code window, opened in a checkout for an
unrelated question, was blocked by this hook, charged nine of the run's seventeen
iterations, and ended a producing run with no_progress — the executor had never
stopped once. The same rule ends a latent defect in default driver runs, where the hook
fired inside every worker (they run in the project's cwd with the user's hooks loaded)
and told each one to pick the next plan item after its status block
(SDK Worker Hooks).
Two more consequences of one owner: the context budget is measured on the owner's
transcript only (a foreign stop no longer overwrites transcript_path), and every
event the hook logs carries session (the first eight characters of the id).
One writer at a time. Every counted stop takes a mkdir lock
(.leopold/.state.lock) around its read-modify-write of state.json; a lock older
than a minute is a dead hook's and is reaped. Before it, two stops in the same second —
or one stop with the hook wired twice — lost an update on every counter. If the lock
cannot be taken within about five seconds the stop is still counted and a lock_timeout
event says so: continuity beats counter accuracy.
The hook owns the counters. iteration, no_progress, progress_sig, windows,
window_*, context_mb, transcript_path, last_turn and owner are written by the
hook (and by activation) only; the run skill says so in its hard rules.
Who else reads the owner. scripts/leopold-owner.sh is the one reader shared by
/leopold-run (which refuses to start beside a live owner and takes over a stale one;
--takeover forces it), /leopold-stop (which refuses to end another live session's
run without --force), /leopold-status, leopold doctor and leopold watch. Liveness
is any one signal within ten minutes: the owner's harness pid still runs, last_turn is
fresh, or the owner's transcript file was modified — so an executor that works one long
turn without stopping never reads as stale.
Verified live on Claude Code 2.1.258 and Codex CLI 0.150.1: the Stop payload's
session_id equals the shell's CLAUDE_CODE_SESSION_ID / CODEX_THREAD_ID; it survives
claude -p --resume; Agent-tool subagents fire SubagentStop (with the parent's id),
never Stop; and a Codex hook process inherits no CODEX_* environment at all, so on
Codex the payload is the only identity a hook has.
The context window roll¶
Since 0.18.0 the context budget is a maintenance event, not a death — the
behavior change is explicit, not implied. The hook measures the transcript against
max_context_mb (default 5 MB) every turn:
- At ~80% of the budget the turn is blocked with a checkpoint instruction: write
or merge
.leopold/CHECKPOINT.md— the one contract inpackages/driver/src/checkpoint.ts(fixed title, seven fixed sections, merge-don't-nest, 32768-byte cap that fails loud) — then continue the plan. The instruction re-injects every turn in the band; merging is idempotent. Acheckpoint_instructionevent is logged. - At 100% with no checkpoint yet, the window gets one turn to write one. The 80%
band is only ever reached by a window that climbed through it; a run activated
inside a session already past the budget lands straight on the roll and would
never once be told to checkpoint — exactly the window whose working state is most
expensive to lose. So the turn is blocked with the checkpoint instruction instead
of rolling, and a
checkpoint_graceevent is logged. The bound lives in code, not in the prompt:checkpoint_grace_windowrecords the window that spent it, so a window defers at most once and the next turn rolls regardless. That turn does not spend the run's one failure rescue — it exists to preserve state, not to re-attempt the item. - At 100% the stop happens with the reason it always had —
stopped_reason: context_budget, consumers read it — but the state says roll:windowsis incremented, the plan's checkbox vector is snapshotted, andcheckpoint_writtenrecords whether the checkpoint exists (a missing one is named loudly in the stop message, never silently). The message always names the resume path; awindow_rollevent is logged. - Before rolling, two gates run. The livelock gate: each roll records how
many plan items the ending window closed (checkbox-vector diff vs the
window-start snapshot); two consecutive windows closing zero items stop the run
with
no_progress_across_windows— no resume pointer, nothing relaunches. Andmax_windows(state >GUARDRAILS.md> 10) caps the total windows one run may consume; reaching it stops the run withmax_windows. - Budgets survive the roll.
iteration/max_iterationsis the run's ceiling across all windows, and spent one-shots (the failure rescue, the deadlock repair) stay spent through every reseed. Nothing a roll does refreshes a budget or clears.leopold/STOP.
Under continuity: auto (the default in GUARDRAILS.md), leopold watch detects
the roll and relaunches the run headless on the harness that owns it (claude -p /
codex exec), after independently re-checking the kill switch, max_windows and
the livelock gate. Under continuity: manual nothing relaunches — you resume with
/leopold-run. The re-injected continue instruction also carries the window line
(Window N/max) and tells the agent to treat the workspace, tool results and
durable state as authoritative over earlier narration. Full story:
Continuity.
A project with no checkpoint and default guardrails behaves exactly as 0.17.x except that the stop message now names the resume path.
Node kinds¶
PLAN.md items may declare a node kind (@node work|gate|human|tool|verify|feedback, or
the @gate / @human / @tool / @verify / @feedback shorthands). The in-session engine acts on one
of them — @human — and what it does with one depends on the judgment posture
(autonomy), never on which harness you are running:
autonomy: full(the default). No person is coming, so the hook blocks the stop and re-injects an instruction to synthesize the role that decision needs — a name, a role title, the expertise the item actually demands, what it optimizes for, and the hard rules lifted verbatim fromCHARTER.md— take that role, do the item, and append the call to.leopold/DECISIONS.mdwith a Reversal line. It logs apersonaevent (fork: "human",engine: "hook") and names the item on stderr. The trust boundary is unchanged: a role decides, it never ships — and the re-injected instruction is precise about what actually enforces that.guard-irreversible.shdeniesgit commitandgit push(force-push unconditionally) and nothing else;git tag,npm publish,gh pr create,gh release create, raising a budget instate.jsonand editingGUARDRAILS.mdare not blocked by any hook — they are rules the role is told to keep itself, and it is told plainly that no hook will stop it. See what the guard does and does not enforce.autonomy: ask. The hook allows the stop withstopped_reason: awaiting_human, names the item in the stop notice (and on stderr) and logs anawaiting_humanevent. Answer it, mark the item[x], and/leopold-runresumes.
Either way it matches the driver, which resolves the same node the same way from the same posture — so a plan means the same thing on both engines.
Every other kind continues exactly as before, and an item that declares no kind is a
work node — so a plan written before the grammar existed takes an identical path
through the hook. packages/driver/test/hook-kinds.test.ts parses the same plans with
the hook and with the driver's parser and fails the build if they ever disagree.
autonomy: full | ask¶
The posture is read from LEOPOLD_AUTONOMY first, then autonomy: in
.leopold/GUARDRAILS.md, and defaults to full. ask, halt and human all spell the
strict posture; a value neither engine recognizes is treated as absent rather than as
ask, because an unreadable line must never silently halt a run. This mirrors
resolveAutonomy() in packages/driver/src/config.ts — the driver's one extra source is
its --autonomy / --ask flag, which an in-session run has no equivalent of.
guard-irreversible.sh — the PreToolUse hook¶
Runs before every tool call. Contract: read JSON on stdin; print a
hookSpecificOutput with permissionDecision: "deny" to block, or exit 0 to
allow. It only adds denials; it never loosens the harness's own permissions.
Codex delivers this event with the same keys — tool_name (its shell tool is
reported as Bash), tool_input.command, cwd, transcript_path — and honors the
same deny reply.
What the guard enforces¶
The scope is deliberately two commands wide, and it matters that you know exactly which
two — under autonomy: full a @human node is executed by a synthesized role, and that is
where irreversible calls live. Every row below has a case in scripts/test-guard.sh.
| Attempt | Guard | Why |
|---|---|---|
git commit (incl. git -c …, git -C …, /usr/bin/git, tabs) |
denied — unless .leopold/ALLOW_GIT exists |
the run stages, the human commits |
git push |
denied — unless .leopold/ALLOW_PUSH exists |
pushing is the user's call |
git push --force / -f |
denied, always, token or not | nothing a run does justifies it |
git tag, npm publish, cargo publish, gh pr create, gh release create |
allowed | outside the lock's scope |
rm -rf, git reset --hard, git clean -fd, any other shell command |
allowed | the worker is free to work |
editing any file, .leopold/GUARDRAILS.md and state.json included |
allowed — the guard only inspects Bash |
edits are never guarded |
So the run's other rules — do not tag, do not publish, do not open an external PR, never
raise a budget or edit GUARDRAILS.md — are policy, not enforcement. Leopold tells
every synthesized role exactly that, in those words: a role that believes a hook will catch
npm publish has no reason to hold back, and nothing would stop it. If you need those
enforced rather than instructed, deny them in the harness's own permission settings —
guard-irreversible.sh never loosens those, it only adds the two git denials.
See the policy table in Guardrails.
permission-policy.sh — the PermissionRequest hook¶
Runs when the harness would otherwise stop and ask a human for permission. Contract:
read JSON on stdin; print a hookSpecificOutput with decision.behavior allow or
deny (a deny carries message), or exit 0 with no output to let the harness prompt
exactly as it does today. The reply shape is the one captured live on both harnesses in
Hook Events.
Why it exists. An autonomous run that stalls on a prompt nobody is watching is a run
that stopped. Under an active run this hook answers the prompt — and the maintainer's
tie-breaker at this seam is autonomy: safety stays where it already was (the PreToolUse
guards below, the harness sandbox, the harness's own permission settings), none of which
this hook can loosen. It only ever adds an answer where the harness would have waited.
The one exception is git, and it is not re-implemented here. A payload that reaches
this hook is handed to guard-irreversible.sh as a synthesized PreToolUse payload, and
its deny is repeated verbatim — the same reason string, .leopold/ALLOW_GIT /
ALLOW_PUSH honored identically, force-push denied token or not — because it is the same
script deciding. scripts/test-guard.sh runs the guard's whole red-team list through the
policy and asserts that every denied command comes back denied with the guard's own
reason, and every allowed one allowed.
| Situation | The hook |
|---|---|
no .leopold/state.json, or the run is not active |
silence — the harness prompts as today |
| a session that is not the one conducting the run (ownership read exactly as the Stop hook reads it) | silence — a run must never conscript a second window, and that includes granting it autonomy |
the payload carries no session_id while an owner is recorded |
silence — no proof of ownership, no answer |
| the payload itself does not parse | deny — every field below is read out of it, so an allow here would be granted over a request nobody read, with no command left for the git lock to judge |
state.json does not parse |
deny, naming the file — this is a guard, and a guard that cannot read the run's scope fails closed |
| the git lock is missing or does not decide | deny — the allow is granted on the strength of the git lock |
a Bash git commit / git push / force-push the guard denies |
deny, in the guard's words |
| anything else, under the run's own session | allow |
Every decision appends permission_decided (tool, command, decision, reason, session) to
.leopold/events.jsonl. A git deny leaves two lines — the guard's own guard_block and
this hook's permission_decided that repeats it. Both are true.
Per harness (hooks/hook-matrix.tsv, row permission-policy): on Claude Code
2.1.259 the event fires under --permission-prompts none and both replies are
honored; the default host prompt never reaches the hook, which costs nothing because a
human is sitting there. On Codex CLI 0.152.1 it fires only under --approve-for-me and
only the deny half is honored — so on Codex this hook is the git lock's voice at the
prompt and nothing more, and Codex autonomy stays with the driver's sandbox flags. That
is a substitute row, not an available one, and leopold doctor says so on the line.
The semantic second axis — it may only ever DENY¶
When the optional decisions capability is installed and the project has a
.leopold/decisions/permission.json catalog, the hook asks one more question before it grants:
how destructive and hard to reverse is this command? — a four-level Score.
It is consulted only on a path that has already decided allow. It is never reached on a path
heading for a deny, so it cannot grant, soften or reword one. guard-irreversible.sh decides git
before this line and its verdict was already repeated verbatim.
It denies only when both bounds are cleared: the answer is above its confidence bar and the
score is at or above 2.5 on the 0–3 rubric — the top level, "irreversible, or reaches outside this
machine". A merely cautious answer cannot block work. The refusal quotes the score, because
"a model said no" is not a reason a person can argue with and "scored it 3 of 3 on how
destructive and hard to reverse is this command?" is. Each denial logs decision_denied with
the command and the score.
Every failure keeps today's behaviour, and most cost no network call at all:
| situation | what happens |
|---|---|
| the extension is not installed | allow, silently, exactly as before |
| the project has no permission catalog | allow, silently, exactly as before |
| the provider is unreachable, slow, or answers below the bar | allow, logged as decision_timeout |
| a chat-completions provider is configured | the shell seam answers unsupported; allow, logged |
The call is bounded by a hard outer kill, not just by the seam's own --timeout-ms: that
bounds curl, not the seam, and a seam stalled on anything else would hold the prompt open —
which is the precise failure this hook exists to end. timeout(1) is not portable (macOS ships
without it), so the wait is a bounded poll in the shell. Default budget 2000 ms, overridable with
LEOPOLD_DECISIONS_TIMEOUT_MS.
compact-checkpoint.sh — the PreCompact / PostCompact hook¶
Runs when the harness compacts the context window: once before it rewrites the
transcript, once after. Contract: read JSON on stdin, write nothing but an optional
systemMessage. It never blocks a compaction and never answers a tool call — it is a
continuity hook, and it fails open on everything it cannot read.
Why it exists. The Stop hook already asks the agent to write
.leopold/CHECKPOINT.md when the context budget fills. That is an instruction, and a
compaction does not wait for one: the harness decides it, it fires without warning, and
the turn being compacted is exactly the turn with no room left to compose anything. So
on PreCompact this hook composes the checkpoint itself.
Composed from durable state, never from the payload. Claude Code hands PostCompact
the whole compact_summary; Codex hands it trigger, turn_id and model and nothing
else (both captured in Hook Events). Reading the summary would make
the checkpoint better on one harness and impossible on the other, so nothing here reads
it — and the two harnesses write the byte-identical document from the same inputs:
| Section | Composed from |
|---|---|
| In-Flight Item | the first open item in .leopold/PLAN.md |
| Files and Code | the paths git status --porcelain reports (40 max, then one stable line) |
| Errors and Fixes | the run's failure / rescue events in .leopold/events.jsonl, selected lexically by event name |
| Decisions This Run | the .leopold/DECISIONS.md entries stamped at or after started_at |
| Learned Constraints | the prior checkpoint's ledger, carried forward by the merge — this window invents none |
| Current Work | compaction (<trigger>) at iteration N, window W |
| Next Step | the open plan item after the in-flight one |
One contract, one format. The document is the one defined in
packages/driver/src/checkpoint.ts: the title # Leopold Checkpoint, then exactly seven
## sections in a fixed order. An existing checkpoint is merged, never nested —
In-Flight Item, Current Work and Next Step are replaced by this window's view; Files and
Code, Errors and Fixes, Decisions This Run and Learned Constraints keep their prior lines,
gain the new ones, and collapse exact duplicates. packages/driver/test/checkpoint.test.ts
runs this hook and parses what it wrote with the real parseCheckpoint(), then asserts
the bytes equal serializeCheckpoint()'s — the bash writer is not a lookalike.
Content never becomes structure. Every variable field goes through the hook's
cp_line(): whitespace collapsed, and every leading # run stripped, so a plan item
that reads ## ## Files and Code or # # Leopold Checkpoint lands as body text instead
of a second section or a second title. The hook then re-reads the document it just
composed through the same contract reader it used on the prior file, and a composition
that would not parse is never moved into place: checkpoint_unmergeable says
document: composed and nothing is written. serializeCheckpoint() refuses the same
bodies loudly on the TypeScript side — the two writers accept and refuse the same
documents, which is what one contract means.
The cap fails loud; nothing is ever truncated. The effective cap is
min(32768, max(8192, 2% of max_context_mb)), overridden outright by
max_checkpoint_kb: in GUARDRAILS.md — the same formula the Stop hook and the driver
compute. A merged document one byte over it writes nothing: the previous file is left
byte-identical, checkpoint_oversize records the size and the cap, and systemMessage
says so. A half-checkpoint that still looks authoritative would seed the next window with
a lie.
| Situation | The hook |
|---|---|
no .leopold/state.json, run not active, or state.json does not parse |
silence — a continuity hook fails open |
| a session that is not the one conducting the run (ownership read exactly as the Stop hook reads it) | silence — nothing is written for a run this session does not conduct |
PreCompact, owned and active |
compose, merge, cap, write; log compact_checkpoint and bump compact_checkpoints under .leopold/.state.lock |
| the merged document is over the cap | write nothing, log checkpoint_oversize with the byte count, say so |
CHECKPOINT.md exists but does not parse under the contract |
write nothing, log checkpoint_unmergeable (document: prior) with the reason, print the contract |
| the document the hook itself composed would not parse back | write nothing, log checkpoint_unmergeable (document: composed) — the writer validates its own output |
PostCompact, owned and active |
re-ground the window: the one re-grounding sentence, the brief's four files, and the checkpoint framed as past-window data; log compact_resumed |
The re-grounding text goes out as systemMessage on both harnesses. The probe honored
no reply on either harness's compaction events, so additionalContext is not used
here: it was never proved read, and Leopold does not code against an unproved field.
Per harness (hooks/hook-matrix.tsv, row compact-checkpoint): all four rows are
available. Claude Code 2.1.259 fires both events with trigger (auto / manual),
adding custom_instructions before and compact_summary after; Codex CLI 0.152.1 fires
both with trigger, turn_id and model. Because the checkpoint is composed from state,
the missing compact_summary costs Codex nothing — which is exactly why it must stay
that way.
stop-failure.sh — the StopFailure hook¶
Runs when a turn dies on an API error. Contract: read JSON on stdin, write nothing but an
optional systemMessage. It blocks nothing — it cannot, the turn is already over — and
fails open on everything it cannot read.
Why it exists. Stop does not fire on a failed turn. The probe drove real 429,
500, 529 and 401 responses through a stdlib stub and recorded zero Stop hooks
(Hook Events, StopFailure). So the hook that counts turns, notices stop
conditions and writes stopped_reason never runs, and before this hook such a run stayed
active: true in .leopold/state.json forever: leopold watch showed a live run,
/leopold-status showed a live run, and nothing anywhere said the API had refused.
StopFailure is the only witness, so it is where the run is marked stopped.
What it writes — three fields, and nothing else:
{ "active": false,
"stopped_reason": "api_error",
"api_error": { "type": "rate_limit", "at": "2026-09-04T05:12:44Z",
"retryable": true, "hint": "…" } }
Never iteration, no_progress, windows, context_mb, transcript_path, last_turn
or owner — those belong to stop-continuity.sh and to activation. A failed turn is not
a turn: charging one would spend a budget on the API's mistake, and no_progress would
blame the run for work it was never allowed to do. scripts/test-hooks.sh diffs the whole
state file around the hook and fails on any fourth field.
And it clears the run's tokens — .leopold/STOP, ALLOW_GIT, ALLOW_PUSH,
ALLOW_PUBLISH — because an api_error is a terminal stop, and every terminal stop in
Leopold clears them: allow_stop() in stop-continuity.sh, clearRunTokens() in the
driver, /leopold-stop. Those tokens are scoped to one run by documentation alone and
nothing clears them at activation, so leaving them behind is how your per-run
touch .leopold/ALLOW_GIT outlives the run it was granted for: the next /leopold-run in
that project would start with git commit already unlocked, from turn 1, with no human in
the loop. A surviving STOP is the mirror image — the resume this hook's own hint
recommends would halt on turn 1 with kill_switch. It is a filesystem step, not a state
field, so the three-field invariant above is untouched by it.
It never ends a run it does not conduct — including a driver's. Passing the ownership
gate means this payload may act for the run; it does not mean this payload conducts it.
On a driver-conducted run the session that passes is the driver's spawned worker, and a
worker's failed turn is caught and retried by the conductor (loop.ts counts one
consecutive_failures and keeps dispatching to max_failures). Writing active: false
from there would take the project-wide git lock off mid-run — guard-irreversible.sh
gates on exactly that field — while the driver is still conducting and, under --parallel,
while sibling workers are live in the same window. So the driver branch writes nothing,
clears nothing, and logs api_error_observed instead, the way stop-continuity.sh already
exits for that same session class: the conductor decides what happens next. The witness
still matters — the driver's own log records only item_incomplete, so without that line a
run that dies of three 429s reads as three bad worker attempts.
retryable is decided lexically from error, which is the field the payload actually
carries (not error_type), in the vocabulary the probe captured: rate_limit for 429,
server_error for 500 and 529, authentication_failed for 401 and for a missing
login.
error |
retryable |
the hint says |
|---|---|---|
rate_limit |
true | wait for the limit to clear, then resume with /leopold-run |
overloaded |
true | it clears on its own; retry in a moment |
server_error |
true | usually transient; retry, and check any gateway in front of the API |
authentication_failed (any auth) |
false | log in again (claude /login, codex login) — and only then take the seat back; a relaunch is never offered, because the credentials are what failed |
| billing / credit / payment | false | settle the account or raise the limit first |
invalid_request |
false | a bug in what was sent, not a transient failure |
| anything else | false | Leopold does not recognize it, so it is treated as permanent and nothing resumes on its own |
An unknown class defaults to not retryable on purpose: a wrong true buys an
automatic relaunch loop against a failure that will never clear, and a wrong false costs
one command. The model-facing last_assistant_message is never read and never copied into
state — the classification is lexical, over the harness's own error word.
The hint reaches you through systemMessage and stderr. StopFailure was captured in
observe mode only, so no reply field is proved honored there; systemMessage is what both
harnesses deserialize everywhere else, and stderr costs nothing.
Why its timeout is 15, not 5. The hook is small, but it takes the same .state.lock
as the Stop hook, and that lock is budgeted at fifty attempts a tenth of a second apart —
about six seconds of wall clock — before it gives up and writes unlocked. Wired at a five-second timeout the two numbers meet: with a compaction or
a stop holding the lock, the harness kills the hook inside the wait — no state write, no
lock_timeout event, no systemMessage, and a run left active: true forever, which is
the one failure this hook exists to end. hooks/_lib.sh names the budget
(LEO_LOCK_TRIES × LEO_LOCK_SLEEP, plus LEO_LOCK_HEADROOM) and
scripts/test-harness-lib.sh derives from it the minimum timeout every lock-taking spec
must declare, so the arithmetic cannot drift back.
| Situation | The hook |
|---|---|
no .leopold/state.json, run not active, or a payload/state that does not parse |
silence — a continuity hook fails open |
| a session that is not the one conducting the run | silence — a stranger's API error never ends a run it does not conduct, and clears none of its tokens |
any event but StopFailure |
silence |
| a driver run, from the driver's own worker | log api_error_observed (error_type, retryable, conducted_by) and say so — no state write, no token cleared: the conductor decides |
| owned and active | log stop_failure (error_type, retryable, session), mark the run stopped under .leopold/.state.lock, clear the run's tokens, and put the hint in front of a person |
Per harness (hooks/hook-matrix.tsv, row api-error-stop): available on Claude Code
2.1.259. unavailable on Codex CLI 0.152.1 — an API error ends a Codex run as a plain
stop (turn.failed in the --json stream, no Stop and no failure hook: the probe's stub
ended every turn that way and only SessionStart, UserPromptSubmit and SessionEnd
fired). Nothing is wired there, extensions/lib/harness.sh refuses the spec by name, and
leopold doctor prints the row rather than letting the gap be discovered. Resume a Codex
run after an API error with /leopold-run.
subagent-account.sh — the SubagentStart / SubagentStop ledger¶
One script on two events (it branches on hook_event_name, which both harnesses send),
counting what a run's children cost.
It exists because the meter was lying. leopold watch has drawn a subagents meter
since 0.9 — value subagents_spawned, ceiling max_subagents — and nothing in Leopold
ever wrote that field: /leopold-run seeds it at 0 and never touches it again, and the
driver has no reference to it at all. Every run ever conducted read 0/8. A zero that
reads as success is worse than an absent number, and the prompt that asked the run to
"keep subagents lean" had nothing counting them.
| Event | What it writes |
|---|---|
SubagentStart |
subagents_spawned +1 under .leopold/.state.lock, subagents[<agent_id>] = {agent_type, started_at}, and a subagent_started line (agent_id, count, agent_type, session) |
SubagentStop |
subagents[<agent_id>].stopped_at and .transcript_bytes — the size of the child's own agent_transcript_path, never the parent's — and a subagent_stopped line |
Those two fields are the whole write. Never iteration, no_progress, windows,
context_mb, transcript_path, last_turn or owner: a subagent is not a turn, and
charging one would spend a budget on work the run never did. last_assistant_message is
in the payload and is deliberately not read — it is model-facing text, and a hook that
re-interprets what the model said is exactly what this project's charter forbids. The
child's cost comes from a fact of the filesystem instead.
A stop for an agent_id that never started here (the run was activated mid-flight) writes
the entry it can and leaves the count alone; a stop_hook_active: true re-firing — what a
second hook's exit 2 produces — overwrites the same fields with the same kind of value.
The ledger never blocks: exit 2 is honored on SubagentStop on both harnesses, and a
ledger that could refuse a child's stop would spin it forever.
Why its timeout is 10, not 5. The increment is a read-modify-write, and four children
can start in the same second. It goes through the same .state.lock the Stop hook uses,
whose budget is about six seconds of wall clock; a hook wired at five is killed inside the
wait and counts nothing. scripts/test-harness-lib.sh derives that floor from
hooks/_lib.sh rather than trusting this paragraph.
Per harness (hooks/hook-matrix.tsv, row subagent-accounting): all four rows
available. agent_id and agent_type on start; agent_id,
agent_transcript_path, last_assistant_message and stop_hook_active on stop — the
same keys on Claude Code 2.1.259 and Codex CLI 0.152.1, so subagents[agent_id] keys
identically on both and no second shape exists. Codex adds turn_id and model; neither
is needed.
subagent-cap.sh — the PreToolUse spawn ceiling¶
The bound that refuses. Wired on PreToolUse with matcher
Agent|Task|collaborationspawn_agent — Claude Code's spawn tools and the name Codex's
spawn arrives under — and it re-checks tool_name itself, so a harness that applied no
matcher at all would still get one answer, and only for a spawn.
Not SubagentStart: by the time that fires the child exists, and the probe captured no
honored deny reply there. PreToolUse's permissionDecision: deny was captured as
honored on both harnesses — the tool never ran and the reason reached the model.
The ceiling comes from max_subagents in .leopold/state.json, else the
max_subagents: line in .leopold/GUARDRAILS.md, else there is no cap and the hook
prints nothing — a project that never set a ceiling runs byte-for-byte as it did before
this hook existed. max_subagents: 0 is a real ceiling ("no subagents this run"), the
same way max_forks: 0 already is; only an absent or non-numeric value means no cap.
| Situation | The hook |
|---|---|
| the tool is not a spawn tool | silence, before anything else is read — the git lock decides Bash |
no .leopold/state.json, run not active, or a session that is not the one conducting the run |
silence |
.leopold/state.json does not parse |
deny — the one fail-closed case: a ceiling that lapses because a file is malformed is not a ceiling |
no max_subagents anywhere |
silence — today's behavior |
subagents_spawned < the ceiling |
silence |
subagents_spawned ≥ the ceiling |
deny, naming count/cap and where the number came from, and log subagent_cap_denied (tool, count, cap, source, session) |
It only ever denies. A PreToolUse hook that answered allow would override the git lock
and the persona allowlist, which decide first and whose denials nothing here may loosen.
The denial does not invite the run to raise its own ceiling either — a budget is the
human's to set, in .leopold/GUARDRAILS.md, which no run may edit.
A missing hooks/_lib.sh is the one place this hook parts company with
permission-policy.sh. The policy denies there because it exists to grant autonomy and
must not grant it on a gate that never opened. The cap grants nothing: refusing every
spawn in the project because an installed file went missing would stop the run's actual
work, while the git lock (which needs no library) and the permission policy (which denies
loudly for the same cause) already carry the safety half. So it says so on stderr and gets
out of the way.
Per harness (hooks/hook-matrix.tsv, row subagent-cap): available on both. The
matcher is one string on both harnesses and each ignores the alternatives it has no tool
for, exactly as the git lock's does.
verify-receipt.sh — the PostToolUse / PostToolUseFailure receipts¶
The evidence half of done means verified. One script on two events (it branches on
hook_event_name), and it only ever records — the gate that refuses reads what it
writes.
It exists because the rule had no enforcement. "An item is done when a verification
command ran with exit 0 after its last edit" has been in .leopold/GUARDRAILS.md and in
the run skill's prose since the beginning, and nothing anywhere compared a test run to an
edit. A rule that lives only in a prompt is a wish.
The fact the whole hook turns on: there is no exit code in a PostToolUse payload on
either harness. The probe went looking for one and captured none. What it captured
instead is why this hook is wired twice:
| Harness | What the capture shows | What a receipt therefore proves |
|---|---|---|
| Claude Code 2.1.259 | tool_response is an object (stdout, stderr, interrupted, …), and most non-zero Bash exits do not arrive here at all — they fire PostToolUseFailure, whose error is the string Exit code 1 |
exit 0 only when the response object carries none of the three denials below; the clearly non-zero half rides the failure event |
| Codex CLI 0.152.1 | PostToolUse fires for pass and fail alike, tool_response is a string of stdout only ("" for both true and false), and there is no PostToolUseFailure at all |
the verification ran after the last edit — never that it passed |
The firing of PostToolUse is not itself a pass. Three fields of the Claude Code
response object each deny a clean exit 0, and the probe captured all three:
| Field | What it means | Why it is not a pass |
|---|---|---|
returnCodeInterpretation |
the harness re-interpreted a non-zero status as "not an error" and named its meaning | the probe's own capture is grep -c zzz /dev/null — which exits 1 — arriving at PostToolUse with {"stdout":"0",…,"returnCodeInterpretation":"No matches found"}, in the same run whose false fired PostToolUseFailure. The binary settles the rule: the default classifier is isError = code !== 0, but grep, rg, egrep, fgrep, find, diff, test, [, git grep and git diff use isError = code >= 2 with the message set iff code === 1. So the field is present exactly when the exit was 1, never when it was 0 — and a brief verifying with grep -q "0 failures" build/report.txt would otherwise record its failure as a pass |
interrupted |
the command was cut off mid-run | partial stdout is not a result: a maintainer who Ctrl-Cs a red make test has not verified anything |
backgroundTaskId |
the command was launched, not finished — run_in_background, or a timeout that moved it to the background |
the object comes back at launch time with an empty stdout and interrupted: false, otherwise indistinguishable from a clean pass (verified in a live 2.1.260 transcript) |
So the status comes from where the capture shows it, and where the capture shows nothing
the receipt says so: exit_code: null, never a 0 rounded up from stdout or inferred from
the event firing. Re-running the command from the hook to learn its exit code is not an
option the charter allows (a hook never runs the suite), and reading an outcome the payload
does not carry would be a fabricated receipt.
exit_code and outcome answer two different questions, which is why a receipt
carries both. exit_code is the number the harness reported, or null when it reported
none — never inferred. outcome is what the payload proves, and it alone decides whether
last_verify_at moves:
outcome |
When | exit_code |
Moves last_verify_at |
|---|---|---|---|
passed |
a response object with none of the three denials | 0 |
yes |
failed |
PostToolUseFailure |
the number in error, else 1 |
no |
nonzero |
returnCodeInterpretation set |
null — the payload names the meaning, not the number |
no |
incomplete |
interrupted, or backgroundTaskId set |
null |
no |
ran |
Codex: no status anywhere in any payload | null |
yes |
| Event | Tool | What it writes |
|---|---|---|
PostToolUse |
Edit, Write, MultiEdit, NotebookEdit, apply_patch — on a file outside .leopold/ |
last_edit_at |
PostToolUse |
Bash matching a verification entry |
verify_receipts += {command, exit_code, outcome, at, session} and — on passed or ran — last_verify_at, all under .leopold/.state.lock, plus a verify_recorded line |
PostToolUseFailure |
Bash matching a verification entry |
the same receipt with the exit code read out of error — and last_verify_at does not move |
PostToolUseFailure |
an edit tool | nothing: a failed edit changed nothing |
Those three fields are the whole write. Never iteration, no_progress, windows,
context_mb, transcript_path, last_turn or owner — a test run is not a turn.
The run's own bookkeeping is not work. .leopold/ is where the run writes itself
down — the plan it ticks, the decisions it logs, the journal, the state file — and no
verification command has ever covered any of it, so an edit there stamps nothing.
last_edit_at is the last edit outside .leopold/. Counting the run's paperwork denied
the turn loop the run skill prescribes on its correct path (verify → log the decision
→ tick the box: the log is an edit newer than the receipt), and made the TaskCompleted
half unsatisfiable outright, since the tick is itself an edit. A Codex patch that touches
the plan and a source file did change the work and stamps; an edit whose path the hook
cannot read stamps too, the strict direction.
What counts as a verification command is the ## Verification commands section of
.leopold/GUARDRAILS.md, read lexically: the list items under that heading, until the
next heading. Backticks and bold markers are stripped as markdown, and the trailing
# comment is stripped as shell syntax — by the same normalizer that reads the command
— so - `make test` # the gate is the entry make test.
## Verification commands
> What counts as evidence that an item is done: one of these ran with exit 0 after the
> item's last edit.
- make hooks-test
- make test
No section, no receipts. A brief that does not declare one gets no receipts and no
events — nothing says what evidence means here, so nothing is claimed, and a project that
predates this hook behaves exactly as it did. (last_edit_at is stamped either way: it is
a fact about the work, not a claim about evidence.)
Matching is lexical and never semantic, and an entry only counts where it begins a
command. Both sides go through the same normalizer — heredoc bodies dropped, shell
comments dropped, tabs and whitespace collapsed, and a command-start marker emitted at the
start of every line and in place of every shell separator (;, &, |, parentheses) —
and an entry matches when its normalized form, which begins with one of those markers,
appears in the command's. make hooks-test, cd sub && make hooks-test,
make hooks-test 2>&1 and echo $(make hooks-test) all match - make hooks-test,
because in each of them the entry begins a command. ls -la does not, and neither does
anything that merely names the entry:
echo "- ran make hooks-test after the edit" >> notes.md
git commit -m "make hooks-test green"
cat >> .leopold/DECISIONS.md <<EOF
Verified by:
make hooks-test
EOF
Those are arguments and heredoc data, not commands that ran. It matters because appending
to DECISIONS.md by heredoc and ticking PLAN.md by echo is the run's own prescribed
loop: with a plain substring search the run could mint its own exit-0 receipts simply by
narrating them, in the one mechanism whose whole purpose is to stop unearned done. So
the boundary is required rather than assumed, and the rule is deliberately strict where it
cannot tell — time make test, sudo make test and if make test; then record nothing,
because a missing receipt can only ever make the gate ask for a later verification,
never accept an earlier lie. An entry is a literal string and never a glob, so
- pytest tests/* matches pytest tests/* and never pytest tests/unit. The hook never
asks what the model meant by a command.
A command mentioned in a comment is not a command that ran: make build # make
hooks-test comes later and echo skip; # make hooks-test match nothing. Comments are
stripped per line, the way a shell reads them (a # at the start of a line or after
whitespace, to the end of that line), so a multi-line Bash call whose first line is
# run the suite still matches on its second, and a # inside a token
(make test URL=http://x#frag) is not a comment at all. Stripping # from the entry but
not from the command was a live false-evidence path in the one mechanism whose whole
purpose is to stop unearned done.
Per harness (hooks/hook-matrix.tsv, rows verify-receipt): PostToolUse
available on Claude Code and substitute on Codex, PostToolUseFailure
available on Claude Code and unavailable on Codex — the second event the matrix
refuses there, after StopFailure. On Codex last_verify_at therefore means a
verification command ran, not passed; the receipt keeps the difference visible with
exit_code: null and outcome: ran — the only null outcome that is evidence, which is
why the stamp turns on the word and not on the number — and leopold doctor quotes the
row's note on the Codex line rather
than implying parity. Refusing to move the field there would not make Codex stricter — it
would make the bound unavailable on Codex, which is the opposite of both harnesses or
neither.
The receipt list keeps its most recent 200 entries; last_verify_at is a scalar and never
rolls off. Its timeout is 10, not 5, for the same reason the subagent ledger's is: the
append is a read-modify-write under the state lock, and scripts/test-harness-lib.sh
derives that floor from hooks/_lib.sh.
done-gate.sh — the PreToolUse / TaskCompleted evidence gate¶
The half of done means verified that refuses. verify-receipt.sh records the two
halves of the sentence — last_edit_at and last_verify_at — and this reads them back
at the two moments a run claims an item is finished.
One script on two events, because it is one rule. It branches on hook_event_name and
answers in that event's own reply shape, both captured live:
| Event | Matcher | The claim it reads | How it refuses |
|---|---|---|---|
PreToolUse |
Edit\|Write\|MultiEdit\|apply_patch |
an edit of .leopold/PLAN.md that turns a - [ ] into a - [x] |
permissionDecision: deny — the tool never runs and the reason reaches the model |
TaskCompleted |
none | the task itself | exit 2 with the reason on stderr — the task stays pending |
What it compares. last_verify_at must be newer than last_edit_at — the last
edit outside .leopold/, so ticking this plan, logging a decision and writing the
journal never invalidate the receipt they follow. Both stamps are ISO-8601 UTC seconds, so
the comparison is their string order:
| State | Answer |
|---|---|
| neither field | allow — nothing has been recorded, so nothing is claimed (a run that predates the receipts hook behaves as it always did) |
last_verify_at only |
allow — verified, nothing edited since |
last_edit_at only |
refuse — edited, never verified |
| both, verify > edit | allow |
| both, verify ≤ edit | refuse |
Equal counts as stale on purpose. The stamps are one-second grained, and a guard that cannot tell which came first inside the same second answers the way that can only ever ask for a later verification, never accept an earlier lie. Re-running the command clears it.
The flip is read lexically, never semantically. The hook counts the ticked boxes on each side of the edit and refuses when the new side has more; it never asks what the model meant. That is what makes all four edit shapes answerable, none of which says "item three was ticked" anywhere:
| Tool | Old side | New side |
|---|---|---|
Edit |
old_string |
new_string |
MultiEdit |
every old_string in the batch |
every new_string — one claim, summed |
Write |
.leopold/PLAN.md as it stands on disk (a Write carries no old_string) |
content |
apply_patch (Codex) |
the - lines of the patch's .leopold/PLAN.md hunks |
the + lines of the same hunks |
A patch that touches the plan and a source file is judged by the plan's hunks alone: an
[x] in someone's fixture is not a claim of done. An edit that rewords an item, unticks
one, or touches another file goes through untouched — only the tick is gated.
The reason names the evidence that would clear it, read out of the same
## Verification commands section of .leopold/GUARDRAILS.md that mints the receipts,
with each entry's trailing # comment stripped exactly as the recorder strips it:
Leopold: this edit ticks a box in .leopold/PLAN.md, and done means verified — last edit
2026-09-04T10:00:00Z, last passing verification none. Nothing has verified this work since
it was last edited, so the claim was refused. run the verification first: make hooks-test;
make test. When one of them passes, tick the box and it goes through. Nothing else is
blocked — only the tick.
No section, no gate. A brief that never declared what evidence means here cannot have a
claim refused for lacking it, and a state with neither stamp allows exactly as before.
Every refusal writes one done_denied line to the event log carrying via —
plan_edit or task_completed — the stamps it compared, and the session.
It writes no state at all: the fields it reads belong to verify-receipt.sh, so it
never takes the state lock and is wired at 5s. It never runs a verification command either
— the charter's rule is to record cheaply at PostToolUse and check the record at the
gate, never to run a suite from a hook.
Being a guard, it fails closed: a state.json that does not parse denies the claim and
names the file, because nothing can then say anything was verified. (The recorder next door
fails open on the same file. Opposite jobs, opposite directions, both deliberate.) That
refusal is scoped to a claim of done and nothing else — the claim check and the
no-section pass-through run before the state is consulted, so a malformed state file
never refuses an edit of src/foo.ts, never refuses a reworded plan item, and never blocks
its own repair (the state file is fixed with the very tools this gate sees). A foreign
session's tick, an inactive run and a project that is not Leopold's are all silent no-ops,
as everywhere else.
Per harness (hooks/hook-matrix.tsv, rows done-gate): the PreToolUse gate is
available on both — Claude Code's Edit / Write / MultiEdit, Codex's
apply_patch. TaskCompleted is available on Claude Code and substitute on Codex,
which has no task events at all; the matrix gate refuses that spec there by name, the
PLAN.md half carries the whole bound, and leopold doctor prints the Codex row as
done-gate · Codex: … (PreToolUse) — TaskCompleted: unavailable on Codex codex-cli 0.152.1
— … rather than letting one wired event read as parity.
The run skill still tells the agent to verify before ticking a box. The prompt is the belt; this is the braces.
file-watch.sh — the FileChanged second-writer detector¶
Claude Code only. The run writes its own record — .leopold/PLAN.md is the plan it
ticks, .leopold/DECISIONS.md the reasoning it leaves behind — and nothing used to notice
when something else wrote them mid-run: a second window, a sed in another terminal, an
editor with the file open. The run kept conducting from a plan that had changed under it.
This hook makes that visible. It warns and never blocks — a human editing the plan on
purpose is a thing that happens, and the ownership gate already keeps a run to one
executor.
What it reads. A FileChanged payload is file_path + event and nothing else
(the captures): the session's own Edit and
another process's append produce byte-identical shapes. So "was this us?" is a
correlation, not a payload fact. hooks/verify-receipt.sh stamps
own_edits[<basename>] on every edit tool call that touched a file inside .leopold/ —
the exact complement of its last_edit_at, which stays the last edit outside .leopold/
so the evidence gate is untouched — and a change delivered within two seconds of that
stamp is read as the run's own. Measured, not guessed: in the live probe an Edit's
PostToolUse landed 0.6 s before its FileChanged, every time. The cost is stated rather
than hidden: an external write to a file this run edited less than two seconds ago is not
reported.
The stamp is validated before it is compared, and the window is bounded at both ends.
The comparison is lexicographic over ISO strings, so any other value — "unknown",
"9999-01-01T00:00:00Z" — would sort at or after the cutoff and silence the detector
permanently, which would hand the disarm to exactly the second writer this hook exists to
catch: whoever can append to .leopold/PLAN.md can also write one field of
.leopold/state.json. So only YYYY-MM-DDTHH:MM:SSZ is read as a stamp, a stamp after
now is not a record of an edit that already happened, and anything else falls through to
the warning — the direction a detector fails in.
The wiring is four entries for two files, and that is not tidiable. FileChanged's
matcher is a literal file name. Probed live on Claude Code 2.1.260, one headless session
per wiring:
| Wiring | Fired |
|---|---|
one entry, matcher PLAN.md\|DECISIONS.md (alternation) |
0 |
basename-shaped entries only (PLAN.md, DECISIONS.md) |
0 |
path-shaped entries only (.leopold/PLAN.md, .leopold/DECISIONS.md) |
0 |
| both shapes, per file (four entries) | 8 |
The path-shaped entry registers the watch and never fires; the basename-shaped entry
receives. Neither works alone and an alternation matches nothing, so a clever regex
here would be a detector that silently detects nothing. Two more facts from the same probe:
every change is delivered twice (~11 ms apart) and a cd in the session detaches the
watches entirely. The two deliveries are two processes, so the fold that makes one write
one warning — read the last external_write for this file, append only if it is older than
a second — is taken under the run's .leopold/.state.lock. Unlocked, the second delivery's
read lands before the first delivery's append and both warn: measured against this hook,
two simultaneous deliveries produced two events in 5/5 trials. That lock is also why the
four entries are wired at a 10 s timeout and the config guard at 5 — a hook that takes the
lock has to be able to finish waiting for it, and scripts/test-harness-lib.sh derives
that floor from the budget in hooks/_lib.sh.
.leopold/state.json is deliberately not watched. The run's own hooks are that file's
dominant writers — the Stop hook, the receipts, the subagent ledger and the compaction
checkpoint rewrite it several times a turn, from processes with no tool call and therefore
no stamp to correlate against — so a watch on it would report the run's own bookkeeping as
tampering every turn, and the one signal it exists to carry would be buried in its own
noise. The second-writer bound on state.json is already enforced in code, and has been
since the 2026-09-02 incident: ownership is compared on every stop (foreign_stop,
owner_takeover, owner_unknown) and by scripts/leopold-owner.sh before a run is
activated.
What lands. external_write in .leopold/events.jsonl with the file, the resolved
path and the session, plus a systemMessage telling the run to re-read the file before
acting on it. Nothing is blocked, no state is written (the state lock is taken only to make
the de-duplication atomic, and released), and the file's contents are never read — reporting that it changed is the hook's whole job.
Per harness (hooks/hook-matrix.tsv, rows file-watch): available on Claude Code,
unavailable on Codex, which fires no FileChanged at all — a second writer on this
run's files is simply not detected there, the matrix gate refuses the specs, and leopold
doctor prints file-watch · Codex: unavailable on Codex codex-cli 0.152.1 — ….
config-guard.sh — the ConfigChange tamper guard¶
Claude Code only. Every bound Leopold enforces during a run is a hook declared in the harness's settings — the git lock first among them — so a settings file rewritten mid-run can disarm the run's guards from inside the run. This is the half of the pair that refuses.
What it blocks, and what that buys. Exit 2 blocks the reload, not the write —
measured, not inferred. The event probe could not judge this
one: a ConfigChange hook never reaches the session's stream, so there is no
hook_response and no exit code to read, and its row says unobservable
(ConfigChange). It was measured separately, by
side effect — three paired runs on Claude Code 2.1.260 in which the session rewrote its own
.claude/settings.json to one declaring an extra PreToolUse hook and then made one more
tool call: with the guard observing, the new hook fired; with the guard exiting 2, it never
did, and the file on disk kept the edit either way. So the block buys exactly one thing —
the running session does not adopt the new configuration and keeps the wiring it started
with — and the hook logs config_change_blocked (source, file_path, session) as loudly
as it refuses, because that event is the durable record that the file and the live session
have diverged.
What passes. policy_settings (the administrator's managed policy: not a run's to
overrule) and skills (prompt material, which cannot disarm a guard). Everything else is
refused, including a source this guard has never heard of and a payload with no
source at all — the fail-closed direction, because an unmeasured layer is one whose
blast radius nobody knows. The cost of being wrong is one skipped reload in one session,
said out loud twice; the cost of the other direction is a run whose git lock was replaced
while it was running.
Both hooks are inert without an active run, and silent for a session that does not conduct this one.
Per harness (hooks/hook-matrix.tsv, rows config-guard): available on Claude Code,
unavailable on Codex — no ConfigChange event exists there, so a mid-run edit of
config.toml or of the hook wiring is not detected, and leopold doctor says so in the
matrix's own words.
persona-guard.sh — the run-scoped persona PreToolUse hook¶
The one hook in hooks/ that is not part of the always-on wiring above: the
persona conductor wires it (matcher mcp__.*|WebFetch, its own
leopold-persona-guard managed tag through the same shared writer) only while a
persona run is active, and unwires it at run end. While wired, it checks every
url in an MCP tool call or WebFetch against the active flow's domain
allowlist and denies anything outside —
before the MCP server receives the call, on both harnesses, verified live. The
hook is additionally inert without an active .leopold/persona/ACTIVE.json, so
a stale wire can never bound a normal session. Captured payloads, versions, and
the full policy: Persona Guard Hooks; red-team suite:
scripts/test-persona-guard.sh.
enhance.py — the UserPromptSubmit prompt enhancer¶
Runs on every prompt you submit (the event takes no matcher, so all gating is
internal). Contract: read the hook JSON on stdin; plain text on stdout is injected
as context next to the raw prompt (plain text, not JSON, so it concatenates safely
with other UserPromptSubmit hooks); always exit 0 — the prompt itself is
never modified or blocked. Codex validates hook stdout as strict JSON, so on that
harness the same text is wrapped in hookSpecificOutput.additionalContext instead —
the plain-text form logs hook: UserPromptSubmit Failed there and never reaches the
model. The engine detects which harness sent the payload and answers in its dialect.
flowchart TD
In["UserPromptSubmit (JSON on stdin)"] --> Rec{recursion / kill-switch env?}
Rec -- yes --> Silent([exit 0 · silent])
Rec -- no --> On{enabled in state.json?}
On -- no --> Silent
On -- yes --> Skips{"command · ack · pasted code ·<br/>>60 words · active run · cooldown?"}
Skips -- yes --> Silent
Skips -- no --> Score{weakness score ≥ 4?<br/>anchor vetoes}
Score -- no --> Silent
Score -- yes --> Call["claude -p haiku<br/>(charter + profile + transcript tail)"]
Call -- failure --> Ledger2["ledger: injected=false"] --> Silent
Call -- ok --> Inject["inject interpretation ·<br/>ledger · cooldown stamp"]
Fail-open: no claude on PATH, timeout, API error, malformed output — nothing is
emitted and the prompt goes through untouched. Full detail (gate table, state,
ledger, the learn loop): Prompt Enhancer.
Wiring, per harness¶
Claude Code — ~/.claude/settings.json¶
{
"hooks": {
"Stop": [
{ "hooks": [ { "type": "command", "command": "~/.claude/leopold/hooks/stop-continuity.sh", "timeout": 15 } ] }
],
"PreToolUse": [
{ "matcher": "Bash|Edit|Write|MultiEdit|NotebookEdit",
"hooks": [ { "type": "command", "command": "~/.claude/leopold/hooks/guard-irreversible.sh", "timeout": 5 } ] },
{ "matcher": "Agent|Task|collaborationspawn_agent",
"hooks": [ { "type": "command", "command": "~/.claude/leopold/hooks/subagent-cap.sh", "timeout": 5 } ] },
{ "matcher": "Edit|Write|MultiEdit|apply_patch",
"hooks": [ { "type": "command", "command": "~/.claude/leopold/hooks/done-gate.sh", "timeout": 5 } ] }
],
"PermissionRequest": [
{ "hooks": [ { "type": "command", "command": "~/.claude/leopold/hooks/permission-policy.sh", "timeout": 5 } ] }
],
"PreCompact": [
{ "hooks": [ { "type": "command", "command": "~/.claude/leopold/hooks/compact-checkpoint.sh", "timeout": 10 } ] }
],
"PostCompact": [
{ "hooks": [ { "type": "command", "command": "~/.claude/leopold/hooks/compact-checkpoint.sh", "timeout": 10 } ] }
],
"StopFailure": [
{ "hooks": [ { "type": "command", "command": "~/.claude/leopold/hooks/stop-failure.sh", "timeout": 15 } ] }
],
"SubagentStart": [
{ "hooks": [ { "type": "command", "command": "~/.claude/leopold/hooks/subagent-account.sh", "timeout": 10 } ] }
],
"SubagentStop": [
{ "hooks": [ { "type": "command", "command": "~/.claude/leopold/hooks/subagent-account.sh", "timeout": 10 } ] }
],
"PostToolUse": [
{ "matcher": "Bash|Edit|Write|MultiEdit|NotebookEdit|apply_patch",
"hooks": [ { "type": "command", "command": "~/.claude/leopold/hooks/verify-receipt.sh", "timeout": 10 } ] }
],
"PostToolUseFailure": [
{ "matcher": "Bash|Edit|Write|MultiEdit|NotebookEdit|apply_patch",
"hooks": [ { "type": "command", "command": "~/.claude/leopold/hooks/verify-receipt.sh", "timeout": 10 } ] }
],
"TaskCompleted": [
{ "hooks": [ { "type": "command", "command": "~/.claude/leopold/hooks/done-gate.sh", "timeout": 5 } ] }
],
"FileChanged": [
{ "matcher": ".leopold/PLAN.md",
"hooks": [ { "type": "command", "command": "~/.claude/leopold/hooks/file-watch.sh", "timeout": 10 } ] },
{ "matcher": "PLAN.md",
"hooks": [ { "type": "command", "command": "~/.claude/leopold/hooks/file-watch.sh", "timeout": 10 } ] },
{ "matcher": ".leopold/DECISIONS.md",
"hooks": [ { "type": "command", "command": "~/.claude/leopold/hooks/file-watch.sh", "timeout": 10 } ] },
{ "matcher": "DECISIONS.md",
"hooks": [ { "type": "command", "command": "~/.claude/leopold/hooks/file-watch.sh", "timeout": 10 } ] }
],
"ConfigChange": [
{ "hooks": [ { "type": "command", "command": "~/.claude/leopold/hooks/config-guard.sh", "timeout": 5 } ] }
],
"UserPromptSubmit": [
{ "hooks": [ { "type": "command", "command": "python3 ~/.claude/enhance/enhance.py --event user-prompt", "timeout": 30 } ] }
]
}
}
Codex CLI — ~/.codex/config.toml¶
The same hooks, in TOML, inside a marker-delimited managed block that a re-install
replaces and nothing else — minus the ones the matrix refuses on Codex, which is why
StopFailure is not here (an API error ends a Codex run as a plain stop), nor is
PostToolUseFailure (no tool-failure event) or TaskCompleted (no task events at all).
The engine
hooks Codex can fire:
# >>> leopold (managed) >>>
[[hooks.Stop]]
[[hooks.Stop.hooks]]
type = "command"
command = "/home/you/.claude/leopold/hooks/stop-continuity.sh"
timeout = 15
[[hooks.PreToolUse]]
matcher = "Bash|Edit|Write|MultiEdit|NotebookEdit"
[[hooks.PreToolUse.hooks]]
type = "command"
command = "/home/you/.claude/leopold/hooks/guard-irreversible.sh"
timeout = 5
[[hooks.PermissionRequest]]
[[hooks.PermissionRequest.hooks]]
type = "command"
command = "/home/you/.claude/leopold/hooks/permission-policy.sh"
timeout = 5
[[hooks.PreCompact]]
[[hooks.PreCompact.hooks]]
type = "command"
command = "/home/you/.claude/leopold/hooks/compact-checkpoint.sh"
timeout = 10
[[hooks.PostCompact]]
[[hooks.PostCompact.hooks]]
type = "command"
command = "/home/you/.claude/leopold/hooks/compact-checkpoint.sh"
timeout = 10
[[hooks.SubagentStart]]
[[hooks.SubagentStart.hooks]]
type = "command"
command = "/home/you/.claude/leopold/hooks/subagent-account.sh"
timeout = 10
[[hooks.SubagentStop]]
[[hooks.SubagentStop.hooks]]
type = "command"
command = "/home/you/.claude/leopold/hooks/subagent-account.sh"
timeout = 10
[[hooks.PreToolUse]]
matcher = "Agent|Task|collaborationspawn_agent"
[[hooks.PreToolUse.hooks]]
type = "command"
command = "/home/you/.claude/leopold/hooks/subagent-cap.sh"
timeout = 5
[[hooks.PostToolUse]]
matcher = "Bash|Edit|Write|MultiEdit|NotebookEdit|apply_patch"
[[hooks.PostToolUse.hooks]]
type = "command"
command = "/home/you/.claude/leopold/hooks/verify-receipt.sh"
timeout = 10
[[hooks.PreToolUse]]
matcher = "Edit|Write|MultiEdit|apply_patch"
[[hooks.PreToolUse.hooks]]
type = "command"
command = "/home/you/.claude/leopold/hooks/done-gate.sh"
timeout = 5
# <<< leopold (managed) <<<
The prompt enhancer and every extension get their own tagged block
(# >>> leopold:enhance (managed) >>> and friends), so each one is installed,
updated and removed without touching the others.
The config is backed up before the merge and the result is validated: a write that
would not parse is rolled back and the block printed for you to paste. Both formats
are produced by one shared writer, extensions/lib/harness.sh, so the two harnesses
cannot drift.
Codex hooks are inert until trusted
Codex will not execute a hook declared in config.toml until you have approved
it once (hooks.state."<id>".trusted_hash) — no error, it simply does not run.
Approve it in one interactive session, or install Leopold as a Codex plugin,
which trusts plugin-provided hooks through the install. Headless workers started
by leopold run --provider codex pass --dangerously-bypass-hook-trust so they
arm their own git lock. leopold doctor reports which state each harness is in.
What may be declared, and what the installer tells you¶
Both blocks above come from one list — leo_core_hook_specs in that same writer — and
the writer checks each event against hooks/hook-matrix.tsv before declaring it. An
event the target harness cannot fire is refused by name instead of landing as a dead
[[hooks.<event>]] table that waits forever:
Only that answer drops a hook, and only the matrix's own status word produces it:
unavailable, or a substitute whose evidence says the event is not on that harness at
all (Codex has no TaskCompleted, so the PLAN.md PreToolUse gate carries that bound
there). A row that says available is wired whatever section it cites as evidence — the
status column is the contract, the anchor is evidence for a human. A matrix that is
missing, unreadable, or that the reader cannot parse means the question could not be
asked, so everything is wired and the installer says so loudly — a chmod must never be
able to quietly uninstall the git lock. And what the installer prints at the end is what actually landed, counted by
the writer: if nothing was declared it says the git lock is not armed, rather than
reporting the list it was handed.
Every event the harnesses fire¶
The hooks above ride Stop, PreToolUse, PermissionRequest, PreCompact, PostCompact,
StopFailure, SubagentStart, SubagentStop, PostToolUse and PostToolUseFailure. Which of the other documented
lifecycle events actually fire from a headless session on the installed binaries — with
what payload, and honoring which reply — is captured, not assumed:
Hook Events is generated by scripts/probe-hook-events.sh from live
runs of both harnesses and records a verbatim payload, or the trigger tried, for every
event in each harness's matrix.
The capability matrix in leopold doctor¶
leopold doctor joins hooks/hook-matrix.tsv with the live wiring and prints one row
per capability per harness that is actually on this machine — never silence:
| row | what it means |
|---|---|
verified |
the hook is declared in that harness's config and every evidence anchor the matrix cites resolves to a heading of the installed docs/reference/hook-events.md |
wired |
declared, but the proof is not here — the bound is in force. Two problems, two lines, two remedies: the evidence page is not installed at all (… is not installed (looked in: …); re-run ./install.sh to bring it), or it is installed and the section the matrix cites has moved (… has no #<anchor> section; re-run: make probe-hook-events) |
not wired — run ./install.sh |
the matrix says the event fires here and nothing declares it (persona-guard is the one exception: it is armed per run) |
unavailable on <harness> <probed version> — <note> |
the matrix refuses it here, quoting the row's own note so the cost is stated in words |
The evidence page travels with the hooks: install.sh copies docs/ into the asset
home, and the driver's build vendors it into the npm package
(packages/driver/scripts/copy-runtime.mjs), so verified is reachable on both install
paths. scripts/test-doctor-matrix.sh builds the npm asset home from that script's own
list and fails if the page stops shipping.
A capability the matrix marks substitute on a harness is wired there — the event fires
with a weaker guarantee — and its row carries the tsv's note on the same line
(… — substitute on PermissionRequest: …), so a harness that honors half the reply never
reads as full parity.
One capability's substrate is not a hook at all: review-lens-roles is a set of Codex
agent roles — one file per driver review lens under $CODEX_HOME/agents/ (see
Quality & orchestration).
Its Codex row counts those files (verified (SubagentStart) — 4 role files in …,
incomplete — 3/4 … (missing: …), or not installed — run ./install.sh) and repeats what
the probe proved: codex exec cannot run as a role, so a headless lens is held read-only
by --sandbox. Its Claude Code row states the substitute — there are no role files there,
each lens is the driver's own SDK session — rather than sending a healthy install to look
for a hook that is not supposed to exist.
Above them, one drift row per harness whenever the installed binary is not the one the
matrix was probed against — Claude Code <installed> installed, matrix probed on <probed>
— re-run scripts/probe-hook-events.sh — because every status below it is a claim about
that version. scripts/test-doctor-matrix.sh (under make doctor-test) asserts all four
statuses and the drift row hermetically, with stub claude / codex binaries on PATH.
Event log¶
The engine hooks append structured events to .leopold/events.jsonl
(turn_start, stop, guard_block, permission_decided, stop_failure, the ownership
events foreign_stop, owner_unknown, owner_takeover and lock_timeout, the compaction
events compact_checkpoint, compact_resumed, checkpoint_oversize and
checkpoint_unmergeable, the subagent events subagent_started, subagent_stopped and
subagent_cap_denied, the verification receipt verify_recorded, the evidence gate's done_denied, the
second-writer warning external_write and the config guard's config_change_blocked,
and the continuity events
checkpoint_instruction, checkpoint_grace, window_roll,
no_progress_across_windows, max_windows; the watcher adds window_relaunch,
window_relaunch_refused, window_relaunch_failed, api_error_relaunch and
api_error_relaunch_refused; persona-guard.sh adds persona_guard_block; and the SDK
driver adds its own — item_start, item_done, subagent_spawn, hypothesis,
failure_rescue, failure_rescue_declined, merge_conflict),
which /leopold-status reads. The enhancer
logs to its own global ledger instead — ~/.claude/enhance/enhancements.jsonl,
one line per injection or failed attempt — which /leopold-enhance learn mines.
Every event name has an entry in the EVENTS registry of scripts/leopold-watch.py —
a severity class and a one-line meaning — from which the dashboard's SEV map and the
feed's description line are generated. An unregistered event (the driver's own, an
extension's) still renders: the fallback shows its name and its scalar fields, never a
blank row. scripts/test-watch-events.py (under make watch-test) derives the set of
names every script in hooks/ emits and fails on any one the registry lacks.