@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.
Files changed (48) hide show
  1. package/dist/budget-derivation.js +15 -3
  2. package/dist/commands/specs.js +28 -15
  3. package/dist/commands/status.js +1 -1
  4. package/dist/commands/verify-acs.js +471 -0
  5. package/dist/commands/worktree.js +107 -15
  6. package/dist/index.js +21 -1
  7. package/dist/parallel/parallel-manager.js +5 -12
  8. package/dist/scaffold/cursor-hooks.js +0 -1
  9. package/dist/scaffold/git-hooks.js +18 -1
  10. package/dist/templates/.caws/tools/README.md +4 -7
  11. package/dist/templates/.caws/tools/scope-guard.js +115 -171
  12. package/dist/templates/.claude/hooks/audit.sh +25 -0
  13. package/dist/templates/.claude/hooks/block-dangerous.sh +39 -0
  14. package/dist/templates/.claude/hooks/lite-sprawl-check.sh +30 -2
  15. package/dist/templates/.claude/hooks/naming-check.sh +5 -2
  16. package/dist/templates/.claude/hooks/scope-guard.sh +66 -4
  17. package/dist/templates/.claude/hooks/session-log.sh +38 -5
  18. package/dist/templates/.claude/hooks/worktree-write-guard.sh +13 -1
  19. package/dist/templates/.claude/rules/worktree-isolation.md +36 -4
  20. package/dist/templates/.cursor/README.md +0 -9
  21. package/dist/templates/.cursor/hooks/audit.sh +1 -1
  22. package/dist/templates/.cursor/hooks/block-dangerous.sh +1 -0
  23. package/dist/templates/.cursor/hooks/scan-secrets.sh +8 -3
  24. package/dist/templates/.cursor/hooks.json +0 -8
  25. package/dist/templates/.vscode/launch.json +0 -12
  26. package/dist/utils/detection.js +37 -0
  27. package/dist/utils/project-analysis.js +0 -1
  28. package/dist/utils/spec-resolver.js +23 -10
  29. package/dist/validation/spec-validation.js +8 -0
  30. package/dist/worktree/worktree-manager.js +242 -6
  31. package/package.json +1 -1
  32. package/templates/.caws/tools/README.md +4 -7
  33. package/templates/.caws/tools/scope-guard.js +115 -171
  34. package/templates/.claude/hooks/audit.sh +25 -0
  35. package/templates/.claude/hooks/block-dangerous.sh +39 -0
  36. package/templates/.claude/hooks/lite-sprawl-check.sh +30 -2
  37. package/templates/.claude/hooks/naming-check.sh +5 -2
  38. package/templates/.claude/hooks/scope-guard.sh +66 -4
  39. package/templates/.claude/hooks/session-log.sh +38 -5
  40. package/templates/.claude/hooks/worktree-write-guard.sh +13 -1
  41. package/templates/.claude/rules/worktree-isolation.md +36 -4
  42. package/templates/.cursor/README.md +0 -9
  43. package/templates/.cursor/hooks/audit.sh +1 -1
  44. package/templates/.cursor/hooks/block-dangerous.sh +1 -0
  45. package/templates/.cursor/hooks/scan-secrets.sh +8 -3
  46. package/templates/.cursor/hooks.json +0 -8
  47. package/templates/.vscode/launch.json +0 -12
  48. 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(70)));
76
+ console.log(chalk.cyan('='.repeat(85)));
74
77
  console.log(
75
78
  chalk.bold(
76
- 'Name'.padEnd(20) +
79
+ 'Name'.padEnd(18) +
77
80
  'Status'.padEnd(12) +
78
81
  'Branch'.padEnd(20) +
79
- 'Scope'
82
+ 'Last Commit'.padEnd(16) +
83
+ 'Owner'
80
84
  )
81
85
  );
82
- console.log(chalk.gray('-'.repeat(70)));
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
- ? chalk.gray
90
- : chalk.yellow;
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(20) +
94
- statusColor(entry.status.padEnd(12)) +
117
+ entry.name.padEnd(18) +
118
+ statusColor(statusStr.padEnd(12)) +
95
119
  (entry.branch || '').padEnd(20) +
