@paths.design/caws-cli 10.0.1 → 10.2.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 (60) hide show
  1. package/README.md +13 -5
  2. package/dist/budget-derivation.js +221 -74
  3. package/dist/commands/agents.js +124 -0
  4. package/dist/commands/evaluate.js +26 -12
  5. package/dist/commands/gates.js +31 -4
  6. package/dist/commands/init.js +7 -4
  7. package/dist/commands/iterate.js +7 -3
  8. package/dist/commands/scope.js +264 -0
  9. package/dist/commands/sidecar.js +6 -3
  10. package/dist/commands/specs.js +359 -4
  11. package/dist/commands/status.js +29 -4
  12. package/dist/commands/templates.js +0 -8
  13. package/dist/commands/validate.js +34 -13
  14. package/dist/commands/verify-acs.js +25 -10
  15. package/dist/commands/waivers.js +147 -5
  16. package/dist/commands/worktree.js +200 -4
  17. package/dist/gates/budget-limit.js +6 -1
  18. package/dist/gates/scope-boundary.js +26 -7
  19. package/dist/gates/spec-completeness.js +8 -1
  20. package/dist/index.js +56 -0
  21. package/dist/policy/PolicyManager.js +14 -7
  22. package/dist/session/session-manager.js +34 -0
  23. package/dist/templates/.caws/schemas/policy.schema.json +101 -34
  24. package/dist/templates/.caws/schemas/scope.schema.json +3 -3
  25. package/dist/templates/.caws/schemas/waivers.schema.json +91 -21
  26. package/dist/templates/.caws/schemas/working-spec.schema.json +253 -89
  27. package/dist/templates/.caws/templates/working-spec.template.yml +3 -1
  28. package/dist/templates/.caws/tools/scope-guard.js +66 -15
  29. package/dist/templates/.claude/README.md +1 -1
  30. package/dist/templates/.claude/hooks/protected-paths.sh +39 -0
  31. package/dist/templates/.claude/hooks/scope-guard.sh +106 -27
  32. package/dist/templates/.claude/hooks/worktree-write-guard.sh +96 -3
  33. package/dist/templates/.claude/rules/worktree-isolation.md +21 -3
  34. package/dist/templates/.claude/settings.json +5 -0
  35. package/dist/templates/CLAUDE.md +56 -0
  36. package/dist/templates/agents.md +47 -0
  37. package/dist/utils/agent-display.js +210 -0
  38. package/dist/utils/agent-session.js +142 -0
  39. package/dist/utils/event-log.js +584 -0
  40. package/dist/utils/event-renderer.js +521 -0
  41. package/dist/utils/schema-validator.js +10 -2
  42. package/dist/utils/working-state.js +25 -0
  43. package/dist/validation/spec-validation.js +102 -9
  44. package/dist/waivers-manager.js +84 -0
  45. package/dist/worktree/worktree-manager.js +593 -26
  46. package/package.json +5 -4
  47. package/templates/.caws/schemas/policy.schema.json +101 -34
  48. package/templates/.caws/schemas/scope.schema.json +3 -3
  49. package/templates/.caws/schemas/waivers.schema.json +91 -21
  50. package/templates/.caws/schemas/working-spec.schema.json +253 -89
  51. package/templates/.caws/templates/working-spec.template.yml +3 -1
  52. package/templates/.caws/tools/scope-guard.js +66 -15
  53. package/templates/.claude/README.md +1 -1
  54. package/templates/.claude/hooks/protected-paths.sh +39 -0
  55. package/templates/.claude/hooks/scope-guard.sh +106 -27
  56. package/templates/.claude/hooks/worktree-write-guard.sh +96 -3
  57. package/templates/.claude/rules/worktree-isolation.md +21 -3
  58. package/templates/.claude/settings.json +5 -0
  59. package/templates/CLAUDE.md +56 -0
  60. package/templates/agents.md +47 -0
@@ -66,10 +66,12 @@ async function waiversCommand(subcommand = 'list', options = {}) {
66
66
  return await showWaiver(options.id, options);
67
67
  case 'revoke':
68
68
  return await revokeWaiver(options.id, options);
69
+ case 'prune':
70
+ return await pruneWaivers(options);
69
71
  default:
70
72
  throw new Error(
71
73
  `Unknown waiver subcommand: ${subcommand}.\n` +
72
- 'Available subcommands: create, list, show, revoke'
74
+ 'Available subcommands: create, list, show, revoke, prune'
73
75
  );
74
76
  }
