@paths.design/caws-cli 10.1.0 → 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.
@@ -16,31 +16,34 @@ const { createValidator, getSchemaPath } = require('../utils/schema-validator');
16
16
  * Accepts:
17
17
  * - Single-segment: FEAT-001, EVLOG-002, CAWSFIX-06 (legacy shape)
18
18
  * - Multi-segment: P03-IMPL-01, ALG-001A-HARDEN-01, CAWS-FIX-03
19
+ * - Lowercase suffix: APC-01a, ALG-01b (CAWSFIX-25 / D2)
19
20
  *
20
21
  * Rejects:
21
- * - lowercase (feat-001)
22
+ * - lowercase prefix (feat-001)
23
+ * - lowercase in a non-final segment (AB-cd-01)
22
24
  * - leading digit (01-FEAT)
23
25
  * - missing number suffix (FEAT-)
24
26
  * - trailing hyphen (FEAT-01-)
25
27
  * - leading/double hyphen (--FEAT-01, FEAT--001)
26
28
  * - empty string
27
29
  *
28
- * Grammar: [PREFIX](-[SEGMENT])*-NUMBER
30
+ * Grammar: [PREFIX](-[SEGMENT])*-NUMBER[SUFFIX]?
29
31
  * - PREFIX = [A-Z] followed by zero+ [A-Z0-9]
30
32
  * - SEGMENT = one+ [A-Z0-9] (alphanumeric, uppercase only)
31
33
  * - NUMBER = one+ digits
34
+ * - SUFFIX = zero+ [a-z] (optional lowercase tail on final segment only)
32
35
  *
33
36
  * Defined once per A4 invariant; referenced by both the basic validator
34
37
  * (line ~125 pre-fix) and the enhanced validator (line ~307 pre-fix).
35
38
  */
36
- const SPEC_ID_PATTERN = /^[A-Z][A-Z0-9]*(-[A-Z0-9]+)*-\d+$/;
39
+ const SPEC_ID_PATTERN = /^[A-Z][A-Z0-9]*(-[A-Z0-9]+)*-\d+[a-z]*$/;
37
40
 
38
41
  /**
39
42
  * User-facing error message for bad spec IDs (CAWSFIX-10 A5).
40
43
  * Kept as a module constant so the message stays in sync with the pattern.
41
44
  */
42
45
  const SPEC_ID_ERROR_MESSAGE =
43
- 'Project ID should be in format: PREFIX-NUMBER or PREFIX-SEGMENT-NUMBER (e.g., FEAT-001, P03-IMPL-01)';
46
+ 'Project ID should be in format: PREFIX-NUMBER or PREFIX-SEGMENT-NUMBER with optional lowercase suffix (e.g., FEAT-001, P03-IMPL-01, APC-01a)';
44
47
 
