fix(implement-loop): guard against a task discarding sibling tasks' uncommitted work

During a multi-task run nothing commits between tasks, so one task's
file-level `git checkout -- <file>` / `git restore <file>` silently
destroys an earlier task's uncommitted DONE work in a shared file. The
loss was structurally invisible: spec- and quality-reviewers are scoped
to the current task's footprint, diff_fingerprint only detects
same-task repair cycles, and the end-of-iteration tree gate counts
files (the surviving changes keep it nonzero). Observed in the wild as
a PARTIAL run whose DONE reports did not match the tree. Two layers,
both plugin-side (harness-level prevention is ruled out — decision log
on the issue):

Prose guard — implementer.md Step 8 no longer sanctions file checkout
for scope curation: over-reach is undone by editing back, a broken
intermediate state is repaired forward or reported BLOCKED (new fourth
BLOCKED bucket in the status protocol). A matching Iron Law line in
implement/SKILL.md covers all in-loop agents, whole-file and --patch;
docs/conventions.md now marks the checkout discard idiom as the
orchestrator's, between iterations.

Mechanical guard — after every task of a multi-task run a snapshot
agent records `git stash create` (a dangling commit; HEAD, index, and
tree untouched — semantics verified empirically in a scratch repo) plus
the `git diff HEAD --name-only --no-renames` path set. A path that was
HEAD-modified at one boundary and gone at the next trips a hard
BLOCKED naming the lost paths, the boundary, and the recovery snapshot
sha (`git show <sha>:<path>`); the discard verdict outranks per-task
outcomes in blocked_detail since the reports and the tree have
diverged. Coarse by design, in both directions, and documented as
such: a checkout-then-re-edit or a --patch hunk restore escapes the
comparison (the snapshot keeps it diagnosable); a legitimate
back-to-HEAD edit trips it (the verdict says adjudicate against the
snapshot). --no-renames keeps a staged rename from reading as a loss;
untracked files are outside the threat model (checkout cannot discard
them). Cost: one sonnet/medium call per task, multi-task runs only;
single-task and mini runs are unchanged.

Verified: node --check on the async-wrapped script and a stub-agent
harness — discard trips the hard BLOCKED and stops the loop; an
accumulating happy path, a plan-intended deletion, a single-task run,
and a dead snapshot agent all pass without a false positive.

closes #23
This commit is contained in:
2026-07-09 11:27:51 +02:00
parent 22aafe892a
commit e7009bc304
5 changed files with 172 additions and 12 deletions
+15 -2
View File
@@ -162,8 +162,17 @@ substitute.
Law from `tester.md` applies to your tests too.
8. Self-review: re-read `git diff HEAD`. Did it match the
task text? Did you do anything not in the task text?
If yes, undo that part with `git checkout -- <path>` or
by editing back — controller curates scope, not you.
If yes, undo that part BY EDITING BACK — never with
`git checkout` / `git restore`, whole-file or
`-p`/`--patch`; the same holds for recovering from a
broken intermediate state: repair forward by editing,
or report `BLOCKED`.
Nothing commits between the tasks of an iteration, so a
file you touched may also carry EARLIER tasks'
uncommitted, already-reviewed work — `git diff HEAD`
shows those sibling chunks, and a file-level checkout
silently destroys them along with yours. Controller
curates scope, not you.
Did the test you wrote actually fail before the GREEN
code, or did you write it after? If after, delete the
production code and start over. (TDD is letter-and-
@@ -196,6 +205,10 @@ End every report with exactly one of:
design ledger forbids)
- hypothesis-space exhaustion (≥ 3 implementation
strategies failed — architecture is wrong, escalate)
- unrecoverable intermediate state (the tree broke
mid-task and forward repair by editing failed —
checkout/restore is not an option: it destroys
sibling tasks' uncommitted work, Step 8)
Never push past BLOCKED by hand.
## Output format