Warp-Drive State Reference
Warp-Drive State Reference
Section titled “Warp-Drive State Reference”The warp-drive state machine, exhaustively. State file shape, the state-machine CLI, and the full phase-transition table. Extracted from the warp-drive guide; how the loop works is Warp-drive internals.
Internals Reference
Section titled “Internals Reference”For contributors and power users.
State File
Section titled “State File”Location: <project>/.claude/.warp-drive-state.json
This is what state-machine.js init produces (with a few rows populated after the first transitions). Keep this in sync with the state object literal at the bottom of init() in scripts/warp-drive/state-machine.js.
{ "version": 1, "session": { "started_at": "2026-05-08T15:56:59.401Z", "started_at_epoch": 1778255819402, "pid": 51506, "session_id": "5cb6e2cf-ff3e-4376-969a-ff604b7dd712", "level": 3, "github_user": "paulirv", "work_type": "req", "area": null, "tmux": null, "iterm": null, "kickoff": {}, "initial_dirt": [], "initial_dirt_fingerprints": {}, "initial_head": "<sha at kickoff>" }, "phase": "coding", "requirement": "42", "bug": null, "qa": { "e2e_passed": false, "skipped": false, "passed_at": null, "evidence": null, "cycles": 0, "bugs_filed": [] }, "branch": "feature/issue-42-title", "chunk": { "index": 1, "total": 5, "acs": [["AC-01","AC-02"],["AC-03"]] }, "checklist": { "docs_updated": false, "tests_passed": false, "committed": false, "report_filed": false }, "budgets": { "phase_started_at": "2026-05-08T15:57:53.487Z", "retry_count": 0, "coding_cycles": 0 }, "aborted": false, "edit_counts": { "src/file.ts": 3 }, "doom_loop_events": [], "gates": { "code_complete": { "semantic": "diff_relevance_check" }, "docs_updated": { "semantic": "ac_count_check" } }, "completed_chunks": [], "attempt_trail": [], "attempt_dropped": 0, "history": [ { "phase": "prerequisites", "at": "..." }, { "phase": "discovering", "at": "...", "from": "prerequisites", "event": "prerequisites_ok", "source": "agent" } ], "pre_commit_head": "<sha recorded at tests_passed — the committed precondition, #1835; null on an unborn HEAD, #1955>", "docs_gate": { "issue": "42", "at": "...", "acs_total": 3, "acs_checked_at_entry": 1, "acs_checked_at_exit": 2, "verdict": "hook-missed | nothing-to-tick | no-ac-completed | null", "reason": null }, "last_bash_eval": { "last_tool_command": "...", "payload_shape": { "tool_response": ["stdout", "stderr"], "tool_output": "null" }, "at": "...", "verdict": "advanced" }, "harness_defects": [], "metrics": { "commits": 0, "reports_filed": 0, "tests_run": 0, "chunks_completed": 0, "session_duration_minutes": 0 }, "token_usage": { "session_total": null, "chunk_snapshots": [], "current_chunk_started_at": null }, "main_branch": "master", "session_branch": null, "merge_target": "master", "merge_strategy": "ff-only"}Fields appear roughly in this order in the file. Notes on the less-obvious ones:
session.work_typeisreq,bugortask(#518, #1959), frominit --work-typeor thework_typeonwork_selected. Any other value is stored asreq. It chooses the phase labels and instruction addenda.bugis the evidence record for bug work (reproduced,root_cause,regression_test).taskis the Verification-checklist verdict recorded atrequirement_donefor task work ({verification: {section, total, checked, unchecked[], done, readable, at}};readable: falsewhen the issue could not be read). It is reset tonullonwork_selectedand appears once a task reachesrequirement_done.session.started_at_epochandsession.github_userare populated atinit; the latter comes fromgh api user -q .login(empty string ifghis unauthenticated, which silently disables@mentionnotifications).session.initial_dirt(#1756) is the working-tree dirt the run inherited, snapshotted atinitviagit status --porcelain -uall(nullwhen git could not be read).session.initial_dirt_fingerprints(#1995, #2260) records a fingerprint per inherited path —diff:<sha1>of the inherited change itself for a path tracked at HEAD (so a later run commit to another hunk of the file leaves it matching), elseblob:<sha1>,link:<target>,dir, orabsent— andsession.initial_headthe HEAD at kickoff. The segmentation checkpoint classifies against them so an operator’s untouched uncommitted changes are carried across the boundary rather than refused or reported as the loop’s unfinished work — see Session segmentation.session.session_id(#2275) is the Claude Code session that owns the run. The state file sits in a shared checkout, so every session whose cwd is that checkout sees it; ownership is what lets the guards tell the run’s own session from a bystander. It is recorded atinit(--session-id, else the Bash tool’sCLAUDE_CODE_SESSION_ID); a state that predates the field records the session behind its first agent-firedtransition(a hook-fired one never stamps).runOwnership(state, callerSessionId)decides it. A caller naming another session is not gated:gate git_commit --session-id <id>passes withforeign: trueand a one-lineadvisorynaming the run’s phase, andstatus --session-id <id>addsowned_by_caller/ownership_basis. A caller that names no session, or a run with no recorded owner, is gated exactly as before.inject --session-id <id>re-anchorspidandsession_idonly when the starting session may take the run over (injectAdoption): the same session, a spawned checkpoint no session has adopted yet, or a stale owner. Any other session gets a one-line advisory context and changes nothing. A session that did not start in the run’s checkout never runsinject, so its first agent-firedtransitionre-claims the run under the same rules (#3305). Only a stale owner or an unadopted spawned checkpoint is taken, never a live owner.pidbecomes the nearestclaudeancestor of the call,tmuxis re-detected, and the response carriesreclaimed: {basis, session_id, pid}.session.pidalone cannot decide ownership, because a hook’s$PPIDis a transient runner and not the REPL.aborted: falseflips totruewhen the engine routes through theabortedphase. Cleanup messaging keys off this flag.gatesis seeded with the two semantic gates (diff_relevance_check,ac_count_check) plus anyverification_gatesfrom_workflowconfig. Thetargetsarrays from config land alongsidesemanticon the matching event keys.metrics.session_duration_minutesis updated when the engine writes the state — useful when reporting frombob warp status.main_branch/session_branch/merge_target/merge_strategyare populated from~/.claude/scripts/branch-detect.shatinitand consulted duringmerging.session_branchis the declared_branch.sessionpin only — never HEAD (#2313) — so an unpinned run on a feature branch hassession_branch: nullandmerge_target= main. When HEAD is a non-main branch with no pin,initalso recordsbranch_warning(naming the branch and the merge target it will use) and surfaces it in the init response andbob warp status.- After
budget_exceeded,budgetsgainsexceeded_reasons,exceeded_types,exceeded_at, andexceeded_from_phaseto record the trip. budgets.budget_auto_continuescounts the bounded L4 auto-continues taken (see below);budget_extensionscounts all continues (human or auto).checkpointandsession.segments[]appear only once a long run has segmented (see Session segmentation below).checkpointrecords the last clean boundary (at,segment_index,tokens_at,chunks_at,reason,carried_preexisting— the inherited dirt carried untouched into the next segment, #1995 —spawned_atoncesegment-spawn.shhas consumed it by launching the continuation, andadopted_session_id/adopted_atonce the continuation’s SessionStart has taken the run over, #2275 — a handoff is adopted once);session.segments[]is the append-only audit trail of segments ended so far, each carrying the samecarried_preexistinglist.session.completed_issues(#992) accumulates the issue number of each work item as it finishes (recorded on entry toawaiting_continue). Together withsession.kickoff.issueit defines how far an issue-scoped run may still reach: the kickoff scope minus the completed list is the remaining authorized work, computed bykickoffScope()(scripts/warp-drive/kickoff.js) and surfaced asscopein thestatusoutput.
Bounded budget_exceeded auto-continue (Level 4, #873)
Section titled “Bounded budget_exceeded auto-continue (Level 4, #873)”budget_exceeded is normally a mandatory human checkpoint at every level. Under automation Level 4 it may become a bounded auto-continue — the one breaker A4 relaxes, and never silently:
- Opt-in. Off unless
_workflow.max_budget_auto_continues > 0. At 0 (the default) — and at L2/L3 always — the breaker is unchanged. - Bounded. Under L4 the loop may auto-continue at most
max_budget_auto_continuestimes for soft breakers (no-progress, cycle, phase-time, chunk, qa). Each auto-continue incrementsbudgets.budget_auto_continues; once it reaches the cap, the breaker halts for a human. - Hard ceiling always halts. The cost/token/dollar ceiling (
cost_budget_exceeded) is never auto-continued past, even under L4 with budget left. - Reviewable. Every auto-continue is logged and recorded as a
decisionissue.
The decision is a pure function (scripts/warp-drive/budget-policy.js), consulted at runtime via the budget-decision CLI action (exit 0 = may auto-continue, 3 = halt). The counter advances only on transition <root> budget_continue --data '{"auto":true}'; a human-approved budget_continue does not count against the cap.
Session segmentation (long runs, #870)
Section titled “Session segmentation (long runs, #870)”A long L3/L4 run degrades in quality as one Claude context accumulates hours of history. Session segmentation lets the loop checkpoint at a clean per-requirement boundary, end the session, and resume in a fresh context — reusing the existing substrate (the persisted state file + the warp-drive-inject.sh resume hook + a cdfork-style fresh-process spawn). There is no new resume engine; the only new pieces are the threshold trigger and the clean-boundary checkpoint.
-
Default-on, opt-out.
segment_after_tokensdefaults to120000(#959 — keeping segments below the ~150k high-cost context regime);segment_after_chunksdefaults to0. The default is resolved in exactly one place (the state-machine config layer,WORKFLOW_DEFAULTS), and an explicit_workflowvalue always wins — an explicitsegment_after_tokens: 0(withsegment_after_chunksunset/0) fully disables segmentation. -
Boundary. Segmentation is only evaluated at
awaiting_continue(a requirement is done and merged onto the integration branch, git is clean). Thecheckpointaction refuses to run at any other phase, or over a working tree that carries real changes — see the classification below for what “real” means. -
Clean means clean of work (#1756). The checkpoint classifies each dirty path rather than counting them, because a
--flightplanrun’s provision overlay rewrites the fenced cdprov-managed block in the tracked.gitignorefor the entire window in which segmentation can fire:Classification Outcome Refusal kindbob provision’s own ignore-block edit — the overlay’s, or the provisioning preflight’s with no overlay (#3066) Tolerated — bookkeeping, not work — A BoB-derived path: a session-state file declared in scripts/lib/derived-paths.json, or a.claude/*symlink resolving into BOB_HOME / BOB_SOURCE (#1969, #1970)Tolerated — written by the loop or bob init, never a requirement’s work; reported underderived— Changes that pre-date the session and that the run never touched (#1995) Carried — left in the tree, recorded as carried_preexisting— A pre-existing path the run modified, deleted, or committed since kickoff Refused: it became the run’s work inflightAnything else Refused: the requirement is not committed yet inflightOverlay tolerance is narrow by design: the working
.gitignoremust be byte-identical toHEADonce the fenced block is stripped from both sides. An edit anywhere outside the fence is never excused. Since #3066 no applied overlay is required: only cdprov writes inside the fence, and the L3 provisioning preflight rebuilds it with no overlay atprerequisites.session.initial_dirt— agit status --porcelain -uallpath snapshot taken atinit— is the baseline separating dirt the run inherited from dirt it created;session.initial_dirt_fingerprintsandsession.initial_head(#1995, #2260) are the evidence that an inherited path is still untouched: for a tracked path the fingerprint is the inherited diff, and only a diff that no longer matches (edited, deleted, or absorbed by a commit) reclassifies it as in-flight — a run commit to another hunk of the same file does not; for a byte-fingerprinted path, bytes that no longer match or a commit between the kickoff HEAD and HEAD that changed the path reclassify it. When carried dirt sits next to in-flight work the refusal reportsinflight(the condition the run itself owns) and lists the carried paths underalso_preexisting. A session with no baseline reads every path as in-flight, the stricter reading; a state that predates the fingerprint field carries on the path baseline alone.segment-spawn.shrefuses (exit 3) unless the current handoff’s checkpoint landed — a missing or already-spawned_at-stamped checkpoint never spawns a second session on the same state file. The classification is stated once as a frozen contract in Checkpoint dirt rules and pinned by fixture (#2037). -
Per-segment trigger, not cumulative. The threshold measures tokens/chunks accrued since the last checkpoint (
tokens_at/chunks_atanchors), computed from the monotonictoken_usage.chunk_snapshotssum. Because the state file persists across a segment, a cumulative measure would re-trip the threshold immediately in the fresh session and loop forever — the per-segment delta resets the budget at each checkpoint. -
Level floor. Only Level 3+ auto-segments (where
awaiting_continuealready auto-continues). At L2 the boundary asks a human, so there is nothing to auto-segment. -
Seam-aware (#960).
work_selectedrecords the item’s seam metadata tostate.work_meta({issue, cap, area}— parent cap fromPart of #NN,area:label slug; best-effort, nulls on failure). Atawaiting_continuethe shared segment context compares it against the next queued item’s metadata (seamBetween): if a known-on-both-sides cap or area differs, the lowersegment_at_seam_tokensbar (default40000) governs instead ofsegment_after_tokens. Missing metadata never seams;0disables the seam trigger; the full segmentation opt-out keeps it inert. Seam trips returnkind: "seam"and asegment at seam: …reason, which also becomes the checkpoint log line. -
State is preserved, not deleted. A checkpoint writes
state.checkpoint+ appendssession.segments[]and leaves the phase atawaiting_continue— the fresh session resumes from the same state file via the inject hook, losing no state. (The state file is only deleted when the whole run reachescompleted.) -
Scope-bounded (#992). The decision’s
moreWorkinput is computed within the run’s kickoff scope: an issue-scoped run (session.kickoff.issue) measures its issue list minussession.completed_issues(deterministic, no queue peek), so a scoped-and-done run never segments — and theawaiting_continuecaller routesmoreWork: falsetocontinue_noinstead of continuing or widening into the approved queue.
The decision is a pure function (scripts/warp-drive/segmentation.js), consulted via the segment-decision CLI action (exit 0 = segment, 3 = keep going in-process). The checkpoint action records the boundary. bob warp status surfaces segmentation (the thresholds, the per-segment usage so far, the checkpoint lineage, and session.segments[]).
State Machine CLI
Section titled “State Machine CLI”node ~/.claude/scripts/warp-drive/state-machine.js <action> <project-root> [args]node ~/.claude/scripts/warp-drive/state-machine.js --help [<action>] [--json]Every subcommand and flag is declared once in scripts/warp-drive/flags.json (warp-flags/1, #2085) — type, default and a one-line meaning. parseFlagArg resolves through that table (reading an undeclared flag throws), an undeclared flag on the command line is refused with exit 2 naming the declared ones, and --help / -h anywhere prints the table instead of running the subcommand (--json for the machine-readable document); bob warp help and bob warp --help append the same table. Add a flag to the table before the code reads it. Every flag, by subcommand: Warp-drive state-machine flags — generated from --help --json, gated by make docs-reference-check, guarded by tests/test-state-machine-help.js.
| Action | Purpose |
|---|---|
init |
Create new session: init <root> --level 2 --issue 42 --pid $PPID |
graph |
Print the static phase/transition topology (project root optional) |
preflight |
Run the preflight gate and let the engine fire preflight_ok / preflight_failed (preflight phase only, #1923; --converge) |
status |
Show current state (exit 2 if no session); carries contract — the phase contract’s identity (phase-contract/1, counts, freeze date, decision — #2042) |
declared |
List the declared automatic behaviours with, per entry, whether the source hook is present in $BOB_HOME/hooks and wired in $BOB_HOME/settings.json (--json; exit 3 when a promise cannot be kept) |
budget-decision |
L4 bounded-auto-continue verdict for the current budget_exceeded halt (exit 0 = may auto-continue, 3 = halt) — see above |
segment-decision |
Should the run segment at the current awaiting_continue boundary? (exit 0 = segment, 3 = keep going) — see Session segmentation |
checkpoint |
Record a segmentation checkpoint (clean boundary; keeps state, doesn’t change phase): checkpoint <root> [--data '{"reason":"..."}'] |
decisions-digest |
Print the session-end decisions digest (markdown) aggregating state.decisions[] for the session summary — one-place review/reversal, cross-links the tool-level audit log (#877) |
record-decision |
Append an auto-decision to state.decisions[], counted against max_auto_decisions (#876) |
record-sync |
Record a stream-freshness sync result on state.stream_sync (#1178) |
decisions |
Print the auto-decision log as JSON — the unreviewed window by default, --all for full history (#876) |
transition |
Move phases: transition <root> <event> [--data '{"key":"val"}'] |
inject |
Get phase context for SessionStart hook |
gate |
Check if an operation is allowed: gate <root> git_commit |
verify |
Run verification gates: verify <root> code_complete |
abort |
Shorthand for transition <root> abort |
config |
The _workflow config table as a CLI (#2041): config get <key> [--cwd DIR] [--json] resolves one declared key with loadConfig’s four-layer precedence (WORKFLOW_DEFAULTS ← provisions/_default.json ← provisions/<project>.json ← settings.local.json, #2703; a string prints raw, other types as JSON; --json adds source: default|fleet-manifest|manifest|settings); config list every key resolved, --sources adding the layer per key; config keys the declared names. An undeclared key is refused (exit 2). Shell reads it through scripts/lib/workflow-config.sh, Node through scripts/lib/workflow-key.js (#2719). Takes --cwd, not a positional root. See Workflow config |
reset |
Delete state file immediately |
help |
Print the flag table (also --help / -h anywhere) |
Declared automatic behaviours (#1834)
Section titled “Declared automatic behaviours (#1834)”The automatic behaviours BoB promises — hook auto-advances, gates, detectors — are declared once, in scripts/warp-drive/declared-behaviours.js, each with a stable fingerprint of the form <kind>:<source>:<phase>→<event> (gates omit the event). A fingerprint names the declared behaviour, never a diagnosed cause, so later tooling can key compensation events and bug de-dup on it without re-diagnosing (#1833).
| Fingerprint | Hook | Promise |
|---|---|---|
hook:warp-drive-commit-detector:committing→committed |
PostToolUse (Bash) warp-drive-commit-detector.sh |
A successful git commit during committing advances to reporting — no manual committed transition |
hook:warp-drive-docs-detector:updating_docs→docs_updated |
PostToolUse (Edit|Write|Bash) warp-drive-docs-detector.sh |
During updating_docs, a successful gh issue edit <req> --body… (the AC tick) or a docs/ edit advances to testing (#1852) |
gate:warp-drive-gate:pre-commit |
PreToolUse (Bash) warp-drive-gate.sh |
A real git commit (argv-position detection, #858) is blocked outside committing |
contract:phase-contract:all |
fixture tests/fixtures/phase-contract.json (not a hook — wired is null) |
Every phase, every (from, event, to) transition and the per-phase reasoning budget equal the fixture; a change lands only by editing the fixture in the same commit, and until 2026-10-11 only with a linked decision (#2118). contractVersion() renders the one-line identity bob warp status (Contract:) and bob doctor (INFO contract:) print — see Warp-drive contract (#2042) |
The registry is the source the state machine renders from: the advances text for the two hook-fired phases (shown in bob warp status / bob warp viz tooltips) is generated from the registry entry, so the promise a human reads and the fingerprint tooling keys on cannot drift apart. tests/test-declared-behaviours.js (make test-declared-behaviours) asserts every fingerprint names an existing hook file and a valid (phase, event) pair in the transition table, and that every hook that calls state-machine.js transition has an entry.
node ~/.claude/scripts/warp-drive/state-machine.js declared "$(pwd)" [--json]present = the hook file exists in $BOB_HOME/hooks; wired = $BOB_HOME/settings.json references it (null when that file is unreadable — unknown is not the same as unwired). Exit 3 when any declared behaviour is missing or unwired: a promise the runtime cannot keep.
Compensation detection (#1835)
Section titled “Compensation detection (#1835)”The state machine — not the model — notices when an agent compensates for a declared automatic behaviour that did not happen. A hand-fired committed “because the hook didn’t” used to be indistinguishable from the hook working; that is how #1830 was normalised into memory and loop notes for two months.
Source attribution. Every hook that calls state-machine.js transition exports WARP_HOOK_SOURCE=<hook-name> first. transition records source: "hook:<name>" or source: "agent" on the history[] entry and in its JSON output (bob warp status --log shows it).
What is recorded. When an event that a kind: hook registry entry promises arrives with source: "agent" in that entry’s phase, transition appends to harness_defects[] in the state file:
{ "fingerprint": "hook:warp-drive-commit-detector:committing→committed", "phase": "committing", "event": "committed", "at": "2026-08-21T05:19:02Z", "session_id": "…", "last_tool_command": "git commit -q -F -", "payload_shape": { "tool_response": ["interrupted", "isImage", "noOutputExpected", "stderr", "stdout"], "tool_output": "null" }, "payload_file": ".claude/harness-defects/hook_warp-drive-commit-detector_committing_committed-2026-08-21T05-19-02-719Z.json"}and returns "compensation": {"fingerprint"} plus a COMPENSATION RECORDED system message so the agent knows it is compensating. last_tool_command / payload_shape come from last_bash_eval, which warp-drive-commit-detector.sh writes on every evaluated Bash call during committing (before any gate, so a failed commit is captured); payload_shape holds only the top-level keys (or JSON type) of tool_response / tool_output, never values. verdict (#1897) records why the hook did or did not advance — advanced (a commit landed, committed fired), head-unmoved (HEAD is where tests_passed recorded it), call-failed (the Bash call failed and no single new commit sits on the recorded HEAD — a rebase or an unrelated move, not a landed commit), not-a-commit (prose / data / a non-commit command). The hook judges by the landed commit, never by the whole call’s exit status: git commit … && git push with a rejected push is advanced. The verdict is copied onto the defect entry and into the filed report. The entries feed defect filing (#1836); they are never deleted by the loop. warp-drive-docs-detector.sh writes the same record on every Bash call evaluated in updating_docs (#2169) — hook names the writer, and its verdicts are advanced (the AC tick of the current requirement succeeded and docs_updated fired), call-failed (the tick command failed), not-a-tick (not a gh issue edit <req> --body… of the current requirement: another issue, a label-only edit, prose or an unrelated command), no-requirement (state carries no requirement to match); its last_tool_command is bounded to 500 chars as head + … + tail rather than head only, because the tick usually follows a heredoc and the tail is the evidence. The two phases are disjoint, so the record always belongs to the detector of the current phase; the Edit/Write path of the docs detector leaves it untouched. Before #2169 the docs detector recorded nothing here, so every docs defect it filed read “hook saw not captured after n/a”.
Live payload (#1839). Shape alone cannot reproduce a dead hook, so the detector hooks (warp-drive-commit-detector.sh in committing, warp-drive-docs-detector.sh in updating_docs) also keep the full redacted payload of the last call they evaluated at .claude/.last-hook-payload.json — every string truncated to 400 chars, token-shaped values (ghp_…, sk-…, xox…, AKIA…) replaced with <redacted>, stamped _captured_by / _captured_at; the same redaction is applied to last_tool_command. When transition records a compensation it copies that file to .claude/harness-defects/<fingerprint>-<timestamp>.json (fingerprint characters outside [A-Za-z0-9_.-] become _) and sets payload_file to the project-relative path; null when nothing was captured or the capture is not valid JSON. harness-defect-report.js reads payload_file and appends a ## Payload fenced JSON block (bounded at 6000 chars, the file path named) to the created issue body and to each recurrence comment, so the filed bug carries what the hook actually saw. Both paths are derived session state, gitignored, and reaped with the other .claude/ residue. A captured payload that exposes a new harness shape is also the raw material for a fixture — scripts/capture-hook-fixture.js record writes the same redacted form into tests/fixtures/hooks/.
Precondition rules. A compensation may never run the state ahead of reality, so an agent-sourced declared event is accepted only if the fact it claims is observable:
| Event | Accepted only if | Otherwise |
|---|---|---|
committed |
git rev-parse HEAD differs from pre_commit_head (recorded at tests_passed; falls back to the last commit_hashes[] sha). On an unborn HEAD (a git repo with no commits, so pre_commit_head is null) a commit must have landed — HEAD must now exist (#1955). A project that is not a git repo fails open |
exit 1, error: "Compensation precondition unmet", precondition names HEAD as unmoved (or unborn), phase unchanged |
docs_updated |
a box was newly ticked since updating_docs began (docs_gate.acs_checked_at_entry, snapshotted at code_complete) — the hook had its trigger and missed. When no unchecked box remains (none at all, or all ticked earlier) the fire is accepted as docs_gate.verdict: nothing-to-tick and is not a compensation: no harness_defects[] entry, a DOCS GATE notice instead of COMPENSATION RECORDED (#1856). Unchecked boxes with none ticked are accepted only with --data '{"docs_reason":"…"}' (verdict: no-ac-completed, not a defect). When the issue body cannot be read (gh unreachable, or no requirement on state) the verdict is unverifiable: accepted, and not a compensation either — a defect record needs evidence of a trigger the hook missed — unless the detector’s own record shows it decided to advance and the phase still did not move (last_bash_eval.hook: warp-drive-docs-detector, verdict: advanced), in which case the entry is recorded with that verdict and command (#2169). The 2026-09-12 recurrences of #2169 were unverifiable fires from a scripted sandbox with no hook in the loop |
exit 1, precondition names the unchecked count and the docs_reason escape, phase unchanged |
Both guards fail open when the fact cannot be verified (not a git repo; gh unreachable) — blocking the loop on a forge outage would be worse than a missed refusal. Failing open never means filing blind: an unverifiable docs_updated is accepted but recorded as a defect only on detector evidence (#2169). An unborn HEAD is not an unverifiable fact: the repo is there and demonstrably has no commit, so it refuses (#1955). Hook-sourced events are never guarded or recorded: the hook observed the fact directly.
What this means for an agent. The committing and updating_docs phase instructions no longer say “fire it yourself if the hook didn’t”. If the phase is still committing after git log -1 shows the commit, the hook is broken; the right move is to record that (the manual fire does it for you) and carry on — not to add a “known quirk” to memory.
Too early is not a miss (#4119, #4120). The detectors are PostToolUse hooks: they run when the Bash call that made the commit or ticked the AC returns. A committed or docs_updated fired inside that same call comes before the hook has had its turn. The engine reads two signs from the detectors’ own record, last_bash_eval. First, no call has been evaluated since the phase began (last_bash_eval.at is before the phase’s entry in history). Second, for committed only, HEAD is past the HEAD the commit detector last saw (last_bash_eval.head). On either sign, the engine refuses the agent’s fire once with TOO EARLY (#4119). The phase is unchanged, no defect is recorded, and the hook advances the phase when the call ends. A second fire on the same evidence means the call ended and the hook still did not advance. It is accepted and recorded as a hook miss, so a dead hook never wedges the run. Make the commit or the tick its own call, and read the phase in the next one.
Outward filing of harness defects (#1836)
Section titled “Outward filing of harness defects (#1836)”harness_defects[] entries would be just another accommodation if they stayed on the state file. scripts/warp-drive/harness-defect-report.js <project-root> [--dry-run] [--json] is the channel that crosses into the maintainer’s tracker. The state machine runs it on entry to chunk_complete and session_ending (after the state is written; best-effort — a failure is reported as harness_defect_filing: {result: failed} in the transition output and never blocks; _workflow.harness_defect_filing: false opts out).
A root with no git remote files nothing (#2479). BoB tracks work on the repo’s forge, so a tree with no origin cannot own a tracker, and a compensation recorded there is evidence for the local state file — not something to send to somebody else’s. report() therefore checks the project’s remote first: with none, it calls gh for nothing, returns skipped: "no-remote" with the count it marked, and stamps each pending entry unreportable: {reason: "no-remote", at}. That stamp is as final as reported for pending purposes, so the defects are not retried on every later phase entry; the transition response carries harness_defect_filing: {result: "skipped", reason: "no-remote", marked: N} instead of a filing record, and --dry-run states the rule and marks nothing.
This is not a test-mode seam, deliberately: the sandboxes that produced #2169’s four 2026-09-12 recurrences (proj-unattended2/3/4, proj-attended3) drove the real engine on purpose, and a seam would only have protected the runs that remembered to set it. Ownership is the rule, so ownership is what is checked.
Per unreported fingerprint it either bumps the existing open harness-defect issue — found by gh issue list --label harness-defect --search "<fingerprint>" and confirmed by the body marker <!-- harness-defect: <fingerprint> --> — updating its Recurrences: N · first seen · last seen line and commenting (project, session id, date, compensation one-liner), or creates one in the /report-bug structure with labels bug, harness-defect, p3-medium. A second occurrence anywhere in the fleet lands on the same issue; it never opens a duplicate.
Routing keys on the fingerprint’s <source>: a BoB hook/script → the bigbrain tracker (resolved from the BOB_SOURCE checkout’s origin remote — never hard-coded; BOB_BIGBRAIN_REPO overrides); project-owned configuration (dev-json*, dev-health*, project-hook*, reap*, checks*) → the project repo (from its origin); unknown → both, cross-linked.
Workaround provenance (#1837). Once a filed defect has an issue number, the report also records the compensation as a loop note tagged defect: {repo, number} (kind gotcha, summary <fingerprint> did not fire — agent fired <event> by hand in <phase>), so the project’s next run sees it as [workaround for open <repo>#<n>] rather than a permanent rule. init runs loop-notes.js retire on entry to prerequisites and surfaces loop_notes_retire: {text, retired, fixed[], remaining} in its response (the retired N workaround note(s) (fixed: #a, #b) line is also prepended to the phase instruction); a project with no tagged notes makes no forge call. A queued create (forge degraded, number unknown) records no note — an untagged compensation is exactly what the tagging exists to prevent. Closure lookups share scripts/lib/defect-status.js (see the loop-memory reference).
Visibility and escalation (#1838). scripts/warp-drive/harness-defect-summary.js is the read side of the same two sources. state [<root>] summarises the state file’s reported and unreported entries as {open, recurrences, fingerprints[], issues[], reported, unreported} — the bob dash status line harness-defects: N open (M recurrences) and the fleet snapshot’s per-project harness_defects field read it. issues / report list the open harness-defect issues on bigbrain and the project repo (recurrences and first/last seen from the Recurrences: line, affected projects from the body plus Recurred on comments) — report prints the /what-next --report block and nothing at all when nothing is open; an unreachable forge is stated, never read as zero. escalate bumps any bigbrain issue at/over _workflow.harness_defect_escalate_after (default 3; 0 disables) to p2-high with a comment carrying the <!-- harness-defect-escalated --> marker, skipping issues already at p1/p2 or already marked (idempotent); harness-defect-report.js runs it after each filing pass and surfaces the result as escalation. bob dash status prints the state line under each project (and under bob dash status --check <project>) only when the count is non-zero; scripts/fleet/audit.js snapshot records harness_defects: {open, recurrences, unreported, fingerprints[], issues[]} per present project from the state file alone (a snapshot never calls the forge), summary.harness_defects rolls it up per machine (null when no project records any), and audit.js view renders a “Harness defects” table — per-machine rows plus a FLEET row that de-dupes fingerprints across machines — in both the human table and --json. make test-harness-defect-summary (state, issues, report, escalate, the bob dash status line) and make test-fleet-audit (snapshot field, roll-up, view) cover these against fixture state files and issue payloads.
Every gh call goes through the bin/gh shim: when the forge is degraded the create/edit/comment is spooled to the #1750 write-ahead queue (the entry is stamped reported.queued: true with issue: null so it is not re-filed) and replays on recovery. Reported entries carry reported: {repo, issue, at[, queued, also]}; --dry-run prints the plan (reads only); re-running is idempotent. make test-harness-defect-report covers create, bump, routing, dry-run, idempotence and the degraded-forge spool against the real shim.
Transition Table
Section titled “Transition Table”Every phase lists its valid events and where they lead. This is the frozen contract of #2042: it lists exactly the TRANSITIONS object in scripts/warp-drive/state-machine.js, pinned by tests/fixtures/phase-contract.json (23 phases, 64 transitions). make check-phase-contract fails when this table and the fixture disagree, and tests/test-phase-contract.js fails when the fixture and the code disagree — a phase or transition is added only by changing the fixture in the same commit, and until 2026-10-11 only with a linked decision issue (#2118; the fixture now links #4206, which broke the freeze for #3476’s two work_skipped edges). The why is in Warp-drive contract.
| Phase | Event | Next Phase |
|---|---|---|
| idle | start | prerequisites |
| idle | abort | aborted |
| prerequisites | prerequisites_ok | preflight |
| prerequisites | abort | aborted |
| preflight | preflight_ok | discovering (engine-fired only, #1923) |
| preflight | preflight_failed | blocked (engine-fired only, #1923) |
| preflight | abort | aborted |
| blocked | preflight_retry | preflight |
| blocked | blocked_end | session_ending |
| blocked | abort | aborted |
| discovering | work_selected | planning |
| discovering | no_work | session_ending |
| discovering | abort | aborted |
| planning | plan_ready | chunking |
| planning | work_skipped | discovering |
| planning | dry_run_complete | session_ending |
| planning | abort | aborted |
| chunking | chunks_defined | coding |
| chunking | work_skipped | discovering |
| chunking | abort | aborted |
| coding | code_complete | updating_docs |
| coding | work_skipped | discovering |
| coding | abort | aborted |
| updating_docs | docs_updated | testing |
| updating_docs | abort | aborted |
| testing | tests_passed | committing |
| testing | tests_failed | coding |
| testing | abort | aborted |
| committing | committed | reporting |
| committing | commit_with_doc_gate | doc_drift_check |
| committing | abort | aborted |
| doc_drift_check | drift_clean | reporting |
| doc_drift_check | drift_blocked | coding |
| doc_drift_check | abort | aborted |
| reporting | report_filed | chunk_complete |
| reporting | abort | aborted |
| chunk_complete | next_chunk | coding |
| chunk_complete | requirement_done | requirement_complete |
| chunk_complete | abort | aborted |
| requirement_complete | merge_ready | qa |
| requirement_complete | abort | aborted |
| qa | qa_passed | merging |
| qa | qa_skipped | merging |
| qa | qa_bugs_found | coding |
| qa | abort | aborted |
| merging | merged | promoting |
| merging | merge_failed | merging |
| merging | push_failed | merging |
| merging | abort | aborted |
| promoting | promoted | awaiting_continue |
| promoting | promote_skipped | awaiting_continue |
| promoting | promote_failed | promoting |
| promoting | abort | aborted |
| awaiting_continue | continue_yes | discovering |
| awaiting_continue | continue_no | session_ending |
| awaiting_continue | abort | aborted |
| session_ending | session_ended | completed |
| session_ending | abort | aborted |
| budget_exceeded | budget_continue | coding (the interrupted phase — budgets.exceeded_from_phase, #1183 — when recorded; the table edge is the fallback) |
| budget_exceeded | budget_abort | aborted |
| budget_exceeded | abort | aborted |
| aborted | abort_resolved | completed |
| aborted | abort_cleanup_failed | completed |
| aborted | restart | idle |
Skipping blocked items (work_skipped, #1270). An item discovered at planning to have zero agent-side work remaining (every open AC is human-only, a blocked label, or an open-todo gate) is skipped state-only: work_skipped records {issue, reason, at} to state.skipped_issues, clears the selection, and returns to discovering — no commit, no prerequisites re-run. Skipped issues are excluded from re-discovery for the remainder of the run (segment-context scope math, the discovery cache’s emitted queue, and the resumed-segment context injection all honour the list) and are surfaced by bob warp status (a Skipped: line) and the session summary (“Skipped (blocked)” section). The exit is legal from planning, and from chunking and coding (#3476) while no commit has landed for the item this run: an item can turn out human-only after plan_ready, for example when its one edit is refused. The engine records the commit count at work_selected (state.item_commits_from). A work_skipped from chunking or coding after a commit for the item is refused, and the refusal names the commit, because a state-only skip would strand it. That case stays on the error-escalation/todo path. Discard or keep any uncommitted edits yourself before skipping.
Re-validation sub-mode (revalidating, #1008). Planning has a display-only sub-mode with no new transition edges: when work_selected crosses a cap/area seam (same seamBetween signal as #960 — both sides known and different; the first selection of a run and missing metadata never arm it), the state machine sets state.revalidation.pending with the from/to boundary, the transition’s planning instruction gains a RE-VALIDATION (#1008) addendum, and displayPhase reports revalidating until the pass is recorded. The pass itself is scripts/warp-drive/revalidate.js: gate <issue> compares HEAD against the issue’s <!-- approved-at: sha --> marker (exit 0 = fresh, near no-op; exit 3 = moved/unknown → lightweight AC re-check, drift classified by the #878 reversibility engine), and record appends the findings to state.revalidation.entries plus a phase-history line (event: "revalidation", rendered by bob warp log) and clears the pending flag. The flag is re-armed or cleared on every selection, so an unresolved pass can never leak onto the next item. Gated by _workflow.revalidate_on_area_entry (default on); scope-safe under #992 (re-validates only the already-selected item). See the how-to section.
Budget-trigger reasons. When an enforced budget trips, the engine routes the current phase into budget_exceeded and records the reason on state.budgets.exceeded_reasons. The reasons are:
| Reason | Source | Default Threshold | Config Key |
|---|---|---|---|
phase_timeout |
Phase elapsed too long | 30 min | max_phase_minutes (+ phase_timeout_enforcement) |
retry_exceeded |
Per-chunk retry counter exceeded | 5 retries | max_retries_per_chunk |
coding_cycles_exceeded |
Code/test cycles for this chunk exceeded | 3 cycles | max_coding_cycles |
no_progress |
Consecutive identical failed attempts — same failure signature AND no new diff (stagnation, not raw attempts) | 3 attempts | max_no_progress |
total_chunks_exceeded |
Chunks completed in this session exceeded hard limit. Each human-approved budget_continue extends the cap by 10 (budgets.max_total_chunks_extension, #1149). Gates starting new chunk work only — wrap-up transitions that land/close in-flight work (merge_ready, qa_passed/qa_skipped, merged/merge_failed/push_failed, promoted/promote_skipped/promote_failed, continue_no) are exempt (#1183) |
20 chunks | max_total_chunks |
session_timeout |
Total session duration exceeded hard limit. Each budget_continue grants enough minutes to clear the overrun plus an hour (budgets.max_session_minutes_extension, #1538) |
480 min | max_session_minutes |
qa_cycles_exceeded |
Another qa_bugs_found loop would exceed the in-session QA cap (#868) |
3 cycles | max_qa_cycles |
decision_budget_exceeded |
Auto-decisions this window reached the cap — the mandatory decisions-digest checkpoint (#876) | 10 decisions | max_auto_decisions (0 disables) |
cost_budget_exceeded |
Session tokens, or a qualified dollar estimate, exceeded the ceiling (#587); an unqualified estimate is reported as the advisory cost_budget_unqualified and never enforced (#1805) |
disabled (0) |
max_session_tokens / max_session_usd (or the kickoff --max-tokens / --budget) |
phase_timeout is only enforced (rather than a soft warning) when phase_timeout_enforcement is block or abort — see Configuration. The others are always enforced.
Testing budget (testing_budget_minutes, #1979) is not a budget_exceeded reason. It is a runner-level budget (default 10 minutes, 0 = unlimited) enforced inside run-checks.js during the testing phase: once spent, the runner stops launching checks, records the remainder skipped: budget, cuts a check still running, and the phase advances on the result of what ran. The outcome is carried on state.last_check_result (budget: {ms, exhausted, elapsed_ms}, per-check status: skipped + skip_reason), which the engine renders into the committing instruction as the exact Tests-Skipped: trailer line and into the reporting instruction as the ran-vs-skipped list. last_check_result is cleared at chunks_defined and next_chunk, so one chunk’s skipped list can never leak into the next chunk’s trailer — and that same clearing is what makes the flaky-on-base re-run cap (2 per check per chunk, carried as reruns on each entry) naturally per-chunk. A check the runner quarantined (skip_reason: quarantined, summary.quarantine) triggers quarantine-report.js at tests_passed / tests_failed (_workflow.quarantine_bug_filing); the filed bug number is stamped back onto summary.quarantine.issue and the outcome rides the transition response as quarantine_filing. Never a budget_exceeded reason and never a blocker.
Proportionate qa (qa.scope_decision, #1979). On the transition into qa the engine classifies the item’s changed files (git diff --name-only <base>...HEAD plus the working tree) into behaviour (code, SQL, tests, anything unknown) and presentation (templates, styles, config, copy, assets) and records {scope, verdict, behaviour_files, presentation_files, at} on state.qa.scope_decision — verdict: presentation-only means the phase instruction recommends qa_skipped with reason presentation-only; run names the code files to drive. _workflow.qa_scope: always forces run. The same object rides the transition response as qa_scope.