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.
@@ -630,6 +630,151 @@ export class DispatchCapError extends Error {
630
630
  this.pair = params.pair;
631
631
  }
632
632
  }
633
+ /**
634
+ * Patterns describing repo-relative paths owned by the cclaw managed
635
+ * runtime under `.cclaw/`. Workers MUST NOT claim these as
636
+ * `claimedPaths` because they are regenerated/rebound by `cclaw-cli sync`
637
+ * (and similar managed flows), and worker writes silently bypass the
638
+ * managed-resources manifest. Note: `.cclaw/artifacts/` is intentionally
639
+ * NOT protected — slice-builders legitimately write slice cards there.
640
+ *
641
+ * Motivated by the hox-session 7.0.5 finding: subagent S-36 hand-edited
642
+ * `.cclaw/hooks/delegation-record.mjs`, which had to be reverted because
643
+ * the next `cclaw-cli sync` would have stomped the change.
644
+ */
645
+ const MANAGED_RUNTIME_PATH_PATTERNS = [
646
+ /^\.cclaw\/(hooks|agents|skills|commands|templates|seeds|rules|state)\//u,
647
+ /^\.cclaw\/config\.yaml$/u,
648
+ /^\.cclaw\/managed-resources\.json$/u,
649
+ /^\.cclaw\/\.flow-state\.guard\.json$/u
650
+ ];
651
+ /**
652
+ * Return `true` when `path` is a repo-relative path owned by the cclaw
653
+ * managed runtime under `.cclaw/`. Used by `validateClaimedPathsNotProtected`
654
+ * during `appendDelegation` to reject `slice-builder` (or any worker)
655
+ * spans that try to claim ownership of cclaw-managed files. Does not
656
+ * normalise the input — callers pass the path exactly as the worker wrote
657
+ * it into `claimedPaths` so the error message points at the real string.
658
+ */
659
+ export function isManagedRuntimePath(path) {
660
+ if (typeof path !== "string" || path.length === 0)
661
+ return false;
662
+ return MANAGED_RUNTIME_PATH_PATTERNS.some((pattern) => pattern.test(path));
663
+ }
664
+ /**
665
+ * Thrown by `appendDelegation` when a scheduled span declares a
666
+ * `claimedPaths` entry that lives under the cclaw managed runtime
667
+ * (see `isManagedRuntimePath`). Workers must never edit those paths
668
+ * directly — they are owned by the managed sync surface. The error
669
+ * lists the offending paths so the operator can drop or rewrite them.
670
+ */
671
+ export class DispatchClaimedPathProtectedError extends Error {
672
+ protectedPaths;
673
+ spanId;
674
+ constructor(params) {
675
+ super(`dispatch_claimed_path_protected — span ${params.spanId} claims managed-runtime path(s) ${params.protectedPaths.join(", ")}; ` +
676
+ `paths under .cclaw/{hooks,agents,skills,commands,templates,seeds,rules,state}/, .cclaw/config.yaml, .cclaw/managed-resources.json, and .cclaw/.flow-state.guard.json are owned by cclaw-cli sync and must not appear in claimedPaths. ` +
677
+ `Drop them from claimedPaths or, if a managed-runtime change is genuinely required, ship it through a cclaw release rather than a worker span.`);
678
+ this.name = "DispatchClaimedPathProtectedError";
679
+ this.protectedPaths = params.protectedPaths;
680
+ this.spanId = params.spanId;
681
+ }
682
+ }
683
+ /**
684
+ * Reject any worker span that declares `claimedPaths` entries owned by
685
+ * the cclaw managed runtime. Called from `appendDelegation` for
686
+ * `status === "scheduled"` rows alongside the overlap and fan-out
687
+ * checks. Throws `DispatchClaimedPathProtectedError` listing every
688
+ * offending path so the operator can fix the dispatch in one pass.
689
+ */
690
+ export function validateClaimedPathsNotProtected(stamped) {
691
+ const claimed = Array.isArray(stamped.claimedPaths) ? stamped.claimedPaths : [];
692
+ if (claimed.length === 0)
693
+ return;
694
+ const offending = claimed.filter((p) => isManagedRuntimePath(p));
695
+ if (offending.length === 0)
696
+ return;
697
+ throw new DispatchClaimedPathProtectedError({
698
+ protectedPaths: offending,
699
+ spanId: stamped.spanId ?? "unknown"
700
+ });
701
+ }
702
+ /**
703
+ * Thrown by `appendDelegation` when a new `scheduled` span would open a
704
+ * second TDD cycle for a slice that already has at least one closed span
705
+ * (a span with completed phase rows for `red`, `green`, at least one of
706
+ * `refactor`/`refactor-deferred`, and `doc`) in the same run. Re-running
707
+ * a slice under a fresh span is almost always controller drift —
708
+ * legitimate replay reuses the original spanId and is absorbed by the
709
+ * existing dedup. Motivated by the hox-session 7.0.5 finding where
710
+ * `S-36` had two scheduled spans (`span-w07-S-36-final` and `span-w07-S-36`)
711
+ * that the linter then misread as out-of-order phases.
712
+ */
713
+ export class SliceAlreadyClosedError extends Error {
714
+ sliceId;
715
+ runId;
716
+ closedSpanId;
717
+ newSpanId;
718
+ constructor(params) {
719
+ super(`slice ${params.sliceId} already has a closed span (${params.closedSpanId}); refusing to schedule new span ${params.newSpanId} in run ${params.runId}`);
720
+ this.name = "SliceAlreadyClosedError";
721
+ this.sliceId = params.sliceId;
722
+ this.runId = params.runId;
723
+ this.closedSpanId = params.closedSpanId;
724
+ this.newSpanId = params.newSpanId;
725
+ }
726
+ }
727
+ /**
728
+ * Detect closed spans for `(sliceId, runId)`. A span is considered
729
+ * closed when it has completed phase rows for `red`, `green`, REFACTOR
730
+ * coverage (either `phase=refactor`, `phase=refactor-deferred`, or
731
+ * `phase=green` carrying `refactorOutcome`), AND `doc`. Returns the set of
732
+ * closed spanIds; callers use this to reject new scheduled spans on
733
+ * already-closed slices.
734
+ */
735
+ function closedSliceSpans(prior, sliceId, runId) {
736
+ const closed = new Set();
737
+ if (typeof sliceId !== "string" || sliceId.length === 0)
738
+ return closed;
739
+ const matches = prior.filter((entry) => entry.sliceId === sliceId &&
740
+ entry.runId === runId &&
741
+ typeof entry.spanId === "string" &&
742
+ entry.spanId.length > 0);
743
+ const bySpan = new Map();
744
+ for (const entry of matches) {
745
+ const spanId = entry.spanId;
746
+ const existing = bySpan.get(spanId) ?? [];
747
+ existing.push(entry);
748
+ bySpan.set(spanId, existing);
749
+ }
750
+ for (const [spanId, entries] of bySpan.entries()) {
751
+ const phases = new Set(entries
752
+ .filter((e) => e.status === "completed" && typeof e.phase === "string")
753
+ .map((e) => e.phase));
754
+ const hasRed = phases.has("red");
755
+ const hasGreen = phases.has("green");
756
+ const hasRefactorPhase = phases.has("refactor") || phases.has("refactor-deferred");
757
+ const greens = entries.filter((e) => e.status === "completed" && e.phase === "green");
758
+ const greenWithOutcome = greens.find((e) => e.refactorOutcome &&
759
+ (e.refactorOutcome.mode === "inline" || e.refactorOutcome.mode === "deferred"));
760
+ let hasRefactorFromGreen = false;
761
+ if (greenWithOutcome?.refactorOutcome?.mode === "deferred") {
762
+ hasRefactorFromGreen = !!((greenWithOutcome.refactorOutcome.rationale &&
763
+ greenWithOutcome.refactorOutcome.rationale.trim().length > 0) ||
764
+ (Array.isArray(greenWithOutcome.evidenceRefs) &&
765
+ greenWithOutcome.evidenceRefs.some((ref) => typeof ref === "string" && ref.trim().length > 0)));
766
+ }
767
+ else if (greenWithOutcome?.refactorOutcome?.mode === "inline") {
768
+ hasRefactorFromGreen = true;
769
+ }
770
+ const hasRefactor = hasRefactorPhase || hasRefactorFromGreen;
771
+ const hasDoc = phases.has("doc");
772
+ if (hasRed && hasGreen && hasRefactor && hasDoc) {
773
+ closed.add(spanId);
774
+ }
775
+ }
776
+ return closed;
777
+ }
633
778
  /**
634
779
  * Default cap on active `slice-builder` spans in a single TDD run. Override
635
780
  * via `CCLAW_MAX_PARALLEL_SLICE_BUILDERS=<int>` (validated `>=1`).
@@ -1037,7 +1182,23 @@ export async function appendDelegation(projectRoot, entry) {
1037
1182
  return;
1038
1183
  }
1039
1184
  validateMonotonicTimestamps(stamped, prior.entries);
1185
+ if (stamped.status === "scheduled" &&
1186
+ typeof stamped.sliceId === "string" &&
1187
+ stamped.sliceId.length > 0 &&
1188
+ stamped.phase === undefined) {
1189
+ const closed = closedSliceSpans(prior.entries, stamped.sliceId, activeRunId);
1190
+ if (closed.size > 0 && !(stamped.spanId && closed.has(stamped.spanId))) {
1191
+ const closedSpanId = closed.values().next().value;
1192
+ throw new SliceAlreadyClosedError({
1193
+ sliceId: stamped.sliceId,
1194
+ runId: activeRunId,
1195
+ closedSpanId,
1196
+ newSpanId: stamped.spanId ?? "unknown"
1197
+ });
1198
+ }
1199
+ }
1040
1200
  if (stamped.status === "scheduled") {
1201
+ validateClaimedPathsNotProtected(stamped);
1041
1202
  const sameRunPrior = prior.entries.filter((entry) => entry.runId === activeRunId);
1042
1203
  const activeForRun = computeActiveSubagents(sameRunPrior);
1043
1204
  const overlap = validateFileOverlap(stamped, activeForRun);
package/dist/install.js CHANGED
@@ -13,7 +13,7 @@ import { cancelCommandContract, cancelCommandSkillMarkdown } from "./content/can
13
13
  import { subagentDrivenDevSkill, parallelAgentsSkill } from "./content/subagents.js";
14
14
  import { sessionHooksSkillMarkdown } from "./content/session-hooks.js";
15
15
  import { ironLawsSkillMarkdown } from "./content/iron-laws.js";
16
- import { stageCompleteScript, startFlowScript, cancelRunScript, runHookCmdScript, delegationRecordScript, opencodePluginJs, claudeHooksJson, codexHooksJson, cursorHooksJson } from "./content/hooks.js";
16
+ import { stageCompleteScript, startFlowScript, cancelRunScript, runHookCmdScript, delegationRecordScript, sliceCommitScript, opencodePluginJs, claudeHooksJson, codexHooksJson, cursorHooksJson } from "./content/hooks.js";
17
17
  import { nodeHookRuntimeScript } from "./content/node-hooks.js";
18
18
  import { META_SKILL_NAME, usingCclawSkillMarkdown } from "./content/meta-skill.js";
19
19
  import { ARTIFACT_TEMPLATES, CURSOR_GUIDELINES_RULE_MDC, CURSOR_WORKFLOW_RULE_MDC, RULEBOOK_MARKDOWN, buildRulesJson } from "./content/templates.js";
@@ -692,6 +692,7 @@ async function writeHooks(projectRoot, config) {
692
692
  await writeFileSafe(path.join(hooksDir, "run-hook.mjs"), bundledHookRuntime ?? nodeHookRuntimeScript(hookRuntimeOptions));
693
693
  await writeFileSafe(path.join(hooksDir, "run-hook.cmd"), runHookCmdScript());
694
694
  await writeFileSafe(path.join(hooksDir, "delegation-record.mjs"), delegationRecordScript());
695
+ await writeFileSafe(path.join(hooksDir, "slice-commit.mjs"), sliceCommitScript());
695
696
  const opencodePluginSource = opencodePluginJs();
696
697
  await writeFileSafe(path.join(hooksDir, "opencode-plugin.mjs"), opencodePluginSource);
697
698
  try {
@@ -701,6 +702,7 @@ async function writeHooks(projectRoot, config) {
701
702
  "run-hook.mjs",
702
703
  "run-hook.cmd",
703
704
  "delegation-record.mjs",
705
+ "slice-commit.mjs",
704
706
  "opencode-plugin.mjs",
705
707
  "cancel-run.mjs"
706
708
  ]) {
@@ -14,10 +14,11 @@ import { parseAdvanceStageArgs, parseCancelRunArgs, parseHookArgs, parseRewindAr
14
14
  import { parseFlowStateRepairArgs, runFlowStateRepair } from "./flow-state-repair.js";
15
15
  import { parseWaiverGrantArgs, runWaiverGrant } from "./waiver-grant.js";
16
16
  import { FlowStateGuardMismatchError, verifyFlowStateGuard } from "../run-persistence.js";
17
- import { DelegationTimestampError, DispatchCapError, DispatchDuplicateError, DispatchOverlapError } from "../delegation.js";
17
+ import { DelegationTimestampError, DispatchCapError, DispatchClaimedPathProtectedError, DispatchDuplicateError, DispatchOverlapError, SliceAlreadyClosedError } from "../delegation.js";
18
18
  import { parsePlanSplitWavesArgs, runPlanSplitWaves } from "./plan-split-waves.js";
19
19
  import { runWaveStatusCommand } from "./wave-status.js";
20
20
  import { runCohesionContractCommand } from "./cohesion-contract-stub.js";
21
+ import { runSliceCommitCommand } from "./slice-commit.js";
21
22
  /**
22
23
  * Subcommands that mutate or consult flow-state.json via the CLI runtime.
23
24
  * They all require the sha256 sidecar to match before continuing so a
@@ -35,7 +36,7 @@ const GUARD_ENFORCED_SUBCOMMANDS = new Set([
35
36
  export async function runInternalCommand(projectRoot, argv, io) {
36
37
  const [subcommand, ...tokens] = argv;
37
38
  if (!subcommand) {
38
- io.stderr.write("cclaw internal requires a subcommand: advance-stage | start-flow | cancel-run | rewind | verify-flow-state-diff | verify-current-state | envelope-validate | tdd-red-evidence | tdd-loop-status | early-loop-status | compound-readiness | runtime-integrity | hook | flow-state-repair | waiver-grant | plan-split-waves | wave-status | cohesion-contract\n");
39
+ io.stderr.write("cclaw internal requires a subcommand: advance-stage | start-flow | cancel-run | rewind | verify-flow-state-diff | verify-current-state | envelope-validate | tdd-red-evidence | tdd-loop-status | early-loop-status | compound-readiness | runtime-integrity | hook | slice-commit | flow-state-repair | waiver-grant | plan-split-waves | wave-status | cohesion-contract\n");
39
40
  return 1;
40
41
  }
41
42
  try {
@@ -81,6 +82,9 @@ export async function runInternalCommand(projectRoot, argv, io) {
81
82
  if (subcommand === "hook") {
82
83
  return await runHookCommand(projectRoot, parseHookArgs(tokens), io);
83
84
  }
85
+ if (subcommand === "slice-commit") {
86
+ return await runSliceCommitCommand(projectRoot, tokens, io);
87
+ }
84
88
  if (subcommand === "flow-state-repair") {
85
89
  return await runFlowStateRepair(projectRoot, parseFlowStateRepairArgs(tokens), io);
86
90
  }
@@ -96,7 +100,7 @@ export async function runInternalCommand(projectRoot, argv, io) {
96
100
  if (subcommand === "cohesion-contract") {
97
101
  return await runCohesionContractCommand(projectRoot, tokens, io);
98
102
  }
99
- io.stderr.write(`Unknown internal subcommand: ${subcommand}. Expected advance-stage | start-flow | cancel-run | rewind | verify-flow-state-diff | verify-current-state | envelope-validate | tdd-red-evidence | tdd-loop-status | early-loop-status | compound-readiness | runtime-integrity | hook | flow-state-repair | waiver-grant | plan-split-waves | wave-status | cohesion-contract\n`);
103
+ io.stderr.write(`Unknown internal subcommand: ${subcommand}. Expected advance-stage | start-flow | cancel-run | rewind | verify-flow-state-diff | verify-current-state | envelope-validate | tdd-red-evidence | tdd-loop-status | early-loop-status | compound-readiness | runtime-integrity | hook | slice-commit | flow-state-repair | waiver-grant | plan-split-waves | wave-status | cohesion-contract\n`);
100
104
  return 1;
101
105
  }
102
106
  catch (err) {
@@ -116,6 +120,14 @@ export async function runInternalCommand(projectRoot, argv, io) {
116
120
  io.stderr.write(`error: dispatch_overlap — ${err.message}\n`);
117
121
  return 2;
118
122
  }
123
+ if (err instanceof DispatchClaimedPathProtectedError) {
124
+ io.stderr.write(`error: dispatch_claimed_path_protected — ${err.message}\n`);
125
+ return 2;
126
+ }
127
+ if (err instanceof SliceAlreadyClosedError) {
128
+ io.stderr.write(`error: slice_already_closed — ${err.message}\n`);
129
+ return 2;
130
+ }
119
131
  if (err instanceof DispatchCapError) {
120
132
  io.stderr.write(`error: dispatch_cap — ${err.message}\n`);
121
133
  return 2;
@@ -0,0 +1,7 @@
1
+ import type { Writable } from "node:stream";
2
+ interface InternalIo {
3
+ stdout: Writable;
4
+ stderr: Writable;
5
+ }
6
+ export declare function runSliceCommitCommand(projectRoot: string, tokens: string[], io: InternalIo): Promise<number>;
7
+ export {};
@@ -0,0 +1,296 @@
1
+ import { execFile } from "node:child_process";
2
+ import path from "node:path";
3
+ import { promisify } from "node:util";
4
+ import { readConfig, resolveTddCommitMode } from "../config.js";
5
+ import { readDelegationLedger } from "../delegation.js";
6
+ import { exists } from "../fs-utils.js";
7
+ const execFileAsync = promisify(execFile);
8
+ function parseCsv(raw) {
9
+ return raw
10
+ .split(",")
11
+ .map((value) => value.trim())
12
+ .filter((value) => value.length > 0);
13
+ }
14
+ function normalizePathLike(value) {
15
+ const slashes = value.replace(/\\/gu, "/");
16
+ const withoutDot = slashes.replace(/^\.\//u, "");
17
+ return withoutDot.replace(/\/+$/u, "");
18
+ }
19
+ function parseSliceCommitArgs(tokens) {
20
+ let sliceId = "";
21
+ let spanId = "";
22
+ let taskId;
23
+ let title;
24
+ let runId;
25
+ const claimedPaths = [];
26
+ let json = false;
27
+ let quiet = false;
28
+ for (let i = 0; i < tokens.length; i += 1) {
29
+ const token = tokens[i];
30
+ const next = tokens[i + 1];
31
+ const valueFrom = (flag) => {
32
+ if (token.startsWith(`${flag}=`))
33
+ return token.slice(flag.length + 1);
34
+ if (token === flag && next && !next.startsWith("--")) {
35
+ i += 1;
36
+ return next;
37
+ }
38
+ throw new Error(`${flag} requires a value.`);
39
+ };
40
+ if (token === "--json") {
41
+ json = true;
42
+ continue;
43
+ }
44
+ if (token === "--quiet") {
45
+ quiet = true;
46
+ continue;
47
+ }
48
+ if (token.startsWith("--slice=") || token === "--slice") {
49
+ sliceId = valueFrom("--slice").trim();
50
+ continue;
51
+ }
52
+ if (token.startsWith("--span-id=") || token === "--span-id") {
53
+ spanId = valueFrom("--span-id").trim();
54
+ continue;
55
+ }
56
+ if (token.startsWith("--task-id=") || token === "--task-id") {
57
+ taskId = valueFrom("--task-id").trim();
58
+ continue;
59
+ }
60
+ if (token.startsWith("--title=") || token === "--title") {
61
+ title = valueFrom("--title").trim();
62
+ continue;
63
+ }
64
+ if (token.startsWith("--run-id=") || token === "--run-id") {
65
+ runId = valueFrom("--run-id").trim();
66
+ continue;
67
+ }
68
+ if (token.startsWith("--claimed-paths=") || token === "--claimed-paths") {
69
+ claimedPaths.push(...parseCsv(valueFrom("--claimed-paths")));
70
+ continue;
71
+ }
72
+ if (token.startsWith("--claimed-path=") || token === "--claimed-path") {
73
+ const one = valueFrom("--claimed-path").trim();
74
+ if (one.length > 0)
75
+ claimedPaths.push(one);
76
+ continue;
77
+ }
78
+ throw new Error(`Unknown flag for internal slice-commit: ${token}`);
79
+ }
80
+ if (sliceId.length === 0) {
81
+ throw new Error("internal slice-commit requires --slice=<S-N>.");
82
+ }
83
+ if (spanId.length === 0) {
84
+ throw new Error("internal slice-commit requires --span-id=<span-id>.");
85
+ }
86
+ return {
87
+ sliceId,
88
+ spanId,
89
+ taskId,
90
+ title,
91
+ runId,
92
+ claimedPaths,
93
+ json,
94
+ quiet
95
+ };
96
+ }
97
+ function output(io, args, payload, channel = "stdout") {
98
+ if (args.quiet && channel === "stdout")
99
+ return;
100
+ const writer = channel === "stdout" ? io.stdout : io.stderr;
101
+ if (args.json) {
102
+ writer.write(`${JSON.stringify(payload)}\n`);
103
+ return;
104
+ }
105
+ const message = typeof payload.message === "string"
106
+ ? payload.message
107
+ : JSON.stringify(payload);
108
+ writer.write(`${message}\n`);
109
+ }
110
+ function parsePorcelainPaths(raw) {
111
+ const out = [];
112
+ for (const line of raw.split(/\r?\n/gu)) {
113
+ const trimmed = line.trimEnd();
114
+ if (trimmed.length < 4)
115
+ continue;
116
+ // porcelain line shape: XY<space><path>
117
+ const status = trimmed.slice(0, 2);
118
+ if (status === "??") {
119
+ const p = normalizePathLike(trimmed.slice(3).trim());
120
+ if (p.length > 0)
121
+ out.push(p);
122
+ continue;
123
+ }
124
+ let p = trimmed.slice(3).trim();
125
+ const renameIdx = p.indexOf(" -> ");
126
+ if (renameIdx >= 0) {
127
+ p = p.slice(renameIdx + 4);
128
+ }
129
+ p = normalizePathLike(p.replace(/^"/u, "").replace(/"$/u, ""));
130
+ if (p.length > 0)
131
+ out.push(p);
132
+ }
133
+ return [...new Set(out)];
134
+ }
135
+ function matchesClaimedPath(changedPath, claimedPaths) {
136
+ const changed = normalizePathLike(changedPath);
137
+ return claimedPaths.some((rawClaimed) => {
138
+ const claimed = normalizePathLike(rawClaimed);
139
+ if (claimed.length === 0)
140
+ return false;
141
+ if (changed === claimed)
142
+ return true;
143
+ return changed.startsWith(`${claimed}/`);
144
+ });
145
+ }
146
+ async function resolveClaimedPathsFromLedger(projectRoot, args) {
147
+ const ledger = await readDelegationLedger(projectRoot);
148
+ const matches = ledger.entries.filter((entry) => entry.stage === "tdd" &&
149
+ entry.agent === "slice-builder" &&
150
+ entry.sliceId === args.sliceId &&
151
+ entry.spanId === args.spanId &&
152
+ (!args.runId || entry.runId === args.runId) &&
153
+ Array.isArray(entry.claimedPaths) &&
154
+ entry.claimedPaths.length > 0);
155
+ matches.sort((a, b) => {
156
+ const aTs = a.ts ?? a.startTs ?? "";
157
+ const bTs = b.ts ?? b.startTs ?? "";
158
+ return aTs < bTs ? 1 : aTs > bTs ? -1 : 0;
159
+ });
160
+ const fromLedger = matches[0]?.claimedPaths ?? [];
161
+ return [...new Set(fromLedger.map((p) => normalizePathLike(p)).filter((p) => p.length > 0))];
162
+ }
163
+ export async function runSliceCommitCommand(projectRoot, tokens, io) {
164
+ let args;
165
+ try {
166
+ args = parseSliceCommitArgs(tokens);
167
+ }
168
+ catch (err) {
169
+ io.stderr.write(`cclaw internal slice-commit: ${err instanceof Error ? err.message : String(err)}\n`);
170
+ return 1;
171
+ }
172
+ const config = await readConfig(projectRoot).catch(() => null);
173
+ const commitMode = resolveTddCommitMode(config);
174
+ if (commitMode !== "managed-per-slice") {
175
+ output(io, args, {
176
+ ok: true,
177
+ skipped: true,
178
+ reason: "commit-mode-not-managed",
179
+ commitMode,
180
+ message: `slice-commit skipped: commitMode=${commitMode}`
181
+ });
182
+ return 0;
183
+ }
184
+ const gitPresent = await exists(path.join(projectRoot, ".git"));
185
+ if (!gitPresent) {
186
+ output(io, args, {
187
+ ok: true,
188
+ skipped: true,
189
+ reason: "no-git",
190
+ message: "slice-commit skipped: .git is missing"
191
+ });
192
+ return 0;
193
+ }
194
+ const claimedPaths = args.claimedPaths.length > 0
195
+ ? [...new Set(args.claimedPaths.map((p) => normalizePathLike(p)).filter((p) => p.length > 0))]
196
+ : await resolveClaimedPathsFromLedger(projectRoot, args);
197
+ if (claimedPaths.length === 0) {
198
+ output(io, args, {
199
+ ok: false,
200
+ errorCode: "slice_commit_claimed_paths_missing",
201
+ details: {
202
+ sliceId: args.sliceId,
203
+ spanId: args.spanId
204
+ },
205
+ message: `slice_commit_claimed_paths_missing: no claimed paths for ${args.sliceId}/${args.spanId}`
206
+ }, "stderr");
207
+ return 2;
208
+ }
209
+ const { stdout: statusRaw } = await execFileAsync("git", ["status", "--porcelain", "-uall"], {
210
+ cwd: projectRoot
211
+ });
212
+ const changedPaths = parsePorcelainPaths(statusRaw);
213
+ if (changedPaths.length === 0) {
214
+ output(io, args, {
215
+ ok: true,
216
+ skipped: true,
217
+ reason: "no-changes",
218
+ message: `slice-commit skipped: no working-tree changes for ${args.sliceId}`
219
+ });
220
+ return 0;
221
+ }
222
+ const pathDrift = changedPaths.filter((p) => !matchesClaimedPath(p, claimedPaths));
223
+ if (pathDrift.length > 0) {
224
+ output(io, args, {
225
+ ok: false,
226
+ errorCode: "slice_commit_path_drift",
227
+ details: {
228
+ sliceId: args.sliceId,
229
+ spanId: args.spanId,
230
+ claimedPaths,
231
+ driftPaths: pathDrift
232
+ },
233
+ message: `slice_commit_path_drift: ${pathDrift.join(", ")}`
234
+ }, "stderr");
235
+ return 2;
236
+ }
237
+ const changedInClaim = changedPaths.filter((p) => matchesClaimedPath(p, claimedPaths));
238
+ if (changedInClaim.length === 0) {
239
+ output(io, args, {
240
+ ok: true,
241
+ skipped: true,
242
+ reason: "claimed-paths-unchanged",
243
+ message: `slice-commit skipped: no changes within claimed paths for ${args.sliceId}`
244
+ });
245
+ return 0;
246
+ }
247
+ try {
248
+ await execFileAsync("git", ["add", "--", ...claimedPaths], {
249
+ cwd: projectRoot
250
+ });
251
+ const taskPart = args.taskId && args.taskId.length > 0 ? args.taskId : "task";
252
+ const titlePart = args.title && args.title.length > 0 ? args.title : "slice update";
253
+ const header = `${args.sliceId}/${taskPart}: ${titlePart}`;
254
+ const body = [
255
+ `span-id: ${args.spanId}`,
256
+ `run-id: ${args.runId ?? "unknown"}`,
257
+ "phase-cycle: red->green->refactor->doc"
258
+ ].join("\n");
259
+ await execFileAsync("git", ["commit", "-m", header, "-m", body], {
260
+ cwd: projectRoot
261
+ });
262
+ }
263
+ catch (err) {
264
+ const message = err instanceof Error ? err.message : String(err);
265
+ if (/nothing to commit/iu.test(message)) {
266
+ output(io, args, {
267
+ ok: true,
268
+ skipped: true,
269
+ reason: "nothing-to-commit",
270
+ message: `slice-commit skipped: nothing to commit for ${args.sliceId}`
271
+ });
272
+ return 0;
273
+ }
274
+ output(io, args, {
275
+ ok: false,
276
+ errorCode: "slice_commit_failed",
277
+ details: { message },
278
+ message: `slice_commit_failed: ${message}`
279
+ }, "stderr");
280
+ return 1;
281
+ }
282
+ const { stdout: shaStdout } = await execFileAsync("git", ["rev-parse", "HEAD"], {
283
+ cwd: projectRoot
284
+ });
285
+ const commitSha = shaStdout.trim();
286
+ output(io, args, {
287
+ ok: true,
288
+ commitSha,
289
+ sliceId: args.sliceId,
290
+ spanId: args.spanId,
291
+ claimedPaths,
292
+ changedPaths: changedInClaim,
293
+ message: `slice commit created for ${args.sliceId}: ${commitSha}`
294
+ });
295
+ return 0;
296
+ }