Hooks Reference
Hooks Reference
Section titled “Hooks Reference”Every BoB hook, by event. Extracted from handbook §14.
Hooks Reference
Section titled “Hooks Reference”Hooks are shell scripts wired into Claude Code events via ~/.claude/settings.json. They run automatically at the right moment — never invoke them manually.
Hooks receive ALL data via stdin as JSON. There are no magic variable substitutions.
INPUT=$(cat)COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command')Template: templates/hooks/command-hook.sh. Requires jq.
Payload field table. Every field a hook reads comes from this JSON; the PostToolUse result is under tool_response, not tool_output (#1831):
| Hook | Fields |
|---|---|
| Bash PreToolUse | tool_input.command |
| Edit/Write PreToolUse | tool_input.file_path (Edit: tool_input.old_string / new_string; Write: tool_input.content) |
| Bash PostToolUse | tool_input.command, tool_response — an object {stdout, stderr, interrupted, isImage, noOutputExpected} on success, a string beginning Error: Exit code N on failure. There is no exit code field. |
| All hooks | session_id, transcript_path, cwd, hook_event_name, tool_name |
A Bash PostToolUse hook tests success with jq -e '(.tool_response? | type == "object" and (.interrupted != true)) or (.tool_output?.exit_code? == 0)' — the second clause is the legacy tool_output shape older harnesses sent, accepted only as a fallback. An unrecognised payload must be treated as a failed call, never defaulted to success.
Fixture shapes are captured from transcripts, never authored from memory. Hook tests build their stdin from tests/fixtures/hooks/ (via tests/lib/hook-payload.sh), each fixture produced by scripts/capture-hook-fixture.js <session.jsonl> --event <PreToolUse|PostToolUse> [--tool Bash] [--result success|failure] from a real session — the assistant tool_use input becomes tool_input and the following toolUseResult becomes tool_response, redacted (bodies truncated, tokens replaced, envelope kept) — so refreshing the corpus on a harness upgrade is one command per fixture (#1839). The corpus covers PreToolUse Bash / Edit / Write / AskUserQuestion and PostToolUse Bash (success, failure, legacy) / Edit / Write / AskUserQuestion, each with provenance (transcript id + harness version) in tests/fixtures/hooks/README.md. Stop and SubagentStop envelopes are not written to transcripts: wire capture-hook-fixture.js record as that event’s hook for one session to capture them live (it always exits 0).
Hook tests build payloads only through the fixture helpers — tests/lib/hook-payload.sh (hook_payload, hook_payload_file, hook_payload_ask, hook_fixture_with, hook_payload_shape) or its JS twin tests/lib/hook-payload.js — overriding just the command / file_path / questions the test needs; a historical shape is derived from the real one by moving fields, never retyped. tests/test-hook-payload-drift.js (make test-hook-payload-drift) fails the build when any hook or template reads .tool_output without a .tool_response alternative, or when a test that feeds a hook contains a hand-written tool_response: / tool_output: literal — so the fictional shape cannot be reintroduced. (tests/test-hook-drift.js is a different probe: the #1442 declared-vs-wired settings check.)
Hooks that emit JSON output must include hookEventName. Claude Code validates hook stdout: a hookSpecificOutput object is rejected (and its additionalContext discarded, with a startup error) unless it names the event it belongs to (#1371):
{ "hookSpecificOutput": { "hookEventName": "SessionStart", "additionalContext": "..." }}Hooks by event
Section titled “Hooks by event”This table is the declared hook set — the 24 wirings in BOB_SOURCE/settings.json, matcher included, because a hook’s matcher decides which tool calls it sees. A script in hooks/ that appears nowhere below is not running; those are listed under Scripts that are not wired.
| Event | Matcher | Hook | Purpose |
|---|---|---|---|
| SessionStart | — | temporal-context.sh |
Inject current date/time so Claude doesn’t drift |
| SessionStart | — | warp-drive-inject.sh |
Restore loop state on session resume |
| SessionStart | — | loop-notes-inject.sh |
Inject the project’s loop episodic-memory notes (#588) so a run doesn’t re-learn recorded quirks; read-only, fail-open (#1451) |
| SessionStart | — | dev-auto-start.sh |
Auto-start dev server when project requires it |
| PreToolUse | Bash |
strip-ai-boilerplate.sh |
Strip generic AI prose from outgoing edits |
| PreToolUse | Bash |
warp-drive-gate.sh |
Block real git commit invocations outside the committing phase — argv-position detection via scripts/lib/git-commit-detect.js, so the verb in prose/heredoc/quoted data never trips it (#858) |
| PreToolUse | Bash |
doc-orphan-gate.sh |
Refuse a commit that stages a doc nothing links to, naming the index to edit; PR-independent, suppressible via _workflow.suppress_doc_orphan_gate (#1444) |
| PreToolUse | Bash |
warp-drive-pre-exit.sh |
Cleanup before exit |
| PreToolUse | Bash |
session-branch-guard.sh |
Guard main + refuse off-stream commits when a stream is bound (child-of-stream allowed, #1180) |
| PreToolUse | Bash |
promotion-ceiling-guard.sh |
Refuse a deploy past the project’s promotion ceiling (#452) |
| PreToolUse | Bash |
autonomous-audit-log.sh |
At automation L3 only, record every auto-approved tool call (tool, target, ts) to a per-session JSONL audit log; strict no-op at L1/L2 (#420) |
| PreToolUse | Edit|Write |
check-branch.sh |
Block writes on master/main |
| PreToolUse | Edit|Write |
autonomous-audit-log.sh |
As above, for edits |
| PreToolUse | AskUserQuestion|…converse |
rdb-enforce-ask-remote.sh |
When _rdb.enabled, force ask_remote over AskUserQuestion |
| PostToolUse | Edit|Write |
warp-drive-edit-tracker.sh |
Track edits during a warp-drive chunk |
| PostToolUse | Edit|Write|Bash |
warp-drive-docs-detector.sh |
Advance updating_docs on the AC tick (gh issue edit <req> --body…) or a successful Edit/Write under the project’s own docs/ tree — anchored, so .claude/docs/ or node_modules/*/docs/ never count, and a string/absent tool_response is not evidence (#1852, #1832); keeps the redacted live payload of each evaluated call at .claude/.last-hook-payload.json for defect evidence (#1839) |
| PostToolUse | Bash |
warp-drive-commit-detector.sh |
Detect real commits (same argv-position detection as the gate, #858) and advance the state machine; records last_bash_eval (#1835) and the redacted live payload (#1839) on every call evaluated in committing |
| PostToolUse | Bash |
doc-drift-warning.sh |
Doc-drift gate on git commit: tooling touched, no *.md → advisory warn (default) or block (exit 2) per _workflow.doc_drift_blocking; PR-independent (#757) |
| PostToolUse | AskUserQuestion|…ask_remote |
capture-decision.sh |
Capture AskUserQuestion / RDB ask_remote outcomes to the decision log (#332) |
| PostToolUse | Skill|Task|Agent |
session-ledger-append.sh |
Append Skill/Task/Agent invocations to the project’s session evidence ledger (#1131); fail-open, see session-ledger.md |
| Stop | — | warp-drive-stop.sh |
Persist state on session stop |
| Stop | — | session-summary-reminder.sh |
Remind to capture session summary |
| Stop | — | stop-notify-rdb.sh |
Notify Telegram when warp-drive stops |
| SubagentStop | — | swarm-result-gate.sh |
Per-worker swarm result gate (#579): when a swarm worker subagent stops, run the deterministic gate (tests/lint/secrets/scope) and block an ineligible stop so the worker revises; fail-open outside swarm context (#1451) |
Verify with make check-hooks: it confirms every referenced script exists and is executable, and that the declaration above matches what the runtime file actually wires.
Scripts that are not wired
Section titled “Scripts that are not wired”Seven scripts in hooks/ are referenced by neither settings file. They are inert — nothing invokes them. All seven are superseded legacy (reviewed for #1451: none is a documented-as-active gap — the two that were, loop-notes-inject.sh and swarm-result-gate.sh, are wired above since that fix). Listed here so their presence on disk is never mistaken for behaviour, and reported as NOTE (never a failure) by make check-hooks:
| Script | Why it is unwired |
|---|---|
autoloop-gate.sh, autoloop-inject.sh, autoloop-stop.sh |
Legacy predecessors of the warp-drive-* hooks that replaced them. Superseded. |
validate-requirement-refs.sh, validate-pr-requirements.sh |
Enforce REQ-NNNN references against the local PM-file scheme that GitHub Issues replaced (handbook §15.3). Superseded. |
validate-commit-message.sh |
Conventional-commit enforcement now lives in the git commit-msg hook cdi installs (below), which covers human commits too. Superseded. |
pre-release-checklist.sh |
Predates the promotion ceiling gate (#452); promotion-ceiling-guard.sh is the live mechanism. Superseded. |
BOB_SOURCE vs BOB_HOME settings.json
Section titled “BOB_SOURCE vs BOB_HOME settings.json”There are two settings.json files and they are not the same kind of thing:
| File | Role |
|---|---|
BOB_SOURCE/settings.json |
The declaration. Version-controlled, reviewed, fleet-wide. Not read by Claude Code. |
BOB_HOME/settings.json (~/.claude/settings.json) |
The runtime. What Claude Code actually reads. Per-machine, and on deploy.sh’s protected list — never overwritten as a file. Its hooks block is the one exception: reconciled from the declaration on every deploy (#1918). |
The repo’s copy is neither a pure template nor a pure reference: the hooks block is intended-convergent state — the runtime file is expected to match it exactly, on every machine — while these keys are legitimately machine-scoped and are expected to differ:
model · permissions · statusLine · enabledPlugins · agentPushNotifEnabled · inputNeededNotifEnabled · remoteControlAtStartup · skipWorkflowUsageWarning · _hooks_local
That split is enumerated once, in MACHINE_SCOPED_KEYS in scripts/fleet/hook-drift.js, so the check and this document cannot disagree.
Reconcile on deploy (#1918)
Section titled “Reconcile on deploy (#1918)”Because deploy.sh protects the runtime file, a hook added to the declaration used to have no propagation path — the two diverged silently. #1442 found five declared hooks that had never been wired on one machine; harness-defect #1856 later found a laptop eight behind, including warp-drive-docs-detector.sh (breaking warp-drive’s docs auto-advance on every run) and several safety/audit hooks. A frozen file is not a protected file — it is a drifting one.
deploy.sh therefore runs scripts/fleet/settings-hooks-reconcile.js on every deploy, right after listing the protected files. The contract:
-
Only
.hooksis written. Every other key (permissions,model,statusLine, …) is copied through untouched, in its original order. The file stays protected; its hook wiring stops being frozen. -
Source is authoritative per
(event, matcher)group — the group’s command list and order are rewritten to matchBOB_SOURCE/settings.json, so a gate that must run before a detector runs before it on every machine. -
Local hooks survive under
_hooks_local. A machine declares its intentional extras in a top-level_hooks_localkey, shaped exactly like.hooks. The reconcile merges that overlay over the source base (appended after the source group’s commands, or as a new group) and never drops it;hook-drift.jsreports those entries asNOTE … intentional local hook(s), not as undeclared drift. This is theCLAUDE.local.mdlayering applied to hooks: the overlay is the declaration,.hooksis the derived wiring."_hooks_local": {"PreToolUse": [ { "matcher": "Bash", "hooks": [ { "type": "command", "command": "$HOME/.claude/hooks/my-laptop-guard.sh" } ] } ]} -
Undeclared hooks are kept and named, never pruned silently. A runtime hook declared in neither source nor
_hooks_localis reported as[undeclared]with the fix (declare it in source, move it under_hooks_local, or run the engine with--prune).deploy.shnever passes--prune. -
Reported, never silent. Each hook added / updated / removed / reordered is named, as is every preserved local hook. A converged machine prints
PASS settings hooks convergedand writes nothing — the reconcile is idempotent. -
Backup first. Before any apply, the pre-reconcile file is copied to
~/.claude/backups/settings/settings.json.<UTC timestamp>.bak. No write, no backup. -
--dry-runshows the delta (deploy.sh --dry-runrunsplan, which exits 3 on drift and writes nothing). -
Failure is loud, not fatal. An unparseable runtime file is left alone with a warning naming the resume command; the rest of the deploy completes.
Run the engine directly to inspect or converge a machine without a full deploy:
node ~/.claude/scripts/fleet/settings-hooks-reconcile.js plan # delta only; exit 0 converged, 3 driftnode ~/.claude/scripts/fleet/settings-hooks-reconcile.js apply # backup + write .hooksnode ~/.claude/scripts/fleet/settings-hooks-reconcile.js apply --prune # also remove undeclared hooksnode ~/.claude/scripts/fleet/settings-hooks-reconcile.js plan --json # bob-settings-hooks-reconcile/1 documentObserving convergence
Section titled “Observing convergence”Two surfaces assert it, so drift is caught rather than discovered by a broken run:
make check-hooks— the local machine. Fails on a declared-but-unwired hook and on a runtime hook nothing declares (_hooks_localentries excepted). ReportsSKIP(not a failure) when there is no runtime file at all, which is the normal case in CI — runtime wiring is per-machine state, not a property of the checkout.audit.js view— the whole fleet. Divergence is per-machine, so the snapshot carries ahook_driftrecord naming the unconverged scripts (-scriptdeclared but never runs,+scriptruns but nothing declares it,~scriptan intentional_hooks_localhook). A farm worker missing a safety hook is visible without SSHing to it.
To converge a machine, run scripts/deploy.sh (or the engine’s apply), then re-run make check-hooks.
Git hooks (distinct from Claude Code hooks)
Section titled “Git hooks (distinct from Claude Code hooks)”Separately from the settings.json event hooks above, cdi installs a git
commit-msg hook (templates/git/hooks/commit-msg) into each project. It runs for
every commit — Claude-made or human — and does two things in order:
- Strips AI boilerplate (
Co-Authored-By: Claude,🤖 Generated, …). - On projects with automated versioning (
.versionrc.json/commitlint.config.*present), enforces Conventional Commits —commitlintif installed, else a built-in dependency-free check (#278). Non-versioned projects keep strip-only behavior. See the versioning guide.
Decision log — outer improvement loop (#332)
Section titled “Decision log — outer improvement loop (#332)”capture-decision.sh (above) records the stream of user decisions so they can
later be distilled into updated, version-controlled instructions. The capture
writers and the schema live under scripts/decision-log/ and schemas/:
- Log:
<project-root>/.claude/.decisions.jsonl— JSONL, append-only, one record per decision. Runtime state, gitignored (the distilled output is what gets git-tracked, not the raw stream). - Schema (source of truth):
schemas/decision-log.schema.json, validated inmake checkagainst a committed sample fixture. - Sources:
AskUserQuestionand RDBask_remote(captured automatically by the hook);/code-reviewaccept/reject (captured viascripts/decision-log/log-code-review.sh, since a slash command’s outcome is not a hookable tool call). - Rotation: see
scripts/decision-log/README.md.