cclaw-cli 7.7.0 → 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 -766
  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 -132
  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 -36
  193. package/dist/execution-topology.js +0 -73
  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 -63
  258. package/dist/internal/wave-status.js +0 -450
  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,2163 +0,0 @@
1
- import { SHIP_FINALIZATION_MODES } from "../constants.js";
2
- import { questionBudgetHint } from "../track-heuristics.js";
3
- import { FLOW_STAGES } from "../types.js";
4
- import { stageSchema } from "../content/stage-schema.js";
5
- /**
6
- * Recognized stop-signal phrases that satisfy the Q&A floor escape hatch
7
- * when recorded as a Q&A Log row. Mirrors `Stop Signals (Natural Language)`
8
- * in `adaptive-elicitation/SKILL.md`.
9
- */
10
- /**
11
- * Stop-signal phrases. ASCII tokens use `\b` word boundaries; non-ASCII
12
- * (RU/UA) tokens use Unicode-aware boundaries built from `\p{L}` so cyrillic
13
- * characters around the phrase prevent partial matches without breaking on
14
- * `\b`'s ASCII-only boundary semantics.
15
- */
16
- const QA_LOG_STOP_SIGNAL_PATTERNS = [
17
- /\bstop[-\s]?signal\b/iu,
18
- /\bachieved\s+enough\b/iu,
19
- /\benough\b/iu,
20
- /\bskip\b/iu,
21
- /\bjust\s+draft\s+it\b/iu,
22
- /\bstop\s+asking\b/iu,
23
- /\bmove\s+on\b/iu,
24
- /\bno\s+more\s+questions\b/iu,
25
- /(?<![\p{L}\p{N}_])достаточно(?![\p{L}\p{N}_])/iu,
26
- /(?<![\p{L}\p{N}_])хватит(?![\p{L}\p{N}_])/iu,
27
- /(?<![\p{L}\p{N}_])давай\s+драфт(?![\p{L}\p{N}_])/iu,
28
- /(?<![\p{L}\p{N}_])досить(?![\p{L}\p{N}_])/iu,
29
- /(?<![\p{L}\p{N}_])вистачить(?![\p{L}\p{N}_])/iu,
30
- /(?<![\p{L}\p{N}_])рухаємось\s+далі(?![\p{L}\p{N}_])/iu
31
- ];
32
- /**
33
- * Stages that run adaptive elicitation. The `qa_log_unconverged` rule
34
- * only fires for these. Other stages may still record a Q&A Log but no
35
- * convergence floor is enforced.
36
- */
37
- export const ELICITATION_STAGES = new Set([
38
- "brainstorm",
39
- "scope",
40
- "design"
41
- ]);
42
- /**
43
- * Phrases that mark a Q&A Log row as "no new decision" — used by the
44
- * Ralph-Loop convergence detector. When the last 2 substantive rows have
45
- * a Decision impact tagged with one of these phrases, convergence has
46
- * been reached even if not every forcing question was explicitly
47
- * addressed.
48
- */
49
- const QA_LOG_NO_DECISION_TOKENS = [
50
- /\bskip(?:ped)?\b/iu,
51
- /\bcontinue\b/iu,
52
- /\bno[-\s]?change\b/iu,
53
- /\bno[-\s]?decision\b/iu,
54
- /\bno[-\s]?op\b/iu,
55
- /\bnoop\b/iu,
56
- /\bdone\b/iu,
57
- /\bsame\b/iu,
58
- /\bok\b/iu
59
- ];
60
- /**
61
- * Decide whether a Q&A Log row counts as a "substantive" entry. Rows
62
- * whose decision_impact column reads `skipped` / `waived` only do not
63
- * count.
64
- */
65
- function isSubstantiveQaRow(cells) {
66
- if (cells.length === 0)
67
- return false;
68
- const last = cells[cells.length - 1] ?? "";
69
- const normalized = last.toLowerCase();
70
- if (/^\s*(?:skipped|waived)\b/u.test(normalized))
71
- return false;
72
- return true;
73
- }
74
- /**
75
- * Detect a stop-signal row in the Q&A Log. Pattern is matched across
76
- * all cells of any row so the user's quote can live in any column.
77
- */
78
- function detectStopSignal(rows) {
79
- for (const row of rows) {
80
- const joined = row.join(" | ");
81
- for (const pattern of QA_LOG_STOP_SIGNAL_PATTERNS) {
82
- if (pattern.test(joined))
83
- return true;
84
- }
85
- }
86
- return false;
87
- }
88
- /**
89
- * Validate the kebab-case ASCII shape of a forcing-question topic ID.
90
- * IDs are short, language-neutral identifiers authors can paste into a
91
- * `[topic:<id>]` tag without typos.
92
- */
93
- const TOPIC_ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/u;
94
- function isValidTopicId(id) {
95
- return TOPIC_ID_PATTERN.test(id);
96
- }
97
- /**
98
- * Parse a single checklist row into the list of forcing-question topic
99
- * descriptors it declares. Returns `null` when the row is not a
100
- * forcing-questions header. Throws when the header is found but its
101
- * body does not match the `id: topic; id: topic; ...` syntax — authors
102
- * fix the stage definition rather than silently ship un-coverable
103
- * topics.
104
- *
105
- * Exposed for unit tests that exercise the parser without depending on
106
- * the live stage schema.
107
- */
108
- export function parseForcingQuestionsRow(row, context = "row") {
109
- const headerMatch = /\*\*\s*[A-Za-z]+\s+forcing\s+questions\s*\([^)]*\)\s*\*\*\s*(?:[—\-–:]+)?\s*(.+)/iu.exec(row);
110
- if (!headerMatch)
111
- return null;
112
- const body = (headerMatch[1] ?? "").trim();
113
- if (body.length === 0)
114
- return [];
115
- // Take everything up to the first sentence-ending `.` followed by a
116
- // space + capital letter. We split on `;` only; commas are part of
117
- // human labels. Authors stop the list with `.` so the trailing
118
- // prose ("Tag the matching ...") is excluded.
119
- const listSection = body.split(/\.\s+(?=[A-Z])/u)[0] ?? body;
120
- const segments = listSection
121
- .split(/;\s*/u)
122
- .map((segment) => segment.trim())
123
- .filter((segment) => segment.length > 0);
124
- const topics = [];
125
- for (const segment of segments) {
126
- const match = /^[`*_]?\s*([A-Za-z0-9][A-Za-z0-9-]*)\s*[`*_]?\s*:\s*(.+?)\s*$/u.exec(segment);
127
- if (!match) {
128
- throw new Error(`parseForcingQuestionsRow(${context}): segment "${segment}" does not match required \`id: topic\` syntax. Use \`id: topic; id: topic; ...\` form.`);
129
- }
130
- const id = (match[1] ?? "").toLowerCase();
131
- const topic = (match[2] ?? "").replace(/[`*_]+$/u, "").trim();
132
- if (!isValidTopicId(id)) {
133
- throw new Error(`parseForcingQuestionsRow(${context}): invalid topic id "${id}" in segment "${segment}". IDs must match ${TOPIC_ID_PATTERN.source}.`);
134
- }
135
- if (topic.length === 0) {
136
- throw new Error(`parseForcingQuestionsRow(${context}): empty topic label after id "${id}" in segment "${segment}".`);
137
- }
138
- topics.push({ id, topic });
139
- }
140
- return topics;
141
- }
142
- /**
143
- * Extract forcing-question topics from a stage's checklist.
144
- *
145
- * Only the `id: topic; id: topic; ...` syntax is accepted. Throws when
146
- * the syntax is malformed so authors fix the stage definition rather
147
- * than silently shipping un-coverable topics.
148
- *
149
- * Returns empty array when no forcing-questions row is present (caller
150
- * treats absence as "no forcing requirement" — convergence falls back
151
- * to the no-new-decisions / stop-signal detectors). Returning [] when
152
- * the row exists but lists no segments is also legal.
153
- */
154
- export function extractForcingQuestions(stage) {
155
- let checklist;
156
- try {
157
- checklist = stageSchema(stage).executionModel.checklist;
158
- }
159
- catch {
160
- return [];
161
- }
162
- for (const row of checklist) {
163
- const parsed = parseForcingQuestionsRow(row, `stage=${stage}`);
164
- if (parsed === null)
165
- continue;
166
- return parsed;
167
- }
168
- return [];
169
- }
170
- /**
171
- * Detect whether a Q&A Log row carries an explicit `[topic:<id>]` tag
172
- * for the requested forcing-topic id. Matching is case-insensitive on
173
- * the id, ASCII-only on the tag boundary. NO keyword fallback: the user
174
- * must stamp the tag in any cell of the row.
175
- */
176
- function isTopicAddressed(id, rows) {
177
- const needle = id.toLowerCase();
178
- const tagPattern = /\[topic:\s*([A-Za-z0-9][A-Za-z0-9-]*)\s*\]/giu;
179
- for (const row of rows) {
180
- const haystack = row.join(" | ");
181
- tagPattern.lastIndex = 0;
182
- let match;
183
- while ((match = tagPattern.exec(haystack)) !== null) {
184
- const candidate = (match[1] ?? "").toLowerCase();
185
- if (candidate === needle)
186
- return true;
187
- }
188
- }
189
- return false;
190
- }
191
- function lastTwoRowsAllNoDecision(substantiveRows) {
192
- if (substantiveRows.length < 2)
193
- return false;
194
- const tail = substantiveRows.slice(-2);
195
- for (const row of tail) {
196
- const decisionImpact = (row[row.length - 1] ?? "").trim();
197
- if (decisionImpact.length === 0)
198
- return false;
199
- const matched = QA_LOG_NO_DECISION_TOKENS.some((pattern) => pattern.test(decisionImpact));
200
- if (!matched)
201
- return false;
202
- }
203
- return true;
204
- }
205
- /**
206
- * Evaluate the Q&A Log convergence floor for a brainstorm / scope /
207
- * design artifact. Returns ok=true when convergence is reached or any
208
- * escape hatch fires.
209
- *
210
- * Convergence sources (any one can set ok=true — see also
211
- * `adaptiveElicitationSkillMarkdown`):
212
- * - Every forcing-question topic id from the stage checklist is tagged
213
- * `[topic:<id>]` on at least one `## Q&A Log` row.
214
- * - Ralph-Loop path: last 2 substantive rows read as no-new-decisions,
215
- * substantive count ≥ max(2, questionBudgetHint(discoveryMode, stage).min),
216
- * and not (guided/deep discovery with pending forcing-topic ids).
217
- * - Stop-signal row (`QA_LOG_STOP_SIGNAL_PATTERNS`).
218
- * - `--skip-questions` (`options.skipQuestions`): ok remains false but
219
- * `skipQuestionsAdvisory` is true (linter treats as non-blocking).
220
- * - No forcing-questions row in the checklist and ≥1 substantive row.
221
- *
222
- * `[topic:<id>]` is the sole topic-coverage signal. The `min` and
223
- * `liteShortCircuit` fields stay for harness compatibility (min is
224
- * always 0; liteShortCircuit false).
225
- */
226
- export function evaluateQaLogFloor(qaLogBody, track, stage, options = {}) {
227
- const rows = qaLogBody !== null ? getMarkdownTableRows(qaLogBody) : [];
228
- const substantiveRows = rows.filter(isSubstantiveQaRow);
229
- const count = substantiveRows.length;
230
- const hasStopSignal = detectStopSignal(rows);
231
- const skipQuestionsAdvisory = options.skipQuestions === true;
232
- const discoveryMode = options.discoveryMode ?? (track === "quick" ? "lean" : "guided");
233
- const forcingTopics = (options.forcingQuestions ?? extractForcingQuestions(stage)).map((entry) => (typeof entry === "string" ? { id: entry, topic: entry } : entry));
234
- const forcingCovered = [];
235
- const forcingPending = [];
236
- for (const topic of forcingTopics) {
237
- if (isTopicAddressed(topic.id, rows))
238
- forcingCovered.push(topic.id);
239
- else
240
- forcingPending.push(topic.id);
241
- }
242
- const budget = questionBudgetHint(discoveryMode, stage);
243
- const noNewDecisions = lastTwoRowsAllNoDecision(substantiveRows);
244
- const allForcingCovered = forcingTopics.length > 0 ? forcingPending.length === 0 : count >= 1;
245
- const minimumRowsReached = count >= Math.max(2, budget.min);
246
- const riskEscalationNeeded = forcingPending.length > 0 && /^(guided|deep)$/u.test(discoveryMode);
247
- const noNewDecisionConverged = noNewDecisions && minimumRowsReached && !riskEscalationNeeded;
248
- const ok = allForcingCovered || noNewDecisionConverged || hasStopSignal;
249
- const pendingIdsBracket = forcingPending.length > 0
250
- ? `[${forcingPending.join(", ")}]`
251
- : "[none]";
252
- let details;
253
- if (ok) {
254
- if (allForcingCovered && forcingTopics.length > 0) {
255
- details = `Q&A Log converged: all ${forcingTopics.length} forcing-question topic(s) addressed across ${count} substantive row(s).`;
256
- }
257
- else if (allForcingCovered) {
258
- details = `Q&A Log converged: stage exposes no forcing-questions row and ${count} substantive entry recorded.`;
259
- }
260
- else if (noNewDecisionConverged) {
261
- const remaining = forcingPending.length > 0
262
- ? ` ${forcingPending.length} forcing topic IDs still pending: ${pendingIdsBracket} after the minimum ${budget.min}-row discovery pass.`
263
- : ` Ralph-Loop convergence detector says no new decision-changing rows in the last 2 turns after the minimum ${budget.min}-row discovery pass.`;
264
- details = `Q&A Log converged via no-new-decisions detector at ${count} row(s).${remaining}`;
265
- }
266
- else {
267
- details = `Q&A Log converged: explicit user stop-signal row recorded at ${count} row(s).`;
268
- }
269
- }
270
- else if (skipQuestionsAdvisory) {
271
- details = `Q&A Log unconverged at ${count} row(s); --skip-questions flag downgraded the finding to advisory. Forcing topic IDs pending: ${pendingIdsBracket}.`;
272
- }
273
- else if (noNewDecisions && !minimumRowsReached) {
274
- details = `Q&A Log still below the minimum ${budget.min}-row ${discoveryMode} discovery pass (${count} substantive row(s)). Forcing topic IDs pending: ${pendingIdsBracket}. Continue asking decision-changing questions before drafting.`;
275
- }
276
- else if (riskEscalationNeeded && noNewDecisions) {
277
- details = `Q&A Log cannot converge via Ralph-Loop yet because ${discoveryMode} mode keeps pending forcing topic IDs blocking: ${pendingIdsBracket}. Cover the remaining topics or record an explicit stop-signal row.`;
278
- }
279
- else {
280
- details = `Q&A Log unconverged at ${count} row(s). Forcing topic IDs pending: ${pendingIdsBracket}. Tag each Q&A row with \`[topic:<id>]\` to mark coverage, complete the minimum ${budget.min}-row ${discoveryMode} discovery pass, or record an explicit user stop-signal row.`;
281
- }
282
- const advisoryBudget = budget.recommended;
283
- return {
284
- ok,
285
- count,
286
- min: 0,
287
- hasStopSignal,
288
- liteShortCircuit: false,
289
- skipQuestionsAdvisory,
290
- forcingCovered,
291
- forcingPending,
292
- noNewDecisions: noNewDecisionConverged,
293
- details: advisoryBudget > 0
294
- ? `${details} (advisory budget for ${discoveryMode}/${stage}: ~${advisoryBudget} Q&A turns)`
295
- : details
296
- };
297
- }
298
- export function normalizeHeadingTitle(title) {
299
- return title.trim().replace(/\s+/g, " ");
300
- }
301
- /**
302
- * Collect H2 sections and body content (`## Section Name`).
303
- *
304
- * - Ignores lines that live inside fenced code blocks (``` / ~~~) so a
305
- * commented `## Approaches` inside an example doesn't open a phantom
306
- * section and swallow real content.
307
- * - When the same heading appears more than once at the top level we
308
- * concatenate the bodies rather than silently overwriting the earlier
309
- * occurrence. This keeps lint rules honest when authors split a section
310
- * into multiple passes.
311
- */
312
- export function extractH2Sections(markdown) {
313
- const sections = new Map();
314
- const lines = markdown.split(/\r?\n/);
315
- let currentHeading = null;
316
- let buffer = [];
317
- let fenced = null;
318
- const flush = () => {
319
- if (currentHeading === null)
320
- return;
321
- const existing = sections.get(currentHeading);
322
- const body = buffer.join("\n");
323
- sections.set(currentHeading, existing === undefined ? body : `${existing}\n${body}`);
324
- };
325
- for (const line of lines) {
326
- const fenceMatch = /^(```|~~~)/u.exec(line);
327
- if (fenceMatch) {
328
- if (fenced === null) {
329
- fenced = fenceMatch[1] ?? null;
330
- }
331
- else if (line.startsWith(fenced)) {
332
- fenced = null;
333
- }
334
- if (currentHeading !== null)
335
- buffer.push(line);
336
- continue;
337
- }
338
- if (fenced === null) {
339
- const match = /^##\s+(.+)$/u.exec(line);
340
- if (match) {
341
- flush();
342
- currentHeading = normalizeHeadingTitle(match[1] ?? "");
343
- buffer = [];
344
- continue;
345
- }
346
- }
347
- if (currentHeading !== null) {
348
- buffer.push(line);
349
- }
350
- }
351
- flush();
352
- return sections;
353
- }
354
- export function duplicateH2Headings(markdown) {
355
- const lines = markdown.split(/\r?\n/);
356
- let fenced = null;
357
- const counts = new Map();
358
- const displayHeading = new Map();
359
- for (const line of lines) {
360
- const fenceMatch = /^(```|~~~)/u.exec(line);
361
- if (fenceMatch) {
362
- if (fenced === null) {
363
- fenced = fenceMatch[1] ?? null;
364
- }
365
- else if (line.startsWith(fenced)) {
366
- fenced = null;
367
- }
368
- continue;
369
- }
370
- if (fenced !== null)
371
- continue;
372
- const match = /^##\s+(.+)$/u.exec(line);
373
- if (!match)
374
- continue;
375
- const heading = normalizeHeadingTitle(match[1] ?? "");
376
- const key = heading.toLowerCase();
377
- counts.set(key, (counts.get(key) ?? 0) + 1);
378
- if (!displayHeading.has(key)) {
379
- displayHeading.set(key, heading);
380
- }
381
- }
382
- return [...counts.entries()]
383
- .filter(([, count]) => count > 1)
384
- .map(([key]) => displayHeading.get(key) ?? key);
385
- }
386
- /**
387
- * Return the author-authored prose of an artifact, stripping linter meta
388
- * regions so free-text scans (placeholder tokens, scope-reduction phrases,
389
- * investigation trigger words) don't self-cannibalize by matching the
390
- * linter's own templated meta-phrases.
391
- *
392
- * Stripping rules (in order):
393
- * 1. `<!-- linter-meta --> ... <!-- /linter-meta -->` paired blocks.
394
- * Both markers must appear on their own line; unterminated openings
395
- * are left as-is so a malformed artifact cannot hide arbitrary
396
- * content by omitting the closing marker.
397
- * 2. Every other HTML comment (`<!-- ... -->`, possibly multi-line).
398
- * 3. Fenced code blocks that are tagged `linter-rule` (e.g.
399
- * ```` ```linter-rule ````). Plain fenced code blocks are preserved
400
- * because many stages quote code samples that the linter should
401
- * still see.
402
- *
403
- * The function guarantees the returned string is a strict subset of the
404
- * original: no characters are synthesized, and line offsets are
405
- * preserved for any surviving line (blank lines stand in for stripped
406
- * regions). This keeps regex-based linter checks stable when authors
407
- * add or remove linter-meta blocks between runs.
408
- */
409
- export function extractAuthoredBody(rawArtifact) {
410
- if (typeof rawArtifact !== "string" || rawArtifact.length === 0) {
411
- return "";
412
- }
413
- const linterMetaBlock = /^[ \t]*<!--\s*linter-meta\s*-->[\s\S]*?^[ \t]*<!--\s*\/linter-meta\s*-->[ \t]*$/gmu;
414
- let body = rawArtifact.replace(linterMetaBlock, (match) => match.replace(/[^\n]/gu, ""));
415
- const htmlComment = /<!--[\s\S]*?-->/gu;
416
- body = body.replace(htmlComment, (match) => match.replace(/[^\n]/gu, ""));
417
- const linterRuleFence = /^([ \t]*)(`{3,}|~{3,})\s*linter-rule\b[^\n]*\n[\s\S]*?\n\1\2[ \t]*$/gmu;
418
- body = body.replace(linterRuleFence, (match) => match.replace(/[^\n]/gu, ""));
419
- return body;
420
- }
421
- export function headingPresent(sections, section) {
422
- const want = normalizeHeadingTitle(section).toLowerCase();
423
- for (const h of sections.keys()) {
424
- if (h.toLowerCase() === want) {
425
- return true;
426
- }
427
- }
428
- return false;
429
- }
430
- export function sectionBodyByName(sections, section) {
431
- const want = normalizeHeadingTitle(section).toLowerCase();
432
- for (const [heading, body] of sections.entries()) {
433
- if (heading.toLowerCase() === want) {
434
- return body;
435
- }
436
- }
437
- return null;
438
- }
439
- export function sectionBodyByAnyName(sections, sectionNames) {
440
- const bodies = sectionNames.flatMap((section) => {
441
- const body = sectionBodyByName(sections, section);
442
- return body === null ? [] : [`### ${section}\n${body}`];
443
- });
444
- if (bodies.length === 0)
445
- return null;
446
- return bodies.join("\n");
447
- }
448
- export function sectionBodyByHeadingPrefix(sections, prefix) {
449
- const want = normalizeHeadingTitle(prefix).toLowerCase();
450
- for (const [heading, body] of sections.entries()) {
451
- if (heading.toLowerCase().startsWith(want)) {
452
- return body;
453
- }
454
- }
455
- return null;
456
- }
457
- export function checkCriticPredictionsContract(sections) {
458
- const criticFindingsBody = sectionBodyByName(sections, "Critic Findings");
459
- const layeredReviewBody = sectionBodyByHeadingPrefix(sections, "Layered review");
460
- const layeredReviewMentionsCritic = layeredReviewBody !== null && /\bcritic\b/iu.test(layeredReviewBody);
461
- const sourceBody = criticFindingsBody ?? (layeredReviewMentionsCritic ? layeredReviewBody : null);
462
- if (sourceBody === null)
463
- return null;
464
- const predictionsMatch = /(?:^|\n)#{3,4}\s*Pre-commitment predictions\b([\s\S]*?)(?=\n#{2,4}\s+|$)/iu.exec(sourceBody);
465
- const predictionsCount = predictionsMatch ? countListItems(predictionsMatch[1] ?? "") : 0;
466
- const hasPredictions = predictionsCount >= 1;
467
- const hasValidated = /(?:^|\n)#{3,4}\s*Validated\s*\/\s*Disproven\b/iu.test(sourceBody);
468
- const hasOpenQuestions = /(?:^|\n)#{3,4}\s*Open Questions\b/iu.test(sourceBody);
469
- const missing = [];
470
- if (!hasPredictions) {
471
- missing.push("`Pre-commitment predictions` subsection is missing or has no list items");
472
- }
473
- if (!hasValidated) {
474
- missing.push("`Validated / Disproven` subsection is missing");
475
- }
476
- if (!hasOpenQuestions) {
477
- missing.push("`Open Questions` subsection is missing");
478
- }
479
- return {
480
- found: missing.length === 0,
481
- details: missing.length === 0
482
- ? "Critic pre-commitment predictions contract is present (predictions, validated/disproven mapping, open questions)."
483
- : missing.join("; ")
484
- };
485
- }
486
- const DOCUMENT_REVIEWER_NAMES = [
487
- "coherence-reviewer",
488
- "scope-guardian-reviewer",
489
- "feasibility-reviewer"
490
- ];
491
- export function evaluateLayeredDocumentReviewStatus(sections, confidenceFindingRegexSource) {
492
- const layeredReviewBody = sectionBodyByHeadingPrefix(sections, "Layered review");
493
- if (layeredReviewBody === null)
494
- return null;
495
- const triggeredReviewers = DOCUMENT_REVIEWER_NAMES.filter((reviewer) => new RegExp(`\\b${reviewer}\\b`, "iu").test(layeredReviewBody));
496
- if (triggeredReviewers.length === 0)
497
- return null;
498
- const findingRegex = new RegExp(confidenceFindingRegexSource, "iu");
499
- const hasCalibratedFinding = findingRegex.test(layeredReviewBody);
500
- const missingStructured = [];
501
- const failOrPartialWithoutWaiver = [];
502
- const waiverRegex = /(?:explicit\s+waiver|waiver\s*:|waived\s*:|accepted[-\s]?risk)/iu;
503
- for (const reviewer of triggeredReviewers) {
504
- const escaped = reviewer.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
505
- const subsectionMatch = new RegExp(`(?:^|\\n)#{3,4}\\s*${escaped}\\b([\\s\\S]*?)(?=\\n#{2,4}\\s+|$)`, "iu")
506
- .exec(layeredReviewBody);
507
- const reviewerBlock = subsectionMatch?.[1] ?? layeredReviewBody;
508
- const statusMatch = /\b(?:Status|Result|Verdict)\s*:\s*(PASS|PASS_WITH_GAPS|FAIL|PARTIAL|BLOCKED)\b/iu
509
- .exec(reviewerBlock);
510
- const inlineStatusMatch = new RegExp(`${escaped}[\\s\\S]{0,120}\\b(PASS|PASS_WITH_GAPS|FAIL|PARTIAL|BLOCKED)\\b`, "iu")
511
- .exec(layeredReviewBody);
512
- const status = (statusMatch?.[1] ?? inlineStatusMatch?.[1] ?? "").toUpperCase();
513
- if (!hasCalibratedFinding || status.length === 0) {
514
- missingStructured.push(reviewer);
515
- }
516
- if ((status === "FAIL" || status === "PARTIAL") && !waiverRegex.test(reviewerBlock) && !waiverRegex.test(layeredReviewBody)) {
517
- failOrPartialWithoutWaiver.push(`${reviewer}:${status}`);
518
- }
519
- }
520
- return {
521
- triggeredReviewers,
522
- missingStructured,
523
- failOrPartialWithoutWaiver
524
- };
525
- }
526
- /**
527
- * Build a regex that matches `<field>: <value>` even when the field name
528
- * and/or value are wrapped in markdown emphasis (`*`, `**`, `_`, `__`).
529
- *
530
- * The shipped templates render fields as `- **Field name:** value`, so any
531
- * structural check that searches for `Field:\s*token` against the rendered
532
- * artifact must tolerate the closing `**` between the colon and the value.
533
- *
534
- * `field` is treated as literal text (regex meta-characters are escaped).
535
- * `value` is inserted verbatim so callers can pass alternation
536
- * (`STARTUP|BUILDER|...`). `flags` defaults to case-insensitive Unicode.
537
- */
538
- export function markdownFieldRegex(field, value, flags = "iu") {
539
- const escapedField = field.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
540
- const emph = "[*_]{0,2}";
541
- const source = `(?:^|[\\s>])${emph}\\s*${escapedField}\\s*${emph}\\s*:\\s*${emph}\\s*(?:${value})\\b`;
542
- return new RegExp(source, flags);
543
- }
544
- export function extractMarkdownSectionBody(markdown, section) {
545
- return sectionBodyByName(extractH2Sections(markdown), section);
546
- }
547
- export function headingLineIndex(markdown, section) {
548
- const want = normalizeHeadingTitle(section).toLowerCase();
549
- const lines = markdown.split(/\r?\n/);
550
- let fenced = null;
551
- for (let i = 0; i < lines.length; i++) {
552
- const line = lines[i];
553
- const fence = /^\s*(```+|~~~+)\s*([A-Za-z0-9_-]+)?\s*$/u.exec(line);
554
- if (fence) {
555
- const marker = fence[1];
556
- if (fenced === null) {
557
- fenced = marker;
558
- }
559
- else if (fenced === marker) {
560
- fenced = null;
561
- }
562
- continue;
563
- }
564
- if (fenced !== null)
565
- continue;
566
- const heading = /^##\s+(.+)$/u.exec(line);
567
- if (!heading)
568
- continue;
569
- if (normalizeHeadingTitle(heading[1] ?? "").toLowerCase() === want) {
570
- return i;
571
- }
572
- }
573
- return -1;
574
- }
575
- export function parseShortCircuitStatus(sectionBody) {
576
- if (!sectionBody)
577
- return "";
578
- const lines = sectionBody.split(/\r?\n/u);
579
- return lines
580
- .map((line) => line.replace(/[*_`]/gu, "").trim())
581
- .map((line) => /^[-*]?\s*status\s*:\s*(.+)$/iu.exec(line)?.[1] ?? "")
582
- .find((value) => value.trim().length > 0)?.trim().toLowerCase() ?? "";
583
- }
584
- export function isShortCircuitActivated(sectionBody) {
585
- const statusValue = parseShortCircuitStatus(sectionBody);
586
- return /^(?:activated|yes|true)$/u.test(statusValue) || /\bactivated\b/iu.test(statusValue);
587
- }
588
- export function meaningfulLineCount(sectionBody) {
589
- return sectionBody
590
- .split(/\r?\n/)
591
- .map((line) => line.trim())
592
- .filter((line) => line.length > 0)
593
- .filter((line) => !line.startsWith("<!--"))
594
- .filter((line) => !/^[-:| ]+$/u.test(line))
595
- .filter((line) => /[\p{L}\p{N}]/u.test(line))
596
- .length;
597
- }
598
- export function lineHasToken(line, token) {
599
- return new RegExp(`\\b${token}\\b`, "u").test(line);
600
- }
601
- export function countListItems(sectionBody) {
602
- const lines = sectionBody.split(/\r?\n/).map((line) => line.trim());
603
- const bullets = lines.filter((line) => /^[-*]\s+\S+/u.test(line)).length;
604
- const tableRows = lines.filter((line) => /^\|.*\|$/u.test(line) && !/^\|[-:| ]+\|$/u.test(line));
605
- const tableDataRows = tableRows.length > 0 ? Math.max(0, tableRows.length - 1) : 0;
606
- return Math.max(bullets, tableDataRows);
607
- }
608
- export function parseMarkdownTableRow(line) {
609
- return line
610
- .trim()
611
- .split("|")
612
- .map((cell) => cell.trim())
613
- .filter((cell) => cell.length > 0);
614
- }
615
- export function tableHeaderCells(sectionBody) {
616
- const lines = sectionBody.split(/\r?\n/).map((line) => line.trim());
617
- const headerIndex = lines.findIndex((line) => /^\|.*\|$/u.test(line));
618
- if (headerIndex < 0)
619
- return null;
620
- const separator = lines[headerIndex + 1];
621
- if (!separator || !/^\|[-:| ]+\|$/u.test(separator)) {
622
- return null;
623
- }
624
- return parseMarkdownTableRow(lines[headerIndex]);
625
- }
626
- export function extractMinItemsFromRule(rule) {
627
- const match = /at least\s+(\d+)/iu.exec(rule);
628
- if (!match)
629
- return null;
630
- const parsed = Number.parseInt(match[1] ?? "", 10);
631
- return Number.isFinite(parsed) ? parsed : null;
632
- }
633
- export function tokensFromRule(rule) {
634
- const allCaps = rule.match(/\b[A-Z][A-Z0-9_]{2,}\b/g) ?? [];
635
- if (allCaps.length > 0) {
636
- return [...new Set(allCaps)];
637
- }
638
- if (/finalization enum token/iu.test(rule)) {
639
- return [...SHIP_FINALIZATION_MODES];
640
- }
641
- if (/final verdict/iu.test(rule)) {
642
- return ["APPROVED", "APPROVED_WITH_CONCERNS", "BLOCKED"];
643
- }
644
- return [];
645
- }
646
- export const VAGUE_AC_ADJECTIVES = [
647
- "fast",
648
- "quick",
649
- "slow",
650
- "fast enough",
651
- "quickly",
652
- "intuitive",
653
- "robust",
654
- "reliable",
655
- "scalable",
656
- "simple",
657
- "easy",
658
- "user-friendly",
659
- "user friendly",
660
- "nice",
661
- "good",
662
- "clean",
663
- "secure enough",
664
- "responsive",
665
- "efficient",
666
- "performant",
667
- "smooth",
668
- "seamless",
669
- "modern"
670
- ];
671
- export function isSeparatorRow(line) {
672
- return /^\|[-:| ]+\|$/u.test(line);
673
- }
674
- export function getMarkdownTableRows(sectionBody) {
675
- const lines = sectionBody.split(/\r?\n/).map((line) => line.trim());
676
- const rows = [];
677
- let sawSeparator = false;
678
- for (const line of lines) {
679
- if (!/^\|.*\|$/u.test(line))
680
- continue;
681
- if (isSeparatorRow(line)) {
682
- sawSeparator = true;
683
- continue;
684
- }
685
- if (!sawSeparator)
686
- continue;
687
- rows.push(parseMarkdownTableRow(line));
688
- }
689
- return rows;
690
- }
691
- export function parseBinaryFlag(value) {
692
- const normalized = value.trim().toLowerCase();
693
- if (/^(?:y|yes|true|1)$/u.test(normalized))
694
- return "yes";
695
- if (/^(?:n|no|false|0|none)$/u.test(normalized))
696
- return "no";
697
- return "unknown";
698
- }
699
- export function parseKeyedBinaryFlag(value, key) {
700
- const match = new RegExp(`${key}\\s*=\\s*(y|yes|true|1|n|no|false|0)`, "iu").exec(value);
701
- if (!match)
702
- return "unknown";
703
- return /^(?:y|yes|true|1)$/iu.test(match[1] ?? "") ? "yes" : "no";
704
- }
705
- export function parseFailureModeRescueFlag(rescueCell) {
706
- const keyed = parseKeyedBinaryFlag(rescueCell, "rescued");
707
- if (keyed !== "unknown")
708
- return keyed;
709
- const direct = parseBinaryFlag(rescueCell);
710
- if (direct !== "unknown")
711
- return direct;
712
- if (/\b(?:no rescue|without rescue|unrescued|no fallback|none|absent)\b/iu.test(rescueCell)) {
713
- return "no";
714
- }
715
- if (/\b(?:fallback|retry|degrade|recover|rescue|mitigat)\b/iu.test(rescueCell)) {
716
- return "yes";
717
- }
718
- return "unknown";
719
- }
720
- export function parseFailureModeTestFlag(rowText) {
721
- const keyed = parseKeyedBinaryFlag(rowText, "test");
722
- if (keyed !== "unknown")
723
- return keyed;
724
- if (/\b(?:no tests?|untested|without tests?)\b/iu.test(rowText)) {
725
- return "no";
726
- }
727
- if (/\b(?:tested|has tests?|with tests?|covered by tests?)\b/iu.test(rowText)) {
728
- return "yes";
729
- }
730
- return "unknown";
731
- }
732
- export function validateFailureModeTable(sectionBody) {
733
- const header = tableHeaderCells(sectionBody);
734
- if (!header) {
735
- return {
736
- ok: false,
737
- details: "Failure Mode Table must include a markdown header row and separator."
738
- };
739
- }
740
- const expectedHeader = ["Method", "Exception", "Rescue", "UserSees"];
741
- const normalizedHeader = header.map((cell) => cell.toLowerCase());
742
- const normalizedExpected = expectedHeader.map((cell) => cell.toLowerCase());
743
- const headerMatches = normalizedHeader.length === normalizedExpected.length &&
744
- normalizedHeader.every((cell, index) => cell === normalizedExpected[index]);
745
- if (!headerMatches) {
746
- return {
747
- ok: false,
748
- details: `Failure Mode Table header must be exactly: ${expectedHeader.join(" | ")}.`
749
- };
750
- }
751
- const rows = getMarkdownTableRows(sectionBody);
752
- if (rows.length === 0) {
753
- return {
754
- ok: false,
755
- details: "Failure Mode Table must include at least one data row."
756
- };
757
- }
758
- for (const [index, row] of rows.entries()) {
759
- if (row.length < 4) {
760
- return {
761
- ok: false,
762
- details: `Failure Mode Table row ${index + 1} must provide 4 columns (Method, Exception, Rescue, UserSees).`
763
- };
764
- }
765
- const method = (row[0] ?? "").trim();
766
- const exception = (row[1] ?? "").trim();
767
- const rescue = (row[2] ?? "").trim();
768
- const userSees = (row[3] ?? "").trim();
769
- if (!method || !exception || !rescue || !userSees) {
770
- return {
771
- ok: false,
772
- details: `Failure Mode Table row ${index + 1} must populate all columns (Method, Exception, Rescue, UserSees).`
773
- };
774
- }
775
- const rescueFlag = parseFailureModeRescueFlag(rescue);
776
- const testFlag = parseFailureModeTestFlag(`${method} ${exception} ${rescue} ${userSees}`);
777
- const userSilent = /\bsilent\b/iu.test(userSees);
778
- if (rescueFlag === "no" && testFlag === "no" && userSilent) {
779
- return {
780
- ok: false,
781
- details: `Failure Mode Table CRITICAL row ${index + 1} (${method}): RESCUED=N + TEST=N + UserSees=Silent. Add rescue path, add test coverage, or make user impact explicit.`
782
- };
783
- }
784
- }
785
- return {
786
- ok: true,
787
- details: "Failure Mode Table header and critical-risk checks passed."
788
- };
789
- }
790
- // Canonical scope mode tokens (gstack CEO review). The four mode names live in
791
- // the scope skill, the artifact template, and downstream traces. Requiring one
792
- // of them in Scope Summary is **structural** — not free-form English keyword
793
- // matching on user prose. Authors may also use the canonical short form on a
794
- // `Mode:` / `Selected mode:` line (e.g. `Selected mode: hold`) as a courtesy.
795
- export const SCOPE_MODE_FULL_TOKENS = [
796
- "SCOPE EXPANSION",
797
- "SELECTIVE EXPANSION",
798
- "HOLD SCOPE",
799
- "SCOPE REDUCTION"
800
- ];
801
- // Short-form synonyms accepted only when stamped on an explicit `Mode:` /
802
- // `Selected mode:` / `Scope mode:` line. Plain prose with the same word does
803
- // not count, so `strict` / `broad` / `narrow` / similar non-mode adjectives
804
- // remain rejected.
805
- export const SCOPE_MODE_LINE_REGEX = /(?:^|\n)\s*[-*]?\s*\**\s*(?:Selected\s+|Scope\s+)?Mode\**\s*:\s*\**\s*([^\n]+)/iu;
806
- export const SCOPE_MODE_SHORT_TOKEN_REGEX = /\b(?:hold(?:[\s_-]?scope)?|selective(?:[\s_-]?expansion)?|scope[\s_-]?expansion|expansion|scope[\s_-]?reduction|reduction|expand|reduce)\b/iu;
807
- export const SPEC_MAX_MODULES = 5;
808
- // Next-stage handoff token. We only enforce the canonical machine-surface stage
809
- // IDs (`design`, `spec`) plus stable handoff phrases. The surrounding prose may
810
- // be written in any language — this guards the downstream cross-stage trace,
811
- // not the wording of the rationale.
812
- export const NEXT_STAGE_HANDOFF_REGEX = /(?:`(?:design|spec)`|\bdesign\b|\bspec\b|next[-\s_]stage|next stage|handoff|hand[-\s]off)/iu;
813
- export function hasCanonicalScopeMode(body) {
814
- return extractCanonicalScopeMode(body) !== null;
815
- }
816
- export function canonicalModesInText(text) {
817
- const normalized = text
818
- .toUpperCase()
819
- .replace(/[_-]+/gu, " ")
820
- .replace(/\s+/gu, " ")
821
- .trim();
822
- const hits = [];
823
- if (/\bSCOPE EXPANSION\b/u.test(normalized))
824
- hits.push("SCOPE EXPANSION");
825
- if (/\bSELECTIVE EXPANSION\b/u.test(normalized))
826
- hits.push("SELECTIVE EXPANSION");
827
- if (/\bHOLD SCOPE\b/u.test(normalized))
828
- hits.push("HOLD SCOPE");
829
- if (/\bSCOPE REDUCTION\b/u.test(normalized))
830
- hits.push("SCOPE REDUCTION");
831
- return hits;
832
- }
833
- export function shortModeToCanonical(text) {
834
- if (!SCOPE_MODE_SHORT_TOKEN_REGEX.test(text))
835
- return null;
836
- const normalized = text
837
- .toLowerCase()
838
- .replace(/[_-]+/gu, " ")
839
- .replace(/\s+/gu, " ");
840
- if (/\bselective(?:\s+expansion)?\b/u.test(normalized))
841
- return "SELECTIVE EXPANSION";
842
- if (/\bhold(?:\s+scope)?\b/u.test(normalized))
843
- return "HOLD SCOPE";
844
- if (/\b(?:scope\s+reduction|reduction|reduce)\b/u.test(normalized))
845
- return "SCOPE REDUCTION";
846
- if (/\b(?:scope\s+expansion|expansion|expand)\b/u.test(normalized))
847
- return "SCOPE EXPANSION";
848
- return null;
849
- }
850
- export function canonicalModeFromCandidate(candidate) {
851
- const canonicalHits = canonicalModesInText(candidate);
852
- if (canonicalHits.length === 1)
853
- return canonicalHits[0];
854
- if (canonicalHits.length > 1)
855
- return null;
856
- return shortModeToCanonical(candidate);
857
- }
858
- export function extractCanonicalScopeMode(body) {
859
- // Strict: a Mode: / Selected mode: line that picks exactly ONE canonical mode
860
- // is the strongest signal. The template scaffolding contains all four mode
861
- // tokens inside an instructional `(one of ...)` placeholder; we ignore that
862
- // line so authors who never replace the scaffolding still fail validation.
863
- for (const match of body.matchAll(new RegExp(SCOPE_MODE_LINE_REGEX, "giu"))) {
864
- const raw = (match[1] ?? "").trim();
865
- const sanitized = raw.replace(/\(.*?\)/gu, "").trim();
866
- if (sanitized.length === 0)
867
- continue;
868
- const mode = canonicalModeFromCandidate(sanitized);
869
- if (mode)
870
- return mode;
871
- }
872
- // Fallback: any line outside an instructional `(one of ...)` placeholder
873
- // names exactly one mode. Block lines that list multiple modes (the
874
- // unfilled template) or are wrapped in an instructional parenthetical.
875
- for (const rawLine of body.split(/\r?\n/u)) {
876
- const line = rawLine.trim();
877
- if (line.length === 0)
878
- continue;
879
- if (/\(\s*one\s+of\b/iu.test(line))
880
- continue;
881
- const sanitized = line.replace(/\(.*?\)/gu, "");
882
- const mode = canonicalModeFromCandidate(sanitized);
883
- if (mode)
884
- return mode;
885
- }
886
- return null;
887
- }
888
- // Premise challenge is owned solely by brainstorm (`## Premise Check`);
889
- // scope only records `## Premise Drift` when scope-stage Q&A surfaces
890
- // new evidence that materially changes the brainstorm answer. The
891
- // drift section is optional and structural-only via the default
892
- // `validateSectionBody` path (no specialized validator required).
893
- export function validateScopeSummary(sectionBody) {
894
- const meaningfulLines = sectionBody
895
- .split(/\r?\n/)
896
- .map((line) => line.trim())
897
- .filter((line) => line.length > 0 && /[\p{L}\p{N}]/u.test(line));
898
- if (meaningfulLines.length < 2) {
899
- return {
900
- ok: false,
901
- details: "Scope Summary must list at least 2 substantive lines covering the selected mode and the next-stage handoff."
902
- };
903
- }
904
- if (!hasCanonicalScopeMode(sectionBody)) {
905
- return {
906
- ok: false,
907
- details: "Scope Summary must name the selected mode using a canonical token (SCOPE EXPANSION, SELECTIVE EXPANSION, HOLD SCOPE, SCOPE REDUCTION) or a short form on a `Mode:` line (hold, selective, expansion, reduction)."
908
- };
909
- }
910
- if (!NEXT_STAGE_HANDOFF_REGEX.test(sectionBody)) {
911
- return {
912
- ok: false,
913
- details: "Scope Summary must record the track-aware next-stage handoff (mention `design` for standard, `spec` for medium, or include a `Next-stage handoff:` line)."
914
- };
915
- }
916
- return {
917
- ok: true,
918
- details: "Scope Summary names the selected mode and the next-stage handoff."
919
- };
920
- }
921
- export const APPROACH_ROLE_VALUES = ["baseline", "challenger", "wild-card"];
922
- export const APPROACH_UPSIDE_VALUES = ["low", "modest", "high", "higher"];
923
- export const REQUIREMENT_PRIORITY_VALUES = ["P0", "P1", "P2", "P3", "DROPPED"];
924
- export function normalizeTableToken(value) {
925
- return value
926
- .replace(/[`*_]/gu, "")
927
- .trim()
928
- .toLowerCase()
929
- .replace(/\s+/gu, "-");
930
- }
931
- export function columnIndex(header, expected) {
932
- return header.findIndex((cell) => normalizeTableToken(cell) === expected);
933
- }
934
- export function validateApproachesTaxonomy(sectionBody) {
935
- const header = tableHeaderCells(sectionBody);
936
- const rows = getMarkdownTableRows(sectionBody);
937
- if (!header) {
938
- return {
939
- rowCount: 0,
940
- roleUpsideOk: false,
941
- challengerOk: false,
942
- details: "Approaches must be a markdown table with canonical Role and Upside columns."
943
- };
944
- }
945
- const roleIndex = columnIndex(header, "role");
946
- const upsideIndex = columnIndex(header, "upside");
947
- if (roleIndex < 0 || upsideIndex < 0) {
948
- const firstColumnTokens = rows.map((row) => normalizeTableToken(row[0] ?? ""));
949
- const appearsTransposed = firstColumnTokens.includes("role") || firstColumnTokens.includes("upside");
950
- return {
951
- rowCount: rows.length,
952
- roleUpsideOk: false,
953
- challengerOk: false,
954
- details: appearsTransposed
955
- ? "Approaches table appears transposed: `Role`/`Upside` are rows, but must be columns. Use `| Approach | Role | Upside | ... |` with one approach per row."
956
- : "Approaches table must include canonical `Role` and `Upside` columns (Role: baseline | challenger | wild-card; Upside: low | modest | high | higher)."
957
- };
958
- }
959
- let challengerRows = 0;
960
- let challengerHasHighUpside = false;
961
- for (const [index, row] of rows.entries()) {
962
- const role = normalizeTableToken(row[roleIndex] ?? "");
963
- const upside = normalizeTableToken(row[upsideIndex] ?? "");
964
- if (!APPROACH_ROLE_VALUES.includes(role)) {
965
- return {
966
- rowCount: rows.length,
967
- roleUpsideOk: false,
968
- challengerOk: false,
969
- details: `Approaches row ${index + 1} has invalid Role "${row[roleIndex] ?? ""}". Expected one of: ${APPROACH_ROLE_VALUES.join(", ")}.`
970
- };
971
- }
972
- if (!APPROACH_UPSIDE_VALUES.includes(upside)) {
973
- return {
974
- rowCount: rows.length,
975
- roleUpsideOk: false,
976
- challengerOk: false,
977
- details: `Approaches row ${index + 1} has invalid Upside "${row[upsideIndex] ?? ""}". Expected one of: ${APPROACH_UPSIDE_VALUES.join(", ")}.`
978
- };
979
- }
980
- if (role === "challenger") {
981
- challengerRows += 1;
982
- if (upside === "high" || upside === "higher") {
983
- challengerHasHighUpside = true;
984
- }
985
- }
986
- }
987
- const challengerOk = challengerRows === 1 && challengerHasHighUpside;
988
- return {
989
- rowCount: rows.length,
990
- roleUpsideOk: true,
991
- challengerOk,
992
- details: challengerOk
993
- ? "Approaches table uses canonical Role/Upside values and exactly one high/higher-upside challenger."
994
- : `Approaches table must include exactly one challenger row with Upside high or higher. Found ${challengerRows} challenger row(s).`
995
- };
996
- }
997
- export function validateCalibratedSelfReview(sectionBody) {
998
- const statusLineMatch = /^\s*-\s*Status:\s*(.*)$/imu.exec(sectionBody);
999
- const statusValue = statusLineMatch ? statusLineMatch[1].trim() : "";
1000
- const mentionsApproved = /\bApproved\b/iu.test(statusValue);
1001
- const mentionsIssuesFound = /\bIssues Found\b/iu.test(statusValue);
1002
- const statusPickedExactlyOne = statusLineMatch !== null && (mentionsApproved !== mentionsIssuesFound);
1003
- const hasPatchesHeader = /^\s*-\s*Patches applied:/imu.test(sectionBody);
1004
- const hasConcernsHeader = /^\s*-\s*Remaining concerns:/imu.test(sectionBody);
1005
- if (statusPickedExactlyOne && hasPatchesHeader && hasConcernsHeader) {
1006
- return {
1007
- ok: true,
1008
- details: "Self-Review Notes use the calibrated review prompt format."
1009
- };
1010
- }
1011
- const problems = [];
1012
- if (!statusLineMatch) {
1013
- problems.push("missing `- Status:` line");
1014
- }
1015
- else if (!mentionsApproved && !mentionsIssuesFound) {
1016
- problems.push("`- Status:` must include `Approved` or `Issues Found`");
1017
- }
1018
- else if (mentionsApproved && mentionsIssuesFound) {
1019
- problems.push("`- Status:` must pick exactly one of `Approved` or `Issues Found` (the placeholder `Approved | Issues Found` is not a decision)");
1020
- }
1021
- if (!hasPatchesHeader)
1022
- problems.push("missing `- Patches applied:` line");
1023
- if (!hasConcernsHeader)
1024
- problems.push("missing `- Remaining concerns:` line");
1025
- return {
1026
- ok: false,
1027
- details: "Self-Review Notes must use the calibrated review prompt format: `- Status: Approved` (or `Issues Found`), `- Patches applied:` (inline note or sub-bullets), and `- Remaining concerns:` (inline note or sub-bullets). Issues: " +
1028
- problems.join("; ") +
1029
- "."
1030
- };
1031
- }
1032
- export function validateRequirementsTaxonomy(sectionBody) {
1033
- const header = tableHeaderCells(sectionBody);
1034
- if (!header) {
1035
- return {
1036
- ok: false,
1037
- details: "Requirements must be a markdown table with a Priority column."
1038
- };
1039
- }
1040
- const priorityIndex = columnIndex(header, "priority");
1041
- if (priorityIndex < 0) {
1042
- return {
1043
- ok: false,
1044
- details: "Requirements table must include a canonical `Priority` column."
1045
- };
1046
- }
1047
- const rows = getMarkdownTableRows(sectionBody);
1048
- if (rows.length === 0) {
1049
- return {
1050
- ok: false,
1051
- details: "Requirements table must include at least one requirement row."
1052
- };
1053
- }
1054
- for (const [index, row] of rows.entries()) {
1055
- const rawPriority = (row[priorityIndex] ?? "").replace(/[`*_]/gu, "").trim().toUpperCase();
1056
- if (!REQUIREMENT_PRIORITY_VALUES.includes(rawPriority)) {
1057
- return {
1058
- ok: false,
1059
- details: `Requirements row ${index + 1} has invalid Priority "${row[priorityIndex] ?? ""}". Expected one of: ${REQUIREMENT_PRIORITY_VALUES.join(", ")}.`
1060
- };
1061
- }
1062
- }
1063
- return {
1064
- ok: true,
1065
- details: "Requirements table uses canonical Priority values."
1066
- };
1067
- }
1068
- export const INTERACTION_EDGE_CASE_REQUIREMENTS = [
1069
- { label: "double-click", pattern: /\bdouble[\s-]?click\b/iu },
1070
- {
1071
- label: "nav-away-mid-request",
1072
- pattern: /\b(?:nav(?:igate)?[\s-]?away(?:[\s-]?mid[\s-]?request)?|leave\s+(?:page|view|screen).*(?:request|save|submit)|close\s+tab.*(?:request|save|submit))\b/iu
1073
- },
1074
- {
1075
- label: "10K-result dataset",
1076
- pattern: /\b(?:10k(?:[\s-]?result)?|10,?000|large[\s-]?result(?:[\s-]?dataset)?)\b/iu
1077
- },
1078
- {
1079
- label: "background-job abandonment",
1080
- pattern: /\b(?:background[\s-]?job.*abandon(?:ed|ment)?|abandon(?:ed|ment)?.*background[\s-]?job)\b/iu
1081
- },
1082
- { label: "zombie connection", pattern: /\bzombie[\s-]?connection\b/iu }
1083
- ];
1084
- const INTERACTION_EDGE_CASE_NA_PATTERN = /^\s*n\s*\/\s*a\b/iu;
1085
- const INTERACTION_EDGE_CASE_NA_WITH_REASON_PATTERN = /^\s*n\s*\/\s*a\s*[—–\-:]\s*\S/iu;
1086
- const INTERACTION_EDGE_CASE_NETWORK_DEPENDENT_LABELS = new Set([
1087
- "nav-away-mid-request",
1088
- "10K-result dataset",
1089
- "background-job abandonment",
1090
- "zombie connection"
1091
- ]);
1092
- function shouldRelaxNetworkDependentEdgeCases(context) {
1093
- if (!context.liteTier)
1094
- return false;
1095
- const sections = context.sections ?? null;
1096
- if (!sections)
1097
- return true;
1098
- const diagramBody = sectionBodyByName(sections, "Architecture Diagram");
1099
- const failureModeBody = sectionBodyByName(sections, "Failure Mode Table");
1100
- const failureModeRowCount = failureModeBody !== null ? getMarkdownTableRows(failureModeBody).length : 0;
1101
- if (failureModeRowCount > 0)
1102
- return false;
1103
- if (diagramBody && DIAGRAM_EXTERNAL_DEPENDENCY_PATTERN.test(diagramBody))
1104
- return false;
1105
- return true;
1106
- }
1107
- export function validateInteractionEdgeCaseMatrix(sectionBody, context = {}) {
1108
- const rows = getMarkdownTableRows(sectionBody);
1109
- const relaxNetworkRows = shouldRelaxNetworkDependentEdgeCases(context);
1110
- if (rows.length === 0) {
1111
- if (relaxNetworkRows) {
1112
- return {
1113
- ok: true,
1114
- details: "Data Flow Interaction Edge Case matrix is advisory for lite-tier no-network designs (no Failure Mode Table rows and no external-dependency nodes detected)."
1115
- };
1116
- }
1117
- return {
1118
- ok: false,
1119
- details: "Data Flow must include an Interaction Edge Case matrix table with required rows."
1120
- };
1121
- }
1122
- const seen = new Map();
1123
- for (const [, row] of rows.entries()) {
1124
- const labelCell = (row[0] ?? "").trim();
1125
- if (!labelCell)
1126
- continue;
1127
- const requirement = INTERACTION_EDGE_CASE_REQUIREMENTS.find((candidate) => candidate.pattern.test(labelCell));
1128
- if (!requirement)
1129
- continue;
1130
- if (row.length < 4) {
1131
- return {
1132
- ok: false,
1133
- details: `Interaction Edge Case row "${requirement.label}" must include 4 columns: Edge case | Handled? | Design response | Deferred item.`
1134
- };
1135
- }
1136
- const handledRaw = (row[1] ?? "").trim();
1137
- const handled = parseBinaryFlag(handledRaw);
1138
- const response = (row[2] ?? "").trim();
1139
- const deferred = (row[3] ?? "").trim();
1140
- const isNA = INTERACTION_EDGE_CASE_NA_PATTERN.test(handledRaw);
1141
- if (handled === "unknown" && !isNA) {
1142
- return {
1143
- ok: false,
1144
- details: `Interaction Edge Case row "${requirement.label}" must mark Handled? as yes/no, or write \`N/A — <reason>\` (em-dash + free-text reason) when the case does not apply.`
1145
- };
1146
- }
1147
- if (isNA) {
1148
- // `N/A — <reason>` short-circuits both the "must mark yes/no"
1149
- // rule and the "must reference a deferred item id" rule. The
1150
- // reason satisfies justification.
1151
- const hasReason = INTERACTION_EDGE_CASE_NA_WITH_REASON_PATTERN.test(handledRaw) || response.length > 0;
1152
- if (!hasReason) {
1153
- return {
1154
- ok: false,
1155
- details: `Interaction Edge Case row "${requirement.label}" marked N/A but missing reason. Use \`N/A — <reason>\` (em-dash + free-text reason) in the Handled? cell or fill the Design response cell.`
1156
- };
1157
- }
1158
- seen.set(requirement.label, true);
1159
- continue;
1160
- }
1161
- if (!response) {
1162
- return {
1163
- ok: false,
1164
- details: `Interaction Edge Case row "${requirement.label}" must describe the design response.`
1165
- };
1166
- }
1167
- if (handled === "no" && (!deferred || /\bnone\b/iu.test(deferred))) {
1168
- return {
1169
- ok: false,
1170
- details: `Interaction Edge Case row "${requirement.label}" is unhandled and must reference a deferred item id (for example D-12) or mark Handled? as \`N/A — <reason>\`.`
1171
- };
1172
- }
1173
- seen.set(requirement.label, true);
1174
- }
1175
- const missing = INTERACTION_EDGE_CASE_REQUIREMENTS
1176
- .map((requirement) => requirement.label)
1177
- .filter((label) => !seen.has(label));
1178
- const stillMissing = relaxNetworkRows
1179
- ? missing.filter((label) => !INTERACTION_EDGE_CASE_NETWORK_DEPENDENT_LABELS.has(label))
1180
- : missing;
1181
- const advisoryMissing = relaxNetworkRows
1182
- ? missing.filter((label) => INTERACTION_EDGE_CASE_NETWORK_DEPENDENT_LABELS.has(label))
1183
- : [];
1184
- if (stillMissing.length > 0) {
1185
- const advisoryNote = advisoryMissing.length > 0
1186
- ? ` (${advisoryMissing.length} network-dependent row(s) demoted to advisory by lite-tier no-network detection: ${advisoryMissing.join(", ")})`
1187
- : "";
1188
- return {
1189
- ok: false,
1190
- details: `Interaction Edge Case matrix is missing required row(s): ${stillMissing.join(", ")}${advisoryNote}.`
1191
- };
1192
- }
1193
- const advisoryNote = advisoryMissing.length > 0
1194
- ? ` (${advisoryMissing.length} network-dependent row(s) advisory under lite-tier no-network: ${advisoryMissing.join(", ")})`
1195
- : "";
1196
- return {
1197
- ok: true,
1198
- details: `Interaction Edge Case matrix contains all required rows with handled/deferred status${advisoryNote}.`
1199
- };
1200
- }
1201
- export const PRE_SCOPE_AUDIT_SIGNALS = [
1202
- { label: "git log -30 --oneline", pattern: /\bgit\s+log\b[^\n]*-30[^\n]*\boneline\b/iu },
1203
- { label: "git diff --stat", pattern: /\bgit\s+diff\b[^\n]*--stat\b/iu },
1204
- { label: "git stash list", pattern: /\bgit\s+stash\s+list\b/iu },
1205
- {
1206
- label: "debt marker scan (TODO|FIXME|XXX|HACK)",
1207
- pattern: /\b(?:rg|ripgrep)\b[^\n]*(?:TODO|FIXME|XXX|HACK)|\bTODO\b|\bFIXME\b|\bXXX\b|\bHACK\b/iu
1208
- }
1209
- ];
1210
- export function validatePreScopeSystemAudit(sectionBody) {
1211
- const missing = PRE_SCOPE_AUDIT_SIGNALS
1212
- .filter((signal) => !signal.pattern.test(sectionBody))
1213
- .map((signal) => signal.label);
1214
- if (missing.length > 0) {
1215
- return {
1216
- ok: false,
1217
- details: `Pre-Scope System Audit is missing required signal(s): ${missing.join(", ")}.`
1218
- };
1219
- }
1220
- return {
1221
- ok: true,
1222
- details: "Pre-Scope System Audit captures git log/diff/stash/debt-marker checks."
1223
- };
1224
- }
1225
- export const DIAGRAM_ARROW_PATTERN = /(?:<--?>|<?==?>|--?>|->>|=>|-\.->|→|⟶|↦|={2,}>|-{3,}>|\.{3,}>|-(?:\s-){1,}\s?->)/u;
1226
- export const DIAGRAM_FAILURE_EDGE_PATTERN = /\b(fail(?:ed|ure)?|error|timeout|fallback|degrad(?:e|ed|ation)|retry|backoff|circuit|unavailable|recover(?:y)?|rescue|mitigat(?:e|ion)|rollback|exception|abort|dead[\s-]?letter|dlq)\b/iu;
1227
- export const DIAGRAM_GENERIC_NODE_PATTERN = /\b(service|component|module|system)\s*(?:[A-Z0-9])?\b/iu;
1228
- /**
1229
- * external-dependency keywords that trigger the
1230
- * failure-edge requirement. The architecture diagram is allowed to
1231
- * omit failure edges only when ALL of:
1232
- * - Failure Mode Table has zero rows.
1233
- * - The diagram body mentions no external-dependency keyword.
1234
- *
1235
- * Static landing pages (3 HTML/CSS/JS files, no network) match this:
1236
- * no failure modes to map, no external systems to fail. The previous
1237
- * blanket "must include at least one failure-edge" rule produced
1238
- * ceremony-only failures that the agent worked around with fake
1239
- * `(timeout)` annotations, defeating the spirit of the rule.
1240
- */
1241
- export const DIAGRAM_EXTERNAL_DEPENDENCY_PATTERN = /\b(http|https|api|rest|grpc|graphql|websocket|socket|tcp|udp|rpc|fetch|request|database|db|sql|postgres|mysql|sqlite|mongo|redis|cache|queue|kafka|rabbitmq|sqs|sns|s3|cdn|external|upstream|downstream|third[\s-]?party|webhook|cloud|service[\s-]?bus|event[\s-]?bus|broker|stream|topic)\b/iu;
1242
- export const TEST_COMMAND_MARKER_PATTERN = /\b(?:npm|pnpm|yarn|bun|vitest|jest|pytest|go test|cargo test|mvn test|gradle test|dotnet test)\b/iu;
1243
- export const RED_FAILURE_MARKER_PATTERN = /\b(?:fail|failed|failing|assertionerror|cannot find|exception|error|exit code\s*[:=]?\s*[1-9])\b/iu;
1244
- export const GREEN_SUCCESS_MARKER_PATTERN = /\b(?:pass|passed|green|ok|0 failed|exit code\s*[:=]?\s*0)\b/iu;
1245
- export function diagramEdgeLines(sectionBody) {
1246
- return sectionBody
1247
- .split(/\r?\n/)
1248
- .map((line) => line.trim())
1249
- .filter((line) => line.length > 0)
1250
- .filter((line) => !line.startsWith("```"))
1251
- .filter((line) => !line.startsWith("%%"))
1252
- .filter((line) => DIAGRAM_ARROW_PATTERN.test(line));
1253
- }
1254
- export function hasFailureEdgeInDiagram(sectionBody) {
1255
- const lines = diagramEdgeLines(sectionBody);
1256
- for (const line of lines) {
1257
- if (DIAGRAM_ARROW_PATTERN.test(line) && DIAGRAM_FAILURE_EDGE_PATTERN.test(line)) {
1258
- return true;
1259
- }
1260
- }
1261
- return false;
1262
- }
1263
- export function hasLabeledDiagramArrow(lines) {
1264
- return lines.some((line) => /\|[^|]+\|/u.test(line) || /:\s*[A-Za-z]/u.test(line));
1265
- }
1266
- /**
1267
- * accepted async edge patterns. Returns true when
1268
- * a line carries any of:
1269
- *
1270
- * - `-.->`, `-->>`, `~~>` (mermaid dotted/messaging arrows)
1271
- * - `- - ->` (loose dotted ASCII arrow with optional spaces)
1272
- * - `.....>` (3-or-more dots followed by `>`)
1273
- * - `\basync\b` text token (label-based)
1274
- * - `[async]` bracketed label, `async:` prefix, `async:` cell content
1275
- *
1276
- * The error message printed when this fails (see
1277
- * `validateArchitectureDiagram`) lists every accepted pattern
1278
- * verbatim so the agent does not have to guess.
1279
- */
1280
- export function hasAsyncDiagramEdge(lines) {
1281
- return lines.some((line) => {
1282
- if (/-\.->|-->>|~~>/u.test(line))
1283
- return true;
1284
- if (/-(?:\s-){1,}\s?->/u.test(line))
1285
- return true;
1286
- if (/\.{3,}\s*>/u.test(line))
1287
- return true;
1288
- if (/\basync\b/iu.test(line))
1289
- return true;
1290
- if (/\[\s*async\s*\]/iu.test(line))
1291
- return true;
1292
- if (/(?:^|[\s|:])async\s*:/iu.test(line))
1293
- return true;
1294
- return false;
1295
- });
1296
- }
1297
- /**
1298
- * accepted sync edge patterns. Returns true when a
1299
- * line carries any of:
1300
- *
1301
- * - `\bsync\b` text token (label-based)
1302
- * - `[sync]` bracketed label, `sync:` prefix, `sync:` cell content
1303
- * - Solid `-->`, `->`, `=>`, `→`, `⟶`, `↦` arrow that is NOT a known
1304
- * dotted/async variant (`-.->`, `-->>`, `~~>`)
1305
- * - `===>` (3+ `=` then `>`) and `--->` (3+ `-` then `>`) heavy solid
1306
- * arrows
1307
- */
1308
- export function hasSyncDiagramEdge(lines) {
1309
- return lines.some((line) => {
1310
- if (/\bsync\b/iu.test(line) && !/\basync\b/iu.test(line))
1311
- return true;
1312
- if (/\[\s*sync\s*\]/iu.test(line))
1313
- return true;
1314
- if (/(?:^|[\s|:])sync\s*:/iu.test(line))
1315
- return true;
1316
- if (/={2,}>/u.test(line))
1317
- return true;
1318
- if (/-{3,}>/u.test(line))
1319
- return true;
1320
- if (!/(-->|->|=>|→|⟶|↦)/u.test(line))
1321
- return false;
1322
- if (/-\.->|-->>|~~>/u.test(line))
1323
- return false;
1324
- if (/-(?:\s-){1,}\s?->/u.test(line))
1325
- return false;
1326
- return true;
1327
- });
1328
- }
1329
- /**
1330
- * exact accepted-pattern list shown in the error
1331
- * message when sync/async distinction fails. Keep in sync with
1332
- * `hasAsyncDiagramEdge` / `hasSyncDiagramEdge` above.
1333
- */
1334
- export const DIAGRAM_SYNC_ASYNC_ACCEPTED_PATTERNS = [
1335
- "Solid arrows: `-->`, `->`, `===>`, `--->`, `=>`, `→`, `⟶`, `↦`",
1336
- "Dotted/async arrows: `-.->`, `-->>`, `~~>`, `- - ->`, `.....>`",
1337
- "Text labels on the same line: `sync` / `async`",
1338
- "Bracket labels: `[sync]` / `[async]`",
1339
- "Cell-prefix labels: `sync:` / `async:` (e.g. `A -->|sync: persist| B`)"
1340
- ];
1341
- /**
1342
- * Architecture Diagram structural check.
1343
- *
1344
- * Promoted out of `validateSectionBody` so it can take a `sections`
1345
- * map and conditionally enforce the failure-edge rule based on
1346
- * cross-section context (Failure Mode Table presence + diagram body
1347
- * mentioning external-dependency keywords).
1348
- */
1349
- export function validateArchitectureDiagram(sectionBody, context = {}) {
1350
- const edgeLines = diagramEdgeLines(sectionBody);
1351
- if (edgeLines.length === 0) {
1352
- return {
1353
- ok: false,
1354
- details: "Architecture Diagram must include at least one directional edge line (for example `A -->|action| B`)."
1355
- };
1356
- }
1357
- if (!hasLabeledDiagramArrow(edgeLines)) {
1358
- return {
1359
- ok: false,
1360
- details: "Architecture Diagram must label each edge with an action/message (for example `A -->|sync: persist| B`)."
1361
- };
1362
- }
1363
- const genericLine = edgeLines.find((line) => DIAGRAM_GENERIC_NODE_PATTERN.test(line));
1364
- if (genericLine) {
1365
- return {
1366
- ok: false,
1367
- details: `Architecture Diagram uses a generic node label in edge "${genericLine}". Use concrete component names instead of placeholders like Service/Component.`
1368
- };
1369
- }
1370
- if (!hasAsyncDiagramEdge(edgeLines) || !hasSyncDiagramEdge(edgeLines)) {
1371
- const acceptedList = DIAGRAM_SYNC_ASYNC_ACCEPTED_PATTERNS.map((line) => ` - ${line}`).join("\n");
1372
- return {
1373
- ok: false,
1374
- details: `Architecture Diagram must distinguish sync vs async edges. Accepted patterns:\n${acceptedList}\nExample line that satisfies both: \`Browser -->|sync: render| App\` plus \`App -.->|async: log| Telemetry\`.`
1375
- };
1376
- }
1377
- if (!shouldEnforceFailureEdge(sectionBody, context)) {
1378
- return {
1379
- ok: true,
1380
- details: "Architecture Diagram includes labeled directional edges with sync/async distinction; failure-edge enforcement skipped (no failure-mode rows and no external-dependency nodes detected)."
1381
- };
1382
- }
1383
- if (!hasFailureEdgeInDiagram(sectionBody)) {
1384
- return {
1385
- ok: false,
1386
- details: "Architecture Diagram must include at least one failure-edge arrow with a failure keyword (for example: timeout, error, fallback, degraded, retry). Mark a failure path in the diagram (e.g. `App -->|timeout| FallbackCache`)."
1387
- };
1388
- }
1389
- return {
1390
- ok: true,
1391
- details: "Architecture Diagram contains labeled edges, sync/async distinction, and a failure-edge."
1392
- };
1393
- }
1394
- /**
1395
- * decide whether the failure-edge enforcement
1396
- * should fire for the given Architecture Diagram body. Returns
1397
- * `false` (skip the rule) when BOTH:
1398
- * - The artifact's `## Failure Mode Table` (if present) has zero
1399
- * data rows OR is absent entirely.
1400
- * - The architecture diagram body mentions NO known external-
1401
- * dependency keyword (network, db, queue, …).
1402
- *
1403
- * Static landing pages (no network, no failure modes) hit this
1404
- * path. Designs with even one Failure Mode row OR one external
1405
- * dependency keyword in the diagram fall through to the legacy
1406
- * blanket failure-edge requirement.
1407
- */
1408
- function shouldEnforceFailureEdge(diagramBody, context) {
1409
- const sections = context.sections ?? null;
1410
- const failureModeBody = sections ? sectionBodyByName(sections, "Failure Mode Table") : null;
1411
- const failureModeRowCount = failureModeBody !== null ? getMarkdownTableRows(failureModeBody).length : 0;
1412
- if (failureModeRowCount > 0)
1413
- return true;
1414
- if (DIAGRAM_EXTERNAL_DEPENDENCY_PATTERN.test(diagramBody))
1415
- return true;
1416
- return false;
1417
- }
1418
- /**
1419
- * Sync helper that scans for `Evidence:` lines in a section body and
1420
- * returns the trimmed value of each. Used by the lint pipeline to
1421
- * pre-resolve pointers (filesystem path-existence or delegation ledger
1422
- * spanId match) before invoking the validators.
1423
- *
1424
- * Recognised forms:
1425
- * Evidence: <path>
1426
- * Evidence: spanId:<id>
1427
- * - Evidence: <path>
1428
- */
1429
- export function extractEvidencePointers(sectionBody) {
1430
- const pointers = [];
1431
- const pattern = /^\s*-?\s*evidence\s*:\s*(.+?)\s*$/imu;
1432
- for (const line of sectionBody.split(/\r?\n/u)) {
1433
- const match = pattern.exec(line);
1434
- if (match && match[1] !== undefined) {
1435
- const value = match[1].trim();
1436
- if (value.length > 0)
1437
- pointers.push(value);
1438
- }
1439
- }
1440
- return pointers;
1441
- }
1442
- export function validateTddRedEvidence(sectionBody, opts = {}) {
1443
- if (opts.phaseEventsSatisfied) {
1444
- return {
1445
- ok: true,
1446
- details: "RED Evidence auto-satisfied: delegation-events.jsonl carries a phase=red row with non-empty evidenceRefs for the active run."
1447
- };
1448
- }
1449
- if (opts.pointerSatisfied) {
1450
- return {
1451
- ok: true,
1452
- details: "RED Evidence satisfied via `Evidence: <path|spanId:...>` pointer (resolved to an existing artifact or delegation span)."
1453
- };
1454
- }
1455
- const meaningful = meaningfulLineCount(sectionBody);
1456
- if (meaningful < 2) {
1457
- return {
1458
- ok: false,
1459
- details: "RED Evidence must include at least 2 meaningful lines (command plus failing output context)."
1460
- };
1461
- }
1462
- if (!TEST_COMMAND_MARKER_PATTERN.test(sectionBody)) {
1463
- return {
1464
- ok: false,
1465
- details: "RED Evidence must include the test command that produced the failure."
1466
- };
1467
- }
1468
- if (!RED_FAILURE_MARKER_PATTERN.test(sectionBody)) {
1469
- return {
1470
- ok: false,
1471
- details: "RED Evidence must include explicit failing output markers (FAIL/FAILED/AssertionError/exit code != 0)."
1472
- };
1473
- }
1474
- return {
1475
- ok: true,
1476
- details: "RED Evidence includes command + failing output markers."
1477
- };
1478
- }
1479
- export function validateTddGreenEvidence(sectionBody, opts = {}) {
1480
- if (opts.phaseEventsSatisfied) {
1481
- return {
1482
- ok: true,
1483
- details: "GREEN Evidence auto-satisfied: delegation-events.jsonl carries a phase=green row with non-empty evidenceRefs for the active run."
1484
- };
1485
- }
1486
- if (opts.pointerSatisfied) {
1487
- return {
1488
- ok: true,
1489
- details: "GREEN Evidence satisfied via `Evidence: <path|spanId:...>` pointer (resolved to an existing artifact or delegation span)."
1490
- };
1491
- }
1492
- const meaningful = meaningfulLineCount(sectionBody);
1493
- if (meaningful < 2) {
1494
- return {
1495
- ok: false,
1496
- details: "GREEN Evidence must include at least 2 meaningful lines (command and passing result)."
1497
- };
1498
- }
1499
- if (!TEST_COMMAND_MARKER_PATTERN.test(sectionBody)) {
1500
- return {
1501
- ok: false,
1502
- details: "GREEN Evidence must include the full-suite test command."
1503
- };
1504
- }
1505
- if (!GREEN_SUCCESS_MARKER_PATTERN.test(sectionBody)) {
1506
- return {
1507
- ok: false,
1508
- details: "GREEN Evidence must include explicit passing markers (PASS/PASSED/OK/exit code 0)."
1509
- };
1510
- }
1511
- return {
1512
- ok: true,
1513
- details: "GREEN Evidence includes command + passing output markers."
1514
- };
1515
- }
1516
- export function validateVerificationLadder(sectionBody) {
1517
- const hasTextLine = /highest tier reached/iu.test(sectionBody);
1518
- const hasCanonicalTable = hasVerificationLadderTableRow(sectionBody);
1519
- if (!hasTextLine && !hasCanonicalTable) {
1520
- return {
1521
- ok: false,
1522
- details: "Verification Ladder must include either a 'Highest tier reached' line or a canonical table row (Slice | Tier reached | Evidence) with non-empty tier and evidence."
1523
- };
1524
- }
1525
- if (!/\b(static|command|behavioral|human)\b/iu.test(sectionBody)) {
1526
- return {
1527
- ok: false,
1528
- details: "Verification Ladder must name a tier (static | command | behavioral | human)."
1529
- };
1530
- }
1531
- if (!/\b(evidence|command|sha|commit)\b/iu.test(sectionBody)) {
1532
- return {
1533
- ok: false,
1534
- details: "Verification Ladder must include evidence details (command output or commit SHA)."
1535
- };
1536
- }
1537
- return {
1538
- ok: true,
1539
- details: "Verification Ladder includes tier + evidence fields."
1540
- };
1541
- }
1542
- export function hasVerificationLadderTableRow(sectionBody) {
1543
- const lines = sectionBody.split(/\r?\n/u);
1544
- let sawHeader = false;
1545
- let sawSeparator = false;
1546
- for (const line of lines) {
1547
- const trimmed = line.trim();
1548
- if (!trimmed.startsWith("|")) {
1549
- sawHeader = false;
1550
- sawSeparator = false;
1551
- continue;
1552
- }
1553
- const cells = trimmed
1554
- .replace(/^\|/u, "")
1555
- .replace(/\|$/u, "")
1556
- .split("|")
1557
- .map((cell) => cell.trim());
1558
- if (!sawHeader) {
1559
- const lowered = cells.map((cell) => cell.toLowerCase());
1560
- const hasTierColumn = lowered.some((cell) => /tier(?:\s+reached)?/u.test(cell));
1561
- const hasEvidenceColumn = lowered.some((cell) => cell.includes("evidence"));
1562
- if (hasTierColumn && hasEvidenceColumn) {
1563
- sawHeader = true;
1564
- continue;
1565
- }
1566
- continue;
1567
- }
1568
- if (!sawSeparator) {
1569
- if (cells.every((cell) => /^[:\-\s]+$/u.test(cell))) {
1570
- sawSeparator = true;
1571
- continue;
1572
- }
1573
- sawHeader = false;
1574
- continue;
1575
- }
1576
- if (cells.length >= 2 && cells.some((cell) => /\b(static|command|behavioral|human)\b/iu.test(cell))) {
1577
- const evidenceCellHasContent = cells.some((cell) => cell.length > 0 && !/^\s*$/u.test(cell) && !/^[:\-\s]+$/u.test(cell));
1578
- if (evidenceCellHasContent) {
1579
- return true;
1580
- }
1581
- }
1582
- }
1583
- return false;
1584
- }
1585
- /** Multiline block used by linter + learnings harvest stderr (identical text). */
1586
- export function formatLearningsErrorsBullets(errors) {
1587
- if (errors.length === 0) {
1588
- return "Errors:\n - Learnings section could not be parsed.";
1589
- }
1590
- return `Errors:\n${errors.map((error) => ` - ${error}`).join("\n")}`;
1591
- }
1592
- export function learningsParseFailureHumanSummary(artifactRelPath, errors) {
1593
- return `learnings harvest failed for \`${artifactRelPath}\`.\n${formatLearningsErrorsBullets(errors)}`;
1594
- }
1595
- export const LEARNING_TYPE_SET = new Set(["rule", "pattern", "lesson", "compound"]);
1596
- export const LEARNING_CONFIDENCE_SET = new Set(["high", "medium", "low"]);
1597
- export const LEARNING_SEVERITY_SET = new Set(["critical", "important", "suggestion"]);
1598
- export const LEARNING_SOURCE_SET = new Set([
1599
- "stage",
1600
- "retro",
1601
- "compound",
1602
- "idea",
1603
- "manual"
1604
- ]);
1605
- export const FLOW_STAGE_SET = new Set(FLOW_STAGES);
1606
- export const LEARNING_ALLOWED_KEYS = new Set([
1607
- "type",
1608
- "trigger",
1609
- "action",
1610
- "confidence",
1611
- "severity",
1612
- "stage",
1613
- "origin_stage",
1614
- "frequency",
1615
- "created",
1616
- "first_seen_ts",
1617
- "last_seen_ts",
1618
- "project",
1619
- "source"
1620
- ]);
1621
- export function isIsoUtcTimestamp(value) {
1622
- return /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/u.test(value);
1623
- }
1624
- export function isNullableString(value) {
1625
- return value === null || typeof value === "string";
1626
- }
1627
- export function isNullableStage(value) {
1628
- return value === null || (typeof value === "string" && FLOW_STAGE_SET.has(value));
1629
- }
1630
- export function parseLearningSeedEntry(raw, index) {
1631
- if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
1632
- return { ok: false, error: `Learnings bullet #${index} must be a JSON object.` };
1633
- }
1634
- const obj = raw;
1635
- for (const key of Object.keys(obj)) {
1636
- if (!LEARNING_ALLOWED_KEYS.has(key)) {
1637
- return {
1638
- ok: false,
1639
- error: `Learnings bullet #${index} includes unknown key "${key}" (allowed keys mirror knowledge JSONL fields).`
1640
- };
1641
- }
1642
- }
1643
- const type = typeof obj.type === "string" ? obj.type.toLowerCase() : "";
1644
- if (!LEARNING_TYPE_SET.has(type)) {
1645
- return {
1646
- ok: false,
1647
- error: `Learnings bullet #${index} must set type to one of: rule, pattern, lesson, compound.`
1648
- };
1649
- }
1650
- const trigger = typeof obj.trigger === "string" ? obj.trigger.trim() : "";
1651
- if (trigger.length === 0) {
1652
- return {
1653
- ok: false,
1654
- error: `Learnings bullet #${index} must include non-empty "trigger".`
1655
- };
1656
- }
1657
- const action = typeof obj.action === "string" ? obj.action.trim() : "";
1658
- if (action.length === 0) {
1659
- return {
1660
- ok: false,
1661
- error: `Learnings bullet #${index} must include non-empty "action".`
1662
- };
1663
- }
1664
- const confidence = typeof obj.confidence === "string" ? obj.confidence.toLowerCase() : "";
1665
- if (!LEARNING_CONFIDENCE_SET.has(confidence)) {
1666
- return {
1667
- ok: false,
1668
- error: `Learnings bullet #${index} must set confidence to high|medium|low.`
1669
- };
1670
- }
1671
- const severity = typeof obj.severity === "string" ? obj.severity.toLowerCase() : undefined;
1672
- if (severity !== undefined && !LEARNING_SEVERITY_SET.has(severity)) {
1673
- return {
1674
- ok: false,
1675
- error: `Learnings bullet #${index} field "severity" must be critical|important|suggestion.`
1676
- };
1677
- }
1678
- if (obj.stage !== undefined && !isNullableStage(obj.stage)) {
1679
- return {
1680
- ok: false,
1681
- error: `Learnings bullet #${index} field "stage" must be one of ${FLOW_STAGES.join(", ")} or null.`
1682
- };
1683
- }
1684
- if (obj.origin_stage !== undefined && !isNullableStage(obj.origin_stage)) {
1685
- return {
1686
- ok: false,
1687
- error: `Learnings bullet #${index} field "origin_stage" must be one of ${FLOW_STAGES.join(", ")} or null.`
1688
- };
1689
- }
1690
- if (obj.project !== undefined && !isNullableString(obj.project)) {
1691
- return { ok: false, error: `Learnings bullet #${index} field "project" must be string or null.` };
1692
- }
1693
- if (obj.source !== undefined &&
1694
- obj.source !== null &&
1695
- (typeof obj.source !== "string" || !LEARNING_SOURCE_SET.has(obj.source))) {
1696
- return {
1697
- ok: false,
1698
- error: `Learnings bullet #${index} field "source" must be stage|retro|compound|idea|manual or null.`
1699
- };
1700
- }
1701
- if (obj.frequency !== undefined &&
1702
- (typeof obj.frequency !== "number" || !Number.isInteger(obj.frequency) || obj.frequency < 1)) {
1703
- return { ok: false, error: `Learnings bullet #${index} field "frequency" must be an integer >= 1.` };
1704
- }
1705
- for (const timestampField of ["created", "first_seen_ts", "last_seen_ts"]) {
1706
- const value = obj[timestampField];
1707
- if (value === undefined)
1708
- continue;
1709
- if (typeof value !== "string" || !isIsoUtcTimestamp(value)) {
1710
- return {
1711
- ok: false,
1712
- error: `Learnings bullet #${index} field "${timestampField}" must be ISO UTC (YYYY-MM-DDTHH:MM:SSZ).`
1713
- };
1714
- }
1715
- }
1716
- return {
1717
- ok: true,
1718
- entry: {
1719
- ...obj,
1720
- type: type,
1721
- trigger,
1722
- action,
1723
- confidence: confidence,
1724
- ...(severity ? { severity: severity } : {})
1725
- }
1726
- };
1727
- }
1728
- export function parseLearningsSection(sectionBody) {
1729
- const lines = sectionBody.split(/\r?\n/).map((line) => line.trim());
1730
- const nonEmpty = lines.filter((line) => line.length > 0);
1731
- const bullets = nonEmpty.filter((line) => /^-\s+\S+/u.test(line));
1732
- if (bullets.length === 0) {
1733
- return {
1734
- ok: false,
1735
- none: false,
1736
- entries: [],
1737
- errors: ["Learnings section must contain bullet entries."],
1738
- details: "Learnings section must contain bullet entries."
1739
- };
1740
- }
1741
- const nonBulletContent = nonEmpty.filter((line) => !/^-\s+\S+/u.test(line));
1742
- if (nonBulletContent.length > 0) {
1743
- return {
1744
- ok: false,
1745
- none: false,
1746
- entries: [],
1747
- errors: ["Learnings section must only contain bullet lines (one bullet per learning)."],
1748
- details: "Learnings section must only contain bullet lines (one bullet per learning)."
1749
- };
1750
- }
1751
- if (bullets.length === 1) {
1752
- const payload = bullets[0].replace(/^-\s+/u, "").trim();
1753
- if (/^none this stage\.?$/iu.test(payload)) {
1754
- return {
1755
- ok: true,
1756
- none: true,
1757
- entries: [],
1758
- errors: [],
1759
- details: "Learnings section explicitly marked as none."
1760
- };
1761
- }
1762
- }
1763
- const entries = [];
1764
- const errors = [];
1765
- for (let i = 0; i < bullets.length; i += 1) {
1766
- const payload = bullets[i].replace(/^-\s+/u, "").trim();
1767
- let parsed;
1768
- try {
1769
- parsed = JSON.parse(payload);
1770
- }
1771
- catch (err) {
1772
- errors.push(`Learnings bullet #${i + 1} must be valid JSON object or "None this stage.": ${err instanceof Error ? err.message : String(err)}`);
1773
- continue;
1774
- }
1775
- const parsedEntry = parseLearningSeedEntry(parsed, i + 1);
1776
- if (!parsedEntry.ok || !parsedEntry.entry) {
1777
- errors.push(parsedEntry.error ?? `Learnings bullet #${i + 1} is invalid.`);
1778
- continue;
1779
- }
1780
- entries.push(parsedEntry.entry);
1781
- }
1782
- if (errors.length > 0) {
1783
- return {
1784
- ok: false,
1785
- none: false,
1786
- entries: [],
1787
- errors,
1788
- details: errors.join(" | ")
1789
- };
1790
- }
1791
- return {
1792
- ok: true,
1793
- none: false,
1794
- entries,
1795
- errors: [],
1796
- details: `Parsed ${entries.length} learning bullet(s) as knowledge-compatible JSON entries.`
1797
- };
1798
- }
1799
- /**
1800
- * file-path / reference detector for the
1801
- * `investigation_path_first_missing` advisory rule.
1802
- *
1803
- * The detector is intentionally permissive: it only needs to recognize
1804
- * "the author wrote down a path or ref" — the linter does NOT validate
1805
- * the path resolves on disk. Patterns matched (any one is enough):
1806
- * - TS/JS/MD/JSON/YAML path with extension
1807
- * (`src/foo/bar.ts`, `tests/spec.test.ts`, `docs/quality-gates.md`).
1808
- * - Slash-bearing path under a known repo root prefix
1809
- * (`src/...`, `tests/...`, `docs/...`, `scripts/...`,
1810
- * `.cclaw/...`, `.cursor/...`, `node_modules/...`,
1811
- * `examples/...`, `e2e/...`).
1812
- * - GitHub-style ref (`owner/repo#123`, `org/repo@sha`,
1813
- * `path:line`, `path:line-line`).
1814
- * - Explicit `path:` / `paths:` / `ref:` / `refs:` marker.
1815
- * - Stable cclaw IDs (`R1`, `D-12`, `AC-3`, `T-4`, `S-2`, `DD-5`,
1816
- * `ADR-1`, `R-1`, `F-1`, `CR-1`, `I-1`, `QS-1`).
1817
- * - Backticked path-like token containing a slash.
1818
- *
1819
- * Exposed for unit tests (`tests/unit/investigation-trace-evaluator.test.ts`).
1820
- */
1821
- export const INVESTIGATION_TRACE_PATH_PATTERNS = [
1822
- /(?:^|[\s`(\[])(?:[A-Za-z0-9_.-]+\/)+[A-Za-z0-9_.-]+\.(?:ts|tsx|js|jsx|mjs|cjs|md|mdx|json|yaml|yml|toml|sh|py|rs|go|java|kt|swift|rb|css|scss|html)\b/iu,
1823
- /(?:^|[\s`(\[])(?:src|tests?|docs?|scripts?|e2e|examples?|packages?|apps?|cmd|internal|pkg|lib|app|server|client|backend|frontend|\.cclaw|\.cursor|\.github|node_modules)\/[A-Za-z0-9_./-]+/iu,
1824
- /\b[A-Za-z0-9_./-]+(?:\.[A-Za-z0-9]+)?:\d+(?:[-:]\d+)?\b/u,
1825
- /\b[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+(?:#\d+|@[0-9a-f]{6,40})\b/iu,
1826
- /(?:^|\s)(?:paths?|refs?|file|files|cite|citation)\s*:\s*\S/iu,
1827
- /\b(?:R|D|AC|T|S|DD|ADR|F|CR|I|QS)-?\d+\b/u,
1828
- /`[^`]*\/[^`]+`/u
1829
- ];
1830
- const INVESTIGATION_TRACE_PLACEHOLDER_PATTERN = /^(?:none|none\.|n\/a|tbd|todo|fixme|placeholder|optional|fill[\s-]?in)\b/u;
1831
- const INVESTIGATION_TRACE_ID_ONLY_CELL = /^[A-Z]{1,4}-?\d+$/u;
1832
- function isInvestigationTracePlaceholderCell(cell) {
1833
- const stripped = cell.replace(/[`*_>#]/gu, "").trim();
1834
- if (stripped.length === 0)
1835
- return true;
1836
- if (INVESTIGATION_TRACE_PLACEHOLDER_PATTERN.test(stripped.toLowerCase()))
1837
- return true;
1838
- return false;
1839
- }
1840
- function isInvestigationTracePlaceholderProseLine(line) {
1841
- const stripped = line.replace(/[`*_>#-]/gu, "").trim();
1842
- if (stripped.length === 0)
1843
- return true;
1844
- const lower = stripped.toLowerCase();
1845
- if (INVESTIGATION_TRACE_PLACEHOLDER_PATTERN.test(lower))
1846
- return true;
1847
- if (/^\(\s*(?:none|n\/a|tbd|todo|fixme|placeholder|optional|fill[\s-]?in)\b/u.test(lower)) {
1848
- return true;
1849
- }
1850
- return false;
1851
- }
1852
- /**
1853
- * Internal core that does NOT depend on `StageLintContext`. Returned
1854
- * shape is consumed by `evaluateInvestigationTrace` (which pushes a
1855
- * finding into the context) and by unit tests that exercise the
1856
- * detector directly.
1857
- *
1858
- * Returns `null` for sections that are missing, empty, or contain only
1859
- * template scaffolding (table headers, separators, placeholder rows
1860
- * with empty cells, lone `- None.` lines). Callers treat `null` as
1861
- * silent — no finding is emitted.
1862
- */
1863
- export function checkInvestigationTrace(sectionBody) {
1864
- if (sectionBody === null)
1865
- return null;
1866
- const lines = sectionBody.split(/\r?\n/u);
1867
- const candidates = [];
1868
- for (let index = 0; index < lines.length; index += 1) {
1869
- const raw = lines[index] ?? "";
1870
- const trimmed = raw.trim();
1871
- if (trimmed.length === 0)
1872
- continue;
1873
- if (trimmed.startsWith("<!--"))
1874
- continue;
1875
- const isTableLine = /^\|.*\|$/u.test(trimmed);
1876
- if (isTableLine) {
1877
- if (/^\|[-:| ]+\|$/u.test(trimmed))
1878
- continue; // separator row
1879
- const next = (lines[index + 1] ?? "").trim();
1880
- if (/^\|[-:| ]+\|$/u.test(next))
1881
- continue; // header row (followed by separator)
1882
- const cells = trimmed
1883
- .split("|")
1884
- .slice(1, -1)
1885
- .map((cell) => cell.trim());
1886
- const substantive = cells.filter((cell) => !isInvestigationTracePlaceholderCell(cell));
1887
- if (substantive.length === 0)
1888
- continue;
1889
- if (substantive.length === 1 && INVESTIGATION_TRACE_ID_ONLY_CELL.test(substantive[0])) {
1890
- continue;
1891
- }
1892
- candidates.push(substantive.join(" "));
1893
- continue;
1894
- }
1895
- if (isInvestigationTracePlaceholderProseLine(trimmed))
1896
- continue;
1897
- candidates.push(trimmed);
1898
- }
1899
- if (candidates.length === 0)
1900
- return null;
1901
- const sample = candidates.slice(0, Math.min(5, candidates.length));
1902
- const detectorMatched = sample.some((line) => INVESTIGATION_TRACE_PATH_PATTERNS.some((pattern) => pattern.test(line)));
1903
- if (detectorMatched) {
1904
- return {
1905
- ok: true,
1906
- details: "Investigation trace cites file paths or refs in the first non-empty row(s)."
1907
- };
1908
- }
1909
- return {
1910
- ok: false,
1911
- details: "Investigation trace has prose-only content in its first row(s). Pass paths and refs, not pasted file contents (e.g. `src/foo/bar.ts:42`, `D-12`, `AC-3`)."
1912
- };
1913
- }
1914
- /**
1915
- * advisory rule wired into the brainstorm / scope /
1916
- * design / tdd / plan / review linters.
1917
- *
1918
- * Behavior contract:
1919
- * - Section missing or empty / placeholder-only: silent (no finding).
1920
- * - Section has substantive content with a recognizable file path /
1921
- * ref / explicit `path:`-style marker in the first non-empty rows:
1922
- * advisory pass (no finding).
1923
- * - Section has substantive content but no path/ref signal: advisory
1924
- * FAIL finding with ruleId `investigation_path_first_missing`.
1925
- *
1926
- * The rule is `required: false` so it never blocks `stage-complete`.
1927
- */
1928
- export function evaluateInvestigationTrace(ctx, sectionName) {
1929
- const body = sectionBodyByName(ctx.sections, sectionName);
1930
- const authoredBody = body === null ? null : extractAuthoredBody(body);
1931
- const result = checkInvestigationTrace(authoredBody);
1932
- if (result === null)
1933
- return;
1934
- ctx.findings.push({
1935
- section: "investigation_path_first_missing",
1936
- required: false,
1937
- rule: `[P3] investigation_path_first_missing — \`## ${sectionName}\` should cite paths and refs in the first non-empty row(s); pass paths and refs, not content.`,
1938
- found: result.ok,
1939
- details: result.details
1940
- });
1941
- }
1942
- export function lineContainsVagueAdjective(text) {
1943
- const lower = text.toLowerCase();
1944
- for (const adjective of VAGUE_AC_ADJECTIVES) {
1945
- const pattern = new RegExp(`(?:^|[^A-Za-z])${adjective.replace(/ /g, "\\s+")}(?:[^A-Za-z]|$)`, "iu");
1946
- if (pattern.test(lower))
1947
- return adjective;
1948
- }
1949
- return null;
1950
- }
1951
- export const FRONTMATTER_REQUIRED_KEYS = [
1952
- "stage",
1953
- "schema_version",
1954
- "version",
1955
- "locked_decisions",
1956
- "inputs_hash"
1957
- ];
1958
- export const PLACEHOLDER_PATTERNS = [
1959
- { label: "TODO", regex: /\bTODO\b/iu },
1960
- { label: "TBD", regex: /\bTBD\b/iu },
1961
- { label: "FIXME", regex: /\bFIXME\b/iu },
1962
- { label: "<fill-in>", regex: /<fill-in>/iu },
1963
- { label: "<your-*-here>", regex: /<your-[^>]*-here>/iu },
1964
- { label: "xxx", regex: /\bxxx\b/iu },
1965
- { label: "ellipsis", regex: /\.{3}/u }
1966
- ];
1967
- export const SCOPE_REDUCTION_PATTERNS = [
1968
- { label: "v1", regex: /\bv1\b/iu },
1969
- { label: "for now", regex: /\bfor now\b/iu },
1970
- { label: "later", regex: /\blater\b/iu },
1971
- { label: "temporary", regex: /\btemporary\b/iu },
1972
- { label: "placeholder", regex: /\bplaceholder\b/iu },
1973
- { label: "mock for now", regex: /\bmock for now\b/iu },
1974
- { label: "hardcoded for now", regex: /\bhardcoded for now\b/iu },
1975
- { label: "will improve later", regex: /\bwill improve later\b/iu }
1976
- ];
1977
- export function parseFrontmatter(markdown) {
1978
- const lines = markdown.split(/\r?\n/);
1979
- if (lines[0]?.trim() !== "---") {
1980
- return { hasFrontmatter: false, values: {} };
1981
- }
1982
- const endIndex = lines.findIndex((line, index) => index > 0 && line.trim() === "---");
1983
- if (endIndex < 0) {
1984
- return { hasFrontmatter: false, values: {} };
1985
- }
1986
- const values = {};
1987
- for (const line of lines.slice(1, endIndex)) {
1988
- const match = /^([A-Za-z0-9_-]+)\s*:\s*(.*)$/u.exec(line.trim());
1989
- if (!match)
1990
- continue;
1991
- const key = match[1];
1992
- const value = match[2].trim();
1993
- values[key] = value;
1994
- }
1995
- return { hasFrontmatter: true, values };
1996
- }
1997
- export function extractDecisionIds(text) {
1998
- const ids = text.match(/\bD-\d+\b/gu) ?? [];
1999
- return [...new Set(ids)];
2000
- }
2001
- export function extractRequirementIdsFromMarkdown(text) {
2002
- const ids = text.match(/\bR\d+\b/gu) ?? [];
2003
- return [...new Set(ids)];
2004
- }
2005
- export function extractAcceptanceCriterionIdsFromMarkdown(text) {
2006
- const ids = text.match(/\bAC-\d+\b/giu) ?? [];
2007
- const normalized = ids.map((id) => id.toUpperCase());
2008
- return [...new Set(normalized)];
2009
- }
2010
- // Cross-stage decision traceability uses stable D-XX IDs which the
2011
- // agent can edit safely without recomputing content hashes.
2012
- export function collectPatternHits(text, patterns) {
2013
- const hits = [];
2014
- for (const pattern of patterns) {
2015
- if (pattern.regex.test(text)) {
2016
- hits.push(pattern.label);
2017
- }
2018
- }
2019
- return hits;
2020
- }
2021
- export function validateSectionBody(sectionBody, rule, sectionName, context = {}) {
2022
- const bodyLines = sectionBody.split(/\r?\n/).map((line) => line.trim());
2023
- const meaningful = meaningfulLineCount(sectionBody);
2024
- if (meaningful === 0) {
2025
- return {
2026
- ok: false,
2027
- details: "Section exists but has no meaningful content yet."
2028
- };
2029
- }
2030
- const minItems = extractMinItemsFromRule(rule);
2031
- if (minItems !== null) {
2032
- const count = countListItems(sectionBody);
2033
- if (count < minItems) {
2034
- return {
2035
- ok: false,
2036
- details: `Rule expects at least ${minItems} item(s), found ${count}.`
2037
- };
2038
- }
2039
- }
2040
- if (/table must use 4 columns/iu.test(rule)) {
2041
- const header = tableHeaderCells(sectionBody);
2042
- if (!header) {
2043
- return {
2044
- ok: false,
2045
- details: "Rule expects a markdown table header with a separator row."
2046
- };
2047
- }
2048
- const expected = ["Category", "Question asked", "User answer", "Evidence note"];
2049
- const normalizedHeader = header.map((cell) => cell.toLowerCase());
2050
- const normalizedExpected = expected.map((cell) => cell.toLowerCase());
2051
- const matches = normalizedHeader.length === normalizedExpected.length &&
2052
- normalizedHeader.every((cell, index) => cell === normalizedExpected[index]);
2053
- if (!matches) {
2054
- return {
2055
- ok: false,
2056
- details: `Rule expects Clarification Log header: ${expected.join(" | ")}.`
2057
- };
2058
- }
2059
- }
2060
- if (/exactly one/iu.test(rule)) {
2061
- const tokens = tokensFromRule(rule);
2062
- if (tokens.length > 0) {
2063
- const selected = new Set();
2064
- const tokenLines = [];
2065
- for (const line of bodyLines) {
2066
- if (!line)
2067
- continue;
2068
- for (const token of tokens) {
2069
- if (!lineHasToken(line, token))
2070
- continue;
2071
- tokenLines.push({ line, token });
2072
- if (/\[x\]/iu.test(line) || /selected|verdict|enum|execution result|status/iu.test(line)) {
2073
- selected.add(token);
2074
- }
2075
- }
2076
- }
2077
- if (selected.size === 0 && tokenLines.length === 1 && !tokenLines[0].line.includes("|")) {
2078
- selected.add(tokenLines[0].token);
2079
- }
2080
- if (selected.size !== 1) {
2081
- return {
2082
- ok: false,
2083
- details: `Rule expects exactly one selected token (${tokens.join(", ")}); found ${selected.size}.`
2084
- };
2085
- }
2086
- return { ok: true, details: "Exactly one token selected as expected." };
2087
- }
2088
- }
2089
- if (/Status:\s*pending\s+until/iu.test(rule)) {
2090
- const statusLine = bodyLines.find((l) => /^\s*-?\s*Status\s*:/iu.test(l));
2091
- if (!statusLine) {
2092
- return { ok: false, details: "WAIT_FOR_CONFIRM section must contain a 'Status:' line." };
2093
- }
2094
- const validStatuses = ["pending", "approved"];
2095
- const statusMatch = /Status\s*:\s*(\S+)/iu.exec(statusLine);
2096
- const statusValue = statusMatch?.[1]?.toLowerCase();
2097
- if (!statusValue || !validStatuses.includes(statusValue)) {
2098
- const foundLabel = statusValue || "(empty)";
2099
- return {
2100
- ok: false,
2101
- details: "WAIT_FOR_CONFIRM Status must be exactly one of: " + validStatuses.join(", ") + ". Found: " + foundLabel + "."
2102
- };
2103
- }
2104
- }
2105
- const sectionNameNormalized = normalizeHeadingTitle(sectionName).toLowerCase();
2106
- if (sectionNameNormalized === "red evidence") {
2107
- return validateTddRedEvidence(sectionBody, context.tddEvidence?.red ?? {});
2108
- }
2109
- if (sectionNameNormalized === "green evidence") {
2110
- return validateTddGreenEvidence(sectionBody, context.tddEvidence?.green ?? {});
2111
- }
2112
- if (sectionNameNormalized === "verification ladder") {
2113
- return validateVerificationLadder(sectionBody);
2114
- }
2115
- if (sectionNameNormalized === "failure mode table") {
2116
- return validateFailureModeTable(sectionBody);
2117
- }
2118
- if (sectionNameNormalized === "pre-scope system audit") {
2119
- return validatePreScopeSystemAudit(sectionBody);
2120
- }
2121
- if (sectionNameNormalized === "scope summary") {
2122
- return validateScopeSummary(sectionBody);
2123
- }
2124
- if (sectionNameNormalized.startsWith("requirements")) {
2125
- return validateRequirementsTaxonomy(sectionBody);
2126
- }
2127
- if (sectionNameNormalized === "data flow") {
2128
- return validateInteractionEdgeCaseMatrix(sectionBody, {
2129
- sections: context.sections ?? null,
2130
- liteTier: context.liteTier ?? false
2131
- });
2132
- }
2133
- if (sectionNameNormalized === "architecture diagram") {
2134
- return validateArchitectureDiagram(sectionBody, { sections: context.sections ?? null });
2135
- }
2136
- if (sectionNameNormalized === "acceptance criteria" &&
2137
- /observable[\s,]*measurable[\s,]+(and )?falsifiable/iu.test(rule)) {
2138
- const rows = getMarkdownTableRows(sectionBody);
2139
- for (const row of rows) {
2140
- const criterionText = row[1] ?? row[0] ?? "";
2141
- const adjective = lineContainsVagueAdjective(criterionText);
2142
- if (adjective) {
2143
- return {
2144
- ok: false,
2145
- details: `Acceptance criterion uses vague adjective "${adjective}" without a measurable predicate: "${criterionText.slice(0, 140)}". Rewrite with a numeric threshold or boolean outcome.`
2146
- };
2147
- }
2148
- const hasDigit = /\d/u.test(criterionText);
2149
- const hasMeasurableVerb = /\b(blocks?|rejects?|returns?|matches?|equals?|emits?|succeeds?|fails?|publishes?|logs?|persists?|reads?|writes?|creates?|deletes?|throws?|contains?|restores?|exceeds?|responds?|warns?|quarantines?|includes?|raises?|passes?|denies|refuses|exits|succeeds|completes|prevents|allows|maps|points|signals|surfaces|records|produces|accepts|requires)\b/iu.test(criterionText);
2150
- const hasMeaningfulText = /[A-Za-z]/u.test(criterionText) && criterionText.trim().length >= 12;
2151
- if (hasMeaningfulText && !hasDigit && !hasMeasurableVerb) {
2152
- return {
2153
- ok: false,
2154
- details: `Acceptance criterion lacks a measurable predicate (no numeric threshold, no observable verb like blocks/returns/publishes/matches): "${criterionText.slice(0, 140)}". Rewrite so the criterion is falsifiable by a single test.`
2155
- };
2156
- }
2157
- }
2158
- }
2159
- return {
2160
- ok: true,
2161
- details: "Section heading and content satisfy lint heuristics."
2162
- };
2163
- }