@paths.design/caws-cli 9.3.2 → 10.1.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 (286) hide show
  1. package/README.md +71 -32
  2. package/dist/budget-derivation.js +221 -74
  3. package/dist/commands/archive.js +67 -28
  4. package/dist/commands/burnup.js +20 -11
  5. package/dist/commands/diagnose.js +34 -22
  6. package/dist/commands/evaluate.js +41 -15
  7. package/dist/commands/gates.js +149 -0
  8. package/dist/commands/init.js +150 -19
  9. package/dist/commands/iterate.js +81 -4
  10. package/dist/commands/parallel.js +4 -0
  11. package/dist/commands/plan.js +9 -19
  12. package/dist/commands/provenance.js +53 -17
  13. package/dist/commands/quality-monitor.js +64 -45
  14. package/dist/commands/scope.js +264 -0
  15. package/dist/commands/sidecar.js +74 -0
  16. package/dist/commands/specs.js +381 -45
  17. package/dist/commands/status.js +117 -9
  18. package/dist/commands/templates.js +0 -8
  19. package/dist/commands/tutorial.js +10 -9
  20. package/dist/commands/validate.js +70 -6
  21. package/dist/commands/verify-acs.js +48 -76
  22. package/dist/commands/waivers.js +212 -13
  23. package/dist/commands/worktree.js +131 -26
  24. package/dist/error-handler.js +2 -13
  25. package/dist/gates/budget-limit.js +121 -0
  26. package/dist/gates/feedback.js +260 -0
  27. package/dist/gates/format.js +179 -0
  28. package/dist/gates/god-object.js +117 -0
  29. package/dist/gates/pipeline.js +167 -0
  30. package/dist/gates/scope-boundary.js +93 -0
  31. package/dist/gates/spec-completeness.js +109 -0
  32. package/dist/gates/todo-detection.js +205 -0
  33. package/dist/index.js +157 -151
  34. package/dist/parallel/parallel-manager.js +3 -3
  35. package/dist/policy/PolicyManager.js +51 -17
  36. package/dist/scaffold/claude-hooks.js +24 -1
  37. package/dist/scaffold/git-hooks.js +45 -102
  38. package/dist/scaffold/index.js +4 -3
  39. package/dist/session/session-manager.js +105 -14
  40. package/dist/sidecars/index.js +33 -0
  41. package/dist/sidecars/listeners.js +40 -0
  42. package/dist/sidecars/provenance-summary.js +238 -0
  43. package/dist/sidecars/quality-gaps.js +258 -0
  44. package/dist/sidecars/schema.js +149 -0
  45. package/dist/sidecars/spec-drift.js +151 -0
  46. package/dist/sidecars/waiver-draft.js +176 -0
  47. package/dist/templates/.caws/schemas/policy.schema.json +112 -0
  48. package/dist/templates/.caws/schemas/scope.schema.json +3 -3
  49. package/dist/templates/.caws/schemas/waivers.schema.json +96 -20
  50. package/dist/templates/.caws/schemas/working-spec.schema.json +264 -57
  51. package/dist/templates/.caws/schemas/worktrees.schema.json +3 -1
  52. package/dist/templates/.caws/templates/working-spec.template.yml +10 -4
  53. package/dist/templates/.caws/tools/scope-guard.js +66 -15
  54. package/dist/templates/.claude/README.md +1 -1
  55. package/dist/templates/.claude/hooks/audit.sh +0 -0
  56. package/dist/templates/.claude/hooks/block-dangerous.sh +52 -11
  57. package/dist/templates/.claude/hooks/classify_command.py +592 -0
  58. package/dist/templates/.claude/hooks/doc-frontmatter-check.sh +173 -0
  59. package/dist/templates/.claude/hooks/protected-paths.sh +39 -0
  60. package/dist/templates/.claude/hooks/quality-check.sh +23 -10
  61. package/dist/templates/.claude/hooks/scope-guard.sh +136 -55
  62. package/dist/templates/.claude/hooks/session-caws-status.sh +2 -2
  63. package/dist/templates/.claude/hooks/session-log.sh +76 -3
  64. package/dist/templates/.claude/hooks/stop-worktree-check.sh +1 -1
  65. package/dist/templates/.claude/hooks/test_classify_command.py +370 -0
  66. package/dist/templates/.claude/hooks/test_wrapper_smoke.sh +96 -0
  67. package/dist/templates/.claude/hooks/worktree-guard.sh +2 -2
  68. package/dist/templates/.claude/hooks/worktree-write-guard.sh +97 -4
  69. package/dist/templates/.claude/settings.json +31 -0
  70. package/dist/templates/.cursor/hooks/caws-quality-check.sh +4 -4
  71. package/dist/templates/.cursor/hooks/caws-scope-guard.sh +1 -1
  72. package/dist/templates/.cursor/hooks/session-log.sh +924 -0
  73. package/dist/templates/.cursor/hooks.json +25 -0
  74. package/dist/templates/.cursor/rules/02-quality-gates.mdc +3 -5
  75. package/dist/templates/.cursor/rules/10-documentation-quality-standards.mdc +6 -11
  76. package/dist/templates/.cursor/rules/11-scope-management-waivers.mdc +14 -18
  77. package/dist/templates/.cursor/rules/12-implementation-completeness.mdc +4 -4
  78. package/dist/templates/.cursor/rules/13-language-agnostic-standards.mdc +3 -13
  79. package/dist/templates/.github/copilot-instructions.md +5 -5
  80. package/dist/templates/.idea/runConfigurations/CAWS_Evaluate.xml +1 -1
  81. package/dist/templates/.junie/guidelines.md +2 -2
  82. package/dist/templates/.vscode/settings.json +3 -1
  83. package/dist/templates/.windsurf/rules/caws-quality-standards.md +2 -2
  84. package/dist/templates/.windsurf/workflows/caws-guided-development.md +3 -3
  85. package/dist/templates/CLAUDE.md +77 -8
  86. package/dist/templates/agents.md +50 -9
  87. package/dist/templates/docs/README.md +8 -7
  88. package/dist/templates/scripts/new_feature.sh +80 -0
  89. package/dist/test-analysis.js +43 -30
  90. package/dist/tool-loader.js +1 -1
  91. package/dist/utils/agent-session.js +202 -0
  92. package/dist/utils/detection.js +8 -2
  93. package/dist/utils/event-log.js +584 -0
  94. package/dist/utils/event-renderer.js +521 -0
  95. package/dist/utils/finalization.js +7 -6
  96. package/dist/utils/gitignore-updater.js +3 -0
  97. package/dist/utils/lifecycle-events.js +94 -0
  98. package/dist/utils/quality-gates-utils.js +29 -44
  99. package/dist/utils/schema-validator.js +50 -0
  100. package/dist/utils/spec-resolver.js +93 -21
  101. package/dist/utils/working-state.js +530 -0
  102. package/dist/validation/spec-validation.js +191 -31
  103. package/dist/waivers-manager.js +144 -6
  104. package/dist/worktree/worktree-manager.js +598 -95
  105. package/package.json +9 -8
  106. package/templates/.caws/schemas/policy.schema.json +112 -0
  107. package/templates/.caws/schemas/scope.schema.json +3 -3
  108. package/templates/.caws/schemas/waivers.schema.json +96 -20
  109. package/templates/.caws/schemas/working-spec.schema.json +264 -57
  110. package/templates/.caws/schemas/worktrees.schema.json +3 -1
  111. package/templates/.caws/templates/working-spec.template.yml +10 -4
  112. package/templates/.caws/tools/scope-guard.js +66 -15
  113. package/templates/.claude/README.md +1 -1
  114. package/templates/.claude/hooks/block-dangerous.sh +52 -11
  115. package/templates/.claude/hooks/classify_command.py +592 -0
  116. package/templates/.claude/hooks/doc-frontmatter-check.sh +173 -0
  117. package/templates/.claude/hooks/protected-paths.sh +39 -0
  118. package/templates/.claude/hooks/quality-check.sh +23 -10
  119. package/templates/.claude/hooks/scope-guard.sh +136 -55
  120. package/templates/.claude/hooks/session-caws-status.sh +2 -2
  121. package/templates/.claude/hooks/session-log.sh +76 -3
  122. package/templates/.claude/hooks/stop-worktree-check.sh +1 -1
  123. package/templates/.claude/hooks/test_classify_command.py +370 -0
  124. package/templates/.claude/hooks/test_wrapper_smoke.sh +96 -0
  125. package/templates/.claude/hooks/worktree-guard.sh +2 -2
  126. package/templates/.claude/hooks/worktree-write-guard.sh +97 -4
  127. package/templates/.claude/settings.json +31 -0
  128. package/templates/.cursor/hooks/caws-quality-check.sh +4 -4
  129. package/templates/.cursor/hooks/caws-scope-guard.sh +1 -1
  130. package/templates/.cursor/hooks/session-log.sh +924 -0
  131. package/templates/.cursor/hooks.json +25 -0
  132. package/templates/.cursor/rules/02-quality-gates.mdc +3 -5
  133. package/templates/.cursor/rules/10-documentation-quality-standards.mdc +6 -11
  134. package/templates/.cursor/rules/11-scope-management-waivers.mdc +14 -18
  135. package/templates/.cursor/rules/12-implementation-completeness.mdc +4 -4
  136. package/templates/.cursor/rules/13-language-agnostic-standards.mdc +3 -13
  137. package/templates/.github/copilot-instructions.md +5 -5
  138. package/templates/.idea/runConfigurations/CAWS_Evaluate.xml +1 -1
  139. package/templates/.junie/guidelines.md +2 -2
  140. package/templates/.vscode/settings.json +3 -1
  141. package/templates/.windsurf/rules/caws-quality-standards.md +2 -2
  142. package/templates/.windsurf/workflows/caws-guided-development.md +3 -3
  143. package/templates/CLAUDE.md +77 -8
  144. package/templates/{AGENTS.md → agents.md} +50 -9
  145. package/templates/docs/README.md +8 -7
  146. package/templates/scripts/new_feature.sh +80 -0
  147. package/dist/budget-derivation.d.ts +0 -74
  148. package/dist/budget-derivation.d.ts.map +0 -1
  149. package/dist/cicd-optimizer.d.ts +0 -142
  150. package/dist/cicd-optimizer.d.ts.map +0 -1
  151. package/dist/commands/archive.d.ts +0 -51
  152. package/dist/commands/archive.d.ts.map +0 -1
  153. package/dist/commands/burnup.d.ts +0 -6
  154. package/dist/commands/burnup.d.ts.map +0 -1
  155. package/dist/commands/diagnose.d.ts +0 -52
  156. package/dist/commands/diagnose.d.ts.map +0 -1
  157. package/dist/commands/evaluate.d.ts +0 -8
  158. package/dist/commands/evaluate.d.ts.map +0 -1
  159. package/dist/commands/init.d.ts +0 -5
  160. package/dist/commands/init.d.ts.map +0 -1
  161. package/dist/commands/iterate.d.ts +0 -8
  162. package/dist/commands/iterate.d.ts.map +0 -1
  163. package/dist/commands/mode.d.ts +0 -25
  164. package/dist/commands/mode.d.ts.map +0 -1
  165. package/dist/commands/parallel.d.ts +0 -7
  166. package/dist/commands/parallel.d.ts.map +0 -1
  167. package/dist/commands/plan.d.ts +0 -49
  168. package/dist/commands/plan.d.ts.map +0 -1
  169. package/dist/commands/provenance.d.ts +0 -32
  170. package/dist/commands/provenance.d.ts.map +0 -1
  171. package/dist/commands/quality-gates.d.ts +0 -6
  172. package/dist/commands/quality-gates.d.ts.map +0 -1
  173. package/dist/commands/quality-gates.js +0 -444
  174. package/dist/commands/quality-monitor.d.ts +0 -17
  175. package/dist/commands/quality-monitor.d.ts.map +0 -1
  176. package/dist/commands/session.d.ts +0 -7
  177. package/dist/commands/session.d.ts.map +0 -1
  178. package/dist/commands/specs.d.ts +0 -77
  179. package/dist/commands/specs.d.ts.map +0 -1
  180. package/dist/commands/status.d.ts +0 -44
  181. package/dist/commands/status.d.ts.map +0 -1
  182. package/dist/commands/templates.d.ts +0 -74
  183. package/dist/commands/templates.d.ts.map +0 -1
  184. package/dist/commands/tool.d.ts +0 -13
  185. package/dist/commands/tool.d.ts.map +0 -1
  186. package/dist/commands/troubleshoot.d.ts +0 -8
  187. package/dist/commands/troubleshoot.d.ts.map +0 -1
  188. package/dist/commands/troubleshoot.js +0 -104
  189. package/dist/commands/tutorial.d.ts +0 -55
  190. package/dist/commands/tutorial.d.ts.map +0 -1
  191. package/dist/commands/validate.d.ts +0 -15
  192. package/dist/commands/validate.d.ts.map +0 -1
  193. package/dist/commands/waivers.d.ts +0 -8
  194. package/dist/commands/waivers.d.ts.map +0 -1
  195. package/dist/commands/workflow.d.ts +0 -85
  196. package/dist/commands/workflow.d.ts.map +0 -1
  197. package/dist/commands/worktree.d.ts +0 -7
  198. package/dist/commands/worktree.d.ts.map +0 -1
  199. package/dist/config/index.d.ts +0 -29
  200. package/dist/config/index.d.ts.map +0 -1
  201. package/dist/config/lite-scope.d.ts +0 -33
  202. package/dist/config/lite-scope.d.ts.map +0 -1
  203. package/dist/config/modes.d.ts +0 -264
  204. package/dist/config/modes.d.ts.map +0 -1
  205. package/dist/constants/spec-types.d.ts +0 -93
  206. package/dist/constants/spec-types.d.ts.map +0 -1
  207. package/dist/error-handler.d.ts +0 -151
  208. package/dist/error-handler.d.ts.map +0 -1
  209. package/dist/generators/jest-config-generator.d.ts +0 -32
  210. package/dist/generators/jest-config-generator.d.ts.map +0 -1
  211. package/dist/generators/jest-config.d.ts +0 -32
  212. package/dist/generators/jest-config.d.ts.map +0 -1
  213. package/dist/generators/jest-config.js +0 -242
  214. package/dist/generators/working-spec.d.ts +0 -13
  215. package/dist/generators/working-spec.d.ts.map +0 -1
  216. package/dist/index-new.d.ts +0 -5
  217. package/dist/index-new.d.ts.map +0 -1
  218. package/dist/index-new.js +0 -317
  219. package/dist/index.d.ts +0 -5
  220. package/dist/index.d.ts.map +0 -1
  221. package/dist/index.js.backup +0 -4711
  222. package/dist/minimal-cli.d.ts +0 -3
  223. package/dist/minimal-cli.d.ts.map +0 -1
  224. package/dist/parallel/parallel-manager.d.ts +0 -67
  225. package/dist/parallel/parallel-manager.d.ts.map +0 -1
  226. package/dist/policy/PolicyManager.d.ts +0 -104
  227. package/dist/policy/PolicyManager.d.ts.map +0 -1
  228. package/dist/scaffold/claude-hooks.d.ts +0 -28
  229. package/dist/scaffold/claude-hooks.d.ts.map +0 -1
  230. package/dist/scaffold/cursor-hooks.d.ts +0 -7
  231. package/dist/scaffold/cursor-hooks.d.ts.map +0 -1
  232. package/dist/scaffold/git-hooks.d.ts +0 -38
  233. package/dist/scaffold/git-hooks.d.ts.map +0 -1
  234. package/dist/scaffold/index.d.ts +0 -17
  235. package/dist/scaffold/index.d.ts.map +0 -1
  236. package/dist/session/session-manager.d.ts +0 -94
  237. package/dist/session/session-manager.d.ts.map +0 -1
  238. package/dist/spec/SpecFileManager.d.ts +0 -146
  239. package/dist/spec/SpecFileManager.d.ts.map +0 -1
  240. package/dist/templates/.cursor/hooks/caws-tool-validation.sh +0 -121
  241. package/dist/templates/.github/copilot/instructions.md +0 -311
  242. package/dist/test-analysis.d.ts +0 -231
  243. package/dist/test-analysis.d.ts.map +0 -1
  244. package/dist/tool-interface.d.ts +0 -236
  245. package/dist/tool-interface.d.ts.map +0 -1
  246. package/dist/tool-loader.d.ts +0 -77
  247. package/dist/tool-loader.d.ts.map +0 -1
  248. package/dist/tool-validator.d.ts +0 -72
  249. package/dist/tool-validator.d.ts.map +0 -1
  250. package/dist/utils/async-utils.d.ts +0 -73
  251. package/dist/utils/async-utils.d.ts.map +0 -1
  252. package/dist/utils/command-wrapper.d.ts +0 -66
  253. package/dist/utils/command-wrapper.d.ts.map +0 -1
  254. package/dist/utils/detection.d.ts +0 -14
  255. package/dist/utils/detection.d.ts.map +0 -1
  256. package/dist/utils/error-categories.d.ts +0 -52
  257. package/dist/utils/error-categories.d.ts.map +0 -1
  258. package/dist/utils/finalization.d.ts +0 -17
  259. package/dist/utils/finalization.d.ts.map +0 -1
  260. package/dist/utils/git-lock.d.ts +0 -13
  261. package/dist/utils/git-lock.d.ts.map +0 -1
  262. package/dist/utils/gitignore-updater.d.ts +0 -39
  263. package/dist/utils/gitignore-updater.d.ts.map +0 -1
  264. package/dist/utils/ide-detection.d.ts +0 -89
  265. package/dist/utils/ide-detection.d.ts.map +0 -1
  266. package/dist/utils/project-analysis.d.ts +0 -34
  267. package/dist/utils/project-analysis.d.ts.map +0 -1
  268. package/dist/utils/promise-utils.d.ts +0 -30
  269. package/dist/utils/promise-utils.d.ts.map +0 -1
  270. package/dist/utils/quality-gates-utils.d.ts +0 -49
  271. package/dist/utils/quality-gates-utils.d.ts.map +0 -1
  272. package/dist/utils/quality-gates.d.ts +0 -49
  273. package/dist/utils/quality-gates.d.ts.map +0 -1
  274. package/dist/utils/quality-gates.js +0 -402
  275. package/dist/utils/spec-resolver.d.ts +0 -80
  276. package/dist/utils/spec-resolver.d.ts.map +0 -1
  277. package/dist/utils/typescript-detector.d.ts +0 -66
  278. package/dist/utils/typescript-detector.d.ts.map +0 -1
  279. package/dist/utils/yaml-validation.d.ts +0 -32
  280. package/dist/utils/yaml-validation.d.ts.map +0 -1
  281. package/dist/validation/spec-validation.d.ts +0 -43
  282. package/dist/validation/spec-validation.d.ts.map +0 -1
  283. package/dist/waivers-manager.d.ts +0 -167
  284. package/dist/waivers-manager.d.ts.map +0 -1
  285. package/dist/worktree/worktree-manager.d.ts +0 -54
  286. package/dist/worktree/worktree-manager.d.ts.map +0 -1
