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
+27
View File
@@ -101,6 +101,7 @@ regression around the RED-first gate.
```
THE LOOP NEVER COMMITS. CODE EDITS, TESTS, AND THE STATS FILE LIVE IN THE WORKING TREE AS UNSTAGED CHANGES UNTIL THE ORCHESTRATOR COMMITS THEM.
WITHIN THIS LOOP, MAIN HEAD IS SACROSANCT — NO RESET, NO REVERT; THE LOOP ONLY EVER WRITES UNSTAGED CHANGES. MAIN MOVES FORWARD ONLY VIA ORCHESTRATOR COMMITS. (THE `/boss` ROLLBACK SANDBOX WINDS BACK ALREADY-COMMITTED AUTONOMOUS WORK — A BOSS-LEVEL RECOVERY, NOT A STEP IN THIS LOOP.)
NO IN-LOOP AGENT EVER DISCARDS WORKING-TREE STATE VIA `git checkout` / `git restore` — WHOLE-FILE OR `--patch`: NOTHING COMMITS BETWEEN TASKS, SO A SHARED FILE CARRIES EARLIER TASKS' UNCOMMITTED DONE WORK, AND A CHECKOUT SILENTLY DESTROYS IT (issue #23). RECOVERY IS FORWARD — EDIT BACK, OR REPORT `BLOCKED`. (THE ORCHESTRATOR'S OWN END-OF-ITERATION `git checkout -- .` DISPOSITION HAPPENS OUTSIDE THE LOOP.)
PER-TASK PHASES ARE SEPARATE AGENT CALLS IN THE WORKFLOW SCRIPT — implementer, then spec-compliance, then quality. SPEC COMPLIANCE IS GATED BEFORE QUALITY.
TASKS RUN SEQUENTIALLY; A BLOCKED TASK STOPS THE LOOP — NO SKIP-AHEAD (TASK ORDERING DEPENDENCIES ARE UNKNOWN).
NEVER PUSH PAST `BLOCKED` BY HAND.
@@ -155,6 +156,32 @@ ground truth, never a self-report** — a self-report must never be able to
The E2E phase's `status` is also read now (not just its fixtures): a
non-`DONE` E2E status or a zero-fixture run surfaces as a concern.
**Cross-task discard guard** (issue #23). In a multi-task run nothing
commits between tasks, so one task's file-level `git checkout`/`git
restore` can silently destroy an earlier task's DONE work in a shared
file — invisible to the reviewers (scoped to the current task's
footprint) and to the tree-count gate (the surviving changes keep the
count nonzero). The loop therefore records a snapshot boundary after
every task (`git stash create` — a dangling commit; HEAD, index, and
working tree untouched) and hard-`BLOCK`s when a path that was
HEAD-modified at one boundary is HEAD-identical at the next: the
end-report names the lost paths, the boundary, and the recovery
snapshot sha the discarded content can be read from
(`git show <sha>:<path>`). Coarse by design, in both directions: a
checkout followed by re-edits of the same file, or a `--patch`-level
restore of a single hunk, escapes the comparison (the path never
leaves the diff) — the snapshot still keeps any loss diagnosable and
recoverable; conversely a task that legitimately edits a shared file
back to byte-identical HEAD content trips the guard, so a tripped
verdict means *adjudicate against the snapshot*, not proven malice.
Renames do not trip it (the boundary diff runs `--no-renames`, so a
renamed file's old path stays listed as a deletion). Untracked files
sit outside the threat model (`git checkout -- <path>` cannot discard
them). Cost: one extra sonnet/medium snapshot call per task of a
multi-task run; single-task and mini runs skip the guard. The
detection details live in `workflows/implement-loop.js`, the single
source.
## The Process — orchestrator side
### Step 1 — Run the `implement-loop` workflow
+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
+120 -6
View File
@@ -39,6 +39,28 @@
// implementer's call, not enforced in code (the script cannot tell a
// cosmetic hold from an ignored bug) — hence the neutral concern label
// and the orchestrator backstop, same trust placed in its other reports.
// • Cross-task discard guard (issue #23): nothing commits between tasks,
// so a task's file-level `git checkout`/`git restore` can silently
// destroy an EARLIER task's uncommitted DONE work in a shared file —
// invisible to the footprint-scoped reviewers and to the tree-count
// gate (the surviving changes keep the count nonzero). In a multi-task
// run the loop records a snapshot boundary after every task
// (`git stash create`: a dangling commit — HEAD, index, and tree
// untouched) and hard-BLOCKs when a path that was HEAD-modified at one
// boundary is HEAD-identical at the next, naming the lost paths and the
// recovery snapshot sha. Coarse by design, in BOTH directions: a
// checkout-then-re-edit of the same file or a `--patch`-level hunk
// restore escapes the comparison (the path never leaves the diff) — the
// snapshot still keeps any loss diagnosable; conversely a task that
// legitimately edits a shared file back to byte-identical HEAD content
// trips the guard, so the verdict reads "adjudicate against the
// snapshot", not proof of a discard. `--no-renames` keeps a staged
// rename's old path listed (as a deletion) rather than reading as a
// loss. The snapshot agent itself is prompt-bound to the read-only
// `stash create` (a bare `git stash` would move the tree; the following
// boundary would catch that wipe, but only after the fact). Untracked
// files sit outside the threat model (`git checkout -- <path>` cannot
// discard them).
// • The script has no filesystem or shell access of its own — every
// working-tree mutation (code, stats.json, BLOCKED.md) happens inside
// an agent() call.
@@ -50,8 +72,9 @@
// • Effort policy mirrors the model policy: every agent() call pins an
// explicit effort — never the session inherit. Code-writing and review
// steps run high, the opus quality gate runs xhigh, and schema-bound
// extraction/check steps (preflight, plan-index, plan-extract, mini-verify,
// tree-check, finalize) run medium. See docs/agent-template.md § effort.
// extraction/check steps (preflight, plan-index, plan-extract, snapshot,
// mini-verify, tree-check, finalize) run medium. See
// docs/agent-template.md § effort.
//
// Carrier — passed as the Workflow `args` object by the orchestrator:
// mode: 'standard' | 'mini'
@@ -319,6 +342,31 @@ const TREE_SCHEMA = {
},
},
}
// Cross-task discard guard (issue #23): the per-boundary snapshot verdict.
// `git stash create` writes a dangling commit capturing the tracked
// working-tree state without touching HEAD, the index, or the tree; the
// HEAD-modified path set is the comparison key across boundaries.
const SNAP_SCHEMA = {
type: 'object',
additionalProperties: false,
required: ['snapshot_sha', 'modified_paths'],
properties: {
snapshot_sha: {
type: 'string',
description:
'the sha printed by `git stash create "<label>"` — empty string if it printed nothing (no tracked ' +
'modification to snapshot)',
},
modified_paths: {
type: 'array',
items: { type: 'string' },
description:
'every path printed by `git diff HEAD --name-only --no-renames` (tracked modifications and deletions; ' +
'`--no-renames` keeps a renamed file\'s old path listed as a deletion; untracked files are invisible ' +
'here by design)',
},
},
}
const FINALIZE_SCHEMA = {
type: 'object',
additionalProperties: false,
@@ -652,9 +700,50 @@ async function runTask(task) {
}
const results = []
// Cross-task discard guard (issue #23) state: the previous task boundary
// (snapshot sha + HEAD-modified path set) and, when tripped, the discard
// evidence. Only a multi-task run needs the guard — with a single task there
// is no sibling work to protect.
let prevBoundary = null
let discard = null
const guardConcerns = []
for (const task of tasks) {
const r = await runTask(task)
results.push(r)
if (tasks.length > 1) {
const snap = await agent(
`${STANDING}\n\nRecord a working-tree snapshot boundary WITHOUT changing anything. Run exactly these two ` +
'commands and report their output:\n' +
`1. \`git stash create "iter ${iter_id} post-task ${task.id}"\` — prints a sha (a dangling commit; it ` +
'touches neither HEAD, nor the index, nor the working tree). Report it as snapshot_sha; empty string if ' +
'it printed nothing.\n' +
'2. `git diff HEAD --name-only --no-renames` — report every printed path as modified_paths.\n' +
'NEVER run bare `git stash` or `git stash push/pop/apply/drop` — those MOVE the changes. Do NOT edit, ' +
'stage, or commit anything.',
{ model: 'sonnet', effort: 'medium', label: `snapshot:${task.id}`, phase: 'Per-task loop', schema: SNAP_SCHEMA },
)
if (snap && prevBoundary) {
const now = new Set(snap.modified_paths || [])
const lost = [...prevBoundary.paths].filter((p) => !now.has(p))
if (lost.length) {
// A path that was HEAD-modified at the previous boundary is
// HEAD-identical now: a file-level checkout/restore discarded
// sibling-task work mid-run. Hard stop — the per-task DONE reports
// and the tree have diverged, so nothing downstream is trustworthy.
discard = {
lost_paths: lost,
modified_through_task: prevBoundary.taskId,
discarded_during_task: task.id,
recovery_snapshot: prevBoundary.sha || null,
}
break
}
}
if (!snap) guardConcerns.push(`discard-guard snapshot after task #${task.id} did not return — that boundary went unchecked`)
// A non-returning snapshot agent skips the NEXT comparison (prevBoundary
// null) rather than false-positiving against a stale boundary.
prevBoundary = snap ? { taskId: task.id, sha: snap.snapshot_sha || null, paths: new Set(snap.modified_paths || []) } : null
}
if (r.outcome === 'BLOCKED') break // do not skip ahead — task ordering dependencies are unknown
}
@@ -675,6 +764,29 @@ else outcome = 'BLOCKED'
let synthBlock = null
let noopClean = false
// Cross-task discard override (issue #23): a tripped discard guard outranks
// every per-task outcome — the reports and the tree have diverged, so neither
// DONE nor PARTIAL is trustworthy (the reported-DONE work may be the very
// content that was destroyed). Hard BLOCKED; the recovery snapshot is a
// dangling commit the orchestrator reads the discarded content from
// (`git show <sha>:<path>`, `git diff <sha> HEAD -- <path>`).
if (discard) {
outcome = 'BLOCKED'
synthBlock = {
task: discard.discarded_during_task,
reason:
`working-tree discard detected: [${discard.lost_paths.join(', ')}] carried uncommitted edits through ` +
`task #${discard.modified_through_task} and reverted to HEAD-identical during task #${discard.discarded_during_task}` +
'a file-level checkout/restore destroyed sibling-task work (or, rarely, a task legitimately edited the ' +
'file back to HEAD content); adjudicate against the recovery snapshot — until then the DONE reports ' +
'covering these paths are not trustworthy',
detail: discard.recovery_snapshot
? `recovery snapshot (dangling commit): ${discard.recovery_snapshot} — read the discarded content via ` +
`git show ${discard.recovery_snapshot}:<path>`
: 'no recovery snapshot available: the pre-discard boundary printed no sha',
}
}
// ---- Phase 2.5 — verify (issue #12, facets 1+2+4) ----------------------
// mini mode: independently OBSERVE the handed-off RED test is now GREEN (a no-op
// leaves it RED while the implementer can still report DONE). standard mode: the
@@ -781,10 +893,12 @@ const aggregate = {
tasks_total: tasks.length,
tasks_completed: completed.length,
task_summaries: results.map((r) => ({ id: r.id, title: r.title, outcome: r.outcome })),
concerns: [...results.flatMap((r) => r.concerns || []), ...e2eConcerns],
blocked_detail: blocked
? { task: blocked.id, reason: blocked.reason, detail: blocked.detail || null }
: synthBlock,
concerns: [...results.flatMap((r) => r.concerns || []), ...e2eConcerns, ...guardConcerns],
// synthBlock (a discard or iteration-level no-op verdict) outranks the
// per-task block: when the discard guard broke the loop on a task that
// ALSO blocked, the discard is the cause and the task block the symptom.
blocked_detail:
synthBlock || (blocked ? { task: blocked.id, reason: blocked.reason, detail: blocked.detail || null } : null),
}
const fin = await agent(