create-claude-cabinet 0.44.0 → 0.45.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.
Files changed (63) hide show
  1. package/README.md +5 -0
  2. package/lib/cli.js +51 -6
  3. package/lib/copy.js +56 -10
  4. package/lib/mux-setup.js +1 -0
  5. package/package.json +1 -1
  6. package/templates/cabinet/checklist-stats-schema.md +104 -0
  7. package/templates/cabinet/checkpoint-protocol.md +17 -5
  8. package/templates/cabinet/qa-dimensions-template.yaml +7 -0
  9. package/templates/cabinet/watchtower-contracts.md +38 -0
  10. package/templates/engagement/pib-db-patches/pib-db-lib.mjs +4 -1
  11. package/templates/hooks/action-completion-gate.sh +17 -0
  12. package/templates/hooks/watchtower-session-start.sh +80 -5
  13. package/templates/mux/__tests__/claude-carveout.fixture.sh +136 -0
  14. package/templates/mux/__tests__/claude-carveout.test.mjs +38 -0
  15. package/templates/mux/__tests__/mux-fail-loud.fixture.sh +254 -0
  16. package/templates/mux/__tests__/mux-fail-loud.test.mjs +41 -0
  17. package/templates/mux/__tests__/worktree-dirty-check.fixture.sh +184 -0
  18. package/templates/mux/__tests__/worktree-dirty-check.test.mjs +35 -0
  19. package/templates/mux/bin/mux +212 -60
  20. package/templates/mux/config/worktree-cleanup.sh +55 -9
  21. package/templates/mux/config/worktree-dirty-check.sh +128 -0
  22. package/templates/mux/config/worktree-session-health.sh +62 -35
  23. package/templates/scripts/__tests__/qa-handoff-aging.e2e.test.mjs +108 -0
  24. package/templates/scripts/__tests__/qa-handoff-gate.test.mjs +335 -0
  25. package/templates/scripts/__tests__/resolve-project.test.mjs +144 -0
  26. package/templates/scripts/__tests__/ring-state-ownership.test.mjs +228 -0
  27. package/templates/scripts/pib-db-lib.mjs +4 -1
  28. package/templates/scripts/pib-db.mjs +4 -1
  29. package/templates/scripts/validate-memory.mjs +6 -2
  30. package/templates/scripts/watchtower-build-context.mjs +12 -8
  31. package/templates/scripts/watchtower-lib.mjs +265 -2
  32. package/templates/scripts/watchtower-migrate-keys.mjs +305 -0
  33. package/templates/scripts/watchtower-queue.mjs +226 -1
  34. package/templates/scripts/watchtower-ring1.mjs +19 -3
  35. package/templates/scripts/watchtower-ring2.mjs +4 -2
  36. package/templates/scripts/watchtower-ring3-close.mjs +92 -88
  37. package/templates/skills/audit/SKILL.md +25 -6
  38. package/templates/skills/audit/phases/checklist-pruning.md +108 -0
  39. package/templates/skills/briefing/SKILL.md +12 -1
  40. package/templates/skills/cabinet/SKILL.md +2 -2
  41. package/templates/skills/collab-consultant/SKILL.md +1 -1
  42. package/templates/skills/debrief/SKILL.md +33 -3
  43. package/templates/skills/debrief/phases/checklist-feedback.md +10 -3
  44. package/templates/skills/debrief/phases/qa-handoff-sweep.md +78 -0
  45. package/templates/skills/engagement-create/SKILL.md +1 -1
  46. package/templates/skills/engagement-help/SKILL.md +1 -1
  47. package/templates/skills/execute/SKILL.md +1 -1
  48. package/templates/skills/execute/phases/post-impl-checklist.md +18 -0
  49. package/templates/skills/execute-group/SKILL.md +76 -24
  50. package/templates/skills/inbox/SKILL.md +30 -7
  51. package/templates/skills/orient/SKILL.md +100 -6
  52. package/templates/skills/orient/phases/checklist-status.md +12 -0
  53. package/templates/skills/plan/SKILL.md +14 -6
  54. package/templates/skills/qa-handoff/SKILL.md +132 -5
  55. package/templates/skills/session-handoff/SKILL.md +165 -0
  56. package/templates/skills/setup-accounts/SKILL.md +1 -1
  57. package/templates/skills/unwrap/SKILL.md +1 -1
  58. package/templates/skills/verify/SKILL.md +2 -2
  59. package/templates/skills/watchtower/SKILL.md +19 -1
  60. package/templates/watchtower/queue/items/item.json.schema +9 -0
  61. package/templates/workflows/deliberative-audit.js +3 -0
  62. package/templates/workflows/execute-group-complete.js +93 -16
  63. package/templates/workflows/execute-group-implement.js +164 -19
