cclaw-cli 7.4.0 → 7.6.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,13 +1,19 @@
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";
5
5
  import fs from "node:fs/promises";
6
6
  import path from "node:path";
7
7
  import { PLAN_SPLIT_SMALL_PLAN_THRESHOLD, parseImplementationUnits, parseImplementationUnitParallelFields } from "../internal/plan-split-waves.js";
8
+ import { compareSliceIds, parseSliceId } from "../util/slice-id.js";
9
+ import { execFile } from "node:child_process";
10
+ import { promisify } from "node:util";
11
+ import { loadStackAdapter } from "../stack-detection.js";
12
+ const execFileAsync = promisify(execFile);
8
13
  const PARALLEL_EXEC_MANAGED_START = "<!-- parallel-exec-managed-start -->";
9
14
  const PARALLEL_EXEC_MANAGED_END = "<!-- parallel-exec-managed-end -->";
10
15
  const TASK_ID_PATTERN = /\bT-\d{3}[a-z]?(?:\.\d{1,3})?\b/giu;
16
+ const ACCEPTANCE_ID_PATTERN = /\bAC-\d+\b/giu;
11
17
  const PLAN_LANE_WHITELIST = new Set([
12
18
  "production",
13
19
  "test",
@@ -30,6 +36,21 @@ function extractTaskIds(body) {
30
36
  }
31
37
  return ids;
32
38
  }
39
+ function extractAcceptanceTaskLinks(body) {
40
+ const links = [];
41
+ for (const line of body.split(/\r?\n/u)) {
42
+ const acIds = [...line.matchAll(ACCEPTANCE_ID_PATTERN)].map((match) => match[0].toUpperCase());
43
+ const taskIds = [...line.matchAll(TASK_ID_PATTERN)].map((match) => match[0]);
44
+ if (acIds.length === 0 || taskIds.length === 0)
45
+ continue;
46
+ for (const acId of acIds) {
47
+ for (const taskId of taskIds) {
48
+ links.push({ acId, taskId });
49
+ }
50
+ }
51
+ }
52
+ return links;
53
+ }
33
54
  /**
34
55
  * Return the body between the parallel-exec managed comment markers, or
35
56
  * an empty string if the block is absent. The TDD wave parser uses the
@@ -104,13 +125,15 @@ function parseParallelWaveTableMetadata(planMarkdown) {
104
125
  continue;
105
126
  }
106
127
  const sliceCell = cells[0];
107
- if (!/^S-\d+$/iu.test(sliceCell))
128
+ const parsedSlice = parseSliceId(sliceCell);
129
+ if (!parsedSlice)
108
130
  continue;
109
131
  const idx = headerIdx ?? new Map();
110
132
  const unitIdx = idx.get("unit") ?? idx.get("taskid") ?? 1;
111
133
  const pathsIdx = idx.get("claimedpaths");
112
134
  const parallelizableIdx = idx.get("parallelizable");
113
135
  const laneIdx = idx.get("lane");
136
+ const dependsOnIdx = idx.get("dependson");
114
137
  const rawPaths = pathsIdx !== undefined ? (cells[pathsIdx] ?? "") : "";
115
138
  const claimedPaths = rawPaths.length === 0
116
139
  ? []
@@ -125,12 +148,22 @@ function parseParallelWaveTableMetadata(planMarkdown) {
125
148
  if (rawParallel === "false" || rawParallel === "no")
126
149
  parallelizable = false;
127
150
  const laneRaw = laneIdx !== undefined ? (cells[laneIdx] ?? "").trim().toLowerCase() : "";
151
+ const rawDeps = dependsOnIdx !== undefined ? (cells[dependsOnIdx] ?? "") : "";
152
+ const dependsOn = rawDeps.length === 0
153
+ ? []
154
+ : rawDeps
155
+ .replace(/^\[|\]$/gu, "")
156
+ .split(/[,\s]+/u)
157
+ .map((token) => token.trim().replace(/^`|`$/gu, ""))
158
+ .map((token) => parseSliceId(token)?.id ?? "")
159
+ .filter((id) => id.length > 0);
128
160
  current.rows.push({
129
- sliceId: sliceCell.toUpperCase(),
161
+ sliceId: parsedSlice.id,
130
162
  unit: (cells[unitIdx] ?? "").trim(),
131
163
  claimedPaths,
132
164
  parallelizable,
133
- lane: laneRaw.length > 0 ? laneRaw : null
165
+ lane: laneRaw.length > 0 ? laneRaw : null,
166
+ dependsOn
134
167
  });
135
168
  }
136
169
  flush();
@@ -140,6 +173,65 @@ function waveHasSequentialModeHint(wave) {
140
173
  const noteText = wave.notes.join("\n").toLowerCase();
141
174
  return /mode\s*:\s*sequential/iu.test(noteText) || /\bsequential\b/iu.test(noteText) || /\bserial\b/iu.test(noteText);
142
175
  }
176
+ /**
177
+ * Capture the set of repo-relative paths tracked at HEAD. Returns an
178
+ * empty set when the project root is not a git repo or `git ls-files`
179
+ * fails — the wiring linter degrades to "no aggregator required" in
180
+ * that case rather than crashing the whole stage check.
181
+ */
182
+ async function readHeadFiles(projectRoot) {
183
+ try {
184
+ const { stdout } = await execFileAsync("git", ["ls-files", "-z"], { cwd: projectRoot, maxBuffer: 64 * 1024 * 1024 });
185
+ const out = new Set();
186
+ for (const segment of stdout.split("\u0000")) {
187
+ const trimmed = segment.trim();
188
+ if (trimmed.length === 0)
189
+ continue;
190
+ out.add(trimmed.replace(/\\/gu, "/"));
191
+ }
192
+ return out;
193
+ }
194
+ catch {
195
+ return new Set();
196
+ }
197
+ }
198
+ function buildSliceClaimGraph(waves) {
199
+ const bySliceId = new Map();
200
+ for (const wave of waves) {
201
+ for (const row of wave.rows) {
202
+ bySliceId.set(row.sliceId, row);
203
+ }
204
+ }
205
+ return { bySliceId };
206
+ }
207
+ /**
208
+ * Walk the dependsOn graph from `sliceId` and return the set of
209
+ * predecessor slice ids (transitive). Skips ids that aren't in the
210
+ * graph and handles cycles via a `visiting` set so a malformed plan
211
+ * doesn't lock the linter.
212
+ */
213
+ function transitivePredecessors(sliceId, graph) {
214
+ const out = new Set();
215
+ const stack = [sliceId];
216
+ const visiting = new Set();
217
+ while (stack.length > 0) {
218
+ const current = stack.pop();
219
+ if (visiting.has(current))
220
+ continue;
221
+ visiting.add(current);
222
+ const row = graph.bySliceId.get(current);
223
+ if (!row)
224
+ continue;
225
+ for (const predecessor of row.dependsOn) {
226
+ const normalized = parseSliceId(predecessor)?.id ?? predecessor;
227
+ if (out.has(normalized))
228
+ continue;
229
+ out.add(normalized);
230
+ stack.push(normalized);
231
+ }
232
+ }
233
+ return out;
234
+ }
143
235
  export async function lintPlanStage(ctx) {
144
236
  const { projectRoot, track, raw, absFile, sections, findings, parsedFrontmatter, brainstormShortCircuitBody, brainstormShortCircuitActivated, staleDiagramAuditEnabled, isTrivialOverride } = ctx;
145
237
  evaluateInvestigationTrace(ctx, "Implementation Units");
@@ -190,6 +282,59 @@ export async function lintPlanStage(ctx) {
190
282
  ? "No scope-reduction phrases detected in Task List."
191
283
  : `Detected scope-reduction phrase(s) in Task List: ${reductionHits.join(", ")}.`
192
284
  });
285
+ const authoredTaskIds = extractTaskIds(taskListBody);
286
+ const acceptanceMappingBody = sectionBodyByName(sections, "Acceptance Mapping") ?? "";
287
+ const acTaskLinks = [
288
+ ...extractAcceptanceTaskLinks(taskListBody),
289
+ ...extractAcceptanceTaskLinks(acceptanceMappingBody)
290
+ ];
291
+ const mappedTaskToAcs = new Map();
292
+ const mappedAcToTasks = new Map();
293
+ for (const link of acTaskLinks) {
294
+ const taskSet = mappedTaskToAcs.get(link.taskId) ?? new Set();
295
+ taskSet.add(link.acId);
296
+ mappedTaskToAcs.set(link.taskId, taskSet);
297
+ const acSet = mappedAcToTasks.get(link.acId) ?? new Set();
298
+ acSet.add(link.taskId);
299
+ mappedAcToTasks.set(link.acId, acSet);
300
+ }
301
+ const tasksMissingAc = [...authoredTaskIds].filter((taskId) => !mappedTaskToAcs.has(taskId));
302
+ let specAcIds = [];
303
+ const specArtifact = await resolveStageArtifactPath("spec", {
304
+ projectRoot,
305
+ track,
306
+ intent: "read"
307
+ });
308
+ if (await exists(specArtifact.absPath)) {
309
+ try {
310
+ const specRaw = await fs.readFile(specArtifact.absPath, "utf8");
311
+ const specSections = extractH2Sections(specRaw);
312
+ const acceptanceBody = sectionBodyByName(specSections, "Acceptance Criteria") ?? specRaw;
313
+ specAcIds = extractAcceptanceCriterionIdsFromMarkdown(acceptanceBody);
314
+ }
315
+ catch {
316
+ specAcIds = [];
317
+ }
318
+ }
319
+ const acsMissingTask = specAcIds.filter((acId) => !mappedAcToTasks.has(acId));
320
+ const mappingFound = authoredTaskIds.size > 0 &&
321
+ tasksMissingAc.length === 0 &&
322
+ acsMissingTask.length === 0;
323
+ findings.push({
324
+ section: "plan_acceptance_mapped",
325
+ required: authoredTaskIds.size > 0,
326
+ rule: "Every T-NNN task must reference >=1 AC-N, and every AC-N from spec must be referenced by >=1 plan task.",
327
+ found: mappingFound,
328
+ details: authoredTaskIds.size === 0
329
+ ? "Task List has no T-NNN ids; acceptance mapping check skipped."
330
+ : tasksMissingAc.length > 0
331
+ ? `Task(s) missing AC mapping: ${tasksMissingAc.join(", ")}. Add AC-N references in Task List or Acceptance Mapping.`
332
+ : acsMissingTask.length > 0
333
+ ? `Spec AC(s) missing task coverage: ${acsMissingTask.join(", ")}.`
334
+ : specAcIds.length === 0
335
+ ? `Mapped ${authoredTaskIds.size} task(s) to AC ids; spec artifact AC list is empty or unavailable.`
336
+ : `Mapped ${authoredTaskIds.size} task(s) across ${specAcIds.length} spec AC(s).`
337
+ });
193
338
  // Universal Layer 2.5 structural checks (superpowers writing-plans + ce-plan).
194
339
  // Plan-wide placeholder scan (broader than Task List) using the
195
340
  // FORBIDDEN_PLACEHOLDER_TOKENS list shared with the cross-cutting block.
@@ -531,7 +676,7 @@ export async function lintPlanStage(ctx) {
531
676
  : `Serial slice(s) found without sequential wave mode hints in: ${inconsistentParallelizable.join(", ")}. Add a wave mode/note indicating sequential execution.`
532
677
  });
