@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
|
@@ -9,6 +9,7 @@ const {
|
|
|
9
9
|
createWorktree,
|
|
10
10
|
listWorktrees,
|
|
11
11
|
destroyWorktree,
|
|
12
|
+
mergeWorktree,
|
|
12
13
|
pruneWorktrees,
|
|
13
14
|
} = require('../worktree/worktree-manager');
|
|
14
15
|
|
|
@@ -26,11 +27,13 @@ async function worktreeCommand(subcommand, options = {}) {
|
|
|
26
27
|
return handleList();
|
|
27
28
|
case 'destroy':
|
|
28
29
|
return handleDestroy(options);
|
|
30
|
+
case 'merge':
|
|
31
|
+
return handleMerge(options);
|
|
29
32
|
case 'prune':
|
|
30
33
|
return handlePrune(options);
|
|
31
34
|
default:
|
|
32
35
|
console.error(chalk.red(`Unknown worktree subcommand: ${subcommand}`));
|
|
33
|
-
console.log(chalk.blue('Available: create, list, destroy, prune'));
|
|
36
|
+
console.log(chalk.blue('Available: create, list, destroy, merge, prune'));
|
|
34
37
|
process.exit(1);
|
|
35
38
|
}
|
|
36
39
|
} catch (error) {
|
|
@@ -70,30 +73,52 @@ function handleList() {
|
|
|
70
73
|
}
|
|
71
74
|
|
|
72
75
|
console.log(chalk.bold.cyan('CAWS Worktrees'));
|
|
73
|
-
console.log(chalk.cyan('='.repeat(
|
|
76
|
+
console.log(chalk.cyan('='.repeat(85)));
|
|
74
77
|
console.log(
|
|
75
78
|
chalk.bold(
|
|
76
|
-
'Name'.padEnd(
|
|
79
|
+
'Name'.padEnd(18) +
|
|
77
80
|
'Status'.padEnd(12) +
|
|
78
81
|
'Branch'.padEnd(20) +
|
|
79
|
-
'
|
|
82
|
+
'Last Commit'.padEnd(16) +
|
|
83
|
+
'Owner'
|
|
80
84
|
)
|
|
81
85
|
);
|
|
82
|
-
console.log(chalk.gray('-'.repeat(
|
|
86
|
+
console.log(chalk.gray('-'.repeat(85)));
|
|
83
87
|
|
|
84
88
|
for (const entry of entries) {
|
|
85
89
|
const statusColor =
|
|
86
90
|
entry.status === 'active'
|
|
87
91
|
? chalk.green
|
|
88
92
|
: entry.status === 'destroyed'
|
|
89
|
-
|
|
90
|
-
|
|
93
|
+
? chalk.gray
|
|
94
|
+
: chalk.yellow;
|
|
95
|
+
|
|
96
|
+
// Format last commit age
|
|
97
|
+
let commitAge = chalk.gray('-');
|
|
98
|
+
if (entry.lastCommit) {
|
|
99
|
+
commitAge = chalk.white(entry.lastCommit.age);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Format owner — show truncated session ID or '-'
|
|
103
|
+
let ownerStr = chalk.gray('-');
|
|
104
|
+
if (entry.owner) {
|
|
105
|
+
// Show last 8 chars of session ID for readability
|
|
106
|
+
const short = entry.owner.length > 8 ? '...' + entry.owner.slice(-8) : entry.owner;
|
|
107
|
+
ownerStr = chalk.gray(short);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Status suffix for merged branches
|
|
111
|
+
let statusStr = entry.status;
|
|
112
|
+
if (entry.merged && entry.status === 'active') {
|
|
113
|
+
statusStr = 'merged';
|
|
114
|
+
}
|
|
91
115
|
|
|
92
116
|
console.log(
|
|
93
|
-
entry.name.padEnd(
|
|
94
|
-
statusColor(
|
|
117
|
+
entry.name.padEnd(18) +
|
|
118
|
+
statusColor(statusStr.padEnd(12)) +
|
|
95
119
|
(entry.branch || '').padEnd(20) +
|
|
96
|
-
(
|
|
120
|
+
commitAge.padEnd(16 + 10) + // +10 for chalk color codes
|
|
121
|
+
ownerStr
|
|
97
122
|
);
|
|
98
123
|
}
|
|
99
124
|
|
|
@@ -117,18 +142,85 @@ function handleDestroy(options) {
|
|
|
117
142
|
}
|
|
118
143
|
}
|
|
119
144
|
|
|
145
|
+
function handleMerge(options) {
|
|
146
|
+
const { name, dryRun, deleteBranch = true, message } = options;
|
|
147
|
+
|
|
148
|
+
if (!name) {
|
|
149
|
+
console.error(chalk.red('Worktree name is required'));
|
|
150
|
+
console.log(
|
|
151
|
+
chalk.blue(
|
|
152
|
+
'Usage: caws worktree merge <name> [--dry-run] [--message "..."] [--no-delete-branch]'
|
|
153
|
+
)
|
|
154
|
+
);
|
|
155
|
+
process.exit(1);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (dryRun) {
|
|
159
|
+
console.log(chalk.cyan(`Dry-run merge preview for: ${name}`));
|
|
160
|
+
} else {
|
|
161
|
+
console.log(chalk.cyan(`Merging worktree: ${name}`));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const result = mergeWorktree(name, { dryRun, deleteBranch, message });
|
|
165
|
+
|
|
166
|
+
if (dryRun) {
|
|
167
|
+
if (result.conflicts.length > 0) {
|
|
168
|
+
console.log(chalk.yellow(`\nConflicts detected (${result.conflicts.length}):`));
|
|
169
|
+
for (const conflict of result.conflicts) {
|
|
170
|
+
console.log(chalk.yellow(` ${conflict}`));
|
|
171
|
+
}
|
|
172
|
+
console.log(
|
|
173
|
+
chalk.blue('\nResolve conflicts in the worktree before merging, or merge manually.')
|
|
174
|
+
);
|
|
175
|
+
} else {
|
|
176
|
+
console.log(chalk.green(`\nNo conflicts detected. Safe to merge.`));
|
|
177
|
+
console.log(chalk.blue(`Run without --dry-run to merge: caws worktree merge ${name}`));
|
|
178
|
+
}
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (result.merged) {
|
|
183
|
+
console.log(chalk.green(`Worktree '${name}' merged to ${result.baseBranch}`));
|
|
184
|
+
if (deleteBranch) {
|
|
185
|
+
console.log(chalk.gray(` Branch ${result.branch} deleted`));
|
|
186
|
+
}
|
|
187
|
+
} else {
|
|
188
|
+
console.log(chalk.red(`Merge failed for '${name}'`));
|
|
189
|
+
for (const conflict of result.conflicts) {
|
|
190
|
+
console.log(chalk.yellow(` ${conflict}`));
|
|
191
|
+
}
|
|
192
|
+
console.log(chalk.blue('\nThe worktree has been destroyed but the merge has conflicts.'));
|
|
193
|
+
console.log(chalk.blue('Resolve conflicts and commit manually:'));
|
|
194
|
+
console.log(chalk.gray(` git merge --no-ff ${result.branch}`));
|
|
195
|
+
console.log(chalk.gray(` # resolve conflicts`));
|
|
196
|
+
console.log(chalk.gray(` git commit -m "merge(worktree): ${name}"`));
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
120
200
|
function handlePrune(options) {
|
|
121
201
|
const maxAge = options.maxAge !== undefined ? parseInt(options.maxAge, 10) : 30;
|
|
122
202
|
|
|
123
203
|
console.log(chalk.cyan(`Pruning worktrees (max age: ${maxAge} days)`));
|
|
124
|
-
const
|
|
204
|
+
const result = pruneWorktrees({ maxAgeDays: maxAge });
|
|
205
|
+
|
|
206
|
+
// Handle both old return format (array) and new format (object with pruned/skipped)
|
|
207
|
+
const pruned = Array.isArray(result) ? result : result.pruned;
|
|
208
|
+
const skipped = Array.isArray(result) ? [] : result.skipped || [];
|
|
125
209
|
|
|
126
|
-
if (pruned.length === 0) {
|
|
210
|
+
if (pruned.length === 0 && skipped.length === 0) {
|
|
127
211
|
console.log(chalk.gray('Nothing to prune.'));
|
|
128
212
|
} else {
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
213
|
+
if (pruned.length > 0) {
|
|
214
|
+
console.log(chalk.green(`Pruned ${pruned.length} worktree(s):`));
|
|
215
|
+
for (const entry of pruned) {
|
|
216
|
+
console.log(chalk.gray(` - ${entry.name} (created ${entry.createdAt})`));
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
if (skipped.length > 0) {
|
|
220
|
+
console.log(chalk.yellow(`\nSkipped ${skipped.length} worktree(s) with recent activity:`));
|
|
221
|
+
for (const { name: skName, reason } of skipped) {
|
|
222
|
+
console.log(chalk.yellow(` - ${skName}: ${reason}`));
|
|
223
|
+
}
|
|
132
224
|
}
|
|
133
225
|
}
|
|
134
226
|
}
|
package/dist/index.js
CHANGED
|
@@ -52,6 +52,7 @@ const { planCommand } = require('./commands/plan');
|
|
|
52
52
|
const { worktreeCommand } = require('./commands/worktree');
|
|
53
53
|
const { sessionCommand } = require('./commands/session');
|
|
54
54
|
const { parallelCommand } = require('./commands/parallel');
|
|
55
|
+
const { verifyAcsCommand } = require('./commands/verify-acs');
|
|
55
56
|
|
|
56
57
|
// Import scaffold functionality
|
|
57
58
|
const { scaffoldProject, setScaffoldDependencies } = require('./scaffold');
|
|
@@ -380,6 +381,14 @@ worktreeCmd
|
|
|
380
381
|
.option('--force', 'Force removal even if worktree is dirty', false)
|
|
381
382
|
.action((name, options) => worktreeCommand('destroy', { name, ...options }));
|
|
382
383
|
|
|
384
|
+
worktreeCmd
|
|
385
|
+
.command('merge <name>')
|
|
386
|
+
.description('Merge a worktree branch back to base (destroy + merge + cleanup)')
|
|
387
|
+
.option('--dry-run', 'Preview conflicts without merging', false)
|
|
388
|
+
.option('--message <msg>', 'Custom merge commit message')
|
|
389
|
+
.option('--no-delete-branch', 'Keep the branch after merging')
|
|
390
|
+
.action((name, options) => worktreeCommand('merge', { name, ...options }));
|
|
391
|
+
|
|
383
392
|
worktreeCmd
|
|
384
393
|
.command('prune')
|
|
385
394
|
.description('Clean up stale worktree entries')
|
|
@@ -455,7 +464,7 @@ parallelCmd
|
|
|
455
464
|
parallelCmd
|
|
456
465
|
.command('merge')
|
|
457
466
|
.description('Merge all parallel branches back to base')
|
|
458
|
-
.option('--strategy <strategy>', 'Merge strategy:
|
|
467
|
+
.option('--strategy <strategy>', 'Merge strategy: merge or squash', 'merge')
|
|
459
468
|
.option('--dry-run', 'Preview merge without executing', false)
|
|
460
469
|
.option('--force', 'Force merge even with detected conflicts', false)
|
|
461
470
|
.action((options) => parallelCommand('merge', options));
|
|
@@ -482,6 +491,16 @@ program
|
|
|
482
491
|
.option('--fix', 'Apply automatic fixes', false)
|
|
483
492
|
.action(diagnoseCommand);
|
|
484
493
|
|
|
494
|
+
// Verify Acceptance Criteria command
|
|
495
|
+
program
|
|
496
|
+
.command('verify-acs')
|
|
497
|
+
.description('Verify acceptance criteria in specs are backed by test evidence')
|
|
498
|
+
.option('--spec-id <id>', 'Verify only this spec')
|
|
499
|
+
.option('--run', 'Actually run tests (default: collect-only)', false)
|
|
500
|
+
.option('--runner <runner>', 'Force test runner (pytest, jest, vitest, cargo, go)')
|
|
501
|
+
.option('--format <format>', 'Output format (text, json)', 'text')
|
|
502
|
+
.action(verifyAcsCommand);
|
|
503
|
+
|
|
485
504
|
// Evaluate command
|
|
486
505
|
program
|
|
487
506
|
.command('evaluate [spec-file]')
|
|
@@ -738,6 +757,7 @@ program.exitOverride((err) => {
|
|
|
738
757
|
'worktree',
|
|
739
758
|
'session',
|
|
740
759
|
'parallel',
|
|
760
|
+
'verify-acs',
|
|
741
761
|
];
|
|
742
762
|
const similar = findSimilarCommand(commandName, validCommands);
|
|
743
763
|
|
|
@@ -20,7 +20,9 @@ const {
|
|
|
20
20
|
// session-manager available if needed: require('../session/session-manager')
|
|
21
21
|
|
|
22
22
|
const PARALLEL_REGISTRY = '.caws/parallel.json';
|
|
23
|
-
|
|
23
|
+
// 'rebase' removed: it rewrites branch history, which is unsafe when
|
|
24
|
+
// worktrees are still active and other agents may depend on those commits.
|
|
25
|
+
const VALID_STRATEGIES = ['merge', 'squash'];
|
|
24
26
|
const NAME_REGEX = /^[a-zA-Z0-9_-]+$/;
|
|
25
27
|
|
|
26
28
|
/**
|
|
@@ -353,12 +355,7 @@ function mergeParallel(options = {}) {
|
|
|
353
355
|
|
|
354
356
|
for (const agent of activeAgents) {
|
|
355
357
|
try {
|
|
356
|
-
if (strategy === '
|
|
357
|
-
execFileSync('git', ['rebase', agent.branch], {
|
|
358
|
-
cwd: root,
|
|
359
|
-
stdio: 'pipe',
|
|
360
|
-
});
|
|
361
|
-
} else if (strategy === 'squash') {
|
|
358
|
+
if (strategy === 'squash') {
|
|
362
359
|
execFileSync('git', ['merge', '--squash', agent.branch], {
|
|
363
360
|
cwd: root,
|
|
364
361
|
stdio: 'pipe',
|
|
@@ -380,11 +377,7 @@ function mergeParallel(options = {}) {
|
|
|
380
377
|
try {
|
|
381
378
|
execFileSync('git', ['merge', '--abort'], { cwd: root, stdio: 'pipe' });
|
|
382
379
|
} catch {
|
|
383
|
-
|
|
384
|
-
execFileSync('git', ['rebase', '--abort'], { cwd: root, stdio: 'pipe' });
|
|
385
|
-
} catch {
|
|
386
|
-
// Already clean
|
|
387
|
-
}
|
|
380
|
+
// Already clean
|
|
388
381
|
}
|
|
389
382
|
failed.push({ name: agent.name, error: err.message });
|
|
390
383
|
}
|
|
@@ -106,7 +106,6 @@ async function scaffoldCursorHooks(projectDir, levels = ['safety', 'quality', 's
|
|
|
106
106
|
{ command: './.cursor/hooks/block-dangerous.sh' },
|
|
107
107
|
{ command: './.cursor/hooks/audit.sh' },
|
|
108
108
|
];
|
|
109
|
-
hooksConfig.hooks.beforeMCPExecution = [{ command: './.cursor/hooks/audit.sh' }];
|
|
110
109
|
hooksConfig.hooks.beforeReadFile = [{ command: './.cursor/hooks/scan-secrets.sh' }];
|
|
111
110
|
}
|
|
112
111
|
|
|
@@ -408,8 +408,25 @@ elif [ -f "scripts/quality-gates/run-quality-gates.js" ]; then
|
|
|
408
408
|
fi
|
|
409
409
|
# Option 3: CAWS CLI validation
|
|
410
410
|
elif command -v caws >/dev/null 2>&1; then
|
|
411
|
+
# In a worktree, validate only the associated spec to avoid false positives
|
|
412
|
+
CAWS_VALIDATE_ARGS="--quiet"
|
|
413
|
+
WORKTREE_BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "")
|
|
414
|
+
if [ -f ".caws/worktrees.json" ] && command -v node >/dev/null 2>&1; then
|
|
415
|
+
SPEC_ID=$(node -e "
|
|
416
|
+
try {
|
|
417
|
+
var reg = JSON.parse(require('fs').readFileSync('.caws/worktrees.json', 'utf8'));
|
|
418
|
+
var wt = Object.values(reg.worktrees || {}).find(function(w) {
|
|
419
|
+
return w.branch === '$WORKTREE_BRANCH';
|
|
420
|
+
});
|
|
421
|
+
if (wt && wt.specId) console.log(wt.specId);
|
|
422
|
+
} catch(e) {}
|
|
423
|
+
" 2>/dev/null || echo "")
|
|
424
|
+
if [ -n "$SPEC_ID" ]; then
|
|
425
|
+
CAWS_VALIDATE_ARGS="--quiet --spec-id $SPEC_ID"
|
|
426
|
+
fi
|
|
427
|
+
fi
|
|
411
428
|
echo "Running CAWS CLI validation..."
|
|
412
|
-
if caws validate
|
|
429
|
+
if caws validate $CAWS_VALIDATE_ARGS 2>/dev/null; then
|
|
413
430
|
echo "CAWS validation passed"
|
|
414
431
|
QUALITY_GATES_RAN=true
|
|
415
432
|
else
|
|
@@ -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
|
-
|
|
@@ -1,208 +1,152 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* @fileoverview CAWS Scope Guard
|
|
5
|
-
*
|
|
4
|
+
* @fileoverview CAWS Scope Guard (file-level)
|
|
5
|
+
* Checks whether a given file path is within scope of active specs.
|
|
6
|
+
* Used by Cursor hooks for scope validation on file attachments.
|
|
6
7
|
* @author @darianrosebrook
|
|
7
8
|
*/
|
|
8
9
|
|
|
9
10
|
const fs = require('fs');
|
|
10
|
-
const
|
|
11
|
+
const path = require('path');
|
|
11
12
|
|
|
12
13
|
/**
|
|
13
|
-
*
|
|
14
|
-
* @param {string} workingSpecPath - Path to working spec file
|
|
15
|
-
* @returns {Object} Scope validation results
|
|
14
|
+
* Convert a glob pattern to a RegExp, handling **, *, ?, [abc], {a,b}
|
|
16
15
|
*/
|
|
17
|
-
function
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
16
|
+
function globToRegex(pattern) {
|
|
17
|
+
let i = 0, re = '';
|
|
18
|
+
while (i < pattern.length) {
|
|
19
|
+
const c = pattern[i];
|
|
20
|
+
if (c === '*' && pattern[i + 1] === '*') {
|
|
21
|
+
re += '.*'; i += 2;
|
|
22
|
+
if (pattern[i] === '/') i++; // skip trailing slash after **
|
|
23
|
+
} else if (c === '*') {
|
|
24
|
+
re += '[^/]*'; i++;
|
|
25
|
+
} else if (c === '?') {
|
|
26
|
+
re += '[^/]'; i++;
|
|
27
|
+
} else if (c === '[') {
|
|
28
|
+
const end = pattern.indexOf(']', i);
|
|
29
|
+
if (end > i) { re += pattern.slice(i, end + 1); i = end + 1; }
|
|
30
|
+
else { re += '\\['; i++; }
|
|
31
|
+
} else if (c === '{') {
|
|
32
|
+
const end = pattern.indexOf('}', i);
|
|
33
|
+
if (end > i) {
|
|
34
|
+
const alts = pattern.slice(i + 1, end).split(',').map(a => a.trim());
|
|
35
|
+
re += '(?:' + alts.join('|') + ')'; i = end + 1;
|
|
36
|
+
} else { re += '\\{'; i++; }
|
|
37
|
+
} else if ('.+^$|()'.includes(c)) {
|
|
38
|
+
re += '\\' + c; i++;
|
|
39
|
+
} else {
|
|
40
|
+
re += c; i++;
|
|
39
41
|
}
|
|
42
|
+
}
|
|
43
|
+
return new RegExp(re);
|
|
44
|
+
}
|
|
40
45
|
|
|
41
|
-
|
|
42
|
-
console.log(`🔍 Checking containment for experimental code in: ${sandboxLocation}`);
|
|
46
|
+
const TERMINAL = new Set(['completed', 'closed', 'archived']);
|
|
43
47
|
|
|
44
|
-
|
|
45
|
-
|
|
48
|
+
/**
|
|
49
|
+
* Check if a file is within scope of active specs.
|
|
50
|
+
* @param {string} filePath - Relative path from project root
|
|
51
|
+
* @param {string} projectDir - Project root directory
|
|
52
|
+
* @returns {{inScope: boolean, reason: string}}
|
|
53
|
+
*/
|
|
54
|
+
function checkFileScope(filePath, projectDir) {
|
|
55
|
+
// Smart allowlist: root-level files, .caws/, .claude/ always pass
|
|
56
|
+
if (!filePath.includes('/') || filePath.startsWith('.caws/') || filePath.startsWith('.claude/')) {
|
|
57
|
+
return { inScope: true, reason: 'allowlisted path' };
|
|
58
|
+
}
|
|
46
59
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
return results;
|
|
50
|
-
}
|
|
60
|
+
const specFile = path.join(projectDir, '.caws/working-spec.yaml');
|
|
61
|
+
const specsDir = path.join(projectDir, '.caws/specs');
|
|
51
62
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
file.startsWith(sandboxLocation) ||
|
|
56
|
-
file.includes(`/${sandboxLocation}`) ||
|
|
57
|
-
file.includes(sandboxLocation);
|
|
58
|
-
|
|
59
|
-
if (isInSandbox) {
|
|
60
|
-
results.experimentalFiles.push(file);
|
|
61
|
-
console.log(`✅ Experimental file properly contained: ${file}`);
|
|
62
|
-
} else {
|
|
63
|
-
results.nonExperimentalFiles.push(file);
|
|
64
|
-
results.valid = false;
|
|
65
|
-
results.errors.push(`Experimental code found outside sandbox: ${file}`);
|
|
66
|
-
console.error(`❌ Experimental code outside sandbox: ${file}`);
|
|
67
|
-
}
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
// Check if experimental files actually exist
|
|
71
|
-
results.experimentalFiles.forEach((file) => {
|
|
72
|
-
if (!fs.existsSync(file)) {
|
|
73
|
-
results.warnings.push(`Experimental file not found (may have been deleted): ${file}`);
|
|
74
|
-
console.warn(`⚠️ Experimental file not found: ${file}`);
|
|
75
|
-
}
|
|
76
|
-
});
|
|
63
|
+
if (!fs.existsSync(specFile) && !fs.existsSync(specsDir)) {
|
|
64
|
+
return { inScope: true, reason: 'no specs found' };
|
|
65
|
+
}
|
|
77
66
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
return {
|
|
67
|
+
// Load all active specs
|
|
68
|
+
let yaml;
|
|
69
|
+
try { yaml = require('js-yaml'); } catch (_) {
|
|
70
|
+
return { inScope: true, reason: 'js-yaml not available' };
|
|
82
71
|
}
|
|
83
|
-
}
|
|
84
72
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
const staged = execSync('git diff --cached --name-only', { encoding: 'utf8' })
|
|
93
|
-
.split('\n')
|
|
94
|
-
.filter((file) => file.trim());
|
|
95
|
-
|
|
96
|
-
const modified = execSync('git diff --name-only', { encoding: 'utf8' })
|
|
97
|
-
.split('\n')
|
|
98
|
-
.filter((file) => file.trim());
|
|
99
|
-
|
|
100
|
-
// Combine and deduplicate
|
|
101
|
-
const allFiles = [...new Set([...staged, ...modified])];
|
|
102
|
-
|
|
103
|
-
// Filter out deleted files (they might still be in the diff)
|
|
104
|
-
return allFiles.filter((file) => {
|
|
105
|
-
try {
|
|
106
|
-
return fs.existsSync(file);
|
|
107
|
-
} catch {
|
|
108
|
-
return false;
|
|
73
|
+
const specs = [];
|
|
74
|
+
|
|
75
|
+
if (fs.existsSync(specFile)) {
|
|
76
|
+
try {
|
|
77
|
+
const s = yaml.load(fs.readFileSync(specFile, 'utf8'));
|
|
78
|
+
if (s && !TERMINAL.has(s.status)) {
|
|
79
|
+
specs.push({ source: 'working-spec', spec: s });
|
|
109
80
|
}
|
|
110
|
-
})
|
|
111
|
-
} catch (error) {
|
|
112
|
-
console.warn('⚠️ Could not get changed files from git:', error.message);
|
|
113
|
-
return [];
|
|
81
|
+
} catch (_) {}
|
|
114
82
|
}
|
|
115
|
-
}
|
|
116
83
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
if (!results.valid) {
|
|
127
|
-
console.error('\n❌ Experimental containment validation failed:');
|
|
128
|
-
results.errors.forEach((error) => {
|
|
129
|
-
console.error(` - ${error}`);
|
|
130
|
-
});
|
|
131
|
-
|
|
132
|
-
if (results.warnings.length > 0) {
|
|
133
|
-
console.warn('\n⚠️ Warnings:');
|
|
134
|
-
results.warnings.forEach((warning) => {
|
|
135
|
-
console.warn(` - ${warning}`);
|
|
136
|
-
});
|
|
84
|
+
if (fs.existsSync(specsDir)) {
|
|
85
|
+
for (const f of fs.readdirSync(specsDir).filter(f => f.endsWith('.yaml') || f.endsWith('.yml'))) {
|
|
86
|
+
try {
|
|
87
|
+
const s = yaml.load(fs.readFileSync(path.join(specsDir, f), 'utf8'));
|
|
88
|
+
if (s && !TERMINAL.has(s.status)) {
|
|
89
|
+
specs.push({ source: f, spec: s });
|
|
90
|
+
}
|
|
91
|
+
} catch (_) {}
|
|
137
92
|
}
|
|
93
|
+
}
|
|
138
94
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
console.error(' 3. Or disable experimental mode if this is production code');
|
|
95
|
+
if (specs.length === 0) {
|
|
96
|
+
return { inScope: true, reason: 'no active specs' };
|
|
97
|
+
}
|
|
143
98
|
|
|
144
|
-
|
|
99
|
+
// Check scope.out — any match blocks
|
|
100
|
+
for (const { source, spec } of specs) {
|
|
101
|
+
for (const pattern of (spec.scope?.out || [])) {
|
|
102
|
+
if (globToRegex(pattern).test(filePath)) {
|
|
103
|
+
return { inScope: false, reason: `out-of-scope in ${source} (pattern: ${pattern})` };
|
|
104
|
+
}
|
|
105
|
+
}
|
|
145
106
|
}
|
|
146
107
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
108
|
+
// Union all scope.in — must match at least one
|
|
109
|
+
const allIn = specs.flatMap(({ spec }) => spec.scope?.in || []);
|
|
110
|
+
if (allIn.length > 0) {
|
|
111
|
+
const found = allIn.some(pattern => globToRegex(pattern).test(filePath));
|
|
112
|
+
if (!found) {
|
|
113
|
+
return { inScope: false, reason: 'not in any active spec scope.in' };
|
|
114
|
+
}
|
|
152
115
|
}
|
|
153
116
|
|
|
154
|
-
|
|
155
|
-
console.log(` - Files in sandbox: ${results.experimentalFiles.length}`);
|
|
156
|
-
console.log(` - Files outside sandbox: ${results.nonExperimentalFiles.length}`);
|
|
117
|
+
return { inScope: true, reason: 'in scope' };
|
|
157
118
|
}
|
|
158
119
|
|
|
159
120
|
// CLI interface
|
|
160
121
|
if (require.main === module) {
|
|
161
122
|
const command = process.argv[2];
|
|
162
|
-
const
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
console.log(`
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
console.
|
|
177
|
-
|
|
178
|
-
if (results.errors.length > 0) {
|
|
179
|
-
console.log('\n❌ Errors:');
|
|
180
|
-
results.errors.forEach((error) => console.log(` - ${error}`));
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
if (results.warnings.length > 0) {
|
|
184
|
-
console.log('\n⚠️ Warnings:');
|
|
185
|
-
results.warnings.forEach((warning) => console.log(` - ${warning}`));
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
process.exit(results.valid ? 0 : 1);
|
|
189
|
-
break;
|
|
190
|
-
|
|
191
|
-
default:
|
|
192
|
-
console.log('CAWS Scope Guard');
|
|
193
|
-
console.log('Usage:');
|
|
194
|
-
console.log(' node scope-guard.js validate [spec-path]');
|
|
195
|
-
console.log(' node scope-guard.js check [spec-path]');
|
|
196
|
-
console.log('');
|
|
197
|
-
console.log('Examples:');
|
|
198
|
-
console.log(' node scope-guard.js validate');
|
|
199
|
-
console.log(' node scope-guard.js check .caws/working-spec.yaml');
|
|
123
|
+
const filePath = process.argv[3];
|
|
124
|
+
|
|
125
|
+
if (command === 'check' && filePath) {
|
|
126
|
+
// Resolve relative to cwd
|
|
127
|
+
const projectDir = process.cwd();
|
|
128
|
+
const rel = filePath.startsWith(projectDir)
|
|
129
|
+
? filePath.slice(projectDir.length + 1)
|
|
130
|
+
: filePath;
|
|
131
|
+
|
|
132
|
+
const result = checkFileScope(rel, projectDir);
|
|
133
|
+
if (result.inScope) {
|
|
134
|
+
console.log(`in_scope: ${result.reason}`);
|
|
135
|
+
process.exit(0);
|
|
136
|
+
} else {
|
|
137
|
+
console.error(`out_of_scope: ${result.reason}`);
|
|
200
138
|
process.exit(1);
|
|
139
|
+
}
|
|
140
|
+
} else {
|
|
141
|
+
console.log('CAWS Scope Guard');
|
|
142
|
+
console.log('Usage:');
|
|
143
|
+
console.log(' node scope-guard.js check <file-path>');
|
|
144
|
+
console.log('');
|
|
145
|
+
console.log('Examples:');
|
|
146
|
+
console.log(' node scope-guard.js check src/index.js');
|
|
147
|
+
console.log(' node scope-guard.js check packages/cli/lib/main.ts');
|
|
148
|
+
process.exit(1);
|
|
201
149
|
}
|
|
202
150
|
}
|
|
203
151
|
|
|
204
|
-
module.exports = {
|
|
205
|
-
checkExperimentalContainment,
|
|
206
|
-
validateExperimentalScope,
|
|
207
|
-
getChangedFiles,
|
|
208
|
-
};
|
|
152
|
+
module.exports = { checkFileScope, globToRegex };
|