@paths.design/caws-cli 9.3.0 → 9.3.2

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.
@@ -39,7 +39,7 @@ function detectTestRunner(projectRoot) {
39
39
  if (fs.existsSync(fp)) {
40
40
  try {
41
41
  if (fs.readFileSync(fp, 'utf8').includes(needle)) return check.runner;
42
- } catch (_) {}
42
+ } catch (_) { /* ignore unreadable config */ }
43
43
  }
44
44
  }
45
45
  }
@@ -123,7 +123,7 @@ function runNodeid(nodeid, runner, projectRoot) {
123
123
  try {
124
124
  switch (runner) {
125
125
  case 'pytest': {
126
- const out = execFileSync('python3', ['-m', 'pytest', '-x', '--tb=short', nodeid], {
126
+ execFileSync('python3', ['-m', 'pytest', '-x', '--tb=short', nodeid], {
127
127
  cwd: projectRoot, encoding: 'utf8', timeout: 120000, stdio: ['pipe', 'pipe', 'pipe'],
128
128
  });
129
129
  return { passed: true, detail: 'tests passed' };
@@ -215,7 +215,7 @@ function checkEvidence(evidence, projectRoot) {
215
215
  if (files) {
216
216
  return { found: true, detail: `found: ${files.split('\n')[0]}` };
217
217
  }
218
- } catch (_) {}
218
+ } catch (_) { /* ignore unreadable config */ }
219
219
  }
220
220
  }
221
221
 
@@ -350,7 +350,7 @@ function loadSpecs(projectRoot, targetSpecId) {
350
350
  if (s && !TERMINAL_STATUSES.has(s.status)) {
351
351
  specs.push({ path: workingSpec, spec: s });
352
352
  }
353
- } catch (_) {}
353
+ } catch (_) { /* ignore unreadable config */ }
354
354
  }
355
355
 
356
356
  if (fs.existsSync(specsDir)) {
@@ -360,7 +360,7 @@ function loadSpecs(projectRoot, targetSpecId) {
360
360
  if (s && !TERMINAL_STATUSES.has(s.status)) {
361
361
  specs.push({ path: path.join(specsDir, f), spec: s });
362
362
  }
363
- } catch (_) {}
363
+ } catch (_) { /* ignore unreadable config */ }
364
364
  }
365
365
  }
366
366
 
@@ -11,6 +11,7 @@ const {
11
11
  destroyWorktree,
12
12
  mergeWorktree,
13
13
  pruneWorktrees,
14
+ repairWorktrees,
14
15
  } = require('../worktree/worktree-manager');
15
16
 
