@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.
@@ -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')) {