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}. 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": "..." }}Hooks by event
Section titled “Hooks by event”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.tsvlists the guards per(event, matcher)— one row<event> TAB <matcher> TAB <guard>; row order is run order (the ordersettings.jsondeclared them in before #2082). Adding a guard is a row there, not a newsettings.jsonentry. - The guard. A guard’s body is the function
bob_hook_<name>(dashes become underscores) inhooks/lib/<guard>. It reads the payload asINPUT=${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 whosesettings.jsonstill 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 ahookSpecificOutput.permissionDecisionof"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>recordsguard<TAB>exit<TAB>cksumper guard). It prints the hook processes one Bash call starts, before and after. - The tooling.
scripts/lib/hook-dispatch.jsis the one reader of the table:list [<event> <matcher>]prints the guards in run order andcheckgrades the table againsthooks/libandsettings.json(one dispatcher entry per matcher, called with its own event and matcher; every row wired; every guard defined).hook-drift.jsanddeclared-behaviours.jsexpand a dispatcher entry into its guards, so a dispatched guard counts as wired, never as unwired.make check-hooksrunshook-dispatch.js check. - The migration. A machine whose runtime
settings.jsonstill wires a guard by name, beside the dispatcher entry that now runs it, would run that guard twice.settings-hooks-reconcile.jsretires the direct entry on the next deploy (see Reconcile on deploy). Until then,hook-drift.jsreports it assuperseded.
Which bridge the Stop hook asks (#3616)
Section titled “Which bridge the Stop hook asks (#3616)”stop-notify-rdb.sh reads four files, in this order, and each one can only narrow what it does — none can widen it:
<project>/.claude/settings.local.json_rdb.enabled— the per-project switch.falseor absent: the hook exits silently, nothing else is read.- The operator’s declared bridge for this machine, the trust anchor, from the first that resolves and every one that does:
BRIDGE_WORKER_URLin the environment (the operator profile’s decisions route,env:BRIDGE_WORKER_URL, points here); the machine’s$BOB_HOME/.mcp.jsonremote-decision-bridgeentry — the filebob readycopies into a project on aproject_rdbmachine; the operator profile’sroutes.decisions.endpointwhen 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. <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.$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.jsonunder a key made from the exact command. The refusal tells the session to callask_remotewith a question that contains[cf-hold:<key>]and the options “Yes, delete” and “No, keep”. The guard’s PostToolUse half, wired onask_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 throughscripts/warp-drive/ask-gate.jsnaming 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).
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 errorTake 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.
Reading _workflow from a hook (#2041)
Section titled “Reading _workflow from a hook (#2041)”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:
HOOK_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)". "$HOOK_DIR/../scripts/lib/workflow-config.sh" # same layout in BOB_SOURCE and BOB_HOMEBLOCKING="$(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; fiworkflow_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.
Scripts that are not wired
Section titled “Scripts that are not wired”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.
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 (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.
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 in a sibling file,
~/.claude/settings.hooks-local.json. A machine declares its intentional extras there, under ahookskey shaped exactly likesettings.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.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. The declaration lives besidesettings.json, not inside it, because Claude Code’s settings loader rejects the whole file when it finds aPreToolUse/PermissionRequestkey anywhere outsidehooks— the original in-file_hooks_localkey (#1918) did exactly that the first time a real machine adopted iTerm2’scc-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.jsonstays 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_localkey is still read, and the nextapply(deploy.sh) migrates it: its groups move into the sibling file and the key is removed fromsettings.json, each move reported as[migrate]. Migration is the recovery path for a machine #1998 broke — runapplyfrom 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.shnever 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--pruneis 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’shooks/dispatch.tsvguards 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.jsonstays local; - a hook the table does not dispatch on that matcher stays
[undeclared]and is kept — iTerm2’scc-status, or a same-named script outside.claude/hooks/; adoptnever 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).
- an entry declared in
-
A hook the retirement record names is retired (#4135). When a runtime entry’s script is a
hooksrecord inscripts/lib/retired-items.json(dispositionremovedorsuperseded-by, not still scheduled), the source has said the hook is gone. The nextdeploy.shorapplyremoves it with no flag, names it[retire]with the issue that retired it, and counts it underretired. 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 insettings.hooks-local.jsonis never retired. The script is read past an interpreter and quotes, sobash $HOME/.claude/hooks/<x>and"$HOME/.claude/hooks/<x>"are retired too.live-check --target <tag>reads the record at that tag, sobob updatesees 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.jsonby 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()insettings-hooks-reconcile.jsdecides whether an undeclared entry is retired or has a missing script, andhook-drift.jscalls the same function. Sobob doctor/ preflight (host-drift.jssettings_hooks) list the same hooks asplan: aretired-hookfinding, fixed by a plaindeploy.sh, is exactly whatplanlists as[would retire]. Eachundeclared-hookfinding is whatplankeeps as[undeclared]and whatplan --pruneremoves. -
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 — intosettings.hooks-local.json, backup first;settings.jsonis not touched at all (the entry is already wired in.hooks). The nextplan/make check-hooksreports it aslocal, 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-undeclaredadopts everything at once. This is the path for third-party tools that write straight into.hooks— iTerm2’s Claude Code integration installs itscc-statusutility 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. Becauseadoptnever rewritessettings.json, it is safe to run inside a live Claude Code session;applyanddeploy.share 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.jsononce per session; after the runtime file is rewritten underneath a running session (apply, ordeploy.shthrough it — even with.hookssemantically unchanged), every hook in that session stops firing until it restarts, with no warning from the session.applycannot 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):applyanddeploy.shrefuse by default. Before writing, the engine counts runningclaudeCLI processes and looks for an active warp-drive state file under every project registered inprovisions/; 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’ssession.pid,claude process: pid <N> — <command>fromps— plus the fix. A run whose process is dead is not a session (#3895). A state file whosesession.pidis not in the process list belongs to a run that was closed mid-flight: nothing is left to lose its hooks. It is listed asinterrupted 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, elsetty <tty>, cwd <dir>, between the pid and the rest of the line (where: { tmux, tty, cwd }on each entry oflive.pidsandlive.projectsin--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 spelledclaude bg-spare …andclaude 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, andbob updatehas 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 asignored(kind: daemon-spare), and counts what is left once per session, keyed by--session-idwhere 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 (matchedin--jsonreports the difference). The fixtures are capturedpsoutput, never authored (#1831):tests/fixtures/live-session/.deploy.shruns 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,applyrefuses 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(onapply,live-check, ordeploy.sh) orBOB_DEPLOY_FORCE_LIVE=1proceeds and prints the #1997NOTE: N running Claude Code session(s) will not run hooks until restarted — warp-drive run(s) in: <project> (<phase>)naming what to restart;deploy.shrepeats it as a[deploy]warning.plananddeploy.sh --dry-runnever 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 —applyis a no-op,deploy.shasksplanfirst and syncs its surfaces without consulting the guard, andlive-checkpasses (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 thesettings.jsonrewrite does. Aplanthat 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_commandin--json). The rule stands: rundeploy.sh/applyfrom a plain terminal, not inside a session — the guard now enforces it.adoptis exempt (it never touchessettings.json). Warp-drive itself never callsapplyordeploy.shduring a run:bob preflight --convergeis kickoff-only and runs onlybob 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 startandbob tickrundeploy.shbefore claude starts. A run that needs a deploy mid-flight hands off instead: its freshwarp-segwindow runsdeploy.shonly 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 (thelive-checkpass, #2126) — and never with--force-live. See Preflight gate andscripts/warp-drive/self-redeploy.js.live-checkis the guard as a command (exit 0 / 4,--json=bob-live-session-guard/1withhooks,strictandforce_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 runslive-check --target <tag>, which plans the hook wiring that tag declares (itssettings.jsonandhooks/dispatch.tsv, read withgit 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 runsbob updatewithBOB_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.hookscannot converge the runtime until it merges anddeploy.shruns 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 deployedsettings.jsonagainst the source at the kickoff commit whensettings.jsonorhooks/dispatch.tsvchanged on the branch, reports the own-branch delta asself-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
applyrenames its temp file into place, the document is checked against the loader rule above (noPreToolUse/PermissionRequestkey outsidehooks); 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 whatadoptandapplywrite 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 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;adoptbacks up the sibling file the same way (settings.hooks-local.json.<UTC timestamp>.bak) when one exists. 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 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-undeclarednode ~/.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 (fix:deploy.sh/apply) and on a runtime hook nothing declares (settings.hooks-local.jsonentries excepted; fix:adopt <script>to keep it,--pruneto 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). EachFAILline names its fix, so a red gate is never something to step over.bob doctor’ssettings_hookssection names the fix the same way, per finding (#3610):deploy.shfor a missing hook, andsettings-hooks-reconcile.js apply --prune(remove) oradopt <script>(keep it local) for an undeclared one. It never namesdeploy.shfor an undeclared hook, because a deploy keeps undeclared hooks by design. 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. The same target gradeshooks/dispatch.tsvagainsthooks/liband 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 ahook_driftrecord naming the unconverged scripts (-scriptdeclared but never runs,+scriptruns but nothing declares it,*scripta dispatched guard still wired directly so it runs twice,~scriptan intentional local hook fromsettings.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).
Queued deploys (#3162)
Section titled “Queued deploys (#3162)”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:
kindisdeployorupdate;requested_atandrequesters[]say when it was first refused and by whom;sourceandshaare the checkout to deploy from;channelandtoare the target the request named: a channel for a deploy or an update, a tag for an update only;attemptsandlast_errorrecord 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:
- Take the
bob ticklock. Held: exit 3. - Check the strict live-session guard. Any live session: exit 3, and the record is kept.
- Run the source checkout’s
deploy.sh(orbob updatefor an update request), thenbob doctor. It uses the same commands as the self-redeploy (#2472).deploy.shresolves 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 queueddeploy.sh --channel checkoutdrains asdeploy.sh --channel checkout, never as the pinned channel. A record the drain cannot run as queued (adeploynaming a tag) runs nothing and is a failure. - 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.
node ~/.claude/scripts/fleet/deploy-pending.js status # exit 0 nothing queued · 3 queuednode ~/.claude/scripts/fleet/deploy-pending.js drain [--wait-s N] [--dry-run] [--json] # 0 drained or none · 1 failed · 3 not nowRefusal log (#2745)
Section titled “Refusal log (#2745)”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:
- 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.
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
placeholdermatches (REPLACE_ME,EXAMPLE,${VAR}), and a file the project’sscripts/checks/release-content-allow.jsoncovers for thecredentialrule. - Why a git hook. An agent stages and commits in one command
(
git add … && git commit …); a Claude CodePreToolUseguard 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
nodeor no scanner on the machine it printsthis commit was NOT scannedand lets the commit through.git commit --no-verifyskips it. - A project’s own hook.
bob initnever replaces apre-commithook it did not install; it reports[skip]and names the template to call from it. An existing project gets the hook the next timebob initruns 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 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.