cclaw-cli 6.10.0 → 6.12.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.
- package/dist/artifact-linter/shared.d.ts +7 -4
- package/dist/artifact-linter/shared.js +4 -4
- package/dist/artifact-linter/tdd.d.ts +67 -17
- package/dist/artifact-linter/tdd.js +663 -131
- package/dist/artifact-linter.js +46 -40
- package/dist/content/core-agents.d.ts +14 -0
- package/dist/content/core-agents.js +36 -1
- package/dist/content/examples.js +5 -5
- package/dist/content/hooks.js +68 -3
- package/dist/content/skills.d.ts +10 -0
- package/dist/content/skills.js +60 -2
- package/dist/content/stage-schema.js +13 -7
- package/dist/content/stages/schema-types.d.ts +1 -1
- package/dist/content/stages/tdd.js +21 -20
- package/dist/content/subagents.js +10 -9
- package/dist/content/templates.d.ts +6 -0
- package/dist/content/templates.js +38 -20
- package/dist/delegation.d.ts +27 -0
- package/dist/delegation.js +13 -1
- package/dist/flow-state.d.ts +15 -0
- package/dist/install.js +118 -0
- package/dist/internal/advance-stage.js +2 -6
- package/dist/run-persistence.js +13 -0
- package/package.json +1 -1
- package/dist/tdd-slices.d.ts +0 -90
- package/dist/tdd-slices.js +0 -375
|
@@ -1,14 +1,39 @@
|
|
|
1
1
|
import fs from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { readDelegationLedger } from "../delegation.js";
|
|
4
|
-
import { foldTddSliceLedger, readTddSliceLedger } from "../tdd-slices.js";
|
|
5
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
|
+
*/
|
|
6
28
|
export async function lintTddStage(ctx) {
|
|
7
|
-
const { projectRoot,
|
|
8
|
-
|
|
9
|
-
|
|
29
|
+
const { projectRoot, discoveryMode, raw, absFile, sections, findings, parsedFrontmatter } = ctx;
|
|
30
|
+
void projectRoot;
|
|
31
|
+
void parsedFrontmatter;
|
|
10
32
|
evaluateInvestigationTrace(ctx, "Watched-RED Proof");
|
|
11
|
-
|
|
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;
|
|
12
37
|
const ironLawBody = sectionBodyByName(sections, "Iron Law Acknowledgement");
|
|
13
38
|
if (ironLawBody === null) {
|
|
14
39
|
findings.push({
|
|
@@ -32,18 +57,14 @@ export async function lintTddStage(ctx) {
|
|
|
32
57
|
});
|
|
33
58
|
}
|
|
34
59
|
const watchedRedBody = sectionBodyByName(sections, "Watched-RED Proof");
|
|
35
|
-
if (
|
|
36
|
-
|
|
37
|
-
// the source of truth for RED observation evidence; the markdown
|
|
38
|
-
// table is auto-derived (or left as a template stub) and must not
|
|
39
|
-
// block stage advance.
|
|
40
|
-
const sidecarResult = evaluateSidecarWatchedRed(sliceLedger.entries);
|
|
60
|
+
if (eventsActive) {
|
|
61
|
+
const redResult = evaluateEventsWatchedRed(slicesByEvents);
|
|
41
62
|
findings.push({
|
|
42
63
|
section: "Watched-RED Proof Shape",
|
|
43
64
|
required: true,
|
|
44
|
-
rule: "Watched-RED Proof: when
|
|
45
|
-
found:
|
|
46
|
-
details:
|
|
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
|
|
47
68
|
});
|
|
48
69
|
}
|
|
49
70
|
else if (watchedRedBody === null) {
|
|
@@ -61,9 +82,8 @@ export async function lintTddStage(ctx) {
|
|
|
61
82
|
const populatedRows = dataRows.filter((row) => row
|
|
62
83
|
.split("|")
|
|
63
84
|
.slice(1, -1)
|
|
64
|
-
.filter((_, idx) => idx !== 0)
|
|
85
|
+
.filter((_, idx) => idx !== 0)
|
|
65
86
|
.some((cell) => cell.trim().length > 0));
|
|
66
|
-
// Each populated row must include an ISO timestamp in column 3.
|
|
67
87
|
const isoRegex = /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/u;
|
|
68
88
|
const validProofRows = populatedRows.filter((row) => isoRegex.test(row));
|
|
69
89
|
const hasPopulatedRows = populatedRows.length > 0;
|
|
@@ -80,15 +100,18 @@ export async function lintTddStage(ctx) {
|
|
|
80
100
|
: `${populatedRows.length - validProofRows.length} watched-RED proof row(s) lack an ISO timestamp.`
|
|
81
101
|
});
|
|
82
102
|
}
|
|
83
|
-
if (
|
|
84
|
-
const cycleResult =
|
|
103
|
+
if (eventsActive) {
|
|
104
|
+
const cycleResult = evaluateEventsSliceCycle(slicesByEvents);
|
|
85
105
|
findings.push({
|
|
86
106
|
section: "Vertical Slice Cycle Coverage",
|
|
87
107
|
required: true,
|
|
88
|
-
rule: "Vertical Slice Cycle:
|
|
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).",
|
|
89
109
|
found: cycleResult.ok,
|
|
90
110
|
details: cycleResult.details
|
|
91
111
|
});
|
|
112
|
+
for (const finding of cycleResult.findings) {
|
|
113
|
+
findings.push(finding);
|
|
114
|
+
}
|
|
92
115
|
}
|
|
93
116
|
else {
|
|
94
117
|
const sliceCycleBody = sectionBodyByName(sections, "Vertical Slice Cycle");
|
|
@@ -112,24 +135,73 @@ export async function lintTddStage(ctx) {
|
|
|
112
135
|
});
|
|
113
136
|
}
|
|
114
137
|
}
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
if (
|
|
138
|
+
// v6.12.0 Phase R — slice-documenter coverage is mandatory on every
|
|
139
|
+
// TDD run regardless of discoveryMode. `discoveryMode` is now strictly
|
|
140
|
+
// an early-stage knob (brainstorm/scope/design); TDD parallelism must
|
|
141
|
+
// be uniform across lean/guided/deep so the controller cannot quietly
|
|
142
|
+
// skip per-slice prose by picking a non-deep mode.
|
|
143
|
+
void discoveryMode;
|
|
144
|
+
if (eventsActive) {
|
|
145
|
+
const docResult = evaluateSliceDocumenterCoverage(slicesByEvents);
|
|
146
|
+
if (docResult.missing.length > 0) {
|
|
147
|
+
findings.push({
|
|
148
|
+
section: "tdd_slice_documenter_missing",
|
|
149
|
+
required: true,
|
|
150
|
+
rule: "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`. The requirement is independent of discoveryMode (v6.12.0 Phase R).",
|
|
151
|
+
found: false,
|
|
152
|
+
details: `Slices missing slice-documenter coverage: ${docResult.missing.join(", ")}. Dispatch slice-documenter --slice <id> --phase doc in parallel with slice-implementer --phase green for each slice.`
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
// v6.12.0 Phase M — slice-implementer must own GREEN. For each slice
|
|
157
|
+
// with a phase=red row carrying non-empty evidenceRefs, require a
|
|
158
|
+
// matching phase=green event whose `agent === "slice-implementer"`.
|
|
159
|
+
// This catches "controller wrote GREEN itself" — the most common
|
|
160
|
+
// backslide we have observed in fresh runs (hox S-11).
|
|
161
|
+
if (eventsActive) {
|
|
162
|
+
const implResult = evaluateSliceImplementerCoverage(slicesByEvents);
|
|
163
|
+
if (implResult.missing.length > 0) {
|
|
164
|
+
findings.push({
|
|
165
|
+
section: "tdd_slice_implementer_missing",
|
|
166
|
+
required: true,
|
|
167
|
+
rule: "Every TDD slice that recorded a phase=red event with non-empty evidenceRefs must reach phase=green via the `slice-implementer` agent. Controller writing GREEN production code itself is forbidden (v6.12.0 Phase M).",
|
|
168
|
+
found: false,
|
|
169
|
+
details: `Slices missing slice-implementer GREEN coverage: ${implResult.missing.join(", ")}. Dispatch slice-implementer --slice <id> --phase green --paths <comma-separated production paths>.`
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
// v6.12.0 Phase W — RED checkpoint enforcement. The wave protocol
|
|
174
|
+
// requires ALL Phase A REDs to land before ANY Phase B GREEN starts.
|
|
175
|
+
// Enforced per-wave: explicit `wave-plans/wave-NN.md` manifest if
|
|
176
|
+
// present, otherwise implicit detection via contiguous red blocks
|
|
177
|
+
// (size >= 2). Sequential per-slice runs (red→green→refactor in a
|
|
178
|
+
// tight loop) form size-1 implicit waves and are unaffected.
|
|
179
|
+
if (eventsActive) {
|
|
180
|
+
const waveManifest = await readWaveManifest(path.dirname(absFile));
|
|
181
|
+
const checkpointResult = evaluateRedCheckpoint(slicesByEvents, waveManifest);
|
|
182
|
+
if (!checkpointResult.ok) {
|
|
124
183
|
findings.push({
|
|
125
|
-
section: "
|
|
126
|
-
required:
|
|
127
|
-
rule: "
|
|
184
|
+
section: "tdd_red_checkpoint_violation",
|
|
185
|
+
required: true,
|
|
186
|
+
rule: "Wave Batch Mode (v6.12.0 Phase W): every slice in a wave must complete phase=red before any slice in the same wave starts phase=green. Detected: a phase=green completedTs precedes the last phase=red completedTs of the same wave.",
|
|
128
187
|
found: false,
|
|
129
|
-
details:
|
|
188
|
+
details: checkpointResult.details
|
|
130
189
|
});
|
|
131
190
|
}
|
|
132
191
|
}
|
|
192
|
+
// v6.12.0 Phase L — advisory backslide detection. When a cutover is
|
|
193
|
+
// recorded in flow-state, slice-id rows in the legacy per-slice
|
|
194
|
+
// sections of `06-tdd.md` that exceed the cutover boundary should
|
|
195
|
+
// migrate to `tdd-slices/S-<id>.md`. Surface as advisory so it does
|
|
196
|
+
// not block the gate but does keep the controller honest.
|
|
197
|
+
const cutoverFinding = await evaluateLegacySectionBackslide({
|
|
198
|
+
projectRoot,
|
|
199
|
+
raw,
|
|
200
|
+
sections
|
|
201
|
+
});
|
|
202
|
+
if (cutoverFinding) {
|
|
203
|
+
findings.push(cutoverFinding);
|
|
204
|
+
}
|
|
133
205
|
const assertionBody = sectionBodyByName(sections, "Assertion Correctness Notes");
|
|
134
206
|
if (assertionBody !== null) {
|
|
135
207
|
const tableRows = assertionBody.split("\n").filter((line) => /^\|/u.test(line));
|
|
@@ -169,8 +241,6 @@ export async function lintTddStage(ctx) {
|
|
|
169
241
|
: "Mocks/spies detected without boundary justification; add explicit trust-boundary rationale or replace with real/fake/stub coverage."
|
|
170
242
|
});
|
|
171
243
|
}
|
|
172
|
-
const delegationLedger = await readDelegationLedger(projectRoot);
|
|
173
|
-
const activeRunEntries = delegationLedger.entries.filter((entry) => entry.stage === "tdd" && entry.runId === delegationLedger.runId);
|
|
174
244
|
const completedSliceImplementers = activeRunEntries.filter((entry) => entry.agent === "slice-implementer" && entry.status === "completed");
|
|
175
245
|
const fanOutDetected = completedSliceImplementers.length > 1;
|
|
176
246
|
if (fanOutDetected) {
|
|
@@ -240,145 +310,518 @@ export async function lintTddStage(ctx) {
|
|
|
240
310
|
: "integration-overseer completion exists, but PASS/PASS_WITH_GAPS evidence is missing in delegation evidenceRefs and artifact text."
|
|
241
311
|
});
|
|
242
312
|
}
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
313
|
+
const verificationBody = sectionBodyByName(sections, "Verification Ladder") ??
|
|
314
|
+
sectionBodyByName(sections, "Verification Status") ??
|
|
315
|
+
sectionBodyByName(sections, "Verification");
|
|
316
|
+
const ladderResult = evaluateVerificationLadder(verificationBody);
|
|
317
|
+
findings.push({
|
|
318
|
+
section: "tdd_verification_pending",
|
|
319
|
+
required: true,
|
|
320
|
+
rule: "Verification Ladder rows must not remain `pending`; promote each row to `passed`, `n/a`, `failed`, `skipped`, or `deferred` (with rationale) before stage-complete.",
|
|
321
|
+
found: ladderResult.ok,
|
|
322
|
+
details: ladderResult.details
|
|
323
|
+
});
|
|
324
|
+
// Phase S — sharded slice files. Validate per-slice file presence
|
|
325
|
+
// and required headings. `tdd-slices/` is optional; missing folder
|
|
326
|
+
// simply means main-only mode (legacy fallback).
|
|
327
|
+
const artifactsDir = path.dirname(absFile);
|
|
328
|
+
const slicesDir = path.join(artifactsDir, "tdd-slices");
|
|
329
|
+
const sliceFiles = await listSliceFiles(slicesDir);
|
|
330
|
+
for (const sliceFile of sliceFiles) {
|
|
331
|
+
const sliceId = sliceFile.sliceId;
|
|
332
|
+
const requiredForSlice = slicesByEvents.has(sliceId) &&
|
|
333
|
+
slicesByEvents.get(sliceId).some((entry) => entry.phase === "doc");
|
|
334
|
+
let content = "";
|
|
335
|
+
try {
|
|
336
|
+
content = await fs.readFile(sliceFile.absPath, "utf8");
|
|
337
|
+
}
|
|
338
|
+
catch {
|
|
339
|
+
content = "";
|
|
340
|
+
}
|
|
341
|
+
const issues = [];
|
|
342
|
+
if (!new RegExp(`^#\\s+Slice\\s+${escapeForRegex(sliceId)}\\b`, "mu").test(content) &&
|
|
343
|
+
!/^#\s+Slice\b/mu.test(content)) {
|
|
344
|
+
issues.push("missing `# Slice <id>` heading");
|
|
345
|
+
}
|
|
346
|
+
if (!/^##\s+Plan unit\b/imu.test(content)) {
|
|
347
|
+
issues.push("missing `## Plan unit` section");
|
|
348
|
+
}
|
|
349
|
+
if (!/^##\s+REFACTOR notes\b/imu.test(content)) {
|
|
350
|
+
issues.push("missing `## REFACTOR notes` section");
|
|
351
|
+
}
|
|
352
|
+
if (!/^##\s+Learnings\b/imu.test(content)) {
|
|
353
|
+
issues.push("missing `## Learnings` section");
|
|
354
|
+
}
|
|
248
355
|
findings.push({
|
|
249
|
-
section:
|
|
250
|
-
required:
|
|
251
|
-
rule: "
|
|
252
|
-
found:
|
|
253
|
-
details:
|
|
356
|
+
section: `tdd_slice_file:${sliceId}`,
|
|
357
|
+
required: requiredForSlice,
|
|
358
|
+
rule: "Sharded slice file must include `# Slice <id>`, `## Plan unit`, `## REFACTOR notes`, and `## Learnings` headings.",
|
|
359
|
+
found: issues.length === 0,
|
|
360
|
+
details: issues.length === 0
|
|
361
|
+
? `tdd-slices/${path.basename(sliceFile.absPath)} has all required headings.`
|
|
362
|
+
: `tdd-slices/${path.basename(sliceFile.absPath)}: ${issues.join(", ")}.`
|
|
254
363
|
});
|
|
255
364
|
}
|
|
365
|
+
// Auto-render the slice summary inside `06-tdd.md` between markers.
|
|
366
|
+
// Idempotent — content outside the markers is preserved. Skipped
|
|
367
|
+
// entirely when there is nothing to render, so legacy artifacts (no
|
|
368
|
+
// phase events, no sharded files) stay byte-for-byte unchanged.
|
|
369
|
+
if (eventsActive || sliceFiles.length > 0) {
|
|
370
|
+
try {
|
|
371
|
+
await renderTddSliceSummary({
|
|
372
|
+
mainArtifactPath: absFile,
|
|
373
|
+
slicesByEvents,
|
|
374
|
+
sliceFiles,
|
|
375
|
+
renderSummary: eventsActive,
|
|
376
|
+
renderIndex: sliceFiles.length > 0
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
catch {
|
|
380
|
+
// best-effort render — never block the gate.
|
|
381
|
+
}
|
|
382
|
+
}
|
|
256
383
|
}
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
384
|
+
async function listSliceFiles(slicesDir) {
|
|
385
|
+
let entries = [];
|
|
386
|
+
try {
|
|
387
|
+
entries = await fs.readdir(slicesDir);
|
|
388
|
+
}
|
|
389
|
+
catch {
|
|
390
|
+
return [];
|
|
391
|
+
}
|
|
392
|
+
const files = [];
|
|
393
|
+
for (const name of entries) {
|
|
394
|
+
const match = /^(S-[A-Za-z0-9._-]+)\.md$/u.exec(name);
|
|
395
|
+
if (!match)
|
|
396
|
+
continue;
|
|
397
|
+
files.push({ sliceId: match[1], absPath: path.join(slicesDir, name) });
|
|
398
|
+
}
|
|
399
|
+
files.sort((a, b) => (a.sliceId < b.sliceId ? -1 : a.sliceId > b.sliceId ? 1 : 0));
|
|
400
|
+
return files;
|
|
401
|
+
}
|
|
402
|
+
function escapeForRegex(value) {
|
|
403
|
+
return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
|
|
404
|
+
}
|
|
405
|
+
function groupBySlice(entries) {
|
|
406
|
+
const grouped = new Map();
|
|
407
|
+
for (const entry of entries) {
|
|
408
|
+
if (typeof entry.sliceId !== "string" || entry.sliceId.length === 0)
|
|
409
|
+
continue;
|
|
410
|
+
if (typeof entry.phase !== "string" || entry.phase.length === 0)
|
|
411
|
+
continue;
|
|
412
|
+
if (entry.status !== "completed")
|
|
413
|
+
continue;
|
|
414
|
+
const list = grouped.get(entry.sliceId) ?? [];
|
|
415
|
+
list.push(entry);
|
|
416
|
+
grouped.set(entry.sliceId, list);
|
|
417
|
+
}
|
|
418
|
+
return grouped;
|
|
419
|
+
}
|
|
420
|
+
export function evaluateEventsWatchedRed(slices) {
|
|
421
|
+
const errors = [];
|
|
422
|
+
let redCount = 0;
|
|
423
|
+
for (const [sliceId, rows] of slices.entries()) {
|
|
424
|
+
const reds = rows.filter((entry) => entry.phase === "red");
|
|
425
|
+
if (reds.length === 0)
|
|
426
|
+
continue;
|
|
427
|
+
redCount += 1;
|
|
428
|
+
const issues = [];
|
|
429
|
+
for (const red of reds) {
|
|
430
|
+
const ts = red.completedTs ?? red.endTs ?? red.ts ?? "";
|
|
431
|
+
if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/u.test(ts)) {
|
|
432
|
+
issues.push("phase=red row missing ISO completedTs");
|
|
433
|
+
}
|
|
434
|
+
if (!Array.isArray(red.evidenceRefs) ||
|
|
435
|
+
red.evidenceRefs.filter((ref) => typeof ref === "string" && ref.trim().length > 0).length === 0) {
|
|
436
|
+
issues.push("phase=red row has empty evidenceRefs");
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
if (issues.length > 0) {
|
|
440
|
+
errors.push(`${sliceId}: ${issues.join(", ")}`);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
if (redCount === 0) {
|
|
266
444
|
return {
|
|
267
445
|
ok: false,
|
|
268
|
-
details: "
|
|
446
|
+
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."
|
|
269
447
|
};
|
|
270
448
|
}
|
|
271
|
-
|
|
449
|
+
if (errors.length > 0) {
|
|
450
|
+
return {
|
|
451
|
+
ok: false,
|
|
452
|
+
details: `Watched-RED slice events missing required fields: ${errors.join(" | ")}.`
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
return {
|
|
456
|
+
ok: true,
|
|
457
|
+
details: `${redCount} slice(s) carry phase=red events with ISO completedTs and evidenceRefs.`
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
export function evaluateEventsSliceCycle(slices) {
|
|
272
461
|
const errors = [];
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
462
|
+
const findings = [];
|
|
463
|
+
for (const [sliceId, rows] of slices.entries()) {
|
|
464
|
+
const reds = rows.filter((entry) => entry.phase === "red");
|
|
465
|
+
const greens = rows.filter((entry) => entry.phase === "green");
|
|
466
|
+
const refactors = rows.filter((entry) => entry.phase === "refactor" || entry.phase === "refactor-deferred");
|
|
467
|
+
const redTs = pickEventTs(reds);
|
|
468
|
+
const greenTs = pickEventTs(greens);
|
|
469
|
+
if (reds.length === 0) {
|
|
470
|
+
errors.push(`${sliceId}: phase=red event missing.`);
|
|
471
|
+
findings.push({
|
|
472
|
+
section: `tdd_slice_red_missing:${sliceId}`,
|
|
473
|
+
required: true,
|
|
474
|
+
rule: "Each TDD slice with phase events must include a `phase=red` row.",
|
|
475
|
+
found: false,
|
|
476
|
+
details: `${sliceId}: no phase=red event recorded for the active run.`
|
|
477
|
+
});
|
|
478
|
+
continue;
|
|
277
479
|
}
|
|
278
|
-
|
|
279
|
-
|
|
480
|
+
if (greens.length === 0) {
|
|
481
|
+
errors.push(`${sliceId}: phase=green event missing.`);
|
|
482
|
+
findings.push({
|
|
483
|
+
section: `tdd_slice_green_missing:${sliceId}`,
|
|
484
|
+
required: true,
|
|
485
|
+
rule: "Each TDD slice with a phase=red event must reach a `phase=green` row before stage-complete.",
|
|
486
|
+
found: false,
|
|
487
|
+
details: `${sliceId}: no phase=green event recorded; RED has no matching GREEN.`
|
|
488
|
+
});
|
|
489
|
+
continue;
|
|
280
490
|
}
|
|
281
|
-
if (
|
|
282
|
-
|
|
491
|
+
if (greenTs && redTs && greenTs < redTs) {
|
|
492
|
+
errors.push(`${sliceId}: phase=green completedTs (${greenTs}) precedes phase=red (${redTs}).`);
|
|
493
|
+
findings.push({
|
|
494
|
+
section: `tdd_slice_phase_order_invalid:${sliceId}`,
|
|
495
|
+
required: true,
|
|
496
|
+
rule: "Phase events must be monotonic: phase=green completedTs >= phase=red completedTs.",
|
|
497
|
+
found: false,
|
|
498
|
+
details: `${sliceId}: green at ${greenTs} precedes red at ${redTs}.`
|
|
499
|
+
});
|
|
500
|
+
continue;
|
|
283
501
|
}
|
|
284
|
-
|
|
285
|
-
|
|
502
|
+
const greenEvidenceRef = greens
|
|
503
|
+
.flatMap((entry) => Array.isArray(entry.evidenceRefs) ? entry.evidenceRefs : [])
|
|
504
|
+
.find((ref) => typeof ref === "string" && ref.trim().length > 0);
|
|
505
|
+
if (!greenEvidenceRef) {
|
|
506
|
+
errors.push(`${sliceId}: phase=green row has empty evidenceRefs.`);
|
|
507
|
+
findings.push({
|
|
508
|
+
section: `tdd_slice_evidence_missing:${sliceId}`,
|
|
509
|
+
required: true,
|
|
510
|
+
rule: "Each `phase=green` event must record at least one evidenceRef (path to test artifact, span id, or pasted-output pointer).",
|
|
511
|
+
found: false,
|
|
512
|
+
details: `${sliceId}: phase=green event missing evidenceRefs.`
|
|
513
|
+
});
|
|
514
|
+
continue;
|
|
286
515
|
}
|
|
287
|
-
if (
|
|
288
|
-
|
|
516
|
+
if (refactors.length === 0) {
|
|
517
|
+
errors.push(`${sliceId}: phase=refactor or phase=refactor-deferred event missing.`);
|
|
518
|
+
findings.push({
|
|
519
|
+
section: `tdd_slice_refactor_missing:${sliceId}`,
|
|
520
|
+
required: true,
|
|
521
|
+
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.",
|
|
522
|
+
found: false,
|
|
523
|
+
details: `${sliceId}: no phase=refactor or phase=refactor-deferred event.`
|
|
524
|
+
});
|
|
525
|
+
continue;
|
|
289
526
|
}
|
|
290
|
-
|
|
291
|
-
|
|
527
|
+
const deferred = refactors.find((entry) => entry.phase === "refactor-deferred");
|
|
528
|
+
if (deferred && refactors.every((entry) => entry.phase === "refactor-deferred")) {
|
|
529
|
+
const refs = Array.isArray(deferred.evidenceRefs) ? deferred.evidenceRefs : [];
|
|
530
|
+
const hasRationale = refs.some((ref) => typeof ref === "string" && ref.trim().length > 0);
|
|
531
|
+
if (!hasRationale) {
|
|
532
|
+
errors.push(`${sliceId}: phase=refactor-deferred row needs evidenceRefs containing a rationale.`);
|
|
533
|
+
findings.push({
|
|
534
|
+
section: `tdd_slice_refactor_missing:${sliceId}`,
|
|
535
|
+
required: true,
|
|
536
|
+
rule: "phase=refactor-deferred must record a rationale via --refactor-rationale or via --evidence-ref pointing at the rationale text.",
|
|
537
|
+
found: false,
|
|
538
|
+
details: `${sliceId}: phase=refactor-deferred recorded without rationale evidenceRefs.`
|
|
539
|
+
});
|
|
540
|
+
continue;
|
|
541
|
+
}
|
|
292
542
|
}
|
|
293
543
|
}
|
|
294
544
|
if (errors.length > 0) {
|
|
295
545
|
return {
|
|
296
546
|
ok: false,
|
|
297
|
-
details:
|
|
547
|
+
details: errors.join(" "),
|
|
548
|
+
findings
|
|
298
549
|
};
|
|
299
550
|
}
|
|
300
551
|
return {
|
|
301
552
|
ok: true,
|
|
302
|
-
details:
|
|
553
|
+
details: `${slices.size} slice(s) show monotonic phase=red -> phase=green -> phase=refactor (deferred-with-rationale accepted).`,
|
|
554
|
+
findings: []
|
|
303
555
|
};
|
|
304
556
|
}
|
|
557
|
+
export function evaluateSliceDocumenterCoverage(slices) {
|
|
558
|
+
const missing = [];
|
|
559
|
+
for (const [sliceId, rows] of slices.entries()) {
|
|
560
|
+
const hasGreen = rows.some((entry) => entry.phase === "green");
|
|
561
|
+
if (!hasGreen)
|
|
562
|
+
continue;
|
|
563
|
+
const docRow = rows.find((entry) => entry.phase === "doc");
|
|
564
|
+
if (!docRow) {
|
|
565
|
+
missing.push(sliceId);
|
|
566
|
+
continue;
|
|
567
|
+
}
|
|
568
|
+
const refs = Array.isArray(docRow.evidenceRefs) ? docRow.evidenceRefs : [];
|
|
569
|
+
const hasSliceFileRef = refs.some((ref) => typeof ref === "string" && /tdd-slices\/S-[^/]+\.md/u.test(ref));
|
|
570
|
+
if (!hasSliceFileRef) {
|
|
571
|
+
missing.push(sliceId);
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
return { missing };
|
|
575
|
+
}
|
|
305
576
|
/**
|
|
306
|
-
* v6.
|
|
307
|
-
*
|
|
308
|
-
*
|
|
309
|
-
*
|
|
310
|
-
*
|
|
311
|
-
* that is `>= greenAt`. Slices stuck at `red` are tolerated only when
|
|
312
|
-
* the run is still in TDD; the linter surfaces them as advisory through
|
|
313
|
-
* the watched-RED check above.
|
|
577
|
+
* v6.12.0 Phase M — slice-implementer must own GREEN. For each slice
|
|
578
|
+
* that recorded a phase=red event with non-empty evidenceRefs, require a
|
|
579
|
+
* phase=green event whose `agent === "slice-implementer"`. Slices whose
|
|
580
|
+
* GREEN event came from a different agent (e.g. controller wrote GREEN
|
|
581
|
+
* itself and recorded a green row under another agent name) are flagged.
|
|
314
582
|
*/
|
|
315
|
-
export function
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
583
|
+
export function evaluateSliceImplementerCoverage(slices) {
|
|
584
|
+
const missing = [];
|
|
585
|
+
for (const [sliceId, rows] of slices.entries()) {
|
|
586
|
+
const reds = rows.filter((entry) => entry.phase === "red");
|
|
587
|
+
if (reds.length === 0)
|
|
588
|
+
continue;
|
|
589
|
+
const hasRedEvidence = reds.some((red) => {
|
|
590
|
+
const refs = Array.isArray(red.evidenceRefs) ? red.evidenceRefs : [];
|
|
591
|
+
return refs.some((ref) => typeof ref === "string" && ref.trim().length > 0);
|
|
592
|
+
});
|
|
593
|
+
if (!hasRedEvidence)
|
|
594
|
+
continue;
|
|
595
|
+
const greens = rows.filter((entry) => entry.phase === "green");
|
|
596
|
+
const ownedByImplementer = greens.some((entry) => entry.agent === "slice-implementer");
|
|
597
|
+
if (!ownedByImplementer) {
|
|
598
|
+
missing.push(sliceId);
|
|
599
|
+
}
|
|
321
600
|
}
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
601
|
+
return { missing };
|
|
602
|
+
}
|
|
603
|
+
/**
|
|
604
|
+
* v6.12.0 Phase W — RED checkpoint enforcement. The wave protocol
|
|
605
|
+
* requires ALL Phase A REDs to land before ANY Phase B GREEN starts.
|
|
606
|
+
* The rule is enforced on a per-wave basis, where a wave is defined by
|
|
607
|
+
* `<artifacts-dir>/wave-plans/wave-NN.md` files (when present) listing
|
|
608
|
+
* slice ids. When no wave manifest exists, the linter falls back to a
|
|
609
|
+
* conservative implicit detection: a wave is a contiguous run of
|
|
610
|
+
* `phase=red` events with no other-phase events between them; the rule
|
|
611
|
+
* fires only when the implicit wave has 2+ members.
|
|
612
|
+
*
|
|
613
|
+
* @param waveMembers Optional explicit wave manifest. Map key is wave
|
|
614
|
+
* name (e.g. `"W-01"`); value is the set of slice ids in that wave.
|
|
615
|
+
*/
|
|
616
|
+
export function evaluateRedCheckpoint(slices, waveMembers = null) {
|
|
617
|
+
const events = [];
|
|
618
|
+
for (const [sliceId, rows] of slices.entries()) {
|
|
619
|
+
for (const entry of rows) {
|
|
620
|
+
const ts = entry.completedTs ?? entry.endTs ?? entry.ts;
|
|
621
|
+
if (typeof ts !== "string" || ts.length === 0)
|
|
330
622
|
continue;
|
|
331
|
-
|
|
332
|
-
if (redIso !== null && greenIso < redIso) {
|
|
333
|
-
errors.push(`${entry.sliceId}: greenAt (${entry.greenAt}) precedes redObservedAt (${entry.redObservedAt}) — order must be monotonic.`);
|
|
623
|
+
if (typeof entry.phase !== "string")
|
|
334
624
|
continue;
|
|
335
|
-
}
|
|
625
|
+
events.push({ sliceId, phase: entry.phase, ts });
|
|
336
626
|
}
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
627
|
+
}
|
|
628
|
+
events.sort((a, b) => (a.ts < b.ts ? -1 : a.ts > b.ts ? 1 : 0));
|
|
629
|
+
// Build the canonical wave list. Explicit manifest wins; otherwise
|
|
630
|
+
// derive implicit waves from contiguous red event blocks.
|
|
631
|
+
const waves = [];
|
|
632
|
+
if (waveMembers && waveMembers.size > 0) {
|
|
633
|
+
for (const [name, members] of waveMembers.entries()) {
|
|
634
|
+
if (members.size === 0)
|
|
340
635
|
continue;
|
|
636
|
+
waves.push({ name, members });
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
else {
|
|
640
|
+
let current = null;
|
|
641
|
+
let waveIdx = 0;
|
|
642
|
+
for (const evt of events) {
|
|
643
|
+
if (evt.phase === "red") {
|
|
644
|
+
if (current === null)
|
|
645
|
+
current = new Set();
|
|
646
|
+
current.add(evt.sliceId);
|
|
647
|
+
}
|
|
648
|
+
else if (current !== null) {
|
|
649
|
+
if (current.size >= 2) {
|
|
650
|
+
waveIdx += 1;
|
|
651
|
+
waves.push({ name: `implicit-${waveIdx}`, members: current });
|
|
652
|
+
}
|
|
653
|
+
current = null;
|
|
341
654
|
}
|
|
342
655
|
}
|
|
343
|
-
if (
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
656
|
+
if (current !== null && current.size >= 2) {
|
|
657
|
+
waveIdx += 1;
|
|
658
|
+
waves.push({ name: `implicit-${waveIdx}`, members: current });
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
if (waves.length === 0) {
|
|
662
|
+
return {
|
|
663
|
+
ok: true,
|
|
664
|
+
details: "RED checkpoint inactive: no wave manifest detected and no implicit wave (2+ contiguous reds) found."
|
|
665
|
+
};
|
|
666
|
+
}
|
|
667
|
+
const violations = [];
|
|
668
|
+
for (const wave of waves) {
|
|
669
|
+
const memberReds = events.filter((e) => e.phase === "red" && wave.members.has(e.sliceId));
|
|
670
|
+
const memberGreens = events.filter((e) => e.phase === "green" && wave.members.has(e.sliceId));
|
|
671
|
+
if (memberReds.length === 0 || memberGreens.length === 0)
|
|
672
|
+
continue;
|
|
673
|
+
const lastRedTs = memberReds.reduce((acc, e) => (e.ts > acc ? e.ts : acc), memberReds[0].ts);
|
|
674
|
+
for (const g of memberGreens) {
|
|
675
|
+
if (g.ts < lastRedTs) {
|
|
676
|
+
violations.push(`${wave.name}: ${g.sliceId} phase=green at ${g.ts} precedes wave's last phase=red completedTs at ${lastRedTs}`);
|
|
349
677
|
}
|
|
350
|
-
|
|
351
|
-
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
if (violations.length === 0) {
|
|
681
|
+
return {
|
|
682
|
+
ok: true,
|
|
683
|
+
details: `RED checkpoint holds across ${waves.length} wave(s): all phase=green events follow the last phase=red of their wave.`
|
|
684
|
+
};
|
|
685
|
+
}
|
|
686
|
+
return {
|
|
687
|
+
ok: false,
|
|
688
|
+
details: `RED checkpoint violation: ${violations.join("; ")}. ` +
|
|
689
|
+
"Dispatch ALL Phase A test-author --phase red calls in one message, verify every phase=red event lands with non-empty evidenceRefs, and only then dispatch Phase B slice-implementer --phase green + slice-documenter --phase doc fan-out."
|
|
690
|
+
};
|
|
691
|
+
}
|
|
692
|
+
/**
|
|
693
|
+
* Read explicit wave manifest from `<artifacts-dir>/wave-plans/wave-NN.md`
|
|
694
|
+
* files. Returns a map from wave name to the set of slice ids it
|
|
695
|
+
* contains. Slice ids are extracted via `S-<digits>` regex matches in
|
|
696
|
+
* each wave file. Returns null when no wave files exist or all are
|
|
697
|
+
* empty/unparseable.
|
|
698
|
+
*/
|
|
699
|
+
async function readWaveManifest(artifactsDir) {
|
|
700
|
+
const wavePlansDir = path.join(artifactsDir, "wave-plans");
|
|
701
|
+
let entries = [];
|
|
702
|
+
try {
|
|
703
|
+
entries = await fs.readdir(wavePlansDir);
|
|
704
|
+
}
|
|
705
|
+
catch {
|
|
706
|
+
return null;
|
|
707
|
+
}
|
|
708
|
+
const waves = new Map();
|
|
709
|
+
for (const name of entries) {
|
|
710
|
+
const match = /^wave-(\d+)\.md$/u.exec(name);
|
|
711
|
+
if (!match)
|
|
712
|
+
continue;
|
|
713
|
+
const wavePath = path.join(wavePlansDir, name);
|
|
714
|
+
let body = "";
|
|
715
|
+
try {
|
|
716
|
+
body = await fs.readFile(wavePath, "utf8");
|
|
717
|
+
}
|
|
718
|
+
catch {
|
|
719
|
+
continue;
|
|
720
|
+
}
|
|
721
|
+
const ids = extractSliceIdsFromBody(body);
|
|
722
|
+
if (ids.length === 0)
|
|
723
|
+
continue;
|
|
724
|
+
waves.set(`W-${match[1]}`, new Set(ids));
|
|
725
|
+
}
|
|
726
|
+
return waves.size > 0 ? waves : null;
|
|
727
|
+
}
|
|
728
|
+
const LEGACY_PER_SLICE_SECTIONS = [
|
|
729
|
+
"Test Discovery",
|
|
730
|
+
"RED Evidence",
|
|
731
|
+
"GREEN Evidence",
|
|
732
|
+
"Watched-RED Proof",
|
|
733
|
+
"Vertical Slice Cycle",
|
|
734
|
+
"Per-Slice Review",
|
|
735
|
+
"Failure Analysis",
|
|
736
|
+
"Acceptance Mapping"
|
|
737
|
+
];
|
|
738
|
+
/**
|
|
739
|
+
* v6.12.0 Phase L — advisory finding when post-cutover slice ids appear
|
|
740
|
+
* in legacy per-slice sections of `06-tdd.md`. Reads
|
|
741
|
+
* `flow-state.json::tddCutoverSliceId` (e.g. `"S-10"`) and scans each
|
|
742
|
+
* legacy section for `S-<N>` references with N > cutover.
|
|
743
|
+
*/
|
|
744
|
+
async function evaluateLegacySectionBackslide(ctx) {
|
|
745
|
+
const cutover = await readTddCutoverSliceId(ctx.projectRoot);
|
|
746
|
+
if (cutover === null)
|
|
747
|
+
return null;
|
|
748
|
+
const cutoverNum = parseSliceNumber(cutover);
|
|
749
|
+
if (cutoverNum === null)
|
|
750
|
+
return null;
|
|
751
|
+
const offenders = [];
|
|
752
|
+
for (const sectionName of LEGACY_PER_SLICE_SECTIONS) {
|
|
753
|
+
const body = sectionBodyByName(ctx.sections, sectionName);
|
|
754
|
+
if (body === null)
|
|
755
|
+
continue;
|
|
756
|
+
const ids = extractSliceIdsFromBody(body);
|
|
757
|
+
for (const id of ids) {
|
|
758
|
+
const num = parseSliceNumber(id);
|
|
759
|
+
if (num === null)
|
|
352
760
|
continue;
|
|
761
|
+
if (num > cutoverNum) {
|
|
762
|
+
offenders.push({ section: sectionName, sliceId: id });
|
|
353
763
|
}
|
|
354
764
|
}
|
|
355
765
|
}
|
|
356
|
-
if (
|
|
357
|
-
return
|
|
358
|
-
|
|
766
|
+
if (offenders.length === 0)
|
|
767
|
+
return null;
|
|
768
|
+
const summary = offenders
|
|
769
|
+
.map((row) => `${row.sliceId} appears in legacy section \`## ${row.section}\``)
|
|
770
|
+
.join("; ");
|
|
359
771
|
return {
|
|
360
|
-
|
|
361
|
-
|
|
772
|
+
section: "tdd_legacy_section_writes_after_cutover",
|
|
773
|
+
required: false,
|
|
774
|
+
rule: "After v6.12.0 cutover, per-slice prose for slices > cutoverSliceId must live in `tdd-slices/S-<id>.md`, not in legacy `06-tdd.md` sections (Test Discovery, RED Evidence, GREEN Evidence, Watched-RED Proof, Vertical Slice Cycle, Per-Slice Review, Failure Analysis, Acceptance Mapping).",
|
|
775
|
+
found: false,
|
|
776
|
+
details: `${summary}. Move post-cutover slice prose into \`tdd-slices/<id>.md\` and let slice-documenter own the writes.`
|
|
362
777
|
};
|
|
363
778
|
}
|
|
364
|
-
function
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
779
|
+
async function readTddCutoverSliceId(projectRoot) {
|
|
780
|
+
const flowStatePath = path.join(projectRoot, ".cclaw/state/flow-state.json");
|
|
781
|
+
let raw;
|
|
782
|
+
try {
|
|
783
|
+
raw = await fs.readFile(flowStatePath, "utf8");
|
|
784
|
+
}
|
|
785
|
+
catch {
|
|
786
|
+
return null;
|
|
787
|
+
}
|
|
788
|
+
let parsed;
|
|
789
|
+
try {
|
|
790
|
+
parsed = JSON.parse(raw);
|
|
791
|
+
}
|
|
792
|
+
catch {
|
|
793
|
+
return null;
|
|
794
|
+
}
|
|
795
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
796
|
+
return null;
|
|
797
|
+
const value = parsed.tddCutoverSliceId;
|
|
798
|
+
if (typeof value !== "string" || value.length === 0)
|
|
799
|
+
return null;
|
|
800
|
+
return value;
|
|
801
|
+
}
|
|
802
|
+
function parseSliceNumber(sliceId) {
|
|
803
|
+
const match = /^S-(\d+)\b/u.exec(sliceId);
|
|
804
|
+
if (!match)
|
|
805
|
+
return null;
|
|
806
|
+
const num = Number.parseInt(match[1], 10);
|
|
807
|
+
return Number.isFinite(num) ? num : null;
|
|
808
|
+
}
|
|
809
|
+
function extractSliceIdsFromBody(body) {
|
|
810
|
+
const ids = new Set();
|
|
811
|
+
const regex = /\bS-(\d+)\b/gu;
|
|
812
|
+
let match;
|
|
813
|
+
while ((match = regex.exec(body)) !== null) {
|
|
814
|
+
ids.add(`S-${match[1]}`);
|
|
380
815
|
}
|
|
381
|
-
return
|
|
816
|
+
return [...ids];
|
|
817
|
+
}
|
|
818
|
+
function pickEventTs(rows) {
|
|
819
|
+
for (const entry of rows) {
|
|
820
|
+
const ts = entry.completedTs ?? entry.endTs ?? entry.ts;
|
|
821
|
+
if (typeof ts === "string" && ts.length > 0)
|
|
822
|
+
return ts;
|
|
823
|
+
}
|
|
824
|
+
return undefined;
|
|
382
825
|
}
|
|
383
826
|
export function parseVerticalSliceCycle(body) {
|
|
384
827
|
const tableLines = body.split("\n").filter((line) => /^\|/u.test(line));
|
|
@@ -490,7 +933,6 @@ function extractDeferRationale(cell) {
|
|
|
490
933
|
if (match !== null && match[1] !== undefined && match[1].trim().length > 0) {
|
|
491
934
|
return match[1].trim();
|
|
492
935
|
}
|
|
493
|
-
// Accept any free-form rationale text following the deferral marker.
|
|
494
936
|
const fallback = cleaned.replace(/^\s*(deferred|not[\s-]?needed|skipped|n\/?a)\b[:\s-]*/iu, "").trim();
|
|
495
937
|
return fallback;
|
|
496
938
|
}
|
|
@@ -537,3 +979,93 @@ export function evaluateVerificationLadder(body) {
|
|
|
537
979
|
"Promote each to `passed`, `n/a`, `failed`, `skipped`, or `deferred` (with rationale) before stage-complete."
|
|
538
980
|
};
|
|
539
981
|
}
|
|
982
|
+
export async function renderTddSliceSummary(input) {
|
|
983
|
+
let raw;
|
|
984
|
+
try {
|
|
985
|
+
raw = await fs.readFile(input.mainArtifactPath, "utf8");
|
|
986
|
+
}
|
|
987
|
+
catch {
|
|
988
|
+
return;
|
|
989
|
+
}
|
|
990
|
+
let next = raw;
|
|
991
|
+
if (input.renderSummary !== false) {
|
|
992
|
+
const summaryBlock = renderSliceSummaryBlock(input.slicesByEvents);
|
|
993
|
+
next = upsertAutoBlock(next, SLICE_SUMMARY_START, SLICE_SUMMARY_END, summaryBlock);
|
|
994
|
+
}
|
|
995
|
+
if (input.renderIndex !== false) {
|
|
996
|
+
const indexBlock = renderSlicesIndexBlock(input.sliceFiles);
|
|
997
|
+
next = upsertAutoBlock(next, SLICES_INDEX_START, SLICES_INDEX_END, indexBlock);
|
|
998
|
+
}
|
|
999
|
+
if (next !== raw) {
|
|
1000
|
+
try {
|
|
1001
|
+
await fs.writeFile(input.mainArtifactPath, next, "utf8");
|
|
1002
|
+
}
|
|
1003
|
+
catch {
|
|
1004
|
+
// best-effort render
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
function renderSliceSummaryBlock(slices) {
|
|
1009
|
+
if (slices.size === 0) {
|
|
1010
|
+
return "## Vertical Slice Cycle\n\n_No slice phase events recorded for the active run._";
|
|
1011
|
+
}
|
|
1012
|
+
const sortedIds = [...slices.keys()].sort();
|
|
1013
|
+
const rows = [];
|
|
1014
|
+
rows.push("## Vertical Slice Cycle");
|
|
1015
|
+
rows.push("");
|
|
1016
|
+
rows.push("| Slice | RED ts | GREEN ts | REFACTOR | Implementer | Test refs |");
|
|
1017
|
+
rows.push("|---|---|---|---|---|---|");
|
|
1018
|
+
for (const sliceId of sortedIds) {
|
|
1019
|
+
const events = slices.get(sliceId);
|
|
1020
|
+
const red = events.find((entry) => entry.phase === "red");
|
|
1021
|
+
const green = events.find((entry) => entry.phase === "green");
|
|
1022
|
+
const refactor = events.find((entry) => entry.phase === "refactor" || entry.phase === "refactor-deferred");
|
|
1023
|
+
const redTs = red?.completedTs ?? red?.endTs ?? red?.ts ?? "";
|
|
1024
|
+
const greenTs = green?.completedTs ?? green?.endTs ?? green?.ts ?? "";
|
|
1025
|
+
let refactorCell;
|
|
1026
|
+
if (!refactor) {
|
|
1027
|
+
refactorCell = "";
|
|
1028
|
+
}
|
|
1029
|
+
else if (refactor.phase === "refactor-deferred") {
|
|
1030
|
+
const refs = Array.isArray(refactor.evidenceRefs) ? refactor.evidenceRefs : [];
|
|
1031
|
+
const rationale = refs.find((ref) => typeof ref === "string" && ref.trim().length > 0) ?? "";
|
|
1032
|
+
refactorCell = `deferred because ${rationale}`.trim();
|
|
1033
|
+
}
|
|
1034
|
+
else {
|
|
1035
|
+
refactorCell = refactor.completedTs ?? refactor.ts ?? "";
|
|
1036
|
+
}
|
|
1037
|
+
const implementer = green?.agent ?? red?.agent ?? "";
|
|
1038
|
+
const refsList = green?.evidenceRefs ?? red?.evidenceRefs ?? [];
|
|
1039
|
+
const testRefs = Array.isArray(refsList) ? refsList.join(", ") : "";
|
|
1040
|
+
rows.push(`| ${sliceId} | ${redTs} | ${greenTs} | ${escapeTableCell(refactorCell)} | ${implementer} | ${escapeTableCell(testRefs)} |`);
|
|
1041
|
+
}
|
|
1042
|
+
return rows.join("\n");
|
|
1043
|
+
}
|
|
1044
|
+
function renderSlicesIndexBlock(sliceFiles) {
|
|
1045
|
+
if (sliceFiles.length === 0) {
|
|
1046
|
+
return "## Slices Index\n\n_No `tdd-slices/S-*.md` files present._";
|
|
1047
|
+
}
|
|
1048
|
+
const lines = [];
|
|
1049
|
+
lines.push("## Slices Index");
|
|
1050
|
+
lines.push("");
|
|
1051
|
+
for (const file of sliceFiles) {
|
|
1052
|
+
lines.push(`- [${file.sliceId}](tdd-slices/${path.basename(file.absPath)})`);
|
|
1053
|
+
}
|
|
1054
|
+
return lines.join("\n");
|
|
1055
|
+
}
|
|
1056
|
+
function escapeTableCell(value) {
|
|
1057
|
+
return value.replace(/\|/gu, "\\|").replace(/\r?\n/gu, " ");
|
|
1058
|
+
}
|
|
1059
|
+
function upsertAutoBlock(raw, startMarker, endMarker, bodyContent) {
|
|
1060
|
+
const startIdx = raw.indexOf(startMarker);
|
|
1061
|
+
const endIdx = raw.indexOf(endMarker);
|
|
1062
|
+
const replacement = `${startMarker}\n${bodyContent}\n${endMarker}`;
|
|
1063
|
+
if (startIdx >= 0 && endIdx > startIdx) {
|
|
1064
|
+
const before = raw.slice(0, startIdx);
|
|
1065
|
+
const after = raw.slice(endIdx + endMarker.length);
|
|
1066
|
+
return `${before}${replacement}${after}`;
|
|
1067
|
+
}
|
|
1068
|
+
// append to end
|
|
1069
|
+
const sep = raw.endsWith("\n") ? "" : "\n";
|
|
1070
|
+
return `${raw}${sep}\n${replacement}\n`;
|
|
1071
|
+
}
|