cclaw-cli 7.0.5 → 7.1.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.
@@ -481,6 +481,134 @@ function groupBySlice(entries) {
481
481
  }
482
482
  return grouped;
483
483
  }
484
+ /** Group completed phase rows for a slice by `spanId` (falls back to a single legacy bucket). */
485
+ function groupSliceRowsBySpanId(rows) {
486
+ const grouped = new Map();
487
+ for (const entry of rows) {
488
+ const spanKey = typeof entry.spanId === "string" && entry.spanId.length > 0 ? entry.spanId : "__missing-span__";
489
+ const list = grouped.get(spanKey) ?? [];
490
+ list.push(entry);
491
+ grouped.set(spanKey, list);
492
+ }
493
+ return grouped;
494
+ }
495
+ function maxPhaseTimestampForSpan(rows) {
496
+ let max = "";
497
+ for (const entry of rows) {
498
+ const ts = entry.completedTs ?? entry.endTs ?? entry.ts ?? "";
499
+ if (typeof ts === "string" && ts.length > 0 && ts > max)
500
+ max = ts;
501
+ }
502
+ return max;
503
+ }
504
+ /**
505
+ * Validate RED→GREEN→REFACTOR (incl. green `refactorOutcome`) monotonicity for one slice-builder span.
506
+ * `rows` must contain only entries for that span.
507
+ */
508
+ function evaluateSingleSpanSliceCycle(sliceId, spanId, rows) {
509
+ const errors = [];
510
+ const findings = [];
511
+ const sec = (slug) => `${slug}:${sliceId}@${spanId}`;
512
+ const reds = rows.filter((entry) => entry.phase === "red");
513
+ const greens = rows.filter((entry) => entry.phase === "green");
514
+ const refactors = rows.filter((entry) => entry.phase === "refactor" || entry.phase === "refactor-deferred");
515
+ const redTs = pickEventTs(reds);
516
+ const greenTs = pickEventTs(greens);
517
+ if (reds.length === 0) {
518
+ errors.push(`${sliceId}: phase=red event missing.`);
519
+ findings.push({
520
+ section: sec("tdd_slice_red_missing"),
521
+ required: true,
522
+ rule: "Each TDD slice with phase events must include a `phase=red` row.",
523
+ found: false,
524
+ details: `${sliceId} (span ${spanId}): no phase=red event recorded for the active run.`
525
+ });
526
+ return { ok: false, errors, findings };
527
+ }
528
+ if (greens.length === 0) {
529
+ errors.push(`${sliceId}: phase=green event missing.`);
530
+ findings.push({
531
+ section: sec("tdd_slice_green_missing"),
532
+ required: true,
533
+ rule: "Each TDD slice with a phase=red event must reach a `phase=green` row before stage-complete.",
534
+ found: false,
535
+ details: `${sliceId} (span ${spanId}): no phase=green event recorded; RED has no matching GREEN.`
536
+ });
537
+ return { ok: false, errors, findings };
538
+ }
539
+ if (greenTs && redTs && greenTs < redTs) {
540
+ errors.push(`${sliceId}: phase=green completedTs (${greenTs}) precedes phase=red (${redTs}).`);
541
+ findings.push({
542
+ section: sec("tdd_slice_phase_order_invalid"),
543
+ required: true,
544
+ rule: "Phase events must be monotonic: phase=green completedTs >= phase=red completedTs.",
545
+ found: false,
546
+ details: `${sliceId} (span ${spanId}): green at ${greenTs} precedes red at ${redTs}.`
547
+ });
548
+ return { ok: false, errors, findings };
549
+ }
550
+ const greenEvidenceRef = greens
551
+ .flatMap((entry) => (Array.isArray(entry.evidenceRefs) ? entry.evidenceRefs : []))
552
+ .find((ref) => typeof ref === "string" && ref.trim().length > 0);
553
+ if (!greenEvidenceRef) {
554
+ errors.push(`${sliceId}: phase=green row has empty evidenceRefs.`);
555
+ findings.push({
556
+ section: sec("tdd_slice_evidence_missing"),
557
+ required: true,
558
+ rule: "Each `phase=green` event must record at least one evidenceRef (path to test artifact, span id, or pasted-output pointer).",
559
+ found: false,
560
+ details: `${sliceId} (span ${spanId}): phase=green event missing evidenceRefs.`
561
+ });
562
+ return { ok: false, errors, findings };
563
+ }
564
+ const greenWithOutcome = greens.find((entry) => entry.refactorOutcome &&
565
+ (entry.refactorOutcome.mode === "inline" || entry.refactorOutcome.mode === "deferred"));
566
+ if (refactors.length === 0 && !greenWithOutcome) {
567
+ errors.push(`${sliceId}: phase=refactor or phase=refactor-deferred event missing.`);
568
+ findings.push({
569
+ section: sec("tdd_slice_refactor_missing"),
570
+ required: true,
571
+ rule: "Each TDD slice must close with a `phase=refactor` event, a `phase=refactor-deferred` event whose evidenceRefs / refactorRationale captures why refactor was deferred, OR a `phase=green` event carrying `refactorOutcome`.",
572
+ found: false,
573
+ details: `${sliceId} (span ${spanId}): no phase=refactor / phase=refactor-deferred event and no refactorOutcome on phase=green.`
574
+ });
575
+ return { ok: false, errors, findings };
576
+ }
577
+ if (greenWithOutcome &&
578
+ greenWithOutcome.refactorOutcome?.mode === "deferred" &&
579
+ !greenWithOutcome.refactorOutcome.rationale &&
580
+ !(Array.isArray(greenWithOutcome.evidenceRefs) &&
581
+ greenWithOutcome.evidenceRefs.some((ref) => typeof ref === "string" && ref.trim().length > 0))) {
582
+ errors.push(`${sliceId}: phase=green refactorOutcome=deferred missing rationale.`);
583
+ findings.push({
584
+ section: sec("tdd_slice_refactor_missing"),
585
+ required: true,
586
+ rule: "phase=green refactorOutcome=deferred requires a rationale (via --refactor-rationale or --evidence-ref).",
587
+ found: false,
588
+ details: `${sliceId} (span ${spanId}): phase=green refactorOutcome.mode=deferred recorded without rationale.`
589
+ });
590
+ return { ok: false, errors, findings };
591
+ }
592
+ const deferred = refactors.find((entry) => entry.phase === "refactor-deferred");
593
+ if (refactors.length > 0 &&
594
+ deferred &&
595
+ refactors.every((entry) => entry.phase === "refactor-deferred")) {
596
+ const refs = Array.isArray(deferred.evidenceRefs) ? deferred.evidenceRefs : [];
597
+ const hasRationale = refs.some((ref) => typeof ref === "string" && ref.trim().length > 0);
598
+ if (!hasRationale) {
599
+ errors.push(`${sliceId}: phase=refactor-deferred row needs evidenceRefs containing a rationale.`);
600
+ findings.push({
601
+ section: sec("tdd_slice_refactor_missing"),
602
+ required: true,
603
+ rule: "phase=refactor-deferred must record a rationale via --refactor-rationale or via --evidence-ref pointing at the rationale text.",
604
+ found: false,
605
+ details: `${sliceId} (span ${spanId}): phase=refactor-deferred recorded without rationale evidenceRefs.`
606
+ });
607
+ return { ok: false, errors, findings };
608
+ }
609
+ }
610
+ return { ok: true, errors: [], findings: [] };
611
+ }
484
612
  export function evaluateEventsWatchedRed(slices) {
485
613
  const errors = [];
486
614
  let redCount = 0;
@@ -525,109 +653,23 @@ export function evaluateEventsSliceCycle(slices) {
525
653
  const errors = [];
526
654
  const findings = [];
527
655
  for (const [sliceId, rows] of slices.entries()) {
528
- const reds = rows.filter((entry) => entry.phase === "red");
529
- const greens = rows.filter((entry) => entry.phase === "green");
530
- const refactors = rows.filter((entry) => entry.phase === "refactor" || entry.phase === "refactor-deferred");
531
- const redTs = pickEventTs(reds);
532
- const greenTs = pickEventTs(greens);
533
- if (reds.length === 0) {
534
- errors.push(`${sliceId}: phase=red event missing.`);
535
- findings.push({
536
- section: `tdd_slice_red_missing:${sliceId}`,
537
- required: true,
538
- rule: "Each TDD slice with phase events must include a `phase=red` row.",
539
- found: false,
540
- details: `${sliceId}: no phase=red event recorded for the active run.`
541
- });
542
- continue;
543
- }
544
- if (greens.length === 0) {
545
- errors.push(`${sliceId}: phase=green event missing.`);
546
- findings.push({
547
- section: `tdd_slice_green_missing:${sliceId}`,
548
- required: true,
549
- rule: "Each TDD slice with a phase=red event must reach a `phase=green` row before stage-complete.",
550
- found: false,
551
- details: `${sliceId}: no phase=green event recorded; RED has no matching GREEN.`
552
- });
553
- continue;
554
- }
555
- if (greenTs && redTs && greenTs < redTs) {
556
- errors.push(`${sliceId}: phase=green completedTs (${greenTs}) precedes phase=red (${redTs}).`);
557
- findings.push({
558
- section: `tdd_slice_phase_order_invalid:${sliceId}`,
559
- required: true,
560
- rule: "Phase events must be monotonic: phase=green completedTs >= phase=red completedTs.",
561
- found: false,
562
- details: `${sliceId}: green at ${greenTs} precedes red at ${redTs}.`
656
+ const bySpan = groupSliceRowsBySpanId(rows);
657
+ const spanOutcomes = [];
658
+ for (const [spanId, spanRows] of bySpan.entries()) {
659
+ const result = evaluateSingleSpanSliceCycle(sliceId, spanId, spanRows);
660
+ spanOutcomes.push({
661
+ spanId,
662
+ maxTs: maxPhaseTimestampForSpan(spanRows),
663
+ result
563
664
  });
564
- continue;
565
665
  }
566
- const greenEvidenceRef = greens
567
- .flatMap((entry) => Array.isArray(entry.evidenceRefs) ? entry.evidenceRefs : [])
568
- .find((ref) => typeof ref === "string" && ref.trim().length > 0);
569
- if (!greenEvidenceRef) {
570
- errors.push(`${sliceId}: phase=green row has empty evidenceRefs.`);
571
- findings.push({
572
- section: `tdd_slice_evidence_missing:${sliceId}`,
573
- required: true,
574
- rule: "Each `phase=green` event must record at least one evidenceRef (path to test artifact, span id, or pasted-output pointer).",
575
- found: false,
576
- details: `${sliceId}: phase=green event missing evidenceRefs.`
577
- });
666
+ if (spanOutcomes.some((s) => s.result.ok)) {
578
667
  continue;
579
668
  }
580
- // refactorOutcome on phase=green satisfies REFACTOR coverage
581
- // without a separate phase=refactor / phase=refactor-deferred row.
582
- // - mode: "inline" → REFACTOR ran inline as part of GREEN.
583
- // - mode: "deferred" → rationale required (carried in evidenceRefs[0]
584
- // by the hook helper so legacy linters keep working).
585
- const greenWithOutcome = greens.find((entry) => entry.refactorOutcome &&
586
- (entry.refactorOutcome.mode === "inline" || entry.refactorOutcome.mode === "deferred"));
587
- if (refactors.length === 0 && !greenWithOutcome) {
588
- errors.push(`${sliceId}: phase=refactor or phase=refactor-deferred event missing.`);
589
- findings.push({
590
- section: `tdd_slice_refactor_missing:${sliceId}`,
591
- required: true,
592
- rule: "Each TDD slice must close with a `phase=refactor` event, a `phase=refactor-deferred` event whose evidenceRefs / refactorRationale captures why refactor was deferred, OR a `phase=green` event carrying `refactorOutcome`.",
593
- found: false,
594
- details: `${sliceId}: no phase=refactor / phase=refactor-deferred event and no refactorOutcome on phase=green.`
595
- });
596
- continue;
597
- }
598
- if (greenWithOutcome &&
599
- greenWithOutcome.refactorOutcome?.mode === "deferred" &&
600
- !greenWithOutcome.refactorOutcome.rationale &&
601
- !(Array.isArray(greenWithOutcome.evidenceRefs) &&
602
- greenWithOutcome.evidenceRefs.some((ref) => typeof ref === "string" && ref.trim().length > 0))) {
603
- errors.push(`${sliceId}: phase=green refactorOutcome=deferred missing rationale.`);
604
- findings.push({
605
- section: `tdd_slice_refactor_missing:${sliceId}`,
606
- required: true,
607
- rule: "phase=green refactorOutcome=deferred requires a rationale (via --refactor-rationale or --evidence-ref).",
608
- found: false,
609
- details: `${sliceId}: phase=green refactorOutcome.mode=deferred recorded without rationale.`
610
- });
611
- continue;
612
- }
613
- const deferred = refactors.find((entry) => entry.phase === "refactor-deferred");
614
- if (refactors.length > 0 &&
615
- deferred &&
616
- refactors.every((entry) => entry.phase === "refactor-deferred")) {
617
- const refs = Array.isArray(deferred.evidenceRefs) ? deferred.evidenceRefs : [];
618
- const hasRationale = refs.some((ref) => typeof ref === "string" && ref.trim().length > 0);
619
- if (!hasRationale) {
620
- errors.push(`${sliceId}: phase=refactor-deferred row needs evidenceRefs containing a rationale.`);
621
- findings.push({
622
- section: `tdd_slice_refactor_missing:${sliceId}`,
623
- required: true,
624
- rule: "phase=refactor-deferred must record a rationale via --refactor-rationale or via --evidence-ref pointing at the rationale text.",
625
- found: false,
626
- details: `${sliceId}: phase=refactor-deferred recorded without rationale evidenceRefs.`
627
- });
628
- continue;
629
- }
630
- }
669
+ spanOutcomes.sort((a, b) => (a.maxTs < b.maxTs ? 1 : a.maxTs > b.maxTs ? -1 : 0));
670
+ const chosen = spanOutcomes[0];
671
+ errors.push(...chosen.result.errors);
672
+ findings.push(...chosen.result.findings);
631
673
  }
632
674
  if (errors.length > 0) {
633
675
  return {
@@ -638,7 +680,7 @@ export function evaluateEventsSliceCycle(slices) {
638
680
  }
639
681
  return {
640
682
  ok: true,
641
- details: `${slices.size} slice(s) show monotonic phase=red -> phase=green -> phase=refactor (deferred-with-rationale accepted).`,
683
+ details: `${slices.size} slice(s) show monotonic phase=red -> phase=green -> phase=refactor (deferred-with-rationale accepted); at least one span per slice satisfies the cycle.`,
642
684
  findings: []
643
685
  };
644
686
  }
package/dist/config.d.ts CHANGED
@@ -1,4 +1,6 @@
1
- import type { CclawConfig, FlowTrack, HarnessId, LanguageRulePack } from "./types.js";
1
+ import type { CclawConfig, FlowTrack, HarnessId, LanguageRulePack, TddCommitMode } from "./types.js";
2
+ export declare const TDD_COMMIT_MODES: readonly ["managed-per-slice", "agent-required", "checkpoint-only", "off"];
3
+ export declare const DEFAULT_TDD_COMMIT_MODE: TddCommitMode;
2
4
  export declare const DEFAULT_TDD_TEST_PATH_PATTERNS: readonly string[];
3
5
  export declare const DEFAULT_TDD_TEST_GLOBS: readonly string[];
4
6
  export declare const DEFAULT_TDD_PRODUCTION_PATH_PATTERNS: readonly string[];
@@ -16,6 +18,7 @@ export declare class InvalidConfigError extends Error {
16
18
  }
17
19
  export declare function configPath(projectRoot: string): string;
18
20
  export declare function createDefaultConfig(harnesses?: HarnessId[], _defaultTrack?: FlowTrack): CclawConfig;
21
+ export declare function resolveTddCommitMode(config: Pick<CclawConfig, "tdd"> | null | undefined): TddCommitMode;
19
22
  export declare function detectLanguageRulePacks(_projectRoot: string): Promise<LanguageRulePack[]>;
20
23
  export declare function readConfig(projectRoot: string, _options?: ReadConfigOptions): Promise<CclawConfig>;
21
24
  export interface WriteConfigOptions {
package/dist/config.js CHANGED
@@ -6,8 +6,16 @@ import { exists, writeFileSafe } from "./fs-utils.js";
6
6
  import { HARNESS_IDS } from "./types.js";
7
7
  const CONFIG_PATH = `${RUNTIME_ROOT}/config.yaml`;
8
8
  const HARNESS_ID_SET = new Set(HARNESS_IDS);
9
- const ALLOWED_CONFIG_KEYS = new Set(["version", "flowVersion", "harnesses"]);
9
+ const ALLOWED_CONFIG_KEYS = new Set(["version", "flowVersion", "harnesses", "tdd"]);
10
10
  const SUPPORTED_HARNESSES_TEXT = HARNESS_IDS.join(", ");
11
+ export const TDD_COMMIT_MODES = [
12
+ "managed-per-slice",
13
+ "agent-required",
14
+ "checkpoint-only",
15
+ "off"
16
+ ];
17
+ const TDD_COMMIT_MODE_SET = new Set(TDD_COMMIT_MODES);
18
+ export const DEFAULT_TDD_COMMIT_MODE = "managed-per-slice";
11
19
  // Kept for runtime modules that use these defaults directly.
12
20
  export const DEFAULT_TDD_TEST_PATH_PATTERNS = [
13
21
  "**/*.test.*",
@@ -30,7 +38,9 @@ export class InvalidConfigError extends Error {
30
38
  function configFixExample() {
31
39
  return `harnesses:
32
40
  - claude
33
- - cursor`;
41
+ - cursor
42
+ tdd:
43
+ commitMode: managed-per-slice`;
34
44
  }
35
45
  function configValidationError(configFilePath, reason) {
36
46
  return new InvalidConfigError(`Invalid cclaw config at ${configFilePath}: ${reason}\n` +
@@ -48,9 +58,19 @@ export function createDefaultConfig(harnesses = DEFAULT_HARNESSES, _defaultTrack
48
58
  return {
49
59
  version: CCLAW_VERSION,
50
60
  flowVersion: FLOW_VERSION,
51
- harnesses: [...new Set(harnesses)]
61
+ harnesses: [...new Set(harnesses)],
62
+ tdd: {
63
+ commitMode: DEFAULT_TDD_COMMIT_MODE
64
+ }
52
65
  };
53
66
  }
67
+ export function resolveTddCommitMode(config) {
68
+ const raw = config?.tdd?.commitMode;
69
+ if (typeof raw === "string" && TDD_COMMIT_MODE_SET.has(raw)) {
70
+ return raw;
71
+ }
72
+ return DEFAULT_TDD_COMMIT_MODE;
73
+ }
54
74
  function assertOnlySupportedKeys(parsed, fullPath) {
55
75
  const unknownKeys = Object.keys(parsed).filter((key) => !ALLOWED_CONFIG_KEYS.has(key));
56
76
  if (unknownKeys.length === 0)
@@ -84,6 +104,10 @@ export async function readConfig(projectRoot, _options = {}) {
84
104
  !Array.isArray(parsed.harnesses)) {
85
105
  throw configValidationError(fullPath, `"harnesses" must be an array`);
86
106
  }
107
+ if (Object.prototype.hasOwnProperty.call(parsed, "tdd") &&
108
+ !isRecord(parsed.tdd)) {
109
+ throw configValidationError(fullPath, `"tdd" must be an object when provided`);
110
+ }
87
111
  const rawHarnesses = Array.isArray(parsed.harnesses) ? parsed.harnesses : DEFAULT_HARNESSES;
88
112
  const normalizedHarnesses = [];
89
113
  for (const harness of rawHarnesses) {
@@ -103,17 +127,32 @@ export async function readConfig(projectRoot, _options = {}) {
103
127
  const flowVersion = typeof parsed.flowVersion === "string" && parsed.flowVersion.trim().length > 0
104
128
  ? parsed.flowVersion
105
129
  : FLOW_VERSION;
130
+ const parsedTdd = isRecord(parsed.tdd) ? parsed.tdd : {};
131
+ const rawCommitMode = parsedTdd.commitMode;
132
+ if (rawCommitMode !== undefined &&
133
+ (typeof rawCommitMode !== "string" || !TDD_COMMIT_MODE_SET.has(rawCommitMode))) {
134
+ throw configValidationError(fullPath, `"tdd.commitMode" must be one of: ${TDD_COMMIT_MODES.join(", ")}`);
135
+ }
136
+ const commitMode = typeof rawCommitMode === "string"
137
+ ? rawCommitMode
138
+ : DEFAULT_TDD_COMMIT_MODE;
106
139
  return {
107
140
  version,
108
141
  flowVersion,
109
- harnesses: normalizedHarnesses
142
+ harnesses: normalizedHarnesses,
143
+ tdd: {
144
+ commitMode
145
+ }
110
146
  };
111
147
  }
112
148
  export async function writeConfig(projectRoot, config, _options = {}) {
113
149
  const serialisable = {
114
150
  version: config.version,
115
151
  flowVersion: config.flowVersion,
116
- harnesses: config.harnesses
152
+ harnesses: config.harnesses,
153
+ tdd: {
154
+ commitMode: resolveTddCommitMode(config)
155
+ }
117
156
  };
118
157
  await writeFileSafe(configPath(projectRoot), stringify(serialisable));
119
158
  }
@@ -156,6 +156,7 @@ export function sliceBuilderProtocol() {
156
156
  "### Invariants",
157
157
  "- Produce failing RED evidence (or cite the delegated RED artifact) **before** production edits.",
158
158
  "- Stay inside the slice contract: `claimedPaths`, acceptance mapping, and forbidden-change lists from the parent.",
159
+ "- When `tdd.commitMode=managed-per-slice`, do **not** hand-edit git state for slice files (no manual `git add/commit` on claimed paths). Let `.cclaw/hooks/slice-commit.mjs` own per-slice commits.",
159
160
  "- After GREEN, refactor inline **or** record deferred refactor via the same `--refactor-outcome` mechanics the controller specifies.",
160
161
  "- Own the prose slice summary at `<artifacts-dir>/tdd-slices/S-<id>.md` yourself.",
161
162
  "",
@@ -2,6 +2,7 @@ export declare function startFlowScript(): string;
2
2
  export declare function cancelRunScript(): string;
3
3
  export declare function stageCompleteScript(): string;
4
4
  export declare function delegationRecordScript(): string;
5
+ export declare function sliceCommitScript(): string;
5
6
  export declare function runHookCmdScript(): string;
6
7
  export { claudeHooksJsonWithObservation as claudeHooksJson } from "./observe.js";
7
8
  export { cursorHooksJsonWithObservation as cursorHooksJson } from "./observe.js";
@@ -200,6 +200,7 @@ export function stageCompleteScript() {
200
200
  export function delegationRecordScript() {
201
201
  return `#!/usr/bin/env node
202
202
  import { createHash } from "node:crypto";
203
+ import { spawn } from "node:child_process";
203
204
  import fs from "node:fs/promises";
204
205
  import path from "node:path";
205
206
  import process from "node:process";
@@ -1189,6 +1190,101 @@ async function runRepair(args, json) {
1189
1190
  }
1190
1191
  }
1191
1192
 
1193
+ async function runSliceCommitIfNeeded(root, row, runId) {
1194
+ if (
1195
+ row.stage !== "tdd" ||
1196
+ row.agent !== "slice-builder" ||
1197
+ row.status !== "completed" ||
1198
+ row.phase !== "doc"
1199
+ ) {
1200
+ return { ok: true, skipped: true };
1201
+ }
1202
+ const sliceId = typeof row.sliceId === "string" ? row.sliceId.trim() : "";
1203
+ const spanId = typeof row.spanId === "string" ? row.spanId.trim() : "";
1204
+ if (sliceId.length === 0 || spanId.length === 0) {
1205
+ return { ok: true, skipped: true };
1206
+ }
1207
+ const helperPath = path.join(root, RUNTIME_ROOT, "hooks", "slice-commit.mjs");
1208
+ if (!(await exists(helperPath))) {
1209
+ return { ok: true, skipped: true };
1210
+ }
1211
+ const helperArgs = [
1212
+ helperPath,
1213
+ "--json",
1214
+ "--quiet",
1215
+ "--slice=" + sliceId,
1216
+ "--span-id=" + spanId,
1217
+ "--run-id=" + runId
1218
+ ];
1219
+ if (typeof row.taskId === "string" && row.taskId.trim().length > 0) {
1220
+ helperArgs.push("--task-id=" + row.taskId.trim());
1221
+ }
1222
+ if (Array.isArray(row.claimedPaths) && row.claimedPaths.length > 0) {
1223
+ helperArgs.push("--claimed-paths=" + row.claimedPaths.join(","));
1224
+ }
1225
+ if (Array.isArray(row.evidenceRefs) && row.evidenceRefs.length > 0) {
1226
+ const title = String(row.evidenceRefs[0] || "").trim();
1227
+ if (title.length > 0) {
1228
+ helperArgs.push("--title=" + title.slice(0, 120));
1229
+ }
1230
+ }
1231
+
1232
+ return await new Promise((resolve) => {
1233
+ const child = spawn(process.execPath, helperArgs, {
1234
+ cwd: root,
1235
+ env: process.env,
1236
+ stdio: ["ignore", "pipe", "pipe"]
1237
+ });
1238
+ let out = "";
1239
+ let err = "";
1240
+ child.stdout.on("data", (chunk) => {
1241
+ out += String(chunk ?? "");
1242
+ });
1243
+ child.stderr.on("data", (chunk) => {
1244
+ err += String(chunk ?? "");
1245
+ });
1246
+ child.on("error", (error) => {
1247
+ resolve({
1248
+ ok: false,
1249
+ errorCode: "slice_commit_failed",
1250
+ details: {
1251
+ message: error instanceof Error ? error.message : String(error)
1252
+ }
1253
+ });
1254
+ });
1255
+ child.on("close", (code) => {
1256
+ let payload = null;
1257
+ const trimmed = out.trim();
1258
+ if (trimmed.length > 0) {
1259
+ try {
1260
+ payload = JSON.parse(trimmed);
1261
+ } catch {
1262
+ payload = null;
1263
+ }
1264
+ }
1265
+ if (code === 0) {
1266
+ resolve({ ok: true, payload });
1267
+ return;
1268
+ }
1269
+ const payloadCode =
1270
+ payload && typeof payload === "object" && typeof payload.errorCode === "string"
1271
+ ? payload.errorCode
1272
+ : "slice_commit_failed";
1273
+ resolve({
1274
+ ok: false,
1275
+ errorCode: payloadCode,
1276
+ details:
1277
+ payload && typeof payload === "object"
1278
+ ? payload
1279
+ : {
1280
+ stderr: err.trim(),
1281
+ stdout: out.trim()
1282
+ }
1283
+ });
1284
+ });
1285
+ });
1286
+ }
1287
+
1192
1288
  async function main() {
1193
1289
  const args = parseArgs(process.argv.slice(2));
1194
1290
  const json = args.json !== undefined;
@@ -1573,6 +1669,23 @@ async function main() {
1573
1669
  }
1574
1670
  }
1575
1671
 
1672
+ const sliceCommitResult = await runSliceCommitIfNeeded(root, clean, runId);
1673
+ if (!sliceCommitResult.ok) {
1674
+ emitErrorJson(
1675
+ sliceCommitResult.errorCode || "slice_commit_failed",
1676
+ sliceCommitResult.details || {},
1677
+ json
1678
+ );
1679
+ return;
1680
+ }
1681
+ if (
1682
+ sliceCommitResult.payload &&
1683
+ typeof sliceCommitResult.payload === "object" &&
1684
+ typeof sliceCommitResult.payload.commitSha === "string"
1685
+ ) {
1686
+ event.sliceCommitSha = sliceCommitResult.payload.commitSha;
1687
+ }
1688
+
1576
1689
  await persistEntry(root, runId, clean, event);
1577
1690
 
1578
1691
  process.stdout.write(JSON.stringify({ ok: true, event }, null, 2) + "\\n");
@@ -1581,6 +1694,9 @@ async function main() {
1581
1694
  void main();
1582
1695
  `;
1583
1696
  }
1697
+ export function sliceCommitScript() {
1698
+ return internalHelperScript("slice-commit", "slice-commit", "Usage: node " + RUNTIME_ROOT + "/hooks/slice-commit.mjs --slice=<S-N> --span-id=<span-id> [--task-id=<T-id>] [--title=<text>] [--run-id=<run-id>] [--claimed-paths=<path1,path2,...>] [--claimed-path=<path> ...] [--json] [--quiet]");
1699
+ }
1584
1700
  export function runHookCmdScript() {
1585
1701
  return `: << 'CMDBLOCK'
1586
1702
  @echo off
@@ -52,7 +52,7 @@ export const TDD = {
52
52
  "Controller never writes production code or per-slice prose — the delegated worker does. Record routing decisions; cite `wave-status` before redundant slice questions.",
53
53
  "Discover existing tests and commands before RED; run a system-wide impact check (callbacks, state, interfaces, contracts) before GREEN.",
54
54
  "RED must fail for the right reason; capture logs. GREEN must run the full relevant suite, not a narrow subset.",
55
- "Before calling a slice done, run verification-before-completion (command + PASS/FAIL + commit SHA or no-VCS attestation).",
55
+ "Before calling a slice done, run verification-before-completion (command + PASS/FAIL + durable commit evidence: managed-per-slice git commits when `.git` is present, or explicit no-VCS attestation + hash).",
56
56
  "Integration-overseer must complete with PASS/PASS_WITH_GAPS when fan-out closes a wave unless the controller emits `cclaw_integration_overseer_skipped` for a documented heuristic skip.",
57
57
  "Investigation discipline + behavior anchor in this skill govern evidence: cite commands and paths, not pasted source dumps.",
58
58
  ],
@@ -72,7 +72,7 @@ export const TDD = {
72
72
  { id: "tdd_red_test_written", description: "Failing tests exist before implementation changes." },
73
73
  { id: "tdd_green_full_suite", description: "Full relevant suite passes in GREEN state." },
74
74
  { id: "tdd_refactor_completed", description: "Refactor pass completed with behavior preservation verified." },
75
- { id: "tdd_verified_before_complete", description: "Fresh verification evidence includes test command, explicit pass/fail status, and a durable ref: commit SHA when `.git` is present or explicit no-VCS attestation + hash when not." },
75
+ { id: "tdd_verified_before_complete", description: "Fresh verification evidence includes test command + explicit pass/fail status; when `tdd.commitMode=managed-per-slice` and `.git` exists, closed slices must be backed by real git commits, otherwise provide explicit no-VCS attestation + hash." },
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." },
@@ -89,7 +89,7 @@ export const TDD = {
89
89
  "REFACTOR coverage: separate `phase=refactor|refactor-deferred` rows or `refactorOutcome` folded into GREEN as the hook documents.",
90
90
  "`tdd-slices/S-<id>.md` kept current with the builder span; phase events remain the ground truth for lint auto-render blocks.",
91
91
  "`event: slice-completed` umbrella rows tie RED/GREEN timestamps to the builder once that writer runs on the repo.",
92
- "Fresh verification (command + PASS/FAIL + commit SHA or no-VCS reason + hash); Iron Law acknowledgement; acceptance mapping + traceability IDs.",
92
+ "Fresh verification (command + PASS/FAIL + managed-per-slice commit proof from git log when `.git` exists, or no-VCS reason + hash); Iron Law acknowledgement; acceptance mapping + traceability IDs.",
93
93
  ],
94
94
  inputs: ["approved plan slice", "spec acceptance criterion", "test harness configuration", "coding standards and constraints"],
95
95
  requiredContext: ["plan artifact", "spec artifact", "existing test patterns", "affected contracts and state boundaries"],
@@ -135,7 +135,7 @@ export const TDD = {
135
135
  { section: "REFACTOR Notes", required: true, validationRule: "What changed, why, behavior preservation confirmed." },
136
136
  { section: "Traceability", required: true, validationRule: "Plan task ID and spec criterion linked." },
137
137
  { section: "Iron Law Acknowledgement", required: true, validationRule: "Must include `Acknowledged: yes` and list exceptions (or `None`)." },
138
- { section: "Verification Ladder", required: true, validationRule: "Per-slice verification tier (static, command, behavioral, human) with evidence captured for the highest tier reached this turn. Must include command + PASS/FAIL + commit SHA when VCS is present, or explicit no-vcs reason plus content/artifact hash/config override." },
138
+ { section: "Verification Ladder", required: true, validationRule: "Per-slice verification tier (static, command, behavioral, human) with evidence captured for the highest tier reached this turn. Must include command + PASS/FAIL and durable commit evidence: managed-per-slice git commit proof when VCS is present, or explicit no-vcs reason plus content/artifact hash/config override." },
139
139
  { section: "TDD Blocker Taxonomy", required: false, validationRule: "When blocked, classify as NO_SOURCE_CONTEXT, NO_TEST_SURFACE, NO_IMPLEMENTABLE_SLICE, RED_NOT_EXPRESSIBLE, or NO_VCS_MODE; include blockedBecause, missingInputs, recommendedRoute, nextCommand, and resumeCriteria." }
140
140
  ]
141
141
  },
@@ -344,6 +344,61 @@ export declare class DispatchCapError extends Error {
344
344
  };
345
345
  });
346
346
  }
347
+ /**
348
+ * Return `true` when `path` is a repo-relative path owned by the cclaw
349
+ * managed runtime under `.cclaw/`. Used by `validateClaimedPathsNotProtected`
350
+ * during `appendDelegation` to reject `slice-builder` (or any worker)
351
+ * spans that try to claim ownership of cclaw-managed files. Does not
352
+ * normalise the input — callers pass the path exactly as the worker wrote
353
+ * it into `claimedPaths` so the error message points at the real string.
354
+ */
355
+ export declare function isManagedRuntimePath(path: string): boolean;
356
+ /**
357
+ * Thrown by `appendDelegation` when a scheduled span declares a
358
+ * `claimedPaths` entry that lives under the cclaw managed runtime
359
+ * (see `isManagedRuntimePath`). Workers must never edit those paths
360
+ * directly — they are owned by the managed sync surface. The error
361
+ * lists the offending paths so the operator can drop or rewrite them.
362
+ */
363
+ export declare class DispatchClaimedPathProtectedError extends Error {
364
+ readonly protectedPaths: string[];
365
+ readonly spanId: string;
366
+ constructor(params: {
367
+ protectedPaths: string[];
368
+ spanId: string;
369
+ });
370
+ }
371
+ /**
372
+ * Reject any worker span that declares `claimedPaths` entries owned by
373
+ * the cclaw managed runtime. Called from `appendDelegation` for
374
+ * `status === "scheduled"` rows alongside the overlap and fan-out
375
+ * checks. Throws `DispatchClaimedPathProtectedError` listing every
376
+ * offending path so the operator can fix the dispatch in one pass.
377
+ */
378
+ export declare function validateClaimedPathsNotProtected(stamped: DelegationEntry): void;
379
+ /**
380
+ * Thrown by `appendDelegation` when a new `scheduled` span would open a
381
+ * second TDD cycle for a slice that already has at least one closed span
382
+ * (a span with completed phase rows for `red`, `green`, at least one of
383
+ * `refactor`/`refactor-deferred`, and `doc`) in the same run. Re-running
384
+ * a slice under a fresh span is almost always controller drift —
385
+ * legitimate replay reuses the original spanId and is absorbed by the
386
+ * existing dedup. Motivated by the hox-session 7.0.5 finding where
387
+ * `S-36` had two scheduled spans (`span-w07-S-36-final` and `span-w07-S-36`)
388
+ * that the linter then misread as out-of-order phases.
389
+ */
390
+ export declare class SliceAlreadyClosedError extends Error {
391
+ readonly sliceId: string;
392
+ readonly runId: string;
393
+ readonly closedSpanId: string;
394
+ readonly newSpanId: string;
395
+ constructor(params: {
396
+ sliceId: string;
397
+ runId: string;
398
+ closedSpanId: string;
399
+ newSpanId: string;
400
+ });
401
+ }
347
402
  /**
348
403
  * Default cap on active `slice-builder` spans in a single TDD run. Override
349
404
  * via `CCLAW_MAX_PARALLEL_SLICE_BUILDERS=<int>` (validated `>=1`).