cclaw-cli 6.10.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.
- package/dist/artifact-linter/shared.d.ts +7 -4
- package/dist/artifact-linter/shared.js +4 -4
- package/dist/artifact-linter/tdd.d.ts +42 -21
- package/dist/artifact-linter/tdd.js +379 -140
- package/dist/artifact-linter.js +46 -40
- package/dist/content/core-agents.d.ts +14 -0
- package/dist/content/core-agents.js +35 -0
- package/dist/content/examples.js +4 -4
- package/dist/content/hooks.js +68 -3
- package/dist/content/skills.js +1 -1
- package/dist/content/stage-schema.js +0 -3
- 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/install.js +9 -0
- package/dist/internal/advance-stage.js +2 -6
- 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,21 +135,19 @@ export async function lintTddStage(ctx) {
|
|
|
112
135
|
});
|
|
113
136
|
}
|
|
114
137
|
}
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
const
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
hasPopulatedTableRows(sliceCycleBodyAdvisory);
|
|
123
|
-
if (markdownIsAuthored) {
|
|
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";
|
|
124
145
|
findings.push({
|
|
125
|
-
section: "
|
|
126
|
-
required:
|
|
127
|
-
rule: "
|
|
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.",
|
|
128
149
|
found: false,
|
|
129
|
-
details:
|
|
150
|
+
details: `Slices missing slice-documenter coverage: ${docResult.missing.join(", ")}.`
|
|
130
151
|
});
|
|
131
152
|
}
|
|
132
153
|
}
|
|
@@ -169,8 +190,6 @@ export async function lintTddStage(ctx) {
|
|
|
169
190
|
: "Mocks/spies detected without boundary justification; add explicit trust-boundary rationale or replace with real/fake/stub coverage."
|
|
170
191
|
});
|
|
171
192
|
}
|
|
172
|
-
const delegationLedger = await readDelegationLedger(projectRoot);
|
|
173
|
-
const activeRunEntries = delegationLedger.entries.filter((entry) => entry.stage === "tdd" && entry.runId === delegationLedger.runId);
|
|
174
193
|
const completedSliceImplementers = activeRunEntries.filter((entry) => entry.agent === "slice-implementer" && entry.status === "completed");
|
|
175
194
|
const fanOutDetected = completedSliceImplementers.length > 1;
|
|
176
195
|
if (fanOutDetected) {
|
|
@@ -240,145 +259,276 @@ export async function lintTddStage(ctx) {
|
|
|
240
259
|
: "integration-overseer completion exists, but PASS/PASS_WITH_GAPS evidence is missing in delegation evidenceRefs and artifact text."
|
|
241
260
|
});
|
|
242
261
|
}
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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
|
+
}
|
|
248
304
|
findings.push({
|
|
249
|
-
section:
|
|
250
|
-
required:
|
|
251
|
-
rule: "
|
|
252
|
-
found:
|
|
253
|
-
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(", ")}.`
|
|
254
312
|
});
|
|
255
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
|
+
}
|
|
256
332
|
}
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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);
|
|
270
366
|
}
|
|
271
|
-
|
|
367
|
+
return grouped;
|
|
368
|
+
}
|
|
369
|
+
export function evaluateEventsWatchedRed(slices) {
|
|
272
370
|
const errors = [];
|
|
273
|
-
|
|
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;
|
|
274
377
|
const issues = [];
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
if (!entry.testCommand || entry.testCommand.length === 0) {
|
|
285
|
-
issues.push("missing testCommand");
|
|
286
|
-
}
|
|
287
|
-
if (!Array.isArray(entry.claimedPaths) || entry.claimedPaths.length === 0) {
|
|
288
|
-
issues.push("missing claimedPaths");
|
|
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
|
+
}
|
|
289
387
|
}
|
|
290
388
|
if (issues.length > 0) {
|
|
291
|
-
errors.push(`${
|
|
389
|
+
errors.push(`${sliceId}: ${issues.join(", ")}`);
|
|
292
390
|
}
|
|
293
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
|
+
}
|
|
294
398
|
if (errors.length > 0) {
|
|
295
399
|
return {
|
|
296
400
|
ok: false,
|
|
297
|
-
details: `
|
|
401
|
+
details: `Watched-RED slice events missing required fields: ${errors.join(" | ")}.`
|
|
298
402
|
};
|
|
299
403
|
}
|
|
300
404
|
return {
|
|
301
405
|
ok: true,
|
|
302
|
-
details:
|
|
406
|
+
details: `${redCount} slice(s) carry phase=red events with ISO completedTs and evidenceRefs.`
|
|
303
407
|
};
|
|
304
408
|
}
|
|
305
|
-
|
|
306
|
-
* v6.10.0 (T2) — sidecar-aware Vertical Slice Cycle check. Each slice
|
|
307
|
-
* must have a row whose effective status (latest by sliceId) implies a
|
|
308
|
-
* monotonic progression. Once a slice carries `greenAt`, the linter
|
|
309
|
-
* requires `greenAt >= redObservedAt`. `refactor-deferred` requires a
|
|
310
|
-
* non-empty `refactorRationale`. `refactor-done` requires a `refactorAt`
|
|
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.
|
|
314
|
-
*/
|
|
315
|
-
export function evaluateSidecarSliceCycle(rawEntries) {
|
|
316
|
-
if (rawEntries.length === 0) {
|
|
317
|
-
return {
|
|
318
|
-
ok: false,
|
|
319
|
-
details: "Sidecar 06-tdd-slices.jsonl is empty; the vertical slice cycle has no rows."
|
|
320
|
-
};
|
|
321
|
-
}
|
|
322
|
-
const folded = foldTddSliceLedger(rawEntries);
|
|
409
|
+
export function evaluateEventsSliceCycle(slices) {
|
|
323
410
|
const errors = [];
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
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;
|
|
336
428
|
}
|
|
337
|
-
if (
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
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;
|
|
342
439
|
}
|
|
343
|
-
if (
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
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
|
+
});
|
|
352
489
|
continue;
|
|
353
490
|
}
|
|
354
491
|
}
|
|
355
492
|
}
|
|
356
493
|
if (errors.length > 0) {
|
|
357
|
-
return {
|
|
494
|
+
return {
|
|
495
|
+
ok: false,
|
|
496
|
+
details: errors.join(" "),
|
|
497
|
+
findings
|
|
498
|
+
};
|
|
358
499
|
}
|
|
359
500
|
return {
|
|
360
501
|
ok: true,
|
|
361
|
-
details: `${
|
|
502
|
+
details: `${slices.size} slice(s) show monotonic phase=red -> phase=green -> phase=refactor (deferred-with-rationale accepted).`,
|
|
503
|
+
findings: []
|
|
362
504
|
};
|
|
363
505
|
}
|
|
364
|
-
function
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
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;
|
|
380
530
|
}
|
|
381
|
-
return
|
|
531
|
+
return undefined;
|
|
382
532
|
}
|
|
383
533
|
export function parseVerticalSliceCycle(body) {
|
|
384
534
|
const tableLines = body.split("\n").filter((line) => /^\|/u.test(line));
|
|
@@ -490,7 +640,6 @@ function extractDeferRationale(cell) {
|
|
|
490
640
|
if (match !== null && match[1] !== undefined && match[1].trim().length > 0) {
|
|
491
641
|
return match[1].trim();
|
|
492
642
|
}
|
|
493
|
-
// Accept any free-form rationale text following the deferral marker.
|
|
494
643
|
const fallback = cleaned.replace(/^\s*(deferred|not[\s-]?needed|skipped|n\/?a)\b[:\s-]*/iu, "").trim();
|
|
495
644
|
return fallback;
|
|
496
645
|
}
|
|
@@ -537,3 +686,93 @@ export function evaluateVerificationLadder(body) {
|
|
|
537
686
|
"Promote each to `passed`, `n/a`, `failed`, `skipped`, or `deferred` (with rationale) before stage-complete."
|
|
538
687
|
};
|
|
539
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
|
+
}
|