Appearance
Hooks Reference
Every BoB hook, by event. Extracted from handbook §14.
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.
bash
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):
json
{
"hookSpecificOutput": {
"hookEventName": "SessionStart",
"additionalContext": "..."
}
}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
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
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)
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.json"_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:
bash
node ~/.claude/scripts/fleet/settings-hooks-reconcile.js plan # delta only; exit 0 converged, 3 drift
node ~/.claude/scripts/fleet/settings-hooks-reconcile.js apply # backup + write .hooks
node ~/.claude/scripts/fleet/settings-hooks-reconcile.js apply --prune # also remove undeclared hooks
node ~/.claude/scripts/fleet/settings-hooks-reconcile.js plan --json # bob-settings-hooks-reconcile/1 documentObserving 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)
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)
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.