@@ -8,11 +8,215 @@ const { execFileSync } = require('child_process');
8
8
  const fs = require('fs-extra');
9
9
  const path = require('path');
10
10
  const chalk = require('chalk');
11
+ const { createValidator, getSchemaPath } = require('../utils/schema-validator');
12
+ const { getAgentSessionId } = require('../utils/agent-session');
13
+ const { lifecycle, EVENTS } = require('../utils/lifecycle-events');
11
14
 
12
15
  const WORKTREES_DIR = '.caws/worktrees';
13
16
  const REGISTRY_FILE = '.caws/worktrees.json';
14
17
  const BRANCH_PREFIX = 'caws/';
15
18
 
19
+ function findFeatureSpecPath(root, specId) {
20
+ if (!specId) return null;
21
+
22
+ const candidates = [
23
+ path.join(root, '.caws', 'specs', `${specId}.yaml`),
24
+ path.join(root, '.caws', 'specs', `${specId}.yml`),
25
+ ];
26
+
27
+ return candidates.find((candidate) => fs.existsSync(candidate)) || null;
28
+ }
29
+
30
+ function writeSpecWithWorktree(filePath, worktreeName) {
31
+ const yaml = require('js-yaml');
32
+ const content = fs.readFileSync(filePath, 'utf8');
33
+ const parsed = yaml.load(content);
34
+ if (!parsed || typeof parsed !== 'object') {
35
+ return content;
36
+ }
37
+
38
+ parsed.worktree = worktreeName;
39
+ return yaml.dump(parsed, { lineWidth: 120, noRefs: true });
40
+ }
41
+
42
+ function hasPathChanges(root, relativePath) {
43
+ try {
44
+ const output = execFileSync(
45
+ 'git',
46
+ ['status', '--porcelain', '--', relativePath],
47
+ { cwd: root, encoding: 'utf8', stdio: 'pipe' }
48
+ ).trim();
49
+ return output.length > 0;
50
+ } catch {
51
+ return false;
52
+ }
53
+ }
54
+
55
+ function ensureCanonicalSpecCommitted(root, specPath, specId, worktreeName) {
56
+ const relativeSpecPath = path.relative(root, specPath);
57
+ const nextContent = writeSpecWithWorktree(specPath, worktreeName);
58
+ const currentContent = fs.readFileSync(specPath, 'utf8');
59
+
60
+ if (currentContent !== nextContent) {
61
+ fs.writeFileSync(specPath, nextContent);
62
+ }
63
+
64
+ if (!hasPathChanges(root, relativeSpecPath)) {
65
+ return false;
66
+ }
67
+
68
+ execFileSync('git', ['add', '--', relativeSpecPath], {
69
+ cwd: root,
70
+ stdio: 'pipe',
71
+ });
72
+ execFileSync(
73
+ 'git',
74
+ ['commit', '-m', `chore(caws): bind spec ${specId} to worktree ${worktreeName}`, '--', relativeSpecPath],
75
+ {
76
+ cwd: root,
77
+ stdio: 'pipe',
78
+ }
79
+ );
80
+ return true;
81
+ }
82
+
83
+ function materializeWorktreeSpec(root, cawsDest, specId, worktreeName, scope) {
84
+ if (!specId) return;
85
+
86
+ const canonicalSpecPath = findFeatureSpecPath(root, specId);
87
+ const workingSpecPath = path.join(cawsDest, 'working-spec.yaml');
88
+
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
+ }
94
+
95
+ if (canonicalSpecPath) {
96
+ const destSpecsDir = path.join(cawsDest, 'specs');
97
+ const destSpecPath = path.join(destSpecsDir, path.basename(canonicalSpecPath));
98
+ fs.ensureDirSync(destSpecsDir);
99
+
100
+ // Keep a canonical feature-spec copy inside the worktree and align
101
+ // working-spec.yaml to that exact content for legacy-compatible commands.
102
+ const specContent = writeSpecWithWorktree(canonicalSpecPath, worktreeName);
103
+ fs.writeFileSync(destSpecPath, specContent);
104
+ fs.writeFileSync(workingSpecPath, specContent);
105
+ return;
106
+ }
107
+
108
+ const { generateWorkingSpec } = require('../generators/working-spec');
109
+ let specContent = generateWorkingSpec({
110
+ projectId: specId,
111
+ projectTitle: `Worktree: ${worktreeName}`,
112
+ projectDescription: `Isolated worktree for ${worktreeName}`,
113
+ riskTier: 3,
114
+ projectMode: 'feature',
115
+ scopeIn: scope || 'src/',
116
+ scopeOut: 'node_modules/, dist/, build/',
117
+ maxFiles: 25,
118
+ maxLoc: 1000,
119
+ blastModules: scope || 'src',
120
+ dataMigration: false,
121
+ rollbackSlo: '5m',
122
+ projectThreats: '',
123
+ projectInvariants: 'System maintains data consistency',
124
+ acceptanceCriteria: 'Given current state, when action occurs, then expected result',
125
+ a11yRequirements: 'keyboard',
126
+ perfBudget: 250,
127
+ securityRequirements: 'validation',
128
+ contractType: '',
129
+ contractPath: '',
130
+ observabilityLogs: '',
131
+ observabilityMetrics: '',
132
+ observabilityTraces: '',
133
+ migrationPlan: '',
134
+ rollbackPlan: '',
135
+ needsOverride: false,
136
+ isExperimental: false,
137
+ aiConfidence: 0.8,
138
+ uncertaintyAreas: '',
139
+ complexityFactors: '',
140
+ });
141
+
142
+ try {
143
+ const yaml = require('js-yaml');
144
+ const parsed = yaml.load(specContent);
145
+ if (parsed && typeof parsed === 'object') {
146
+ parsed.worktree = worktreeName;
147
+ specContent = yaml.dump(parsed, { lineWidth: 120, noRefs: true });
148
+ }
149
+ } catch {
150
+ // Keep generated spec content if augmentation fails.
151
+ }
152
+
153
+ fs.ensureDirSync(path.dirname(workingSpecPath));
154
+ fs.writeFileSync(workingSpecPath, specContent);
155
+ }
156
+
157
+ function parseSpecIdFromYamlFile(filePath) {
158
+ try {
159
+ const yaml = require('js-yaml');
160
+ const doc = yaml.load(fs.readFileSync(filePath, 'utf8'));
161
+ if (doc && typeof doc.id === 'string' && doc.id.trim()) {
162
+ return doc.id.trim();
163
+ }
164
+ } catch {
165
+ // Ignore malformed YAML during inference
166
+ }
167
+ return null;
168
+ }
169
+
170
+ /**
171
+ * Scan .caws/specs/ for a spec that declares `worktree: <name>`.
172
+ * Returns the spec's id if found, null otherwise.
173
+ * This enables auto-binding: when a spec already names the worktree
174
+ * it expects, the registry entry gets the specId automatically.
175
+ * @param {string} root - Repository root
176
+ * @param {string} worktreeName - Worktree name to match
177
+ * @returns {string|null} Spec ID or null
178
+ */
179
+ function findSpecByWorktreeName(root, worktreeName) {
180
+ const yaml = require('js-yaml');
181
+ const specsDir = path.join(root, '.caws', 'specs');
182
+ if (!fs.existsSync(specsDir)) return null;
183
+
184
+ const specFiles = fs.readdirSync(specsDir)
185
+ .filter((name) => name.endsWith('.yaml') || name.endsWith('.yml'));
186
+
187
+ for (const specFile of specFiles) {
188
+ try {
189
+ const doc = yaml.load(fs.readFileSync(path.join(specsDir, specFile), 'utf8'));
190
+ if (doc && doc.worktree === worktreeName && typeof doc.id === 'string') {
191
+ return doc.id.trim();
192
+ }
193
+ } catch {
194
+ // Skip malformed spec files
195
+ }
196
+ }
197
+ return null;
198
+ }
199
+
200
+ function inferSpecIdForWorktree(worktreePath) {
201
+ if (!worktreePath) return null;
202
+
203
+ const specsDir = path.join(worktreePath, '.caws', 'specs');
204
+ if (fs.existsSync(specsDir)) {
205
+ const specFiles = fs.readdirSync(specsDir)
206
+ .filter((name) => name.endsWith('.yaml') || name.endsWith('.yml'))
207
+ .sort();
208
+
209
+ for (const specFile of specFiles) {
210
+ const inferred = parseSpecIdFromYamlFile(path.join(specsDir, specFile));
211
+ if (inferred) {
212
+ return inferred;
213
+ }
214
+ }
215
+ }
216
+
217
+ return parseSpecIdFromYamlFile(path.join(worktreePath, '.caws', 'working-spec.yaml'));
218
+ }
219
+
16
220
  /**
17
221
  * Get the last commit info for a branch
18
222
  * @param {string} branch - Branch name
@@ -53,6 +257,44 @@ function isBranchMerged(branch, target, root) {
53
257
  }
54
258
  }
55
259
 
260
+ /**
261
+ * Check if a branch has divergent commits from target (commits on branch not on target).
262
+ * @param {string} branch - Branch to check
263
+ * @param {string} target - Target branch (e.g., "main")
264
+ * @param {string} root - Repository root
265
+ * @returns {boolean}
266
+ */
267
+ function hasDivergentCommits(branch, target, root) {
268
+ try {
269
+ const count = execFileSync(
270
+ 'git',
271
+ ['rev-list', '--count', `${target}..${branch}`],
272
+ { cwd: root, encoding: 'utf8', stdio: 'pipe' }
273
+ ).trim();
274
+ return parseInt(count, 10) > 0;
275
+ } catch {
276
+ return false;
277
+ }
278
+ }
279
+
280
+ /**
281
+ * Check if a worktree directory has dirty (uncommitted) files.
282
+ * @param {string} worktreePath - Path to the worktree
283
+ * @returns {boolean}
284
+ */
285
+ function hasDirtyFiles(worktreePath) {
286
+ try {
287
+ const status = execFileSync(
288
+ 'git',
289
+ ['status', '--porcelain'],
290
+ { cwd: worktreePath, encoding: 'utf8', stdio: 'pipe' }
291
+ ).trim();
292
+ return status.length > 0;
293
+ } catch {
294
+ return false;
295
+ }
296
+ }
297
+
56
298
  /**
57
299
  * Get the canonical git repository root (main worktree, not a linked worktree).
58
300
  *
@@ -85,6 +327,11 @@ function getCurrentBranch() {
85
327
  }).trim();
86
328
  }
87
329
 
330
+ // Track whether we've already warned about schema violations this process.
331
+ // loadRegistry() is called multiple times per command; warning every time
332
+ // floods stderr and contributes to Claude Code context-window exhaustion.
333
+ let _schemaWarned = false;
334
+
88
335
  /**
89
336
  * Load the worktree registry
90
337
  * @param {string} root - Repository root
@@ -94,7 +341,21 @@ function loadRegistry(root) {
94
341
  const registryPath = path.join(root, REGISTRY_FILE);
95
342
  try {
96
343
  if (fs.existsSync(registryPath)) {
97
- return JSON.parse(fs.readFileSync(registryPath, 'utf8'));
344
+ const data = JSON.parse(fs.readFileSync(registryPath, 'utf8'));
345
+ try {
346
+ const validate = createValidator(getSchemaPath('worktrees.schema.json', root));
347
+ const result = validate(data);
348
+ if (!result.valid && !_schemaWarned) {
349
+ _schemaWarned = true;
350
+ console.warn('Worktree registry has schema violations:', result.errors);
351
+ }
352
+ } catch (schemaErr) {
353
+ if (!_schemaWarned) {
354
+ _schemaWarned = true;
355
+ console.warn('Could not validate worktree registry schema:', schemaErr.message);
356
+ }
357
+ }
358
+ return data;
98
359
  }
99
360
  } catch {
100
361
  // Corrupted registry, start fresh
@@ -108,6 +369,27 @@ function loadRegistry(root) {
108
369
  * @param {Object} registry - Registry object
109
370
  */
