cclaw-cli 6.9.0 → 6.11.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.
@@ -2,10 +2,38 @@ import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { readDelegationLedger } from "../delegation.js";
4
4
  import { evaluateInvestigationTrace, sectionBodyByName } from "./shared.js";
5
+ const SLICE_SUMMARY_START = "<!-- auto-start: tdd-slice-summary -->";
6
+ const SLICE_SUMMARY_END = "<!-- auto-end: tdd-slice-summary -->";
7
+ const SLICES_INDEX_START = "<!-- auto-start: slices-index -->";
8
+ const SLICES_INDEX_END = "<!-- auto-end: slices-index -->";
9
+ /**
10
+ * v6.11.0 — TDD stage linter.
11
+ *
12
+ * Source-of-truth ladder, in order of precedence:
13
+ *
14
+ * 1. **Phase events** in `delegation-events.jsonl` for the active run
15
+ * (`stage=tdd`, `sliceId=S-N`, `phase=red|green|refactor|refactor-deferred|doc`).
16
+ * When at least one slice carries any phase event, the linter
17
+ * auto-derives Watched-RED / Vertical Slice Cycle from the events
18
+ * and writes a rendered summary block between auto-render markers
19
+ * in `06-tdd.md`. Markdown table content is no longer required.
20
+ * 2. **Legacy markdown tables** (Watched-RED Proof + Vertical Slice
21
+ * Cycle) — used as a fallback when the events ledger has no slice
22
+ * phase rows for the active run. Existing v6.10 and earlier
23
+ * artifacts continue to validate via this path.
24
+ * 3. **Sharded slice files** under `<artifacts-dir>/tdd-slices/S-*.md`.
25
+ * Per-slice prose lives there. The main `06-tdd.md` is auto-indexed
26
+ * via `## Slices Index`.
27
+ */
5
28
  export async function lintTddStage(ctx) {
6
- const { projectRoot, track, raw, absFile, sections, findings, parsedFrontmatter, brainstormShortCircuitBody, brainstormShortCircuitActivated, staleDiagramAuditEnabled, isTrivialOverride } = ctx;
29
+ const { projectRoot, discoveryMode, raw, absFile, sections, findings, parsedFrontmatter } = ctx;
30
+ void projectRoot;
31
+ void parsedFrontmatter;
7
32
  evaluateInvestigationTrace(ctx, "Watched-RED Proof");
8
- // Universal Layer 2.6 structural checks (superpowers TDD + evanflow vertical slices).
33
+ const delegationLedger = await readDelegationLedger(ctx.projectRoot);
34
+ const activeRunEntries = delegationLedger.entries.filter((entry) => entry.stage === "tdd" && entry.runId === delegationLedger.runId);
35
+ const slicesByEvents = groupBySlice(activeRunEntries);
36
+ const eventsActive = slicesByEvents.size > 0;
9
37
  const ironLawBody = sectionBodyByName(sections, "Iron Law Acknowledgement");
10
38
  if (ironLawBody === null) {
11
39
  findings.push({
@@ -29,7 +57,17 @@ export async function lintTddStage(ctx) {
29
57
  });
30
58
  }
31
59
  const watchedRedBody = sectionBodyByName(sections, "Watched-RED Proof");
32
- if (watchedRedBody === null) {
60
+ if (eventsActive) {
61
+ const redResult = evaluateEventsWatchedRed(slicesByEvents);
62
+ findings.push({
63
+ section: "Watched-RED Proof Shape",
64
+ required: true,
65
+ rule: "Watched-RED Proof: when delegation-events.jsonl carries slice phase events, every slice with a phase=red row must include a non-empty evidenceRefs[] (test path, span ref, or pasted-output pointer) and a completedTs.",
66
+ found: redResult.ok,
67
+ details: redResult.details
68
+ });
69
+ }
70
+ else if (watchedRedBody === null) {
33
71
  findings.push({
34
72
  section: "Watched-RED Proof Shape",
35
73
  required: true,
@@ -44,9 +82,8 @@ export async function lintTddStage(ctx) {
44
82
  const populatedRows = dataRows.filter((row) => row
45
83
  .split("|")
46
84
  .slice(1, -1)
47
- .filter((_, idx) => idx !== 0) // skip slice column
85
+ .filter((_, idx) => idx !== 0)
48
86
  .some((cell) => cell.trim().length > 0));
49
- // Each populated row must include an ISO timestamp in column 3.
50
87
  const isoRegex = /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/u;
51
88
  const validProofRows = populatedRows.filter((row) => isoRegex.test(row));
52
89
  const hasPopulatedRows = populatedRows.length > 0;
@@ -63,25 +100,56 @@ export async function lintTddStage(ctx) {
63
100
  : `${populatedRows.length - validProofRows.length} watched-RED proof row(s) lack an ISO timestamp.`
64
101
  });
65
102
  }
66
- const sliceCycleBody = sectionBodyByName(sections, "Vertical Slice Cycle");
67
- if (sliceCycleBody === null) {
103
+ if (eventsActive) {
104
+ const cycleResult = evaluateEventsSliceCycle(slicesByEvents);
68
105
  findings.push({
69
106
  section: "Vertical Slice Cycle Coverage",
70
107
  required: true,
71
- rule: "Vertical Slice Cycle must include RED, GREEN, and REFACTOR per slice (refactor may be deferred with rationale).",
72
- found: false,
73
- details: "No ## heading matching required section \"Vertical Slice Cycle\"."
74
- });
75
- }
76
- else {
77
- const cycleResult = parseVerticalSliceCycle(sliceCycleBody);
78
- findings.push({
79
- section: "Vertical Slice Cycle Coverage",
80
- required: true,
81
- rule: "Vertical Slice Cycle must show RED -> GREEN -> REFACTOR monotonic progression per slice (refactor may be deferred with one-line rationale, e.g. `deferred because <reason>`).",
108
+ rule: "Vertical Slice Cycle: every slice with phase events must show RED before GREEN (completedTs monotonic), and a REFACTOR phase event (`refactor` with completedTs OR `refactor-deferred` with non-empty refactorRationale or evidenceRefs).",
82
109
  found: cycleResult.ok,
83
110
  details: cycleResult.details
84
111
  });
112
+ for (const finding of cycleResult.findings) {
113
+ findings.push(finding);
114
+ }
115
+ }
116
+ else {
117
+ const sliceCycleBody = sectionBodyByName(sections, "Vertical Slice Cycle");
118
+ if (sliceCycleBody === null) {
119
+ findings.push({
120
+ section: "Vertical Slice Cycle Coverage",
121
+ required: true,
122
+ rule: "Vertical Slice Cycle must include RED, GREEN, and REFACTOR per slice (refactor may be deferred with rationale).",
123
+ found: false,
124
+ details: "No ## heading matching required section \"Vertical Slice Cycle\"."
125
+ });
126
+ }
127
+ else {
128
+ const cycleResult = parseVerticalSliceCycle(sliceCycleBody);
129
+ findings.push({
130
+ section: "Vertical Slice Cycle Coverage",
131
+ required: true,
132
+ rule: "Vertical Slice Cycle must show RED -> GREEN -> REFACTOR monotonic progression per slice (refactor may be deferred with one-line rationale, e.g. `deferred because <reason>`).",
133
+ found: cycleResult.ok,
134
+ details: cycleResult.details
135
+ });
136
+ }
137
+ }
138
+ // Phase C — slice-documenter coverage. When discoveryMode=deep, every
139
+ // slice with a green phase event must also carry a phase=doc event with
140
+ // non-empty evidenceRefs pointing at `tdd-slices/S-<id>.md`.
141
+ if (eventsActive) {
142
+ const docResult = evaluateSliceDocumenterCoverage(slicesByEvents);
143
+ if (docResult.missing.length > 0) {
144
+ const isDeep = discoveryMode === "deep";
145
+ findings.push({
146
+ section: "tdd_slice_documenter_missing_for_deep",
147
+ required: isDeep,
148
+ rule: "On discoveryMode=deep, every TDD slice with a phase=green event must also carry a slice-documenter `phase=doc` event whose evidenceRefs reference `<artifacts-dir>/tdd-slices/S-<id>.md`. On other discovery modes the requirement is advisory.",
149
+ found: false,
150
+ details: `Slices missing slice-documenter coverage: ${docResult.missing.join(", ")}.`
151
+ });
152
+ }
85
153
  }
86
154
  const assertionBody = sectionBodyByName(sections, "Assertion Correctness Notes");
87
155
  if (assertionBody !== null) {
@@ -122,8 +190,6 @@ export async function lintTddStage(ctx) {
122
190
  : "Mocks/spies detected without boundary justification; add explicit trust-boundary rationale or replace with real/fake/stub coverage."
123
191
  });
124
192
  }
125
- const delegationLedger = await readDelegationLedger(projectRoot);
126
- const activeRunEntries = delegationLedger.entries.filter((entry) => entry.stage === "tdd" && entry.runId === delegationLedger.runId);
127
193
  const completedSliceImplementers = activeRunEntries.filter((entry) => entry.agent === "slice-implementer" && entry.status === "completed");
128
194
  const fanOutDetected = completedSliceImplementers.length > 1;
129
195
  if (fanOutDetected) {
@@ -193,19 +259,276 @@ export async function lintTddStage(ctx) {
193
259
  : "integration-overseer completion exists, but PASS/PASS_WITH_GAPS evidence is missing in delegation evidenceRefs and artifact text."
194
260
  });
195
261
  }
196
- {
197
- const verificationBody = sectionBodyByName(sections, "Verification Ladder") ??
198
- sectionBodyByName(sections, "Verification Status") ??
199
- sectionBodyByName(sections, "Verification");
200
- const ladderResult = evaluateVerificationLadder(verificationBody);
262
+ const verificationBody = sectionBodyByName(sections, "Verification Ladder") ??
263
+ sectionBodyByName(sections, "Verification Status") ??
264
+ sectionBodyByName(sections, "Verification");
265
+ const ladderResult = evaluateVerificationLadder(verificationBody);
266
+ findings.push({
267
+ section: "tdd_verification_pending",
268
+ required: true,
269
+ rule: "Verification Ladder rows must not remain `pending`; promote each row to `passed`, `n/a`, `failed`, `skipped`, or `deferred` (with rationale) before stage-complete.",
270
+ found: ladderResult.ok,
271
+ details: ladderResult.details
272
+ });
273
+ // Phase S — sharded slice files. Validate per-slice file presence
274
+ // and required headings. `tdd-slices/` is optional; missing folder
275
+ // simply means main-only mode (legacy fallback).
276
+ const artifactsDir = path.dirname(absFile);
277
+ const slicesDir = path.join(artifactsDir, "tdd-slices");
278
+ const sliceFiles = await listSliceFiles(slicesDir);
279
+ for (const sliceFile of sliceFiles) {
280
+ const sliceId = sliceFile.sliceId;
281
+ const requiredForSlice = slicesByEvents.has(sliceId) &&
282
+ slicesByEvents.get(sliceId).some((entry) => entry.phase === "doc");
283
+ let content = "";
284
+ try {
285
+ content = await fs.readFile(sliceFile.absPath, "utf8");
286
+ }
287
+ catch {
288
+ content = "";
289
+ }
290
+ const issues = [];
291
+ if (!new RegExp(`^#\\s+Slice\\s+${escapeForRegex(sliceId)}\\b`, "mu").test(content) &&
292
+ !/^#\s+Slice\b/mu.test(content)) {
293
+ issues.push("missing `# Slice <id>` heading");
294
+ }
295
+ if (!/^##\s+Plan unit\b/imu.test(content)) {
296
+ issues.push("missing `## Plan unit` section");
297
+ }
298
+ if (!/^##\s+REFACTOR notes\b/imu.test(content)) {
299
+ issues.push("missing `## REFACTOR notes` section");
300
+ }
301
+ if (!/^##\s+Learnings\b/imu.test(content)) {
302
+ issues.push("missing `## Learnings` section");
303
+ }
201
304
  findings.push({
202
- section: "tdd_verification_pending",
203
- required: true,
204
- rule: "Verification Ladder rows must not remain `pending`; promote each row to `passed`, `n/a`, `failed`, `skipped`, or `deferred` (with rationale) before stage-complete.",
205
- found: ladderResult.ok,
206
- details: ladderResult.details
305
+ section: `tdd_slice_file:${sliceId}`,
306
+ required: requiredForSlice,
307
+ rule: "Sharded slice file must include `# Slice <id>`, `## Plan unit`, `## REFACTOR notes`, and `## Learnings` headings.",
308
+ found: issues.length === 0,
309
+ details: issues.length === 0
310
+ ? `tdd-slices/${path.basename(sliceFile.absPath)} has all required headings.`
311
+ : `tdd-slices/${path.basename(sliceFile.absPath)}: ${issues.join(", ")}.`
207
312
  });
208
313
  }
314
+ // Auto-render the slice summary inside `06-tdd.md` between markers.
315
+ // Idempotent — content outside the markers is preserved. Skipped
316
+ // entirely when there is nothing to render, so legacy artifacts (no
317
+ // phase events, no sharded files) stay byte-for-byte unchanged.
318
+ if (eventsActive || sliceFiles.length > 0) {
319
+ try {
320
+ await renderTddSliceSummary({
321
+ mainArtifactPath: absFile,
322
+ slicesByEvents,
323
+ sliceFiles,
324
+ renderSummary: eventsActive,
325
+ renderIndex: sliceFiles.length > 0
326
+ });
327
+ }
328
+ catch {
329
+ // best-effort render — never block the gate.
330
+ }
331
+ }
332
+ }
333
+ async function listSliceFiles(slicesDir) {
334
+ let entries = [];
335
+ try {
336
+ entries = await fs.readdir(slicesDir);
337
+ }
338
+ catch {
339
+ return [];
340
+ }
341
+ const files = [];
342
+ for (const name of entries) {
343
+ const match = /^(S-[A-Za-z0-9._-]+)\.md$/u.exec(name);
344
+ if (!match)
345
+ continue;
346
+ files.push({ sliceId: match[1], absPath: path.join(slicesDir, name) });
347
+ }
348
+ files.sort((a, b) => (a.sliceId < b.sliceId ? -1 : a.sliceId > b.sliceId ? 1 : 0));
349
+ return files;
350
+ }
351
+ function escapeForRegex(value) {
352
+ return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
353
+ }
354
+ function groupBySlice(entries) {
355
+ const grouped = new Map();
356
+ for (const entry of entries) {
357
+ if (typeof entry.sliceId !== "string" || entry.sliceId.length === 0)
358
+ continue;
359
+ if (typeof entry.phase !== "string" || entry.phase.length === 0)
360
+ continue;
361
+ if (entry.status !== "completed")
362
+ continue;
363
+ const list = grouped.get(entry.sliceId) ?? [];
364
+ list.push(entry);
365
+ grouped.set(entry.sliceId, list);
366
+ }
367
+ return grouped;
368
+ }
369
+ export function evaluateEventsWatchedRed(slices) {
370
+ const errors = [];
371
+ let redCount = 0;
372
+ for (const [sliceId, rows] of slices.entries()) {
373
+ const reds = rows.filter((entry) => entry.phase === "red");
374
+ if (reds.length === 0)
375
+ continue;
376
+ redCount += 1;
377
+ const issues = [];
378
+ for (const red of reds) {
379
+ const ts = red.completedTs ?? red.endTs ?? red.ts ?? "";
380
+ if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/u.test(ts)) {
381
+ issues.push("phase=red row missing ISO completedTs");
382
+ }
383
+ if (!Array.isArray(red.evidenceRefs) ||
384
+ red.evidenceRefs.filter((ref) => typeof ref === "string" && ref.trim().length > 0).length === 0) {
385
+ issues.push("phase=red row has empty evidenceRefs");
386
+ }
387
+ }
388
+ if (issues.length > 0) {
389
+ errors.push(`${sliceId}: ${issues.join(", ")}`);
390
+ }
391
+ }
392
+ if (redCount === 0) {
393
+ return {
394
+ ok: false,
395
+ details: "Watched-RED Proof: events ledger has slice phase rows but none with phase=red. Dispatch test-author --slice <id> --phase red so RED is observable in delegation-events.jsonl."
396
+ };
397
+ }
398
+ if (errors.length > 0) {
399
+ return {
400
+ ok: false,
401
+ details: `Watched-RED slice events missing required fields: ${errors.join(" | ")}.`
402
+ };
403
+ }
404
+ return {
405
+ ok: true,
406
+ details: `${redCount} slice(s) carry phase=red events with ISO completedTs and evidenceRefs.`
407
+ };
408
+ }
409
+ export function evaluateEventsSliceCycle(slices) {
410
+ const errors = [];
411
+ const findings = [];
412
+ for (const [sliceId, rows] of slices.entries()) {
413
+ const reds = rows.filter((entry) => entry.phase === "red");
414
+ const greens = rows.filter((entry) => entry.phase === "green");
415
+ const refactors = rows.filter((entry) => entry.phase === "refactor" || entry.phase === "refactor-deferred");
416
+ const redTs = pickEventTs(reds);
417
+ const greenTs = pickEventTs(greens);
418
+ if (reds.length === 0) {
419
+ errors.push(`${sliceId}: phase=red event missing.`);
420
+ findings.push({
421
+ section: `tdd_slice_red_missing:${sliceId}`,
422
+ required: true,
423
+ rule: "Each TDD slice with phase events must include a `phase=red` row.",
424
+ found: false,
425
+ details: `${sliceId}: no phase=red event recorded for the active run.`
426
+ });
427
+ continue;
428
+ }
429
+ if (greens.length === 0) {
430
+ errors.push(`${sliceId}: phase=green event missing.`);
431
+ findings.push({
432
+ section: `tdd_slice_green_missing:${sliceId}`,
433
+ required: true,
434
+ rule: "Each TDD slice with a phase=red event must reach a `phase=green` row before stage-complete.",
435
+ found: false,
436
+ details: `${sliceId}: no phase=green event recorded; RED has no matching GREEN.`
437
+ });
438
+ continue;
439
+ }
440
+ if (greenTs && redTs && greenTs < redTs) {
441
+ errors.push(`${sliceId}: phase=green completedTs (${greenTs}) precedes phase=red (${redTs}).`);
442
+ findings.push({
443
+ section: `tdd_slice_phase_order_invalid:${sliceId}`,
444
+ required: true,
445
+ rule: "Phase events must be monotonic: phase=green completedTs >= phase=red completedTs.",
446
+ found: false,
447
+ details: `${sliceId}: green at ${greenTs} precedes red at ${redTs}.`
448
+ });
449
+ continue;
450
+ }
451
+ const greenEvidenceRef = greens
452
+ .flatMap((entry) => Array.isArray(entry.evidenceRefs) ? entry.evidenceRefs : [])
453
+ .find((ref) => typeof ref === "string" && ref.trim().length > 0);
454
+ if (!greenEvidenceRef) {
455
+ errors.push(`${sliceId}: phase=green row has empty evidenceRefs.`);
456
+ findings.push({
457
+ section: `tdd_slice_evidence_missing:${sliceId}`,
458
+ required: true,
459
+ rule: "Each `phase=green` event must record at least one evidenceRef (path to test artifact, span id, or pasted-output pointer).",
460
+ found: false,
461
+ details: `${sliceId}: phase=green event missing evidenceRefs.`
462
+ });
463
+ continue;
464
+ }
465
+ if (refactors.length === 0) {
466
+ errors.push(`${sliceId}: phase=refactor or phase=refactor-deferred event missing.`);
467
+ findings.push({
468
+ section: `tdd_slice_refactor_missing:${sliceId}`,
469
+ required: true,
470
+ rule: "Each TDD slice must close with a `phase=refactor` event or a `phase=refactor-deferred` event whose evidenceRefs / refactorRationale captures why refactor was deferred.",
471
+ found: false,
472
+ details: `${sliceId}: no phase=refactor or phase=refactor-deferred event.`
473
+ });
474
+ continue;
475
+ }
476
+ const deferred = refactors.find((entry) => entry.phase === "refactor-deferred");
477
+ if (deferred && refactors.every((entry) => entry.phase === "refactor-deferred")) {
478
+ const refs = Array.isArray(deferred.evidenceRefs) ? deferred.evidenceRefs : [];
479
+ const hasRationale = refs.some((ref) => typeof ref === "string" && ref.trim().length > 0);
480
+ if (!hasRationale) {
481
+ errors.push(`${sliceId}: phase=refactor-deferred row needs evidenceRefs containing a rationale.`);
482
+ findings.push({
483
+ section: `tdd_slice_refactor_missing:${sliceId}`,
484
+ required: true,
485
+ rule: "phase=refactor-deferred must record a rationale via --refactor-rationale or via --evidence-ref pointing at the rationale text.",
486
+ found: false,
487
+ details: `${sliceId}: phase=refactor-deferred recorded without rationale evidenceRefs.`
488
+ });
489
+ continue;
490
+ }
491
+ }
492
+ }
493
+ if (errors.length > 0) {
494
+ return {
495
+ ok: false,
496
+ details: errors.join(" "),
497
+ findings
498
+ };
499
+ }
500
+ return {
501
+ ok: true,
502
+ details: `${slices.size} slice(s) show monotonic phase=red -> phase=green -> phase=refactor (deferred-with-rationale accepted).`,
503
+ findings: []
504
+ };
505
+ }
506
+ export function evaluateSliceDocumenterCoverage(slices) {
507
+ const missing = [];
508
+ for (const [sliceId, rows] of slices.entries()) {
509
+ const hasGreen = rows.some((entry) => entry.phase === "green");
510
+ if (!hasGreen)
511
+ continue;
512
+ const docRow = rows.find((entry) => entry.phase === "doc");
513
+ if (!docRow) {
514
+ missing.push(sliceId);
515
+ continue;
516
+ }
517
+ const refs = Array.isArray(docRow.evidenceRefs) ? docRow.evidenceRefs : [];
518
+ const hasSliceFileRef = refs.some((ref) => typeof ref === "string" && /tdd-slices\/S-[^/]+\.md/u.test(ref));
519
+ if (!hasSliceFileRef) {
520
+ missing.push(sliceId);
521
+ }
522
+ }
523
+ return { missing };
524
+ }
525
+ function pickEventTs(rows) {
526
+ for (const entry of rows) {
527
+ const ts = entry.completedTs ?? entry.endTs ?? entry.ts;
528
+ if (typeof ts === "string" && ts.length > 0)
529
+ return ts;
530
+ }
531
+ return undefined;
209
532
  }
210
533
  export function parseVerticalSliceCycle(body) {
211
534
  const tableLines = body.split("\n").filter((line) => /^\|/u.test(line));
@@ -317,7 +640,6 @@ function extractDeferRationale(cell) {
317
640
  if (match !== null && match[1] !== undefined && match[1].trim().length > 0) {
318
641
  return match[1].trim();
319
642
  }
320
- // Accept any free-form rationale text following the deferral marker.
321
643
  const fallback = cleaned.replace(/^\s*(deferred|not[\s-]?needed|skipped|n\/?a)\b[:\s-]*/iu, "").trim();
322
644
  return fallback;
323
645
  }
@@ -364,3 +686,93 @@ export function evaluateVerificationLadder(body) {
364
686
  "Promote each to `passed`, `n/a`, `failed`, `skipped`, or `deferred` (with rationale) before stage-complete."
365
687
  };
366
688
  }
689
+ export async function renderTddSliceSummary(input) {
690
+ let raw;
691
+ try {
692
+ raw = await fs.readFile(input.mainArtifactPath, "utf8");
693
+ }
694
+ catch {
695
+ return;
696
+ }
697
+ let next = raw;
698
+ if (input.renderSummary !== false) {
699
+ const summaryBlock = renderSliceSummaryBlock(input.slicesByEvents);
700
+ next = upsertAutoBlock(next, SLICE_SUMMARY_START, SLICE_SUMMARY_END, summaryBlock);
701
+ }
702
+ if (input.renderIndex !== false) {
703
+ const indexBlock = renderSlicesIndexBlock(input.sliceFiles);
704
+ next = upsertAutoBlock(next, SLICES_INDEX_START, SLICES_INDEX_END, indexBlock);
705
+ }
706
+ if (next !== raw) {
707
+ try {
708
+ await fs.writeFile(input.mainArtifactPath, next, "utf8");
709
+ }
710
+ catch {
711
+ // best-effort render
712
+ }
713
+ }
714
+ }
715
+ function renderSliceSummaryBlock(slices) {
716
+ if (slices.size === 0) {
717
+ return "## Vertical Slice Cycle\n\n_No slice phase events recorded for the active run._";
718
+ }
719
+ const sortedIds = [...slices.keys()].sort();
720
+ const rows = [];
721
+ rows.push("## Vertical Slice Cycle");
722
+ rows.push("");
723
+ rows.push("| Slice | RED ts | GREEN ts | REFACTOR | Implementer | Test refs |");
724
+ rows.push("|---|---|---|---|---|---|");
725
+ for (const sliceId of sortedIds) {
726
+ const events = slices.get(sliceId);
727
+ const red = events.find((entry) => entry.phase === "red");
728
+ const green = events.find((entry) => entry.phase === "green");
729
+ const refactor = events.find((entry) => entry.phase === "refactor" || entry.phase === "refactor-deferred");
730
+ const redTs = red?.completedTs ?? red?.endTs ?? red?.ts ?? "";
731
+ const greenTs = green?.completedTs ?? green?.endTs ?? green?.ts ?? "";
732
+ let refactorCell;
733
+ if (!refactor) {
734
+ refactorCell = "";
735
+ }
736
+ else if (refactor.phase === "refactor-deferred") {
737
+ const refs = Array.isArray(refactor.evidenceRefs) ? refactor.evidenceRefs : [];
738
+ const rationale = refs.find((ref) => typeof ref === "string" && ref.trim().length > 0) ?? "";
739
+ refactorCell = `deferred because ${rationale}`.trim();
740
+ }
741
+ else {
742
+ refactorCell = refactor.completedTs ?? refactor.ts ?? "";
743
+ }
744
+ const implementer = green?.agent ?? red?.agent ?? "";
745
+ const refsList = green?.evidenceRefs ?? red?.evidenceRefs ?? [];
746
+ const testRefs = Array.isArray(refsList) ? refsList.join(", ") : "";
747
+ rows.push(`| ${sliceId} | ${redTs} | ${greenTs} | ${escapeTableCell(refactorCell)} | ${implementer} | ${escapeTableCell(testRefs)} |`);
748
+ }
749
+ return rows.join("\n");
750
+ }
751
+ function renderSlicesIndexBlock(sliceFiles) {
752
+ if (sliceFiles.length === 0) {
753
+ return "## Slices Index\n\n_No `tdd-slices/S-*.md` files present._";
754
+ }
755
+ const lines = [];
756
+ lines.push("## Slices Index");
757
+ lines.push("");
758
+ for (const file of sliceFiles) {
759
+ lines.push(`- [${file.sliceId}](tdd-slices/${path.basename(file.absPath)})`);
760
+ }
761
+ return lines.join("\n");
762
+ }
763
+ function escapeTableCell(value) {
764
+ return value.replace(/\|/gu, "\\|").replace(/\r?\n/gu, " ");
765
+ }
766
+ function upsertAutoBlock(raw, startMarker, endMarker, bodyContent) {
767
+ const startIdx = raw.indexOf(startMarker);
768
+ const endIdx = raw.indexOf(endMarker);
769
+ const replacement = `${startMarker}\n${bodyContent}\n${endMarker}`;
770
+ if (startIdx >= 0 && endIdx > startIdx) {
771
+ const before = raw.slice(0, startIdx);
772
+ const after = raw.slice(endIdx + endMarker.length);
773
+ return `${before}${replacement}${after}`;
774
+ }
775
+ // append to end
776
+ const sep = raw.endsWith("\n") ? "" : "\n";
777
+ return `${raw}${sep}\n${replacement}\n`;
778
+ }
@@ -1,9 +1,10 @@
1
1
  import fs from "node:fs/promises";
2
+ import path from "node:path";
2
3
  import { resolveArtifactPath as resolveStageArtifactPath } from "./artifact-paths.js";
3
4
  import { exists } from "./fs-utils.js";
4
5
  import { stageSchema } from "./content/stage-schema.js";
5
6
  import { readFlowState } from "./run-persistence.js";
6
- import { duplicateH2Headings, extractH2Sections, extractRequirementIdsFromMarkdown, isShortCircuitActivated, normalizeHeadingTitle, parseFrontmatter, parseLearningsSection, sectionBodyByAnyName, sectionBodyByHeadingPrefix, sectionBodyByName, validateSectionBody, formatLearningsErrorsBullets } from "./artifact-linter/shared.js";
7
+ import { duplicateH2Headings, extractEvidencePointers, extractH2Sections, extractRequirementIdsFromMarkdown, isShortCircuitActivated, normalizeHeadingTitle, parseFrontmatter, parseLearningsSection, sectionBodyByAnyName, sectionBodyByHeadingPrefix, sectionBodyByName, validateSectionBody, formatLearningsErrorsBullets } from "./artifact-linter/shared.js";
7
8
  import { shouldDemoteArtifactValidationByTrack } from "./content/stage-schema.js";
8
9
  import { readDelegationLedger, recordArtifactValidationDemotedByTrack } from "./delegation.js";
9
10
  import { classifyAndPersistFindings } from "./artifact-linter/findings-dedup.js";
@@ -145,6 +146,18 @@ export async function lintArtifact(projectRoot, stage, track = "standard", optio
145
146
  }
146
147
  }
147
148
  const liteTierForValidators = shouldDemoteArtifactValidationByTrack(track, taskClass);
149
+ // v6.11.0 (D5) — pre-resolve RED/GREEN Evidence pointers AND
150
+ // delegation phase events so `validateSectionBody` (sync) can
151
+ // short-circuit. The Evidence: pointer mode (v6.10.0 T3) stays as a
152
+ // fallback alongside legacy markdown content; phase events with a
153
+ // `phase=red`/`phase=green` row plus non-empty evidenceRefs auto-pass
154
+ // the corresponding markdown validator.
155
+ const tddEvidenceContext = stage === "tdd"
156
+ ? await resolveTddEvidencePointerContext({
157
+ projectRoot,
158
+ sections
159
+ })
160
+ : { red: {}, green: {} };
148
161
  for (const v of schema.artifactValidation) {
149
162
  const sectionKey = normalizeHeadingTitle(v.section).toLowerCase();
150
163
  const scopeBoundaryAlias = stage === "scope" && sectionKey === "in scope / out of scope";
@@ -164,7 +177,8 @@ export async function lintArtifact(projectRoot, stage, track = "standard", optio
164
177
  ? { ok: false, details: `No ## heading matching required section "${v.section}".` }
165
178
  : validateSectionBody(body, v.validationRule, v.section, {
166
179
  sections,
167
- liteTier: liteTierForValidators
180
+ liteTier: liteTierForValidators,
181
+ tddEvidence: stage === "tdd" ? tddEvidenceContext : undefined
168
182
  });
169
183
  const found = hasHeading && validation.ok;
170
184
  findings.push({
@@ -420,3 +434,80 @@ const ARTIFACT_VALIDATION_LITE_DEMOTE_SECTIONS = new Set([
420
434
  "Stale Diagram Drift Check",
421
435
  "Product Discovery Delegation (Strategist Mode)"
422
436
  ]);
437
+ /**
438
+ * v6.11.0 (D5) — pre-resolve `Evidence:` pointers and delegation
439
+ * phase-event auto-satisfy state for the TDD stage's RED/GREEN
440
+ * Evidence rows so `validateSectionBody` (sync) can short-circuit.
441
+ *
442
+ * - `<path>` pointer is satisfied when the path exists on disk relative
443
+ * to the project root.
444
+ * - `spanId:<id>` pointer is satisfied when any delegation ledger row
445
+ * carries that span id.
446
+ * - Phase-event auto-satisfy fires when `delegation-events.jsonl`
447
+ * carries at least one slice-tagged event for the active run with
448
+ * `phase=red`/`phase=green` and non-empty `evidenceRefs`. This is the
449
+ * v6.11.0 replacement for the v6.10.0 sidecar auto-satisfy hook —
450
+ * slice events are now the source of truth, the RED/GREEN markdown
451
+ * tables are auto-rendered from them, and the validators MUST NOT
452
+ * demand pasted stdout when the events already prove RED/GREEN.
453
+ */
454
+ async function resolveTddEvidencePointerContext(input) {
455
+ const { projectRoot, sections } = input;
456
+ const redSection = sectionBodyByName(sections, "RED Evidence") ?? "";
457
+ const greenSection = sectionBodyByName(sections, "GREEN Evidence") ?? "";
458
+ const redPointers = extractEvidencePointers(redSection);
459
+ const greenPointers = extractEvidencePointers(greenSection);
460
+ let knownSpanIds = new Set();
461
+ let phaseEventsAutoSatisfy = { red: false, green: false };
462
+ try {
463
+ const ledger = await readDelegationLedger(projectRoot);
464
+ knownSpanIds = new Set(ledger.entries
465
+ .map((entry) => entry.spanId)
466
+ .filter((id) => typeof id === "string" && id.length > 0));
467
+ const runId = ledger.runId;
468
+ const slicePhaseRows = ledger.entries.filter((entry) => entry.runId === runId &&
469
+ entry.stage === "tdd" &&
470
+ typeof entry.sliceId === "string" &&
471
+ entry.sliceId.length > 0 &&
472
+ typeof entry.phase === "string");
473
+ const redOk = slicePhaseRows.some((entry) => entry.phase === "red" &&
474
+ Array.isArray(entry.evidenceRefs) &&
475
+ entry.evidenceRefs.some((ref) => typeof ref === "string" && ref.trim().length > 0));
476
+ const greenOk = slicePhaseRows.some((entry) => entry.phase === "green" &&
477
+ Array.isArray(entry.evidenceRefs) &&
478
+ entry.evidenceRefs.some((ref) => typeof ref === "string" && ref.trim().length > 0));
479
+ phaseEventsAutoSatisfy = { red: redOk, green: greenOk };
480
+ }
481
+ catch {
482
+ knownSpanIds = new Set();
483
+ phaseEventsAutoSatisfy = { red: false, green: false };
484
+ }
485
+ async function pointerResolves(value) {
486
+ const trimmed = value.replace(/[`*_]/gu, "").trim();
487
+ if (trimmed.length === 0)
488
+ return false;
489
+ if (/^spanid\s*:/iu.test(trimmed)) {
490
+ const id = trimmed.replace(/^spanid\s*:\s*/iu, "").trim();
491
+ return id.length > 0 && knownSpanIds.has(id);
492
+ }
493
+ const candidate = path.isAbsolute(trimmed) ? trimmed : path.join(projectRoot, trimmed);
494
+ return exists(candidate);
495
+ }
496
+ async function anyResolved(values) {
497
+ for (const value of values) {
498
+ if (await pointerResolves(value))
499
+ return true;
500
+ }
501
+ return false;
502
+ }
503
+ return {
504
+ red: {
505
+ pointerSatisfied: await anyResolved(redPointers),
506
+ phaseEventsSatisfied: phaseEventsAutoSatisfy.red
507
+ },
508
+ green: {
509
+ pointerSatisfied: await anyResolved(greenPointers),
510
+ phaseEventsSatisfied: phaseEventsAutoSatisfy.green
511
+ }
512
+ };
513
+ }