cclaw-cli 7.1.1 → 7.3.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/cli.d.ts +1 -0
- package/dist/cli.js +12 -0
- package/dist/config.d.ts +6 -1
- package/dist/config.js +46 -4
- package/dist/constants.d.ts +1 -1
- package/dist/constants.js +1 -0
- package/dist/content/core-agents.js +2 -1
- package/dist/content/hooks.js +239 -40
- package/dist/content/stage-schema.js +7 -32
- package/dist/delegation.d.ts +5 -0
- package/dist/delegation.js +1 -0
- package/dist/flow-state.d.ts +1 -1
- package/dist/install.d.ts +1 -0
- package/dist/install.js +51 -1
- package/dist/internal/advance-stage/start-flow.js +6 -18
- package/dist/internal/advance-stage/verify.js +6 -18
- package/dist/internal/slice-commit.js +179 -10
- package/dist/runtime/run-hook.mjs +1 -0
- package/dist/stack-detection.d.ts +22 -0
- package/dist/stack-detection.js +58 -0
- package/dist/types.d.ts +12 -0
- package/dist/worktree-manager.d.ts +20 -0
- package/dist/worktree-manager.js +108 -0
- package/package.json +1 -1
|
@@ -3,6 +3,7 @@ import { RUNTIME_ROOT } from "../../constants.js";
|
|
|
3
3
|
import { stageSchema } from "../../content/stage-schema.js";
|
|
4
4
|
import { readFlowState } from "../../runs.js";
|
|
5
5
|
import { TRACK_STAGES } from "../../types.js";
|
|
6
|
+
import { STACK_DISCOVERY_DIR_MARKERS, STACK_DISCOVERY_MARKERS } from "../../stack-detection.js";
|
|
6
7
|
import { coerceCandidateFlowState } from "./flow-state-coercion.js";
|
|
7
8
|
import { buildValidationReport } from "./advance.js";
|
|
8
9
|
import fs from "node:fs/promises";
|
|
@@ -171,24 +172,11 @@ export async function discoverStartFlowContext(projectRoot) {
|
|
|
171
172
|
lines.push(originFiles.length > 0
|
|
172
173
|
? `- Origin docs scanned: found ${originFiles.join(", ")}.`
|
|
173
174
|
: "- Origin docs scanned: no PRD/RFC/ADR/design/spec files found in configured locations.");
|
|
174
|
-
const stackMarkers = await listExistingFiles(projectRoot, [
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
".python-version",
|
|
180
|
-
"go.mod",
|
|
181
|
-
"Cargo.toml",
|
|
182
|
-
"pom.xml",
|
|
183
|
-
"build.gradle",
|
|
184
|
-
"build.gradle.kts",
|
|
185
|
-
"Dockerfile",
|
|
186
|
-
"docker-compose.yml",
|
|
187
|
-
"docker-compose.yaml",
|
|
188
|
-
".gitlab-ci.yml"
|
|
189
|
-
]);
|
|
190
|
-
if (await pathExists(projectRoot, ".github/workflows")) {
|
|
191
|
-
stackMarkers.push(".github/workflows/");
|
|
175
|
+
const stackMarkers = await listExistingFiles(projectRoot, [...STACK_DISCOVERY_MARKERS]);
|
|
176
|
+
for (const markerDir of STACK_DISCOVERY_DIR_MARKERS) {
|
|
177
|
+
if (await pathExists(projectRoot, markerDir)) {
|
|
178
|
+
stackMarkers.push(`${markerDir}/`);
|
|
179
|
+
}
|
|
192
180
|
}
|
|
193
181
|
lines.push(stackMarkers.length > 0
|
|
194
182
|
? `- Stack markers scanned: found ${stackMarkers.join(", ")}.`
|
|
@@ -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
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
const
|
|
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:
|
|
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:
|
|
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:
|
|
420
|
+
cwd: activeCwd
|
|
284
421
|
});
|
|
285
|
-
|
|
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;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export interface StackReviewRouteProfile {
|
|
2
|
+
stack: string;
|
|
3
|
+
/**
|
|
4
|
+
* Signals shown in review routing documentation/skills.
|
|
5
|
+
* These are human-facing pointers, not strict parsers.
|
|
6
|
+
*/
|
|
7
|
+
reviewSignals: string[];
|
|
8
|
+
/** Root-level markers used by start-flow context discovery. */
|
|
9
|
+
discoveryMarkers: string[];
|
|
10
|
+
focus: string;
|
|
11
|
+
}
|
|
12
|
+
export declare const STACK_REVIEW_ROUTE_PROFILES: readonly StackReviewRouteProfile[];
|
|
13
|
+
/**
|
|
14
|
+
* Unified root-marker list used by start-flow context discovery.
|
|
15
|
+
* Keep this in one place so stage skill routing and start-flow scanning
|
|
16
|
+
* evolve together.
|
|
17
|
+
*/
|
|
18
|
+
export declare const STACK_DISCOVERY_MARKERS: readonly string[];
|
|
19
|
+
/**
|
|
20
|
+
* Directory markers (checked with pathExists) for stack discovery.
|
|
21
|
+
*/
|
|
22
|
+
export declare const STACK_DISCOVERY_DIR_MARKERS: readonly string[];
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
export const STACK_REVIEW_ROUTE_PROFILES = [
|
|
2
|
+
{
|
|
3
|
+
stack: "TypeScript/JavaScript",
|
|
4
|
+
reviewSignals: ["package.json", "tsconfig.json"],
|
|
5
|
+
discoveryMarkers: ["package.json", "tsconfig.json", "jsconfig.json"],
|
|
6
|
+
focus: "type safety, package scripts, build/test config, dependency boundaries"
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
stack: "Python",
|
|
10
|
+
reviewSignals: ["pyproject.toml", "requirements.txt"],
|
|
11
|
+
discoveryMarkers: ["pyproject.toml", "requirements.txt", "requirements-dev.txt", ".python-version"],
|
|
12
|
+
focus: "packaging, virtualenv assumptions, typing, pytest or unittest evidence"
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
stack: "Ruby/Rails",
|
|
16
|
+
reviewSignals: ["Gemfile", "config/"],
|
|
17
|
+
discoveryMarkers: ["Gemfile"],
|
|
18
|
+
focus: "Rails conventions, migrations, routes/controllers, RSpec or Minitest evidence"
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
stack: "Go",
|
|
22
|
+
reviewSignals: ["go.mod"],
|
|
23
|
+
discoveryMarkers: ["go.mod"],
|
|
24
|
+
focus: "interfaces, concurrency, error handling, go test coverage"
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
stack: "Rust",
|
|
28
|
+
reviewSignals: ["Cargo.toml"],
|
|
29
|
+
discoveryMarkers: ["Cargo.toml"],
|
|
30
|
+
focus: "ownership, error/result handling, feature flags, cargo test coverage"
|
|
31
|
+
}
|
|
32
|
+
];
|
|
33
|
+
const EXTRA_DISCOVERY_MARKERS = [
|
|
34
|
+
"pom.xml",
|
|
35
|
+
"build.gradle",
|
|
36
|
+
"build.gradle.kts",
|
|
37
|
+
"Dockerfile",
|
|
38
|
+
"docker-compose.yml",
|
|
39
|
+
"docker-compose.yaml",
|
|
40
|
+
".gitlab-ci.yml"
|
|
41
|
+
];
|
|
42
|
+
/**
|
|
43
|
+
* Unified root-marker list used by start-flow context discovery.
|
|
44
|
+
* Keep this in one place so stage skill routing and start-flow scanning
|
|
45
|
+
* evolve together.
|
|
46
|
+
*/
|
|
47
|
+
export const STACK_DISCOVERY_MARKERS = [
|
|
48
|
+
...new Set([
|
|
49
|
+
...STACK_REVIEW_ROUTE_PROFILES.flatMap((profile) => profile.discoveryMarkers),
|
|
50
|
+
...EXTRA_DISCOVERY_MARKERS
|
|
51
|
+
])
|
|
52
|
+
];
|
|
53
|
+
/**
|
|
54
|
+
* Directory markers (checked with pathExists) for stack discovery.
|
|
55
|
+
*/
|
|
56
|
+
export const STACK_DISCOVERY_DIR_MARKERS = [
|
|
57
|
+
".github/workflows"
|
|
58
|
+
];
|
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
|
+
}
|