@@ -0,0 +1,35 @@
1
+ // Guard test for the shared worktree dirty-detection seam (act:04bdcc6b).
2
+ //
3
+ // Drives worktree-dirty-check.fixture.sh, which builds a throwaway git repo
4
+ // + worktrees under mktemp with HOME overridden — it never touches the real
5
+ // ~/.mux/worktrees, ~/.config/mux, or any live worktree.
6
+ //
7
+ // Covers:
8
+ // - `mux worktree refresh` preserves uncommitted .claude/plans/ edits
9
+ // (authored-record carve-out; artifacts-of-thought)
10
+ // - modified tracked .mcp.json => dirty in BOTH bin/mux and the
11
+ // pane-exited cleanup hook (one implementation, same verdict)
12
+ // - lockfile-only churn => clean in both paths
13
+ // - missing/failed helper degrades to fail-DIRTY (never deletes)
14
+
15
+ import { test } from 'node:test';
16
+ import assert from 'node:assert';
17
+ import { spawnSync } from 'node:child_process';
18
+ import { fileURLToPath } from 'node:url';
19
+ import path from 'node:path';
20
+
21
+ const fixture = path.join(
22
+ path.dirname(fileURLToPath(import.meta.url)),
23
+ 'worktree-dirty-check.fixture.sh'
24
+ );
25
+
26
+ test('worktree dirty-detection + refresh carve-out (sandboxed fixture suite)', () => {
27
+ const res = spawnSync('bash', [fixture], { encoding: 'utf8', timeout: 120_000 });
28
+ const output = `${res.stdout}\n${res.stderr}`;
29
+ assert.strictEqual(
30
+ res.status,
31
+ 0,
32
+ `fixture suite reported failures:\n${output}`
33
+ );
34
+ assert.match(output, /RESULT: \d+ passed, 0 failed/);
35
+ });
@@ -234,38 +234,16 @@ create_worktree() {
234
234
  }
235
235
  }
236
236
 
