@paths.design/caws-cli 10.0.1 → 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 (54) hide show
  1. package/README.md +13 -5
  2. package/dist/budget-derivation.js +221 -74
  3. package/dist/commands/evaluate.js +26 -12
  4. package/dist/commands/gates.js +31 -4
  5. package/dist/commands/init.js +7 -4
  6. package/dist/commands/iterate.js +7 -3
  7. package/dist/commands/scope.js +264 -0
  8. package/dist/commands/sidecar.js +6 -3
  9. package/dist/commands/specs.js +148 -1
  10. package/dist/commands/status.js +8 -4
  11. package/dist/commands/templates.js +0 -8
  12. package/dist/commands/validate.js +34 -13
  13. package/dist/commands/verify-acs.js +25 -10
  14. package/dist/commands/waivers.js +147 -5
  15. package/dist/commands/worktree.js +81 -1
  16. package/dist/gates/budget-limit.js +6 -1
  17. package/dist/gates/spec-completeness.js +8 -1
  18. package/dist/index.js +27 -0
  19. package/dist/policy/PolicyManager.js +9 -7
  20. package/dist/session/session-manager.js +34 -0
  21. package/dist/templates/.caws/schemas/policy.schema.json +96 -34
  22. package/dist/templates/.caws/schemas/scope.schema.json +3 -3
  23. package/dist/templates/.caws/schemas/waivers.schema.json +91 -21
  24. package/dist/templates/.caws/schemas/working-spec.schema.json +253 -89
  25. package/dist/templates/.caws/templates/working-spec.template.yml +3 -1
  26. package/dist/templates/.caws/tools/scope-guard.js +66 -15
  27. package/dist/templates/.claude/README.md +1 -1
  28. package/dist/templates/.claude/hooks/protected-paths.sh +39 -0
  29. package/dist/templates/.claude/hooks/scope-guard.sh +106 -27
  30. package/dist/templates/.claude/hooks/worktree-write-guard.sh +96 -3
  31. package/dist/templates/.claude/settings.json +5 -0
  32. package/dist/templates/CLAUDE.md +34 -0
  33. package/dist/templates/agents.md +21 -0
  34. package/dist/utils/event-log.js +584 -0
  35. package/dist/utils/event-renderer.js +521 -0
  36. package/dist/utils/schema-validator.js +10 -2
  37. package/dist/utils/working-state.js +25 -0
  38. package/dist/validation/spec-validation.js +99 -9
  39. package/dist/waivers-manager.js +84 -0
  40. package/dist/worktree/worktree-manager.js +214 -8
  41. package/package.json +5 -4
  42. package/templates/.caws/schemas/policy.schema.json +96 -34
  43. package/templates/.caws/schemas/scope.schema.json +3 -3
  44. package/templates/.caws/schemas/waivers.schema.json +91 -21
  45. package/templates/.caws/schemas/working-spec.schema.json +253 -89
  46. package/templates/.caws/templates/working-spec.template.yml +3 -1
  47. package/templates/.caws/tools/scope-guard.js +66 -15
  48. package/templates/.claude/README.md +1 -1
  49. package/templates/.claude/hooks/protected-paths.sh +39 -0
  50. package/templates/.claude/hooks/scope-guard.sh +106 -27
  51. package/templates/.claude/hooks/worktree-write-guard.sh +96 -3
  52. package/templates/.claude/settings.json +5 -0
  53. package/templates/CLAUDE.md +34 -0
  54. package/templates/agents.md +21 -0
@@ -6,10 +6,42 @@
6
6
 
7
7
  const fs = require('fs');
8
8
  const path = require('path');
9
- const { deriveBudget, checkBudgetCompliance } = require('../budget-derivation');
9
+ const { deriveBudgetSync, checkBudgetCompliance } = require('../budget-derivation');
10
10
  const { execSync } = require('child_process');
11
11
  const { createValidator, getSchemaPath } = require('../utils/schema-validator');
12
12
 
