cclaw-cli 0.48.31 → 0.48.32

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.
@@ -1,6 +1,7 @@
1
1
  import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { resolveArtifactPath as resolveStageArtifactPath } from "./artifact-paths.js";
4
+ import { readConfig } from "./config.js";
4
5
  import { RUNTIME_ROOT, SHIP_FINALIZATION_MODES } from "./constants.js";
5
6
  import { exists } from "./fs-utils.js";
6
7
  import { stageSchema } from "./content/stage-schema.js";
@@ -87,6 +88,154 @@ function sectionBodyByName(sections, section) {
87
88
  export function extractMarkdownSectionBody(markdown, section) {
88
89
  return sectionBodyByName(extractH2Sections(markdown), section);
89
90
  }
91
+ function headingLineIndex(markdown, section) {
92
+ const want = normalizeHeadingTitle(section).toLowerCase();
93
+ const lines = markdown.split(/\r?\n/);
94
+ let fenced = null;
95
+ for (let i = 0; i < lines.length; i++) {
96
+ const line = lines[i];
97
+ const fence = /^\s*(```+|~~~+)\s*([A-Za-z0-9_-]+)?\s*$/u.exec(line);
98
+ if (fence) {
99
+ const marker = fence[1];
100
+ if (fenced === null) {
101
+ fenced = marker;
102
+ }
103
+ else if (fenced === marker) {
104
+ fenced = null;
105
+ }
106
+ continue;
107
+ }
108
+ if (fenced !== null)
109
+ continue;
110
+ const heading = /^##\s+(.+)$/u.exec(line);
111
+ if (!heading)
112
+ continue;
113
+ if (normalizeHeadingTitle(heading[1] ?? "").toLowerCase() === want) {
114
+ return i;
115
+ }
116
+ }
117
+ return -1;
118
+ }
119
+ function parseShortCircuitStatus(sectionBody) {
120
+ if (!sectionBody)
121
+ return "";
122
+ const lines = sectionBody.split(/\r?\n/u);
123
+ return lines
124
+ .map((line) => line.replace(/[*_`]/gu, "").trim())
125
+ .map((line) => /^[-*]?\s*status\s*:\s*(.+)$/iu.exec(line)?.[1] ?? "")
126
+ .find((value) => value.trim().length > 0)?.trim().toLowerCase() ?? "";
127
+ }
128
+ function isShortCircuitActivated(sectionBody) {
129
+ const statusValue = parseShortCircuitStatus(sectionBody);
130
+ return /^(?:activated|yes|true)$/u.test(statusValue) || /\bactivated\b/iu.test(statusValue);
131
+ }
132
+ const DESIGN_DIAGRAM_REQUIREMENTS = {
133
+ lightweight: [
134
+ {
135
+ section: "Architecture Diagram",
136
+ marker: "architecture",
137
+ note: "Architecture diagram is required for all tiers."
138
+ }
139
+ ],
140
+ standard: [
141
+ {
142
+ section: "Architecture Diagram",
143
+ marker: "architecture",
144
+ note: "Architecture diagram is required for all tiers."
145
+ },
146
+ {
147
+ section: "Data-Flow Shadow Paths",
148
+ marker: "data-flow-shadow-paths",
149
+ note: "Standard+ requires data-flow shadow path coverage."
150
+ },
151
+ {
152
+ section: "Error Flow Diagram",
153
+ marker: "error-flow",
154
+ note: "Standard+ requires explicit error-flow rescue mapping."
155
+ }
156
+ ],
157
+ deep: [
158
+ {
159
+ section: "Architecture Diagram",
160
+ marker: "architecture",
161
+ note: "Architecture diagram is required for all tiers."
162
+ },
163
+ {
164
+ section: "Data-Flow Shadow Paths",
165
+ marker: "data-flow-shadow-paths",
166
+ note: "Standard+ requires data-flow shadow path coverage."
167
+ },
168
+ {
169
+ section: "Error Flow Diagram",
170
+ marker: "error-flow",
171
+ note: "Standard+ requires explicit error-flow rescue mapping."
172
+ },
173
+ {
174
+ section: "State Machine Diagram",
175
+ marker: "state-machine",
176
+ note: "Deep tier requires state-machine coverage for lifecycle transitions."
177
+ },
178
+ {
179
+ section: "Rollback Flowchart",
180
+ marker: "rollback-flowchart",
181
+ note: "Deep tier requires rollback flowchart coverage."
182
+ },
183
+ {
184
+ section: "Deployment Sequence Diagram",
185
+ marker: "deployment-sequence",
186
+ note: "Deep tier requires deployment sequence coverage."
187
+ }
188
+ ]
189
+ };
190
+ function normalizeDesignDiagramTier(value) {
191
+ if (!value)
192
+ return null;
193
+ const normalized = value.trim().toLowerCase();
194
+ if (/^light(?:weight)?$/u.test(normalized))
195
+ return "lightweight";
196
+ if (/^standard$/u.test(normalized))
197
+ return "standard";
198
+ if (/^deep$/u.test(normalized))
199
+ return "deep";
200
+ return null;
201
+ }
202
+ function parseApproachTierSection(sectionBody) {
203
+ if (!sectionBody)
204
+ return null;
205
+ for (const line of sectionBody.split(/\r?\n/u)) {
206
+ const cleaned = line.replace(/[*_`]/gu, "").trim();
207
+ const directMatch = /(?:^|\b)tier\s*:\s*(lightweight|light|standard|deep)\b/iu.exec(cleaned);
208
+ if (directMatch) {
209
+ return normalizeDesignDiagramTier(directMatch[1] ?? null);
210
+ }
211
+ }
212
+ const token = /\b(lightweight|light|standard|deep)\b/iu.exec(sectionBody)?.[1] ?? null;
213
+ return normalizeDesignDiagramTier(token);
214
+ }
215
+ async function resolveDesignDiagramTier(projectRoot, track, designRaw) {
216
+ const fromDesign = parseApproachTierSection(extractMarkdownSectionBody(designRaw, "Approach Tier"));
217
+ if (fromDesign) {
218
+ return { tier: fromDesign, source: "design-artifact:Approach Tier" };
219
+ }
220
+ try {
221
+ const brainstormArtifact = await resolveStageArtifactPath("brainstorm", {
222
+ projectRoot,
223
+ track,
224
+ intent: "read"
225
+ });
226
+ if (await exists(brainstormArtifact.absPath)) {
227
+ const brainstormRaw = await fs.readFile(brainstormArtifact.absPath, "utf8");
228
+ const fromBrainstorm = parseApproachTierSection(extractMarkdownSectionBody(brainstormRaw, "Approach Tier"));
229
+ if (fromBrainstorm) {
230
+ return { tier: fromBrainstorm, source: "brainstorm-artifact:Approach Tier" };
231
+ }
232
+ }
233
+ }
234
+ catch {
235
+ // Ignore read/resolve errors and fall back to default tier.
236
+ }
237
+ return { tier: "standard", source: "default:standard" };
238
+ }
90
239
  function meaningfulLineCount(sectionBody) {
91
240
  return sectionBody
92
241
  .split(/\r?\n/)
@@ -206,6 +355,296 @@ function getMarkdownTableRows(sectionBody) {
206
355
  }
207
356
  return rows;
208
357
  }
358
+ function parseBinaryFlag(value) {
359
+ const normalized = value.trim().toLowerCase();
360
+ if (/^(?:y|yes|true|1)$/u.test(normalized))
361
+ return "yes";
362
+ if (/^(?:n|no|false|0|none)$/u.test(normalized))
363
+ return "no";
364
+ return "unknown";
365
+ }
366
+ function parseKeyedBinaryFlag(value, key) {
367
+ const match = new RegExp(`${key}\\s*=\\s*(y|yes|true|1|n|no|false|0)`, "iu").exec(value);
368
+ if (!match)
369
+ return "unknown";
370
+ return /^(?:y|yes|true|1)$/iu.test(match[1] ?? "") ? "yes" : "no";
371
+ }
372
+ function parseFailureModeRescueFlag(rescueCell) {
373
+ const keyed = parseKeyedBinaryFlag(rescueCell, "rescued");
374
+ if (keyed !== "unknown")
375
+ return keyed;
376
+ const direct = parseBinaryFlag(rescueCell);
377
+ if (direct !== "unknown")
378
+ return direct;
379
+ if (/\b(?:no rescue|without rescue|unrescued|no fallback|none|absent)\b/iu.test(rescueCell)) {
380
+ return "no";
381
+ }
382
+ if (/\b(?:fallback|retry|degrade|recover|rescue|mitigat)\b/iu.test(rescueCell)) {
383
+ return "yes";
384
+ }
385
+ return "unknown";
386
+ }
387
+ function parseFailureModeTestFlag(rowText) {
388
+ const keyed = parseKeyedBinaryFlag(rowText, "test");
389
+ if (keyed !== "unknown")
390
+ return keyed;
391
+ if (/\b(?:no tests?|untested|without tests?)\b/iu.test(rowText)) {
392
+ return "no";
393
+ }
394
+ if (/\b(?:tested|has tests?|with tests?|covered by tests?)\b/iu.test(rowText)) {
395
+ return "yes";
396
+ }
397
+ return "unknown";
398
+ }
399
+ function validateFailureModeTable(sectionBody) {
400
+ const header = tableHeaderCells(sectionBody);
401
+ if (!header) {
402
+ return {
403
+ ok: false,
404
+ details: "Failure Mode Table must include a markdown header row and separator."
405
+ };
406
+ }
407
+ const expectedHeader = ["Method", "Exception", "Rescue", "UserSees"];
408
+ const normalizedHeader = header.map((cell) => cell.toLowerCase());
409
+ const normalizedExpected = expectedHeader.map((cell) => cell.toLowerCase());
410
+ const headerMatches = normalizedHeader.length === normalizedExpected.length &&
411
+ normalizedHeader.every((cell, index) => cell === normalizedExpected[index]);
412
+ if (!headerMatches) {
413
+ return {
414
+ ok: false,
415
+ details: `Failure Mode Table header must be exactly: ${expectedHeader.join(" | ")}.`
416
+ };
417
+ }
418
+ const rows = getMarkdownTableRows(sectionBody);
419
+ if (rows.length === 0) {
420
+ return {
421
+ ok: false,
422
+ details: "Failure Mode Table must include at least one data row."
423
+ };
424
+ }
425
+ for (const [index, row] of rows.entries()) {
426
+ if (row.length < 4) {
427
+ return {
428
+ ok: false,
429
+ details: `Failure Mode Table row ${index + 1} must provide 4 columns (Method, Exception, Rescue, UserSees).`
430
+ };
431
+ }
432
+ const method = (row[0] ?? "").trim();
433
+ const exception = (row[1] ?? "").trim();
434
+ const rescue = (row[2] ?? "").trim();
435
+ const userSees = (row[3] ?? "").trim();
436
+ if (!method || !exception || !rescue || !userSees) {
437
+ return {
438
+ ok: false,
439
+ details: `Failure Mode Table row ${index + 1} must populate all columns (Method, Exception, Rescue, UserSees).`
440
+ };
441
+ }
442
+ const rescueFlag = parseFailureModeRescueFlag(rescue);
443
+ const testFlag = parseFailureModeTestFlag(`${method} ${exception} ${rescue} ${userSees}`);
444
+ const userSilent = /\bsilent\b/iu.test(userSees);
445
+ if (rescueFlag === "no" && testFlag === "no" && userSilent) {
446
+ return {
447
+ ok: false,
448
+ 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.`
449
+ };
450
+ }
451
+ }
452
+ return {
453
+ ok: true,
454
+ details: "Failure Mode Table header and critical-risk checks passed."
455
+ };
456
+ }
457
+ const INTERACTION_EDGE_CASE_REQUIREMENTS = [
458
+ { label: "double-click", pattern: /\bdouble[\s-]?click\b/iu },
459
+ {
460
+ label: "nav-away-mid-request",
461
+ 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
462
+ },
463
+ {
464
+ label: "10K-result dataset",
465
+ pattern: /\b(?:10k(?:[\s-]?result)?|10,?000|large[\s-]?result(?:[\s-]?dataset)?)\b/iu
466
+ },
467
+ {
468
+ label: "background-job abandonment",
469
+ pattern: /\b(?:background[\s-]?job.*abandon(?:ed|ment)?|abandon(?:ed|ment)?.*background[\s-]?job)\b/iu
470
+ },
471
+ { label: "zombie connection", pattern: /\bzombie[\s-]?connection\b/iu }
472
+ ];
473
+ function validateInteractionEdgeCaseMatrix(sectionBody) {
474
+ const rows = getMarkdownTableRows(sectionBody);
475
+ if (rows.length === 0) {
476
+ return {
477
+ ok: false,
478
+ details: "Data Flow must include an Interaction Edge Case matrix table with required rows."
479
+ };
480
+ }
481
+ const seen = new Map();
482
+ for (const [index, row] of rows.entries()) {
483
+ const labelCell = (row[0] ?? "").trim();
484
+ if (!labelCell)
485
+ continue;
486
+ const requirement = INTERACTION_EDGE_CASE_REQUIREMENTS.find((candidate) => candidate.pattern.test(labelCell));
487
+ if (!requirement)
488
+ continue;
489
+ if (row.length < 4) {
490
+ return {
491
+ ok: false,
492
+ details: `Interaction Edge Case row "${requirement.label}" must include 4 columns: Edge case | Handled? | Design response | Deferred item.`
493
+ };
494
+ }
495
+ const handled = parseBinaryFlag((row[1] ?? "").trim());
496
+ const response = (row[2] ?? "").trim();
497
+ const deferred = (row[3] ?? "").trim();
498
+ if (handled === "unknown") {
499
+ return {
500
+ ok: false,
501
+ details: `Interaction Edge Case row "${requirement.label}" must mark Handled? as yes/no.`
502
+ };
503
+ }
504
+ if (!response) {
505
+ return {
506
+ ok: false,
507
+ details: `Interaction Edge Case row "${requirement.label}" must describe the design response.`
508
+ };
509
+ }
510
+ if (handled === "no" && (!deferred || /\bnone\b/iu.test(deferred))) {
511
+ return {
512
+ ok: false,
513
+ details: `Interaction Edge Case row "${requirement.label}" is unhandled and must reference a deferred item id (for example D-12).`
514
+ };
515
+ }
516
+ seen.set(requirement.label, true);
517
+ }
518
+ const missing = INTERACTION_EDGE_CASE_REQUIREMENTS
519
+ .map((requirement) => requirement.label)
520
+ .filter((label) => !seen.has(label));
521
+ if (missing.length > 0) {
522
+ return {
523
+ ok: false,
524
+ details: `Interaction Edge Case matrix is missing required row(s): ${missing.join(", ")}.`
525
+ };
526
+ }
527
+ return {
528
+ ok: true,
529
+ details: "Interaction Edge Case matrix contains all required rows with handled/deferred status."
530
+ };
531
+ }
532
+ const PRE_SCOPE_AUDIT_SIGNALS = [
533
+ { label: "git log -30 --oneline", pattern: /\bgit\s+log\b[^\n]*-30[^\n]*\boneline\b/iu },
534
+ { label: "git diff --stat", pattern: /\bgit\s+diff\b[^\n]*--stat\b/iu },
535
+ { label: "git stash list", pattern: /\bgit\s+stash\s+list\b/iu },
536
+ {
537
+ label: "debt marker scan (TODO|FIXME|XXX|HACK)",
538
+ pattern: /\b(?:rg|ripgrep)\b[^\n]*(?:TODO|FIXME|XXX|HACK)|\bTODO\b|\bFIXME\b|\bXXX\b|\bHACK\b/iu
539
+ }
540
+ ];
541
+ function validatePreScopeSystemAudit(sectionBody) {
542
+ const missing = PRE_SCOPE_AUDIT_SIGNALS
543
+ .filter((signal) => !signal.pattern.test(sectionBody))
544
+ .map((signal) => signal.label);
545
+ if (missing.length > 0) {
546
+ return {
547
+ ok: false,
548
+ details: `Pre-Scope System Audit is missing required signal(s): ${missing.join(", ")}.`
549
+ };
550
+ }
551
+ return {
552
+ ok: true,
553
+ details: "Pre-Scope System Audit captures git log/diff/stash/debt-marker checks."
554
+ };
555
+ }
556
+ function normalizeCodebaseInvestigationFileRef(value) {
557
+ const cleaned = value
558
+ .replace(/`/gu, "")
559
+ .replace(/^\s*[-*]\s*/u, "")
560
+ .trim();
561
+ if (!cleaned)
562
+ return null;
563
+ if (/^(?:file|n\/a|none|\(none\)|tbd|\?)$/iu.test(cleaned))
564
+ return null;
565
+ return cleaned;
566
+ }
567
+ function collectCodebaseInvestigationFiles(sectionBody) {
568
+ const refs = [];
569
+ for (const row of getMarkdownTableRows(sectionBody)) {
570
+ const fileCell = normalizeCodebaseInvestigationFileRef(row[0] ?? "");
571
+ if (fileCell)
572
+ refs.push(fileCell);
573
+ }
574
+ return [...new Set(refs)];
575
+ }
576
+ async function runStaleDiagramAudit(projectRoot, artifactPath, artifactRaw, codebaseInvestigationBody) {
577
+ const markerCount = (artifactRaw.match(/<!--\s*diagram:\s*[a-z0-9-]+\s*-->/giu) ?? []).length;
578
+ if (markerCount === 0) {
579
+ return {
580
+ ok: false,
581
+ details: "No diagram markers found in design artifact; stale-diagram baseline cannot be computed."
582
+ };
583
+ }
584
+ let artifactStat;
585
+ try {
586
+ artifactStat = await fs.stat(artifactPath);
587
+ }
588
+ catch {
589
+ return {
590
+ ok: false,
591
+ details: "Cannot stat design artifact to compute diagram marker baseline."
592
+ };
593
+ }
594
+ const refs = collectCodebaseInvestigationFiles(codebaseInvestigationBody);
595
+ if (refs.length === 0) {
596
+ return {
597
+ ok: false,
598
+ details: "Codebase Investigation must list at least one blast-radius file for stale-diagram audit."
599
+ };
600
+ }
601
+ const stale = [];
602
+ const missing = [];
603
+ let scanned = 0;
604
+ for (const ref of refs) {
605
+ const absPath = path.isAbsolute(ref) ? ref : path.join(projectRoot, ref);
606
+ if (!(await exists(absPath))) {
607
+ missing.push(ref);
608
+ continue;
609
+ }
610
+ let fileStat;
611
+ try {
612
+ fileStat = await fs.stat(absPath);
613
+ }
614
+ catch {
615
+ missing.push(ref);
616
+ continue;
617
+ }
618
+ if (!fileStat.isFile())
619
+ continue;
620
+ scanned += 1;
621
+ if (fileStat.mtimeMs > artifactStat.mtimeMs) {
622
+ stale.push(ref);
623
+ }
624
+ }
625
+ if (missing.length > 0) {
626
+ return {
627
+ ok: false,
628
+ details: `Stale Diagram Audit could not read blast-radius file(s): ${missing.join(", ")}.`
629
+ };
630
+ }
631
+ if (scanned === 0) {
632
+ return {
633
+ ok: false,
634
+ details: "Stale Diagram Audit found no readable blast-radius files in Codebase Investigation."
635
+ };
636
+ }
637
+ if (stale.length > 0) {
638
+ return {
639
+ ok: false,
640
+ details: `Stale Diagram Audit flagged stale file(s) newer than diagram baseline: ${stale.join(", ")}.`
641
+ };
642
+ }
643
+ return {
644
+ ok: true,
645
+ details: `Stale Diagram Audit clear: ${scanned} blast-radius file(s) are not newer than diagram baseline.`
646
+ };
647
+ }
209
648
  const DIAGRAM_ARROW_PATTERN = /(?:<--?>|<?==?>|--?>|->>|=>|-\.->|→|⟶|↦)/u;
210
649
  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;
211
650
  const DIAGRAM_GENERIC_NODE_PATTERN = /\b(service|component|module|system)\s*(?:[A-Z0-9])?\b/iu;
@@ -713,6 +1152,15 @@ function validateSectionBody(sectionBody, rule, sectionName) {
713
1152
  if (sectionNameNormalized === "verification ladder") {
714
1153
  return validateVerificationLadder(sectionBody);
715
1154
  }
1155
+ if (sectionNameNormalized === "failure mode table") {
1156
+ return validateFailureModeTable(sectionBody);
1157
+ }
1158
+ if (sectionNameNormalized === "pre-scope system audit") {
1159
+ return validatePreScopeSystemAudit(sectionBody);
1160
+ }
1161
+ if (sectionNameNormalized === "data flow") {
1162
+ return validateInteractionEdgeCaseMatrix(sectionBody);
1163
+ }
716
1164
  if (sectionNameNormalized === "architecture diagram") {
717
1165
  const edgeLines = diagramEdgeLines(sectionBody);
718
1166
  if (edgeLines.length === 0) {
@@ -817,6 +1265,7 @@ export async function lintArtifact(projectRoot, stage, track = "standard") {
817
1265
  }
818
1266
  const raw = await fs.readFile(absFile, "utf8");
819
1267
  const sections = extractH2Sections(raw);
1268
+ const projectConfig = await readConfig(projectRoot);
820
1269
  const parsedFrontmatter = parseFrontmatter(raw);
821
1270
  const frontmatterMissingKeys = FRONTMATTER_REQUIRED_KEYS.filter((key) => {
822
1271
  const value = parsedFrontmatter.values[key];
@@ -848,17 +1297,28 @@ export async function lintArtifact(projectRoot, stage, track = "standard") {
848
1297
  ? "Frontmatter inputs_hash must be sha256:pending or sha256:<64 hex chars>."
849
1298
  : "Frontmatter integrity checks passed."
850
1299
  });
1300
+ const brainstormShortCircuitBody = stage === "brainstorm" ? sectionBodyByName(sections, "Short-Circuit Decision") : null;
1301
+ const brainstormShortCircuitActivated = stage === "brainstorm" && isShortCircuitActivated(brainstormShortCircuitBody);
1302
+ const scopePreAuditEnabled = projectConfig.optInAudits?.scopePreAudit === true;
1303
+ const staleDiagramAuditEnabled = projectConfig.optInAudits?.staleDiagramAudit === true;
851
1304
  const isTrivialOverride = schema.trivialOverrideSections &&
852
1305
  schema.trivialOverrideSections.length > 0 &&
853
- /trivial.change|mini.design|escape.hatch/iu.test(raw);
1306
+ (/trivial.change|mini.design|escape.hatch/iu.test(raw) ||
1307
+ brainstormShortCircuitActivated);
854
1308
  const overrideSet = isTrivialOverride
855
1309
  ? new Set(schema.trivialOverrideSections.map((s) => normalizeHeadingTitle(s).toLowerCase()))
856
1310
  : null;
857
1311
  for (const v of schema.artifactValidation) {
858
- const effectiveRequired = overrideSet
859
- ? overrideSet.has(normalizeHeadingTitle(v.section).toLowerCase()) ? true : false
860
- : v.required;
1312
+ const sectionKey = normalizeHeadingTitle(v.section).toLowerCase();
861
1313
  const hasHeading = headingPresent(sections, v.section);
1314
+ const effectiveRequiredFromOverride = overrideSet
1315
+ ? overrideSet.has(sectionKey) ? true : false
1316
+ : v.required;
1317
+ const effectiveRequired = stage === "design" && sectionKey === "data flow" && hasHeading
1318
+ ? true
1319
+ : stage === "scope" && sectionKey === "pre-scope system audit" && scopePreAuditEnabled
1320
+ ? true
1321
+ : effectiveRequiredFromOverride;
862
1322
  const body = hasHeading ? sectionBodyByName(sections, v.section) : null;
863
1323
  const validation = body === null
864
1324
  ? { ok: false, details: `No ## heading matching required section "${v.section}".` }
@@ -901,19 +1361,33 @@ export async function lintArtifact(projectRoot, stage, track = "standard") {
901
1361
  // prose-only — nothing failed when the Selected Direction section
902
1362
  // omitted an approval marker, or when the Approaches table collapsed
903
1363
  // to a single row (defeating the "2-3 distinct approaches" gate).
1364
+ const tierBody = sectionBodyByName(sections, "Approach Tier");
1365
+ if (tierBody !== null) {
1366
+ const hasTierToken = /\b(?:lightweight|standard|deep)\b/iu.test(tierBody);
1367
+ findings.push({
1368
+ section: "Approach Tier Classification",
1369
+ required: true,
1370
+ rule: "Approach Tier must explicitly classify depth as Lightweight, Standard, or Deep.",
1371
+ found: hasTierToken,
1372
+ details: hasTierToken
1373
+ ? "Approach Tier includes a recognized depth token."
1374
+ : "Approach Tier is missing a recognized depth token (Lightweight/Standard/Deep)."
1375
+ });
1376
+ }
904
1377
  const approachesBody = sectionBodyByName(sections, "Approaches");
905
1378
  if (approachesBody !== null) {
906
- const tableRows = approachesBody
907
- .split(/\r?\n/u)
908
- .map((line) => line.trim())
909
- .filter((line) => line.startsWith("|"))
910
- .filter((line) => !/^\|\s*[-: |]+\|\s*$/u.test(line))
911
- .filter((line) => !/^\|\s*approach\b/iu.test(line));
1379
+ const tableRows = getMarkdownTableRows(approachesBody);
912
1380
  const bulletRows = approachesBody
913
1381
  .split(/\r?\n/u)
914
1382
  .map((line) => line.trim())
915
1383
  .filter((line) => /^(?:[-*]|\d+\.)\s+\S/u.test(line));
916
1384
  const rowCount = Math.max(tableRows.length, bulletRows.length);
1385
+ const hasChallengerFromTable = tableRows.some((row) => {
1386
+ const joined = row.join(" ");
1387
+ return /\bchallenger\b/iu.test(joined) && /\bhigher[-\s]?upside\b/iu.test(joined);
1388
+ });
1389
+ const hasChallengerFromBullets = bulletRows.some((row) => /\bchallenger\b/iu.test(row) && /\bhigher[-\s]?upside\b/iu.test(row));
1390
+ const hasChallenger = hasChallengerFromTable || hasChallengerFromBullets;
917
1391
  findings.push({
918
1392
  section: "Distinct Approaches Enforcement",
919
1393
  required: true,
@@ -923,6 +1397,29 @@ export async function lintArtifact(projectRoot, stage, track = "standard") {
923
1397
  ? `Detected ${rowCount} approach row(s).`
924
1398
  : `Detected ${rowCount} approach row(s); at least 2 required.`
925
1399
  });
1400
+ findings.push({
1401
+ section: "Challenger Alternative Enforcement",
1402
+ required: true,
1403
+ rule: "Approaches must include one option labeled `challenger: higher-upside`.",
1404
+ found: hasChallenger,
1405
+ details: hasChallenger
1406
+ ? "Challenger higher-upside alternative detected."
1407
+ : "Missing `challenger: higher-upside` alternative in Approaches."
1408
+ });
1409
+ }
1410
+ const reactionIndex = headingLineIndex(raw, "Approach Reaction");
1411
+ const directionIndex = headingLineIndex(raw, "Selected Direction");
1412
+ if (directionIndex >= 0 && !brainstormShortCircuitActivated) {
1413
+ const orderOk = reactionIndex >= 0 && reactionIndex < directionIndex;
1414
+ findings.push({
1415
+ section: "Approach Reaction Ordering",
1416
+ required: true,
1417
+ rule: "Approach Reaction must appear before Selected Direction (propose -> react -> recommend).",
1418
+ found: orderOk,
1419
+ details: orderOk
1420
+ ? "Approach Reaction appears before Selected Direction."
1421
+ : "Approach Reaction must be present before Selected Direction."
1422
+ });
926
1423
  }
927
1424
  const directionBody = sectionBodyByName(sections, "Selected Direction");
928
1425
  if (directionBody !== null) {
@@ -936,6 +1433,108 @@ export async function lintArtifact(projectRoot, stage, track = "standard") {
936
1433
  ? "Approval marker present in Selected Direction."
937
1434
  : "No explicit `approved`/`approval` marker found in Selected Direction."
938
1435
  });
1436
+ if (!brainstormShortCircuitActivated) {
1437
+ const reactionTrace = /\b(?:reaction|feedback|concern(?:s)?)\b/iu.test(directionBody);
1438
+ findings.push({
1439
+ section: "Direction Reaction Trace",
1440
+ required: true,
1441
+ rule: "Selected Direction rationale must reference user reaction/feedback before recommendation.",
1442
+ found: reactionTrace,
1443
+ details: reactionTrace
1444
+ ? "Selected Direction rationale references user reaction/feedback."
1445
+ : "Selected Direction rationale does not reference user reaction/feedback."
1446
+ });
1447
+ }
1448
+ }
1449
+ const shortCircuitBody = brainstormShortCircuitBody;
1450
+ if (shortCircuitBody !== null) {
1451
+ const statusValue = parseShortCircuitStatus(shortCircuitBody);
1452
+ const hasStatus = statusValue.length > 0;
1453
+ findings.push({
1454
+ section: "Short-Circuit Status",
1455
+ required: true,
1456
+ rule: "Short-Circuit Decision must include a `Status:` line (`activated` or `bypassed`).",
1457
+ found: hasStatus,
1458
+ details: hasStatus
1459
+ ? `Short-circuit status declared as "${statusValue}".`
1460
+ : "Short-Circuit Decision is missing a `Status:` line."
1461
+ });
1462
+ if (brainstormShortCircuitActivated) {
1463
+ const artifactLines = meaningfulLineCount(raw);
1464
+ const withinStubLimit = artifactLines <= 30;
1465
+ const hasScopeHandoff = /\bscope\b/iu.test(shortCircuitBody);
1466
+ findings.push({
1467
+ section: "Short-Circuit Stub Size",
1468
+ required: true,
1469
+ rule: "When short-circuit is activated, brainstorm artifact must remain a <=30 meaningful-line stub.",
1470
+ found: withinStubLimit,
1471
+ details: withinStubLimit
1472
+ ? `Short-circuit stub size within limit (${artifactLines} meaningful lines).`
1473
+ : `Short-circuit stub too large (${artifactLines} meaningful lines); expected <= 30.`
1474
+ });
1475
+ findings.push({
1476
+ section: "Short-Circuit Scope Handoff",
1477
+ required: true,
1478
+ rule: "When short-circuit is activated, the section must explicitly hand off to scope.",
1479
+ found: hasScopeHandoff,
1480
+ details: hasScopeHandoff
1481
+ ? "Short-circuit section includes explicit scope handoff."
1482
+ : "Short-circuit section is missing explicit scope handoff guidance."
1483
+ });
1484
+ }
1485
+ }
1486
+ }
1487
+ if (stage === "design") {
1488
+ const tierResolution = await resolveDesignDiagramTier(projectRoot, track, raw);
1489
+ const diagramTier = isTrivialOverride
1490
+ ? "lightweight"
1491
+ : tierResolution.tier;
1492
+ const tierSource = isTrivialOverride
1493
+ ? `${tierResolution.source}; trivial override forced lightweight`
1494
+ : tierResolution.source;
1495
+ for (const requirement of DESIGN_DIAGRAM_REQUIREMENTS[diagramTier]) {
1496
+ const sectionBody = sectionBodyByName(sections, requirement.section);
1497
+ const hasSection = sectionBody !== null;
1498
+ const escapedMarker = requirement.marker.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
1499
+ const markerRegex = new RegExp(`<!--\\s*diagram:\\s*${escapedMarker}\\s*-->`, "iu");
1500
+ const hasMarker = sectionBody !== null && markerRegex.test(sectionBody);
1501
+ const hasContent = sectionBody !== null && meaningfulLineCount(sectionBody) > 0;
1502
+ const found = hasSection && hasMarker && hasContent;
1503
+ findings.push({
1504
+ section: `Diagram Requirement: ${requirement.section}`,
1505
+ required: true,
1506
+ rule: `Design tier "${diagramTier}" requires "${requirement.section}" with marker \`<!-- diagram: ${requirement.marker} -->\`. ${requirement.note}`,
1507
+ found,
1508
+ details: found
1509
+ ? `Satisfied (${tierSource}).`
1510
+ : !hasSection
1511
+ ? `Missing section "${requirement.section}" (${tierSource}).`
1512
+ : !hasMarker
1513
+ ? `Missing marker \`<!-- diagram: ${requirement.marker} -->\` in section "${requirement.section}" (${tierSource}).`
1514
+ : `Section "${requirement.section}" has marker but no meaningful content (${tierSource}).`
1515
+ });
1516
+ }
1517
+ if (staleDiagramAuditEnabled) {
1518
+ const codebaseInvestigation = sectionBodyByName(sections, "Codebase Investigation");
1519
+ if (codebaseInvestigation === null) {
1520
+ findings.push({
1521
+ section: "Stale Diagram Drift Check",
1522
+ required: true,
1523
+ rule: "When `.cclaw/config.yaml::optInAudits.staleDiagramAudit` is true, stale diagram audit requires Codebase Investigation blast-radius files.",
1524
+ found: false,
1525
+ details: "No ## heading matching required section \"Codebase Investigation\"."
1526
+ });
1527
+ }
1528
+ else {
1529
+ const staleAudit = await runStaleDiagramAudit(projectRoot, absFile, raw, codebaseInvestigation);
1530
+ findings.push({
1531
+ section: "Stale Diagram Drift Check",
1532
+ required: true,
1533
+ rule: "When `.cclaw/config.yaml::optInAudits.staleDiagramAudit` is true, blast-radius files must not be newer than current design diagram baseline.",
1534
+ found: staleAudit.ok,
1535
+ details: staleAudit.details
1536
+ });
1537
+ }
939
1538
  }
940
1539
  }
941
1540
  if (stage === "plan") {