ADR 0047: done blocks a fix stage that strands uncommitted changes
Amendments.
- Vocabulary: current spellings are
standards(formerlyratchets),done(formerlyfinish),accept(formerlygraduate), anddiscern, the gate, or the bar for the retired product-category wording; the decision and reasoning are unchanged.- ADR 0148 — widened coverage: the strand check widened from the fix stage to every gate stage, and the failed-stage signal is renamed
fix_drift→tree_drift; everything else about this record stands.
Status: accepted. Mirrors, for the working tree, the generated-artifact currency check from ADR 0034; relies on the post-fix timing of the plan/apply seam and rides in the result envelope from ADR 0028.
Context
§The fix stage runs first in done and is meant to mutate the tree — a formatter, an import-sorter, a codemod. It auto-fixes and exits zero, so the gate goes green. But done never checked what the fix stage left behind, and the success tail said only "Everything built and all checks passed." Nothing told the agent the working tree was now dirty.
That gap had teeth in the final lifecycle. The healthy order is iterate on uncommitted work → commit the intended final tree → run done on the clean HEAD → hand off or accept only when asked. If the fix stage reformats a file the agent already committed — the everyday case for deno fmt, which reflows committed Markdown to 80 columns — that reformat lands uncommitted, and a plain green done would tell the agent the branch is done even though the worktree is no longer clean. Acceptance now refuses dirty worktrees, but surfacing the formatter diff at done is still the useful point of failure: the agent is already looking at the gate result and can commit the fixer output deliberately.
Two facts framed the fix:
- The whole committed tree already sits at the formatter's fixed point (CI enforces it), so a whole-tree fixer on a clean checkout is a no-op. The fix stage can only dirty a file the agent committed in a non-canonical state — or was mid-editing. The first is the trap; the second is the normal inner loop and must stay silent.
- CI already guards this, with a
git diff --exit-codeafterdone. So the property is wanted; it was simply absent from the local gate an agent actually runs. (Discern was extracted from a project whose only fixers were code formatters, which reformat the agent's active, uncommitted work — never a committed-then-finished doc — so the gap never surfaced there. Pointingdeno fmtat hand-written prose docs is what exposed it.)
Decision
§done blocks when the fix stage strands changes on a previously clean tracked file, detected by a before/after snapshot around the fix stage.
- The signal is
D1 \ D0. Snapshot the set of tracked-dirty paths immediately before the fix stage (D0) and immediately after it (D1). The stranded set is the paths dirty inD1but notD0— files the fix stage dirtied that were not already dirty. A fixer reworking the agent's own uncommitted edits touches files already inD0, so the inner loop never trips; only a fixer touching a committed-clean file does. The comparison is by path, not porcelain line, so a file whose status code merely changes (a staged edit the fixer extends) is not mistaken for a fresh strand. - Tracked changes only. The snapshots use
git status --untracked-files=no— exactly what CI'sgit diff --exit-codesees. A fixer that emits a brand-new untracked file is out of scope by design: it shows plainly as??ingit status,git diffignores it too, and a codemod that creates a file (with a scope gate to validate it) is a legitimate, pre-existing pattern this must not break. - It is a final gate check, not a plan stage. Like the guidance/skills currency checks and the merge check, it sets
failed_stage = "fix_drift"and attaches a diagnostic rather than appearing as a job step. It runs only when the gate is otherwise green (every real stage passed); commit the fixer output before integratingmain. (At the time of this ADR it ran just before the merge check; ADR 0050 later moved that check to the front of the gate as a fail-fast precondition, so it now precedes this one.) - The diagnostic carries the rescue. It lists the stranded files, embeds a capped
git diffof them (so the agent sees the change is the fixer's own, usually trivial), and says to commit and re-run;git diffis its reproduce command. - It blocks; it does not auto-fix.
doneneither commits nor stages the fixer output. A gate is not a committer: it surfaces the diff and lets the agent commit it. - No toggle. A fix stage exists to produce changes you then commit; a green gate that hides uncommitted fixer output is the bug, so the check is core gate behaviour, not a feature.
Consequences
§- A green local
donenow means a clean tree (in tracked files), the same guarantee CI gave — so the bar discern advertises for "done" is finally true at the point an agent checks it. - The macrograph-era implicit discipline becomes enforced. "The agent will have run the formatter before committing" held for code by habit and failed for prose; the check turns it into an invariant independent of content type or editing rhythm.
- The surprise moves early and explained. The agent commits the fixer output at
done(with a diff in hand) instead of reaching a later dirty-tree refusal. It is the same one commit either way, but with the cause in view. - CI's
git diff --exit-codebecomes partly redundant but stays. It still catches tracked mutations from non-fix stages (a test that writes a tracked file), which this check, scoped to the fix stage, does not. - Two extra
git statuscalls perdone(skipped when no fix stage is wired), plus onegit diffonly when a strand is found. Negligible, and the gate already shells to git for scope classification and the merge check. - A new
failed_stagevalue,fix_drift, joins the envelope. Consumers switch on it with a default already, so it flows through; the human headline and the diagnostic make it self-explanatory. - A fixer that emits a new untracked file can still leave the worktree dirty. Accepted: that case is visible in
git statusand outside CI's diff guard too. Acceptance refuses it under ADR 0094; catching it indoneremains a possible later extension.
Alternatives considered
§- Advisory hint on a green result instead of blocking. Rejected: agents key on
ok, and a hint riding on a green gate is exactly what gets ignored today — that is the current failure mode.ok:falsewith a diagnostic is the signal that reliably changes behaviour. - Exclude Markdown from
deno fmt(project-level). Rejected as the fix: it abandons automated doc formatting (which the prose check and standard complement, not replace) and leaves the engine gap — any mutating fixer, in any project, has the same trap. This is an engine fix, not a config workaround. - Block on any dirty tree after the fix stage. Rejected: it would fire in the inner loop, where the agent's own uncommitted work is legitimately dirty and a fixer reformatting it is expected. The
D1 \ D0set is the surgical signal that excludes exactly that case. - Block on untracked fixer output too. Rejected for parity: CI's
git diff --exit-codeignores untracked files, the timing-sensitive codemod pattern relies on creating them, and a new file is visible ingit statuswhere a silent reflow is not. - Auto-commit (or stage) the fixer output in the fix stage. Rejected: a gate that silently commits surprises in the other direction, and folding formatter noise into the agent's commit without review is the opposite of surfacing it.