cclaw-cli 7.4.0 → 7.5.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.
@@ -1,4 +1,4 @@
1
- import { evaluateInvestigationTrace, evaluateLayeredDocumentReviewStatus, extractAuthoredBody, headingPresent, sectionBodyByName, collectPatternHits, PLACEHOLDER_PATTERNS, extractDecisionIds, SCOPE_REDUCTION_PATTERNS } from "./shared.js";
1
+ import { evaluateInvestigationTrace, evaluateLayeredDocumentReviewStatus, extractAcceptanceCriterionIdsFromMarkdown, extractAuthoredBody, extractH2Sections, headingPresent, sectionBodyByName, collectPatternHits, PLACEHOLDER_PATTERNS, extractDecisionIds, SCOPE_REDUCTION_PATTERNS } from "./shared.js";
2
2
  import { resolveArtifactPath as resolveStageArtifactPath } from "../artifact-paths.js";
3
3
  import { exists } from "../fs-utils.js";
4
4
  import { FORBIDDEN_PLACEHOLDER_TOKENS, CONFIDENCE_FINDING_REGEX_SOURCE } from "../content/skills.js";
@@ -8,6 +8,7 @@ import { PLAN_SPLIT_SMALL_PLAN_THRESHOLD, parseImplementationUnits, parseImpleme
8
8
  const PARALLEL_EXEC_MANAGED_START = "<!-- parallel-exec-managed-start -->";
9
9
  const PARALLEL_EXEC_MANAGED_END = "<!-- parallel-exec-managed-end -->";
10
10
  const TASK_ID_PATTERN = /\bT-\d{3}[a-z]?(?:\.\d{1,3})?\b/giu;
11
+ const ACCEPTANCE_ID_PATTERN = /\bAC-\d+\b/giu;
11
12
  const PLAN_LANE_WHITELIST = new Set([
12
13
  "production",
13
14
  "test",
@@ -30,6 +31,21 @@ function extractTaskIds(body) {
30
31
  }
31
32
  return ids;
32
33
  }
34
+ function extractAcceptanceTaskLinks(body) {
35
+ const links = [];
36
+ for (const line of body.split(/\r?\n/u)) {
37
+ const acIds = [...line.matchAll(ACCEPTANCE_ID_PATTERN)].map((match) => match[0].toUpperCase());
38
+ const taskIds = [...line.matchAll(TASK_ID_PATTERN)].map((match) => match[0]);
39
+ if (acIds.length === 0 || taskIds.length === 0)
40
+ continue;
41
+ for (const acId of acIds) {
42
+ for (const taskId of taskIds) {
43
+ links.push({ acId, taskId });
44
+ }
45
+ }
46
+ }
47
+ return links;
48
+ }
33
49
  /**
34
50
  * Return the body between the parallel-exec managed comment markers, or
35
51
  * an empty string if the block is absent. The TDD wave parser uses the
@@ -190,6 +206,59 @@ export async function lintPlanStage(ctx) {
190
206
  ? "No scope-reduction phrases detected in Task List."
191
207
  : `Detected scope-reduction phrase(s) in Task List: ${reductionHits.join(", ")}.`
192
208
  });
209
+ const authoredTaskIds = extractTaskIds(taskListBody);
210
+ const acceptanceMappingBody = sectionBodyByName(sections, "Acceptance Mapping") ?? "";
211
+ const acTaskLinks = [
212
+ ...extractAcceptanceTaskLinks(taskListBody),
213
+ ...extractAcceptanceTaskLinks(acceptanceMappingBody)
214
+ ];
215
+ const mappedTaskToAcs = new Map();
216
+ const mappedAcToTasks = new Map();
217
+ for (const link of acTaskLinks) {
218
+ const taskSet = mappedTaskToAcs.get(link.taskId) ?? new Set();
219
+ taskSet.add(link.acId);
220
+ mappedTaskToAcs.set(link.taskId, taskSet);
221
+ const acSet = mappedAcToTasks.get(link.acId) ?? new Set();
222
+ acSet.add(link.taskId);
223
+ mappedAcToTasks.set(link.acId, acSet);
224
+ }
225
+ const tasksMissingAc = [...authoredTaskIds].filter((taskId) => !mappedTaskToAcs.has(taskId));
226
+ let specAcIds = [];
227
+ const specArtifact = await resolveStageArtifactPath("spec", {
228
+ projectRoot,
229
+ track,
230
+ intent: "read"
231
+ });
232
+ if (await exists(specArtifact.absPath)) {
233
+ try {
234
+ const specRaw = await fs.readFile(specArtifact.absPath, "utf8");
235
+ const specSections = extractH2Sections(specRaw);
236
+ const acceptanceBody = sectionBodyByName(specSections, "Acceptance Criteria") ?? specRaw;
237
+ specAcIds = extractAcceptanceCriterionIdsFromMarkdown(acceptanceBody);
238
+ }
239
+ catch {
240
+ specAcIds = [];
241
+ }
242
+ }
243
+ const acsMissingTask = specAcIds.filter((acId) => !mappedAcToTasks.has(acId));
244
+ const mappingFound = authoredTaskIds.size > 0 &&
245
+ tasksMissingAc.length === 0 &&
246
+ acsMissingTask.length === 0;
247
+ findings.push({
248
+ section: "plan_acceptance_mapped",
249
+ required: authoredTaskIds.size > 0,
250
+ rule: "Every T-NNN task must reference >=1 AC-N, and every AC-N from spec must be referenced by >=1 plan task.",
251
+ found: mappingFound,
252
+ details: authoredTaskIds.size === 0
253
+ ? "Task List has no T-NNN ids; acceptance mapping check skipped."
254
+ : tasksMissingAc.length > 0
255
+ ? `Task(s) missing AC mapping: ${tasksMissingAc.join(", ")}. Add AC-N references in Task List or Acceptance Mapping.`
256
+ : acsMissingTask.length > 0
257
+ ? `Spec AC(s) missing task coverage: ${acsMissingTask.join(", ")}.`
258
+ : specAcIds.length === 0
259
+ ? `Mapped ${authoredTaskIds.size} task(s) to AC ids; spec artifact AC list is empty or unavailable.`
260
+ : `Mapped ${authoredTaskIds.size} task(s) across ${specAcIds.length} spec AC(s).`
261
+ });
193
262
  // Universal Layer 2.5 structural checks (superpowers writing-plans + ce-plan).
194
263
  // Plan-wide placeholder scan (broader than Task List) using the
195
264
  // FORBIDDEN_PLACEHOLDER_TOKENS list shared with the cross-cutting block.
@@ -560,6 +560,7 @@ export declare const SCOPE_REDUCTION_PATTERNS: Array<{
560
560
  export declare function parseFrontmatter(markdown: string): ParsedFrontmatter;
561
561
  export declare function extractDecisionIds(text: string): string[];
562
562
  export declare function extractRequirementIdsFromMarkdown(text: string): string[];
563
+ export declare function extractAcceptanceCriterionIdsFromMarkdown(text: string): string[];
563
564
  export declare function collectPatternHits(text: string, patterns: Array<{
564
565
  label: string;
565
566
  regex: RegExp;
@@ -2002,6 +2002,11 @@ export function extractRequirementIdsFromMarkdown(text) {
2002
2002
  const ids = text.match(/\bR\d+\b/gu) ?? [];
2003
2003
  return [...new Set(ids)];
2004
2004
  }
2005
+ export function extractAcceptanceCriterionIdsFromMarkdown(text) {
2006
+ const ids = text.match(/\bAC-\d+\b/giu) ?? [];
2007
+ const normalized = ids.map((id) => id.toUpperCase());
2008
+ return [...new Set(normalized)];
2009
+ }
2005
2010
  // Cross-stage decision traceability uses stable D-XX IDs which the
2006
2011
  // agent can edit safely without recomputing content hashes.
2007
2012
  export function collectPatternHits(text, patterns) {
@@ -1,5 +1,121 @@
1
+ import { execFile } from "node:child_process";
2
+ import fs from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { promisify } from "node:util";
1
5
  import { readDelegationLedger } from "../delegation.js";
2
- import { sectionBodyByName } from "./shared.js";
6
+ import { resolveArtifactPath as resolveStageArtifactPath } from "../artifact-paths.js";
7
+ import { exists } from "../fs-utils.js";
8
+ import { extractAcceptanceCriterionIdsFromMarkdown, extractH2Sections, sectionBodyByName } from "./shared.js";
9
+ import { readFlowState } from "../run-persistence.js";
10
+ const execFileAsync = promisify(execFile);
11
+ function extractSliceCardClosedAcceptanceCriteria(content) {
12
+ const ids = new Set();
13
+ for (const match of content.matchAll(/^\s*(?:[-*]\s*)?closes\s*:\s*(.+)$/gimu)) {
14
+ const tail = match[1] ?? "";
15
+ for (const id of extractAcceptanceCriterionIdsFromMarkdown(tail)) {
16
+ ids.add(id);
17
+ }
18
+ }
19
+ return [...ids];
20
+ }
21
+ function escapeForRegex(value) {
22
+ return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
23
+ }
24
+ function entryTimestamp(entry) {
25
+ return entry.startTs ?? entry.ts ?? entry.launchedTs ?? entry.ackTs ?? entry.completedTs ?? entry.endTs ?? "";
26
+ }
27
+ async function readSpecAcceptanceCriteriaIds(projectRoot, track) {
28
+ const specArtifact = await resolveStageArtifactPath("spec", {
29
+ projectRoot,
30
+ track,
31
+ intent: "read"
32
+ });
33
+ if (!(await exists(specArtifact.absPath))) {
34
+ return [];
35
+ }
36
+ try {
37
+ const specRaw = await fs.readFile(specArtifact.absPath, "utf8");
38
+ const sections = extractH2Sections(specRaw);
39
+ const acceptanceBody = sectionBodyByName(sections, "Acceptance Criteria") ?? specRaw;
40
+ return extractAcceptanceCriterionIdsFromMarkdown(acceptanceBody);
41
+ }
42
+ catch {
43
+ return [];
44
+ }
45
+ }
46
+ async function collectAcceptanceSlicesMap(tddSlicesDir) {
47
+ const map = new Map();
48
+ let names = [];
49
+ try {
50
+ names = await fs.readdir(tddSlicesDir);
51
+ }
52
+ catch {
53
+ return map;
54
+ }
55
+ for (const name of names) {
56
+ const match = /^(S-[A-Za-z0-9._-]+)\.md$/u.exec(name);
57
+ if (!match)
58
+ continue;
59
+ const sliceId = match[1];
60
+ let content = "";
61
+ try {
62
+ content = await fs.readFile(path.join(tddSlicesDir, name), "utf8");
63
+ }
64
+ catch {
65
+ continue;
66
+ }
67
+ const acIds = extractSliceCardClosedAcceptanceCriteria(content);
68
+ for (const acId of acIds) {
69
+ const set = map.get(acId) ?? new Set();
70
+ set.add(sliceId);
71
+ map.set(acId, set);
72
+ }
73
+ }
74
+ return map;
75
+ }
76
+ async function sliceHasManagedCommit(projectRoot, sliceId, sinceTs) {
77
+ const grepPattern = `^${escapeForRegex(sliceId)}/`;
78
+ const args = ["log", "--extended-regexp", "--grep", grepPattern, "--pretty=%H"];
79
+ if (sinceTs && sinceTs.length > 0) {
80
+ args.push(`--since=${sinceTs}`);
81
+ }
82
+ try {
83
+ const { stdout } = await execFileAsync("git", args, { cwd: projectRoot });
84
+ return stdout.trim().length > 0;
85
+ }
86
+ catch {
87
+ return false;
88
+ }
89
+ }
90
+ async function resolveActiveRunStartTs(projectRoot, runId) {
91
+ const ledger = await readDelegationLedger(projectRoot).catch(() => null);
92
+ const runRows = ledger
93
+ ? ledger.entries.filter((entry) => entry.runId === runId)
94
+ : [];
95
+ const stamps = runRows
96
+ .map((entry) => entryTimestamp(entry))
97
+ .filter((value) => typeof value === "string" && value.length > 0)
98
+ .sort();
99
+ if (stamps.length > 0) {
100
+ return stamps[0];
101
+ }
102
+ try {
103
+ const state = await readFlowState(projectRoot);
104
+ if (state.activeRunId === runId) {
105
+ const completed = Object.values(state.completedStageMeta ?? {})
106
+ .map((meta) => meta?.completedAt)
107
+ .filter((value) => typeof value === "string" && value.length > 0)
108
+ .sort();
109
+ if (completed.length > 0) {
110
+ return completed[0];
111
+ }
112
+ }
113
+ }
114
+ catch {
115
+ // no-op fallback
116
+ }
117
+ return null;
118
+ }
3
119
  export async function lintShipStage(ctx) {
4
120
  const { projectRoot, track, raw, absFile, sections, findings, parsedFrontmatter, brainstormShortCircuitBody, brainstormShortCircuitActivated, staleDiagramAuditEnabled, isTrivialOverride } = ctx;
5
121
  // Universal Layer 2.8 structural checks (superpowers finishing-a-development-branch).
@@ -79,4 +195,56 @@ export async function lintShipStage(ctx) {
79
195
  ? "Architect cross-stage verification reported DRIFT_DETECTED; ship must not proceed."
80
196
  : "No DRIFT_DETECTED signal found in ship artifact or architect delegation evidence."
81
197
  });
198
+ const specAcceptanceIds = await readSpecAcceptanceCriteriaIds(projectRoot, track);
199
+ const tddSlicesDir = path.join(path.dirname(absFile), "tdd-slices");
200
+ const acceptanceSlices = await collectAcceptanceSlicesMap(tddSlicesDir);
201
+ const gitPresent = await exists(path.join(projectRoot, ".git"));
202
+ const runStartTs = gitPresent
203
+ ? await resolveActiveRunStartTs(projectRoot, delegationLedger.runId)
204
+ : null;
205
+ const uncoveredCriteria = [];
206
+ if (specAcceptanceIds.length > 0 && gitPresent && runStartTs !== null) {
207
+ for (const acId of specAcceptanceIds) {
208
+ const slices = [...(acceptanceSlices.get(acId) ?? new Set())];
209
+ let covered = false;
210
+ if (slices.length > 0) {
211
+ for (const sliceId of slices) {
212
+ if (await sliceHasManagedCommit(projectRoot, sliceId, runStartTs)) {
213
+ covered = true;
214
+ break;
215
+ }
216
+ }
217
+ }
218
+ if (!covered) {
219
+ const reason = slices.length === 0
220
+ ? `${acId} has no \`Closes: ${acId}\` slice mapping`
221
+ : `${acId} mapped slices ${slices.join(", ")} have no managed commit since run start`;
222
+ uncoveredCriteria.push(reason);
223
+ findings.push({
224
+ section: `acceptance_criterion_${acId}_uncovered`,
225
+ required: true,
226
+ rule: "Every acceptance criterion must map to at least one slice card and at least one managed slice commit.",
227
+ found: false,
228
+ details: reason
229
+ });
230
+ }
231
+ }
232
+ }
233
+ const allAcceptanceCovered = specAcceptanceIds.length === 0 ||
234
+ ((!gitPresent || runStartTs !== null) && uncoveredCriteria.length === 0);
235
+ findings.push({
236
+ section: "ship_all_acceptance_criteria_have_commits",
237
+ required: true,
238
+ rule: "For every spec AC-N, at least one `tdd-slices/S-*.md` card must declare `Closes: AC-N` and at least one managed slice commit (`^S-<id>/`) must exist since run start.",
239
+ found: allAcceptanceCovered,
240
+ details: specAcceptanceIds.length === 0
241
+ ? "Spec acceptance criteria list is empty or unreadable; AC commit coverage check is idle."
242
+ : !gitPresent
243
+ ? "No .git directory detected; AC-to-commit coverage check is skipped for no-VCS mode."
244
+ : runStartTs === null
245
+ ? "Unable to resolve active run start timestamp from delegation ledger/flow-state."
246
+ : uncoveredCriteria.length > 0
247
+ ? `Uncovered acceptance criteria: ${uncoveredCriteria.join(" | ")}.`
248
+ : `All ${specAcceptanceIds.length} acceptance criteria map to slice cards and managed commits since ${runStartTs}.`
249
+ });
82
250
  }
@@ -1,4 +1,4 @@
1
- import { evaluateLayeredDocumentReviewStatus, sectionBodyByName, SPEC_MAX_MODULES } from "./shared.js";
1
+ import { getMarkdownTableRows, evaluateLayeredDocumentReviewStatus, extractAcceptanceCriterionIdsFromMarkdown, sectionBodyByName, SPEC_MAX_MODULES } from "./shared.js";
2
2
  import { CONFIDENCE_FINDING_REGEX_SOURCE } from "../content/skills.js";
3
3
  export async function lintSpecStage(ctx) {
4
4
  const { projectRoot, track, raw, absFile, sections, findings, parsedFrontmatter, brainstormShortCircuitBody, brainstormShortCircuitActivated, staleDiagramAuditEnabled, isTrivialOverride } = ctx;
@@ -128,6 +128,38 @@ export async function lintSpecStage(ctx) {
128
128
  });
129
129
  }
130
130
  const acceptanceCriteriaBody = sectionBodyByName(sections, "Acceptance Criteria");
131
+ if (acceptanceCriteriaBody === null) {
132
+ findings.push({
133
+ section: "spec_ac_ids_present",
134
+ required: true,
135
+ rule: "Acceptance Criteria must assign stable IDs in `AC-N` format for every criterion row.",
136
+ found: false,
137
+ details: "No ## heading matching required section \"Acceptance Criteria\"."
138
+ });
139
+ }
140
+ else {
141
+ const tableRows = getMarkdownTableRows(acceptanceCriteriaBody);
142
+ const bulletRows = acceptanceCriteriaBody
143
+ .split("\n")
144
+ .map((line) => line.trim())
145
+ .filter((line) => /^[-*]\s+/u.test(line));
146
+ const candidateRows = tableRows.length > 0
147
+ ? tableRows.map((row) => row.join(" | ").trim()).filter((row) => row.length > 0)
148
+ : bulletRows;
149
+ const missingIds = candidateRows.filter((row) => extractAcceptanceCriterionIdsFromMarkdown(row).length === 0);
150
+ const found = candidateRows.length > 0 && missingIds.length === 0;
151
+ findings.push({
152
+ section: "spec_ac_ids_present",
153
+ required: true,
154
+ rule: "Acceptance Criteria must assign stable IDs in `AC-N` format for every criterion row.",
155
+ found,
156
+ details: candidateRows.length === 0
157
+ ? "Acceptance Criteria has no populated criterion rows."
158
+ : missingIds.length === 0
159
+ ? `All ${candidateRows.length} acceptance criterion row(s) include AC-N identifiers.`
160
+ : `${missingIds.length} acceptance criterion row(s) are missing AC-N identifiers.`
161
+ });
162
+ }
131
163
  if (acceptanceCriteriaBody !== null && /\|/u.test(acceptanceCriteriaBody)) {
132
164
  const hasParallel = /\bparallelSafe\b/iu.test(acceptanceCriteriaBody);
133
165
  const hasTouch = /\btouchSurface\b/iu.test(acceptanceCriteriaBody);
@@ -23,6 +23,11 @@ interface SliceFileInfo {
23
23
  sliceId: string;
24
24
  absPath: string;
25
25
  }
26
+ interface SliceNoOrphanChangesResult {
27
+ ok: boolean;
28
+ details: string;
29
+ }
30
+ export declare function evaluateSliceNoOrphanChanges(projectRoot: string, rows: DelegationEntry[]): Promise<SliceNoOrphanChangesResult>;
26
31
  interface ParsedSliceCycleResult {
27
32
  ok: boolean;
28
33
  details: string;
@@ -1,12 +1,17 @@
1
1
  import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
+ import { execFile } from "node:child_process";
4
+ import { promisify } from "node:util";
3
5
  import { loadTddReadySlicePool, readDelegationLedger, readDelegationEvents, selectReadySlices } from "../delegation.js";
6
+ import { resolveArtifactPath as resolveStageArtifactPath } from "../artifact-paths.js";
7
+ import { exists } from "../fs-utils.js";
4
8
  import { mergeParallelWaveDefinitions, parseParallelExecutionPlanWaves, parseWavePlanDirectory } from "../internal/plan-split-waves.js";
5
- import { evaluateInvestigationTrace, sectionBodyByName } from "./shared.js";
9
+ import { extractAcceptanceCriterionIdsFromMarkdown, extractH2Sections, evaluateInvestigationTrace, sectionBodyByName } from "./shared.js";
6
10
  const SLICE_SUMMARY_START = "<!-- auto-start: tdd-slice-summary -->";
7
11
  const SLICE_SUMMARY_END = "<!-- auto-end: tdd-slice-summary -->";
8
12
  const SLICES_INDEX_START = "<!-- auto-start: slices-index -->";
9
13
  const SLICES_INDEX_END = "<!-- auto-end: slices-index -->";
14
+ const execFileAsync = promisify(execFile);
10
15
  /**
11
16
  * TDD stage linter.
12
17
  *
@@ -351,6 +356,11 @@ export async function lintTddStage(ctx) {
351
356
  // simply means main-only mode (legacy fallback).
352
357
  const slicesDir = path.join(artifactsDir, "tdd-slices");
353
358
  const sliceFiles = await listSliceFiles(slicesDir);
359
+ const specAcceptanceIds = await readSpecAcceptanceCriteriaIds(projectRoot, ctx.track);
360
+ const specAcceptanceSet = new Set(specAcceptanceIds);
361
+ const slicesMissingCloses = [];
362
+ const slicesWithUnknownAcs = [];
363
+ let checkedSliceCards = 0;
354
364
  for (const sliceFile of sliceFiles) {
355
365
  const sliceId = sliceFile.sliceId;
356
366
  const requiredForSlice = slicesByEvents.has(sliceId) &&
@@ -376,6 +386,17 @@ export async function lintTddStage(ctx) {
376
386
  if (!/^##\s+Learnings\b/imu.test(content)) {
377
387
  issues.push("missing `## Learnings` section");
378
388
  }
389
+ checkedSliceCards += 1;
390
+ const closesIds = extractSliceCardClosedAcceptanceCriteria(content);
391
+ if (closesIds.length === 0) {
392
+ slicesMissingCloses.push(sliceId);
393
+ }
394
+ else if (specAcceptanceSet.size > 0) {
395
+ const unknown = closesIds.filter((acId) => !specAcceptanceSet.has(acId));
396
+ if (unknown.length > 0) {
397
+ slicesWithUnknownAcs.push(`${sliceId}: ${unknown.join(", ")}`);
398
+ }
399
+ }
379
400
  findings.push({
380
401
  section: `tdd_slice_file:${sliceId}`,
381
402
  required: requiredForSlice,
@@ -386,6 +407,34 @@ export async function lintTddStage(ctx) {
386
407
  : `tdd-slices/${path.basename(sliceFile.absPath)}: ${issues.join(", ")}.`
387
408
  });
388
409
  }
410
+ const closesRequired = checkedSliceCards > 0;
411
+ const closesGatePassed = !closesRequired
412
+ ? true
413
+ : slicesMissingCloses.length === 0 &&
414
+ slicesWithUnknownAcs.length === 0;
415
+ findings.push({
416
+ section: "tdd_slice_closes_ac",
417
+ required: true,
418
+ rule: "Every `tdd-slices/S-<id>.md` card must include `Closes: AC-N` links (comma-separated allowed) that reference real spec AC ids.",
419
+ found: closesGatePassed,
420
+ details: !closesRequired
421
+ ? "No `tdd-slices/S-*.md` slice cards found yet; `Closes: AC-N` check is idle."
422
+ : slicesMissingCloses.length > 0
423
+ ? `Slice card(s) missing \`Closes: AC-N\`: ${slicesMissingCloses.join(", ")}.`
424
+ : slicesWithUnknownAcs.length > 0
425
+ ? `Slice card(s) reference unknown AC ids: ${slicesWithUnknownAcs.join(" | ")}.`
426
+ : specAcceptanceSet.size === 0
427
+ ? `All ${checkedSliceCards} slice card(s) include Closes links; spec AC list unavailable for strict ID cross-check.`
428
+ : `All ${checkedSliceCards} slice card(s) include valid Closes links to spec AC ids.`
429
+ });
430
+ const orphanCheck = await evaluateSliceNoOrphanChanges(projectRoot, activeRunEntries);
431
+ findings.push({
432
+ section: "slice_no_orphan_changes",
433
+ required: true,
434
+ rule: "On slice phase=doc, there must be no staged/unstaged changes outside the slice `claimedPaths` (worktree root when present, otherwise project root).",
435
+ found: orphanCheck.ok,
436
+ details: orphanCheck.details
437
+ });
389
438
  // Auto-render the slice summary inside `06-tdd.md` between markers.
390
439
  // Idempotent — content outside the markers is preserved. Skipped
391
440
  // entirely when there is nothing to render, so legacy artifacts (no
@@ -466,6 +515,156 @@ async function listSliceFiles(slicesDir) {
466
515
  function escapeForRegex(value) {
467
516
  return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
468
517
  }
518
+ function normalizePathLike(value) {
519
+ const slashes = value.replace(/\\/gu, "/");
520
+ const withoutDot = slashes.replace(/^\.\//u, "");
521
+ return withoutDot.replace(/\/+$/u, "");
522
+ }
523
+ function parsePorcelainPaths(raw) {
524
+ const out = [];
525
+ for (const line of raw.split(/\r?\n/gu)) {
526
+ const trimmed = line.trimEnd();
527
+ if (trimmed.length < 4)
528
+ continue;
529
+ const status = trimmed.slice(0, 2);
530
+ if (status === "??") {
531
+ const p = normalizePathLike(trimmed.slice(3).trim());
532
+ if (p.length > 0)
533
+ out.push(p);
534
+ continue;
535
+ }
536
+ let p = trimmed.slice(3).trim();
537
+ const renameIdx = p.indexOf(" -> ");
538
+ if (renameIdx >= 0) {
539
+ p = p.slice(renameIdx + 4);
540
+ }
541
+ p = normalizePathLike(p.replace(/^"/u, "").replace(/"$/u, ""));
542
+ if (p.length > 0)
543
+ out.push(p);
544
+ }
545
+ return [...new Set(out)];
546
+ }
547
+ async function gitChangedPaths(cwd) {
548
+ const { stdout } = await execFileAsync("git", ["status", "--porcelain", "-uall"], { cwd });
549
+ return parsePorcelainPaths(stdout);
550
+ }
551
+ function matchesClaimedPath(changedPath, claimedPaths) {
552
+ const changed = normalizePathLike(changedPath);
553
+ return claimedPaths.some((rawClaimed) => {
554
+ const claimed = normalizePathLike(rawClaimed);
555
+ if (claimed.length === 0)
556
+ return false;
557
+ if (changed === claimed)
558
+ return true;
559
+ return changed.startsWith(`${claimed}/`);
560
+ });
561
+ }
562
+ function extractSliceCardClosedAcceptanceCriteria(content) {
563
+ const ids = new Set();
564
+ for (const match of content.matchAll(/^\s*(?:[-*]\s*)?closes\s*:\s*(.+)$/gimu)) {
565
+ const tail = match[1] ?? "";
566
+ for (const id of extractAcceptanceCriterionIdsFromMarkdown(tail)) {
567
+ ids.add(id);
568
+ }
569
+ }
570
+ return [...ids];
571
+ }
572
+ async function readSpecAcceptanceCriteriaIds(projectRoot, track) {
573
+ const specArtifact = await resolveStageArtifactPath("spec", {
574
+ projectRoot,
575
+ track,
576
+ intent: "read"
577
+ });
578
+ if (!(await exists(specArtifact.absPath))) {
579
+ return [];
580
+ }
581
+ try {
582
+ const specRaw = await fs.readFile(specArtifact.absPath, "utf8");
583
+ const specSections = extractH2Sections(specRaw);
584
+ const acceptanceBody = sectionBodyByName(specSections, "Acceptance Criteria") ?? specRaw;
585
+ return extractAcceptanceCriterionIdsFromMarkdown(acceptanceBody);
586
+ }
587
+ catch {
588
+ return [];
589
+ }
590
+ }
591
+ function resolveClaimedPathsForDocRow(row, allRows) {
592
+ const fromRow = Array.isArray(row.claimedPaths) ? row.claimedPaths : [];
593
+ if (fromRow.length > 0) {
594
+ return [...new Set(fromRow.map((value) => normalizePathLike(value)).filter((value) => value.length > 0))];
595
+ }
596
+ const fromSpan = allRows
597
+ .filter((entry) => entry.spanId === row.spanId &&
598
+ Array.isArray(entry.claimedPaths) &&
599
+ entry.claimedPaths.length > 0)
600
+ .flatMap((entry) => entry.claimedPaths);
601
+ return [...new Set(fromSpan.map((value) => normalizePathLike(value)).filter((value) => value.length > 0))];
602
+ }
603
+ async function resolveWorktreeCwdForDocRow(projectRoot, row, allRows) {
604
+ const candidates = [
605
+ typeof row.worktreePath === "string" ? row.worktreePath.trim() : "",
606
+ ...allRows
607
+ .filter((entry) => entry.spanId === row.spanId)
608
+ .map((entry) => (typeof entry.worktreePath === "string" ? entry.worktreePath.trim() : ""))
609
+ ].filter((value) => value.length > 0);
610
+ for (const candidateRaw of candidates) {
611
+ const candidateAbs = path.isAbsolute(candidateRaw)
612
+ ? candidateRaw
613
+ : path.join(projectRoot, candidateRaw);
614
+ if (await exists(candidateAbs)) {
615
+ return candidateAbs;
616
+ }
617
+ }
618
+ return projectRoot;
619
+ }
620
+ export async function evaluateSliceNoOrphanChanges(projectRoot, rows) {
621
+ if (!(await exists(path.join(projectRoot, ".git")))) {
622
+ return {
623
+ ok: true,
624
+ details: "No .git directory detected; orphan-change check skipped."
625
+ };
626
+ }
627
+ const docRows = rows.filter((entry) => entry.stage === "tdd" &&
628
+ entry.agent === "slice-builder" &&
629
+ entry.status === "completed" &&
630
+ entry.phase === "doc");
631
+ if (docRows.length === 0) {
632
+ return {
633
+ ok: true,
634
+ details: "No completed phase=doc rows found for the active run."
635
+ };
636
+ }
637
+ const missingClaimedPaths = [];
638
+ const driftRows = [];
639
+ for (const row of docRows) {
640
+ const claimedPaths = resolveClaimedPathsForDocRow(row, rows);
641
+ const rowKey = `${row.sliceId ?? "unknown-slice"}@${row.spanId ?? "unknown-span"}`;
642
+ if (claimedPaths.length === 0) {
643
+ missingClaimedPaths.push(rowKey);
644
+ continue;
645
+ }
646
+ const cwd = await resolveWorktreeCwdForDocRow(projectRoot, row, rows);
647
+ const changedPaths = await gitChangedPaths(cwd);
648
+ const driftPaths = changedPaths.filter((changedPath) => !matchesClaimedPath(changedPath, claimedPaths));
649
+ if (driftPaths.length > 0) {
650
+ driftRows.push(`${rowKey}: ${driftPaths.join(", ")}`);
651
+ }
652
+ }
653
+ if (missingClaimedPaths.length > 0 || driftRows.length > 0) {
654
+ const parts = [];
655
+ if (missingClaimedPaths.length > 0) {
656
+ parts.push(`doc row(s) missing claimedPaths: ${missingClaimedPaths.join(", ")}`);
657
+ }
658
+ if (driftRows.length > 0) {
659
+ parts.push(`orphan working-tree changes detected: ${driftRows.join(" | ")}`);
660
+ }
661
+ return { ok: false, details: parts.join(". ") };
662
+ }
663
+ return {
664
+ ok: true,
665
+ details: `Checked ${docRows.length} doc row(s); no orphan changes escaped claimedPaths.`
666
+ };
667
+ }
469
668
  function groupBySlice(entries) {
470
669
  const grouped = new Map();
471
670
  for (const entry of entries) {
@@ -258,6 +258,7 @@ const REQUIRED_GATE_IDS = {
258
258
  "design_test_and_perf_defined"
259
259
  ],
260
260
  spec: [
261
+ "spec_ac_ids_present",
261
262
  "spec_acceptance_measurable",
262
263
  "spec_testability_confirmed",
263
264
  "spec_assumptions_surfaced",
@@ -283,6 +284,8 @@ const REQUIRED_GATE_IDS = {
283
284
  "tdd_iron_law_acknowledged",
284
285
  "tdd_watched_red_observed",
285
286
  "tdd_slice_cycle_complete",
287
+ "tdd_slice_closes_ac",
288
+ "slice_no_orphan_changes",
286
289
  "tdd_docs_drift_check",
287
290
  ...(track === "quick" ? [] : ["tdd_traceable_to_plan"])
288
291
  ],
@@ -297,6 +300,7 @@ const REQUIRED_GATE_IDS = {
297
300
  "ship_review_verdict_valid",
298
301
  "ship_preflight_passed",
299
302
  "ship_rollback_plan_ready",
303
+ "ship_all_acceptance_criteria_have_commits",
300
304
  "ship_finalization_executed"
301
305
  ]
302
306
  };
@@ -341,7 +345,7 @@ const REQUIRED_ARTIFACT_SECTIONS = {
341
345
  "Verification Ladder"
342
346
  ],
343
347
  review: ["Review Evidence Scope", "Changed-File Coverage", "Layer 1 Verdict", "Review Findings Contract", "Severity Summary", "Final Verdict"],
344
- ship: ["Preflight Results", "Release Notes", "Rollback Plan", "Finalization"]
348
+ ship: ["Preflight Results", "Release Notes", "Traceability Matrix", "Rollback Plan", "Finalization"]
345
349
  };
346
350
  function resolveRequiredGateIds(stage, track) {
347
351
  const raw = REQUIRED_GATE_IDS[stage];
@@ -45,6 +45,7 @@ export const SHIP = {
45
45
  "Merge-base detection (git only) — identify the correct base branch. Run `git merge-base HEAD <base>`. If the base has diverged significantly, flag for rebase-first.",
46
46
  "Re-run tests on merged result — if merging locally, run the full test suite AFTER the merge, not just before. Post-merge failures are common.",
47
47
  "Generate release notes — summarize what changed, why, and what it affects. Reference spec criteria. Include: breaking changes, new dependencies, migration steps if any.",
48
+ "Assemble acceptance traceability matrix — for each spec AC-N, list mapped slice IDs and at least one managed commit proving closure.",
48
49
  "Write rollback plan — trigger conditions (what tells you it is broken), rollback steps (exact commands/git operations), and verification (how to confirm rollback worked).",
49
50
  "Load utility skills — `verification-before-completion` for fresh evidence and `finishing-a-development-branch` for finalization workflow.",
50
51
  "Monitoring checklist — what should be watched after deploy? Error rates, latency, key business metrics. If no monitoring exists, flag it as a risk.",
@@ -74,11 +75,13 @@ export const SHIP = {
74
75
  { id: "ship_review_verdict_valid", description: "Review verdict is APPROVED or APPROVED_WITH_CONCERNS." },
75
76
  { id: "ship_preflight_passed", description: "Preflight checks passed or exceptions documented and approved." },
76
77
  { id: "ship_rollback_plan_ready", description: "Rollback trigger, steps, and verification are documented." },
78
+ { id: "ship_all_acceptance_criteria_have_commits", description: "Every spec AC-N has at least one `Closes: AC-N` slice mapping and a managed slice commit in the active run." },
77
79
  { id: "ship_finalization_executed", description: "Selected finalization action was executed and verified." }
78
80
  ],
79
81
  requiredEvidence: [
80
82
  "Artifact written to `.cclaw/artifacts/08-ship.md`.",
81
83
  "Release notes section is complete.",
84
+ "Traceability Matrix maps each spec AC-N to slice IDs and managed commit evidence.",
82
85
  "Rollback section includes trigger conditions, steps, and verification.",
83
86
  "Finalization section shows exactly one selected enum token.",
84
87
  "Victory Detector result documented: review verdict valid, preflight fresh, rollback ready, finalization enum selected, and execution result present."
@@ -115,6 +118,7 @@ export const SHIP = {
115
118
  { section: "Upstream Handoff", required: false, validationRule: "Summarizes review/tdd decisions, constraints, open questions, and explicit drift before finalization." },
116
119
  { section: "Preflight Results", required: true, validationRule: "Build, test, lint, type-check results captured with fresh output. Exceptions documented if any." },
117
120
  { section: "Release Notes", required: true, validationRule: "What changed, why, impact. References spec criteria. Breaking changes flagged." },
121
+ { section: "Traceability Matrix", required: true, validationRule: "One row per spec AC-N with mapped slice IDs (`S-<id>`), managed commit evidence (`^S-<id>/` subject), and coverage status." },
118
122
  { section: "Rollback Plan", required: true, validationRule: "Trigger conditions, rollback steps (exact commands), verification steps." },
119
123
  { section: "Monitoring", required: false, validationRule: "If applicable: what metrics/logs to watch post-deploy. Risk note if no monitoring." },
120
124
  { section: "Finalization", required: true, validationRule: "Exactly one finalization enum token selected (FINALIZE_MERGE_LOCAL | FINALIZE_OPEN_PR | FINALIZE_KEEP_BRANCH | FINALIZE_DISCARD_BRANCH | FINALIZE_NO_VCS). Execution result documented. Worktree cleaned if applicable." },
@@ -68,6 +68,7 @@ export const SPEC = {
68
68
  "Write spec artifact and request approval."
69
69
  ],
70
70
  requiredGates: [
71
+ { id: "spec_ac_ids_present", description: "Acceptance Criteria rows include stable `AC-N` identifiers." },
71
72
  { id: "spec_acceptance_measurable", description: "Acceptance criteria are measurable and observable." },
72
73
  { id: "spec_testability_confirmed", description: "Each criterion has a described test method." },
73
74
  { id: "spec_assumptions_surfaced", description: "Assumptions were explicitly reviewed with source/confidence, validation path, and disposition before approval." },
@@ -76,6 +76,8 @@ export const TDD = {
76
76
  { id: "tdd_iron_law_acknowledged", description: "Iron Law acknowledgement is explicit (`Acknowledged: yes`) before implementation proceeds." },
77
77
  { id: "tdd_watched_red_observed", description: "Watched-RED Proof records at least one observed failing test with ISO timestamp evidence." },
78
78
  { id: "tdd_slice_cycle_complete", description: "Vertical Slice Cycle records RED, GREEN, and REFACTOR phases per active slice." },
79
+ { id: "tdd_slice_closes_ac", description: "Every `tdd-slices/S-<id>.md` card includes valid `Closes: AC-N` links to spec acceptance criteria." },
80
+ { id: "slice_no_orphan_changes", description: "After `phase=doc`, working tree changes remain inside the slice claimedPaths boundary (worktree root when present)." },
79
81
  { id: "tdd_traceable_to_plan", description: "Change traceability to plan slice is explicit." },
80
82
  { id: "tdd_docs_drift_check", description: "When public API/config/CLI surfaces change, docs drift is addressed via a completed doc-updater pass." }
81
83
  ],
@@ -1377,6 +1377,11 @@ ${renderBehaviorAnchorTemplateLine("ship")}
1377
1377
  ## Release Notes
1378
1378
  -
1379
1379
 
1380
+ ## Traceability Matrix
1381
+ | AC ID | Slice ID(s) | Managed commit evidence | Coverage status |
1382
+ |---|---|---|---|
1383
+ | AC-1 | S-1 | \`abc1234 S-1/green: ...\` | covered |
1384
+
1380
1385
  ## Structured PR Body
1381
1386
  > Required when selected option is \`OPEN_PR\`. The structure is universal — replace placeholder bullets with concrete content, do not introduce domain-specific subsections.
1382
1387
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cclaw-cli",
3
- "version": "7.4.0",
3
+ "version": "7.5.0",
4
4
  "description": "Installer-first flow toolkit for coding agents",
5
5
  "type": "module",
6
6
  "bin": {