cclaw-cli 7.7.1 → 8.1.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 (282) hide show
  1. package/README.md +210 -134
  2. package/dist/artifact-frontmatter.d.ts +51 -0
  3. package/dist/artifact-frontmatter.js +131 -0
  4. package/dist/artifact-paths.d.ts +7 -27
  5. package/dist/artifact-paths.js +20 -249
  6. package/dist/cancel.d.ts +16 -0
  7. package/dist/cancel.js +66 -0
  8. package/dist/cli.d.ts +2 -27
  9. package/dist/cli.js +90 -508
  10. package/dist/compound.d.ts +26 -0
  11. package/dist/compound.js +96 -0
  12. package/dist/config.d.ts +14 -51
  13. package/dist/config.js +23 -359
  14. package/dist/constants.d.ts +11 -18
  15. package/dist/constants.js +19 -106
  16. package/dist/content/antipatterns.d.ts +1 -0
  17. package/dist/content/antipatterns.js +109 -0
  18. package/dist/content/artifact-templates.d.ts +10 -0
  19. package/dist/content/artifact-templates.js +550 -0
  20. package/dist/content/cancel-command.d.ts +2 -2
  21. package/dist/content/cancel-command.js +25 -17
  22. package/dist/content/core-agents.d.ts +9 -233
  23. package/dist/content/core-agents.js +39 -768
  24. package/dist/content/decision-protocol.d.ts +1 -12
  25. package/dist/content/decision-protocol.js +27 -20
  26. package/dist/content/examples.d.ts +8 -42
  27. package/dist/content/examples.js +293 -425
  28. package/dist/content/idea-command.d.ts +2 -0
  29. package/dist/content/idea-command.js +38 -0
  30. package/dist/content/iron-laws.d.ts +4 -138
  31. package/dist/content/iron-laws.js +18 -197
  32. package/dist/content/meta-skill.d.ts +1 -3
  33. package/dist/content/meta-skill.js +57 -134
  34. package/dist/content/node-hooks.d.ts +12 -8
  35. package/dist/content/node-hooks.js +188 -838
  36. package/dist/content/recovery.d.ts +8 -0
  37. package/dist/content/recovery.js +179 -0
  38. package/dist/content/reference-patterns.d.ts +4 -13
  39. package/dist/content/reference-patterns.js +260 -389
  40. package/dist/content/research-playbooks.d.ts +8 -8
  41. package/dist/content/research-playbooks.js +108 -121
  42. package/dist/content/review-loop.d.ts +6 -192
  43. package/dist/content/review-loop.js +29 -731
  44. package/dist/content/skills.d.ts +8 -38
  45. package/dist/content/skills.js +681 -732
  46. package/dist/content/specialist-prompts/architect.d.ts +1 -0
  47. package/dist/content/specialist-prompts/architect.js +225 -0
  48. package/dist/content/specialist-prompts/brainstormer.d.ts +1 -0
  49. package/dist/content/specialist-prompts/brainstormer.js +168 -0
  50. package/dist/content/specialist-prompts/index.d.ts +2 -0
  51. package/dist/content/specialist-prompts/index.js +14 -0
  52. package/dist/content/specialist-prompts/planner.d.ts +1 -0
  53. package/dist/content/specialist-prompts/planner.js +182 -0
  54. package/dist/content/specialist-prompts/reviewer.d.ts +1 -0
  55. package/dist/content/specialist-prompts/reviewer.js +193 -0
  56. package/dist/content/specialist-prompts/security-reviewer.d.ts +1 -0
  57. package/dist/content/specialist-prompts/security-reviewer.js +133 -0
  58. package/dist/content/specialist-prompts/slice-builder.d.ts +1 -0
  59. package/dist/content/specialist-prompts/slice-builder.js +232 -0
  60. package/dist/content/stage-playbooks.d.ts +8 -0
  61. package/dist/content/stage-playbooks.js +404 -0
  62. package/dist/content/start-command.d.ts +2 -12
  63. package/dist/content/start-command.js +221 -207
  64. package/dist/flow-state.d.ts +21 -178
  65. package/dist/flow-state.js +67 -170
  66. package/dist/fs-utils.d.ts +6 -26
  67. package/dist/fs-utils.js +29 -162
  68. package/dist/gitignore.d.ts +2 -1
  69. package/dist/gitignore.js +51 -34
  70. package/dist/harness-detect.d.ts +10 -0
  71. package/dist/harness-detect.js +29 -0
  72. package/dist/install.d.ts +27 -15
  73. package/dist/install.js +230 -1342
  74. package/dist/knowledge-store.d.ts +19 -163
  75. package/dist/knowledge-store.js +56 -590
  76. package/dist/logger.d.ts +8 -3
  77. package/dist/logger.js +13 -4
  78. package/dist/orchestrator-routing.d.ts +29 -0
  79. package/dist/orchestrator-routing.js +156 -0
  80. package/dist/run-persistence.d.ts +7 -118
  81. package/dist/run-persistence.js +29 -845
  82. package/dist/runtime/run-hook.entry.d.ts +1 -3
  83. package/dist/runtime/run-hook.entry.js +19 -4
  84. package/dist/runtime/run-hook.mjs +13 -1024
  85. package/dist/types.d.ts +25 -261
  86. package/dist/types.js +8 -36
  87. package/package.json +6 -3
  88. package/dist/artifact-linter/brainstorm.d.ts +0 -2
  89. package/dist/artifact-linter/brainstorm.js +0 -353
  90. package/dist/artifact-linter/design.d.ts +0 -18
  91. package/dist/artifact-linter/design.js +0 -444
  92. package/dist/artifact-linter/findings-dedup.d.ts +0 -56
  93. package/dist/artifact-linter/findings-dedup.js +0 -232
  94. package/dist/artifact-linter/plan.d.ts +0 -2
  95. package/dist/artifact-linter/plan.js +0 -826
  96. package/dist/artifact-linter/review-army.d.ts +0 -49
  97. package/dist/artifact-linter/review-army.js +0 -520
  98. package/dist/artifact-linter/review.d.ts +0 -2
  99. package/dist/artifact-linter/review.js +0 -113
  100. package/dist/artifact-linter/scope.d.ts +0 -2
  101. package/dist/artifact-linter/scope.js +0 -158
  102. package/dist/artifact-linter/shared.d.ts +0 -637
  103. package/dist/artifact-linter/shared.js +0 -2163
  104. package/dist/artifact-linter/ship.d.ts +0 -2
  105. package/dist/artifact-linter/ship.js +0 -250
  106. package/dist/artifact-linter/spec.d.ts +0 -2
  107. package/dist/artifact-linter/spec.js +0 -176
  108. package/dist/artifact-linter/tdd.d.ts +0 -118
  109. package/dist/artifact-linter/tdd.js +0 -1404
  110. package/dist/artifact-linter.d.ts +0 -15
  111. package/dist/artifact-linter.js +0 -517
  112. package/dist/codex-feature-flag.d.ts +0 -58
  113. package/dist/codex-feature-flag.js +0 -193
  114. package/dist/content/closeout-guidance.d.ts +0 -14
  115. package/dist/content/closeout-guidance.js +0 -44
  116. package/dist/content/diff-command.d.ts +0 -1
  117. package/dist/content/diff-command.js +0 -43
  118. package/dist/content/harness-doc.d.ts +0 -1
  119. package/dist/content/harness-doc.js +0 -65
  120. package/dist/content/hook-events.d.ts +0 -9
  121. package/dist/content/hook-events.js +0 -23
  122. package/dist/content/hook-manifest.d.ts +0 -81
  123. package/dist/content/hook-manifest.js +0 -156
  124. package/dist/content/hooks.d.ts +0 -11
  125. package/dist/content/hooks.js +0 -1972
  126. package/dist/content/idea.d.ts +0 -60
  127. package/dist/content/idea.js +0 -416
  128. package/dist/content/language-policy.d.ts +0 -2
  129. package/dist/content/language-policy.js +0 -13
  130. package/dist/content/learnings.d.ts +0 -6
  131. package/dist/content/learnings.js +0 -141
  132. package/dist/content/observe.d.ts +0 -19
  133. package/dist/content/observe.js +0 -86
  134. package/dist/content/opencode-plugin.d.ts +0 -1
  135. package/dist/content/opencode-plugin.js +0 -635
  136. package/dist/content/review-prompts.d.ts +0 -1
  137. package/dist/content/review-prompts.js +0 -104
  138. package/dist/content/runtime-shared-snippets.d.ts +0 -8
  139. package/dist/content/runtime-shared-snippets.js +0 -80
  140. package/dist/content/session-hooks.d.ts +0 -7
  141. package/dist/content/session-hooks.js +0 -107
  142. package/dist/content/skills-elicitation.d.ts +0 -1
  143. package/dist/content/skills-elicitation.js +0 -167
  144. package/dist/content/stage-command.d.ts +0 -2
  145. package/dist/content/stage-command.js +0 -17
  146. package/dist/content/stage-schema.d.ts +0 -117
  147. package/dist/content/stage-schema.js +0 -955
  148. package/dist/content/stages/_lint-metadata/index.d.ts +0 -2
  149. package/dist/content/stages/_lint-metadata/index.js +0 -97
  150. package/dist/content/stages/brainstorm.d.ts +0 -2
  151. package/dist/content/stages/brainstorm.js +0 -184
  152. package/dist/content/stages/design.d.ts +0 -2
  153. package/dist/content/stages/design.js +0 -288
  154. package/dist/content/stages/index.d.ts +0 -8
  155. package/dist/content/stages/index.js +0 -11
  156. package/dist/content/stages/plan.d.ts +0 -2
  157. package/dist/content/stages/plan.js +0 -191
  158. package/dist/content/stages/review.d.ts +0 -2
  159. package/dist/content/stages/review.js +0 -240
  160. package/dist/content/stages/schema-types.d.ts +0 -203
  161. package/dist/content/stages/schema-types.js +0 -1
  162. package/dist/content/stages/scope.d.ts +0 -2
  163. package/dist/content/stages/scope.js +0 -254
  164. package/dist/content/stages/ship.d.ts +0 -2
  165. package/dist/content/stages/ship.js +0 -159
  166. package/dist/content/stages/spec.d.ts +0 -2
  167. package/dist/content/stages/spec.js +0 -170
  168. package/dist/content/stages/tdd.d.ts +0 -4
  169. package/dist/content/stages/tdd.js +0 -273
  170. package/dist/content/state-contracts.d.ts +0 -1
  171. package/dist/content/state-contracts.js +0 -63
  172. package/dist/content/status-command.d.ts +0 -4
  173. package/dist/content/status-command.js +0 -109
  174. package/dist/content/subagent-context-skills.d.ts +0 -4
  175. package/dist/content/subagent-context-skills.js +0 -279
  176. package/dist/content/subagents.d.ts +0 -3
  177. package/dist/content/subagents.js +0 -997
  178. package/dist/content/templates.d.ts +0 -26
  179. package/dist/content/templates.js +0 -1692
  180. package/dist/content/track-render-context.d.ts +0 -18
  181. package/dist/content/track-render-context.js +0 -53
  182. package/dist/content/tree-command.d.ts +0 -1
  183. package/dist/content/tree-command.js +0 -64
  184. package/dist/content/utility-skills.d.ts +0 -30
  185. package/dist/content/utility-skills.js +0 -160
  186. package/dist/content/view-command.d.ts +0 -2
  187. package/dist/content/view-command.js +0 -92
  188. package/dist/delegation.d.ts +0 -649
  189. package/dist/delegation.js +0 -1539
  190. package/dist/early-loop.d.ts +0 -70
  191. package/dist/early-loop.js +0 -302
  192. package/dist/execution-topology.d.ts +0 -44
  193. package/dist/execution-topology.js +0 -95
  194. package/dist/gate-evidence.d.ts +0 -85
  195. package/dist/gate-evidence.js +0 -631
  196. package/dist/harness-adapters.d.ts +0 -151
  197. package/dist/harness-adapters.js +0 -756
  198. package/dist/harness-selection.d.ts +0 -31
  199. package/dist/harness-selection.js +0 -214
  200. package/dist/hook-schema.d.ts +0 -6
  201. package/dist/hook-schema.js +0 -114
  202. package/dist/hook-schemas/claude-hooks.v1.json +0 -10
  203. package/dist/hook-schemas/codex-hooks.v1.json +0 -10
  204. package/dist/hook-schemas/cursor-hooks.v1.json +0 -13
  205. package/dist/init-detect.d.ts +0 -2
  206. package/dist/init-detect.js +0 -50
  207. package/dist/internal/advance-stage/advance.d.ts +0 -89
  208. package/dist/internal/advance-stage/advance.js +0 -655
  209. package/dist/internal/advance-stage/cancel-run.d.ts +0 -8
  210. package/dist/internal/advance-stage/cancel-run.js +0 -19
  211. package/dist/internal/advance-stage/flow-state-coercion.d.ts +0 -3
  212. package/dist/internal/advance-stage/flow-state-coercion.js +0 -81
  213. package/dist/internal/advance-stage/helpers.d.ts +0 -14
  214. package/dist/internal/advance-stage/helpers.js +0 -145
  215. package/dist/internal/advance-stage/hook.d.ts +0 -8
  216. package/dist/internal/advance-stage/hook.js +0 -40
  217. package/dist/internal/advance-stage/parsers.d.ts +0 -72
  218. package/dist/internal/advance-stage/parsers.js +0 -357
  219. package/dist/internal/advance-stage/proactive-delegation-trace.d.ts +0 -24
  220. package/dist/internal/advance-stage/proactive-delegation-trace.js +0 -56
  221. package/dist/internal/advance-stage/review-loop.d.ts +0 -16
  222. package/dist/internal/advance-stage/review-loop.js +0 -199
  223. package/dist/internal/advance-stage/rewind.d.ts +0 -14
  224. package/dist/internal/advance-stage/rewind.js +0 -108
  225. package/dist/internal/advance-stage/start-flow.d.ts +0 -13
  226. package/dist/internal/advance-stage/start-flow.js +0 -241
  227. package/dist/internal/advance-stage/verify.d.ts +0 -21
  228. package/dist/internal/advance-stage/verify.js +0 -185
  229. package/dist/internal/advance-stage.d.ts +0 -7
  230. package/dist/internal/advance-stage.js +0 -138
  231. package/dist/internal/cohesion-contract-stub.d.ts +0 -24
  232. package/dist/internal/cohesion-contract-stub.js +0 -148
  233. package/dist/internal/compound-readiness.d.ts +0 -23
  234. package/dist/internal/compound-readiness.js +0 -102
  235. package/dist/internal/detect-public-api-changes.d.ts +0 -5
  236. package/dist/internal/detect-public-api-changes.js +0 -45
  237. package/dist/internal/detect-supply-chain-changes.d.ts +0 -6
  238. package/dist/internal/detect-supply-chain-changes.js +0 -138
  239. package/dist/internal/early-loop-status.d.ts +0 -7
  240. package/dist/internal/early-loop-status.js +0 -93
  241. package/dist/internal/envelope-validate.d.ts +0 -7
  242. package/dist/internal/envelope-validate.js +0 -66
  243. package/dist/internal/flow-state-repair.d.ts +0 -20
  244. package/dist/internal/flow-state-repair.js +0 -104
  245. package/dist/internal/plan-split-waves.d.ts +0 -190
  246. package/dist/internal/plan-split-waves.js +0 -764
  247. package/dist/internal/runtime-integrity.d.ts +0 -7
  248. package/dist/internal/runtime-integrity.js +0 -268
  249. package/dist/internal/slice-commit.d.ts +0 -7
  250. package/dist/internal/slice-commit.js +0 -619
  251. package/dist/internal/tdd-loop-status.d.ts +0 -14
  252. package/dist/internal/tdd-loop-status.js +0 -68
  253. package/dist/internal/tdd-red-evidence.d.ts +0 -7
  254. package/dist/internal/tdd-red-evidence.js +0 -153
  255. package/dist/internal/waiver-grant.d.ts +0 -62
  256. package/dist/internal/waiver-grant.js +0 -294
  257. package/dist/internal/wave-status.d.ts +0 -74
  258. package/dist/internal/wave-status.js +0 -506
  259. package/dist/managed-resources.d.ts +0 -53
  260. package/dist/managed-resources.js +0 -313
  261. package/dist/policy.d.ts +0 -10
  262. package/dist/policy.js +0 -167
  263. package/dist/retro-gate.d.ts +0 -9
  264. package/dist/retro-gate.js +0 -47
  265. package/dist/run-archive.d.ts +0 -61
  266. package/dist/run-archive.js +0 -391
  267. package/dist/runs.d.ts +0 -2
  268. package/dist/runs.js +0 -2
  269. package/dist/stack-detection.d.ts +0 -116
  270. package/dist/stack-detection.js +0 -489
  271. package/dist/streaming/event-stream.d.ts +0 -31
  272. package/dist/streaming/event-stream.js +0 -114
  273. package/dist/tdd-cycle.d.ts +0 -107
  274. package/dist/tdd-cycle.js +0 -289
  275. package/dist/tdd-verification-evidence.d.ts +0 -17
  276. package/dist/tdd-verification-evidence.js +0 -122
  277. package/dist/track-heuristics.d.ts +0 -27
  278. package/dist/track-heuristics.js +0 -154
  279. package/dist/util/slice-id.d.ts +0 -58
  280. package/dist/util/slice-id.js +0 -89
  281. package/dist/worktree-manager.d.ts +0 -20
  282. package/dist/worktree-manager.js +0 -108
