@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
@@ -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,33 +33,151 @@ 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
+
78
+ function writeSpecWithWorktree(filePath, worktreeName) {
79
+ const yaml = require('js-yaml');
80
+ const content = fs.readFileSync(filePath, 'utf8');
81
+ const parsed = yaml.load(content);
82
+ if (!parsed || typeof parsed !== 'object') {
83
+ return content;
84
+ }
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
+
98
+ parsed.worktree = worktreeName;
99
+ return yaml.dump(parsed, { lineWidth: 120, noRefs: true });
100
+ }
101
+
102
+ function hasPathChanges(root, relativePath) {
103
+ try {
104
+ const output = execFileSync(
105
+ 'git',
106
+ ['status', '--porcelain', '--', relativePath],
107
+ { cwd: root, encoding: 'utf8', stdio: 'pipe' }
108
+ ).trim();
109
+ return output.length > 0;
110
+ } catch {
111
+ return false;
112
+ }
113
+ }
114
+
115
+ function ensureCanonicalSpecCommitted(root, specPath, specId, worktreeName) {
116
+ const relativeSpecPath = path.relative(root, specPath);
117
+ const nextContent = writeSpecWithWorktree(specPath, worktreeName);
118
+ const currentContent = fs.readFileSync(specPath, 'utf8');
119
+
120
+ if (currentContent !== nextContent) {
121
+ fs.writeFileSync(specPath, nextContent);
122
+ }
123
+
124
+ if (!hasPathChanges(root, relativeSpecPath)) {
125
+ return false;
126
+ }
127
+
128
+ execFileSync('git', ['add', '--', relativeSpecPath], {
129
+ cwd: root,
130
+ stdio: 'pipe',
131
+ });
132
+ execFileSync(
133
+ 'git',
134
+ ['commit', '-m', `chore(caws): bind spec ${specId} to worktree ${worktreeName}`, '--', relativeSpecPath],
135
+ {
136
+ cwd: root,
137
+ stdio: 'pipe',
138
+ }
139
+ );
140
+ return true;
141
+ }
142
+
30
143
  function materializeWorktreeSpec(root, cawsDest, specId, worktreeName, scope) {
31
144
  if (!specId) return;
32
145
 
33
146
  const canonicalSpecPath = findFeatureSpecPath(root, specId);
34
- const workingSpecPath = path.join(cawsDest, 'working-spec.yaml');
147
+ const destSpecsDir = path.join(cawsDest, 'specs');
35
148
 
36
- if (!canonicalSpecPath) {
37
- console.warn(
38
- chalk.yellow(`Warning: spec '${specId}' not found in .caws/specs/ generating default working spec for worktree`)
39
- );
40
- }
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.
41
154
 
42
155
  if (canonicalSpecPath) {
43
- const destSpecsDir = path.join(cawsDest, 'specs');
44
156
  const destSpecPath = path.join(destSpecsDir, path.basename(canonicalSpecPath));
45
157
  fs.ensureDirSync(destSpecsDir);
46
158
 
47
- // Keep a canonical feature-spec copy inside the worktree and align
48
- // working-spec.yaml to that exact content for legacy-compatible commands.
49
- const specContent = fs.readFileSync(canonicalSpecPath, 'utf8');
50
- fs.writeFileSync(destSpecPath, specContent);
51
- fs.writeFileSync(workingSpecPath, specContent);
159
+ // Keep a canonical feature-spec copy inside the worktree.
160
+ const specContent = writeSpecWithWorktree(canonicalSpecPath, worktreeName);
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
+ }
52
169
  return;
53
170
  }
54
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
+
55
179
  const { generateWorkingSpec } = require('../generators/working-spec');
