cohorte 1.3.4 → 1.4.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.
@@ -1,513 +0,0 @@
1
- // cohorte — the FULL dev cycle as one deterministic workflow (opt-in):
2
- // contract → build → [preflight → review(+cross-check) (∥ smoke if opted in) → fix]* → done.
3
- //
4
- // Invoke with args = {feature: "<feature_id>", maxRounds?: 5, smoke?: true} in the
5
- // feature's checkout (main checkout on the feature branch, or its worktree).
6
- // Smoke is OFF by default — running the app every round is the human's call
7
- // (/cycle <id> smoke, or standalone /smoke before /ship).
8
- //
9
- // The contract with the human: a workflow can NEVER ask a question mid-run, so
10
- // everything decisional is moved to the edges —
11
- // · UPSTREAM: the spec must be frozen and self-sufficient (design links in the
12
- // front-matter, complete §5 contract). A readiness gate checks this FIRST
13
- // and aborts with the list of gaps as `questions` before spending anything.
14
- // A well-run /brainstorm + /spec IS the answer sheet — the sharper it is,
15
- // the further the cycle runs with an empty questions array.
16
- // · DOWNSTREAM: the loop runs review→fix→review until ZERO open findings and
17
- // a PASS smoke (bounded by maxRounds + the token budget). Even a finding
18
- // that implies a CONTRACT change stays inside the loop: a lead-equivalent
19
- // agent re-authors spec §5 + the contract file (exactly what /fix does
20
- // conversationally — implementers still never touch it), the affected
21
- // surfaces re-dispatch, and the loop continues. Only what is genuinely
22
- // human comes back at the END, in the result's `questions` array (spec
23
- // ambiguities the readiness gate flagged, a hit round-cap/budget) — ready
24
- // to feed a follow-up fix/review loop if you decide to keep going.
25
- // /ship stays out on purpose: it is the outward-facing, irreversible gate and
26
- // keeps its human confirmation. A SHIP exit ticks the DoD + stamps the
27
- // freshness gate, so `/ship <id>` right after is a straight shot.
28
- //
29
- // Loop economics: when smoke is opted in it runs CONCURRENTLY with review each
30
- // round (both observe, neither edits); fix rounds re-dispatch only the surfaces
31
- // owning findings;
32
- // the loop is bounded by maxRounds AND by the session token budget if one is
33
- // set. Disk state stays pipeline-coherent: reports staged to specs/reports/,
34
- // unresolved findings appended to the spec's ## Remediation — a conversational
35
- // /fix can always pick up where the workflow stopped.
36
-
37
- export const meta = {
38
- name: 'cohorte-cycle',
39
- description: 'Full cohorte dev cycle: contract, parallel build, then bounded review→fix rounds (smoke opt-in via args.smoke); deferred questions in the output, never mid-run',
40
- whenToUse: 'Only when the human explicitly asks to run the full dev-cycle workflow on a FROZEN spec. args = {feature: "<feature_id>", maxRounds?: 5, smoke?: true}.',
41
- phases: [
42
- { title: 'Profile', detail: 'PIPELINE.md → JSON via profile-reader', model: 'haiku' },
43
- { title: 'Ready', detail: 'spec frozen + self-sufficient, or abort with the gaps', model: 'haiku' },
44
- { title: 'Contract', detail: 'author the frozen contract from spec §5' },
45
- { title: 'Build', detail: 'one implementer per surface, parallel' },
46
- { title: 'Verify', detail: 'per round: preflight → review + cross-check (∥ smoke if opted in)' },
47
- { title: 'Fix', detail: 'per round: re-dispatch only the surfaces with findings' },
48
- { title: 'Close', detail: 'reports, Remediation/DoD, freshness stamp, metrics', model: 'haiku' },
49
- ],
50
- }
51
-
52
- const feature = typeof args === 'string' ? args.trim() : args && args.feature
53
- if (!feature) throw new Error('cohorte-cycle needs args = {feature: "<feature_id>"}')
54
- // Runaway protection, not a target — the loop's real exit is 0 findings (+ PASS if smoking).
55
- const MAX_ROUNDS = Math.max(1, (args && args.maxRounds) || 5)
56
- // Smoke is the human's call: booting infra every round is expensive, and lib-only
57
- // projects have nothing to smoke. Off ⇒ the cycle verifies by review alone, the
58
- // runtime-flow DoD boxes stay unticked, and /smoke remains available standalone.
59
- const SMOKE_ON = !!(args && args.smoke)
60
-
61
- const questions = [] // every deferred human decision ends up here — emitted at the END
62
- const contractChanges = [] // contract re-authorings the loop performed (info, not questions)
63
-
64
- const PROFILE = { type: 'object', additionalProperties: true }
65
- const READY = {
66
- type: 'object', required: ['frozen', 'gaps', 'designLinks'], additionalProperties: false,
67
- properties: {
68
- frozen: { type: 'boolean', description: 'front-matter status is frozen or in-review' },
69
- gaps: { type: 'array', items: { type: 'string' }, description: 'anything the cycle would have had to ask about' },
70
- designLinks: { type: 'string', description: 'the design_files links, comma-joined, or "none"' },
71
- },
72
- }
73
- const PREFLIGHT = {
74
- type: 'object', required: ['pass'], additionalProperties: false,
75
- properties: { pass: { type: 'boolean' }, tail: { type: 'string' } },
76
- }
77
- const STAGE = {
78
- type: 'object', required: ['surfaces'], additionalProperties: false,
79
- properties: {
80
- surfaces: {
81
- type: 'array',
82
- items: {
83
- type: 'object', required: ['key', 'diff', 'files'], additionalProperties: false,
84
- properties: {
85
- key: { type: 'string' }, diff: { type: 'string' },
86
- files: { type: 'array', items: { type: 'string' } },
87
- },
88
- },
89
- },
90
- },
91
- }
92
- const FINDING = {
93
- type: 'object', required: ['severity', 'file', 'line', 'kind', 'problem', 'fix'],
94
- additionalProperties: false,
95
- properties: {
96
- severity: { enum: ['CRITICAL', 'HIGH', 'MEDIUM', 'LOW'] },
97
- file: { type: 'string' }, line: { type: 'integer' },
98
- kind: { enum: ['spec-violation', 'quality', 'security'] },
99
- problem: { type: 'string' }, fix: { type: 'string' },
100
- },
101
- }
102
- const REPORT = {
103
- type: 'object', required: ['verdict', 'findings'], additionalProperties: false,
104
- properties: {
105
- verdict: { enum: ['SHIP', 'REVISE', 'BLOCK'] },
106
- findings: { type: 'array', maxItems: 20, items: FINDING },
107
- overflow: { type: 'integer' },
108
- },
109
- }
110
- const VERDICT = {
111
- type: 'object', required: ['refuted', 'reason'], additionalProperties: false,
112
- properties: { refuted: { type: 'boolean' }, reason: { type: 'string' } },
113
- }
114
- const SMOKE = {
115
- type: 'object', required: ['pass', 'failures'], additionalProperties: false,
116
- properties: {
117
- pass: { type: 'boolean' },
118
- failures: {
119
- type: 'array', maxItems: 10,
120
- items: { type: 'string', description: '❌ <flow/endpoint> · expected <x> got <y> · file hint if known' },
121
- },
122
- },
123
- }
124
-
125
- // ── Phase 0 — profile ────────────────────────────────────────────────────────
126
- phase('Profile')
127
- const profile = await agent(
128
- 'Return this project\'s PIPELINE.md `yaml pipeline-profile` block as JSON, per your instructions.',
129
- { agentType: 'profile-reader', label: 'profile', schema: PROFILE, effort: 'low' },
130
- )
131
- if (!profile || profile.error) {
132
- return { outcome: 'ABORTED', questions: [`profile unreadable: ${(profile && profile.error) || 'no return'} — run /init-pipeline or /doctor`] }
133
- }
134
- const surfaces = Array.isArray(profile.surfaces) ? profile.surfaces : []
135
- const byKey = Object.fromEntries(surfaces.map(s => [s.key, s]))
136
- const cmds = profile.commands || {}
137
- const base = (profile.vcs && profile.vcs.default_branch) || 'main'
138
- const contract = profile.contract || {}
139
- const contractFile = contract.enabled ? `${contract.path}/${feature}.${contract.ext || 'ts'}` : ''
140
- const quiet = (q, full) => (q && !String(q).startsWith('<') ? q : full ? `${full} 2>&1 | tail -40` : '')
141
- const checks = [cmds.typecheck, quiet(cmds.lint_quiet, cmds.lint), quiet(cmds.test_quiet, cmds.test)]
142
- .filter(c => c && !String(c).startsWith('<'))
143
-
144
- // ── Phase 1 — readiness gate: the spec must pre-answer everything ───────────
145
- phase('Ready')
146
- const ready = await agent(
147
- `Readiness check for the cohorte full-cycle workflow on spec specs/${feature}.md — the run can ask ` +
148
- 'NOTHING mid-flight, so list every gap a lead would normally have to ask about. Read the spec ' +
149
- '(front-matter + §5 contract + per-surface tasks + §9 acceptance) and report:\n' +
150
- '- frozen: front-matter status is `frozen` or `in-review`\n' +
151
- '- gaps: e.g. status draft/missing; §5 contract absent or with open placeholders/TODOs; a surface\'s ' +
152
- 'tasks empty while §5 clearly implies work there; `design_files` empty while a uses_design surface ' +
153
- `has tasks (uses_design surfaces: ${surfaces.filter(s => s.uses_design).map(s => s.key).join(', ') || 'none'}); ` +
154
- 'open `## Remediation` items that imply a contract change\n' +
155
- '- designLinks: the design_files links comma-joined, or "none"',
156
- { model: 'haiku', label: 'ready', schema: READY, effort: 'low' },
157
- )
158
- if (!ready || !ready.frozen) {
159
- return {
160
- outcome: 'NOT-READY',
161
- questions: ((ready && ready.gaps) || ['spec unreadable']).concat(
162
- ['freeze the spec first: /spec (the cycle only runs on a frozen, self-sufficient spec)']),
163
- }
164
- }
165
- questions.push(...(ready.gaps || [])) // non-blocking gaps ride along as deferred questions
166
- const designLinks = ready.designLinks || 'none'
167
-
168
- // ── Phase 2 — author the contract (lead-equivalent, the single sync channel) ─
169
- if (contract.enabled) {
170
- phase('Contract')
171
- const c = await agent(
172
- `Author the frozen contract for cohorte feature ${feature}, acting as the lead (/build §2): from ` +
173
- `spec specs/${feature}.md §5, write/update ${contractFile} in the profile's mechanism ` +
174
- `(${contract.mechanism})${contract.index ? `, exported from ${contract.index}` : ''}. ` +
175
- 'Postcondition: the file exists and typechecks. Implementers import it read-only. ' +
176
- 'Return one line: what you wrote, or what blocked you.',
177
- { label: 'contract' },
178
- )
179
- if (c == null) return { outcome: 'ABORTED', questions: questions.concat(['contract authoring failed — run /build manually']) }
180
- }
181
-
182
- // ── Phase 3 — build: one implementer per surface, parallel ───────────────────
183
- phase('Build')
184
- const buildPrompt = s =>
185
- `Implement the **${s.key}** surface for feature \`${feature}\`. Read \`PIPELINE.md\` first. ` +
186
- `Spec: \`specs/${feature}.md\`. Contract: \`${contractFile || 'none — spec §5 prose is the contract'}\` ` +
187
- '(import read-only). Work test-first. Touch only `' + s.path + '`. Need the current state of your ' +
188
- `tree? Compute it yourself: \`git diff ${base} -- ${s.path}\`. Return the handoff in the format ` +
189
- `your agent instructions define. Design files: ${s.uses_design ? designLinks : 'none'}. ` +
190
- 'Open Remediation items for YOUR surface (`none` ⇒ first build, implement the spec\'s tasks for ' +
191
- 'your surface): none'
192
- const handoffs = await parallel(surfaces.map(s => () =>
193
- agent(buildPrompt(s), { agentType: s.agent, label: `build:${s.key}`, phase: 'Build' })
194
- // agent() resolves to null (never throws) when a subagent dies — wrapping
195
- // unconditionally would hide every death from the `dead` check below.
196
- .then(h => (h == null ? null : { key: s.key, handoff: h }))))
197
- const built = handoffs.filter(Boolean)
198
- const dead = surfaces.filter(s => !built.some(b => b.key === s.key)).map(s => s.key)
199
- if (dead.length) questions.push(`implementer(s) died during build: ${dead.join(', ')} — inspect and re-run /build if their surface matters`)
200
-
201
- // ── helpers for the verify/fix rounds ────────────────────────────────────────
202
- const surfaceOf = file => {
203
- for (const s of surfaces) if (file && String(file).startsWith(String(s.path))) return s.key
204
- return null
205
- }
206
- const itemLine = f => `- [ ] ${f.severity} · ${f.file}:${f.line} · ${f.kind} · ${f.fix}`
207
- const runPreflight = () => agent(
208
- `Run the cohorte deterministic pre-flight for feature ${feature} in ONE Bash call:\n` +
209
- `<core>/pipeline/scripts/preflight.sh specs/reports/${feature}.preflight.txt ` +
210
- checks.map(c => JSON.stringify(c)).join(' ') + '\n' +
211
- '(<core> = .claude if .claude/pipeline/scripts/preflight.sh exists, else ~/.claude; script absent ⇒ ' +
212
- 'run the quoted commands yourself into the same file, stopping at the first failure.) ' +
213
- 'pass=true only on fully green; on failure put the raw last 40 lines in tail, verbatim.',
214
- { model: 'haiku', label: 'preflight', phase: 'Verify', schema: PREFLIGHT, effort: 'low' },
215
- )
216
-
217
- let verdict = null
218
- let smokePass = false
219
- let smokeFails = [] // last round's smoke failures — Close reports the real count
220
- let open = [] // findings still open, each {severity,file,line,kind,problem,fix,src}
221
- let unreviewed = [] // surfaces whose reviewer died this round — they carry NO verdict
222
- let rounds = 0
223
- let fixRounds = 0 // fix dispatches performed — the close step's `fix` usage ping needs it
224
- let preflightRed = false // did the LAST round end on a red preflight (open/verdict then stale)?
225
-
226
- // ── Phases 4/5 — bounded verify → fix rounds ────────────────────────────────
227
- while (rounds < MAX_ROUNDS && (!budget.total || budget.remaining() > 30000)) {
228
- rounds++
229
- log(`Round ${rounds}/${MAX_ROUNDS}`)
230
- phase('Verify')
231
-
232
- // 4a. preflight — mechanical red short-circuits straight to a fix round
233
- const pre = await runPreflight()
234
- preflightRed = !pre || !pre.pass
235
- if (preflightRed) {
236
- const tail = (pre && pre.tail) || ''
237
- const hit = new Set(surfaces.filter(s => tail.includes(String(s.path))).map(s => s.key))
238
- const targets = (hit.size ? [...hit] : built.map(b => b.key)).filter(k => byKey[k])
239
- // No target = nobody to dispatch, so the next round finds the same red gates
240
- // and spins again: the loop would burn every remaining round doing literally
241
- // nothing, then report a stale verdict. Stop and say why instead.
242
- if (!targets.length) {
243
- questions.push(
244
- 'the mechanical gates are RED but no surface owns the failure (nothing in the tail matches a ' +
245
- `surface path, and no implementer survived the build) — fix it by hand, then rerun /cycle ${feature}. ` +
246
- `Failure tail:\n${tail.slice(-1500)}`)
247
- break
248
- }
249
- log(`Preflight red — mechanical fix round on: ${targets.join(', ')}`)
250
- phase('Fix')
251
- fixRounds++
252
- await parallel(targets.map(k => () => agent(
253
- `Fix loop for feature \`${feature}\` on your surface (**${k}**). Read \`PIPELINE.md\` first. ` +
254
- `Contract: \`${contractFile || 'spec §5'}\` (read-only). Touch only \`${byKey[k].path}\`. ` +
255
- 'The mechanical gates (typecheck/lint/tests) are RED. Raw failure tail below — fix exactly ' +
256
- 'what concerns your tree, then rerun your quiet commands until green. Failures:\n' + tail,
257
- { agentType: byKey[k].agent, label: `fix:${k}`, phase: 'Fix' })))
258
- continue
259
- }
260
-
261
- // 4b. stage the diff once for the reviewers
262
- const staged = await agent(
263
- `Stage review inputs for cohorte feature ${feature}. Diff base: ${base}. Surfaces: ` +
264
- surfaces.map(s => `${s.key} → ${s.path}`).join(' · ') + '.\n' +
265
- `1. git diff ${base} --stat > specs/reports/${feature}.stat.txt (never print it). ` +
266
- '2. Group changed paths by surface prefix (unowned paths = shared remainder → most relevant surface). ' +
267
- `3. Per touched surface only: git diff ${base} -- <path> [remainder] > specs/reports/${feature}.<key>.diff. ` +
268
- '4. Return the touched surfaces (empty array if no diff).',
269
- { model: 'haiku', label: 'stage-diff', phase: 'Verify', schema: STAGE, effort: 'low' },
270
- )
271
- // "The staging agent died" and "there is genuinely no diff" are different facts;
272
- // conflating them diagnosed a dead agent as a wrong branch.
273
- if (!staged) { questions.push('the diff-staging agent died — nothing could be reviewed this round; rerun the cycle'); break }
274
- const touched = staged.surfaces || []
275
- if (!touched.length) { questions.push(`no diff against ${base} after build — wrong branch/checkout?`); break }
276
-
277
- // 4c. review(+cross-check), ∥ smoke only when the human opted in — both observe, neither edits
278
- const [smoke, reviewed] = await parallel([
279
- () => !SMOKE_ON ? Promise.resolve(null) : agent(
280
- 'Smoke-test one feature, per your agent instructions (bring it up, exercise the contract + §8 UI ' +
281
- 'flows, stage the full SMOKE REPORT, tear down; return the capped shape — pass + max 10 one-line ' +
282
- `failures with a file hint when you have one). — Variable slots: feature ${feature} · spec: ` +
283
- `specs/${feature}.md · contract: ${contractFile || 'spec §5'} · report: specs/reports/${feature}.smoke.md · ` +
284
- 'checkout: the current working directory (already the feature checkout) · ports/db: the profile/slot defaults',
285
- { agentType: 'smoke', label: 'smoke', phase: 'Verify', schema: SMOKE }),
286
- () => pipeline(
287
- touched,
288
- s => agent(
289
- 'Review one feature surface against its frozen spec, per your agent instructions (staged diff ' +
290
- 'FIRST; capped shape — max 20 one-line findings, no code excerpts). — Variable slots: ' +
291
- `feature ${feature} · scope: the ${s.key} surface only · spec: specs/${feature}.md · ` +
292
- `staged diff: ${s.diff} · changed files: ${s.files.join(', ')}`,
293
- { agentType: 'review', label: `review:${s.key}`, phase: 'Verify', schema: REPORT }),
294
- async (report, s) => {
295
- if (!report) return null
296
- const hard = report.findings.filter(f => f.severity === 'CRITICAL' || f.kind === 'security')
297
- const rest = report.findings.filter(f => !hard.includes(f))
298
- if (!hard.length) return { key: s.key, kept: rest }
299
- const votes = await parallel(hard.map(f => () => agent(
300
- 'Adversarially verify ONE review finding — REFUTE it if you can (code, guard, test, or spec ' +
301
- 'shows it does not hold); uncertain ⇒ refuted=false. — Finding: ' +
302
- `[${f.severity}/${f.kind}] ${f.file}:${f.line} — ${f.problem} (fix: ${f.fix}). ` +
303
- `Feature ${feature} · spec: specs/${feature}.md · staged diff: ${s.diff}`,
304
- { agentType: 'review', label: `verify:${s.key}`, phase: 'Verify', schema: VERDICT },
305
- ).then(v => ({ f, refuted: !!(v && v.refuted) }))))
306
- return { key: s.key, kept: rest.concat(votes.filter(Boolean).filter(v => !v.refuted).map(v => v.f)) }
307
- },
308
- ),
309
- ])
310
-
311
- smokePass = !!(smoke && smoke.pass)
312
- smokeFails = (smoke && smoke.failures) || []
313
- const reviewedOk = (reviewed || []).filter(Boolean)
314
- // A reviewer that DIED returns null, and a dead reviewer yields zero findings —
315
- // byte-identical to a clean surface. Unchecked, "every reviewer crashed" scores
316
- // SHIP and this loop exits SHIP-READY, ticking the DoD and stamping the freshness
317
- // gate over code nobody read. Track the surfaces that carry no verdict.
318
- unreviewed = touched.filter(s => !reviewedOk.some(r => r.key === s.key)).map(s => s.key)
319
- open = reviewedOk.flatMap(r => r.kept.map(f => ({ ...f, src: r.key })))
320
- verdict = open.some(f => f.kind === 'security') ? 'BLOCK'
321
- : (open.length || unreviewed.length) ? 'REVISE' : 'SHIP'
322
- log(`Round ${rounds}: review ${verdict} (${open.length} finding(s)` +
323
- `${unreviewed.length ? `, ${unreviewed.length} surface(s) UNREVIEWED: ${unreviewed.join(', ')}` : ''}) · ` +
324
- `smoke ${!SMOKE_ON ? 'SKIPPED' : smokePass ? 'PASS' : `FAIL:${smokeFails.length}`}`)
325
-
326
- // The loop's contract is ZERO open findings on FULLY reviewed surfaces + a smoke
327
- // PASS when smoking — a SHIP verdict alone (which older revisions granted despite
328
- // HIGH/MEDIUM leftovers) is not enough. Smoke off ⇒ review alone decides, at the
329
- // human's explicit risk.
330
- if (!open.length && !unreviewed.length && (smokePass || !SMOKE_ON)) break
331
- if (rounds >= MAX_ROUNDS) break
332
- // Nothing to fix, but a reviewer died: spend the next round re-reviewing rather
333
- // than dispatching a fix round with no items (which would fall through to the
334
- // "findings map to no surface" abort and end the run on a misleading question).
335
- if (!open.length && !smokeFails.length && unreviewed.length) {
336
- log(`No findings, but ${unreviewed.join(', ')} went unreviewed — retrying the review round`)
337
- continue
338
- }
339
-
340
- // 5. fix round. Contract-impacting findings stay INSIDE the loop: a
341
- // lead-equivalent agent re-authors spec §5 + the contract file (implementers
342
- // still never touch it — same division of labor as conversational /fix §1),
343
- // and the surfaces it names re-dispatch against the updated shapes.
344
- phase('Fix')
345
- fixRounds++
346
- const contractFindings = contractFile
347
- ? open.filter(f => String(f.file).startsWith(String(contract.path))) : []
348
- let rippleSurfaces = []
349
- if (contractFindings.length) {
350
- const cf = await agent(
351
- `Act as the cohorte lead on a contract change for feature ${feature} (exactly /fix §1's contract ` +
352
- `check): the findings below show the frozen contract is wrong. Update spec specs/${feature}.md §5 ` +
353
- `accordingly, then re-author ${contractFile} (mechanism: ${contract.mechanism}` +
354
- `${contract.index ? `, exported from ${contract.index}` : ''}) until it typechecks. Findings:\n` +
355
- contractFindings.map(f => `- ${f.file}:${f.line} — ${f.problem} (fix: ${f.fix})`).join('\n') + '\n' +
356
- 'Return ONE line: `<what changed> || <comma-separated surface keys that consume the changed shapes>` ' +
357
- `(surfaces: ${surfaces.map(s => s.key).join(', ')}; when in doubt list them all).`,
358
- { label: 'contract-fix', phase: 'Fix' },
359
- )
360
- // A DEAD contract agent (agent() ⇒ null) must not be reported as a successful
361
- // re-authoring: doing so both fabricates a `contractChanges` entry and hands
362
- // every consuming surface a CRITICAL "the contract was RE-AUTHORED — realign"
363
- // item pointing at a file nobody touched. Same failure shape as a dead
364
- // reviewer scoring SHIP.
365
- if (cf == null) {
366
- questions.push(
367
- `the contract needed a change (${contractFindings.length} finding(s) under ${contract.path}) but the ` +
368
- `contract agent died — the contract is UNCHANGED; re-run /fix ${feature} or author it yourself`)
369
- } else {
370
- const [what, keys] = String(cf).split('||').map(x => x && x.trim())
371
- contractChanges.push(what || 'contract re-authored (agent gave no summary)')
372
- rippleSurfaces = (keys ? keys.split(',').map(k => k.trim()) : surfaces.map(s => s.key))
373
- .filter(k => byKey[k])
374
- }
375
- }
376
-
377
- // Group the remaining findings by owning surface; smoke failures ride with
378
- // the surface their file hint maps to, else the surface with most findings.
379
- const perSurface = {}
380
- for (const k of rippleSurfaces) {
381
- (perSurface[k] = perSurface[k] || []).push(
382
- `- [ ] CRITICAL · ${contractFile} · spec-violation · the contract was RE-AUTHORED this round (${contractChanges[contractChanges.length - 1]}) — re-read it and realign your surface's implementation + tests`)
383
- }
384
- // A finding whose file sits under no surface path AND whose reporting surface
385
- // is not a live key has no owner: it stays in `open` (so the loop can never
386
- // exit clean) while nobody is ever dispatched to fix it — a guaranteed burn to
387
- // the round cap. Surface them instead of dropping them silently.
388
- const orphaned = []
389
- for (const f of open.filter(f => !contractFindings.includes(f))) {
390
- const k = surfaceOf(f.file) || f.src
391
- if (byKey[k]) (perSurface[k] = perSurface[k] || []).push(itemLine(f))
392
- else orphaned.push(f)
393
- }
394
- if (orphaned.length) {
395
- questions.push(
396
- `${orphaned.length} finding(s) map to no surface and were never dispatched — fix them by hand ` +
397
- `or give their tree a surface in PIPELINE.md: ` +
398
- orphaned.slice(0, 5).map(f => `${f.file}:${f.line}`).join(', ') +
399
- (orphaned.length > 5 ? `, +${orphaned.length - 5} more` : ''))
400
- }
401
- const fallbackKey = Object.keys(perSurface).sort((a, b) => perSurface[b].length - perSurface[a].length)[0]
402
- || (built[0] && built[0].key)
403
- for (const line of smokeFails) {
404
- const k = surfaceOf((line.match(/[\w./-]+\.\w{1,4}/) || [])[0]) || fallbackKey
405
- if (byKey[k]) (perSurface[k] = perSurface[k] || []).push(`- [ ] HIGH · runtime · smoke failure · ${line}`)
406
- }
407
- if (!Object.keys(perSurface).length) { questions.push('open findings map to no surface — run /fix manually'); break }
408
- await parallel(Object.entries(perSurface).map(([k, items]) => () => agent(
409
- `Fix loop for feature \`${feature}\` on your surface (**${k}**). Read \`PIPELINE.md\` first. ` +
410
- `Contract: \`${contractFile || 'spec §5'}\` (read-only — report mismatches, never edit it). Touch only ` +
411
- `\`${byKey[k].path}\`. Need your tree's current state? \`git diff ${base} -- ${byKey[k].path}\`. ` +
412
- 'Fix exactly the open items below (self-contained — read only the files they name), then rerun your ' +
413
- 'quiet commands until green. Return the handoff your agent instructions define. Design files: ' +
414
- `${byKey[k].uses_design ? designLinks : 'none'}. Open items for YOUR surface:\n` + items.join('\n'),
415
- { agentType: byKey[k].agent, label: `fix:${k}`, phase: 'Fix' })))
416
- }
417
-
418
- const smokeOk = !SMOKE_ON || smokePass
419
- const smokeLabel = !SMOKE_ON ? 'SKIPPED' : smokePass ? 'PASS' : 'FAIL'
420
- if (preflightRed) {
421
- questions.push('the last round ended on a RED preflight — the reported verdict/findings are from the previous round and may already be fixed; rerun /review after the mechanical fixes land')
422
- }
423
- // Only meaningful when the last round actually reviewed: after a preflight-red
424
- // round `unreviewed` still holds the PREVIOUS round's value, and preflightRed's
425
- // own question already says the reported state is one round stale.
426
- if (unreviewed.length && !preflightRed) {
427
- questions.push(`no reviewer completed on: ${unreviewed.join(', ')} — those surfaces are NOT reviewed (the verdict covers the others only); rerun /review ${feature}`)
428
- }
429
- if (rounds >= MAX_ROUNDS && !(verdict === 'SHIP' && smokeOk)) {
430
- questions.push(`round cap (${MAX_ROUNDS}) reached with ${open.length} finding(s) open — rerun the cycle (maxRounds higher) or continue with /fix ${feature} + /review`)
431
- }
432
- if (budget.total && budget.remaining() <= 30000 && !(verdict === 'SHIP' && smokeOk)) {
433
- questions.push('token budget nearly spent — cycle stopped early; rerun the cycle or continue conversationally')
434
- }
435
-
436
- // ── Phase 6 — close: reports, spec bookkeeping, freshness stamp, metrics ────
437
- phase('Close')
438
- const success = verdict === 'SHIP' && smokeOk && !unreviewed.length
439
- const findingLine = f => `- **[${f.severity}]** \`${f.file}:${f.line}\` · ${f.kind} · ${f.problem} → **Fix:** ${f.fix}`
440
- const reportBody = [
441
- '# REVIEW REPORT', `feature_id: ${feature} · merged by cohorte-cycle workflow (round ${rounds})`, '',
442
- `Verdict: ${verdict || 'NOT-REACHED'} · smoke: ${smokeLabel}`, '', '## Findings', '',
443
- open.length ? open.map(findingLine).join('\n') : 'None.',
444
- ...(unreviewed.length ? ['', '## NOT reviewed (reviewer died — no verdict on these)', '',
445
- unreviewed.map(k => `- \`${k}\` — rerun /review ${feature}`).join('\n')] : []),
446
- ].join('\n')
447
- // Per-surface results for the metrics line. `surfaces` means SURFACES: putting the
448
- // run summary (rounds/verdict/smoke) in there made the dashboard render them as
449
- // three phantom surface rows and score `rounds:"1"` as a failed surface.
450
- const surfaceResults = built.map(b => `"${b.key}":"${
451
- unreviewed.includes(b.key) ? 'error' : `${verdict || 'none'}:${open.filter(f => f.src === b.key).length}`}"`).join(',')
452
- const closed = await agent(
453
- `Close a cohorte cycle run for feature ${feature}, mechanically:\n` +
454
- `1. Write EXACTLY this to specs/reports/${feature}.md (overwrite):\n<<<REPORT\n${reportBody}\nREPORT\n` +
455
- (success
456
- ? `2. In specs/${feature}.md tick the DoD boxes the cycle verified (spec conformance + copy language — ` +
457
- 'review SHIP; tests/lint/typecheck — green preflight; ' +
458
- (SMOKE_ON ? 'runtime flows — smoke PASS' : 'runtime flows — LEAVE UNTICKED, smoke was skipped this run') +
459
- '); leave anything ' +
460
- 'unverified unticked. 3. Stamp the freshness gate in the spec front-matter, exactly as /review §3 ' +
461
- `does: BASE=$(git merge-base ${base} HEAD); reviewed_base: $BASE; reviewed_digest: ` +
462
- `$(git diff $BASE -- . ':(exclude)specs/' | sha256sum | cut -c1-16).\n` :
463
- `2. Append the open findings to specs/${feature}.md \`## Remediation\` under a subheading ` +
464
- `\`### cohorte-cycle round ${rounds}\`, one \`- [ ]\` line each (so a conversational /fix picks them ` +
465
- 'up):\n' + (open.map(itemLine).join('\n') || '(none — see questions in the workflow result)') + '\n' +
466
- `3. Set the front-matter status: in-review.\n`) +
467
- `4. Append ONE metrics line to $(dirname "$(git rev-parse --git-common-dir)")/.claude/pipeline-metrics.jsonl: ` +
468
- `{"ts":"<ISO now>","feature":"${feature}","phase":"cycle","seconds":0,"rounds":${rounds},"smoke":"${smokeLabel}","surfaces":{${surfaceResults}}}\n` +
469
- '5. Chain the opt-in usage pings (all funnel phases this run executed, 0 seconds each; ' +
470
- '<core> = .claude if .claude/pipeline/scripts/telemetry-send.sh exists, else ~/.claude — script on neither ⇒ skip the pings): ' +
471
- // One result per DECLARED surface, not per survivor: `built` holds only the
472
- // implementers that returned, so mapping over it reported 2-of-3 as "ok,ok" and
473
- // the dead one vanished from the funnel entirely.
474
- `<core>/pipeline/scripts/telemetry-send.sh build "${feature}" 0 "${
475
- surfaces.map(s => (built.some(b => b.key === s.key) ? 'ok' : 'error')).join(',') || 'error'}" || true; ` +
476
- (SMOKE_ON ? `<core>/pipeline/scripts/telemetry-send.sh smoke "${feature}" 0 "${smokePass ? 'PASS' : 'FAIL:' + smokeFails.length}" || true; ` : '') +
477
- (fixRounds ? `<core>/pipeline/scripts/telemetry-send.sh fix "${feature}" 0 "rounds:${fixRounds}" || true; ` : '') +
478
- `<core>/pipeline/scripts/telemetry-send.sh review "${feature}" 0 "${verdict || 'none'}:${open.length}" || true\n` +
479
- 'Return the single word: done.',
480
- { model: 'haiku', label: 'close', effort: 'low' },
481
- )
482
-
483
- // The close agent is what actually writes the report, ticks the DoD, stamps the
484
- // freshness gate and appends the metrics. If it died, NONE of that is on disk —
485
- // and "SHIP-READY · /ship is a straight shot" would be a claim about a stamp that
486
- // was never written (/ship's freshness gate skips silently when the fields are
487
- // absent, so the human would ship on it).
488
- const closeOk = closed != null && /done/i.test(String(closed))
489
- if (!closeOk) {
490
- questions.push(
491
- 'the close agent died — the report, DoD ticks, freshness stamp and metrics were NEVER written. ' +
492
- 'The verdict above is real, but nothing landed on disk: rerun the cycle, or run /review ' +
493
- `${feature} to re-stamp before /ship.`)
494
- }
495
-
496
- return {
497
- outcome: success && closeOk ? 'SHIP-READY' : 'STOPPED',
498
- rounds,
499
- verdict: verdict || 'not reached',
500
- smoke: smokeLabel,
501
- contractChanges, // re-authorings the loop performed itself — review them in the diff
502
- unreviewedSurfaces: unreviewed, // reviewers that died — these carry NO verdict
503
- openFindings: open.slice(0, 10).map(f => `[${f.severity}] ${f.file}:${f.line} — ${f.problem}`),
504
- questions, // everything deferred to you — empty when /brainstorm + /spec did their job
505
- report: closeOk ? `specs/reports/${feature}.md` : '(NOT written — the close agent died)',
506
- next: !closeOk
507
- ? `nothing was written to disk (close agent died) — rerun the cycle, or /review ${feature} to re-stamp`
508
- : success
509
- ? (SMOKE_ON
510
- ? `/ship ${feature} — DoD ticked + freshness stamped, ship is a straight shot (human confirm stays)`
511
- : `/ship ${feature} — or /smoke ${feature} first: the cycle skipped it (opt-in), nobody has RUN this code; the runtime DoD boxes are unticked`)
512
- : `answer the questions, then rerun the cycle — or continue with /fix ${feature} + /review (Remediation is up to date)`,
513
- }