Files
Skills/implement/workflows/implement-loop.js
T
Brummel 7a58a530b1 feat(pipeline): route to the lightest correct methodology; move execution loops onto the Workflow substrate
The selector forced every task through the heaviest methodology's
critical path: a behaviour-preserving, type-enumerable change paid the
same specify -> planner -> implement front-half as a novel feature,
because it was neither new behaviour (tdd) nor an observed bug (debug)
and so fell to specify by elimination. Two coupled defects — a selector
with no verification axis, and an all-or-nothing executor — kept the
existing lighter path unreachable and uneconomical. This fixes both.

Part A — verification-keyed selector (boss/SKILL.md):
- Replace the three-way "design line" with an ordered cascade that adds
  a verification/enumeration axis ahead of the settled-vs-fork question.
  Each lighter arm carries a positive trigger matched by signature, not
  reached by elimination.
- New `compiler-driven` arm: a type/signature edit at a definition site
  that propagates mechanically. Observe-then-bounce — make the edit,
  build, run the suite; clean build AND suite green unchanged commits;
  a hole bounces up (specify for a design choice, tdd for discovered
  test-specifiable new behaviour); a regression bounces to debug.
- The observed-bug RED-first gate is first in the cascade, so a
  mechanical-looking fix cannot bypass it.
- The straddle rule ("add an enum variant") is codified as a rule:
  mechanical/forwarding -> compiler-driven; encodes new behaviour ->
  tdd/spec; doubt routes up.
- The executor is the elevated inline carve-out plus a shipped workflow,
  not a heavy new skill ("the largest concrete win is small").

Part B — Workflow substrate (implement/workflows/):
- implement-loop.js: the per-task loop as a deterministic script. Each
  phase (implementer -> spec-compliance -> quality, + tester for E2E) is
  a separate top-level agent() call, so a single phase is independently
  invokable and inter-phase aggregation/re-loop is code. Retires the
  implement-orchestrator agent's inline-role-switch workaround (the four
  phase agents survive as the agent-types the script dispatches).
- compiler-driven-edit.js: the observe-then-bounce loop.
- install.sh / uninstall.sh symlink shipped workflows into
  ~/.claude/workflows/.
- specify and brainstorm stay prose + interactive (human-intent oracle);
  only the autonomous/mechanical loops moved. try-and-error is deferred.

Docs (pipeline taxonomy, design, agent-template, migration, README) and
all selector<->executor cross-references updated; the arm and its
executor are co-located so a future re-route through the full loop is a
visible regression.

Verified by an adversarial multi-agent pass: PASS on all six acceptance
criteria; two coherence concerns fixed. The shipped scripts are
syntax-validated but exercised only in a downstream target project (the
skills repo is not itself a pipeline target).

closes #7
2026-06-17 12:27:51 +02:00