45
48
  /**
46
49
  * Get actual budget statistics from git history
@@ -9,7 +9,13 @@ const fs = require('fs-extra');
9
9
  const path = require('path');
10
10
  const chalk = require('chalk');
11
11
  const { createValidator, getSchemaPath } = require('../utils/schema-validator');
12
- const { getAgentSessionId } = require('../utils/agent-session');
12
+ const {
13
+ getAgentSessionId,
14
+ loadAgentRegistry,
15
+ findSessionLogs,
16
+ refreshAgentClaim,
17
+ } = require('../utils/agent-session');
18
+ const { formatClaimNotice, formatOrphanLogHint } = require('../utils/agent-display');
13
19
  const { lifecycle, EVENTS } = require('../utils/lifecycle-events');
14
20
 
15
21
  const WORKTREES_DIR = '.caws/worktrees';
@@ -27,6 +33,48 @@ function findFeatureSpecPath(root, specId) {
27
33
  return candidates.find((candidate) => fs.existsSync(candidate)) || null;
28
34
  }
29
35
 
36
+ /**
37
+ * Resolve a feature spec path, preferring a worktree-local copy when cwd
38
+ * is inside a worktree. Falls back to the main repo.
39
+ *
40
+ * Why two-step: `caws worktree bind` may be invoked from inside a worktree
41
+ * that was forked off a non-main base branch (Option C fork-off-sibling
42
+ * pattern). In that workflow the spec is committed on the worktree's own
43
+ * branch and never lands on main. The pre-CAWSFIX-25 behavior of looking
44
+ * only in `root/.caws/specs/` made bind unusable there (D8 ledger entry).
45
+ *
46
+ * @param {string} root - Main repo root (from getRepoRoot())
47
+ * @param {string} specId - Spec identifier
48
+ * @param {string} [cwd=process.cwd()] - Directory to resolve from
49
+ * @returns {string|null} Absolute path to the spec file, or null
50
+ */
51
+ function findFeatureSpecPathFromCwd(root, specId, cwd) {
52
+ if (!specId) return null;
53
+ const effectiveCwd = cwd || process.cwd();
54
+
55
+ // Normalize both sides against symlinks before comparing. On macOS
56
+ // `/tmp` and `/var/folders` are symlinks under `/private`, so the literal
57
+ // `startsWith` check fails intermittently in the test fixture. Fall back
58
+ // to the pre-resolution path if realpath throws (e.g., cwd removed).
59
+ const resolve = (p) => {
60
+ try { return fs.realpathSync(p); } catch { return p; }
61
+ };
62
+ const resolvedCwd = resolve(effectiveCwd);
63
+ const worktreesBase = resolve(path.join(root, '.caws', 'worktrees'));
64
+
65
+ if (resolvedCwd.startsWith(worktreesBase + path.sep)) {
66
+ const relative = path.relative(worktreesBase, resolvedCwd);
67
+ const worktreeName = relative.split(path.sep)[0];
68
+ if (worktreeName) {
69
+ const worktreeRoot = path.join(worktreesBase, worktreeName);
70
+ const local = findFeatureSpecPath(worktreeRoot, specId);
71
+ if (local) return local;
72
+ }
73
+ }
74
+
75
+ return findFeatureSpecPath(root, specId);
76
+ }
77
+
30
78
  function writeSpecWithWorktree(filePath, worktreeName) {
31
79
  const yaml = require('js-yaml');
32
80
  const content = fs.readFileSync(filePath, 'utf8');
@@ -35,6 +83,18 @@ function writeSpecWithWorktree(filePath, worktreeName) {
35
83
  return content;
36
84
  }
37
85
 
86
+ // CAWSFIX-24 / D10: if the on-disk spec already declares the target
87
+ // worktree and reloads to an equivalent object, return the original
88
+ // bytes untouched. js-yaml.dump re-wraps folded scalars at its own
89
+ // line-width preference, which otherwise produces spurious bytes-only
90
+ // diffs on every bind/create. That mechanical churn (a) leaves dirty
91
+ // files on main after worktree create, and (b) causes merge conflicts
92
+ // when two validator invocations wrap the same title at different
93
+ // widths.
94
+ if (parsed.worktree === worktreeName) {
95
+ return content;
96
+ }
97
+
38
98
  parsed.worktree = worktreeName;
39
99
  return yaml.dump(parsed, { lineWidth: 120, noRefs: true });
40
100
  }