16
17
  /**
@@ -31,9 +32,11 @@ async function worktreeCommand(subcommand, options = {}) {
31
32
  return handleMerge(options);
32
33
  case 'prune':
33
34
  return handlePrune(options);
35
+ case 'repair':
36
+ return handleRepair(options);
34
37
  default:
35
38
  console.error(chalk.red(`Unknown worktree subcommand: ${subcommand}`));
36
- console.log(chalk.blue('Available: create, list, destroy, merge, prune'));
39
+ console.log(chalk.blue('Available: create, list, destroy, merge, prune, repair'));
37
40
  process.exit(1);
38
41
  }
39
42
  } catch (error) {
@@ -72,18 +75,20 @@ function handleList() {
72
75
  return;
73
76
  }
74
77
 
78
+ const maxNameLen = Math.max(18, ...entries.map((e) => e.name.length + 2));
79
+ const totalWidth = maxNameLen + 12 + 20 + 16 + 10;
75
80
  console.log(chalk.bold.cyan('CAWS Worktrees'));
76
- console.log(chalk.cyan('='.repeat(85)));
81
+ console.log(chalk.cyan('='.repeat(totalWidth)));
77
82
  console.log(
78
83
  chalk.bold(
79
- 'Name'.padEnd(18) +
84
+ 'Name'.padEnd(maxNameLen) +
80
85
  'Status'.padEnd(12) +
81
86
  'Branch'.padEnd(20) +
82
87
  'Last Commit'.padEnd(16) +
83
88
  'Owner'
84
89
  )
85
90
  );
86
- console.log(chalk.gray('-'.repeat(85)));
91
+ console.log(chalk.gray('-'.repeat(totalWidth)));
87
92
 
88
93
  for (const entry of entries) {
89
94
  const statusColor =
@@ -114,7 +119,7 @@ function handleList() {
114
119
  }
115
120
 
116
121
  console.log(
117
- entry.name.padEnd(18) +
122
+ entry.name.padEnd(maxNameLen) +
118
123
  statusColor(statusStr.padEnd(12)) +
119
124
  (entry.branch || '').padEnd(20) +
120
125
  commitAge.padEnd(16 + 10) + // +10 for chalk color codes
@@ -225,4 +230,52 @@ function handlePrune(options) {
225
230
  }
226
231
  }
227
232
 
233
+
234
+ function handleRepair(options) {
235
+ const dryRun = options.dryRun || false;
236
+ const shouldPrune = options.prune || false;
237
+
238
+ if (dryRun) {
239
+ console.log(chalk.cyan('Repair dry-run (no changes will be persisted)'));
240
+ } else {
241
+ console.log(chalk.cyan('Repairing worktree registry'));
242
+ }
243
+
244
+ const result = repairWorktrees({ prune: shouldPrune, dryRun });
245
+
246
+ if (result.repaired.length === 0 && result.pruned.length === 0 && result.skipped.length === 0) {
247
+ console.log(chalk.green('Registry is consistent. Nothing to repair.'));
248
+ return;
249
+ }
250
+
251
+ if (result.repaired.length > 0) {
252
+ console.log(chalk.green('\nRepaired ' + result.repaired.length + ' entry/entries:'));
253
+ for (const r of result.repaired) {
254
+ if (r.action === 'registered') {
255
+ console.log(chalk.gray(' + ' + r.name + ' (auto-registered from git)'));
256
+ } else {
257
+ console.log(chalk.gray(' ~ ' + r.name + ' (' + r.from + ' -> ' + r.to + ')'));
258
+ }
259
+ }
260
+ }
261
+
262
+ if (result.pruned.length > 0) {
263
+ console.log(chalk.green('\nPruned ' + result.pruned.length + ' stale entry/entries:'));
264
+ for (const p of result.pruned) {
265
+ console.log(chalk.gray(' - ' + p.name + ' (' + p.status + ')'));
266
+ }
267
+ }
268
+
269
+ if (result.skipped.length > 0) {
270
+ console.log(chalk.yellow('\nSkipped ' + result.skipped.length + ' entry/entries:'));
271
+ for (const s of result.skipped) {
272
+ console.log(chalk.yellow(' ? ' + s.name + ': ' + s.reason));
273
+ }
274
+ }
275
+
276
+ if (dryRun) {
277
+ console.log(chalk.blue('\nDry-run complete. Run without --dry-run to persist changes.'));
278
+ }
279
+ }
280
+
228
281
  module.exports = { worktreeCommand };
package/dist/index.js CHANGED
@@ -395,6 +395,13 @@ worktreeCmd
395
395
  .option('--max-age <days>', 'Remove entries older than N days', '30')
396
396
  .action((options) => worktreeCommand('prune', options));
397
397
 
398
+ worktreeCmd
399
+ .command('repair')
400
+ .description('Reconcile registry with git and filesystem state')
401
+ .option('--dry-run', 'Report only, do not persist changes', false)
402
+ .option('--prune', 'Remove destroyed and stale-merged entries', false)
403
+ .action((options) => worktreeCommand('repair', options));
404
+
398
405
  // Session command group
399
406
  const sessionCmd = program
400
407
  .command('session')
@@ -11,6 +11,7 @@ INPUT=$(cat)
11
11
  # Extract tool info
12
12
  TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // ""')
13
13
  COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // ""')
14
+ HOOK_CWD=$(echo "$INPUT" | jq -r '.cwd // ""')
14
15
 
15
16
  # Only check Bash tool
16
17
  if [[ "$TOOL_NAME" != "Bash" ]] || [[ -z "$COMMAND" ]]; then
@@ -46,26 +47,38 @@ if echo "$COMMAND" | grep -qE 'caws\s+(worktree\s+create|parallel\s+setup).*--sc
46
47
  fi
47
48
 
48
49
  # --- Gap 5: Block cross-boundary file copies ---
50
+ # Only block copies FROM a worktree back to the main repo (defeats isolation).
51
+ # Copies INTO a worktree are fine — the agent is working there and the files
52
+ # live on the worktree branch, disappearing on merge.
49
53
  WORKTREE_BASE="$PROJECT_DIR/.caws/worktrees"
50
54
  if [[ -d "$WORKTREE_BASE" ]]; then
51
55
  if echo "$COMMAND" | grep -qE '\b(cp|mv)\b'; then
52
- if echo "$COMMAND" | grep -qF ".caws/worktrees/" || echo "$COMMAND" | grep -qF "$WORKTREE_BASE"; then
53
- # Check if the command references both a worktree path and the main repo
54
- HAS_WT_PATH=false
55
- HAS_MAIN_PATH=false
56
- if echo "$COMMAND" | grep -qE '\.caws/worktrees/|'"$(echo "$WORKTREE_BASE" | sed 's/[\/&]/\\&/g')"''; then
57
- HAS_WT_PATH=true
58
- fi
59
- # Check if destination/source is outside the worktree
60
- if echo "$COMMAND" | grep -qE "(^|\s)$PROJECT_DIR/[^.]|core/|src/|tests/|packages/" && [[ "$HAS_WT_PATH" == "true" ]]; then
61
- HAS_MAIN_PATH=true
62
- fi
63
- if [[ "$HAS_WT_PATH" == "true" ]] && [[ "$HAS_MAIN_PATH" == "true" ]]; then
64
- echo "BLOCKED: Copying files between a worktree and the main repo is forbidden." >&2
65
- echo "This bypasses worktree isolation. Work entirely within your worktree." >&2
66
- echo "If tests need the main repo's venv, activate it with:" >&2
67
- echo " source $PROJECT_DIR/.venv/bin/activate" >&2
68
- exit 2
56
+ # If the agent is working inside a worktree, allow all copies they're
57
+ # in their own workspace
58
+ AGENT_IN_WORKTREE=false
59
+ if [[ -n "$HOOK_CWD" ]] && [[ "$HOOK_CWD" == "$WORKTREE_BASE"/* ]]; then
60
+ AGENT_IN_WORKTREE=true
61
+ fi
62
+
63
+ if [[ "$AGENT_IN_WORKTREE" != "true" ]]; then
64
+ if echo "$COMMAND" | grep -qF ".caws/worktrees/" || echo "$COMMAND" | grep -qF "$WORKTREE_BASE"; then
65
+ # Check if the command references both a worktree path and the main repo
66
+ HAS_WT_PATH=false
67
+ HAS_MAIN_PATH=false
68
+ if echo "$COMMAND" | grep -qE '\.caws/worktrees/|'"$(echo "$WORKTREE_BASE" | sed 's/[\/&]/\\&/g')"''; then
69
+ HAS_WT_PATH=true
70
+ fi
71
+ # Check if destination/source is outside the worktree
72
+ if echo "$COMMAND" | grep -qE "(^|\s)$PROJECT_DIR/[^.]|core/|src/|tests/|packages/" && [[ "$HAS_WT_PATH" == "true" ]]; then
73
+ HAS_MAIN_PATH=true
74
+ fi
75
+ if [[ "$HAS_WT_PATH" == "true" ]] && [[ "$HAS_MAIN_PATH" == "true" ]]; then
76
+ echo "BLOCKED: Copying files from a worktree to the main repo is forbidden." >&2
77
+ echo "This bypasses worktree isolation. Work entirely within your worktree." >&2
78
+ echo "If tests need the main repo's venv, activate it with:" >&2
79
+ echo " source $PROJECT_DIR/.venv/bin/activate" >&2
80
+ exit 2
81
+ fi
69
82
  fi
70
83
  fi
71
84
  fi
@@ -151,10 +164,10 @@ if echo "$COMMAND" | grep -qE 'git\s+push\s+.*(--force|-f\s)'; then
151
164
  fi
152
165
 
153
166
  # --- Base branch protections ---
154
- # Use the agent's actual working directory (CLAUDE_PROJECT_DIR), not the resolved
155
- # main repo root (PROJECT_DIR). In a worktree, PROJECT_DIR points to the main repo
156
- # (to find .caws/worktrees.json), but the agent's branch is in CLAUDE_PROJECT_DIR.
157
- AGENT_DIR="${CLAUDE_PROJECT_DIR:-.}"
167
+ # Use the hook input's cwd (where the git command will actually execute), not
168
+ # CLAUDE_PROJECT_DIR (which always points to the main repo root, even when the
169
+ # agent has cd'd into a worktree at .caws/worktrees/<name>/).
170
+ AGENT_DIR="${HOOK_CWD:-${CLAUDE_PROJECT_DIR:-.}}"
158
171
  CURRENT_BRANCH=$(cd "$AGENT_DIR" && git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "unknown")
159
172
 
160
173
  # Determine the base branch to protect
@@ -13,6 +13,7 @@ INPUT=$(cat)
13
13
  # Extract tool info
14
14
  TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // ""')
15
15
  FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // ""')
16
+ HOOK_CWD=$(echo "$INPUT" | jq -r '.cwd // ""')
16
17
 
17
18
  # Only check Write and Edit tools
18
19
  case "$TOOL_NAME" in
@@ -42,10 +43,10 @@ if ! command -v node >/dev/null 2>&1; then
42
43
  exit 0
43
44
  fi
44
45
 
45
- # Use the agent's actual working directory, not the resolved main repo root.
46
- # In a worktree, PROJECT_DIR points to the main repo (to find .caws/worktrees.json),
47
- # but the agent's branch is in CLAUDE_PROJECT_DIR.
48
- AGENT_DIR="${CLAUDE_PROJECT_DIR:-.}"
46
+ # Use the hook input's cwd (where the agent is actually working), not
47
+ # CLAUDE_PROJECT_DIR (which always points to the main repo root, even when the
48
+ # agent has cd'd into a worktree at .caws/worktrees/<name>/).
49
+ AGENT_DIR="${HOOK_CWD:-${CLAUDE_PROJECT_DIR:-.}}"
49
50
  CURRENT_BRANCH=$(cd "$AGENT_DIR" && git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "unknown")
50
51
 
51
52
  WT_INFO=$(node -e "
@@ -203,17 +203,8 @@ function detectCAWSSetup(cwd = process.cwd()) {
203
203
  * @returns {string} Project root directory path
204
204
  */