56
- const specContent = generateWorkingSpec({
180
+ let specContent = generateWorkingSpec({
57
181
  projectId: specId,
58
182
  projectTitle: `Worktree: ${worktreeName}`,
59
183
  projectDescription: `Isolated worktree for ${worktreeName}`,
@@ -86,8 +210,83 @@ function materializeWorktreeSpec(root, cawsDest, specId, worktreeName, scope) {
86
210
  complexityFactors: '',
87
211
  });
88
212
 
89
- fs.ensureDirSync(path.dirname(workingSpecPath));
90
- fs.writeFileSync(workingSpecPath, specContent);
213
+ try {
214
+ const yaml = require('js-yaml');
215
+ const parsed = yaml.load(specContent);
216
+ if (parsed && typeof parsed === 'object') {
217
+ parsed.worktree = worktreeName;
218
+ specContent = yaml.dump(parsed, { lineWidth: 120, noRefs: true });
219
+ }
220
+ } catch {
221
+ // Keep generated spec content if augmentation fails.
222
+ }
223
+
224
+ fs.ensureDirSync(destSpecsDir);
225
+ const generatedSpecPath = path.join(destSpecsDir, `${specId}.yaml`);
226
+ fs.writeFileSync(generatedSpecPath, specContent);
227
+ }
228
+
229
+ function parseSpecIdFromYamlFile(filePath) {
230
+ try {
231
+ const yaml = require('js-yaml');
232
+ const doc = yaml.load(fs.readFileSync(filePath, 'utf8'));
233
+ if (doc && typeof doc.id === 'string' && doc.id.trim()) {
234
+ return doc.id.trim();
235
+ }
236
+ } catch {
237
+ // Ignore malformed YAML during inference
238
+ }
239
+ return null;
240
+ }
241
+
242
+ /**
243
+ * Scan .caws/specs/ for a spec that declares `worktree: <name>`.
244
+ * Returns the spec's id if found, null otherwise.
245
+ * This enables auto-binding: when a spec already names the worktree
246
+ * it expects, the registry entry gets the specId automatically.
247
+ * @param {string} root - Repository root
248
+ * @param {string} worktreeName - Worktree name to match
249
+ * @returns {string|null} Spec ID or null
250
+ */
251
+ function findSpecByWorktreeName(root, worktreeName) {
252
+ const yaml = require('js-yaml');
253
+ const specsDir = path.join(root, '.caws', 'specs');
254
+ if (!fs.existsSync(specsDir)) return null;
255
+
256
+ const specFiles = fs.readdirSync(specsDir)
257
+ .filter((name) => name.endsWith('.yaml') || name.endsWith('.yml'));
258
+
259
+ for (const specFile of specFiles) {
260
+ try {
261
+ const doc = yaml.load(fs.readFileSync(path.join(specsDir, specFile), 'utf8'));
262
+ if (doc && doc.worktree === worktreeName && typeof doc.id === 'string') {
263
+ return doc.id.trim();
264
+ }
265
+ } catch {
266
+ // Skip malformed spec files
267
+ }
268
+ }
269
+ return null;
270
+ }
271
+
272
+ function inferSpecIdForWorktree(worktreePath) {
273
+ if (!worktreePath) return null;
274
+
275
+ const specsDir = path.join(worktreePath, '.caws', 'specs');
276
+ if (fs.existsSync(specsDir)) {
277
+ const specFiles = fs.readdirSync(specsDir)
278
+ .filter((name) => name.endsWith('.yaml') || name.endsWith('.yml'))
279
+ .sort();
280
+
281
+ for (const specFile of specFiles) {
282
+ const inferred = parseSpecIdFromYamlFile(path.join(specsDir, specFile));
283
+ if (inferred) {
284
+ return inferred;
285
+ }
286
+ }
287
+ }
288
+
289
+ return parseSpecIdFromYamlFile(path.join(worktreePath, '.caws', 'working-spec.yaml'));
91
290
  }
92
291
 
93
292
  /**
@@ -205,6 +404,132 @@ function getCurrentBranch() {
205
404
  // floods stderr and contributes to Claude Code context-window exhaustion.
206
405
  let _schemaWarned = false;
207
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
+
208
533
  /**
209
534
  * Load the worktree registry
210
535
  * @param {string} root - Repository root
@@ -242,10 +567,22 @@ function loadRegistry(root) {
242
567
  * @param {Object} registry - Registry object
243
568
  */
244
569
  function saveRegistry(root, registry) {
245
- // Auto-prune destroyed entries whose branch and directory are both gone.
246
- // 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.
247
585
  for (const [name, entry] of Object.entries(registry.worktrees || {})) {
248
- if (entry.status !== 'destroyed') continue;
249
586
  const dirGone = !fs.existsSync(entry.path);
250
587
  let branchGone = true;
251
588
  if (entry.branch) {
@@ -356,7 +693,7 @@ function autoRegisterWorktree(root, registry, discovered) {
356
693
  branch: discovered.branch,
357
694
  baseBranch,
358
695
  scope: null,
359
- specId: null,
696
+ specId: inferSpecIdForWorktree(discovered.path),
360
697
  owner: null,
361
698
  createdAt: new Date().toISOString(),
362
699
  status: 'active',
@@ -424,6 +761,21 @@ function createWorktree(name, options = {}) {
424
761
  const branchName = BRANCH_PREFIX + name;
425
762
  const base = baseBranch || getCurrentBranch();
426
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);
778
+
427
779
  // Check if the branch already exists in git (even if not in registry)
428
780
  // This catches cases where another agent created the branch outside CAWS
429
781
  try {
@@ -449,6 +801,16 @@ function createWorktree(name, options = {}) {
449
801
  // Create the worktree directory
450
802
  fs.ensureDirSync(path.dirname(worktreePath));
451
803
 
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);
812
+ }
813
+
452
814
  // Create git worktree with new branch
453
815
  try {
454
816
  execFileSync('git', ['worktree', 'add', '-b', branchName, worktreePath, base], {
@@ -510,15 +872,20 @@ function createWorktree(name, options = {}) {
510
872
  }
511
873
  }
512
874
 
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.
879
+
513
880
  // Materialize a worktree-local working spec. Prefer the canonical feature
514
881
  // spec when it exists so isolated worktrees stay aligned with the main
515
882
  // registry/resolver model.
516
- if (specId) {
883
+ if (resolvedSpecId) {
517
884
  try {
518
- materializeWorktreeSpec(root, cawsDest, specId, name, scope);
885
+ materializeWorktreeSpec(root, cawsDest, resolvedSpecId, name, scope);
519
886
  } catch (error) {
520
887
  console.warn(
521
- chalk.yellow(`Could not materialize spec '${specId}' for worktree '${name}': ${error.message}`)
888
+ chalk.yellow(`Could not materialize spec '${resolvedSpecId}' for worktree '${name}': ${error.message}`)
522
889
  );
523
890
  // Non-fatal: spec generation is optional
524
891
  }
@@ -531,7 +898,7 @@ function createWorktree(name, options = {}) {
531
898
  branch: branchName,
532
899
  baseBranch: base,
533
900
  scope: scope || null,
534
- specId: specId || null,
901
+ specId: resolvedSpecId,
535
902
  owner: options.owner || getAgentSessionId(root) || null,
536
903
  createdAt: new Date().toISOString(),
537
904
  status: 'fresh',
@@ -540,6 +907,11 @@ function createWorktree(name, options = {}) {
540
907
  registry.worktrees[name] = entry;
541
908
  saveRegistry(root, registry);
542
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
+
543
915
  return entry;
544
916
  }
545
917
 
@@ -892,9 +1264,31 @@ function destroyWorktree(name, options = {}) {
892
1264
  }
893
1265
 
894
1266
  // Update registry
1267
+ const wasAlreadyDestroyed = registry.worktrees[name].status === 'destroyed';
895
1268
  registry.worktrees[name].status = 'destroyed';
896
1269
  registry.worktrees[name].destroyedAt = new Date().toISOString();
897
1270
  saveRegistry(root, registry);
1271
+
1272
+ // CAWSFIX-18: auto-commit the registry so the working tree stays clean
1273
+ if (!wasAlreadyDestroyed) {
1274
+ try {
1275
+ const status = execFileSync('git', ['status', '--porcelain', '.caws/worktrees.json'], {
1276
+ cwd: root, stdio: ['pipe', 'pipe', 'pipe'],
1277
+ }).toString().trim();
1278
+ if (status) {
1279
+ const otherActive = Object.values(registry.worktrees || {}).some(
1280
+ (e) => e.status === 'active' || e.status === 'fresh'
1281
+ );
1282
+ const prefix = otherActive ? 'wip(checkpoint)' : 'chore(worktree)';
1283
+ execFileSync('git', ['add', '.caws/worktrees.json'], { cwd: root, stdio: 'pipe' });
1284
+ execFileSync('git', ['commit', '-m', `${prefix}: record destroyed ${name}`], {
1285
+ cwd: root, stdio: 'pipe',
1286
+ });
1287
+ }
1288
+ } catch (err) {
1289
+ console.warn(chalk.yellow(` Warning: could not auto-commit .caws/worktrees.json: ${err.message}`));
1290
+ }
1291
+ }
898
1292
  }
899
1293
 
900
1294
  /**
@@ -910,7 +1304,7 @@ function destroyWorktree(name, options = {}) {
910
1304
  function mergeWorktree(name, options = {}) {
911
1305
  const root = getRepoRoot();
912
1306
  const registry = loadRegistry(root);
913
- const { dryRun = false, deleteBranch = true, message } = options;
1307
+ const { dryRun = false, deleteBranch = true, message, takeover = false } = options;
914
1308
 
915
1309
  let entry = registry.worktrees[name];
916
1310
  if (!entry) {
@@ -925,6 +1319,26 @@ function mergeWorktree(name, options = {}) {
925
1319
  }
926
1320
  }
927
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
+
928
1342
  const baseBranch = entry.baseBranch || 'main';
929
1343
 
930
1344
  // Check for uncommitted work in the worktree.
@@ -1055,13 +1469,159 @@ function mergeWorktree(name, options = {}) {
1055
1469
  }
1056
1470
  }
1057
1471
 
1058
- const mergeResult = { name, branch: entry.branch, baseBranch, merged: true, conflicts: [] };
1472
+ // Auto-close the bound spec if one exists. A worktree merge is the
1473
+ // lifecycle signal that the spec's work is done; leaving the spec
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
+ };
1482
+ if (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
+ }
1516
+ }
1517
+
1518
+ const mergeResult = {
1519
+ name, branch: entry.branch, baseBranch, merged: true, conflicts: [],
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,
1526
+ };
1059
1527
  try {
1060
1528
  lifecycle.emit(EVENTS.MERGE_POST, { ...mergeResult, timestamp: new Date().toISOString() });
1061
1529
  } catch { /* non-fatal */ }
1062
1530
  return mergeResult;
1063
1531
  }
1064
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
+
1562
+ /**
1563
+ * Flip a spec's status to `closed` by rewriting just the `status:` line.
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.
1568
+ * @param {string} root - Repo root
1569
+ * @param {string} specId - Spec identifier (e.g. CAWSFIX-14)
1570
+ * @returns {{specId: string|null, acsPassing: boolean|null, acsFailureCount: number, acsTotal: number, acsFailureIds: string[]}}
1571
+ */
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
+ };
1582
+ try {
1583
+ const specPath = findFeatureSpecPath(root, specId);
1584
+ if (!specPath || !fs.existsSync(specPath)) return result;
1585
+ result.specPath = specPath;
1586
+ const original = fs.readFileSync(specPath, 'utf8');
1587
+ // Idempotent: already closed → no-op, no write, no diff.
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
+ }
1616
+ fs.writeFileSync(specPath, patched, 'utf8');
1617
+ result.specId = specId;
1618
+ result.didWrite = true;
1619
+ return result;
1620
+ } catch {
1621
+ return result;
1622
+ }
1623
+ }
1624
+
1065
1625
  /**
1066
1626
  * Prune stale worktree entries
1067
1627
  * @param {Object} options - Prune options
@@ -1149,10 +1709,14 @@ module.exports = {
1149
1709
  listWorktrees,
1150
1710
  destroyWorktree,
1151
1711
  mergeWorktree,
1712
+ autoActivateBoundSpec,
1713
+ autoCloseBoundSpec,
1152
1714
  pruneWorktrees,
1153
1715
  repairWorktrees,
1154
1716
  reconcileRegistry,
1155
1717
  loadRegistry,
1718
+ saveRegistry,
1719
+ assertWorktreeOwnership,
1156
1720
  getRepoRoot,
1157
1721
  getLastCommitInfo,
1158
1722
  isBranchMerged,
@@ -1164,5 +1728,8 @@ module.exports = {
1164
1728
  REGISTRY_FILE,
1165
1729
  BRANCH_PREFIX,
1166
1730
  findFeatureSpecPath,
1731
+ findFeatureSpecPathFromCwd,
1167
1732
  materializeWorktreeSpec,
1733
+ inferSpecIdForWorktree,
1734
+ findSpecByWorktreeName,
1168
1735
  };