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.
- package/dist/artifact-linter/plan.js +213 -5
- package/dist/artifact-linter/shared.d.ts +1 -0
- package/dist/artifact-linter/shared.js +5 -0
- package/dist/artifact-linter/ship.js +169 -1
- package/dist/artifact-linter/spec.js +33 -1
- package/dist/artifact-linter/tdd.d.ts +5 -0
- package/dist/artifact-linter/tdd.js +202 -2
- package/dist/config.d.ts +4 -1
- package/dist/config.js +24 -3
- package/dist/content/core-agents.js +15 -0
- package/dist/content/hooks.js +37 -0
- package/dist/content/stage-schema.js +6 -1
- package/dist/content/stages/plan.js +1 -0
- package/dist/content/stages/ship.js +4 -0
- package/dist/content/stages/spec.js +1 -0
- package/dist/content/stages/tdd.js +2 -0
- package/dist/content/templates.js +5 -0
- package/dist/delegation.d.ts +39 -0
- package/dist/delegation.js +66 -1
- package/dist/gate-evidence.js +10 -12
- package/dist/internal/advance-stage/start-flow.js +13 -4
- package/dist/internal/cohesion-contract-stub.js +2 -14
- package/dist/internal/plan-split-waves.js +19 -14
- package/dist/internal/slice-commit.js +161 -7
- package/dist/internal/wave-status.js +6 -4
- package/dist/stack-detection.d.ts +94 -0
- package/dist/stack-detection.js +431 -0
- package/dist/tdd-cycle.js +7 -5
- package/dist/types.d.ts +22 -0
- package/dist/util/slice-id.d.ts +58 -0
- package/dist/util/slice-id.js +89 -0
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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:
|
|
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
|
|
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 {
|
|
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;
|