205
205
  function findProjectRoot(startDir = process.cwd()) {
206
- // Walk up looking for .caws/ directory
207
- let dir = path.resolve(startDir);
208
- const root = path.parse(dir).root;
209
- while (dir !== root) {
210
- if (fs.existsSync(path.join(dir, '.caws'))) {
211
- return dir;
212
- }
213
- dir = path.dirname(dir);
214
- }
215
-
216
- // Fallback: try git root
206
+ // In a monorepo, nested packages may have their own .caws/ (scaffold debris).
207
+ // The git root's .caws/ is authoritative — check it first.
217
208
  try {
218
209
  const { execFileSync } = require('child_process');
219
210
  const gitRoot = execFileSync('git', ['rev-parse', '--show-toplevel'], {
@@ -225,7 +216,17 @@ function findProjectRoot(startDir = process.cwd()) {
225
216
  return gitRoot;
226
217
  }
227
218
  } catch {
228
- // Not a git repo or git not available
219
+ // Not a git repo or git not available — fall through
220
+ }
221
+
222
+ // Walk up looking for .caws/ directory (non-git projects)
223
+ let dir = path.resolve(startDir);
224
+ const root = path.parse(dir).root;
225
+ while (dir !== root) {
226
+ if (fs.existsSync(path.join(dir, '.caws'))) {
227
+ return dir;
228
+ }
229
+ dir = path.dirname(dir);
229
230
  }
230
231
 
231
232
  // Final fallback: cwd
@@ -54,13 +54,25 @@ function isBranchMerged(branch, target, root) {
54
54
  }
55
55
 
56
56
  /**
57
- * Get the git repository root
58
- * @returns {string} Absolute path to repo root
57
+ * Get the canonical git repository root (main worktree, not a linked worktree).
58
+ *
59
+ * `git rev-parse --show-toplevel` returns the root of whichever worktree
60
+ * the CWD is inside. In a linked worktree that is NOT the main repo root,
61
+ * so CAWS would read the wrong (or missing) .caws/worktrees.json.
62
+ *
63
+ * `--git-common-dir` always resolves to the main repo's .git directory,
64
+ * even from inside a linked worktree. Its parent is the canonical repo root.
65
+ *
66
+ * @returns {string} Absolute path to the main repo root
59
67
  */
60
68
  function getRepoRoot() {
61
- return execFileSync('git', ['rev-parse', '--show-toplevel'], {
62
- encoding: 'utf8',
63
- }).trim();
69
+ const gitCommonDir = execFileSync(
70
+ 'git',
71
+ ['rev-parse', '--path-format=absolute', '--git-common-dir'],
72
+ { encoding: 'utf8' }
73
+ ).trim();
74
+ // gitCommonDir is /path/to/main-repo/.git — parent is the repo root
75
+ return path.dirname(gitCommonDir);
64
76
  }
65
77
 
66
78
  /**
@@ -101,6 +113,106 @@ function saveRegistry(root, registry) {
101
113
  fs.writeFileSync(registryPath, JSON.stringify(registry, null, 2));
102
114
  }
103
115
 
116
+ /**
117
+ * Discover git worktrees under .caws/worktrees/ that are not in the registry.
118
+ * @param {string} root - Repository root
119
+ * @param {Object} registry - Current registry object
120
+ * @returns {Array<{ name: string, path: string, branch: string }>}
121
+ */
122
+ function discoverUnregisteredWorktrees(root, registry) {
123
+ const unregistered = [];
124
+ try {
125
+ const output = execFileSync('git', ['worktree', 'list', '--porcelain'], {
126
+ cwd: root,
127
+ encoding: 'utf8',
128
+ stdio: 'pipe',
129
+ });
130
+ let worktreesDir;
131
+ try {
132
+ worktreesDir = fs.realpathSync(path.resolve(root, WORKTREES_DIR));
133
+ } catch {
134
+ // Directory might not exist yet
135
+ worktreesDir = path.resolve(root, WORKTREES_DIR);
136
+ }
137
+
138
+ const blocks = output.split('\n\n').filter(Boolean);
139
+ for (const block of blocks) {
140
+ const lines = block.split('\n');
141
+ const wtLine = lines.find((l) => l.startsWith('worktree '));
142
+ const branchLine = lines.find((l) => l.startsWith('branch '));
143
+ if (!wtLine) continue;
144
+
145
+ const wtPath = wtLine.replace('worktree ', '');
146
+ let resolvedPath;
147
+ try {
148
+ resolvedPath = fs.realpathSync(wtPath);
149
+ } catch {
150
+ resolvedPath = path.resolve(wtPath);
151
+ }
152
+
153
+ // Only consider worktrees under .caws/worktrees/
154
+ if (!resolvedPath.startsWith(worktreesDir + path.sep)) continue;
155
+
156
+ const name = path.basename(resolvedPath);
157
+ if (registry.worktrees[name]) continue;
158
+
159
+ const branch = branchLine
160
+ ? branchLine.replace('branch refs/heads/', '')
161
+ : `${BRANCH_PREFIX}${name}`;
162
+ unregistered.push({ name, path: resolvedPath, branch });
163
+ }
164
+ } catch {
165
+ // git worktree list failed
166
+ }
167
+ return unregistered;
168
+ }
169
+
170
+ /**
171
+ * Auto-register an unregistered worktree. Infers baseBranch via merge-base.
172
+ * @param {string} root - Repository root
173
+ * @param {Object} registry - Registry object (mutated in place)
174
+ * @param {{ name: string, path: string, branch: string }} discovered
175
+ * @returns {Object} The registered entry
176
+ */
177
+ function autoRegisterWorktree(root, registry, discovered) {
178
+ let baseBranch = 'main';
179
+ try {
180
+ execFileSync(
181
+ 'git',
182
+ ['merge-base', discovered.branch, 'main'],
183
+ { cwd: root, encoding: 'utf8', stdio: 'pipe' }
184
+ );
185
+ } catch {
186
+ try {
187
+ execFileSync(
188
+ 'git',
189
+ ['merge-base', discovered.branch, 'master'],
190
+ { cwd: root, encoding: 'utf8', stdio: 'pipe' }
191
+ );
192
+ baseBranch = 'master';
193
+ } catch {
194
+ // Keep 'main' as default
195
+ }
196
+ }
197
+
198
+ const entry = {
199
+ name: discovered.name,
200
+ path: discovered.path,
201
+ branch: discovered.branch,
202
+ baseBranch,
203
+ scope: null,
204
+ specId: null,
205
+ owner: null,
206
+ createdAt: new Date().toISOString(),
207
+ status: 'active',
208
+ autoRegistered: true,
209
+ };
210
+
211
+ registry.worktrees[discovered.name] = entry;
212
+ saveRegistry(root, registry);
213
+ return entry;
214
+ }
215
+
104
216
  /**
105
217
  * Create a new git worktree with scope isolation
106
218
  * @param {string} name - Worktree name
@@ -258,19 +370,31 @@ function createWorktree(name, options = {}) {
258
370
  }
259
371
 
260
372
  /**
261
- * List all registered worktrees with filesystem validation
262
- * @returns {Array} Worktree entries with status
373
+ * Reconcile registry state against git worktree list and filesystem.
374
+ *
375
+ * Non-destructive read that classifies every known worktree entry
376
+ * (from registry + git discovery) into one of:
377
+ * active — directory exists AND in git worktree list
378
+ * orphaned — directory exists but NOT in git worktree list
379
+ * missing — directory gone, branch may or may not exist
380
+ * destroyed — explicitly destroyed via CAWS
381
+ * unregistered — in git worktree list but not in registry
382
+ * stale-merged — missing + branch already merged to base
383
+ *
384
+ * Does NOT mutate the registry. Callers decide what to persist.
385
+ *
386
+ * @param {string} root - Repository root
387
+ * @returns {{ entries: Array, gitWorktrees: string[] }}
263
388
  */
264
- function listWorktrees() {
265
- const root = getRepoRoot();
389
+ function reconcileRegistry(root) {
266
390
  const registry = loadRegistry(root);
267
391
 
268
- // Get actual git worktrees for validation
269
392
  let gitWorktrees = [];
270
393
  try {
271
394
  const output = execFileSync('git', ['worktree', 'list', '--porcelain'], {
272
395
  cwd: root,
273
396
  encoding: 'utf8',
397
+ stdio: 'pipe',
274
398
  });
275
399
  gitWorktrees = output
276
400
  .split('\n\n')
@@ -290,24 +414,121 @@ function listWorktrees() {
290
414
  const inGit = gitWorktrees.some(
291
415
  (wt) => path.resolve(wt) === path.resolve(entry.path)
292
416
  );
293
- const status = exists && inGit ? 'active' : exists ? 'orphaned' : 'missing';
294
417
 
295
- // Enrich with commit recency
296
- const lastCommit = entry.branch ? getLastCommitInfo(entry.branch, root) : null;
418
+ let status;
419
+ if (entry.status === 'destroyed') {
420
+ status = 'destroyed';
421
+ } else if (exists && inGit) {
422
+ status = 'active';
423
+ } else if (exists) {
424
+ status = 'orphaned';
425
+ } else {
426
+ const merged = entry.branch && entry.baseBranch
427
+ ? isBranchMerged(entry.branch, entry.baseBranch, root)
428
+ : false;
429
+ status = merged ? 'stale-merged' : 'missing';
430
+ }
297
431
 
298
- // Check if branch is already merged to base
432
+ const lastCommit = entry.branch ? getLastCommitInfo(entry.branch, root) : null;
299
433
  const merged = entry.branch && entry.baseBranch
300
434
  ? isBranchMerged(entry.branch, entry.baseBranch, root)
301
435
  : false;
302
436
 
303
- return {
304
- ...entry,
305
- status,
306
- lastCommit,
307
- merged,
308
- };
437
+ return { ...entry, status, lastCommit, merged };
309
438
  });
310
439
 
440
+ // Append unregistered worktrees discovered from git
441
+ const unregistered = discoverUnregisteredWorktrees(root, registry);
442
+ for (const discovered of unregistered) {
443
+ const lastCommit = getLastCommitInfo(discovered.branch, root);
444
+ entries.push({
445
+ name: discovered.name,
446
+ path: discovered.path,
447
+ branch: discovered.branch,
448
+ baseBranch: null,
449
+ scope: null,
450
+ specId: null,
451
+ owner: null,
452
+ createdAt: null,
453
+ status: 'unregistered',
454
+ lastCommit,
455
+ merged: false,
456
+ });
457
+ }
458
+
459
+ return { entries, gitWorktrees };
460
+ }
461
+
462
+ /**
463
+ * Repair registry drift caused by manual git operations outside CAWS.
464
+ *
465
+ * Scans registry vs git vs filesystem, classifies each entry, and optionally
466
+ * prunes stale entries. Reports the delta before persisting.
467
+ *
468
+ * @param {Object} options
469
+ * @param {boolean} [options.prune=false] - Remove destroyed and stale-merged entries
470
+ * @param {boolean} [options.dryRun=false] - Report only, do not persist
471
+ * @returns {{ repaired: Array, pruned: Array, skipped: Array }}
472
+ */
473
+ function repairWorktrees(options = {}) {
474
+ const { prune: shouldPrune = false, dryRun = false } = options;
475
+ const root = getRepoRoot();
476
+ const registry = loadRegistry(root);
477
+ const { entries } = reconcileRegistry(root);
478
+
479
+ const repaired = [];
480
+ const pruned = [];
481
+ const skipped = [];
482
+
483
+ for (const entry of entries) {
484
+ const regEntry = registry.worktrees[entry.name];
485
+
486
+ if (entry.status === 'unregistered') {
487
+ if (!dryRun) {
488
+ autoRegisterWorktree(root, registry, entry);
489
+ }
490
+ repaired.push({ name: entry.name, action: 'registered', status: entry.status });
491
+ continue;
492
+ }
493
+
494
+ if (!regEntry) continue;
495
+
496
+ // Update registry status to match filesystem reality
497
+ if (regEntry.status === 'active' && (entry.status === 'missing' || entry.status === 'stale-merged')) {
498
+ repaired.push({ name: entry.name, action: 'status-updated', from: 'active', to: entry.status });
499
+ }
500
+
501
+ // Prune if requested and entry is dead
502
+ if (shouldPrune && (entry.status === 'destroyed' || entry.status === 'stale-merged')) {
503
+ if (!dryRun) {
504
+ delete registry.worktrees[entry.name];
505
+ }
506
+ pruned.push({ name: entry.name, status: entry.status });
507
+ } else if (!shouldPrune && (entry.status === 'destroyed' || entry.status === 'stale-merged')) {
508
+ skipped.push({ name: entry.name, reason: entry.status + ' (use --prune to remove)' });
509
+ }
510
+ }
511
+
512
+ if (!dryRun) {
513
+ saveRegistry(root, registry);
514
+ try {
515
+ execFileSync('git', ['worktree', 'prune'], { cwd: root, stdio: 'pipe' });
516
+ } catch {
517
+ // Non-fatal
518
+ }
519
+ }
520
+
521
+ return { repaired, pruned, skipped };
522
+ }
523
+
524
+ /**
525
+ * List all registered worktrees with filesystem validation.
526
+ * Delegates to reconcileRegistry() for state classification.
527
+ * @returns {Array} Worktree entries with status
528
+ */
529
+ function listWorktrees() {
530
+ const root = getRepoRoot();
531
+ const { entries } = reconcileRegistry(root);
311
532
  return entries;
312
533
  }
313
534
 
@@ -323,9 +544,17 @@ function destroyWorktree(name, options = {}) {
323
544
  const registry = loadRegistry(root);
324
545
  const { deleteBranch = false, force = false } = options;
325
546
 
326
- const entry = registry.worktrees[name];
547
+ let entry = registry.worktrees[name];
327
548
  if (!entry) {
328
- throw new Error(`Worktree '${name}' not found in registry`);
549
+ // Fallback: scan git for unregistered worktree and auto-register
550
+ const unregistered = discoverUnregisteredWorktrees(root, registry);
551
+ const discovered = unregistered.find((u) => u.name === name);
552
+ if (discovered) {
553
+ console.log(chalk.yellow(`Worktree '${name}' not in registry but found in git. Auto-registering.`));
554
+ entry = autoRegisterWorktree(root, registry, discovered);
555
+ } else {
556
+ throw new Error(`Worktree '${name}' not found in registry or git worktree list`);
557
+ }
329
558
  }
330
559
 
331
560
  // Ownership check: refuse to destroy another agent's active worktree without --force
@@ -438,9 +667,17 @@ function mergeWorktree(name, options = {}) {
438
667
  const registry = loadRegistry(root);
439
668
  const { dryRun = false, deleteBranch = true, message } = options;
440
669
 
441
- const entry = registry.worktrees[name];
670
+ let entry = registry.worktrees[name];
442
671
  if (!entry) {
443
- throw new Error(`Worktree '${name}' not found in registry`);
672
+ // Fallback: scan git for unregistered worktree and auto-register
673
+ const unregistered = discoverUnregisteredWorktrees(root, registry);
674
+ const discovered = unregistered.find((u) => u.name === name);
675
+ if (discovered) {
676
+ console.log(chalk.yellow(`Worktree '${name}' not in registry but found in git. Auto-registering.`));
677
+ entry = autoRegisterWorktree(root, registry, discovered);
678
+ } else {
679
+ throw new Error(`Worktree '${name}' not found in registry or git worktree list`);
680
+ }
444
681
  }
445
682
 
446
683
  const baseBranch = entry.baseBranch || 'main';
@@ -469,7 +706,7 @@ function mergeWorktree(name, options = {}) {
469
706
  let conflicts = [];
470
707
  try {
471
708
  // New-style merge-tree: takes two branches, computes merge-base automatically
472
- const mergeTreeResult = execFileSync(
709
+ execFileSync(
473
710
  'git',
474
711
  ['merge-tree', '--write-tree', baseBranch, entry.branch],
475
712
  { cwd: root, encoding: 'utf8', stdio: 'pipe' }
@@ -620,10 +857,14 @@ module.exports = {
620
857
  destroyWorktree,
621
858
  mergeWorktree,
622
859
  pruneWorktrees,
860
+ repairWorktrees,
861
+ reconcileRegistry,
623
862
  loadRegistry,
624
863
  getRepoRoot,
625
864
  getLastCommitInfo,
626
865
  isBranchMerged,
866
+ discoverUnregisteredWorktrees,
867
+ autoRegisterWorktree,
627
868
  WORKTREES_DIR,
628
869
  REGISTRY_FILE,
629
870
  BRANCH_PREFIX,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@paths.design/caws-cli",
3
- "version": "9.3.0",
3
+ "version": "9.3.2",
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": {
@@ -11,6 +11,7 @@ INPUT=$(cat)
11
11
  # Extract tool info
12
12
  TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // ""')
13
13
  COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command // ""')
14
+ HOOK_CWD=$(echo "$INPUT" | jq -r '.cwd // ""')
14
15
 
15
16
  # Only check Bash tool
16
17
  if [[ "$TOOL_NAME" != "Bash" ]] || [[ -z "$COMMAND" ]]; then
@@ -46,26 +47,38 @@ if echo "$COMMAND" | grep -qE 'caws\s+(worktree\s+create|parallel\s+setup).*--sc
46
47
  fi
47
48
 
48
49
  # --- Gap 5: Block cross-boundary file copies ---
50
+ # Only block copies FROM a worktree back to the main repo (defeats isolation).
51
+ # Copies INTO a worktree are fine — the agent is working there and the files
52
+ # live on the worktree branch, disappearing on merge.
49
53
  WORKTREE_BASE="$PROJECT_DIR/.caws/worktrees"
50
54
  if [[ -d "$WORKTREE_BASE" ]]; then
51
55
  if echo "$COMMAND" | grep -qE '\b(cp|mv)\b'; then
52
- if echo "$COMMAND" | grep -qF ".caws/worktrees/" || echo "$COMMAND" | grep -qF "$WORKTREE_BASE"; then
53
- # Check if the command references both a worktree path and the main repo
54
- HAS_WT_PATH=false
55
- HAS_MAIN_PATH=false
56
- if echo "$COMMAND" | grep -qE '\.caws/worktrees/|'"$(echo "$WORKTREE_BASE" | sed 's/[\/&]/\\&/g')"''; then
57
- HAS_WT_PATH=true
58
- fi
59
- # Check if destination/source is outside the worktree
60
- if echo "$COMMAND" | grep -qE "(^|\s)$PROJECT_DIR/[^.]|core/|src/|tests/|packages/" && [[ "$HAS_WT_PATH" == "true" ]]; then
61
- HAS_MAIN_PATH=true
62
- fi
63
- if [[ "$HAS_WT_PATH" == "true" ]] && [[ "$HAS_MAIN_PATH" == "true" ]]; then
64
- echo "BLOCKED: Copying files between a worktree and the main repo is forbidden." >&2
65
- echo "This bypasses worktree isolation. Work entirely within your worktree." >&2
66
- echo "If tests need the main repo's venv, activate it with:" >&2
67
- echo " source $PROJECT_DIR/.venv/bin/activate" >&2
68
- exit 2
56
+ # If the agent is working inside a worktree, allow all copies they're
57
+ # in their own workspace
58
+ AGENT_IN_WORKTREE=false
59
+ if [[ -n "$HOOK_CWD" ]] && [[ "$HOOK_CWD" == "$WORKTREE_BASE"/* ]]; then
60
+ AGENT_IN_WORKTREE=true
61
+ fi
62
+
63
+ if [[ "$AGENT_IN_WORKTREE" != "true" ]]; then
64
+ if echo "$COMMAND" | grep -qF ".caws/worktrees/" || echo "$COMMAND" | grep -qF "$WORKTREE_BASE"; then
65
+ # Check if the command references both a worktree path and the main repo
66
+ HAS_WT_PATH=false
67
+ HAS_MAIN_PATH=false
68
+ if echo "$COMMAND" | grep -qE '\.caws/worktrees/|'"$(echo "$WORKTREE_BASE" | sed 's/[\/&]/\\&/g')"''; then
69
+ HAS_WT_PATH=true
70
+ fi
71
+ # Check if destination/source is outside the worktree
72
+ if echo "$COMMAND" | grep -qE "(^|\s)$PROJECT_DIR/[^.]|core/|src/|tests/|packages/" && [[ "$HAS_WT_PATH" == "true" ]]; then
73
+ HAS_MAIN_PATH=true
74
+ fi
75
+ if [[ "$HAS_WT_PATH" == "true" ]] && [[ "$HAS_MAIN_PATH" == "true" ]]; then
76
+ echo "BLOCKED: Copying files from a worktree to the main repo is forbidden." >&2
77
+ echo "This bypasses worktree isolation. Work entirely within your worktree." >&2
78
+ echo "If tests need the main repo's venv, activate it with:" >&2
79
+ echo " source $PROJECT_DIR/.venv/bin/activate" >&2
80
+ exit 2
81
+ fi
69
82
  fi
70
83
  fi
71
84
  fi
@@ -151,10 +164,10 @@ if echo "$COMMAND" | grep -qE 'git\s+push\s+.*(--force|-f\s)'; then
151
164
  fi
152
165
 
153
166
  # --- Base branch protections ---
154
- # Use the agent's actual working directory (CLAUDE_PROJECT_DIR), not the resolved
155
- # main repo root (PROJECT_DIR). In a worktree, PROJECT_DIR points to the main repo
156
- # (to find .caws/worktrees.json), but the agent's branch is in CLAUDE_PROJECT_DIR.
157
- AGENT_DIR="${CLAUDE_PROJECT_DIR:-.}"
167
+ # Use the hook input's cwd (where the git command will actually execute), not
168
+ # CLAUDE_PROJECT_DIR (which always points to the main repo root, even when the
169
+ # agent has cd'd into a worktree at .caws/worktrees/<name>/).
170
+ AGENT_DIR="${HOOK_CWD:-${CLAUDE_PROJECT_DIR:-.}}"
158
171
  CURRENT_BRANCH=$(cd "$AGENT_DIR" && git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "unknown")
159
172
 
160
173
  # Determine the base branch to protect
@@ -13,6 +13,7 @@ INPUT=$(cat)
13
13
  # Extract tool info
14
14
  TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // ""')
15
15
  FILE_PATH=$(echo "$INPUT" | jq -r '.tool_input.file_path // ""')
16
+ HOOK_CWD=$(echo "$INPUT" | jq -r '.cwd // ""')
16
17
 
17
18
  # Only check Write and Edit tools
18
19
  case "$TOOL_NAME" in
@@ -42,10 +43,10 @@ if ! command -v node >/dev/null 2>&1; then
42
43
  exit 0
43
44
  fi
44
45
 
45
- # Use the agent's actual working directory, not the resolved main repo root.
46
- # In a worktree, PROJECT_DIR points to the main repo (to find .caws/worktrees.json),
47
- # but the agent's branch is in CLAUDE_PROJECT_DIR.
48
- AGENT_DIR="${CLAUDE_PROJECT_DIR:-.}"
46
+ # Use the hook input's cwd (where the agent is actually working), not
47
+ # CLAUDE_PROJECT_DIR (which always points to the main repo root, even when the
48
+ # agent has cd'd into a worktree at .caws/worktrees/<name>/).
49
+ AGENT_DIR="${HOOK_CWD:-${CLAUDE_PROJECT_DIR:-.}}"
49
50
  CURRENT_BRANCH=$(cd "$AGENT_DIR" && git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "unknown")
50
51
 
51
52
  WT_INFO=$(node -e "