@paths.design/caws-cli 9.1.1 → 9.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/budget-derivation.js +15 -3
- package/dist/commands/specs.js +28 -15
- package/dist/commands/status.js +1 -1
- package/dist/commands/verify-acs.js +471 -0
- package/dist/commands/worktree.js +107 -15
- package/dist/index.js +21 -1
- package/dist/parallel/parallel-manager.js +5 -12
- package/dist/scaffold/cursor-hooks.js +0 -1
- package/dist/scaffold/git-hooks.js +18 -1
- package/dist/templates/.caws/tools/README.md +4 -7
- package/dist/templates/.caws/tools/scope-guard.js +115 -171
- package/dist/templates/.claude/hooks/audit.sh +25 -0
- package/dist/templates/.claude/hooks/block-dangerous.sh +39 -0
- package/dist/templates/.claude/hooks/lite-sprawl-check.sh +30 -2
- package/dist/templates/.claude/hooks/naming-check.sh +5 -2
- package/dist/templates/.claude/hooks/scope-guard.sh +66 -4
- package/dist/templates/.claude/hooks/session-log.sh +38 -5
- package/dist/templates/.claude/hooks/worktree-write-guard.sh +13 -1
- package/dist/templates/.claude/rules/worktree-isolation.md +36 -4
- package/dist/templates/.cursor/README.md +0 -9
- package/dist/templates/.cursor/hooks/audit.sh +1 -1
- package/dist/templates/.cursor/hooks/block-dangerous.sh +1 -0
- package/dist/templates/.cursor/hooks/scan-secrets.sh +8 -3
- package/dist/templates/.cursor/hooks.json +0 -8
- package/dist/templates/.vscode/launch.json +0 -12
- package/dist/utils/detection.js +37 -0
- package/dist/utils/project-analysis.js +0 -1
- package/dist/utils/spec-resolver.js +23 -10
- package/dist/validation/spec-validation.js +8 -0
- package/dist/worktree/worktree-manager.js +242 -6
- package/package.json +1 -1
- package/templates/.caws/tools/README.md +4 -7
- package/templates/.caws/tools/scope-guard.js +115 -171
- package/templates/.claude/hooks/audit.sh +25 -0
- package/templates/.claude/hooks/block-dangerous.sh +39 -0
- package/templates/.claude/hooks/lite-sprawl-check.sh +30 -2
- package/templates/.claude/hooks/naming-check.sh +5 -2
- package/templates/.claude/hooks/scope-guard.sh +66 -4
- package/templates/.claude/hooks/session-log.sh +38 -5
- package/templates/.claude/hooks/worktree-write-guard.sh +13 -1
- package/templates/.claude/rules/worktree-isolation.md +36 -4
- package/templates/.cursor/README.md +0 -9
- package/templates/.cursor/hooks/audit.sh +1 -1
- package/templates/.cursor/hooks/block-dangerous.sh +1 -0
- package/templates/.cursor/hooks/scan-secrets.sh +8 -3
- package/templates/.cursor/hooks.json +0 -8
- package/templates/.vscode/launch.json +0 -12
- package/templates/.cursor/hooks/caws-tool-validation.sh +0 -121
|
@@ -12,6 +12,7 @@ const chalk = require('chalk');
|
|
|
12
12
|
|
|
13
13
|
// Import SPEC_TYPES from constants for consistent display
|
|
14
14
|
const { SPEC_TYPES } = require('../constants/spec-types');
|
|
15
|
+
const { findProjectRoot } = require('./detection');
|
|
15
16
|
|
|
16
17
|
/**
|
|
17
18
|
* Spec resolution priority:
|
|
@@ -22,6 +23,18 @@ const SPECS_DIR = '.caws/specs';
|
|
|
22
23
|
const LEGACY_SPEC = '.caws/working-spec.yaml';
|
|
23
24
|
const SPECS_REGISTRY = '.caws/specs/registry.json';
|
|
24
25
|
|
|
26
|
+
/**
|
|
27
|
+
* Get the project root for spec resolution.
|
|
28
|
+
* Caches per process to avoid repeated filesystem walks.
|
|
29
|
+
*/
|
|
30
|
+
let _cachedProjectRoot = null;
|
|
31
|
+
function getProjectRoot() {
|
|
32
|
+
if (!_cachedProjectRoot) {
|
|
33
|
+
_cachedProjectRoot = findProjectRoot();
|
|
34
|
+
}
|
|
35
|
+
return _cachedProjectRoot;
|
|
36
|
+
}
|
|
37
|
+
|
|
25
38
|
/**
|
|
26
39
|
* Resolve spec file path based on priority
|
|
27
40
|
* @param {Object} options - Resolution options
|
|
@@ -36,7 +49,7 @@ async function resolveSpec(options = {}) {
|
|
|
36
49
|
|
|
37
50
|
// 1. Explicit file path takes highest priority
|
|
38
51
|
if (specFile) {
|
|
39
|
-
const explicitPath = path.isAbsolute(specFile) ? specFile : path.join(
|
|
52
|
+
const explicitPath = path.isAbsolute(specFile) ? specFile : path.join(getProjectRoot(), specFile);
|
|
40
53
|
|
|
41
54
|
if (await fs.pathExists(explicitPath)) {
|
|
42
55
|
const yaml = require('js-yaml');
|
|
@@ -55,7 +68,7 @@ async function resolveSpec(options = {}) {
|
|
|
55
68
|
|
|
56
69
|
// 2. Feature-specific spec (preferred for multi-agent)
|
|
57
70
|
if (specId) {
|
|
58
|
-
const featurePath = path.join(
|
|
71
|
+
const featurePath = path.join(getProjectRoot(), SPECS_DIR, `${specId}.yaml`);
|
|
59
72
|
|
|
60
73
|
if (await fs.pathExists(featurePath)) {
|
|
61
74
|
const yaml = require('js-yaml');
|
|
@@ -83,7 +96,7 @@ async function resolveSpec(options = {}) {
|
|
|
83
96
|
if (specIds.length === 1) {
|
|
84
97
|
// Single spec - use it automatically
|
|
85
98
|
const singleSpecId = specIds[0];
|
|
86
|
-
const singleSpecPath = path.join(
|
|
99
|
+
const singleSpecPath = path.join(getProjectRoot(), SPECS_DIR, registry.specs[singleSpecId].path);
|
|
87
100
|
|
|
88
101
|
if (await fs.pathExists(singleSpecPath)) {
|
|
89
102
|
const yaml = require('js-yaml');
|
|
@@ -179,7 +192,7 @@ async function resolveSpec(options = {}) {
|
|
|
179
192
|
}
|
|
180
193
|
|
|
181
194
|
// 4. Fall back to legacy working-spec.yaml (with warning)
|
|
182
|
-
const legacyPath = path.join(
|
|
195
|
+
const legacyPath = path.join(getProjectRoot(), LEGACY_SPEC);
|
|
183
196
|
|
|
184
197
|
if (await fs.pathExists(legacyPath)) {
|
|
185
198
|
const yaml = require('js-yaml');
|
|
@@ -211,7 +224,7 @@ async function resolveSpec(options = {}) {
|
|
|
211
224
|
* @returns {Promise<Object>} Registry data
|
|
212
225
|
*/
|
|
213
226
|
async function loadSpecsRegistry() {
|
|
214
|
-
const registryPath = path.join(
|
|
227
|
+
const registryPath = path.join(getProjectRoot(), SPECS_REGISTRY);
|
|
215
228
|
|
|
216
229
|
if (!(await fs.pathExists(registryPath))) {
|
|
217
230
|
return {
|
|
@@ -241,7 +254,7 @@ async function listAvailableSpecs() {
|
|
|
241
254
|
const specs = [];
|
|
242
255
|
|
|
243
256
|
// Check feature-specific specs
|
|
244
|
-
const specsDir = path.join(
|
|
257
|
+
const specsDir = path.join(getProjectRoot(), SPECS_DIR);
|
|
245
258
|
if (await fs.pathExists(specsDir)) {
|
|
246
259
|
const files = await fs.readdir(specsDir);
|
|
247
260
|
const yamlFiles = files.filter((f) => f.endsWith('.yaml') || f.endsWith('.yml'));
|
|
@@ -257,7 +270,7 @@ async function listAvailableSpecs() {
|
|
|
257
270
|
|
|
258
271
|
specs.push({
|
|
259
272
|
id: spec.id || path.basename(file, path.extname(file)),
|
|
260
|
-
path: path.relative(
|
|
273
|
+
path: path.relative(getProjectRoot(), specPath),
|
|
261
274
|
type: 'feature',
|
|
262
275
|
title: spec.title || 'Untitled',
|
|
263
276
|
});
|
|
@@ -268,7 +281,7 @@ async function listAvailableSpecs() {
|
|
|
268
281
|
}
|
|
269
282
|
|
|
270
283
|
// Check legacy working-spec.yaml
|
|
271
|
-
const legacyPath = path.join(
|
|
284
|
+
const legacyPath = path.join(getProjectRoot(), LEGACY_SPEC);
|
|
272
285
|
if (await fs.pathExists(legacyPath)) {
|
|
273
286
|
try {
|
|
274
287
|
const yaml = require('js-yaml');
|
|
@@ -342,7 +355,7 @@ async function interactiveSpecSelection(specIds) {
|
|
|
342
355
|
async function checkMultiSpecStatus() {
|
|
343
356
|
const registry = await loadSpecsRegistry();
|
|
344
357
|
const hasFeatureSpecs = Object.keys(registry.specs ?? {}).length > 0;
|
|
345
|
-
const legacyPath = path.join(
|
|
358
|
+
const legacyPath = path.join(getProjectRoot(), LEGACY_SPEC);
|
|
346
359
|
const hasLegacySpec = await fs.pathExists(legacyPath);
|
|
347
360
|
|
|
348
361
|
return {
|
|
@@ -374,7 +387,7 @@ async function checkScopeConflicts(specIds) {
|
|
|
374
387
|
try {
|
|
375
388
|
spec = yaml.load(content);
|
|
376
389
|
} catch (yamlError) {
|
|
377
|
-
const relativePath = path.relative(
|
|
390
|
+
const relativePath = path.relative(getProjectRoot(), specPath);
|
|
378
391
|
throw new Error(
|
|
379
392
|
`Invalid YAML syntax in ${relativePath}: ${yamlError.message}\n` +
|
|
380
393
|
(yamlError.mark
|
|
@@ -85,6 +85,14 @@ const validateWorkingSpec = (spec, _options = {}) => {
|
|
|
85
85
|
// For new policy-based specs, change_budget is not required
|
|
86
86
|
// It's derived from policy.yaml + waivers
|
|
87
87
|
|
|
88
|
+
// Normalize risk_tier: accept "T1"/"T2"/"T3" strings and convert to numeric
|
|
89
|
+
if (spec.risk_tier !== undefined && typeof spec.risk_tier === 'string') {
|
|
90
|
+
const match = spec.risk_tier.match(/^T?(\d)$/i);
|
|
91
|
+
if (match) {
|
|
92
|
+
spec.risk_tier = parseInt(match[1], 10);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
88
96
|
for (const field of requiredFields) {
|
|
89
97
|
if (!spec[field]) {
|
|
90
98
|
return {
|
|
@@ -13,6 +13,46 @@ const WORKTREES_DIR = '.caws/worktrees';
|
|
|
13
13
|
const REGISTRY_FILE = '.caws/worktrees.json';
|
|
14
14
|
const BRANCH_PREFIX = 'caws/';
|
|
15
15
|
|
|
16
|
+
/**
|
|
17
|
+
* Get the last commit info for a branch
|
|
18
|
+
* @param {string} branch - Branch name
|
|
19
|
+
* @param {string} root - Repository root
|
|
20
|
+
* @returns {{ age: string, timestamp: Date, sha: string } | null}
|
|
21
|
+
*/
|
|
22
|
+
function getLastCommitInfo(branch, root) {
|
|
23
|
+
try {
|
|
24
|
+
const output = execFileSync(
|
|
25
|
+
'git',
|
|
26
|
+
['log', branch, '-1', '--format=%H%n%aI%n%ar'],
|
|
27
|
+
{ cwd: root, encoding: 'utf8', stdio: 'pipe' }
|
|
28
|
+
).trim();
|
|
29
|
+
const [sha, iso, age] = output.split('\n');
|
|
30
|
+
return { sha, timestamp: new Date(iso), age };
|
|
31
|
+
} catch {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Check if a branch has been merged into another branch
|
|
38
|
+
* @param {string} branch - Branch to check
|
|
39
|
+
* @param {string} target - Target branch (e.g., "main")
|
|
40
|
+
* @param {string} root - Repository root
|
|
41
|
+
* @returns {boolean}
|
|
42
|
+
*/
|
|
43
|
+
function isBranchMerged(branch, target, root) {
|
|
44
|
+
try {
|
|
45
|
+
const merged = execFileSync(
|
|
46
|
+
'git',
|
|
47
|
+
['branch', '--merged', target, '--list', branch],
|
|
48
|
+
{ cwd: root, encoding: 'utf8', stdio: 'pipe' }
|
|
49
|
+
).trim();
|
|
50
|
+
return merged.length > 0;
|
|
51
|
+
} catch {
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
16
56
|
/**
|
|
17
57
|
* Get the git repository root
|
|
18
58
|
* @returns {string} Absolute path to repo root
|
|
@@ -250,10 +290,21 @@ function listWorktrees() {
|
|
|
250
290
|
const inGit = gitWorktrees.some(
|
|
251
291
|
(wt) => path.resolve(wt) === path.resolve(entry.path)
|
|
252
292
|
);
|
|
293
|
+
const status = exists && inGit ? 'active' : exists ? 'orphaned' : 'missing';
|
|
294
|
+
|
|
295
|
+
// Enrich with commit recency
|
|
296
|
+
const lastCommit = entry.branch ? getLastCommitInfo(entry.branch, root) : null;
|
|
297
|
+
|
|
298
|
+
// Check if branch is already merged to base
|
|
299
|
+
const merged = entry.branch && entry.baseBranch
|
|
300
|
+
? isBranchMerged(entry.branch, entry.baseBranch, root)
|
|
301
|
+
: false;
|
|
253
302
|
|
|
254
303
|
return {
|
|
255
304
|
...entry,
|
|
256
|
-
status
|
|
305
|
+
status,
|
|
306
|
+
lastCommit,
|
|
307
|
+
merged,
|
|
257
308
|
};
|
|
258
309
|
});
|
|
259
310
|
|
|
@@ -277,16 +328,62 @@ function destroyWorktree(name, options = {}) {
|
|
|
277
328
|
throw new Error(`Worktree '${name}' not found in registry`);
|
|
278
329
|
}
|
|
279
330
|
|
|
331
|
+
// Ownership check: refuse to destroy another agent's active worktree without --force
|
|
332
|
+
const currentSession = process.env.CLAUDE_SESSION_ID || null;
|
|
333
|
+
if (
|
|
334
|
+
!force &&
|
|
335
|
+
entry.status === 'active' &&
|
|
336
|
+
entry.owner &&
|
|
337
|
+
currentSession &&
|
|
338
|
+
entry.owner !== currentSession
|
|
339
|
+
) {
|
|
340
|
+
const lastCommit = entry.branch ? getLastCommitInfo(entry.branch, root) : null;
|
|
341
|
+
const recency = lastCommit ? ` (last commit: ${lastCommit.age})` : '';
|
|
342
|
+
throw new Error(
|
|
343
|
+
`Worktree '${name}' belongs to another session${recency}.\n` +
|
|
344
|
+
` Owner: ${entry.owner}\n` +
|
|
345
|
+
` You: ${currentSession}\n` +
|
|
346
|
+
`Another agent may be actively working here.\n` +
|
|
347
|
+
`Do NOT destroy worktrees you did not create. Ask the user if cleanup is needed.`
|
|
348
|
+
);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// Even with --force, warn loudly when destroying another session's worktree
|
|
352
|
+
if (
|
|
353
|
+
force &&
|
|
354
|
+
entry.status === 'active' &&
|
|
355
|
+
entry.owner &&
|
|
356
|
+
currentSession &&
|
|
357
|
+
entry.owner !== currentSession
|
|
358
|
+
) {
|
|
359
|
+
const lastCommit = entry.branch ? getLastCommitInfo(entry.branch, root) : null;
|
|
360
|
+
const recency = lastCommit ? ` (last commit: ${lastCommit.age})` : '';
|
|
361
|
+
console.log(chalk.red(`\n ⚠ WARNING: Force-destroying worktree '${name}' owned by another session${recency}`));
|
|
362
|
+
console.log(chalk.red(` Owner: ${entry.owner}`));
|
|
363
|
+
console.log(chalk.red(` You: ${currentSession}`));
|
|
364
|
+
console.log(chalk.red(` If the other agent is still running, this WILL break their work.\n`));
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// Auto-force when the branch is already merged to its base branch.
|
|
368
|
+
// Dirty files in a merged worktree are definitionally stale.
|
|
369
|
+
const merged = entry.branch && entry.baseBranch
|
|
370
|
+
? isBranchMerged(entry.branch, entry.baseBranch, root)
|
|
371
|
+
: false;
|
|
372
|
+
const effectiveForce = force || merged;
|
|
373
|
+
if (merged && !force) {
|
|
374
|
+
console.log(chalk.gray(` Branch ${entry.branch} already merged to ${entry.baseBranch}, auto-forcing cleanup`));
|
|
375
|
+
}
|
|
376
|
+
|
|
280
377
|
// Remove git worktree — handle already-deleted directories gracefully
|
|
281
378
|
const dirExists = fs.existsSync(entry.path);
|
|
282
379
|
if (dirExists) {
|
|
283
380
|
try {
|
|
284
381
|
const args = ['worktree', 'remove'];
|
|
285
|
-
if (
|
|
382
|
+
if (effectiveForce) args.push('--force');
|
|
286
383
|
args.push(entry.path);
|
|
287
384
|
execFileSync('git', args, { cwd: root, stdio: 'pipe' });
|
|
288
385
|
} catch (error) {
|
|
289
|
-
if (
|
|
386
|
+
if (effectiveForce) {
|
|
290
387
|
// Force cleanup: remove directory manually
|
|
291
388
|
fs.removeSync(entry.path);
|
|
292
389
|
} else {
|
|
@@ -310,7 +407,7 @@ function destroyWorktree(name, options = {}) {
|
|
|
310
407
|
try {
|
|
311
408
|
execFileSync('git', ['branch', '-d', entry.branch], { cwd: root, stdio: 'pipe' });
|
|
312
409
|
} catch {
|
|
313
|
-
if (
|
|
410
|
+
if (effectiveForce) {
|
|
314
411
|
try {
|
|
315
412
|
execFileSync('git', ['branch', '-D', entry.branch], { cwd: root, stdio: 'pipe' });
|
|
316
413
|
} catch {
|
|
@@ -326,19 +423,143 @@ function destroyWorktree(name, options = {}) {
|
|
|
326
423
|
saveRegistry(root, registry);
|
|
327
424
|
}
|
|
328
425
|
|
|
426
|
+
/**
|
|
427
|
+
* Merge a worktree branch back to base in one operation.
|
|
428
|
+
* Sequence: dry-run conflict check → destroy worktree → merge → cleanup.
|
|
429
|
+
* @param {string} name - Worktree name
|
|
430
|
+
* @param {Object} options - Merge options
|
|
431
|
+
* @param {boolean} [options.dryRun] - Preview conflicts without merging
|
|
432
|
+
* @param {boolean} [options.deleteBranch] - Delete branch after merge
|
|
433
|
+
* @param {string} [options.message] - Custom merge commit message
|
|
434
|
+
* @returns {Object} Merge result
|
|
435
|
+
*/
|
|
436
|
+
function mergeWorktree(name, options = {}) {
|
|
437
|
+
const root = getRepoRoot();
|
|
438
|
+
const registry = loadRegistry(root);
|
|
439
|
+
const { dryRun = false, deleteBranch = true, message } = options;
|
|
440
|
+
|
|
441
|
+
const entry = registry.worktrees[name];
|
|
442
|
+
if (!entry) {
|
|
443
|
+
throw new Error(`Worktree '${name}' not found in registry`);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
const baseBranch = entry.baseBranch || 'main';
|
|
447
|
+
|
|
448
|
+
// Check for uncommitted work in the worktree
|
|
449
|
+
if (fs.existsSync(entry.path)) {
|
|
450
|
+
try {
|
|
451
|
+
const status = execFileSync(
|
|
452
|
+
'git',
|
|
453
|
+
['status', '--porcelain'],
|
|
454
|
+
{ cwd: entry.path, encoding: 'utf8', stdio: 'pipe' }
|
|
455
|
+
).trim();
|
|
456
|
+
if (status) {
|
|
457
|
+
throw new Error(
|
|
458
|
+
`Worktree '${name}' has uncommitted changes:\n${status}\n` +
|
|
459
|
+
`Commit or discard changes before merging.`
|
|
460
|
+
);
|
|
461
|
+
}
|
|
462
|
+
} catch (error) {
|
|
463
|
+
if (error.message.includes('uncommitted changes')) throw error;
|
|
464
|
+
// Non-fatal: status check failed, proceed cautiously
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// Dry-run: check for conflicts using git merge-tree (new-style, git 2.38+)
|
|
469
|
+
let conflicts = [];
|
|
470
|
+
try {
|
|
471
|
+
// New-style merge-tree: takes two branches, computes merge-base automatically
|
|
472
|
+
const mergeTreeResult = execFileSync(
|
|
473
|
+
'git',
|
|
474
|
+
['merge-tree', '--write-tree', baseBranch, entry.branch],
|
|
475
|
+
{ cwd: root, encoding: 'utf8', stdio: 'pipe' }
|
|
476
|
+
);
|
|
477
|
+
// Exit 0 = clean merge, no conflicts
|
|
478
|
+
} catch (mergeTreeError) {
|
|
479
|
+
// Exit 1 = conflicts detected; parse them from output
|
|
480
|
+
const output = (mergeTreeError.stdout || '') + (mergeTreeError.stderr || '');
|
|
481
|
+
const conflictLines = output.split('\n').filter(
|
|
482
|
+
(l) => l.includes('CONFLICT') || l.includes('conflict')
|
|
483
|
+
);
|
|
484
|
+
if (mergeTreeError.status === 1 && conflictLines.length > 0) {
|
|
485
|
+
conflicts = conflictLines;
|
|
486
|
+
} else if (mergeTreeError.status === 1) {
|
|
487
|
+
conflicts = ['Merge conflicts detected (run merge manually to inspect)'];
|
|
488
|
+
}
|
|
489
|
+
// Other exit codes (e.g., merge-tree not supported) = can't detect, proceed
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
if (dryRun) {
|
|
493
|
+
return {
|
|
494
|
+
name,
|
|
495
|
+
branch: entry.branch,
|
|
496
|
+
baseBranch,
|
|
497
|
+
conflicts,
|
|
498
|
+
wouldMerge: conflicts.length === 0,
|
|
499
|
+
};
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
// Destroy the worktree (auto-forces since we're about to merge)
|
|
503
|
+
destroyWorktree(name, { deleteBranch: false, force: true });
|
|
504
|
+
|
|
505
|
+
// Switch to base branch
|
|
506
|
+
const currentBranch = getCurrentBranch();
|
|
507
|
+
if (currentBranch !== baseBranch) {
|
|
508
|
+
execFileSync('git', ['checkout', baseBranch], { cwd: root, stdio: 'pipe' });
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// Merge
|
|
512
|
+
const mergeMessage = message || `merge(worktree): ${name}`;
|
|
513
|
+
try {
|
|
514
|
+
execFileSync(
|
|
515
|
+
'git',
|
|
516
|
+
['merge', '--no-ff', entry.branch, '-m', mergeMessage],
|
|
517
|
+
{ cwd: root, stdio: 'pipe' }
|
|
518
|
+
);
|
|
519
|
+
} catch (error) {
|
|
520
|
+
return {
|
|
521
|
+
name,
|
|
522
|
+
branch: entry.branch,
|
|
523
|
+
baseBranch,
|
|
524
|
+
merged: false,
|
|
525
|
+
conflicts: [`Merge failed: ${error.message}`],
|
|
526
|
+
message: 'Merge conflicts detected. Resolve with git and commit.',
|
|
527
|
+
};
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// Delete branch after successful merge
|
|
531
|
+
if (deleteBranch) {
|
|
532
|
+
try {
|
|
533
|
+
execFileSync('git', ['branch', '-d', entry.branch], { cwd: root, stdio: 'pipe' });
|
|
534
|
+
} catch {
|
|
535
|
+
// Non-fatal
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
return {
|
|
540
|
+
name,
|
|
541
|
+
branch: entry.branch,
|
|
542
|
+
baseBranch,
|
|
543
|
+
merged: true,
|
|
544
|
+
conflicts: [],
|
|
545
|
+
};
|
|
546
|
+
}
|
|
547
|
+
|
|
329
548
|
/**
|
|
330
549
|
* Prune stale worktree entries
|
|
331
550
|
* @param {Object} options - Prune options
|
|
332
551
|
* @param {number} [options.maxAgeDays] - Remove entries older than this many days
|
|
552
|
+
* @param {number} [options.recentCommitMinutes] - Protect branches with commits newer than this (default: 60)
|
|
333
553
|
* @returns {Array} Pruned entries
|
|
334
554
|
*/
|
|
335
555
|
function pruneWorktrees(options = {}) {
|
|
336
556
|
const root = getRepoRoot();
|
|
337
557
|
const registry = loadRegistry(root);
|
|
338
|
-
const { maxAgeDays = 30 } = options;
|
|
558
|
+
const { maxAgeDays = 30, recentCommitMinutes = 60 } = options;
|
|
339
559
|
|
|
340
560
|
const now = new Date();
|
|
341
561
|
const pruned = [];
|
|
562
|
+
const skipped = [];
|
|
342
563
|
|
|
343
564
|
for (const [name, entry] of Object.entries(registry.worktrees)) {
|
|
344
565
|
const created = new Date(entry.createdAt);
|
|
@@ -354,6 +575,18 @@ function pruneWorktrees(options = {}) {
|
|
|
354
575
|
(!dirExists && ageDays > maxAgeDays);
|
|
355
576
|
|
|
356
577
|
if (shouldPrune) {
|
|
578
|
+
// Before pruning a non-destroyed entry, check for recent commits
|
|
579
|
+
if (entry.status !== 'destroyed' && entry.branch) {
|
|
580
|
+
const lastCommit = getLastCommitInfo(entry.branch, root);
|
|
581
|
+
if (lastCommit) {
|
|
582
|
+
const commitAgeMinutes = (now - lastCommit.timestamp) / (1000 * 60);
|
|
583
|
+
if (commitAgeMinutes < recentCommitMinutes) {
|
|
584
|
+
skipped.push({ name, reason: `recent commit (${lastCommit.age})`, entry });
|
|
585
|
+
continue;
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
|
|
357
590
|
// Clean up filesystem if still exists
|
|
358
591
|
if (dirExists) {
|
|
359
592
|
try {
|
|
@@ -378,16 +611,19 @@ function pruneWorktrees(options = {}) {
|
|
|
378
611
|
}
|
|
379
612
|
|
|
380
613
|
saveRegistry(root, registry);
|
|
381
|
-
return pruned;
|
|
614
|
+
return { pruned, skipped };
|
|
382
615
|
}
|
|
383
616
|
|
|
384
617
|
module.exports = {
|
|
385
618
|
createWorktree,
|
|
386
619
|
listWorktrees,
|
|
387
620
|
destroyWorktree,
|
|
621
|
+
mergeWorktree,
|
|
388
622
|
pruneWorktrees,
|
|
389
623
|
loadRegistry,
|
|
390
624
|
getRepoRoot,
|
|
625
|
+
getLastCommitInfo,
|
|
626
|
+
isBranchMerged,
|
|
391
627
|
WORKTREES_DIR,
|
|
392
628
|
REGISTRY_FILE,
|
|
393
629
|
BRANCH_PREFIX,
|
package/package.json
CHANGED
|
@@ -4,18 +4,15 @@ This directory contains CAWS-specific tools that aren't available in the CLI.
|
|
|
4
4
|
|
|
5
5
|
## scope-guard.js
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
Checks whether a file is within scope of active working-spec and feature specs. Used by Cursor hooks for scope validation on file attachments.
|
|
8
8
|
|
|
9
9
|
```bash
|
|
10
|
-
#
|
|
11
|
-
node .caws/tools/scope-guard.js
|
|
10
|
+
# Check if a file is in scope
|
|
11
|
+
node .caws/tools/scope-guard.js check src/index.js
|
|
12
12
|
|
|
13
|
-
#
|
|
14
|
-
node .caws/tools/scope-guard.js check .caws/working-spec.yaml
|
|
13
|
+
# Exit code 0 = in scope, 1 = out of scope
|
|
15
14
|
```
|
|
16
15
|
|
|
17
16
|
**Usage in Cursor Hooks:**
|
|
18
17
|
|
|
19
18
|
The `.cursor/hooks/scope-guard.sh` hook automatically uses this tool to validate file attachments against working spec scope boundaries.
|
|
20
|
-
|
|
21
|
-
|