// 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, // the stats file, and (on PARTIAL/BLOCKED) BLOCKED.md live in the // working tree as unstaged changes; the orchestrator that invoked // this workflow inspects and commits. // • 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 -- ` cannot // discard them). // • The script has no filesystem or shell access of its own — every // working-tree mutation (code, stats.json, BLOCKED.md) happens inside // an agent() call. // • 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, finalize) 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' // 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, tests, stats.json, and (on PARTIAL/BLOCKED) BLOCKED.md to the working tree as unstaged changes; never commits, never moves main HEAD.', phases: [ { title: 'Preflight' }, { title: 'Per-task loop' }, { title: 'Verify' }, { title: 'E2E coverage' }, { title: 'Report' }, ], } // 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: ['total_tasks', 'task_ids'], properties: { 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: ': — 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/finalize, so neither E2E fixtures nor the stats/BLOCKED.md // artefacts can inflate the count. 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 "