@paths.design/caws-cli 9.3.1 → 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.
@@ -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 "
@@ -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
  /**
@@ -358,19 +370,31 @@ function createWorktree(name, options = {}) {
358
370
  }
359
371
 
360
372
  /**
361
- * List all registered worktrees with filesystem validation
362
- * @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[] }}
363
388
  */
364
- function listWorktrees() {
365
- const root = getRepoRoot();
389
+ function reconcileRegistry(root) {
366
390
  const registry = loadRegistry(root);
367
391
 
368
- // Get actual git worktrees for validation
369
392
  let gitWorktrees = [];
370
393
  try {
371
394
  const output = execFileSync('git', ['worktree', 'list', '--porcelain'], {
372
395
  cwd: root,
373
396
  encoding: 'utf8',
397
+ stdio: 'pipe',
374
398
  });
375
399
  gitWorktrees = output
376
400
  .split('\n\n')
@@ -390,22 +414,27 @@ function listWorktrees() {
390
414
  const inGit = gitWorktrees.some(
391
415
  (wt) => path.resolve(wt) === path.resolve(entry.path)
392
416
  );
393
- const status = exists && inGit ? 'active' : exists ? 'orphaned' : 'missing';
394
417
 
395
- // Enrich with commit recency
396
- 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
+ }
397
431
 
398
- // Check if branch is already merged to base
432
+ const lastCommit = entry.branch ? getLastCommitInfo(entry.branch, root) : null;
399
433
  const merged = entry.branch && entry.baseBranch
400
434
  ? isBranchMerged(entry.branch, entry.baseBranch, root)
401
435
  : false;
402
436
 
403
- return {
404
- ...entry,
405
- status,
406
- lastCommit,
407
- merged,
408
- };
437
+ return { ...entry, status, lastCommit, merged };
409
438
  });
410
439
 
411
440
  // Append unregistered worktrees discovered from git
@@ -427,6 +456,79 @@ function listWorktrees() {
427
456
  });
428
457
  }
429
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);
430
532
  return entries;
431
533
  }
432
534
 
@@ -755,6 +857,8 @@ module.exports = {
755
857
  destroyWorktree,
756
858
  mergeWorktree,
757
859
  pruneWorktrees,
860
+ repairWorktrees,
861
+ reconcileRegistry,
758
862
  loadRegistry,
759
863
  getRepoRoot,
760
864
  getLastCommitInfo,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@paths.design/caws-cli",
3
- "version": "9.3.1",
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 "