533
678
  const mermaidBlocks = raw.match(/```mermaid[\s\S]*?```/giu) ?? [];
534
- const hasParallelExecMermaid = mermaidBlocks.some((block) => /(flowchart|gantt)/iu.test(block) && /\bW-\d+\b/iu.test(block) && /\bS-\d+\b/iu.test(block));
679
+ const hasParallelExecMermaid = mermaidBlocks.some((block) => /(flowchart|gantt)/iu.test(block) && /\bW-\d+\b/iu.test(block) && /\bS-\d+(?:[a-z][a-z0-9]*)?\b/iu.test(block));
535
680
  findings.push({
536
681
  section: "plan_parallel_exec_mermaid_present",
537
682
  required: false,
@@ -541,5 +686,68 @@ export async function lintPlanStage(ctx) {
541
686
  ? "Mermaid visualization for parallel execution waves is present."
542
687
  : "No mermaid parallel-execution visualization found (advisory). Add a ` ```mermaid ` flowchart or gantt with W-* and S-* nodes."
543
688
  });
689
+ // 7.6.0 — plan_module_introducing_slice_wires_root.
690
+ // Stack-aware: stack-adapter exposes a `wiringAggregator` contract
691
+ // for stacks where introducing a new module file requires a
692
+ // sibling aggregator update (Rust lib.rs, Python __init__.py,
693
+ // optional Node-TS index.ts). For each NEW path in a slice's
694
+ // claim, if the adapter says an aggregator is required, the
695
+ // aggregator path must appear in the slice's own claim or in any
696
+ // transitive predecessor's claim within the same flow.
697
+ //
698
+ // For unknown stacks (Go, Java, Ruby, Swift, .NET, Elixir, …)
699
+ // the adapter returns `wiringAggregator: undefined`, so this
700
+ // gate is a no-op and `found: true`.
701
+ const stackAdapter = await loadStackAdapter(projectRoot);
702
+ const headFiles = await readHeadFiles(projectRoot);
703
+ const wiringIssues = [];
704
+ if (stackAdapter.wiringAggregator) {
705
+ const claimGraph = buildSliceClaimGraph(waveMeta);
706
+ for (const wave of waveMeta) {
707
+ for (const row of [...wave.rows].sort((a, b) => compareSliceIds(a.sliceId, b.sliceId))) {
708
+ const predecessors = transitivePredecessors(row.sliceId, claimGraph);
709
+ const predecessorClaims = new Set();
710
+ for (const predId of predecessors) {
711
+ const predRow = claimGraph.bySliceId.get(predId);
712
+ if (!predRow)
713
+ continue;
714
+ for (const claim of predRow.claimedPaths) {
715
+ predecessorClaims.add(normalizePathToken(claim));
716
+ }
717
+ }
718
+ const ownClaims = new Set(row.claimedPaths.map(normalizePathToken));
719
+ for (const rawClaim of row.claimedPaths) {
720
+ const claim = normalizePathToken(rawClaim);
721
+ if (claim.length === 0)
722
+ continue;
723
+ // Only NEW paths (not present at HEAD) require an
724
+ // aggregator update — existing modules are already wired.
725
+ if (headFiles.size > 0 && headFiles.has(claim))
726
+ continue;
727
+ const required = stackAdapter.wiringAggregator.resolveAggregatorFor(claim, { headFiles });
728
+ if (!required)
729
+ continue;
730
+ const aggregatorPath = normalizePathToken(required);
731
+ if (ownClaims.has(aggregatorPath))
732
+ continue;
733
+ if (predecessorClaims.has(aggregatorPath))
734
+ continue;
735
+ wiringIssues.push(`${wave.waveId}/${row.sliceId} introduces ${claim} but wiring aggregator ${aggregatorPath} is not in its claim or any predecessor's claim`);
736
+ }
737
+ }
738
+ }
739
+ }
740
+ const wiringApplies = stackAdapter.wiringAggregator !== undefined;
741
+ findings.push({
742
+ section: "plan_module_introducing_slice_wires_root",
743
+ required: taskListPresent && wiringApplies,
744
+ rule: "When a slice introduces a new module file, the stack-adapter's wiring aggregator (e.g. Rust `lib.rs`, Python `__init__.py`, Node-TS barrel `index.*` when present) must be in the same slice's claim or in a transitive predecessor's claim, otherwise the new module is dead code and RED can't be expressed.",
745
+ found: !wiringApplies || wiringIssues.length === 0,
746
+ details: !wiringApplies
747
+ ? `Stack adapter (id=${stackAdapter.id}) does not declare a wiring aggregator; gate is a no-op for this stack.`
748
+ : wiringIssues.length === 0
749
+ ? `Stack adapter (id=${stackAdapter.id}) wiring aggregator coverage verified across all wave slices.`
750
+ : `Wiring aggregator coverage gaps: ${wiringIssues.slice(0, 12).join(" | ")}${wiringIssues.length > 12 ? ` | … (${wiringIssues.length - 12} more)` : ""}.`
751
+ });
544
752
  }
545
753
  }
@@ -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;