237
- # Copy .claude/ infra into the worktree so CC sees worktree-local paths in
238
- # its system context (a symlink makes CC resolve through to the main repo
239
- # path, leaking it into every Read/Edit/Write). EXCEPT the authored project
240
- # record — .claude/plans/ and .claude/methodology/ which is real work that
241
- # must commit from the worktree. Those stay as normal git-tracked files,
242
- # left exactly as `git worktree add` checked them out from HEAD. See
243
- # .claude/rules/artifacts-of-thought.md.
244
- git -C "$wt_path" ls-files .claude/ 2>/dev/null \
245
- | grep -vE '^\.claude/(plans|methodology)/' \
246
- | while IFS= read -r f; do
247
- git -C "$wt_path" update-index --assume-unchanged "$f" 2>/dev/null || true
248
- done
249
- if [[ -d "$proj_path/.claude" ]]; then
250
- # Refresh infra subdirs from main, one top-level entry at a time, never
251
- # the authored-record dirs (leave the worktree's HEAD-checked-out copies).
252
- local entry name
253
- for entry in "$proj_path"/.claude/*; do
254
- [[ -e "$entry" ]] || continue
255
- name=$(basename "$entry")
256
- [[ "$name" == "plans" || "$name" == "methodology" ]] && continue
257
- rm -rf "$wt_path/.claude/$name"
258
- cp -R "$entry" "$wt_path/.claude/$name"
259
- done
260
- # Hide infra from the worktree's git status, but keep authored records
261
- # visible so new design docs created in a worktree show up and commit.
262
- local wt_gitdir
263
- wt_gitdir=$(git -C "$wt_path" rev-parse --git-dir 2>/dev/null)
264
- if [[ -n "$wt_gitdir" ]]; then
265
- mkdir -p "$wt_gitdir/info"
266
- { echo '.claude/*'; echo '!.claude/plans/'; echo '!.claude/methodology/'; } >> "$wt_gitdir/info/exclude"
267
- fi
268
- fi
237
+ # .claude/ infra is copied into the worktree (not symlinked a symlink
238
+ # makes CC resolve through to the main repo path, leaking it into every
239
+ # Read/Edit/Write) by the health check below, which runs with --refresh.
240
+ # The authored/infra classification lives in exactly one place
241
+ # worktree-session-health.sh: ANY tracked .claude/ file is authored project
242
+ # record (plans, methodology, rules, cabinet docs, anything committed) that
243
+ # stays exactly as `git worktree add` checked it out and is never frozen
244
+ # with assume-unchanged, so worktree edits commit; only gitignored infra
245
+ # (skills, agents, settings copies) is copied from main and hidden from
246
+ # status. See .claude/rules/artifacts-of-thought.md.
269
247
  for f in .mcp.json .claudeignore; do
270
248
  [[ -f "$proj_path/$f" ]] && ln -sf "$proj_path/$f" "$wt_path/$f"
271
249
  done
@@ -285,9 +263,10 @@ create_worktree() {
285
263
 
286
264
  worktree_registry_add "$project" "$task_slug" "$branch_name" "$wt_path"
287
265
 
288
- # Validate the worktree we just created
266
+ # Populate .claude/ infra (--refresh forces sync_claude_infra) and validate
267
+ # the worktree we just created.
289
268
  local health_output health_ok
290
- health_output=$(worktree_health_check "$proj_path" "$wt_path" 2>&1) && health_ok=1 || health_ok=0
269
+ health_output=$(worktree_health_check "$proj_path" "$wt_path" --refresh 2>&1) && health_ok=1 || health_ok=0
291
270
  if [[ "$health_ok" -eq 0 ]]; then
292
271
  echo "⚠ Worktree health issues:" >&2
293
272
  echo "$health_output" >&2
@@ -299,8 +278,10 @@ create_worktree() {
299
278
  }
300
279
 
301
280
  worktree_health_check() {
281
+ # Extra args pass through (e.g. --refresh forces an infra re-sync).
302
282
  local proj_path="$1" wt_path="$2"
303
- "${HOME}/.config/mux/worktree-session-health.sh" "$proj_path" "$wt_path" 2>&1
283
+ shift 2
284
+ "${HOME}/.config/mux/worktree-session-health.sh" "$proj_path" "$wt_path" "$@" 2>&1
304
285
  }
305
286
 
306
287
  worktree_cleanup() {
@@ -325,11 +306,25 @@ worktree_cleanup() {
325
306
  fi
326
307
  fi
327
308
 
328
- local has_commits has_uncommitted
329
- has_commits=$(git -C "$wt_path" log "$(git -C "$wt_path" merge-base HEAD "$(git -C "$proj_path" rev-parse HEAD)")..HEAD" --oneline 2>/dev/null | wc -l | tr -d ' ')
330
- has_uncommitted=$(git -C "$wt_path" status --porcelain 2>/dev/null | grep -vE '^\?\? \.claude/|^.T \.mcp\.json$' | wc -l | tr -d ' ')
309
+ # Shared dirty detection — single source of truth in
310
+ # ~/.config/mux/worktree-dirty-check.sh (the tmux pane-exited cleanup hook
311
+ # delegates to the same helper, so both paths reach the same verdict from
312
+ # one implementation). Fail-DIRTY: a missing or failing helper classifies
313
+ # the worktree dirty — never clean on the strength of a failed check.
314
+ local dirty_check="${MUX_CONFIG_DIR}/worktree-dirty-check.sh"
315
+ local dirty_line="" kv has_commits="?" has_uncommitted="?" verdict="dirty"
316
+ if [[ -x "$dirty_check" ]]; then
317
+ dirty_line=$("$dirty_check" "$wt_path" "$proj_path" 2>/dev/null) || dirty_line=""
318
+ fi
319
+ for kv in $dirty_line; do
320
+ case "$kv" in
321
+ commits=*) has_commits="${kv#commits=}" ;;
322
+ uncommitted=*) has_uncommitted="${kv#uncommitted=}" ;;
323
+ verdict=*) verdict="${kv#verdict=}" ;;
324
+ esac
325
+ done
331
326
 
332
- if [[ "$has_commits" -eq 0 ]] && [[ "$has_uncommitted" -eq 0 ]]; then
327
+ if [[ "$verdict" == "clean" ]]; then
333
328
  git -C "$proj_path" worktree remove "$wt_path" 2>/dev/null || rm -rf "$wt_path"
334
329
  git -C "$proj_path" branch -d "$branch_name" 2>/dev/null || true
335
330
  worktree_registry_remove "$project" "$task_slug"
@@ -340,8 +335,9 @@ worktree_cleanup() {
340
335
 
341
336
  echo ""
342
337
  echo "Session '${task_slug}' has work to merge:"
343
- [[ "$has_uncommitted" -gt 0 ]] && echo " ${has_uncommitted} uncommitted file(s)"
344
- [[ "$has_commits" -gt 0 ]] && echo " ${has_commits} commit(s) ahead"
338
+ # Counts may be "?" when the dirty-check could not determine them (fail-DIRTY).
339
+ [[ "$has_uncommitted" != "0" ]] && echo " ${has_uncommitted} uncommitted file(s)"
340
+ [[ "$has_commits" != "0" ]] && echo " ${has_commits} commit(s) ahead"
345
341
  echo ""
346
342
  echo "What do you want to do?"
347
343
  echo " 1) Merge to main branch"
@@ -354,7 +350,7 @@ worktree_cleanup() {
354
350
 
355
351
  case "$choice" in
356
352
  1)
357
- if [[ "$has_uncommitted" -gt 0 ]]; then
353
+ if [[ "$has_uncommitted" != "0" ]]; then
358
354
  git -C "$wt_path" add -A
359
355
  git -C "$wt_path" commit -m "mux: work from ${task_slug} session"
360
356
  fi
@@ -373,7 +369,7 @@ worktree_cleanup() {
373
369
  fi
374
370
  ;;
375
371
  2)
376
- if [[ "$has_uncommitted" -gt 0 ]]; then
372
+ if [[ "$has_uncommitted" != "0" ]]; then
377
373
  git -C "$wt_path" add -A
378
374
  git -C "$wt_path" commit -m "mux: parked work from ${task_slug} session"
379
375
  fi
@@ -406,7 +402,7 @@ worktree_cleanup() {
406
402
  }
407
403
 
408
404
  queue_claude_start() {
409
- local target="$1" prompt="$2" win_path="$3"
405
+ local target="$1" prompt="$2" win_path="$3" auto_orient="${4:-1}"
410
406
  # Resolve window index immediately (names can be unreliable in background)
411
407
  local sess win_idx
412
408
  sess=$(tmux display-message -t "$target" -p '#{session_name}' 2>/dev/null)
@@ -414,11 +410,18 @@ queue_claude_start() {
414
410
  local stable="${sess}:${win_idx}"
415
411
  tmux set-window-option -t "$stable" @mux_claude 1 2>/dev/null || true
416
412
  tmux send-keys -t "$stable" "cd '${win_path}' && claude" Enter
413
+ # auto_orient=1 (default): inject the canned /orient-quick before the prompt
414
+ # — the standard "start a session" sequence. auto_orient=0: inject ONLY the
415
+ # prompt, for callers whose prompt orchestrates its own orient (e.g.
416
+ # `mux handoff`, whose seed runs /orient then the seeded work thread —
417
+ # QA drains stay with window 1's standing station, never the seed).
417
418
  (
418
419
  sleep 5
419
- tmux send-keys -t "$stable" "/orient-quick" Enter
420
- sleep 2
421
- tmux send-keys -t "$stable" -l "$prompt"
420
+ if [[ "$auto_orient" == "1" ]]; then
421
+ tmux send-keys -t "$stable" "/orient-quick" Enter
422
+ sleep 2
423
+ fi
424
+ [[ -n "$prompt" ]] && tmux send-keys -t "$stable" -l "$prompt"
422
425
  ) &
423
426
  disown
424
427
  }
@@ -606,6 +609,26 @@ create_work_worktree() {
606
609
  return 1
607
610
  }
608
611
 
612
+ # Can this desk get an isolated worktree at all? Requires a registered mux
613
+ # project whose directory is a git repository. When isolation is impossible
614
+ # BY DESIGN (unregistered desk, non-git project dir), callers fall through to
615
+ # the main checkout LOUDLY — a visible warning naming why — instead of dying.
616
+ # die is reserved for real worktree-creation failures on isolation-capable
617
+ # desks. On failure, echoes the human-readable reason; on success, nothing.
618
+ worktree_isolation_capable() {
619
+ local project="$1" proj_path
620
+ proj_path=$(project_path "$project" 2>/dev/null) || proj_path=""
621
+ if [[ -z "$proj_path" ]]; then
622
+ echo "this desk isn't a registered mux project"
623
+ return 1
624
+ fi
625
+ if ! git -C "$proj_path" rev-parse --git-dir >/dev/null 2>&1; then
626
+ echo "project directory isn't a git repository: ${proj_path}"
627
+ return 1
628
+ fi
629
+ return 0
630
+ }
631
+
609
632
  # --- Subcommands ---
610
633
 
611
634
  cmd_picker() {
@@ -793,9 +816,18 @@ cmd_new() {
793
816
  queue_claude_start "=${session}:${win_name}" "$prompt" "$win_path"
794
817
  else
795
818
  if has_active_claude "$session"; then
796
- local win_name win_path
819
+ local win_name win_path skip_reason
797
820
  win_name="window-$(date +%s | tail -c 5)"
798
- win_path=$(create_worktree "$session" "$win_name") || win_path="$path"
821
+ if skip_reason=$(worktree_isolation_capable "$session"); then
822
+ # Never fall back to main — a second session landing next to a live
823
+ # Claude is the exact collision worktrees exist to prevent.
824
+ win_path=$(create_worktree "$session" "$win_name") \
825
+ || die "Couldn't create a worktree for '${win_name}' — refusing to run work on the main checkout. Try: mux worktree ls"
826
+ else
827
+ # Isolation impossible by design — fall through loudly, never silently.
828
+ echo "⚠ No worktree isolation for this window (${skip_reason}) — opening on the main checkout." >&2
829
+ win_path="$path"
830
+ fi
799
831
  tmux new-window -n "$win_name" -c "$win_path"
800
832
  if [[ "$win_path" == "$MUX_WORKTREES_DIR/"* ]]; then
801
833
  tmux set-window-option -t "=${session}:${win_name}" @mux_worktree 1 2>/dev/null || true
@@ -1219,16 +1251,16 @@ cmd_worktree() {
1219
1251
  [[ -d "$wt_path" ]] || die "Worktree not found: $wt_path"
1220
1252
  [[ -d "$proj_path/.claude" ]] || die "Main repo has no .claude/"
1221
1253
 
1222
- rm -rf "$wt_path/.claude"
1223
- cp -R "$proj_path/.claude" "$wt_path/.claude"
1224
- # Re-exclude from git status
1225
- local wt_gitdir
1226
- wt_gitdir=$(git -C "$wt_path" rev-parse --git-dir 2>/dev/null)
1227
- if [[ -n "$wt_gitdir" ]]; then
1228
- mkdir -p "$wt_gitdir/info"
1229
- grep -q '^\.claude/$' "$wt_gitdir/info/exclude" 2>/dev/null || echo '.claude/' >> "$wt_gitdir/info/exclude"
1254
+ # Delegate to the shared health script's carve-out-aware sync (the
1255
+ # worktree_health_check → worktree-session-health.sh consolidation).
1256
+ # Never wholesale `rm -rf .claude/` here — that destroys uncommitted
1257
+ # edits to the authored project record (any tracked .claude/ file).
1258
+ # See .claude/rules/artifacts-of-thought.md.
1259
+ if worktree_health_check "$proj_path" "$wt_path" --refresh; then
1260
+ echo "Refreshed .claude/ infra from main repo (authored records preserved)."
1261
+ else
1262
+ echo "Refreshed .claude/ infra, but health issues remain — see above." >&2
1230
1263
  fi
1231
- echo "Refreshed .claude/ from main repo."
1232
1264
  ;;
1233
1265
  *)
1234
1266
  echo "Usage: mux worktree [ls|cleanup|remove|health|refresh] [project] [task]"
@@ -1376,6 +1408,94 @@ qa_cmd_clear() {
1376
1408
  echo "Cleared queued QA handoffs for ${project}."
1377
1409
  }
1378
1410
 
1411
+ # --- Session handoff (session-handoff skill, phase 2) ---
1412
+ #
1413
+ # Open the NEXT session in a NEW window on the MAIN checkout, seeded with an
1414
+ # operator-approved prompt. This is the FORWARD handoff (close one session →
1415
+ # start the next), distinct from `mux qa dispatch`'s BACKWARD handoff (route a
1416
+ # merge to the live QA station). /session-handoff persists the seed to disk and
1417
+ # only calls this on approval, so a launch failure can't lose the seed.
1418
+ #
1419
+ # Why a new window on MAIN (not a worktree): the seed tells the next session to
1420
+ # run /orient then `mux qa drain`, and acting on drained QA (e2e / publish /
1421
+ # deploy) needs the main checkout. The window is named by work-context, NEVER
1422
+ # by the prompt (cmd_new's prompt path slugifies prompt→name + forces a
1423
+ # worktree — the wrong path; we deliberately don't use it).
1424
+ #
1425
+ # Descriptor JSON: { project (mux desk), project_path (repo root),
1426
+ # name (window name), seed_prompt }
1427
+ cmd_handoff() {
1428
+ require_python
1429
+ require_tmux
1430
+ local descriptor="${1:-}"
1431
+ [[ -f "$descriptor" ]] || die "mux handoff: descriptor file not found: ${descriptor}"
1432
+
1433
+ local project ppath name prompt
1434
+ project=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get("project",""))' "$descriptor" 2>/dev/null)
1435
+ ppath=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get("project_path",""))' "$descriptor" 2>/dev/null)
1436
+ name=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get("name",""))' "$descriptor" 2>/dev/null)
1437
+ prompt=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get("seed_prompt",""))' "$descriptor" 2>/dev/null)
1438
+ [[ -n "$prompt" ]] || die "mux handoff: descriptor missing 'seed_prompt'"
1439
+ [[ -n "$name" ]] || name="next-session"
1440
+
1441
+ # `project` should be the mux DESK (tmux session). Desk short-names often
1442
+ # differ from the repo dir name, so if it isn't a live session but the
1443
+ # descriptor's project_path matches a desk's MUX_PROJECT_PATH, remap to that
1444
+ # desk (same fallback as qa dispatch).
1445
+ if [[ -z "$project" ]] || ! tmux has-session -t "=$project" 2>/dev/null; then
1446
+ if [[ -n "$ppath" ]]; then
1447
+ local desk mp
1448
+ while IFS= read -r desk; do
1449
+ mp=$(tmux show-environment -t "$desk" MUX_PROJECT_PATH 2>/dev/null | sed 's/^MUX_PROJECT_PATH=//')
1450
+ if [[ "$mp" == "$ppath" ]]; then project="$desk"; break; fi
1451
+ done < <(tmux list-sessions -F '#{session_name}' 2>/dev/null)
1452
+ fi
1453
+ fi
1454
+ [[ -n "$project" ]] && tmux has-session -t "=$project" 2>/dev/null \
1455
+ || die "mux handoff: desk '${project:-?}' isn't open — seed not launched (it's saved; open the desk and paste it)."
1456
+
1457
+ # Main checkout path: prefer the descriptor's project_path, else the desk's
1458
+ # registered project dir, else the current dir.
1459
+ local main_path="$ppath"
1460
+ [[ -n "$main_path" ]] || main_path=$(project_path "$project" 2>/dev/null) || main_path="$PWD"
1461
+
1462
+ # Worktree, not main: the seeded session is a SECOND session on this desk,
1463
+ # so mux's core isolation rule applies — main stays window 1's standing
1464
+ # station. Two sessions sharing the main checkout sweep each other's
1465
+ # in-progress edits into unrelated commits (the 1adc5ef incident). Tracked
1466
+ # .claude/ files commit normally from worktrees since the 40cc831
1467
+ # carve-out, so the authoring work a seed carries is safe there. The
1468
+ # seeded session merges to main when done; main-only tail work (dogfood
1469
+ # reinstall, propagation, publish) happens post-merge or is dispatched to
1470
+ # window 1, consistent with "QA drains belong to window 1".
1471
+ local win_path where skip_reason
1472
+ if skip_reason=$(worktree_isolation_capable "$project"); then
1473
+ win_path=$(create_worktree "$project" "$name") \
1474
+ || die "mux handoff: couldn't create a worktree for '${name}' — refusing to seed a second session onto the main checkout. Seed is saved; try: mux worktree ls"
1475
+ where="a fresh worktree of ${project}"
1476
+ else
1477
+ # Isolation impossible by design (non-git / unregistered) — fall through
1478
+ # loudly, never silently.
1479
+ echo "⚠ No worktree isolation for this handoff (${skip_reason}) — opening on the main checkout." >&2
1480
+ win_path="$main_path"
1481
+ where="${project}'s main checkout (no isolation: ${skip_reason})"
1482
+ fi
1483
+
1484
+ # -P -F gives an unambiguous session:index target (window names can
1485
+ # collide — option-setting by NAME hits the first match, act:c6f6bfd1).
1486
+ # queue_claude_start with auto_orient=0: inject only the seed; the seed
1487
+ # itself runs /orient.
1488
+ local target
1489
+ target=$(tmux new-window -t "=$project" -n "$name" -c "$win_path" -P -F '#{session_name}:#{window_index}')
1490
+ [[ -n "$target" ]] || die "mux handoff: couldn't open a window on ${project} — seed is saved; open the desk and paste it."
1491
+ if [[ "$win_path" == "$MUX_WORKTREES_DIR/"* ]]; then
1492
+ tmux set-window-option -t "$target" @mux_worktree 1 2>/dev/null || true
1493
+ tmux set-window-option -t "$target" @wt_healthy 1 2>/dev/null || true
1494
+ fi
1495
+ queue_claude_start "=$target" "$prompt" "$win_path" 0
1496
+ echo "✓ Next session launched: window '${name}' on ${where}, seeded (it will /orient, then pick up the seeded work thread; it merges to main when done — QA handoffs and main-only tail work stay with window 1)."
1497
+ }
1498
+
1379
1499
  cmd_setup() {
1380
1500
  echo "=== mux setup ==="
1381
1501
  echo ""
@@ -1617,22 +1737,53 @@ cmd_resume() {
1617
1737
  require_tmux
1618
1738
  in_tmux || die "You need to be in a desk first. Try: mux <project-name>"
1619
1739
  [[ -n "$session_id" ]] || die "Usage: mux resume <session-id>"
1740
+ # The raw id flows into a branch name, a worktree path, and a send-keys
1741
+ # line — refuse anything that isn't id-shaped before it gets there.
1742
+ [[ "$session_id" =~ ^[0-9a-fA-F-]{8,}$ ]] \
1743
+ || die "That doesn't look like a Claude session id: '${session_id}'. Expected hex-and-dashes (8+ chars), e.g. a UUID."
1620
1744
 
1621
1745
  local session
1622
1746
  session=$(tmux display-message -p '#{session_name}')
1623
1747
  local path
1624
1748
  path=$(project_path "$session" 2>/dev/null || tmux display-message -p '#{pane_current_path}')
1625
1749
 
1626
- local win_name win_path
1750
+ local win_name win_path wt_path skip_reason
1627
1751
  win_name="resume-$(echo "$session_id" | cut -c1-8)"
1628
1752
 
1629
1753
  if has_active_claude "$session"; then
1630
- win_path=$(create_worktree "$session" "$win_name") || win_path="$path"
1754
+ if skip_reason=$(worktree_isolation_capable "$session"); then
1755
+ wt_path="${MUX_WORKTREES_DIR}/${session}-${win_name}"
1756
+ if [[ -d "$wt_path" ]]; then
1757
+ # The resume-<id8> slug is deterministic — a second resume of the
1758
+ # same session REUSES its existing worktree (it holds that session's
1759
+ # prior work). Only an invalid leftover at that path is an error.
1760
+ if git -C "$wt_path" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
1761
+ win_path="$wt_path"
1762
+ else
1763
+ die "Worktree path ${wt_path} already exists but isn't a valid git worktree — refusing to resume on the main checkout. Try: rm -rf '${wt_path}' && mux resume ${session_id}"
1764
+ fi
1765
+ else
1766
+ # Never fall back to main — that would dump the resumed session onto
1767
+ # the clean station next to a live Claude.
1768
+ win_path=$(create_worktree "$session" "$win_name") \
1769
+ || die "Couldn't create a worktree for '${win_name}' — refusing to run work on the main checkout. Try: mux worktree ls"
1770
+ fi
1771
+ else
1772
+ # Isolation impossible by design — fall through loudly, never silently.
1773
+ echo "⚠ No worktree isolation for this resume (${skip_reason}) — opening on the main checkout." >&2
1774
+ win_path="$path"
1775
+ fi
1631
1776
  else
1777
+ # No active Claude on this desk — resuming on the main checkout is the
1778
+ # intended landing (nothing to isolate from). By design, not a fallback.
1632
1779
  win_path="$path"
1633
1780
  fi
1634
1781
 
1635
1782
  tmux new-window -n "$win_name" -c "$win_path"
1783
+ if [[ "$win_path" == "$MUX_WORKTREES_DIR/"* ]]; then
1784
+ tmux set-window-option -t "=${session}:${win_name}" @mux_worktree 1 2>/dev/null || true
1785
+ tmux set-window-option -t "=${session}:${win_name}" @wt_healthy 1 2>/dev/null || true
1786
+ fi
1636
1787
  queue_claude_resume "=${session}:${win_name}" "$session_id" "$win_path"
1637
1788
  }
1638
1789
 
@@ -1660,6 +1811,7 @@ main() {
1660
1811
  portal) shift; cmd_portal "${1:-}" ;;
1661
1812
  worktree) shift; cmd_worktree "$@" ;;
1662
1813
  qa) shift; cmd_qa "$@" ;;
1814
+ handoff) shift; cmd_handoff "$@" ;;
1663
1815
  setup) cmd_setup ;;
1664
1816
  help) cmd_help ;;
1665
1817
  -h|--help) cmd_help ;;
@@ -25,11 +25,32 @@ branch_name="mux/${WINDOW}"
25
25
 
26
26
  [[ -d "$wt_path" ]] || exit 0
27
27
 
28
- # Check for real changes
29
- has_commits=$(git -C "$wt_path" log "$(git -C "$wt_path" merge-base HEAD "$(git -C "$proj_path" rev-parse HEAD)" 2>/dev/null)..HEAD" --oneline 2>/dev/null | wc -l | tr -d ' ')
30
- has_uncommitted=$(git -C "$wt_path" status --porcelain 2>/dev/null | grep -cvE '(^\?\? \.claude$|^\?\? \.claude/|\.mcp\.json$)' || true)
28
+ # Check for real changes via the shared dirty detection — single source of
29
+ # truth in worktree-dirty-check.sh (`mux worktree cleanup` delegates to the
30
+ # same helper, so both paths reach the same verdict from one implementation).
31
+ #
32
+ # FAIL-DIRTY: this hook silently DELETES worktrees, so a failed or missing
33
+ # check must never authorize a deletion. If the helper is absent, errors, or
34
+ # its output is unparseable, we classify the worktree dirty and fall through
35
+ # to the note-leaving branch. Every fallible step is guarded so that under
36
+ # set -e this degrades to fail-DIRTY rather than aborting silently.
37
+ DIRTY_CHECK="${HOME}/.config/mux/worktree-dirty-check.sh"
38
+ dirty_line=""
39
+ if [[ -x "$DIRTY_CHECK" ]]; then
40
+ dirty_line=$("$DIRTY_CHECK" "$wt_path" "$proj_path" 2>/dev/null) || dirty_line=""
41
+ fi
42
+ has_commits="?"
43
+ has_uncommitted="?"
44
+ verdict="dirty"
45
+ for kv in $dirty_line; do
46
+ case "$kv" in
47
+ commits=*) has_commits="${kv#commits=}" ;;
48
+ uncommitted=*) has_uncommitted="${kv#uncommitted=}" ;;
49
+ verdict=*) verdict="${kv#verdict=}" ;;
50
+ esac
51
+ done
31
52
 
32
- if [[ "$has_commits" -eq 0 ]] && [[ "$has_uncommitted" -eq 0 ]]; then
53
+ if [[ "$verdict" == "clean" ]]; then
33
54
  # Clean — silently remove
34
55
  # Clean up project identity symlink
35
56
  projects_dir="$HOME/.claude/projects"
@@ -40,18 +61,41 @@ if [[ "$has_commits" -eq 0 ]] && [[ "$has_uncommitted" -eq 0 ]]; then
40
61
  git -C "$proj_path" branch -d "$branch_name" 2>/dev/null || true
41
62
  python3 "$MUX_LIB" worktree-remove "$SESSION" "$WINDOW" 2>/dev/null || true
42
63
  else
43
- # Dirty — create urgent watchtower inbox item + sticky note
64
+ # Dirty — create urgent watchtower inbox item + sticky note.
65
+ # Counts may be "?" when the check could not determine them (fail-DIRTY).
44
66
  detail=""
45
- [[ "$has_uncommitted" -gt 0 ]] && detail="${has_uncommitted} uncommitted"
46
- [[ "$has_commits" -gt 0 ]] && detail="${detail:+$detail, }${has_commits} commit(s)"
67
+ if [[ "$has_uncommitted" != "0" && "$has_uncommitted" != "?" ]]; then
68
+ detail="${has_uncommitted} uncommitted"
69
+ fi
70
+ if [[ "$has_commits" != "0" && "$has_commits" != "?" ]]; then
71
+ detail="${detail:+$detail, }${has_commits} commit(s)"
72
+ fi
73
+ if [[ -z "$detail" ]]; then
74
+ detail="changes the dirty-check could not verify (treated as dirty)"
75
+ fi
47
76
 
48
77
  # Watchtower inbox item (if watchtower is installed)
49
78
  WATCHTOWER_QUEUE="${HOME}/.claude-cabinet/watchtower/scripts/watchtower-queue.mjs"
79
+ WATCHTOWER_LIB="${HOME}/.claude-cabinet/watchtower/scripts/watchtower-lib.mjs"
50
80
  if [[ -f "$WATCHTOWER_QUEUE" ]]; then
51
81
  NODE_BIN="${WATCHTOWER_NODE_PATH:-$(command -v node 2>/dev/null || true)}"
52
82
  if [[ -n "$NODE_BIN" ]]; then
53
83
  "$NODE_BIN" --input-type=module -e "
54
84
  import { createItem, listPending } from '${WATCHTOWER_QUEUE}';
85
+ // project must be the watchtower config key, not the mux desk name —
86
+ // /inbox and the rings group by config key; desk-name keying
87
+ // ('cabinet' vs 'claude-cabinet') filed items nobody could find.
88
+ // The desk name is preserved in its own field.
89
+ // Dynamic import with fallback: this script (mux-setup) and the lib
90
+ // (/watchtower install) ship on different installers — a static named
91
+ // import against an older lib without the export is an ESM hard
92
+ // failure that would kill the whole filing silently. Degrade to the
93
+ // old desk-name keying instead; the key migration re-keys it later.
94
+ let resolveProjectIdentity, loadConfig;
95
+ try { ({ resolveProjectIdentity, loadConfig } = await import('${WATCHTOWER_LIB}')); } catch {}
96
+ let config = null;
97
+ try { config = loadConfig?.() ?? null; } catch {}
98
+ const identity = resolveProjectIdentity?.('${proj_path}', config) ?? null;
55
99
  const existing = listPending({ category: 'worktree-unmerged' });
56
100
  const isDup = existing.some(i =>
57
101
  i.evidence?.branch === '${branch_name}' &&
@@ -59,8 +103,10 @@ else
59
103
  );
60
104
  if (!isDup) {
61
105
  createItem({
62
- project: '${SESSION}',
63
- project_path: '${proj_path}',
106
+ project: identity?.name || '${SESSION}',
107
+ project_path: identity?.path || '${proj_path}',
108
+ desk: '${SESSION}',
109
+ ...(identity ? {} : { project_unresolved: true }),
64
110
  filed_by: 'pane-close',
65
111
  category: 'worktree-unmerged',
66
112
  urgency: 'urgent',