cclaw-cli 7.2.0 → 7.4.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/config.d.ts CHANGED
@@ -1,6 +1,9 @@
1
- import type { CclawConfig, FlowTrack, HarnessId, LanguageRulePack, TddCommitMode } from "./types.js";
1
+ import type { CclawConfig, FlowTrack, HarnessId, LanguageRulePack, 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
+ export declare const TDD_ISOLATION_MODES: readonly ["worktree", "in-place", "auto"];
5
+ export declare const DEFAULT_TDD_ISOLATION_MODE: TddIsolationMode;
6
+ export declare const DEFAULT_TDD_WORKTREE_ROOT = ".cclaw/worktrees";
4
7
  export declare const DEFAULT_TDD_TEST_PATH_PATTERNS: readonly string[];
5
8
  export declare const DEFAULT_TDD_TEST_GLOBS: readonly string[];
6
9
  export declare const DEFAULT_TDD_PRODUCTION_PATH_PATTERNS: readonly string[];
@@ -19,6 +22,8 @@ export declare class InvalidConfigError extends Error {
19
22
  export declare function configPath(projectRoot: string): string;
20
23
  export declare function createDefaultConfig(harnesses?: HarnessId[], _defaultTrack?: FlowTrack): CclawConfig;
21
24
  export declare function resolveTddCommitMode(config: Pick<CclawConfig, "tdd"> | null | undefined): TddCommitMode;
25
+ export declare function resolveTddIsolationMode(config: Pick<CclawConfig, "tdd"> | null | undefined): TddIsolationMode;
26
+ export declare function resolveTddWorktreeRoot(config: Pick<CclawConfig, "tdd"> | null | undefined): string;
22
27
  export declare function detectLanguageRulePacks(_projectRoot: string): Promise<LanguageRulePack[]>;
23
28
  export declare function readConfig(projectRoot: string, _options?: ReadConfigOptions): Promise<CclawConfig>;
24
29
  export interface WriteConfigOptions {
package/dist/config.js CHANGED
@@ -16,6 +16,10 @@ export const TDD_COMMIT_MODES = [
16
16
  ];
17
17
  const TDD_COMMIT_MODE_SET = new Set(TDD_COMMIT_MODES);
18
18
  export const DEFAULT_TDD_COMMIT_MODE = "managed-per-slice";
19
+ export const TDD_ISOLATION_MODES = ["worktree", "in-place", "auto"];
20
+ const TDD_ISOLATION_MODE_SET = new Set(TDD_ISOLATION_MODES);
21
+ export const DEFAULT_TDD_ISOLATION_MODE = "worktree";
22
+ export const DEFAULT_TDD_WORKTREE_ROOT = `${RUNTIME_ROOT}/worktrees`;
19
23
  // Kept for runtime modules that use these defaults directly.
20
24
  export const DEFAULT_TDD_TEST_PATH_PATTERNS = [
21
25
  "**/*.test.*",
@@ -40,7 +44,9 @@ function configFixExample() {
40
44
  - claude
41
45
  - cursor
42
46
  tdd:
43
- commitMode: managed-per-slice`;
47
+ commitMode: managed-per-slice
48
+ isolationMode: worktree
49
+ worktreeRoot: .cclaw/worktrees`;
44
50
  }
45
51
  function configValidationError(configFilePath, reason) {
46
52
  return new InvalidConfigError(`Invalid cclaw config at ${configFilePath}: ${reason}\n` +
@@ -60,7 +66,9 @@ export function createDefaultConfig(harnesses = DEFAULT_HARNESSES, _defaultTrack
60
66
  flowVersion: FLOW_VERSION,
61
67
  harnesses: [...new Set(harnesses)],
62
68
  tdd: {
63
- commitMode: DEFAULT_TDD_COMMIT_MODE
69
+ commitMode: DEFAULT_TDD_COMMIT_MODE,
70
+ isolationMode: DEFAULT_TDD_ISOLATION_MODE,
71
+ worktreeRoot: DEFAULT_TDD_WORKTREE_ROOT
64
72
  }
65
73
  };
66
74
  }
@@ -71,6 +79,20 @@ export function resolveTddCommitMode(config) {
71
79
  }
72
80
  return DEFAULT_TDD_COMMIT_MODE;
73
81
  }
82
+ export function resolveTddIsolationMode(config) {
83
+ const raw = config?.tdd?.isolationMode;
84
+ if (typeof raw === "string" && TDD_ISOLATION_MODE_SET.has(raw)) {
85
+ return raw;
86
+ }
87
+ return DEFAULT_TDD_ISOLATION_MODE;
88
+ }
89
+ export function resolveTddWorktreeRoot(config) {
90
+ const raw = config?.tdd?.worktreeRoot;
91
+ if (typeof raw === "string" && raw.trim().length > 0) {
92
+ return raw.trim();
93
+ }
94
+ return DEFAULT_TDD_WORKTREE_ROOT;
95
+ }
74
96
  function assertOnlySupportedKeys(parsed, fullPath) {
75
97
  const unknownKeys = Object.keys(parsed).filter((key) => !ALLOWED_CONFIG_KEYS.has(key));
76
98
  if (unknownKeys.length === 0)
@@ -129,19 +151,37 @@ export async function readConfig(projectRoot, _options = {}) {
129
151
  : FLOW_VERSION;
130
152
  const parsedTdd = isRecord(parsed.tdd) ? parsed.tdd : {};
131
153
  const rawCommitMode = parsedTdd.commitMode;
154
+ const rawIsolationMode = parsedTdd.isolationMode;
155
+ const rawWorktreeRoot = parsedTdd.worktreeRoot;
132
156
  if (rawCommitMode !== undefined &&
133
157
  (typeof rawCommitMode !== "string" || !TDD_COMMIT_MODE_SET.has(rawCommitMode))) {
134
158
  throw configValidationError(fullPath, `"tdd.commitMode" must be one of: ${TDD_COMMIT_MODES.join(", ")}`);
135
159
  }
160
+ if (rawIsolationMode !== undefined &&
161
+ (typeof rawIsolationMode !== "string" || !TDD_ISOLATION_MODE_SET.has(rawIsolationMode))) {
162
+ throw configValidationError(fullPath, `"tdd.isolationMode" must be one of: ${TDD_ISOLATION_MODES.join(", ")}`);
163
+ }
164
+ if (rawWorktreeRoot !== undefined &&
165
+ (typeof rawWorktreeRoot !== "string" || rawWorktreeRoot.trim().length === 0)) {
166
+ throw configValidationError(fullPath, `"tdd.worktreeRoot" must be a non-empty string when provided`);
167
+ }
136
168
  const commitMode = typeof rawCommitMode === "string"
137
169
  ? rawCommitMode
138
170
  : DEFAULT_TDD_COMMIT_MODE;
171
+ const isolationMode = typeof rawIsolationMode === "string"
172
+ ? rawIsolationMode
173
+ : DEFAULT_TDD_ISOLATION_MODE;
174
+ const worktreeRoot = typeof rawWorktreeRoot === "string" && rawWorktreeRoot.trim().length > 0
175
+ ? rawWorktreeRoot.trim()
176
+ : DEFAULT_TDD_WORKTREE_ROOT;
139
177
  return {
140
178
  version,
141
179
  flowVersion,
142
180
  harnesses: normalizedHarnesses,
143
181
  tdd: {
144
- commitMode
182
+ commitMode,
183
+ isolationMode,
184
+ worktreeRoot
145
185
  }
146
186
  };
147
187
  }
@@ -151,7 +191,9 @@ export async function writeConfig(projectRoot, config, _options = {}) {
151
191
  flowVersion: config.flowVersion,
152
192
  harnesses: config.harnesses,
153
193
  tdd: {
154
- commitMode: resolveTddCommitMode(config)
194
+ commitMode: resolveTddCommitMode(config),
195
+ isolationMode: resolveTddIsolationMode(config),
196
+ worktreeRoot: resolveTddWorktreeRoot(config)
155
197
  }
156
198
  };
157
199
  await writeFileSafe(configPath(projectRoot), stringify(serialisable));
@@ -10,7 +10,7 @@ export declare const FLOW_VERSION = "1.0.0";
10
10
  export declare const SHIP_FINALIZATION_MODES: readonly ["FINALIZE_MERGE_LOCAL", "FINALIZE_OPEN_PR", "FINALIZE_KEEP_BRANCH", "FINALIZE_DISCARD_BRANCH", "FINALIZE_NO_VCS"];
11
11
  export type ShipFinalizationMode = (typeof SHIP_FINALIZATION_MODES)[number];
12
12
  export declare const DEFAULT_HARNESSES: HarnessId[];
13
- export declare const REQUIRED_DIRS: readonly [".cclaw", ".cclaw/commands", ".cclaw/skills", ".cclaw/templates", ".cclaw/templates/state-contracts", ".cclaw/artifacts", ".cclaw/wave-plans", ".cclaw/archive", ".cclaw/state", ".cclaw/rules", ".cclaw/agents", ".cclaw/hooks", ".cclaw/skills/review-prompts"];
13
+ export declare const REQUIRED_DIRS: readonly [".cclaw", ".cclaw/commands", ".cclaw/skills", ".cclaw/templates", ".cclaw/templates/state-contracts", ".cclaw/artifacts", ".cclaw/wave-plans", ".cclaw/archive", ".cclaw/worktrees", ".cclaw/state", ".cclaw/rules", ".cclaw/agents", ".cclaw/hooks", ".cclaw/skills/review-prompts"];
14
14
  export declare const REQUIRED_GITIGNORE_PATTERNS: readonly ["# cclaw generated artifacts", ".cclaw/", ".claude/commands/cc-*.md", ".claude/commands/cc.md", ".cursor/commands/cc-*.md", ".cursor/commands/cc.md", ".opencode/commands/cc-*.md", ".opencode/commands/cc.md", ".agents/skills/cc/SKILL.md", ".agents/skills/cc-*/SKILL.md", ".claude/hooks/hooks.json", ".cursor/hooks.json", ".codex/hooks.json", ".opencode/plugins/cclaw-plugin.mjs", ".cursor/rules/cclaw-workflow.mdc"];
15
15
  /**
16
16
  * Canonical stage -> skill folder mapping.
package/dist/constants.js CHANGED
@@ -61,6 +61,7 @@ export const REQUIRED_DIRS = [
61
61
  `${RUNTIME_ROOT}/artifacts`,
62
62
  `${RUNTIME_ROOT}/wave-plans`,
63
63
  `${RUNTIME_ROOT}/archive`,
64
+ `${RUNTIME_ROOT}/worktrees`,
64
65
  `${RUNTIME_ROOT}/state`,
65
66
  `${RUNTIME_ROOT}/rules`,
66
67
  `${RUNTIME_ROOT}/agents`,
@@ -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.isolationMode=worktree|auto`, run the slice inside the assigned worktree path (never in the shared repo root) so filesystem isolation enforces the claimed-path fence.",
159
160
  "- 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.",
160
161
  "- After GREEN, refactor inline **or** record deferred refactor via the same `--refactor-outcome` mechanics the controller specifies.",
161
162
  "- Own the prose slice summary at `<artifacts-dir>/tdd-slices/S-<id>.md` yourself.",
@@ -164,6 +165,11 @@ export function sliceBuilderProtocol() {
164
165
  "- Honor every `delegation-record`/`delegation-record.mjs` row shape the controller requests so artifact linters keep passing.",
165
166
  "- The umbrella `slice-completed` row ties RED/GREEN/REFACTOR/DOC timestamps to your builder span.",
166
167
  "",
168
+ "### Streaming output contract",
169
+ "- 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
+ "- 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.",
171
+ "- If streaming is unavailable in your harness/runtime, keep writing canonical `delegation-record` rows; controller-side live mode will fall back to file-based events.",
172
+ "",
167
173
  "**Role boundary:** do not widen scope, do not self-approve ship-level review, and do not recurse into other agents unless the parent explicitly directs it."
168
174
  ].join("\n");
169
175
  }
@@ -614,6 +614,10 @@ function buildRow(args, status, runId, now, options) {
614
614
  riskTierRaw === "low" || riskTierRaw === "medium" || riskTierRaw === "high"
615
615
  ? riskTierRaw
616
616
  : undefined;
617
+ const worktreePath =
618
+ typeof args["worktree-path"] === "string" && args["worktree-path"].trim().length > 0
619
+ ? args["worktree-path"].trim()
620
+ : undefined;
617
621
  return {
618
622
  stage: args.stage,
619
623
  agent: args.agent,
@@ -637,6 +641,7 @@ function buildRow(args, status, runId, now, options) {
637
641
  schemaVersion: LEDGER_SCHEMA_VERSION,
638
642
  allowParallel: args["allow-parallel"] === true ? true : undefined,
639
643
  claimedPaths: claimedPaths.length > 0 ? claimedPaths : undefined,
644
+ worktreePath,
640
645
  sliceId,
641
646
  phase,
642
647
  refactorOutcome,
@@ -1251,6 +1256,24 @@ async function runSliceCommitIfNeeded(root, row, runId) {
1251
1256
  "--span-id=" + spanId,
1252
1257
  "--run-id=" + runId
1253
1258
  ];
1259
+ let explicitWorktreePath =
1260
+ typeof row.worktreePath === "string" && row.worktreePath.trim().length > 0
1261
+ ? row.worktreePath.trim()
1262
+ : "";
1263
+ if (explicitWorktreePath.length === 0) {
1264
+ const priorLedger = await readDelegationLedgerEntries(root);
1265
+ const priorSpanPath = priorLedger
1266
+ .filter((entry) => entry && entry.spanId === spanId && entry.runId === runId)
1267
+ .map((entry) =>
1268
+ entry && typeof entry.worktreePath === "string" ? entry.worktreePath.trim() : "")
1269
+ .find((value) => value.length > 0);
1270
+ if (priorSpanPath) {
1271
+ explicitWorktreePath = priorSpanPath;
1272
+ }
1273
+ }
1274
+ if (explicitWorktreePath.length > 0) {
1275
+ helperArgs.push("--worktree-path=" + explicitWorktreePath);
1276
+ }
1254
1277
  if (typeof row.taskId === "string" && row.taskId.trim().length > 0) {
1255
1278
  helperArgs.push("--task-id=" + row.taskId.trim());
1256
1279
  }
@@ -1320,6 +1343,91 @@ async function runSliceCommitIfNeeded(root, row, runId) {
1320
1343
  });
1321
1344
  }
1322
1345
 
1346
+ async function runSliceWorktreePrepareIfNeeded(root, row, runId) {
1347
+ if (
1348
+ row.stage !== "tdd" ||
1349
+ row.agent !== "slice-builder" ||
1350
+ row.status !== "scheduled"
1351
+ ) {
1352
+ return { ok: true, skipped: true };
1353
+ }
1354
+ const sliceId = typeof row.sliceId === "string" ? row.sliceId.trim() : "";
1355
+ const spanId = typeof row.spanId === "string" ? row.spanId.trim() : "";
1356
+ if (sliceId.length === 0 || spanId.length === 0) {
1357
+ return { ok: true, skipped: true };
1358
+ }
1359
+ const helperPath = path.join(root, RUNTIME_ROOT, "hooks", "slice-commit.mjs");
1360
+ if (!(await exists(helperPath))) {
1361
+ return { ok: true, skipped: true };
1362
+ }
1363
+ const helperArgs = [
1364
+ helperPath,
1365
+ "--json",
1366
+ "--quiet",
1367
+ "--prepare-worktree",
1368
+ "--slice=" + sliceId,
1369
+ "--span-id=" + spanId,
1370
+ "--run-id=" + runId
1371
+ ];
1372
+ if (Array.isArray(row.claimedPaths) && row.claimedPaths.length > 0) {
1373
+ helperArgs.push("--claimed-paths=" + row.claimedPaths.join(","));
1374
+ }
1375
+ return await new Promise((resolve) => {
1376
+ const child = spawn(process.execPath, helperArgs, {
1377
+ cwd: root,
1378
+ env: process.env,
1379
+ stdio: ["ignore", "pipe", "pipe"]
1380
+ });
1381
+ let out = "";
1382
+ let err = "";
1383
+ child.stdout.on("data", (chunk) => {
1384
+ out += String(chunk ?? "");
1385
+ });
1386
+ child.stderr.on("data", (chunk) => {
1387
+ err += String(chunk ?? "");
1388
+ });
1389
+ child.on("error", (error) => {
1390
+ resolve({
1391
+ ok: false,
1392
+ errorCode: "worktree_prepare_failed",
1393
+ details: {
1394
+ message: error instanceof Error ? error.message : String(error)
1395
+ }
1396
+ });
1397
+ });
1398
+ child.on("close", (code) => {
1399
+ let payload = null;
1400
+ const trimmed = out.trim();
1401
+ if (trimmed.length > 0) {
1402
+ try {
1403
+ payload = JSON.parse(trimmed);
1404
+ } catch {
1405
+ payload = null;
1406
+ }
1407
+ }
1408
+ if (code === 0) {
1409
+ resolve({ ok: true, payload });
1410
+ return;
1411
+ }
1412
+ const payloadCode =
1413
+ payload && typeof payload === "object" && typeof payload.errorCode === "string"
1414
+ ? payload.errorCode
1415
+ : "worktree_prepare_failed";
1416
+ resolve({
1417
+ ok: false,
1418
+ errorCode: payloadCode,
1419
+ details:
1420
+ payload && typeof payload === "object"
1421
+ ? payload
1422
+ : {
1423
+ stderr: err.trim(),
1424
+ stdout: out.trim()
1425
+ }
1426
+ });
1427
+ });
1428
+ });
1429
+ }
1430
+
1323
1431
  async function main() {
1324
1432
  const args = parseArgs(process.argv.slice(2));
1325
1433
  const json = args.json !== undefined;
@@ -1510,6 +1618,16 @@ async function main() {
1510
1618
  const status = args.status;
1511
1619
  const priorLedger = await readDelegationLedgerEntries(root);
1512
1620
  const priorForSpan = priorLedger.filter((e) => e && e.spanId === args["span-id"]);
1621
+ const inheritedWorktreePath = priorForSpan
1622
+ .map((entry) =>
1623
+ entry && typeof entry.worktreePath === "string" ? entry.worktreePath.trim() : "")
1624
+ .find((value) => value.length > 0);
1625
+ if (
1626
+ inheritedWorktreePath &&
1627
+ (typeof args["worktree-path"] !== "string" || args["worktree-path"].trim().length === 0)
1628
+ ) {
1629
+ args["worktree-path"] = inheritedWorktreePath;
1630
+ }
1513
1631
  const inheritedStartTs = priorForSpan
1514
1632
  .map((e) => e.startTs)
1515
1633
  .filter((ts) => typeof ts === "string" && ts.length > 0)
@@ -1572,6 +1690,24 @@ async function main() {
1572
1690
  emitErrorJson("dispatch_cap", capViolation, json);
1573
1691
  return;
1574
1692
  }
1693
+ const preparedWorktree = await runSliceWorktreePrepareIfNeeded(root, clean, runId);
1694
+ if (!preparedWorktree.ok) {
1695
+ emitErrorJson(
1696
+ preparedWorktree.errorCode || "worktree_prepare_failed",
1697
+ preparedWorktree.details || {},
1698
+ json
1699
+ );
1700
+ return;
1701
+ }
1702
+ if (
1703
+ preparedWorktree.payload &&
1704
+ typeof preparedWorktree.payload === "object" &&
1705
+ typeof preparedWorktree.payload.worktreePath === "string" &&
1706
+ preparedWorktree.payload.worktreePath.trim().length > 0
1707
+ ) {
1708
+ clean.worktreePath = preparedWorktree.payload.worktreePath.trim();
1709
+ event.worktreePath = clean.worktreePath;
1710
+ }
1575
1711
  }
1576
1712
  const dedupViolation = enforceDispatchDedupInline(clean, priorLedger, args);
1577
1713
  if (dedupViolation) {
@@ -1758,7 +1894,7 @@ void main();
1758
1894
  `;
1759
1895
  }
1760
1896
  export function sliceCommitScript() {
1761
- 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]");
1897
+ 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>] [--worktree-path=<abs-or-rel-path>] [--prepare-worktree] [--claimed-paths=<path1,path2,...>] [--claimed-path=<path> ...] [--json] [--quiet]");
1762
1898
  }
1763
1899
  export function runHookCmdScript() {
1764
1900
  return `: << 'CMDBLOCK'
@@ -142,6 +142,11 @@ export type DelegationEntry = {
142
142
  * `src/content/hooks.ts::delegationRecordScript`.
143
143
  */
144
144
  claimedPaths?: string[];
145
+ /**
146
+ * Absolute path of the isolated git worktree assigned to this span when
147
+ * `tdd.isolationMode=worktree|auto`.
148
+ */
149
+ worktreePath?: string;
145
150
  /**
146
151
  * TDD slice identifier, e.g. `"S-1"`. Recorded by the controller when
147
152
  * dispatching `slice-builder` so the artifact linter can auto-derive the
package/dist/install.js CHANGED
@@ -190,7 +190,7 @@ const DEPRECATED_HOOK_FILES = [
190
190
  "context-monitor.sh"
191
191
  ];
192
192
  const DEPRECATED_RUNTIME_ROOT_FILES = ["learnings.jsonl", "observations.jsonl"];
193
- const DEPRECATED_RUNTIME_DIRS = ["evals", "worktrees", "references", "contexts"];
193
+ const DEPRECATED_RUNTIME_DIRS = ["evals", "references", "contexts"];
194
194
  async function resolveGitHooksDir(projectRoot) {
195
195
  try {
196
196
  const { stdout } = await execFileAsync("git", ["rev-parse", "--git-path", "hooks"], {
@@ -1,9 +1,10 @@
1
1
  import { execFile } from "node:child_process";
2
2
  import path from "node:path";
3
3
  import { promisify } from "node:util";
4
- import { readConfig, resolveTddCommitMode } from "../config.js";
4
+ import { readConfig, resolveTddCommitMode, resolveTddIsolationMode, resolveTddWorktreeRoot } from "../config.js";
5
5
  import { readDelegationLedger } from "../delegation.js";
6
6
  import { exists } from "../fs-utils.js";
7
+ import { cleanupWorktree, commitAndMergeBack, createSliceWorktree, WorktreeMergeConflictError, WorktreeUnsupportedError } from "../worktree-manager.js";
7
8
  const execFileAsync = promisify(execFile);
8
9
  function parseCsv(raw) {
9
10
  return raw
@@ -22,7 +23,9 @@ function parseSliceCommitArgs(tokens) {
22
23
  let taskId;
23
24
  let title;
24
25
  let runId;
26
+ let worktreePath;
25
27
  const claimedPaths = [];
28
+ let prepareWorktree = false;
26
29
  let json = false;
27
30
  let quiet = false;
28
31
  for (let i = 0; i < tokens.length; i += 1) {
@@ -45,6 +48,10 @@ function parseSliceCommitArgs(tokens) {
45
48
  quiet = true;
46
49
  continue;
47
50
  }
51
+ if (token === "--prepare-worktree") {
52
+ prepareWorktree = true;
53
+ continue;
54
+ }
48
55
  if (token.startsWith("--slice=") || token === "--slice") {
49
56
  sliceId = valueFrom("--slice").trim();
50
57
  continue;
@@ -65,6 +72,13 @@ function parseSliceCommitArgs(tokens) {
65
72
  runId = valueFrom("--run-id").trim();
66
73
  continue;
67
74
  }
75
+ if (token.startsWith("--worktree-path=") || token === "--worktree-path") {
76
+ const resolved = valueFrom("--worktree-path").trim();
77
+ if (resolved.length > 0) {
78
+ worktreePath = resolved;
79
+ }
80
+ continue;
81
+ }
68
82
  if (token.startsWith("--claimed-paths=") || token === "--claimed-paths") {
69
83
  claimedPaths.push(...parseCsv(valueFrom("--claimed-paths")));
70
84
  continue;
@@ -89,7 +103,9 @@ function parseSliceCommitArgs(tokens) {
89
103
  taskId,
90
104
  title,
91
105
  runId,
106
+ worktreePath,
92
107
  claimedPaths,
108
+ prepareWorktree,
93
109
  json,
94
110
  quiet
95
111
  };
@@ -132,6 +148,12 @@ function parsePorcelainPaths(raw) {
132
148
  }
133
149
  return [...new Set(out)];
134
150
  }
151
+ async function gitChangedPaths(cwd) {
152
+ const { stdout: statusRaw } = await execFileAsync("git", ["status", "--porcelain", "-uall"], {
153
+ cwd
154
+ });
155
+ return parsePorcelainPaths(statusRaw);
156
+ }
135
157
  function matchesClaimedPath(changedPath, claimedPaths) {
136
158
  const changed = normalizePathLike(changedPath);
137
159
  return claimedPaths.some((rawClaimed) => {
@@ -171,6 +193,67 @@ export async function runSliceCommitCommand(projectRoot, tokens, io) {
171
193
  }
172
194
  const config = await readConfig(projectRoot).catch(() => null);
173
195
  const commitMode = resolveTddCommitMode(config);
196
+ const isolationMode = resolveTddIsolationMode(config);
197
+ const worktreeRoot = resolveTddWorktreeRoot(config);
198
+ const gitPresent = await exists(path.join(projectRoot, ".git"));
199
+ if (args.prepareWorktree) {
200
+ if (!gitPresent) {
201
+ output(io, args, {
202
+ ok: true,
203
+ skipped: true,
204
+ reason: "no-git",
205
+ message: "slice-worktree skipped: .git is missing"
206
+ });
207
+ return 0;
208
+ }
209
+ if (isolationMode === "in-place") {
210
+ output(io, args, {
211
+ ok: true,
212
+ skipped: true,
213
+ reason: "isolation-in-place",
214
+ isolationMode,
215
+ message: "slice-worktree skipped: tdd.isolationMode=in-place"
216
+ });
217
+ return 0;
218
+ }
219
+ try {
220
+ const { stdout } = await execFileAsync("git", ["rev-parse", "HEAD"], { cwd: projectRoot });
221
+ const prepared = await createSliceWorktree(args.sliceId, stdout.trim(), args.claimedPaths, {
222
+ projectRoot,
223
+ worktreeRoot
224
+ });
225
+ output(io, args, {
226
+ ok: true,
227
+ prepared: true,
228
+ sliceId: args.sliceId,
229
+ spanId: args.spanId,
230
+ worktreePath: prepared.path,
231
+ baseRef: prepared.ref
232
+ });
233
+ return 0;
234
+ }
235
+ catch (error) {
236
+ if (error instanceof WorktreeUnsupportedError) {
237
+ output(io, args, {
238
+ ok: true,
239
+ skipped: true,
240
+ reason: "worktree-unavailable",
241
+ degradedCommitMode: "agent-required",
242
+ message: error.message
243
+ });
244
+ return 0;
245
+ }
246
+ output(io, args, {
247
+ ok: false,
248
+ errorCode: "worktree_prepare_failed",
249
+ details: {
250
+ message: error instanceof Error ? error.message : String(error)
251
+ },
252
+ message: `worktree_prepare_failed: ${error instanceof Error ? error.message : String(error)}`
253
+ }, "stderr");
254
+ return 1;
255
+ }
256
+ }
174
257
  if (commitMode !== "managed-per-slice") {
175
258
  output(io, args, {
176
259
  ok: true,
@@ -181,7 +264,6 @@ export async function runSliceCommitCommand(projectRoot, tokens, io) {
181
264
  });
182
265
  return 0;
183
266
  }
184
- const gitPresent = await exists(path.join(projectRoot, ".git"));
185
267
  if (!gitPresent) {
186
268
  output(io, args, {
187
269
  ok: true,
@@ -206,11 +288,64 @@ export async function runSliceCommitCommand(projectRoot, tokens, io) {
206
288
  }, "stderr");
207
289
  return 2;
208
290
  }
209
- const { stdout: statusRaw } = await execFileAsync("git", ["status", "--porcelain", "-uall"], {
210
- cwd: projectRoot
211
- });
212
- const changedPaths = parsePorcelainPaths(statusRaw);
291
+ let managedWorktreePath = null;
292
+ let activeCwd = projectRoot;
293
+ let degradedToInPlace = false;
294
+ const requestedWorktreePath = typeof args.worktreePath === "string" && args.worktreePath.trim().length > 0
295
+ ? path.resolve(projectRoot, args.worktreePath.trim())
296
+ : null;
297
+ if (requestedWorktreePath && await exists(requestedWorktreePath)) {
298
+ managedWorktreePath = requestedWorktreePath;
299
+ activeCwd = requestedWorktreePath;
300
+ }
301
+ else if (isolationMode !== "in-place") {
302
+ try {
303
+ const { stdout } = await execFileAsync("git", ["rev-parse", "HEAD"], { cwd: projectRoot });
304
+ const prepared = await createSliceWorktree(args.sliceId, stdout.trim(), claimedPaths, {
305
+ projectRoot,
306
+ worktreeRoot
307
+ });
308
+ managedWorktreePath = prepared.path;
309
+ activeCwd = prepared.path;
310
+ }
311
+ catch (error) {
312
+ if (error instanceof WorktreeUnsupportedError) {
313
+ output(io, args, {
314
+ ok: true,
315
+ skipped: true,
316
+ reason: "worktree-unavailable",
317
+ degradedCommitMode: "agent-required",
318
+ message: error.message
319
+ });
320
+ return 0;
321
+ }
322
+ output(io, args, {
323
+ ok: false,
324
+ errorCode: "worktree_prepare_failed",
325
+ details: {
326
+ message: error instanceof Error ? error.message : String(error)
327
+ },
328
+ message: `worktree_prepare_failed: ${error instanceof Error ? error.message : String(error)}`
329
+ }, "stderr");
330
+ return 1;
331
+ }
332
+ }
333
+ const cleanupManagedWorktree = async () => {
334
+ if (!managedWorktreePath)
335
+ return;
336
+ await cleanupWorktree(managedWorktreePath, { projectRoot }).catch(() => undefined);
337
+ };
338
+ let changedPaths = await gitChangedPaths(activeCwd);
339
+ if (changedPaths.length === 0 && managedWorktreePath && activeCwd !== projectRoot) {
340
+ const rootChangedPaths = await gitChangedPaths(projectRoot);
341
+ if (rootChangedPaths.length > 0) {
342
+ activeCwd = projectRoot;
343
+ changedPaths = rootChangedPaths;
344
+ degradedToInPlace = true;
345
+ }
346
+ }
213
347
  if (changedPaths.length === 0) {
348
+ await cleanupManagedWorktree();
214
349
  output(io, args, {
215
350
  ok: true,
216
351
  skipped: true,
@@ -236,6 +371,7 @@ export async function runSliceCommitCommand(projectRoot, tokens, io) {
236
371
  }
237
372
  const changedInClaim = changedPaths.filter((p) => matchesClaimedPath(p, claimedPaths));
238
373
  if (changedInClaim.length === 0) {
374
+ await cleanupManagedWorktree();
239
375
  output(io, args, {
240
376
  ok: true,
241
377
  skipped: true,
@@ -246,7 +382,7 @@ export async function runSliceCommitCommand(projectRoot, tokens, io) {
246
382
  }
247
383
  try {
248
384
  await execFileAsync("git", ["add", "--", ...claimedPaths], {
249
- cwd: projectRoot
385
+ cwd: activeCwd
250
386
  });
251
387
  const taskPart = args.taskId && args.taskId.length > 0 ? args.taskId : "task";
252
388
  const titlePart = args.title && args.title.length > 0 ? args.title : "slice update";
@@ -257,12 +393,13 @@ export async function runSliceCommitCommand(projectRoot, tokens, io) {
257
393
  "phase-cycle: red->green->refactor->doc"
258
394
  ].join("\n");
259
395
  await execFileAsync("git", ["commit", "-m", header, "-m", body], {
260
- cwd: projectRoot
396
+ cwd: activeCwd
261
397
  });
262
398
  }
263
399
  catch (err) {
264
400
  const message = err instanceof Error ? err.message : String(err);
265
401
  if (/nothing to commit/iu.test(message)) {
402
+ await cleanupManagedWorktree();
266
403
  output(io, args, {
267
404
  ok: true,
268
405
  skipped: true,
@@ -280,9 +417,39 @@ export async function runSliceCommitCommand(projectRoot, tokens, io) {
280
417
  return 1;
281
418
  }
282
419
  const { stdout: shaStdout } = await execFileAsync("git", ["rev-parse", "HEAD"], {
283
- cwd: projectRoot
420
+ cwd: activeCwd
284
421
  });
285
- const commitSha = shaStdout.trim();
422
+ let commitSha = shaStdout.trim();
423
+ if (managedWorktreePath && activeCwd !== projectRoot) {
424
+ try {
425
+ const merged = await commitAndMergeBack(activeCwd, `merge ${args.sliceId}`, { projectRoot });
426
+ commitSha = merged.commitSha;
427
+ }
428
+ catch (error) {
429
+ if (error instanceof WorktreeMergeConflictError) {
430
+ output(io, args, {
431
+ ok: false,
432
+ errorCode: "worktree_merge_conflict",
433
+ details: {
434
+ sliceId: args.sliceId,
435
+ spanId: args.spanId,
436
+ worktreePath: activeCwd,
437
+ message: error.message
438
+ },
439
+ message: error.message
440
+ }, "stderr");
441
+ return 2;
442
+ }
443
+ output(io, args, {
444
+ ok: false,
445
+ errorCode: "slice_commit_failed",
446
+ details: { message: error instanceof Error ? error.message : String(error) },
447
+ message: `slice_commit_failed: ${error instanceof Error ? error.message : String(error)}`
448
+ }, "stderr");
449
+ return 1;
450
+ }
451
+ }
452
+ await cleanupManagedWorktree();
286
453
  output(io, args, {
287
454
  ok: true,
288
455
  commitSha,
@@ -290,6 +457,8 @@ export async function runSliceCommitCommand(projectRoot, tokens, io) {
290
457
  spanId: args.spanId,
291
458
  claimedPaths,
292
459
  changedPaths: changedInClaim,
460
+ worktreePath: managedWorktreePath ?? undefined,
461
+ degradedToInPlace: degradedToInPlace || undefined,
293
462
  message: `slice commit created for ${args.sliceId}: ${commitSha}`
294
463
  });
295
464
  return 0;
@@ -32,6 +32,17 @@ export interface RunWaveStatusOptions {
32
32
  * `<projectRoot>/.cclaw/artifacts`.
33
33
  */
34
34
  artifactsDir?: string;
35
+ /**
36
+ * Event ingestion mode:
37
+ * - auto: prefer live stream file; fallback to delegation-events.jsonl.
38
+ * - live: force live stream first; fallback to delegation-events.jsonl with warning.
39
+ * - file: skip stream file and use delegation-events.jsonl only.
40
+ */
41
+ streamMode?: "auto" | "live" | "file";
42
+ /**
43
+ * Optional absolute stream path override (tests).
44
+ */
45
+ streamPath?: string;
35
46
  }
36
47
  /**
37
48
  * Deterministic helper for the TDD controller. Reads the managed
@@ -3,16 +3,21 @@ import path from "node:path";
3
3
  import { RUNTIME_ROOT } from "../constants.js";
4
4
  import { readDelegationEvents, readDelegationLedger } from "../delegation.js";
5
5
  import { readFlowState } from "../runs.js";
6
+ import { DEFAULT_SLICE_STREAM_REL_PATH, readEventStreamFile } from "../streaming/event-stream.js";
6
7
  import { mergeParallelWaveDefinitions, parseParallelExecutionPlanWaves, parseWavePlanDirectory } from "./plan-split-waves.js";
7
8
  const PARALLEL_EXEC_MANAGED_START = "<!-- parallel-exec-managed-start -->";
8
9
  const PARALLEL_EXEC_MANAGED_END = "<!-- parallel-exec-managed-end -->";
9
10
  function parseArgs(tokens) {
10
- const args = { format: "json" };
11
+ const args = { format: "json", streamMode: "auto" };
11
12
  for (const token of tokens) {
12
13
  if (token === "--json")
13
14
  args.format = "json";
14
15
  else if (token === "--human" || token === "--text")
15
16
  args.format = "human";
17
+ else if (token === "--live")
18
+ args.streamMode = "live";
19
+ else if (token === "--file-only")
20
+ args.streamMode = "file";
16
21
  else if (token.startsWith("--format=")) {
17
22
  const raw = token.slice("--format=".length).trim();
18
23
  if (raw === "json" || raw === "human")
@@ -20,6 +25,13 @@ function parseArgs(tokens) {
20
25
  else
21
26
  throw new Error(`Unknown wave-status --format value: ${raw}`);
22
27
  }
28
+ else if (token.startsWith("--stream-mode=")) {
29
+ const raw = token.slice("--stream-mode=".length).trim();
30
+ if (raw === "auto" || raw === "live" || raw === "file")
31
+ args.streamMode = raw;
32
+ else
33
+ throw new Error(`Unknown wave-status --stream-mode value: ${raw}`);
34
+ }
23
35
  else {
24
36
  throw new Error(`Unknown wave-status flag: ${token}`);
25
37
  }
@@ -228,34 +240,72 @@ export async function runWaveStatus(projectRoot, options = {}) {
228
240
  }
229
241
  }
230
242
  }
231
- // Also consult the JSONL events in case the ledger projection lags.
232
- try {
233
- const { events } = await readDelegationEvents(projectRoot);
234
- for (const ev of events) {
235
- if (ev.event !== "completed")
236
- continue;
237
- if (ev.runId !== activeRunId)
238
- continue;
239
- if (ev.stage !== "tdd")
240
- continue;
241
- if (typeof ev.sliceId !== "string")
242
- continue;
243
- if (typeof ev.phase !== "string")
244
- continue;
245
- if (TERMINAL_PHASES.has(ev.phase)) {
246
- closedSlices.add(ev.sliceId);
247
- continue;
248
- }
249
- if (ev.phase === "green" && ev.refactorOutcome) {
250
- const mode = ev.refactorOutcome.mode;
251
- if (mode === "inline" || mode === "deferred") {
243
+ const warnings = [];
244
+ const streamMode = options.streamMode ?? "auto";
245
+ const streamPath = options.streamPath ?? path.join(projectRoot, DEFAULT_SLICE_STREAM_REL_PATH);
246
+ let shouldReadJsonlFallback = streamMode === "file";
247
+ // Prefer live stream events when requested/available.
248
+ if (streamMode !== "file") {
249
+ const streamResult = await readEventStreamFile(streamPath);
250
+ if (streamResult.droppedLines > 0) {
251
+ warnings.push(`wave_stream_dropped_lines: ${streamResult.droppedLines}`);
252
+ }
253
+ if (streamResult.events.length > 0) {
254
+ for (const ev of streamResult.events) {
255
+ if (ev.runId && ev.runId !== activeRunId)
256
+ continue;
257
+ if (ev.stage && ev.stage !== "tdd")
258
+ continue;
259
+ if (TERMINAL_PHASES.has(ev.phase)) {
252
260
  closedSlices.add(ev.sliceId);
261
+ continue;
262
+ }
263
+ if (ev.phase === "green" && ev.refactorOutcome) {
264
+ const mode = ev.refactorOutcome.mode;
265
+ if (mode === "inline" || mode === "deferred") {
266
+ closedSlices.add(ev.sliceId);
267
+ }
253
268
  }
254
269
  }
270
+ shouldReadJsonlFallback = false;
271
+ }
272
+ else {
273
+ shouldReadJsonlFallback = true;
274
+ if (streamMode === "auto" || streamMode === "live") {
275
+ warnings.push("wave_status_live_fallback_to_file: no parseable stream events");
276
+ }
255
277
  }
256
278
  }
257
- catch {
258
- // best-effort; ledger already covers the canonical case.
279
+ // Fallback to JSONL events when live stream is absent or disabled.
280
+ if (shouldReadJsonlFallback) {
281
+ try {
282
+ const { events } = await readDelegationEvents(projectRoot);
283
+ for (const ev of events) {
284
+ if (ev.event !== "completed")
285
+ continue;
286
+ if (ev.runId !== activeRunId)
287
+ continue;
288
+ if (ev.stage !== "tdd")
289
+ continue;
290
+ if (typeof ev.sliceId !== "string")
291
+ continue;
292
+ if (typeof ev.phase !== "string")
293
+ continue;
294
+ if (TERMINAL_PHASES.has(ev.phase)) {
295
+ closedSlices.add(ev.sliceId);
296
+ continue;
297
+ }
298
+ if (ev.phase === "green" && ev.refactorOutcome) {
299
+ const mode = ev.refactorOutcome.mode;
300
+ if (mode === "inline" || mode === "deferred") {
301
+ closedSlices.add(ev.sliceId);
302
+ }
303
+ }
304
+ }
305
+ }
306
+ catch {
307
+ // best-effort; ledger already covers the canonical case.
308
+ }
259
309
  }
260
310
  const waves = merged.map((wave) => {
261
311
  const members = wave.members.map((m) => m.sliceId);
@@ -272,7 +322,6 @@ export async function runWaveStatus(projectRoot, options = {}) {
272
322
  };
273
323
  });
274
324
  const firstOpenWave = waves.find((w) => w.status === "open" || w.status === "partial") ?? null;
275
- const warnings = [];
276
325
  if (merged.length === 0 && planRaw.length === 0) {
277
326
  warnings.push("wave_plan_missing: 05-plan.md not found or empty under <artifacts-dir>.");
278
327
  }
@@ -349,7 +398,7 @@ export async function runWaveStatusCommand(projectRoot, argv, io) {
349
398
  io.stderr.write(`cclaw internal wave-status: ${err instanceof Error ? err.message : String(err)}\n`);
350
399
  return 1;
351
400
  }
352
- const report = await runWaveStatus(projectRoot);
401
+ const report = await runWaveStatus(projectRoot, { streamMode: parsed.streamMode });
353
402
  if (parsed.format === "json") {
354
403
  io.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
355
404
  }
@@ -40,6 +40,7 @@ var REQUIRED_DIRS = [
40
40
  `${RUNTIME_ROOT}/artifacts`,
41
41
  `${RUNTIME_ROOT}/wave-plans`,
42
42
  `${RUNTIME_ROOT}/archive`,
43
+ `${RUNTIME_ROOT}/worktrees`,
43
44
  `${RUNTIME_ROOT}/state`,
44
45
  `${RUNTIME_ROOT}/rules`,
45
46
  `${RUNTIME_ROOT}/agents`,
@@ -0,0 +1,31 @@
1
+ export declare const DEFAULT_SLICE_STREAM_REL_PATH = ".cclaw/state/slice-builder-stream.jsonl";
2
+ export interface SliceBuilderPhaseEvent {
3
+ event: "phase-completed";
4
+ runId?: string;
5
+ stage?: string;
6
+ sliceId: string;
7
+ phase: string;
8
+ spanId?: string;
9
+ refactorOutcome?: {
10
+ mode?: string;
11
+ };
12
+ }
13
+ export interface EventStreamParseResult {
14
+ events: SliceBuilderPhaseEvent[];
15
+ droppedLines: number;
16
+ }
17
+ /**
18
+ * Incremental JSONL parser with bounded in-memory buffer. If chunks arrive
19
+ * faster than the consumer drains complete lines, we trim the oldest partial
20
+ * payload once maxBufferBytes is exceeded instead of letting memory grow
21
+ * unbounded.
22
+ */
23
+ export declare class EventStreamLineBuffer {
24
+ private readonly maxBufferBytes;
25
+ private buffer;
26
+ constructor(maxBufferBytes?: number);
27
+ push(chunk: string | Buffer): EventStreamParseResult;
28
+ flush(): EventStreamParseResult;
29
+ }
30
+ export declare function parseEventStreamText(raw: string): EventStreamParseResult;
31
+ export declare function readEventStreamFile(absPath: string): Promise<EventStreamParseResult>;
@@ -0,0 +1,114 @@
1
+ import fs from "node:fs/promises";
2
+ export const DEFAULT_SLICE_STREAM_REL_PATH = ".cclaw/state/slice-builder-stream.jsonl";
3
+ function isRecord(value) {
4
+ return typeof value === "object" && value !== null && !Array.isArray(value);
5
+ }
6
+ function asString(value) {
7
+ return typeof value === "string" && value.trim().length > 0 ? value.trim() : undefined;
8
+ }
9
+ function parseEventLine(rawLine) {
10
+ let parsed;
11
+ try {
12
+ parsed = JSON.parse(rawLine);
13
+ }
14
+ catch {
15
+ return null;
16
+ }
17
+ if (!isRecord(parsed))
18
+ return null;
19
+ if (parsed.event !== "phase-completed")
20
+ return null;
21
+ const sliceId = asString(parsed.sliceId);
22
+ const phase = asString(parsed.phase);
23
+ if (!sliceId || !phase)
24
+ return null;
25
+ const stage = asString(parsed.stage);
26
+ const runId = asString(parsed.runId);
27
+ const spanId = asString(parsed.spanId);
28
+ const refactorOutcome = isRecord(parsed.refactorOutcome)
29
+ ? { mode: asString(parsed.refactorOutcome.mode) }
30
+ : undefined;
31
+ return {
32
+ event: "phase-completed",
33
+ ...(runId ? { runId } : {}),
34
+ ...(stage ? { stage } : {}),
35
+ sliceId,
36
+ phase,
37
+ ...(spanId ? { spanId } : {}),
38
+ ...(refactorOutcome ? { refactorOutcome } : {})
39
+ };
40
+ }
41
+ /**
42
+ * Incremental JSONL parser with bounded in-memory buffer. If chunks arrive
43
+ * faster than the consumer drains complete lines, we trim the oldest partial
44
+ * payload once maxBufferBytes is exceeded instead of letting memory grow
45
+ * unbounded.
46
+ */
47
+ export class EventStreamLineBuffer {
48
+ maxBufferBytes;
49
+ buffer = "";
50
+ constructor(maxBufferBytes = 256 * 1024) {
51
+ this.maxBufferBytes = maxBufferBytes;
52
+ }
53
+ push(chunk) {
54
+ this.buffer += typeof chunk === "string" ? chunk : chunk.toString("utf8");
55
+ let droppedLines = 0;
56
+ if (this.buffer.length > this.maxBufferBytes) {
57
+ const overflowStart = this.buffer.length - this.maxBufferBytes;
58
+ const nextBreak = this.buffer.indexOf("\n", overflowStart);
59
+ if (nextBreak >= 0) {
60
+ this.buffer = this.buffer.slice(nextBreak + 1);
61
+ }
62
+ else {
63
+ this.buffer = "";
64
+ }
65
+ droppedLines += 1;
66
+ }
67
+ const events = [];
68
+ let newlineIdx = this.buffer.indexOf("\n");
69
+ while (newlineIdx >= 0) {
70
+ const line = this.buffer.slice(0, newlineIdx).trim();
71
+ this.buffer = this.buffer.slice(newlineIdx + 1);
72
+ if (line.length > 0) {
73
+ const parsed = parseEventLine(line);
74
+ if (parsed)
75
+ events.push(parsed);
76
+ else
77
+ droppedLines += 1;
78
+ }
79
+ newlineIdx = this.buffer.indexOf("\n");
80
+ }
81
+ return { events, droppedLines };
82
+ }
83
+ flush() {
84
+ if (this.buffer.trim().length === 0) {
85
+ this.buffer = "";
86
+ return { events: [], droppedLines: 0 };
87
+ }
88
+ const parsed = parseEventLine(this.buffer.trim());
89
+ this.buffer = "";
90
+ if (parsed) {
91
+ return { events: [parsed], droppedLines: 0 };
92
+ }
93
+ return { events: [], droppedLines: 1 };
94
+ }
95
+ }
96
+ export function parseEventStreamText(raw) {
97
+ const buffer = new EventStreamLineBuffer();
98
+ const pushed = buffer.push(raw);
99
+ const flushed = buffer.flush();
100
+ return {
101
+ events: [...pushed.events, ...flushed.events],
102
+ droppedLines: pushed.droppedLines + flushed.droppedLines
103
+ };
104
+ }
105
+ export async function readEventStreamFile(absPath) {
106
+ let raw = "";
107
+ try {
108
+ raw = await fs.readFile(absPath, "utf8");
109
+ }
110
+ catch {
111
+ return { events: [], droppedLines: 0 };
112
+ }
113
+ return parseEventStreamText(raw);
114
+ }
package/dist/types.d.ts CHANGED
@@ -162,6 +162,7 @@ export interface ReviewLoopConfig {
162
162
  }
163
163
  export type VcsMode = "git-with-remote" | "git-local-only" | "none";
164
164
  export type TddCommitMode = "managed-per-slice" | "agent-required" | "checkpoint-only" | "off";
165
+ export type TddIsolationMode = "worktree" | "in-place" | "auto";
165
166
  export interface TddConfig {
166
167
  /**
167
168
  * Commit ownership model for closed TDD slices.
@@ -171,6 +172,17 @@ export interface TddConfig {
171
172
  * - off: skip commit-shape enforcement.
172
173
  */
173
174
  commitMode?: TddCommitMode;
175
+ /**
176
+ * Slice execution isolation model.
177
+ * - worktree: default; allocate one git worktree per slice span.
178
+ * - in-place: run in the main working tree.
179
+ * - auto: prefer worktree, degrade to in-place when unavailable.
180
+ */
181
+ isolationMode?: TddIsolationMode;
182
+ /**
183
+ * Repo-relative root used for managed slice worktrees.
184
+ */
185
+ worktreeRoot?: string;
174
186
  }
175
187
  export interface CclawConfig {
176
188
  version: string;
@@ -0,0 +1,20 @@
1
+ export interface WorktreeManagerOptions {
2
+ projectRoot?: string;
3
+ worktreeRoot?: string;
4
+ }
5
+ export declare class WorktreeUnsupportedError extends Error {
6
+ readonly code = "worktree_unavailable";
7
+ constructor(message: string);
8
+ }
9
+ export declare class WorktreeMergeConflictError extends Error {
10
+ readonly code = "worktree_merge_conflict";
11
+ constructor(message: string);
12
+ }
13
+ export declare function createSliceWorktree(sliceId: string, baseRef: string, _claimedPaths: string[], options?: WorktreeManagerOptions): Promise<{
14
+ path: string;
15
+ ref: string;
16
+ }>;
17
+ export declare function commitAndMergeBack(worktreePath: string, _message: string, options?: WorktreeManagerOptions): Promise<{
18
+ commitSha: string;
19
+ }>;
20
+ export declare function cleanupWorktree(worktreePath: string, options?: WorktreeManagerOptions): Promise<void>;
@@ -0,0 +1,108 @@
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";
5
+ import { DEFAULT_TDD_WORKTREE_ROOT } from "./config.js";
6
+ import { exists } from "./fs-utils.js";
7
+ const execFileAsync = promisify(execFile);
8
+ export class WorktreeUnsupportedError extends Error {
9
+ code = "worktree_unavailable";
10
+ constructor(message) {
11
+ super(message);
12
+ this.name = "WorktreeUnsupportedError";
13
+ }
14
+ }
15
+ export class WorktreeMergeConflictError extends Error {
16
+ code = "worktree_merge_conflict";
17
+ constructor(message) {
18
+ super(message);
19
+ this.name = "WorktreeMergeConflictError";
20
+ }
21
+ }
22
+ function sanitizeSliceId(sliceId) {
23
+ return sliceId.trim().replace(/[^A-Za-z0-9._-]+/gu, "-");
24
+ }
25
+ function resolveProjectRoot(options) {
26
+ return options?.projectRoot ?? process.cwd();
27
+ }
28
+ function resolveWorktreeRoot(projectRoot, options) {
29
+ const root = typeof options?.worktreeRoot === "string" && options.worktreeRoot.trim().length > 0
30
+ ? options.worktreeRoot.trim()
31
+ : DEFAULT_TDD_WORKTREE_ROOT;
32
+ return path.resolve(projectRoot, root);
33
+ }
34
+ async function resolveMainRepoRootFromWorktree(worktreePath) {
35
+ const { stdout } = await execFileAsync("git", ["rev-parse", "--path-format=absolute", "--git-common-dir"], { cwd: worktreePath });
36
+ const commonDir = stdout.trim();
37
+ if (commonDir.length === 0) {
38
+ throw new Error(`Cannot resolve git common-dir from worktree ${worktreePath}`);
39
+ }
40
+ return path.dirname(commonDir);
41
+ }
42
+ export async function createSliceWorktree(sliceId, baseRef, _claimedPaths, options = {}) {
43
+ const projectRoot = resolveProjectRoot(options);
44
+ const ref = baseRef.trim().length > 0 ? baseRef.trim() : "HEAD";
45
+ const worktreeRoot = resolveWorktreeRoot(projectRoot, options);
46
+ const safeSliceId = sanitizeSliceId(sliceId);
47
+ if (safeSliceId.length === 0) {
48
+ throw new WorktreeUnsupportedError("Cannot create worktree: empty slice id.");
49
+ }
50
+ const worktreePath = path.join(worktreeRoot, safeSliceId);
51
+ try {
52
+ await execFileAsync("git", ["rev-parse", "--git-dir"], { cwd: projectRoot });
53
+ }
54
+ catch {
55
+ throw new WorktreeUnsupportedError("Cannot create worktree: repository has no .git metadata.");
56
+ }
57
+ await fs.mkdir(worktreeRoot, { recursive: true });
58
+ if (await exists(worktreePath)) {
59
+ if (await exists(path.join(worktreePath, ".git"))) {
60
+ return { path: worktreePath, ref };
61
+ }
62
+ await fs.rm(worktreePath, { recursive: true, force: true });
63
+ }
64
+ try {
65
+ await execFileAsync("git", ["worktree", "add", "--detach", worktreePath, ref], {
66
+ cwd: projectRoot
67
+ });
68
+ }
69
+ catch (error) {
70
+ throw new WorktreeUnsupportedError(`Cannot create worktree for ${sliceId}: ${error instanceof Error ? error.message : String(error)}`);
71
+ }
72
+ return { path: worktreePath, ref };
73
+ }
74
+ export async function commitAndMergeBack(worktreePath, _message, options = {}) {
75
+ const projectRoot = options.projectRoot ?? await resolveMainRepoRootFromWorktree(worktreePath);
76
+ try {
77
+ const { stdout: mainHeadStdout } = await execFileAsync("git", ["rev-parse", "HEAD"], {
78
+ cwd: projectRoot
79
+ });
80
+ const mainHead = mainHeadStdout.trim();
81
+ await execFileAsync("git", ["rebase", mainHead], { cwd: worktreePath });
82
+ const { stdout } = await execFileAsync("git", ["rev-parse", "HEAD"], { cwd: worktreePath });
83
+ const commitSha = stdout.trim();
84
+ await execFileAsync("git", ["fetch", worktreePath, "HEAD"], { cwd: projectRoot });
85
+ await execFileAsync("git", ["merge", "--ff-only", "FETCH_HEAD"], { cwd: projectRoot });
86
+ return { commitSha };
87
+ }
88
+ catch (error) {
89
+ throw new WorktreeMergeConflictError(`worktree_merge_conflict: ${error instanceof Error ? error.message : String(error)}`);
90
+ }
91
+ }
92
+ export async function cleanupWorktree(worktreePath, options = {}) {
93
+ if (!(await exists(worktreePath)))
94
+ return;
95
+ const projectRoot = options.projectRoot ?? await resolveMainRepoRootFromWorktree(worktreePath).catch(() => null);
96
+ if (projectRoot) {
97
+ try {
98
+ await execFileAsync("git", ["worktree", "remove", "--force", worktreePath], {
99
+ cwd: projectRoot
100
+ });
101
+ return;
102
+ }
103
+ catch {
104
+ // fall through to rm fallback below
105
+ }
106
+ }
107
+ await fs.rm(worktreePath, { recursive: true, force: true });
108
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cclaw-cli",
3
- "version": "7.2.0",
3
+ "version": "7.4.0",
4
4
  "description": "Installer-first flow toolkit for coding agents",
5
5
  "type": "module",
6
6
  "bin": {