Files
claude 5d1cffab76 fix(implement-loop): make resume survive plan edits and interim subset commits
Two interacting defects broke the documented PARTIAL repair flow (fix
the plan, commit the known-good subset, rm BLOCKED.md, resume) —
observed on run wf_81a13333-be3 (aura): the resume replayed all 78
agents from cache in 33 ms and returned the byte-identical stale
end-report.

1. Plan content was invisible to the resume cache key. The substrate
   caches per (prompt, opts) and every plan-reading prompt carried only
   the plan PATH, so a plan edit changed no prompt and the extraction
   replayed stale text. New required standard-mode carrier field
   plan_sha256 (recomputed by the orchestrator at every invocation) is
   stamped into all three plan-reading prompts (plan-index,
   plan-extract-all, plan-extract:<id>): a plan edit re-runs the
   extraction, and a downstream task replays from cache iff its
   re-extracted text is byte-identical — worst case a redundant live
   re-run, never stale text. Alternative considered: documenting "plan
   edits need a fresh run with task_range" — rejected, it keeps the
   trap armed and forfeits the cache replay a resume exists for.

2. The cross-task discard guard compared boundary path sets across a
   moved HEAD. Boundary sets are diffs AGAINST HEAD; after the
   sanctioned interim subset commit, the first live boundary measures
   against the new HEAD while the replayed boundary carries the old
   one — the entire committed subset read as "lost paths" and
   hard-BLOCKed. The snapshot agent now also reports
   `git rev-parse HEAD`; a boundary pair straddling a HEAD move resets
   the guard with a concern (which also flags a rogue mid-run commit)
   instead of comparing. Same-HEAD discards still hard-BLOCK; a blank
   sha falls through to the comparison so a flaky report cannot disarm
   the guard.

Verified with a zero-token mock harness (AsyncFunction + stubbed
agent()): fail-fast on missing plan_sha256, hash present in all three
extraction prompts (batched and fan-out paths), same-HEAD discard
still BLOCKs, HEAD-move resets with a concern, mini mode unaffected.
Adversarially reviewed (4-verifier workflow): SOUND. Known residuals:
the script cannot verify the hash matches the plan bytes (operator
obligation, documented in the carrier contract and SKILL.md), and a
coincident HEAD-move + discard surfaces as a concern rather than a
BLOCK.

closes #32
2026-07-21 10:09:41 +02:00