75
77
  },
@@ -178,9 +180,8 @@ async function createWaiver(options) {
178
180
  const { createValidator, getSchemaPath } = require('../utils/schema-validator');
179
181
  const schemaPath = getSchemaPath('waivers.schema.json', process.cwd());
180
182
  const validate = createValidator(schemaPath);
181
- // waivers.schema.json uses patternProperties keyed by waiver ID
182
- const waiverDoc = { [waiverId]: waiver };
183
- const result = validate(waiverDoc);
183
+ // waivers.schema.json validates a single waiver document directly (CAWSFIX-17)
184
+ const result = validate(waiver);
184
185
  if (!result.valid) {
185
186
  console.warn(chalk.yellow('Waiver has schema violations:'));
186
187
  result.errors.forEach((err) => {
@@ -248,7 +249,7 @@ async function listWaivers(_options) {
248
249
 
249
250
  // Validate each loaded waiver against schema
250
251
  if (waiverValidate && waiver && waiver.id) {
251
- const result = waiverValidate({ [waiver.id]: waiver });
252
+ const result = waiverValidate(waiver);
252
253
  if (!result.valid) {
253
254
  console.warn(chalk.yellow(`Schema warning for ${file}:`));
254
255
  result.errors.forEach((err) => {
@@ -405,6 +406,147 @@ async function revokeWaiver(waiverId, options) {
405
406
  console.log(` Reason: ${waiver.revocation_reason}\n`);
406
407
  }
407
408
 
409
+ /**
410
+ * Prune expired waivers.
411
+ *
412
+ * Behavior per CAWSFIX-04 AC3-A6:
413
+ * --expired : dry run — list prunable waivers, no disk changes,
414
+ * no events emitted. Exit 0.
415
+ * --expired --apply : transition each prunable waiver from
416
+ * `status: active` to `status: expired` in place
417
+ * (file is updated, NOT deleted, to preserve the
418
+ * audit trail) and append a `waiver_pruned` event
419
+ * to the event log for each.
420
+ *
421
+ * A waiver is "prunable" iff `status === 'active'` AND `expires_at < now`.
422
+ * Waivers already `expired` or `revoked` are untouched (A4). Non-expired
423
+ * active waivers are untouched (A5). Empty registries exit 0 with a
424
+ * friendly message (A6).
425
+ *
426
+ * @param {object} options
427
+ * @param {boolean} [options.expired] — currently the only prune criterion
428
+ * @param {boolean} [options.apply] — if false, dry-run (default)
429
+ * @param {boolean} [options.json] — machine-readable output
430
+ */
431
+ async function pruneWaivers(options = {}) {
432
+ if (!options.expired) {
433
+ console.error(chalk.red('\n`caws waivers prune` requires --expired\n'));
434
+ console.log(chalk.yellow('Usage: caws waivers prune --expired [--apply]\n'));
435
+ process.exit(1);
436
+ }
437
+
438
+ const waiversManager = new WaiversManager();
439
+
440
+ // Fast path: no waivers directory or no waiver files at all.
441
+ const allWaivers = waiversManager.enumerateWaiverFiles();
442
+ if (allWaivers.length === 0) {
443
+ const msg = 'No active waivers to check.';
444
+ if (options.json) {
445
+ console.log(JSON.stringify({ status: 'ok', pruned: [], message: msg }));
446
+ } else {
447
+ console.log(chalk.yellow(`\n${msg}\n`));
448
+ }
449
+ return { pruned: [], applied: false };
450
+ }
451
+
452
+ const candidates = waiversManager.findExpiredWaivers();
453
+
454
+ // No prunable waivers — report and return.
455
+ if (candidates.length === 0) {
456
+ const msg = 'No expired waivers to prune.';
457
+ if (options.json) {
458
+ console.log(JSON.stringify({ status: 'ok', pruned: [], message: msg }));
459
+ } else {
460
+ console.log(chalk.green(`\n${msg}\n`));
461
+ }
462
+ return { pruned: [], applied: false };
463
+ }
464
+
465
+ const apply = Boolean(options.apply);
466
+
467
+ if (!apply) {
468
+ // Dry run — report only, no disk changes, no events.
469
+ if (options.json) {
470
+ console.log(
471
+ JSON.stringify({
472
+ status: 'dry_run',
473
+ applied: false,
474
+ pruned: candidates.map((c) => ({ id: c.id, expires_at: c.expires_at })),
475
+ })
476
+ );
477
+ } else {
478
+ console.log(chalk.yellow(`\nDry run — ${candidates.length} waiver(s) would be pruned:\n`));
479
+ candidates.forEach((c) => {
480
+ console.log(` ${chalk.bold(c.id)} expired at ${c.expires_at}`);
481
+ });
482
+ console.log(chalk.dim('\nRe-run with --apply to transition status to expired.\n'));
483
+ }
484
+ return { pruned: candidates, applied: false };
485
+ }
486
+
487
+ // Apply path — transition each file and emit events.
488
+ const { appendEvent } = require('../utils/event-log');
489
+ const pruned = [];
490
+ const failures = [];
491
+
492
+ for (const c of candidates) {
493
+ try {
494
+ const updated = waiversManager.markWaiverExpired(c.path);
495
+
496
+ // Emit waiver_pruned event. spec_id is optional for this event
497
+ // (waivers may or may not be tied to a spec). Using the waiver's
498
+ // applies_to field when available, otherwise omitting.
499
+ const spec_id =
500
+ updated && typeof updated.applies_to === 'string' && updated.applies_to.trim() !== ''
501
+ ? updated.applies_to
502
+ : undefined;
503
+
504
+ await appendEvent({
505
+ actor: 'cli',
506
+ event: 'waiver_pruned',
507
+ spec_id,
508
+ data: {
509
+ waiver_id: c.id,
510
+ expires_at: c.expires_at,
511
+ previous_status: 'active',
512
+ new_status: 'expired',
513
+ },
514
+ });
515
+
516
+ pruned.push({ id: c.id, expires_at: c.expires_at });
517
+ } catch (err) {
518
+ failures.push({ id: c.id, error: err.message });
519
+ }
520
+ }
521
+
522
+ if (options.json) {
523
+ console.log(
524
+ JSON.stringify({
525
+ status: failures.length === 0 ? 'ok' : 'partial',
526
+ applied: true,
527
+ pruned,
528
+ failures,
529
+ })
530
+ );
531
+ } else {
532
+ console.log(
533
+ chalk.green(`\nPruned ${pruned.length} expired waiver(s):\n`)
534
+ );
535
+ pruned.forEach((p) => {
536
+ console.log(` ${chalk.bold(p.id)} (was expired at ${p.expires_at})`);
537
+ });
538
+ if (failures.length > 0) {
539
+ console.log(chalk.red(`\n${failures.length} failure(s):\n`));
540
+ failures.forEach((f) => {
541
+ console.log(` ${chalk.bold(f.id)}: ${f.error}`);
542
+ });
543
+ }
544
+ console.log();
545
+ }
546
+
547
+ return { pruned, applied: true, failures };
548
+ }
549
+
408
550
  /**
409
551
  * Add waiver to active waivers file for quality gates integration
410
552
  */
@@ -12,8 +12,14 @@ const {
12
12
  mergeWorktree,
13
13
  pruneWorktrees,
14
14
  repairWorktrees,
15
+ loadRegistry,
16
+ saveRegistry,
17
+ assertWorktreeOwnership,
18
+ getRepoRoot,
19
+ findFeatureSpecPathFromCwd,
20
+ autoActivateBoundSpec,
15
21
  } = require('../worktree/worktree-manager');
16
- const { getAgentSessionId } = require('../utils/agent-session');
22
+ const { getAgentSessionId, refreshAgentClaim } = require('../utils/agent-session');
17
23
 
18
24
  /**
19
25
  * Handle worktree subcommands
@@ -35,9 +41,13 @@ async function worktreeCommand(subcommand, options = {}) {
35
41
  return handlePrune(options);
36
42
  case 'repair':
37
43
  return handleRepair(options);
44
+ case 'bind':
45
+ return handleBind(options);
46
+ case 'claim':
47
+ return handleClaim(options);
38
48
  default:
39
49
  console.error(chalk.red(`Unknown worktree subcommand: ${subcommand}`));
40
- console.log(chalk.blue('Available: create, list, destroy, merge, prune, repair'));
50
+ console.log(chalk.blue('Available: create, list, destroy, merge, prune, repair, bind, claim'));
41
51
  process.exit(1);
42
52
  }
43
53
  } catch (error) {
@@ -169,7 +179,7 @@ function handleDestroy(options) {
169
179
  }
170
180
 
171
181
  function handleMerge(options) {
172
- const { name, dryRun, deleteBranch = true, message } = options;
182
+ const { name, dryRun, deleteBranch = true, message, takeover = false } = options;
173
183
 
174
184
  if (!name) {
175
185
  console.error(chalk.red('Worktree name is required'));
@@ -187,7 +197,7 @@ function handleMerge(options) {
187
197
  console.log(chalk.cyan(`Merging worktree: ${name}`));
188
198
  }
189
199
 
190
- const result = mergeWorktree(name, { dryRun, deleteBranch, message });
200
+ const result = mergeWorktree(name, { dryRun, deleteBranch, message, takeover });
191
201
 
192
202
  if (dryRun) {
193
203
  if (result.conflicts.length > 0) {
@@ -303,4 +313,190 @@ function handleRepair(options) {
303
313
  }
304
314
  }
305
315
 
316
+ function handleBind(options) {
317
+ const path = require('path');
318
+ const fs = require('fs-extra');
319
+ const yaml = require('js-yaml');
320
+ const { specId, name, takeover } = options;
321
+
322
+ if (!specId) {
323
+ console.error(chalk.red('Spec ID is required'));
324
+ console.log(chalk.blue('Usage: caws worktree bind <spec-id> [--name <worktree-name>] [--takeover]'));
325
+ process.exit(1);
326
+ }
327
+
328
+ // Determine worktree name: from option, or detect from cwd
329
+ let worktreeName = name;
330
+ if (!worktreeName) {
331
+ const root = getRepoRoot();
332
+ const cwd = process.cwd();
333
+ const worktreesBase = path.join(root, '.caws', 'worktrees');
334
+
335
+ if (cwd.startsWith(worktreesBase + path.sep)) {
336
+ const relative = path.relative(worktreesBase, cwd);
337
+ worktreeName = relative.split(path.sep)[0];
338
+ }
339
+ }
340
+
341
+ if (!worktreeName) {
342
+ console.error(chalk.red('Could not determine worktree name.'));
343
+ console.log(chalk.blue('Either run this from inside a worktree, or pass --name <worktree-name>'));
344
+ process.exit(1);
345
+ }
346
+
347
+ const root = getRepoRoot();
348
+ // CAWSFIX-32: probe the registry for existence first, but do NOT load
349
+ // the full registry into a variable yet — assertWorktreeOwnership may
350
+ // mutate it on takeover, and we'd overwrite the takeover write later
351
+ // with our stale in-memory copy. Re-load after the ownership check.
352
+ const probe = loadRegistry(root);
353
+ if (!probe.worktrees || !probe.worktrees[worktreeName]) {
354
+ console.error(chalk.red(`Worktree '${worktreeName}' not found in registry.`));
355
+ console.log(chalk.blue('Run: caws worktree list to see available worktrees'));
356
+ process.exit(1);
357
+ return;
358
+ }
359
+
360
+ // CAWSFIX-32: assert ownership BEFORE any registry/spec mutation.
361
+ // Foreign claim soft-blocks unless --takeover is supplied, mirroring
362
+ // `caws worktree claim`.
363
+ const ownership = assertWorktreeOwnership(root, worktreeName, {
364
+ allowTakeover: !!takeover,
365
+ takeoverCommandHint: `caws worktree bind ${specId} --name ${worktreeName} --takeover`,
366
+ });
367
+ if (!ownership.allowed) {
368
+ console.error(chalk.yellow(ownership.warning));
369
+ process.exit(1);
370
+ return;
371
+ }
372
+
373
+ // Now load the registry fresh — assertWorktreeOwnership may have
374
+ // rewritten owner + appended prior_owners on takeover.
375
+ const registry = loadRegistry(root);
376
+
377
+ // Load the spec file. CAWSFIX-25 / D8: when bind runs from inside a
378
+ // worktree, prefer the worktree's own .caws/specs/ copy so specs that
379
+ // live only on a feature branch (never mirrored to main) can be bound.
380
+ const specPath = findFeatureSpecPathFromCwd(root, specId, process.cwd());
381
+ if (!specPath) {
382
+ console.error(chalk.red(`Spec '${specId}' not found in .caws/specs/ (checked worktree-local first, then main)`));
383
+ console.log(chalk.blue('Run: caws specs list to see available specs'));
384
+ process.exit(1);
385
+ }
386
+
387
+ const specContent = fs.readFileSync(specPath, 'utf8');
388
+ const specData = yaml.load(specContent);
389
+
390
+ // Warn if spec already bound to a different worktree
391
+ if (specData.worktree && specData.worktree !== worktreeName) {
392
+ console.log(chalk.yellow(`Warning: Spec '${specId}' is currently bound to worktree '${specData.worktree}'.`));
393
+ console.log(chalk.yellow(`Rebinding to '${worktreeName}'.`));
394
+ }
395
+
396
+ // Update registry side: set specId on the worktree entry
397
+ registry.worktrees[worktreeName].specId = specId;
398
+ saveRegistry(root, registry);
399
+
400
+ // Update spec side: set worktree field. CAWSFIX-24 / D10: skip the write
401
+ // if the parsed spec already declares the target worktree — yaml.dump
402
+ // would otherwise re-wrap folded scalars with no semantic change.
403
+ if (specData.worktree !== worktreeName) {
404
+ specData.worktree = worktreeName;
405
+ const updatedYaml = yaml.dump(specData, { lineWidth: 120, noRefs: true });
406
+ fs.writeFileSync(specPath, updatedYaml, 'utf8');
407
+ }
408
+
409
+ // CAWSFIX-23: activate the spec if it's still at draft — bind is the
410
+ // lifecycle signal that work is starting. CAWSFIX-25 / D8: pass the
411
+ // resolved specPath so the flip lands on whichever copy (worktree-local
412
+ // or main) was actually bound.
413
+ const activated = autoActivateBoundSpec(root, specId, specPath);
414
+
415
+ // CAWSFIX-32: heartbeat the current session into agents.json so the
416
+ // bound worktree+spec context is visible to other agents and to
417
+ // `caws status` / `caws agents list`.
418
+ refreshAgentClaim(root, { worktree: worktreeName, specId });
419
+
420
+ console.log(chalk.green(`Binding established`));
421
+ console.log(chalk.gray(` Worktree: ${worktreeName} -> spec: ${specId}`));
422
+ console.log(chalk.gray(` Spec: ${specId} -> worktree: ${worktreeName}`));
423
+ if (activated) {
424
+ console.log(chalk.gray(` Status: draft -> active`));
425
+ }
426
+ console.log(chalk.gray(` Registry: ${path.join(root, '.caws', 'worktrees.json')}`));
427
+ console.log(chalk.gray(` Spec file: ${specPath}`));
428
+ }
429
+
430
+ /**
431
+ * CAWSFIX-31: caws worktree claim <name> [--takeover]
432
+ *
433
+ * Without --takeover: read-only context surface. Prints the current
434
+ * claim (owner, heartbeat, session-log pointers) and exits 1 when the
435
+ * worktree is owned by a different session id. Modifies nothing.
436
+ *
437
+ * With --takeover: rewrites the owner to the current session id,
438
+ * appends the prior owner to the worktree entry's prior_owners audit
439
+ * array (including their lastSeen-at-takeover from agents.json), and
440
+ * exits 0.
441
+ *
442
+ * Same session-id silent-proceed: if the current session already owns
443
+ * the worktree, the command is a successful no-op (exit 0, brief
444
+ * confirmation).
445
+ */
446
+ function handleClaim(options) {
447
+ const { name, takeover } = options;
448
+
449
+ if (!name) {
450
+ console.error(chalk.red('Worktree name is required'));
451
+ console.log(chalk.blue('Usage: caws worktree claim <name> [--takeover]'));
452
+ process.exit(1);
453
+ }
454
+
455
+ const root = getRepoRoot();
456
+ const registry = loadRegistry(root);
457
+ if (!registry.worktrees || !registry.worktrees[name]) {
458
+ console.error(chalk.red(`Worktree '${name}' not found in registry.`));
459
+ console.log(chalk.blue('Run: caws worktree list to see available worktrees'));
460
+ process.exit(1);
461
+ }
462
+
463
+ const result = assertWorktreeOwnership(root, name, {
464
+ allowTakeover: !!takeover,
465
+ takeoverCommandHint: `caws worktree claim ${name} --takeover`,
466
+ });
467
+
468
+ if (!result.allowed) {
469
+ // Foreign claim, no --takeover. Print the structured warning that
470
+ // assertWorktreeOwnership built (claimer, heartbeat, session-log
471
+ // pointers, takeover hint) and exit 1. Modifies nothing.
472
+ console.error(chalk.yellow(result.warning));
473
+ process.exit(1);
474
+ }
475
+
476
+ // allowed = true. Three sub-cases:
477
+ // 1. takeover happened (priorOwner present)
478
+ // 2. orphan-log soft notice (warning present, no priorOwner)
479
+ // 3. clean / same-session (no warning)
480
+ if (result.priorOwner) {
481
+ console.log(
482
+ chalk.green(`Took over worktree '${name}'.`)
483
+ );
484
+ console.log(
485
+ chalk.gray(
486
+ ` Prior owner ${result.priorOwner.sessionId}:${result.priorOwner.platform || 'unknown'} recorded in prior_owners audit.`
487
+ )
488
+ );
489
+ } else if (result.warning) {
490
+ console.log(chalk.yellow(result.warning));
491
+ console.log(chalk.green(`Proceeding — no CAWS-tracked claim on '${name}'.`));
492
+ } else {
493
+ const entry = registry.worktrees[name];
494
+ if (entry.owner === getAgentSessionId(root)) {
495
+ console.log(chalk.green(`Worktree '${name}' is already claimed by the current session.`));
496
+ } else {
497
+ console.log(chalk.green(`Worktree '${name}' has no active claim.`));
498
+ }
499
+ }
500
+ }
501
+
306
502
  module.exports = { worktreeCommand };
@@ -60,8 +60,13 @@ async function run({ stagedFiles, spec, _policy, projectRoot, riskTier, context
60
60
 
61
61
  // Budget limits apply to changes, not to the entire repo.
62
62
  // In cli context with all tracked files, budget check is meaningless.
63
+ // Return 'skipped' (not 'pass') so the pipeline summary counts this gate under
64
+ // `skipped`, matching pipeline.js semantics for gates that did not actually run.
63
65
  if (context === 'cli') {
64
- return { status: 'pass', messages: ['Budget check skipped in CLI context (budget applies to changes, not full repo)'] };
66
+ return {
67
+ status: 'skipped',
68
+ messages: ['Budget check skipped in CLI context (budget applies to changes, not full repo)'],
69
+ };
65
70
  }
66
71
 
67
72
  // Build a minimal spec for deriveBudget
@@ -21,14 +21,27 @@ function matchesAny(filePath, patterns) {
21
21
  }
22
22
 
23
23
  /**
24
- * Check if a file is an infrastructure file that always passes scope checks.
25
- * Root-level files are exempt UNLESS they match an explicit scope.out pattern.
24
+ * Check if a file is infrastructure or lives in a policy-declared
25
+ * non-governed zone. Exempt files bypass both scope.in and scope.out.
26
+ *
26
27
  * @param {string} filePath - File path to check
27
- * @returns {boolean} Whether the file is exempt from scope.in checks
28
+ * @param {string[]} [nonGovernedZones=[]] - Glob patterns from
29
+ * policy.non_governed_zones. Paths matching any pattern are exempt
30
+ * from scope enforcement entirely. (CAWSFIX-26 / D9)
31
+ * @returns {boolean} Whether the file is exempt from scope checks
28
32
  */
29
- function isExempt(filePath) {
33
+ function isExempt(filePath, nonGovernedZones = []) {
30
34
  // .caws and .claude directories always pass (infrastructure)
31
35
  if (filePath.startsWith('.caws/') || filePath.startsWith('.claude/')) return true;
36
+
37
+ // Policy-declared non-governed zones short-circuit scope enforcement.
38
+ // Intentionally wins over scope.out: the contract is that these
39
+ // subtrees are outside the governance model, not merely excluded
40
+ // from one spec's scope.
41
+ if (nonGovernedZones.length > 0 && matchesAny(filePath, nonGovernedZones)) {
42
+ return true;
43
+ }
44
+
32
45
  return false;
33
46
  }
34
47
 
@@ -47,14 +60,20 @@ function isRootLevel(filePath) {
47
60
  * @param {Object} params - Gate parameters
48
61
  * @param {string[]} params.stagedFiles - Staged file paths
49
62
  * @param {Object} params.spec - Working spec with scope.in/scope.out
63
+ * @param {Object} [params.policy] - Optional CAWS policy. Reads
64
+ * policy.non_governed_zones for path exemption (CAWSFIX-26 / D9).
65
+ * When absent or the field is empty, only infra dirs are exempt.
50
66
  * @returns {Promise<Object>} Gate result with status and messages
51
67
  */
52
- async function run({ stagedFiles, spec }) {
68
+ async function run({ stagedFiles, spec, policy }) {
53
69
  const messages = [];
54
70
  const violations = [];
55
71
 
56
72
  const scopeIn = spec?.scope?.in || [];
57
73
  const scopeOut = spec?.scope?.out || [];
74
+ const nonGovernedZones = Array.isArray(policy?.non_governed_zones)
75
+ ? policy.non_governed_zones
76
+ : [];
58
77
 
59
78
  // If no scope defined, pass
60
79
  if (scopeIn.length === 0 && scopeOut.length === 0) {
@@ -62,8 +81,8 @@ async function run({ stagedFiles, spec }) {
62
81
  }
63
82
 
64
83
  for (const file of stagedFiles) {
65
- // Infrastructure dirs are always exempt
66
- if (isExempt(file)) continue;
84
+ // Infrastructure dirs AND policy-declared non-governed zones are exempt.
85
+ if (isExempt(file, nonGovernedZones)) continue;
67
86
 
68
87
  // Check scope.out first (explicit exclusion) — applies to ALL files including root-level
69
88
  if (scopeOut.length > 0 && matchesAny(file, scopeOut)) {
@@ -42,10 +42,17 @@ async function run({ projectRoot, spec }) {
42
42
  return { status: 'fail', messages: ['Resolved spec is empty'] };
43
43
  }
44
44
 
45
- // Try to find and load the schema
45
+ // Try to find and load the schema.
46
+ // CAWSFIX-03: The canonical project-level schema lives at
47
+ // `.caws/working-spec.schema.json` (flat layout, matches the pattern used for
48
+ // policy.schema.json and waiver.schema.json). Older projects may still have
49
+ // a `.caws/schemas/` directory from scaffolded templates, so we check both.
50
+ // Bundled fallbacks cover packaged installs.
46
51
  const schemaPaths = [
52
+ path.join(projectRoot, '.caws', 'working-spec.schema.json'),
47
53
  path.join(projectRoot, '.caws', 'schemas', 'working-spec.schema.json'),
48
54
  path.join(projectRoot, 'node_modules', '@caws', 'cli', 'templates', '.caws', 'schemas', 'working-spec.schema.json'),
55
+ path.join(projectRoot, 'node_modules', '@paths.design', 'caws-cli', 'templates', '.caws', 'schemas', 'working-spec.schema.json'),
49
56
  ];
50
57
 
51
58
  let schema = null;
package/dist/index.js CHANGED
@@ -55,6 +55,7 @@ const { sessionCommand } = require('./commands/session');
55
55
  const { parallelCommand } = require('./commands/parallel');
56
56
  const { verifyAcsCommand } = require('./commands/verify-acs');
57
57
  const { sidecarCommand } = require('./commands/sidecar');
58
+ const { scopeCommand } = require('./commands/scope');
58
59
 
59
60
  // Import scaffold functionality
60
61
  const { scaffoldProject, setScaffoldDependencies } = require('./scaffold');
@@ -232,6 +233,11 @@ specsCmd
232
233
  .description('Close a completed spec (removes scope enforcement)')
233
234
  .action((id) => specsCommand('close', { id }));
234
235
 
236
+ specsCmd
237
+ .command('archive <id>')
238
+ .description('Archive a spec — move to .caws/specs/.archive/ and flip status to archived')
239
+ .action((id) => specsCommand('archive', { id }));
240
+
235
241
  specsCmd
236
242
  .command('conflicts')
237
243
  .description('Check for scope conflicts between specs')
@@ -363,6 +369,7 @@ worktreeCmd
363
369
  .option('--dry-run', 'Preview conflicts without merging', false)
364
370
  .option('--message <msg>', 'Custom merge commit message')
365
371
  .option('--no-delete-branch', 'Keep the branch after merging')
372
+ .option('--takeover', 'Force takeover of a foreign worktree claim (writes prior_owners audit)', false)
366
373
  .action((name, options) => worktreeCommand('merge', { name, ...options }));
367
374
 
368
375
  worktreeCmd
@@ -380,6 +387,45 @@ worktreeCmd
380
387
  .option('--force', 'Allow pruning entries owned by other sessions', false)
381
388
  .action((options) => worktreeCommand('repair', options));
382
389
 
390
+ worktreeCmd
391
+ .command('bind <spec-id>')
392
+ .description('Bind a spec to this worktree (fixes mutual reference)')
393
+ .option('--name <name>', 'Worktree name (auto-detected from cwd if omitted)')
394
+ .option('--takeover', 'Force takeover of a foreign worktree claim (writes prior_owners audit)', false)
395
+ .action((specId, options) => worktreeCommand('bind', { specId, ...options }));
396
+
397
+ worktreeCmd
398
+ .command('claim <name>')
399
+ .description('Claim worktree session ownership (read-only without --takeover)')
400
+ .option('--takeover', 'Force takeover of a foreign claim (writes prior_owners audit)', false)
401
+ .action((name, options) => worktreeCommand('claim', { name, ...options }));
402
+
403
+ // Agents command group
404
+ const { agentsCommand } = require('./commands/agents');
405
+ const agentsCmd = program
406
+ .command('agents')
407
+ .description('Inspect the agent registry and session-log pointers');
408
+
409
+ agentsCmd
410
+ .command('list')
411
+ .description('List active CAWS-registered agent sessions')
412
+ .action(() => agentsCommand('list', {}));
413
+
414
+ agentsCmd
415
+ .command('show <session-id>')
416
+ .description('Show details for a specific agent session, including session-log pointer')
417
+ .action((id) => agentsCommand('show', { id }));
418
+
419
+ // Scope command group
420
+ const scopeCmd = program
421
+ .command('scope')
422
+ .description('Inspect and manage scope boundaries');
423
+
424
+ scopeCmd
425
+ .command('show')
426
+ .description('Show effective scope for the current context')
427
+ .action(() => scopeCommand('show'));
428
+
383
429
  // Session command group
384
430
  const sessionCmd = program
385
431
  .command('session')
@@ -552,6 +598,15 @@ waiversCmd
552
598
  .option('-v, --verbose', 'Show detailed error information', false)
553
599
  .action((id, options) => waiversCommand('revoke', { ...options, id }));
554
600
 
601
+ waiversCmd
602
+ .command('prune')
603
+ .description('Prune expired waivers (dry-run by default; use --apply to persist)')
604
+ .option('--expired', 'Prune active waivers whose expires_at is in the past')
605
+ .option('--apply', 'Actually transition status (default: dry run)')
606
+ .option('--json', 'Emit machine-readable JSON output')
607
+ .option('-v, --verbose', 'Show detailed error information', false)
608
+ .action((options) => waiversCommand('prune', options));
609
+
555
610
  // Workflow command group
556
611
  program
557
612
  .command('workflow <type>')
@@ -745,6 +800,7 @@ const VALID_COMMANDS = [
745
800
  'session',
746
801
  'parallel',
747
802
  'verify-acs',
803
+ 'scope',
748
804
  ];
749
805
 
750
806
  program.exitOverride((err) => {
@@ -270,15 +270,17 @@ class PolicyManager {
270
270
  throw new Error('Policy missing risk_tiers configuration');
271
271
  }
272
272
 
273
- // Validate all tiers have required fields
274
- for (const tier of [1, 2, 3]) {
275
- const budget = policy.risk_tiers[tier];
276
- if (!budget) {
277
- throw new Error(`Policy missing risk tier ${tier} configuration`);
273
+ const tierKeys = Object.keys(policy.risk_tiers);
274
+ if (tierKeys.length === 0) {
275
+ throw new Error('Policy risk_tiers must define at least one risk tier (1, 2, or 3)');
276
+ }
277
+ for (const key of tierKeys) {
278
+ if (!/^[1-3]$/.test(key)) {
279
+ throw new Error(`Policy risk_tiers has unknown tier '${key}' (expected 1, 2, or 3)`);
278
280
  }
279
-
281
+ const budget = policy.risk_tiers[key];
280
282
  if (typeof budget.max_files !== 'number' || typeof budget.max_loc !== 'number') {
281
- throw new Error(`Risk tier ${tier} missing or invalid budget limits`);
283
+ throw new Error(`Risk tier ${key} missing or invalid budget limits`);
282
284
  }
283
285
  }
284
286
 
@@ -354,6 +356,11 @@ class PolicyManager {
354
356
  description: 'Scan for TODO/FIXME/HACK/XXX markers',
355
357
  },
356
358
  },
359
+ // CAWSFIX-26 / D9: empty by default. Projects that need to opt a
360
+ // subtree out of scope enforcement (e.g., research/, playground/)
361
+ // add glob patterns here, e.g. ['research/**']. Paths matching any
362
+ // pattern bypass scope-boundary checks entirely.
363
+ non_governed_zones: [],
357
364
  };
358
365
  }
359
366