13
+ /**
14
+ * CAWSFIX-10: Canonical regex for valid spec IDs.
15
+ *
16
+ * Accepts:
17
+ * - Single-segment: FEAT-001, EVLOG-002, CAWSFIX-06 (legacy shape)
18
+ * - Multi-segment: P03-IMPL-01, ALG-001A-HARDEN-01, CAWS-FIX-03
19
+ *
20
+ * Rejects:
21
+ * - lowercase (feat-001)
22
+ * - leading digit (01-FEAT)
23
+ * - missing number suffix (FEAT-)
24
+ * - trailing hyphen (FEAT-01-)
25
+ * - leading/double hyphen (--FEAT-01, FEAT--001)
26
+ * - empty string
27
+ *
28
+ * Grammar: [PREFIX](-[SEGMENT])*-NUMBER
29
+ * - PREFIX = [A-Z] followed by zero+ [A-Z0-9]
30
+ * - SEGMENT = one+ [A-Z0-9] (alphanumeric, uppercase only)
31
+ * - NUMBER = one+ digits
32
+ *
33
+ * Defined once per A4 invariant; referenced by both the basic validator
34
+ * (line ~125 pre-fix) and the enhanced validator (line ~307 pre-fix).
35
+ */
36
+ const SPEC_ID_PATTERN = /^[A-Z][A-Z0-9]*(-[A-Z0-9]+)*-\d+$/;
37
+
38
+ /**
39
+ * User-facing error message for bad spec IDs (CAWSFIX-10 A5).
40
+ * Kept as a module constant so the message stays in sync with the pattern.
41
+ */
42
+ 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)';
44
+
13
45
  /**
14
46
  * Get actual budget statistics from git history
15
47
  * Analyzes changes since last tag or initial commit
@@ -66,6 +98,42 @@ function getActualBudgetStats(specDir) {
66
98
  }
67
99
  }
68
100
 
101
+ /**
102
+ * Alias the modern `acceptance_criteria` key into `acceptance` so the semantic
103
+ * validator (which historically keys off `acceptance`) accepts both shapes.
104
+ *
105
+ * Precedence (per CAWSFIX-09 A3 invariant):
106
+ * - If `acceptance` is present (legacy shape: {id,given,when,then}), it wins.
107
+ * - Otherwise `acceptance_criteria` (modern shape: {id,description,test_nodeids,status})
108
+ * is copied into `acceptance`.
109
+ *
110
+ * IMPORTANT: this function mutates the spec in place. The existing validator
111
+ * also mutates in place (risk_tier string→number coercion at line ~141; auto-fix
112
+ * writes via `current[pathParts[...]] = fix.value`). Callers of
113
+ * `validateWorkingSpecWithSuggestions({...}, {autoFix:true})` observe those
114
+ * mutations on the object they passed in — see `Multiple Auto-Fixes` tests.
115
+ * Returning a clone here would silently break that contract.
116
+ *
117
+ * @param {Object} spec - Raw spec object (mutated in place)
118
+ * @returns {Object} Same spec reference
119
+ */
120
+ function aliasAcceptanceCriteria(spec) {
121
+ if (!spec || typeof spec !== 'object') return spec;
122
+
123
+ const hasLegacy = Array.isArray(spec.acceptance) && spec.acceptance.length > 0;
124
+ const hasModern =
125
+ Array.isArray(spec.acceptance_criteria) && spec.acceptance_criteria.length > 0;
126
+
127
+ // Only alias when: legacy is absent AND modern has content.
128
+ // (Legacy wins when both present; empty modern arrays do not satisfy the
129
+ // required-field check — see edge-case tests in acceptance-criteria-alias.test.js.)
130
+ if (!hasLegacy && hasModern) {
131
+ spec.acceptance = spec.acceptance_criteria;
132
+ }
133
+
134
+ return spec;
135
+ }
136
+
69
137
  /**
70
138
  * Basic validation of working spec
71
139
  * @param {Object} spec - Working spec object
@@ -74,6 +142,11 @@ function getActualBudgetStats(specDir) {
74
142
  */