110
371
  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.
374
+ for (const [name, entry] of Object.entries(registry.worktrees || {})) {
375
+ if (entry.status !== 'destroyed') continue;
376
+ const dirGone = !fs.existsSync(entry.path);
377
+ let branchGone = true;
378
+ if (entry.branch) {
379
+ try {
380
+ execFileSync('git', ['rev-parse', '--verify', entry.branch], {
381
+ cwd: root, stdio: 'pipe',
382
+ });
383
+ branchGone = false;
384
+ } catch {
385
+ branchGone = true;
386
+ }
387
+ }
388
+ if (dirGone && branchGone) {
389
+ delete registry.worktrees[name];
390
+ }
391
+ }
392
+
111
393
  const registryPath = path.join(root, REGISTRY_FILE);
112
394
  fs.ensureDirSync(path.dirname(registryPath));
113
395
  fs.writeFileSync(registryPath, JSON.stringify(registry, null, 2));
@@ -201,7 +483,7 @@ function autoRegisterWorktree(root, registry, discovered) {
201
483
  branch: discovered.branch,
202
484
  baseBranch,
203
485
  scope: null,
204
- specId: null,
486
+ specId: inferSpecIdForWorktree(discovered.path),
205
487
  owner: null,
206
488
  createdAt: new Date().toISOString(),
207
489
  status: 'active',
@@ -233,18 +515,72 @@ function createWorktree(name, options = {}) {
233
515
 
234
516
  const registry = loadRegistry(root);
235
517
 
236
- // Check for duplicate
518
+ // Check for duplicate in registry
237
519
  if (registry.worktrees[name]) {
238
- throw new Error(`Worktree '${name}' already exists. Use 'caws worktree destroy ${name}' first.`);
520
+ const existing = registry.worktrees[name];
521
+ if (existing.status !== 'destroyed') {
522
+ const ownerInfo = existing.owner ? ` (owned by session ${existing.owner})` : '';
523
+ throw new Error(
524
+ `Worktree '${name}' already exists with status '${existing.status}'${ownerInfo}.\n` +
525
+ `Use 'caws worktree destroy ${name}' first, or choose a different name.`
526
+ );
527
+ }
528
+ // Destroyed entries: check if another session owns the branch
529
+ if (existing.owner && existing.owner !== getAgentSessionId(root)) {
530
+ // Branch may still be in use by the owning session for merge
531
+ try {
532
+ const branchExists = execFileSync('git', ['rev-parse', '--verify', BRANCH_PREFIX + name], {
533
+ cwd: root, stdio: 'pipe',
534
+ }).toString().trim();
535
+ if (branchExists) {
536
+ throw new Error(
537
+ `Worktree '${name}' was destroyed but branch '${BRANCH_PREFIX}${name}' still exists ` +
538
+ `(owned by session ${existing.owner}).\n` +
539
+ `The owning session may still need this branch for merging.\n` +
540
+ `Choose a different name, or delete the branch first: git branch -d ${BRANCH_PREFIX}${name}`
541
+ );
542
+ }
543
+ } catch (e) {
544
+ if (e.message.includes('owned by session')) throw e;
545
+ // Branch doesn't exist — safe to reuse the name
546
+ }
547
+ }
239
548
  }
240
549
 
241
550
  const worktreePath = path.join(root, WORKTREES_DIR, name);
242
551
  const branchName = BRANCH_PREFIX + name;
243
552
  const base = baseBranch || getCurrentBranch();
553
+ const canonicalSpecPath = findFeatureSpecPath(root, specId);
554
+
555
+ // Check if the branch already exists in git (even if not in registry)
556
+ // This catches cases where another agent created the branch outside CAWS
557
+ try {
558
+ execFileSync('git', ['rev-parse', '--verify', branchName], {
559
+ cwd: root, stdio: 'pipe',
560
+ });
561
+ // Branch exists — refuse unless it's fully merged into base
562
+ const currentSession = getAgentSessionId(root);
563
+ const registryOwner = registry.worktrees[name]?.owner;
564
+ if (registryOwner && registryOwner !== currentSession) {
565
+ throw new Error(
566
+ `Branch '${branchName}' already exists and is owned by another session (${registryOwner}).\n` +
567
+ `Another agent may be using this branch. Choose a different worktree name.`
568
+ );
569
+ }
570
+ // Branch exists but no owner conflict — warn and reuse
571
+ console.warn(`Warning: Branch '${branchName}' already exists, reusing it.`);
572
+ } catch (e) {
573
+ if (e.message.includes('already exists and is owned')) throw e;
574
+ // Branch doesn't exist — this is the normal path
575
+ }
244
576
 
245
577
  // Create the worktree directory
246
578
  fs.ensureDirSync(path.dirname(worktreePath));
247
579
 
580
+ if (canonicalSpecPath) {
581
+ ensureCanonicalSpecCommitted(root, canonicalSpecPath, specId, name);
582
+ }
583
+
248
584
  // Create git worktree with new branch
249
585
  try {
250
586
  execFileSync('git', ['worktree', 'add', '-b', branchName, worktreePath, base], {
@@ -252,7 +588,7 @@ function createWorktree(name, options = {}) {
252
588
  stdio: 'pipe',
253
589
  });
254
590
  } catch (error) {
255
- // Branch might already exist
591
+ // Branch already exists (caught above and allowed) — attach to it
256
592
  if (error.message.includes('already exists')) {
257
593
  execFileSync('git', ['worktree', 'add', worktreePath, branchName], {
258
594
  cwd: root,
@@ -306,46 +642,27 @@ function createWorktree(name, options = {}) {
306
642
  }
307
643
  }
308
644
 
309
- // Generate working spec if in standard+ mode and specId provided
310
- if (specId) {
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
+ }
655
+
656
+ // Materialize a worktree-local working spec. Prefer the canonical feature
657
+ // spec when it exists so isolated worktrees stay aligned with the main
658
+ // registry/resolver model.
659
+ if (resolvedSpecId) {
311
660
  try {
312
- const { generateWorkingSpec } = require('../generators/working-spec');
313
- const specContent = generateWorkingSpec({
314
- projectId: specId,
315
- projectTitle: `Worktree: ${name}`,
316
- projectDescription: `Isolated worktree for ${name}`,
317
- riskTier: 3,
318
- projectMode: 'feature',
319
- scopeIn: scope || 'src/',
320
- scopeOut: 'node_modules/, dist/, build/',
321
- maxFiles: 25,
322
- maxLoc: 1000,
323
- blastModules: scope || 'src',
324
- dataMigration: false,
325
- rollbackSlo: '5m',
326
- projectThreats: '',
327
- projectInvariants: 'System maintains data consistency',
328
- acceptanceCriteria: 'Given current state, when action occurs, then expected result',
329
- a11yRequirements: 'keyboard',
330
- perfBudget: 250,
331
- securityRequirements: 'validation',
332
- contractType: '',
333
- contractPath: '',
334
- observabilityLogs: '',
335
- observabilityMetrics: '',
336
- observabilityTraces: '',
337
- migrationPlan: '',
338
- rollbackPlan: '',
339
- needsOverride: false,
340
- isExperimental: false,
341
- aiConfidence: 0.8,
342
- uncertaintyAreas: '',
343
- complexityFactors: '',
344
- });
345
- const specPath = path.join(cawsDest, 'working-spec.yaml');
346
- fs.ensureDirSync(path.dirname(specPath));
347
- fs.writeFileSync(specPath, specContent);
348
- } catch {
661
+ materializeWorktreeSpec(root, cawsDest, resolvedSpecId, name, scope);
662
+ } catch (error) {
663
+ console.warn(
664
+ chalk.yellow(`Could not materialize spec '${resolvedSpecId}' for worktree '${name}': ${error.message}`)
665
+ );
349
666
  // Non-fatal: spec generation is optional
350
667
  }
351
668
  }
@@ -357,10 +674,10 @@ function createWorktree(name, options = {}) {
357
674
  branch: branchName,
358
675
  baseBranch: base,
359
676
  scope: scope || null,
360
- specId: specId || null,
361
- owner: options.owner || process.env.CLAUDE_SESSION_ID || null,
677
+ specId: resolvedSpecId,
678
+ owner: options.owner || getAgentSessionId(root) || null,
362
679
  createdAt: new Date().toISOString(),
363
- status: 'active',
680
+ status: 'fresh',
364
681
  };
365
682
 
366
683
  registry.worktrees[name] = entry;
@@ -415,26 +732,44 @@ function reconcileRegistry(root) {
415
732
  (wt) => path.resolve(wt) === path.resolve(entry.path)
416
733
  );
417
734
 
735
+ const merged = entry.branch && entry.baseBranch
736
+ ? isBranchMerged(entry.branch, entry.baseBranch, root)
737
+ : false;
738
+ const divergent = entry.branch && entry.baseBranch
739
+ ? hasDivergentCommits(entry.branch, entry.baseBranch, root)
740
+ : false;
741
+ const dirty = exists ? hasDirtyFiles(entry.path) : false;
742
+
418
743
  let status;
419
744
  if (entry.status === 'destroyed') {
420
745
  status = 'destroyed';
421
746
  } else if (exists && inGit) {
422
- status = 'active';
747
+ // Worktree directory exists and is tracked by git
748
+ if (divergent || dirty) {
749
+ // Has commits beyond base or uncommitted work → active
750
+ status = 'active';
751
+ } else if (merged) {
752
+ // No divergent commits, branch aligned with base.
753
+ // Use stored status as history to distinguish fresh vs merged:
754
+ // - stored 'fresh' → never had divergent commits → still fresh
755
+ // - stored 'active' → had work that's now merged → merged
756
+ if (entry.status === 'active') {
757
+ status = 'merged';
758
+ } else {
759
+ status = 'fresh';
760
+ }
761
+ } else {
762
+ status = 'fresh';
763
+ }
423
764
  } else if (exists) {
424
765
  status = 'orphaned';
425
766
  } else {
426
- const merged = entry.branch && entry.baseBranch
427
- ? isBranchMerged(entry.branch, entry.baseBranch, root)
428
- : false;
429
767
  status = merged ? 'stale-merged' : 'missing';
430
768
  }
431
769
 
432
770
  const lastCommit = entry.branch ? getLastCommitInfo(entry.branch, root) : null;
433
- const merged = entry.branch && entry.baseBranch
434
- ? isBranchMerged(entry.branch, entry.baseBranch, root)
435
- : false;
436
771
 
437
- return { ...entry, status, lastCommit, merged };
772
+ return { ...entry, status, lastCommit, merged, divergent, dirty };
438
773
  });
439
774
 
440
775
  // Append unregistered worktrees discovered from git
@@ -466,15 +801,17 @@ function reconcileRegistry(root) {
466
801
  * prunes stale entries. Reports the delta before persisting.
467
802
  *
468
803
  * @param {Object} options
469
- * @param {boolean} [options.prune=false] - Remove destroyed and stale-merged entries
804
+ * @param {boolean} [options.prune=false] - Remove destroyed, stale-merged, and missing entries
470
805
  * @param {boolean} [options.dryRun=false] - Report only, do not persist
806
+ * @param {boolean} [options.force=false] - Allow pruning entries owned by other sessions
471
807
  * @returns {{ repaired: Array, pruned: Array, skipped: Array }}
472
808
  */
473
809
  function repairWorktrees(options = {}) {
474
- const { prune: shouldPrune = false, dryRun = false } = options;
810
+ const { prune: shouldPrune = false, dryRun = false, force = false } = options;
475
811
  const root = getRepoRoot();
476
812
  const registry = loadRegistry(root);
477
813
  const { entries } = reconcileRegistry(root);
814
+ const currentSession = getAgentSessionId(root);
478
815
 
479
816
  const repaired = [];
480
817
  const pruned = [];
@@ -494,18 +831,47 @@ function repairWorktrees(options = {}) {
494
831
  if (!regEntry) continue;
495
832
 
496
833
  // Update registry status to match filesystem reality
497
- if (regEntry.status === 'active' && (entry.status === 'missing' || entry.status === 'stale-merged')) {
498
- repaired.push({ name: entry.name, action: 'status-updated', from: 'active', to: entry.status });
834
+ const wasAlive = regEntry.status === 'active' || regEntry.status === 'fresh';
835
+ const nowDead = entry.status === 'missing' || entry.status === 'stale-merged';
836
+ if (wasAlive && nowDead) {
837
+ repaired.push({
838
+ name: entry.name,
839
+ action: 'status-updated',
840
+ from: regEntry.status,
841
+ to: entry.status,
842
+ owner: entry.owner || null,
843
+ });
499
844
  }
500
845
 
501
- // Prune if requested and entry is dead
502
- if (shouldPrune && (entry.status === 'destroyed' || entry.status === 'stale-merged')) {
503
- if (!dryRun) {
504
- delete registry.worktrees[entry.name];
846
+ // Determine if entry is prunable (destroyed, stale-merged, or missing)
847
+ const isPrunable = entry.status === 'destroyed' ||
848
+ entry.status === 'stale-merged' ||
849
+ entry.status === 'missing';
850
+
851
+ if (!isPrunable) continue;
852
+
853
+ // Ownership check: refuse to prune another session's entries without --force
854
+ const isOwnedByOther = entry.owner && currentSession && entry.owner !== currentSession;
855
+
856
+ if (shouldPrune && isPrunable) {
857
+ if (isOwnedByOther && !force) {
858
+ skipped.push({
859
+ name: entry.name,
860
+ reason: `owned by another session (${entry.owner}). Use --force to override`,
861
+ owner: entry.owner,
862
+ });
863
+ } else {
864
+ if (!dryRun) {
865
+ delete registry.worktrees[entry.name];
866
+ }
867
+ pruned.push({ name: entry.name, status: entry.status, owner: entry.owner || null });
505
868
  }
506
- pruned.push({ name: entry.name, status: entry.status });
507
- } else if (!shouldPrune && (entry.status === 'destroyed' || entry.status === 'stale-merged')) {
508
- skipped.push({ name: entry.name, reason: entry.status + ' (use --prune to remove)' });
869
+ } else if (!shouldPrune && isPrunable) {
870
+ skipped.push({
871
+ name: entry.name,
872
+ reason: entry.status + ' (use --prune to remove)',
873
+ owner: entry.owner || null,
874
+ });
509
875
  }
510
876
  }
511
877
 
@@ -524,11 +890,29 @@ function repairWorktrees(options = {}) {
524
890
  /**
525
891
  * List all registered worktrees with filesystem validation.
526
892
  * Delegates to reconcileRegistry() for state classification.
893
+ * Persists status transitions (fresh → active, active → merged) so
894
+ * future calls can distinguish "never had work" from "work was merged back".
527
895
  * @returns {Array} Worktree entries with status
528
896
  */
529
897
  function listWorktrees() {
530
898
  const root = getRepoRoot();
899
+ const registry = loadRegistry(root);
531
900
  const { entries } = reconcileRegistry(root);
901
+
902
+ // Persist status transitions so future reconcile can use stored status as history
903
+ let dirty = false;
904
+ for (const entry of entries) {
905
+ const regEntry = registry.worktrees[entry.name];
906
+ if (regEntry && regEntry.status !== entry.status &&
907
+ entry.status !== 'unregistered') {
908
+ regEntry.status = entry.status;
909
+ dirty = true;
910
+ }
911
+ }
912
+ if (dirty) {
913
+ saveRegistry(root, registry);
914
+ }
915
+
532
916
  return entries;
533
917
  }
534
918
 
@@ -541,6 +925,9 @@ function listWorktrees() {
541
925
  */
542
926
  function destroyWorktree(name, options = {}) {
543
927
  const root = getRepoRoot();
928
+ // Ensure CWD is not inside the worktree we're about to destroy.
929
+ // If CWD is the worktree directory, removing it crashes subsequent commands.
930
+ try { process.chdir(root); } catch { /* non-fatal */ }
544
931
  const registry = loadRegistry(root);
545
932
  const { deleteBranch = false, force = false } = options;
546
933
 
@@ -557,11 +944,12 @@ function destroyWorktree(name, options = {}) {
557
944
  }
558
945
  }
559
946
 
560
- // Ownership check: refuse to destroy another agent's active worktree without --force
561
- const currentSession = process.env.CLAUDE_SESSION_ID || null;
947
+ // Ownership check: refuse to destroy another agent's worktree without --force
948
+ const currentSession = getAgentSessionId(root);
949
+ const isLiveStatus = entry.status === 'active' || entry.status === 'fresh' || entry.status === 'merged';
562
950
  if (
563
951
  !force &&
564
- entry.status === 'active' &&
952
+ isLiveStatus &&
565
953
  entry.owner &&
566
954
  currentSession &&
567
955
  entry.owner !== currentSession
@@ -580,7 +968,7 @@ function destroyWorktree(name, options = {}) {
580
968
  // Even with --force, warn loudly when destroying another session's worktree
581
969
  if (
582
970
  force &&
583
- entry.status === 'active' &&
971
+ isLiveStatus &&
584
972
  entry.owner &&
585
973
  currentSession &&
586
974
  entry.owner !== currentSession
@@ -647,9 +1035,31 @@ function destroyWorktree(name, options = {}) {
647
1035
  }
648
1036
 
649
1037
  // Update registry
1038
+ const wasAlreadyDestroyed = registry.worktrees[name].status === 'destroyed';
650
1039
  registry.worktrees[name].status = 'destroyed';
651
1040
  registry.worktrees[name].destroyedAt = new Date().toISOString();
652
1041
  saveRegistry(root, registry);
1042
+
1043
+ // CAWSFIX-18: auto-commit the registry so the working tree stays clean
1044
+ if (!wasAlreadyDestroyed) {
1045
+ try {
1046
+ const status = execFileSync('git', ['status', '--porcelain', '.caws/worktrees.json'], {
1047
+ cwd: root, stdio: ['pipe', 'pipe', 'pipe'],
1048
+ }).toString().trim();
1049
+ if (status) {
1050
+ const otherActive = Object.values(registry.worktrees || {}).some(
1051
+ (e) => e.status === 'active' || e.status === 'fresh'
1052
+ );
1053
+ const prefix = otherActive ? 'wip(checkpoint)' : 'chore(worktree)';
1054
+ execFileSync('git', ['add', '.caws/worktrees.json'], { cwd: root, stdio: 'pipe' });
1055
+ execFileSync('git', ['commit', '-m', `${prefix}: record destroyed ${name}`], {
1056
+ cwd: root, stdio: 'pipe',
1057
+ });
1058
+ }
1059
+ } catch (err) {
1060
+ console.warn(chalk.yellow(` Warning: could not auto-commit .caws/worktrees.json: ${err.message}`));
1061
+ }
1062
+ }
653
1063
  }
654
1064
 
655
1065
  /**
@@ -682,17 +1092,32 @@ function mergeWorktree(name, options = {}) {
682
1092
 
683
1093
  const baseBranch = entry.baseBranch || 'main';
684
1094
 
685
- // Check for uncommitted work in the worktree
1095
+ // Check for uncommitted work in the worktree.
1096
+ // Ignore .caws/ changes (provenance chain, registry) — these are
1097
+ // infrastructure artifacts written by git hooks, not user work.
1098
+ // The post-commit hook appends to .caws/provenance/chain.json after
1099
+ // every commit, which immediately dirties the tree and blocks merges.
686
1100
  if (fs.existsSync(entry.path)) {
687
1101
  try {
688
- const status = execFileSync(
1102
+ const rawStatus = execFileSync(
689
1103
  'git',
690
1104
  ['status', '--porcelain'],
691
1105
  { cwd: entry.path, encoding: 'utf8', stdio: 'pipe' }
692
- ).trim();
693
- if (status) {
1106
+ );
1107
+ // Filter out .caws/ infrastructure changes (provenance, registry).
1108
+ // Git porcelain format: "XY PATH" — 2 status chars, space, path.
1109
+ // IMPORTANT: do NOT .trim() the raw output — it strips the leading
1110
+ // space from " M file" (unstaged), corrupting the XY prefix and
1111
+ // breaking substring(3) path extraction.
1112
+ const statusLines = rawStatus.split('\n').filter(l => l.length > 0);
1113
+ const userChanges = statusLines
1114
+ .filter(line => {
1115
+ const filePath = line.substring(3);
1116
+ return !filePath.startsWith('.caws/');
1117
+ }).join('\n');
1118
+ if (userChanges) {
694
1119
  throw new Error(
695
- `Worktree '${name}' has uncommitted changes:\n${status}\n` +
1120
+ `Worktree '${name}' has uncommitted changes:\n${userChanges}\n` +
696
1121
  `Commit or discard changes before merging.`
697
1122
  );
698
1123
  }
@@ -736,32 +1161,54 @@ function mergeWorktree(name, options = {}) {
736
1161
  };
737
1162
  }
738
1163
 
1164
+ // Emit merge:pre event
1165
+ try {
1166
+ lifecycle.emit(EVENTS.MERGE_PRE, {
1167
+ worktreeName: name, branch: entry.branch, baseBranch, conflicts,
1168
+ timestamp: new Date().toISOString(),
1169
+ });
1170
+ } catch { /* non-fatal */ }
1171
+
1172
+ // Ensure CWD is the repo root BEFORE destroying the worktree.
1173
+ // If the caller's CWD is inside the worktree directory, destroying it
1174
+ // removes the CWD out from under the process, causing all subsequent
1175
+ // git commands to fail with "Unable to read current working directory".
1176
+ try { process.chdir(root); } catch { /* non-fatal */ }
1177
+
739
1178
  // Destroy the worktree (auto-forces since we're about to merge)
740
1179
  destroyWorktree(name, { deleteBranch: false, force: true });
741
1180
 
742
- // Switch to base branch
743
- const currentBranch = getCurrentBranch();
1181
+ // Switch to base branch (use cwd: root since getCurrentBranch has no cwd param)
1182
+ const currentBranch = execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
1183
+ cwd: root, encoding: 'utf8', stdio: 'pipe',
1184
+ }).trim();
744
1185
  if (currentBranch !== baseBranch) {
745
1186
  execFileSync('git', ['checkout', baseBranch], { cwd: root, stdio: 'pipe' });
746
1187
  }
747
1188
 
748
1189
  // Merge
1190
+ // Use --no-verify to skip pre-commit/commit-msg hooks during merge.
1191
+ // The worktree commits were already validated by those hooks when originally
1192
+ // committed. Re-running them here adds seconds of blocking time (especially
1193
+ // in projects with heavy hooks like quality gates, YAML validation, etc.)
1194
+ // and can trigger OAuth token expiry races in long-running sessions.
749
1195
  const mergeMessage = message || `merge(worktree): ${name}`;
750
1196
  try {
751
1197
  execFileSync(
752
1198
  'git',
753
- ['merge', '--no-ff', entry.branch, '-m', mergeMessage],
1199
+ ['merge', '--no-ff', '--no-verify', entry.branch, '-m', mergeMessage],
754
1200
  { cwd: root, stdio: 'pipe' }
755
1201
  );
756
1202
  } catch (error) {
757
- return {
758
- name,
759
- branch: entry.branch,
760
- baseBranch,
761
- merged: false,
1203
+ const failResult = {
1204
+ name, branch: entry.branch, baseBranch, merged: false,
762
1205
  conflicts: [`Merge failed: ${error.message}`],
763
1206
  message: 'Merge conflicts detected. Resolve with git and commit.',
764
1207
  };
1208
+ try {
1209
+ lifecycle.emit(EVENTS.MERGE_POST, { ...failResult, timestamp: new Date().toISOString() });
1210
+ } catch { /* non-fatal */ }
1211
+ return failResult;
765
1212
  }
766
1213
 
767
1214
  // Delete branch after successful merge
@@ -773,13 +1220,48 @@ function mergeWorktree(name, options = {}) {
773
1220
  }
774
1221
  }
775
1222
 
776
- return {
777
- name,
778
- branch: entry.branch,
779
- baseBranch,
780
- merged: true,
781
- conflicts: [],
1223
+ // Auto-close the bound spec if one exists. A worktree merge is the
1224
+ // 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;
1229
+ if (entry.specId) {
1230
+ autoClosedSpecId = autoCloseBoundSpec(root, entry.specId);
1231
+ }
1232
+
1233
+ const mergeResult = {
1234
+ name, branch: entry.branch, baseBranch, merged: true, conflicts: [],
1235
+ specId: entry.specId || null, autoClosedSpecId,
782
1236
  };
1237
+ try {
1238
+ lifecycle.emit(EVENTS.MERGE_POST, { ...mergeResult, timestamp: new Date().toISOString() });
1239
+ } catch { /* non-fatal */ }
1240
+ return mergeResult;
1241
+ }
1242
+
1243
+ /**
1244
+ * 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.
1247
+ * @param {string} root - Repo root
1248
+ * @param {string} specId - Spec identifier (e.g. CAWSFIX-14)
1249
+ * @returns {string|null}
1250
+ */
1251
+ function autoCloseBoundSpec(root, specId) {
1252
+ try {
1253
+ const specPath = findFeatureSpecPath(root, specId);
1254
+ if (!specPath || !fs.existsSync(specPath)) return null;
1255
+ const original = fs.readFileSync(specPath, 'utf8');
1256
+ // 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
1260
+ fs.writeFileSync(specPath, patched, 'utf8');
1261
+ return specId;
1262
+ } catch {
1263
+ return null;
1264
+ }
783
1265
  }
784
1266
 
785
1267
  /**
@@ -787,12 +1269,14 @@ function mergeWorktree(name, options = {}) {
787
1269
  * @param {Object} options - Prune options
788
1270
  * @param {number} [options.maxAgeDays] - Remove entries older than this many days
789
1271
  * @param {number} [options.recentCommitMinutes] - Protect branches with commits newer than this (default: 60)
790
- * @returns {Array} Pruned entries
1272
+ * @param {boolean} [options.force] - Allow pruning entries owned by other sessions
1273
+ * @returns {{ pruned: Array, skipped: Array }} Pruned and skipped entries
791
1274
  */
792
1275
  function pruneWorktrees(options = {}) {
793
1276
  const root = getRepoRoot();
794
1277
  const registry = loadRegistry(root);
795
- const { maxAgeDays = 30, recentCommitMinutes = 60 } = options;
1278
+ const { maxAgeDays = 30, recentCommitMinutes = 60, force = false } = options;
1279
+ const currentSession = getAgentSessionId(root);
796
1280
 
797
1281
  const now = new Date();
798
1282
  const pruned = [];
@@ -806,14 +1290,25 @@ function pruneWorktrees(options = {}) {
806
1290
  const shouldPrune =
807
1291
  // Always prune destroyed entries
808
1292
  entry.status === 'destroyed' ||
809
- // Prune active entries whose directory is gone (filesystem-registry desync)
810
- (entry.status === 'active' && !dirExists) ||
1293
+ // Prune active/fresh entries whose directory is gone (filesystem-registry desync)
1294
+ ((entry.status === 'active' || entry.status === 'fresh') && !dirExists) ||
811
1295
  // Prune old missing entries
812
1296
  (!dirExists && ageDays > maxAgeDays);
813
1297
 
814
1298
  if (shouldPrune) {
815
- // Before pruning a non-destroyed entry, check for recent commits
816
- if (entry.status !== 'destroyed' && entry.branch) {
1299
+ // Ownership check: skip entries owned by other sessions unless --force
1300
+ const isOwnedByOther = entry.owner && currentSession && entry.owner !== currentSession;
1301
+ if (isOwnedByOther && entry.status !== 'destroyed' && !force) {
1302
+ skipped.push({
1303
+ name,
1304
+ reason: `owned by another session (${entry.owner})`,
1305
+ entry,
1306
+ });
1307
+ continue;
1308
+ }
1309
+
1310
+ // Before pruning a non-destroyed entry, check for recent commits (skip if --force)
1311
+ if (!force && entry.status !== 'destroyed' && entry.branch) {
817
1312
  const lastCommit = getLastCommitInfo(entry.branch, root);
818
1313
  if (lastCommit) {
819
1314
  const commitAgeMinutes = (now - lastCommit.timestamp) / (1000 * 60);
@@ -856,16 +1351,24 @@ module.exports = {
856
1351
  listWorktrees,
857
1352
  destroyWorktree,
858
1353
  mergeWorktree,
1354
+ autoCloseBoundSpec,
859
1355
  pruneWorktrees,
860
1356
  repairWorktrees,
861
1357
  reconcileRegistry,
862
1358
  loadRegistry,
1359
+ saveRegistry,
863
1360
  getRepoRoot,
864
1361
  getLastCommitInfo,
865
1362
  isBranchMerged,
1363
+ hasDivergentCommits,
1364
+ hasDirtyFiles,
866
1365
  discoverUnregisteredWorktrees,
867
1366
  autoRegisterWorktree,
868
1367
  WORKTREES_DIR,
869
1368
  REGISTRY_FILE,
870
1369
  BRANCH_PREFIX,
1370
+ findFeatureSpecPath,
1371
+ materializeWorktreeSpec,
1372
+ inferSpecIdForWorktree,
1373
+ findSpecByWorktreeName,
871
1374
  };