96
- (entry.scope || '-')
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 pruned = pruneWorktrees({ maxAgeDays: maxAge });
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
- console.log(chalk.green(`Pruned ${pruned.length} worktree(s):`));
130
- for (const entry of pruned) {
131
- console.log(chalk.gray(` - ${entry.name} (created ${entry.createdAt})`));
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: rebase, merge, or squash', 'merge')
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
- const VALID_STRATEGIES = ['merge', 'rebase', 'squash'];
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 === 'rebase') {
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
- try {
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 --quiet 2>/dev/null; then
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
- Enforces that experimental code stays within designated sandbox areas. Used by Cursor hooks for scope validation.
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
- # Validate experimental code containment
11
- node .caws/tools/scope-guard.js validate
10
+ # Check if a file is in scope
11
+ node .caws/tools/scope-guard.js check src/index.js
12
12
 
13
- # Check containment status
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
- * Enforces that experimental code stays within designated sandbox areas
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 { execSync } = require('child_process');
11
+ const path = require('path');
11
12
 
12
13
  /**
13
- * Check if experimental code is properly contained
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 checkExperimentalContainment(workingSpecPath = '.caws/working-spec.yaml') {
18
- try {
19
- if (!fs.existsSync(workingSpecPath)) {
20
- console.error('❌ Working spec not found:', workingSpecPath);
21
- return { valid: false, errors: ['Working spec not found'] };
22
- }
23
-
24
- const yaml = require('js-yaml');
25
- const spec = yaml.load(fs.readFileSync(workingSpecPath, 'utf8'));
26
-
27
- const results = {
28
- valid: true,
29
- errors: [],
30
- warnings: [],
31
- experimentalFiles: [],
32
- nonExperimentalFiles: [],
33
- };
34
-
35
- // Only check if experimental mode is enabled
36
- if (!spec.experimental_mode?.enabled) {
37
- console.log('ℹ️ Experimental mode not enabled - skipping containment check');
38
- return results;
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
- const sandboxLocation = spec.experimental_mode.sandbox_location || 'experimental/';
42
- console.log(`🔍 Checking containment for experimental code in: ${sandboxLocation}`);
46
+ const TERMINAL = new Set(['completed', 'closed', 'archived']);
43
47
 
44
- // Get list of changed files (this would typically come from git diff)
45
- const changedFiles = getChangedFiles();
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
- if (changedFiles.length === 0) {
48
- console.log('ℹ️ No files changed - skipping scope check');
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
- // Check each changed file
53
- changedFiles.forEach((file) => {
54
- const isInSandbox =
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
- return results;
79
- } catch (error) {
80
- console.error('❌ Error checking experimental containment:', error.message);
81
- return { valid: false, errors: [error.message] };
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
- * Get list of changed files from git
87
- * @returns {Array} List of changed file paths
88
- */
89
- function getChangedFiles() {
90
- try {
91
- // Get files that are staged or modified
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
- * Validate that experimental code follows containment rules
119
- * @param {string} workingSpecPath - Path to working spec file
120
- */
121
- function validateExperimentalScope(workingSpecPath = '.caws/working-spec.yaml') {
122
- console.log('🔍 Validating experimental code containment...');
123
-
124
- const results = checkExperimentalContainment(workingSpecPath);
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
- console.error('\n💡 To fix containment issues:');
140
- console.error(' 1. Move experimental code to the designated sandbox location');
141
- console.error(' 2. Update the sandbox_location in your working spec');
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
- process.exit(1);
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
- if (results.warnings.length > 0) {
148
- console.warn('\n⚠️ Experimental containment warnings:');
149
- results.warnings.forEach((warning) => {
150
- console.warn(` - ${warning}`);
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
- console.log('✅ Experimental code containment validated');
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 specPath = process.argv[3] || '.caws/working-spec.yaml';
163
-
164
- switch (command) {
165
- case 'validate':
166
- validateExperimentalScope(specPath);
167
- break;
168
-
169
- case 'check':
170
- const results = checkExperimentalContainment(specPath);
171
- console.log('\n📊 Containment Check Results:');
172
- console.log(` Valid: ${results.valid}`);
173
- console.log(` Experimental files: ${results.experimentalFiles.length}`);
174
- console.log(` Non-experimental files: ${results.nonExperimentalFiles.length}`);
175
- console.log(` Errors: ${results.errors.length}`);
176
- console.log(` Warnings: ${results.warnings.length}`);
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 };