cohorte 2.10.1 → 3.0.0-dev.1

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 (213) 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 +154 -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 +2187 -0
  41. package/dist/agent-host.mjs.map +1 -0
  42. package/dist/bundle-manifest.json +104 -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-CYOh60KG.mjs +93 -0
  50. package/dist/chunks/auth-CYOh60KG.mjs.map +1 -0
  51. package/dist/chunks/brainstorm-BwKA6yZP.mjs +7 -0
  52. package/dist/chunks/brainstorm-BwKA6yZP.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-Dvxunpto.mjs +7307 -0
  60. package/dist/chunks/compose-Dvxunpto.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-BkMjU1kR.mjs +295 -0
  64. package/dist/chunks/contract-BkMjU1kR.mjs.map +1 -0
  65. package/dist/chunks/contract-bQ9FJl4N.mjs +123 -0
  66. package/dist/chunks/contract-bQ9FJl4N.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-eS01-me1.mjs +21 -0
  72. package/dist/chunks/discover-eS01-me1.mjs.map +1 -0
  73. package/dist/chunks/doctor-B-H2UK5e.mjs +222 -0
  74. package/dist/chunks/doctor-B-H2UK5e.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-BUnzIkeE.mjs +63 -0
  80. package/dist/chunks/init-BUnzIkeE.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-DybihKzZ.mjs +33 -0
  86. package/dist/chunks/migrate-DybihKzZ.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-Bhy3IIYo.mjs +50 -0
  96. package/dist/chunks/reconcile-Bhy3IIYo.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/scan-8Gy_B6kI.mjs +122 -0
  110. package/dist/chunks/scan-8Gy_B6kI.mjs.map +1 -0
  111. package/dist/chunks/schema-C9QbUW4H.mjs +578 -0
  112. package/dist/chunks/schema-C9QbUW4H.mjs.map +1 -0
  113. package/dist/chunks/send-BdnJ85Nz.mjs +19 -0
  114. package/dist/chunks/send-BdnJ85Nz.mjs.map +1 -0
  115. package/dist/chunks/ship-DTuLGoaJ.mjs +19 -0
  116. package/dist/chunks/ship-DTuLGoaJ.mjs.map +1 -0
  117. package/dist/chunks/shutdown-BM92Iyv2.mjs +15 -0
  118. package/dist/chunks/shutdown-BM92Iyv2.mjs.map +1 -0
  119. package/dist/chunks/skip-DZG5kpcH.mjs +18 -0
  120. package/dist/chunks/skip-DZG5kpcH.mjs.map +1 -0
  121. package/dist/chunks/spec-Bq0P3jVh.mjs +20 -0
  122. package/dist/chunks/spec-Bq0P3jVh.mjs.map +1 -0
  123. package/dist/chunks/src-CId6kMsY.mjs +1689 -0
  124. package/dist/chunks/src-CId6kMsY.mjs.map +1 -0
  125. package/dist/chunks/src-CfAOde7C.mjs +5403 -0
  126. package/dist/chunks/src-CfAOde7C.mjs.map +1 -0
  127. package/dist/chunks/src-CmFjh4M0.mjs +928 -0
  128. package/dist/chunks/src-CmFjh4M0.mjs.map +1 -0
  129. package/dist/chunks/src-Cu3TxNob.mjs +783 -0
  130. package/dist/chunks/src-Cu3TxNob.mjs.map +1 -0
  131. package/dist/chunks/src-DcU4IXMe.mjs +117 -0
  132. package/dist/chunks/src-DcU4IXMe.mjs.map +1 -0
  133. package/dist/chunks/src-s7YRvk0t.mjs +2116 -0
  134. package/dist/chunks/src-s7YRvk0t.mjs.map +1 -0
  135. package/dist/chunks/status-SMjiOMYy.mjs +29 -0
  136. package/dist/chunks/status-SMjiOMYy.mjs.map +1 -0
  137. package/dist/chunks/tail-BuhT-2PH.mjs +25 -0
  138. package/dist/chunks/tail-BuhT-2PH.mjs.map +1 -0
  139. package/dist/chunks/update-BXIppsbi.mjs +16 -0
  140. package/dist/chunks/update-BXIppsbi.mjs.map +1 -0
  141. package/dist/cli.mjs +175 -0
  142. package/dist/cli.mjs.map +1 -0
  143. package/package.json +17 -32
  144. package/CHANGELOG.md +0 -1461
  145. package/bin/cli.js +0 -817
  146. package/bin/report.js +0 -141
  147. package/core/adapter/render.js +0 -415
  148. package/core/agents/implementer.template.md +0 -142
  149. package/core/agents/profile-reader.md +0 -50
  150. package/core/agents/release.md +0 -58
  151. package/core/agents/review.md +0 -179
  152. package/core/commands/cohorte-align-ds.md +0 -33
  153. package/core/commands/cohorte-audit.md +0 -58
  154. package/core/commands/cohorte-brainstorm.md +0 -70
  155. package/core/commands/cohorte-build.md +0 -219
  156. package/core/commands/cohorte-doctor.md +0 -194
  157. package/core/commands/cohorte-fix.md +0 -80
  158. package/core/commands/cohorte-fleet.md +0 -103
  159. package/core/commands/cohorte-init-pipeline.md +0 -31
  160. package/core/commands/cohorte-intake.md +0 -92
  161. package/core/commands/cohorte-patch.md +0 -118
  162. package/core/commands/cohorte-refactor.md +0 -59
  163. package/core/commands/cohorte-retro.md +0 -85
  164. package/core/commands/cohorte-review.md +0 -270
  165. package/core/commands/cohorte-ship.md +0 -122
  166. package/core/commands/cohorte-spec.md +0 -101
  167. package/core/commands/cohorte-update-pipeline.md +0 -165
  168. package/core/hooks/gate.py +0 -588
  169. package/core/runtimes/claude.json +0 -74
  170. package/core/runtimes/codex.json +0 -85
  171. package/core/runtimes/cursor.json +0 -76
  172. package/core/runtimes/gemini.json +0 -76
  173. package/core/runtimes/opencode.json +0 -73
  174. package/core/templates/agent-handoff.md +0 -34
  175. package/core/templates/brainstorm-return.md +0 -36
  176. package/core/templates/decisions.template.md +0 -42
  177. package/core/templates/design-brief.md +0 -44
  178. package/core/templates/patch.template.md +0 -86
  179. package/core/templates/pr-body.md +0 -25
  180. package/core/templates/review-feedback.md +0 -38
  181. package/core/templates/spec.template.md +0 -87
  182. package/core/templates/steps/init-pipeline/01-detect-stack.md +0 -44
  183. package/core/templates/steps/init-pipeline/02-interview-gaps.md +0 -55
  184. package/core/templates/steps/init-pipeline/03-draft-profile.md +0 -10
  185. package/core/templates/steps/init-pipeline/04-write-render.md +0 -145
  186. package/core/templates/steps/init-pipeline/05-report.md +0 -12
  187. package/core/workflows/audit.js +0 -240
  188. package/core/workflows/loop.js +0 -641
  189. package/core/workflows/refactor.js +0 -276
  190. package/core/workflows/review.js +0 -456
  191. package/install.ps1 +0 -103
  192. package/install.sh +0 -114
  193. package/lib/doctor.js +0 -534
  194. package/lib/runtime.js +0 -139
  195. package/lib/versions.js +0 -149
  196. package/lib/yaml.js +0 -129
  197. package/profile/PIPELINE.template.md +0 -259
  198. package/profile/SCHEMA.md +0 -735
  199. package/profile/cohorte.config.template.yaml +0 -43
  200. package/scripts/assert-gate-hook.mjs +0 -75
  201. package/scripts/kanban-move.sh +0 -313
  202. package/scripts/metrics/collect.mjs +0 -531
  203. package/scripts/metrics/prices.json +0 -42
  204. package/scripts/new-feature.sh.template +0 -98
  205. package/scripts/preflight.sh +0 -124
  206. package/scripts/remove-feature.sh.template +0 -56
  207. package/scripts/test-adapter.mjs +0 -437
  208. package/scripts/test-gate.mjs +0 -443
  209. package/scripts/test-kanban.mjs +0 -304
  210. package/scripts/test-lib.mjs +0 -356
  211. package/scripts/test-metrics.mjs +0 -156
  212. package/scripts/test-workflows.mjs +0 -758
  213. package/scripts/validate-core.mjs +0 -325
