cclaw-cli 0.51.29 → 0.55.2

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 (151) hide show
  1. package/README.md +22 -16
  2. package/dist/artifact-linter/brainstorm.d.ts +2 -0
  3. package/dist/artifact-linter/brainstorm.js +245 -0
  4. package/dist/artifact-linter/design.d.ts +2 -0
  5. package/dist/artifact-linter/design.js +323 -0
  6. package/dist/artifact-linter/plan.d.ts +2 -0
  7. package/dist/artifact-linter/plan.js +162 -0
  8. package/dist/artifact-linter/review-army.d.ts +24 -0
  9. package/dist/artifact-linter/review-army.js +365 -0
  10. package/dist/artifact-linter/review.d.ts +2 -0
  11. package/dist/artifact-linter/review.js +65 -0
  12. package/dist/artifact-linter/scope.d.ts +2 -0
  13. package/dist/artifact-linter/scope.js +115 -0
  14. package/dist/artifact-linter/shared.d.ts +246 -0
  15. package/dist/artifact-linter/shared.js +1488 -0
  16. package/dist/artifact-linter/ship.d.ts +2 -0
  17. package/dist/artifact-linter/ship.js +46 -0
  18. package/dist/artifact-linter/spec.d.ts +2 -0
  19. package/dist/artifact-linter/spec.js +108 -0
  20. package/dist/artifact-linter/tdd.d.ts +2 -0
  21. package/dist/artifact-linter/tdd.js +124 -0
  22. package/dist/artifact-linter.d.ts +4 -76
  23. package/dist/artifact-linter.js +56 -2949
  24. package/dist/cli.d.ts +2 -18
  25. package/dist/cli.js +8 -246
  26. package/dist/codex-feature-flag.d.ts +1 -1
  27. package/dist/codex-feature-flag.js +1 -1
  28. package/dist/config.d.ts +3 -2
  29. package/dist/config.js +67 -3
  30. package/dist/constants.d.ts +1 -7
  31. package/dist/constants.js +9 -15
  32. package/dist/content/cancel-command.js +2 -2
  33. package/dist/content/closeout-guidance.js +13 -10
  34. package/dist/content/core-agents.d.ts +18 -0
  35. package/dist/content/core-agents.js +51 -7
  36. package/dist/content/decision-protocol.d.ts +1 -1
  37. package/dist/content/decision-protocol.js +1 -1
  38. package/dist/content/examples.js +6 -6
  39. package/dist/content/harness-doc.js +20 -2
  40. package/dist/content/hook-inline-snippets.d.ts +17 -4
  41. package/dist/content/hook-inline-snippets.js +218 -5
  42. package/dist/content/hook-manifest.d.ts +2 -2
  43. package/dist/content/hook-manifest.js +2 -2
  44. package/dist/content/hooks.d.ts +1 -0
  45. package/dist/content/hooks.js +32 -137
  46. package/dist/content/idea-command.d.ts +8 -0
  47. package/dist/content/{ideate-command.js → idea-command.js} +57 -50
  48. package/dist/content/idea-frames.d.ts +31 -0
  49. package/dist/content/{ideate-frames.js → idea-frames.js} +9 -9
  50. package/dist/content/idea-ranking.d.ts +25 -0
  51. package/dist/content/{ideate-ranking.js → idea-ranking.js} +5 -5
  52. package/dist/content/iron-laws.d.ts +0 -1
  53. package/dist/content/iron-laws.js +31 -16
  54. package/dist/content/learnings.js +1 -1
  55. package/dist/content/meta-skill.js +11 -13
  56. package/dist/content/node-hooks.d.ts +10 -0
  57. package/dist/content/node-hooks.js +45 -11
  58. package/dist/content/opencode-plugin.js +3 -3
  59. package/dist/content/session-hooks.js +1 -1
  60. package/dist/content/skills.js +19 -7
  61. package/dist/content/stage-command.js +1 -1
  62. package/dist/content/stage-schema.js +44 -2
  63. package/dist/content/stages/_lint-metadata/index.js +26 -2
  64. package/dist/content/stages/brainstorm.js +13 -7
  65. package/dist/content/stages/design.js +16 -11
  66. package/dist/content/stages/plan.js +9 -6
  67. package/dist/content/stages/review.js +4 -4
  68. package/dist/content/stages/schema-types.d.ts +1 -1
  69. package/dist/content/stages/scope.js +15 -12
  70. package/dist/content/stages/ship.js +2 -2
  71. package/dist/content/stages/spec.js +9 -3
  72. package/dist/content/stages/tdd.js +14 -4
  73. package/dist/content/start-command.d.ts +2 -2
  74. package/dist/content/start-command.js +24 -21
  75. package/dist/content/status-command.js +8 -8
  76. package/dist/content/subagents.js +61 -7
  77. package/dist/content/templates.d.ts +1 -1
  78. package/dist/content/templates.js +104 -152
  79. package/dist/content/tree-command.js +2 -2
  80. package/dist/content/utility-skills.d.ts +2 -2
  81. package/dist/content/utility-skills.js +2 -2
  82. package/dist/content/view-command.js +4 -2
  83. package/dist/delegation.d.ts +2 -0
  84. package/dist/delegation.js +2 -1
  85. package/dist/early-loop.d.ts +66 -0
  86. package/dist/early-loop.js +275 -0
  87. package/dist/flow-state.d.ts +1 -1
  88. package/dist/flow-state.js +1 -1
  89. package/dist/gate-evidence.d.ts +8 -0
  90. package/dist/gate-evidence.js +141 -5
  91. package/dist/harness-adapters.d.ts +2 -2
  92. package/dist/harness-adapters.js +54 -122
  93. package/dist/harness-selection.d.ts +31 -0
  94. package/dist/harness-selection.js +214 -0
  95. package/dist/install.js +166 -38
  96. package/dist/internal/advance-stage/advance.d.ts +50 -0
  97. package/dist/internal/advance-stage/advance.js +480 -0
  98. package/dist/internal/advance-stage/cancel-run.d.ts +8 -0
  99. package/dist/internal/advance-stage/cancel-run.js +19 -0
  100. package/dist/internal/advance-stage/flow-state-coercion.d.ts +3 -0
  101. package/dist/internal/advance-stage/flow-state-coercion.js +81 -0
  102. package/dist/internal/advance-stage/helpers.d.ts +14 -0
  103. package/dist/internal/advance-stage/helpers.js +145 -0
  104. package/dist/internal/advance-stage/hook.d.ts +8 -0
  105. package/dist/internal/advance-stage/hook.js +40 -0
  106. package/dist/internal/advance-stage/parsers.d.ts +54 -0
  107. package/dist/internal/advance-stage/parsers.js +307 -0
  108. package/dist/internal/advance-stage/review-loop.d.ts +7 -0
  109. package/dist/internal/advance-stage/review-loop.js +170 -0
  110. package/dist/internal/advance-stage/rewind.d.ts +14 -0
  111. package/dist/internal/advance-stage/rewind.js +108 -0
  112. package/dist/internal/advance-stage/start-flow.d.ts +11 -0
  113. package/dist/internal/advance-stage/start-flow.js +136 -0
  114. package/dist/internal/advance-stage/verify.d.ts +29 -0
  115. package/dist/internal/advance-stage/verify.js +225 -0
  116. package/dist/internal/advance-stage.js +21 -1470
  117. package/dist/internal/compound-readiness.d.ts +1 -1
  118. package/dist/internal/compound-readiness.js +2 -2
  119. package/dist/internal/early-loop-status.d.ts +7 -0
  120. package/dist/internal/early-loop-status.js +90 -0
  121. package/dist/internal/runtime-integrity.d.ts +7 -0
  122. package/dist/internal/runtime-integrity.js +288 -0
  123. package/dist/internal/tdd-red-evidence.js +1 -1
  124. package/dist/knowledge-store.d.ts +3 -8
  125. package/dist/knowledge-store.js +16 -29
  126. package/dist/managed-resources.js +24 -2
  127. package/dist/policy.js +5 -7
  128. package/dist/run-archive.d.ts +1 -1
  129. package/dist/run-archive.js +16 -16
  130. package/dist/run-persistence.js +112 -12
  131. package/dist/tdd-cycle.d.ts +3 -3
  132. package/dist/tdd-cycle.js +1 -1
  133. package/dist/types.d.ts +18 -10
  134. package/package.json +1 -1
  135. package/dist/content/finish-command.d.ts +0 -2
  136. package/dist/content/finish-command.js +0 -26
  137. package/dist/content/ideate-command.d.ts +0 -8
  138. package/dist/content/ideate-frames.d.ts +0 -31
  139. package/dist/content/ideate-ranking.d.ts +0 -25
  140. package/dist/content/next-command.d.ts +0 -20
  141. package/dist/content/next-command.js +0 -298
  142. package/dist/content/seed-shelf.d.ts +0 -36
  143. package/dist/content/seed-shelf.js +0 -301
  144. package/dist/content/stage-common-guidance.d.ts +0 -1
  145. package/dist/content/stage-common-guidance.js +0 -106
  146. package/dist/doctor-registry.d.ts +0 -10
  147. package/dist/doctor-registry.js +0 -186
  148. package/dist/doctor.d.ts +0 -17
  149. package/dist/doctor.js +0 -2206
  150. package/dist/internal/hook-manifest.d.ts +0 -16
  151. package/dist/internal/hook-manifest.js +0 -77
