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
+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,
}