create-claude-cabinet 0.43.0 → 0.44.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/cli.js CHANGED
@@ -397,9 +397,12 @@ function generateAgentWrappers(projectDir) {
397
397
  if (/websearch/.test(toolSignal)) tools.push('WebSearch');
398
398
  if (/webfetch|fetch_docs/.test(toolSignal)) tools.push('WebFetch');
399
399
 
400
- // model: none of the cabinet skills declare one today; default to sonnet,
401
- // but honor an explicit declaration if a member ever sets one.
402
- const model = (typeof fm.model === 'string' && fm.model.trim()) || 'sonnet';
400
+ // model: none of the cabinet skills declare one today; default to inherit
401
+ // (follow the session model a family alias like 'sonnet' goes stale when
402
+ // the frontier moves families), but honor an explicit declaration if a
403
+ // member ever sets one. Background watchtower rings pin their own model
404
+ // separately and are unaffected.
405
+ const model = (typeof fm.model === 'string' && fm.model.trim()) || 'inherit';
403
406
 
404
407
  const wrapper =
405
408
  `---\n` +
package/lib/mux-setup.js CHANGED
@@ -57,6 +57,7 @@ const DATA_DIRS = [
57
57
  path.join(os.homedir(), '.config', 'mux', 'dx'),
58
58
  path.join(os.homedir(), '.config', 'mux', 'pending-prompts'),
59
59
  path.join(os.homedir(), '.local', 'share', 'mux', 'wt-health'),
60
+ path.join(os.homedir(), '.local', 'share', 'mux', 'qa-handoff'),
60
61
  ];
61
62
 
62
63
  function sha256(content) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-claude-cabinet",
3
- "version": "0.43.0",
3
+ "version": "0.44.0",
4
4
  "description": "Claude Cabinet — opinionated process scaffolding for Claude Code projects",
5
5
  "bin": {
6
6
  "create-claude-cabinet": "bin/create-claude-cabinet.js"
@@ -162,10 +162,17 @@ setup_session_hooks() {
162
162
  "run-shell '${HOME}/.config/mux/worktree-cleanup.sh \"${project}\" \"#{window_name}\" 2>/dev/null || true'" 2>/dev/null || true
163
163
 
164
164
  # Window format: ·wt suffix colored by health (green=healthy, red=issues)
165
+ # on worktree windows; ·N amber QA-handoff badge on the main window (the
166
+ # two are mutually exclusive — @mux_qa is only ever set on the non-worktree
167
+ # window). See the QA handoff dispatch helpers.
165
168
  tmux set-option -t "$project" window-status-format \
166
- '#[fg=#888888] #I:#W#{?@mux_worktree,#{?@wt_healthy,#[fg=#66bbaa],#[fg=#ff6666]}·wt#[default],} ' 2>/dev/null || true
169
+ '#[fg=#888888] #I:#W#{?@mux_worktree,#{?@wt_healthy,#[fg=#66bbaa],#[fg=#ff6666]}·wt#[default],}#{?@mux_qa,#[fg=#e0af68]·#{@mux_qa}#[default],} ' 2>/dev/null || true
167
170
  tmux set-option -t "$project" window-status-current-format \
168
- '#[fg=#ffffff,bg=#4a4a8a,bold] #I:#W#{?@mux_worktree,#{?@wt_healthy,#[fg=#88ddcc],#[fg=#ff6666]}·wt#[default],} ' 2>/dev/null || true
171
+ '#[fg=#ffffff,bg=#4a4a8a,bold] #I:#W#{?@mux_worktree,#{?@wt_healthy,#[fg=#88ddcc],#[fg=#ff6666]}·wt#[default],}#{?@mux_qa,#[fg=#e0af68]·#{@mux_qa}#[default],} ' 2>/dev/null || true
172
+
173
+ # Restore the ·N QA badge from the durable queue on desk open (the tmux
174
+ # window option is ephemeral; the queue dir is the source of truth).
175
+ qa_refresh_indicator "$project" 2>/dev/null || true
169
176
 
170
177
  # Global bindings live in mux.tmux.conf (single source of truth), which
171
178
  # ~/.tmux.conf sources at server start. Re-sourcing here applies upgrades
@@ -426,6 +433,179 @@ queue_claude_resume() {
426
433
  tmux send-keys -t "$stable" "cd '${win_path}' && claude --resume ${session_id}" Enter
427
434
  }
428
435
 
436
+ # --- QA handoff dispatch (qa-handoff-protocol.md, stage 2) ---
437
+ #
438
+ # The push model. /qa-handoff packages a just-merged worktree into an inbox
439
+ # item, then calls `mux qa dispatch <descriptor>`. We route that handoff to
440
+ # the desk's MAIN window (window 1 — the permanent main session that owns
441
+ # post-merge QA / publish / deploy):
442
+ #
443
+ # verified-idle Claude → inject the pickup prompt (send-keys)
444
+ # bare shell (no Claude) → launch a fresh Claude with the prompt
445
+ # busy / other / closed → queue on disk + light the ·N tab badge
446
+ #
447
+ # Window 1 drains the queue one at a time with `mux qa drain`. mux stays
448
+ # DUMB: it routes an opaque, self-contained prompt and never parses handoff
449
+ # content — so it's decoupled from the watchtower inbox schema and degrades
450
+ # to inbox-only when not installed. The on-disk queue is the durable source
451
+ # of truth; the ·N badge is a pure projection of it, recomputed at every
452
+ # mutation and on desk open, so a tmux restart can't desync the two.
453
+
454
+ MUX_QA_DIR="${HOME}/.local/share/mux/qa-handoff"
455
+
456
+ # A descriptor is the small JSON /qa-handoff writes per merge:
457
+ # { project, project_path, item_id, merged_commit, what, pickup_prompt }
458
+ # mux only reads project / pickup_prompt / item_id / what / merged_commit.
459
+
460
+ qa_count() {
461
+ find "${MUX_QA_DIR}/${1}" -maxdepth 1 -name '*.json' 2>/dev/null | wc -l | tr -d ' '
462
+ }
463
+
464
+ # Resolve a project's MAIN STATION window — where post-merge QA lands.
465
+ # Worktree windows (@mux_worktree=1) are never the station. Among the
466
+ # non-worktree windows it PREFERS the lowest-index one running a live Claude
467
+ # (@mux_claude=1), and otherwise falls back to the lowest-index non-worktree
468
+ # window (a bare shell we can launch into). That fallback is the hardening:
469
+ # if window 1's Claude died and the live main session moved to another
470
+ # non-worktree window, the handoff still finds it. Echoes "<session>:<idx>",
471
+ # or fails if the desk isn't open. list-windows is index-ordered.
472
+ qa_main_window() {
473
+ local project="$1" idx wt claude fallback=""
474
+ while IFS='|' read -r idx wt claude; do
475
+ [[ "$wt" == "1" ]] && continue
476
+ [[ -z "$fallback" ]] && fallback="${project}:${idx}"
477
+ if [[ "$claude" == "1" ]]; then
478
+ echo "${project}:${idx}"
479
+ return 0
480
+ fi
481
+ done < <(tmux list-windows -t "=$project" -F '#{window_index}|#{@mux_worktree}|#{@mux_claude}' 2>/dev/null)
482
+ [[ -n "$fallback" ]] && { echo "$fallback"; return 0; }
483
+ return 1
484
+ }
485
+
486
+ # Classify the main window's pane:
487
+ # claude-idle | claude-busy | shell | other | no-window
488
+ # Conservative by design: anything ambiguous resolves to claude-busy, so we
489
+ # queue instead of injecting (better dark than wrong — the indicator and the
490
+ # inject path only fire on a positively verified state). The idle/busy
491
+ # markers track Claude Code's footer and may need tuning across CC versions;
492
+ # `mux qa status` surfaces the detected state so any drift is debuggable, not
493
+ # silent.
494
+ qa_pane_state() {
495
+ local project="$1" win cmd is_claude snap
496
+ win=$(qa_main_window "$project") || { echo "no-window"; return; }
497
+ cmd=$(tmux display-message -t "=$win" -p '#{pane_current_command}' 2>/dev/null)
498
+ is_claude=$(tmux show-window-option -t "=$win" -v @mux_claude 2>/dev/null)
499
+
500
+ if [[ "$is_claude" != "1" ]]; then
501
+ case "$cmd" in
502
+ zsh|bash|sh|fish|login|-zsh|-bash|-sh) echo "shell" ;;
503
+ *) echo "other" ;;
504
+ esac
505
+ return
506
+ fi
507
+
508
+ snap=$(tmux capture-pane -t "=$win" -p -S -30 2>/dev/null)
509
+ if printf '%s' "$snap" | grep -qiE 'esc to interrupt|· *[0-9]+ tokens|Running…|Compacting|Thinking…'; then
510
+ echo "claude-busy"
511
+ elif printf '%s' "$snap" | grep -qiE '\? for shortcuts|\? for help'; then
512
+ echo "claude-idle"
513
+ else
514
+ echo "claude-busy"
515
+ fi
516
+ }
517
+
518
+ # Recompute the ·N badge from the durable queue and set/clear it on the main
519
+ # window. Called after every mutation and from setup_session_hooks (desk
520
+ # open), keeping the badge a faithful projection of the queue dir.
521
+ qa_refresh_indicator() {
522
+ local project="$1" win count
523
+ win=$(qa_main_window "$project") || return 0
524
+ count=$(qa_count "$project")
525
+ if [[ "$count" -gt 0 ]]; then
526
+ tmux set-window-option -t "=$win" @mux_qa "$count" 2>/dev/null || true
527
+ else
528
+ tmux set-window-option -t "=$win" -u @mux_qa 2>/dev/null || true
529
+ fi
530
+ }
531
+
532
+ qa_enqueue() {
533
+ local project="$1" descriptor="$2" item_id
534
+ mkdir -p "${MUX_QA_DIR}/${project}"
535
+ item_id=$(python3 -c 'import json,sys,re; v=str(json.load(open(sys.argv[1])).get("item_id") or "handoff"); print(re.sub(r"[^A-Za-z0-9._-]","-",v))' "$descriptor" 2>/dev/null)
536
+ [[ -n "$item_id" ]] || item_id="handoff-$(date +%s)"
537
+ cp "$descriptor" "${MUX_QA_DIR}/${project}/${item_id}.json"
538
+ qa_refresh_indicator "$project"
539
+ }
540
+
541
+ # Inject into an idle Claude composer. C-u clears any half-typed input first
542
+ # so we never prepend to whatever was sitting in the box.
543
+ qa_inject() {
544
+ local win="$1" prompt="$2"
545
+ tmux send-keys -t "=$win" C-u 2>/dev/null || true
546
+ tmux send-keys -t "=$win" -l "$prompt"
547
+ tmux send-keys -t "=$win" Enter
548
+ }
549
+
550
+ # Launch a fresh Claude in a bare-shell main window with the handoff as its
551
+ # opening prompt. Reuses queue_claude_start (the same cd→claude→/orient-quick
552
+ # →prompt sequence mux uses everywhere) — single source of truth for "start a
553
+ # Claude session with a prompt". C-u clears the shell line first.
554
+ qa_launch_fresh() {
555
+ local win="$1" prompt="$2" project="$3" path
556
+ path=$(project_path "$project" 2>/dev/null) || path="$PWD"
557
+ tmux send-keys -t "=$win" C-u 2>/dev/null || true
558
+ queue_claude_start "=$win" "$prompt" "$path"
559
+ }
560
+
561
+ # Ensure the desk has a live MAIN STATION (window 1 — a Claude on the main
562
+ # checkout that receives post-merge QA handoffs). Idempotent, and the keystone
563
+ # of the standing-station model: because a clean station is guaranteed, every
564
+ # `mux … "prompt"` can safely route its work to a worktree and let the merge
565
+ # hand back to the station. Three cases, in order:
566
+ # - a live non-worktree Claude already exists → reuse it (no-op)
567
+ # - the main window is a bare shell (window 1's Claude died) → relaunch there
568
+ # - no non-worktree window at all (defensive) → create window 1, launch there
569
+ # Never touches worktree windows. Reuses qa_main_window (live-station-preferring
570
+ # resolution) and qa_launch_fresh (the single launch path) — no forked logic.
571
+ ensure_main_station() {
572
+ local project="$1" path="$2" station claude
573
+ station=$(qa_main_window "$project" 2>/dev/null || true)
574
+ if [[ -n "$station" ]]; then
575
+ claude=$(tmux show-window-option -t "=$station" -v @mux_claude 2>/dev/null)
576
+ [[ "$claude" == "1" ]] && return 0
577
+ qa_launch_fresh "$station" "" "$project"
578
+ return 0
579
+ fi
580
+ tmux new-window -t "=$project" -c "$path" 2>/dev/null || true
581
+ station=$(qa_main_window "$project" 2>/dev/null || true)
582
+ [[ -n "$station" ]] && qa_launch_fresh "$station" "" "$project"
583
+ return 0
584
+ }
585
+
586
+ # Create a worktree for WORK, and never silently fall back to the main
587
+ # checkout — dumping work onto the clean station is the exact silent failure
588
+ # the standing-station model exists to prevent. On a slug collision with an
589
+ # existing worktree, uniquify (slug-2, slug-3, …) so the work always gets its
590
+ # own isolated worktree, and surface the rename. Echoes the final slug; fails
591
+ # (caller refuses to run on main) only on a genuine, non-collision error.
592
+ create_work_worktree() {
593
+ local project="$1" base="$2" slug="$2" n=1
594
+ while (( n <= 20 )); do
595
+ if create_worktree "$project" "$slug" >/dev/null 2>&1; then
596
+ [[ "$slug" != "$base" ]] && \
597
+ printf "worktree '%s' already existed — created '%s' instead\n" "$base" "$slug" >&2
598
+ printf '%s\n' "$slug"
599
+ return 0
600
+ fi
601
+ # A pre-existing dir means a slug collision → uniquify and retry. Anything
602
+ # else is a real failure (bad git state) → don't mask it as main.
603
+ [[ -d "${MUX_WORKTREES_DIR}/${project}-${slug}" ]] || return 1
604
+ (( n++ )); slug="${base}-${n}"
605
+ done
606
+ return 1
607
+ }
608
+
429
609
  # --- Subcommands ---
430
610
 
431
611
  cmd_picker() {
@@ -521,17 +701,17 @@ cmd_open() {
521
701
  local win_name win_path
522
702
  win_name=$(slugify "$prompt")
523
703
 
524
- if has_active_claude "$project"; then
525
- win_path=$(create_worktree "$project" "$win_name") || win_path="$path"
526
- else
527
- win_path="$path"
528
- fi
704
+ # Standing-station model: keep window 1 a clean main station and run the
705
+ # work in an isolated worktree, so the merge produces a real handoff.
706
+ ensure_main_station "$project" "$path"
707
+ # Never fall back to main — that would dump work onto the clean station.
708
+ win_name=$(create_work_worktree "$project" "$win_name") \
709
+ || die "Couldn't create a worktree for '${win_name}' — refusing to run work on the main checkout. Try: mux worktree ls"
710
+ win_path="${MUX_WORKTREES_DIR}/${project}-${win_name}"
529
711
 
530
712
  tmux new-window -t "=$project" -n "$win_name" -c "$win_path"
531
- if [[ "$win_path" == "$MUX_WORKTREES_DIR/"* ]]; then
532
- tmux set-window-option -t "=${project}:${win_name}" @mux_worktree 1 2>/dev/null || true
533
- tmux set-window-option -t "=${project}:${win_name}" @wt_healthy 1 2>/dev/null || true
534
- fi
713
+ tmux set-window-option -t "=${project}:${win_name}" @mux_worktree 1 2>/dev/null || true
714
+ tmux set-window-option -t "=${project}:${win_name}" @wt_healthy 1 2>/dev/null || true
535
715
  queue_claude_start "=${project}:${win_name}" "$prompt" "$win_path"
536
716
 
537
717
  if in_tmux; then
@@ -547,23 +727,38 @@ cmd_open() {
547
727
  fi
548
728
  fi
549
729
  else
550
- if [[ -n "$prompt" ]]; then
551
- local win_name
552
- win_name=$(slugify "$prompt")
553
- tmux new-session -d -s "$project" -n "$win_name" -c "$path"
554
- queue_claude_start "=${project}:${win_name}" "$prompt" "$path"
555
- else
556
- tmux new-session -d -s "$project" -c "$path"
557
- fi
558
-
559
- # Store project path and set up session hooks
730
+ # New desk: window 1 is the main checkout. Create it + wire hooks first so
731
+ # the station and any worktree windows inherit the right format/env.
732
+ tmux new-session -d -s "$project" -c "$path"
560
733
  tmux set-environment -t "$project" MUX_PROJECT_PATH "$path" 2>/dev/null || true
561
734
  setup_session_hooks "$project" "$path"
562
735
 
563
- if in_tmux; then
564
- tmux switch-client -t "=$project"
736
+ if [[ -n "$prompt" ]]; then
737
+ # Work on a fresh desk: window 1 becomes the main station, the work runs
738
+ # in a worktree (window 2). Land the operator on the work.
739
+ local win_name win_path
740
+ win_name=$(slugify "$prompt")
741
+ ensure_main_station "$project" "$path"
742
+ # Never fall back to main — that would dump work onto the clean station.
743
+ win_name=$(create_work_worktree "$project" "$win_name") \
744
+ || die "Couldn't create a worktree for '${win_name}' — refusing to run work on the main checkout. Try: mux worktree ls"
745
+ win_path="${MUX_WORKTREES_DIR}/${project}-${win_name}"
746
+ tmux new-window -t "=$project" -n "$win_name" -c "$win_path"
747
+ tmux set-window-option -t "=${project}:${win_name}" @mux_worktree 1 2>/dev/null || true
748
+ tmux set-window-option -t "=${project}:${win_name}" @wt_healthy 1 2>/dev/null || true
749
+ queue_claude_start "=${project}:${win_name}" "$prompt" "$win_path"
750
+ if in_tmux; then
751
+ tmux switch-client -t "=${project}:${win_name}"
752
+ else
753
+ tmux attach-session -t "=${project}:${win_name}"
754
+ fi
565
755
  else
566
- tmux attach-session -t "=$project"
756
+ # No prompt — the lightweight "just drop me into main" escape hatch.
757
+ if in_tmux; then
758
+ tmux switch-client -t "=$project"
759
+ else
760
+ tmux attach-session -t "=$project"
761
+ fi
567
762
  fi
568
763
  fi
569
764
  }
@@ -584,17 +779,17 @@ cmd_new() {
584
779
  local win_name win_path
585
780
  win_name=$(slugify "$prompt")
586
781
 
587
- if has_active_claude "$session"; then
588
- win_path=$(create_worktree "$session" "$win_name") || win_path="$path"
589
- else
590
- win_path="$path"
591
- fi
782
+ # Standing-station model: ensure window 1 is a main station, run the work
783
+ # in an isolated worktree so its merge hands back to the station.
784
+ ensure_main_station "$session" "$path"
785
+ # Never fall back to main — that would dump work onto the clean station.
786
+ win_name=$(create_work_worktree "$session" "$win_name") \
787
+ || die "Couldn't create a worktree for '${win_name}' — refusing to run work on the main checkout. Try: mux worktree ls"
788
+ win_path="${MUX_WORKTREES_DIR}/${session}-${win_name}"
592
789
 
593
790
  tmux new-window -n "$win_name" -c "$win_path"
594
- if [[ "$win_path" == "$MUX_WORKTREES_DIR/"* ]]; then
595
- tmux set-window-option -t "=${session}:${win_name}" @mux_worktree 1 2>/dev/null || true
596
- tmux set-window-option -t "=${session}:${win_name}" @wt_healthy 1 2>/dev/null || true
597
- fi
791
+ tmux set-window-option -t "=${session}:${win_name}" @mux_worktree 1 2>/dev/null || true
792
+ tmux set-window-option -t "=${session}:${win_name}" @wt_healthy 1 2>/dev/null || true
598
793
  queue_claude_start "=${session}:${win_name}" "$prompt" "$win_path"
599
794
  else
600
795
  if has_active_claude "$session"; then
@@ -1041,6 +1236,146 @@ cmd_worktree() {
1041
1236
  esac
1042
1237
  }
1043
1238
 
1239
+ cmd_qa() {
1240
+ local action="${1:-list}"
1241
+ shift || true
1242
+ case "$action" in
1243
+ dispatch) qa_cmd_dispatch "$@" ;;
1244
+ list) qa_cmd_list "$@" ;;
1245
+ drain) qa_cmd_drain "$@" ;;
1246
+ status) qa_cmd_status "$@" ;;
1247
+ clear) qa_cmd_clear "$@" ;;
1248
+ *) die "Usage: mux qa {dispatch <file>|list|drain|status|clear} [project]" ;;
1249
+ esac
1250
+ }
1251
+
1252
+ # Route one packaged handoff to window 1. Called by /qa-handoff after it
1253
+ # files the inbox item. Never dies on a closed/absent desk — it queues, so
1254
+ # the handoff is never lost (it also lives in the inbox regardless).
1255
+ qa_cmd_dispatch() {
1256
+ require_python
1257
+ local descriptor="${1:-}"
1258
+ [[ -f "$descriptor" ]] || die "mux qa dispatch: descriptor file not found: ${descriptor}"
1259
+
1260
+ local project prompt
1261
+ project=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get("project",""))' "$descriptor" 2>/dev/null)
1262
+ prompt=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get("pickup_prompt",""))' "$descriptor" 2>/dev/null)
1263
+ [[ -n "$project" ]] || die "mux qa dispatch: descriptor missing 'project'"
1264
+ [[ -n "$prompt" ]] || die "mux qa dispatch: descriptor missing 'pickup_prompt'"
1265
+
1266
+ # `project` should be the mux DESK (tmux session). Desk short-names often
1267
+ # differ from the repo dir name (desk `cabinet`, repo `claude-cabinet`), so
1268
+ # if it isn't a live session but the descriptor's project_path matches a
1269
+ # desk's MUX_PROJECT_PATH, remap to that desk — otherwise a repo-name
1270
+ # descriptor would queue to a dead desk and never light the badge.
1271
+ if ! tmux has-session -t "=$project" 2>/dev/null; then
1272
+ local ppath desk mp
1273
+ ppath=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get("project_path",""))' "$descriptor" 2>/dev/null)
1274
+ if [[ -n "$ppath" ]]; then
1275
+ while IFS= read -r desk; do
1276
+ mp=$(tmux show-environment -t "$desk" MUX_PROJECT_PATH 2>/dev/null | sed 's/^MUX_PROJECT_PATH=//')
1277
+ if [[ "$mp" == "$ppath" ]]; then project="$desk"; break; fi
1278
+ done < <(tmux list-sessions -F '#{session_name}' 2>/dev/null)
1279
+ fi
1280
+ fi
1281
+
1282
+ local state win
1283
+ state=$(qa_pane_state "$project")
1284
+ win=$(qa_main_window "$project" 2>/dev/null || true)
1285
+
1286
+ # Self-dispatch guard: if dispatch is invoked FROM the main-station window
1287
+ # itself, the work was done on main (no worktree to hand off from) and there
1288
+ # is no second window to route to. No-op cleanly instead of queuing a handoff
1289
+ # to the very window that wrote it. This is the deliberate work-on-main
1290
+ # escape hatch; the standing-station default makes it rare.
1291
+ if [[ -n "${TMUX_PANE:-}" && -n "$win" ]]; then
1292
+ local here
1293
+ here=$(tmux display-message -t "$TMUX_PANE" -p '#{session_name}:#{window_index}' 2>/dev/null)
1294
+ if [[ -n "$here" && "$here" == "$win" ]]; then
1295
+ echo "• Already on the main station (work was on main, not a worktree) — the handoff is filed in the inbox; act on it here or it ages. No dispatch needed."
1296
+ return 0
1297
+ fi
1298
+ fi
1299
+
1300
+ case "$state" in
1301
+ claude-idle)
1302
+ qa_inject "$win" "$prompt"
1303
+ echo "✓ Handoff pushed to window 1 (injected into the idle main session)."
1304
+ ;;
1305
+ shell)
1306
+ qa_launch_fresh "$win" "$prompt" "$project"
1307
+ echo "✓ Handoff pushed to window 1 (launched a fresh main session with it)."
1308
+ ;;
1309
+ claude-busy|other|no-window)
1310
+ qa_enqueue "$project" "$descriptor"
1311
+ local n; n=$(qa_count "$project")
1312
+ local why; case "$state" in
1313
+ claude-busy) why="window 1 is busy" ;;
1314
+ other) why="window 1 is running another program" ;;
1315
+ no-window) why="the ${project} desk isn't open" ;;
1316
+ esac
1317
+ echo "• Queued — ${why}. ${n} handoff(s) pending; the ·${n} badge is lit. Window 1 drains with: mux qa drain"
1318
+ ;;
1319
+ esac
1320
+ }
1321
+
1322
+ qa_cmd_list() {
1323
+ local project="${1:-$(tmux display-message -p '#{session_name}' 2>/dev/null)}"
1324
+ [[ -n "$project" ]] || die "mux qa list: no project (run inside a desk or pass a name)"
1325
+ local n; n=$(qa_count "$project")
1326
+ if [[ "$n" -eq 0 ]]; then
1327
+ echo "No QA handoffs queued for ${project}."
1328
+ return 0
1329
+ fi
1330
+ echo "${n} QA handoff(s) queued for ${project} (oldest first):"
1331
+ local f
1332
+ while IFS= read -r f; do
1333
+ [[ -n "$f" ]] || continue
1334
+ python3 -c 'import json,sys
1335
+ d=json.load(open(sys.argv[1]))
1336
+ what=d.get("what","(no summary)")
1337
+ sha=str(d.get("merged_commit","?"))[:8]
1338
+ iid=d.get("item_id","?")
1339
+ print(f" - {what} [merged {sha}] {iid}")' "$f" 2>/dev/null
1340
+ done < <(ls -1tr "${MUX_QA_DIR}/${project}"/*.json 2>/dev/null)
1341
+ echo ""
1342
+ echo "Drain the oldest into this session: mux qa drain"
1343
+ }
1344
+
1345
+ # Pop the oldest queued handoff and print its pickup prompt to stdout. The
1346
+ # window-1 Claude session runs this (via the Bash tool) and acts on the
1347
+ # prompt it gets back — the prompt IS the instruction. Removing the
1348
+ # descriptor decrements the badge.
1349
+ qa_cmd_drain() {
1350
+ local project="${1:-$(tmux display-message -p '#{session_name}' 2>/dev/null)}"
1351
+ [[ -n "$project" ]] || die "mux qa drain: no project (run inside a desk or pass a name)"
1352
+ local oldest
1353
+ oldest=$(ls -1tr "${MUX_QA_DIR}/${project}"/*.json 2>/dev/null | head -1)
1354
+ if [[ -z "$oldest" ]]; then
1355
+ echo "No QA handoffs queued for ${project}."
1356
+ return 0
1357
+ fi
1358
+ python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get("pickup_prompt",""))' "$oldest" 2>/dev/null
1359
+ rm -f "$oldest"
1360
+ qa_refresh_indicator "$project"
1361
+ }
1362
+
1363
+ qa_cmd_status() {
1364
+ local project="${1:-$(tmux display-message -p '#{session_name}' 2>/dev/null)}"
1365
+ [[ -n "$project" ]] || die "mux qa status: no project (run inside a desk or pass a name)"
1366
+ printf 'QA handoff — %s\n' "$project"
1367
+ printf ' window 1 pane: %s\n' "$(qa_pane_state "$project")"
1368
+ printf ' queued: %s\n' "$(qa_count "$project")"
1369
+ }
1370
+
1371
+ qa_cmd_clear() {
1372
+ local project="${1:-$(tmux display-message -p '#{session_name}' 2>/dev/null)}"
1373
+ [[ -n "$project" ]] || die "mux qa clear: no project (run inside a desk or pass a name)"
1374
+ rm -f "${MUX_QA_DIR}/${project}"/*.json 2>/dev/null || true
1375
+ qa_refresh_indicator "$project"
1376
+ echo "Cleared queued QA handoffs for ${project}."
1377
+ }
1378
+
1044
1379
  cmd_setup() {
1045
1380
  echo "=== mux setup ==="
1046
1381
  echo ""
@@ -1324,6 +1659,7 @@ main() {
1324
1659
  dx) shift; cmd_dx "$@" ;;
1325
1660
  portal) shift; cmd_portal "${1:-}" ;;
1326
1661
  worktree) shift; cmd_worktree "$@" ;;
1662
+ qa) shift; cmd_qa "$@" ;;
1327
1663
  setup) cmd_setup ;;
1328
1664
  help) cmd_help ;;
1329
1665
  -h|--help) cmd_help ;;
@@ -19,6 +19,7 @@
19
19
  │ mux portal on/off Toggle voice │
20
20
  │ mux worktree ls List worktrees │
21
21
  │ mux worktree health Check health │
22
+ │ mux qa list/drain QA handoff queue │
22
23
  │ mux setup First-time setup │
23
24
  │ mux help This screen │
24
25
  │ │
@@ -32,6 +33,7 @@
32
33
  │ │
33
34
  │ ·wt tab marker = worktree window │
34
35
  │ green = healthy, red = issues │
36
+ │ ·N tab marker = QA handoffs queued │
35
37
  │ │
36
38
  │ Terminal text (scroll + copy): │
37
39
  │ wheel or Ctrl-Space [ scroll back │
@@ -1,5 +1,4 @@
1
1
  ---
2
- model: opus
3
2
  name: audit
4
3
  description: |
5
4
  Convene the full cabinet for a quality review. Each cabinet member examines
@@ -23,7 +23,12 @@ directives:
23
23
  (2) Additions — check if this session built, changed, or published
24
24
  anything that should be recorded in those files but isn't yet
25
25
  (new capabilities, version bumps, count changes, new conventions).
26
- Fix what you find don't create findings.
26
+ Verification guard — before you write or update ANY claim, grep the
27
+ named file, symbol, command, path, or count in the working tree and
28
+ confirm it matches reality. Never extrapolate what a change does from
29
+ its stated intent; read the actual diff or code. If a claim can't be
30
+ verified against the tree, do not record it as fact — mark it
31
+ unverified instead. Fix what you find — don't create findings.
27
32
  files:
28
33
  - CLAUDE.md
29
34
  - "**/CLAUDE.md"
@@ -1,5 +1,4 @@
1
1
  ---
2
- model: opus
3
2
  name: cc-upgrade
4
3
  description: |
5
4
  Conversational upgrade when Claude Cabinet updates. Runs the installer to
@@ -1,5 +1,4 @@
1
1
  ---
2
- model: opus
3
2
  name: execute
4
3
  description: |
5
4
  Execute a plan with cabinet member checkpoints. Reads the plan, activates
@@ -1,5 +1,4 @@
1
1
  ---
2
- model: opus
3
2
  name: investigate
4
3
  description: |
5
4
  Structured codebase exploration before planning. Four phases: observe
@@ -17,7 +16,6 @@ related:
17
16
  - type: file
18
17
  path: cabinet/_briefing.md
19
18
  role: "System briefing for investigation scope"
20
- model: opus
21
19
  argument-hint: "what to investigate — e.g., 'why orient fails on fresh installs'"
22
20
  ---
23
21
 
@@ -1,5 +1,4 @@
1
1
  ---
2
- model: opus
3
2
  name: plan
4
3
  description: |
5
4
  Structured planning with cabinet critique. The relevant cabinet members
@@ -6,8 +6,8 @@ description: |
6
6
  happen on main (e2e tests against a live server, npm publish, deploy).
7
7
  Writes an operator-level handoff (what merged, what the worktree could
8
8
  NOT verify, what hangs on the next step) to the watchtower inbox so it
9
- surfaces and ages until QA happens. Stage 1 of the QA-handoff protocol:
10
- this PACKAGES the handoff; it does not yet dispatch it to window 1.
9
+ surfaces and ages until QA happens, then pushes it to the desk's main
10
+ window (mux dispatch) so QA starts without hand-carrying it.
11
11
  Use when: "qa-handoff", "/qa-handoff", "hand off to QA", "package the
12
12
  handoff", or right after merging a worktree branch into main.
13
13
  argument-hint: "optional branch — e.g., (none = most recent merge), 'mux/my-branch'"
@@ -22,11 +22,12 @@ main. These are all **post-merge** activities, and there's no bridge
22
22
  between "the worktree work is merged" and "QA / publish / deploy runs on
23
23
  main." This skill builds the handoff that crosses that gap.
24
24
 
25
- Full design: `.claude/plans/qa-handoff-protocol.md`. This skill is
26
- **stage 1** — it packages a merge into a durable, surfaced handoff. The
27
- dispatch half (mux poking window 1, pane-state detection, the on-disk
28
- queue) is a later stage; for now the handoff lands in the inbox and is
29
- surfaced by `/briefing` until the operator runs QA.
25
+ Full design: `.claude/plans/qa-handoff-protocol.md`. This skill packages
26
+ a merge into a durable, surfaced handoff (stage 1) and then pushes it to
27
+ the desk's main window via `mux qa dispatch` (stage 2) injecting into
28
+ an idle window-1 Claude, launching a fresh one, or queuing behind a `·N`
29
+ tab badge when window 1 is busy. The handoff lands in the inbox either
30
+ way, so `/briefing` resurfaces it until QA happens.
30
31
 
31
32
  ## The verification contract this enforces
32
33
 
@@ -145,21 +146,27 @@ reserve `urgent` for a handoff that genuinely blocks (a publish the
145
146
  operator is waiting on). Better dark than wrong: a wall of urgent
146
147
  handoffs trains the operator to ignore them.
147
148
 
148
- Write the payload to a temp file, then file via the installed queue API:
149
+ Write the payload to a **run-scoped** temp file, then file via the installed
150
+ queue API. Handoffs accumulate — never reuse one fixed filename (a second
151
+ handoff collides, and the Write tool refuses to overwrite an unread leftover
152
+ from a prior run). Key the temp file by the merged short SHA and `rm -f` it
153
+ first:
149
154
 
150
155
  ```bash
151
156
  WT="${WATCHTOWER_DIR:-$HOME/.claude-cabinet/watchtower}"
152
- # 1. Write the createItem args to /tmp/qa-handoff-item.json:
157
+ ITEM="/tmp/qa-handoff-item-<short-sha>.json" # run-scoped — never a fixed name
158
+ rm -f "$ITEM"
159
+ # Write the createItem args to "$ITEM" (here `project` is the WATCHTOWER
160
+ # project/repo name — the inbox keys on it):
153
161
  # { project, project_path, category: "qa-handoff", urgency,
154
162
  # title, summary, context_anchor, evidence: <the payload above>,
155
- # thread_ids: [<linked thread slugs, if known>],
156
- # filed_by: "manual" }
163
+ # thread_ids: [<linked thread slugs, if known>], filed_by: "manual" }
157
164
  node --input-type=module -e '
158
165
  import { readFileSync } from "fs";
159
166
  import { createItem } from "'"$WT"'/scripts/watchtower-queue.mjs";
160
167
  const args = JSON.parse(readFileSync(process.argv[1], "utf8"));
161
168
  console.log("Filed qa-handoff item:", createItem(args));
162
- ' /tmp/qa-handoff-item.json
169
+ ' "$ITEM"
163
170
  ```
164
171
 
165
172
  - `title`: `QA handoff: <what>, merged <short-sha>`
@@ -170,11 +177,84 @@ console.log("Filed qa-handoff item:", createItem(args));
170
177
  Ring 3 mints threads at session close, so a fresh thread may not exist
171
178
  yet; leave `[]` rather than guessing.
172
179
 
173
- ## Step 6: Report to the operator
180
+ ## Step 6: Dispatch to window 1 (push model)
181
+
182
+ Stage 1 stops at the inbox. Stage 2 pushes the handoff to the desk's
183
+ **main window** (window 1 — the permanent main session that owns
184
+ post-merge QA / publish / deploy) so QA starts without the operator
185
+ hand-carrying it. mux owns the routing; this skill just hands it an
186
+ opaque, self-contained prompt and lets mux decide where it lands:
187
+
188
+ - verified-idle Claude in window 1 → injected immediately
189
+ - bare-shell window 1 (no Claude) → a fresh session launches with it
190
+ - window 1 busy / desk closed → queued on disk + a `·N` tab badge lights;
191
+ window 1 drains it with `mux qa drain`
192
+
193
+ mux never parses the handoff — it routes the prompt — so the inbox item
194
+ filed in Step 5 stays the single canonical record. The `pickup_prompt`
195
+ is a pointer to that item plus the one thing QA must not miss; do not
196
+ inline the whole payload (that would fork the source of truth).
197
+
198
+ Write the dispatch descriptor to a **run-scoped** path, then call mux. Two
199
+ fields are load-bearing:
200
+
201
+ - **`project` here is the mux DESK** — the tmux session name, NOT the
202
+ repo/project name. mux resolves window 1 with `tmux -t "=<project>"`, so
203
+ when the desk's short name differs from the repo name (desk `cabinet`,
204
+ repo `claude-cabinet`) a repo-name descriptor silently queues to a
205
+ non-existent desk and never lights the badge. Get the desk from
206
+ `tmux display-message -p '#{session_name}'` and use that literal value.
207
+ (mux now also self-corrects by `project_path` if you get this wrong, but
208
+ pass the desk name — don't rely on the fallback.)
209
+ - The temp path is run-scoped (keyed by the merged short SHA) — same
210
+ accumulate-don't-collide reason as Step 5.
211
+
212
+ Use the `item_id` that Step 5's `createItem` returned:
213
+
214
+ ```bash
215
+ tmux display-message -p '#{session_name}' # the mux desk — use as "project" below
216
+ DISPATCH="/tmp/qa-handoff-dispatch-<short-sha>.json"
217
+ cat > "$DISPATCH" <<'JSON'
218
+ {
219
+ "project": "<mux desk name from the command above — NOT the repo name>",
220
+ "project_path": "<repo root>",
221
+ "item_id": "<id from Step 5>",
222
+ "merged_commit": "<sha on main>",
223
+ "what": "<the one-line what>",
224
+ "pickup_prompt": "Post-merge QA: <branch> merged to main at <short-sha>. Read qa-handoff inbox item <item_id> (run /inbox) for the full handoff, then run the runtime-needed checks it names — <e2e/publish/deploy, one phrase> — and stamp the verdict citing the named checks and the commit tested (<short-sha>). Do not publish or deploy without operator go."
225
+ }
226
+ JSON
227
+
228
+ if command -v mux >/dev/null 2>&1; then
229
+ mux qa dispatch "$DISPATCH"
230
+ else
231
+ echo "mux not installed — handoff is in the inbox; /briefing and /inbox surface it."
232
+ fi
233
+ ```
234
+
235
+ Graceful degradation, no silent failure: if mux is absent (the project
236
+ doesn't use it), the handoff still lives in the inbox from Step 5 — the
237
+ command says so rather than failing quietly. mux's own output reports
238
+ which path it took (injected / launched / queued).
239
+
240
+ ### Without mux (the universal path)
241
+
242
+ mux is optional. The substantive part of this skill — the honest handoff
243
+ filed to the inbox — depends only on the **watchtower** module, not mux.
244
+ Without mux, the dispatch above is skipped and the handoff simply waits in
245
+ the inbox: open Claude on the main checkout (any terminal or editor tab),
246
+ pull it with `/inbox` or `/briefing`, and run the QA there. Same handoff,
247
+ same verification contract — *pull* instead of *push*. Everything above
248
+ the dispatch step (the honest packaging) is identical either way; the mux
249
+ routing is purely an accelerant for desks that run it.
250
+
251
+ ## Step 7: Report to the operator
174
252
 
175
253
  Tell the operator, plainly:
176
254
  - What was handed off (the `what` + merged commit).
177
255
  - What QA on main needs to do — the `runtime_needed` list, named.
256
+ - How it dispatched — pushed to window 1 now, or queued behind a `·N`
257
+ badge for window 1 to drain (echo mux's result).
178
258
  - Whether a publish/deploy is staged and waiting on their go.
179
259
  - That it's in the inbox and `/briefing` will resurface it until acted
180
260
  on.
@@ -183,10 +263,21 @@ Do **not** run the publish or deploy. This skill prepares and stops;
183
263
  promotion is an explicit operator decision (and, later, a dispatched
184
264
  window-1 step).
185
265
 
186
- ## Scope boundary (stage 1)
266
+ ## Scope boundary (stages 1–2)
267
+
268
+ This skill now packages the handoff (stage 1) AND pushes it to window 1
269
+ (stage 2, via `mux qa dispatch`). What it still does NOT do:
270
+
271
+ - **Inject into a *busy* window-1 session.** Dispatch only injects into a
272
+ verified-idle Claude or launches a fresh one; a running session is left
273
+ alone and the handoff queues. Reaching into a live session needs the
274
+ unbuilt session-messages layer (ex-Plan 8) — deferred by design.
275
+ - **Auto-run the QA or flip `runtime-needed` → `runtime-verified`.** That
276
+ is the window-1 session's job after it drains the handoff and the named
277
+ checks pass; the flip lands when the operator resolves the inbox item.
278
+ - **Publish or deploy.** Always prepared, never auto-fired (operator go).
187
279
 
188
- This skill does NOT poke window 1, detect pane state, or auto-run QA.
189
- Those are the dispatch stages in `.claude/plans/qa-handoff-protocol.md`.
190
- What it guarantees today: every merge can be packaged into one honest,
191
- surfaced, ageable handoff that names what could not be verified from the
192
- worktree — so nothing silently ships unverified.
280
+ What it guarantees: every merge becomes one honest, surfaced, ageable
281
+ handoff that names what the worktree could not verify — and that handoff
282
+ is actively routed to the QA station, not left for the operator to find.
283
+ See `.claude/plans/qa-handoff-protocol.md` for the full protocol.