// 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 -- ` 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 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-' / 'feat-' (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' // 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, 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 } 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.' // 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: ': — 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'], properties: { snapshot_sha: { type: 'string', description: 'the sha printed by `git stash create "