cohorte 2.10.1 → 3.0.0-dev.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.
Files changed (211) hide show
  1. package/README.md +7 -0
  2. package/assets/manifest.json +193 -0
  3. package/assets/migrations/config/README.md +12 -0
  4. package/assets/migrations/state/0001_init.sql +154 -0
  5. package/assets/prompts/README.md +11 -0
  6. package/assets/prompts/agents/fixer.md +7 -0
  7. package/assets/prompts/agents/implementer.md +12 -0
  8. package/assets/prompts/agents/reviewer.md +8 -0
  9. package/assets/prompts/agents/security-reviewer.md +7 -0
  10. package/assets/prompts/phases/build.md +5 -0
  11. package/assets/prompts/phases/review.md +5 -0
  12. package/assets/prompts/phases/test.md +5 -0
  13. package/assets/prompts/system.md +11 -0
  14. package/assets/schemas/agent-output.schema.json +225 -0
  15. package/assets/schemas/auth-status.schema.json +87 -0
  16. package/assets/schemas/command-result.schema.json +192 -0
  17. package/assets/schemas/commands.schema.json +952 -0
  18. package/assets/schemas/config.schema.json +2259 -0
  19. package/assets/schemas/doctor-report.schema.json +104 -0
  20. package/assets/schemas/events.schema.json +8514 -0
  21. package/assets/schemas/fake-script.schema.json +549 -0
  22. package/assets/schemas/inspect.schema.json +3133 -0
  23. package/assets/schemas/manifest.schema.json +79 -0
  24. package/assets/schemas/ownership.schema.json +52 -0
  25. package/assets/schemas/policy-verdict.schema.json +282 -0
  26. package/assets/schemas/project-model.schema.json +783 -0
  27. package/assets/schemas/project-status.schema.json +217 -0
  28. package/assets/schemas/reconcile-plan.schema.json +155 -0
  29. package/assets/schemas/run-diff.schema.json +177 -0
  30. package/assets/schemas/run-snapshot-manifest.schema.json +2104 -0
  31. package/assets/schemas/run-state.schema.json +1755 -0
  32. package/assets/schemas/runtime-capabilities.schema.json +1545 -0
  33. package/assets/schemas/sandbox-capabilities.schema.json +116 -0
  34. package/assets/schemas/skill.schema.json +87 -0
  35. package/assets/schemas/spec.schema.json +202 -0
  36. package/assets/schemas/tool-catalogue.schema.json +655 -0
  37. package/assets/schemas/trust-record.schema.json +38 -0
  38. package/assets/skills/README.md +9 -0
  39. package/assets/skills/inspect-surface.md +4 -0
  40. package/dist/agent-host.mjs +50 -0
  41. package/dist/agent-host.mjs.map +1 -0
  42. package/dist/bundle-manifest.json +102 -0
  43. package/dist/chunks/__host-DgGPyco9.mjs +18 -0
  44. package/dist/chunks/__host-DgGPyco9.mjs.map +1 -0
  45. package/dist/chunks/approve-Cv135UqI.mjs +19 -0
  46. package/dist/chunks/approve-Cv135UqI.mjs.map +1 -0
  47. package/dist/chunks/auth-BT5oVp76.mjs +132 -0
  48. package/dist/chunks/auth-BT5oVp76.mjs.map +1 -0
  49. package/dist/chunks/auth-D8rCVY33.mjs +84 -0
  50. package/dist/chunks/auth-D8rCVY33.mjs.map +1 -0
  51. package/dist/chunks/brainstorm-CU10T1tm.mjs +7 -0
  52. package/dist/chunks/brainstorm-CU10T1tm.mjs.map +1 -0
  53. package/dist/chunks/cancel-KV_cpsqS.mjs +15 -0
  54. package/dist/chunks/cancel-KV_cpsqS.mjs.map +1 -0
  55. package/dist/chunks/canonical-C8UxeEmB.mjs +57 -0
  56. package/dist/chunks/canonical-C8UxeEmB.mjs.map +1 -0
  57. package/dist/chunks/catalogue-BvwSrM_j.mjs +1549 -0
  58. package/dist/chunks/catalogue-BvwSrM_j.mjs.map +1 -0
  59. package/dist/chunks/compose-Cr0hP0qm.mjs +7303 -0
  60. package/dist/chunks/compose-Cr0hP0qm.mjs.map +1 -0
  61. package/dist/chunks/config-DefHA2Lw.mjs +96 -0
  62. package/dist/chunks/config-DefHA2Lw.mjs.map +1 -0
  63. package/dist/chunks/contract-B0ra2-bm.mjs +295 -0
  64. package/dist/chunks/contract-B0ra2-bm.mjs.map +1 -0
  65. package/dist/chunks/contract-vYbwYr0Y.mjs +123 -0
  66. package/dist/chunks/contract-vYbwYr0Y.mjs.map +1 -0
  67. package/dist/chunks/deny-ClTxSvHJ.mjs +15 -0
  68. package/dist/chunks/deny-ClTxSvHJ.mjs.map +1 -0
  69. package/dist/chunks/diff-Qw0II0sj.mjs +101 -0
  70. package/dist/chunks/diff-Qw0II0sj.mjs.map +1 -0
  71. package/dist/chunks/discover-30Ciya_0.mjs +20 -0
  72. package/dist/chunks/discover-30Ciya_0.mjs.map +1 -0
  73. package/dist/chunks/doctor-DfCrKpfT.mjs +222 -0
  74. package/dist/chunks/doctor-DfCrKpfT.mjs.map +1 -0
  75. package/dist/chunks/fix-D4Tj8RVA.mjs +18 -0
  76. package/dist/chunks/fix-D4Tj8RVA.mjs.map +1 -0
  77. package/dist/chunks/gc-BBXlBF5w.mjs +161 -0
  78. package/dist/chunks/gc-BBXlBF5w.mjs.map +1 -0
  79. package/dist/chunks/init-Okz4mlhW.mjs +30 -0
  80. package/dist/chunks/init-Okz4mlhW.mjs.map +1 -0
  81. package/dist/chunks/inspect-Buby4cQD.mjs +22 -0
  82. package/dist/chunks/inspect-Buby4cQD.mjs.map +1 -0
  83. package/dist/chunks/logs-Giq5sUau.mjs +25 -0
  84. package/dist/chunks/logs-Giq5sUau.mjs.map +1 -0
  85. package/dist/chunks/migrate-DRoFUg53.mjs +19 -0
  86. package/dist/chunks/migrate-DRoFUg53.mjs.map +1 -0
  87. package/dist/chunks/models-Btp6X2YZ.mjs +24 -0
  88. package/dist/chunks/models-Btp6X2YZ.mjs.map +1 -0
  89. package/dist/chunks/pause-BASBfKLO.mjs +16 -0
  90. package/dist/chunks/pause-BASBfKLO.mjs.map +1 -0
  91. package/dist/chunks/policy-DUIgzF3W.mjs +139 -0
  92. package/dist/chunks/policy-DUIgzF3W.mjs.map +1 -0
  93. package/dist/chunks/providers-Dx78imKj.mjs +19 -0
  94. package/dist/chunks/providers-Dx78imKj.mjs.map +1 -0
  95. package/dist/chunks/reconcile-BfD785gd.mjs +26 -0
  96. package/dist/chunks/reconcile-BfD785gd.mjs.map +1 -0
  97. package/dist/chunks/render-DyZr5Lgi.mjs +53 -0
  98. package/dist/chunks/render-DyZr5Lgi.mjs.map +1 -0
  99. package/dist/chunks/resume-GJQRb2Eb.mjs +15 -0
  100. package/dist/chunks/resume-GJQRb2Eb.mjs.map +1 -0
  101. package/dist/chunks/retry-CLrUem1H.mjs +15 -0
  102. package/dist/chunks/retry-CLrUem1H.mjs.map +1 -0
  103. package/dist/chunks/review-BDdjb6kB.mjs +17 -0
  104. package/dist/chunks/review-BDdjb6kB.mjs.map +1 -0
  105. package/dist/chunks/run-CgTWYlcd.mjs +82 -0
  106. package/dist/chunks/run-CgTWYlcd.mjs.map +1 -0
  107. package/dist/chunks/run-tool-C3rAv4XF.mjs +26 -0
  108. package/dist/chunks/run-tool-C3rAv4XF.mjs.map +1 -0
  109. package/dist/chunks/schema-C9QbUW4H.mjs +578 -0
  110. package/dist/chunks/schema-C9QbUW4H.mjs.map +1 -0
  111. package/dist/chunks/send-BdnJ85Nz.mjs +19 -0
  112. package/dist/chunks/send-BdnJ85Nz.mjs.map +1 -0
  113. package/dist/chunks/ship-DTuLGoaJ.mjs +19 -0
  114. package/dist/chunks/ship-DTuLGoaJ.mjs.map +1 -0
  115. package/dist/chunks/shutdown-BM92Iyv2.mjs +15 -0
  116. package/dist/chunks/shutdown-BM92Iyv2.mjs.map +1 -0
  117. package/dist/chunks/skip-DZG5kpcH.mjs +18 -0
  118. package/dist/chunks/skip-DZG5kpcH.mjs.map +1 -0
  119. package/dist/chunks/spec-Bq0P3jVh.mjs +20 -0
  120. package/dist/chunks/spec-Bq0P3jVh.mjs.map +1 -0
  121. package/dist/chunks/src-CId6kMsY.mjs +1689 -0
  122. package/dist/chunks/src-CId6kMsY.mjs.map +1 -0
  123. package/dist/chunks/src-CfAOde7C.mjs +5403 -0
  124. package/dist/chunks/src-CfAOde7C.mjs.map +1 -0
  125. package/dist/chunks/src-Cu3TxNob.mjs +783 -0
  126. package/dist/chunks/src-Cu3TxNob.mjs.map +1 -0
  127. package/dist/chunks/src-DcU4IXMe.mjs +117 -0
  128. package/dist/chunks/src-DcU4IXMe.mjs.map +1 -0
  129. package/dist/chunks/src-JxawBQBn.mjs +446 -0
  130. package/dist/chunks/src-JxawBQBn.mjs.map +1 -0
  131. package/dist/chunks/src-s7YRvk0t.mjs +2116 -0
  132. package/dist/chunks/src-s7YRvk0t.mjs.map +1 -0
  133. package/dist/chunks/status-SMjiOMYy.mjs +29 -0
  134. package/dist/chunks/status-SMjiOMYy.mjs.map +1 -0
  135. package/dist/chunks/tail-BuhT-2PH.mjs +25 -0
  136. package/dist/chunks/tail-BuhT-2PH.mjs.map +1 -0
  137. package/dist/chunks/update-BXIppsbi.mjs +16 -0
  138. package/dist/chunks/update-BXIppsbi.mjs.map +1 -0
  139. package/dist/cli.mjs +175 -0
  140. package/dist/cli.mjs.map +1 -0
  141. package/package.json +17 -32
  142. package/CHANGELOG.md +0 -1461
  143. package/bin/cli.js +0 -817
  144. package/bin/report.js +0 -141
  145. package/core/adapter/render.js +0 -415
  146. package/core/agents/implementer.template.md +0 -142
  147. package/core/agents/profile-reader.md +0 -50
  148. package/core/agents/release.md +0 -58
  149. package/core/agents/review.md +0 -179
  150. package/core/commands/cohorte-align-ds.md +0 -33
  151. package/core/commands/cohorte-audit.md +0 -58
  152. package/core/commands/cohorte-brainstorm.md +0 -70
  153. package/core/commands/cohorte-build.md +0 -219
  154. package/core/commands/cohorte-doctor.md +0 -194
  155. package/core/commands/cohorte-fix.md +0 -80
  156. package/core/commands/cohorte-fleet.md +0 -103
  157. package/core/commands/cohorte-init-pipeline.md +0 -31
  158. package/core/commands/cohorte-intake.md +0 -92
  159. package/core/commands/cohorte-patch.md +0 -118
  160. package/core/commands/cohorte-refactor.md +0 -59
  161. package/core/commands/cohorte-retro.md +0 -85
  162. package/core/commands/cohorte-review.md +0 -270
  163. package/core/commands/cohorte-ship.md +0 -122
  164. package/core/commands/cohorte-spec.md +0 -101
  165. package/core/commands/cohorte-update-pipeline.md +0 -165
  166. package/core/hooks/gate.py +0 -588
  167. package/core/runtimes/claude.json +0 -74
  168. package/core/runtimes/codex.json +0 -85
  169. package/core/runtimes/cursor.json +0 -76
  170. package/core/runtimes/gemini.json +0 -76
  171. package/core/runtimes/opencode.json +0 -73
  172. package/core/templates/agent-handoff.md +0 -34
  173. package/core/templates/brainstorm-return.md +0 -36
  174. package/core/templates/decisions.template.md +0 -42
  175. package/core/templates/design-brief.md +0 -44
  176. package/core/templates/patch.template.md +0 -86
  177. package/core/templates/pr-body.md +0 -25
  178. package/core/templates/review-feedback.md +0 -38
  179. package/core/templates/spec.template.md +0 -87
  180. package/core/templates/steps/init-pipeline/01-detect-stack.md +0 -44
  181. package/core/templates/steps/init-pipeline/02-interview-gaps.md +0 -55
  182. package/core/templates/steps/init-pipeline/03-draft-profile.md +0 -10
  183. package/core/templates/steps/init-pipeline/04-write-render.md +0 -145
  184. package/core/templates/steps/init-pipeline/05-report.md +0 -12
  185. package/core/workflows/audit.js +0 -240
  186. package/core/workflows/loop.js +0 -641
  187. package/core/workflows/refactor.js +0 -276
  188. package/core/workflows/review.js +0 -456
  189. package/install.ps1 +0 -103
  190. package/install.sh +0 -114
  191. package/lib/doctor.js +0 -534
  192. package/lib/runtime.js +0 -139
  193. package/lib/versions.js +0 -149
  194. package/lib/yaml.js +0 -129
  195. package/profile/PIPELINE.template.md +0 -259
  196. package/profile/SCHEMA.md +0 -735
  197. package/profile/cohorte.config.template.yaml +0 -43
  198. package/scripts/assert-gate-hook.mjs +0 -75
  199. package/scripts/kanban-move.sh +0 -313
  200. package/scripts/metrics/collect.mjs +0 -531
  201. package/scripts/metrics/prices.json +0 -42
  202. package/scripts/new-feature.sh.template +0 -98
  203. package/scripts/preflight.sh +0 -124
  204. package/scripts/remove-feature.sh.template +0 -56
  205. package/scripts/test-adapter.mjs +0 -437
  206. package/scripts/test-gate.mjs +0 -443
  207. package/scripts/test-kanban.mjs +0 -304
  208. package/scripts/test-lib.mjs +0 -356
  209. package/scripts/test-metrics.mjs +0 -156
  210. package/scripts/test-workflows.mjs +0 -758
  211. package/scripts/validate-core.mjs +0 -325