75
143
  const validateWorkingSpec = (spec, _options = {}) => {
76
144
  try {
145
+ // CAWSFIX-09: Alias `acceptance_criteria` -> `acceptance` before any
146
+ // semantic checks so specs using the modern shape don't trigger
147
+ // "Missing required field: acceptance" false negatives.
148
+ aliasAcceptanceCriteria(spec);
149
+
77
150
  // First pass: AJV schema validation (non-blocking — results collected as warnings)
78
151
  let schemaWarnings = [];
79
152
  try {
@@ -121,14 +194,14 @@ const validateWorkingSpec = (spec, _options = {}) => {
121
194
  }
122
195
  }
123
196
 
124
- // Validate specific field formats
125
- if (!/^[A-Z]+-\d+$/.test(spec.id)) {
197
+ // Validate specific field formats (CAWSFIX-10: DRY regex via SPEC_ID_PATTERN)
198
+ if (!SPEC_ID_PATTERN.test(spec.id)) {
126
199
  return {
127
200
  valid: false,
128
201
  errors: [
129
202
  {
130
203
  instancePath: '/id',
131
- message: 'Project ID should be in format: PREFIX-NUMBER (e.g., FEAT-1234)',
204
+ message: SPEC_ID_ERROR_MESSAGE,
132
205
  },
133
206
  ],
134
207
  };
@@ -252,6 +325,12 @@ function validateWorkingSpecWithSuggestions(spec, options = {}) {
252
325
  const { autoFix = false, checkBudget = false, projectRoot } = options;
253
326
 
254
327
  try {
328
+ // CAWSFIX-09: Alias `acceptance_criteria` -> `acceptance` so the
329
+ // required-field check and the "No acceptance criteria defined" warning
330
+ // recognize the modern shape as valid. Mutates in place to preserve the
331
+ // existing auto-fix contract (callers observe fixes on their object).
332
+ aliasAcceptanceCriteria(spec);
333
+
255
334
  let errors = [];
256
335
  let warnings = [];
257
336
  let fixes = [];
@@ -303,12 +382,12 @@ function validateWorkingSpecWithSuggestions(spec, options = {}) {
303
382
 
304
383
  // Semantic checks that AJV can't express
305
384
 
306
- // Validate specific field formats
307
- if (spec.id && !/^[A-Z]+-\d+$/.test(spec.id)) {
385
+ // Validate specific field formats (CAWSFIX-10: DRY regex via SPEC_ID_PATTERN)
386
+ if (spec.id && !SPEC_ID_PATTERN.test(spec.id)) {
308
387
  errors.push({
309
388
  instancePath: '/id',
310
- message: 'Project ID should be in format: PREFIX-NUMBER (e.g., FEAT-1234)',
311
- suggestion: 'Use format like: PROJ-001, FEAT-002, FIX-003',
389
+ message: SPEC_ID_ERROR_MESSAGE,
390
+ suggestion: 'Use format like: PROJ-001, FEAT-002, P03-IMPL-01, ALG-001A-HARDEN-01',
312
391
  canAutoFix: false,
313
392
  });
314
393
  }
@@ -646,10 +725,18 @@ function validateWorkingSpecWithSuggestions(spec, options = {}) {
646
725
  }
647
726
 
648
727
  // Derive and check budget if requested
728
+ //
729
+ // CAWSFIX-07: use `deriveBudgetSync` here. The async `deriveBudget`
730
+ // returns a Promise; this synchronous function previously passed the
731
+ // Promise straight into `checkBudgetCompliance`, which then read
732
+ // `derivedBudget.effective.max_files` on an undefined `.effective` and
733
+ // threw "Cannot read properties of undefined (reading 'max_files')" —
734
+ // surfaced as the "Budget derivation failed" warning on every
735
+ // schema-compliant spec.
649
736
  let budgetCheck = null;
650
737
  if (checkBudget && projectRoot) {
651
738
  try {
652
- const derivedBudget = deriveBudget(spec, projectRoot);
739
+ const derivedBudget = deriveBudgetSync(spec, projectRoot);
653
740
 
654
741
  // Get actual stats from git history
655
742
  const actualStats = getActualBudgetStats(projectRoot) || {
@@ -828,4 +915,7 @@ module.exports = {
828
915
  canAutoFixField,
829
916
  calculateComplianceScore,
830
917
  getComplianceGrade,
918
+ // CAWSFIX-10: exported so init.js and tests reference the same regex
919
+ SPEC_ID_PATTERN,
920
+ SPEC_ID_ERROR_MESSAGE,
831
921
  };
@@ -275,6 +275,90 @@ class WaiversManager {
275
275
  return activeWaivers;
276
276
  }
277
277
 
278
+ /**
279
+ * Enumerate individual waiver files (WV-XXXX.yaml) on disk and return
280
+ * their parsed contents. These files are the source of truth per the
281
+ * CAWSFIX-04 invariants; active-waivers.yaml is an aggregate index.
282
+ *
283
+ * @returns {Array<{id: string, path: string, data: object}>}
284
+ */
285
+ enumerateWaiverFiles() {
286
+ const out = [];
287
+ if (!fs.existsSync(this.waiversDir)) return out;
288
+
289
+ const files = fs.readdirSync(this.waiversDir);
290
+ for (const file of files) {
291
+ const match = file.match(/^(WV-\d{4})\.yaml$/);
292
+ if (!match) continue;
293
+
294
+ const filePath = path.join(this.waiversDir, file);
295
+ let data;
296
+ try {
297
+ data = yaml.load(fs.readFileSync(filePath, 'utf8'));
298
+ } catch (err) {
299
+ // Skip unparseable files; do not swallow — warn the caller.
300
+ console.warn(`Warning: could not parse ${file}: ${err.message}`);
301
+ continue;
302
+ }
303
+ if (data && typeof data === 'object') {
304
+ out.push({ id: match[1], path: filePath, data });
305
+ }
306
+ }
307
+ return out;
308
+ }
309
+
310
+ /**
311
+ * Identify waivers that are candidates for expiry-based pruning.
312
+ * A waiver is prunable iff `status === 'active'` AND
313
+ * `expires_at < now`. Already-expired or revoked waivers are skipped
314
+ * (their status is correct; pruning wouldn't change anything).
315
+ *
316
+ * @param {Date} [nowOverride] — inject clock for tests
317
+ * @returns {Array<{id: string, path: string, expires_at: string}>}
318
+ */
319
+ findExpiredWaivers(nowOverride) {
320
+ const now = nowOverride instanceof Date ? nowOverride : new Date();
321
+ const records = this.enumerateWaiverFiles();
322
+ const candidates = [];
323
+
324
+ for (const rec of records) {
325
+ const w = rec.data;
326
+ const status = w.status;
327
+ // Only active waivers are prunable. Waivers with no status field are
328
+ // treated as active (matches existing loadActiveWaivers() assumption).
329
+ if (status && status !== 'active') continue;
330
+ if (!w.expires_at) continue;
331
+
332
+ const expiresAt = new Date(w.expires_at);
333
+ if (!Number.isFinite(expiresAt.getTime())) continue; // malformed date
334
+ if (expiresAt < now) {
335
+ candidates.push({
336
+ id: rec.id,
337
+ path: rec.path,
338
+ expires_at: w.expires_at,
339
+ });
340
+ }
341
+ }
342
+ return candidates;
343
+ }
344
+
345
+ /**
346
+ * Transition a single waiver file from `status: active` to
347
+ * `status: expired` in place. The file is rewritten with its existing
348
+ * field order where possible; a `status` field is added or replaced.
349
+ *
350
+ * @param {string} filePath
351
+ * @returns {object} the updated waiver object
352
+ */
353
+ markWaiverExpired(filePath) {
354
+ const raw = fs.readFileSync(filePath, 'utf8');
355
+ const data = yaml.load(raw) || {};
356
+ data.status = 'expired';
357
+ data.expired_at = new Date().toISOString();
358
+ fs.writeFileSync(filePath, yaml.dump(data, { lineWidth: -1 }), 'utf8');
359
+ return data;
360
+ }
361
+
278
362
  /**
279
363
  * Revoke a waiver
280
364
  */
@@ -27,6 +27,59 @@ function findFeatureSpecPath(root, specId) {
27
27
  return candidates.find((candidate) => fs.existsSync(candidate)) || null;
28
28
  }
29
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
+
30
83
  function materializeWorktreeSpec(root, cawsDest, specId, worktreeName, scope) {
31
84
  if (!specId) return;
32
85
 
@@ -46,14 +99,14 @@ function materializeWorktreeSpec(root, cawsDest, specId, worktreeName, scope) {
46
99
 
47
100
  // Keep a canonical feature-spec copy inside the worktree and align
48
101
  // working-spec.yaml to that exact content for legacy-compatible commands.
49
- const specContent = fs.readFileSync(canonicalSpecPath, 'utf8');
102
+ const specContent = writeSpecWithWorktree(canonicalSpecPath, worktreeName);
50
103
  fs.writeFileSync(destSpecPath, specContent);
51
104
  fs.writeFileSync(workingSpecPath, specContent);
52
105
  return;
53
106
  }
54
107
 
55
108
  const { generateWorkingSpec } = require('../generators/working-spec');
56
- const specContent = generateWorkingSpec({
109
+ let specContent = generateWorkingSpec({
57
110
  projectId: specId,
58
111
  projectTitle: `Worktree: ${worktreeName}`,
59
112
  projectDescription: `Isolated worktree for ${worktreeName}`,
@@ -86,10 +139,84 @@ function materializeWorktreeSpec(root, cawsDest, specId, worktreeName, scope) {
86
139
  complexityFactors: '',
87
140
  });
88
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
+
89
153
  fs.ensureDirSync(path.dirname(workingSpecPath));
90
154
  fs.writeFileSync(workingSpecPath, specContent);
91
155
  }
92
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
+
93
220
  /**
94
221
  * Get the last commit info for a branch
95
222
  * @param {string} branch - Branch name
@@ -356,7 +483,7 @@ function autoRegisterWorktree(root, registry, discovered) {
356
483
  branch: discovered.branch,
357
484
  baseBranch,
358
485
  scope: null,
359
- specId: null,
486
+ specId: inferSpecIdForWorktree(discovered.path),
360
487
  owner: null,
361
488
  createdAt: new Date().toISOString(),
362
489
  status: 'active',
@@ -423,6 +550,7 @@ function createWorktree(name, options = {}) {
423
550
  const worktreePath = path.join(root, WORKTREES_DIR, name);
424
551
  const branchName = BRANCH_PREFIX + name;
425
552
  const base = baseBranch || getCurrentBranch();
553
+ const canonicalSpecPath = findFeatureSpecPath(root, specId);
426
554
 
427
555
  // Check if the branch already exists in git (even if not in registry)
428
556
  // This catches cases where another agent created the branch outside CAWS
@@ -449,6 +577,10 @@ function createWorktree(name, options = {}) {
449
577
  // Create the worktree directory
450
578
  fs.ensureDirSync(path.dirname(worktreePath));
451
579
 
580
+ if (canonicalSpecPath) {
581
+ ensureCanonicalSpecCommitted(root, canonicalSpecPath, specId, name);
582
+ }
583
+
452
584
  // Create git worktree with new branch
453
585
  try {
454
586
  execFileSync('git', ['worktree', 'add', '-b', branchName, worktreePath, base], {
@@ -510,15 +642,26 @@ function createWorktree(name, options = {}) {
510
642
  }
511
643
  }
512
644
 
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
+
513
656
  // Materialize a worktree-local working spec. Prefer the canonical feature
514
657
  // spec when it exists so isolated worktrees stay aligned with the main
515
658
  // registry/resolver model.
516
- if (specId) {
659
+ if (resolvedSpecId) {
517
660
  try {
518
- materializeWorktreeSpec(root, cawsDest, specId, name, scope);
661
+ materializeWorktreeSpec(root, cawsDest, resolvedSpecId, name, scope);
519
662
  } catch (error) {
520
663
  console.warn(
521
- chalk.yellow(`Could not materialize spec '${specId}' for worktree '${name}': ${error.message}`)
664
+ chalk.yellow(`Could not materialize spec '${resolvedSpecId}' for worktree '${name}': ${error.message}`)
522
665
  );
523
666
  // Non-fatal: spec generation is optional
524
667
  }
@@ -531,7 +674,7 @@ function createWorktree(name, options = {}) {
531
674
  branch: branchName,
532
675
  baseBranch: base,
533
676
  scope: scope || null,
534
- specId: specId || null,
677
+ specId: resolvedSpecId,
535
678
  owner: options.owner || getAgentSessionId(root) || null,
536
679
  createdAt: new Date().toISOString(),
537
680
  status: 'fresh',
@@ -892,9 +1035,31 @@ function destroyWorktree(name, options = {}) {
892
1035
  }
893
1036
 
894
1037
  // Update registry
1038
+ const wasAlreadyDestroyed = registry.worktrees[name].status === 'destroyed';
895
1039
  registry.worktrees[name].status = 'destroyed';
896
1040
  registry.worktrees[name].destroyedAt = new Date().toISOString();
897
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
+ }
898
1063
  }
899
1064
 
900
1065
  /**
@@ -1055,13 +1220,50 @@ function mergeWorktree(name, options = {}) {
1055
1220
  }
1056
1221
  }
1057
1222
 
1058
- const mergeResult = { name, branch: entry.branch, baseBranch, merged: true, 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,
1236
+ };
1059
1237
  try {
1060
1238
  lifecycle.emit(EVENTS.MERGE_POST, { ...mergeResult, timestamp: new Date().toISOString() });
1061
1239
  } catch { /* non-fatal */ }
1062
1240
  return mergeResult;
1063
1241
  }
1064
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
+ }
1265
+ }
1266
+
1065
1267
  /**
1066
1268
  * Prune stale worktree entries
1067
1269
  * @param {Object} options - Prune options
@@ -1149,10 +1351,12 @@ module.exports = {
1149
1351
  listWorktrees,
1150
1352
  destroyWorktree,
1151
1353
  mergeWorktree,
1354
+ autoCloseBoundSpec,
1152
1355
  pruneWorktrees,
1153
1356
  repairWorktrees,
1154
1357
  reconcileRegistry,
1155
1358
  loadRegistry,
1359
+ saveRegistry,
1156
1360
  getRepoRoot,
1157
1361
  getLastCommitInfo,
1158
1362
  isBranchMerged,
@@ -1165,4 +1369,6 @@ module.exports = {
1165
1369
  BRANCH_PREFIX,
1166
1370
  findFeatureSpecPath,
1167
1371
  materializeWorktreeSpec,
1372
+ inferSpecIdForWorktree,
1373
+ findSpecByWorktreeName,
1168
1374
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@paths.design/caws-cli",
3
- "version": "10.0.1",
3
+ "version": "10.1.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": {
@@ -32,7 +32,7 @@
32
32
  "lint:staged": "npx eslint src/**/*.js tests/**/*.js",
33
33
  "format": "prettier --write src/**/*.js tests/**/*.js",
34
34
  "validate": "echo 'CLI package validation not required'",
35
- "caws:validate": "node ../../.caws/validate.js ../../.caws/working-spec.yaml",
35
+ "caws:validate": "node dist/index.js validate",
36
36
  "clean": "rm -rf dist test-caws-project .agent && npm run test:cleanup",
37
37
  "prepare": "husky >/dev/null 2>&1 || true"
38
38
  },
@@ -61,7 +61,9 @@
61
61
  "fs-extra": "^11.0.0",
62
62
  "inquirer": "8.2.7",
63
63
  "ajv": "8.17.1",
64
- "js-yaml": "4.1.0"
64
+ "js-yaml": "4.1.0",
65
+ "micromatch": "4.0.8",
66
+ "minimatch": "^10.0.1"
65
67
  },
66
68
  "devDependencies": {
67
69
  "@eslint/js": "^9.0.0",
@@ -78,7 +80,6 @@
78
80
  "husky": "9.1.7",
79
81
  "jest": "30.1.3",
80
82
  "lint-staged": "15.5.2",
81
- "micromatch": "4.0.8",
82
83
  "prettier": "^3.0.0",
83
84
  "semantic-release": "25.0.0-beta.6",
84
85
  "typescript": "^5.0.0"