Skip to content

Hooks Reference

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

← docs home

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.

Terminal window
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}. PostToolUse fires only for a call that succeeded. There is no exit code field.
Bash PostToolUseFailure tool_input.command, error — a string Exit code N\n<output> — and is_interrupt, duration_ms; no tool_response. A failed call fires this event instead of PostToolUse, so a hook that must see failed calls has to be wired on it too (#2385).
All hooks session_id, transcript_path, cwd, hook_event_name, tool_name

A Bash hook tests success with jq -e '(.hook_event_name? != "PostToolUseFailure") and ((.tool_response? | type == "object" and (.interrupted != true)) or (.tool_output?.exit_code? == 0))' — the last 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 / Skill / Agent (the Task alias rides the Agent fixture), each with provenance (transcript id + harness version) in tests/fixtures/hooks/README.md. SessionStart, Stop and SubagentStop stdin is not written to transcripts (only each hook’s stdout is): wire capture-hook-fixture.js record as that event’s hook for one session — declared in settings.hooks-local.json, applied from a plain terminal (#1997) — to capture them live (it always exits 0). Any tool, MCP tools included, is transcript-derivable with --tool <name>; the two MCP matchers stay pending until a transcript on this machine contains a call (#2039).

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, when a test that feeds a hook contains a hand-written tool_response: / tool_output: literal, or when a fixture in tests/fixtures/hooks/ lacks the _provenance stamp capture-hook-fixture.js writes into every file it produces (captured_by, mode: transcript|record, transcript, captured_at — #2038; the one retained historical shape, bash-posttooluse-legacy.json, is allow-listed by name and declares itself captured_by: "retained") — so the fictional shape cannot be reintroduced by a hook, a test, or the corpus itself. The payload libs strip _provenance before a hook sees the payload; the key is underscored because SessionStart’s payload carries its own top-level source, which a source stamp would have overwritten on the first live capture (#2107), and stamp() now refuses to shadow any key the payload already has. The same test is the coverage gate (#2039): it derives every wired (event, matcher-alternative) from BOB_SOURCE/settings.json .hooks through settings-hooks-reconcile.js’s own resolvePaths / groupsOf, reports each as covered (a fixture whose hook_event_name / tool_name match) or pending, and fails only when an entry in the committed ledger tests/fixtures/hooks/coverage.json has lost its fixture — a newly covered event is a NOTE naming --update-ledger, which rewrites the ledger from the corpus. For every hook on a covered event it lists each stdin field the script reads (jq programs applied to $INPUT, plus the node delegate of session-ledger-append.sh) that no fixture for that event carries as a field-not-in-fixture WARN — report only, the shopping list for the next capture — and for a pending event the fields-wanted its hooks would read. The commit detector is exercised end to end on these fixtures by tests/test-commit-detector-hook.sh (make test-commit-detector-hook, #2038): the hook binary runs in a scratch git repo against a fake state-machine.js that records the transition it is asked to fire, and each of its four verdicts — not-a-commit, head-unmoved, call-failed (on the captured PostToolUseFailure fixture, and on the older transcript-derived Error: Exit code N string shape), advanced (exactly one committed with the new HEAD) — is asserted from a captured payload. (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": "..."
}
}

This table is the declared hook set — every hook BOB_SOURCE/settings.json runs, matcher included, because a hook’s matcher decides which tool calls it sees. The PreToolUse and PostToolUse rows are guards: settings.json wires one dispatch.sh <event> <matcher> entry per matcher, and the dispatcher runs that matcher’s guards in the order listed here — the row order of hooks/dispatch.tsv (One dispatcher per event and matcher). 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. Passes the payload session_id and --pid ${CLAUDE_PID:-$PPID}. Only a session that may take the run over re-anchors it: the owner, the continuation a spawned checkpoint handed off to, or anyone once the owner is stale. Any other session starting in the checkout gets a one-line advisory context and changes nothing (#2275)
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 warp-drive-gate.sh Block real git commit invocations outside the committing phase — argv-position detection via scripts/lib/git-commit-detect.js (a thin front end over the shared git-command-detect.js lexer), so the verb in prose/heredoc/quoted data never trips it (#858). Gates the repo the commit runs in, not the session cwd: a leading cd <dir> or git -C <dir> is resolved (git-commit-detect.js --dir), and a git worktree is its own root. A quoted or variable target falls back to the cwd. It gates only a run the calling session owns: a run another session owns passes the commit with a one-line systemMessage naming that run’s phase (#2275)
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). When orphan-check.js cannot run it still allows the commit, but warns that the check did not run and names the error — never a silent pass (#2692)
PreToolUse Bash warp-drive-pre-exit.sh Cleanup before exit. Speaks its self-verification prompt only to the session that owns the run (#2306) — a bystander running a phase-exit command in the checkout is not prompted
PreToolUse Bash session-branch-guard.sh Guard main + refuse off-stream commits when a stream is bound (child-of-stream allowed, #1180) Prints one stderr warning and skips when jq is missing instead of passing silently (#3617). Each real git call is judged in the repo it runs in (cd <dir> &&, git -C <dir>), against that repo’s _branch.session and branches; a heredoc body or quoted argument that only mentions git is never matched. A directory change ends with its subshell (( … ), $( … )) and popd undoes its pushd, so the call after them is judged where it really runs; the script of bash|sh|zsh -c '…' is read as commands, so a git call handed to a nested shell is still seen (#4366).
PreToolUse Bash promotion-ceiling-guard.sh Refuse a direct advance of main past the project’s promotion ceiling (#452) — argv-position detection via scripts/lib/git-command-detect.js, so the verb in prose/heredoc/quoted data never trips it and git -C <dir> push never slips past (#1961). The routes it reads (a merge on main, any refspec whose destination is main, a bare push on main, and in an unattended run gh pr merge and a refs API write) come from scripts/promotion/main-advance-detect.js; unattended, it fails closed (#3624, farm-tick)
PreToolUse Bash destructive-git-guard.sh At L2 and above, refuse a git command that destroys history or working-tree state whatever position its flags are in — a force push in any form to any remote, git clean -f, git stash drop|clear, git branch -D, git reset --hard — read from git argv on the shared lexer by scripts/security/destructive-git-detect.js; the profiles’ prefix floor stays the outer layer (#3621)
PreToolUse Bash self-modification-guard.sh At L3 and L4, refuse a command that writes to a .claude/settings.json / settings.local.json, to $BOB_HOME/hooks/** or to $BOB_HOME/logs/autonomous-audit/** — output redirections, and any command that is not a known reader (scripts/security/self-write-detect.js). Reads and the owner scripts pass; strict no-op at L1/L2 (#3618). See Self-modification guard
PreToolUse Bash secret-store-guard.sh At L3 and L4, refuse a command that runs gh auth token or a macOS keychain reader, or that names a .dev.vars* / .mcp.json file — the hook layer behind the profiles’ secret-store deny rules; BoB’s own readers (bob notify, bob secrets check, cred-check.js) pass because they open the store inside the script (#3620)
PreToolUse Bash cf-destructive-guard.sh At every level, hold a command that would delete a Cloudflare D1 database, R2 bucket, KV namespace, production Worker, zone or DNS record until the operator answers yes. The held commands are listed in one rule file, scripts/security/cf-destructive-rules.json; everything else passes. See Cloudflare destructive-command guard (#3588)
PreToolUse Edit|Write check-branch.sh Block writes on master/main Prints one stderr warning and skips when jq is missing instead of passing silently (#3617).
PreToolUse Edit|Write self-modification-guard.sh As above, for the file an edit or write targets (#3618)
PreToolUse AskUserQuestion|…converse rdb-enforce-ask-remote.sh When _rdb.enabled, force ask_remote over AskUserQuestion Keeps no debug log — the former fixed /tmp path is gone — and prints one stderr warning and skips when jq is missing (#3617).
PreToolUse Read secret-store-guard.sh As above, for a Read of a file named .dev.vars, .dev.vars.<env> or .mcp.json — the one Read matcher dispatched (#3620)
PreToolUse * (every tool) autonomous-audit-log.sh At automation L3 and L4, record every tool call — Bash, Edit/Write, NotebookEdit, WebFetch, WebSearch, browser and mcp__* tools alike — as one line (tool, target, ts) in the per-session JSONL audit log; strict no-op at L1/L2 (#420). Wired once, from the all-tools row, and from no per-tool row, so a call is never logged twice; since it is its own dispatch, a call another guard blocks is on the trail too (#3619). The level is the one the session started at, and each line is chained to the one before it — scripts/security/audit-chain.sh verify checks the chain (#3618) The target passes through the shared token redaction (scripts/lib/hook-redact.sh) and the log is created mode 600 (#3617).
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); a hand-fired docs_updated counts as a miss only when a box was newly ticked during the phase — nothing-to-tick is accepted by the engine without a defect (#1856); records last_bash_eval (hook, the redacted command, the payload key shape, a verdict of advanced / call-failed / not-a-tick / no-requirement) on every Bash call evaluated in updating_docs, the evidence a filed docs defect used to lack (#2169). Acts only for the session that owns the run (#2306): the ownership verdict from state-machine.js status --session-id is read before any write, so a bystander working in the same checkout neither advances the owner’s phase nor overwrites its evidence; an unreadable verdict, a missing engine or a caller that names no session all fail open. Resolves the run from the checkout the call acts on (#3306): a leading cd <dir> before the tick, or the checkout of an edited docs file when that checkout carries a run, then the payload cwd — so a run driven from another checkout advances on its own tick. The payload keeps paths and the key shape, never a file body (content, old/new strings, the original file, the patch are omitted), passes through the shared scripts/lib/hook-redact.sh and is created mode 600 (#3617).
PostToolUse Bash warp-drive-commit-detector.sh Detect real commits (same argv-position detection as the gate, #858) and advance the state machine only when a commit landed — HEAD moved since tests_passed (#1830), or exists at all on an unborn branch (#1955) — judged by the landed commit, never by the whole Bash call’s exit status: a failed git commit … && git push whose commit landed still advances (#1897); records last_bash_eval with a verdict (advanced / head-unmoved / call-failed / not-a-commit, #1835/#1897) and the redacted live payload (#1839) on every call evaluated in committing. Acts only for the session that owns the run (#2306): the ownership verdict from state-machine.js status --session-id is read before any write, so a bystander working in the same checkout neither advances the owner’s phase nor overwrites its evidence; an unreadable verdict, a missing engine or a caller that names no session all fail open. The payload keeps paths and the key shape, never a file body, through the shared scripts/lib/hook-redact.sh, created mode 600; the .claude/harness-defects/ copies are mode 600 and in the managed ignore block (#3617).
PostToolUseFailure Bash warp-drive-commit-detector.sh The same guard on the event a failed call fires (#2385): git commit … && git push …; git rev-parse … exits non-zero after the commit landed, never reaches PostToolUse, and advances here through the #1897 branch (exactly one new commit on pre_commit_head). last_bash_eval.event names the event evaluated.
PostToolUseFailure Bash warp-drive-docs-detector.sh The same guard on a failed call: records call-failed as evidence, never advances (#2385)
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 AskUserQuestion|…ask_remote cf-destructive-guard.sh Mark a held Cloudflare delete approved when the operator answers “Yes, delete” to the ask_remote question that carries its hold key; prints nothing, never blocks (#3588)
PostToolUse Skill|Task|Agent|Bash session-ledger-append.sh Append Skill/Task/Agent invocations, and each routed bob command a Bash call runs as a cli event (#3923), to the project’s session evidence ledger (#1131); a Bash call that never says bob returns before node starts; fail-open, see session-ledger.md
Stop — warp-drive-stop.sh Block the stop while the run’s chunk report is pending, and give an advisory in other active phases. It acts only for the session that owns the run (status --session-id): another session’s stop is approved with a one-line advisory naming the live run, never blocked on a report it did not produce (#2275)
Stop — stop-notify-rdb.sh Ask over Telegram whether to continue, or notify when the session ends — sends through scripts/rdb/notify.sh, the bob notify engine (#2048). Uses only a bridge the operator declares; see Which bridge the Stop hook asks
SessionEnd — deploy-pending-drain.sh Finish a queued deploy (#3162): when $BOB_HOME/.deploy-pending exists, start the drain (scripts/fleet/deploy-pending.js drain) detached. The drain waits up to BOB_DEPLOY_DRAIN_WAIT_S (300 s) for the live sessions to end, then deploys under the bob tick lock. If another session is still live, the record stays for the hook of the last session to end. The hook exits at once when nothing is queued and always exits 0. Log: $BOB_HOME/logs/deploy-drain.log
SessionEnd — token-ledger-session-end.sh Record what an interactive session spent in the token ledger (#4101): one type: session record of kind interactive in ~/.claude/token-usage.jsonl, measured from the session’s transcript by scripts/lib/token-ledger.js session-end, LESS every session record already written for the same session id (a bob do, bob decompose or warp-drive run inside it), so nothing is counted twice. Writes nothing when BOB_RUN_KIND is set (a bob groom session records itself) or a warp-drive run is still live in the checkout. Measures detached; always exits 0
SubagentStop — swarm-result-gate.sh Per-worker swarm result gate (#579): when a swarm worker subagent stops, run the deterministic gate (tests/verification/lint/secrets/scope — verification is the subtask’s own done-check, #3556) and block an ineligible stop so the worker revises. A failed gate also writes the unit’s return record, and once the unit has failed four times (three corrections) it is needs-attention and the stop is allowed (#4301). 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.

One dispatcher per event and matcher (#2082)

Section titled “One dispatcher per event and matcher (#2082)”

A Bash tool call used to start seven PreToolUse and three PostToolUse hook processes, each a fresh bash parsing the same stdin with its own jq. settings.json now wires exactly one entry per PreToolUse / PostToolUse matcher, and that entry runs every guard for it in one process:

{ "matcher": "Edit|Write", "hooks": [ { "type": "command", "command": "$HOME/.claude/hooks/dispatch.sh PreToolUse 'Edit|Write'" } ] }
  • The table. hooks/dispatch.tsv lists the guards per (event, matcher) — one row <event> TAB <matcher> TAB <guard>; row order is run order (the order settings.json declared them in before #2082). Adding a guard is a row there, not a new settings.json entry.
  • The guard. A guard’s body is the function bob_hook_<name> (dashes become underscores) in hooks/lib/<guard>. It reads the payload as INPUT=${BOB_HOOK_INPUT-$(cat)} — the dispatcher’s one read when it set it, stdin otherwise — and resolves sibling scripts from its own file, one level deeper than a hook: $(dirname "${BASH_SOURCE[0]}")/../../scripts. hooks/<guard> is a thin wrapper that sources the lib and calls the function, kept for one release cycle so a machine whose settings.json still names the script, and direct callers such as the hook tests, behave exactly as before.
  • The verdict. The dispatcher calls each guard in its own subshell, so a guard’s exit, stdout and exit status are what the script gave. A guard blocks when it exits 2 or prints {"decision":"block"} (or a hookSpecificOutput.permissionDecision of "deny"). PreToolUse stops at the first block and emits that guard’s stdout and exit status — the call will not run, so there is nothing left to guard. PostToolUse — and PostToolUseFailure, the event a failed call fires instead (#2385) — runs every guard, because the call already ran and its detectors and recorders must all see it, then emits the first block. With no block, a single non-empty stdout is emitted verbatim and several are merged into one JSON document; stderr passes through. A guard whose lib or function is missing is skipped with a [dispatch] stderr line naming it, never silently.
  • The proof. tests/test-dispatch.sh (make test-dispatch) replays every captured fixture a dispatched matcher receives, plus block and approve scenarios derived from them, through the pre-#2082 per-hook chain and through the dispatcher, and asserts the same verdict, stdout, exit status and per-guard trace (BOB_DISPATCH_TRACE=<file> records guard<TAB>exit<TAB>cksum per guard). It prints the hook processes one Bash call starts, before and after.
  • The tooling. scripts/lib/hook-dispatch.js is the one reader of the table: list [<event> <matcher>] prints the guards in run order and check grades the table against hooks/lib and settings.json (one dispatcher entry per matcher, called with its own event and matcher; every row wired; every guard defined). hook-drift.js and declared-behaviours.js expand a dispatcher entry into its guards, so a dispatched guard counts as wired, never as unwired. make check-hooks runs hook-dispatch.js check.
  • The migration. A machine whose runtime settings.json still wires a guard by name, beside the dispatcher entry that now runs it, would run that guard twice. settings-hooks-reconcile.js retires the direct entry on the next deploy (see Reconcile on deploy). Until then, hook-drift.js reports it as superseded.

stop-notify-rdb.sh reads four files, in this order, and each one can only narrow what it does — none can widen it:

  1. <project>/.claude/settings.local.json _rdb.enabled — the per-project switch. false or absent: the hook exits silently, nothing else is read.
  2. The operator’s declared bridge for this machine, the trust anchor, from the first that resolves and every one that does: BRIDGE_WORKER_URL in the environment (the operator profile’s decisions route, env:BRIDGE_WORKER_URL, points here); the machine’s $BOB_HOME/.mcp.json remote-decision-bridge entry — the file bob ready copies into a project on a project_rdb machine; the operator profile’s routes.decisions.endpoint when it is a URL. No declared bridge means the operator has not enabled RDB on this machine: the project’s switch alone enables nothing, and the hook says so on stderr.
  3. <project>/.mcp.json — used only when its bridge URL equals a declared one (trailing slash ignored). Any other URL is refused with both URLs named on stderr and nothing is sent — a cloned repo that commits the switch and its own bridge cannot make the hook ask it.
  4. $BOB_HOME/.mcp.json — the bridge used when the project declares none.

The bearer travels on a curl --config fd, never on argv, and every call carries --max-time (#3615). The reply is text from a remote channel: the block decision is built by jq -n --arg, so quotes, newlines or JSON in it stay text, and its reason opens Remote reply from the bridge (Telegram), verbatim text — … so it is never read as the user’s own instruction. tests/test-bob-notify.sh sections 10–12 pin every row of this through the real hook with a recording curl stub.

Cloudflare destructive-command guard (#3588)

Section titled “Cloudflare destructive-command guard (#3588)”

Full access lets every destructive Cloudflare call run from a session no person is watching. cf-destructive-guard.sh holds those calls for the operator, at every automation level, and leaves every other command alone: wrangler deploy, wrangler d1 execute and wrangler tail pass.

The rule file. Every held command is a rule in scripts/security/cf-destructive-rules.json:

Rule Held command
d1-delete wrangler d1 delete <database>
r2-bucket-delete wrangler r2 bucket delete <bucket>
kv-namespace-delete wrangler kv namespace delete (or kv:namespace)
worker-delete-prod wrangler delete with no --env, or with --env prod / --env production
zone-delete curl -X DELETE (or --request DELETE) to api.cloudflare.com/client/v4/zones/<id>
dns-record-delete curl -X DELETE to …/zones/<id>/dns_records/<id>

A rule names the program, the regexes the words after it must all match (all), the ones none may match (none), and the regexes whose first group names what would be deleted (target). scripts/security/cf-destructive-detect.js reads the file on the shared lexer, so a quoted commit message or a heredoc body that mentions wrangler d1 delete is never held, and npx, pnpm exec, yarn, bunx, sudo and env assignments before the program are skipped. To hold another command, add a rule to the file. No code changes.

How a held command is answered.

  • Attended, RDB off. The hook answers ask, so Claude Code asks the operator in the terminal, naming what would be deleted, even where the profile auto-approves Bash. Yes runs the command. No refuses it.
  • RDB on (_rdb.enabled). The terminal shows no prompt. The call is refused once and the hold is recorded in .claude/.cf-holds.json under a key made from the exact command. The refusal tells the session to call ask_remote with a question that contains [cf-hold:<key>] and the options “Yes, delete” and “No, keep”. The guard’s PostToolUse half, wired on ask_remote, marks the hold approved when the answer is “Yes, delete”. The same command, run again unchanged within 30 minutes, then runs once, and the approval is spent. Any other answer approves nothing.
  • Unattended, RDB off (_workflow.unattended). No one sees a terminal prompt, so the call is refused with the steps to park it: file a todo through scripts/warp-drive/ask-gate.js naming the command, then skip the item.

When the detector or the rule file cannot be read, an unattended run is blocked and an attended one is let through, the rule the other guards follow. tests/test-cf-destructive-guard.sh drives each case through hooks/dispatch.sh with the captured Bash payload.

Matching a shell command in a hook (#858, #1961)

Section titled “Matching a shell command in a hook (#858, #1961)”

A hook that decides based on what a Bash command does must never substring-match tool_input.command. The verb appears in prose (an issue body), in heredoc data and in quoted arguments — while a real invocation behind a global option (git -C <dir> push) does not look adjacent at all. Both failure directions have bitten BoB: #858 (false positives on the commit gate) and #1961, where promotion-ceiling-guard.sh blocked gh pr create --base main — the very remediation its own error message prescribed.

One lexer serves every guard: scripts/lib/git-command-detect.js. A single pass masks quoted regions (as QSTR, preserving argv slots), drops comments and heredoc bodies, and tracks quote state so a << inside a quoted string is never read as a heredoc opener. The surviving code is split on command separators and each simple command is tested at argv position, allowing git global options and benign prefixes (sudo, env, command, env assignments).

Terminal window
printf '%s' "$COMMAND" | node scripts/lib/git-command-detect.js --verb 'merge|push'
# stdout: one match per line as `verb<TAB>command` exit: 0 match · 1 none · 2 error

Take the verb from that output rather than re-matching git <verb> yourself — adjacency is exactly what git -C <dir> push defeats. Treat exit 2 as present (fail safe): a guard must over-block on a crash, never open. git-commit-detect.js remains the commit-specific front end for the two #858 hooks; it delegates here rather than carrying its own copy.

Known limit, unchanged from before: a branch name written as a quoted argument (git push origin "main") is not seen, because quoted regions collapse to QSTR.

A hook never reads _workflow.<key> with its own jq default. Every key is declared once, with its default, in WORKFLOW_DEFAULTS (scripts/warp-drive/state-machine.js), and shell resolves it through the one wrapper:

Terminal window
HOOK_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
. "$HOOK_DIR/../scripts/lib/workflow-config.sh" # same layout in BOB_SOURCE and BOB_HOME
BLOCKING="$(workflow_config doc_drift_blocking "$PROJECT_ROOT" 2>/dev/null || echo false)"
if workflow_config_true suppress_doc_drift_warning "$PROJECT_ROOT"; then exit 0; fi

workflow_config prints the value the state machine itself would see (table default overridden by the project’s settings.local.json); an undeclared key is refused with exit 2, so a new key cannot be born inside a hook — declare it in the table first. Exit 3 means no engine (no node, or state-machine.js missing): a hook that must never break the session treats that as unresolvable and picks its fail-open behaviour (|| echo false above keeps the doc-drift gate advisory), never a second copy of the default. make check (check-workflow-keys) fails on any jq read of _workflow. outside the wrapper, and make test-workflow-config pins the resolver. Full key list: Workflow config.

5 scripts in hooks/ are wired by no hook in BOB_SOURCE/settings.json: pre-release-checklist.sh, strip-ai-boilerplate.sh, validate-commit-message.sh, validate-pr-requirements.sh, validate-requirement-refs.sh.

They are inert — nothing invokes them. All 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; make check-hooks reports them as NOTE (never a failure), and fails when the count above is stale or an unwired script has no row below. The three autoloop-* scripts that used to sit here, legacy predecessors of the warp-drive-* hooks, were archived to the tag archive/2026-09-dead-slices in #2079:

Script Why it is unwired
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 bob init 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.
strip-ai-boilerplate.sh Blocked attribution trailers (and the bare word “Anthropic”) in Bash commands. Attribution is now switched off at the source, in the attribution key of ~/.claude/settings.json, so the guard is redundant (#2718). Superseded.

A project-local reference to any superseded script is dangling by definition — the fleet copies were never materialized and the gate it named is dead — so make repair-fleet ARGS=--yes prunes it from that project’s .claude/settings.json the same way it prunes a reference to a removed script (SUPERSEDED_HOOKS in scripts/rebob/deprecated-cleanup.js, #661 — since #2314 derived from the superseded-by hook records in scripts/lib/retired-items.json whose script is still present, never hand-coded). validate-commit-message.sh is deliberately not in that set: it still runs wherever it is wired.

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 (legacy — migrated out, see below)

That split is enumerated once, in MACHINE_SCOPED_KEYS in scripts/fleet/hook-drift.js, so the check and this document cannot disagree. A machine’s own hook declarations live in a third file, ~/.claude/settings.hooks-local.json, which is protected like the runtime file and never read by Claude Code.

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 in a sibling file, ~/.claude/settings.hooks-local.json. A machine declares its intentional extras there, under a hooks key shaped exactly like settings.json’s .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. The declaration lives beside settings.json, not inside it, because Claude Code’s settings loader rejects the whole file when it finds a PreToolUse / PermissionRequest key anywhere outside hooks — the original in-file _hooks_local key (#1918) did exactly that the first time a real machine adopted iTerm2’s cc-status, and every new session on the machine started hookless (#1998). A file Claude Code never reads is immune to that rule and to any future one; settings.json stays pure Claude Code schema.

    {
    "schema": "bob-hooks-local/1",
    "hooks": {
    "PreToolUse": [ { "matcher": "Bash", "hooks": [ { "type": "command", "command": "$HOME/.claude/hooks/my-laptop-guard.sh" } ] } ]
    }
    }

    A legacy in-file _hooks_local key is still read, and the next apply (deploy.sh) migrates it: its groups move into the sibling file and the key is removed from settings.json, each move reported as [migrate]. Migration is the recovery path for a machine #1998 broke — run apply from a plain terminal and the loader accepts the file again.

  • Undeclared hooks are kept and named, never pruned silently. A runtime hook declared in neither source nor the sibling file is reported as [undeclared] with the fix (declare it in source, adopt it as intentional-local, or run the engine with --prune). deploy.sh never passes --prune. When the entry points into ~/.claude/hooks/ and its script is not there, the [undeclared] line also says the script is absent; it is still kept unless --prune is passed.

  • A guard the dispatcher replaced is retired (#2082). Sometimes a source group is a dispatch.sh <event> <matcher> entry, but the runtime group still wires one of that matcher’s hooks/dispatch.tsv guards directly as $HOME/.claude/hooks/<guard> (the spelling from before #2082). The reconcile then removes the direct entry and names it [retire], because keeping it runs the guard twice: once by name and once through the dispatcher. Retiring needs no --prune:

    • an entry declared in settings.hooks-local.json stays local;
    • a hook the table does not dispatch on that matcher stays [undeclared] and is kept — iTerm2’s cc-status, or a same-named script outside .claude/hooks/;
    • adopt never sees a retiring guard.

    The first deploy of #2082 predates this rule. It left every guard wired twice on the machine it reached, until the direct entries were removed by hand (#2202).

  • A hook the retirement record names is retired (#4135). When a runtime entry’s script is a hooks record in scripts/lib/retired-items.json (disposition removed or superseded-by, not still scheduled), the source has said the hook is gone. The next deploy.sh or apply removes it with no flag, names it [retire] with the issue that retired it, and counts it under retired. Only an entry that points into ~/.claude/hooks/ is retired this way: a third-party hook elsewhere that shares a retired script’s name stays [undeclared] and is kept. An entry declared in settings.hooks-local.json is never retired. The script is read past an interpreter and quotes, so bash $HOME/.claude/hooks/<x> and "$HOME/.claude/hooks/<x>" are retired too. live-check --target <tag> reads the record at that tag, so bob update sees a retirement the tag adds. These two retirements are the only things removed without --prune.

  • Doctor and the reconcile give one verdict. Both match a runtime entry to the source and to settings.hooks-local.json by one identity, hookIdentity(): the script path with $HOME, ${HOME}, ~ and the absolute home path treated as one spelling, plus its arguments. A hook wired as ~/.claude/hooks/<x> is the same hook as a declared $HOME/.claude/hooks/<x>, and the reconcile keeps the runtime spelling rather than rewriting it. hookRetirement() in settings-hooks-reconcile.js decides whether an undeclared entry is retired or has a missing script, and hook-drift.js calls the same function. So bob doctor / preflight (host-drift.js settings_hooks) list the same hooks as plan: a retired-hook finding, fixed by a plain deploy.sh, is exactly what plan lists as [would retire]. Each undeclared-hook finding is what plan keeps as [undeclared] and what plan --prune removes.

  • Adopting is mechanical, not a hand edit (#1913). settings-hooks-reconcile.js adopt <script|command>… copies every undeclared runtime entry that matches — by script basename or full command, on every (event, matcher) it is wired on — into settings.hooks-local.json, backup first; settings.json is not touched at all (the entry is already wired in .hooks). The next plan / make check-hooks reports it as local, not drift. It is idempotent (an already-local name is a reported no-op, no write) and atomic (a name matching nothing writes nothing and exits 2); --all-undeclared adopts everything at once. This is the path for third-party tools that write straight into .hooks — iTerm2’s Claude Code integration installs its cc-status utility on ten events — and it exists because #1442 was closed by hand-wiring a protected file and recurred as #1913: a fix with no convergence path does not hold. Because adopt never rewrites settings.json, it is safe to run inside a live Claude Code session; apply and deploy.sh are not — a rewrite of the runtime file silences every hook in sessions already running until they restart (#1997), so they refuse while a session is live (#2040, next bullet).

  • A rewrite silences running sessions — so it is refused while one is live (#1997 → #2040). Claude Code reads settings.json once per session; after the runtime file is rewritten underneath a running session (apply, or deploy.sh through it — even with .hooks semantically unchanged), every hook in that session stops firing until it restarts, with no warning from the session. apply cannot make the rewrite safe (the loader is Claude Code’s). #1997 chose to warn and proceed (deploy must stay non-interactive) — and the self-inflicted un-hooking kept happening by habit, so #2040 reversed that decision (recorded as decision #2111): apply and deploy.sh refuse by default. Before writing, the engine counts running claude CLI processes and looks for an active warp-drive state file under every project registered in provisions/; when either signal is non-zero it exits 4 (live-session), writes nothing (no file, no backup), and names each live session — warp-drive run: <project> (<phase>) pid <N> from the state file’s session.pid, claude process: pid <N> — <command> from ps — plus the fix. A run whose process is dead is not a session (#3895). A state file whose session.pid is not in the process list belongs to a run that was closed mid-flight: nothing is left to lose its hooks. It is listed as interrupted warp-drive run (not live): <project> (<phase>) — pid <N> is not running (live.interrupted[] in --json) and is never counted, so it cannot hold a queued deploy. A state file with no recorded pid stays live. Each live session is named with where it is (#3895): tmux session <name> when a tmux pane owns its tty, else tty <tty>, cwd <dir>, between the pid and the rest of the line (where: { tmux, tty, cwd } on each entry of live.pids and live.projects in --json). A field that cannot be read is left out, and the line is the pid and command alone. The daemon’s own pre-warmed spare slot is not a session (#2763). The daemon warms a spare before it hands a job its own slot; the spare’s processes are spelled claude bg-spare … and claude bg-pty-host …/spare/<slot>.pty.sock, they run no hooks, belong to no project, and outlive every session — counted, they made the refusal permanent (no operator action cleared it, and bob update has no --force-live). The detector classifies each matching process positionally — from the subcommand and the socket path the pty host was given, never from a /spare/ substring a session’s own prompt or cwd could carry — reports the daemon’s processes as ignored (kind: daemon-spare), and counts what is left once per session, keyed by --session-id where the argv carries one. A session slot (…/pty/<id>.sock) still counts, so the verdict is the number of sessions, not the number of matching processes (matched in --json reports the difference). The fixtures are captured ps output, never authored (#1831): tests/fixtures/live-session/. deploy.sh runs the same guard before any sync (settings-hooks-reconcile.js live-check), so a refused deploy lands nothing; if a session goes live between that check and the rewrite, apply refuses on its own, deploy reports the wiring as not reconciled and exits 4 with the files already synced. The refusal is an exit code, never a prompt — non-interactivity is preserved. One explicit override: --force-live (on apply, live-check, or deploy.sh) or BOB_DEPLOY_FORCE_LIVE=1 proceeds and prints the #1997 NOTE: N running Claude Code session(s) will not run hooks until restarted — warp-drive run(s) in: <project> (<phase>) naming what to restart; deploy.sh repeats it as a [deploy] warning. plan and deploy.sh --dry-run never consult the guard (they never write); the guard is narrowed to the hazard (#2746, #2126): a converged hooks block rewrites nothing, so it is never refused — apply is a no-op, deploy.sh asks plan first and syncs its surfaces without consulting the guard, and live-check passes (PASS settings hooks converged — a deploy writes no settings.json — N live Claude Code session(s) keep their hooks, hooks: "converged" in --json). Surface rsync never silences a hook; only the settings.json rewrite does. A plan that cannot run fails closed — treated as drift, the guard decides. When hooks drift and a session is live, the refusal names every session to restart and the exact override for this checkout — bash <BOB_SOURCE>/scripts/deploy.sh --force-live (force_command in --json). The rule stands: run deploy.sh / apply from a plain terminal, not inside a session — the guard now enforces it. adopt is exempt (it never touches settings.json). Warp-drive itself never calls apply or deploy.sh during a run: bob preflight --converge is kickoff-only and runs only bob ready’s project steps (a grep-verified test pins that no phase shells to either). The self-hosting redeploy (#2472) keeps that rule. bob warp start and bob tick run deploy.sh before claude starts. A run that needs a deploy mid-flight hands off instead: its fresh warp-seg window runs deploy.sh only after the retiring session’s process has exited and only when no other session would lose its hooks — none is live, or the hooks block is converged (the live-check pass, #2126) — and never with --force-live. See Preflight gate and scripts/warp-drive/self-redeploy.js. live-check is the guard as a command (exit 0 / 4, --json = bob-live-session-guard/1 with hooks, strict and force_command) — one verdict, never re-implemented. bob update (#2046) asks about the tag it is moving to, not the checkout it is leaving: after the fetch, before the checkout moves, it runs live-check --target <tag>, which plans the hook wiring that tag declares (its settings.json and hooks/dispatch.tsv, read with git show) against the deployed file. A live session refuses the update only when that target would rewrite .hooks; a ref that cannot be read fails closed (#2746). bob tick (#2054) runs no check of its own: it runs bob update with BOB_UPDATE_CALLER="bob tick", so the update’s guard decides, a refusal (exit 4, queued) is an outcome and never a failed tick, and the refusal is recorded as the tick’s. --strict, the any-session guard, remains for an operator asking whether anything is live at all. The corollary for a self-hosted run (#2203): a warp-drive run on BOB_SOURCE that rewires .hooks cannot converge the runtime until it merges and deploy.sh runs from a plain terminal, so the mid-session host re-check must not read that rewire as machine drift — host-drift.js --in-flight-base <kickoff-sha> grades the deployed settings.json against the source at the kickoff commit when settings.json or hooks/dispatch.tsv changed on the branch, reports the own-branch delta as self-hosting: hooks changed on this branch (not deployed until merge), and blocks only when the deployed file differs from that kickoff-commit source (genuine drift, same todo as before). Whether an in-place write (same inode) would survive where the rename does not is an open question recorded on #1997 with a two-minute A/B recipe.

  • Never a file the loader would reject. Before apply renames its temp file into place, the document is checked against the loader rule above (no PreToolUse / PermissionRequest key outside hooks); a document that would fail it is refused with exit 2 and neither the file nor a backup is written. The regression test builds the #1998 shape and asserts, by construction, that what adopt and apply write cannot reproduce it.

  • Reported, never silent. Each hook added / updated / removed / retired / reordered / migrated 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; adopt backs up the sibling file the same way (settings.hooks-local.json.<UTC timestamp>.bak) when one exists. 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:

Terminal window
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 adopt cc-status # declare an undeclared runtime hook in settings.hooks-local.json (#1913; settings.json untouched)
node ~/.claude/scripts/fleet/settings-hooks-reconcile.js adopt --all-undeclared
node ~/.claude/scripts/fleet/settings-hooks-reconcile.js plan --json # bob-settings-hooks-reconcile/1 document

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 (fix: deploy.sh / apply) and on a runtime hook nothing declares (settings.hooks-local.json entries excepted; fix: adopt <script> to keep it, --prune to drop it). It also fails on a dispatched guard still wired directly beside its dispatcher (superseded — it fires twice; fix: deploy.sh / apply, which retires it, #2082). Each FAIL line names its fix, so a red gate is never something to step over. bob doctor’s settings_hooks section names the fix the same way, per finding (#3610): deploy.sh for a missing hook, and settings-hooks-reconcile.js apply --prune (remove) or adopt <script> (keep it local) for an undeclared one. It never names deploy.sh for an undeclared hook, because a deploy keeps undeclared hooks by design. 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. The same target grades hooks/dispatch.tsv against hooks/lib and the source wiring (hook-dispatch.js check), which is a property of the checkout and runs in CI too.
  • 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 a dispatched guard still wired directly so it runs twice, ~script an intentional local hook from settings.hooks-local.json). 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) for the declared side, adopt for a runtime hook the machine wants to keep (safe from inside a session), then re-run make check-hooks. If a session is live, deploy.sh queues the deploy and BoB runs it when the last session ends (see Queued deploys).

The live-session refusal above is not a handback. deploy.sh, bob update and the self-redeploy each record a refused deploy in $BOB_HOME/.deploy-pending (deploy-pending/1) and print QUEUED. They still exit 4 for their callers. Every field of the record is defined in scripts/fleet/deploy-pending.js. The main ones:

  • kind is deploy or update;
  • requested_at and requesters[] say when it was first refused and by whom;
  • source and sha are the checkout to deploy from;
  • channel and to are the target the request named: a channel for a deploy or an update, a tag for an update only;
  • attempts and last_error record the drain attempts so far.

There is one record per machine. A second refusal folds into it, and an update request upgrades a deploy one.

scripts/fleet/deploy-pending.js drain is the only thing that clears the record. Three safe moments call it:

Caller When
deploy-pending-drain.sh (SessionEnd) A session ends. The drain is started detached and waits up to BOB_DEPLOY_DRAIN_WAIT_S (300 s) for the live sessions to exit. If another session is still live, the record stays for the hook of the last session to end.
bob tick step 7b Before the update step, under the lock the tick holds (BOB_TICK_LOCK_HELD=1).
audit.js snapshot The fleet-snapshot timer, before the snapshot is built. It skips --stdout pulls, and BOB_SNAPSHOT_DRAIN=0 opts out.

The drain runs in this order:

  1. Take the bob tick lock. Held: exit 3.
  2. Check the strict live-session guard. Any live session: exit 3, and the record is kept.
  3. Run the source checkout’s deploy.sh (or bob update for an update request), then bob doctor. It uses the same commands as the self-redeploy (#2472). deploy.sh resolves the machine’s declared channel, so a pinned machine deploys its tag (#3171). A request that named a channel is run with it (#3896): a queued deploy.sh --channel checkout drains as deploy.sh --channel checkout, never as the pinned channel. A record the drain cannot run as queued (a deploy naming a tag) runs nothing and is a failure.
  4. On success, remove the record and send the result through bob notify. On failure (exit 1), keep the record with the error and notify once per distinct error.

bob doctor, bob status --scope host, the fleet snapshot (summary.host_drift.deploy_pending) and bob viz show deploy queued since <time> until the drain succeeds.

Terminal window
node ~/.claude/scripts/fleet/deploy-pending.js status # exit 0 nothing queued · 3 queued
node ~/.claude/scripts/fleet/deploy-pending.js drain [--wait-s N] [--dry-run] [--json]
# 0 drained or none · 1 failed · 3 not now

Every real refusal is also counted. The guard appends one line to $BOB_HOME/.live-session-refusals.jsonl (bob-live-refusal/1: at, caller, strict, sessions, processes[], projects[]) when it refuses an action that named itself with live-check --caller <name>: deploy.sh, bob update, bob tick, and apply. A bare live-check, a dry run, a --force-live override and a converged no-op are not refusals and are not recorded. A line that cannot be written is lost; the verdict and exit code never change. scripts/fleet/ci-health.js reads the log for the refusals/day figure.

Git hooks (distinct from Claude Code hooks)

Section titled “Git hooks (distinct from Claude Code hooks)”

Separately from the settings.json event hooks above, bob init 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 Commits — commitlint if installed, else a built-in dependency-free check (#278). Non-versioned projects keep strip-only behavior. See the versioning guide.

bob init also installs a git pre-commit hook (templates/git/hooks/pre-commit): the commit secret scan (#3662). It runs scripts/security/secret-scan.js over the staged diff and refuses a commit that adds a literal credential.

  • What it grades. Added lines only, against the credential catalogue the release content gate reads (registry/skills/secrets-management/references/secret-patterns.json). There is one catalogue: a pattern added there is enforced at commit time and at release time.
  • What it prints. The file, the line and the pattern name, for example src/config.sh:4 Assigned secret (upper-case name). The value is never printed.
  • What passes. A line the catalogue’s placeholder matches (REPLACE_ME, EXAMPLE, ${VAR}), and a file the project’s scripts/checks/release-content-allow.json covers for the credential rule.
  • Why a git hook. An agent stages and commits in one command (git add … && git commit …); a Claude Code PreToolUse guard would look before anything was staged. The git hook runs after the add, for a person’s commit as well.
  • When it cannot run. With no node or no scanner on the machine it prints this commit was NOT scanned and lets the commit through. git commit --no-verify skips it.
  • A project’s own hook. bob init never replaces a pre-commit hook it did not install; it reports [skip] and names the template to call from it. An existing project gets the hook the next time bob init runs there.

Run the scan by hand with node ~/.claude/scripts/security/secret-scan.js [--cwd DIR] [--json] (exit 0 clean, 1 hits, 2 could not run).

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 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.