1362 lines
74 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// implement-loop — the per-task implement loop, as a Workflow script.
//
// This is the Workflow-substrate form of what used to be the
// `implement-orchestrator` agent's inline three-phase role-switch loop.
// The substrate change is the point of issue #7's Part B:
//
// • Each per-task phase is now a separate `agent()` call — implementer,
// spec-reviewer, quality-reviewer are dispatched as their existing
// agent-types (they SURVIVE the migration; the inline-role-switch
// ORCHESTRATOR is what retires). A single phase is therefore
// independently invokable, which the inline loop could not offer.
// • Inter-phase aggregation and the re-loop limit are deterministic
// code here, not natural-language reasoning between dispatches.
// • Routing keys on the structured verdict each agent reports (which
// encodes the project's real build/test outcome), not on a guess.
//
// Discipline preserved verbatim from the old loop:
// • The script NEVER commits and NEVER moves main HEAD. All code edits
// live in the working tree as unstaged changes; the end-report carries
// the finished stats.json and (on PARTIAL/BLOCKED) BLOCKED.md contents,
// which the orchestrator that invoked this workflow writes, inspects,
// and commits (issue #29 — the former finalize dispatch only templated
// the script's own aggregate).
// • Tasks run SEQUENTIALLY — plan tasks carry implicit ordering
// dependencies, and the script does not know the graph, so a BLOCKED
// task stops the loop rather than skipping ahead.
// • Spec-compliance is gated BEFORE quality, per task.
// • Re-loop limit: 2 repair retries per failure-mode per task; the 3rd
// unresolved attempt escalates to BLOCKED.
// • Minor-only quality rounds stop gating after the first repair: a
// changes_requested verdict carrying no Important finding, arriving
// after at least one quality-repair dispatch, surfaces its findings as
// concerns and lets the task close DONE. Each round's reviewer reads
// the diff fresh, so late rounds keep surfacing NEW polish (severity
// trending down) — without this valve a green task hard-BLOCKs on
// cosmetic residue and the whole iteration gets re-dispatched.
// Important findings still consume the full retry budget and BLOCK.
// • Quality re-loop hold (issue #10): a quality finding whose only remedy
// would contradict the ratified plan (a name/signature/structure the
// task prescribes) is HELD, not chased into a deviation the next review
// then re-flags. The repair implementer keeps the plan and records the
// finding in `held`; a no-op backstop holds it anyway when a repair
// leaves the diff unchanged (or cycles back) yet quality still flags —
// severity-aware (issue #29): a surviving IMPORTANT finding over a
// byte-identical repair diff is BLOCKED, never a held concern; only
// minor-only residue rides as a concern.
// Either way a held finding surfaces as a concern, not a false BLOCKED on
// a green task — and the orchestrator weighs it on its Step-3 inspection.
// A finding the implementer judges correctness-breaking (would not
// build / a test fails / a prescribed caller breaks) escalates to
// BLOCKED instead of holding. The held/no-op partition is the
// 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). Boundaries are HEAD-aware (issue #32): each snapshot
// records the HEAD its diff ran against, and a boundary pair straddling
// a HEAD move — the sanctioned commit-the-subset-then-resume repair — is
// reset-and-surfaced as a concern instead of compared, so a replayed
// pre-commit boundary can never count the committed subset as lost paths.
// • Resume content-addressing (issue #32): the carrier's plan_sha256 rides
// every plan-reading prompt, so the documented PARTIAL repair (edit the
// plan, commit the good subset, resume) re-runs the extraction instead
// of replaying the stale pre-edit plan from cache. Downstream, a task
// replays iff its re-extracted text is byte-identical — typically every
// unchanged task; the failure mode is a redundant live re-run, never
// stale text.
// • The script has no filesystem or shell access of its own — every
// working-tree mutation (code, tests) happens inside an agent() call;
// the stats.json / BLOCKED.md contents are built in-script and ride
// the end-report for the orchestrator to write.
// • Model policy: every agent() call pins an explicit model — never the
// session-model inherit (which could route to an unintended tier, e.g.
// fable — reserved for the four ratified frontmatter gates, never for
// workflow stages; agent-template § model rule 1). Mechanical and
// in-loop steps run sonnet; the quality gate runs opus by default (last
// correctness review before the end-verify), TIERED to sonnet/high for a
// small (<= 25 changed lines), non-contract diff as measured
// independently by the spec-reviewer (issue #30) — the end-verify /
// mini-verify suite gate is the deterministic backstop that makes the
// tier defensible. Unmeasured or contract-touching diffs stay opus.
// • 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[-all],
// snapshot, mini-verify, verify) run medium. See
// docs/agent-template.md § effort.
//
// Carrier — passed as the Workflow `args` object by the orchestrator:
// mode: 'standard' | 'mini'
// iter_id: e.g. 'ct.2.3' (standard) or 'bugfix-<symptom>' / 'feat-<behaviour>' (mini)
// plan_path: (standard) path under docs/plans
// plan_sha256: (standard) sha256 hex of the plan file's CURRENT bytes
// (`sha256sum <plan_path>`). Stamped into every plan-reading
// prompt as a content-address (issue #32): a resume replays
// agents cached per (prompt, opts), and a prompt that
// carries only the plan PATH replays a stale extraction
// after a plan edit — the documented edit-and-resume repair
// silently no-ops. With the bytes hashed into the prompt, a
// plan edit re-runs the extraction; downstream, a task
// replays from cache iff its re-extracted text is
// byte-identical — typically every unchanged task, worst
// case a redundant live re-run, never stale text. An
// unchanged plan replays fully from cache. Recompute it at
// EVERY (re-)invocation — a stale or constant value
// re-opens the no-op resume.
// task_range: (standard, optional) [from, to] inclusive, 1-based
// red_test_path: (mini) absolute path to the RED test from debug / tdd
// cause_summary: (mini) 12 sentences (debugger cause, or tdd spec_summary)
// constraint: (mini) e.g. 'minimal fix, no surrounding cleanup'
// repo_root: (both modes; optional but STRONGLY recommended, and
// effectively required in worktree sessions — issue #25)
// absolute path of the working tree this loop operates on.
// Stamped into every stage prompt as a `git -C` anchor so a
// dispatched agent whose cwd resolves to the PRIMARY checkout
// (the observed per-dispatch race) cannot review the wrong
// tree — false infra_blocked quality reviews, a false no-op
// BLOCKED from the end-verify. When absent, the first
// dispatched stage probes `git rev-parse --show-toplevel` as
// a fallback (itself subject to the same race the carrier
// field exists to remove — pass the field).
// A non-object carrier, an unknown mode, or a missing required field fails
// fast as an infra BLOCKED before any agent is dispatched (issue #24).
export const meta = {
name: 'implement-loop',
description:
'Execute a plan iteration (standard) or a debug/tdd RED->GREEN handoff (mini) task-by-task: implementer -> spec-compliance -> quality, with deterministic re-loop and aggregation. Writes code and tests to the working tree as unstaged changes; the end-report carries the finished stats.json and (on PARTIAL/BLOCKED) BLOCKED.md contents for the orchestrator to write. Never commits, never moves main HEAD.',
phases: [
{ title: 'Preflight' },
{ title: 'Per-task loop' },
{ title: 'Verify' },
{ title: 'E2E coverage' },
],
}
// Carrier guard (issue #24): a named-workflow invocation can deliver `args`
// as a STRING. `args || {}` alone does not catch that — a non-empty string is
// truthy, so every field would destructure to undefined and the failure
// would surface far downstream (a plan-index dispatch against "Read the plan
// at undefined"), mislabelled as a plan-content problem after burning agent
// calls. Normalize, then reject a malformed carrier HERE, before any agent
// is dispatched — a distinct infra verdict, never a substantive one.
let carrier = args
if (typeof carrier === 'string') {
// Tolerate a JSON-SERIALIZED object carrier (a stringifying caller or
// substrate layer still authored the documented object form); free text
// does not parse to an object and falls through to the rejection below.
try {
carrier = JSON.parse(carrier)
} catch {}
}
if ((carrier !== undefined && carrier !== null && typeof carrier !== 'object') || Array.isArray(carrier)) {
return {
status: 'BLOCKED',
iter_id: null,
reason:
`infra: args arrived as ${Array.isArray(carrier) ? 'an array' : `a ${typeof carrier}`}, not the documented ` +
'object carrier — nothing was dispatched; re-invoke with args: { mode, iter_id, ' +
'plan_path | red_test_path + cause_summary, ... } (invocation examples in implement/SKILL.md)',
}
}
const {
mode = 'standard',
iter_id,
plan_path,
plan_sha256,
task_range = null,
red_test_path,
cause_summary,
constraint = 'minimal change, no surrounding cleanup',
repo_root,
} = carrier || {}
// Blank-safe echo for the guard verdicts below: never mirror a blank/non-string
// iter_id into the end-report while also naming it missing.
const iterIdEcho = typeof iter_id === 'string' && iter_id.trim() ? iter_id : null
if (mode !== 'standard' && mode !== 'mini') {
return {
status: 'BLOCKED',
iter_id: iterIdEcho,
reason: `infra: unknown mode '${mode}' — expected 'standard' or 'mini'; nothing was dispatched`,
}
}
// The required carrier fields per mode (the header comment above is the
// contract). Missing or blank fields fail fast by name — the loop must never
// glob for a plan or brief an implementer with the literal string "undefined".
const requiredByMode = mode === 'mini' ? { iter_id, red_test_path, cause_summary } : { iter_id, plan_path, plan_sha256 }
const missingFields = Object.entries(requiredByMode)
.filter(([, v]) => typeof v !== 'string' || !v.trim())
.map(([k]) => k)
if (missingFields.length) {
return {
status: 'BLOCKED',
iter_id: iterIdEcho,
reason:
`infra: missing required carrier field(s) for mode '${mode}': ${missingFields.join(', ')} — ` +
'nothing was dispatched (carrier contract in the header comment and implement/SKILL.md)',
}
}
const STANDING =
"Standing reading first (the plugin convention): read the project's CLAUDE.md " +
'(its "## Skills plugin: project facts" name the build and test commands and any ' +
'design ledger) and `git log -10 --format=full`. Resolve the build/test commands ' +
'from those facts — do not assume a toolchain.'
// Standing-reading tiering (issue #31): the full standing read primes the
// judgment roles (implementer, reviewers, tester), but a schema-bound check
// stage consumes none of it — at ~40k+ real tokens per freshly-primed context
// the blanket read was pure priming tax on the highest-volume dispatches.
// Two slim variants, extending the narrowed per-role standing reading the
// named agents already practise (conventions.md § Standing reading):
// STANDING_FACTS — verify-type stages that run the suite: only the
// project-facts build/test commands, no git log.
// STANDING_NONE — tree/extraction stages (preflight, plan-index,
// plan-extract[-all], snapshot): nothing beyond their own instructions.
const STANDING_FACTS =
'Project facts first: read ONLY the "## Skills plugin: project facts" section of the project\'s CLAUDE.md for ' +
'the build and test commands — no other standing reading (no git log). This is a schema-bound check stage; ' +
'do not assume a toolchain.'
const STANDING_NONE =
'Schema-bound mechanical stage: NO standing reading (no CLAUDE.md, no git log) — do exactly what is instructed ' +
'below, nothing else.'
// Plan content-address (issue #32): appended to EVERY prompt that reads the
// plan file (plan-index, plan-extract-all, plan-extract:<id>). The resume
// cache keys on (prompt, opts); with only the plan PATH in the prompt, an
// edit-and-resume replays the pre-edit task text from cache and the repair
// silently no-ops. Hashing the plan's bytes into each reading prompt makes a
// plan edit re-run the extraction — and, because the extracted text flows
// verbatim into the downstream per-task prompts, a task replays from cache
// iff its re-extracted text is byte-identical (typically every unchanged
// task; the failure mode is a redundant live re-run, never stale text).
const PLAN_ADDRESS =
mode === 'standard'
? `\n\nPlan content-address: sha256 ${plan_sha256} — binds this extraction to the plan's current bytes for ` +
'resume caching; informational only, nothing to verify or act on.'
: ''
// Working-directory anchor (issue #25): in worktree sessions a dispatched
// agent's cwd can resolve to the PRIMARY checkout instead of the worktree the
// loop operates on — observed as a per-dispatch race producing false
// infra_blocked quality reviews (empty `git diff HEAD`, the sha256-of-empty
// fingerprint) and a false no-op BLOCKED from the end-verify counting the
// wrong (clean) tree. The remedy validated on issue #25: stamp the loop's
// repo root into every stage prompt as a `git -C` instruction. The root comes
// from the carrier (deterministic — preferred) or, as a fallback, from the
// first dispatched stage's `git rev-parse --show-toplevel` probe.
const anchorText = (root) =>
root
? `\n\nWORKING-DIRECTORY ANCHOR (do not skip): the working tree under review lives at ${root}. Run EVERY git ` +
`command as \`git -C ${root} ...\` and run the build/test commands from that directory. An empty ` +
'`git diff HEAD`, an unexpected HEAD sha, or a clean tree right after edits were reported means you are in ' +
'the WRONG directory — re-run against the anchor; never report a clean tree from an unanchored cwd.'
: ''
const carrierRoot = typeof repo_root === 'string' && repo_root.trim() ? repo_root.trim() : ''
let ANCHOR = anchorText(carrierRoot)
// JSON Schemas — force structured verdicts so the script can branch on real outcomes.
const PREFLIGHT_SCHEMA = {
type: 'object',
additionalProperties: false,
required: ['clean', 'head_sha', 'branch', 'repo_root'],
properties: {
clean: { type: 'boolean', description: '`git status --porcelain` produced no output' },
head_sha: { type: 'string' },
branch: { type: 'string' },
repo_root: { type: 'string', description: 'output of `git rev-parse --show-toplevel` (the anchor fallback, issue #25)' },
dirty_paths: { type: 'array', items: { type: 'string' } },
},
}
// Plan extraction is a TWO-step, cardinality-guarded operation (issue #22),
// never a single unbounded verbatim dump. TASK_INDEX_SCHEMA carries the cheap
// enumerate pass (ids + one-line titles only — one short line per task, so it
// self-limits far later than a verbatim dump; the `total_tasks` field is a
// self-consistency backstop for the residual). TASK_TEXT_SCHEMA carries one
// task's verbatim body per agent call (per-task extraction removes the
// ACROSS-ALL-TASKS output-budget ceiling that silently truncated big plans to a
// prefix; a single task whose own body overflows one response is bounded only by
// the planner's bite-sized-task invariant — see the block comment below).
const TASK_INDEX_SCHEMA = {
type: 'object',
additionalProperties: false,
required: ['clean', 'head_sha', 'branch', 'total_tasks', 'task_ids'],
properties: {
// Folded clean-tree preflight (standard mode; issue #29): the same checks
// the dedicated preflight dispatch runs in mini mode, carried by the
// plan-index agent so a standard iteration saves one whole primed context.
// The script evaluates `clean` BEFORE any cardinality guard — a dirty tree
// never reaches the per-task loop.
clean: { type: 'boolean', description: '`git status --porcelain` produced no output' },
head_sha: { type: 'string' },
branch: { type: 'string' },
repo_root: { type: 'string', description: 'output of `git rev-parse --show-toplevel` (the anchor fallback, issue #25)' },
dirty_paths: { type: 'array', items: { type: 'string' } },
total_tasks: {
type: 'integer',
description:
'the TOTAL number of tasks the ENTIRE plan defines — count all of them directly (e.g. count the task ' +
'headers), IGNORING any range filter. This is a self-consistency check: the plan text is fully readable ' +
'(input budget is ample), so this count is authoritative even if the id list below self-limits.',
},
task_ids: {
type: 'array',
items: {
type: 'object',
additionalProperties: false,
required: ['id', 'title', 'approx_bytes'],
properties: {
id: { type: 'integer' },
title: { type: 'string', description: 'one-line title ONLY — never the task body' },
approx_bytes: {
type: 'integer',
description:
"rough byte length of this task's verbatim body in the plan (headers + prose + code blocks). An " +
'ESTIMATE for choosing the extraction strategy only — never load-bearing for correctness; when in ' +
'doubt, estimate HIGH (an overestimate merely keeps the per-task fan-out).',
},
},
},
},
},
}
// Batched extraction carrier (issue #29): every requested task's verbatim
// block in ONE response — used only under the byte ceiling below, with a
// deterministic completeness guard and a fan-out fallback (issue #22 is the
// reason this is adaptive, not unconditional).
const TASK_TEXTS_SCHEMA = {
type: 'object',
additionalProperties: false,
required: ['tasks'],
properties: {
tasks: {
type: 'array',
items: {
type: 'object',
additionalProperties: false,
required: ['id', 'title', 'text'],
properties: {
id: { type: 'integer' },
title: { type: 'string' },
text: { type: 'string', description: 'verbatim task block from the plan (this one task only)' },
},
},
},
},
}
const TASK_TEXT_SCHEMA = {
type: 'object',
additionalProperties: false,
required: ['id', 'title', 'text'],
properties: {
id: { type: 'integer' },
title: { type: 'string' },
text: { type: 'string', description: 'verbatim task block from the plan (this one task only)' },
},
}
const IMPL_SCHEMA = {
type: 'object',
additionalProperties: false,
required: ['status', 'summary', 'applied_changes'],
properties: {
status: { type: 'string', enum: ['DONE', 'DONE_WITH_CONCERNS', 'NEEDS_CONTEXT', 'BLOCKED'] },
summary: { type: 'string', description: 'one line: what changed (paths + functions)' },
applied_changes: {
type: 'boolean',
description:
'GROUND TRUTH, not intent: did THIS task write to at least one file? true if you called Edit/Write (or ' +
'equivalent) on any file; false if you made no file change at all — the code was already in the target ' +
'state, or you skipped/misunderstood the task. Report it honestly even when status is DONE: a DONE that ' +
'touched nothing is not a contribution, and the loop surfaces it for the orchestrator to weigh.',
},
concerns: { type: 'array', items: { type: 'string' } },
reason: { type: 'string', description: 'on BLOCKED/NEEDS_CONTEXT: the blocker' },
held: {
type: 'array',
description:
'quality-repair dispatch only: quality findings KEPT against the ratified plan because honouring them ' +
'would deviate from a plan-prescribed name/signature/structure while the tree stays green. Each is a ' +
'concern to surface, not a deviation to chase. A finding that would break correctness is NOT held — it ' +
'is escalated via status BLOCKED.',
items: {
type: 'object',
additionalProperties: false,
required: ['finding', 'kept', 'why'],
properties: {
finding: { type: 'string', description: 'the quality finding being kept (held)' },
kept: { type: 'string', description: 'the plan-prescribed element preserved (name/signature/structure)' },
why: { type: 'string', description: 'why honouring the finding would contradict the ratified plan' },
},
},
},
},
}
const SPEC_SCHEMA = {
type: 'object',
additionalProperties: false,
required: ['status', 'diff_magnitude', 'touches_contract'],
properties: {
status: { type: 'string', enum: ['compliant', 'non_compliant', 'unclear', 'infra_blocked'] },
findings: { type: 'array', items: { type: 'string' }, description: 'missing requirements + unrequested extras' },
ambiguity: { type: 'string' },
// Quality-tier inputs (issue #30): measured HERE because the spec-reviewer
// runs before the quality gate, reads the diff itself, and is independent
// of the implementer — never a producer self-report.
diff_magnitude: {
type: 'integer',
description:
'total changed lines of the FULL `git diff HEAD` (insertions + deletions, from `git diff HEAD --shortstat`) ' +
'— measure it yourself; never take it from the implementer',
},
touches_contract: {
type: 'boolean',
description:
'true if any diffed path is part of (or referenced by) the design ledger / contracts the project\'s ' +
'CLAUDE.md facts declare; false when the project declares none',
},
},
}
const QUAL_SCHEMA = {
type: 'object',
additionalProperties: false,
required: ['status', 'diff_fingerprint'],
properties: {
status: { type: 'string', enum: ['approved', 'changes_requested', 'infra_blocked'] },
issues: {
type: 'array',
description: 'Important + Minor only (Nits never gate)',
items: {
type: 'object',
additionalProperties: false,
required: ['severity', 'text'],
properties: {
severity: {
type: 'string',
enum: ['important', 'minor'],
description:
'honest classification per the quality-reviewer severity definitions — the loop stops gating on ' +
'minor-only residue after the first repair round, and that valve only works if a minor is never ' +
'inflated to important',
},
text: { type: 'string', description: '<file>:<line> — the issue' },
},
},
},
diff_fingerprint: {
type: 'string',
description:
'sha256 hex of the FULL `git diff HEAD` (the whole working-tree diff, not a footprint subset) — REQUIRED; ' +
'the loop compares it across rounds to detect a no-op/cyclic repair and stop oscillating to a false BLOCKED',
},
},
}
const E2E_SCHEMA = {
type: 'object',
additionalProperties: false,
required: ['status'],
properties: {
status: { type: 'string', enum: ['DONE', 'DONE_WITH_CONCERNS', 'NEEDS_CONTEXT', 'BLOCKED'] },
fixtures: { type: 'array', items: { type: 'string' } },
note: { type: 'string' },
},
}
// mini-mode only: independently OBSERVE that the RED test is now GREEN, rather
// than trusting the implementer's self-reported DONE. The whole telos of a
// mini-mode handoff is to drive one specific RED test to GREEN; a no-op leaves
// it RED while the implementer can still report DONE. This is the implement-loop
// analogue of compiler-driven-edit's verify agent (build+suite re-run +
// working_tree_dirty) — observe, do not assert.
const MINI_VERIFY_SCHEMA = {
type: 'object',
additionalProperties: false,
required: ['red_test_now_green', 'suite_green', 'working_tree_dirty'],
properties: {
red_test_now_green: {
type: 'boolean',
description: 'the handed-off RED test, re-run by name, now PASSES (it was red before the fix; confirm it is green now)',
},
suite_green: { type: 'boolean', description: 'the full suite passes — the fix introduced no regression' },
working_tree_dirty: {
type: 'boolean',
description: 'GROUND TRUTH from `git status --porcelain` being non-empty (NOT `git diff HEAD`, which omits brand-new untracked files): a fix actually landed (a clean tree means the RED test, if green, was never red, or nothing was done)',
},
detail: { type: 'string', description: 'on any false: which test is still red, what regressed, or that the tree is clean' },
},
}
// Standard-mode end-verify (issue #12 facets 1+2 + issue #29 / the pipeline
// audit): the authoritative ground truth that the iteration's per-task work
// actually landed AND left the project suite green. Runs after the loop but
// BEFORE E2E, so E2E fixtures cannot inflate the count (stats/BLOCKED.md are
// no longer written in-loop at all — the orchestrator writes them from the
// end-report, after this gate).
// Two legs, both gating:
// • files_touched from `git status --porcelain` (counts brand-new UNTRACKED
// files — the implementer leaves edits unstaged, so a new-file deliverable
// is untracked and `git diff HEAD` would miss it). 0 → no-op BLOCKED.
// • suite_green from a full independent suite run. Standard mode previously
// rode on the per-task implementers' self-reported green while mini and
// compiler-driven both re-ran the suite as a hard gate — this closes that
// asymmetry. red → BLOCKED (route back to debug, RED-first). Same
// flaky-suite exposure mini-verify already has, by design.
// A self-report can never reach this verdict; it is git/suite ground truth.
const STD_VERIFY_SCHEMA = {
type: 'object',
additionalProperties: false,
required: ['files_touched', 'suite_green'],
properties: {
files_touched: {
type: 'integer',
description:
'the line count of `git status --porcelain` (every changed/added/untracked path). Run exactly that — do NOT ' +
'use `git diff`, which omits untracked new files. Do NOT edit anything. 0 means the iteration is a no-op.',
},
suite_green: {
type: 'boolean',
description:
"the FULL project suite passes (the test command from the project's CLAUDE.md facts), re-run by you — " +
'never taken from an earlier report',
},
detail: { type: 'string', description: 'on suite_green false: which tests failed' },
},
}
// 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', 'head_sha'],
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)',
},
head_sha: {
type: 'string',
description:
'output of `git rev-parse HEAD` — the baseline modified_paths was diffed against; the guard compares two ' +
'boundaries only when their baselines match (issue #32)',
},
},
}
// ---- Phase 0 — clean-tree preflight (mini mode only) --------------------
// Standard mode folds these checks into the plan-index dispatch (issue #29):
// the check itself stays deterministic in the script (schema fields evaluated
// before anything else), only the dedicated carrier dispatch is saved. Mini
// mode has no plan-index to fold into, so it keeps the dedicated preflight.
let startSha = null
if (mode === 'mini') {
phase('Preflight')
const pre = await agent(
`${STANDING_NONE}${ANCHOR}\n\nYou are the clean-tree preflight for an implement iteration. ` +
'Run `git status --porcelain`, `git rev-parse HEAD`, `git rev-parse --abbrev-ref HEAD`, and ' +
'`git rev-parse --show-toplevel`. ' +
'Report whether the tree is clean (no porcelain output), the HEAD sha, the branch, the repo_root, and any ' +
'dirty paths. ' +
'Do NOT modify anything, do NOT commit. The HEAD sha is an informational anchor only — never a reset target.',
{ model: 'sonnet', effort: 'medium', label: 'preflight', phase: 'Preflight', schema: PREFLIGHT_SCHEMA },
)
if (!pre) return { status: 'BLOCKED', iter_id, reason: 'infra: preflight agent did not return' }
if (!pre.clean) {
return {
status: 'BLOCKED',
iter_id,
reason: 'infra: working tree dirty — the orchestrator must clean it before re-invoking',
dirty_paths: pre.dirty_paths || [],
}
}
startSha = pre.head_sha
if (!carrierRoot && typeof pre.repo_root === 'string' && pre.repo_root.trim()) ANCHOR = anchorText(pre.repo_root.trim())
}
// ---- Phase 1 — assemble the task list ----------------------------------
let tasks
if (mode === 'mini') {
tasks = [
{
id: 1,
title: 'drive the RED test to GREEN',
text:
`Make this RED test pass: ${red_test_path}\n\n` +
`Context (debugger cause, or tdd spec_summary): ${cause_summary}\n\n` +
`Constraint: ${constraint}`,
},
]
} else {
// Plan extraction is split into an enumerate pass + per-task verbatim
// extraction, guarded by cardinality checks (issue #22). The old design
// pulled every requested task's VERBATIM block through ONE schema-bound
// response; on a large plan (many tasks, or a few tasks carrying long code
// blocks) that response self-limits and the agent returns a well-formed
// PREFIX of the tasks — commonly just task 1. The short list was non-empty,
// so the only guard (reject empty) passed, and the loop ran to a clean DONE
// over the truncated subset. The missing tasks were never attempted, yet the
// end-report read as a completion. This fix removes the across-all-tasks
// ceiling AND adds the backstops:
// • Enumerate pass (plan-index) — returns ONLY task ids + one-line titles,
// one short line per task, so it self-limits far later than a verbatim
// dump. It also reports `total_tasks` (the whole plan's task count,
// computed from the fully-readable plan text independently of the emitted
// list) — the self-consistency oracle the whole-plan path otherwise lacks.
// • Per-task extract (plan-extract:<id>) — one agent() per id carries
// exactly one task's verbatim block, so no single response has to hold
// every task's text. Extraction is read-only and order-independent (unlike
// the implement loop, whose tasks carry ordering dependencies), so the
// calls fan out via parallel() (which also honours the concurrency cap on
// a big plan). The requested id is bound INSIDE each thunk and the results
// are re-associated by it — never by array position — so the mapping is
// correct regardless of the order parallel() resolves in.
// • Cardinality guards — (a) the enumerate must list exactly `total_tasks`
// on the whole-plan path; (b) a task_range must be well-formed and covered
// exactly by the plan; (c) every expected id must extract non-empty text.
// Any breach is a hard BLOCKED, never a silent short run.
// Residual (bounded, not closed): a SINGLE task whose own verbatim body
// overflows its dedicated per-task response would return a non-empty prefix
// that part-(c) accepts — there is no per-task round-trip oracle. This is far
// narrower than the closed across-tasks vector and rests on the planner's
// bite-sized-task invariant; it is the one truncation slice left open here.
const index = await agent(
`${STANDING_NONE}${ANCHOR}\n\nFIRST the folded clean-tree preflight: run \`git status --porcelain\`, \`git rev-parse HEAD\`, ` +
'`git rev-parse --abbrev-ref HEAD`, and `git rev-parse --show-toplevel`; report `clean` (no porcelain output), ' +
'`head_sha`, `branch`, `repo_root`, and any ' +
'`dirty_paths`. The HEAD sha is an informational anchor only — never a reset target. If the tree is DIRTY, ' +
'stop there: return task_ids: [] and total_tasks: 0 without reading the plan.\n\n' +
`Otherwise read the plan at ${plan_path}. List the task ` +
(task_range
? `ids in the range ${task_range[0]}..${task_range[1]} (inclusive, 1-based)`
: 'ids of EVERY task') +
' that the plan actually defines, each with its one-line title. Return ONLY ids and titles — do NOT include ' +
'any task body or code block. Do not summarise, invent, reorder, or pad the list. Separately report ' +
`\`total_tasks\`: the count of ALL tasks the entire plan defines, ignoring any range filter. Do not edit anything.${PLAN_ADDRESS}`,
{ model: 'sonnet', effort: 'medium', label: 'plan-index', phase: 'Per-task loop', schema: TASK_INDEX_SCHEMA },
)
if (!index) {
return { status: 'BLOCKED', iter_id, reason: 'infra: plan-index agent did not return' }
}
// Folded preflight verdict — evaluated before every cardinality guard.
if (index.clean === false) {
return {
status: 'BLOCKED',
iter_id,
reason: 'infra: working tree dirty — the orchestrator must clean it before re-invoking',
dirty_paths: index.dirty_paths || [],
}
}
startSha = index.head_sha
if (!carrierRoot && typeof index.repo_root === 'string' && index.repo_root.trim()) ANCHOR = anchorText(index.repo_root.trim())
if (!index.task_ids || index.task_ids.length === 0) {
return { status: 'NEEDS_CONTEXT', iter_id, reason: `no tasks found in ${plan_path}` }
}
// Dedup the enumerated ids (first title wins); they define the enumerate set.
const titleById = new Map()
for (const t of index.task_ids) if (!titleById.has(t.id)) titleById.set(t.id, t.title)
const enumeratedIds = [...titleById.keys()].sort((a, b) => a - b)
const planTotal = typeof index.total_tasks === 'number' ? index.total_tasks : null
// Which ids MUST be extracted, and the guard that the enumerate is complete.
let expectedIds
if (task_range) {
// Guard 0 — a well-formed 1-based [from, to]. A degenerate/inverted range
// (from > to, non-integer, < 1) is a caller error surfaced as its own
// BLOCKED, not swallowed into an empty task list that later mis-reports as
// a no-op iteration.
const [from, to] = task_range
if (!Number.isInteger(from) || !Number.isInteger(to) || from < 1 || to < from) {
return {
status: 'BLOCKED',
iter_id,
reason: `invalid task_range [${from}, ${to}]: expected a 1-based [from, to] with 1 <= from <= to`,
}
}
// Guard (b) — the range must be covered EXACTLY by the plan. A range that
// overshoots (e.g. [1,10] on a 5-task plan) is surfaced, not run short.
const wanted = []
for (let i = from; i <= to; i++) wanted.push(i)
const missing = wanted.filter((i) => !titleById.has(i))
if (missing.length) {
return {
status: 'BLOCKED',
iter_id,
reason:
`plan-extract cardinality mismatch: range ${from}..${to} asked ${wanted.length} task(s) but ` +
`${plan_path} defines ${planTotal ?? enumeratedIds.length} task(s) (missing in-range id(s): ${missing.join(', ')})`,
}
}
expectedIds = wanted
} else {
// Guard (a) — whole-plan path: the enumerate is the sole authority, so
// cross-check its own count. `total_tasks` is computed from the full plan
// independently of the emitted list; a listed-vs-counted mismatch is the
// enumerate self-limiting to a prefix — issue #22's symptom relocated to the
// (bounded) enumerate pass, and the only backstop this path can have without
// a caller-supplied range.
if (planTotal !== null && enumeratedIds.length !== planTotal) {
return {
status: 'BLOCKED',
iter_id,
reason:
`plan-index truncated: enumerated ${enumeratedIds.length} task(s) but the plan at ${plan_path} ` +
`defines ${planTotal} — the enumerate pass self-limited; re-run, do not proceed over a partial index`,
}
}
expectedIds = enumeratedIds
}
// Extraction strategy (issue #29, guarded against issue #22): the per-task
// fan-out re-primes a fresh context per task (~59k real tokens each in the
// 206-run corpus) to re-read the SAME plan — for a small plan that is pure
// priming overhead. Extraction is therefore adaptive on the index's byte
// estimates:
// • total estimate <= EXTRACT_ALL_BYTE_CEILING → ONE extract-all call
// carries every requested task's verbatim block. The ceiling is on
// BYTES, never task count — the #22 truncation (a schema-bound response
// self-limits to a well-formed PREFIX) is output-byte-driven: a 5-task
// plan with long code blocks can truncate where 10 one-liner tasks would
// not. The estimates are agent-reported, so they are a ROUTING heuristic
// only; correctness rests on the deterministic completeness guard below:
// the batched result must carry EXACTLY the expected id set (count
// match, every id present, no empty text) or it is discarded WHOLESALE
// and the fan-out runs instead — one bounded wasted call, never a
// truncated run. (A prefix-truncated batch drops trailing tasks, so the
// count-match catches it; association keys on the agent's self-reported
// ids, and the exact-set requirement makes a mislabelled response fail
// closed.) No single-shot retry: the fan-out IS the retry, and it is the
// truncation-proof path.
// • above the ceiling, estimates missing/implausible, or a failed
// extract-all → the per-task fan-out, unchanged: read-only and
// order-independent, requested id bound in the closure and returned
// alongside the result, so association is by id, never by resolution
// order. parallel() yields null for a thunk that threw / whose agent
// died; agent() itself returns null on a terminal error — both surface
// as a missing id below.
const EXTRACT_ALL_BYTE_CEILING = 20000
const approxById = new Map()
for (const t of index.task_ids) if (!approxById.has(t.id)) approxById.set(t.id, t.approx_bytes)
const estimates = expectedIds.map((id) => approxById.get(id))
const totalApprox = estimates.every((b) => Number.isInteger(b) && b >= 0)
? estimates.reduce((s, b) => s + b, 0)
: null
let extractedTasks = null
if (expectedIds.length > 1 && totalApprox !== null && totalApprox <= EXTRACT_ALL_BYTE_CEILING) {
const all = await agent(
`${STANDING_NONE}${ANCHOR}\n\nRead the plan at ${plan_path}. Extract EXACTLY these tasks (1-based ids): ${expectedIds.join(', ')} — ` +
'EACH as its VERBATIM block: the full task text as written, including any code blocks. Do not summarise, ' +
`truncate, reorder, or merge tasks into each other. Return exactly those tasks, nothing else. Do not edit anything.${PLAN_ADDRESS}`,
{ model: 'sonnet', effort: 'medium', label: 'plan-extract-all', phase: 'Per-task loop', schema: TASK_TEXTS_SCHEMA },
)
const returned = all && Array.isArray(all.tasks) ? all.tasks : []
const byId = new Map()
for (const t of returned) {
if (t && Number.isInteger(t.id) && typeof t.text === 'string' && t.text.trim() && !byId.has(t.id)) byId.set(t.id, t)
}
if (returned.length === expectedIds.length && expectedIds.every((id) => byId.has(id))) {
extractedTasks = expectedIds.map((id) => ({ reqId: id, t: byId.get(id) }))
}
}
if (!extractedTasks) {
extractedTasks = await parallel(
expectedIds.map((id) => () =>
agent(
`${STANDING_NONE}${ANCHOR}\n\nRead the plan at ${plan_path}. Extract EXACTLY task ${id} (1-based) as its VERBATIM block — ` +
'the full task text as written, including any code blocks. Do not summarise, truncate, reorder, or merge in ' +
`any other task. Return that one task only. Do not edit anything.${PLAN_ADDRESS}`,
{ model: 'sonnet', effort: 'medium', label: `plan-extract:${id}`, phase: 'Per-task loop', schema: TASK_TEXT_SCHEMA },
).then((t) => ({ reqId: id, t })),
),
)
}
// Guard (c) — every expected id must have produced non-empty verbatim text.
// A null result (thunk threw, or agent died) or empty text is the truncation
// this issue is about; fail loudly with "got N of M". Keyed by the REQUESTED
// id carried in the closure (never the agent's self-reported id, so a
// mislabelled response cannot masquerade as coverage, and never array
// position, so parallel()'s resolution order is irrelevant).
const tasksById = new Map()
for (const slot of extractedTasks) {
if (!slot) continue // parallel() null: the thunk threw
const { reqId, t } = slot
if (t && typeof t.text === 'string' && t.text.trim()) {
const title = (t.title && t.title.trim()) || titleById.get(reqId) || `task ${reqId}`
tasksById.set(reqId, { id: reqId, title, text: t.text })
}
}
const missingText = expectedIds.filter((id) => !tasksById.has(id))
if (missingText.length) {
return {
status: 'BLOCKED',
iter_id,
reason:
`plan-extract truncated: got ${expectedIds.length - missingText.length} of ${expectedIds.length} task(s) ` +
`from ${plan_path} (missing id(s): ${missingText.join(', ')}) — re-run; do not proceed over a partial plan`,
}
}
// Restore plan order — expectedIds is already sorted ascending.
tasks = expectedIds.map((id) => tasksById.get(id))
}
// ---- Phase 2 — per-task loop (sequential) ------------------------------
phase('Per-task loop')
async function runTask(task) {
// 2.1 — implementer phase
const impl = await agent(
`${STANDING}${ANCHOR}\n\nImplement this task exactly, RED-first if it adds behaviour (TDD is an independent ` +
'inner-loop discipline — add the failing test inline even if the task text forgot to script it). ' +
'Build and test must be green before you report DONE. Leave all edits UNSTAGED; never commit.\n\n' +
`mode: ${mode}\niter_id: ${iter_id}\n\nTASK ${task.id}${task.title}\n${task.text}`,
{ agentType: 'implementer', model: 'sonnet', effort: 'high', label: `impl:${task.id}`, phase: 'Per-task loop', schema: IMPL_SCHEMA },
)
if (!impl) return { id: task.id, title: task.title, outcome: 'BLOCKED', reason: 'implementer agent did not return' }
if (impl.status === 'BLOCKED') return { id: task.id, title: task.title, outcome: 'BLOCKED', reason: impl.reason || 'worker-blocked' }
if (impl.status === 'NEEDS_CONTEXT') return { id: task.id, title: task.title, outcome: 'BLOCKED', reason: `context-exhausted: ${impl.reason || ''}` }
const concerns = impl.concerns || []
// Did THIS task write a file, across the initial dispatch AND any repair? The
// initial implementer may legitimately no-op (the code was already in shape)
// while a spec/quality repair does the real writing — so OR the self-reports
// across every dispatch. This feeds a concern and the E2E gate; it is NOT the
// no-op verdict (the git file count is — see the iteration guard).
let wroteAnyFile = impl.applied_changes === true
// 2.2 — spec-compliance check (gated before quality), with repair re-loop
let specMeta = null
for (let round = 0; ; round++) {
const spec = await agent(
`${STANDING}${ANCHOR}\n\nSpec-compliance review of task ${task.id}. Compare \`git diff HEAD\` against the task ` +
'text ONLY — list missing requirements and unrequested extras. No quality opinions, no fix proposals. ' +
'If a requirement can only be judged missing because its scripted literal is contradicted by reality ' +
'you have verified yourself (the actual behaviour provably differs from what the task text asserts), or ' +
'two task requirements contradict each other, report status `unclear` with the contradiction in ' +
'`ambiguity` — never `non_compliant` for a reality-forced deviation, which no repair can resolve. ' +
'Additionally measure and report, independent of any implementer report: `diff_magnitude` (total changed ' +
'lines from `git diff HEAD --shortstat`, insertions + deletions) and `touches_contract` (does any diffed ' +
"path belong to or get referenced by the design ledger / contracts the project's CLAUDE.md facts declare; " +
'false when the project declares none). ' +
`Focus on the files this task claims to touch.\n\nTASK ${task.id}${task.title}\n${task.text}`,
{ agentType: 'spec-reviewer', model: 'sonnet', effort: 'high', label: `spec:${task.id}`, phase: 'Per-task loop', schema: SPEC_SCHEMA },
)
if (spec && spec.status === 'compliant') {
specMeta = spec
break
}
if (spec && spec.status === 'unclear') return { id: task.id, title: task.title, outcome: 'BLOCKED', reason: 'spec-ambiguous', detail: spec.ambiguity }
if (round >= 2) return { id: task.id, title: task.title, outcome: 'BLOCKED', reason: 'review-loop-exhausted (spec-compliance)' }
// repair: re-dispatch the implementer with the review findings as the repair brief
const specFix = await agent(
`${STANDING}${ANCHOR}\n\nRepair task ${task.id} to satisfy the spec-compliance review. Address EACH finding; ` +
'do not widen scope. Keep build + tests green; leave edits unstaged.\n\n' +
`TASK ${task.id}${task.title}\n${task.text}\n\nFINDINGS:\n- ${(spec?.findings || ['(none reported)']).join('\n- ')}`,
{ agentType: 'implementer', model: 'sonnet', effort: 'high', label: `impl-fix-spec:${task.id}`, phase: 'Per-task loop', schema: IMPL_SCHEMA },
)
if (specFix && specFix.applied_changes === true) wroteAnyFile = true
}
// 2.3 — quality check, with repair re-loop.
// The plan-vs-finding judgement lives here, with the implementer that holds
// the task text — the quality-reviewer is deliberately blind to it. The hold
// is keyed on two structural signals, never on a self-reported status enum
// (which the implementer's contract overloads): the `held` array the repair
// returns, and a no-op backstop (a changes_requested verdict over a diff
// whose fingerprint was already reviewed-and-flagged this task means the
// intervening repair changed nothing or cycled back, so re-running quality
// is futile).
// Quality-tier selection (issue #30): the review model scales with the
// independently-measured diff. The spec-reviewer — a separate agent that
// read `git diff HEAD` itself — supplied diff_magnitude and
// touches_contract; a small, non-contract diff gets sonnet/high, because
// consequence-of-a-miss (the opus pin's rationale, agent-template § model)
// scales with diff size and the independent end-verify / mini-verify suite
// gate is the deterministic correctness backstop. Anything larger,
// contract-touching, or unmeasured (missing/implausible fields) keeps
// opus/xhigh — fail conservative. Measured once, before round 0; a repair
// can grow the diff, but bounded (it addresses findings on a small diff),
// so the tier is not re-derived per round.
const QUAL_TIER_MAX_LINES = 25
const smallDiff =
specMeta !== null &&
Number.isInteger(specMeta.diff_magnitude) &&
specMeta.diff_magnitude >= 0 &&
specMeta.diff_magnitude <= QUAL_TIER_MAX_LINES &&
specMeta.touches_contract === false
const qualModel = smallDiff ? 'sonnet' : 'opus'
const qualEffort = smallDiff ? 'high' : 'xhigh'
const qualTier = `${qualModel}/${qualEffort}`
let heldConcerns = []
const seenFingerprints = new Set()
for (let round = 0; ; round++) {
const qual = await agent(
`${STANDING}${ANCHOR}\n\nQuality review of task ${task.id} (spec-compliance is already green — do not re-check it). ` +
'Read every chunk of `git diff HEAD` in this task\'s footprint. Report only Important and Minor issues, ' +
'each with its `severity` classified honestly per your severity definitions; Nits never gate. ' +
'Also report `diff_fingerprint` (REQUIRED): the sha256 hex of the FULL `git diff HEAD` ' +
'(`git diff HEAD | sha256sum` — the whole working-tree diff, NOT narrowed to this task\'s footprint). ' +
'The loop compares it across rounds to detect a no-op/cyclic repair.',
// The loop's last correctness gate before the end-verify (spec-reviewer only
// checks task-text correspondence) — opus by default (real-bug finding is the
// documented opus strength), tiered per the selection above.
{ agentType: 'quality-reviewer', model: qualModel, effort: qualEffort, label: `qual:${task.id}`, phase: 'Per-task loop', schema: QUAL_SCHEMA },
)
if (qual && qual.status === 'approved') break
// No-op / cyclic-repair backstop: a changes_requested verdict over a diff-state already reviewed-and-flagged
// this task means the intervening repair changed nothing (or cycled back to an earlier state) — the
// implementer kept the plan, or rejected the finding. Re-running quality would only re-flag identical code,
// so stop here rather than burning the retry budget down to a false BLOCKED.
// Severity check (issue #29, from the pipeline audit): the early exit is severity-aware. A byte-identical
// diff under a surviving IMPORTANT finding is not a cosmetic hold — an important finding names a defect the
// repair did not address, and letting it ride a green commit as a mere concern is exactly the erosion this
// gate exists to stop → BLOCKED. Only a minor-only (or unreported) finding set is held as a neutral concern:
// a byte-identical diff cannot distinguish a principled plan-hold from an ignored cosmetic finding, so that
// case is labelled neutrally and left for the orchestrator to adjudicate on its Step-3 inspection.
if (qual && qual.status === 'changes_requested' && qual.diff_fingerprint && seenFingerprints.has(qual.diff_fingerprint)) {
const importants = (qual.issues || []).filter((i) => i.severity === 'important')
if (importants.length) {
return {
id: task.id,
title: task.title,
outcome: 'BLOCKED',
reason: 'unresolved important quality finding — the repair left the diff unchanged',
detail: importants.map((i) => i.text).join('; '),
qual_tier: qualTier,
}
}
heldConcerns = (qual.issues && qual.issues.length ? qual.issues.map((i) => `${i.severity}: ${i.text}`) : ['(finding not reported)']).map(
(i) =>
'unresolved quality finding — the repair left the diff unchanged (a kept plan-prescribed element, or a ' +
`finding the implementer rejected); verify by hand on inspection: ${i}`,
)
break
}
// Minor-only valve: after at least one repair dispatch, a changes_requested
// verdict with no Important finding is polish, not a gate. Each round's
// reviewer reads the diff fresh, so late rounds keep surfacing NEW minor
// residue (severity trending down) — chasing it exhausts the retry budget
// and hard-BLOCKs a green task over cosmetics, discarding the iteration's
// remaining tasks. Surface the findings as concerns for the orchestrator's
// Step-3 inspection and close the task instead.
if (
qual &&
qual.status === 'changes_requested' &&
round >= 1 &&
(qual.issues || []).length > 0 &&
qual.issues.every((i) => i.severity === 'minor')
) {
heldConcerns = qual.issues.map(
(i) => `minor-only quality residue (not gating after a repair round; fix on inspection if desired): ${i.text}`,
)
break
}
if (round >= 2) return { id: task.id, title: task.title, outcome: 'BLOCKED', reason: 'review-loop-exhausted (quality)', qual_tier: qualTier }
if (qual && qual.diff_fingerprint) seenFingerprints.add(qual.diff_fingerprint)
const fix = await agent(
`${STANDING}${ANCHOR}\n\nThe quality review of task ${task.id} requested changes. Address EACH Important/Minor issue; ` +
'do not widen scope; keep build + tests green; leave edits unstaged. The task text below is the ratified plan.\n\n' +
'HOLD CLAUSE — most issues you simply fix. But if honouring an issue would force you to deviate from a ' +
'name, signature, or structure THIS TASK prescribes (or to break the downstream callers the plan ' +
'prescribes), do NOT deviate. Two cases:\n' +
' • The finding is cosmetic — keeping the plan leaves the build and tests GREEN (e.g. a name reads ' +
'slightly off but everything compiles and passes). KEEP the plan, fix every other issue, and record each ' +
'kept finding in `held` (finding / kept / why). Do not rename or restructure to chase it.\n' +
' • Honouring the plan would make the code WRONG — it would not build, a test would fail, or a ' +
'prescribed caller would break. That is a real plan-vs-correctness conflict, not a cosmetic hold: report ' +
'status BLOCKED with the conflict in `reason`. Never bury a correctness defect in `held`.\n' +
'A finding whose only remedy is to diverge from the ratified plan must be held or escalated, never chased ' +
'into a deviation the next review flags.\n\n' +
`TASK ${task.id}${task.title}\n${task.text}\n\nISSUES:\n- ` +
(qual?.issues?.length ? qual.issues.map((i) => `[${i.severity}] ${i.text}`) : ['(none reported)']).join('\n- '),
{ agentType: 'implementer', model: 'sonnet', effort: 'high', label: `impl-fix-qual:${task.id}`, phase: 'Per-task loop', schema: IMPL_SCHEMA },
)
if (fix && fix.applied_changes === true) wroteAnyFile = true
if (fix && (fix.status === 'BLOCKED' || fix.status === 'NEEDS_CONTEXT')) {
// The implementer judged a finding a real plan-vs-correctness conflict. Escalate loudly so it is
// adjudicated, rather than letting that judgement ride a green commit as a quiet concern.
return {
id: task.id,
title: task.title,
outcome: 'BLOCKED',
reason: `quality/plan conflict: ${fix.reason || 'honouring a quality finding would break the ratified plan'}`,
qual_tier: qualTier,
}
}
if (fix && fix.held && fix.held.length) {
// The implementer kept plan-prescribed elements against cosmetic findings. Stop chasing — re-running
// quality would only re-flag the unchanged code. Surface the held findings as concerns to weigh.
heldConcerns = fix.held.map(
(h) => `quality finding held against the ratified plan: ${h.finding} — kept ${h.kept}; honouring it would ${h.why}`,
)
break
}
}
// Per-task no-op surfacing (issue #12, facet 1): a task that reports DONE yet
// wrote no file across any dispatch is suspicious — it was either genuinely
// already satisfied, or skipped/misunderstood. The script cannot tell those
// apart (same trust placed in the held/no-op partition above), so it does NOT
// gate on it — it surfaces a neutral concern for the orchestrator's Step-3
// inspection. The iteration-level git file count is the gate, never this
// self-report (issue #12 review: the self-report must not be able to fail a
// run that actually did the work).
const noopConcern = !wroteAnyFile
? [
`task #${task.id} reported ${impl.status} but wrote no file — confirm it was genuinely already ` +
'satisfied by the baseline (or earlier tasks), not skipped or misunderstood',
]
: []
return {
id: task.id,
title: task.title,
outcome: 'DONE',
concerns: [...concerns, ...heldConcerns, ...noopConcern],
qual_tier: qualTier,
}
}
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_NONE}${ANCHOR}\n\nRecord a working-tree snapshot boundary WITHOUT changing anything. Run exactly these three ` +
'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' +
'3. `git rev-parse HEAD` — report it as head_sha.\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) {
// HEAD-aware baseline (issue #32): each boundary's path set is a diff
// AGAINST HEAD, so two boundaries are comparable only when they were
// measured against the SAME HEAD. The sanctioned PARTIAL repair
// (commit the known-good subset, then resume) moves HEAD between the
// replayed boundaries and the fresh ones — comparing across that move
// would report the entire committed subset as "lost" and hard-BLOCK
// the first live task. On a baseline move the guard resets instead of
// comparing, and surfaces the move: the loop itself never commits, so
// a HEAD move inside a single live run means some agent committed
// mid-run — worth eyes either way. A blank/missing sha falls through
// to the comparison — a flaky report must not quietly disarm the guard.
const prevHead = typeof prevBoundary.head === 'string' ? prevBoundary.head.trim() : ''
const nowHead = typeof snap.head_sha === 'string' ? snap.head_sha.trim() : ''
if (prevHead && nowHead && prevHead !== nowHead) {
guardConcerns.push(
`discard-guard baseline moved between task #${prevBoundary.taskId} and task #${task.id} ` +
`(HEAD ${prevHead} -> ${nowHead}) — expected only on a resume after the orchestrator committed a ` +
'known-good subset (the sanctioned PARTIAL repair); in a single live run with no interim commit it ' +
'means an agent moved HEAD mid-run — investigate. The guard reset its baseline; this boundary pair ' +
'went uncompared.',
)
} else {
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 || []), head: snap.head_sha || null }
: null
}
if (r.outcome === 'BLOCKED') break // do not skip ahead — task ordering dependencies are unknown
}
const completed = results.filter((r) => r.outcome === 'DONE')
const blocked = results.find((r) => r.outcome === 'BLOCKED')
let outcome
if (!blocked) outcome = 'DONE'
else if (completed.length > 0) outcome = 'PARTIAL'
else outcome = 'BLOCKED'
// An iteration-level guard (issue #12) may downgrade a DONE outcome to BLOCKED on
// positive-evidence grounds. It runs BEFORE the report assembly so the built
// stats.json content records the final outcome (not a stale DONE), and so E2E
// does not run over a no-op. The verdict is git ground truth, never a per-task self-report — a
// self-report must never be able to FAIL a run that actually did the work. It
// carries its own blocked_detail (no single offending task) and a `noopClean`
// flag: a clean-tree no-op needs no BLOCKED.md (nothing to explain).
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
// git file count is the authoritative no-op gate. Both are git ground truth, the
// implement-loop analogue of compiler-driven-edit's verify agent.
let miniVerify = null
let treeState = null
if (mode === 'mini' && outcome === 'DONE') {
phase('Verify')
miniVerify = await agent(
`${STANDING_FACTS}${ANCHOR}\n\nVerify a bug/feature fix WITHOUT changing any code or test. The fix was meant to drive this ` +
`RED test to GREEN: ${red_test_path}\n\nRun that test by name and confirm it now PASSES (it was RED before the ` +
'fix). Then run the full suite and confirm it is green (no regression). Also report working_tree_dirty — ' +
'whether `git status --porcelain` (NOT `git diff HEAD`, which misses new untracked files) shows any change at ' +
'all (a clean tree means no fix landed). Do NOT edit anything; do NOT commit.',
{ model: 'sonnet', effort: 'medium', label: 'mini-verify', phase: 'Verify', schema: MINI_VERIFY_SCHEMA },
)
if (!miniVerify || !miniVerify.red_test_now_green || !miniVerify.suite_green || !miniVerify.working_tree_dirty) {
outcome = 'BLOCKED'
synthBlock = {
task: null,
reason: !miniVerify
? 'mini-verify agent did not return'
: !miniVerify.working_tree_dirty
? 'no-op fix: the working tree is clean — the RED->GREEN handoff produced no change'
: !miniVerify.red_test_now_green
? 'the handed-off RED test is still red — the fix did not land; route back to debug (RED-first)'
: 'the fix regressed the suite — not behaviour-correct; route back to debug (RED-first)',
detail: miniVerify ? miniVerify.detail || null : null,
}
// a verified-clean tree is a clean no-op (no BLOCKED.md); a still-red/regression
// tree is genuinely dirty (BLOCKED.md explains it). A null agent is infra, not a
// known-clean tree — leave noopClean false so a BLOCKED.md is written if dirty.
if (miniVerify && !miniVerify.working_tree_dirty) noopClean = true
}
} else if (mode === 'standard' && outcome === 'DONE') {
phase('Verify')
treeState = await agent(
`${STANDING_FACTS}${ANCHOR}\n\nEnd-verify an implement iteration WITHOUT changing any code or test. Two independent checks:\n` +
'1. Run `git status --porcelain` and report files_touched = its line count (every changed, added, or ' +
'untracked path). Use `git status --porcelain`, NOT `git diff`, so brand-new untracked files are counted.\n' +
'2. Run the FULL project suite (the test command from the CLAUDE.md project facts) and report suite_green — ' +
'on red, name the failing tests in detail.\n' +
'Do NOT edit, do NOT commit.',
{ model: 'sonnet', effort: 'medium', label: 'verify', phase: 'Verify', schema: STD_VERIFY_SCHEMA },
)
if (!treeState || treeState.files_touched === 0 || !treeState.suite_green) {
outcome = 'BLOCKED'
synthBlock = {
task: null,
reason: !treeState
? 'infra: end-verify agent did not return — cannot confirm the iteration landed; treat as not-done'
: treeState.files_touched === 0
? 'no-op iteration: `git status --porcelain` is empty — every task reported DONE but nothing landed in the working tree'
: 'suite red at end-verify: the iteration regressed the project suite — route back to debug (RED-first)',
detail: treeState && !treeState.suite_green ? treeState.detail || null : null,
}
// a confirmed-empty tree is a clean no-op (no BLOCKED.md); a null agent is infra
// (tree state unknown — possibly dirty), so leave noopClean false. A suite-red
// verdict has a dirty tree by definition — BLOCKED.md is written (by the
// orchestrator, from the end-report).
if (treeState && treeState.files_touched === 0) noopClean = true
}
}
// ---- Phase 3 — E2E coverage (standard mode, only on a full clean run) --
// outcome is DONE only past the no-op gate above, so E2E never runs over a no-op
// and cannot inflate the (already-taken) file count.
let e2e = null
if (mode === 'standard' && outcome === 'DONE') {
phase('E2E coverage')
e2e = await agent(
`${STANDING}${ANCHOR}\n\nThe iteration completed. Identify 13 invariants worth protecting and write the smallest ` +
'E2E fixtures for them in the project\'s canonical fixture form; each test\'s doc comment names the property ' +
'it protects. Run the project\'s test command — all green. Leave edits unstaged; never commit.\n\n' +
`iter_id: ${iter_id}\nshipped: ${completed.map((c) => `#${c.id} ${c.title}`).join('; ')}`,
{ agentType: 'tester', model: 'sonnet', effort: 'high', label: 'e2e', phase: 'E2E coverage', schema: E2E_SCHEMA },
)
}
// E2E status is load-bearing (issue #12, facet 3): the phase was previously
// dispatched but only its `fixtures` were read, so a tester that returned a
// non-DONE status or wrote zero fixtures silently left the iteration reading
// DONE with no protecting test. Surface it as a concern (never a gate — the
// coverage gap is the orchestrator's call, not a reason to fail a green run).
const e2eConcerns = []
if (e2e) {
if (e2e.status && e2e.status !== 'DONE') {
e2eConcerns.push(
`E2E coverage did not complete cleanly (status ${e2e.status})` +
(e2e.note ? `: ${e2e.note}` : '') +
" — verify the iteration's invariants are actually protected",
)
} else if (!(e2e.fixtures && e2e.fixtures.length)) {
e2eConcerns.push('E2E coverage reported DONE but added no fixtures — the iteration shipped without a protecting test')
}
}
// ---- Report assembly (issue #29): the script BUILDS the artefact contents —
// no finalize dispatch. The former `finalize` agent only templated the
// already-computed aggregate into two files (~41k real tokens per run); the
// script has no filesystem access, so the templates now live HERE (single
// source) and the END-REPORT carries the finished contents. The orchestrator
// writes them byte-identical, filling exactly the two runtime slots it owns
// anyway: {{DATE}} (`date +%F`) and, in BLOCKED.md, {{FILES_TOUCHED}}
// (`git status --porcelain` — its own Step-3/4 inspection duty).
// A clean-tree no-op (mini or standard) gets NO BLOCKED.md (issue #12 /
// consistency): there is no dirty tree to explain, so the status + reason ride
// the end-report. `noopClean` was set by whichever no-op gate fired above.
const writeBlockedMd = outcome !== 'DONE' && !noopClean
const aggregate = {
iter_id,
mode,
started_from: startSha,
outcome,
tasks_total: tasks.length,
tasks_completed: completed.length,
// qual_tier (issue #30): which review tier each task's quality gate ran at
// (sonnet/high for a small non-contract diff, opus/xhigh otherwise; null
// when the task never reached the quality gate) — logged so the tiering
// split stays auditable per iteration.
task_summaries: results.map((r) => ({ id: r.id, title: r.title, outcome: r.outcome, qual_tier: r.qual_tier || null })),
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 statsJson =
JSON.stringify(
{
iter_id,
date: '{{DATE}}',
mode,
outcome,
tasks_total: tasks.length,
tasks_completed: completed.length,
blocked_reason: aggregate.blocked_detail ? aggregate.blocked_detail.reason : null,
},
null,
2,
) + '\n'
const blockedMd = writeBlockedMd
? [
`# BLOCKED — iter ${iter_id}`,
'',
'Date: {{DATE}}',
`Started-from: ${startSha}`,
`Status: ${outcome}`,
`Tasks-completed: ${completed.length}/${tasks.length}`,
'',
'## What ran',
...(completed.length ? completed.map((c) => `- #${c.id} ${c.title}`) : ['- (none)']),
'',
'## What did not',
...(aggregate.blocked_detail
? [
`- ${aggregate.blocked_detail.task != null ? `task #${aggregate.blocked_detail.task}: ` : ''}${aggregate.blocked_detail.reason}`,
...(aggregate.blocked_detail.detail ? [`- detail: ${aggregate.blocked_detail.detail}`] : []),
]
: ['- (no blocked detail recorded)']),
'',
'## Concerns',
...(aggregate.concerns.length ? aggregate.concerns.map((c) => `- ${c}`) : ['- (none)']),
'',
'## Files touched',
'{{FILES_TOUCHED}}',
'',
].join('\n')
: null
// files_touched comes from the standard-mode end-verify (git status
// --porcelain, untracked-aware); it is null for mini / PARTIAL / BLOCKED,
// where the orchestrator inspects the tree itself.
// ---- End-report — the orchestrator reads this, WRITES the artifacts
// (artifacts.stats_json to the stats path; artifacts.blocked_md — when
// non-null — verbatim to BLOCKED.md at the repo root, filling {{DATE}} and
// {{FILES_TOUCHED}}), inspects the tree, and commits.
return {
status: outcome,
iter_id,
mode,
started_from: startSha,
tasks_total: tasks.length,
tasks_completed: completed.length,
task_summaries: aggregate.task_summaries,
concerns: aggregate.concerns,
e2e_fixtures: e2e ? e2e.fixtures || [] : [],
artifacts: {
stats_json: statsJson,
stats_path_hint: `/tmp/iter-${iter_id}/stats.json (or the project stats dir if its CLAUDE.md facts declare one)`,
blocked_md: blockedMd,
},
files_touched: treeState ? treeState.files_touched : null,
blocked_detail: aggregate.blocked_detail,
}