cohorte 2.6.0 → 2.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +110 -0
- package/README.md +11 -9
- package/bin/cli.js +15 -2
- package/core/agents/review.md +4 -2
- package/core/commands/cohorte-build.md +3 -1
- package/core/commands/cohorte-doctor.md +27 -4
- package/core/commands/cohorte-fix.md +6 -5
- package/core/commands/cohorte-review.md +32 -22
- package/core/commands/cohorte-ship.md +2 -1
- package/core/commands/cohorte-spec.md +1 -1
- package/core/hooks/gate.py +10 -4
- package/core/runtimes/claude.json +1 -0
- package/core/runtimes/codex.json +1 -0
- package/core/runtimes/cursor.json +1 -0
- package/core/runtimes/gemini.json +1 -0
- package/core/runtimes/opencode.json +1 -0
- package/core/templates/design-brief.md +12 -3
- package/core/workflows/audit.js +20 -5
- package/core/workflows/loop.js +617 -0
- package/core/workflows/refactor.js +21 -8
- package/core/workflows/review.js +81 -12
- package/dashboard/dist/assets/{index-D1rsbLat.js → index-DO3_nq2Q.js} +1 -1
- package/dashboard/dist/index.html +1 -1
- package/dashboard/server/doctor.js +11 -3
- package/dashboard/server/index.js +6 -1
- package/dashboard/server/kanban.js +15 -4
- package/dashboard/server/runtime.js +20 -1
- package/install.ps1 +16 -333
- package/install.sh +27 -297
- package/package.json +1 -1
- package/profile/SCHEMA.md +34 -10
- package/profile/cohorte.config.template.yaml +1 -1
- package/scripts/kanban-move.sh +15 -5
- package/scripts/metrics/prices.json +6 -3
- package/scripts/new-feature.sh.template +8 -1
- package/scripts/preflight.sh +10 -2
- package/scripts/remove-feature.sh.template +3 -1
- package/scripts/test-dashboard.mjs +42 -1
- package/scripts/test-gate.mjs +6 -0
- package/scripts/test-metrics.mjs +2 -2
- package/scripts/test-workflows.mjs +386 -6
- package/scripts/validate-core.mjs +64 -31
|
@@ -0,0 +1,617 @@
|
|
|
1
|
+
// cohorte — /cohorte-loop: build → review → [fix → review]* for ONE feature, unattended.
|
|
2
|
+
//
|
|
3
|
+
// WORKFLOW-ONLY, on purpose: there is no core/commands/cohorte-loop.md and there must never
|
|
4
|
+
// be one (validate-core.mjs enforces it). If the Workflow runtime is unavailable, this
|
|
5
|
+
// refuses explicitly rather than degrading to a conversational loop — a lead re-reasoning
|
|
6
|
+
// the fan-out every round at session-model prices, which is the exact cost this script
|
|
7
|
+
// exists to avoid, silently becoming the default on every runtime that is not Claude Code.
|
|
8
|
+
//
|
|
9
|
+
// Invoke with args = {feature: "<feature_id>", maxRounds?: 1..10} (or the bare id string).
|
|
10
|
+
// Requires Claude Code >= 2.1.154 with workflows enabled — /cohorte-doctor reports this.
|
|
11
|
+
//
|
|
12
|
+
// The human's part happens BEFORE this runs, and the loop verifies it rather than working
|
|
13
|
+
// around it: /cohorte-spec froze the spec; /cohorte-build ran once — §1.5 reconciled the
|
|
14
|
+
// surfaces, §2 authored the contract (lead-only, the single sync channel), §1.6 wrote
|
|
15
|
+
// readiness.json. Three decisions stay human and abort the loop when they come up mid-run:
|
|
16
|
+
// a surface the profile does not own (§1.5 reconcile), a contract that must change
|
|
17
|
+
// (/cohorte-fix §1's lead-only step), and a NOT-READY spec (more passes cannot fix it).
|
|
18
|
+
//
|
|
19
|
+
// State is a reducer over files on disk — readiness.json + build.json (build's),
|
|
20
|
+
// verdict.json (review's), and loop.json (this script's own) — nothing lives in script
|
|
21
|
+
// memory between rounds. Re-invoking on the same feature resumes from loop.json; every
|
|
22
|
+
// file older than the spec's mtime is treated as absent (v2's killer was reading a missing
|
|
23
|
+
// file as an empty one; the mirror-image bug is reading an old file as a current one).
|
|
24
|
+
//
|
|
25
|
+
// NOTE for unattended runs: workflow subagents run in acceptEdits whatever the session
|
|
26
|
+
// mode, so hooks/gate.py is the only brake on file edits for the whole run (SCHEMA.md
|
|
27
|
+
// §Preflight) — and in bypassPermissions its asks escalate to hard denies.
|
|
28
|
+
|
|
29
|
+
export const meta = {
|
|
30
|
+
name: 'cohorte-loop',
|
|
31
|
+
description: 'Run one feature unattended: build (if not already on disk), review, then fix→review rounds until zero blocking findings, treading water, or maxRounds',
|
|
32
|
+
whenToUse: 'Only when the human explicitly asks to loop a cohorte feature. Preconditions: frozen spec, fresh readiness.json (from /cohorte-build §1.6), contract on disk. args = {feature: "<feature_id>", maxRounds?: 1..10}.',
|
|
33
|
+
phases: [
|
|
34
|
+
{ title: 'Profile', detail: 'PIPELINE.md → JSON via profile-reader', model: 'haiku' },
|
|
35
|
+
{ title: 'Preconditions', detail: 'raw facts from disk — every gate decided in script', model: 'haiku' },
|
|
36
|
+
{ title: 'Build', detail: 'one implementer per surface, parallel (skipped on a fresh build.json)' },
|
|
37
|
+
{ title: 'Review', detail: 'the cohorte-review workflow, one call per round' },
|
|
38
|
+
{ title: 'Fix', detail: 'Remediation append + scoped re-dispatch, only surfaces with findings' },
|
|
39
|
+
{ title: 'Close', detail: 'final loop.json + spec status + kanban', model: 'haiku' },
|
|
40
|
+
],
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// The Workflow runtime hands `args` to a script verbatim, so a caller that passes a
|
|
44
|
+
// JSON-ENCODED STRING instead of a real object gets that string back here. Parse it back
|
|
45
|
+
// into the object it was meant to be; a bare slug stays valid shorthand.
|
|
46
|
+
const ARGS = (() => {
|
|
47
|
+
if (typeof args === 'string') {
|
|
48
|
+
const t = args.trim()
|
|
49
|
+
if (t.startsWith('{')) {
|
|
50
|
+
try { const o = JSON.parse(t); if (o && typeof o === 'object' && !Array.isArray(o)) return o } catch {}
|
|
51
|
+
}
|
|
52
|
+
return { feature: t }
|
|
53
|
+
}
|
|
54
|
+
return args && typeof args === 'object' ? args : {}
|
|
55
|
+
})()
|
|
56
|
+
const isSlug = s => typeof s === 'string' && /^[A-Za-z0-9._-]+$/.test(s)
|
|
57
|
+
const feature = ARGS.feature
|
|
58
|
+
if (!feature) throw new Error('cohorte-loop needs args = {feature: "<feature_id>"}')
|
|
59
|
+
if (!isSlug(feature)) {
|
|
60
|
+
throw new Error(`cohorte-loop got a feature id that is not a slug: ${JSON.stringify(feature)}. ` +
|
|
61
|
+
'Pass args as a real object, e.g. {feature: "titlebar-project-switcher"} — not a JSON string.')
|
|
62
|
+
}
|
|
63
|
+
const maxRounds = Math.max(1, Math.min(10, Number(ARGS.maxRounds) || 5))
|
|
64
|
+
|
|
65
|
+
// ── The reducer — the whole design, as one pure function ─────────────────────
|
|
66
|
+
// Evaluated in this order; the first match wins, and the order is load-bearing:
|
|
67
|
+
// 1. A child abort (preflight red, dead stager) carries no coverage claim — relay it.
|
|
68
|
+
// 2. `unreviewed` BEFORE `blocking`: SCHEMA.md §Dead agents — a dead reviewer makes
|
|
69
|
+
// blocking == 0 a statement about code nobody read. Checked the other way round,
|
|
70
|
+
// the loop ships a feature whose reviewer crashed, silently and "successfully".
|
|
71
|
+
// 3. blocking == 0 ⇒ ship. HIGH/MEDIUM/LOW never block (they route to the backlog)
|
|
72
|
+
// and deferred findings never cost a round — both already enforced upstream.
|
|
73
|
+
// 4. Same blocking-item identity two CONSECUTIVE rounds ⇒ the fix round changed
|
|
74
|
+
// nothing the reviewer can see: treading water, abort now rather than proving it
|
|
75
|
+
// again for the remaining rounds. (An A→B→A oscillation passes this check and is
|
|
76
|
+
// caught by maxRounds — an acceptable second net.) Identity excludes `:line` by
|
|
77
|
+
// construction (/cohorte-review §3's blocking_items contract), so a fix that only
|
|
78
|
+
// shifts lines does not mask the stall.
|
|
79
|
+
// 5. maxRounds is the last net, never the first.
|
|
80
|
+
// It runs NO agent: every field is in the contract the review path guarantees.
|
|
81
|
+
const decide = (r, lastKey, round, max) => {
|
|
82
|
+
if (!r || typeof r !== 'object') return { outcome: 'abort', reason: 'review-died' }
|
|
83
|
+
if (r.verdict === 'ABORTED') return { outcome: 'abort', reason: r.aborted || 'review-aborted' }
|
|
84
|
+
if ((r.unreviewedSurfaces || []).length) return { outcome: 'abort', reason: 'unreviewed' }
|
|
85
|
+
if (typeof r.blocking !== 'number') return { outcome: 'abort', reason: 'no-verdict' }
|
|
86
|
+
if (r.blocking === 0) return { outcome: 'ship' }
|
|
87
|
+
const key = (r.blockingItems || []).join('\n')
|
|
88
|
+
if (lastKey != null && key === lastKey) return { outcome: 'abort', reason: 'treading-water' }
|
|
89
|
+
if (round >= max) return { outcome: 'abort', reason: 'max-rounds' }
|
|
90
|
+
return { outcome: 'continue', key }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Map a finding's file path onto the owning surface, /cohorte-fix §2's rule in code:
|
|
94
|
+
// longest surfaces[].path prefix wins; a path under no surface goes to the surface
|
|
95
|
+
// already owning the most items this round (or the first) — and is logged, not hidden.
|
|
96
|
+
const surfaceFor = (file, surfaces) => {
|
|
97
|
+
let best = null
|
|
98
|
+
for (const s of surfaces) {
|
|
99
|
+
if (s.path && String(file).startsWith(s.path) && (!best || s.path.length > best.path.length)) best = s
|
|
100
|
+
}
|
|
101
|
+
return best
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// A cheap, deterministic 16-hex digest for the round history — workflow scripts have no
|
|
105
|
+
// crypto, and equality of the treading-water key is decided on the full string anyway;
|
|
106
|
+
// this only makes a bad run diagnosable at a glance. (FNV-1a, twice, seeded apart.)
|
|
107
|
+
const hash16 = s => {
|
|
108
|
+
const fnv = seed => {
|
|
109
|
+
let h = seed >>> 0
|
|
110
|
+
for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 0x01000193) >>> 0 }
|
|
111
|
+
return h.toString(16).padStart(8, '0')
|
|
112
|
+
}
|
|
113
|
+
return s ? fnv(0x811c9dc5) + fnv(0x01000197) : ''
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Same profile hardening as the other three scripts — see review.js for the full story.
|
|
117
|
+
const PROFILE = {
|
|
118
|
+
type: 'object', additionalProperties: true,
|
|
119
|
+
properties: {
|
|
120
|
+
error: { type: 'string', description: 'set ONLY when PIPELINE.md is missing or unparseable' },
|
|
121
|
+
surfaces: {
|
|
122
|
+
type: 'array',
|
|
123
|
+
description: "one entry per surface, at the TOP LEVEL of this object — never a JSON string",
|
|
124
|
+
items: {
|
|
125
|
+
type: 'object', required: ['key'], additionalProperties: true,
|
|
126
|
+
properties: { key: { type: 'string' }, path: { type: 'string' }, agent: { type: 'string' } },
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
}
|
|
131
|
+
const unwrapProfile = p => {
|
|
132
|
+
if (typeof p === 'string') { try { return JSON.parse(p) } catch { return null } }
|
|
133
|
+
if (!p || typeof p !== 'object') return null
|
|
134
|
+
if (Array.isArray(p.surfaces) || p.error) return p
|
|
135
|
+
for (const v of Object.values(p)) {
|
|
136
|
+
if (typeof v !== 'string') continue
|
|
137
|
+
try {
|
|
138
|
+
const inner = JSON.parse(v)
|
|
139
|
+
if (inner && typeof inner === 'object' && !Array.isArray(inner)) return inner
|
|
140
|
+
} catch {}
|
|
141
|
+
}
|
|
142
|
+
return p
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// The precondition agent returns RAW FACTS (existence, mtimes, front-matter fields,
|
|
146
|
+
// file contents) and every gate is decided HERE, in script code — so the freshness and
|
|
147
|
+
// abort logic is pinned by scripts/test-workflows.mjs instead of living in a prompt
|
|
148
|
+
// where no test can see it.
|
|
149
|
+
const FACTS = {
|
|
150
|
+
type: 'object', required: ['now', 'spec', 'readiness', 'contractFile', 'build', 'loop'],
|
|
151
|
+
additionalProperties: false,
|
|
152
|
+
properties: {
|
|
153
|
+
now: {
|
|
154
|
+
type: 'object', required: ['epoch', 'iso'], additionalProperties: false,
|
|
155
|
+
properties: { epoch: { type: 'integer' }, iso: { type: 'string' } },
|
|
156
|
+
},
|
|
157
|
+
spec: {
|
|
158
|
+
type: 'object', required: ['exists'], additionalProperties: false,
|
|
159
|
+
properties: {
|
|
160
|
+
exists: { type: 'boolean' },
|
|
161
|
+
status: { type: 'string', description: 'front-matter status, "" if none' },
|
|
162
|
+
kind: { type: 'string', description: 'front-matter kind, "" if none (⇒ feature)' },
|
|
163
|
+
mtimeEpoch: { type: 'integer' },
|
|
164
|
+
designFiles: { type: 'array', items: { type: 'string' }, description: 'front-matter design_files links, [] if none' },
|
|
165
|
+
contractDelta: { type: 'string', description: "kind:patch only — 'none' when §5 Contract delta is none/absent, else 'present'" },
|
|
166
|
+
},
|
|
167
|
+
},
|
|
168
|
+
readiness: {
|
|
169
|
+
type: 'object', required: ['exists'], additionalProperties: false,
|
|
170
|
+
properties: {
|
|
171
|
+
exists: { type: 'boolean' }, mtimeEpoch: { type: 'integer' },
|
|
172
|
+
verdict: { type: 'string' },
|
|
173
|
+
gaps: { type: 'array', items: { type: 'string' } },
|
|
174
|
+
surfaces: { type: 'array', items: { type: 'string' } },
|
|
175
|
+
},
|
|
176
|
+
},
|
|
177
|
+
contractFile: {
|
|
178
|
+
type: 'object', required: ['exists'], additionalProperties: false,
|
|
179
|
+
properties: { exists: { type: 'boolean' } },
|
|
180
|
+
},
|
|
181
|
+
build: {
|
|
182
|
+
type: 'object', required: ['exists'], additionalProperties: false,
|
|
183
|
+
properties: {
|
|
184
|
+
exists: { type: 'boolean' }, mtimeEpoch: { type: 'integer' },
|
|
185
|
+
dead: { type: 'array', items: { type: 'string' } },
|
|
186
|
+
},
|
|
187
|
+
},
|
|
188
|
+
loop: {
|
|
189
|
+
type: 'object', required: ['exists'], additionalProperties: false,
|
|
190
|
+
properties: {
|
|
191
|
+
exists: { type: 'boolean' }, mtimeEpoch: { type: 'integer' },
|
|
192
|
+
raw: { type: 'string', description: 'the loop.json file content, verbatim' },
|
|
193
|
+
},
|
|
194
|
+
},
|
|
195
|
+
},
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const ITEMS = {
|
|
199
|
+
type: 'object', required: ['items'], additionalProperties: false,
|
|
200
|
+
properties: {
|
|
201
|
+
items: {
|
|
202
|
+
type: 'array',
|
|
203
|
+
items: {
|
|
204
|
+
type: 'object', required: ['line', 'file'], additionalProperties: false,
|
|
205
|
+
properties: {
|
|
206
|
+
line: { type: 'string', description: 'the appended `- [ ] …` Remediation line, verbatim' },
|
|
207
|
+
file: { type: 'string', description: 'the finding\'s file path (no :line)' },
|
|
208
|
+
},
|
|
209
|
+
},
|
|
210
|
+
},
|
|
211
|
+
},
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// kanban-move.sh is optional infrastructure — every prompt that runs it defines <core>
|
|
215
|
+
// and tolerates its absence, per SCHEMA.md §Kanban.
|
|
216
|
+
const CORE_DEF = '(<core> = .claude if .claude/pipeline/scripts/kanban-move.sh exists, else ~/.claude — ' +
|
|
217
|
+
'probe with test -f; absent on both ⇒ skip the kanban step, the board is optional.)'
|
|
218
|
+
const METRICS_PATH = '$(dirname "$(git rev-parse --git-common-dir)")/.claude/pipeline-metrics.jsonl'
|
|
219
|
+
|
|
220
|
+
// ── Phase 0 — profile ────────────────────────────────────────────────────────
|
|
221
|
+
phase('Profile')
|
|
222
|
+
const profile = unwrapProfile(await agent(
|
|
223
|
+
'Return this project\'s PIPELINE.md `yaml pipeline-profile` block as JSON, per your instructions.',
|
|
224
|
+
{ agentType: 'profile-reader', label: 'profile', schema: PROFILE, effort: 'low' },
|
|
225
|
+
))
|
|
226
|
+
if (!profile || profile.error) {
|
|
227
|
+
return { id: feature, outcome: 'abort', reason: 'precondition', rounds: 0, detail: `profile unreadable: ${(profile && profile.error) || 'profile-reader returned nothing'}` }
|
|
228
|
+
}
|
|
229
|
+
const surfaces = Array.isArray(profile.surfaces) ? profile.surfaces : []
|
|
230
|
+
if (!surfaces.length) {
|
|
231
|
+
return { id: feature, outcome: 'abort', reason: 'precondition', rounds: 0, detail: 'profile has no surfaces — nothing could be built or reviewed; run /cohorte-doctor' }
|
|
232
|
+
}
|
|
233
|
+
const byKey = Object.fromEntries(surfaces.map(s => [s.key, s]))
|
|
234
|
+
const contract = profile.contract || {}
|
|
235
|
+
const base = (profile.vcs && profile.vcs.default_branch) || 'main'
|
|
236
|
+
const contractOn = contract.enabled !== false && contract.path && !String(contract.path).startsWith('<')
|
|
237
|
+
const contractFilePath = contractOn ? `${contract.path}/${feature}.${contract.ext || 'ts'}` : ''
|
|
238
|
+
|
|
239
|
+
// ── Phase 1 — preconditions: raw facts in, every verdict decided in script ───
|
|
240
|
+
phase('Preconditions')
|
|
241
|
+
const facts = await agent(
|
|
242
|
+
`Collect RAW facts for the cohorte loop on feature ${feature} — mechanical reads only, no judgment, ` +
|
|
243
|
+
'never print file bodies. Use ONE Bash call chain where possible; file mtimes as unix epochs ' +
|
|
244
|
+
'(stat -f %m on macOS, stat -c %Y on Linux), now = date +%s and date -u +%Y-%m-%dT%H:%M:%SZ.\n' +
|
|
245
|
+
`- specs/${feature}.md: exists · front-matter \`status\` and \`kind\` (grep the first ~15 lines) · mtime · ` +
|
|
246
|
+
'front-matter `design_files` entries as a list · and, ONLY when kind is patch: contractDelta = "none" if ' +
|
|
247
|
+
'its §5 Contract delta section is `none`/absent, else "present".\n' +
|
|
248
|
+
`- specs/reports/${feature}.readiness.json: exists · mtime · its verdict, gaps, surfaces fields (jq or grep).\n` +
|
|
249
|
+
(contractOn ? `- ${contractFilePath}: exists.\n` : '- contractFile: report exists=false (the profile has no contract mechanism).\n') +
|
|
250
|
+
`- specs/reports/${feature}.build.json: exists · mtime · its dead[] array.\n` +
|
|
251
|
+
`- specs/reports/${feature}.loop.json: exists · mtime · the file content VERBATIM in raw.\n` +
|
|
252
|
+
'A file that does not exist ⇒ exists=false and omit its other fields.',
|
|
253
|
+
{ model: 'haiku', label: 'preconditions', schema: FACTS, effort: 'low' },
|
|
254
|
+
)
|
|
255
|
+
if (!facts) {
|
|
256
|
+
return { id: feature, outcome: 'abort', reason: 'precondition', rounds: 0, detail: 'the precondition agent died — nothing was dispatched; re-run the loop workflow' }
|
|
257
|
+
}
|
|
258
|
+
let clock = (facts.now && facts.now.epoch) || 0
|
|
259
|
+
const specMtime = (facts.spec && facts.spec.mtimeEpoch) || 0
|
|
260
|
+
|
|
261
|
+
// Resume state is parsed BEFORE the freshness gates, because the baseline they compare
|
|
262
|
+
// against comes from it. The loop EDITS the spec as it runs (status stamps, Remediation
|
|
263
|
+
// appends, DoD ticks), so the spec's mtime moves past readiness.json on the very first
|
|
264
|
+
// round — measured naively, the loop's own footprint would invalidate its own resume
|
|
265
|
+
// ("readiness is older than the spec") the moment anything real happened. loop.json
|
|
266
|
+
// therefore stores the spec mtime observed when the run STARTED, and an unfinished run
|
|
267
|
+
// for this feature measures freshness against that baseline. A human who rewrites the
|
|
268
|
+
// spec mid-run without re-entering through /cohorte-build is outside what mtimes can
|
|
269
|
+
// distinguish — delete specs/reports/<id>.loop.json to force a fresh run.
|
|
270
|
+
let resumed = null
|
|
271
|
+
if (facts.loop && facts.loop.exists && facts.loop.raw) {
|
|
272
|
+
try {
|
|
273
|
+
const prev = JSON.parse(facts.loop.raw)
|
|
274
|
+
if (prev && prev.id === feature && !prev.outcome) resumed = prev
|
|
275
|
+
} catch { /* unparseable loop.json = no resume; a fresh run overwrites it */ }
|
|
276
|
+
}
|
|
277
|
+
const baselineMtime = (resumed && typeof resumed.specMtime === 'number') ? resumed.specMtime : specMtime
|
|
278
|
+
const startedAt = (resumed && resumed.startedAt) || (facts.now && facts.now.iso) || ''
|
|
279
|
+
// Freshness: a report older than the (baseline) spec describes a spec that no longer
|
|
280
|
+
// exists — treat it exactly as absent (the mirror image of v2's missing-file-read-as-
|
|
281
|
+
// empty bug).
|
|
282
|
+
const fresh = f => !!(f && f.exists && typeof f.mtimeEpoch === 'number' && f.mtimeEpoch >= baselineMtime)
|
|
283
|
+
|
|
284
|
+
const abortPre = detail => ({ id: feature, outcome: 'abort', reason: 'precondition', rounds: 0, detail })
|
|
285
|
+
|
|
286
|
+
// 1. The spec, in a buildable state. `in-progress` is accepted because THIS script
|
|
287
|
+
// stamps it — a resumed loop must not refuse its own footprint.
|
|
288
|
+
if (!facts.spec.exists) return abortPre(`specs/${feature}.md does not exist — /cohorte-spec first`)
|
|
289
|
+
const status = facts.spec.status || ''
|
|
290
|
+
if (!['frozen', 'in-review', 'in-progress'].includes(status)) {
|
|
291
|
+
return abortPre(`spec status is '${status || '(none)'}' — the loop needs frozen | in-review | in-progress ` +
|
|
292
|
+
`(SCHEMA.md §Spec status). draft ⇒ /cohorte-spec; blocked ⇒ route by the spec's ## Remediation first; shipped ⇒ done already`)
|
|
293
|
+
}
|
|
294
|
+
// 2. A fresh readiness verdict — the gate /cohorte-build §1.6 wrote. NOT-READY is the one
|
|
295
|
+
// outcome more passes cannot fix; stale-or-absent means nobody has judged THIS spec.
|
|
296
|
+
if (!fresh(facts.readiness)) {
|
|
297
|
+
return abortPre(`specs/reports/${feature}.readiness.json is ${facts.readiness.exists ? 'older than the spec' : 'absent'} — ` +
|
|
298
|
+
`run /cohorte-build ${feature} once (its §1.6 writes the readiness verdict), then re-run the loop`)
|
|
299
|
+
}
|
|
300
|
+
if (facts.readiness.verdict === 'NOT-READY') {
|
|
301
|
+
return { ...abortPre(`readiness verdict is NOT-READY — /cohorte-spec ${feature} must close these gaps first`), gaps: facts.readiness.gaps || [] }
|
|
302
|
+
}
|
|
303
|
+
if (!['READY', 'RESERVATIONS'].includes(facts.readiness.verdict || '')) {
|
|
304
|
+
return abortPre(`readiness verdict is '${facts.readiness.verdict || '(none)'}' — not a verdict the loop can build on; re-run /cohorte-build ${feature}`)
|
|
305
|
+
}
|
|
306
|
+
// 3. The contract file — the single sync channel, lead-authored. A patch whose §5 is
|
|
307
|
+
// `none` legitimately has no contract; everything else without one means /cohorte-build
|
|
308
|
+
// §2 never ran, and authoring it is exactly the step a workflow must not do.
|
|
309
|
+
const isPatch = facts.spec.kind === 'patch'
|
|
310
|
+
if (contractOn && !facts.contractFile.exists && !(isPatch && facts.spec.contractDelta !== 'present')) {
|
|
311
|
+
return abortPre(`contract file ${contractFilePath} is absent — /cohorte-build ${feature} authors it (lead-only, §2); the loop never will`)
|
|
312
|
+
}
|
|
313
|
+
// 4. Every surface the readiness verdict names must resolve in the profile. One that
|
|
314
|
+
// does not is /cohorte-build §1.5's reconcile case — a human decision (new agent,
|
|
315
|
+
// new tree owner), so name it and stop rather than dispatch around it.
|
|
316
|
+
const wantedKeys = (facts.readiness.surfaces || []).filter(Boolean)
|
|
317
|
+
if (!wantedKeys.length) return abortPre('readiness.json names no surfaces — nothing to dispatch; re-run /cohorte-build')
|
|
318
|
+
const unknown = wantedKeys.filter(k => !byKey[k])
|
|
319
|
+
if (unknown.length) {
|
|
320
|
+
return abortPre(`readiness names surface(s) the profile does not own: ${unknown.join(', ')} — ` +
|
|
321
|
+
`that is /cohorte-build §1.5's reconcile (a human renders the new agent); run /cohorte-build ${feature}, then re-run the loop`)
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// Resume — loop.json is state, not memory (parsed above, before the gates). The
|
|
325
|
+
// persisted history already carries the round being resumed when the run died after
|
|
326
|
+
// its review — keep only rounds BEFORE the current one, or the re-review double-counts.
|
|
327
|
+
let round = 1
|
|
328
|
+
let lastKey = null
|
|
329
|
+
let history = []
|
|
330
|
+
if (resumed) {
|
|
331
|
+
round = Math.max(1, Number(resumed.round) || 1)
|
|
332
|
+
lastKey = Array.isArray(resumed.lastItems) ? resumed.lastItems.join('\n') : null
|
|
333
|
+
history = (Array.isArray(resumed.history) ? resumed.history : []).filter(h => h && h.round < round)
|
|
334
|
+
log(`Resuming ${feature} at round ${round} (unfinished loop.json)`)
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// Readiness gaps (RESERVATIONS) inline into the dispatch of the surface they affect —
|
|
338
|
+
// gap format: `<check>|<where>|<what>`; route by `<where>` when it names a surface key,
|
|
339
|
+
// else give the gap to every dispatched surface (better a stated assumption twice than
|
|
340
|
+
// an invented answer once).
|
|
341
|
+
const gapsFor = key => (facts.readiness.gaps || [])
|
|
342
|
+
.filter(g => { const w = String(g).split('|')[1]; return !w || !byKey[w] || w === key })
|
|
343
|
+
|
|
344
|
+
// ── The one byte-stable dispatch template — /cohorte-build §3 and /cohorte-fix §2 ─────
|
|
345
|
+
// Build rounds and fix rounds use the SAME prompt; only the trailing variable slots
|
|
346
|
+
// differ, so every repeat hits the prompt-cache prefix, and the fix rounds can never
|
|
347
|
+
// drift from the build (the drift nobody notices for months).
|
|
348
|
+
const contractSlot = contractOn
|
|
349
|
+
? (isPatch && facts.spec.contractDelta !== 'present' ? 'none (kind: patch, no contract delta)' : `\`${contractFilePath}\``)
|
|
350
|
+
: 'none — the spec prose is the sync channel'
|
|
351
|
+
const dispatchPrompt = j =>
|
|
352
|
+
`Implement the **${j.s.key}** surface for feature \`${feature}\`. Read \`PIPELINE.md\` first. ` +
|
|
353
|
+
`Spec: \`specs/${feature}.md\`. Contract: ${contractSlot} (import read-only). Work test-first. ` +
|
|
354
|
+
`Touch only \`${j.s.path}\`. Need the current state of your tree? Compute it yourself: ` +
|
|
355
|
+
`git diff ${base} -- ${j.s.path}. Return the handoff in the format your agent instructions define. ` +
|
|
356
|
+
`Design files: ${j.design}. ` +
|
|
357
|
+
'Open Remediation items for YOUR surface (self-contained — fix exactly these, reading only the files ' +
|
|
358
|
+
`they name; \`none\` ⇒ first build, implement the spec's tasks for your surface): ${j.items}. ` +
|
|
359
|
+
'Readiness gaps for YOUR surface (§1.6 RESERVATIONS — the spec is silent here: implement the stated ' +
|
|
360
|
+
`assumption and flag what you assumed in your handoff): ${j.gaps}.`
|
|
361
|
+
|
|
362
|
+
// Dispatch + roll call, SCHEMA.md §Dead agents: parallel fan-out, then each silent
|
|
363
|
+
// surface retried ONCE, alone, byte-identical. Never a surface that answered.
|
|
364
|
+
const dispatchSurfaces = async (jobs, phaseTitle) => {
|
|
365
|
+
const tag = phaseTitle === 'Build' ? 'build' : 'fix'
|
|
366
|
+
const first = await parallel(jobs.map(j => () =>
|
|
367
|
+
agent(dispatchPrompt(j), { agentType: j.s.agent, label: `${tag}:${j.s.key}`, phase: phaseTitle })))
|
|
368
|
+
const out = []
|
|
369
|
+
for (let i = 0; i < jobs.length; i++) {
|
|
370
|
+
let h = first[i]
|
|
371
|
+
if (h == null) {
|
|
372
|
+
log(`${jobs[i].s.key}: implementer died — one byte-identical retry, alone`)
|
|
373
|
+
h = await agent(dispatchPrompt(jobs[i]), { agentType: jobs[i].s.agent, label: `${tag}-retry:${jobs[i].s.key}`, phase: phaseTitle })
|
|
374
|
+
}
|
|
375
|
+
out.push({ key: jobs[i].s.key, handoff: h })
|
|
376
|
+
}
|
|
377
|
+
return out
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const designSlot = s => (s.uses_design && (facts.spec.designFiles || []).length)
|
|
381
|
+
? (facts.spec.designFiles || []).join(' · ') : 'none'
|
|
382
|
+
|
|
383
|
+
// One mechanical state writer per boundary: loop.json (EXACT content composed here —
|
|
384
|
+
// the agent only substitutes <ISO> and never invents fields), plus the optional spec
|
|
385
|
+
// status stamp, kanban move and metrics line. Returns `done <epoch>` so wall-clock
|
|
386
|
+
// threads through the agents' own `date +%s` (scripts have no clock of their own).
|
|
387
|
+
const writeState = async ({ label, phaseName, outcome, reason, status: st, kanban, metrics }) => {
|
|
388
|
+
const loopJson = JSON.stringify({
|
|
389
|
+
id: feature, round, maxRounds, phase: phaseName, startedAt, specMtime: baselineMtime, ts: '<ISO>',
|
|
390
|
+
lastItems: lastKey == null ? null : lastKey.split('\n').filter(Boolean),
|
|
391
|
+
history, ...(outcome ? { outcome, reason } : {}),
|
|
392
|
+
})
|
|
393
|
+
const steps = [
|
|
394
|
+
`1. mkdir -p specs/reports; write EXACTLY this to specs/reports/${feature}.loop.json (overwrite), ` +
|
|
395
|
+
'substituting EVERY <ISO> token in this and every later step with `date -u +%Y-%m-%dT%H:%M:%SZ` ' +
|
|
396
|
+
`and changing NOTHING else:\n${loopJson}`,
|
|
397
|
+
]
|
|
398
|
+
if (st) steps.push(
|
|
399
|
+
`${steps.length + 1}. In specs/${feature}.md front-matter set \`status: ${st}\` (sed the existing status: line; ` +
|
|
400
|
+
'a spec with no front-matter ⇒ skip silently — the loop never dies over a status line).')
|
|
401
|
+
if (kanban) steps.push(
|
|
402
|
+
`${steps.length + 1}. <core>/pipeline/scripts/kanban-move.sh auto ${feature} ${kanban} || true ${CORE_DEF}`)
|
|
403
|
+
if (metrics) steps.push(
|
|
404
|
+
`${steps.length + 1}. Append ONE line to ${METRICS_PATH}: ` +
|
|
405
|
+
`{"ts":"<ISO>","feature":"${feature}","phase":"${metrics.phase}","seconds":$(($(date +%s)-${clock || 0})),` +
|
|
406
|
+
`"surfaces":${JSON.stringify(metrics.surfaces)}}` + (metrics.buildJson
|
|
407
|
+
? `\n${steps.length + 2}. Write to specs/reports/${feature}.build.json (overwrite): ` +
|
|
408
|
+
`{"id":"${feature}","phase":"build","ts":"<ISO>","surfaces":${JSON.stringify(metrics.surfaces)},"dead":${JSON.stringify(metrics.dead)}}`
|
|
409
|
+
: ''))
|
|
410
|
+
const r = await agent(
|
|
411
|
+
`Mechanical state write for the cohorte loop, feature ${feature} — do ALL steps, then return exactly: done <epoch> ` +
|
|
412
|
+
'(<epoch> = date +%s).\n' + steps.join('\n'),
|
|
413
|
+
{ model: 'haiku', label, phase: outcome ? 'Close' : undefined, effort: 'low' },
|
|
414
|
+
)
|
|
415
|
+
const m = /done\s+(\d+)/.exec(String(r || ''))
|
|
416
|
+
if (m) clock = Number(m[1])
|
|
417
|
+
return r != null
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// Close the run: final loop.json + status + kanban, then the return object. Status
|
|
421
|
+
// doctrine (SCHEMA.md §Spec status): `blocked` = a round gave up (resumable, reason
|
|
422
|
+
// named); `in-review` = zero blocking, awaiting /cohorte-ship; precondition aborts
|
|
423
|
+
// never stamped anything, so they change nothing here either.
|
|
424
|
+
const close = async (outcome, reason, extra = {}) => {
|
|
425
|
+
// Every abort that reaches close() is a round giving up — preconditions abort via
|
|
426
|
+
// abortPre() before anything was stamped and never come through here. An abort left
|
|
427
|
+
// outside this list once stranded the spec at `in-progress` forever.
|
|
428
|
+
const gaveUp = outcome === 'abort'
|
|
429
|
+
const closed = await writeState({
|
|
430
|
+
label: 'close', phaseName: 'done', outcome, reason,
|
|
431
|
+
status: outcome === 'ship' ? 'in-review' : gaveUp ? 'blocked' : undefined,
|
|
432
|
+
kanban: outcome === 'ship' ? 'review' : gaveUp ? 'fix' : undefined,
|
|
433
|
+
})
|
|
434
|
+
return {
|
|
435
|
+
id: feature, outcome, reason, rounds: history.length,
|
|
436
|
+
blocking: history.length ? history[history.length - 1].blocking : null,
|
|
437
|
+
history, report: `specs/reports/${feature}.md`,
|
|
438
|
+
...(closed ? {} : { stateWarning: 'the closing state agent died — loop.json/status/kanban may not reflect this outcome' }),
|
|
439
|
+
...extra,
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// ── Phase 2 — build, unless build's own work is already fresh on disk ────────
|
|
444
|
+
// /cohorte-build has no stop-after-§1.6 mode: when the human ran it through, the
|
|
445
|
+
// implementers' work and build.json are on disk and re-building would be waste; when
|
|
446
|
+
// build.json is stale, absent, or carries dead[], the loop builds (a full re-dispatch —
|
|
447
|
+
// implementers are idempotent against a frozen contract, the same property fix rounds
|
|
448
|
+
// already rely on).
|
|
449
|
+
if (fresh(facts.build) && !(facts.build.dead || []).length) {
|
|
450
|
+
log('build.json is fresh with no dead surfaces — skipping the build phase, entering at review')
|
|
451
|
+
await writeState({ label: 'state:enter', phaseName: 'review', status: 'in-progress', kanban: 'review' })
|
|
452
|
+
} else {
|
|
453
|
+
phase('Build')
|
|
454
|
+
await writeState({ label: 'state:build', phaseName: 'build', status: 'in-progress', kanban: 'building' })
|
|
455
|
+
const jobs = wantedKeys.map(k => ({
|
|
456
|
+
s: byKey[k], design: designSlot(byKey[k]), items: 'none',
|
|
457
|
+
gaps: gapsFor(k).join(' · ') || 'none',
|
|
458
|
+
}))
|
|
459
|
+
const built = await dispatchSurfaces(jobs, 'Build')
|
|
460
|
+
const dead = built.filter(b => b.handoff == null).map(b => b.key)
|
|
461
|
+
// Write build.json + the metrics line EVEN WHEN a surface died — an incomplete batch
|
|
462
|
+
// is exactly the batch worth recording (SCHEMA.md §Dead agents).
|
|
463
|
+
await writeState({
|
|
464
|
+
label: 'state:built', phaseName: dead.length ? 'build' : 'review',
|
|
465
|
+
metrics: {
|
|
466
|
+
phase: 'build', buildJson: true, dead,
|
|
467
|
+
surfaces: Object.fromEntries(built.map(b => [b.key, b.handoff == null ? 'dead' : 'ok'])),
|
|
468
|
+
},
|
|
469
|
+
})
|
|
470
|
+
if (dead.length) {
|
|
471
|
+
return close('abort', 'dead-implementers', {
|
|
472
|
+
detail: `surface(s) returned nothing twice: ${dead.join(', ')} — their trees are UNVERIFIED ` +
|
|
473
|
+
'(a dead agent has no handoff to speak for); re-run the loop, or /cohorte-build conversationally',
|
|
474
|
+
})
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// ── Phases 3–5 — the loop body: review → decide → fix → … ────────────────────
|
|
479
|
+
while (true) {
|
|
480
|
+
phase('Review')
|
|
481
|
+
await writeState({ label: `state:round-${round}`, phaseName: 'review', status: 'in-progress', kanban: 'review' })
|
|
482
|
+
let review
|
|
483
|
+
try {
|
|
484
|
+
review = await workflow('cohorte-review', { feature })
|
|
485
|
+
} catch (e) {
|
|
486
|
+
return close('abort', 'review-workflow-unavailable', {
|
|
487
|
+
detail: `workflow('cohorte-review') failed: ${e.message} — the review workflow must be installed ` +
|
|
488
|
+
'(<core>/workflows/review.js; /cohorte-doctor check 8); the loop never falls back to a conversational review',
|
|
489
|
+
})
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
const verdict = decide(review, lastKey, round, maxRounds)
|
|
493
|
+
if (review && typeof review.blocking === 'number') {
|
|
494
|
+
history.push({ round, blocking: review.blocking, fingerprint: hash16((review.blockingItems || []).join('\n')) })
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
if (verdict.outcome === 'ship') {
|
|
498
|
+
// review.js stamps the freshness gate + DoD ticks only when every leftover is LOW
|
|
499
|
+
// (`clean`); its `next` already says which side this run landed on — relay it, so a
|
|
500
|
+
// ship exit with surviving HIGH/MEDIUM does not read as "run /cohorte-ship" when
|
|
501
|
+
// /cohorte-ship would refuse (no reviewed_base/reviewed_digest stamp).
|
|
502
|
+
return close('ship', 'ship', {
|
|
503
|
+
deferred: review.deferred || 0,
|
|
504
|
+
next: review.next || `/cohorte-ship ${feature}`,
|
|
505
|
+
})
|
|
506
|
+
}
|
|
507
|
+
if (verdict.outcome === 'abort') {
|
|
508
|
+
return close('abort', verdict.reason, {
|
|
509
|
+
...(verdict.reason === 'unreviewed' ? { unreviewed: review.unreviewedSurfaces } : {}),
|
|
510
|
+
...(verdict.reason === 'preflight' ? { detail: 'preflight red — mechanical failures need /cohorte-fix or the human, not more rounds' } : {}),
|
|
511
|
+
...(verdict.reason === 'treading-water' ? { detail: `round ${round} left the same ${review.blocking} blocking finding(s) as round ${round - 1} — the fix rounds are not converging; a human look is cheaper than ${maxRounds - round} more rounds` } : {}),
|
|
512
|
+
...(verdict.reason === 'max-rounds' ? { detail: `${review.blocking} blocking finding(s) still open after ${maxRounds} round(s)` } : {}),
|
|
513
|
+
})
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// The fix round ingests the staged report from disk; a dead staging agent means that
|
|
517
|
+
// file is the PREVIOUS round's (or absent) — ingesting it fixes the wrong items and
|
|
518
|
+
// manufactures a false treading-water abort next round. The verdict itself is real;
|
|
519
|
+
// only the handoff is missing, so this is a resumable give-up, not a verdict.
|
|
520
|
+
if (review.reportStaged === false) {
|
|
521
|
+
return close('abort', 'report-not-staged', {
|
|
522
|
+
detail: `round ${round}'s review completed (blocking: ${review.blocking}) but its report was never ` +
|
|
523
|
+
`staged to disk (the staging agent died) — re-run the loop; it resumes at round ${round}`,
|
|
524
|
+
})
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// A blocking finding pointing at THE contract file itself is /cohorte-fix §1's
|
|
528
|
+
// lead-only step (update spec §5, re-author the contract). A loop that rewrites its
|
|
529
|
+
// own contract between rounds has no frozen contract — abort and name the finding.
|
|
530
|
+
// Exact-file match, not a contract.path prefix: ordinary surface code can live under
|
|
531
|
+
// the same tree (a `shared` surface at the contract package), and a prefix match
|
|
532
|
+
// would make every blocking finding there permanently unfixable by the loop.
|
|
533
|
+
if (contractOn) {
|
|
534
|
+
const onContract = (review.blockingItems || []).filter(it => String(it.split('|')[1] || '') === contractFilePath)
|
|
535
|
+
if (onContract.length) {
|
|
536
|
+
return close('abort', 'contract-change', {
|
|
537
|
+
detail: 'blocking finding(s) target the frozen contract itself — a lead must update spec §5 and ' +
|
|
538
|
+
`re-author the contract (/cohorte-spec ${feature} Mode B, then /cohorte-build), then re-enter the loop`,
|
|
539
|
+
findings: onContract,
|
|
540
|
+
})
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// ── Fix round ──────────────────────────────────────────────────────────────
|
|
545
|
+
phase('Fix')
|
|
546
|
+
const ingest = await agent(
|
|
547
|
+
`Mechanical ingest of a cohorte review round for feature ${feature} (loop round ${round}):\n` +
|
|
548
|
+
`1. <core>/pipeline/scripts/kanban-move.sh auto ${feature} fix || true ${CORE_DEF}\n` +
|
|
549
|
+
`2. Read the BLOCKING findings from specs/reports/${feature}.md ## Findings — exactly the lines whose ` +
|
|
550
|
+
'severity is CRITICAL or whose kind is security (they are the only lines matching this identity list, ' +
|
|
551
|
+
`one \`<surface>|<file>|<first words of the problem>\` per finding): ${JSON.stringify(review.blockingItems)}\n` +
|
|
552
|
+
`3. Append them to specs/${feature}.md under \`## Remediation\` (create the heading if absent) beneath a ` +
|
|
553
|
+
`new subheading \`### $(date -u +%Y-%m-%d) — cohorte-loop round ${round}\`, one line each: ` +
|
|
554
|
+
'`- [ ] <SEVERITY> · <file:line> · <kind> · <the finding\'s concrete fix>`. Append with >>, never rewrite the spec.\n' +
|
|
555
|
+
'4. Return each appended line verbatim plus its bare file path (no :line).',
|
|
556
|
+
{ model: 'haiku', label: `ingest:${round}`, phase: 'Fix', schema: ITEMS, effort: 'low' },
|
|
557
|
+
)
|
|
558
|
+
// A dead ingest agent appended nothing — dispatching implementers with no Remediation
|
|
559
|
+
// items would re-build surfaces blind. Abort as a give-up, resumable at this round.
|
|
560
|
+
if (!ingest || !(ingest.items || []).length) {
|
|
561
|
+
return close('abort', ingest ? 'no-fix-items' : 'ingest-died', {
|
|
562
|
+
detail: 'the Remediation ingest returned nothing — the blocking findings were never turned into ' +
|
|
563
|
+
`fix items; re-run the loop (it resumes at round ${round}), or run /cohorte-fix ${feature} by hand`,
|
|
564
|
+
})
|
|
565
|
+
}
|
|
566
|
+
// Surface mapping in script, /cohorte-fix §2's rule — longest path prefix owns the
|
|
567
|
+
// item; unowned items ride with the surface already owning the most (logged).
|
|
568
|
+
const grouped = {}
|
|
569
|
+
const unowned = []
|
|
570
|
+
for (const it of ingest.items) {
|
|
571
|
+
const s = surfaceFor(it.file, surfaces)
|
|
572
|
+
if (s) (grouped[s.key] = grouped[s.key] || []).push(it.line)
|
|
573
|
+
else unowned.push(it)
|
|
574
|
+
}
|
|
575
|
+
if (unowned.length) {
|
|
576
|
+
const host = Object.keys(grouped).sort((a, b) => grouped[b].length - grouped[a].length)[0] || wantedKeys[0]
|
|
577
|
+
log(`${unowned.length} item(s) under no surface path — attached to ${host}: ${unowned.map(i => i.file).join(', ')}`)
|
|
578
|
+
;(grouped[host] = grouped[host] || []).push(...unowned.map(i => i.line))
|
|
579
|
+
}
|
|
580
|
+
const fixJobs = Object.keys(grouped).filter(k => byKey[k]).map(k => ({
|
|
581
|
+
s: byKey[k], design: designSlot(byKey[k]), items: grouped[k].join(' · '),
|
|
582
|
+
gaps: 'none',
|
|
583
|
+
}))
|
|
584
|
+
const fixed = await dispatchSurfaces(fixJobs, 'Fix')
|
|
585
|
+
const fixDead = fixed.filter(f => f.handoff == null).map(f => f.key)
|
|
586
|
+
// The metrics line is written EVEN WHEN a surface died — an incomplete batch is
|
|
587
|
+
// exactly the batch worth recording (SCHEMA.md §Dead agents), so it lands before
|
|
588
|
+
// the dead-implementers abort, with the dead surfaces named.
|
|
589
|
+
await writeState({ label: `state:fixed-${round}`, phaseName: 'review', metrics: {
|
|
590
|
+
phase: 'fix', surfaces: Object.fromEntries(fixed.map(f => [f.key, f.handoff == null ? 'dead' : 'ok'])),
|
|
591
|
+
} })
|
|
592
|
+
if (fixDead.length) {
|
|
593
|
+
// Their items stay `- [ ]` — a dead agent never ticks a box (/cohorte-fix §3), and
|
|
594
|
+
// nothing here has ticked anything yet, so the spec is already honest.
|
|
595
|
+
return close('abort', 'dead-implementers', {
|
|
596
|
+
detail: `fix implementer(s) returned nothing twice: ${fixDead.join(', ')} — their items stay open ` +
|
|
597
|
+
`\`- [ ]\` in the spec; re-run the loop (resumes at round ${round}) or /cohorte-fix ${feature}`,
|
|
598
|
+
})
|
|
599
|
+
}
|
|
600
|
+
// Tick ONLY what a live handoff claims — the excerpt passed per surface is its
|
|
601
|
+
// `## Remediation addressed` section, so the ticker never sees (or trusts) silence.
|
|
602
|
+
const excerpt = h => {
|
|
603
|
+
const m = /## Remediation addressed[\s\S]*?(?=\n## |$)/.exec(String(h))
|
|
604
|
+
return (m ? m[0] : String(h)).slice(0, 2000)
|
|
605
|
+
}
|
|
606
|
+
const ticked = await agent(
|
|
607
|
+
`In specs/${feature}.md, flip \`- [ ]\` → \`- [x]\` for EXACTLY the Remediation items these surface handoffs ` +
|
|
608
|
+
'report fixed (match by file:line; append a terse ` — fixed: <what>` note). Leave every item no handoff ' +
|
|
609
|
+
'claims — and every other line — untouched. Then return the single word: done.\n' +
|
|
610
|
+
fixed.map(f => `--- handoff ${f.key} ---\n${excerpt(f.handoff)}`).join('\n'),
|
|
611
|
+
{ model: 'haiku', label: `tick:${round}`, phase: 'Fix', effort: 'low' },
|
|
612
|
+
)
|
|
613
|
+
if (ticked == null) log('the ticking agent died — items stay `- [ ]`; the next round re-reviews regardless')
|
|
614
|
+
|
|
615
|
+
lastKey = verdict.key
|
|
616
|
+
round += 1
|
|
617
|
+
}
|