@paths.design/caws-cli 8.2.3 → 9.0.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/commands/parallel.js +238 -0
- package/dist/commands/specs.js +55 -2
- package/dist/commands/status.js +13 -3
- package/dist/constants/spec-types.js +25 -2
- package/dist/index.js +43 -2
- package/dist/parallel/parallel-manager.js +443 -0
- package/dist/scaffold/claude-hooks.js +54 -1
- package/dist/scaffold/git-hooks.js +188 -14
- package/dist/session/session-manager.js +14 -0
- package/dist/templates/.claude/hooks/scope-guard.sh +67 -21
- package/dist/templates/.claude/hooks/session-caws-status.sh +117 -0
- package/dist/templates/.claude/hooks/stop-worktree-check.sh +46 -0
- package/dist/templates/.claude/hooks/worktree-guard.sh +207 -0
- package/dist/templates/.claude/hooks/worktree-write-guard.sh +84 -0
- package/dist/templates/.claude/rules/git-safety.md +26 -0
- package/dist/templates/.claude/rules/worktree-isolation.md +51 -0
- package/dist/templates/.claude/settings.json +15 -0
- package/dist/validation/spec-validation.js +16 -0
- package/dist/worktree/worktree-manager.js +31 -21
- package/package.json +1 -1
- package/templates/.claude/hooks/scope-guard.sh +67 -21
- package/templates/.claude/hooks/session-caws-status.sh +117 -0
- package/templates/.claude/hooks/stop-worktree-check.sh +46 -0
- package/templates/.claude/hooks/worktree-guard.sh +207 -0
- package/templates/.claude/hooks/worktree-write-guard.sh +84 -0
- package/templates/.claude/rules/git-safety.md +26 -0
- package/templates/.claude/rules/worktree-isolation.md +51 -0
- package/templates/.claude/settings.json +15 -0
|
@@ -0,0 +1,443 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview CAWS Parallel Workspace Manager
|
|
3
|
+
* Orchestrates multi-agent worktree + session setup from a plan file.
|
|
4
|
+
* @author @darianrosebrook
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const { execFileSync } = require('child_process');
|
|
8
|
+
const fs = require('fs-extra');
|
|
9
|
+
const path = require('path');
|
|
10
|
+
const yaml = require('js-yaml');
|
|
11
|
+
|
|
12
|
+
const {
|
|
13
|
+
createWorktree,
|
|
14
|
+
listWorktrees,
|
|
15
|
+
destroyWorktree,
|
|
16
|
+
getRepoRoot,
|
|
17
|
+
BRANCH_PREFIX,
|
|
18
|
+
} = require('../worktree/worktree-manager');
|
|
19
|
+
|
|
20
|
+
const {
|
|
21
|
+
listSessions,
|
|
22
|
+
endSession,
|
|
23
|
+
} = require('../session/session-manager');
|
|
24
|
+
|
|
25
|
+
const PARALLEL_REGISTRY = '.caws/parallel.json';
|
|
26
|
+
const VALID_STRATEGIES = ['merge', 'rebase', 'squash'];
|
|
27
|
+
const NAME_REGEX = /^[a-zA-Z0-9_-]+$/;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Get current branch name
|
|
31
|
+
* @returns {string}
|
|
32
|
+
*/
|
|
33
|
+
function getCurrentBranch() {
|
|
34
|
+
return execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
|
|
35
|
+
encoding: 'utf8',
|
|
36
|
+
}).trim();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Load the parallel registry
|
|
41
|
+
* @param {string} root - Repository root
|
|
42
|
+
* @returns {Object|null} Registry or null if not found
|
|
43
|
+
*/
|
|
44
|
+
function loadParallelRegistry(root) {
|
|
45
|
+
const regPath = path.join(root, PARALLEL_REGISTRY);
|
|
46
|
+
try {
|
|
47
|
+
if (fs.existsSync(regPath)) {
|
|
48
|
+
return JSON.parse(fs.readFileSync(regPath, 'utf8'));
|
|
49
|
+
}
|
|
50
|
+
} catch {
|
|
51
|
+
// Corrupted registry
|
|
52
|
+
}
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Save the parallel registry
|
|
58
|
+
* @param {string} root - Repository root
|
|
59
|
+
* @param {Object} data - Registry data
|
|
60
|
+
*/
|
|
61
|
+
function saveParallelRegistry(root, data) {
|
|
62
|
+
const regPath = path.join(root, PARALLEL_REGISTRY);
|
|
63
|
+
fs.ensureDirSync(path.dirname(regPath));
|
|
64
|
+
fs.writeFileSync(regPath, JSON.stringify(data, null, 2));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Remove the parallel registry
|
|
69
|
+
* @param {string} root - Repository root
|
|
70
|
+
*/
|
|
71
|
+
function removeParallelRegistry(root) {
|
|
72
|
+
const regPath = path.join(root, PARALLEL_REGISTRY);
|
|
73
|
+
if (fs.existsSync(regPath)) {
|
|
74
|
+
fs.removeSync(regPath);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Load and validate a parallel plan YAML file
|
|
80
|
+
* @param {string} filePath - Path to plan YAML file
|
|
81
|
+
* @returns {Object} Parsed and validated plan
|
|
82
|
+
*/
|
|
83
|
+
function loadPlan(filePath) {
|
|
84
|
+
if (!fs.existsSync(filePath)) {
|
|
85
|
+
throw new Error(`Plan file not found: ${filePath}`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
89
|
+
let plan;
|
|
90
|
+
try {
|
|
91
|
+
plan = yaml.load(content);
|
|
92
|
+
} catch (err) {
|
|
93
|
+
throw new Error(`Invalid YAML in plan file: ${err.message}`);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (!plan || typeof plan !== 'object') {
|
|
97
|
+
throw new Error('Plan file must contain a YAML object');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (plan.version !== 1) {
|
|
101
|
+
throw new Error(`Unsupported plan version: ${plan.version}. Expected 1.`);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (!Array.isArray(plan.agents) || plan.agents.length === 0) {
|
|
105
|
+
throw new Error('Plan must define at least one agent');
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (plan.merge_strategy && !VALID_STRATEGIES.includes(plan.merge_strategy)) {
|
|
109
|
+
throw new Error(`Invalid merge_strategy: ${plan.merge_strategy}. Must be one of: ${VALID_STRATEGIES.join(', ')}`);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const names = new Set();
|
|
113
|
+
for (const agent of plan.agents) {
|
|
114
|
+
if (!agent.name) {
|
|
115
|
+
throw new Error('Each agent must have a name');
|
|
116
|
+
}
|
|
117
|
+
if (!NAME_REGEX.test(agent.name)) {
|
|
118
|
+
throw new Error(`Invalid agent name '${agent.name}': must contain only letters, numbers, hyphens, and underscores`);
|
|
119
|
+
}
|
|
120
|
+
if (names.has(agent.name)) {
|
|
121
|
+
throw new Error(`Duplicate agent name: ${agent.name}`);
|
|
122
|
+
}
|
|
123
|
+
names.add(agent.name);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
version: plan.version,
|
|
128
|
+
baseBranch: plan.base_branch || null,
|
|
129
|
+
mergeStrategy: plan.merge_strategy || 'merge',
|
|
130
|
+
agents: plan.agents,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Set up parallel worktrees from a plan
|
|
136
|
+
* @param {Object} plan - Validated plan from loadPlan
|
|
137
|
+
* @returns {Object[]} Array of created worktree entries
|
|
138
|
+
*/
|
|
139
|
+
function setupParallel(plan) {
|
|
140
|
+
const root = getRepoRoot();
|
|
141
|
+
|
|
142
|
+
// Check for existing parallel run
|
|
143
|
+
const existing = loadParallelRegistry(root);
|
|
144
|
+
if (existing && existing.agents && existing.agents.length > 0) {
|
|
145
|
+
throw new Error(
|
|
146
|
+
'A parallel run is already active. Run `caws parallel teardown` first, or `caws parallel status` to inspect.'
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const baseBranch = plan.baseBranch || getCurrentBranch();
|
|
151
|
+
const results = [];
|
|
152
|
+
|
|
153
|
+
for (const agent of plan.agents) {
|
|
154
|
+
const entry = createWorktree(agent.name, {
|
|
155
|
+
scope: agent.scope || null,
|
|
156
|
+
baseBranch,
|
|
157
|
+
specId: agent.spec_id || null,
|
|
158
|
+
});
|
|
159
|
+
results.push({ ...entry, intent: agent.intent || null, role: agent.role || 'worker' });
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Write parallel registry
|
|
163
|
+
saveParallelRegistry(root, {
|
|
164
|
+
version: 1,
|
|
165
|
+
createdAt: new Date().toISOString(),
|
|
166
|
+
baseBranch,
|
|
167
|
+
mergeStrategy: plan.mergeStrategy || 'merge',
|
|
168
|
+
agents: plan.agents.map((a) => ({
|
|
169
|
+
name: a.name,
|
|
170
|
+
scope: a.scope || null,
|
|
171
|
+
specId: a.spec_id || null,
|
|
172
|
+
role: a.role || 'worker',
|
|
173
|
+
intent: a.intent || null,
|
|
174
|
+
})),
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
return results;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Get status of all parallel worktrees
|
|
182
|
+
* @returns {Object|null} Parallel status or null if no active run
|
|
183
|
+
*/
|
|
184
|
+
function getParallelStatus() {
|
|
185
|
+
const root = getRepoRoot();
|
|
186
|
+
const parallelReg = loadParallelRegistry(root);
|
|
187
|
+
if (!parallelReg) return null;
|
|
188
|
+
|
|
189
|
+
const worktrees = listWorktrees();
|
|
190
|
+
|
|
191
|
+
const agentStatuses = parallelReg.agents.map((agent) => {
|
|
192
|
+
const wt = worktrees.find((w) => w.name === agent.name);
|
|
193
|
+
let commitCount = 0;
|
|
194
|
+
let dirty = false;
|
|
195
|
+
|
|
196
|
+
if (wt && wt.status === 'active') {
|
|
197
|
+
try {
|
|
198
|
+
const log = execFileSync(
|
|
199
|
+
'git',
|
|
200
|
+
['log', '--oneline', `${parallelReg.baseBranch}..${wt.branch}`],
|
|
201
|
+
{ cwd: root, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }
|
|
202
|
+
);
|
|
203
|
+
commitCount = log.trim().split('\n').filter(Boolean).length;
|
|
204
|
+
} catch {
|
|
205
|
+
// Branch may not have diverged
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
try {
|
|
209
|
+
const status = execFileSync('git', ['status', '--porcelain'], {
|
|
210
|
+
cwd: wt.path,
|
|
211
|
+
encoding: 'utf8',
|
|
212
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
213
|
+
});
|
|
214
|
+
dirty = status.trim().length > 0;
|
|
215
|
+
} catch {
|
|
216
|
+
// Worktree may be inaccessible
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return {
|
|
221
|
+
name: agent.name,
|
|
222
|
+
branch: wt ? wt.branch : BRANCH_PREFIX + agent.name,
|
|
223
|
+
status: wt ? wt.status : 'missing',
|
|
224
|
+
scope: agent.scope || '(all)',
|
|
225
|
+
commitCount,
|
|
226
|
+
dirty,
|
|
227
|
+
intent: agent.intent,
|
|
228
|
+
};
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
// Detect file-level conflicts between agents
|
|
232
|
+
const conflicts = detectFileConflicts(parallelReg.baseBranch, agentStatuses);
|
|
233
|
+
|
|
234
|
+
return {
|
|
235
|
+
baseBranch: parallelReg.baseBranch,
|
|
236
|
+
mergeStrategy: parallelReg.mergeStrategy,
|
|
237
|
+
createdAt: parallelReg.createdAt,
|
|
238
|
+
agents: agentStatuses,
|
|
239
|
+
conflicts,
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Detect file-level conflicts between agent branches
|
|
245
|
+
* @param {string} baseBranch - Base branch name
|
|
246
|
+
* @param {Object[]} agentStatuses - Agent status objects with branch field
|
|
247
|
+
* @returns {Object[]} Conflicts: [{file, agents: [name, name]}]
|
|
248
|
+
*/
|
|
249
|
+
function detectFileConflicts(baseBranch, agentStatuses) {
|
|
250
|
+
const root = getRepoRoot();
|
|
251
|
+
const filesByAgent = {};
|
|
252
|
+
|
|
253
|
+
for (const agent of agentStatuses) {
|
|
254
|
+
if (agent.status !== 'active') continue;
|
|
255
|
+
try {
|
|
256
|
+
const diff = execFileSync(
|
|
257
|
+
'git',
|
|
258
|
+
['diff', '--name-only', `${baseBranch}...${agent.branch}`],
|
|
259
|
+
{ cwd: root, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }
|
|
260
|
+
);
|
|
261
|
+
filesByAgent[agent.name] = diff.trim().split('\n').filter(Boolean);
|
|
262
|
+
} catch {
|
|
263
|
+
filesByAgent[agent.name] = [];
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Find overlapping files
|
|
268
|
+
const conflicts = [];
|
|
269
|
+
const agentNames = Object.keys(filesByAgent);
|
|
270
|
+
for (let i = 0; i < agentNames.length; i++) {
|
|
271
|
+
for (let j = i + 1; j < agentNames.length; j++) {
|
|
272
|
+
const a = agentNames[i];
|
|
273
|
+
const b = agentNames[j];
|
|
274
|
+
const overlap = filesByAgent[a].filter((f) => filesByAgent[b].includes(f));
|
|
275
|
+
for (const file of overlap) {
|
|
276
|
+
conflicts.push({ file, agents: [a, b] });
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
return conflicts;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Merge all parallel branches back to base
|
|
286
|
+
* @param {Object} options - Merge options
|
|
287
|
+
* @param {string} [options.strategy] - Override merge strategy
|
|
288
|
+
* @param {boolean} [options.dryRun] - Preview without executing
|
|
289
|
+
* @param {boolean} [options.force] - Force merge even with conflicts
|
|
290
|
+
* @returns {Object} Merge results {merged, failed, conflicts}
|
|
291
|
+
*/
|
|
292
|
+
function mergeParallel(options = {}) {
|
|
293
|
+
const root = getRepoRoot();
|
|
294
|
+
const parallelReg = loadParallelRegistry(root);
|
|
295
|
+
if (!parallelReg) {
|
|
296
|
+
throw new Error('No active parallel run found. Run `caws parallel setup` first.');
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const strategy = options.strategy || parallelReg.mergeStrategy || 'merge';
|
|
300
|
+
const worktrees = listWorktrees();
|
|
301
|
+
const activeAgents = parallelReg.agents
|
|
302
|
+
.map((a) => {
|
|
303
|
+
const wt = worktrees.find((w) => w.name === a.name);
|
|
304
|
+
return wt ? { ...a, ...wt } : null;
|
|
305
|
+
})
|
|
306
|
+
.filter((a) => a && a.status === 'active');
|
|
307
|
+
|
|
308
|
+
// Check for dirty worktrees
|
|
309
|
+
for (const agent of activeAgents) {
|
|
310
|
+
try {
|
|
311
|
+
const status = execFileSync('git', ['status', '--porcelain'], {
|
|
312
|
+
cwd: agent.path,
|
|
313
|
+
encoding: 'utf8',
|
|
314
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
315
|
+
});
|
|
316
|
+
if (status.trim().length > 0) {
|
|
317
|
+
throw new Error(
|
|
318
|
+
`Worktree '${agent.name}' has uncommitted changes. Commit or stash before merging.`
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
} catch (err) {
|
|
322
|
+
if (err.message.includes('uncommitted changes')) throw err;
|
|
323
|
+
// Worktree may be inaccessible
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// Build agent status for conflict detection
|
|
328
|
+
const agentStatuses = activeAgents.map((a) => ({
|
|
329
|
+
name: a.name,
|
|
330
|
+
branch: a.branch,
|
|
331
|
+
status: 'active',
|
|
332
|
+
}));
|
|
333
|
+
const conflicts = detectFileConflicts(parallelReg.baseBranch, agentStatuses);
|
|
334
|
+
|
|
335
|
+
if (conflicts.length > 0 && !options.force) {
|
|
336
|
+
return { merged: [], failed: [], conflicts };
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
if (options.dryRun) {
|
|
340
|
+
return {
|
|
341
|
+
merged: activeAgents.map((a) => a.name),
|
|
342
|
+
failed: [],
|
|
343
|
+
conflicts,
|
|
344
|
+
dryRun: true,
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// Checkout base branch
|
|
349
|
+
execFileSync('git', ['checkout', parallelReg.baseBranch], {
|
|
350
|
+
cwd: root,
|
|
351
|
+
stdio: 'pipe',
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
const merged = [];
|
|
355
|
+
const failed = [];
|
|
356
|
+
|
|
357
|
+
for (const agent of activeAgents) {
|
|
358
|
+
try {
|
|
359
|
+
if (strategy === 'rebase') {
|
|
360
|
+
execFileSync('git', ['rebase', agent.branch], {
|
|
361
|
+
cwd: root,
|
|
362
|
+
stdio: 'pipe',
|
|
363
|
+
});
|
|
364
|
+
} else if (strategy === 'squash') {
|
|
365
|
+
execFileSync('git', ['merge', '--squash', agent.branch], {
|
|
366
|
+
cwd: root,
|
|
367
|
+
stdio: 'pipe',
|
|
368
|
+
});
|
|
369
|
+
execFileSync(
|
|
370
|
+
'git',
|
|
371
|
+
['commit', '-m', `feat: merge ${agent.name} (squashed)`],
|
|
372
|
+
{ cwd: root, stdio: 'pipe' }
|
|
373
|
+
);
|
|
374
|
+
} else {
|
|
375
|
+
execFileSync('git', ['merge', agent.branch, '--no-edit'], {
|
|
376
|
+
cwd: root,
|
|
377
|
+
stdio: 'pipe',
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
merged.push(agent.name);
|
|
381
|
+
} catch (err) {
|
|
382
|
+
// Abort failed merge
|
|
383
|
+
try {
|
|
384
|
+
execFileSync('git', ['merge', '--abort'], { cwd: root, stdio: 'pipe' });
|
|
385
|
+
} catch {
|
|
386
|
+
try {
|
|
387
|
+
execFileSync('git', ['rebase', '--abort'], { cwd: root, stdio: 'pipe' });
|
|
388
|
+
} catch {
|
|
389
|
+
// Already clean
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
failed.push({ name: agent.name, error: err.message });
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
return { merged, failed, conflicts };
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Tear down all parallel worktrees
|
|
401
|
+
* @param {Object} options - Teardown options
|
|
402
|
+
* @param {boolean} [options.deleteBranches] - Also delete branches
|
|
403
|
+
* @param {boolean} [options.force] - Force removal even if dirty
|
|
404
|
+
* @returns {Object} Teardown results {destroyed, failed}
|
|
405
|
+
*/
|
|
406
|
+
function teardownParallel(options = {}) {
|
|
407
|
+
const root = getRepoRoot();
|
|
408
|
+
const parallelReg = loadParallelRegistry(root);
|
|
409
|
+
if (!parallelReg) {
|
|
410
|
+
throw new Error('No active parallel run found.');
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
const { deleteBranches = false, force = false } = options;
|
|
414
|
+
const destroyed = [];
|
|
415
|
+
const failed = [];
|
|
416
|
+
|
|
417
|
+
for (const agent of parallelReg.agents) {
|
|
418
|
+
try {
|
|
419
|
+
destroyWorktree(agent.name, { deleteBranch: deleteBranches, force });
|
|
420
|
+
destroyed.push(agent.name);
|
|
421
|
+
} catch (err) {
|
|
422
|
+
failed.push({ name: agent.name, error: err.message });
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// Remove parallel registry
|
|
427
|
+
removeParallelRegistry(root);
|
|
428
|
+
|
|
429
|
+
return { destroyed, failed };
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
module.exports = {
|
|
433
|
+
loadPlan,
|
|
434
|
+
setupParallel,
|
|
435
|
+
getParallelStatus,
|
|
436
|
+
mergeParallel,
|
|
437
|
+
teardownParallel,
|
|
438
|
+
detectFileConflicts,
|
|
439
|
+
loadParallelRegistry,
|
|
440
|
+
saveParallelRegistry,
|
|
441
|
+
removeParallelRegistry,
|
|
442
|
+
PARALLEL_REGISTRY,
|
|
443
|
+
};
|
|
@@ -53,7 +53,7 @@ async function scaffoldClaudeHooks(projectDir, levels = ['safety', 'quality', 's
|
|
|
53
53
|
|
|
54
54
|
// Map levels to hook scripts
|
|
55
55
|
const hookMapping = {
|
|
56
|
-
safety: ['block-dangerous.sh', 'scan-secrets.sh'],
|
|
56
|
+
safety: ['block-dangerous.sh', 'scan-secrets.sh', 'worktree-guard.sh', 'worktree-write-guard.sh', 'stop-worktree-check.sh', 'session-caws-status.sh'],
|
|
57
57
|
quality: ['quality-check.sh', 'validate-spec.sh'],
|
|
58
58
|
scope: ['scope-guard.sh', 'naming-check.sh'],
|
|
59
59
|
audit: ['audit.sh'],
|
|
@@ -83,6 +83,10 @@ async function scaffoldClaudeHooks(projectDir, levels = ['safety', 'quality', 's
|
|
|
83
83
|
'naming-check.sh',
|
|
84
84
|
'lite-sprawl-check.sh',
|
|
85
85
|
'simplification-guard.sh',
|
|
86
|
+
'worktree-guard.sh',
|
|
87
|
+
'worktree-write-guard.sh',
|
|
88
|
+
'stop-worktree-check.sh',
|
|
89
|
+
'session-caws-status.sh',
|
|
86
90
|
];
|
|
87
91
|
|
|
88
92
|
for (const script of allHookScripts) {
|
|
@@ -131,6 +135,14 @@ async function scaffoldClaudeHooks(projectDir, levels = ['safety', 'quality', 's
|
|
|
131
135
|
await fs.copy(readmePath, path.join(claudeDir, 'README.md'));
|
|
132
136
|
}
|
|
133
137
|
|
|
138
|
+
// Copy rules directory if it exists
|
|
139
|
+
const rulesTemplateDir = path.join(claudeTemplateDir, 'rules');
|
|
140
|
+
if (fs.existsSync(rulesTemplateDir)) {
|
|
141
|
+
const rulesDir = path.join(claudeDir, 'rules');
|
|
142
|
+
await fs.ensureDir(rulesDir);
|
|
143
|
+
await fs.copy(rulesTemplateDir, rulesDir, { overwrite: false });
|
|
144
|
+
}
|
|
145
|
+
|
|
134
146
|
console.log(chalk.green('Claude Code hooks configured'));
|
|
135
147
|
console.log(chalk.gray(` Enabled: ${levels.join(', ')}`));
|
|
136
148
|
console.log(
|
|
@@ -168,6 +180,11 @@ function generateClaudeSettings(levels, _enabledHooks) {
|
|
|
168
180
|
command: '"$CLAUDE_PROJECT_DIR"/.claude/hooks/block-dangerous.sh',
|
|
169
181
|
timeout: 10,
|
|
170
182
|
},
|
|
183
|
+
{
|
|
184
|
+
type: 'command',
|
|
185
|
+
command: '"$CLAUDE_PROJECT_DIR"/.claude/hooks/worktree-guard.sh',
|
|
186
|
+
timeout: 10,
|
|
187
|
+
},
|
|
171
188
|
],
|
|
172
189
|
});
|
|
173
190
|
|
|
@@ -182,6 +199,42 @@ function generateClaudeSettings(levels, _enabledHooks) {
|
|
|
182
199
|
},
|
|
183
200
|
],
|
|
184
201
|
});
|
|
202
|
+
|
|
203
|
+
// Block Write/Edit on base branch while worktrees are active
|
|
204
|
+
settings.hooks.PreToolUse.push({
|
|
205
|
+
matcher: 'Write|Edit',
|
|
206
|
+
hooks: [
|
|
207
|
+
{
|
|
208
|
+
type: 'command',
|
|
209
|
+
command: '"$CLAUDE_PROJECT_DIR"/.claude/hooks/worktree-write-guard.sh',
|
|
210
|
+
timeout: 10,
|
|
211
|
+
},
|
|
212
|
+
],
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
// Worktree status warning on session start
|
|
216
|
+
settings.hooks.SessionStart = settings.hooks.SessionStart || [];
|
|
217
|
+
settings.hooks.SessionStart.push({
|
|
218
|
+
hooks: [
|
|
219
|
+
{
|
|
220
|
+
type: 'command',
|
|
221
|
+
command: '"$CLAUDE_PROJECT_DIR"/.claude/hooks/session-caws-status.sh session-start',
|
|
222
|
+
timeout: 10,
|
|
223
|
+
},
|
|
224
|
+
],
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
// Worktree cleanup reminder on session end
|
|
228
|
+
settings.hooks.Stop = settings.hooks.Stop || [];
|
|
229
|
+
settings.hooks.Stop.push({
|
|
230
|
+
hooks: [
|
|
231
|
+
{
|
|
232
|
+
type: 'command',
|
|
233
|
+
command: '"$CLAUDE_PROJECT_DIR"/.claude/hooks/stop-worktree-check.sh',
|
|
234
|
+
timeout: 10,
|
|
235
|
+
},
|
|
236
|
+
],
|
|
237
|
+
});
|
|
185
238
|
}
|
|
186
239
|
|
|
187
240
|
if (levels.includes('quality')) {
|