@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.
- package/dist/budget-derivation.js +15 -3
- package/dist/commands/tutorial.d.ts.map +1 -1
- package/dist/commands/validate.d.ts.map +1 -1
- package/dist/commands/validate.js +42 -1
- package/dist/commands/worktree.js +107 -15
- package/dist/index.js +9 -0
- package/dist/parallel/parallel-manager.d.ts.map +1 -1
- package/dist/templates/.claude/hooks/worktree-write-guard.sh +13 -1
- package/dist/templates/.claude/rules/worktree-isolation.md +33 -4
- package/dist/templates/CLAUDE.md +7 -3
- package/dist/validation/spec-validation.d.ts.map +1 -1
- package/dist/validation/spec-validation.js +11 -10
- package/dist/worktree/worktree-manager.js +225 -6
- package/package.json +1 -1
- package/templates/.claude/hooks/worktree-write-guard.sh +13 -1
- package/templates/.claude/rules/worktree-isolation.md +33 -4
- package/templates/CLAUDE.md +7 -3
|
@@ -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[
|
|
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[
|
|
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":"
|
|
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,
|
|
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
|
|
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(
|
|
76
|
+
console.log(chalk.cyan('='.repeat(85)));
|
|
74
77
|
console.log(
|
|
75
78
|
chalk.bold(
|
|
76
|
-
'Name'.padEnd(
|
|
79
|
+
'Name'.padEnd(18) +
|
|
77
80
|
'Status'.padEnd(12) +
|
|
78
81
|
'Branch'.padEnd(20) +
|
|
79
|
-
'
|
|
82
|
+
'Last Commit'.padEnd(16) +
|
|
83
|
+
'Owner'
|
|
80
84
|
)
|
|
81
85
|
);
|
|
82
|
-
console.log(chalk.gray('-'.repeat(
|
|
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
|
-
|
|
90
|
-
|
|
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(
|
|
94
|
-
statusColor(
|
|
117
|
+
entry.name.padEnd(18) +
|
|
118
|
+
statusColor(statusStr.padEnd(12)) +
|
|
95
119
|
(entry.branch || '').padEnd(20) +
|
|
96
|
-
(
|
|
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
|
|
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
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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":"
|
|
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
|
|
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:
|
|
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.
|
|
50
|
-
4.
|
|
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
|
package/dist/templates/CLAUDE.md
CHANGED
|
@@ -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,
|
|
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
|
-
//
|
|
567
|
-
|
|
568
|
-
|
|
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
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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
|
@@ -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
|
|
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:
|
|
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.
|
|
50
|
-
4.
|
|
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
|
package/templates/CLAUDE.md
CHANGED
|
@@ -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:
|