@@ -1,641 +0,0 @@
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
- // Token accounting — the one thing the conversational path cannot do (a lead cannot
215
- // read a subagent's token count) and a workflow can: budget.spent() is the runtime's
216
- // own output-token counter for this turn. Deltas between marks give an approximate
217
- // per-phase cost; the review CHILD workflow stamps its own review-phase line, so the
218
- // loop marks AROUND the child to keep build/fix deltas from double-counting it.
219
- const spent = () => {
220
- try {
221
- const v = (budget && typeof budget.spent === 'function') ? budget.spent() : 0
222
- return Number.isFinite(v) ? v : 0 // a NaN here would poison every metrics JSON downstream
223
- } catch { return 0 }
224
- }
225
-
226
- // kanban-move.sh is optional infrastructure — every prompt that runs it defines <core>
227
- // and tolerates its absence, per SCHEMA.md §Kanban.
228
- const CORE_DEF = '(<core> = .claude if .claude/pipeline/scripts/kanban-move.sh exists, else ~/.claude — ' +
229
- 'probe with test -f; absent on both ⇒ skip the kanban step, the board is optional.)'
230
- const METRICS_PATH = '$(dirname "$(git rev-parse --git-common-dir)")/.claude/pipeline-metrics.jsonl'
231
-
232
- // ── Phase 0 — profile ────────────────────────────────────────────────────────
233
- phase('Profile')
234
- const profile = unwrapProfile(await agent(
235
- 'Return this project\'s PIPELINE.md `yaml pipeline-profile` block as JSON, per your instructions.',
236
- { agentType: 'profile-reader', label: 'profile', schema: PROFILE, effort: 'low' },
237
- ))
238
- if (!profile || profile.error) {
239
- return { id: feature, outcome: 'abort', reason: 'precondition', rounds: 0, detail: `profile unreadable: ${(profile && profile.error) || 'profile-reader returned nothing'}` }
240
- }
241
- const surfaces = Array.isArray(profile.surfaces) ? profile.surfaces : []
242
- if (!surfaces.length) {
243
- return { id: feature, outcome: 'abort', reason: 'precondition', rounds: 0, detail: 'profile has no surfaces — nothing could be built or reviewed; run /cohorte-doctor' }
244
- }
245
- const byKey = Object.fromEntries(surfaces.map(s => [s.key, s]))
246
- const contract = profile.contract || {}
247
- const base = (profile.vcs && profile.vcs.default_branch) || 'main'
248
- const contractOn = contract.enabled !== false && contract.path && !String(contract.path).startsWith('<')
249
- const contractFilePath = contractOn ? `${contract.path}/${feature}.${contract.ext || 'ts'}` : ''
250
-
251
- // ── Phase 1 — preconditions: raw facts in, every verdict decided in script ───
252
- phase('Preconditions')
253
- const facts = await agent(
254
- `Collect RAW facts for the cohorte loop on feature ${feature} — mechanical reads only, no judgment, ` +
255
- 'never print file bodies. Use ONE Bash call chain where possible; file mtimes as unix epochs ' +
256
- '(stat -f %m on macOS, stat -c %Y on Linux), now = date +%s and date -u +%Y-%m-%dT%H:%M:%SZ.\n' +
257
- `- specs/${feature}.md: exists · front-matter \`status\` and \`kind\` (grep the first ~15 lines) · mtime · ` +
258
- 'front-matter `design_files` entries as a list · and, ONLY when kind is patch: contractDelta = "none" if ' +
259
- 'its §5 Contract delta section is `none`/absent, else "present".\n' +
260
- `- specs/reports/${feature}.readiness.json: exists · mtime · its verdict, gaps, surfaces fields (jq or grep).\n` +
261
- (contractOn ? `- ${contractFilePath}: exists.\n` : '- contractFile: report exists=false (the profile has no contract mechanism).\n') +
262
- `- specs/reports/${feature}.build.json: exists · mtime · its dead[] array.\n` +
263
- `- specs/reports/${feature}.loop.json: exists · mtime · the file content VERBATIM in raw.\n` +
264
- 'A file that does not exist ⇒ exists=false and omit its other fields.',
265
- { model: 'haiku', label: 'preconditions', schema: FACTS, effort: 'low' },
266
- )
267
- if (!facts) {
268
- return { id: feature, outcome: 'abort', reason: 'precondition', rounds: 0, detail: 'the precondition agent died — nothing was dispatched; re-run the loop workflow' }
269
- }
270
- let clock = (facts.now && facts.now.epoch) || 0
271
- const specMtime = (facts.spec && facts.spec.mtimeEpoch) || 0
272
-
273
- // Resume state is parsed BEFORE the freshness gates, because the baseline they compare
274
- // against comes from it. The loop EDITS the spec as it runs (status stamps, Remediation
275
- // appends, DoD ticks), so the spec's mtime moves past readiness.json on the very first
276
- // round — measured naively, the loop's own footprint would invalidate its own resume
277
- // ("readiness is older than the spec") the moment anything real happened. loop.json
278
- // therefore stores the spec mtime observed when the run STARTED, and an unfinished run
279
- // for this feature measures freshness against that baseline. A human who rewrites the
280
- // spec mid-run without re-entering through /cohorte-build is outside what mtimes can
281
- // distinguish — delete specs/reports/<id>.loop.json to force a fresh run.
282
- let resumed = null
283
- if (facts.loop && facts.loop.exists && facts.loop.raw) {
284
- try {
285
- const prev = JSON.parse(facts.loop.raw)
286
- if (prev && prev.id === feature && !prev.outcome) resumed = prev
287
- } catch { /* unparseable loop.json = no resume; a fresh run overwrites it */ }
288
- }
289
- const baselineMtime = (resumed && typeof resumed.specMtime === 'number') ? resumed.specMtime : specMtime
290
- const startedAt = (resumed && resumed.startedAt) || (facts.now && facts.now.iso) || ''
291
- // Freshness: a report older than the (baseline) spec describes a spec that no longer
292
- // exists — treat it exactly as absent (the mirror image of v2's missing-file-read-as-
293
- // empty bug).
294
- const fresh = f => !!(f && f.exists && typeof f.mtimeEpoch === 'number' && f.mtimeEpoch >= baselineMtime)
295
-
296
- const abortPre = detail => ({ id: feature, outcome: 'abort', reason: 'precondition', rounds: 0, detail })
297
-
298
- // 1. The spec, in a buildable state. `in-progress` is accepted because THIS script
299
- // stamps it — a resumed loop must not refuse its own footprint.
300
- if (!facts.spec.exists) return abortPre(`specs/${feature}.md does not exist — /cohorte-spec first`)
301
- const status = facts.spec.status || ''
302
- if (!['frozen', 'in-review', 'in-progress'].includes(status)) {
303
- return abortPre(`spec status is '${status || '(none)'}' — the loop needs frozen | in-review | in-progress ` +
304
- `(SCHEMA.md §Spec status). draft ⇒ /cohorte-spec; blocked ⇒ route by the spec's ## Remediation first; shipped ⇒ done already`)
305
- }
306
- // 2. A fresh readiness verdict — the gate /cohorte-build §1.6 wrote. NOT-READY is the one
307
- // outcome more passes cannot fix; stale-or-absent means nobody has judged THIS spec.
308
- if (!fresh(facts.readiness)) {
309
- return abortPre(`specs/reports/${feature}.readiness.json is ${facts.readiness.exists ? 'older than the spec' : 'absent'} — ` +
310
- `run /cohorte-build ${feature} once (its §1.6 writes the readiness verdict), then re-run the loop`)
311
- }
312
- if (facts.readiness.verdict === 'NOT-READY') {
313
- return { ...abortPre(`readiness verdict is NOT-READY — /cohorte-spec ${feature} must close these gaps first`), gaps: facts.readiness.gaps || [] }
314
- }
315
- if (!['READY', 'RESERVATIONS'].includes(facts.readiness.verdict || '')) {
316
- return abortPre(`readiness verdict is '${facts.readiness.verdict || '(none)'}' — not a verdict the loop can build on; re-run /cohorte-build ${feature}`)
317
- }
318
- // 3. The contract file — the single sync channel, lead-authored. A patch whose §5 is
319
- // `none` legitimately has no contract; everything else without one means /cohorte-build
320
- // §2 never ran, and authoring it is exactly the step a workflow must not do.
321
- const isPatch = facts.spec.kind === 'patch'
322
- if (contractOn && !facts.contractFile.exists && !(isPatch && facts.spec.contractDelta !== 'present')) {
323
- return abortPre(`contract file ${contractFilePath} is absent — /cohorte-build ${feature} authors it (lead-only, §2); the loop never will`)
324
- }
325
- // 4. Every surface the readiness verdict names must resolve in the profile. One that
326
- // does not is /cohorte-build §1.5's reconcile case — a human decision (new agent,
327
- // new tree owner), so name it and stop rather than dispatch around it.
328
- const wantedKeys = (facts.readiness.surfaces || []).filter(Boolean)
329
- if (!wantedKeys.length) return abortPre('readiness.json names no surfaces — nothing to dispatch; re-run /cohorte-build')
330
- const unknown = wantedKeys.filter(k => !byKey[k])
331
- if (unknown.length) {
332
- return abortPre(`readiness names surface(s) the profile does not own: ${unknown.join(', ')} — ` +
333
- `that is /cohorte-build §1.5's reconcile (a human renders the new agent); run /cohorte-build ${feature}, then re-run the loop`)
334
- }
335
-
336
- // Resume — loop.json is state, not memory (parsed above, before the gates). The
337
- // persisted history already carries the round being resumed when the run died after
338
- // its review — keep only rounds BEFORE the current one, or the re-review double-counts.
339
- let round = 1
340
- let lastKey = null
341
- let history = []
342
- if (resumed) {
343
- round = Math.max(1, Number(resumed.round) || 1)
344
- lastKey = Array.isArray(resumed.lastItems) ? resumed.lastItems.join('\n') : null
345
- history = (Array.isArray(resumed.history) ? resumed.history : []).filter(h => h && h.round < round)
346
- log(`Resuming ${feature} at round ${round} (unfinished loop.json)`)
347
- }
348
-
349
- // Readiness gaps (RESERVATIONS) inline into the dispatch of the surface they affect —
350
- // gap format: `<check>|<where>|<what>`; route by `<where>` when it names a surface key,
351
- // else give the gap to every dispatched surface (better a stated assumption twice than
352
- // an invented answer once).
353
- const gapsFor = key => (facts.readiness.gaps || [])
354
- .filter(g => { const w = String(g).split('|')[1]; return !w || !byKey[w] || w === key })
355
-
356
- // ── The one byte-stable dispatch template — /cohorte-build §3 and /cohorte-fix §2 ─────
357
- // Build rounds and fix rounds use the SAME prompt; only the trailing variable slots
358
- // differ, so every repeat hits the prompt-cache prefix, and the fix rounds can never
359
- // drift from the build (the drift nobody notices for months).
360
- const contractSlot = contractOn
361
- ? (isPatch && facts.spec.contractDelta !== 'present' ? 'none (kind: patch, no contract delta)' : `\`${contractFilePath}\``)
362
- : 'none — the spec prose is the sync channel'
363
- const dispatchPrompt = j =>
364
- `Implement the **${j.s.key}** surface for feature \`${feature}\`. Read \`PIPELINE.md\` first. ` +
365
- `Spec: \`specs/${feature}.md\`. Contract: ${contractSlot} (import read-only). Work test-first. ` +
366
- `Touch only \`${j.s.path}\`. Need the current state of your tree? Compute it yourself: ` +
367
- `git diff ${base} -- ${j.s.path}. Return the handoff in the format your agent instructions define. ` +
368
- `Design files: ${j.design}. ` +
369
- 'Open Remediation items for YOUR surface (self-contained — fix exactly these, reading only the files ' +
370
- `they name; \`none\` ⇒ first build, implement the spec's tasks for your surface): ${j.items}. ` +
371
- 'Readiness gaps for YOUR surface (§1.6 RESERVATIONS — the spec is silent here: implement the stated ' +
372
- `assumption and flag what you assumed in your handoff): ${j.gaps}.`
373
-
374
- // Dispatch + roll call, SCHEMA.md §Dead agents: parallel fan-out, then each silent
375
- // surface retried ONCE, alone, byte-identical. Never a surface that answered.
376
- const dispatchSurfaces = async (jobs, phaseTitle) => {
377
- const tag = phaseTitle === 'Build' ? 'build' : 'fix'
378
- const first = await parallel(jobs.map(j => () =>
379
- agent(dispatchPrompt(j), { agentType: j.s.agent, label: `${tag}:${j.s.key}`, phase: phaseTitle })))
380
- const out = []
381
- for (let i = 0; i < jobs.length; i++) {
382
- let h = first[i]
383
- if (h == null) {
384
- log(`${jobs[i].s.key}: implementer died — one byte-identical retry, alone`)
385
- h = await agent(dispatchPrompt(jobs[i]), { agentType: jobs[i].s.agent, label: `${tag}-retry:${jobs[i].s.key}`, phase: phaseTitle })
386
- }
387
- out.push({ key: jobs[i].s.key, handoff: h })
388
- }
389
- return out
390
- }
391
-
392
- const designSlot = s => (s.uses_design && (facts.spec.designFiles || []).length)
393
- ? (facts.spec.designFiles || []).join(' · ') : 'none'
394
-
395
- // One mechanical state writer per boundary: loop.json (EXACT content composed here —
396
- // the agent only substitutes <ISO> and never invents fields), plus the optional spec
397
- // status stamp, kanban move and metrics line. Returns `done <epoch>` so wall-clock
398
- // threads through the agents' own `date +%s` (scripts have no clock of their own).
399
- const writeState = async ({ label, phaseName, outcome, reason, status: st, kanban, metrics }) => {
400
- const loopJson = JSON.stringify({
401
- id: feature, round, maxRounds, phase: phaseName, startedAt, specMtime: baselineMtime, ts: '<ISO>',
402
- lastItems: lastKey == null ? null : lastKey.split('\n').filter(Boolean),
403
- history, ...(outcome ? { outcome, reason } : {}),
404
- })
405
- const steps = [
406
- `1. mkdir -p specs/reports; write EXACTLY this to specs/reports/${feature}.loop.json (overwrite), ` +
407
- 'substituting EVERY <ISO> token in this and every later step with `date -u +%Y-%m-%dT%H:%M:%SZ` ' +
408
- `and changing NOTHING else:\n${loopJson}`,
409
- ]
410
- if (st) steps.push(
411
- `${steps.length + 1}. In specs/${feature}.md front-matter set \`status: ${st}\` (sed the existing status: line; ` +
412
- 'a spec with no front-matter ⇒ skip silently — the loop never dies over a status line).')
413
- if (kanban) steps.push(
414
- `${steps.length + 1}. <core>/pipeline/scripts/kanban-move.sh auto ${feature} ${kanban} || true ${CORE_DEF}`)
415
- if (metrics) steps.push(
416
- `${steps.length + 1}. Append ONE line to ${METRICS_PATH}: ` +
417
- `{"ts":"<ISO>","feature":"${feature}","phase":"${metrics.phase}","seconds":$(($(date +%s)-${clock || 0})),` +
418
- `"tokens":${metrics.tokens || 0},` +
419
- `"surfaces":${JSON.stringify(metrics.surfaces)}}` + (metrics.buildJson
420
- ? `\n${steps.length + 2}. Write to specs/reports/${feature}.build.json (overwrite): ` +
421
- `{"id":"${feature}","phase":"build","ts":"<ISO>","surfaces":${JSON.stringify(metrics.surfaces)},"dead":${JSON.stringify(metrics.dead)}}`
422
- : ''))
423
- const r = await agent(
424
- `Mechanical state write for the cohorte loop, feature ${feature} — do ALL steps, then return exactly: done <epoch> ` +
425
- '(<epoch> = date +%s).\n' + steps.join('\n'),
426
- { model: 'haiku', label, phase: outcome ? 'Close' : undefined, effort: 'low' },
427
- )
428
- const m = /done\s+(\d+)/.exec(String(r || ''))
429
- if (m) clock = Number(m[1])
430
- return r != null
431
- }
432
-
433
- // Close the run: final loop.json + status + kanban, then the return object. Status
434
- // doctrine (SCHEMA.md §Spec status): `blocked` = a round gave up (resumable, reason
435
- // named); `in-review` = zero blocking, awaiting /cohorte-ship; precondition aborts
436
- // never stamped anything, so they change nothing here either.
437
- const close = async (outcome, reason, extra = {}) => {
438
- // Every abort that reaches close() is a round giving up — preconditions abort via
439
- // abortPre() before anything was stamped and never come through here. An abort left
440
- // outside this list once stranded the spec at `in-progress` forever.
441
- const gaveUp = outcome === 'abort'
442
- const closed = await writeState({
443
- label: 'close', phaseName: 'done', outcome, reason,
444
- status: outcome === 'ship' ? 'in-review' : gaveUp ? 'blocked' : undefined,
445
- kanban: outcome === 'ship' ? 'review' : gaveUp ? 'fix' : undefined,
446
- })
447
- return {
448
- id: feature, outcome, reason, rounds: history.length,
449
- blocking: history.length ? history[history.length - 1].blocking : null,
450
- tokens: spent(), // approx output tokens for the whole run (runtime counter)
451
- history, report: `specs/reports/${feature}.md`,
452
- ...(closed ? {} : { stateWarning: 'the closing state agent died — loop.json/status/kanban may not reflect this outcome' }),
453
- ...extra,
454
- }
455
- }
456
-
457
- // ── Phase 2 — build, unless build's own work is already fresh on disk ────────
458
- // /cohorte-build has no stop-after-§1.6 mode: when the human ran it through, the
459
- // implementers' work and build.json are on disk and re-building would be waste; when
460
- // build.json is stale, absent, or carries dead[], the loop builds (a full re-dispatch —
461
- // implementers are idempotent against a frozen contract, the same property fix rounds
462
- // already rely on).
463
- let mark = spent()
464
- if (fresh(facts.build) && !(facts.build.dead || []).length) {
465
- log('build.json is fresh with no dead surfaces — skipping the build phase, entering at review')
466
- await writeState({ label: 'state:enter', phaseName: 'review', status: 'in-progress', kanban: 'review' })
467
- } else {
468
- phase('Build')
469
- await writeState({ label: 'state:build', phaseName: 'build', status: 'in-progress', kanban: 'building' })
470
- const jobs = wantedKeys.map(k => ({
471
- s: byKey[k], design: designSlot(byKey[k]), items: 'none',
472
- gaps: gapsFor(k).join(' · ') || 'none',
473
- }))
474
- const built = await dispatchSurfaces(jobs, 'Build')
475
- const dead = built.filter(b => b.handoff == null).map(b => b.key)
476
- // Write build.json + the metrics line EVEN WHEN a surface died — an incomplete batch
477
- // is exactly the batch worth recording (SCHEMA.md §Dead agents).
478
- await writeState({
479
- label: 'state:built', phaseName: dead.length ? 'build' : 'review',
480
- metrics: {
481
- phase: 'build', buildJson: true, dead, tokens: Math.max(0, spent() - mark),
482
- surfaces: Object.fromEntries(built.map(b => [b.key, b.handoff == null ? 'dead' : 'ok'])),
483
- },
484
- })
485
- if (dead.length) {
486
- return close('abort', 'dead-implementers', {
487
- detail: `surface(s) returned nothing twice: ${dead.join(', ')} — their trees are UNVERIFIED ` +
488
- '(a dead agent has no handoff to speak for); re-run the loop, or /cohorte-build conversationally',
489
- })
490
- }
491
- }
492
-
493
- // ── Phases 3–5 — the loop body: review → decide → fix → … ────────────────────
494
- while (true) {
495
- phase('Review')
496
- await writeState({ label: `state:round-${round}`, phaseName: 'review', status: 'in-progress', kanban: 'review' })
497
- const roundMark = spent()
498
- let review
499
- try {
500
- review = await workflow('cohorte-review', { feature })
501
- } catch (e) {
502
- return close('abort', 'review-workflow-unavailable', {
503
- detail: `workflow('cohorte-review') failed: ${e.message} — the review workflow must be installed ` +
504
- '(<core>/workflows/review.js; /cohorte-doctor check 8); the loop never falls back to a conversational review',
505
- })
506
- }
507
-
508
- // The review child stamps its own review-phase metrics line; marking here keeps the
509
- // fix phase's delta clean of it. History carries the round's review cost.
510
- mark = spent()
511
- const verdict = decide(review, lastKey, round, maxRounds)
512
- if (review && typeof review.blocking === 'number') {
513
- history.push({
514
- round, blocking: review.blocking,
515
- fingerprint: hash16((review.blockingItems || []).join('\n')),
516
- tokens: Math.max(0, spent() - roundMark),
517
- })
518
- }
519
-
520
- if (verdict.outcome === 'ship') {
521
- // review.js stamps the freshness gate + DoD ticks only when every leftover is LOW
522
- // (`clean`); its `next` already says which side this run landed on — relay it, so a
523
- // ship exit with surviving HIGH/MEDIUM does not read as "run /cohorte-ship" when
524
- // /cohorte-ship would refuse (no reviewed_base/reviewed_digest stamp).
525
- return close('ship', 'ship', {
526
- deferred: review.deferred || 0,
527
- next: review.next || `/cohorte-ship ${feature}`,
528
- })
529
- }
530
- if (verdict.outcome === 'abort') {
531
- return close('abort', verdict.reason, {
532
- ...(verdict.reason === 'unreviewed' ? { unreviewed: review.unreviewedSurfaces } : {}),
533
- ...(verdict.reason === 'preflight' ? { detail: 'preflight red — mechanical failures need /cohorte-fix or the human, not more rounds' } : {}),
534
- ...(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` } : {}),
535
- ...(verdict.reason === 'max-rounds' ? { detail: `${review.blocking} blocking finding(s) still open after ${maxRounds} round(s)` } : {}),
536
- })
537
- }
538
-
539
- // The fix round ingests the staged report from disk; a dead staging agent means that
540
- // file is the PREVIOUS round's (or absent) — ingesting it fixes the wrong items and
541
- // manufactures a false treading-water abort next round. The verdict itself is real;
542
- // only the handoff is missing, so this is a resumable give-up, not a verdict.
543
- if (review.reportStaged === false) {
544
- return close('abort', 'report-not-staged', {
545
- detail: `round ${round}'s review completed (blocking: ${review.blocking}) but its report was never ` +
546
- `staged to disk (the staging agent died) — re-run the loop; it resumes at round ${round}`,
547
- })
548
- }
549
-
550
- // A blocking finding pointing at THE contract file itself is /cohorte-fix §1's
551
- // lead-only step (update spec §5, re-author the contract). A loop that rewrites its
552
- // own contract between rounds has no frozen contract — abort and name the finding.
553
- // Exact-file match, not a contract.path prefix: ordinary surface code can live under
554
- // the same tree (a `shared` surface at the contract package), and a prefix match
555
- // would make every blocking finding there permanently unfixable by the loop.
556
- if (contractOn) {
557
- const onContract = (review.blockingItems || []).filter(it => String(it.split('|')[1] || '') === contractFilePath)
558
- if (onContract.length) {
559
- return close('abort', 'contract-change', {
560
- detail: 'blocking finding(s) target the frozen contract itself — a lead must update spec §5 and ' +
561
- `re-author the contract (/cohorte-spec ${feature} Mode B, then /cohorte-build), then re-enter the loop`,
562
- findings: onContract,
563
- })
564
- }
565
- }
566
-
567
- // ── Fix round ──────────────────────────────────────────────────────────────
568
- phase('Fix')
569
- const ingest = await agent(
570
- `Mechanical ingest of a cohorte review round for feature ${feature} (loop round ${round}):\n` +
571
- `1. <core>/pipeline/scripts/kanban-move.sh auto ${feature} fix || true ${CORE_DEF}\n` +
572
- `2. Read the BLOCKING findings from specs/reports/${feature}.md ## Findings — exactly the lines whose ` +
573
- 'severity is CRITICAL or whose kind is security (they are the only lines matching this identity list, ' +
574
- `one \`<surface>|<file>|<first words of the problem>\` per finding): ${JSON.stringify(review.blockingItems)}\n` +
575
- `3. Append them to specs/${feature}.md under \`## Remediation\` (create the heading if absent) beneath a ` +
576
- `new subheading \`### $(date -u +%Y-%m-%d) — cohorte-loop round ${round}\`, one line each: ` +
577
- '`- [ ] <SEVERITY> · <file:line> · <kind> · <the finding\'s concrete fix>`. Append with >>, never rewrite the spec.\n' +
578
- '4. Return each appended line verbatim plus its bare file path (no :line).',
579
- { model: 'haiku', label: `ingest:${round}`, phase: 'Fix', schema: ITEMS, effort: 'low' },
580
- )
581
- // A dead ingest agent appended nothing — dispatching implementers with no Remediation
582
- // items would re-build surfaces blind. Abort as a give-up, resumable at this round.
583
- if (!ingest || !(ingest.items || []).length) {
584
- return close('abort', ingest ? 'no-fix-items' : 'ingest-died', {
585
- detail: 'the Remediation ingest returned nothing — the blocking findings were never turned into ' +
586
- `fix items; re-run the loop (it resumes at round ${round}), or run /cohorte-fix ${feature} by hand`,
587
- })
588
- }
589
- // Surface mapping in script, /cohorte-fix §2's rule — longest path prefix owns the
590
- // item; unowned items ride with the surface already owning the most (logged).
591
- const grouped = {}
592
- const unowned = []
593
- for (const it of ingest.items) {
594
- const s = surfaceFor(it.file, surfaces)
595
- if (s) (grouped[s.key] = grouped[s.key] || []).push(it.line)
596
- else unowned.push(it)
597
- }
598
- if (unowned.length) {
599
- const host = Object.keys(grouped).sort((a, b) => grouped[b].length - grouped[a].length)[0] || wantedKeys[0]
600
- log(`${unowned.length} item(s) under no surface path — attached to ${host}: ${unowned.map(i => i.file).join(', ')}`)
601
- ;(grouped[host] = grouped[host] || []).push(...unowned.map(i => i.line))
602
- }
603
- const fixJobs = Object.keys(grouped).filter(k => byKey[k]).map(k => ({
604
- s: byKey[k], design: designSlot(byKey[k]), items: grouped[k].join(' · '),
605
- gaps: 'none',
606
- }))
607
- const fixed = await dispatchSurfaces(fixJobs, 'Fix')
608
- const fixDead = fixed.filter(f => f.handoff == null).map(f => f.key)
609
- // The metrics line is written EVEN WHEN a surface died — an incomplete batch is
610
- // exactly the batch worth recording (SCHEMA.md §Dead agents), so it lands before
611
- // the dead-implementers abort, with the dead surfaces named.
612
- await writeState({ label: `state:fixed-${round}`, phaseName: 'review', metrics: {
613
- phase: 'fix', tokens: Math.max(0, spent() - mark),
614
- surfaces: Object.fromEntries(fixed.map(f => [f.key, f.handoff == null ? 'dead' : 'ok'])),
615
- } })
616
- if (fixDead.length) {
617
- // Their items stay `- [ ]` — a dead agent never ticks a box (/cohorte-fix §3), and
618
- // nothing here has ticked anything yet, so the spec is already honest.
619
- return close('abort', 'dead-implementers', {
620
- detail: `fix implementer(s) returned nothing twice: ${fixDead.join(', ')} — their items stay open ` +
621
- `\`- [ ]\` in the spec; re-run the loop (resumes at round ${round}) or /cohorte-fix ${feature}`,
622
- })
623
- }
624
- // Tick ONLY what a live handoff claims — the excerpt passed per surface is its
625
- // `## Remediation addressed` section, so the ticker never sees (or trusts) silence.
626
- const excerpt = h => {
627
- const m = /## Remediation addressed[\s\S]*?(?=\n## |$)/.exec(String(h))
628
- return (m ? m[0] : String(h)).slice(0, 2000)
629
- }
630
- const ticked = await agent(
631
- `In specs/${feature}.md, flip \`- [ ]\` → \`- [x]\` for EXACTLY the Remediation items these surface handoffs ` +
632
- 'report fixed (match by file:line; append a terse ` — fixed: <what>` note). Leave every item no handoff ' +
633
- 'claims — and every other line — untouched. Then return the single word: done.\n' +
634
- fixed.map(f => `--- handoff ${f.key} ---\n${excerpt(f.handoff)}`).join('\n'),
635
- { model: 'haiku', label: `tick:${round}`, phase: 'Fix', effort: 'low' },
636
- )
637
- if (ticked == null) log('the ticking agent died — items stay `- [ ]`; the next round re-reviews regardless')
638
-
639
- lastKey = verdict.key
640
- round += 1
641
- }