cclaw-cli 3.0.0 → 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/dist/artifact-linter/brainstorm.js +51 -2
  2. package/dist/artifact-linter/design.js +14 -3
  3. package/dist/artifact-linter/review-army.d.ts +25 -0
  4. package/dist/artifact-linter/review-army.js +155 -0
  5. package/dist/artifact-linter/review.js +13 -0
  6. package/dist/artifact-linter/scope.js +27 -48
  7. package/dist/artifact-linter/shared.d.ts +98 -11
  8. package/dist/artifact-linter/shared.js +280 -113
  9. package/dist/artifact-linter.d.ts +12 -2
  10. package/dist/artifact-linter.js +29 -13
  11. package/dist/content/core-agents.js +6 -1
  12. package/dist/content/examples.js +8 -0
  13. package/dist/content/hooks.js +2 -1
  14. package/dist/content/idea.js +14 -2
  15. package/dist/content/review-prompts.js +3 -3
  16. package/dist/content/skills-elicitation.js +61 -20
  17. package/dist/content/skills.js +19 -6
  18. package/dist/content/stage-schema.js +46 -18
  19. package/dist/content/stages/_lint-metadata/index.js +1 -2
  20. package/dist/content/stages/brainstorm.js +6 -3
  21. package/dist/content/stages/design.js +13 -12
  22. package/dist/content/stages/plan.js +1 -1
  23. package/dist/content/stages/review.js +21 -21
  24. package/dist/content/stages/schema-types.d.ts +9 -0
  25. package/dist/content/stages/scope.js +22 -20
  26. package/dist/content/stages/spec.js +3 -3
  27. package/dist/content/stages/tdd.js +1 -0
  28. package/dist/content/templates.d.ts +8 -1
  29. package/dist/content/templates.js +115 -43
  30. package/dist/flow-state.d.ts +12 -0
  31. package/dist/gate-evidence.d.ts +37 -1
  32. package/dist/gate-evidence.js +37 -3
  33. package/dist/harness-adapters.js +8 -0
  34. package/dist/install.js +22 -11
  35. package/dist/internal/advance-stage/advance.d.ts +1 -0
  36. package/dist/internal/advance-stage/advance.js +5 -2
  37. package/dist/internal/advance-stage/parsers.d.ts +8 -0
  38. package/dist/internal/advance-stage/parsers.js +27 -1
  39. package/dist/internal/advance-stage/start-flow.js +13 -0
  40. package/dist/run-persistence.js +14 -2
  41. package/package.json +1 -1
@@ -1,6 +1,7 @@
1
1
  import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
