7a58a530b1
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
328 lines
15 KiB
JavaScript
328 lines
15 KiB
JavaScript
// 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) 1–2 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 1–3 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,
|
||
}
|