Skip to content

Hooks Reference

Every BoB hook, by event. Extracted from handbook §14.

← docs home

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):

HookFields
Bash PreToolUsetool_input.command
Edit/Write PreToolUsetool_input.file_path (Edit: tool_input.old_string / new_string; Write: tool_input.content)
Bash PostToolUsetool_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 hookssession_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.

EventMatcherHookPurpose
SessionStarttemporal-context.shInject current date/time so Claude doesn't drift
SessionStartwarp-drive-inject.shRestore loop state on session resume
SessionStartloop-notes-inject.shInject the project's loop episodic-memory notes (#588) so a run doesn't re-learn recorded quirks; read-only, fail-open (#1451)
SessionStartdev-auto-start.shAuto-start dev server when project requires it
PreToolUseBashstrip-ai-boilerplate.shStrip generic AI prose from outgoing edits
PreToolUseBashwarp-drive-gate.shBlock 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)
PreToolUseBashdoc-orphan-gate.shRefuse a commit that stages a doc nothing links to, naming the index to edit; PR-independent, suppressible via _workflow.suppress_doc_orphan_gate (#1444)
PreToolUseBashwarp-drive-pre-exit.shCleanup before exit
PreToolUseBashsession-branch-guard.shGuard main + refuse off-stream commits when a stream is bound (child-of-stream allowed, #1180)
PreToolUseBashpromotion-ceiling-guard.shRefuse a deploy past the project's promotion ceiling (#452)
PreToolUseBashautonomous-audit-log.shAt 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)
PreToolUseEdit|Writecheck-branch.shBlock writes on master/main
PreToolUseEdit|Writeautonomous-audit-log.shAs above, for edits
PreToolUseAskUserQuestion|…converserdb-enforce-ask-remote.shWhen _rdb.enabled, force ask_remote over AskUserQuestion
PostToolUseEdit|Writewarp-drive-edit-tracker.shTrack edits during a warp-drive chunk
PostToolUseEdit|Write|Bashwarp-drive-docs-detector.shAdvance 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)
PostToolUseBashwarp-drive-commit-detector.shDetect 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
PostToolUseBashdoc-drift-warning.shDoc-drift gate on git commit: tooling touched, no *.md → advisory warn (default) or block (exit 2) per _workflow.doc_drift_blocking; PR-independent (#757)
PostToolUseAskUserQuestion|…ask_remotecapture-decision.shCapture AskUserQuestion / RDB ask_remote outcomes to the decision log (#332)
PostToolUseSkill|Task|Agentsession-ledger-append.shAppend Skill/Task/Agent invocations to the project's session evidence ledger (#1131); fail-open, see session-ledger.md
Stopwarp-drive-stop.shPersist state on session stop
Stopsession-summary-reminder.shRemind to capture session summary
Stopstop-notify-rdb.shNotify Telegram when warp-drive stops
SubagentStopswarm-result-gate.shPer-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:

ScriptWhy it is unwired
autoloop-gate.sh, autoloop-inject.sh, autoloop-stop.shLegacy predecessors of the warp-drive-* hooks that replaced them. Superseded.
validate-requirement-refs.sh, validate-pr-requirements.shEnforce REQ-NNNN references against the local PM-file scheme that GitHub Issues replaced (handbook §15.3). Superseded.
validate-commit-message.shConventional-commit enforcement now lives in the git commit-msg hook cdi installs (below), which covers human commits too. Superseded.
pre-release-checklist.shPredates 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:

FileRole
BOB_SOURCE/settings.jsonThe 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 .hooks is 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 match BOB_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_local key, 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.js reports those entries as NOTE … intentional local hook(s), not as undeclared drift. This is the CLAUDE.local.md layering applied to hooks: the overlay is the declaration, .hooks is 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_local is reported as [undeclared] with the fix (declare it in source, move it under _hooks_local, or run the engine with --prune). deploy.sh never 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 converged and 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-run shows the delta (deploy.sh --dry-run runs plan, 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 document

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_local entries excepted). Reports SKIP (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 a hook_drift record naming the unconverged scripts (-script declared but never runs, +script runs but nothing declares it, ~script an intentional _hooks_local hook). 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:

  1. Strips AI boilerplate (Co-Authored-By: Claude, 🤖 Generated, …).
  2. On projects with automated versioning (.versionrc.json / commitlint.config.* present), enforces Conventional Commitscommitlint if 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 in make check against a committed sample fixture.
  • Sources: AskUserQuestion and RDB ask_remote (captured automatically by the hook); /code-review accept/reject (captured via scripts/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.