@@ -84,27 +144,38 @@ function materializeWorktreeSpec(root, cawsDest, specId, worktreeName, scope) {
84
144
  if (!specId) return;
85
145
 
86
146
  const canonicalSpecPath = findFeatureSpecPath(root, specId);
87
- const workingSpecPath = path.join(cawsDest, 'working-spec.yaml');
147
+ const destSpecsDir = path.join(cawsDest, 'specs');
88
148
 
89
- if (!canonicalSpecPath) {
90
- console.warn(
91
- chalk.yellow(`Warning: spec '${specId}' not found in .caws/specs/ generating default working spec for worktree`)
92
- );
93
- }
149
+ // CAWSFIX-24 / D5: never write to .caws/working-spec.yaml inside the
150
+ // worktree. That file is the shared project baseline and must remain
151
+ // byte-identical to what was checked out from HEAD. The feature spec
152
+ // is materialized only under .caws/specs/<id>.yaml, which is what
153
+ // spec-resolver and commands actually read via --spec-id / registry.
94
154
 
95
155
  if (canonicalSpecPath) {
96
- const destSpecsDir = path.join(cawsDest, 'specs');
97
156
  const destSpecPath = path.join(destSpecsDir, path.basename(canonicalSpecPath));
98
157
  fs.ensureDirSync(destSpecsDir);
99
158
 
100
- // Keep a canonical feature-spec copy inside the worktree and align
101
- // working-spec.yaml to that exact content for legacy-compatible commands.
159
+ // Keep a canonical feature-spec copy inside the worktree.
102
160
  const specContent = writeSpecWithWorktree(canonicalSpecPath, worktreeName);
103
- fs.writeFileSync(destSpecPath, specContent);
104
- fs.writeFileSync(workingSpecPath, specContent);
161
+ // writeSpecWithWorktree is idempotent (CAWSFIX-24 / D10): if the spec
162
+ // already has the worktree field and reloads to equivalent YAML, the
163
+ // returned content matches what's on disk. Skip the write in that case
164
+ // so `git status` stays clean.
165
+ const existing = fs.existsSync(destSpecPath) ? fs.readFileSync(destSpecPath, 'utf8') : null;
166
+ if (existing !== specContent) {
167
+ fs.writeFileSync(destSpecPath, specContent);
168
+ }
105
169
  return;
106
170
  }
107
171
 
172
+ // specId given but no canonical spec found — generate a default feature
173
+ // spec at .caws/specs/<specId>.yaml so the worktree has something to
174
+ // resolve against. Do not touch .caws/working-spec.yaml.
175
+ console.warn(
176
+ chalk.yellow(`Warning: spec '${specId}' not found in .caws/specs/ — generating default feature spec for worktree`)
177
+ );
178
+
108
179
  const { generateWorkingSpec } = require('../generators/working-spec');
109
180
  let specContent = generateWorkingSpec({
110
181
  projectId: specId,
@@ -150,8 +221,9 @@ function materializeWorktreeSpec(root, cawsDest, specId, worktreeName, scope) {
150
221
  // Keep generated spec content if augmentation fails.
151
222
  }
152
223
 
153
- fs.ensureDirSync(path.dirname(workingSpecPath));
154
- fs.writeFileSync(workingSpecPath, specContent);
224
+ fs.ensureDirSync(destSpecsDir);
225
+ const generatedSpecPath = path.join(destSpecsDir, `${specId}.yaml`);
226
+ fs.writeFileSync(generatedSpecPath, specContent);
155
227
  }
156
228
 
157
229
  function parseSpecIdFromYamlFile(filePath) {
@@ -332,6 +404,132 @@ function getCurrentBranch() {
332
404
  // floods stderr and contributes to Claude Code context-window exhaustion.
333
405
  let _schemaWarned = false;
334
406
 
407
+ /**
408
+ * CAWSFIX-31: Assert that the current agent session may operate on
409
+ * worktree `name`. The decision is purely session-id-equality based —
410
+ * never TTL, never log freshness — because rolled-over and resumed
411
+ * sessions should not be auto-blocked just because their registry
412
+ * entry was pruned.
413
+ *
414
+ * Returns `{ allowed, warning?, priorOwner? }`. The caller decides
415
+ * how to react:
416
+ *
417
+ * - allowed=true, no warning → silent proceed (same session id, no claim, etc.)
418
+ * - allowed=true, warning present → soft notice (orphan session log, no block)
419
+ * - allowed=false, warning present → soft-block; surface warning, exit non-zero
420
+ *
421
+ * On takeover (`allowTakeover: true`), the function rewrites the
422
+ * worktree entry's owner to the current session id and appends the
423
+ * prior owner to a `prior_owners` audit array (with lastSeen captured
424
+ * from agents.json at takeover time, or null if pruned).
425
+ *
426
+ * @param {string} root - Project root
427
+ * @param {string} name - Worktree name
428
+ * @param {object} [opts]
429
+ * @param {boolean} [opts.allowTakeover=false] - Apply takeover when true
430
+ * @param {string} [opts.takeoverCommandHint] - Suggested command for the warning
431
+ * @returns {{ allowed: boolean, warning?: string, priorOwner?: object }}
432
+ */
433
+ function assertWorktreeOwnership(root, name, opts = {}) {
434
+ const { allowTakeover = false, takeoverCommandHint } = opts;
435
+ const registry = loadRegistry(root);
436
+ const entry = registry.worktrees[name];
437
+ if (!entry) {
438
+ return { allowed: true };
439
+ }
440
+
441
+ const currentSession = getAgentSessionId(root);
442
+ const owner = entry.owner;
443
+
444
+ // No CAWS-tracked owner — surface session-log hint if present, but
445
+ // allow the operation to proceed.
446
+ if (!owner) {
447
+ const branch = entry.branch || null;
448
+ const logs = branch ? findSessionLogs(root, { branch }) : [];
449
+ if (logs.length > 0) {
450
+ return {
451
+ allowed: true,
452
+ warning: formatOrphanLogHint({ worktree: name, sessionLogs: logs, root }),
453
+ };
454
+ }
455
+ return { allowed: true };
456
+ }
457
+
458
+ // Same session id → silent proceed. Roll-over case included: an
459
+ // agent that resumed with the same session id is its own claimant.
460
+ if (currentSession && owner === currentSession) {
461
+ return { allowed: true };
462
+ }
463
+
464
+ // Foreign claim — gather context.
465
+ const agentRegistry = loadAgentRegistry(root);
466
+ const priorOwnerEntry = agentRegistry.agents[owner] || null;
467
+ const priorOwnerLastSeen = priorOwnerEntry ? priorOwnerEntry.lastSeen : null;
468
+ const priorOwnerPlatform = priorOwnerEntry ? priorOwnerEntry.platform : 'unknown';
469
+
470
+ // Surface session-log pointers (by sid OR by branch).
471
+ const branch = entry.branch || null;
472
+ const seen = new Set();
473
+ const sessionLogs = [];
474
+ for (const log of findSessionLogs(root, { sessionId: owner })) {
475
+ if (seen.has(log.path)) continue;
476
+ seen.add(log.path);
477
+ sessionLogs.push(log);
478
+ }
479
+ if (branch) {
480
+ for (const log of findSessionLogs(root, { branch })) {
481
+ if (seen.has(log.path)) continue;
482
+ seen.add(log.path);
483
+ sessionLogs.push(log);
484
+ }
485
+ }
486
+
487
+ const takeoverCommand =
488
+ takeoverCommandHint || `caws worktree claim ${name} --takeover`;
489
+ const warning = formatClaimNotice({
490
+ worktree: name,
491
+ priorOwnerEntry,
492
+ priorOwnerSessionId: owner,
493
+ sessionLogs,
494
+ root,
495
+ takeoverCommand,
496
+ });
497
+
498
+ if (!allowTakeover) {
499
+ return { allowed: false, warning };
500
+ }
501
+
502
+ // Takeover: rewrite owner, append prior_owners audit entry.
503
+ const priorOwners = Array.isArray(entry.prior_owners) ? entry.prior_owners : [];
504
+ priorOwners.push({
505
+ sessionId: owner,
506
+ platform: priorOwnerPlatform,
507
+ lastSeen: priorOwnerLastSeen,
508
+ takenOver_at: new Date().toISOString(),
509
+ });
510
+ registry.worktrees[name] = {
511
+ ...entry,
512
+ owner: currentSession || null,
513
+ prior_owners: priorOwners,
514
+ };
515
+ saveRegistry(root, registry);
516
+
517
+ // Heartbeat the new owner so agents.json reflects the takeover too.
518
+ // Without this, `caws status` and `caws agents list` would show the
519
+ // takeover'd worktree with an "unknown / pruned" current owner until
520
+ // some other lifecycle verb fires.
521
+ refreshAgentClaim(root, { worktree: name });
522
+
523
+ return {
524
+ allowed: true,
525
+ priorOwner: {
526
+ sessionId: owner,
527
+ platform: priorOwnerPlatform,
528
+ lastSeen: priorOwnerLastSeen,
529
+ },
530
+ };
531
+ }
532
+
335
533
  /**
336
534
  * Load the worktree registry
337
535
  * @param {string} root - Repository root
@@ -369,10 +567,22 @@ function loadRegistry(root) {
369
567
  * @param {Object} registry - Registry object
370
568
  */
371
569
  function saveRegistry(root, registry) {
372
- // Auto-prune destroyed entries whose branch and directory are both gone.
373
- // This prevents the registry from accumulating ghost entries over time.
570
+ // Auto-prune ghost entries: any registry entry whose path directory AND
571
+ // stored branch are BOTH gone. Previously this only fired for entries
572
+ // explicitly marked `status: destroyed`, which missed two common cases:
573
+ // 1. A worktree removed via `git worktree remove` (not `caws worktree
574
+ // destroy`) that later had its branch manually deleted with
575
+ // `git branch -D`.
576
+ // 2. A worktree whose create failed partway, leaving a registry entry
577
+ // at `fresh`/`active` but no artifacts on disk.
578
+ // Both are pure ghost state — no recoverable work remains in either
579
+ // the directory or the branch. Pruning is safe. (CAWSFIX-25 / D7)
580
+ //
581
+ // Entries with ONE artifact intact (dir gone but branch still present,
582
+ // or vice versa) are preserved. The branch may still hold unmerged
583
+ // commits, or the directory may still hold uncommitted work — the user
584
+ // should merge or explicitly destroy.
374
585
  for (const [name, entry] of Object.entries(registry.worktrees || {})) {
375
- if (entry.status !== 'destroyed') continue;
376
586
  const dirGone = !fs.existsSync(entry.path);
377
587
  let branchGone = true;
378
588
  if (entry.branch) {
@@ -550,7 +760,21 @@ function createWorktree(name, options = {}) {
550
760
  const worktreePath = path.join(root, WORKTREES_DIR, name);
551
761
  const branchName = BRANCH_PREFIX + name;
552
762
  const base = baseBranch || getCurrentBranch();
553
- const canonicalSpecPath = findFeatureSpecPath(root, specId);
763
+
764
+ // CAWSFIX-27: resolve the bound specId (explicit --spec-id OR auto-bind
765
+ // via worktree-name match) BEFORE creating the worktree, so the
766
+ // draft→active flip + bind commit land on the base branch before the
767
+ // worktree forks. Pre-CAWSFIX-27 the auto-bind path activated the spec
768
+ // but never committed it, leaving main with a dirty spec after
769
+ // `caws worktree create <name>` (no --spec-id).
770
+ let resolvedSpecId = specId || null;
771
+ if (!resolvedSpecId) {
772
+ resolvedSpecId = findSpecByWorktreeName(root, name);
773
+ if (resolvedSpecId) {
774
+ console.log(chalk.gray(` Auto-bound spec: ${resolvedSpecId}`));
775
+ }
776
+ }
777
+ const canonicalSpecPath = findFeatureSpecPath(root, resolvedSpecId);
554
778
 
555
779
  // Check if the branch already exists in git (even if not in registry)
556
780
  // This catches cases where another agent created the branch outside CAWS
@@ -577,8 +801,14 @@ function createWorktree(name, options = {}) {
577
801
  // Create the worktree directory
578
802
  fs.ensureDirSync(path.dirname(worktreePath));
579
803
 
580
- if (canonicalSpecPath) {
581
- ensureCanonicalSpecCommitted(root, canonicalSpecPath, specId, name);
804
+ if (canonicalSpecPath && resolvedSpecId) {
805
+ // CAWSFIX-23: flip draft→active BEFORE the bind commit so the spec
806
+ // lifecycle transition lands in the same commit as the worktree field.
807
+ // CAWSFIX-27: this block now handles BOTH the explicit --spec-id path
808
+ // and the auto-bind (findSpecByWorktreeName) path — previously only
809
+ // the explicit path committed the flip.
810
+ autoActivateBoundSpec(root, resolvedSpecId);
811
+ ensureCanonicalSpecCommitted(root, canonicalSpecPath, resolvedSpecId, name);
582
812
  }
583
813
 
584
814
  // Create git worktree with new branch
@@ -642,16 +872,10 @@ function createWorktree(name, options = {}) {
642
872
  }
643
873
  }
644
874
 
645
- // Auto-bind specId: if no explicit --spec-id was passed, scan .caws/specs/
646
- // for a spec that declares `worktree: <name>`. This establishes the mutual
647
- // reference that the scope guard uses to treat one spec as authoritative.
648
- let resolvedSpecId = specId || null;
649
- if (!resolvedSpecId) {
650
- resolvedSpecId = findSpecByWorktreeName(root, name);
651
- if (resolvedSpecId) {
652
- console.log(chalk.gray(` Auto-bound spec: ${resolvedSpecId}`));
653
- }
654
- }
875
+ // CAWSFIX-27: resolvedSpecId is now computed before the worktree is
876
+ // added (see block above the `fs.ensureDirSync` call). The activation
877
+ // and bind-commit already ran on the base branch, so the worktree forks
878
+ // from a base that already includes the flip commit.
655
879
 
656
880
  // Materialize a worktree-local working spec. Prefer the canonical feature
657
881
  // spec when it exists so isolated worktrees stay aligned with the main
@@ -683,6 +907,11 @@ function createWorktree(name, options = {}) {
683
907
  registry.worktrees[name] = entry;
684
908
  saveRegistry(root, registry);
685
909
 
910
+ // CAWSFIX-32: heartbeat the current session into agents.json so the
911
+ // worktree+spec context is visible to other agents and to
912
+ // `caws status` / `caws agents list` immediately after create.
913
+ refreshAgentClaim(root, { worktree: name, specId: resolvedSpecId || null });
914
+
686
915
  return entry;
687
916
  }
688
917
 
@@ -1075,7 +1304,7 @@ function destroyWorktree(name, options = {}) {
1075
1304
  function mergeWorktree(name, options = {}) {
1076
1305
  const root = getRepoRoot();
1077
1306
  const registry = loadRegistry(root);
1078
- const { dryRun = false, deleteBranch = true, message } = options;
1307
+ const { dryRun = false, deleteBranch = true, message, takeover = false } = options;
1079
1308
 
1080
1309
  let entry = registry.worktrees[name];
1081
1310
  if (!entry) {
@@ -1090,6 +1319,26 @@ function mergeWorktree(name, options = {}) {
1090
1319
  }
1091
1320
  }
1092
1321
 
1322
+ // CAWSFIX-32: assert ownership BEFORE any merge/git work. Foreign
1323
+ // claim soft-blocks unless --takeover is supplied, matching the
1324
+ // bind/claim semantics. Throws on refusal so the CLI command handler
1325
+ // surfaces the structured warning.
1326
+ const ownership = assertWorktreeOwnership(root, name, {
1327
+ allowTakeover: takeover,
1328
+ takeoverCommandHint: `caws worktree merge ${name} --takeover`,
1329
+ });
1330
+ if (!ownership.allowed) {
1331
+ const err = new Error(ownership.warning);
1332
+ err.claimWarning = true;
1333
+ throw err;
1334
+ }
1335
+
1336
+ // CAWSFIX-32: heartbeat the current session into agents.json now that
1337
+ // ownership is confirmed. Same-session merges need this since the
1338
+ // takeover branch only fires inside assertWorktreeOwnership when a
1339
+ // takeover actually occurs.
1340
+ refreshAgentClaim(root, { worktree: name, specId: entry.specId || null });
1341
+
1093
1342
  const baseBranch = entry.baseBranch || 'main';
1094
1343
 
1095
1344
  // Check for uncommitted work in the worktree.
@@ -1222,17 +1471,58 @@ function mergeWorktree(name, options = {}) {
1222
1471
 
1223
1472
  // Auto-close the bound spec if one exists. A worktree merge is the
1224
1473
  // lifecycle signal that the spec's work is done; leaving the spec
1225
- // `active` after merge accumulates stale-active entries (D6). Direct
1226
- // YAML status flip bypasses the ownership + worktree-reference checks
1227
- // in `closeSpec` — the caller has already proven authority by merging.
1228
- let autoClosedSpecId = null;
1474
+ // `active` (or `draft`, pre-CAWSFIX-23) after merge accumulates stale
1475
+ // entries (D6). Direct YAML status flip bypasses the ownership +
1476
+ // worktree-reference checks in `closeSpec` — the caller has already
1477
+ // proven authority by merging.
1478
+ let autoClose = {
1479
+ specId: null, acsPassing: null, acsFailureCount: 0, acsTotal: 0, acsFailureIds: [],
1480
+ didWrite: false, specPath: null,
1481
+ };
1229
1482
  if (entry.specId) {
1230
- autoClosedSpecId = autoCloseBoundSpec(root, entry.specId);
1483
+ autoClose = autoCloseBoundSpec(root, entry.specId);
1484
+ if (autoClose.acsPassing === false && autoClose.acsFailureCount > 0) {
1485
+ console.warn(chalk.yellow(
1486
+ ` ⚠ Spec ${entry.specId} closed with ${autoClose.acsFailureCount}/${autoClose.acsTotal} failing AC(s): ${autoClose.acsFailureIds.join(', ')}`
1487
+ ));
1488
+ console.warn(chalk.yellow(
1489
+ ` Merge succeeded — the spec reflects that — but follow up to address the failing ACs.`
1490
+ ));
1491
+ }
1492
+
1493
+ // CAWSFIX-24 / D6: if the auto-close flipped the status, commit the
1494
+ // change on the base branch before returning. Leaving it uncommitted
1495
+ // was the "dirty main" footgun: the next worktree merge would abort
1496
+ // on "local changes would be overwritten," after the prior worktree
1497
+ // was already destroyed. Use --no-verify to match the merge commit's
1498
+ // hook-skip discipline (the content was verified when the merge ran).
1499
+ if (autoClose.didWrite && autoClose.specPath) {
1500
+ try {
1501
+ const relPath = path.relative(root, autoClose.specPath);
1502
+ execFileSync('git', ['add', '--', relPath], { cwd: root, stdio: 'pipe' });
1503
+ execFileSync(
1504
+ 'git',
1505
+ ['commit', '--no-verify', '-m', `chore(caws): close ${autoClose.specId} spec post-merge`, '--', relPath],
1506
+ { cwd: root, stdio: 'pipe' }
1507
+ );
1508
+ } catch (commitErr) {
1509
+ // Non-fatal: a failed auto-commit leaves the spec dirty but the
1510
+ // merge itself already succeeded. Warn so the caller can clean up.
1511
+ console.warn(chalk.yellow(
1512
+ ` ⚠ Auto-commit of ${autoClose.specId} close flip failed: ${commitErr.message}. Commit manually.`
1513
+ ));
1514
+ }
1515
+ }
1231
1516
  }
1232
1517
 
1233
1518
  const mergeResult = {
1234
1519
  name, branch: entry.branch, baseBranch, merged: true, conflicts: [],
1235
- specId: entry.specId || null, autoClosedSpecId,
1520
+ specId: entry.specId || null,
1521
+ autoClosedSpecId: autoClose.specId,
1522
+ acsPassing: autoClose.acsPassing,
1523
+ acsFailureCount: autoClose.acsFailureCount,
1524
+ acsTotal: autoClose.acsTotal,
1525
+ acsFailureIds: autoClose.acsFailureIds,
1236
1526
  };
1237
1527
  try {
1238
1528
  lifecycle.emit(EVENTS.MERGE_POST, { ...mergeResult, timestamp: new Date().toISOString() });
@@ -1240,27 +1530,95 @@ function mergeWorktree(name, options = {}) {
1240
1530
  return mergeResult;
1241
1531
  }
1242
1532
 
1533
+ /**
1534
+ * Flip a spec's status from `draft` to `active` by rewriting just the
1535
+ * `status:` line. Called on worktree-bind so specs whose work is
1536
+ * starting transition out of draft without manual intervention.
1537
+ * Idempotent: no-op when the spec is already active/closed/etc.
1538
+ * @param {string} root - Repo root
1539
+ * @param {string} specId - Spec identifier
1540
+ * @returns {string|null} specId on flip, null if no change
1541
+ */
1542
+ function autoActivateBoundSpec(root, specId, specPathOverride = null) {
1543
+ try {
1544
+ // CAWSFIX-25 / D8: callers that resolved a worktree-local spec path
1545
+ // (via findFeatureSpecPathFromCwd) pass it in so the flip lands on
1546
+ // the worktree's copy, not main's. Falls through to main resolution
1547
+ // for backward compatibility when override is null.
1548
+ const specPath = specPathOverride || findFeatureSpecPath(root, specId);
1549
+ if (!specPath || !fs.existsSync(specPath)) return null;
1550
+ const original = fs.readFileSync(specPath, 'utf8');
1551
+ // Idempotent: already active/closed/archived → no write.
1552
+ if (/^status:[ \t]*active[ \t]*$/m.test(original)) return specId;
1553
+ const patched = original.replace(/^status:[ \t]*draft[ \t]*$/m, 'status: active');
1554
+ if (patched === original) return null;
1555
+ fs.writeFileSync(specPath, patched, 'utf8');
1556
+ return specId;
1557
+ } catch {
1558
+ return null;
1559
+ }
1560
+ }
1561
+
1243
1562
  /**
1244
1563
  * Flip a spec's status to `closed` by rewriting just the `status:` line.
1245
- * Idempotent: no-op when the spec is already closed or the file is missing.
1246
- * Returns the spec ID on success, null if skipped or failed.
1564
+ * Accepts both `draft` and `active` as source states merge is the
1565
+ * authoritative "work done" signal regardless of whether the spec ever
1566
+ * transitioned through active. Runs verify-acs in collect-only mode
1567
+ * before the flip and returns AC health so the caller can warn.
1247
1568
  * @param {string} root - Repo root
1248
1569
  * @param {string} specId - Spec identifier (e.g. CAWSFIX-14)
1249
- * @returns {string|null}
1570
+ * @returns {{specId: string|null, acsPassing: boolean|null, acsFailureCount: number, acsTotal: number, acsFailureIds: string[]}}
1250
1571
  */
1251
1572
  function autoCloseBoundSpec(root, specId) {
1573
+ const result = {
1574
+ specId: null,
1575
+ acsPassing: null,
1576
+ acsFailureCount: 0,
1577
+ acsTotal: 0,
1578
+ acsFailureIds: [],
1579
+ didWrite: false,
1580
+ specPath: null,
1581
+ };
1252
1582
  try {
1253
1583
  const specPath = findFeatureSpecPath(root, specId);
1254
- if (!specPath || !fs.existsSync(specPath)) return null;
1584
+ if (!specPath || !fs.existsSync(specPath)) return result;
1585
+ result.specPath = specPath;
1255
1586
  const original = fs.readFileSync(specPath, 'utf8');
1256
1587
  // Idempotent: already closed → no-op, no write, no diff.
1257
- if (/^status:\s*closed\s*$/m.test(original)) return specId;
1258
- const patched = original.replace(/^status:\s*active\s*$/m, 'status: closed');
1259
- if (patched === original) return null; // status was e.g. draft/archived
1588
+ if (/^status:[ \t]*closed[ \t]*$/m.test(original)) {
1589
+ result.specId = specId;
1590
+ return result;
1591
+ }
1592
+ // Run verify-acs in collect-only mode before flipping. Never throws —
1593
+ // any error (missing tests, unavailable runner, malformed spec) leaves
1594
+ // acsPassing: null so the caller knows verification didn't run.
1595
+ try {
1596
+ const yaml = require('js-yaml');
1597
+ const { verifySpec } = require('../commands/verify-acs');
1598
+ const parsed = yaml.load(original);
1599
+ if (parsed && typeof parsed === 'object') {
1600
+ const verdict = verifySpec(parsed, root, { run: false });
1601
+ const fails = (verdict.results || []).filter((r) => r.status === 'FAIL');
1602
+ result.acsTotal = (verdict.results || []).length;
1603
+ result.acsFailureCount = fails.length;
1604
+ result.acsFailureIds = fails.map((r) => r.id);
1605
+ result.acsPassing = fails.length === 0;
1606
+ }
1607
+ } catch {
1608
+ // verify-acs unavailable — don't block close
1609
+ }
1610
+ // Flip status. Accept both draft and active as source so specs that
1611
+ // never transitioned through active (D6 pre-CAWSFIX-23 drift) still close.
1612
+ const patched = original.replace(/^status:[ \t]*(?:draft|active)[ \t]*$/m, 'status: closed');
1613
+ if (patched === original) {
1614
+ return result; // status was archived/unknown — leave alone
1615
+ }
1260
1616
  fs.writeFileSync(specPath, patched, 'utf8');
1261
- return specId;
1617
+ result.specId = specId;
1618
+ result.didWrite = true;
1619
+ return result;
1262
1620
  } catch {
1263
- return null;
1621
+ return result;
1264
1622
  }
1265
1623
  }
1266
1624
 
@@ -1351,12 +1709,14 @@ module.exports = {
1351
1709
  listWorktrees,
1352
1710
  destroyWorktree,
1353
1711
  mergeWorktree,
1712
+ autoActivateBoundSpec,
1354
1713
  autoCloseBoundSpec,
1355
1714
  pruneWorktrees,
1356
1715
  repairWorktrees,
1357
1716
  reconcileRegistry,
1358
1717
  loadRegistry,
1359
1718
  saveRegistry,
1719
+ assertWorktreeOwnership,
1360
1720
  getRepoRoot,
1361
1721
  getLastCommitInfo,
1362
1722
  isBranchMerged,
@@ -1368,6 +1728,7 @@ module.exports = {
1368
1728
  REGISTRY_FILE,
1369
1729
  BRANCH_PREFIX,
1370
1730
  findFeatureSpecPath,
1731
+ findFeatureSpecPathFromCwd,
1371
1732
  materializeWorktreeSpec,
1372
1733
  inferSpecIdForWorktree,
1373
1734
  findSpecByWorktreeName,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@paths.design/caws-cli",
3
- "version": "10.1.0",
3
+ "version": "10.2.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": {
@@ -105,6 +105,11 @@
105
105
  },
106
106
  "additionalProperties": false,
107
107
  "description": "Quality gate configurations"
108
+ },
109
+ "non_governed_zones": {
110
+ "type": "array",
111
+ "items": { "type": "string" },
112
+ "description": "Glob patterns (picomatch, dot:true) for paths declared outside CAWS scope enforcement. Any file matching a pattern is exempt from scope-boundary checks — neither spec.scope.in nor spec.scope.out are consulted. Intended for research, playground, or experimental subtrees where governance is explicitly off by design. Example: [\"research/**\", \"playground/**\"]. (CAWSFIX-26 / D9)"
108
113
  }
109
114
  },
110
115
  "additionalProperties": false,
@@ -23,8 +23,8 @@
23
23
  "properties": {
24
24
  "id": {
25
25
  "type": "string",
26
- "pattern": "^[A-Z][A-Z0-9]*(-[A-Z0-9]+)*-\\d+$",
27
- "description": "Unique identifier for the change. Format: uppercase/digit PREFIX segments separated by dashes, final segment is one+ digits (e.g. CAWSFIX-16, P03-TRUTH-001, ALG-001A-HARDEN-01). Matches SPEC_ID_PATTERN in spec-validation.js (CAWSFIX-21 alignment)."
26
+ "pattern": "^[A-Z][A-Z0-9]*(-[A-Z0-9]+)*-\\d+[a-z]*$",
27
+ "description": "Unique identifier for the change. Format: uppercase/digit PREFIX segments separated by dashes, final segment is one+ digits with an optional lowercase suffix (e.g. CAWSFIX-16, P03-TRUTH-001, ALG-001A-HARDEN-01, APC-01a). Matches SPEC_ID_PATTERN in spec-validation.js (CAWSFIX-25 alignment)."
28
28
  },
29
29
  "title": {
30
30
  "type": "string",