328 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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.
// • 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.
//
// 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) 12 sentences (debugger cause, or tdd spec_summary)
// constraint: (mini) e.g. 'minimal fix, no surrounding cleanup'
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: 'E2E coverage' },
{ title: 'Report' },
],
}
const {
mode = 'standard',
iter_id,
plan_path,
task_range = null,
red_test_path,
cause_summary,
constraint = 'minimal change, no surrounding cleanup',
} = args || {}
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' } },
},
}
const TASKS_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' },
},
},
},
},
}
const IMPL_SCHEMA = {
type: 'object',
additionalProperties: false,
required: ['status', 'summary'],
properties: {
status: { type: 'string', enum: ['DONE', 'DONE_WITH_CONCERNS', 'NEEDS_CONTEXT', 'BLOCKED'] },
summary: { type: 'string', description: 'one line: what changed (paths + functions)' },
concerns: { type: 'array', items: { type: 'string' } },
reason: { type: 'string', description: 'on BLOCKED/NEEDS_CONTEXT: the blocker' },
},
}
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'],
properties: {
status: { type: 'string', enum: ['approved', 'changes_requested', 'infra_blocked'] },
issues: { type: 'array', items: { type: 'string' }, description: 'Important + Minor only (Nits never gate)' },
},
}
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' },
},
}
const FINALIZE_SCHEMA = {
type: 'object',
additionalProperties: false,
required: ['stats_path', 'wrote_blocked_md'],
properties: {
stats_path: { type: 'string' },
wrote_blocked_md: { type: 'boolean' },
files_touched: { type: 'integer' },
},
}
// ---- Phase 0 — clean-tree preflight ------------------------------------
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.',
{ 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 || [],
}
}
const 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 {
const extracted = await agent(
`${STANDING}\n\nRead the plan at ${plan_path}. Extract ` +
(task_range ? `tasks ${task_range[0]}..${task_range[1]} (inclusive, 1-based)` : 'every task') +
', each as its VERBATIM block with the task id and a one-line title. Do not summarise or reorder. Do not edit anything.',
{ label: 'plan-extract', phase: 'Per-task loop', schema: TASKS_SCHEMA },
)
if (!extracted || !extracted.tasks || extracted.tasks.length === 0) {
return { status: 'NEEDS_CONTEXT', iter_id, reason: `no tasks extracted from ${plan_path}` }
}
tasks = extracted.tasks
}
// ---- 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', 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 || []
// 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. ' +
`Focus on the files this task claims to touch.\n\nTASK ${task.id}${task.title}\n${task.text}`,
{ agentType: 'spec-reviewer', 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
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', label: `impl-fix-spec:${task.id}`, phase: 'Per-task loop', schema: IMPL_SCHEMA },
)
}
// 2.3 — quality check, with repair re-loop
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; ' +
'Nits never gate.',
{ agentType: 'quality-reviewer', label: `qual:${task.id}`, phase: 'Per-task loop', schema: QUAL_SCHEMA },
)
if (qual && qual.status === 'approved') break
if (round >= 2) return { id: task.id, title: task.title, outcome: 'BLOCKED', reason: 'review-loop-exhausted (quality)' }
await agent(
`${STANDING}\n\nFix the quality issues on task ${task.id}. Address EACH Important/Minor issue; do not widen ` +
'scope. Keep build + tests green; leave edits unstaged.\n\nISSUES:\n- ' + (qual?.issues || ['(none reported)']).join('\n- '),
{ agentType: 'implementer', label: `impl-fix-qual:${task.id}`, phase: 'Per-task loop', schema: IMPL_SCHEMA },
)
}
return { id: task.id, title: task.title, outcome: 'DONE', concerns }
}
const results = []
for (const task of tasks) {
const r = await runTask(task)
results.push(r)
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'
// ---- Phase 3 — E2E coverage (standard mode, only on a full clean run) --
let e2e = null
if (mode === 'standard' && outcome === 'DONE') {
phase('E2E coverage')
e2e = await agent(
`${STANDING}\n\nThe iteration completed. Identify 13 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', label: 'e2e', phase: 'E2E coverage', schema: E2E_SCHEMA },
)
}
// ---- Phase 4/5 — finalize: stats.json always, BLOCKED.md on non-DONE ----
phase('Report')
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 || []),
blocked_detail: 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' +
(outcome === 'DONE'
? '2. Do NOT write BLOCKED.md — this run is DONE.\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 diff --name-only HEAD`).\n') +
'Report the stats path, whether you wrote BLOCKED.md, and the count from `git diff --name-only HEAD | wc -l`. ' +
'Do NOT commit anything.\n\nAGGREGATE:\n' + JSON.stringify(aggregate, null, 2),
{ label: 'finalize', phase: 'Report', schema: FINALIZE_SCHEMA },
)
// ---- End-report — the orchestrator reads this, inspects the tree, 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 || [] : [],
stats_path: fin ? fin.stats_path : null,
blocked_md: fin && fin.wrote_blocked_md ? 'BLOCKED.md (uncommitted)' : null,
files_touched: fin ? fin.files_touched : null,
blocked_detail: aggregate.blocked_detail,
}