615a76eb01
The finalize agent only templated the script's already-computed
aggregate into stats.json / BLOCKED.md — ~41k real tokens per run
(197 dispatches / 8.0M in the 206-run corpus) for pure formatting.
The templates now live in the script (single source): the end-report
carries artifacts.stats_json and artifacts.blocked_md (null on DONE
and clean no-ops), and the orchestrator writes them byte-identical,
filling exactly two runtime slots it owns anyway — {{DATE}} (date +%F)
and {{FILES_TOUCHED}} (git status --porcelain, its Step-3/4 inspection
duty). File paths, consumers (tdd decompose reads BLOCKED.md,
postmortem parses it), and content shape are unchanged; the
agent-authored 'suggested next step' line is dropped — blocked_detail
carries the diagnosis.
refs #29
1058 lines
56 KiB
JavaScript
1058 lines
56 KiB
JavaScript
// 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.
|
||
// Either way the 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).
|
||
// • 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 — banned for all plugin agents and workflows). Mechanical and
|
||
// in-loop steps run sonnet; the quality gate is the one deliberate opus
|
||
// call (last correctness check before a task is marked DONE).
|
||
// • 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, snapshot,
|
||
// mini-verify, tree-check) 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
|
||
// 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) 1–2 sentences (debugger cause, or tdd spec_summary)
|
||
// constraint: (mini) e.g. 'minimal fix, no surrounding cleanup'
|
||
// 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,
|
||
task_range = null,
|
||
red_test_path,
|
||
cause_summary,
|
||
constraint = 'minimal change, no surrounding cleanup',
|
||
} = 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 }
|
||
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.'
|
||
|
||
// 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'],
|
||
properties: {
|
||
clean: { type: 'boolean', description: '`git status --porcelain` produced no output' },
|
||
head_sha: { type: 'string' },
|
||
branch: { type: 'string' },
|
||
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' },
|
||
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'],
|
||
properties: {
|
||
id: { type: 'integer' },
|
||
title: { type: 'string', description: 'one-line title ONLY — never the task body' },
|
||
},
|
||
},
|
||
},
|
||
},
|
||
}
|
||
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'],
|
||
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' },
|
||
},
|
||
}
|
||
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 no-op gate (issue #12, facets 1+2): the authoritative ground
|
||
// truth that the iteration's per-task work actually landed. 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). Uses `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). A self-report can never reach
|
||
// this verdict; it is git ground truth.
|
||
const TREE_SCHEMA = {
|
||
type: 'object',
|
||
additionalProperties: false,
|
||
required: ['files_touched'],
|
||
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.',
|
||
},
|
||
},
|
||
}
|
||
// 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)',
|
||
},
|
||
},
|
||
}
|
||
// ---- 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}\n\nYou are the clean-tree preflight for an implement iteration. ` +
|
||
'Run `git status --porcelain`, `git rev-parse HEAD`, and `git rev-parse --abbrev-ref HEAD`. ' +
|
||
'Report whether the tree is clean (no porcelain output), the HEAD sha, the branch, 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
|
||
}
|
||
|
||
// ---- 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}\n\nFIRST the folded clean-tree preflight: run \`git status --porcelain\`, \`git rev-parse HEAD\`, ` +
|
||
'and `git rev-parse --abbrev-ref HEAD`; report `clean` (no porcelain output), `head_sha`, `branch`, 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.',
|
||
{ 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 (!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
|
||
}
|
||
|
||
// Per-task verbatim extraction — read-only and order-independent, so fan out
|
||
// via parallel(). The requested id is captured 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 extractedTasks = await parallel(
|
||
expectedIds.map((id) => () =>
|
||
agent(
|
||
`${STANDING}\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.',
|
||
{ 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}\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
|
||
for (let round = 0; ; round++) {
|
||
const spec = await agent(
|
||
`${STANDING}\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. ' +
|
||
`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') 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}\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).
|
||
let heldConcerns = []
|
||
const seenFingerprints = new Set()
|
||
for (let round = 0; ; round++) {
|
||
const qual = await agent(
|
||
`${STANDING}\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.',
|
||
// opus by design: the loop's last correctness gate (spec-reviewer only checks
|
||
// task-text correspondence) — real-bug finding is the documented opus strength.
|
||
{ agentType: 'quality-reviewer', model: 'opus', effort: 'xhigh', 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. A byte-identical diff cannot
|
||
// distinguish a principled plan-hold from an ignored finding, so the concern 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)) {
|
||
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)' }
|
||
if (qual && qual.diff_fingerprint) seenFingerprints.add(qual.diff_fingerprint)
|
||
const fix = await agent(
|
||
`${STANDING}\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'}`,
|
||
}
|
||
}
|
||
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],
|
||
}
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
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}\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}\n\nReport the working-tree footprint of an implement iteration WITHOUT changing anything. 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. Do NOT edit, ' +
|
||
'do NOT commit.',
|
||
{ model: 'sonnet', effort: 'medium', label: 'tree-check', phase: 'Verify', schema: TREE_SCHEMA },
|
||
)
|
||
if (!treeState || treeState.files_touched === 0) {
|
||
outcome = 'BLOCKED'
|
||
synthBlock = {
|
||
task: null,
|
||
reason: !treeState
|
||
? 'infra: tree-check agent did not return — cannot confirm the iteration landed; treat as not-done'
|
||
: 'no-op iteration: `git status --porcelain` is empty — every task reported DONE but nothing landed in the working tree',
|
||
detail: 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.
|
||
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}\n\nThe iteration completed. Identify 1–3 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,
|
||
task_summaries: results.map((r) => ({ id: r.id, title: r.title, outcome: r.outcome })),
|
||
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,
|
||
}
|