@@ -0,0 +1,1488 @@
1
+ import { createHash } from "node:crypto";
2
+ import { SHIP_FINALIZATION_MODES } from "../constants.js";
3
+ import { FLOW_STAGES } from "../types.js";
4
+ export function normalizeHeadingTitle(title) {
5
+ return title.trim().replace(/\s+/g, " ");
6
+ }
7
+ /**
8
+ * Collect H2 sections and body content (`## Section Name`).
9
+ *
10
+ * - Ignores lines that live inside fenced code blocks (``` / ~~~) so a
11
+ * commented `## Approaches` inside an example doesn't open a phantom
12
+ * section and swallow real content.
13
+ * - When the same heading appears more than once at the top level we
14
+ * concatenate the bodies rather than silently overwriting the earlier
15
+ * occurrence. This keeps lint rules honest when authors split a section
16
+ * into multiple passes.
17
+ */
18
+ export function extractH2Sections(markdown) {
19
+ const sections = new Map();
20
+ const lines = markdown.split(/\r?\n/);
21
+ let currentHeading = null;
22
+ let buffer = [];
23
+ let fenced = null;
24
+ const flush = () => {
25
+ if (currentHeading === null)
26
+ return;
27
+ const existing = sections.get(currentHeading);
28
+ const body = buffer.join("\n");
29
+ sections.set(currentHeading, existing === undefined ? body : `${existing}\n${body}`);
30
+ };
31
+ for (const line of lines) {
32
+ const fenceMatch = /^(```|~~~)/u.exec(line);
33
+ if (fenceMatch) {
34
+ if (fenced === null) {
35
+ fenced = fenceMatch[1] ?? null;
36
+ }
37
+ else if (line.startsWith(fenced)) {
38
+ fenced = null;
39
+ }
40
+ if (currentHeading !== null)
41
+ buffer.push(line);
42
+ continue;
43
+ }
44
+ if (fenced === null) {
45
+ const match = /^##\s+(.+)$/u.exec(line);
46
+ if (match) {
47
+ flush();
48
+ currentHeading = normalizeHeadingTitle(match[1] ?? "");
49
+ buffer = [];
50
+ continue;
51
+ }
52
+ }
53
+ if (currentHeading !== null) {
54
+ buffer.push(line);
55
+ }
56
+ }
57
+ flush();
58
+ return sections;
59
+ }
60
+ export function headingPresent(sections, section) {
61
+ const want = normalizeHeadingTitle(section).toLowerCase();
62
+ for (const h of sections.keys()) {
63
+ if (h.toLowerCase() === want) {
64
+ return true;
65
+ }
66
+ }
67
+ return false;
68
+ }
69
+ export function sectionBodyByName(sections, section) {
70
+ const want = normalizeHeadingTitle(section).toLowerCase();
71
+ for (const [heading, body] of sections.entries()) {
72
+ if (heading.toLowerCase() === want) {
73
+ return body;
74
+ }
75
+ }
76
+ return null;
77
+ }
78
+ export function sectionBodyByAnyName(sections, sectionNames) {
79
+ const bodies = sectionNames.flatMap((section) => {
80
+ const body = sectionBodyByName(sections, section);
81
+ return body === null ? [] : [`### ${section}\n${body}`];
82
+ });
83
+ if (bodies.length === 0)
84
+ return null;
85
+ return bodies.join("\n");
86
+ }
87
+ export function sectionBodyByHeadingPrefix(sections, prefix) {
88
+ const want = normalizeHeadingTitle(prefix).toLowerCase();
89
+ for (const [heading, body] of sections.entries()) {
90
+ if (heading.toLowerCase().startsWith(want)) {
91
+ return body;
92
+ }
93
+ }
94
+ return null;
95
+ }
96
+ /**
97
+ * Build a regex that matches `<field>: <value>` even when the field name
98
+ * and/or value are wrapped in markdown emphasis (`*`, `**`, `_`, `__`).
99
+ *
100
+ * The shipped templates render fields as `- **Field name:** value`, so any
101
+ * structural check that searches for `Field:\s*token` against the rendered
102
+ * artifact must tolerate the closing `**` between the colon and the value.
103
+ *
104
+ * `field` is treated as literal text (regex meta-characters are escaped).
105
+ * `value` is inserted verbatim so callers can pass alternation
106
+ * (`STARTUP|BUILDER|...`). `flags` defaults to case-insensitive Unicode.
107
+ */
108
+ export function markdownFieldRegex(field, value, flags = "iu") {
109
+ const escapedField = field.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
110
+ const emph = "[*_]{0,2}";
111
+ const source = `(?:^|[\\s>])${emph}\\s*${escapedField}\\s*${emph}\\s*:\\s*${emph}\\s*(?:${value})\\b`;
112
+ return new RegExp(source, flags);
113
+ }
114
+ export function extractMarkdownSectionBody(markdown, section) {
115
+ return sectionBodyByName(extractH2Sections(markdown), section);
116
+ }
117
+ export function headingLineIndex(markdown, section) {
118
+ const want = normalizeHeadingTitle(section).toLowerCase();
119
+ const lines = markdown.split(/\r?\n/);
120
+ let fenced = null;
121
+ for (let i = 0; i < lines.length; i++) {
122
+ const line = lines[i];
123
+ const fence = /^\s*(```+|~~~+)\s*([A-Za-z0-9_-]+)?\s*$/u.exec(line);
124
+ if (fence) {
125
+ const marker = fence[1];
126
+ if (fenced === null) {
127
+ fenced = marker;
128
+ }
129
+ else if (fenced === marker) {
130
+ fenced = null;
131
+ }
132
+ continue;
133
+ }
134
+ if (fenced !== null)
135
+ continue;
136
+ const heading = /^##\s+(.+)$/u.exec(line);
137
+ if (!heading)
138
+ continue;
139
+ if (normalizeHeadingTitle(heading[1] ?? "").toLowerCase() === want) {
140
+ return i;
141
+ }
142
+ }
143
+ return -1;
144
+ }
145
+ export function parseShortCircuitStatus(sectionBody) {
146
+ if (!sectionBody)
147
+ return "";
148
+ const lines = sectionBody.split(/\r?\n/u);
149
+ return lines
150
+ .map((line) => line.replace(/[*_`]/gu, "").trim())
151
+ .map((line) => /^[-*]?\s*status\s*:\s*(.+)$/iu.exec(line)?.[1] ?? "")
152
+ .find((value) => value.trim().length > 0)?.trim().toLowerCase() ?? "";
153
+ }
154
+ export function isShortCircuitActivated(sectionBody) {
155
+ const statusValue = parseShortCircuitStatus(sectionBody);
156
+ return /^(?:activated|yes|true)$/u.test(statusValue) || /\bactivated\b/iu.test(statusValue);
157
+ }
158
+ export function meaningfulLineCount(sectionBody) {
159
+ return sectionBody
160
+ .split(/\r?\n/)
161
+ .map((line) => line.trim())
162
+ .filter((line) => line.length > 0)
163
+ .filter((line) => !line.startsWith("<!--"))
164
+ .filter((line) => !/^[-:| ]+$/u.test(line))
165
+ .filter((line) => /[\p{L}\p{N}]/u.test(line))
166
+ .length;
167
+ }
168
+ export function lineHasToken(line, token) {
169
+ return new RegExp(`\\b${token}\\b`, "u").test(line);
170
+ }
171
+ export function countListItems(sectionBody) {
172
+ const lines = sectionBody.split(/\r?\n/).map((line) => line.trim());
173
+ const bullets = lines.filter((line) => /^[-*]\s+\S+/u.test(line)).length;
174
+ const tableRows = lines.filter((line) => /^\|.*\|$/u.test(line) && !/^\|[-:| ]+\|$/u.test(line));
175
+ const tableDataRows = tableRows.length > 0 ? Math.max(0, tableRows.length - 1) : 0;
176
+ return Math.max(bullets, tableDataRows);
177
+ }
178
+ export function parseMarkdownTableRow(line) {
179
+ return line
180
+ .trim()
181
+ .split("|")
182
+ .map((cell) => cell.trim())
183
+ .filter((cell) => cell.length > 0);
184
+ }
185
+ export function tableHeaderCells(sectionBody) {
186
+ const lines = sectionBody.split(/\r?\n/).map((line) => line.trim());
187
+ const headerIndex = lines.findIndex((line) => /^\|.*\|$/u.test(line));
188
+ if (headerIndex < 0)
189
+ return null;
190
+ const separator = lines[headerIndex + 1];
191
+ if (!separator || !/^\|[-:| ]+\|$/u.test(separator)) {
192
+ return null;
193
+ }
194
+ return parseMarkdownTableRow(lines[headerIndex]);
195
+ }
196
+ export function extractMinItemsFromRule(rule) {
197
+ const match = /at least\s+(\d+)/iu.exec(rule);
198
+ if (!match)
199
+ return null;
200
+ const parsed = Number.parseInt(match[1] ?? "", 10);
201
+ return Number.isFinite(parsed) ? parsed : null;
202
+ }
203
+ export function tokensFromRule(rule) {
204
+ const allCaps = rule.match(/\b[A-Z][A-Z0-9_]{2,}\b/g) ?? [];
205
+ if (allCaps.length > 0) {
206
+ return [...new Set(allCaps)];
207
+ }
208
+ if (/finalization enum token/iu.test(rule)) {
209
+ return [...SHIP_FINALIZATION_MODES];
210
+ }
211
+ if (/final verdict/iu.test(rule)) {
212
+ return ["APPROVED", "APPROVED_WITH_CONCERNS", "BLOCKED"];
213
+ }
214
+ return [];
215
+ }
216
+ export const VAGUE_AC_ADJECTIVES = [
217
+ "fast",
218
+ "quick",
219
+ "slow",
220
+ "fast enough",
221
+ "quickly",
222
+ "intuitive",
223
+ "robust",
224
+ "reliable",
225
+ "scalable",
226
+ "simple",
227
+ "easy",
228
+ "user-friendly",
229
+ "user friendly",
230
+ "nice",
231
+ "good",
232
+ "clean",
233
+ "secure enough",
234
+ "responsive",
235
+ "efficient",
236
+ "performant",
237
+ "smooth",
238
+ "seamless",
239
+ "modern"
240
+ ];
241
+ export function isSeparatorRow(line) {
242
+ return /^\|[-:| ]+\|$/u.test(line);
243
+ }
244
+ export function getMarkdownTableRows(sectionBody) {
245
+ const lines = sectionBody.split(/\r?\n/).map((line) => line.trim());
246
+ const rows = [];
247
+ let sawSeparator = false;
248
+ for (const line of lines) {
249
+ if (!/^\|.*\|$/u.test(line))
250
+ continue;
251
+ if (isSeparatorRow(line)) {
252
+ sawSeparator = true;
253
+ continue;
254
+ }
255
+ if (!sawSeparator)
256
+ continue;
257
+ rows.push(parseMarkdownTableRow(line));
258
+ }
259
+ return rows;
260
+ }
261
+ export function parseBinaryFlag(value) {
262
+ const normalized = value.trim().toLowerCase();
263
+ if (/^(?:y|yes|true|1)$/u.test(normalized))
264
+ return "yes";
265
+ if (/^(?:n|no|false|0|none)$/u.test(normalized))
266
+ return "no";
267
+ return "unknown";
268
+ }
269
+ export function parseKeyedBinaryFlag(value, key) {
270
+ const match = new RegExp(`${key}\\s*=\\s*(y|yes|true|1|n|no|false|0)`, "iu").exec(value);
271
+ if (!match)
272
+ return "unknown";
273
+ return /^(?:y|yes|true|1)$/iu.test(match[1] ?? "") ? "yes" : "no";
274
+ }
275
+ export function parseFailureModeRescueFlag(rescueCell) {
276
+ const keyed = parseKeyedBinaryFlag(rescueCell, "rescued");
277
+ if (keyed !== "unknown")
278
+ return keyed;
279
+ const direct = parseBinaryFlag(rescueCell);
280
+ if (direct !== "unknown")
281
+ return direct;
282
+ if (/\b(?:no rescue|without rescue|unrescued|no fallback|none|absent)\b/iu.test(rescueCell)) {
283
+ return "no";
284
+ }
285
+ if (/\b(?:fallback|retry|degrade|recover|rescue|mitigat)\b/iu.test(rescueCell)) {
286
+ return "yes";
287
+ }
288
+ return "unknown";
289
+ }
290
+ export function parseFailureModeTestFlag(rowText) {
291
+ const keyed = parseKeyedBinaryFlag(rowText, "test");
292
+ if (keyed !== "unknown")
293
+ return keyed;
294
+ if (/\b(?:no tests?|untested|without tests?)\b/iu.test(rowText)) {
295
+ return "no";
296
+ }
297
+ if (/\b(?:tested|has tests?|with tests?|covered by tests?)\b/iu.test(rowText)) {
298
+ return "yes";
299
+ }
300
+ return "unknown";
301
+ }
302
+ export function validateFailureModeTable(sectionBody) {
303
+ const header = tableHeaderCells(sectionBody);
304
+ if (!header) {
305
+ return {
306
+ ok: false,
307
+ details: "Failure Mode Table must include a markdown header row and separator."
308
+ };
309
+ }
310
+ const expectedHeader = ["Method", "Exception", "Rescue", "UserSees"];
311
+ const normalizedHeader = header.map((cell) => cell.toLowerCase());
312
+ const normalizedExpected = expectedHeader.map((cell) => cell.toLowerCase());
313
+ const headerMatches = normalizedHeader.length === normalizedExpected.length &&
314
+ normalizedHeader.every((cell, index) => cell === normalizedExpected[index]);
315
+ if (!headerMatches) {
316
+ return {
317
+ ok: false,
318
+ details: `Failure Mode Table header must be exactly: ${expectedHeader.join(" | ")}.`
319
+ };
320
+ }
321
+ const rows = getMarkdownTableRows(sectionBody);
322
+ if (rows.length === 0) {
323
+ return {
324
+ ok: false,
325
+ details: "Failure Mode Table must include at least one data row."
326
+ };
327
+ }
328
+ for (const [index, row] of rows.entries()) {
329
+ if (row.length < 4) {
330
+ return {
331
+ ok: false,
332
+ details: `Failure Mode Table row ${index + 1} must provide 4 columns (Method, Exception, Rescue, UserSees).`
333
+ };
334
+ }
335
+ const method = (row[0] ?? "").trim();
336
+ const exception = (row[1] ?? "").trim();
337
+ const rescue = (row[2] ?? "").trim();
338
+ const userSees = (row[3] ?? "").trim();
339
+ if (!method || !exception || !rescue || !userSees) {
340
+ return {
341
+ ok: false,
342
+ details: `Failure Mode Table row ${index + 1} must populate all columns (Method, Exception, Rescue, UserSees).`
343
+ };
344
+ }
345
+ const rescueFlag = parseFailureModeRescueFlag(rescue);
346
+ const testFlag = parseFailureModeTestFlag(`${method} ${exception} ${rescue} ${userSees}`);
347
+ const userSilent = /\bsilent\b/iu.test(userSees);
348
+ if (rescueFlag === "no" && testFlag === "no" && userSilent) {
349
+ return {
350
+ ok: false,
351
+ 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.`
352
+ };
353
+ }
354
+ }
355
+ return {
356
+ ok: true,
357
+ details: "Failure Mode Table header and critical-risk checks passed."
358
+ };
359
+ }
360
+ // Canonical scope mode tokens (gstack CEO review). The four mode names live in
361
+ // the scope skill, the artifact template, and downstream traces. Requiring one
362
+ // of them in Scope Summary is **structural** — not free-form English keyword
363
+ // matching on user prose. Authors may also use the canonical short form on a
364
+ // `Mode:` / `Selected mode:` line (e.g. `Selected mode: hold`) as a courtesy.
365
+ export const SCOPE_MODE_FULL_TOKENS = [
366
+ "SCOPE EXPANSION",
367
+ "SELECTIVE EXPANSION",
368
+ "HOLD SCOPE",
369
+ "SCOPE REDUCTION"
370
+ ];
371
+ // Short-form synonyms accepted only when stamped on an explicit `Mode:` /
372
+ // `Selected mode:` / `Scope mode:` line. Plain prose with the same word does
373
+ // not count, so `strict` / `broad` / `narrow` / similar non-mode adjectives
374
+ // remain rejected.
375
+ export const SCOPE_MODE_LINE_REGEX = /(?:^|\n)\s*[-*]?\s*\**\s*(?:Selected\s+|Scope\s+)?Mode\**\s*:\s*\**\s*([^\n]+)/iu;
376
+ 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;
377
+ export const SPEC_MAX_MODULES = 5;
378
+ // Next-stage handoff token. We only enforce the canonical machine-surface stage
379
+ // IDs (`design`, `spec`) plus stable handoff phrases. The surrounding prose may
380
+ // be written in any language — this guards the downstream cross-stage trace,
381
+ // not the wording of the rationale.
382
+ export const NEXT_STAGE_HANDOFF_REGEX = /(?:`(?:design|spec)`|\bdesign\b|\bspec\b|next[-\s_]stage|next stage|handoff|hand[-\s]off)/iu;
383
+ export function hasCanonicalScopeMode(body) {
384
+ return extractCanonicalScopeMode(body) !== null;
385
+ }
386
+ export function canonicalModesInText(text) {
387
+ const normalized = text
388
+ .toUpperCase()
389
+ .replace(/[_-]+/gu, " ")
390
+ .replace(/\s+/gu, " ")
391
+ .trim();
392
+ const hits = [];
393
+ if (/\bSCOPE EXPANSION\b/u.test(normalized))
394
+ hits.push("SCOPE EXPANSION");
395
+ if (/\bSELECTIVE EXPANSION\b/u.test(normalized))
396
+ hits.push("SELECTIVE EXPANSION");
397
+ if (/\bHOLD SCOPE\b/u.test(normalized))
398
+ hits.push("HOLD SCOPE");
399
+ if (/\bSCOPE REDUCTION\b/u.test(normalized))
400
+ hits.push("SCOPE REDUCTION");
401
+ return hits;
402
+ }
403
+ export function shortModeToCanonical(text) {
404
+ if (!SCOPE_MODE_SHORT_TOKEN_REGEX.test(text))
405
+ return null;
406
+ const normalized = text
407
+ .toLowerCase()
408
+ .replace(/[_-]+/gu, " ")
409
+ .replace(/\s+/gu, " ");
410
+ if (/\bselective(?:\s+expansion)?\b/u.test(normalized))
411
+ return "SELECTIVE EXPANSION";
412
+ if (/\bhold(?:\s+scope)?\b/u.test(normalized))
413
+ return "HOLD SCOPE";
414
+ if (/\b(?:scope\s+reduction|reduction|reduce)\b/u.test(normalized))
415
+ return "SCOPE REDUCTION";
416
+ if (/\b(?:scope\s+expansion|expansion|expand)\b/u.test(normalized))
417
+ return "SCOPE EXPANSION";
418
+ return null;
419
+ }
420
+ export function canonicalModeFromCandidate(candidate) {
421
+ const canonicalHits = canonicalModesInText(candidate);
422
+ if (canonicalHits.length === 1)
423
+ return canonicalHits[0];
424
+ if (canonicalHits.length > 1)
425
+ return null;
426
+ return shortModeToCanonical(candidate);
427
+ }
428
+ export function extractCanonicalScopeMode(body) {
429
+ // Strict: a Mode: / Selected mode: line that picks exactly ONE canonical mode
430
+ // is the strongest signal. The template scaffolding contains all four mode
431
+ // tokens inside an instructional `(one of ...)` placeholder; we ignore that
432
+ // line so authors who never replace the scaffolding still fail validation.
433
+ for (const match of body.matchAll(new RegExp(SCOPE_MODE_LINE_REGEX, "giu"))) {
434
+ const raw = (match[1] ?? "").trim();
435
+ const sanitized = raw.replace(/\(.*?\)/gu, "").trim();
436
+ if (sanitized.length === 0)
437
+ continue;
438
+ const mode = canonicalModeFromCandidate(sanitized);
439
+ if (mode)
440
+ return mode;
441
+ }
442
+ // Fallback: any line outside an instructional `(one of ...)` placeholder
443
+ // names exactly one mode. Block lines that list multiple modes (the
444
+ // unfilled template) or are wrapped in an instructional parenthetical.
445
+ for (const rawLine of body.split(/\r?\n/u)) {
446
+ const line = rawLine.trim();
447
+ if (line.length === 0)
448
+ continue;
449
+ if (/\(\s*one\s+of\b/iu.test(line))
450
+ continue;
451
+ const sanitized = line.replace(/\(.*?\)/gu, "");
452
+ const mode = canonicalModeFromCandidate(sanitized);
453
+ if (mode)
454
+ return mode;
455
+ }
456
+ return null;
457
+ }
458
+ export function validatePremiseChallenge(sectionBody) {
459
+ // gstack-style premise challenge requires a real Q/A structure (table or
460
+ // list), not free-form prose. The validation is *structural* only — we do
461
+ // NOT keyword-grep for English phrases like "right problem"; authors may
462
+ // write the questions in any language, and the answers carry the meaning.
463
+ // The template ships with canonical question labels as scaffolding, but
464
+ // the linter only enforces that the section actually compares premise
465
+ // questions to answers.
466
+ const tableRows = getMarkdownTableRows(sectionBody);
467
+ const bulletRows = sectionBody
468
+ .split(/\r?\n/u)
469
+ .map((line) => line.trim())
470
+ .filter((line) => /^(?:[-*]|\d+\.)\s+\S/u.test(line));
471
+ const rowCount = Math.max(tableRows.length, bulletRows.length);
472
+ if (rowCount < 3) {
473
+ return {
474
+ ok: false,
475
+ details: `Premise Challenge needs at least 3 substantive rows in a table or bullet list. Found ${rowCount}.`
476
+ };
477
+ }
478
+ // For tables, each data row must have at least 2 non-empty cells so the
479
+ // section is genuinely a premise/answer comparison, not a list of headlines.
480
+ // For bullet lists, each line must be substantive so we don't accept
481
+ // placeholders like `- a`; punctuation style and natural language do not
482
+ // matter.
483
+ if (tableRows.length >= 3) {
484
+ const sparseRows = tableRows.filter((row) => {
485
+ const filledCells = row.filter((cell) => cell.replace(/[\s|]/gu, "").length >= 2);
486
+ return filledCells.length < 2;
487
+ });
488
+ if (sparseRows.length > 0) {
489
+ return {
490
+ ok: false,
491
+ details: "Premise Challenge table rows must populate at least the question and answer columns (no empty answers)."
492
+ };
493
+ }
494
+ }
495
+ else if (bulletRows.length >= 3) {
496
+ const sparseBullets = bulletRows.filter((line) => {
497
+ const cleaned = line.replace(/^[-*\d.\s]+/u, "").replace(/[`*_]/gu, "").trim();
498
+ const meaningful = cleaned.match(/[\p{L}\p{N}]/gu)?.length ?? 0;
499
+ return meaningful < 12;
500
+ });
501
+ if (sparseBullets.length > 0) {
502
+ return {
503
+ ok: false,
504
+ details: "Premise Challenge bullet list must include at least 3 substantive rows, not placeholders."
505
+ };
506
+ }
507
+ }
508
+ return {
509
+ ok: true,
510
+ details: `Premise Challenge structures ${rowCount} Q/A rows.`
511
+ };
512
+ }
513
+ export function validateScopeSummary(sectionBody) {
514
+ const meaningfulLines = sectionBody
515
+ .split(/\r?\n/)
516
+ .map((line) => line.trim())
517
+ .filter((line) => line.length > 0 && /[\p{L}\p{N}]/u.test(line));
518
+ if (meaningfulLines.length < 2) {
519
+ return {
520
+ ok: false,
521
+ details: "Scope Summary must list at least 2 substantive lines covering the selected mode and the next-stage handoff."
522
+ };
523
+ }
524
+ if (!hasCanonicalScopeMode(sectionBody)) {
525
+ return {
526
+ ok: false,
527
+ 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)."
528
+ };
529
+ }
530
+ if (!NEXT_STAGE_HANDOFF_REGEX.test(sectionBody)) {
531
+ return {
532
+ ok: false,
533
+ 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)."
534
+ };
535
+ }
536
+ return {
537
+ ok: true,
538
+ details: "Scope Summary names the selected mode and the next-stage handoff."
539
+ };
540
+ }
541
+ export const APPROACH_ROLE_VALUES = ["baseline", "challenger", "wild-card"];
542
+ export const APPROACH_UPSIDE_VALUES = ["low", "modest", "high", "higher"];
543
+ export const REQUIREMENT_PRIORITY_VALUES = ["P0", "P1", "P2", "P3", "DROPPED"];
544
+ export function normalizeTableToken(value) {
545
+ return value
546
+ .replace(/[`*_]/gu, "")
547
+ .trim()
548
+ .toLowerCase()
549
+ .replace(/\s+/gu, "-");
550
+ }
551
+ export function columnIndex(header, expected) {
552
+ return header.findIndex((cell) => normalizeTableToken(cell) === expected);
553
+ }
554
+ export function validateApproachesTaxonomy(sectionBody) {
555
+ const header = tableHeaderCells(sectionBody);
556
+ const rows = getMarkdownTableRows(sectionBody);
557
+ if (!header) {
558
+ return {
559
+ rowCount: 0,
560
+ roleUpsideOk: false,
561
+ challengerOk: false,
562
+ details: "Approaches must be a markdown table with canonical Role and Upside columns."
563
+ };
564
+ }
565
+ const roleIndex = columnIndex(header, "role");
566
+ const upsideIndex = columnIndex(header, "upside");
567
+ if (roleIndex < 0 || upsideIndex < 0) {
568
+ const firstColumnTokens = rows.map((row) => normalizeTableToken(row[0] ?? ""));
569
+ const appearsTransposed = firstColumnTokens.includes("role") || firstColumnTokens.includes("upside");
570
+ return {
571
+ rowCount: rows.length,
572
+ roleUpsideOk: false,
573
+ challengerOk: false,
574
+ details: appearsTransposed
575
+ ? "Approaches table appears transposed: `Role`/`Upside` are rows, but must be columns. Use `| Approach | Role | Upside | ... |` with one approach per row."
576
+ : "Approaches table must include canonical `Role` and `Upside` columns (Role: baseline | challenger | wild-card; Upside: low | modest | high | higher)."
577
+ };
578
+ }
579
+ let challengerRows = 0;
580
+ let challengerHasHighUpside = false;
581
+ for (const [index, row] of rows.entries()) {
582
+ const role = normalizeTableToken(row[roleIndex] ?? "");
583
+ const upside = normalizeTableToken(row[upsideIndex] ?? "");
584
+ if (!APPROACH_ROLE_VALUES.includes(role)) {
585
+ return {
586
+ rowCount: rows.length,
587
+ roleUpsideOk: false,
588
+ challengerOk: false,
589
+ details: `Approaches row ${index + 1} has invalid Role "${row[roleIndex] ?? ""}". Expected one of: ${APPROACH_ROLE_VALUES.join(", ")}.`
590
+ };
591
+ }
592
+ if (!APPROACH_UPSIDE_VALUES.includes(upside)) {
593
+ return {
594
+ rowCount: rows.length,
595
+ roleUpsideOk: false,
596
+ challengerOk: false,
597
+ details: `Approaches row ${index + 1} has invalid Upside "${row[upsideIndex] ?? ""}". Expected one of: ${APPROACH_UPSIDE_VALUES.join(", ")}.`
598
+ };
599
+ }
600
+ if (role === "challenger") {
601
+ challengerRows += 1;
602
+ if (upside === "high" || upside === "higher") {
603
+ challengerHasHighUpside = true;
604
+ }
605
+ }
606
+ }
607
+ const challengerOk = challengerRows === 1 && challengerHasHighUpside;
608
+ return {
609
+ rowCount: rows.length,
610
+ roleUpsideOk: true,
611
+ challengerOk,
612
+ details: challengerOk
613
+ ? "Approaches table uses canonical Role/Upside values and exactly one high/higher-upside challenger."
614
+ : `Approaches table must include exactly one challenger row with Upside high or higher. Found ${challengerRows} challenger row(s).`
615
+ };
616
+ }
617
+ export function validateCalibratedSelfReview(sectionBody) {
618
+ const statusLineMatch = /^\s*-\s*Status:\s*(.*)$/imu.exec(sectionBody);
619
+ const statusValue = statusLineMatch ? statusLineMatch[1].trim() : "";
620
+ const mentionsApproved = /\bApproved\b/iu.test(statusValue);
621
+ const mentionsIssuesFound = /\bIssues Found\b/iu.test(statusValue);
622
+ const statusPickedExactlyOne = statusLineMatch !== null && (mentionsApproved !== mentionsIssuesFound);
623
+ const hasPatchesHeader = /^\s*-\s*Patches applied:/imu.test(sectionBody);
624
+ const hasConcernsHeader = /^\s*-\s*Remaining concerns:/imu.test(sectionBody);
625
+ if (statusPickedExactlyOne && hasPatchesHeader && hasConcernsHeader) {
626
+ return {
627
+ ok: true,
628
+ details: "Self-Review Notes use the calibrated review prompt format."
629
+ };
630
+ }
631
+ const problems = [];
632
+ if (!statusLineMatch) {
633
+ problems.push("missing `- Status:` line");
634
+ }
635
+ else if (!mentionsApproved && !mentionsIssuesFound) {
636
+ problems.push("`- Status:` must include `Approved` or `Issues Found`");
637
+ }
638
+ else if (mentionsApproved && mentionsIssuesFound) {
639
+ problems.push("`- Status:` must pick exactly one of `Approved` or `Issues Found` (the placeholder `Approved | Issues Found` is not a decision)");
640
+ }
641
+ if (!hasPatchesHeader)
642
+ problems.push("missing `- Patches applied:` line");
643
+ if (!hasConcernsHeader)
644
+ problems.push("missing `- Remaining concerns:` line");
645
+ return {
646
+ ok: false,
647
+ 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: " +
648
+ problems.join("; ") +
649
+ "."
650
+ };
651
+ }
652
+ export function validateRequirementsTaxonomy(sectionBody) {
653
+ const header = tableHeaderCells(sectionBody);
654
+ if (!header) {
655
+ return {
656
+ ok: false,
657
+ details: "Requirements must be a markdown table with a Priority column."
658
+ };
659
+ }
660
+ const priorityIndex = columnIndex(header, "priority");
661
+ if (priorityIndex < 0) {
662
+ return {
663
+ ok: false,
664
+ details: "Requirements table must include a canonical `Priority` column."
665
+ };
666
+ }
667
+ const rows = getMarkdownTableRows(sectionBody);
668
+ if (rows.length === 0) {
669
+ return {
670
+ ok: false,
671
+ details: "Requirements table must include at least one requirement row."
672
+ };
673
+ }
674
+ for (const [index, row] of rows.entries()) {
675
+ const rawPriority = (row[priorityIndex] ?? "").replace(/[`*_]/gu, "").trim().toUpperCase();
676
+ if (!REQUIREMENT_PRIORITY_VALUES.includes(rawPriority)) {
677
+ return {
678
+ ok: false,
679
+ details: `Requirements row ${index + 1} has invalid Priority "${row[priorityIndex] ?? ""}". Expected one of: ${REQUIREMENT_PRIORITY_VALUES.join(", ")}.`
680
+ };
681
+ }
682
+ }
683
+ return {
684
+ ok: true,
685
+ details: "Requirements table uses canonical Priority values."
686
+ };
687
+ }
688
+ export function validateLockedDecisionAnchors(sectionBody) {
689
+ const rows = getMarkdownTableRows(sectionBody);
690
+ const lines = sectionBody
691
+ .split(/\r?\n/u)
692
+ .map((line) => line.trim())
693
+ .filter((line) => /^[-*]\s+\S/u.test(line));
694
+ const anchors = [];
695
+ const issues = [];
696
+ for (const [index, row] of rows.entries()) {
697
+ const anchor = (row[0] ?? "").trim().toLowerCase();
698
+ const decisionText = (row[1] ?? "").trim();
699
+ if (!/^ld#[0-9a-f]{8}$/u.test(anchor)) {
700
+ issues.push(`row ${index + 1} has invalid anchor "${row[0] ?? ""}"`);
701
+ continue;
702
+ }
703
+ anchors.push(anchor);
704
+ if (decisionText.length > 0) {
705
+ const expected = lockedDecisionHash(decisionText).toLowerCase();
706
+ if (anchor !== expected) {
707
+ issues.push(`row ${index + 1} anchor should be ${expected} for its Decision text`);
708
+ }
709
+ }
710
+ }
711
+ for (const [index, line] of lines.entries()) {
712
+ const anchor = /\bLD#[0-9a-f]{8}\b/iu.exec(line)?.[0]?.toLowerCase();
713
+ if (!anchor) {
714
+ issues.push(`bullet ${index + 1} is missing an LD#<sha8> anchor`);
715
+ continue;
716
+ }
717
+ anchors.push(anchor);
718
+ }
719
+ const duplicateAnchors = [...new Set(anchors.filter((anchor, index) => anchors.indexOf(anchor) !== index))];
720
+ if (duplicateAnchors.length > 0) {
721
+ issues.push(`duplicate anchors: ${duplicateAnchors.join(", ")}`);
722
+ }
723
+ if (anchors.length === 0 && (rows.length > 0 || lines.length > 0)) {
724
+ issues.push("no LD#<sha8> anchors found");
725
+ }
726
+ return {
727
+ ok: issues.length === 0,
728
+ anchors: [...new Set(anchors)],
729
+ details: issues.length === 0
730
+ ? `${anchors.length} LD#hash anchor(s) recorded with no duplicates.`
731
+ : issues.join("; ")
732
+ };
733
+ }
734
+ export const INTERACTION_EDGE_CASE_REQUIREMENTS = [
735
+ { label: "double-click", pattern: /\bdouble[\s-]?click\b/iu },
736
+ {
737
+ label: "nav-away-mid-request",
738
+ 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
739
+ },
740
+ {
741
+ label: "10K-result dataset",
742
+ pattern: /\b(?:10k(?:[\s-]?result)?|10,?000|large[\s-]?result(?:[\s-]?dataset)?)\b/iu
743
+ },
744
+ {
745
+ label: "background-job abandonment",
746
+ pattern: /\b(?:background[\s-]?job.*abandon(?:ed|ment)?|abandon(?:ed|ment)?.*background[\s-]?job)\b/iu
747
+ },
748
+ { label: "zombie connection", pattern: /\bzombie[\s-]?connection\b/iu }
749
+ ];
750
+ export function validateInteractionEdgeCaseMatrix(sectionBody) {
751
+ const rows = getMarkdownTableRows(sectionBody);
752
+ if (rows.length === 0) {
753
+ return {
754
+ ok: false,
755
+ details: "Data Flow must include an Interaction Edge Case matrix table with required rows."
756
+ };
757
+ }
758
+ const seen = new Map();
759
+ for (const [index, row] of rows.entries()) {
760
+ const labelCell = (row[0] ?? "").trim();
761
+ if (!labelCell)
762
+ continue;
763
+ const requirement = INTERACTION_EDGE_CASE_REQUIREMENTS.find((candidate) => candidate.pattern.test(labelCell));
764
+ if (!requirement)
765
+ continue;
766
+ if (row.length < 4) {
767
+ return {
768
+ ok: false,
769
+ details: `Interaction Edge Case row "${requirement.label}" must include 4 columns: Edge case | Handled? | Design response | Deferred item.`
770
+ };
771
+ }
772
+ const handled = parseBinaryFlag((row[1] ?? "").trim());
773
+ const response = (row[2] ?? "").trim();
774
+ const deferred = (row[3] ?? "").trim();
775
+ if (handled === "unknown") {
776
+ return {
777
+ ok: false,
778
+ details: `Interaction Edge Case row "${requirement.label}" must mark Handled? as yes/no.`
779
+ };
780
+ }
781
+ if (!response) {
782
+ return {
783
+ ok: false,
784
+ details: `Interaction Edge Case row "${requirement.label}" must describe the design response.`
785
+ };
786
+ }
787
+ if (handled === "no" && (!deferred || /\bnone\b/iu.test(deferred))) {
788
+ return {
789
+ ok: false,
790
+ details: `Interaction Edge Case row "${requirement.label}" is unhandled and must reference a deferred item id (for example D-12).`
791
+ };
792
+ }
793
+ seen.set(requirement.label, true);
794
+ }
795
+ const missing = INTERACTION_EDGE_CASE_REQUIREMENTS
796
+ .map((requirement) => requirement.label)
797
+ .filter((label) => !seen.has(label));
798
+ if (missing.length > 0) {
799
+ return {
800
+ ok: false,
801
+ details: `Interaction Edge Case matrix is missing required row(s): ${missing.join(", ")}.`
802
+ };
803
+ }
804
+ return {
805
+ ok: true,
806
+ details: "Interaction Edge Case matrix contains all required rows with handled/deferred status."
807
+ };
808
+ }
809
+ export const PRE_SCOPE_AUDIT_SIGNALS = [
810
+ { label: "git log -30 --oneline", pattern: /\bgit\s+log\b[^\n]*-30[^\n]*\boneline\b/iu },
811
+ { label: "git diff --stat", pattern: /\bgit\s+diff\b[^\n]*--stat\b/iu },
812
+ { label: "git stash list", pattern: /\bgit\s+stash\s+list\b/iu },
813
+ {
814
+ label: "debt marker scan (TODO|FIXME|XXX|HACK)",
815
+ pattern: /\b(?:rg|ripgrep)\b[^\n]*(?:TODO|FIXME|XXX|HACK)|\bTODO\b|\bFIXME\b|\bXXX\b|\bHACK\b/iu
816
+ }
817
+ ];
818
+ export function validatePreScopeSystemAudit(sectionBody) {
819
+ const missing = PRE_SCOPE_AUDIT_SIGNALS
820
+ .filter((signal) => !signal.pattern.test(sectionBody))
821
+ .map((signal) => signal.label);
822
+ if (missing.length > 0) {
823
+ return {
824
+ ok: false,
825
+ details: `Pre-Scope System Audit is missing required signal(s): ${missing.join(", ")}.`
826
+ };
827
+ }
828
+ return {
829
+ ok: true,
830
+ details: "Pre-Scope System Audit captures git log/diff/stash/debt-marker checks."
831
+ };
832
+ }
833
+ export const DIAGRAM_ARROW_PATTERN = /(?:<--?>|<?==?>|--?>|->>|=>|-\.->|→|⟶|↦)/u;
834
+ 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;
835
+ export const DIAGRAM_GENERIC_NODE_PATTERN = /\b(service|component|module|system)\s*(?:[A-Z0-9])?\b/iu;
836
+ 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;
837
+ export const RED_FAILURE_MARKER_PATTERN = /\b(?:fail|failed|failing|assertionerror|cannot find|exception|error|exit code\s*[:=]?\s*[1-9])\b/iu;
838
+ export const GREEN_SUCCESS_MARKER_PATTERN = /\b(?:pass|passed|green|ok|0 failed|exit code\s*[:=]?\s*0)\b/iu;
839
+ export function diagramEdgeLines(sectionBody) {
840
+ return sectionBody
841
+ .split(/\r?\n/)
842
+ .map((line) => line.trim())
843
+ .filter((line) => line.length > 0)
844
+ .filter((line) => !line.startsWith("```"))
845
+ .filter((line) => !line.startsWith("%%"))
846
+ .filter((line) => DIAGRAM_ARROW_PATTERN.test(line));
847
+ }
848
+ export function hasFailureEdgeInDiagram(sectionBody) {
849
+ const lines = diagramEdgeLines(sectionBody);
850
+ for (const line of lines) {
851
+ if (DIAGRAM_ARROW_PATTERN.test(line) && DIAGRAM_FAILURE_EDGE_PATTERN.test(line)) {
852
+ return true;
853
+ }
854
+ }
855
+ return false;
856
+ }
857
+ export function hasLabeledDiagramArrow(lines) {
858
+ return lines.some((line) => /\|[^|]+\|/u.test(line) || /:\s*[A-Za-z]/u.test(line));
859
+ }
860
+ export function hasAsyncDiagramEdge(lines) {
861
+ return lines.some((line) => /-\.->|-->>|~~>|\basync\b/iu.test(line));
862
+ }
863
+ export function hasSyncDiagramEdge(lines) {
864
+ return lines.some((line) => {
865
+ if (/\bsync\b/iu.test(line))
866
+ return true;
867
+ if (!/(-->|->|=>|→|⟶|↦)/u.test(line))
868
+ return false;
869
+ return !/-\.->|-->>|~~>/u.test(line);
870
+ });
871
+ }
872
+ export function validateTddRedEvidence(sectionBody) {
873
+ const meaningful = meaningfulLineCount(sectionBody);
874
+ if (meaningful < 2) {
875
+ return {
876
+ ok: false,
877
+ details: "RED Evidence must include at least 2 meaningful lines (command plus failing output context)."
878
+ };
879
+ }
880
+ if (!TEST_COMMAND_MARKER_PATTERN.test(sectionBody)) {
881
+ return {
882
+ ok: false,
883
+ details: "RED Evidence must include the test command that produced the failure."
884
+ };
885
+ }
886
+ if (!RED_FAILURE_MARKER_PATTERN.test(sectionBody)) {
887
+ return {
888
+ ok: false,
889
+ details: "RED Evidence must include explicit failing output markers (FAIL/FAILED/AssertionError/exit code != 0)."
890
+ };
891
+ }
892
+ return {
893
+ ok: true,
894
+ details: "RED Evidence includes command + failing output markers."
895
+ };
896
+ }
897
+ export function validateTddGreenEvidence(sectionBody) {
898
+ const meaningful = meaningfulLineCount(sectionBody);
899
+ if (meaningful < 2) {
900
+ return {
901
+ ok: false,
902
+ details: "GREEN Evidence must include at least 2 meaningful lines (command and passing result)."
903
+ };
904
+ }
905
+ if (!TEST_COMMAND_MARKER_PATTERN.test(sectionBody)) {
906
+ return {
907
+ ok: false,
908
+ details: "GREEN Evidence must include the full-suite test command."
909
+ };
910
+ }
911
+ if (!GREEN_SUCCESS_MARKER_PATTERN.test(sectionBody)) {
912
+ return {
913
+ ok: false,
914
+ details: "GREEN Evidence must include explicit passing markers (PASS/PASSED/OK/exit code 0)."
915
+ };
916
+ }
917
+ return {
918
+ ok: true,
919
+ details: "GREEN Evidence includes command + passing output markers."
920
+ };
921
+ }
922
+ export function validateVerificationLadder(sectionBody) {
923
+ const hasTextLine = /highest tier reached/iu.test(sectionBody);
924
+ const hasCanonicalTable = hasVerificationLadderTableRow(sectionBody);
925
+ if (!hasTextLine && !hasCanonicalTable) {
926
+ return {
927
+ ok: false,
928
+ 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."
929
+ };
930
+ }
931
+ if (!/\b(static|command|behavioral|human)\b/iu.test(sectionBody)) {
932
+ return {
933
+ ok: false,
934
+ details: "Verification Ladder must name a tier (static | command | behavioral | human)."
935
+ };
936
+ }
937
+ if (!/\b(evidence|command|sha|commit)\b/iu.test(sectionBody)) {
938
+ return {
939
+ ok: false,
940
+ details: "Verification Ladder must include evidence details (command output or commit SHA)."
941
+ };
942
+ }
943
+ return {
944
+ ok: true,
945
+ details: "Verification Ladder includes tier + evidence fields."
946
+ };
947
+ }
948
+ export function hasVerificationLadderTableRow(sectionBody) {
949
+ const lines = sectionBody.split(/\r?\n/u);
950
+ let sawHeader = false;
951
+ let sawSeparator = false;
952
+ for (const line of lines) {
953
+ const trimmed = line.trim();
954
+ if (!trimmed.startsWith("|")) {
955
+ sawHeader = false;
956
+ sawSeparator = false;
957
+ continue;
958
+ }
959
+ const cells = trimmed
960
+ .replace(/^\|/u, "")
961
+ .replace(/\|$/u, "")
962
+ .split("|")
963
+ .map((cell) => cell.trim());
964
+ if (!sawHeader) {
965
+ const lowered = cells.map((cell) => cell.toLowerCase());
966
+ const hasTierColumn = lowered.some((cell) => /tier(?:\s+reached)?/u.test(cell));
967
+ const hasEvidenceColumn = lowered.some((cell) => cell.includes("evidence"));
968
+ if (hasTierColumn && hasEvidenceColumn) {
969
+ sawHeader = true;
970
+ continue;
971
+ }
972
+ continue;
973
+ }
974
+ if (!sawSeparator) {
975
+ if (cells.every((cell) => /^[:\-\s]+$/u.test(cell))) {
976
+ sawSeparator = true;
977
+ continue;
978
+ }
979
+ sawHeader = false;
980
+ continue;
981
+ }
982
+ if (cells.length >= 2 && cells.some((cell) => /\b(static|command|behavioral|human)\b/iu.test(cell))) {
983
+ const evidenceCellHasContent = cells.some((cell) => cell.length > 0 && !/^\s*$/u.test(cell) && !/^[:\-\s]+$/u.test(cell));
984
+ if (evidenceCellHasContent) {
985
+ return true;
986
+ }
987
+ }
988
+ }
989
+ return false;
990
+ }
991
+ export const LEARNING_TYPE_SET = new Set(["rule", "pattern", "lesson", "compound"]);
992
+ export const LEARNING_CONFIDENCE_SET = new Set(["high", "medium", "low"]);
993
+ export const LEARNING_SEVERITY_SET = new Set(["critical", "important", "suggestion"]);
994
+ export const LEARNING_UNIVERSALITY_SET = new Set(["project", "personal", "universal"]);
995
+ export const LEARNING_MATURITY_SET = new Set(["raw", "lifted-to-rule", "lifted-to-enforcement"]);
996
+ export const LEARNING_SOURCE_SET = new Set([
997
+ "stage",
998
+ "retro",
999
+ "compound",
1000
+ "idea",
1001
+ "manual"
1002
+ ]);
1003
+ export const FLOW_STAGE_SET = new Set(FLOW_STAGES);
1004
+ export const LEARNING_ALLOWED_KEYS = new Set([
1005
+ "type",
1006
+ "trigger",
1007
+ "action",
1008
+ "confidence",
1009
+ "severity",
1010
+ "domain",
1011
+ "stage",
1012
+ "origin_stage",
1013
+ "origin_run",
1014
+ "frequency",
1015
+ "universality",
1016
+ "maturity",
1017
+ "created",
1018
+ "first_seen_ts",
1019
+ "last_seen_ts",
1020
+ "project",
1021
+ "source",
1022
+ "supersedes",
1023
+ "superseded_by"
1024
+ ]);
1025
+ export function isIsoUtcTimestamp(value) {
1026
+ return /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/u.test(value);
1027
+ }
1028
+ export function isNullableString(value) {
1029
+ return value === null || typeof value === "string";
1030
+ }
1031
+ export function isNullableStage(value) {
1032
+ return value === null || (typeof value === "string" && FLOW_STAGE_SET.has(value));
1033
+ }
1034
+ export function parseLearningSeedEntry(raw, index) {
1035
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
1036
+ return { ok: false, error: `Learnings bullet #${index} must be a JSON object.` };
1037
+ }
1038
+ const obj = raw;
1039
+ for (const key of Object.keys(obj)) {
1040
+ if (!LEARNING_ALLOWED_KEYS.has(key)) {
1041
+ return {
1042
+ ok: false,
1043
+ error: `Learnings bullet #${index} includes unknown key "${key}" (allowed keys mirror knowledge JSONL fields).`
1044
+ };
1045
+ }
1046
+ }
1047
+ const type = typeof obj.type === "string" ? obj.type.toLowerCase() : "";
1048
+ if (!LEARNING_TYPE_SET.has(type)) {
1049
+ return {
1050
+ ok: false,
1051
+ error: `Learnings bullet #${index} must set type to one of: rule, pattern, lesson, compound.`
1052
+ };
1053
+ }
1054
+ const trigger = typeof obj.trigger === "string" ? obj.trigger.trim() : "";
1055
+ if (trigger.length === 0) {
1056
+ return {
1057
+ ok: false,
1058
+ error: `Learnings bullet #${index} must include non-empty "trigger".`
1059
+ };
1060
+ }
1061
+ const action = typeof obj.action === "string" ? obj.action.trim() : "";
1062
+ if (action.length === 0) {
1063
+ return {
1064
+ ok: false,
1065
+ error: `Learnings bullet #${index} must include non-empty "action".`
1066
+ };
1067
+ }
1068
+ const confidence = typeof obj.confidence === "string" ? obj.confidence.toLowerCase() : "";
1069
+ if (!LEARNING_CONFIDENCE_SET.has(confidence)) {
1070
+ return {
1071
+ ok: false,
1072
+ error: `Learnings bullet #${index} must set confidence to high|medium|low.`
1073
+ };
1074
+ }
1075
+ const severity = typeof obj.severity === "string" ? obj.severity.toLowerCase() : undefined;
1076
+ if (severity !== undefined && !LEARNING_SEVERITY_SET.has(severity)) {
1077
+ return {
1078
+ ok: false,
1079
+ error: `Learnings bullet #${index} field "severity" must be critical|important|suggestion.`
1080
+ };
1081
+ }
1082
+ if (obj.domain !== undefined && !isNullableString(obj.domain)) {
1083
+ return { ok: false, error: `Learnings bullet #${index} field "domain" must be string or null.` };
1084
+ }
1085
+ if (obj.stage !== undefined && !isNullableStage(obj.stage)) {
1086
+ return {
1087
+ ok: false,
1088
+ error: `Learnings bullet #${index} field "stage" must be one of ${FLOW_STAGES.join(", ")} or null.`
1089
+ };
1090
+ }
1091
+ if (obj.origin_stage !== undefined && !isNullableStage(obj.origin_stage)) {
1092
+ return {
1093
+ ok: false,
1094
+ error: `Learnings bullet #${index} field "origin_stage" must be one of ${FLOW_STAGES.join(", ")} or null.`
1095
+ };
1096
+ }
1097
+ if (obj.origin_run !== undefined && !isNullableString(obj.origin_run)) {
1098
+ return { ok: false, error: `Learnings bullet #${index} field "origin_run" must be string or null.` };
1099
+ }
1100
+ if (obj.project !== undefined && !isNullableString(obj.project)) {
1101
+ return { ok: false, error: `Learnings bullet #${index} field "project" must be string or null.` };
1102
+ }
1103
+ if (obj.source !== undefined &&
1104
+ obj.source !== null &&
1105
+ (typeof obj.source !== "string" || !LEARNING_SOURCE_SET.has(obj.source))) {
1106
+ return {
1107
+ ok: false,
1108
+ error: `Learnings bullet #${index} field "source" must be stage|retro|compound|idea|manual or null.`
1109
+ };
1110
+ }
1111
+ if (obj.frequency !== undefined &&
1112
+ (typeof obj.frequency !== "number" || !Number.isInteger(obj.frequency) || obj.frequency < 1)) {
1113
+ return { ok: false, error: `Learnings bullet #${index} field "frequency" must be an integer >= 1.` };
1114
+ }
1115
+ if (obj.universality !== undefined &&
1116
+ (typeof obj.universality !== "string" ||
1117
+ !LEARNING_UNIVERSALITY_SET.has(obj.universality))) {
1118
+ return {
1119
+ ok: false,
1120
+ error: `Learnings bullet #${index} field "universality" must be project|personal|universal.`
1121
+ };
1122
+ }
1123
+ if (obj.maturity !== undefined &&
1124
+ (typeof obj.maturity !== "string" || !LEARNING_MATURITY_SET.has(obj.maturity))) {
1125
+ return {
1126
+ ok: false,
1127
+ error: `Learnings bullet #${index} field "maturity" must be raw|lifted-to-rule|lifted-to-enforcement.`
1128
+ };
1129
+ }
1130
+ for (const timestampField of ["created", "first_seen_ts", "last_seen_ts"]) {
1131
+ const value = obj[timestampField];
1132
+ if (value === undefined)
1133
+ continue;
1134
+ if (typeof value !== "string" || !isIsoUtcTimestamp(value)) {
1135
+ return {
1136
+ ok: false,
1137
+ error: `Learnings bullet #${index} field "${timestampField}" must be ISO UTC (YYYY-MM-DDTHH:MM:SSZ).`
1138
+ };
1139
+ }
1140
+ }
1141
+ if (obj.supersedes !== undefined) {
1142
+ if (!Array.isArray(obj.supersedes) ||
1143
+ obj.supersedes.length === 0 ||
1144
+ obj.supersedes.some((value) => typeof value !== "string" || value.trim().length === 0)) {
1145
+ return { ok: false, error: `Learnings bullet #${index} field "supersedes" must be a non-empty array of strings.` };
1146
+ }
1147
+ }
1148
+ if (obj.superseded_by !== undefined &&
1149
+ (typeof obj.superseded_by !== "string" || obj.superseded_by.trim().length === 0)) {
1150
+ return { ok: false, error: `Learnings bullet #${index} field "superseded_by" must be a non-empty string.` };
1151
+ }
1152
+ return {
1153
+ ok: true,
1154
+ entry: {
1155
+ ...obj,
1156
+ type: type,
1157
+ trigger,
1158
+ action,
1159
+ confidence: confidence,
1160
+ ...(severity ? { severity: severity } : {})
1161
+ }
1162
+ };
1163
+ }
1164
+ export function parseLearningsSection(sectionBody) {
1165
+ const lines = sectionBody.split(/\r?\n/).map((line) => line.trim());
1166
+ const nonEmpty = lines.filter((line) => line.length > 0);
1167
+ const bullets = nonEmpty.filter((line) => /^-\s+\S+/u.test(line));
1168
+ if (bullets.length === 0) {
1169
+ return {
1170
+ ok: false,
1171
+ none: false,
1172
+ entries: [],
1173
+ errors: ["Learnings section must contain bullet entries."],
1174
+ details: "Learnings section must contain bullet entries."
1175
+ };
1176
+ }
1177
+ const nonBulletContent = nonEmpty.filter((line) => !/^-\s+\S+/u.test(line));
1178
+ if (nonBulletContent.length > 0) {
1179
+ return {
1180
+ ok: false,
1181
+ none: false,
1182
+ entries: [],
1183
+ errors: ["Learnings section must only contain bullet lines (one bullet per learning)."],
1184
+ details: "Learnings section must only contain bullet lines (one bullet per learning)."
1185
+ };
1186
+ }
1187
+ if (bullets.length === 1) {
1188
+ const payload = bullets[0].replace(/^-\s+/u, "").trim();
1189
+ if (/^none this stage\.?$/iu.test(payload)) {
1190
+ return {
1191
+ ok: true,
1192
+ none: true,
1193
+ entries: [],
1194
+ errors: [],
1195
+ details: "Learnings section explicitly marked as none."
1196
+ };
1197
+ }
1198
+ }
1199
+ const entries = [];
1200
+ const errors = [];
1201
+ for (let i = 0; i < bullets.length; i += 1) {
1202
+ const payload = bullets[i].replace(/^-\s+/u, "").trim();
1203
+ let parsed;
1204
+ try {
1205
+ parsed = JSON.parse(payload);
1206
+ }
1207
+ catch (err) {
1208
+ errors.push(`Learnings bullet #${i + 1} must be valid JSON object or "None this stage.": ${err instanceof Error ? err.message : String(err)}`);
1209
+ continue;
1210
+ }
1211
+ const parsedEntry = parseLearningSeedEntry(parsed, i + 1);
1212
+ if (!parsedEntry.ok || !parsedEntry.entry) {
1213
+ errors.push(parsedEntry.error ?? `Learnings bullet #${i + 1} is invalid.`);
1214
+ continue;
1215
+ }
1216
+ entries.push(parsedEntry.entry);
1217
+ }
1218
+ if (errors.length > 0) {
1219
+ return {
1220
+ ok: false,
1221
+ none: false,
1222
+ entries: [],
1223
+ errors,
1224
+ details: errors.join(" | ")
1225
+ };
1226
+ }
1227
+ return {
1228
+ ok: true,
1229
+ none: false,
1230
+ entries,
1231
+ errors: [],
1232
+ details: `Parsed ${entries.length} learning bullet(s) as knowledge-compatible JSON entries.`
1233
+ };
1234
+ }
1235
+ export function lineContainsVagueAdjective(text) {
1236
+ const lower = text.toLowerCase();
1237
+ for (const adjective of VAGUE_AC_ADJECTIVES) {
1238
+ const pattern = new RegExp(`(?:^|[^A-Za-z])${adjective.replace(/ /g, "\\s+")}(?:[^A-Za-z]|$)`, "iu");
1239
+ if (pattern.test(lower))
1240
+ return adjective;
1241
+ }
1242
+ return null;
1243
+ }
1244
+ export const FRONTMATTER_REQUIRED_KEYS = [
1245
+ "stage",
1246
+ "schema_version",
1247
+ "version",
1248
+ "locked_decisions",
1249
+ "inputs_hash"
1250
+ ];
1251
+ export const PLACEHOLDER_PATTERNS = [
1252
+ { label: "TODO", regex: /\bTODO\b/iu },
1253
+ { label: "TBD", regex: /\bTBD\b/iu },
1254
+ { label: "FIXME", regex: /\bFIXME\b/iu },
1255
+ { label: "<fill-in>", regex: /<fill-in>/iu },
1256
+ { label: "<your-*-here>", regex: /<your-[^>]*-here>/iu },
1257
+ { label: "xxx", regex: /\bxxx\b/iu },
1258
+ { label: "ellipsis", regex: /\.{3}/u }
1259
+ ];
1260
+ export const SCOPE_REDUCTION_PATTERNS = [
1261
+ { label: "v1", regex: /\bv1\b/iu },
1262
+ { label: "for now", regex: /\bfor now\b/iu },
1263
+ { label: "later", regex: /\blater\b/iu },
1264
+ { label: "temporary", regex: /\btemporary\b/iu },
1265
+ { label: "placeholder", regex: /\bplaceholder\b/iu },
1266
+ { label: "mock for now", regex: /\bmock for now\b/iu },
1267
+ { label: "hardcoded for now", regex: /\bhardcoded for now\b/iu },
1268
+ { label: "will improve later", regex: /\bwill improve later\b/iu }
1269
+ ];
1270
+ export function parseFrontmatter(markdown) {
1271
+ const lines = markdown.split(/\r?\n/);
1272
+ if (lines[0]?.trim() !== "---") {
1273
+ return { hasFrontmatter: false, values: {} };
1274
+ }
1275
+ const endIndex = lines.findIndex((line, index) => index > 0 && line.trim() === "---");
1276
+ if (endIndex < 0) {
1277
+ return { hasFrontmatter: false, values: {} };
1278
+ }
1279
+ const values = {};
1280
+ for (const line of lines.slice(1, endIndex)) {
1281
+ const match = /^([A-Za-z0-9_-]+)\s*:\s*(.*)$/u.exec(line.trim());
1282
+ if (!match)
1283
+ continue;
1284
+ const key = match[1];
1285
+ const value = match[2].trim();
1286
+ values[key] = value;
1287
+ }
1288
+ return { hasFrontmatter: true, values };
1289
+ }
1290
+ export function extractDecisionIds(text) {
1291
+ const ids = text.match(/\bD-\d+\b/gu) ?? [];
1292
+ return [...new Set(ids)];
1293
+ }
1294
+ export function extractRequirementIdsFromMarkdown(text) {
1295
+ const ids = text.match(/\bR\d+\b/gu) ?? [];
1296
+ return [...new Set(ids)];
1297
+ }
1298
+ export function extractLockedDecisionAnchors(text) {
1299
+ const ids = text.match(/\bLD#[0-9a-f]{8}\b/giu) ?? [];
1300
+ return [...new Set(ids.map((id) => id.replace(/^LD#/iu, "LD#").toLowerCase()))];
1301
+ }
1302
+ export function lockedDecisionHash(value) {
1303
+ const normalized = value.replace(/\s+/gu, " ").trim().toLowerCase();
1304
+ return `LD#${createHash("sha256").update(normalized).digest("hex").slice(0, 8)}`;
1305
+ }
1306
+ export function collectPatternHits(text, patterns) {
1307
+ const hits = [];
1308
+ for (const pattern of patterns) {
1309
+ if (pattern.regex.test(text)) {
1310
+ hits.push(pattern.label);
1311
+ }
1312
+ }
1313
+ return hits;
1314
+ }
1315
+ export function validateSectionBody(sectionBody, rule, sectionName) {
1316
+ const bodyLines = sectionBody.split(/\r?\n/).map((line) => line.trim());
1317
+ const meaningful = meaningfulLineCount(sectionBody);
1318
+ if (meaningful === 0) {
1319
+ return {
1320
+ ok: false,
1321
+ details: "Section exists but has no meaningful content yet."
1322
+ };
1323
+ }
1324
+ const minItems = extractMinItemsFromRule(rule);
1325
+ if (minItems !== null) {
1326
+ const count = countListItems(sectionBody);
1327
+ if (count < minItems) {
1328
+ return {
1329
+ ok: false,
1330
+ details: `Rule expects at least ${minItems} item(s), found ${count}.`
1331
+ };
1332
+ }
1333
+ }
1334
+ if (/table must use 4 columns/iu.test(rule)) {
1335
+ const header = tableHeaderCells(sectionBody);
1336
+ if (!header) {
1337
+ return {
1338
+ ok: false,
1339
+ details: "Rule expects a markdown table header with a separator row."
1340
+ };
1341
+ }
1342
+ const expected = ["Category", "Question asked", "User answer", "Evidence note"];
1343
+ const normalizedHeader = header.map((cell) => cell.toLowerCase());
1344
+ const normalizedExpected = expected.map((cell) => cell.toLowerCase());
1345
+ const matches = normalizedHeader.length === normalizedExpected.length &&
1346
+ normalizedHeader.every((cell, index) => cell === normalizedExpected[index]);
1347
+ if (!matches) {
1348
+ return {
1349
+ ok: false,
1350
+ details: `Rule expects Clarification Log header: ${expected.join(" | ")}.`
1351
+ };
1352
+ }
1353
+ }
1354
+ if (/exactly one/iu.test(rule)) {
1355
+ const tokens = tokensFromRule(rule);
1356
+ if (tokens.length > 0) {
1357
+ const selected = new Set();
1358
+ const tokenLines = [];
1359
+ for (const line of bodyLines) {
1360
+ if (!line)
1361
+ continue;
1362
+ for (const token of tokens) {
1363
+ if (!lineHasToken(line, token))
1364
+ continue;
1365
+ tokenLines.push({ line, token });
1366
+ if (/\[x\]/iu.test(line) || /selected|verdict|enum|execution result|status/iu.test(line)) {
1367
+ selected.add(token);
1368
+ }
1369
+ }
1370
+ }
1371
+ if (selected.size === 0 && tokenLines.length === 1 && !tokenLines[0].line.includes("|")) {
1372
+ selected.add(tokenLines[0].token);
1373
+ }
1374
+ if (selected.size !== 1) {
1375
+ return {
1376
+ ok: false,
1377
+ details: `Rule expects exactly one selected token (${tokens.join(", ")}); found ${selected.size}.`
1378
+ };
1379
+ }
1380
+ return { ok: true, details: "Exactly one token selected as expected." };
1381
+ }
1382
+ }
1383
+ if (/Status:\s*pending\s+until/iu.test(rule)) {
1384
+ const statusLine = bodyLines.find((l) => /^\s*-?\s*Status\s*:/iu.test(l));
1385
+ if (!statusLine) {
1386
+ return { ok: false, details: "WAIT_FOR_CONFIRM section must contain a 'Status:' line." };
1387
+ }
1388
+ const validStatuses = ["pending", "approved"];
1389
+ const statusMatch = /Status\s*:\s*(\S+)/iu.exec(statusLine);
1390
+ const statusValue = statusMatch?.[1]?.toLowerCase();
1391
+ if (!statusValue || !validStatuses.includes(statusValue)) {
1392
+ const foundLabel = statusValue || "(empty)";
1393
+ return {
1394
+ ok: false,
1395
+ details: "WAIT_FOR_CONFIRM Status must be exactly one of: " + validStatuses.join(", ") + ". Found: " + foundLabel + "."
1396
+ };
1397
+ }
1398
+ }
1399
+ const sectionNameNormalized = normalizeHeadingTitle(sectionName).toLowerCase();
1400
+ if (sectionNameNormalized === "red evidence") {
1401
+ return validateTddRedEvidence(sectionBody);
1402
+ }
1403
+ if (sectionNameNormalized === "green evidence") {
1404
+ return validateTddGreenEvidence(sectionBody);
1405
+ }
1406
+ if (sectionNameNormalized === "verification ladder") {
1407
+ return validateVerificationLadder(sectionBody);
1408
+ }
1409
+ if (sectionNameNormalized === "failure mode table") {
1410
+ return validateFailureModeTable(sectionBody);
1411
+ }
1412
+ if (sectionNameNormalized === "pre-scope system audit") {
1413
+ return validatePreScopeSystemAudit(sectionBody);
1414
+ }
1415
+ if (sectionNameNormalized === "scope summary") {
1416
+ return validateScopeSummary(sectionBody);
1417
+ }
1418
+ if (sectionNameNormalized === "premise challenge") {
1419
+ return validatePremiseChallenge(sectionBody);
1420
+ }
1421
+ if (sectionNameNormalized.startsWith("requirements")) {
1422
+ return validateRequirementsTaxonomy(sectionBody);
1423
+ }
1424
+ if (sectionNameNormalized === "data flow") {
1425
+ return validateInteractionEdgeCaseMatrix(sectionBody);
1426
+ }
1427
+ if (sectionNameNormalized === "architecture diagram") {
1428
+ const edgeLines = diagramEdgeLines(sectionBody);
1429
+ if (edgeLines.length === 0) {
1430
+ return {
1431
+ ok: false,
1432
+ details: "Architecture Diagram must include at least one directional edge line (for example `A -->|action| B`)."
1433
+ };
1434
+ }
1435
+ if (!hasLabeledDiagramArrow(edgeLines)) {
1436
+ return {
1437
+ ok: false,
1438
+ details: "Architecture Diagram must label each edge with an action/message (for example `A -->|sync: persist| B`)."
1439
+ };
1440
+ }
1441
+ const genericLine = edgeLines.find((line) => DIAGRAM_GENERIC_NODE_PATTERN.test(line));
1442
+ if (genericLine) {
1443
+ return {
1444
+ ok: false,
1445
+ details: `Architecture Diagram uses a generic node label in edge "${genericLine}". Use concrete component names instead of placeholders like Service/Component.`
1446
+ };
1447
+ }
1448
+ if (!hasAsyncDiagramEdge(edgeLines) || !hasSyncDiagramEdge(edgeLines)) {
1449
+ return {
1450
+ ok: false,
1451
+ details: "Architecture Diagram must distinguish sync vs async edges (for example solid + dotted arrows, or `sync:` and `async:` labels)."
1452
+ };
1453
+ }
1454
+ if (!hasFailureEdgeInDiagram(sectionBody)) {
1455
+ return {
1456
+ ok: false,
1457
+ details: "Architecture Diagram must include at least one failure-edge arrow with a failure keyword (for example: timeout, error, fallback, degraded, retry)."
1458
+ };
1459
+ }
1460
+ }
1461
+ if (sectionNameNormalized === "acceptance criteria" &&
1462
+ /observable[\s,]*measurable[\s,]+(and )?falsifiable/iu.test(rule)) {
1463
+ const rows = getMarkdownTableRows(sectionBody);
1464
+ for (const row of rows) {
1465
+ const criterionText = row[1] ?? row[0] ?? "";
1466
+ const adjective = lineContainsVagueAdjective(criterionText);
1467
+ if (adjective) {
1468
+ return {
1469
+ ok: false,
1470
+ details: `Acceptance criterion uses vague adjective "${adjective}" without a measurable predicate: "${criterionText.slice(0, 140)}". Rewrite with a numeric threshold or boolean outcome.`
1471
+ };
1472
+ }
1473
+ const hasDigit = /\d/u.test(criterionText);
1474
+ 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);
1475
+ const hasMeaningfulText = /[A-Za-z]/u.test(criterionText) && criterionText.trim().length >= 12;
1476
+ if (hasMeaningfulText && !hasDigit && !hasMeasurableVerb) {
1477
+ return {
1478
+ ok: false,
1479
+ 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.`
1480
+ };
1481
+ }
1482
+ }
1483
+ }
1484
+ return {
1485
+ ok: true,
1486
+ details: "Section heading and content satisfy lint heuristics."
1487
+ };
1488
+ }