@@ -1,758 +0,0 @@
1
- #!/usr/bin/env node
2
- // Behavioural tests for core/workflows/*.js.
3
- //
4
- // The workflow runtime hands a script an async function body with agent() /
5
- // parallel() / pipeline() / phase() / log() / args / budget injected. Nothing in
6
- // a script touches the filesystem, so the whole orchestration is testable by
7
- // injecting stub agents and asserting the returned verdict object.
8
- //
9
- // This exists because of one specific failure mode: agent() resolves to `null`
10
- // when a subagent dies, and a dead reviewer produces zero findings — which is
11
- // byte-identical to a clean surface. review.js scored that as SHIP over code no
12
- // reviewer had read. A unit test is the only thing that catches it: the
13
- // structural checks in validate-core.mjs cannot see verdict logic.
14
- //
15
- // node scripts/test-workflows.mjs
16
-
17
- import { readFileSync } from "node:fs";
18
- import { join } from "node:path";
19
- import { fileURLToPath } from "node:url";
20
-
21
- const root = fileURLToPath(new URL("..", import.meta.url));
22
- const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor;
23
-
24
- let failures = 0;
25
- const check = (name, cond, detail = "") => {
26
- if (cond) console.log(` ✓ ${name}`);
27
- else { failures++; console.error(` ✗ ${name}${detail ? ` — ${detail}` : ""}`); }
28
- };
29
-
30
- const PROFILE = {
31
- name: "testproj",
32
- vcs: { default_branch: "main" },
33
- contract: { enabled: false, path: "packages/shared/src", ext: "ts", mechanism: "none" },
34
- commands: { typecheck: "tsc --noEmit", lint_quiet: "lint -q", test_quiet: "test --dot" },
35
- surfaces: [
36
- { key: "backend", path: "apps/api", agent: "backend", uses_design: false },
37
- { key: "frontend", path: "apps/web", agent: "frontend", uses_design: true },
38
- ],
39
- };
40
-
41
- const TOUCHED = [
42
- { key: "backend", diff: "specs/reports/f.backend.diff", files: ["apps/api/a.ts"] },
43
- { key: "frontend", diff: "specs/reports/f.frontend.diff", files: ["apps/web/b.tsx"] },
44
- ];
45
-
46
- const finding = (over = {}) => ({
47
- severity: "HIGH", file: "apps/api/a.ts", line: 3, kind: "quality",
48
- problem: "p", fix: "f", ...over,
49
- });
50
-
51
- // Run one workflow script with a `reply(prompt, opts) => value` stub in place of
52
- // every agent call, and an optional `wf(name, args)` stub in place of nested
53
- // workflow() calls (loop.js runs the review workflow as a child). Returns
54
- // { result, calls, prompts } — prompts keyed by label, for byte-identity asserts.
55
- async function run(script, reply, args = { feature: "feat-x" }, wf, budgetStub) {
56
- const text = readFileSync(join(root, "core/workflows", script), "utf8")
57
- .replace(/^export const meta/m, "const meta");
58
- const calls = [];
59
- const prompts = {};
60
- const agent = async (prompt, opts = {}) => {
61
- calls.push(opts.label || "(unlabelled)");
62
- prompts[opts.label || "(unlabelled)"] = prompt;
63
- return reply(prompt, opts, calls);
64
- };
65
- // Mirrors the runtime's contract: a thunk that throws resolves to null, the
66
- // call itself never rejects.
67
- const parallel = thunks =>
68
- Promise.all(thunks.map(t => Promise.resolve().then(t).catch(() => null)));
69
- // Each item runs through every stage independently; a throwing stage drops
70
- // that item to null and skips its remaining stages.
71
- const pipeline = (items, ...stages) =>
72
- Promise.all(items.map(async (item, i) => {
73
- let v = item;
74
- for (const s of stages) {
75
- try { v = await s(v, item, i); } catch { return null; }
76
- }
77
- return v;
78
- }));
79
- const fn = new AsyncFunction(
80
- "agent", "parallel", "pipeline", "phase", "log", "args", "budget", "workflow", text);
81
- const result = await fn(
82
- agent, parallel, pipeline, () => {}, () => {}, args,
83
- budgetStub || { total: null, spent: () => 0, remaining: () => Infinity },
84
- wf || (async () => {}));
85
- return { result, calls, prompts };
86
- }
87
-
88
- // A budget stub whose spent() grows with every agent/workflow call — what the runtime's
89
- // counter does — so token deltas in the scripts come out non-zero and orderable.
90
- const tickingBudget = () => {
91
- let n = 0;
92
- return { total: null, spent: () => (n += 1000), remaining: () => Infinity };
93
- };
94
-
95
- // A reply table keyed by label prefix; the first matching prefix wins.
96
- const replier = table => (prompt, opts) => {
97
- const label = opts.label || "";
98
- for (const [prefix, value] of table) {
99
- if (label === prefix || label.startsWith(prefix)) {
100
- return typeof value === "function" ? value(label) : value;
101
- }
102
- }
103
- return "ok";
104
- };
105
-
106
- const BASE_REVIEW = [
107
- ["profile", PROFILE],
108
- ["preflight", { pass: true }],
109
- ["stage-diff", { surfaces: TOUCHED }],
110
- ["stage-report", "done"],
111
- ];
112
-
113
- console.log("review.js");
114
- {
115
- const { result } = await run("review.js", replier([
116
- ["review:", { verdict: "SHIP", findings: [] }], ...BASE_REVIEW,
117
- ]));
118
- check("clean run ⇒ SHIP", result.verdict === "SHIP", `got ${result.verdict}`);
119
- check("clean run ⇒ no unreviewed surfaces", (result.unreviewedSurfaces || []).length === 0);
120
- check("clean run ⇒ next is /cohorte-ship", String(result.next).startsWith("/cohorte-ship"), result.next);
121
- }
122
- {
123
- // THE regression: every reviewer dies ⇒ zero findings ⇒ must NOT read as SHIP.
124
- const { result } = await run("review.js", replier([
125
- ["review:", null], ...BASE_REVIEW,
126
- ]));
127
- check("all reviewers dead ⇒ not SHIP", result.verdict !== "SHIP", `got ${result.verdict}`);
128
- check("all reviewers dead ⇒ both surfaces reported unreviewed",
129
- (result.unreviewedSurfaces || []).join(",") === "backend,frontend",
130
- JSON.stringify(result.unreviewedSurfaces));
131
- check("all reviewers dead ⇒ next says re-run",
132
- /re-run the review/.test(result.next), result.next);
133
- }
134
- {
135
- // One dead reviewer must not be masked by the other surface coming back clean.
136
- const { result } = await run("review.js", replier([
137
- ["review:backend", null],
138
- ["review:", { verdict: "SHIP", findings: [] }],
139
- ...BASE_REVIEW,
140
- ]));
141
- check("one reviewer dead ⇒ not SHIP", result.verdict !== "SHIP", `got ${result.verdict}`);
142
- check("one reviewer dead ⇒ names only that surface",
143
- (result.unreviewedSurfaces || []).join(",") === "backend", JSON.stringify(result.unreviewedSurfaces));
144
- }
145
- {
146
- // A SHIP carrying HIGH findings is a real verdict, but it is not "go ship it":
147
- // the conversational /cohorte-review routes any surviving HIGH to /cohorte-fix.
148
- const { result } = await run("review.js", replier([
149
- ["review:", { verdict: "SHIP", findings: [finding()] }], ...BASE_REVIEW,
150
- ]));
151
- check("SHIP + HIGH findings ⇒ verdict still SHIP", result.verdict === "SHIP");
152
- check("SHIP + HIGH findings ⇒ next routes to /cohorte-fix, not /cohorte-ship",
153
- String(result.next).startsWith("/cohorte-fix"), result.next);
154
- }
155
- {
156
- const { result } = await run("review.js", replier([
157
- ["review:", { verdict: "SHIP", findings: [finding({ severity: "LOW" })] }], ...BASE_REVIEW,
158
- ]));
159
- check("SHIP + only LOW ⇒ next is /cohorte-ship", String(result.next).startsWith("/cohorte-ship"), result.next);
160
- }
161
- {
162
- // Deferred findings are real but out of the feature's scope: they must be
163
- // counted and routed to the backlog, yet move NEITHER the verdict nor `clean`.
164
- // Both halves matter — a deferred item that blocks costs a fix loop it was
165
- // deferred out of, and one that is dropped is the leak the section exists to close.
166
- const deferred = [{
167
- severity: "HIGH", file: "apps/api/legacy.ts", line: 9, kind: "quality",
168
- problem: "p", fix: "f", outOfScope: "predates this feature; diff never touched it",
169
- }];
170
- let stagePrompt = "";
171
- const { result, calls } = await run("review.js", (prompt, opts) => {
172
- const label = opts.label || "";
173
- if (label.startsWith("review:")) return { verdict: "SHIP", findings: [], deferred };
174
- if (label === "stage-report") { stagePrompt = prompt; return "done"; }
175
- return replier(BASE_REVIEW)(prompt, opts);
176
- });
177
- check("deferred-only ⇒ verdict still SHIP", result.verdict === "SHIP", `got ${result.verdict}`);
178
- check("deferred-only ⇒ next is /cohorte-ship (not a fix loop)",
179
- String(result.next).startsWith("/cohorte-ship"), result.next);
180
- check("deferred are counted (both surfaces)", result.deferred === 2, `got ${result.deferred}`);
181
- check("deferred stay out of the severity counts",
182
- Object.values(result.counts).every(n => n === 0), JSON.stringify(result.counts));
183
- check("deferred are routed to the refactor backlog",
184
- /refactor-backlog\.md/.test(stagePrompt) && /deferred:feat-x/.test(stagePrompt),
185
- stagePrompt.slice(0, 200));
186
- check("deferred are never cross-checked (no verify agent spawned)",
187
- !calls.some(c => c.startsWith("verify:")) && result.refutedByCrossCheck === 0,
188
- calls.join(","));
189
- }
190
- {
191
- const { result } = await run("review.js", replier([
192
- ["preflight", { pass: false, tail: "boom" }], ...BASE_REVIEW,
193
- ]));
194
- check("red preflight ⇒ ABORTED", result.verdict === "ABORTED", `got ${result.verdict}`);
195
- }
196
- {
197
- const { calls } = await run("review.js", replier([
198
- ["preflight", { pass: false, tail: "boom" }], ...BASE_REVIEW,
199
- ]));
200
- check("red preflight ⇒ zero reviewers spawned",
201
- !calls.some(c => c.startsWith("review:")), calls.join(","));
202
- }
203
-
204
- // ── args normalisation ───────────────────────────────────────────────────────
205
- // The runtime passes `args` through verbatim, so a caller that JSON-encodes it
206
- // hands the script a string. That string used to become the feature id itself —
207
- // which is how a report was written to `specs/reports/{"feature": "x"}.md`.
208
- console.log("args");
209
- {
210
- const { result } = await run("review.js", replier([
211
- ["review:", { verdict: "SHIP", findings: [] }], ...BASE_REVIEW,
212
- ]), JSON.stringify({ feature: "feat-x" }));
213
- check("review: a JSON-encoded args string is parsed, not used as the id",
214
- result.verdict === "SHIP", `got ${result.verdict}`);
215
- }
216
- {
217
- let threw = "";
218
- try {
219
- await run("review.js", replier([...BASE_REVIEW]), { feature: '{"feature": "feat-x"}' });
220
- } catch (e) { threw = e.message; }
221
- check("review: a non-slug feature id throws before anything is written",
222
- /not a slug/.test(threw), threw || "(did not throw)");
223
- }
224
- {
225
- let threw = "";
226
- try {
227
- await run("review.js", replier([...BASE_REVIEW]), { feature: "../../etc/passwd" });
228
- } catch (e) { threw = e.message; }
229
- check("review: a path-shaped feature id is rejected",
230
- /not a slug/.test(threw), threw || "(did not throw)");
231
- }
232
-
233
- // ── Phase 0 profile handling ─────────────────────────────────────────────────
234
- // A haiku profile-reader intermittently returns the profile as a JSON *string*
235
- // under a wrapper field instead of at the top level. The old schema accepted that
236
- // wrapper, so `surfaces` read as undefined ⇒ [] ⇒ parallel([]) ⇒ zero agents
237
- // dispatched — and because every later guard compares against `surfaces`, an
238
- // empty list made them all vacuously pass: a run reported a verdict having done
239
- // nothing, indistinguishable from a clean run with an empty diff. Two properties
240
- // are pinned per workflow: a wrapped return is recovered, an empty one aborts.
241
- console.log("profile phase");
242
- const WRAPPED = { output: JSON.stringify(PROFILE) };
243
- const EMPTY_PROFILE = { ...PROFILE, surfaces: [] };
244
- {
245
- const { result } = await run("review.js", replier([
246
- ["profile", WRAPPED],
247
- ["review:", { verdict: "SHIP", findings: [] }], ...BASE_REVIEW,
248
- ]));
249
- check("review: a string-wrapped profile is unwrapped, not silently empty",
250
- result.verdict === "SHIP", `got ${result.verdict}`);
251
- }
252
- {
253
- const { result, calls } = await run("review.js", replier([["profile", EMPTY_PROFILE], ...BASE_REVIEW]));
254
- check("review: no surfaces ⇒ ABORTED, not a verdict",
255
- result.verdict === "ABORTED", `got ${result.verdict}`);
256
- check("review: no surfaces ⇒ zero reviewers spawned",
257
- !calls.some(c => c.startsWith("review:")), calls.join(","));
258
- }
259
- {
260
- const { result } = await run("audit.js", replier([
261
- ["profile", EMPTY_PROFILE], ["gates", { failures: [] }], ["write-backlog", "done"],
262
- ]), {});
263
- check("audit: no surfaces ⇒ error, not an empty backlog",
264
- /no surfaces/.test(result.error || ""), JSON.stringify(result));
265
- }
266
- {
267
- const { result } = await run("refactor.js", replier([
268
- ["profile", EMPTY_PROFILE], ["read-backlog", { domains: [] }],
269
- ]), { domains: "all" });
270
- check("refactor: no surfaces ⇒ error, not a no-op success",
271
- /no surfaces/.test(result.error || ""), JSON.stringify(result));
272
- }
273
-
274
- // ── the dead-agent family, swept across every terminal/staging agent ─────────
275
- // `agent()` returns null when a subagent dies. Any call whose result is turned
276
- // into a CLAIM (a verdict, a path, "it is on disk") must distinguish "died" from
277
- // "succeeded with nothing to say". This block is the sweep.
278
- console.log("dead-agent sweep");
279
- {
280
- const { result } = await run("review.js", replier([
281
- ["stage-diff", null], ...BASE_REVIEW,
282
- ]));
283
- check("review: dead diff-stager ⇒ ABORTED, not 'SHIP — nothing to review'",
284
- result.verdict === "ABORTED", `got ${result.verdict}: ${result.reason}`);
285
- }
286
- {
287
- const { result } = await run("review.js", replier([
288
- ["stage-report", null],
289
- ["review:", { verdict: "SHIP", findings: [] }], ...BASE_REVIEW,
290
- ]));
291
- check("review: dead report-stager ⇒ reportStaged false", result.reportStaged === false);
292
- check("review: dead report-stager ⇒ report path not claimed",
293
- !/^specs\//.test(String(result.report)), result.report);
294
- check("review: dead report-stager ⇒ next says nothing was written",
295
- /NEVER written/.test(result.next), result.next);
296
- }
297
- {
298
- const { result } = await run("audit.js", replier([
299
- ["profile", PROFILE], ["gates", { failures: [] }],
300
- ["audit:backend", null],
301
- ["audit:", { items: [] }], ["write-backlog", "done"],
302
- ]), {});
303
- check("audit: dead auditor ⇒ the domain is listed as NOT audited",
304
- (result.notAudited || []).join(",") === "backend", JSON.stringify(result.notAudited));
305
- check("audit: dead auditor ⇒ next tells you to re-audit it",
306
- /re-audit backend/.test(result.next), result.next);
307
- }
308
- {
309
- const { result } = await run("audit.js", replier([
310
- ["profile", PROFILE], ["gates", { failures: [] }],
311
- ["audit:", { items: [] }], ["write-backlog", null],
312
- ]), {});
313
- check("audit: dead backlog writer ⇒ path not claimed",
314
- !/^specs\//.test(String(result.backlog)), result.backlog);
315
- }
316
- {
317
- const { result } = await run("refactor.js", replier([
318
- ["profile", PROFILE], ["read-backlog", null],
319
- ]), { domains: "all" });
320
- check("refactor: dead backlog reader ⇒ says it died, not 'no open items'",
321
- /agent died/.test(String(result.error)), result.error);
322
- }
323
- {
324
- const items = ["- [ ] a", "- [ ] b", "- [ ] c", "- [ ] d", "- [ ] e"];
325
- const { result } = await run("refactor.js", replier([
326
- ["profile", PROFILE],
327
- ["read-backlog", { domains: [{ key: "backend", items }] }],
328
- ["verify:", { cleared: items, remaining: [], gatesGreen: true }],
329
- ["reverify:", { cleared: items, remaining: [], gatesGreen: true }],
330
- ["tick-backlog", null],
331
- ["refactor:", "handoff"],
332
- ]), { domains: "all" });
333
- check("refactor: dead ticker ⇒ backlogTicked false", result.backlogTicked === false);
334
- check("refactor: dead ticker ⇒ next warns the backlog still shows them open",
335
- /NOT ticked/.test(result.next), result.next);
336
- }
337
-
338
- // ── audit.js / refactor.js — smoke-level: they must return, not throw ────────
339
- console.log("audit.js / refactor.js");
340
- {
341
- const { result } = await run("audit.js", replier([
342
- ["profile", PROFILE],
343
- ["gates", { failures: [] }],
344
- ["audit:", { items: [{ severity: "HIGH", file: "apps/api/a.ts", line: 1, kind: "tdd", fix: "add a test" }] }],
345
- ["write-backlog", "done"],
346
- ]), {});
347
- check("audit returns a backlog path", result.backlog === "specs/refactor-backlog.md", JSON.stringify(result));
348
- check("audit counts every domain (surfaces + shared)",
349
- Object.keys(result.domains || {}).join(",") === "backend,frontend,shared", JSON.stringify(result.domains));
350
- }
351
- {
352
- const { result } = await run("refactor.js", replier([
353
- ["profile", PROFILE],
354
- ["read-backlog", { domains: [{ key: "backend", items: ["- [ ] a", "- [ ] b"] }] }],
355
- ]), { domains: "all" });
356
- check("refactor skips a domain below the item threshold",
357
- result.skipped && result.skipped.backend === 2, JSON.stringify(result));
358
- }
359
-
360
- // ── review.js — the machine verdict contract the loop reduces on ─────────────
361
- console.log("review.js verdict contract");
362
- {
363
- const { result } = await run("review.js", replier([
364
- ["preflight", { pass: false, tail: "boom" }], ...BASE_REVIEW,
365
- ]));
366
- check("red preflight ⇒ aborted: 'preflight' (what a driver branches on)",
367
- result.aborted === "preflight", JSON.stringify(result.aborted));
368
- }
369
- {
370
- // The empty-diff SHIP certified nothing (no review, no stamp) — its `next` must not
371
- // read as "run /cohorte-ship", which would point a driver at a gate that refuses.
372
- const { result } = await run("review.js", replier([
373
- ["stage-diff", { surfaces: [] }], ...BASE_REVIEW,
374
- ]));
375
- check("empty-diff SHIP ⇒ next warns nothing was reviewed, never '/cohorte-ship'",
376
- result.verdict === "SHIP" && !String(result.next).startsWith("/cohorte-ship") && /no review|nothing to ship/i.test(result.next),
377
- result.next);
378
- }
379
- {
380
- // blocking = CRITICAL + security counted once; blocking_items = identity, not wording:
381
- // surface | file WITHOUT :line | first 8 words of the problem, lowercased, collapsed.
382
- const crit = finding({ severity: "CRITICAL", file: "apps/api/a.ts:41",
383
- problem: "Missing auth-check on POST /orders endpoint here now" });
384
- let stagePrompt = "";
385
- const { result } = await run("review.js", (prompt, opts) => {
386
- const label = opts.label || "";
387
- if (label.startsWith("verify:")) return { refuted: false, reason: "holds" };
388
- if (label.startsWith("review:backend")) return { verdict: "REVISE", findings: [crit] };
389
- if (label.startsWith("review:")) return { verdict: "SHIP", findings: [] };
390
- if (label === "stage-report") { stagePrompt = prompt; return "done"; }
391
- return replier(BASE_REVIEW)(prompt, opts);
392
- });
393
- check("blocking counts CRITICAL+security, each once", result.blocking === 1, `got ${result.blocking}`);
394
- check("blockingItems: surface|file-no-line|8-word normalized problem",
395
- (result.blockingItems || [])[0] === "backend|apps/api/a.ts|missing auth check on post orders endpoint here",
396
- JSON.stringify(result.blockingItems));
397
- check("verdict.json is staged, fingerprint computed in Bash (sha256), never by hand",
398
- /verdict\.json/.test(stagePrompt) && /sha256sum/.test(stagePrompt), stagePrompt.slice(0, 200));
399
- }
400
- {
401
- // The cross-check exists so a refuted CRITICAL cannot force a fix loop — and so an
402
- // unrefuted one still does. Both directions, plus security ⇒ BLOCK.
403
- const crit = finding({ severity: "CRITICAL" });
404
- const withVerify = refuted => (prompt, opts) => {
405
- const label = opts.label || "";
406
- if (label.startsWith("verify:")) return { refuted, reason: refuted ? "a guard covers it" : "holds" };
407
- if (label.startsWith("review:backend")) return { verdict: "REVISE", findings: [crit] };
408
- if (label.startsWith("review:")) return { verdict: "SHIP", findings: [] };
409
- return replier(BASE_REVIEW)(prompt, opts);
410
- };
411
- const kept = await run("review.js", withVerify(false));
412
- check("unrefuted CRITICAL ⇒ REVISE, cross-check ran",
413
- kept.result.verdict === "REVISE" && kept.calls.some(c => c.startsWith("verify:")),
414
- JSON.stringify([kept.result.verdict, kept.result.blocking]));
415
- const refutedRun = await run("review.js", withVerify(true));
416
- check("refuted CRITICAL ⇒ SHIP, not a fix loop",
417
- refutedRun.result.verdict === "SHIP" && refutedRun.result.refutedByCrossCheck === 1 && refutedRun.result.blocking === 0,
418
- JSON.stringify([refutedRun.result.verdict, refutedRun.result.refutedByCrossCheck]));
419
- const sec = await run("review.js", (prompt, opts) => {
420
- const label = opts.label || "";
421
- if (label.startsWith("verify:")) return { refuted: false, reason: "holds" };
422
- if (label.startsWith("review:backend")) return { verdict: "REVISE", findings: [finding({ severity: "HIGH", kind: "security" })] };
423
- if (label.startsWith("review:")) return { verdict: "SHIP", findings: [] };
424
- return replier(BASE_REVIEW)(prompt, opts);
425
- });
426
- check("surviving security finding ⇒ BLOCK, counted blocking",
427
- sec.result.verdict === "BLOCK" && sec.result.blocking === 1,
428
- JSON.stringify([sec.result.verdict, sec.result.blocking]));
429
- // A dead cross-check verifier must KEEP the finding (a real CRITICAL must not die
430
- // on a transport error), never silently drop it.
431
- const deadVerify = await run("review.js", (prompt, opts) => {
432
- const label = opts.label || "";
433
- if (label.startsWith("verify:")) return null;
434
- if (label.startsWith("review:backend")) return { verdict: "REVISE", findings: [crit] };
435
- if (label.startsWith("review:")) return { verdict: "SHIP", findings: [] };
436
- return replier(BASE_REVIEW)(prompt, opts);
437
- });
438
- check("dead verifier ⇒ the CRITICAL is kept, verdict REVISE",
439
- deadVerify.result.verdict === "REVISE" && deadVerify.result.blocking === 1,
440
- JSON.stringify([deadVerify.result.verdict, deadVerify.result.blocking]));
441
- }
442
-
443
- // ── refactor.js — args scoping + the retry round's cleared accumulation ──────
444
- console.log("refactor.js retry & args");
445
- {
446
- // Bare-string shorthand names a DOMAIN — it must never widen to 'all' (that
447
- // dispatched code-editing implementers on every big domain).
448
- let backlogPrompt = "";
449
- await run("refactor.js", (prompt, opts) => {
450
- const label = opts.label || "";
451
- if (label === "profile") return PROFILE;
452
- if (label === "read-backlog") { backlogPrompt = prompt; return { domains: [] }; }
453
- return "ok";
454
- }, "backend");
455
- check("bare-string args scope to that domain, not 'all'",
456
- /Requested domains: backend/.test(backlogPrompt) && !/Requested domains: all/.test(backlogPrompt),
457
- backlogPrompt.slice(-140));
458
- }
459
- {
460
- // The re-verify covers only the retried items; round 1's verified clears must
461
- // survive the merge or the backlog un-ticks finished work.
462
- const items = ["- [ ] a", "- [ ] b", "- [ ] c", "- [ ] d", "- [ ] e"];
463
- let tickPrompt = "";
464
- const { result } = await run("refactor.js", (prompt, opts) => {
465
- const label = opts.label || "";
466
- if (label === "profile") return PROFILE;
467
- if (label === "read-backlog") return { domains: [{ key: "backend", items }] };
468
- if (label === "verify:backend") return { cleared: items.slice(0, 3), remaining: items.slice(3), gatesGreen: true };
469
- if (label === "reverify:backend") return { cleared: items.slice(3), remaining: [], gatesGreen: true };
470
- if (label === "tick-backlog") { tickPrompt = prompt; return "done"; }
471
- return "handoff";
472
- }, { domains: "all" });
473
- check("retry round keeps round-1 clears (5/5, not 2/5)",
474
- result.domains.backend.cleared === 5 && result.domains.backend.remaining === 0,
475
- JSON.stringify(result.domains));
476
- check("all five cleared items reach the ticker", items.every(i => tickPrompt.includes(i)),
477
- tickPrompt.slice(0, 160));
478
- }
479
- {
480
- // A dead re-verifier loses only the retry round's claim: round 1's clears stay
481
- // cleared, the retried items stay open — never reset to all-five-open.
482
- const items = ["- [ ] a", "- [ ] b", "- [ ] c", "- [ ] d", "- [ ] e"];
483
- const { result } = await run("refactor.js", (prompt, opts) => {
484
- const label = opts.label || "";
485
- if (label === "profile") return PROFILE;
486
- if (label === "read-backlog") return { domains: [{ key: "backend", items }] };
487
- if (label === "verify:backend") return { cleared: items.slice(0, 3), remaining: items.slice(3), gatesGreen: true };
488
- if (label === "reverify:backend") return null;
489
- if (label === "tick-backlog") return "done";
490
- return "handoff";
491
- }, { domains: "all" });
492
- check("dead re-verifier ⇒ round-1 clears kept, retried items open",
493
- result.domains.backend.cleared === 3 && result.domains.backend.remaining === 2,
494
- JSON.stringify(result.domains));
495
- }
496
- {
497
- // Gates red with everything cleared: the retry must NOT re-open verified items —
498
- // with a dead re-verifier the same lines once sat in `cleared` AND `remaining`
499
- // (ticked off the backlog while reported open).
500
- const items = ["- [ ] a", "- [ ] b", "- [ ] c", "- [ ] d", "- [ ] e"];
501
- const { result } = await run("refactor.js", (prompt, opts) => {
502
- const label = opts.label || "";
503
- if (label === "profile") return PROFILE;
504
- if (label === "read-backlog") return { domains: [{ key: "backend", items }] };
505
- if (label === "verify:backend") return { cleared: items, remaining: [], gatesGreen: false, failures: "lint red" };
506
- if (label === "reverify:backend") return null;
507
- if (label === "tick-backlog") return "done";
508
- return "handoff";
509
- }, { domains: "all" });
510
- check("gates-red + all cleared + dead re-verifier ⇒ no cleared/remaining overlap",
511
- result.domains.backend.cleared === 5 && result.domains.backend.remaining === 0,
512
- JSON.stringify(result.domains));
513
- }
514
-
515
- // ── loop.js — build → review → [fix → review]*, unattended ──────────────────
516
- // The reducer's facts come from stubs, but the DECISIONS under test (freshness,
517
- // precondition gates, exit ordering, treading water) all live in script code —
518
- // which is exactly why they live there and not in an agent prompt.
519
- console.log("loop.js");
520
-
521
- const loopFacts = (over = {}) => ({
522
- now: { epoch: 1000000, iso: "2026-08-22T00:00:00Z" },
523
- spec: { exists: true, status: "frozen", kind: "", mtimeEpoch: 500, designFiles: [] },
524
- readiness: { exists: true, mtimeEpoch: 600, verdict: "READY", gaps: [], surfaces: ["backend", "frontend"] },
525
- contractFile: { exists: true },
526
- build: { exists: false },
527
- loop: { exists: false },
528
- ...over,
529
- });
530
- const FRESH_BUILD = { exists: true, mtimeEpoch: 600, dead: [] };
531
- const loopReply = (facts, over = {}) => (prompt, opts) => {
532
- const label = opts.label || "";
533
- if (label === "profile") return over.profile || PROFILE;
534
- if (label === "preconditions") return facts;
535
- if (label.startsWith("state:") || label === "close") return "done 1000001";
536
- if (label.startsWith("ingest:")) return "ingest" in over ? over.ingest
537
- : { items: [{ line: "- [ ] CRITICAL · apps/api/a.ts:3 · quality · f", file: "apps/api/a.ts" }] };
538
- if (label.startsWith("tick:")) return "done";
539
- if (label.startsWith("build") || label.startsWith("fix")) {
540
- return "impl" in over ? over.impl : "handoff\n## Remediation addressed\n- apps/api/a.ts:3 — fixed";
541
- }
542
- return "ok";
543
- };
544
- const reviewOf = over => ({
545
- verdict: "REVISE", blocking: 1, blockingItems: ["backend|apps/api/a.ts|p"],
546
- unreviewedSurfaces: [], deferred: 0, ...over,
547
- });
548
- const SHIP_CLEAN = { verdict: "SHIP", blocking: 0, blockingItems: [], unreviewedSurfaces: [], deferred: 0, next: "/cohorte-ship feat-x (DoD ticked + freshness stamped)" };
549
-
550
- {
551
- // THE ordering regression: a dead reviewer's zero findings must not read as ship.
552
- // unreviewed is checked BEFORE blocking — the other way round ships unread code.
553
- const wf = async () => reviewOf({ blocking: 0, unreviewedSurfaces: ["backend"] });
554
- const { result } = await run("loop.js", loopReply(loopFacts({ build: FRESH_BUILD })), { feature: "feat-x" }, wf);
555
- check("unreviewed + blocking 0 ⇒ abort/unreviewed, NOT ship",
556
- result.outcome === "abort" && result.reason === "unreviewed", JSON.stringify([result.outcome, result.reason]));
557
- }
558
- {
559
- // Same blocking identity two consecutive rounds ⇒ treading water at round 2,
560
- // not burned down to maxRounds.
561
- const wf = async () => reviewOf();
562
- const { result, calls } = await run("loop.js", loopReply(loopFacts({ build: FRESH_BUILD })), { feature: "feat-x" }, wf);
563
- check("identical fingerprint twice ⇒ abort/treading-water", result.reason === "treading-water", result.reason);
564
- check("…at round 2, not maxRounds", result.rounds === 2, `rounds ${result.rounds}`);
565
- check("…after exactly one fix round", calls.filter(c => c.startsWith("fix:")).length === 1,
566
- calls.filter(c => c.startsWith("fix")).join(","));
567
- }
568
- {
569
- // NOT-READY is the one outcome more passes cannot fix — and a precondition that
570
- // aborts AFTER spawning has not aborted: only profile + preconditions may run.
571
- const facts = loopFacts({ readiness: { exists: true, mtimeEpoch: 600, verdict: "NOT-READY", gaps: ["contract|POST /x|no shape"], surfaces: ["backend"] } });
572
- const { result, calls } = await run("loop.js", loopReply(facts), { feature: "feat-x" }, async () => SHIP_CLEAN);
573
- check("NOT-READY ⇒ abort/precondition with the gaps verbatim",
574
- result.reason === "precondition" && (result.gaps || []).length === 1, JSON.stringify(result));
575
- check("NOT-READY ⇒ zero dispatches (profile + facts only)",
576
- calls.join(",") === "profile,preconditions", calls.join(","));
577
- }
578
- {
579
- // readiness older than the spec describes a spec that no longer exists ⇒ absent.
580
- const facts = loopFacts({ readiness: { exists: true, mtimeEpoch: 400, verdict: "READY", gaps: [], surfaces: ["backend"] } });
581
- const { result, calls } = await run("loop.js", loopReply(facts), { feature: "feat-x" }, async () => SHIP_CLEAN);
582
- check("stale readiness.json ⇒ treated as absent ⇒ abort/precondition",
583
- result.reason === "precondition" && /older than the spec/.test(result.detail), JSON.stringify(result.detail));
584
- check("stale readiness ⇒ zero dispatches", calls.join(",") === "profile,preconditions", calls.join(","));
585
- }
586
- {
587
- // A blocking finding on the contract file is /cohorte-fix §1's lead-only step.
588
- const CPROFILE = { ...PROFILE, contract: { enabled: true, path: "packages/shared/src", ext: "ts", mechanism: "shared-types-zod" } };
589
- const wf = async () => reviewOf({ blockingItems: ["backend|packages/shared/src/feat-x.ts|response shape wrong"] });
590
- const { result, calls } = await run("loop.js",
591
- loopReply(loopFacts({ build: FRESH_BUILD }), { profile: CPROFILE }), { feature: "feat-x" }, wf);
592
- check("blocking finding on the contract file ⇒ abort/contract-change",
593
- result.reason === "contract-change", result.reason);
594
- check("contract-change ⇒ no fix round dispatched", !calls.some(c => c.startsWith("fix")), calls.join(","));
595
- }
596
- {
597
- // Dead build implementers: retried ONCE, byte-identical, then abort — never "ok".
598
- const { result, calls, prompts } = await run("loop.js",
599
- loopReply(loopFacts(), { impl: null }), { feature: "feat-x" }, async () => SHIP_CLEAN);
600
- check("dead implementers ⇒ abort/dead-implementers", result.reason === "dead-implementers", result.reason);
601
- check("each dead surface retried exactly once",
602
- calls.filter(c => c === "build:backend").length === 1 && calls.filter(c => c === "build-retry:backend").length === 1,
603
- calls.join(","));
604
- check("the retry is byte-identical to the dispatch",
605
- prompts["build:backend"] === prompts["build-retry:backend"]);
606
- }
607
- {
608
- // Deferred findings never cost a round: 9 deferred + 0 blocking ships in one.
609
- const wf = async () => ({ ...SHIP_CLEAN, deferred: 9 });
610
- const { result } = await run("loop.js", loopReply(loopFacts({ build: FRESH_BUILD })), { feature: "feat-x" }, wf);
611
- check("deferred 9 + blocking 0 ⇒ ship in one round",
612
- result.outcome === "ship" && result.rounds === 1 && result.deferred === 9, JSON.stringify(result));
613
- }
614
- {
615
- // The degraded preflight verdict has no unreviewed/blocking keys — the reducer
616
- // must branch on `aborted`, not crash on a missing field.
617
- const wf = async () => ({ verdict: "ABORTED", aborted: "preflight", reason: "preflight red" });
618
- const { result } = await run("loop.js", loopReply(loopFacts({ build: FRESH_BUILD })), { feature: "feat-x" }, wf);
619
- check("child preflight abort ⇒ abort/preflight (no crash on the degraded shape)",
620
- result.outcome === "abort" && result.reason === "preflight", JSON.stringify([result.outcome, result.reason]));
621
- }
622
- {
623
- // A fresh build.json with no dead surfaces means the work is on disk — entering
624
- // after a conversational /cohorte-build must not rebuild it.
625
- const { result, calls } = await run("loop.js",
626
- loopReply(loopFacts({ build: FRESH_BUILD })), { feature: "feat-x" }, async () => SHIP_CLEAN);
627
- check("fresh build.json ⇒ build phase skipped", !calls.some(c => c.startsWith("build")), calls.join(","));
628
- check("…and the run still ships", result.outcome === "ship", result.outcome);
629
- // …and a STALE build.json builds: the work on disk predates the spec.
630
- const stale = await run("loop.js",
631
- loopReply(loopFacts({ build: { exists: true, mtimeEpoch: 400, dead: [] } })), { feature: "feat-x" }, async () => SHIP_CLEAN);
632
- check("stale build.json ⇒ build phase runs", stale.calls.some(c => c.startsWith("build:")), stale.calls.join(","));
633
- }
634
- {
635
- // Resume: an unfinished, fresh loop.json restores round + the treading-water key,
636
- // so a run killed mid-round costs a re-review, not a restart.
637
- const prev = JSON.stringify({ id: "feat-x", round: 3, lastItems: ["backend|apps/api/a.ts|p"], history: [{ round: 1, blocking: 3 }, { round: 2, blocking: 1 }] });
638
- const facts = loopFacts({ build: FRESH_BUILD, loop: { exists: true, mtimeEpoch: 700, raw: prev } });
639
- const wf = async () => reviewOf();
640
- const { result, calls } = await run("loop.js", loopReply(facts), { feature: "feat-x" }, wf);
641
- check("resume: same items as the resumed round ⇒ treading-water immediately",
642
- result.reason === "treading-water" && !calls.some(c => c.startsWith("fix")), JSON.stringify([result.reason, result.rounds]));
643
- check("resume: history carries the prior rounds", result.rounds === 3, `rounds ${result.rounds}`);
644
- // A FINISHED loop.json (outcome set) must not resume — fresh run from round 1.
645
- const done = JSON.stringify({ id: "feat-x", round: 4, outcome: "abort", lastItems: ["backend|apps/api/a.ts|p"], history: [] });
646
- const r2 = await run("loop.js",
647
- loopReply(loopFacts({ build: FRESH_BUILD, loop: { exists: true, mtimeEpoch: 700, raw: done } })),
648
- { feature: "feat-x" }, wf);
649
- check("a finished loop.json does not resume (round 1, fix dispatched)",
650
- r2.calls.some(c => c.startsWith("fix:")), r2.calls.join(","));
651
- }
652
- {
653
- // The loop EDITS the spec as it runs (status stamps, Remediation appends), so on
654
- // resume the spec's mtime is NEWER than readiness.json — freshness must be measured
655
- // against the baseline stored in loop.json, or the loop's own footprint aborts its
656
- // own resume with "readiness is older than the spec".
657
- const prev = JSON.stringify({ id: "feat-x", round: 2, specMtime: 500, lastItems: ["backend|apps/api/old.ts|p"], history: [{ round: 1, blocking: 3 }] });
658
- const facts = loopFacts({
659
- spec: { exists: true, status: "in-progress", kind: "", mtimeEpoch: 900, designFiles: [] },
660
- build: FRESH_BUILD,
661
- loop: { exists: true, mtimeEpoch: 700, raw: prev },
662
- });
663
- const { result } = await run("loop.js", loopReply(facts), { feature: "feat-x" }, async () => SHIP_CLEAN);
664
- check("resume survives the loop's own spec writes (baseline mtime, not current)",
665
- result.outcome === "ship", JSON.stringify([result.outcome, result.reason, result.detail]));
666
- check("resume keeps only rounds before the resumed one (no double count)",
667
- result.rounds === 2, `rounds ${result.rounds}`);
668
- }
669
- {
670
- // A real verdict whose report never landed on disk: the fix round would ingest the
671
- // PREVIOUS round's report. Resumable give-up, never a fix round on stale findings.
672
- const wf = async () => reviewOf({ reportStaged: false });
673
- const { result, calls } = await run("loop.js", loopReply(loopFacts({ build: FRESH_BUILD })), { feature: "feat-x" }, wf);
674
- check("blocking verdict + unstaged report ⇒ abort/report-not-staged, no fix round",
675
- result.reason === "report-not-staged" && !calls.some(c => c.startsWith("fix")),
676
- JSON.stringify([result.reason, calls.filter(c => c.startsWith("fix"))]));
677
- }
678
- {
679
- // Ordinary surface code can live UNDER contract.path (a `shared` surface at the
680
- // contract package) — only the feature's contract FILE is the lead-only abort.
681
- const CPROFILE = { ...PROFILE, contract: { enabled: true, path: "packages/shared/src", ext: "ts", mechanism: "shared-types-zod" } };
682
- const wf = async () => reviewOf({ blockingItems: ["backend|packages/shared/src/utils.ts|helper broken"] });
683
- const { result, calls } = await run("loop.js",
684
- loopReply(loopFacts({ build: FRESH_BUILD }), { profile: CPROFILE }), { feature: "feat-x" }, wf);
685
- check("a finding elsewhere under contract.path is NOT contract-change",
686
- result.reason !== "contract-change" && calls.some(c => c.startsWith("fix:")),
687
- JSON.stringify([result.reason, calls.filter(c => c.startsWith("fix"))]));
688
- }
689
- {
690
- // A dead fix implementer still gets its metrics line — an incomplete batch is the
691
- // batch worth recording — written BEFORE the abort.
692
- const wf = async () => reviewOf();
693
- const { result, prompts } = await run("loop.js",
694
- loopReply(loopFacts({ build: FRESH_BUILD }), { impl: null }), { feature: "feat-x" }, wf);
695
- check("dead fix implementer ⇒ abort, with the fix metrics written first (\"dead\")",
696
- result.reason === "dead-implementers" && /"backend":"dead"/.test(prompts["state:fixed-1"] || ""),
697
- JSON.stringify([result.reason, (prompts["state:fixed-1"] || "").slice(-120)]));
698
- }
699
- {
700
- // maxRounds is the last net: distinct findings each round burn down to it.
701
- let n = 0;
702
- const wf = async () => reviewOf({ blockingItems: [`backend|apps/api/f${++n}.ts|p`] });
703
- const { result } = await run("loop.js", loopReply(loopFacts({ build: FRESH_BUILD })),
704
- { feature: "feat-x", maxRounds: 2 }, wf);
705
- check("maxRounds reached with distinct findings ⇒ abort/max-rounds at that round",
706
- result.reason === "max-rounds" && result.rounds === 2, JSON.stringify([result.reason, result.rounds]));
707
- }
708
- {
709
- // A ship with surviving HIGH/MEDIUM has NO freshness stamp — the loop must relay
710
- // review's routing (which says /cohorte-fix), not print "/cohorte-ship".
711
- const wf = async () => ({ ...SHIP_CLEAN, next: "/cohorte-fix feat-x — SHIP verdict, but 2 finding(s) above LOW survived; park them in specs/refactor-backlog.md instead if you deliberately defer them" });
712
- const { result } = await run("loop.js", loopReply(loopFacts({ build: FRESH_BUILD })), { feature: "feat-x" }, wf);
713
- check("ship with HIGH leftovers ⇒ next relays review's /cohorte-fix routing",
714
- String(result.next).startsWith("/cohorte-fix"), result.next);
715
- }
716
- {
717
- // Dead ingest = no Remediation items were appended; dispatching blind would
718
- // re-build surfaces with no instructions. Abort, resumable at this round.
719
- const wf = async () => reviewOf();
720
- const { result } = await run("loop.js",
721
- loopReply(loopFacts({ build: FRESH_BUILD }), { ingest: null }), { feature: "feat-x" }, wf);
722
- check("dead ingest agent ⇒ abort/ingest-died, items never invented",
723
- result.reason === "ingest-died", result.reason);
724
- }
725
- {
726
- // Token accounting: the run's history and metrics lines carry approximate output
727
- // tokens from budget.spent() — the figure the conversational path cannot record.
728
- const wf = async () => reviewOf();
729
- const { result, prompts } = await run("loop.js", loopReply(loopFacts()),
730
- { feature: "feat-x" }, wf, tickingBudget());
731
- check("loop: history rounds carry a tokens figure",
732
- result.history.length > 0 && result.history.every(h => typeof h.tokens === "number" && h.tokens > 0),
733
- JSON.stringify(result.history));
734
- check("loop: build metrics line carries tokens",
735
- /"phase":"build".*"tokens":[1-9]/.test(prompts["state:built"] || ""),
736
- (prompts["state:built"] || "").slice(-200));
737
- check("loop: the run total is returned", typeof result.tokens === "number" && result.tokens > 0,
738
- String(result.tokens));
739
- let stagePrompt = "";
740
- await run("review.js", (prompt, opts) => {
741
- if (opts.label === "stage-report") { stagePrompt = prompt; return "done"; }
742
- return replier([["review:", { verdict: "SHIP", findings: [] }], ...BASE_REVIEW])(prompt, opts);
743
- }, { feature: "feat-x" }, undefined, tickingBudget());
744
- check("review: metrics line carries tokens",
745
- /"phase":"review".*"tokens":[1-9]/.test(stagePrompt), stagePrompt.slice(-200));
746
- }
747
- {
748
- // workflow() unavailable (no runtime / review.js not installed) ⇒ explicit refusal,
749
- // never a conversational fallback.
750
- const wf = async () => { throw new Error("unknown workflow: cohorte-review"); };
751
- const { result } = await run("loop.js", loopReply(loopFacts({ build: FRESH_BUILD })), { feature: "feat-x" }, wf);
752
- check("review workflow unavailable ⇒ abort/review-workflow-unavailable",
753
- result.reason === "review-workflow-unavailable", result.reason);
754
- }
755
-
756
- console.log("");
757
- if (failures) { console.error(`test-workflows: ${failures} failure(s)`); process.exit(1); }
758
- console.log("test-workflows: OK");