@paths.design/caws-cli 9.1.0 → 9.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.
@@ -204,16 +204,28 @@ async function deriveBudget(spec, projectRoot = process.cwd(), options = {}) {
204
204
  }
205
205
  }
206
206
 
207
+ // Normalize risk_tier: accept "T1"/"T2"/"T3" strings and convert to numeric
208
+ let riskTier = spec.risk_tier;
209
+ if (typeof riskTier === 'string') {
210
+ const match = riskTier.match(/^T?(\d)$/i);
211
+ if (match) {
212
+ riskTier = parseInt(match[1], 10);
213
+ }
214
+ }
215
+
207
216
  // Check if risk tier exists in policy
208
- if (!policy.risk_tiers[spec.risk_tier]) {
217
+ if (!policy.risk_tiers[riskTier]) {
209
218
  throw new Error(
210
219
  `Risk tier ${spec.risk_tier} not defined in policy.yaml\n` +
211
220
  `Policy only defines tiers: ${Object.keys(policy.risk_tiers).join(', ')}\n` +
212
- `Valid tiers are: 1 (critical), 2 (standard), 3 (low-risk)`
221
+ `Valid tiers are: 1 (critical), 2 (standard), 3 (low-risk)` +
222
+ (typeof spec.risk_tier === 'string'
223
+ ? `\nHint: use numeric risk_tier (e.g., 2) instead of "${spec.risk_tier}"`
224
+ : '')
213
225
  );
214
226
  }
215
227
 
216
- const tierBudget = policy.risk_tiers[spec.risk_tier];
228
+ const tierBudget = policy.risk_tiers[riskTier];
217
229
  const baseline = {
218
230
  max_files: tierBudget.max_files,
219
231
  max_loc: tierBudget.max_loc,
@@ -1 +1 @@
1
- {"version":3,"file":"tutorial.d.ts","sourceRoot":"","sources":["../../src/commands/tutorial.js"],"names":[],"mappings":"AA4aA;;;;GAIG;AACH,8CAHW,MAAM,+BA4ChB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA/FD;;;;GAIG;AACH,qDAHW,MAAM,GACJ,OAAO,CAAC,IAAI,CAAC,CA4CzB"}
1
+ {"version":3,"file":"tutorial.d.ts","sourceRoot":"","sources":["../../src/commands/tutorial.js"],"names":[],"mappings":"AA0aA;;;;GAIG;AACH,8CAHW,MAAM,+BA4ChB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA/FD;;;;GAIG;AACH,qDAHW,MAAM,GACJ,OAAO,CAAC,IAAI,CAAC,CA4CzB"}
@@ -1 +1 @@
1
- {"version":3,"file":"validate.d.ts","sourceRoot":"","sources":["../../src/commands/validate.js"],"names":[],"mappings":"AAkBA;;;;;;;;GAQG;AACH,0CANW,MAAM,YAEd;IAAyB,MAAM,GAAvB,MAAM;IACY,WAAW,GAA7B,OAAO;IACW,MAAM,GAAxB,OAAO;CACjB,iBA6NA"}
1
+ {"version":3,"file":"validate.d.ts","sourceRoot":"","sources":["../../src/commands/validate.js"],"names":[],"mappings":"AAkBA;;;;;;;;GAQG;AACH,0CANW,MAAM,YAEd;IAAyB,MAAM,GAAvB,MAAM;IACY,WAAW,GAA7B,OAAO;IACW,MAAM,GAAxB,OAAO;CACjB,iBAsQA"}
@@ -49,12 +49,19 @@ async function validateCommand(specFile, options = {}) {
49
49
  console.log(chalk.gray(` Spec: ${path.relative(process.cwd(), specPath)}`));
50
50
  }
51
51
 
52
+ // For feature specs (.caws/specs/<id>.yaml), path.dirname(specPath) resolves
53
+ // to .caws/specs/ — not the project root. Use process.cwd() which is always
54
+ // the project root when the CLI is invoked.
55
+ const projectRoot = specType === 'feature'
56
+ ? process.cwd()
57
+ : path.dirname(specPath);
58
+
52
59
  const result = validateWorkingSpecWithSuggestions(spec, {
53
60
  autoFix: options.autoFix,
54
61
  dryRun: options.dryRun,
55
62
  suggestions: !options.quiet,
56
63
  checkBudget: true,
57
- projectRoot: path.dirname(specPath),
64
+ projectRoot,
58
65
  specType,
59
66
  });
60
67
 
@@ -225,6 +232,40 @@ async function validateCommand(specFile, options = {}) {
225
232
  }
226
233
  }
227
234
  } catch (error) {
235
+ // Multi-spec project without --spec-id: auto-validate all open specs
236
+ if (error.message === 'Spec ID required when multiple specs exist' && !options.specId) {
237
+ const { checkMultiSpecStatus } = require('../utils/spec-resolver');
238
+ const status = await checkMultiSpecStatus();
239
+ const specIds = Object.keys(status.registry?.specs || {});
240
+
241
+ if (specIds.length === 0) {
242
+ console.error(chalk.red('No specs found in registry'));
243
+ if (process.env.NODE_ENV !== 'test' && !process.env.JEST_WORKER_ID) {
244
+ process.exit(1);
245
+ }
246
+ return;
247
+ }
248
+
249
+ console.log(chalk.cyan(`Validating all ${specIds.length} specs...\n`));
250
+ let allPassed = true;
251
+
252
+ for (const sid of specIds) {
253
+ try {
254
+ await validateCommand(specFile, { ...options, specId: sid });
255
+ } catch {
256
+ allPassed = false;
257
+ }
258
+ console.log(''); // blank line between specs
259
+ }
260
+
261
+ if (!allPassed) {
262
+ if (process.env.NODE_ENV !== 'test' && !process.env.JEST_WORKER_ID) {
263
+ process.exit(1);
264
+ }
265
+ }
266
+ return;
267
+ }
268
+
228
269
  if (options.format === 'json') {
229
270
  console.log(
230
271
  JSON.stringify(
@@ -9,6 +9,7 @@ const {
9
9
  createWorktree,
10
10
  listWorktrees,
11
11
  destroyWorktree,
12
+ mergeWorktree,
12
13
  pruneWorktrees,
13
14
  } = require('../worktree/worktree-manager');
14
15
 
@@ -26,11 +27,13 @@ async function worktreeCommand(subcommand, options = {}) {
26
27
  return handleList();
27
28
  case 'destroy':
28
29
  return handleDestroy(options);
30
+ case 'merge':
31
+ return handleMerge(options);
29
32
  case 'prune':
30
33
  return handlePrune(options);
31
34
  default:
32
35
  console.error(chalk.red(`Unknown worktree subcommand: ${subcommand}`));
33
- console.log(chalk.blue('Available: create, list, destroy, prune'));
36
+ console.log(chalk.blue('Available: create, list, destroy, merge, prune'));
34
37
  process.exit(1);
35
38
  }
36
39
  } catch (error) {
@@ -70,30 +73,52 @@ function handleList() {
70
73
  }
71
74
 
72
75
  console.log(chalk.bold.cyan('CAWS Worktrees'));
73
- console.log(chalk.cyan('='.repeat(70)));
76
+ console.log(chalk.cyan('='.repeat(85)));
74
77
  console.log(
75
78
  chalk.bold(
76
- 'Name'.padEnd(20) +
79
+ 'Name'.padEnd(18) +
77
80
  'Status'.padEnd(12) +
78
81
  'Branch'.padEnd(20) +
79
- 'Scope'
82
+ 'Last Commit'.padEnd(16) +
83
+ 'Owner'
80
84
  )
81
85
  );
82
- console.log(chalk.gray('-'.repeat(70)));
86
+ console.log(chalk.gray('-'.repeat(85)));
83
87
 
84
88
  for (const entry of entries) {
85
89
  const statusColor =
86
90
  entry.status === 'active'
87
91
  ? chalk.green
88
92
  : entry.status === 'destroyed'
89
- ? chalk.gray
90
- : chalk.yellow;
93
+ ? chalk.gray
94
+ : chalk.yellow;
95
+
96
+ // Format last commit age
97
+ let commitAge = chalk.gray('-');
98
+ if (entry.lastCommit) {
99
+ commitAge = chalk.white(entry.lastCommit.age);
100
+ }
101
+
102
+ // Format owner — show truncated session ID or '-'
103
+ let ownerStr = chalk.gray('-');
104
+ if (entry.owner) {
105
+ // Show last 8 chars of session ID for readability
106
+ const short = entry.owner.length > 8 ? '...' + entry.owner.slice(-8) : entry.owner;
107
+ ownerStr = chalk.gray(short);
108
+ }
109
+
110
+ // Status suffix for merged branches
111
+ let statusStr = entry.status;
112
+ if (entry.merged && entry.status === 'active') {
113
+ statusStr = 'merged';
114
+ }
91
115
 
92
116
  console.log(
93
- entry.name.padEnd(20) +
94
- statusColor(entry.status.padEnd(12)) +
117
+ entry.name.padEnd(18) +
118
+ statusColor(statusStr.padEnd(12)) +
95
119
  (entry.branch || '').padEnd(20) +
96
- (entry.scope || '-')
120
+ commitAge.padEnd(16 + 10) + // +10 for chalk color codes
121
+ ownerStr
97
122
  );
98
123
  }
99
124
 
@@ -117,18 +142,85 @@ function handleDestroy(options) {
117
142
  }
118
143
  }
119
144
 
145
+ function handleMerge(options) {
146
+ const { name, dryRun, deleteBranch = true, message } = options;
147
+
148
+ if (!name) {
149
+ console.error(chalk.red('Worktree name is required'));
150
+ console.log(
151
+ chalk.blue(
152
+ 'Usage: caws worktree merge <name> [--dry-run] [--message "..."] [--no-delete-branch]'
153
+ )
154
+ );
155
+ process.exit(1);
156
+ }
157
+
158
+ if (dryRun) {
159
+ console.log(chalk.cyan(`Dry-run merge preview for: ${name}`));
160
+ } else {
161
+ console.log(chalk.cyan(`Merging worktree: ${name}`));
162
+ }
163
+
164
+ const result = mergeWorktree(name, { dryRun, deleteBranch, message });
165
+
166
+ if (dryRun) {
167
+ if (result.conflicts.length > 0) {
168
+ console.log(chalk.yellow(`\nConflicts detected (${result.conflicts.length}):`));
169
+ for (const conflict of result.conflicts) {
170
+ console.log(chalk.yellow(` ${conflict}`));
171
+ }
172
+ console.log(
173
+ chalk.blue('\nResolve conflicts in the worktree before merging, or merge manually.')
174
+ );
175
+ } else {
176
+ console.log(chalk.green(`\nNo conflicts detected. Safe to merge.`));
177
+ console.log(chalk.blue(`Run without --dry-run to merge: caws worktree merge ${name}`));
178
+ }
179
+ return;
180
+ }
181
+
182
+ if (result.merged) {
183
+ console.log(chalk.green(`Worktree '${name}' merged to ${result.baseBranch}`));
184
+ if (deleteBranch) {
185
+ console.log(chalk.gray(` Branch ${result.branch} deleted`));
186
+ }
187
+ } else {
188
+ console.log(chalk.red(`Merge failed for '${name}'`));
189
+ for (const conflict of result.conflicts) {
190
+ console.log(chalk.yellow(` ${conflict}`));
191
+ }
192
+ console.log(chalk.blue('\nThe worktree has been destroyed but the merge has conflicts.'));
193
+ console.log(chalk.blue('Resolve conflicts and commit manually:'));
194
+ console.log(chalk.gray(` git merge --no-ff ${result.branch}`));
195
+ console.log(chalk.gray(` # resolve conflicts`));
196
+ console.log(chalk.gray(` git commit -m "merge(worktree): ${name}"`));
197
+ }
198
+ }
199
+
120
200
  function handlePrune(options) {
121
201
  const maxAge = options.maxAge !== undefined ? parseInt(options.maxAge, 10) : 30;
122
202
 
123
203
  console.log(chalk.cyan(`Pruning worktrees (max age: ${maxAge} days)`));
124
- const pruned = pruneWorktrees({ maxAgeDays: maxAge });
204
+ const result = pruneWorktrees({ maxAgeDays: maxAge });
205
+
206
+ // Handle both old return format (array) and new format (object with pruned/skipped)
207
+ const pruned = Array.isArray(result) ? result : result.pruned;
208
+ const skipped = Array.isArray(result) ? [] : result.skipped || [];
125
209
 
126
- if (pruned.length === 0) {
210
+ if (pruned.length === 0 && skipped.length === 0) {
127
211
  console.log(chalk.gray('Nothing to prune.'));
128
212
  } else {
129
- console.log(chalk.green(`Pruned ${pruned.length} worktree(s):`));
130
- for (const entry of pruned) {
131
- console.log(chalk.gray(` - ${entry.name} (created ${entry.createdAt})`));
213
+ if (pruned.length > 0) {
214
+ console.log(chalk.green(`Pruned ${pruned.length} worktree(s):`));
215
+ for (const entry of pruned) {
216
+ console.log(chalk.gray(` - ${entry.name} (created ${entry.createdAt})`));
217
+ }
218
+ }
219
+ if (skipped.length > 0) {
220
+ console.log(chalk.yellow(`\nSkipped ${skipped.length} worktree(s) with recent activity:`));
221
+ for (const { name: skName, reason } of skipped) {
222
+ console.log(chalk.yellow(` - ${skName}: ${reason}`));
223
+ }
132
224
  }
133
225
  }
134
226
  }
package/dist/index.js CHANGED
@@ -127,6 +127,7 @@ program
127
127
  // Validate command
128
128
  program
129
129
  .command('validate')
130
+ .alias('verify')
130
131
  .description('Validate CAWS spec with suggestions')
131
132
  .argument('[spec-file]', 'Path to spec file (optional, uses spec resolution)')
132
133
  .option('--spec-id <id>', 'Feature-specific spec ID (e.g., user-auth, FEAT-001)')
@@ -379,6 +380,14 @@ worktreeCmd
379
380
  .option('--force', 'Force removal even if worktree is dirty', false)
380
381
  .action((name, options) => worktreeCommand('destroy', { name, ...options }));
381
382
 
383
+ worktreeCmd
384
+ .command('merge <name>')
385
+ .description('Merge a worktree branch back to base (destroy + merge + cleanup)')
386
+ .option('--dry-run', 'Preview conflicts without merging', false)
387
+ .option('--message <msg>', 'Custom merge commit message')
388
+ .option('--no-delete-branch', 'Keep the branch after merging')
389
+ .action((name, options) => worktreeCommand('merge', { name, ...options }));
390
+
382
391
  worktreeCmd
383
392
  .command('prune')
384
393
  .description('Clean up stale worktree entries')
@@ -1 +1 @@
1
- {"version":3,"file":"parallel-manager.d.ts","sourceRoot":"","sources":["../../src/parallel/parallel-manager.js"],"names":[],"mappings":"AA6EA;;;;GAIG;AACH,mCAHW,MAAM,OAoDhB;AAED;;;;GAIG;AACH,0CAFa,KAAQ,CAyCpB;AAED;;;GAGG;AACH,qCAFa,MAAO,IAAI,CA2DvB;AA2CD;;;;;;;GAOG;AACH,wCALG;IAAyB,QAAQ,GAAzB,MAAM;IACY,MAAM,GAAxB,OAAO;IACW,KAAK,GAAvB,OAAO;CACf,OA2GF;AAED;;;;;;GAMG;AACH,2CAJG;IAA0B,cAAc,GAAhC,OAAO;IACW,KAAK,GAAvB,OAAO;CACf,OA0BF;AA3LD;;;;;GAKG;AACH,gDAJW,MAAM,iBACN,KAAQ,GACN,KAAQ,CAmCpB;AAnPD;;;;GAIG;AACH,2CAHW,MAAM,GACJ,MAAO,IAAI,CAYvB;AAED;;;;GAIG;AACH,2CAHW,MAAM,mBAOhB;AAED;;;GAGG;AACH,6CAFW,MAAM,QAOhB;AAnDD,gCAA0B,qBAAqB,CAAC"}
1
+ {"version":3,"file":"parallel-manager.d.ts","sourceRoot":"","sources":["../../src/parallel/parallel-manager.js"],"names":[],"mappings":"AA0EA;;;;GAIG;AACH,mCAHW,MAAM,OAoDhB;AAED;;;;GAIG;AACH,0CAFa,KAAQ,CAyCpB;AAED;;;GAGG;AACH,qCAFa,MAAO,IAAI,CA2DvB;AA2CD;;;;;;;GAOG;AACH,wCALG;IAAyB,QAAQ,GAAzB,MAAM;IACY,MAAM,GAAxB,OAAO;IACW,KAAK,GAAvB,OAAO;CACf,OA2GF;AAED;;;;;;GAMG;AACH,2CAJG;IAA0B,cAAc,GAAhC,OAAO;IACW,KAAK,GAAvB,OAAO;CACf,OA0BF;AA3LD;;;;;GAKG;AACH,gDAJW,MAAM,iBACN,KAAQ,GACN,KAAQ,CAmCpB;AAnPD;;;;GAIG;AACH,2CAHW,MAAM,GACJ,MAAO,IAAI,CAYvB;AAED;;;;GAIG;AACH,2CAHW,MAAM,mBAOhB;AAED;;;GAGG;AACH,6CAFW,MAAM,QAOhB;AAnDD,gCAA0B,qBAAqB,CAAC"}
@@ -65,13 +65,22 @@ if [[ "$WT_COUNT" -le 0 ]] 2>/dev/null; then
65
65
  exit 0
66
66
  fi
67
67
 
68
- # Allow edits to .claude/ configuration (hooks, settings, rules)
68
+ # Allow edits to configuration and documentation (benign, no merge conflict risk)
69
69
  if [[ -n "$FILE_PATH" ]]; then
70
70
  case "$FILE_PATH" in
71
71
  */.claude/*|*/.caws/*) exit 0 ;;
72
+ */docs/*) exit 0 ;;
72
73
  esac
73
74
  fi
74
75
 
76
+ # Allow edits during an active merge (conflict resolution).
77
+ # The worktree-isolation rules explicitly permit merge commits on the base branch.
78
+ # Conflict resolution requires Write/Edit on the conflicted files.
79
+ MERGE_HEAD_PATH=$(cd "$AGENT_DIR" && git rev-parse --git-dir 2>/dev/null || echo ".git")
80
+ if [[ -f "$MERGE_HEAD_PATH/MERGE_HEAD" ]]; then
81
+ exit 0
82
+ fi
83
+
75
84
  # Block: we're on the base branch with active worktrees
76
85
  echo "BLOCKED: Cannot write/edit files on '$CURRENT_BRANCH' while $WT_COUNT worktree(s) are active: $WT_NAMES" >&2
77
86
  echo "" >&2
@@ -81,4 +90,7 @@ echo " To create a new worktree: caws worktree create <name>" >&2
81
90
  echo "" >&2
82
91
  echo "Do NOT make changes on main and create a worktree retroactively." >&2
83
92
  echo "The worktree must exist BEFORE you start making changes." >&2
93
+ echo "" >&2
94
+ echo "If you are merging a worktree branch, use: caws worktree merge <name>" >&2
95
+ echo "Or start the merge first (git merge --no-ff <branch>), then resolve conflicts." >&2
84
96
  exit 2
@@ -9,7 +9,7 @@ When multiple agents are working on this project, each agent MUST work in its ow
9
9
 
10
10
  ## Before starting work
11
11
 
12
- 1. Check if worktrees exist: look for `.caws/worktrees.json` or `.caws/parallel.json`
12
+ 1. Check if worktrees exist: `caws worktree list` shows all active worktrees with last commit time and owner
13
13
  2. If worktrees are active and you are on the base branch, switch to your assigned worktree
14
14
  3. If no worktree exists for you, create one with `caws worktree create <name>` or `caws parallel setup <plan-file>`
15
15
 
@@ -21,17 +21,47 @@ When multiple agents are working on this project, each agent MUST work in its ow
21
21
  - `git push --force` -- rewrites remote history
22
22
  - Direct commits to the base branch -- only `merge(worktree):` and `wip(checkpoint):` formats are allowed
23
23
  - Copying files between your worktree and the main repo directory -- defeats isolation
24
+ - Destroying another agent's active worktree -- `caws worktree destroy` will block this unless you use `--force`
24
25
 
25
26
  ## Merging worktree branches back to base
26
27
 
27
28
  Merge commits ARE allowed on the base branch while other worktrees are active. This lets you incrementally merge completed work without waiting for all agents to finish.
28
29
 
30
+ ### Recommended: use `caws worktree merge`
31
+
32
+ The `merge` command handles the full sequence (conflict check, destroy, merge, cleanup):
33
+
34
+ ```bash
35
+ # Preview conflicts before merging
36
+ caws worktree merge <name> --dry-run
37
+
38
+ # Merge (destroys worktree, merges branch, deletes branch)
39
+ caws worktree merge <name>
40
+
41
+ # Merge with custom commit message
42
+ caws worktree merge <name> --message "merge(worktree): description of changes"
43
+ ```
44
+
45
+ ### Manual merge (if you need more control)
46
+
29
47
  1. Destroy the worktree first: `caws worktree destroy <name>`
30
48
  2. Switch to the base branch: `git checkout main`
31
49
  3. Merge with: `git merge --no-ff <worktree-branch>`
32
50
  4. The commit-msg hook enforces the `merge(worktree): <description>` format for non-FF merges
33
51
  5. For manual merge commits: `git commit -m "merge(worktree): integrate scenarios work"`
34
52
 
53
+ ### Conflict resolution during merge
54
+
55
+ The write guard allows edits on the base branch while a merge is in progress (MERGE_HEAD exists). This lets you resolve merge conflicts without needing to abort and retry. After resolving, commit with the `merge(worktree):` format.
56
+
57
+ ## What the write guard allows on the base branch
58
+
59
+ Even when worktrees are active, the following edits are allowed on the base branch:
60
+
61
+ - `.claude/` and `.caws/` configuration files
62
+ - `docs/` directory (documentation changes are benign)
63
+ - Any file while a merge is in progress (conflict resolution)
64
+
35
65
  ## Virtual environment in worktrees
36
66
 
37
67
  Do NOT create a new virtual environment in your worktree. Use the main repo's venv:
@@ -46,6 +76,5 @@ If your project uses `.caws/scope.json`, the `designatedVenvPath` field specifie
46
76
 
47
77
  1. Commit all changes to your worktree branch
48
78
  2. Run tests in your worktree to verify
49
- 3. Destroy your worktree with `caws worktree destroy <name>`
50
- 4. Merge your branch to base: `git merge --no-ff <branch>` (uses `merge(worktree):` format)
51
- 5. Delete the branch if no longer needed: `git branch -d <branch>`
79
+ 3. Merge: `caws worktree merge <name>` (handles destroy + merge + branch cleanup)
80
+ 4. Or manually: destroy worktree, then `git merge --no-ff <branch>`, then delete branch
@@ -38,15 +38,19 @@ caws agent evaluate
38
38
 
39
39
  ### Working Spec
40
40
 
41
- The project spec lives at `.caws/working-spec.yaml`. It defines:
41
+ The project spec lives at `.caws/working-spec.yaml`. Feature specs live at `.caws/specs/<ID>.yaml`. It defines:
42
42
 
43
43
  - **Risk tier**: Quality requirements (T1: critical, T2: standard, T3: low risk)
44
44
  - **Scope**: Which files you can edit (`scope.in`) and which are off-limits (`scope.out`)
45
- - **Change budget**: Max files and lines of code per change
46
- - **Acceptance criteria**: What "done" means
45
+ - **Change budget**: Max files and lines of code per change (see note below)
46
+ - **Acceptance criteria**: What "done" means — IDs must match `^A\d+$` (e.g. `A1`, `A12`)
47
47
 
48
48
  Always stay within scope boundaries and change budgets.
49
49
 
50
+ > **Budget note**: `change_budget:` in a spec is informational documentation only. CAWS
51
+ > derives the enforced budget from `policy.yaml` keyed on `risk_tier`. The field in the
52
+ > spec is not used by `caws validate` for enforcement.
53
+
50
54
  ### Quality Gates
51
55
 
52
56
  Quality requirements are tiered:
@@ -1 +1 @@
1
- {"version":3,"file":"spec-validation.d.ts","sourceRoot":"","sources":["../../src/validation/spec-validation.js"],"names":[],"mappings":"AA6DA;;;;;GAKG;AACH,mEA8IC;AAED;;;;;GAKG;AACH,kFAgdC;AAoCD;;;;;GAKG;AACH,0CAJW,MAAM,eAEJ,MAAM,CAkBlB;AAED;;;;;GAKG;AACH,uCAJW,MAAM,eAEJ,OAAO,CAKnB;AAnED;;;;;;GAMG;AACH,0EAFa,MAAM,CAclB;AAED;;;;GAIG;AACH,0CAHW,MAAM,GACJ,MAAM,CAQlB"}
1
+ {"version":3,"file":"spec-validation.d.ts","sourceRoot":"","sources":["../../src/validation/spec-validation.js"],"names":[],"mappings":"AA6DA;;;;;GAKG;AACH,mEA8IC;AAED;;;;;GAKG;AACH,kFAycC;AAoCD;;;;;GAKG;AACH,0CAJW,MAAM,eAEJ,MAAM,CAkBlB;AAED;;;;;GAKG;AACH,uCAJW,MAAM,eAEJ,OAAO,CAKnB;AAnED;;;;;;GAMG;AACH,0EAFa,MAAM,CAclB;AAED;;;;GAIG;AACH,0CAHW,MAAM,GACJ,MAAM,CAQlB"}
@@ -85,6 +85,14 @@ const validateWorkingSpec = (spec, _options = {}) => {
85
85
  // For new policy-based specs, change_budget is not required
86
86
  // It's derived from policy.yaml + waivers
87
87
 
88
+ // Normalize risk_tier: accept "T1"/"T2"/"T3" strings and convert to numeric
89
+ if (spec.risk_tier !== undefined && typeof spec.risk_tier === 'string') {
90
+ const match = spec.risk_tier.match(/^T?(\d)$/i);
91
+ if (match) {
92
+ spec.risk_tier = parseInt(match[1], 10);
93
+ }
94
+ }
95
+
88
96
  for (const field of requiredFields) {
89
97
  if (!spec[field]) {
90
98
  return {
@@ -563,16 +571,9 @@ function validateWorkingSpecWithSuggestions(spec, options = {}) {
563
571
  }
564
572
  }
565
573
 
566
- // Warn if change_budget is present (deprecated/informational only)
567
- if (spec.change_budget) {
568
- warnings.push({
569
- instancePath: '/change_budget',
570
- message:
571
- 'change_budget field in working spec is informational only and not used for validation',
572
- suggestion:
573
- 'Budget is derived from policy.yaml risk_tier + waivers. This field is auto-calculated.',
574
- });
575
- }
574
+ // Note: change_budget in specs is informational documentation only.
575
+ // Budget enforcement is derived from policy.yaml risk_tier + waivers.
576
+ // No warning emitted — the field is valid and expected.
576
577
 
577
578
  // Derive and check budget if requested
578
579
  let budgetCheck = null;
@@ -13,6 +13,46 @@ const WORKTREES_DIR = '.caws/worktrees';
13
13
  const REGISTRY_FILE = '.caws/worktrees.json';
14
14
  const BRANCH_PREFIX = 'caws/';
15
15
 
16
+ /**
17
+ * Get the last commit info for a branch
18
+ * @param {string} branch - Branch name
19
+ * @param {string} root - Repository root
20
+ * @returns {{ age: string, timestamp: Date, sha: string } | null}
21
+ */
22
+ function getLastCommitInfo(branch, root) {
23
+ try {
24
+ const output = execFileSync(
25
+ 'git',
26
+ ['log', branch, '-1', '--format=%H%n%aI%n%ar'],
27
+ { cwd: root, encoding: 'utf8', stdio: 'pipe' }
28
+ ).trim();
29
+ const [sha, iso, age] = output.split('\n');
30
+ return { sha, timestamp: new Date(iso), age };
31
+ } catch {
32
+ return null;
33
+ }
34
+ }
35
+
36
+ /**
37
+ * Check if a branch has been merged into another branch
38
+ * @param {string} branch - Branch to check
39
+ * @param {string} target - Target branch (e.g., "main")
40
+ * @param {string} root - Repository root
41
+ * @returns {boolean}
42
+ */
43
+ function isBranchMerged(branch, target, root) {
44
+ try {
45
+ const merged = execFileSync(
46
+ 'git',
47
+ ['branch', '--merged', target, '--list', branch],
48
+ { cwd: root, encoding: 'utf8', stdio: 'pipe' }
49
+ ).trim();
50
+ return merged.length > 0;
51
+ } catch {
52
+ return false;
53
+ }
54
+ }
55
+
16
56
  /**
17
57
  * Get the git repository root
18
58
  * @returns {string} Absolute path to repo root
@@ -250,10 +290,21 @@ function listWorktrees() {
250
290
  const inGit = gitWorktrees.some(
251
291
  (wt) => path.resolve(wt) === path.resolve(entry.path)
252
292
  );
293
+ const status = exists && inGit ? 'active' : exists ? 'orphaned' : 'missing';
294
+
295
+ // Enrich with commit recency
296
+ const lastCommit = entry.branch ? getLastCommitInfo(entry.branch, root) : null;
297
+
298
+ // Check if branch is already merged to base
299
+ const merged = entry.branch && entry.baseBranch
300
+ ? isBranchMerged(entry.branch, entry.baseBranch, root)
301
+ : false;
253
302
 
254
303
  return {
255
304
  ...entry,
256
- status: exists && inGit ? 'active' : exists ? 'orphaned' : 'missing',
305
+ status,
306
+ lastCommit,
307
+ merged,
257
308
  };
258
309
  });
259
310
 
@@ -277,16 +328,45 @@ function destroyWorktree(name, options = {}) {
277
328
  throw new Error(`Worktree '${name}' not found in registry`);
278
329
  }
279
330
 
331
+ // Ownership check: refuse to destroy another agent's active worktree without --force
332
+ const currentSession = process.env.CLAUDE_SESSION_ID || null;
333
+ if (
334
+ !force &&
335
+ entry.status === 'active' &&
336
+ entry.owner &&
337
+ currentSession &&
338
+ entry.owner !== currentSession
339
+ ) {
340
+ const lastCommit = entry.branch ? getLastCommitInfo(entry.branch, root) : null;
341
+ const recency = lastCommit ? ` (last commit: ${lastCommit.age})` : '';
342
+ throw new Error(
343
+ `Worktree '${name}' belongs to another session${recency}.\n` +
344
+ ` Owner: ${entry.owner}\n` +
345
+ ` You: ${currentSession}\n` +
346
+ `Another agent may be actively working here. Use --force to override.`
347
+ );
348
+ }
349
+
350
+ // Auto-force when the branch is already merged to its base branch.
351
+ // Dirty files in a merged worktree are definitionally stale.
352
+ const merged = entry.branch && entry.baseBranch
353
+ ? isBranchMerged(entry.branch, entry.baseBranch, root)
354
+ : false;
355
+ const effectiveForce = force || merged;
356
+ if (merged && !force) {
357
+ console.log(chalk.gray(` Branch ${entry.branch} already merged to ${entry.baseBranch}, auto-forcing cleanup`));
358
+ }
359
+
280
360
  // Remove git worktree — handle already-deleted directories gracefully
281
361
  const dirExists = fs.existsSync(entry.path);
282
362
  if (dirExists) {
283
363
  try {
284
364
  const args = ['worktree', 'remove'];
285
- if (force) args.push('--force');
365
+ if (effectiveForce) args.push('--force');
286
366
  args.push(entry.path);
287
367
  execFileSync('git', args, { cwd: root, stdio: 'pipe' });
288
368
  } catch (error) {
289
- if (force) {
369
+ if (effectiveForce) {
290
370
  // Force cleanup: remove directory manually
291
371
  fs.removeSync(entry.path);
292
372
  } else {
@@ -310,7 +390,7 @@ function destroyWorktree(name, options = {}) {
310
390
  try {
311
391
  execFileSync('git', ['branch', '-d', entry.branch], { cwd: root, stdio: 'pipe' });
312
392
  } catch {
313
- if (force) {
393
+ if (effectiveForce) {
314
394
  try {
315
395
  execFileSync('git', ['branch', '-D', entry.branch], { cwd: root, stdio: 'pipe' });
316
396
  } catch {
@@ -326,19 +406,143 @@ function destroyWorktree(name, options = {}) {
326
406
  saveRegistry(root, registry);
327
407
  }
328
408
 
409
+ /**
410
+ * Merge a worktree branch back to base in one operation.
411
+ * Sequence: dry-run conflict check → destroy worktree → merge → cleanup.
412
+ * @param {string} name - Worktree name
413
+ * @param {Object} options - Merge options
414
+ * @param {boolean} [options.dryRun] - Preview conflicts without merging
415
+ * @param {boolean} [options.deleteBranch] - Delete branch after merge
416
+ * @param {string} [options.message] - Custom merge commit message
417
+ * @returns {Object} Merge result
418
+ */
419
+ function mergeWorktree(name, options = {}) {
420
+ const root = getRepoRoot();
421
+ const registry = loadRegistry(root);
422
+ const { dryRun = false, deleteBranch = true, message } = options;
423
+
424
+ const entry = registry.worktrees[name];
425
+ if (!entry) {
426
+ throw new Error(`Worktree '${name}' not found in registry`);
427
+ }
428
+
429
+ const baseBranch = entry.baseBranch || 'main';
430
+
431
+ // Check for uncommitted work in the worktree
432
+ if (fs.existsSync(entry.path)) {
433
+ try {
434
+ const status = execFileSync(
435
+ 'git',
436
+ ['status', '--porcelain'],
437
+ { cwd: entry.path, encoding: 'utf8', stdio: 'pipe' }
438
+ ).trim();
439
+ if (status) {
440
+ throw new Error(
441
+ `Worktree '${name}' has uncommitted changes:\n${status}\n` +
442
+ `Commit or discard changes before merging.`
443
+ );
444
+ }
445
+ } catch (error) {
446
+ if (error.message.includes('uncommitted changes')) throw error;
447
+ // Non-fatal: status check failed, proceed cautiously
448
+ }
449
+ }
450
+
451
+ // Dry-run: check for conflicts using git merge-tree (new-style, git 2.38+)
452
+ let conflicts = [];
453
+ try {
454
+ // New-style merge-tree: takes two branches, computes merge-base automatically
455
+ const mergeTreeResult = execFileSync(
456
+ 'git',
457
+ ['merge-tree', '--write-tree', baseBranch, entry.branch],
458
+ { cwd: root, encoding: 'utf8', stdio: 'pipe' }
459
+ );
460
+ // Exit 0 = clean merge, no conflicts
461
+ } catch (mergeTreeError) {
462
+ // Exit 1 = conflicts detected; parse them from output
463
+ const output = (mergeTreeError.stdout || '') + (mergeTreeError.stderr || '');
464
+ const conflictLines = output.split('\n').filter(
465
+ (l) => l.includes('CONFLICT') || l.includes('conflict')
466
+ );
467
+ if (mergeTreeError.status === 1 && conflictLines.length > 0) {
468
+ conflicts = conflictLines;
469
+ } else if (mergeTreeError.status === 1) {
470
+ conflicts = ['Merge conflicts detected (run merge manually to inspect)'];
471
+ }
472
+ // Other exit codes (e.g., merge-tree not supported) = can't detect, proceed
473
+ }
474
+
475
+ if (dryRun) {
476
+ return {
477
+ name,
478
+ branch: entry.branch,
479
+ baseBranch,
480
+ conflicts,
481
+ wouldMerge: conflicts.length === 0,
482
+ };
483
+ }
484
+
485
+ // Destroy the worktree (auto-forces since we're about to merge)
486
+ destroyWorktree(name, { deleteBranch: false, force: true });
487
+
488
+ // Switch to base branch
489
+ const currentBranch = getCurrentBranch();
490
+ if (currentBranch !== baseBranch) {
491
+ execFileSync('git', ['checkout', baseBranch], { cwd: root, stdio: 'pipe' });
492
+ }
493
+
494
+ // Merge
495
+ const mergeMessage = message || `merge(worktree): ${name}`;
496
+ try {
497
+ execFileSync(
498
+ 'git',
499
+ ['merge', '--no-ff', entry.branch, '-m', mergeMessage],
500
+ { cwd: root, stdio: 'pipe' }
501
+ );
502
+ } catch (error) {
503
+ return {
504
+ name,
505
+ branch: entry.branch,
506
+ baseBranch,
507
+ merged: false,
508
+ conflicts: [`Merge failed: ${error.message}`],
509
+ message: 'Merge conflicts detected. Resolve with git and commit.',
510
+ };
511
+ }
512
+
513
+ // Delete branch after successful merge
514
+ if (deleteBranch) {
515
+ try {
516
+ execFileSync('git', ['branch', '-d', entry.branch], { cwd: root, stdio: 'pipe' });
517
+ } catch {
518
+ // Non-fatal
519
+ }
520
+ }
521
+
522
+ return {
523
+ name,
524
+ branch: entry.branch,
525
+ baseBranch,
526
+ merged: true,
527
+ conflicts: [],
528
+ };
529
+ }
530
+
329
531
  /**
330
532
  * Prune stale worktree entries
331
533
  * @param {Object} options - Prune options
332
534
  * @param {number} [options.maxAgeDays] - Remove entries older than this many days
535
+ * @param {number} [options.recentCommitMinutes] - Protect branches with commits newer than this (default: 60)
333
536
  * @returns {Array} Pruned entries
334
537
  */
335
538
  function pruneWorktrees(options = {}) {
336
539
  const root = getRepoRoot();
337
540
  const registry = loadRegistry(root);
338
- const { maxAgeDays = 30 } = options;
541
+ const { maxAgeDays = 30, recentCommitMinutes = 60 } = options;
339
542
 
340
543
  const now = new Date();
341
544
  const pruned = [];
545
+ const skipped = [];
342
546
 
343
547
  for (const [name, entry] of Object.entries(registry.worktrees)) {
344
548
  const created = new Date(entry.createdAt);
@@ -354,6 +558,18 @@ function pruneWorktrees(options = {}) {
354
558
  (!dirExists && ageDays > maxAgeDays);
355
559
 
356
560
  if (shouldPrune) {
561
+ // Before pruning a non-destroyed entry, check for recent commits
562
+ if (entry.status !== 'destroyed' && entry.branch) {
563
+ const lastCommit = getLastCommitInfo(entry.branch, root);
564
+ if (lastCommit) {
565
+ const commitAgeMinutes = (now - lastCommit.timestamp) / (1000 * 60);
566
+ if (commitAgeMinutes < recentCommitMinutes) {
567
+ skipped.push({ name, reason: `recent commit (${lastCommit.age})`, entry });
568
+ continue;
569
+ }
570
+ }
571
+ }
572
+
357
573
  // Clean up filesystem if still exists
358
574
  if (dirExists) {
359
575
  try {
@@ -378,16 +594,19 @@ function pruneWorktrees(options = {}) {
378
594
  }
379
595
 
380
596
  saveRegistry(root, registry);
381
- return pruned;
597
+ return { pruned, skipped };
382
598
  }
383
599
 
384
600
  module.exports = {
385
601
  createWorktree,
386
602
  listWorktrees,
387
603
  destroyWorktree,
604
+ mergeWorktree,
388
605
  pruneWorktrees,
389
606
  loadRegistry,
390
607
  getRepoRoot,
608
+ getLastCommitInfo,
609
+ isBranchMerged,
391
610
  WORKTREES_DIR,
392
611
  REGISTRY_FILE,
393
612
  BRANCH_PREFIX,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@paths.design/caws-cli",
3
- "version": "9.1.0",
3
+ "version": "9.2.0",
4
4
  "description": "CAWS CLI - Coding Agent Workflow System command-line tools for spec management, quality gates, and AI-assisted development",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -65,13 +65,22 @@ if [[ "$WT_COUNT" -le 0 ]] 2>/dev/null; then
65
65
  exit 0
66
66
  fi
67
67
 
68
- # Allow edits to .claude/ configuration (hooks, settings, rules)
68
+ # Allow edits to configuration and documentation (benign, no merge conflict risk)
69
69
  if [[ -n "$FILE_PATH" ]]; then
70
70
  case "$FILE_PATH" in
71
71
  */.claude/*|*/.caws/*) exit 0 ;;
72
+ */docs/*) exit 0 ;;
72
73
  esac
73
74
  fi
74
75
 
76
+ # Allow edits during an active merge (conflict resolution).
77
+ # The worktree-isolation rules explicitly permit merge commits on the base branch.
78
+ # Conflict resolution requires Write/Edit on the conflicted files.
79
+ MERGE_HEAD_PATH=$(cd "$AGENT_DIR" && git rev-parse --git-dir 2>/dev/null || echo ".git")
80
+ if [[ -f "$MERGE_HEAD_PATH/MERGE_HEAD" ]]; then
81
+ exit 0
82
+ fi
83
+
75
84
  # Block: we're on the base branch with active worktrees
76
85
  echo "BLOCKED: Cannot write/edit files on '$CURRENT_BRANCH' while $WT_COUNT worktree(s) are active: $WT_NAMES" >&2
77
86
  echo "" >&2
@@ -81,4 +90,7 @@ echo " To create a new worktree: caws worktree create <name>" >&2
81
90
  echo "" >&2
82
91
  echo "Do NOT make changes on main and create a worktree retroactively." >&2
83
92
  echo "The worktree must exist BEFORE you start making changes." >&2
93
+ echo "" >&2
94
+ echo "If you are merging a worktree branch, use: caws worktree merge <name>" >&2
95
+ echo "Or start the merge first (git merge --no-ff <branch>), then resolve conflicts." >&2
84
96
  exit 2
@@ -9,7 +9,7 @@ When multiple agents are working on this project, each agent MUST work in its ow
9
9
 
10
10
  ## Before starting work
11
11
 
12
- 1. Check if worktrees exist: look for `.caws/worktrees.json` or `.caws/parallel.json`
12
+ 1. Check if worktrees exist: `caws worktree list` shows all active worktrees with last commit time and owner
13
13
  2. If worktrees are active and you are on the base branch, switch to your assigned worktree
14
14
  3. If no worktree exists for you, create one with `caws worktree create <name>` or `caws parallel setup <plan-file>`
15
15
 
@@ -21,17 +21,47 @@ When multiple agents are working on this project, each agent MUST work in its ow
21
21
  - `git push --force` -- rewrites remote history
22
22
  - Direct commits to the base branch -- only `merge(worktree):` and `wip(checkpoint):` formats are allowed
23
23
  - Copying files between your worktree and the main repo directory -- defeats isolation
24
+ - Destroying another agent's active worktree -- `caws worktree destroy` will block this unless you use `--force`
24
25
 
25
26
  ## Merging worktree branches back to base
26
27
 
27
28
  Merge commits ARE allowed on the base branch while other worktrees are active. This lets you incrementally merge completed work without waiting for all agents to finish.
28
29
 
30
+ ### Recommended: use `caws worktree merge`
31
+
32
+ The `merge` command handles the full sequence (conflict check, destroy, merge, cleanup):
33
+
34
+ ```bash
35
+ # Preview conflicts before merging
36
+ caws worktree merge <name> --dry-run
37
+
38
+ # Merge (destroys worktree, merges branch, deletes branch)
39
+ caws worktree merge <name>
40
+
41
+ # Merge with custom commit message
42
+ caws worktree merge <name> --message "merge(worktree): description of changes"
43
+ ```
44
+
45
+ ### Manual merge (if you need more control)
46
+
29
47
  1. Destroy the worktree first: `caws worktree destroy <name>`
30
48
  2. Switch to the base branch: `git checkout main`
31
49
  3. Merge with: `git merge --no-ff <worktree-branch>`
32
50
  4. The commit-msg hook enforces the `merge(worktree): <description>` format for non-FF merges
33
51
  5. For manual merge commits: `git commit -m "merge(worktree): integrate scenarios work"`
34
52
 
53
+ ### Conflict resolution during merge
54
+
55
+ The write guard allows edits on the base branch while a merge is in progress (MERGE_HEAD exists). This lets you resolve merge conflicts without needing to abort and retry. After resolving, commit with the `merge(worktree):` format.
56
+
57
+ ## What the write guard allows on the base branch
58
+
59
+ Even when worktrees are active, the following edits are allowed on the base branch:
60
+
61
+ - `.claude/` and `.caws/` configuration files
62
+ - `docs/` directory (documentation changes are benign)
63
+ - Any file while a merge is in progress (conflict resolution)
64
+
35
65
  ## Virtual environment in worktrees
36
66
 
37
67
  Do NOT create a new virtual environment in your worktree. Use the main repo's venv:
@@ -46,6 +76,5 @@ If your project uses `.caws/scope.json`, the `designatedVenvPath` field specifie
46
76
 
47
77
  1. Commit all changes to your worktree branch
48
78
  2. Run tests in your worktree to verify
49
- 3. Destroy your worktree with `caws worktree destroy <name>`
50
- 4. Merge your branch to base: `git merge --no-ff <branch>` (uses `merge(worktree):` format)
51
- 5. Delete the branch if no longer needed: `git branch -d <branch>`
79
+ 3. Merge: `caws worktree merge <name>` (handles destroy + merge + branch cleanup)
80
+ 4. Or manually: destroy worktree, then `git merge --no-ff <branch>`, then delete branch
@@ -38,15 +38,19 @@ caws agent evaluate
38
38
 
39
39
  ### Working Spec
40
40
 
41
- The project spec lives at `.caws/working-spec.yaml`. It defines:
41
+ The project spec lives at `.caws/working-spec.yaml`. Feature specs live at `.caws/specs/<ID>.yaml`. It defines:
42
42
 
43
43
  - **Risk tier**: Quality requirements (T1: critical, T2: standard, T3: low risk)
44
44
  - **Scope**: Which files you can edit (`scope.in`) and which are off-limits (`scope.out`)
45
- - **Change budget**: Max files and lines of code per change
46
- - **Acceptance criteria**: What "done" means
45
+ - **Change budget**: Max files and lines of code per change (see note below)
46
+ - **Acceptance criteria**: What "done" means — IDs must match `^A\d+$` (e.g. `A1`, `A12`)
47
47
 
48
48
  Always stay within scope boundaries and change budgets.
49
49
 
50
+ > **Budget note**: `change_budget:` in a spec is informational documentation only. CAWS
51
+ > derives the enforced budget from `policy.yaml` keyed on `risk_tier`. The field in the
52
+ > spec is not used by `caws validate` for enforcement.
53
+
50
54
  ### Quality Gates
51
55
 
52
56
  Quality requirements are tiered: