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,12 +1,18 @@
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 { compareSliceIds } from "../util/slice-id.js";
10
+ import { extractAcceptanceCriterionIdsFromMarkdown, extractH2Sections, evaluateInvestigationTrace, sectionBodyByName } from "./shared.js";
6
11
  const SLICE_SUMMARY_START = "<!-- auto-start: tdd-slice-summary -->";
7
12
  const SLICE_SUMMARY_END = "<!-- auto-end: tdd-slice-summary -->";
8
13
  const SLICES_INDEX_START = "<!-- auto-start: slices-index -->";
9
14
  const SLICES_INDEX_END = "<!-- auto-end: slices-index -->";
15
+ const execFileAsync = promisify(execFile);
10
16
  /**
11
17
  * TDD stage linter.
12
18
  *
@@ -351,6 +357,11 @@ export async function lintTddStage(ctx) {
351
357
  // simply means main-only mode (legacy fallback).
352
358
  const slicesDir = path.join(artifactsDir, "tdd-slices");
353
359
  const sliceFiles = await listSliceFiles(slicesDir);
360
+ const specAcceptanceIds = await readSpecAcceptanceCriteriaIds(projectRoot, ctx.track);
361
+ const specAcceptanceSet = new Set(specAcceptanceIds);
362
+ const slicesMissingCloses = [];
363
+ const slicesWithUnknownAcs = [];
364
+ let checkedSliceCards = 0;
354
365
  for (const sliceFile of sliceFiles) {
355
366
  const sliceId = sliceFile.sliceId;
356
367
  const requiredForSlice = slicesByEvents.has(sliceId) &&
@@ -376,6 +387,17 @@ export async function lintTddStage(ctx) {
376
387
  if (!/^##\s+Learnings\b/imu.test(content)) {
377
388
  issues.push("missing `## Learnings` section");
378
389
  }
390
+ checkedSliceCards += 1;
391
+ const closesIds = extractSliceCardClosedAcceptanceCriteria(content);
392
+ if (closesIds.length === 0) {
393
+ slicesMissingCloses.push(sliceId);
394
+ }
395
+ else if (specAcceptanceSet.size > 0) {
396
+ const unknown = closesIds.filter((acId) => !specAcceptanceSet.has(acId));
397
+ if (unknown.length > 0) {
398
+ slicesWithUnknownAcs.push(`${sliceId}: ${unknown.join(", ")}`);
399
+ }
400
+ }
379
401
  findings.push({
380
402
  section: `tdd_slice_file:${sliceId}`,
381
403
  required: requiredForSlice,
@@ -386,6 +408,34 @@ export async function lintTddStage(ctx) {
386
408
  : `tdd-slices/${path.basename(sliceFile.absPath)}: ${issues.join(", ")}.`
387
409
  });
388
410
  }
411
+ const closesRequired = checkedSliceCards > 0;
412
+ const closesGatePassed = !closesRequired
413
+ ? true
414
+ : slicesMissingCloses.length === 0 &&
415
+ slicesWithUnknownAcs.length === 0;
416
+ findings.push({
417
+ section: "tdd_slice_closes_ac",
418
+ required: true,
419
+ rule: "Every `tdd-slices/S-<id>.md` card must include `Closes: AC-N` links (comma-separated allowed) that reference real spec AC ids.",
420
+ found: closesGatePassed,
421
+ details: !closesRequired
422
+ ? "No `tdd-slices/S-*.md` slice cards found yet; `Closes: AC-N` check is idle."
423
+ : slicesMissingCloses.length > 0
424
+ ? `Slice card(s) missing \`Closes: AC-N\`: ${slicesMissingCloses.join(", ")}.`
425
+ : slicesWithUnknownAcs.length > 0
426
+ ? `Slice card(s) reference unknown AC ids: ${slicesWithUnknownAcs.join(" | ")}.`
427
+ : specAcceptanceSet.size === 0
428
+ ? `All ${checkedSliceCards} slice card(s) include Closes links; spec AC list unavailable for strict ID cross-check.`
429
+ : `All ${checkedSliceCards} slice card(s) include valid Closes links to spec AC ids.`
430
+ });
431
+ const orphanCheck = await evaluateSliceNoOrphanChanges(projectRoot, activeRunEntries);
432
+ findings.push({
433
+ section: "slice_no_orphan_changes",
434
+ required: true,
435
+ rule: "On slice phase=doc, there must be no staged/unstaged changes outside the slice `claimedPaths` (worktree root when present, otherwise project root).",
436
+ found: orphanCheck.ok,
437
+ details: orphanCheck.details
438
+ });
389
439
  // Auto-render the slice summary inside `06-tdd.md` between markers.
390
440
  // Idempotent — content outside the markers is preserved. Skipped
391
441
  // entirely when there is nothing to render, so legacy artifacts (no
@@ -460,12 +510,162 @@ async function listSliceFiles(slicesDir) {
460
510
  continue;
461
511
  files.push({ sliceId: match[1], absPath: path.join(slicesDir, name) });
462
512
  }
463
- files.sort((a, b) => (a.sliceId < b.sliceId ? -1 : a.sliceId > b.sliceId ? 1 : 0));
513
+ files.sort((a, b) => compareSliceIds(a.sliceId, b.sliceId));
464
514
  return files;
465
515
  }
466
516
  function escapeForRegex(value) {
467
517
  return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
468
518
  }
519
+ function normalizePathLike(value) {
520
+ const slashes = value.replace(/\\/gu, "/");
521
+ const withoutDot = slashes.replace(/^\.\//u, "");
522
+ return withoutDot.replace(/\/+$/u, "");
523
+ }
524
+ function parsePorcelainPaths(raw) {
525
+ const out = [];
526
+ for (const line of raw.split(/\r?\n/gu)) {
527
+ const trimmed = line.trimEnd();
528
+ if (trimmed.length < 4)
529
+ continue;
530
+ const status = trimmed.slice(0, 2);
531
+ if (status === "??") {
532
+ const p = normalizePathLike(trimmed.slice(3).trim());
533
+ if (p.length > 0)
534
+ out.push(p);
535
+ continue;
536
+ }
537
+ let p = trimmed.slice(3).trim();
538
+ const renameIdx = p.indexOf(" -> ");
539
+ if (renameIdx >= 0) {
540
+ p = p.slice(renameIdx + 4);
541
+ }
542
+ p = normalizePathLike(p.replace(/^"/u, "").replace(/"$/u, ""));
543
+ if (p.length > 0)
544
+ out.push(p);
545
+ }
546
+ return [...new Set(out)];
547
+ }
548
+ async function gitChangedPaths(cwd) {
549
+ const { stdout } = await execFileAsync("git", ["status", "--porcelain", "-uall"], { cwd });
550
+ return parsePorcelainPaths(stdout);
551
+ }
552
+ function matchesClaimedPath(changedPath, claimedPaths) {
553
+ const changed = normalizePathLike(changedPath);
554
+ return claimedPaths.some((rawClaimed) => {
555
+ const claimed = normalizePathLike(rawClaimed);
556
+ if (claimed.length === 0)
557
+ return false;
558
+ if (changed === claimed)
559
+ return true;
560
+ return changed.startsWith(`${claimed}/`);
561
+ });
562
+ }
563
+ function extractSliceCardClosedAcceptanceCriteria(content) {
564
+ const ids = new Set();
565
+ for (const match of content.matchAll(/^\s*(?:[-*]\s*)?closes\s*:\s*(.+)$/gimu)) {
566
+ const tail = match[1] ?? "";
567
+ for (const id of extractAcceptanceCriterionIdsFromMarkdown(tail)) {
568
+ ids.add(id);
569
+ }
570
+ }
571
+ return [...ids];
572
+ }
573
+ async function readSpecAcceptanceCriteriaIds(projectRoot, track) {
574
+ const specArtifact = await resolveStageArtifactPath("spec", {
575
+ projectRoot,
576
+ track,
577
+ intent: "read"
578
+ });
579
+ if (!(await exists(specArtifact.absPath))) {
580
+ return [];
581
+ }
582
+ try {
583
+ const specRaw = await fs.readFile(specArtifact.absPath, "utf8");
584
+ const specSections = extractH2Sections(specRaw);
585
+ const acceptanceBody = sectionBodyByName(specSections, "Acceptance Criteria") ?? specRaw;
586
+ return extractAcceptanceCriterionIdsFromMarkdown(acceptanceBody);
587
+ }
588
+ catch {
589
+ return [];
590
+ }
591
+ }
592
+ function resolveClaimedPathsForDocRow(row, allRows) {
593
+ const fromRow = Array.isArray(row.claimedPaths) ? row.claimedPaths : [];
594
+ if (fromRow.length > 0) {
595
+ return [...new Set(fromRow.map((value) => normalizePathLike(value)).filter((value) => value.length > 0))];
596
+ }
597
+ const fromSpan = allRows
598
+ .filter((entry) => entry.spanId === row.spanId &&
599
+ Array.isArray(entry.claimedPaths) &&
600
+ entry.claimedPaths.length > 0)
601
+ .flatMap((entry) => entry.claimedPaths);
602
+ return [...new Set(fromSpan.map((value) => normalizePathLike(value)).filter((value) => value.length > 0))];
603
+ }
604
+ async function resolveWorktreeCwdForDocRow(projectRoot, row, allRows) {
605
+ const candidates = [
606
+ typeof row.worktreePath === "string" ? row.worktreePath.trim() : "",
607
+ ...allRows
608
+ .filter((entry) => entry.spanId === row.spanId)
609
+ .map((entry) => (typeof entry.worktreePath === "string" ? entry.worktreePath.trim() : ""))
610
+ ].filter((value) => value.length > 0);
611
+ for (const candidateRaw of candidates) {
612
+ const candidateAbs = path.isAbsolute(candidateRaw)
613
+ ? candidateRaw
614
+ : path.join(projectRoot, candidateRaw);
615
+ if (await exists(candidateAbs)) {
616
+ return candidateAbs;
617
+ }
618
+ }
619
+ return projectRoot;
620
+ }
621
+ export async function evaluateSliceNoOrphanChanges(projectRoot, rows) {
622
+ if (!(await exists(path.join(projectRoot, ".git")))) {
623
+ return {
624
+ ok: true,
625
+ details: "No .git directory detected; orphan-change check skipped."
626
+ };
627
+ }
628
+ const docRows = rows.filter((entry) => entry.stage === "tdd" &&
629
+ entry.agent === "slice-builder" &&
630
+ entry.status === "completed" &&
631
+ entry.phase === "doc");
632
+ if (docRows.length === 0) {
633
+ return {
634
+ ok: true,
635
+ details: "No completed phase=doc rows found for the active run."
636
+ };
637
+ }
638
+ const missingClaimedPaths = [];
639
+ const driftRows = [];
640
+ for (const row of docRows) {
641
+ const claimedPaths = resolveClaimedPathsForDocRow(row, rows);
642
+ const rowKey = `${row.sliceId ?? "unknown-slice"}@${row.spanId ?? "unknown-span"}`;
643
+ if (claimedPaths.length === 0) {
644
+ missingClaimedPaths.push(rowKey);
645
+ continue;
646
+ }
647
+ const cwd = await resolveWorktreeCwdForDocRow(projectRoot, row, rows);
648
+ const changedPaths = await gitChangedPaths(cwd);
649
+ const driftPaths = changedPaths.filter((changedPath) => !matchesClaimedPath(changedPath, claimedPaths));
650
+ if (driftPaths.length > 0) {
651
+ driftRows.push(`${rowKey}: ${driftPaths.join(", ")}`);
652
+ }
653
+ }
654
+ if (missingClaimedPaths.length > 0 || driftRows.length > 0) {
655
+ const parts = [];
656
+ if (missingClaimedPaths.length > 0) {
657
+ parts.push(`doc row(s) missing claimedPaths: ${missingClaimedPaths.join(", ")}`);
658
+ }
659
+ if (driftRows.length > 0) {
660
+ parts.push(`orphan working-tree changes detected: ${driftRows.join(" | ")}`);
661
+ }
662
+ return { ok: false, details: parts.join(". ") };
663
+ }
664
+ return {
665
+ ok: true,
666
+ details: `Checked ${docRows.length} doc row(s); no orphan changes escaped claimedPaths.`
667
+ };
668
+ }
469
669
  function groupBySlice(entries) {
470
670
  const grouped = new Map();
471
671
  for (const entry of entries) {
package/dist/config.d.ts CHANGED
@@ -1,9 +1,11 @@
1
- import type { CclawConfig, FlowTrack, HarnessId, LanguageRulePack, TddCommitMode, TddIsolationMode } from "./types.js";
1
+ import type { CclawConfig, FlowTrack, HarnessId, LanguageRulePack, LockfileTwinPolicy, TddCommitMode, TddIsolationMode } from "./types.js";
2
2
  export declare const TDD_COMMIT_MODES: readonly ["managed-per-slice", "agent-required", "checkpoint-only", "off"];
3
3
  export declare const DEFAULT_TDD_COMMIT_MODE: TddCommitMode;
4
4
  export declare const TDD_ISOLATION_MODES: readonly ["worktree", "in-place", "auto"];
5
5
  export declare const DEFAULT_TDD_ISOLATION_MODE: TddIsolationMode;
6
6
  export declare const DEFAULT_TDD_WORKTREE_ROOT = ".cclaw/worktrees";
7
+ export declare const LOCKFILE_TWIN_POLICIES: readonly ["auto-include", "auto-revert", "strict-fence"];
8
+ export declare const DEFAULT_LOCKFILE_TWIN_POLICY: LockfileTwinPolicy;
7
9
  export declare const DEFAULT_TDD_TEST_PATH_PATTERNS: readonly string[];
8
10
  export declare const DEFAULT_TDD_TEST_GLOBS: readonly string[];
9
11
  export declare const DEFAULT_TDD_PRODUCTION_PATH_PATTERNS: readonly string[];
@@ -24,6 +26,7 @@ export declare function createDefaultConfig(harnesses?: HarnessId[], _defaultTra
24
26
  export declare function resolveTddCommitMode(config: Pick<CclawConfig, "tdd"> | null | undefined): TddCommitMode;
25
27
  export declare function resolveTddIsolationMode(config: Pick<CclawConfig, "tdd"> | null | undefined): TddIsolationMode;
26
28
  export declare function resolveTddWorktreeRoot(config: Pick<CclawConfig, "tdd"> | null | undefined): string;
29
+ export declare function resolveLockfileTwinPolicy(config: Pick<CclawConfig, "tdd"> | null | undefined): LockfileTwinPolicy;
27
30
  export declare function detectLanguageRulePacks(_projectRoot: string): Promise<LanguageRulePack[]>;
28
31
  export declare function readConfig(projectRoot: string, _options?: ReadConfigOptions): Promise<CclawConfig>;
29
32
  export interface WriteConfigOptions {
package/dist/config.js CHANGED
@@ -20,6 +20,9 @@ export const TDD_ISOLATION_MODES = ["worktree", "in-place", "auto"];
20
20
  const TDD_ISOLATION_MODE_SET = new Set(TDD_ISOLATION_MODES);
21
21
  export const DEFAULT_TDD_ISOLATION_MODE = "worktree";
22
22
  export const DEFAULT_TDD_WORKTREE_ROOT = `${RUNTIME_ROOT}/worktrees`;
23
+ export const LOCKFILE_TWIN_POLICIES = ["auto-include", "auto-revert", "strict-fence"];
24
+ const LOCKFILE_TWIN_POLICY_SET = new Set(LOCKFILE_TWIN_POLICIES);
25
+ export const DEFAULT_LOCKFILE_TWIN_POLICY = "auto-include";
23
26
  // Kept for runtime modules that use these defaults directly.
24
27
  export const DEFAULT_TDD_TEST_PATH_PATTERNS = [
25
28
  "**/*.test.*",
