Hooks¶
Three hooks are wired by install.sh into each harness it finds — settings.json
on Claude Code, config.toml on Codex CLI. The two engine hooks live in
<asset home>/hooks/ and are no-ops unless a run is active; the prompt
enhancer lives in <asset home>/enhance/ and is a no-op until you toggle it
on — so all three are 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 two 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. 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 --> 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.
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% 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 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.
persona-guard.sh — the run-scoped persona PreToolUse hook¶
The third hook in hooks/ 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" } ] }
],
"PreToolUse": [
{ "matcher": "Bash|Edit|Write|MultiEdit|NotebookEdit",
"hooks": [ { "type": "command", "command": "~/.claude/leopold/hooks/guard-irreversible.sh" } ] }
],
"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. The two engine hooks:
# >>> leopold (managed) >>>
[[hooks.PreToolUse]]
matcher = "Bash"
[[hooks.PreToolUse.hooks]]
type = "command"
command = "/home/you/.claude/leopold/hooks/guard-irreversible.sh"
timeout = 5
[[hooks.Stop]]
[[hooks.Stop.hooks]]
type = "command"
command = "/home/you/.claude/leopold/hooks/stop-continuity.sh"
timeout = 15
# <<< 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.
Event log¶
The two engine hooks append structured events to .leopold/events.jsonl
(turn_start, stop, guard_block, and the continuity events
checkpoint_instruction, window_roll, no_progress_across_windows,
max_windows; the watcher adds window_relaunch / window_relaunch_refused),
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.