Checkpoint dirt rules
Checkpoint dirt rules
Section titled “Checkpoint dirt rules”The segmentation checkpoint (state-machine.js checkpoint, #870) records a clean per-requirement boundary so a long warp-drive run can end one session and resume in a fresh context. “Clean” does not mean an empty git status: it means no in-flight work. This page is the one statement of what the checkpoint counts as dirt, what it does about each kind, and why — the contract that #1756, #1969 / #1970 and #1995 arrived at, frozen here and pinned by a fixture so a regression on the seam fails make check rather than a night on the farm (#2037).
The rule lives in one place in code — classifyCheckpointTree() in scripts/warp-drive/state-machine.js — and in one place in prose, this page. tests/fixtures/checkpoint-dirt.json enumerates the same classes; tests/test-checkpoint-dirt.js drives the real classifier through every fixture row and fails if this table and the fixture disagree.
When it runs
Section titled “When it runs”- Phase: only at
awaiting_continue— a requirement is done, merged onto the integration branch, and the segmentation decision said segment. At any other phasecheckpointexits 1 withcheckpoint only valid at awaiting_continue. - Input:
git status --porcelain -uall(file granularity — a wholly-untracked directory is enumerated, never collapsed todir/), plus the baseline the kickoff recorded onstate.session. - Output: a checkpoint record on
state.checkpointand an appendedsession.segments[]entry (success), or a refusal on stderr (exit 1). The phase never changes; the state file is never deleted.
The classes
Section titled “The classes”Every dirty path is assigned exactly one class, tested in this order — the first match wins.
| Class | Outcome | What it is | Evidence the classifier uses |
|---|---|---|---|
overlay |
ignore | bob provision’s own edit to the fenced managed block in the tracked .gitignore — the flight-plan provision overlay’s, or the L3 provisioning preflight’s bob provision add … --now with no overlay applied (#3066) (#1133, #1479). Bookkeeping, not work. |
Path is .gitignore and the working file is byte-identical to HEAD:.gitignore once the fenced block is stripped from both. A hand edit outside the fence is never excused. |
derived |
ignore | A BoB-derived path: a session-state or machine-local file declared in scripts/lib/derived-paths.json (overlay state, last hook payload, warp-drive state + lock, project-ready verdict, forge cache, settings.local.json, the Launchpad layout dir launchpad/ — #2139, the session ledger and the decision log the hooks append to and its lock — #3096, #3124, the defect-status cache and the generated .dash/ — #3077, loop-notes.json — #3076, the preflight waiver and dev-up’s .dev-server.log / .pid — #3167), a .claude/{commands,agents,hooks,skills,runbooks}/* symlink whose target resolves into BOB_HOME or BOB_SOURCE, or a tracked vite.config.* / wrangler.toml / dev.json whose only change is BoB’s ports projection (#3167). The loop or bob init wrote it; never a requirement’s work. |
The declaration file (shared with the managed ignore block, so the two can never disagree) lstat + readlink on the path, or the file equals HEAD:<file> once the fenced bob:ports block (or dev.json’s _bob_ports) is stripped from both — portsOnly in scripts/fleet/untrack-links.js. A real file under .claude/commands/, or a symlink pointing anywhere else, is not derived. |
preexisting |
carry | Dirt the run inherited and has not touched: the path was dirty at kickoff and the inherited change is unchanged since. The operator left it there on purpose; it is theirs, not the run’s — even when the run has since committed a different hunk of the same file (#2260). | Path is in session.initial_dirt and its fingerprint still equals session.initial_dirt_fingerprints[path]. For a tracked path that fingerprint is the inherited diff (diff:<sha1> of git diff HEAD -U0’s changed lines, line numbers excluded), so the judgement is diff-level: the working copy’s diff against HEAD must still be exactly the diff kickoff saw. A commit since session.initial_head counts as touching the inherited change only when it absorbed that diff — then the diff left behind differs (or is gone) and the fingerprint no longer matches; a commit to another hunk leaves the inherited diff intact. A byte fingerprint (untracked, binary, symlink, directory, or a state recorded before #2260) keeps the whole-path rule: bytes changed, or a commit between session.initial_head and HEAD changed the path. A state that predates the fingerprint field carries on the path baseline alone. |
brief |
carry | A briefing page or the briefings index under www/bob/briefings/ that changed after an item boundary of this run started the area-completion pass (bob brief --completions, #3169). That pass runs detached at awaiting_continue, where the commit gate refuses a commit, so it cannot commit what it wrote. The checkpoint carries the files, and the run’s next entry to committing names them to stage with that commit (#3735). |
The path starts with www/bob/briefings/, it is not untouched preexisting dirt, and session.area_completions_at is set (the engine records it when a boundary starts the pass). Without that stamp, the same path is inflight. |
inflight |
refuse | Everything else: an untracked file the run created, a tracked file the run modified, or an inherited path the run rewrote, deleted or committed since kickoff — it became the run’s work however it started out. | Not matched by any class above. When session.initial_dirt is absent altogether, every non-overlay, non-derived path is inflight — the stricter reading. |
unknown |
refuse | The tree could not be enumerated (git unavailable, not a repository). The checkpoint cannot prove a clean boundary, so it fails closed. | git status failed. |
Outcomes:
- ignore — tolerated and not recorded; the checkpoint proceeds as if the path were clean.
- carry — tolerated and recorded: the path is left in the tree untouched and listed as
carried_preexistingonstate.checkpointand on the segment record, so the fresh session can see what it inherited without re-deriving it. - refuse — exit 1 with a JSON refusal naming the paths. This is the only outcome the run itself can act on (commit or merge the requirement, then checkpoint again).
Payload shapes
Section titled “Payload shapes”Recorded at init, the baseline the classes are judged against:
"session": { "initial_dirt": ["left-behind.txt", "provisions/ports.json"], "initial_dirt_fingerprints": { "left-behind.txt": "blob:<sha1 of the bytes>", "provisions/ports.json": "blob:<sha1>" }, "initial_head": "<HEAD sha at kickoff>"}Fingerprints are diff:<sha1> for a path tracked at the kickoff HEAD — the sha1 of the inherited change itself (the +/- lines of git diff HEAD -U0 -- <path>, with no file or hunk headers, so a hunk the run later commits above it shifts nothing; #2260) — and otherwise blob:<sha1> of the bytes for a file, link:<target> for a symlink, dir for a directory and absent for a path that no longer exists. A diff with no textual lines (binary, mode-only) falls back to the byte form. So an edit to the inherited change, a retarget and a deletion all read as touched; a commit that leaves the inherited diff exactly as it was does not. initial_dirt is null when git could not be read at kickoff.
A refusal (stderr, exit 1). paths are the in-flight paths — the reason; also_preexisting, when present, are the carried paths that happen to sit beside them — context, not cause:
{"error":"working tree has uncommitted work — a checkpoint requires a clean per-requirement boundary (commit/merge the requirement first)", "kind":"inflight","paths":["src.js"],"also_preexisting":["left-behind.txt"]}{"error":"working tree state is unknown (git unavailable) — a checkpoint requires a verifiably clean per-requirement boundary","kind":"unknown"}The refusal kind preexisting no longer exists — before #1995 inherited dirt refused with its own “commit or stash them outside the run” message, which stranded every segment handoff on a tree the operator had left dirty at kickoff, because the kickoff had already classified exactly those paths as not the run’s.
A success (stdout, exit 0), and what it writes:
{"checkpointed":true,"segment_index":2,"phase":"awaiting_continue","reason":"segment: 130000 tokens since last checkpoint >= 120000 threshold", ...}"checkpoint": {"at":"…","segment_index":2,"tokens_at":…,"chunks_at":…,"reason":"…","carried_preexisting":["left-behind.txt"],"carried_brief":["www/bob/briefings/index.html"]},"session": {"segments": [{"index":2,"ended_at":"…","reason":"…","tokens":…,"chunks":…,"cumulative_tokens":…,"cumulative_chunks":…,"session_branch":"…","carried_preexisting":["left-behind.txt"]}]}Why the rule is shaped this way
Section titled “Why the rule is shaped this way”- Segmentation fires inside the overlay window (#1756). A
--flightplanrun applies its provision overlay atprerequisitesand restores it atsession_ending; segmentation is evaluated atawaiting_continue, always inside that window. A bare “is the tree clean?” guard therefore refused every checkpoint on precisely the long runs segmentation exists for. Theoverlayclass is deliberately narrow — both conditions must hold — so a run with no overlay is completely unaffected and a real.gitignoreedit is never mistaken for bookkeeping. - Ownership, not location (#1969, #1970). The loop writes session-state files into
.claude/, andbob initlinks universal items there after a project last committed.claude/. Both are derived state by the #1479 argument (they re-materialize), so they are tolerated even where the managed ignore block is stale or opted out. The tolerance reads the same declaration the ignore block is built from, so the block and the checkpoint cannot disagree about what counts as work. - The kickoff already decided (#1995).
initsnapshots the dirt the run inherited precisely because it is the human’s uncommitted work. Refusing on those paths at the boundary contradicted that decision and stranded the handoff: the fresh session resumed atawaiting_continue, re-derived segment, hit the same refusal, and two sessions raced on one state file. Carrying untouched inherited dirt — with fingerprints and the kickoff HEAD as the evidence that it is untouched — is what the operator asked for by leaving it dirty. - The inherited change, not the file (#2260). Judged by the file’s bytes, an inherited hunk became “touched” the moment the run committed a different hunk of the same file — the bytes changed and a commit had touched the path — even though
git diff HEAD -- <path>was still exactly the inherited edit. The handoff could then only proceed by committing or stashing someone else’s work, both forbidden here: #1995 stranded again through a different door (flight plan #2092, segment 6,.gitignore). Fingerprinting the inherited diff makes the rule say what it meant: the run has touched the inherited change only if that change is no longer there as it was — edited, deleted, or absorbed into a commit. - Fail closed on the unknown. A checkpoint is the one place the run asserts “nothing is in flight”; if git cannot answer, the assertion cannot be made.
Pinned by fixture
Section titled “Pinned by fixture”tests/fixtures/checkpoint-dirt.json—classes(one row per class above, with its outcome) andcases(at least one per class; the #1995 shape assertscarry, a run-modified inherited path assertsrefuse; the #2260 shape — an inherited hunk left as it was plus a run commit in another hunk of the same file — assertscarry, and its counterpart, a commit that absorbed the inherited hunk, assertsrefuse). Every case builds a real throwaway git repo, records the kickoff fingerprints with the recorderinituses, and runsstate-machine.js checkpoint— the real classifier, never a re-implementation.tests/test-checkpoint-dirt.js— runs every case and, first, compares the class names and outcomes in the table on this page against the fixture; a mismatch fails the suite.make testruns it.scripts/checks/checkpoint-dirt-contract.js— the same doc-vs-fixture comparison as a static gate inmake check.
A divergence between this page and the code found while pinning is filed as a bug, not fixed silently here — the scope of #2037 is no behaviour change.
Related
Section titled “Related”- Warp-drive internals — What a checkpoint counts as dirty — the operator’s view
- warp-drive state reference — Session segmentation — the state fields
scripts/lib/derived-paths.json— the derived-path declaration shared with the managed ignore block