- import { checkCriticPredictionsContract, sectionBodyByName, validateApproachesTaxonomy, headingLineIndex, meaningfulLineCount, getMarkdownTableRows, parseShortCircuitStatus, validateCalibratedSelfReview, markdownFieldRegex } from "./shared.js";
3
+ import { checkCriticPredictionsContract, evaluateQaLogFloor, sectionBodyByName, validateApproachesTaxonomy, headingLineIndex, meaningfulLineCount, getMarkdownTableRows, parseShortCircuitStatus, validateCalibratedSelfReview, markdownFieldRegex } from "./shared.js";
4
+ import { readFlowState } from "../run-persistence.js";
4
5
  export async function lintBrainstormStage(ctx) {
5
6
  const { projectRoot, track, raw, absFile, sections, findings, parsedFrontmatter, brainstormShortCircuitBody, brainstormShortCircuitActivated, staleDiagramAuditEnabled, isTrivialOverride } = ctx;
6
7
  const qaLogBody = sectionBodyByName(sections, "Q&A Log");
@@ -9,7 +10,7 @@ export async function lintBrainstormStage(ctx) {
9
10
  findings.push({
10
11
  section: "qa_log_missing",
11
12
  required: false,
12
- rule: "[P3] qa_log_missing — Q&A Log empty — confirm you actually had a dialogue with the user, not a draft from memory.",
13
+ rule: "[P2] qa_log_missing — Q&A Log empty — confirm you actually had a dialogue with the user, not a draft from memory.",
13
14
  found: qaLogOk,
14
15
  details: qaLogOk
15
16
  ? `Q&A Log contains ${qaLogRows.length} data row(s).`
@@ -17,6 +18,17 @@ export async function lintBrainstormStage(ctx) {
17
18
  ? "Missing `## Q&A Log` section."
18
19
  : "Q&A Log is present but has zero data rows."
19
20
  });
21
+ if (!brainstormShortCircuitActivated) {
22
+ const skipQuestions = ctx.activeStageFlags.includes("--skip-questions");
23
+ const floor = evaluateQaLogFloor(qaLogBody, track, "brainstorm", { skipQuestions });
24
+ findings.push({
25
+ section: "qa_log_unconverged",
26
+ required: !floor.skipQuestionsAdvisory,
27
+ rule: "[P1] qa_log_unconverged — Q&A Log has not converged for this stage. Continue elicitation until forcing-question topics are addressed, the last 2 rows produce no decision-changing impact (Ralph-Loop), or an explicit user stop-signal row is appended.",
28
+ found: floor.ok,
29
+ details: floor.details
30
+ });
31
+ }
20
32
  // Brainstorm Iron Law: "NO ARTIFACT IS COMPLETE WITHOUT AN EXPLICITLY
21
33
  // APPROVED DIRECTION — SILENCE IS NOT APPROVAL." Previously this was
22
34
  // prose-only — nothing failed when the Selected Direction section
@@ -268,6 +280,43 @@ export async function lintBrainstormStage(ctx) {
268
280
  : `Outside Voice section is missing field(s): ${missing.join(", ")}.`
269
281
  });
270
282
  }
283
+ let ideaHint = {};
284
+ try {
285
+ const flowState = await readFlowState(projectRoot);
286
+ const hint = flowState.interactionHints?.brainstorm;
287
+ if (hint) {
288
+ ideaHint = {
289
+ fromIdeaArtifact: hint.fromIdeaArtifact,
290
+ fromIdeaCandidateId: hint.fromIdeaCandidateId
291
+ };
292
+ }
293
+ }
294
+ catch {
295
+ ideaHint = {};
296
+ }
297
+ if (ideaHint.fromIdeaArtifact) {
298
+ const carryBody = sectionBodyByName(sections, "Idea Evidence Carry-forward");
299
+ const hasSection = carryBody !== null && meaningfulLineCount(carryBody) > 0;
300
+ const sourceCited = hasSection &&
301
+ carryBody.includes(ideaHint.fromIdeaArtifact);
302
+ const candidateCited = ideaHint.fromIdeaCandidateId
303
+ ? hasSection && carryBody.toUpperCase().includes(ideaHint.fromIdeaCandidateId.toUpperCase())
304
+ : true;
305
+ const ok = hasSection && sourceCited && candidateCited;
306
+ findings.push({
307
+ section: "brainstorm.idea_evidence_carry_forward",
308
+ required: true,
309
+ rule: "[P1] brainstorm.idea_evidence_carry_forward — when `flow-state.interactionHints.brainstorm.fromIdeaArtifact` is set (Wave 23 / v5.0.0), the brainstorm artifact MUST include `## Idea Evidence Carry-forward` citing the idea artifact path and chosen `I-#`. Reuse divergent + critique + rank work from `/cc-ideate` as the `baseline` Approach; only newly generate the higher-upside challenger row(s).",
310
+ found: ok,
311
+ details: ok
312
+ ? `Idea Evidence Carry-forward cites ${ideaHint.fromIdeaArtifact}${ideaHint.fromIdeaCandidateId ? ` (${ideaHint.fromIdeaCandidateId})` : ""}.`
313
+ : !hasSection
314
+ ? `Brainstorm started from /cc-ideate (artifact ${ideaHint.fromIdeaArtifact}${ideaHint.fromIdeaCandidateId ? `, candidate ${ideaHint.fromIdeaCandidateId}` : ""}) but \`## Idea Evidence Carry-forward\` is missing or empty.`
315
+ : !sourceCited
316
+ ? `\`## Idea Evidence Carry-forward\` does not cite the source idea artifact path \`${ideaHint.fromIdeaArtifact}\`.`
317
+ : `\`## Idea Evidence Carry-forward\` does not cite the chosen candidate id \`${ideaHint.fromIdeaCandidateId ?? ""}\`.`
318
+ });
319
+ }
271
320
  const wavePlansDir = path.join(projectRoot, ".cclaw", "wave-plans");
272
321
  let wavePlanEntries = [];
273
322
  try {
@@ -3,7 +3,7 @@ import path from "node:path";
3
3
  import { resolveArtifactPath as resolveStageArtifactPath } from "../artifact-paths.js";
4
4
  import { exists } from "../fs-utils.js";
5
5
  import { CONFIDENCE_FINDING_REGEX_SOURCE } from "../content/skills.js";
6
- import { checkCriticPredictionsContract, evaluateLayeredDocumentReviewStatus, extractMarkdownSectionBody, getMarkdownTableRows, meaningfulLineCount, sectionBodyByName, markdownFieldRegex } from "./shared.js";
6
+ import { checkCriticPredictionsContract, evaluateLayeredDocumentReviewStatus, evaluateQaLogFloor, extractMarkdownSectionBody, getMarkdownTableRows, meaningfulLineCount, sectionBodyByName, markdownFieldRegex } from "./shared.js";
7
7
  const DESIGN_DIAGRAM_REQUIREMENTS = {
8
8
  lightweight: [
9
9
  {
@@ -203,14 +203,14 @@ async function runStaleDiagramAudit(projectRoot, artifactPath, artifactRaw, code
203
203
  };
204
204
  }
205
205
  export async function lintDesignStage(ctx) {
206
- const { projectRoot, track, raw, absFile, sections, findings, parsedFrontmatter, brainstormShortCircuitBody, brainstormShortCircuitActivated, staleDiagramAuditEnabled, isTrivialOverride } = ctx;
206
+ const { projectRoot, track, raw, absFile, sections, findings, parsedFrontmatter, brainstormShortCircuitBody, brainstormShortCircuitActivated, staleDiagramAuditEnabled, isTrivialOverride, activeStageFlags } = ctx;
207
207
  const qaLogBody = sectionBodyByName(sections, "Q&A Log");
208
208
  const qaLogRows = qaLogBody ? getMarkdownTableRows(qaLogBody) : [];
209
209
  const qaLogOk = qaLogBody !== null && qaLogRows.length > 0;
210
210
  findings.push({
211
211
  section: "qa_log_missing",
212
212
  required: false,
213
- rule: "[P3] qa_log_missing — Q&A Log empty — confirm you actually had a dialogue with the user, not a draft from memory.",
213
+ rule: "[P2] qa_log_missing — Q&A Log empty — confirm you actually had a dialogue with the user, not a draft from memory.",
214
214
  found: qaLogOk,
215
215
  details: qaLogOk
216
216
  ? `Q&A Log contains ${qaLogRows.length} data row(s).`
@@ -218,6 +218,17 @@ export async function lintDesignStage(ctx) {
218
218
  ? "Missing `## Q&A Log` section."
219
219
  : "Q&A Log is present but has zero data rows."
220
220
  });
221
+ {
222
+ const skipQuestions = activeStageFlags.includes("--skip-questions");
223
+ const floor = evaluateQaLogFloor(qaLogBody, track, "design", { skipQuestions });
224
+ findings.push({
225
+ section: "qa_log_unconverged",
226
+ required: !floor.skipQuestionsAdvisory,
227
+ rule: "[P1] qa_log_unconverged — Q&A Log has not converged for this stage. Continue elicitation until forcing-question topics are addressed, the last 2 rows produce no decision-changing impact (Ralph-Loop), or an explicit user stop-signal row is appended.",
228
+ found: floor.ok,
229
+ details: floor.details
230
+ });
231
+ }
221
232
  const criticPredictions = checkCriticPredictionsContract(sections);
222
233
  if (criticPredictions !== null) {
223
234
  findings.push({
@@ -21,4 +21,29 @@ export interface ReviewSecurityNoChangeAttestationResult {
21
21
  * APPROVED while open Critical findings or shipBlockers remain.
22
22
  */
23
23
  export declare function checkReviewVerdictConsistency(projectRoot: string): Promise<ReviewVerdictConsistencyResult>;
24
+ export interface ReviewTddDuplicationConflict {
25
+ findingId: string;
26
+ tddSeverity: string | null;
27
+ reviewSeverity: string | null;
28
+ tddDisposition: string | null;
29
+ reviewDisposition: string | null;
30
+ }
31
+ export interface ReviewTddDuplicationResult {
32
+ ok: boolean;
33
+ errors: string[];
34
+ conflicts: ReviewTddDuplicationConflict[];
35
+ tddArtifactExists: boolean;
36
+ reviewArtifactExists: boolean;
37
+ }
38
+ /**
39
+ * Cross-artifact duplication guard (Wave 23 / v5.0.0).
40
+ *
41
+ * When the same finding ID (`F-NN`) appears in both
42
+ * `06-tdd.md > Per-Slice Review` and `07-review-army.json`, the
43
+ * severity and disposition MUST match. Per-slice tdd reviews own
44
+ * single-slice findings; review cites them, never re-classifies.
45
+ *
46
+ * If neither artifact uses `F-NN` IDs, the check is a no-op.
47
+ */
48
+ export declare function checkReviewTddNoCrossArtifactDuplication(projectRoot: string): Promise<ReviewTddDuplicationResult>;
24
49
  export declare function checkReviewSecurityNoChangeAttestation(projectRoot: string): Promise<ReviewSecurityNoChangeAttestationResult>;
@@ -319,6 +319,161 @@ export async function checkReviewVerdictConsistency(projectRoot) {
319
319
  shipBlockerCount
320
320
  };
321
321
  }
322
+ const FINDING_ID_PATTERN = /\bF-\d+\b/giu;
323
+ const SEVERITY_TOKENS = ["Critical", "Important", "Suggestion"];
324
+ const DISPOSITION_TOKENS = ["open", "accepted", "resolved", "deferred", "won't-fix", "wont-fix"];
325
+ function findFirstToken(text, tokens) {
326
+ for (const token of tokens) {
327
+ const escaped = token.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
328
+ const regex = new RegExp(`\\b${escaped}\\b`, "iu");
329
+ if (regex.test(text))
330
+ return token;
331
+ }
332
+ return null;
333
+ }
334
+ function normalizeDisposition(value) {
335
+ if (value === null)
336
+ return null;
337
+ const lower = value.toLowerCase();
338
+ if (lower === "wont-fix" || lower === "won't-fix")
339
+ return "won't-fix";
340
+ return lower;
341
+ }
342
+ function extractTddPerSliceFindings(perSliceBody) {
343
+ const rows = new Map();
344
+ const lines = perSliceBody.split(/\r?\n/u);
345
+ for (const line of lines) {
346
+ const ids = line.match(FINDING_ID_PATTERN);
347
+ if (!ids || ids.length === 0)
348
+ continue;
349
+ const severity = findFirstToken(line, SEVERITY_TOKENS);
350
+ const disposition = normalizeDisposition(findFirstToken(line, DISPOSITION_TOKENS));
351
+ for (const rawId of ids) {
352
+ const id = rawId.toUpperCase();
353
+ if (rows.has(id))
354
+ continue;
355
+ rows.set(id, { id, severity, disposition });
356
+ }
357
+ }
358
+ return rows;
359
+ }
360
+ /**
361
+ * Cross-artifact duplication guard (Wave 23 / v5.0.0).
362
+ *
363
+ * When the same finding ID (`F-NN`) appears in both
364
+ * `06-tdd.md > Per-Slice Review` and `07-review-army.json`, the
365
+ * severity and disposition MUST match. Per-slice tdd reviews own
366
+ * single-slice findings; review cites them, never re-classifies.
367
+ *
368
+ * If neither artifact uses `F-NN` IDs, the check is a no-op.
369
+ */
370
+ export async function checkReviewTddNoCrossArtifactDuplication(projectRoot) {
371
+ const tddPath = path.join(projectRoot, RUNTIME_ROOT, "artifacts", "06-tdd.md");
372
+ const armyPath = path.join(projectRoot, RUNTIME_ROOT, "artifacts", "07-review-army.json");
373
+ const tddArtifactExists = await exists(tddPath);
374
+ const reviewArtifactExists = await exists(armyPath);
375
+ if (!tddArtifactExists || !reviewArtifactExists) {
376
+ return {
377
+ ok: true,
378
+ errors: [],
379
+ conflicts: [],
380
+ tddArtifactExists,
381
+ reviewArtifactExists
382
+ };
383
+ }
384
+ const tddRaw = await fs.readFile(tddPath, "utf8");
385
+ const tddSections = extractH2Sections(tddRaw);
386
+ const perSliceBody = sectionBodyByName(tddSections, "Per-Slice Review");
387
+ if (!perSliceBody) {
388
+ return {
389
+ ok: true,
390
+ errors: [],
391
+ conflicts: [],
392
+ tddArtifactExists,
393
+ reviewArtifactExists
394
+ };
395
+ }
396
+ const tddFindings = extractTddPerSliceFindings(perSliceBody);
397
+ if (tddFindings.size === 0) {
398
+ return {
399
+ ok: true,
400
+ errors: [],
401
+ conflicts: [],
402
+ tddArtifactExists,
403
+ reviewArtifactExists
404
+ };
405
+ }
406
+ let parsed;
407
+ try {
408
+ parsed = JSON.parse(await fs.readFile(armyPath, "utf8"));
409
+ }
410
+ catch {
411
+ return {
412
+ ok: true,
413
+ errors: [],
414
+ conflicts: [],
415
+ tddArtifactExists,
416
+ reviewArtifactExists
417
+ };
418
+ }
419
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
420
+ return {
421
+ ok: true,
422
+ errors: [],
423
+ conflicts: [],
424
+ tddArtifactExists,
425
+ reviewArtifactExists
426
+ };
427
+ }
428
+ const root = parsed;
429
+ const findings = Array.isArray(root.findings) ? root.findings : [];
430
+ const conflicts = [];
431
+ for (const f of findings) {
432
+ if (!f || typeof f !== "object" || Array.isArray(f))
433
+ continue;
434
+ const o = f;
435
+ if (typeof o.id !== "string")
436
+ continue;
437
+ const id = o.id.toUpperCase();
438
+ const tddRow = tddFindings.get(id);
439
+ if (!tddRow)
440
+ continue;
441
+ const reviewSeverity = typeof o.severity === "string" ? o.severity : null;
442
+ const reviewDisposition = normalizeDisposition(typeof o.status === "string" ? o.status : null);
443
+ const severityMismatch = tddRow.severity !== null &&
444
+ reviewSeverity !== null &&
445
+ tddRow.severity.toLowerCase() !== reviewSeverity.toLowerCase();
446
+ const dispositionMismatch = tddRow.disposition !== null &&
447
+ reviewDisposition !== null &&
448
+ tddRow.disposition !== reviewDisposition;
449
+ if (severityMismatch || dispositionMismatch) {
450
+ conflicts.push({
451
+ findingId: id,
452
+ tddSeverity: tddRow.severity,
453
+ reviewSeverity,
454
+ tddDisposition: tddRow.disposition,
455
+ reviewDisposition
456
+ });
457
+ }
458
+ }
459
+ const errors = conflicts.map((c) => {
460
+ const parts = [];
461
+ if (c.tddSeverity !== null && c.reviewSeverity !== null && c.tddSeverity.toLowerCase() !== c.reviewSeverity.toLowerCase()) {
462
+ parts.push(`severity tdd=${c.tddSeverity} vs review-army=${c.reviewSeverity}`);
463
+ }
464
+ if (c.tddDisposition !== null && c.reviewDisposition !== null && c.tddDisposition !== c.reviewDisposition) {
465
+ parts.push(`disposition tdd=${c.tddDisposition} vs review-army=${c.reviewDisposition}`);
466
+ }
467
+ return `Finding ${c.findingId} appears in both 06-tdd.md > Per-Slice Review and 07-review-army.json with mismatched ${parts.join(" and ")}. Review must cite, not re-classify.`;
468
+ });
469
+ return {
470
+ ok: errors.length === 0,
471
+ errors,
472
+ conflicts,
473
+ tddArtifactExists,
474
+ reviewArtifactExists
475
+ };
476
+ }
322
477
  export async function checkReviewSecurityNoChangeAttestation(projectRoot) {
323
478
  const reviewMdPath = path.join(projectRoot, RUNTIME_ROOT, "artifacts", "07-review.md");
324
479
  if (!(await exists(reviewMdPath))) {
@@ -1,4 +1,5 @@
1
1
  import { markdownFieldRegex, sectionBodyByName } from "./shared.js";
2
+ import { checkReviewTddNoCrossArtifactDuplication } from "./review-army.js";
2
3
  export async function lintReviewStage(ctx) {
3
4
  const { projectRoot, track, raw, absFile, sections, findings, parsedFrontmatter, brainstormShortCircuitBody, brainstormShortCircuitActivated, staleDiagramAuditEnabled, isTrivialOverride } = ctx;
4
5
  // Universal Layer 2.7 structural checks (superpowers requesting + receiving).
@@ -62,6 +63,18 @@ export async function lintReviewStage(ctx) {
62
63
  : "Receiving Posture is missing the anti-sycophancy acknowledgement line."
63
64
  });
64
65
  }
66
+ const dupResult = await checkReviewTddNoCrossArtifactDuplication(projectRoot);
67
+ findings.push({
68
+ section: "review.no_cross_artifact_duplication",
69
+ required: true,
70
+ rule: "[P1] review.no_cross_artifact_duplication — when a finding ID appears in both `06-tdd.md > Per-Slice Review` and `07-review-army.json`, severity and disposition must match (review cites tdd; never re-classifies).",
71
+ found: dupResult.ok,
72
+ details: dupResult.ok
73
+ ? dupResult.tddArtifactExists && dupResult.reviewArtifactExists
74
+ ? "No cross-artifact severity/disposition conflicts between tdd Per-Slice Review and review-army findings."
75
+ : "Skipped: tdd Per-Slice Review or review-army artifact not present."
76
+ : dupResult.errors.join(" ")
77
+ });
65
78
  const lensCoverageBody = sectionBodyByName(sections, "Lens Coverage");
66
79
  if (lensCoverageBody === null) {
67
80
  findings.push({
@@ -1,24 +1,17 @@
1
- import { checkCriticPredictionsContract, sectionBodyByHeadingPrefix, sectionBodyByName, extractCanonicalScopeMode, sectionBodyByAnyName, collectPatternHits, SCOPE_REDUCTION_PATTERNS, validateLockedDecisionAnchors, getMarkdownTableRows } from "./shared.js";
1
+ import { checkCriticPredictionsContract, evaluateQaLogFloor, sectionBodyByHeadingPrefix, sectionBodyByName, extractCanonicalScopeMode, getMarkdownTableRows } from "./shared.js";
2
2
  import { readDelegationLedger } from "../delegation.js";
3
3
  export async function lintScopeStage(ctx) {
4
- const { projectRoot, track, raw, absFile, sections, findings, parsedFrontmatter, brainstormShortCircuitBody, brainstormShortCircuitActivated, staleDiagramAuditEnabled, isTrivialOverride } = ctx;
4
+ const { projectRoot, track, raw, absFile, sections, findings, parsedFrontmatter, brainstormShortCircuitBody, brainstormShortCircuitActivated, staleDiagramAuditEnabled, isTrivialOverride, activeStageFlags } = ctx;
5
5
  const lockedDecisionsBody = sectionBodyByHeadingPrefix(sections, "Locked Decisions") ?? "";
6
6
  const scopeSummaryBody = sectionBodyByName(sections, "Scope Summary") ?? "";
7
7
  const selectedScopeMode = extractCanonicalScopeMode(scopeSummaryBody);
8
- const strictScopeGuards = parsedFrontmatter.hasFrontmatter ||
9
- sectionBodyByHeadingPrefix(sections, "Locked Decisions") !== null;
10
- const scopeSections = [
11
- sectionBodyByAnyName(sections, ["In Scope / Out of Scope", "In Scope", "Out of Scope"]) ?? "",
12
- sectionBodyByName(sections, "Scope Summary") ?? "",
13
- lockedDecisionsBody
14
- ].join("\n");
15
8
  const qaLogBody = sectionBodyByName(sections, "Q&A Log");
16
9
  const qaLogRows = qaLogBody ? getMarkdownTableRows(qaLogBody) : [];
17
10
  const qaLogOk = qaLogBody !== null && qaLogRows.length > 0;
18
11
  findings.push({
19
12
  section: "qa_log_missing",
20
13
  required: false,
21
- rule: "[P3] qa_log_missing — Q&A Log empty — confirm you actually had a dialogue with the user, not a draft from memory.",
14
+ rule: "[P2] qa_log_missing — Q&A Log empty — confirm you actually had a dialogue with the user, not a draft from memory.",
22
15
  found: qaLogOk,
23
16
  details: qaLogOk
24
17
  ? `Q&A Log contains ${qaLogRows.length} data row(s).`
@@ -26,6 +19,17 @@ export async function lintScopeStage(ctx) {
26
19
  ? "Missing `## Q&A Log` section."
27
20
  : "Q&A Log is present but has zero data rows."
28
21
  });
22
+ {
23
+ const skipQuestions = activeStageFlags.includes("--skip-questions");
24
+ const floor = evaluateQaLogFloor(qaLogBody, track, "scope", { skipQuestions });
25
+ findings.push({
26
+ section: "qa_log_unconverged",
27
+ required: !floor.skipQuestionsAdvisory,
28
+ rule: "[P1] qa_log_unconverged — Q&A Log has not converged for this stage. Continue elicitation until forcing-question topics are addressed, the last 2 rows produce no decision-changing impact (Ralph-Loop), or an explicit user stop-signal row is appended.",
29
+ found: floor.ok,
30
+ details: floor.details
31
+ });
32
+ }
29
33
  const strategistRequired = selectedScopeMode === "SCOPE EXPANSION" || selectedScopeMode === "SELECTIVE EXPANSION";
30
34
  if (strategistRequired) {
31
35
  const delegationLedger = await readDelegationLedger(projectRoot);
@@ -57,28 +61,11 @@ export async function lintScopeStage(ctx) {
57
61
  details: criticPredictions.details
58
62
  });
59
63
  }
60
- const reductionHits = collectPatternHits(scopeSections, SCOPE_REDUCTION_PATTERNS);
61
- findings.push({
62
- section: "No Scope Reduction Language",
63
- required: strictScopeGuards,
64
- rule: "Scope boundary sections must not use reduction placeholders (`v1`, `for now`, `later`, `temporary`, `placeholder`).",
65
- found: reductionHits.length === 0,
66
- details: reductionHits.length === 0
67
- ? "No scope-reduction phrases detected in scope boundary sections."
68
- : `Detected scope-reduction phrase(s): ${reductionHits.join(", ")}.`
69
- });
70
64
  if (sectionBodyByHeadingPrefix(sections, "Locked Decisions") !== null) {
71
- const anchorValidation = validateLockedDecisionAnchors(lockedDecisionsBody);
72
- findings.push({
73
- section: "Locked Decisions Hash Integrity",
74
- required: true,
75
- rule: "Locked Decisions section must list unique LD#<sha8> content-derived anchors.",
76
- found: anchorValidation.ok,
77
- details: anchorValidation.details
78
- });
79
- // Legacy D-XX rows remain advisory for older artifacts, but new templates
80
- // use LD#hash anchors. This check keeps D-XX duplicates visible without
81
- // making old artifacts the primary contract.
65
+ // D-XX IDs are the stable contract. The legacy LD#<sha8> hash anchor
66
+ // check was removed in Wave 22 (v4.0.0) — it caused agents to spam
67
+ // shell hash commands when shifting decision rows around, and provided
68
+ // no signal beyond the D-XX uniqueness check below.
82
69
  const listDecisionLines = lockedDecisionsBody
83
70
  .split(/\r?\n/u)
84
71
  .map((line) => line.trim())
@@ -113,27 +100,19 @@ export async function lintScopeStage(ctx) {
113
100
  }
114
101
  findings.push({
115
102
  section: "Locked Decisions ID Integrity",
116
- required: false,
117
- rule: "Locked Decisions section must list each decision with a unique stable D-XX ID.",
103
+ required: true,
104
+ rule: "Locked Decisions section must list each decision with a unique stable D-XX ID. (D-XX IDs replaced the legacy LD#<sha8> hash anchors in Wave 22.)",
118
105
  found: issues.length === 0,
119
106
  details: issues.length === 0
120
107
  ? `${rowDecisionIds.length} decision ID(s) recorded with no duplicates.`
121
108
  : issues.join("; ")
122
109
  });
123
110
  }
124
- // Universal Layer 2.2 structural checks (gstack plan-ceo-review). All
125
- // present-only they validate shape when the section exists.
126
- const altsBody = sectionBodyByName(sections, "Implementation Alternatives");
127
- if (altsBody !== null) {
128
- const recommendation = /^RECOMMENDATION:\s*(.+)$/imu.test(altsBody);
129
- findings.push({
130
- section: "Implementation Alternatives Recommendation",
131
- required: true,
132
- rule: "Implementation Alternatives must conclude with a `RECOMMENDATION:` line citing the chosen option and rationale.",
133
- found: recommendation,
134
- details: recommendation
135
- ? "Recommendation marker present."
136
- : "Missing or empty `RECOMMENDATION:` line under Implementation Alternatives."
137
- });
138
- }
111
+ // Wave 23 (v5.0.0): scope no longer owns architecture-tier alternatives
112
+ // (`## Implementation Alternatives` was removed from the scope template
113
+ // and stage schema). Design OWNS the architecture-tier decision via
114
+ // `## Architecture Decision Record (ADR)` and `## Engineering Lock`.
115
+ // The legacy linter rule `Implementation Alternatives Recommendation`
116
+ // was removed in Wave 23 — if a legacy artifact still has the section,
117
+ // it is now treated as informational only.
139
118
  }
@@ -1,4 +1,95 @@
1
1
  import { type FlowStage, type FlowTrack } from "../types.js";
2
+ /**
3
+ * Stages that run adaptive elicitation. The `qa_log_unconverged` rule
4
+ * only fires for these. Other stages may still record a Q&A Log but no
5
+ * convergence floor is enforced.
6
+ */
7
+ export declare const ELICITATION_STAGES: ReadonlySet<FlowStage>;
8
+ export interface QaLogFloorOptions {
9
+ /**
10
+ * When true, downgrades the finding to advisory (`required: false`).
11
+ * Set when `--skip-questions` was persisted to the active stage flags.
12
+ */
13
+ skipQuestions?: boolean;
14
+ /**
15
+ * Optional pre-extracted forcing-question topics. When omitted, the
16
+ * evaluator calls `extractForcingQuestions(stage)` which scans the
17
+ * stage's checklist row.
18
+ */
19
+ forcingQuestions?: string[];
20
+ }
21
+ export interface QaLogFloorResult {
22
+ /** Whether convergence is satisfied (passes the gate). */
23
+ ok: boolean;
24
+ /** Substantive Q&A Log row count (excludes `skipped`/`waived` only rows). */
25
+ count: number;
26
+ /**
27
+ * Legacy field, retained for harness UI compatibility. Always 0 in
28
+ * Wave 23 — the convergence floor no longer enforces a fixed count.
29
+ * Harness can still surface `questionBudgetHint(track, stage).recommended`
30
+ * as a soft hint, but it is NOT tied to gate blocking.
31
+ */
32
+ min: number;
33
+ /** Whether a stop-signal row was detected. */
34
+ hasStopSignal: boolean;
35
+ /**
36
+ * Legacy field, retained for harness UI compatibility. Always false in
37
+ * Wave 23 — convergence semantics replaced the lite-tier short-circuit.
38
+ */
39
+ liteShortCircuit: boolean;
40
+ /** Whether `--skip-questions` flag downgraded the finding to advisory. */
41
+ skipQuestionsAdvisory: boolean;
42
+ /** Forcing-question topics deemed addressed (substring match in Q&A). */
43
+ forcingCovered: string[];
44
+ /** Forcing-question topics still pending (no matching Q&A row). */
45
+ forcingPending: string[];
46
+ /**
47
+ * True when the last 2 substantive rows have decision_impact marking
48
+ * `skip`/`continue`/`no-change`/`done`/etc. — i.e. Q&A is no longer
49
+ * surfacing decision-changing answers (Ralph-Loop convergence detector).
50
+ */
51
+ noNewDecisions: boolean;
52
+ /** Human-readable details for the linter finding. */
53
+ details: string;
54
+ }
55
+ /**
56
+ * Extract forcing-question topics from a stage's checklist. Looks for
57
+ * the canonical `**<Stage> forcing questions (must be covered or
58
+ * explicitly waived)** — <topic1>, <topic2>, ...` row and tokenizes the
59
+ * comma-separated topic list. Returns trimmed topic strings stripped of
60
+ * leading question words (`what`/`who`/`where`/`which`/`how`/`is`/`do`/`does`).
61
+ *
62
+ * Returns empty array when no forcing-questions row is present (caller
63
+ * should treat absence as "no forcing requirement" — convergence falls
64
+ * back to the no-new-decisions / stop-signal detectors).
65
+ */
66
+ export declare function extractForcingQuestions(stage: FlowStage): string[];
67
+ /**
68
+ * Evaluate the Q&A Log convergence floor for a brainstorm / scope /
69
+ * design artifact. Returns ok=true when convergence is reached or any
70
+ * escape hatch fires.
71
+ *
72
+ * Convergence sources (any one is sufficient):
73
+ * - All forcing-question topics from the stage checklist appear addressed
74
+ * in `## Q&A Log` (substring keyword match in question/answer columns).
75
+ * - The Ralph-Loop convergence detector reports the last 2 substantive
76
+ * rows have decision_impact marking `skip`/`continue`/`no-change`/`done`
77
+ * (i.e. the dialogue is no longer producing decision-changing rows).
78
+ * - Q&A Log contains a stop-signal row (existing
79
+ * `QA_LOG_STOP_SIGNAL_PATTERNS` keep working).
80
+ * - `--skip-questions` flag was persisted to the active stage flags
81
+ * (`options.skipQuestions=true`); finding downgrades to advisory.
82
+ * - The stage checklist exposes no forcing-questions row (e.g. simple
83
+ * refactor) AND the artifact has at least one substantive row — treat
84
+ * as converged because there is nothing left to force.
85
+ *
86
+ * Wave 23 (v5.0.0) replaces the count-based `qa_log_below_min` rule with
87
+ * `qa_log_unconverged`. The fixed count constant (10 for standard) and
88
+ * the `CCLAW_ELICITATION_FLOOR=advisory` env override were removed. The
89
+ * `min` and `liteShortCircuit` fields on the result are retained for
90
+ * harness UI compatibility but are always 0/false.
91
+ */
92
+ export declare function evaluateQaLogFloor(qaLogBody: string | null, track: FlowTrack, stage: FlowStage, options?: QaLogFloorOptions): QaLogFloorResult;
2
93
  export interface LintFinding {
3
94
  section: string;
4
95
  required: boolean;
@@ -89,10 +180,6 @@ export declare function canonicalModesInText(text: string): CanonicalScopeMode[]
89
180
  export declare function shortModeToCanonical(text: string): CanonicalScopeMode | null;
90
181
  export declare function canonicalModeFromCandidate(candidate: string): CanonicalScopeMode | null;
91
182
  export declare function extractCanonicalScopeMode(body: string): CanonicalScopeMode | null;
92
- export declare function validatePremiseChallenge(sectionBody: string): {
93
- ok: boolean;
94
- details: string;
95
- };
96
183
  export declare function validateScopeSummary(sectionBody: string): {
97
184
  ok: boolean;
98
185
  details: string;
@@ -116,11 +203,6 @@ export declare function validateRequirementsTaxonomy(sectionBody: string): {
116
203
  ok: boolean;
117
204
  details: string;
118
205
  };
119
- export declare function validateLockedDecisionAnchors(sectionBody: string): {
120
- ok: boolean;
121
- anchors: string[];
122
- details: string;
123
- };
124
206
  export interface InteractionEdgeCaseRequirement {
125
207
  label: string;
126
208
  pattern: RegExp;
@@ -220,8 +302,6 @@ export declare const SCOPE_REDUCTION_PATTERNS: Array<{
220
302
  export declare function parseFrontmatter(markdown: string): ParsedFrontmatter;
221
303
  export declare function extractDecisionIds(text: string): string[];
222
304
  export declare function extractRequirementIdsFromMarkdown(text: string): string[];
223
- export declare function extractLockedDecisionAnchors(text: string): string[];
224
- export declare function lockedDecisionHash(value: string): string;
225
305
  export declare function collectPatternHits(text: string, patterns: Array<{
226
306
  label: string;
227
307
  regex: RegExp;
@@ -245,4 +325,11 @@ export interface StageLintContext {
245
325
  staleDiagramAuditEnabled: boolean;
246
326
  isTrivialOverride: boolean;
247
327
  overrideSet: Set<string> | null;
328
+ /**
329
+ * Stage-level flags persisted to flow-state.json `activeRun.currentStage.flags`
330
+ * (or equivalent). Used as escape-hatch signal for the Q&A floor rule
331
+ * (e.g. `--skip-questions` downgrades `qa_log_unconverged` to advisory).
332
+ * When orchestrator cannot read flow-state, defaults to an empty array.
333
+ */
334
+ activeStageFlags: string[];
248
335
  }