@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
@@ -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(process.cwd(), specFile);
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(process.cwd(), SPECS_DIR, `${specId}.yaml`);
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(process.cwd(), SPECS_DIR, registry.specs[singleSpecId].path);
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(process.cwd(), LEGACY_SPEC);
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(process.cwd(), SPECS_REGISTRY);
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(process.cwd(), SPECS_DIR);
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(process.cwd(), specPath),
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(process.cwd(), LEGACY_SPEC);
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(process.cwd(), LEGACY_SPEC);
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(process.cwd(), specPath);
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: exists && inGit ? 'active' : exists ? 'orphaned' : 'missing',
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 (force) args.push('--force');
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 (force) {
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 (force) {
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@paths.design/caws-cli",
3
- "version": "9.1.1",
3
+ "version": "9.3.0",
4
4
  "description": "CAWS CLI - Coding Agent Workflow System command-line tools for spec management, quality gates, and AI-assisted development",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -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
-