feat(implement-loop): retire the finalize dispatch; the end-report carries the artifacts

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
This commit is contained in:
2026-07-17 15:29:12 +02:00
parent 8272077e9b
commit 615a76eb01
3 changed files with 119 additions and 73 deletions
+5 -3
View File
@@ -186,9 +186,11 @@ orchestrates from the top level, so the per-task phases are real,
independently-invokable agent calls; this retired the former
`implement-orchestrator` agent's inline-role-switch workaround
(the four phase agents survive as the agent-types the script
dispatches). Writes code, tests, and stats files directly in the
working tree as unstaged changes; never commits. On `PARTIAL` or
`BLOCKED`, also writes `BLOCKED.md` at the repo root.
dispatches). Writes code and tests directly in the working tree as
unstaged changes; never commits. The end-report carries the finished
stats.json and — on `PARTIAL`/`BLOCKED``BLOCKED.md` contents, which
the orchestrator writes (issue #29 retired the finalize dispatch that
only templated the script's own aggregate).
### compiler-driven
+34 -20
View File
@@ -1,6 +1,6 @@
---
name: implement
description: Use when an implementation plan exists under docs/plans and is ready to execute, OR when a debug/tdd RED-test is handed off for the GREEN side. Runs the `implement-loop` Workflow — a deterministic script that executes the per-task loop (implementer → spec-compliance → quality, each a separate agent call) and aggregates in code, writing edits, tests, and a stats file to the working tree without committing (and `BLOCKED.md` on PARTIAL/BLOCKED). The orchestrator reads the end-report, inspects the working tree, decides commit shape, and performs all commits. Also documents the `compiler-driven` light-edit arm.
description: Use when an implementation plan exists under docs/plans and is ready to execute, OR when a debug/tdd RED-test is handed off for the GREEN side. Runs the `implement-loop` Workflow — a deterministic script that executes the per-task loop (implementer → spec-compliance → quality, each a separate agent call) and aggregates in code, writing edits and tests to the working tree without committing. The orchestrator reads the end-report, writes the stats file (and `BLOCKED.md` on PARTIAL/BLOCKED) from the contents the end-report carries, inspects the working tree, decides commit shape, and performs all commits. Also documents the `compiler-driven` light-edit arm.
---
# implement — plan execution on the Workflow substrate
@@ -30,11 +30,17 @@ real build/test outcome). The four phase agents survive unchanged as the
agent-types the script dispatches; only the dispatch/aggregation prose
the orchestrator-agent carried is gone.
All work lives in the working tree: code edits, the stats file, and —
on PARTIAL/BLOCKED — `BLOCKED.md` at the repo root. The script **never
All code edits live in the working tree. The stats file and — on
PARTIAL/BLOCKED — `BLOCKED.md` are **built by the script and carried in
the end-report** (`artifacts.stats_json` / `artifacts.blocked_md`): the
script has no filesystem access of its own, and dispatching an agent
just to template the script's own aggregate cost ~41k real tokens per
run (issue #29). The orchestrator writes them byte-identical, filling
the two runtime slots it owns anyway — `{{DATE}}` (`date +%F`) and
`{{FILES_TOUCHED}}` (`git status --porcelain`). The script **never
commits** and never moves main HEAD. The orchestrator reads one
end-report (the workflow's return value), inspects the unstaged tree,
and decides commit shape.
end-report (the workflow's return value), writes the artifacts,
inspects the unstaged tree, and decides commit shape.
## When to Use / Skipping
@@ -99,13 +105,13 @@ regression around the RED-first gate.
## The Iron Law
```
THE LOOP NEVER COMMITS. CODE EDITS, TESTS, AND THE STATS FILE LIVE IN THE WORKING TREE AS UNSTAGED CHANGES UNTIL THE ORCHESTRATOR COMMITS THEM.
THE LOOP NEVER COMMITS. CODE EDITS AND TESTS LIVE IN THE WORKING TREE AS UNSTAGED CHANGES UNTIL THE ORCHESTRATOR COMMITS THEM; THE STATS FILE AND BLOCKED.md ARE WRITTEN BY THE ORCHESTRATOR FROM THE CONTENTS THE END-REPORT CARRIES.
WITHIN THIS LOOP, MAIN HEAD IS SACROSANCT — NO RESET, NO REVERT; THE LOOP ONLY EVER WRITES UNSTAGED CHANGES. MAIN MOVES FORWARD ONLY VIA ORCHESTRATOR COMMITS. (THE `/boss` ROLLBACK SANDBOX WINDS BACK ALREADY-COMMITTED AUTONOMOUS WORK — A BOSS-LEVEL RECOVERY, NOT A STEP IN THIS LOOP.)
NO IN-LOOP AGENT EVER DISCARDS WORKING-TREE STATE VIA `git checkout` / `git restore` — WHOLE-FILE OR `--patch`: NOTHING COMMITS BETWEEN TASKS, SO A SHARED FILE CARRIES EARLIER TASKS' UNCOMMITTED DONE WORK, AND A CHECKOUT SILENTLY DESTROYS IT (issue #23). RECOVERY IS FORWARD — EDIT BACK, OR REPORT `BLOCKED`. (THE ORCHESTRATOR'S OWN END-OF-ITERATION `git checkout -- .` DISPOSITION HAPPENS OUTSIDE THE LOOP.)
PER-TASK PHASES ARE SEPARATE AGENT CALLS IN THE WORKFLOW SCRIPT — implementer, then spec-compliance, then quality. SPEC COMPLIANCE IS GATED BEFORE QUALITY.
TASKS RUN SEQUENTIALLY; A BLOCKED TASK STOPS THE LOOP — NO SKIP-AHEAD (TASK ORDERING DEPENDENCIES ARE UNKNOWN).
NEVER PUSH PAST `BLOCKED` BY HAND.
ON `PARTIAL` OR `BLOCKED`, THE SCRIPT WRITES `BLOCKED.md` AT THE REPO ROOT — UNCOMMITTED BY CONVENTION. ON `DONE`, NO SEPARATE FILE. (EXCEPTIONS THAT WRITE NO `BLOCKED.md`: A NO-OP ITERATION — ITS TREE IS ALREADY CLEAN — AND ANY PRE-LOOP EARLY EXIT (MALFORMED CARRIER, DIRTY-TREE PREFLIGHT, PLAN-EXTRACT GUARD): NOTHING RAN AND NOTHING WAS WRITTEN, SO THE STATUS + `infra:`-PREFIXED REASON RIDE THE END-REPORT AND THERE IS NOTHING TO CLEAN UP OR EXPLAIN.)
ON `PARTIAL` OR `BLOCKED`, THE END-REPORT CARRIES THE `BLOCKED.md` CONTENT (`artifacts.blocked_md`); THE ORCHESTRATOR'S FIRST ACTION ON READING SUCH A REPORT IS WRITING IT VERBATIM TO `BLOCKED.md` AT THE REPO ROOT — UNCOMMITTED BY CONVENTION. ON `DONE`, `artifacts.blocked_md` IS NULL. (EXCEPTIONS WHERE IT IS ALSO NULL: A NO-OP ITERATION — ITS TREE IS ALREADY CLEAN — AND ANY PRE-LOOP EARLY EXIT (MALFORMED CARRIER, DIRTY-TREE PREFLIGHT, PLAN-EXTRACT GUARD): NOTHING RAN AND NOTHING WAS WRITTEN, SO THE STATUS + `infra:`-PREFIXED REASON RIDE THE END-REPORT AND THERE IS NOTHING TO CLEAN UP OR EXPLAIN.)
`DONE` REQUIRES POSITIVE EVIDENCE THAT AN EDIT LANDED — A NO-OP ITERATION (`git status --porcelain` EMPTY, untracked files INCLUDED) IS NEVER `DONE`, IT IS `BLOCKED` (the issue #11 vacuous-green shape, guarded on the main path). THE VERDICT IS THE GIT TREE COUNT, NEVER A SELF-REPORT.
IN MINI MODE THE RED->GREEN IS OBSERVED, NOT ASSERTED: AN INDEPENDENT VERIFY RE-RUNS THE HANDED-OFF RED TEST AND THE SUITE; A STILL-RED TEST OR A REGRESSION IS `BLOCKED` (route back to debug, RED-first).
THE COMPILER-DRIVEN ARM COMMITS ONLY ON CLEAN BUILD + SUITE GREEN UNCHANGED; ELSE IT BOUNCES (specify for a design hole, tdd for discovered new behaviour, debug for a regression).
@@ -227,20 +233,28 @@ worktree branch).
The workflow returns a small structured end-report (its return value):
`status`, `iter_id`, `started_from`, `tasks_total` / `tasks_completed`,
per-task summaries, concerns, E2E fixtures, the stats path, and — on
per-task summaries, concerns, E2E fixtures, the `artifacts` block
(`stats_json`, `stats_path_hint`, `blocked_md`), and — on
PARTIAL/BLOCKED — the blocked detail. Per-task chatter stayed inside
the workflow's agent contexts and never reached the orchestrator. Read
the end-report; it is the per-task summary you build the commit body
from. An **infra early-exit** (malformed or missing carrier, dirty-tree
from. **First action, before anything else:** materialise the
artifacts — write `artifacts.stats_json` to the stats path (fill
`{{DATE}}` with `date +%F`), and when `artifacts.blocked_md` is
non-null write it verbatim to `BLOCKED.md` at the repo root (fill
`{{DATE}}` and `{{FILES_TOUCHED}}` from `git status --porcelain`). The
contents are the script's single-sourced templates — do not rephrase,
restructure, or embellish them. An **infra early-exit** (malformed or missing carrier, dirty-tree
preflight, a plan-extract guard) returns a minimal report instead:
`status: BLOCKED` plus an `infra:`-prefixed `reason`, no `blocked_detail`
and none of the per-task fields — nothing ran.
### Step 3 — Orchestrator inspect + commit step (on DONE)
The workflow returns with code edits and the stats file sitting in the
working tree as unstaged changes. Nothing is committed yet, and there
is no `BLOCKED.md` (DONE never writes one).
The workflow returns with code edits sitting in the working tree as
unstaged changes; the stats file was just written by you from
`artifacts.stats_json` (Step 2). Nothing is committed yet, and there
is no `BLOCKED.md` (`artifacts.blocked_md` is null on DONE).
1. Inspect: `git status` and `git diff` — confirm the changes match
what the end-report claims. The end-report is the per-task summary;
@@ -268,16 +282,16 @@ is no `BLOCKED.md` (DONE never writes one).
### Step 4 — Orchestrator handling (on PARTIAL or BLOCKED)
The workflow returns with whatever work-in-progress it managed plus
`BLOCKED.md` at the repo root carrying the diagnostic. Nothing is
committed. The orchestrator decides what to do with the dirty working
tree:
The workflow returns with whatever work-in-progress it managed;
`BLOCKED.md` at the repo root carries the diagnostic — written by you
in Step 2 from `artifacts.blocked_md`. Nothing is committed. The
orchestrator decides what to do with the dirty working tree:
0. **No-op `BLOCKED` special case** — if the end-report's status is
`BLOCKED` with `blocked_md` null and a no-op `blocked_detail` (the reason
names a no-op iteration or a clean-tree no-op fix; `files_touched` is 0 in
standard mode, null in mini), there is **no `BLOCKED.md`** and the tree is
already clean. The diagnostic is in the end-report's `blocked_detail`, not
`BLOCKED` with `artifacts.blocked_md` null and a no-op `blocked_detail`
(the reason names a no-op iteration or a clean-tree no-op fix;
`files_touched` is 0 in standard mode, null in mini), there is
**no `BLOCKED.md`** and the tree is already clean. The diagnostic is in the end-report's `blocked_detail`, not
a file. Nothing to clean up or stash; re-plan or re-run from the same
clean tree.
The same no-file shape occurs for an **infra early-exit** (`reason`
+80 -50
View File
@@ -15,10 +15,12 @@
// 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.
// • 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.
@@ -70,8 +72,9 @@
// files sit outside the threat model (`git checkout -- <path>` cannot
// discard them).
// • The script has no filesystem or shell access of its own — every
// working-tree mutation (code, stats.json, BLOCKED.md) happens inside
// an agent() call.
// 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
@@ -81,7 +84,7 @@
// 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
// mini-verify, tree-check) run medium. See
// docs/agent-template.md § effort.
//
// Carrier — passed as the Workflow `args` object by the orchestrator:
@@ -98,13 +101,12 @@
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.',
'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' },
{ title: 'Report' },
],
}
@@ -360,8 +362,9 @@ const MINI_VERIFY_SCHEMA = {
}
// 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
// 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.
@@ -403,16 +406,6 @@ const SNAP_SCHEMA = {
},
},
}
const FINALIZE_SCHEMA = {
type: 'object',
additionalProperties: false,
required: ['stats_path', 'wrote_blocked_md'],
properties: {
stats_path: { type: 'string' },
wrote_blocked_md: { type: 'boolean' },
},
}
// ---- 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
@@ -840,9 +833,9 @@ 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 finalize so the persisted stats.json
// 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
// 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).
@@ -964,8 +957,14 @@ if (e2e) {
}
}
// ---- Phase 4/5 — finalize: stats.json always, BLOCKED.md on non-DONE ----
phase('Report')
// ---- 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.
@@ -986,30 +985,58 @@ const aggregate = {
synthBlock || (blocked ? { task: blocked.id, reason: blocked.reason, detail: blocked.detail || null } : null),
}
const fin = await agent(
`${STANDING}\n\nFinalize an implement iteration. Using the aggregate below:\n` +
'1. Write a stats file at `/tmp/iter-' + iter_id + '/stats.json` (or under the project stats dir if its facts ' +
'declare one) containing: iter_id, date (from `date +%F`), mode, outcome, tasks_total, tasks_completed, and ' +
'blocked_reason (or null).\n' +
(!writeBlockedMd
? '2. Do NOT write BLOCKED.md — ' +
(outcome === 'DONE' ? 'this run is DONE.\n' : 'this is a clean-tree no-op; there is nothing to explain.\n')
: '2. Write `BLOCKED.md` at the repo ROOT (uncommitted by convention; never staged). Sections: a `# BLOCKED — ' +
'iter ' + iter_id + '` header with Date/Started-from/Status/Tasks-completed, `## What ran` (one line per DONE ' +
'task), `## What did not` (the blocked task, its reason, and a one-line suggested next step), `## Concerns`, ' +
'and `## Files touched` (from `git status --porcelain`).\n') +
'Report the stats path and whether you wrote BLOCKED.md. Do NOT commit anything.\n\nAGGREGATE:\n' +
JSON.stringify(aggregate, null, 2),
{ model: 'sonnet', effort: 'medium', label: 'finalize', phase: 'Report', schema: FINALIZE_SCHEMA },
)
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'
// The no-op verdict was already taken before finalize (the tree-check / mini-verify
// gates set outcome + synthBlock + aggregate.blocked_detail), so the end-report
// just relays the final outcome. files_touched comes from the standard-mode
// tree-check (git status --porcelain, untracked-aware); it is null for mini /
// PARTIAL / BLOCKED, where the orchestrator reads BLOCKED.md and inspects the tree.
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
// ---- End-report — the orchestrator reads this, inspects the tree, commits.
// 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,
@@ -1020,8 +1047,11 @@ return {
task_summaries: aggregate.task_summaries,
concerns: aggregate.concerns,
e2e_fixtures: e2e ? e2e.fixtures || [] : [],
stats_path: fin ? fin.stats_path : null,
blocked_md: fin && fin.wrote_blocked_md ? 'BLOCKED.md (uncommitted)' : null,
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,
}