@@ -68,7 +71,8 @@ export function createDefaultConfig(harnesses = DEFAULT_HARNESSES, _defaultTrack
68
71
  tdd: {
69
72
  commitMode: DEFAULT_TDD_COMMIT_MODE,
70
73
  isolationMode: DEFAULT_TDD_ISOLATION_MODE,
71
- worktreeRoot: DEFAULT_TDD_WORKTREE_ROOT
74
+ worktreeRoot: DEFAULT_TDD_WORKTREE_ROOT,
75
+ lockfileTwinPolicy: DEFAULT_LOCKFILE_TWIN_POLICY
72
76
  }
73
77
  };
74
78
  }
@@ -93,6 +97,13 @@ export function resolveTddWorktreeRoot(config) {
93
97
  }
94
98
  return DEFAULT_TDD_WORKTREE_ROOT;
95
99
  }
100
+ export function resolveLockfileTwinPolicy(config) {
101
+ const raw = config?.tdd?.lockfileTwinPolicy;
102
+ if (typeof raw === "string" && LOCKFILE_TWIN_POLICY_SET.has(raw)) {
103
+ return raw;
104
+ }
105
+ return DEFAULT_LOCKFILE_TWIN_POLICY;
106
+ }
96
107
  function assertOnlySupportedKeys(parsed, fullPath) {
97
108
  const unknownKeys = Object.keys(parsed).filter((key) => !ALLOWED_CONFIG_KEYS.has(key));
98
109
  if (unknownKeys.length === 0)
@@ -153,6 +164,7 @@ export async function readConfig(projectRoot, _options = {}) {
153
164
  const rawCommitMode = parsedTdd.commitMode;
154
165
  const rawIsolationMode = parsedTdd.isolationMode;
155
166
  const rawWorktreeRoot = parsedTdd.worktreeRoot;
167
+ const rawLockfileTwinPolicy = parsedTdd.lockfileTwinPolicy;
156
168
  if (rawCommitMode !== undefined &&
157
169
  (typeof rawCommitMode !== "string" || !TDD_COMMIT_MODE_SET.has(rawCommitMode))) {
158
170
  throw configValidationError(fullPath, `"tdd.commitMode" must be one of: ${TDD_COMMIT_MODES.join(", ")}`);
@@ -165,6 +177,10 @@ export async function readConfig(projectRoot, _options = {}) {
165
177
  (typeof rawWorktreeRoot !== "string" || rawWorktreeRoot.trim().length === 0)) {
166
178
  throw configValidationError(fullPath, `"tdd.worktreeRoot" must be a non-empty string when provided`);
167
179
  }
180
+ if (rawLockfileTwinPolicy !== undefined &&
181
+ (typeof rawLockfileTwinPolicy !== "string" || !LOCKFILE_TWIN_POLICY_SET.has(rawLockfileTwinPolicy))) {
182
+ throw configValidationError(fullPath, `"tdd.lockfileTwinPolicy" must be one of: ${LOCKFILE_TWIN_POLICIES.join(", ")}`);
183
+ }
168
184
  const commitMode = typeof rawCommitMode === "string"
169
185
  ? rawCommitMode
170
186
  : DEFAULT_TDD_COMMIT_MODE;
@@ -174,6 +190,9 @@ export async function readConfig(projectRoot, _options = {}) {
174
190
  const worktreeRoot = typeof rawWorktreeRoot === "string" && rawWorktreeRoot.trim().length > 0
175
191
  ? rawWorktreeRoot.trim()
176
192
  : DEFAULT_TDD_WORKTREE_ROOT;
193
+ const lockfileTwinPolicy = typeof rawLockfileTwinPolicy === "string"
194
+ ? rawLockfileTwinPolicy
195
+ : DEFAULT_LOCKFILE_TWIN_POLICY;
177
196
  return {
178
197
  version,
179
198
  flowVersion,
@@ -181,7 +200,8 @@ export async function readConfig(projectRoot, _options = {}) {
181
200
  tdd: {
182
201
  commitMode,
183
202
  isolationMode,
184
- worktreeRoot
203
+ worktreeRoot,
204
+ lockfileTwinPolicy
185
205
  }
186
206
  };
187
207
  }
@@ -193,7 +213,8 @@ export async function writeConfig(projectRoot, config, _options = {}) {
193
213
  tdd: {
194
214
  commitMode: resolveTddCommitMode(config),
195
215
  isolationMode: resolveTddIsolationMode(config),
196
- worktreeRoot: resolveTddWorktreeRoot(config)
216
+ worktreeRoot: resolveTddWorktreeRoot(config),
217
+ lockfileTwinPolicy: resolveLockfileTwinPolicy(config)
197
218
  }
198
219
  };
199
220
  await writeFileSafe(configPath(projectRoot), stringify(serialisable));
@@ -165,6 +165,21 @@ export function sliceBuilderProtocol() {
165
165
  "- Honor every `delegation-record`/`delegation-record.mjs` row shape the controller requests so artifact linters keep passing.",
166
166
  "- The umbrella `slice-completed` row ties RED/GREEN/REFACTOR/DOC timestamps to your builder span.",
167
167
  "",
168
+ "### Event → status flag table (7.6.0 — phase-event status validation)",
169
+ "",
170
+ "Phase-level granularity is only meaningful on terminal outcomes. The dispatch-level ack (no `--phase`) is the controller saying \"I see the dispatch surface back\" — it stays on `--status=acknowledged`. Phase events MUST use `--status=completed` or `--status=failed`. The hook rejects mismatches with `phase_event_requires_completed_or_failed_status` (exit 2) and prints a corrected-command hint.",
171
+ "",
172
+ "| event | --phase | --status |",
173
+ "|---|---|---|",
174
+ "| dispatch ack (controller-side) | (none) | `acknowledged` |",
175
+ "| RED watched-fail captured | `red` | `completed` |",
176
+ "| GREEN test passes | `green` | `completed` (with `--refactor-outcome=inline\\|deferred\\|...`) |",
177
+ "| REFACTOR landed | `refactor` | `completed` |",
178
+ "| DOC card landed (triggers slice-commit) | `doc` | `completed` |",
179
+ "| BLOCKED / unrecoverable | (any phase reached) | `failed` |",
180
+ "",
181
+ "Common slip: recording every phase event with `--status=acknowledged` (e.g. `--phase=doc --status=acknowledged`). The event row is silently dropped from terminal-phase aggregations, `slice-commit.mjs` never fires (it only triggers on `phase=doc status=completed`), and `wave-status` reports the slice as phantom-open. Recovery requires raw backfill commands. The 7.6.0 hook validator forbids this configuration up front.",
182
+ "",
168
183
  "### Streaming output contract",
169
184
  "- Emit one JSON line to stdout per completed phase: `{\"event\":\"phase-completed\",\"stage\":\"tdd\",\"sliceId\":\"S-<n>\",\"phase\":\"<red|green|refactor|refactor-deferred|doc>\",\"spanId\":\"<span>\",\"runId\":\"<run>\",\"ts\":\"<iso>\"}`.",
170
185
  "- For `phase=green` with inline/deferred refactor folding, include `refactorOutcome.mode` in the same JSON line so live controllers can close the slice without waiting for file sync.",
@@ -1489,6 +1489,43 @@ async function main() {
1489
1489
  emitProblems(problems, json, 2);
1490
1490
  return;
1491
1491
  }
1492
+ // 7.6.0 — phase-event status validation.
1493
+ // \`--phase=<phase>\` carries phase-level granularity (RED/GREEN/REFACTOR/DOC
1494
+ // outcomes). It is only meaningful on terminal statuses
1495
+ // (\`completed\` or \`failed\`). The dispatch-level ack (no phase) keeps
1496
+ // \`--status=acknowledged\`. Refuse acknowledged/launched/scheduled/waived/stale
1497
+ // rows that carry a phase so phantom-open slices cannot be recorded.
1498
+ if (
1499
+ typeof args.phase === "string" &&
1500
+ args.phase.length > 0 &&
1501
+ args.status !== "completed" &&
1502
+ args.status !== "failed"
1503
+ ) {
1504
+ const sliceFlag = typeof args.slice === "string" && args.slice.length > 0
1505
+ ? "--slice=" + args.slice + " "
1506
+ : "";
1507
+ const spanFlag = typeof args["span-id"] === "string" && args["span-id"].length > 0
1508
+ ? "--span-id=" + args["span-id"] + " "
1509
+ : "";
1510
+ const correctedCommandHint =
1511
+ "node .cclaw/hooks/delegation-record.mjs --stage=" + (args.stage || "<stage>") +
1512
+ " --agent=" + (args.agent || "<agent>") +
1513
+ " --mode=" + (args.mode || "mandatory") +
1514
+ " --status=completed --phase=" + args.phase +
1515
+ " " + sliceFlag + spanFlag +
1516
+ '--evidence-ref="<phase outcome>"';
1517
+ emitErrorJson(
1518
+ "phase_event_requires_completed_or_failed_status",
1519
+ {
1520
+ phase: args.phase,
1521
+ status: args.status,
1522
+ spanId: args["span-id"] || "unknown",
1523
+ correctedCommandHint
1524
+ },
1525
+ json
1526
+ );
1527
+ return;
1528
+ }
1492
1529
  if (args.phase === "refactor-deferred") {
1493
1530
  const rationaleQuality = validateDeferredRationaleInline(args["refactor-rationale"], args);
1494
1531
  if (rationaleQuality !== "ok") {
@@ -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",
@@ -271,6 +272,7 @@ const REQUIRED_GATE_IDS = {
271
272
  "plan_execution_posture_recorded",
272
273
  "plan_parallel_exec_full_coverage",
273
274
  "plan_wave_paths_disjoint",
275
+ "plan_module_introducing_slice_wires_root",
274
276
  "plan_wait_for_confirm"
275
277
  ],
276
278
  tdd: (track) => [
@@ -283,6 +285,8 @@ const REQUIRED_GATE_IDS = {
283
285
  "tdd_iron_law_acknowledged",
284
286
  "tdd_watched_red_observed",
285
287
  "tdd_slice_cycle_complete",
288
+ "tdd_slice_closes_ac",
289
+ "slice_no_orphan_changes",
286
290
  "tdd_docs_drift_check",
287
291
  ...(track === "quick" ? [] : ["tdd_traceable_to_plan"])
288
292
  ],
@@ -297,6 +301,7 @@ const REQUIRED_GATE_IDS = {
297
301
  "ship_review_verdict_valid",
298
302
  "ship_preflight_passed",
299
303
  "ship_rollback_plan_ready",
304
+ "ship_all_acceptance_criteria_have_commits",
300
305
  "ship_finalization_executed"
301
306
  ]
302
307
  };
@@ -341,7 +346,7 @@ const REQUIRED_ARTIFACT_SECTIONS = {
341
346
  "Verification Ladder"
342
347
  ],
343
348
  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"]
349
+ ship: ["Preflight Results", "Release Notes", "Traceability Matrix", "Rollback Plan", "Finalization"]
345
350
  };
346
351
  function resolveRequiredGateIds(stage, track) {
347
352
  const raw = REQUIRED_GATE_IDS[stage];
@@ -85,6 +85,7 @@ export const PLAN = {
85
85
  { id: "plan_execution_posture_recorded", description: "Execution posture is recorded before implementation handoff." },
86
86
  { id: "plan_parallel_exec_full_coverage", description: "Every T-NNN task in `## Task List` (other than spikes/explicitly-deferred) is assigned to at least one slice inside the `<!-- parallel-exec-managed-start -->` block; TDD cannot fan out work that the plan never authored as waves." },
87
87
  { id: "plan_wave_paths_disjoint", description: "Within each authored wave, slice `claimedPaths` remain disjoint so `wave-fanout` can dispatch safely without overlap conflicts." },
88
+ { id: "plan_module_introducing_slice_wires_root", description: "When a slice introduces a new module file, the stack-adapter's wiring aggregator (Rust `lib.rs`, Python `__init__.py`, Node-TS barrel when present) must appear in the same slice's claim or a transitive predecessor's claim so RED can be expressed." },
88
89
  { id: "plan_wait_for_confirm", description: "Execution blocked until explicit user confirmation." }
89
90
  ],
90
91
  requiredEvidence: [
@@ -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
 
@@ -381,6 +381,45 @@ export declare class DispatchClaimedPathProtectedError extends Error {
381
381
  * offending path so the operator can fix the dispatch in one pass.
382
382
  */
383
383
  export declare function validateClaimedPathsNotProtected(stamped: DelegationEntry): void;
384
+ /**
385
+ * Thrown by `appendDelegation` (and the inline `delegation-record.mjs`
386
+ * helper) when an event with a non-null `phase` is recorded with
387
+ * `status="acknowledged"`. Phase-level granularity only makes sense on
388
+ * terminal outcomes (`completed` or `failed`); the dispatch-level ACK
389
+ * (no phase) is the controller saying "I see the dispatch surface back".
390
+ *
391
+ * Motivated by hox W-08/S-41: the slice-builder agent recorded all four
392
+ * phase events with `--status=acknowledged`, which the helper silently
393
+ * accepted but `slice-commit.mjs` only fires on `phase=doc status=completed`.
394
+ * `wave-status` then saw the slice as phantom-open even though the
395
+ * worker had finished. Recovery required raw backfill commands.
396
+ *
397
+ * 7.6.0 makes the constraint explicit: pair `--phase=<phase>` with
398
+ * `--status=completed` (or `--status=failed`) and use
399
+ * `--status=acknowledged` only for the dispatch-level ack (no phase).
400
+ */
401
+ export declare class PhaseEventRequiresTerminalStatusError extends Error {
402
+ readonly phase: string;
403
+ readonly status: DelegationStatus;
404
+ readonly spanId: string;
405
+ readonly correctedCommandHint: string;
406
+ constructor(params: {
407
+ phase: string;
408
+ status: DelegationStatus;
409
+ spanId: string;
410
+ correctedCommandHint: string;
411
+ });
412
+ }
413
+ /**
414
+ * Reject delegation rows where `phase` is set but `status` is not
415
+ * `completed` or `failed`. Acknowledged/launched/scheduled/waived/stale
416
+ * rows must NOT carry a phase — the phase-level lifecycle exists only
417
+ * to record terminal outcomes per phase (RED/GREEN/REFACTOR/DOC).
418
+ *
419
+ * Throws `PhaseEventRequiresTerminalStatusError`; the message includes
420
+ * an actionable corrected-command hint that the controller can paste.
421
+ */
422
+ export declare function validatePhaseEventStatus(stamped: DelegationEntry): void;
384
423
  /**
385
424
  * Thrown by `appendDelegation` when a new `scheduled` span would open a
386
425
  * second TDD cycle for a slice that already has at least one closed span