@@ -1,1404 +0,0 @@
1
- import fs from "node:fs/promises";
2
- import path from "node:path";
3
- import { execFile } from "node:child_process";
4
- import { promisify } from "node:util";
5
- import { loadTddReadySlicePool, readDelegationLedger, readDelegationEvents, selectReadySlices } from "../delegation.js";
6
- import { resolveArtifactPath as resolveStageArtifactPath } from "../artifact-paths.js";
7
- import { exists } from "../fs-utils.js";
8
- import { mergeParallelWaveDefinitions, parseParallelExecutionPlanWaves, parseWavePlanDirectory } from "../internal/plan-split-waves.js";
9
- import { compareSliceIds } from "../util/slice-id.js";
10
- import { extractAcceptanceCriterionIdsFromMarkdown, extractH2Sections, evaluateInvestigationTrace, sectionBodyByName } from "./shared.js";
11
- const SLICE_SUMMARY_START = "<!-- auto-start: tdd-slice-summary -->";
12
- const SLICE_SUMMARY_END = "<!-- auto-end: tdd-slice-summary -->";
13
- const SLICES_INDEX_START = "<!-- auto-start: slices-index -->";
14
- const SLICES_INDEX_END = "<!-- auto-end: slices-index -->";
15
- const execFileAsync = promisify(execFile);
16
- /**
17
- * TDD stage linter.
18
- *
19
- * Source-of-truth ladder, in order of precedence:
20
- *
21
- * 1. **Phase events** in `delegation-events.jsonl` for the active run
22
- * (`stage=tdd`, `sliceId=S-N`, `phase=red|green|refactor|refactor-deferred|doc`).
23
- * When at least one slice carries any phase event, the linter
24
- * auto-derives Watched-RED / Vertical Slice Cycle from the events
25
- * and writes a rendered summary block between auto-render markers
26
- * in `06-tdd.md`. Markdown table content is no longer required.
27
- * 2. **Hand-authored markdown tables** (Watched-RED Proof + Vertical
28
- * Slice Cycle) — used as a fallback when the events ledger has no
29
- * slice phase rows for the active run.
30
- * 3. **Sharded slice files** under `<artifacts-dir>/tdd-slices/S-*.md`.
31
- * Per-slice prose lives there. The main `06-tdd.md` is auto-indexed
32
- * via `## Slices Index`.
33
- */
34
- export async function lintTddStage(ctx) {
35
- const { projectRoot, discoveryMode, raw, absFile, sections, findings, parsedFrontmatter } = ctx;
36
- void parsedFrontmatter;
37
- const artifactsDir = path.dirname(absFile);
38
- const planPath = path.join(artifactsDir, "05-plan.md");
39
- let planRaw = "";
40
- try {
41
- planRaw = await fs.readFile(planPath, "utf8");
42
- }
43
- catch {
44
- planRaw = "";
45
- }
46
- evaluateInvestigationTrace(ctx, "Watched-RED Proof");
47
- const delegationLedger = await readDelegationLedger(ctx.projectRoot);
48
- const activeRunEntries = delegationLedger.entries.filter((entry) => entry.stage === "tdd" && entry.runId === delegationLedger.runId);
49
- const slicesByEvents = groupBySlice(activeRunEntries);
50
- const eventsActive = slicesByEvents.size > 0;
51
- const ironLawBody = sectionBodyByName(sections, "Iron Law Acknowledgement");
52
- if (ironLawBody === null) {
53
- findings.push({
54
- section: "TDD Iron Law Acknowledgement",
55
- required: true,
56
- rule: "Iron Law Acknowledgement must affirm `Acknowledged: yes`.",
57
- found: false,
58
- details: "No ## heading matching required section \"Iron Law Acknowledgement\"."
59
- });
60
- }
61
- else {
62
- const ack = /acknowledged:\s*(yes|true|y)\b/iu.test(ironLawBody);
63
- findings.push({
64
- section: "TDD Iron Law Acknowledgement",
65
- required: true,
66
- rule: "Iron Law Acknowledgement must affirm `Acknowledged: yes`.",
67
- found: ack,
68
- details: ack
69
- ? "TDD Iron Law acknowledged."
70
- : "Iron Law Acknowledgement is missing explicit `Acknowledged: yes`."
71
- });
72
- }
73
- const watchedRedBody = sectionBodyByName(sections, "Watched-RED Proof");
74
- if (eventsActive) {
75
- const redResult = evaluateEventsWatchedRed(slicesByEvents);
76
- findings.push({
77
- section: "Watched-RED Proof Shape",
78
- required: true,
79
- rule: "Watched-RED Proof: when delegation-events.jsonl carries slice phase events, every slice with a phase=red row must include a non-empty evidenceRefs[] (test path, span ref, or pasted-output pointer) and a completedTs.",
80
- found: redResult.ok,
81
- details: redResult.details
82
- });
83
- }
84
- else if (watchedRedBody === null) {
85
- findings.push({
86
- section: "Watched-RED Proof Shape",
87
- required: true,
88
- rule: "Watched-RED Proof must include at least one populated row, and each row must include an ISO timestamp showing when the test was observed failing.",
89
- found: false,
90
- details: "No ## heading matching required section \"Watched-RED Proof\"."
91
- });
92
- }
93
- else {
94
- const rows = watchedRedBody.split("\n").filter((line) => /^\|/u.test(line));
95
- const dataRows = rows.length >= 3 ? rows.slice(2) : [];
96
- const populatedRows = dataRows.filter((row) => row
97
- .split("|")
98
- .slice(1, -1)
99
- .filter((_, idx) => idx !== 0)
100
- .some((cell) => cell.trim().length > 0));
101
- const isoRegex = /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/u;
102
- const validProofRows = populatedRows.filter((row) => isoRegex.test(row));
103
- const hasPopulatedRows = populatedRows.length > 0;
104
- const allRowsHaveIso = validProofRows.length === populatedRows.length;
105
- findings.push({
106
- section: "Watched-RED Proof Shape",
107
- required: true,
108
- rule: "Watched-RED Proof must include at least one populated row, and each row must include an ISO timestamp showing when the test was observed failing.",
109
- found: hasPopulatedRows && allRowsHaveIso,
110
- details: !hasPopulatedRows
111
- ? "Watched-RED Proof has no populated rows; add at least one slice row with observed RED evidence."
112
- : allRowsHaveIso
113
- ? `All ${populatedRows.length} watched-RED proof row(s) include an ISO timestamp.`
114
- : `${populatedRows.length - validProofRows.length} watched-RED proof row(s) lack an ISO timestamp.`
115
- });
116
- }
117
- if (eventsActive) {
118
- const cycleResult = evaluateEventsSliceCycle(slicesByEvents);
119
- findings.push({
120
- section: "Vertical Slice Cycle Coverage",
121
- required: true,
122
- rule: "Vertical Slice Cycle: every slice with phase events must show RED before GREEN (completedTs monotonic), and a REFACTOR phase event (`refactor` with completedTs OR `refactor-deferred` with non-empty refactorRationale or evidenceRefs).",
123
- found: cycleResult.ok,
124
- details: cycleResult.details
125
- });
126
- for (const finding of cycleResult.findings) {
127
- findings.push(finding);
128
- }
129
- }
130
- else {
131
- const sliceCycleBody = sectionBodyByName(sections, "Vertical Slice Cycle");
132
- if (sliceCycleBody === null) {
133
- findings.push({
134
- section: "Vertical Slice Cycle Coverage",
135
- required: true,
136
- rule: "Vertical Slice Cycle must include RED, GREEN, and REFACTOR per slice (refactor may be deferred with rationale).",
137
- found: false,
138
- details: "No ## heading matching required section \"Vertical Slice Cycle\"."
139
- });
140
- }
141
- else {
142
- const cycleResult = parseVerticalSliceCycle(sliceCycleBody);
143
- findings.push({
144
- section: "Vertical Slice Cycle Coverage",
145
- required: true,
146
- rule: "Vertical Slice Cycle must show RED -> GREEN -> REFACTOR monotonic progression per slice (refactor may be deferred with one-line rationale, e.g. `deferred because <reason>`).",
147
- found: cycleResult.ok,
148
- details: cycleResult.details
149
- });
150
- }
151
- }
152
- // slice-builder owns DOC inline. For every slice with a phase=green
153
- // row, require a matching phase=doc event whose evidenceRefs reference
154
- // `<artifacts-dir>/tdd-slices/S-<id>.md`. Mandatory only on deep
155
- // discoveryMode; advisory otherwise.
156
- if (eventsActive) {
157
- const docResult = evaluateSliceDocCoverage(slicesByEvents);
158
- if (docResult.missing.length > 0) {
159
- const required = discoveryMode === "deep";
160
- findings.push({
161
- section: "tdd_slice_doc_missing",
162
- required,
163
- rule: required
164
- ? "deep mode: every TDD slice with a phase=green event must also carry a slice-builder `phase=doc` event whose evidenceRefs reference `<artifacts-dir>/tdd-slices/S-<id>.md`."
165
- : "lean/guided modes: the slice-builder `phase=doc` event is advisory; the doc step may be folded into the GREEN span. Required only for deep mode.",
166
- found: false,
167
- details: `Slices missing per-slice DOC coverage: ${docResult.missing.join(", ")}. ` +
168
- (required
169
- ? "Have the slice-builder emit a `--phase doc` row referencing `tdd-slices/S-<id>.md` after GREEN."
170
- : "Either emit a `--phase doc` row referencing `tdd-slices/S-<id>.md` or fold the doc write into GREEN.")
171
- });
172
- }
173
- }
174
- // slice-builder must own GREEN. For each slice with a phase=red row
175
- // carrying non-empty evidenceRefs, require a matching phase=green event
176
- // whose `agent === "slice-builder"`. Catches "controller wrote GREEN
177
- // itself" backslides.
178
- if (eventsActive) {
179
- const implResult = evaluateSliceBuilderCoverage(slicesByEvents);
180
- if (implResult.missing.length > 0) {
181
- findings.push({
182
- section: "tdd_slice_builder_missing",
183
- required: true,
184
- rule: "Every TDD slice that recorded a phase=red event with non-empty evidenceRefs must reach phase=green via `slice-builder`. Controller writing GREEN production code itself is forbidden.",
185
- found: false,
186
- details: `Slices missing slice-builder-owned GREEN coverage: ${implResult.missing.join(", ")}. Dispatch slice-builder --slice <id> --phase green --paths <comma-separated production paths>.`
187
- });
188
- }
189
- }
190
- // Per-slice RED-before-GREEN only (no global-red wave barrier in the linter).
191
- if (eventsActive) {
192
- const perSliceResult = evaluatePerSliceRedBeforeGreen(slicesByEvents);
193
- if (!perSliceResult.ok) {
194
- findings.push({
195
- section: "tdd_slice_red_completed_before_green",
196
- required: true,
197
- rule: "Each slice's phase=green completedTs must be >= the same slice's last phase=red completedTs. Lanes run independently within a wave.",
198
- found: false,
199
- details: perSliceResult.details
200
- });
201
- }
202
- }
203
- const { events: jsonlEvents } = await readDelegationEvents(projectRoot);
204
- const runEvents = jsonlEvents.filter((e) => e.runId === delegationLedger.runId);
205
- if (eventsActive && planRaw.length > 0) {
206
- const ignoredWave = await evaluateWavePlanDispatchIgnored({
207
- artifactsDir,
208
- planMarkdown: planRaw,
209
- runEvents,
210
- runId: delegationLedger.runId,
211
- slices: slicesByEvents
212
- });
213
- if (ignoredWave) {
214
- findings.push(ignoredWave);
215
- }
216
- }
217
- const assertionBody = sectionBodyByName(sections, "Assertion Correctness Notes");
218
- if (assertionBody !== null) {
219
- const tableRows = assertionBody.split("\n").filter((line) => /^\|/u.test(line));
220
- const dataRows = tableRows.length >= 3 ? tableRows.slice(2) : [];
221
- const ok = dataRows.length === 0 || dataRows.some((row) => row
222
- .split("|")
223
- .slice(1, -1)
224
- .some((cell) => cell.trim().length > 0));
225
- findings.push({
226
- section: "Assertion Correctness Notes Shape",
227
- required: true,
228
- rule: "Assertion Correctness Notes must include at least one populated row when the slice has new assertions.",
229
- found: ok,
230
- details: ok
231
- ? "Assertion Correctness Notes is populated or absent (single-step slice)."
232
- : "Assertion Correctness Notes table has no populated rows."
233
- });
234
- }
235
- const testDiscoveryBody = sectionBodyByName(sections, "Test Discovery") ?? "";
236
- const redEvidenceBody = sectionBodyByName(sections, "RED Evidence") ?? "";
237
- const mockPreferenceScanBody = `${testDiscoveryBody}\n${redEvidenceBody}`;
238
- const mockTokenRegex = /\b(jest\.mock|vi\.mock|sinon\.stub|mock\.patch|unittest\.mock|magicmock|spyon|tohavebeencalled)\b/iu;
239
- if (mockTokenRegex.test(mockPreferenceScanBody)) {
240
- const boundaryJustificationRegex = /\b(justified\s+by\s+boundary|boundary:\s*[A-Za-z0-9/_ -]*(network|fs|filesystem|time|clock|external)|network|filesystem|clock|external\s+service)\b/iu;
241
- const hasBoundaryJustification = boundaryJustificationRegex.test(mockPreferenceScanBody);
242
- const realPathRegex = /\b(?:src|lib|packages|apps)\/[A-Za-z0-9_./-]+\b/u;
243
- const hasRealPathHint = realPathRegex.test(mockPreferenceScanBody);
244
- findings.push({
245
- section: "Mock Preference Heuristic",
246
- required: false,
247
- rule: "When mocks/spies appear in Test Discovery or RED Evidence, prefer Real > Fake > Stub > Mock. Mock-heavy slices need explicit boundary justification (network/fs/time/external).",
248
- found: hasBoundaryJustification,
249
- details: hasBoundaryJustification
250
- ? "Mock usage is explicitly justified by boundary constraints."
251
- : hasRealPathHint
252
- ? "Mocks/spies detected while real implementation paths are listed; prefer Real > Fake > Stub > Mock unless a boundary justification is added."
253
- : "Mocks/spies detected without boundary justification; add explicit trust-boundary rationale or replace with real/fake/stub coverage."
254
- });
255
- }
256
- const completedSliceBuilders = activeRunEntries.filter((entry) => entry.agent === "slice-builder" && entry.status === "completed");
257
- const fanOutDetected = completedSliceBuilders.length > 1;
258
- if (fanOutDetected) {
259
- const cohesionContractMarkdownPath = path.join(artifactsDir, "cohesion-contract.md");
260
- const cohesionContractJsonPath = path.join(artifactsDir, "cohesion-contract.json");
261
- let cohesionContractFound = true;
262
- const cohesionErrors = [];
263
- try {
264
- const markdown = await fs.readFile(cohesionContractMarkdownPath, "utf8");
265
- if (!/#\s*Cohesion Contract\b/u.test(markdown)) {
266
- cohesionContractFound = false;
267
- cohesionErrors.push("cohesion-contract.md exists but missing `# Cohesion Contract` heading.");
268
- }
269
- }
270
- catch {
271
- cohesionContractFound = false;
272
- cohesionErrors.push("cohesion-contract.md is missing.");
273
- }
274
- try {
275
- const jsonRaw = await fs.readFile(cohesionContractJsonPath, "utf8");
276
- const parsed = JSON.parse(jsonRaw);
277
- const objectLike = parsed !== null && typeof parsed === "object" && !Array.isArray(parsed);
278
- const parsedRecord = objectLike ? parsed : null;
279
- const hasRequiredShape = parsedRecord !== null &&
280
- Array.isArray(parsedRecord.sharedTypes) &&
281
- Array.isArray(parsedRecord.touchpoints) &&
282
- Array.isArray(parsedRecord.slices) &&
283
- parsedRecord.status !== undefined &&
284
- typeof parsedRecord.status === "object" &&
285
- parsedRecord.status !== null;
286
- if (!hasRequiredShape) {
287
- cohesionContractFound = false;
288
- cohesionErrors.push("cohesion-contract.json must parse and include `sharedTypes[]`, `touchpoints[]`, `slices[]`, and `status`.");
289
- }
290
- }
291
- catch {
292
- cohesionContractFound = false;
293
- cohesionErrors.push("cohesion-contract.json is missing or invalid JSON.");
294
- }
295
- findings.push({
296
- section: "tdd.cohesion_contract_missing",
297
- required: true,
298
- rule: "When the delegation ledger has >1 completed slice-builder rows for the active TDD run, require `.cclaw/artifacts/cohesion-contract.md` and a parseable `.cclaw/artifacts/cohesion-contract.json` sidecar.",
299
- found: cohesionContractFound,
300
- details: cohesionContractFound
301
- ? `Fan-out detected (${completedSliceBuilders.length} completed slice-builder rows); cohesion contract markdown+JSON sidecar are present and parseable.`
302
- : `${cohesionErrors.join(" ")} Use \`cclaw-cli internal cohesion-contract --stub\` only as a scaffold; the gate expects real cohesion data for fan-out waves.`
303
- });
304
- const completedOverseerRows = activeRunEntries.filter((entry) => entry.agent === "integration-overseer" && entry.status === "completed");
305
- const overseerStatusInEvidence = completedOverseerRows.some((entry) => {
306
- const refs = Array.isArray(entry.evidenceRefs) ? entry.evidenceRefs.join(" ") : "";
307
- return /\b(?:PASS_WITH_GAPS|PASS)\b/u.test(refs);
308
- });
309
- const overseerStatusInArtifact = /\bintegration-overseer\b[\s\S]{0,200}\b(?:PASS_WITH_GAPS|PASS)\b/iu.test(raw);
310
- const integrationOverseerFound = completedOverseerRows.length > 0 &&
311
- (overseerStatusInEvidence || overseerStatusInArtifact);
312
- const skippedAuditRowCount = await countIntegrationOverseerSkippedAudits(projectRoot, delegationLedger.runId);
313
- const skippedAuditRowFound = skippedAuditRowCount > 0;
314
- // Advisory: when fan-out is detected (2+ completed slice-builders) and
315
- // no `integration-overseer` was dispatched at all (no scheduled or
316
- // completed row for the active run), AND no
317
- // `cclaw_integration_overseer_skipped` audit row exists, the controller
318
- // should call `integrationCheckRequired()` and emit a
319
- // `cclaw_integration_overseer_skipped` audit row so the decision stays
320
- // traceable.
321
- const overseerDispatched = activeRunEntries.some((entry) => entry.agent === "integration-overseer");
322
- if (!overseerDispatched && !skippedAuditRowFound) {
323
- findings.push({
324
- section: "tdd_integration_overseer_skipped_audit_missing",
325
- required: false,
326
- rule: "When a wave with 2+ closed slices closes without any integration-overseer dispatch, the controller should call `integrationCheckRequired()` and emit a `cclaw_integration_overseer_skipped` audit row so the decision is traceable. Advisory — never blocks stage-complete.",
327
- found: false,
328
- details: `Fan-out detected (${completedSliceBuilders.length} completed slice-builder rows) but no integration-overseer dispatch row OR cclaw_integration_overseer_skipped audit row exists for active run. ` +
329
- "Remediation: emit `node .cclaw/hooks/delegation-record.mjs --audit-kind=cclaw_integration_overseer_skipped --audit-reason=\"<reasons>\" --slice-ids=\"<S-1,S-2,...>\"` after wave closure."
330
- });
331
- }
332
- findings.push({
333
- section: "tdd.integration_overseer_missing",
334
- required: true,
335
- rule: "When fan-out is detected, require completed `integration-overseer` evidence with PASS or PASS_WITH_GAPS.",
336
- found: integrationOverseerFound,
337
- details: integrationOverseerFound
338
- ? "integration-overseer completion recorded with PASS/PASS_WITH_GAPS evidence."
339
- : completedOverseerRows.length === 0
340
- ? "Fan-out detected but no completed integration-overseer delegation row exists for active run."
341
- : "integration-overseer completion exists, but PASS/PASS_WITH_GAPS evidence is missing in delegation evidenceRefs and artifact text."
342
- });
343
- }
344
- const verificationBody = sectionBodyByName(sections, "Verification Ladder") ??
345
- sectionBodyByName(sections, "Verification Status") ??
346
- sectionBodyByName(sections, "Verification");
347
- const ladderResult = evaluateVerificationLadder(verificationBody);
348
- findings.push({
349
- section: "tdd_verification_pending",
350
- required: true,
351
- rule: "Verification Ladder rows must not remain `pending`; promote each row to `passed`, `n/a`, `failed`, `skipped`, or `deferred` (with rationale) before stage-complete.",
352
- found: ladderResult.ok,
353
- details: ladderResult.details
354
- });
355
- // Phase S — sharded slice files. Validate per-slice file presence
356
- // and required headings. `tdd-slices/` is optional; missing folder
357
- // simply means main-only mode (legacy fallback).
358
- const slicesDir = path.join(artifactsDir, "tdd-slices");
359
- const sliceFiles = await listSliceFiles(slicesDir);
360
- const specAcceptanceIds = await readSpecAcceptanceCriteriaIds(projectRoot, ctx.track);
361
- const specAcceptanceSet = new Set(specAcceptanceIds);
362
- const slicesMissingCloses = [];
363
- const slicesWithUnknownAcs = [];
364
- let checkedSliceCards = 0;
365
- for (const sliceFile of sliceFiles) {
366
- const sliceId = sliceFile.sliceId;
367
- const requiredForSlice = slicesByEvents.has(sliceId) &&
368
- slicesByEvents.get(sliceId).some((entry) => entry.phase === "doc");
369
- let content = "";
370
- try {
371
- content = await fs.readFile(sliceFile.absPath, "utf8");
372
- }
373
- catch {
374
- content = "";
375
- }
376
- const issues = [];
377
- if (!new RegExp(`^#\\s+Slice\\s+${escapeForRegex(sliceId)}\\b`, "mu").test(content) &&
378
- !/^#\s+Slice\b/mu.test(content)) {
379
- issues.push("missing `# Slice <id>` heading");
380
- }
381
- if (!/^##\s+Plan unit\b/imu.test(content)) {
382
- issues.push("missing `## Plan unit` section");
383
- }
384
- if (!/^##\s+REFACTOR notes\b/imu.test(content)) {
385
- issues.push("missing `## REFACTOR notes` section");
386
- }
387
- if (!/^##\s+Learnings\b/imu.test(content)) {
388
- issues.push("missing `## Learnings` section");
389
- }
390
- checkedSliceCards += 1;
391
- const closesIds = extractSliceCardClosedAcceptanceCriteria(content);
392
- if (closesIds.length === 0) {
393
- slicesMissingCloses.push(sliceId);
394
- }
395
- else if (specAcceptanceSet.size > 0) {
396
- const unknown = closesIds.filter((acId) => !specAcceptanceSet.has(acId));
397
- if (unknown.length > 0) {
398
- slicesWithUnknownAcs.push(`${sliceId}: ${unknown.join(", ")}`);
399
- }
400
- }
401
- findings.push({
402
- section: `tdd_slice_file:${sliceId}`,
403
- required: requiredForSlice,
404
- rule: "Sharded slice file must include `# Slice <id>`, `## Plan unit`, `## REFACTOR notes`, and `## Learnings` headings.",
405
- found: issues.length === 0,
406
- details: issues.length === 0
407
- ? `tdd-slices/${path.basename(sliceFile.absPath)} has all required headings.`
408
- : `tdd-slices/${path.basename(sliceFile.absPath)}: ${issues.join(", ")}.`
409
- });
410
- }
411
- const closesRequired = checkedSliceCards > 0;
412
- const closesGatePassed = !closesRequired
413
- ? true
414
- : slicesMissingCloses.length === 0 &&
415
- slicesWithUnknownAcs.length === 0;
416
- findings.push({
417
- section: "tdd_slice_closes_ac",
418
- required: true,
419
- rule: "Every `tdd-slices/S-<id>.md` card must include `Closes: AC-N` links (comma-separated allowed) that reference real spec AC ids.",
420
- found: closesGatePassed,
421
- details: !closesRequired
422
- ? "No `tdd-slices/S-*.md` slice cards found yet; `Closes: AC-N` check is idle."
423
- : slicesMissingCloses.length > 0
424
- ? `Slice card(s) missing \`Closes: AC-N\`: ${slicesMissingCloses.join(", ")}.`
425
- : slicesWithUnknownAcs.length > 0
426
- ? `Slice card(s) reference unknown AC ids: ${slicesWithUnknownAcs.join(" | ")}.`
427
- : specAcceptanceSet.size === 0
428
- ? `All ${checkedSliceCards} slice card(s) include Closes links; spec AC list unavailable for strict ID cross-check.`
429
- : `All ${checkedSliceCards} slice card(s) include valid Closes links to spec AC ids.`
430
- });
431
- const orphanCheck = await evaluateSliceNoOrphanChanges(projectRoot, activeRunEntries);
432
- findings.push({
433
- section: "slice_no_orphan_changes",
434
- required: true,
435
- rule: "On slice phase=doc, there must be no staged/unstaged changes outside the slice `claimedPaths` (worktree root when present, otherwise project root).",
436
- found: orphanCheck.ok,
437
- details: orphanCheck.details
438
- });
439
- // Auto-render the slice summary inside `06-tdd.md` between markers.
440
- // Idempotent — content outside the markers is preserved. Skipped
441
- // entirely when there is nothing to render, so legacy artifacts (no
442
- // phase events, no sharded files) stay byte-for-byte unchanged.
443
- if (eventsActive || sliceFiles.length > 0) {
444
- try {
445
- await renderTddSliceSummary({
446
- mainArtifactPath: absFile,
447
- slicesByEvents,
448
- sliceFiles,
449
- renderSummary: eventsActive,
450
- renderIndex: sliceFiles.length > 0
451
- });
452
- }
453
- catch {
454
- // best-effort render — never block the gate.
455
- }
456
- }
457
- }
458
- /**
459
- * count `cclaw_integration_overseer_skipped` audit rows in
460
- * `delegation-events.jsonl` for a given runId. The audit row is not a
461
- * `DelegationEvent` (no agent/status), so `readDelegationEvents`
462
- * filters it out; we re-scan the raw file with a narrow JSON match.
463
- *
464
- * Best-effort: missing file or parse errors return 0.
465
- */
466
- async function countIntegrationOverseerSkippedAudits(projectRoot, runId) {
467
- const filePath = path.join(projectRoot, ".cclaw/state/delegation-events.jsonl");
468
- let raw = "";
469
- try {
470
- raw = await fs.readFile(filePath, "utf8");
471
- }
472
- catch {
473
- return 0;
474
- }
475
- let count = 0;
476
- for (const line of raw.split(/\r?\n/u)) {
477
- const trimmed = line.trim();
478
- if (trimmed.length === 0)
479
- continue;
480
- let parsed;
481
- try {
482
- parsed = JSON.parse(trimmed);
483
- }
484
- catch {
485
- continue;
486
- }
487
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
488
- continue;
489
- const obj = parsed;
490
- if (obj.event !== "cclaw_integration_overseer_skipped")
491
- continue;
492
- if (typeof obj.runId === "string" && obj.runId !== runId)
493
- continue;
494
- count += 1;
495
- }
496
- return count;
497
- }
498
- async function listSliceFiles(slicesDir) {
499
- let entries = [];
500
- try {
501
- entries = await fs.readdir(slicesDir);
502
- }
503
- catch {
504
- return [];
505
- }
506
- const files = [];
507
- for (const name of entries) {
508
- const match = /^(S-[A-Za-z0-9._-]+)\.md$/u.exec(name);
509
- if (!match)
510
- continue;
511
- files.push({ sliceId: match[1], absPath: path.join(slicesDir, name) });
512
- }
513
- files.sort((a, b) => compareSliceIds(a.sliceId, b.sliceId));
514
- return files;
515
- }
516
- function escapeForRegex(value) {
517
- return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
518
- }
519
- function normalizePathLike(value) {
520
- const slashes = value.replace(/\\/gu, "/");
521
- const withoutDot = slashes.replace(/^\.\//u, "");
522
- return withoutDot.replace(/\/+$/u, "");
523
- }
524
- function parsePorcelainPaths(raw) {
525
- const out = [];
526
- for (const line of raw.split(/\r?\n/gu)) {
527
- const trimmed = line.trimEnd();
528
- if (trimmed.length < 4)
529
- continue;
530
- const status = trimmed.slice(0, 2);
531
- if (status === "??") {
532
- const p = normalizePathLike(trimmed.slice(3).trim());
533
- if (p.length > 0)
534
- out.push(p);
535
- continue;
536
- }
537
- let p = trimmed.slice(3).trim();
538
- const renameIdx = p.indexOf(" -> ");
539
- if (renameIdx >= 0) {
540
- p = p.slice(renameIdx + 4);
541
- }
542
- p = normalizePathLike(p.replace(/^"/u, "").replace(/"$/u, ""));
543
- if (p.length > 0)
544
- out.push(p);
545
- }
546
- return [...new Set(out)];
547
- }
548
- async function gitChangedPaths(cwd) {
549
- const { stdout } = await execFileAsync("git", ["status", "--porcelain", "-uall"], { cwd });
550
- return parsePorcelainPaths(stdout);
551
- }
552
- function matchesClaimedPath(changedPath, claimedPaths) {
553
- const changed = normalizePathLike(changedPath);
554
- return claimedPaths.some((rawClaimed) => {
555
- const claimed = normalizePathLike(rawClaimed);
556
- if (claimed.length === 0)
557
- return false;
558
- if (changed === claimed)
559
- return true;
560
- return changed.startsWith(`${claimed}/`);
561
- });
562
- }
563
- function extractSliceCardClosedAcceptanceCriteria(content) {
564
- const ids = new Set();
565
- for (const match of content.matchAll(/^\s*(?:[-*]\s*)?closes\s*:\s*(.+)$/gimu)) {
566
- const tail = match[1] ?? "";
567
- for (const id of extractAcceptanceCriterionIdsFromMarkdown(tail)) {
568
- ids.add(id);
569
- }
570
- }
571
- return [...ids];
572
- }
573
- async function readSpecAcceptanceCriteriaIds(projectRoot, track) {
574
- const specArtifact = await resolveStageArtifactPath("spec", {
575
- projectRoot,
576
- track,
577
- intent: "read"
578
- });
579
- if (!(await exists(specArtifact.absPath))) {
580
- return [];
581
- }
582
- try {
583
- const specRaw = await fs.readFile(specArtifact.absPath, "utf8");
584
- const specSections = extractH2Sections(specRaw);
585
- const acceptanceBody = sectionBodyByName(specSections, "Acceptance Criteria") ?? specRaw;
586
- return extractAcceptanceCriterionIdsFromMarkdown(acceptanceBody);
587
- }
588
- catch {
589
- return [];
590
- }
591
- }
592
- function resolveClaimedPathsForDocRow(row, allRows) {
593
- const fromRow = Array.isArray(row.claimedPaths) ? row.claimedPaths : [];
594
- if (fromRow.length > 0) {
595
- return [...new Set(fromRow.map((value) => normalizePathLike(value)).filter((value) => value.length > 0))];
596
- }
597
- const fromSpan = allRows
598
- .filter((entry) => entry.spanId === row.spanId &&
599
- Array.isArray(entry.claimedPaths) &&
600
- entry.claimedPaths.length > 0)
601
- .flatMap((entry) => entry.claimedPaths);
602
- return [...new Set(fromSpan.map((value) => normalizePathLike(value)).filter((value) => value.length > 0))];
603
- }
604
- async function resolveWorktreeCwdForDocRow(projectRoot, row, allRows) {
605
- const candidates = [
606
- typeof row.worktreePath === "string" ? row.worktreePath.trim() : "",
607
- ...allRows
608
- .filter((entry) => entry.spanId === row.spanId)
609
- .map((entry) => (typeof entry.worktreePath === "string" ? entry.worktreePath.trim() : ""))
610
- ].filter((value) => value.length > 0);
611
- for (const candidateRaw of candidates) {
612
- const candidateAbs = path.isAbsolute(candidateRaw)
613
- ? candidateRaw
614
- : path.join(projectRoot, candidateRaw);
615
- if (await exists(candidateAbs)) {
616
- return candidateAbs;
617
- }
618
- }
619
- return projectRoot;
620
- }
621
- export async function evaluateSliceNoOrphanChanges(projectRoot, rows) {
622
- if (!(await exists(path.join(projectRoot, ".git")))) {
623
- return {
624
- ok: true,
625
- details: "No .git directory detected; orphan-change check skipped."
626
- };
627
- }
628
- const docRows = rows.filter((entry) => entry.stage === "tdd" &&
629
- entry.agent === "slice-builder" &&
630
- entry.status === "completed" &&
631
- entry.phase === "doc");
632
- if (docRows.length === 0) {
633
- return {
634
- ok: true,
635
- details: "No completed phase=doc rows found for the active run."
636
- };
637
- }
638
- const missingClaimedPaths = [];
639
- const driftRows = [];
640
- for (const row of docRows) {
641
- const claimedPaths = resolveClaimedPathsForDocRow(row, rows);
642
- const rowKey = `${row.sliceId ?? "unknown-slice"}@${row.spanId ?? "unknown-span"}`;
643
- if (claimedPaths.length === 0) {
644
- missingClaimedPaths.push(rowKey);
645
- continue;
646
- }
647
- const cwd = await resolveWorktreeCwdForDocRow(projectRoot, row, rows);
648
- const changedPaths = await gitChangedPaths(cwd);
649
- const driftPaths = changedPaths.filter((changedPath) => !matchesClaimedPath(changedPath, claimedPaths));
650
- if (driftPaths.length > 0) {
651
- driftRows.push(`${rowKey}: ${driftPaths.join(", ")}`);
652
- }
653
- }
654
- if (missingClaimedPaths.length > 0 || driftRows.length > 0) {
655
- const parts = [];
656
- if (missingClaimedPaths.length > 0) {
657
- parts.push(`doc row(s) missing claimedPaths: ${missingClaimedPaths.join(", ")}`);
658
- }
659
- if (driftRows.length > 0) {
660
- parts.push(`orphan working-tree changes detected: ${driftRows.join(" | ")}`);
661
- }
662
- return { ok: false, details: parts.join(". ") };
663
- }
664
- return {
665
- ok: true,
666
- details: `Checked ${docRows.length} doc row(s); no orphan changes escaped claimedPaths.`
667
- };
668
- }
669
- function groupBySlice(entries) {
670
- const grouped = new Map();
671
- for (const entry of entries) {
672
- if (typeof entry.sliceId !== "string" || entry.sliceId.length === 0)
673
- continue;
674
- if (typeof entry.phase !== "string" || entry.phase.length === 0)
675
- continue;
676
- if (entry.status !== "completed")
677
- continue;
678
- const list = grouped.get(entry.sliceId) ?? [];
679
- list.push(entry);
680
- grouped.set(entry.sliceId, list);
681
- }
682
- return grouped;
683
- }
684
- /** Group completed phase rows for a slice by `spanId` (falls back to a single legacy bucket). */
685
- function groupSliceRowsBySpanId(rows) {
686
- const grouped = new Map();
687
- for (const entry of rows) {
688
- const spanKey = typeof entry.spanId === "string" && entry.spanId.length > 0 ? entry.spanId : "__missing-span__";
689
- const list = grouped.get(spanKey) ?? [];
690
- list.push(entry);
691
- grouped.set(spanKey, list);
692
- }
693
- return grouped;
694
- }
695
- function maxPhaseTimestampForSpan(rows) {
696
- let max = "";
697
- for (const entry of rows) {
698
- const ts = entry.completedTs ?? entry.endTs ?? entry.ts ?? "";
699
- if (typeof ts === "string" && ts.length > 0 && ts > max)
700
- max = ts;
701
- }
702
- return max;
703
- }
704
- /**
705
- * Validate RED→GREEN→REFACTOR (incl. green `refactorOutcome`) monotonicity for one slice-builder span.
706
- * `rows` must contain only entries for that span.
707
- */
708
- function evaluateSingleSpanSliceCycle(sliceId, spanId, rows) {
709
- const errors = [];
710
- const findings = [];
711
- const sec = (slug) => `${slug}:${sliceId}@${spanId}`;
712
- const reds = rows.filter((entry) => entry.phase === "red");
713
- const greens = rows.filter((entry) => entry.phase === "green");
714
- const refactors = rows.filter((entry) => entry.phase === "refactor" || entry.phase === "refactor-deferred");
715
- const redTs = pickEventTs(reds);
716
- const greenTs = pickEventTs(greens);
717
- if (reds.length === 0) {
718
- errors.push(`${sliceId}: phase=red event missing.`);
719
- findings.push({
720
- section: sec("tdd_slice_red_missing"),
721
- required: true,
722
- rule: "Each TDD slice with phase events must include a `phase=red` row.",
723
- found: false,
724
- details: `${sliceId} (span ${spanId}): no phase=red event recorded for the active run.`
725
- });
726
- return { ok: false, errors, findings };
727
- }
728
- if (greens.length === 0) {
729
- errors.push(`${sliceId}: phase=green event missing.`);
730
- findings.push({
731
- section: sec("tdd_slice_green_missing"),
732
- required: true,
733
- rule: "Each TDD slice with a phase=red event must reach a `phase=green` row before stage-complete.",
734
- found: false,
735
- details: `${sliceId} (span ${spanId}): no phase=green event recorded; RED has no matching GREEN.`
736
- });
737
- return { ok: false, errors, findings };
738
- }
739
- if (greenTs && redTs && greenTs < redTs) {
740
- errors.push(`${sliceId}: phase=green completedTs (${greenTs}) precedes phase=red (${redTs}).`);
741
- findings.push({
742
- section: sec("tdd_slice_phase_order_invalid"),
743
- required: true,
744
- rule: "Phase events must be monotonic: phase=green completedTs >= phase=red completedTs.",
745
- found: false,
746
- details: `${sliceId} (span ${spanId}): green at ${greenTs} precedes red at ${redTs}.`
747
- });
748
- return { ok: false, errors, findings };
749
- }
750
- const greenEvidenceRef = greens
751
- .flatMap((entry) => (Array.isArray(entry.evidenceRefs) ? entry.evidenceRefs : []))
752
- .find((ref) => typeof ref === "string" && ref.trim().length > 0);
753
- if (!greenEvidenceRef) {
754
- errors.push(`${sliceId}: phase=green row has empty evidenceRefs.`);
755
- findings.push({
756
- section: sec("tdd_slice_evidence_missing"),
757
- required: true,
758
- rule: "Each `phase=green` event must record at least one evidenceRef (path to test artifact, span id, or pasted-output pointer).",
759
- found: false,
760
- details: `${sliceId} (span ${spanId}): phase=green event missing evidenceRefs.`
761
- });
762
- return { ok: false, errors, findings };
763
- }
764
- const greenWithOutcome = greens.find((entry) => entry.refactorOutcome &&
765
- (entry.refactorOutcome.mode === "inline" || entry.refactorOutcome.mode === "deferred"));
766
- if (refactors.length === 0 && !greenWithOutcome) {
767
- errors.push(`${sliceId}: phase=refactor or phase=refactor-deferred event missing.`);
768
- findings.push({
769
- section: sec("tdd_slice_refactor_missing"),
770
- required: true,
771
- rule: "Each TDD slice must close with a `phase=refactor` event, a `phase=refactor-deferred` event whose evidenceRefs / refactorRationale captures why refactor was deferred, OR a `phase=green` event carrying `refactorOutcome`.",
772
- found: false,
773
- details: `${sliceId} (span ${spanId}): no phase=refactor / phase=refactor-deferred event and no refactorOutcome on phase=green.`
774
- });
775
- return { ok: false, errors, findings };
776
- }
777
- if (greenWithOutcome &&
778
- greenWithOutcome.refactorOutcome?.mode === "deferred" &&
779
- !greenWithOutcome.refactorOutcome.rationale &&
780
- !(Array.isArray(greenWithOutcome.evidenceRefs) &&
781
- greenWithOutcome.evidenceRefs.some((ref) => typeof ref === "string" && ref.trim().length > 0))) {
782
- errors.push(`${sliceId}: phase=green refactorOutcome=deferred missing rationale.`);
783
- findings.push({
784
- section: sec("tdd_slice_refactor_missing"),
785
- required: true,
786
- rule: "phase=green refactorOutcome=deferred requires a rationale (via --refactor-rationale or --evidence-ref).",
787
- found: false,
788
- details: `${sliceId} (span ${spanId}): phase=green refactorOutcome.mode=deferred recorded without rationale.`
789
- });
790
- return { ok: false, errors, findings };
791
- }
792
- const deferred = refactors.find((entry) => entry.phase === "refactor-deferred");
793
- if (refactors.length > 0 &&
794
- deferred &&
795
- refactors.every((entry) => entry.phase === "refactor-deferred")) {
796
- const refs = Array.isArray(deferred.evidenceRefs) ? deferred.evidenceRefs : [];
797
- const hasRationale = refs.some((ref) => typeof ref === "string" && ref.trim().length > 0);
798
- if (!hasRationale) {
799
- errors.push(`${sliceId}: phase=refactor-deferred row needs evidenceRefs containing a rationale.`);
800
- findings.push({
801
- section: sec("tdd_slice_refactor_missing"),
802
- required: true,
803
- rule: "phase=refactor-deferred must record a rationale via --refactor-rationale or via --evidence-ref pointing at the rationale text.",
804
- found: false,
805
- details: `${sliceId} (span ${spanId}): phase=refactor-deferred recorded without rationale evidenceRefs.`
806
- });
807
- return { ok: false, errors, findings };
808
- }
809
- }
810
- return { ok: true, errors: [], findings: [] };
811
- }
812
- export function evaluateEventsWatchedRed(slices) {
813
- const errors = [];
814
- let redCount = 0;
815
- for (const [sliceId, rows] of slices.entries()) {
816
- const reds = rows.filter((entry) => entry.phase === "red");
817
- if (reds.length === 0)
818
- continue;
819
- redCount += 1;
820
- const issues = [];
821
- for (const red of reds) {
822
- const ts = red.completedTs ?? red.endTs ?? red.ts ?? "";
823
- if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/u.test(ts)) {
824
- issues.push("phase=red row missing ISO completedTs");
825
- }
826
- if (!Array.isArray(red.evidenceRefs) ||
827
- red.evidenceRefs.filter((ref) => typeof ref === "string" && ref.trim().length > 0).length === 0) {
828
- issues.push("phase=red row has empty evidenceRefs");
829
- }
830
- }
831
- if (issues.length > 0) {
832
- errors.push(`${sliceId}: ${issues.join(", ")}`);
833
- }
834
- }
835
- if (redCount === 0) {
836
- return {
837
- ok: false,
838
- details: "Watched-RED Proof: events ledger has slice phase rows but none with phase=red. Dispatch slice-builder --slice <id> --phase red so RED is observable in delegation-events.jsonl."
839
- };
840
- }
841
- if (errors.length > 0) {
842
- return {
843
- ok: false,
844
- details: `Watched-RED slice events missing required fields: ${errors.join(" | ")}.`
845
- };
846
- }
847
- return {
848
- ok: true,
849
- details: `${redCount} slice(s) carry phase=red events with ISO completedTs and evidenceRefs.`
850
- };
851
- }
852
- export function evaluateEventsSliceCycle(slices) {
853
- const errors = [];
854
- const findings = [];
855
- for (const [sliceId, rows] of slices.entries()) {
856
- const bySpan = groupSliceRowsBySpanId(rows);
857
- const spanOutcomes = [];
858
- for (const [spanId, spanRows] of bySpan.entries()) {
859
- const result = evaluateSingleSpanSliceCycle(sliceId, spanId, spanRows);
860
- spanOutcomes.push({
861
- spanId,
862
- maxTs: maxPhaseTimestampForSpan(spanRows),
863
- result
864
- });
865
- }
866
- if (spanOutcomes.some((s) => s.result.ok)) {
867
- continue;
868
- }
869
- spanOutcomes.sort((a, b) => (a.maxTs < b.maxTs ? 1 : a.maxTs > b.maxTs ? -1 : 0));
870
- const chosen = spanOutcomes[0];
871
- errors.push(...chosen.result.errors);
872
- findings.push(...chosen.result.findings);
873
- }
874
- if (errors.length > 0) {
875
- return {
876
- ok: false,
877
- details: errors.join(" "),
878
- findings
879
- };
880
- }
881
- return {
882
- ok: true,
883
- details: `${slices.size} slice(s) show monotonic phase=red -> phase=green -> phase=refactor (deferred-with-rationale accepted); at least one span per slice satisfies the cycle.`,
884
- findings: []
885
- };
886
- }
887
- export function evaluateSliceDocCoverage(slices) {
888
- const missing = [];
889
- for (const [sliceId, rows] of slices.entries()) {
890
- const hasGreen = rows.some((entry) => entry.phase === "green");
891
- if (!hasGreen)
892
- continue;
893
- const refsAcrossPhases = rows.flatMap((entry) => Array.isArray(entry.evidenceRefs) ? entry.evidenceRefs : []);
894
- const hasSliceFileRef = refsAcrossPhases.some((ref) => typeof ref === "string" && /tdd-slices\/S-[^/]+\.md/u.test(ref));
895
- if (!hasSliceFileRef) {
896
- missing.push(sliceId);
897
- }
898
- }
899
- return { missing };
900
- }
901
- /**
902
- * `slice-builder` must own GREEN. For each slice that recorded a phase=red
903
- * event with non-empty evidenceRefs, require a phase=green whose agent is
904
- * `slice-builder`.
905
- */
906
- export function evaluateSliceBuilderCoverage(slices) {
907
- const missing = [];
908
- for (const [sliceId, rows] of slices.entries()) {
909
- const reds = rows.filter((entry) => entry.phase === "red");
910
- if (reds.length === 0)
911
- continue;
912
- const hasRedEvidence = reds.some((red) => {
913
- const refs = Array.isArray(red.evidenceRefs) ? red.evidenceRefs : [];
914
- return refs.some((ref) => typeof ref === "string" && ref.trim().length > 0);
915
- });
916
- if (!hasRedEvidence)
917
- continue;
918
- const greens = rows.filter((entry) => entry.phase === "green");
919
- const ownedByBuilder = greens.some((entry) => entry.agent === "slice-builder");
920
- if (!ownedByBuilder) {
921
- missing.push(sliceId);
922
- }
923
- }
924
- return { missing };
925
- }
926
- function sliceRefactorTerminal(sliceId, slices) {
927
- const rows = slices.get(sliceId);
928
- if (!rows)
929
- return false;
930
- return rows.some((e) => e.agent === "slice-builder" &&
931
- (e.phase === "refactor" || e.phase === "refactor-deferred") &&
932
- (e.status === "completed" || e.status === "failed"));
933
- }
934
- /**
935
- * Detect single-slice dispatch when the merged wave plan requires parallel
936
- * ready slice-builder fan-out.
937
- */
938
- export async function evaluateWavePlanDispatchIgnored(params) {
939
- let merged;
940
- try {
941
- merged = mergeParallelWaveDefinitions(parseParallelExecutionPlanWaves(params.planMarkdown), await parseWavePlanDirectory(params.artifactsDir));
942
- }
943
- catch {
944
- return null;
945
- }
946
- if (merged.length === 0)
947
- return null;
948
- let pool;
949
- try {
950
- pool = await loadTddReadySlicePool(params.planMarkdown, params.artifactsDir, {
951
- legacyParallelDefaultSerial: false
952
- });
953
- }
954
- catch {
955
- return null;
956
- }
957
- if (pool.length === 0)
958
- return null;
959
- const completedUnitIds = new Set();
960
- for (const u of pool) {
961
- if (sliceRefactorTerminal(u.sliceId, params.slices)) {
962
- completedUnitIds.add(u.unitId);
963
- }
964
- }
965
- const scoped = params.runEvents.filter((e) => e.runId === params.runId);
966
- const tail = scoped.slice(-20);
967
- const builderInTail = new Set();
968
- for (const e of tail) {
969
- if (e.agent === "slice-builder" &&
970
- typeof e.sliceId === "string" &&
971
- e.sliceId.length > 0) {
972
- builderInTail.add(e.sliceId);
973
- }
974
- }
975
- if (builderInTail.size !== 1)
976
- return null;
977
- for (const wave of merged) {
978
- const waveSliceSet = new Set(wave.members.map((m) => m.sliceId));
979
- const wavePool = pool.filter((u) => waveSliceSet.has(u.sliceId));
980
- if (wavePool.length < 2)
981
- continue;
982
- const waveIncomplete = wave.members.some((m) => !sliceRefactorTerminal(m.sliceId, params.slices));
983
- if (!waveIncomplete)
984
- continue;
985
- const ready = selectReadySlices(wavePool, {
986
- cap: Math.max(32, wavePool.length),
987
- completedUnitIds,
988
- activePathHolders: []
989
- });
990
- if (ready.length < 2)
991
- continue;
992
- const only = [...builderInTail][0];
993
- const missed = ready.map((r) => r.sliceId).filter((s) => s !== only);
994
- if (missed.length === 0)
995
- continue;
996
- return {
997
- section: "tdd_wave_plan_ignored",
998
- required: true,
999
- rule: "When the Parallel Execution Plan (or wave-plans/) defines an open wave with two or more ready parallelizable units/slices, the controller must honor the parallel-builders topology instead of serializing to one slice only.",
1000
- found: false,
1001
- details: `Wave ${wave.waveId}: scheduler-ready members ${ready.map((r) => r.sliceId).join(", ")}; last 20 delegation events show slice workers only for ${only}. Missed parallel dispatch: ${missed.join(", ")}. Remediation: load \`05-plan.md\` (Parallel Execution Plan) and \`wave-plans/\` before routing, honor \`nextDispatch.topology=parallel-builders\`, then dispatch the routed ready builders in one controller message.`
1002
- };
1003
- }
1004
- return null;
1005
- }
1006
- /**
1007
- * Global RED checkpoint enforcement (`global-red` mode).
1008
- *
1009
- * The wave protocol requires ALL Phase A REDs to land before ANY Phase B
1010
- * GREEN starts. The rule is enforced on a per-wave basis, where a wave is
1011
- * defined by the managed `## Parallel Execution Plan` block in
1012
- * `05-plan.md` and/or `<artifacts-dir>/wave-plans/wave-NN.md` files. When
1013
- * no wave manifest exists, the linter falls back to a conservative
1014
- * implicit detection: a wave is a contiguous run of `phase=red` events
1015
- * with no other-phase events between them; the rule fires only when the
1016
- * implicit wave has 2+ members.
1017
- *
1018
- * Default mode is `per-slice` (see `evaluatePerSliceRedBeforeGreen`);
1019
- * this checkpoint applies when a project explicitly opts into
1020
- * `global-red`. Exported under both `evaluateGlobalRedCheckpoint`
1021
- * (canonical name) and `evaluateRedCheckpoint` (back-compat alias for
1022
- * existing tests/consumers).
1023
- *
1024
- * @param waveMembers Optional explicit wave manifest. Map key is wave
1025
- * name (e.g. `"W-01"`); value is the set of slice ids in that wave.
1026
- */
1027
- export function evaluateGlobalRedCheckpoint(slices, waveMembers = null) {
1028
- const events = [];
1029
- for (const [sliceId, rows] of slices.entries()) {
1030
- for (const entry of rows) {
1031
- const ts = entry.completedTs ?? entry.endTs ?? entry.ts;
1032
- if (typeof ts !== "string" || ts.length === 0)
1033
- continue;
1034
- if (typeof entry.phase !== "string")
1035
- continue;
1036
- events.push({ sliceId, phase: entry.phase, ts });
1037
- }
1038
- }
1039
- events.sort((a, b) => (a.ts < b.ts ? -1 : a.ts > b.ts ? 1 : 0));
1040
- // Build the canonical wave list. Explicit manifest wins; otherwise
1041
- // derive implicit waves from contiguous red event blocks.
1042
- const waves = [];
1043
- if (waveMembers && waveMembers.size > 0) {
1044
- for (const [name, members] of waveMembers.entries()) {
1045
- if (members.size === 0)
1046
- continue;
1047
- waves.push({ name, members });
1048
- }
1049
- }
1050
- else {
1051
- let current = null;
1052
- let waveIdx = 0;
1053
- for (const evt of events) {
1054
- if (evt.phase === "red") {
1055
- if (current === null)
1056
- current = new Set();
1057
- current.add(evt.sliceId);
1058
- }
1059
- else if (current !== null) {
1060
- if (current.size >= 2) {
1061
- waveIdx += 1;
1062
- waves.push({ name: `implicit-${waveIdx}`, members: current });
1063
- }
1064
- current = null;
1065
- }
1066
- }
1067
- if (current !== null && current.size >= 2) {
1068
- waveIdx += 1;
1069
- waves.push({ name: `implicit-${waveIdx}`, members: current });
1070
- }
1071
- }
1072
- if (waves.length === 0) {
1073
- return {
1074
- ok: true,
1075
- details: "RED checkpoint inactive: no wave manifest detected and no implicit wave (2+ contiguous reds) found."
1076
- };
1077
- }
1078
- const violations = [];
1079
- for (const wave of waves) {
1080
- const memberReds = events.filter((e) => e.phase === "red" && wave.members.has(e.sliceId));
1081
- const memberGreens = events.filter((e) => e.phase === "green" && wave.members.has(e.sliceId));
1082
- if (memberReds.length === 0 || memberGreens.length === 0)
1083
- continue;
1084
- const lastRedTs = memberReds.reduce((acc, e) => (e.ts > acc ? e.ts : acc), memberReds[0].ts);
1085
- for (const g of memberGreens) {
1086
- if (g.ts < lastRedTs) {
1087
- violations.push(`${wave.name}: ${g.sliceId} phase=green at ${g.ts} precedes wave's last phase=red completedTs at ${lastRedTs}`);
1088
- }
1089
- }
1090
- }
1091
- if (violations.length === 0) {
1092
- return {
1093
- ok: true,
1094
- details: `RED checkpoint holds across ${waves.length} wave(s): all phase=green events follow the last phase=red of their wave.`
1095
- };
1096
- }
1097
- return {
1098
- ok: false,
1099
- details: `RED checkpoint violation: ${violations.join("; ")}. ` +
1100
- "When using the global wave barrier, dispatch ALL slice-builder --phase red calls in one message, verify every phase=red event lands with non-empty evidenceRefs, and only then dispatch the GREEN/REFACTOR/DOC fan-out."
1101
- };
1102
- }
1103
- /**
1104
- * Back-compat alias for `evaluateGlobalRedCheckpoint`. The default mode
1105
- * uses `evaluatePerSliceRedBeforeGreen` instead.
1106
- */
1107
- export const evaluateRedCheckpoint = evaluateGlobalRedCheckpoint;
1108
- /**
1109
- * Per-slice RED-before-GREEN enforcement (default mode).
1110
- *
1111
- * For each slice with both phase=red and phase=green completed events,
1112
- * fail if any green completedTs precedes the slice's last red completedTs.
1113
- * No global wave barrier — different slices may freely interleave their
1114
- * RED/GREEN/REFACTOR phases.
1115
- */
1116
- export function evaluatePerSliceRedBeforeGreen(slices) {
1117
- const violations = [];
1118
- for (const [sliceId, rows] of slices.entries()) {
1119
- const reds = rows.filter((entry) => entry.phase === "red");
1120
- const greens = rows.filter((entry) => entry.phase === "green");
1121
- if (reds.length === 0 || greens.length === 0)
1122
- continue;
1123
- const redTs = reds
1124
- .map((entry) => entry.completedTs ?? entry.endTs ?? entry.ts ?? "")
1125
- .filter((ts) => ts.length > 0)
1126
- .sort();
1127
- const greenTs = greens
1128
- .map((entry) => entry.completedTs ?? entry.endTs ?? entry.ts ?? "")
1129
- .filter((ts) => ts.length > 0)
1130
- .sort();
1131
- if (redTs.length === 0 || greenTs.length === 0)
1132
- continue;
1133
- const lastRed = redTs[redTs.length - 1];
1134
- const earliestGreen = greenTs[0];
1135
- if (earliestGreen < lastRed) {
1136
- violations.push(`${sliceId}: phase=green completedTs (${earliestGreen}) precedes the slice's last phase=red completedTs (${lastRed})`);
1137
- }
1138
- }
1139
- if (violations.length === 0) {
1140
- return {
1141
- ok: true,
1142
- details: `Per-slice RED-before-GREEN holds: ${slices.size} slice(s) checked.`
1143
- };
1144
- }
1145
- return {
1146
- ok: false,
1147
- details: `Per-slice RED-before-GREEN violation: ${violations.join("; ")}. ` +
1148
- "Stream-style TDD requires each slice's RED to land before its own GREEN, but cross-lane interleaving is allowed."
1149
- };
1150
- }
1151
- function pickEventTs(rows) {
1152
- for (const entry of rows) {
1153
- const ts = entry.completedTs ?? entry.endTs ?? entry.ts;
1154
- if (typeof ts === "string" && ts.length > 0)
1155
- return ts;
1156
- }
1157
- return undefined;
1158
- }
1159
- export function parseVerticalSliceCycle(body) {
1160
- const tableLines = body.split("\n").filter((line) => /^\|/u.test(line));
1161
- if (tableLines.length < 3) {
1162
- return {
1163
- ok: false,
1164
- details: "Vertical Slice Cycle table must have a header, separator, and at least one slice row."
1165
- };
1166
- }
1167
- const headerCells = splitMarkdownRow(tableLines[0]).map((cell) => cell.toLowerCase());
1168
- const findIdx = (token) => headerCells.findIndex((cell) => cell.includes(token));
1169
- const sliceIdx = findIdx("slice");
1170
- const redIdx = findIdx("red");
1171
- const greenIdx = findIdx("green");
1172
- const refactorIdx = findIdx("refactor");
1173
- if (sliceIdx < 0 || redIdx < 0 || greenIdx < 0 || refactorIdx < 0) {
1174
- return {
1175
- ok: false,
1176
- details: "Vertical Slice Cycle header must include Slice, RED, GREEN, and REFACTOR columns."
1177
- };
1178
- }
1179
- const dataRows = tableLines.slice(2);
1180
- const populated = dataRows.filter((row) => splitMarkdownRow(row).some((cell) => cell.length > 0));
1181
- if (populated.length === 0) {
1182
- return {
1183
- ok: false,
1184
- details: "Vertical Slice Cycle has no populated slice rows."
1185
- };
1186
- }
1187
- const errors = [];
1188
- for (const row of populated) {
1189
- const cells = splitMarkdownRow(row);
1190
- const slice = cells[sliceIdx] ?? "";
1191
- const red = cells[redIdx] ?? "";
1192
- const green = cells[greenIdx] ?? "";
1193
- const refactor = cells[refactorIdx] ?? "";
1194
- const label = slice.length > 0 ? slice : `row ${populated.indexOf(row) + 1}`;
1195
- const redTs = parseTimestampCell(red);
1196
- const greenTs = parseTimestampCell(green);
1197
- if (red.length === 0) {
1198
- errors.push(`${label}: RED ts is empty.`);
1199
- continue;
1200
- }
1201
- if (green.length === 0) {
1202
- errors.push(`${label}: GREEN ts is empty.`);
1203
- continue;
1204
- }
1205
- if (redTs === null) {
1206
- errors.push(`${label}: RED ts \`${red}\` is not an ISO timestamp.`);
1207
- continue;
1208
- }
1209
- if (greenTs === null) {
1210
- errors.push(`${label}: GREEN ts \`${green}\` is not an ISO timestamp.`);
1211
- continue;
1212
- }
1213
- if (greenTs < redTs) {
1214
- errors.push(`${label}: GREEN (${green}) precedes RED (${red}) — order must be monotonic.`);
1215
- continue;
1216
- }
1217
- if (refactor.length === 0) {
1218
- errors.push(`${label}: REFACTOR cell is empty; provide a timestamp or \`deferred because <reason>\`.`);
1219
- continue;
1220
- }
1221
- if (isDeferredOrNotNeeded(refactor)) {
1222
- const rationale = extractDeferRationale(refactor);
1223
- if (rationale.length === 0) {
1224
- errors.push(`${label}: REFACTOR marked deferred/not-needed but rationale is missing — use \`deferred because <reason>\` or \`not needed because <reason>\`.`);
1225
- }
1226
- continue;
1227
- }
1228
- const refactorTs = parseTimestampCell(refactor);
1229
- if (refactorTs === null) {
1230
- errors.push(`${label}: REFACTOR cell \`${refactor}\` is not an ISO timestamp and not marked deferred/not-needed with rationale.`);
1231
- continue;
1232
- }
1233
- if (refactorTs < greenTs) {
1234
- errors.push(`${label}: REFACTOR (${refactor}) precedes GREEN (${green}) — order must be monotonic.`);
1235
- continue;
1236
- }
1237
- }
1238
- if (errors.length > 0) {
1239
- return { ok: false, details: errors.join(" ") };
1240
- }
1241
- return {
1242
- ok: true,
1243
- details: `${populated.length} slice row(s) show monotonic RED -> GREEN -> REFACTOR (deferred-with-rationale accepted).`
1244
- };
1245
- }
1246
- function splitMarkdownRow(line) {
1247
- const trimmed = line.trim();
1248
- if (!trimmed.startsWith("|"))
1249
- return [];
1250
- const inner = trimmed.replace(/^\|/u, "").replace(/\|$/u, "");
1251
- return inner.split("|").map((cell) => cell.trim());
1252
- }
1253
- function parseTimestampCell(cell) {
1254
- const trimmed = cell.replace(/^[`*_\s]+|[`*_\s]+$/gu, "");
1255
- if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/u.test(trimmed))
1256
- return null;
1257
- const t = Date.parse(trimmed);
1258
- return Number.isFinite(t) ? t : null;
1259
- }
1260
- function isDeferredOrNotNeeded(cell) {
1261
- return /\b(deferred|not[\s-]?needed|n\/?a|skipped)\b/iu.test(cell);
1262
- }
1263
- function extractDeferRationale(cell) {
1264
- const cleaned = cell.replace(/`/gu, "").trim();
1265
- const match = /(?:deferred|not[\s-]?needed|skipped)\s+(?:because|since|due to|—|-)\s*(.+)/iu.exec(cleaned);
1266
- if (match !== null && match[1] !== undefined && match[1].trim().length > 0) {
1267
- return match[1].trim();
1268
- }
1269
- const fallback = cleaned.replace(/^\s*(deferred|not[\s-]?needed|skipped|n\/?a)\b[:\s-]*/iu, "").trim();
1270
- return fallback;
1271
- }
1272
- export function evaluateVerificationLadder(body) {
1273
- if (body === null) {
1274
- return {
1275
- ok: true,
1276
- details: "No Verification Ladder section present; rule advisory."
1277
- };
1278
- }
1279
- const tableLines = body.split("\n").filter((line) => /^\|/u.test(line));
1280
- if (tableLines.length < 3) {
1281
- return {
1282
- ok: true,
1283
- details: "Verification Ladder section has no table rows; rule advisory."
1284
- };
1285
- }
1286
- const dataRows = tableLines.slice(2);
1287
- const pendingRows = [];
1288
- for (const row of dataRows) {
1289
- const cells = splitMarkdownRow(row);
1290
- if (cells.length === 0)
1291
- continue;
1292
- if (cells.every((cell) => cell.length === 0))
1293
- continue;
1294
- const cellsLower = cells.map((cell) => cell.toLowerCase().replace(/`/gu, "").trim());
1295
- const hasPending = cellsLower.some((cell) => /\bpending\b/u.test(cell));
1296
- if (hasPending) {
1297
- const label = cells[0] !== undefined && cells[0].length > 0
1298
- ? cells[0]
1299
- : `row ${dataRows.indexOf(row) + 1}`;
1300
- pendingRows.push(label);
1301
- }
1302
- }
1303
- if (pendingRows.length === 0) {
1304
- return {
1305
- ok: true,
1306
- details: "Verification Ladder has no rows still marked `pending`."
1307
- };
1308
- }
1309
- return {
1310
- ok: false,
1311
- details: `Verification Ladder has ${pendingRows.length} row(s) still marked \`pending\`: ${pendingRows.join(", ")}. ` +
1312
- "Promote each to `passed`, `n/a`, `failed`, `skipped`, or `deferred` (with rationale) before stage-complete."
1313
- };
1314
- }
1315
- export async function renderTddSliceSummary(input) {
1316
- let raw;
1317
- try {
1318
- raw = await fs.readFile(input.mainArtifactPath, "utf8");
1319
- }
1320
- catch {
1321
- return;
1322
- }
1323
- let next = raw;
1324
- if (input.renderSummary !== false) {
1325
- const summaryBlock = renderSliceSummaryBlock(input.slicesByEvents);
1326
- next = upsertAutoBlock(next, SLICE_SUMMARY_START, SLICE_SUMMARY_END, summaryBlock);
1327
- }
1328
- if (input.renderIndex !== false) {
1329
- const indexBlock = renderSlicesIndexBlock(input.sliceFiles);
1330
- next = upsertAutoBlock(next, SLICES_INDEX_START, SLICES_INDEX_END, indexBlock);
1331
- }
1332
- if (next !== raw) {
1333
- try {
1334
- await fs.writeFile(input.mainArtifactPath, next, "utf8");
1335
- }
1336
- catch {
1337
- // best-effort render
1338
- }
1339
- }
1340
- }
1341
- function renderSliceSummaryBlock(slices) {
1342
- if (slices.size === 0) {
1343
- return "## Vertical Slice Cycle\n\n_No slice phase events recorded for the active run._";
1344
- }
1345
- const sortedIds = [...slices.keys()].sort();
1346
- const rows = [];
1347
- rows.push("## Vertical Slice Cycle");
1348
- rows.push("");
1349
- rows.push("| Slice | RED ts | GREEN ts | REFACTOR | Implementer | Test refs |");
1350
- rows.push("|---|---|---|---|---|---|");
1351
- for (const sliceId of sortedIds) {
1352
- const events = slices.get(sliceId);
1353
- const red = events.find((entry) => entry.phase === "red");
1354
- const green = events.find((entry) => entry.phase === "green");
1355
- const refactor = events.find((entry) => entry.phase === "refactor" || entry.phase === "refactor-deferred");
1356
- const redTs = red?.completedTs ?? red?.endTs ?? red?.ts ?? "";
1357
- const greenTs = green?.completedTs ?? green?.endTs ?? green?.ts ?? "";
1358
- let refactorCell;
1359
- if (!refactor) {
1360
- refactorCell = "";
1361
- }
1362
- else if (refactor.phase === "refactor-deferred") {
1363
- const refs = Array.isArray(refactor.evidenceRefs) ? refactor.evidenceRefs : [];
1364
- const rationale = refs.find((ref) => typeof ref === "string" && ref.trim().length > 0) ?? "";
1365
- refactorCell = `deferred because ${rationale}`.trim();
1366
- }
1367
- else {
1368
- refactorCell = refactor.completedTs ?? refactor.ts ?? "";
1369
- }
1370
- const implementer = green?.agent ?? red?.agent ?? "";
1371
- const refsList = green?.evidenceRefs ?? red?.evidenceRefs ?? [];
1372
- const testRefs = Array.isArray(refsList) ? refsList.join(", ") : "";
1373
- rows.push(`| ${sliceId} | ${redTs} | ${greenTs} | ${escapeTableCell(refactorCell)} | ${implementer} | ${escapeTableCell(testRefs)} |`);
1374
- }
1375
- return rows.join("\n");
1376
- }
1377
- function renderSlicesIndexBlock(sliceFiles) {
1378
- if (sliceFiles.length === 0) {
1379
- return "## Slices Index\n\n_No `tdd-slices/S-*.md` files present._";
1380
- }
1381
- const lines = [];
1382
- lines.push("## Slices Index");
1383
- lines.push("");
1384
- for (const file of sliceFiles) {
1385
- lines.push(`- [${file.sliceId}](tdd-slices/${path.basename(file.absPath)})`);
1386
- }
1387
- return lines.join("\n");
1388
- }
1389
- function escapeTableCell(value) {
1390
- return value.replace(/\|/gu, "\\|").replace(/\r?\n/gu, " ");
1391
- }
1392
- function upsertAutoBlock(raw, startMarker, endMarker, bodyContent) {
1393
- const startIdx = raw.indexOf(startMarker);
1394
- const endIdx = raw.indexOf(endMarker);
1395
- const replacement = `${startMarker}\n${bodyContent}\n${endMarker}`;
1396
- if (startIdx >= 0 && endIdx > startIdx) {
1397
- const before = raw.slice(0, startIdx);
1398
- const after = raw.slice(endIdx + endMarker.length);
1399
- return `${before}${replacement}${after}`;
1400
- }
1401
- // append to end
1402
- const sep = raw.endsWith("\n") ? "" : "\n";
1403
- return `${raw}${sep}\n${replacement}\n`;
1404
- }