create-claude-cabinet 0.42.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.
Files changed (36) hide show
  1. package/lib/cli.js +8 -12
  2. package/lib/engagement-setup.js +1 -1
  3. package/lib/mux-setup.js +1 -0
  4. package/package.json +1 -1
  5. package/templates/engagement/pib-db-patches/pib-db-schema.sql +1 -1
  6. package/templates/mux/bin/mux +397 -43
  7. package/templates/mux/config/help.txt +2 -0
  8. package/templates/mux/config/worktree-session-health.sh +66 -12
  9. package/templates/scripts/watchtower-build-context.mjs +4 -3
  10. package/templates/scripts/watchtower-lib.mjs +54 -0
  11. package/templates/scripts/watchtower-ring1.mjs +20 -0
  12. package/templates/scripts/watchtower-ring2.mjs +3 -2
  13. package/templates/scripts/watchtower-ring3-close.mjs +14 -6
  14. package/templates/scripts/watchtower-status.sh +11 -2
  15. package/templates/scripts/watchtower-validate.mjs +1 -0
  16. package/templates/skills/audit/SKILL.md +0 -1
  17. package/templates/skills/briefing/SKILL.md +5 -1
  18. package/templates/skills/cabinet-record-keeper/SKILL.md +6 -1
  19. package/templates/skills/cc-upgrade/SKILL.md +0 -1
  20. package/templates/skills/debrief/phases/methodology-capture.md +13 -0
  21. package/templates/skills/execute/SKILL.md +0 -1
  22. package/templates/skills/inbox/SKILL.md +6 -0
  23. package/templates/skills/investigate/SKILL.md +0 -2
  24. package/templates/skills/plan/SKILL.md +0 -1
  25. package/templates/skills/qa-handoff/SKILL.md +283 -0
  26. package/templates/skills/threads/SKILL.md +14 -8
  27. package/templates/skills/validate/phases/validators.md +34 -0
  28. package/templates/watchtower/queue/items/item.json.schema +2 -1
  29. package/templates/skills/decisions/SKILL.md +0 -13
  30. package/templates/skills/engagement/SKILL.md +0 -9
  31. package/templates/skills/engagement-add/SKILL.md +0 -7
  32. package/templates/skills/engagement-edit/SKILL.md +0 -7
  33. package/templates/skills/engagement-message/SKILL.md +0 -7
  34. package/templates/skills/engagement-progress/SKILL.md +0 -7
  35. package/templates/skills/engagement-status/SKILL.md +0 -7
  36. package/templates/skills/engagement-sync/SKILL.md +0 -7
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` +
@@ -609,15 +612,8 @@ const MODULES = {
609
612
  templates: [
610
613
  'skills/collab-client',
611
614
  'skills/collab-consultant',
612
- 'skills/engagement',
613
- 'skills/engagement-progress',
614
615
  'skills/engagement-help',
615
- 'skills/engagement-message',
616
616
  'skills/engagement-create',
617
- 'skills/engagement-edit',
618
- 'skills/engagement-add',
619
- 'skills/engagement-status',
620
- 'skills/engagement-sync',
621
617
  'skills/setup-accounts',
622
618
  'skills/guide',
623
619
  'engagement',
@@ -635,7 +631,6 @@ const MODULES = {
635
631
  'scripts/watchtower-lib.mjs',
636
632
  'scripts/watchtower-queue.mjs',
637
633
  'skills/inbox',
638
- 'skills/decisions',
639
634
  'hooks/watchtower-session-start.sh',
640
635
  'scripts/watchtower-build-context.mjs',
641
636
  'scripts/watchtower-ring1.mjs',
@@ -657,6 +652,7 @@ const MODULES = {
657
652
  'scripts/watchtower-status.sh',
658
653
  'skills/briefing',
659
654
  'skills/threads',
655
+ 'skills/qa-handoff',
660
656
  ],
661
657
  },
662
658
  mux: {
@@ -666,7 +662,7 @@ const MODULES = {
666
662
  default: false,
667
663
  lean: false,
668
664
  postInstall: 'mux-setup',
669
- templates: ['skills/orient/phases/dx-captures.md'],
665
+ templates: ['skills/orient/phases/dx-captures.md', 'skills/dx-feedback'],
670
666
  },
671
667
  'engagement-server': {
672
668
  name: 'Engagement Server',
@@ -177,7 +177,7 @@ function setupEngagement({ dryRun, projectDir } = {}) {
177
177
  target_fid TEXT,
178
178
  packet_id TEXT,
179
179
  kind TEXT NOT NULL
180
- CHECK(kind IN ('client_feedback','status_push','delegation','approval','note','packet_sent')),
180
+ CHECK(kind IN ('client_feedback','status_push','delegation','approval','note','packet_sent','packet_opened')),
181
181
  author TEXT NOT NULL,
182
182
  verdict TEXT CHECK(verdict IS NULL OR verdict IN ('approve','object','comment','none')),
183
183
  body TEXT CHECK(body IS NULL OR length(body) <= 10000),
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.42.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"
@@ -114,7 +114,7 @@ CREATE TABLE IF NOT EXISTS engagement_events (
114
114
  target_fid TEXT,
115
115
  packet_id TEXT,
116
116
  kind TEXT NOT NULL
117
- CHECK(kind IN ('client_feedback','status_push','delegation','approval','note','packet_sent')),
117
+ CHECK(kind IN ('client_feedback','status_push','delegation','approval','note','packet_sent','packet_opened')),
118
118
  author TEXT NOT NULL,
119
119
  verdict TEXT CHECK(verdict IS NULL OR verdict IN ('approve','object','comment','none')),
120
120
  body TEXT CHECK(body IS NULL OR length(body) <= 10000),
@@ -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
@@ -227,19 +234,37 @@ create_worktree() {
227
234
  }
228
235
  }
229
236
 
230
- # Copy .claude/ into worktree so CC sees worktree-local paths in its
231
- # system context. A symlink here causes CC to resolve through to the
232
- # main repo path, leaking it into every Read/Edit/Write call.
233
- git -C "$wt_path" ls-files .claude/ 2>/dev/null | while IFS= read -r f; do
234
- git -C "$wt_path" update-index --assume-unchanged "$f" 2>/dev/null || true
235
- done
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
236
249
  if [[ -d "$proj_path/.claude" ]]; then
237
- rm -rf "$wt_path/.claude"
238
- cp -R "$proj_path/.claude" "$wt_path/.claude"
239
- # Exclude copied .claude/ from worktree's git status (it's infra, not work)
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.
240
262
  local wt_gitdir
241
263
  wt_gitdir=$(git -C "$wt_path" rev-parse --git-dir 2>/dev/null)
242
- [[ -n "$wt_gitdir" ]] && mkdir -p "$wt_gitdir/info" && echo '.claude/' >> "$wt_gitdir/info/exclude"
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
243
268
  fi
244
269
  for f in .mcp.json .claudeignore; do
245
270
  [[ -f "$proj_path/$f" ]] && ln -sf "$proj_path/$f" "$wt_path/$f"
@@ -408,6 +433,179 @@ queue_claude_resume() {
408
433
  tmux send-keys -t "$stable" "cd '${win_path}' && claude --resume ${session_id}" Enter
409
434
  }
410
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
+
411
609
  # --- Subcommands ---
412
610
 
413
611
  cmd_picker() {
@@ -503,17 +701,17 @@ cmd_open() {
503
701
  local win_name win_path
504
702
  win_name=$(slugify "$prompt")
505
703
 
506
- if has_active_claude "$project"; then
507
- win_path=$(create_worktree "$project" "$win_name") || win_path="$path"
508
- else
509
- win_path="$path"
510
- 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}"
511
711
 
512
712
  tmux new-window -t "=$project" -n "$win_name" -c "$win_path"
513
- if [[ "$win_path" == "$MUX_WORKTREES_DIR/"* ]]; then
514
- tmux set-window-option -t "=${project}:${win_name}" @mux_worktree 1 2>/dev/null || true
515
- tmux set-window-option -t "=${project}:${win_name}" @wt_healthy 1 2>/dev/null || true
516
- 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
517
715
  queue_claude_start "=${project}:${win_name}" "$prompt" "$win_path"
518
716
 
519
717
  if in_tmux; then
@@ -529,23 +727,38 @@ cmd_open() {
529
727
  fi
530
728
  fi
531
729
  else
532
- if [[ -n "$prompt" ]]; then
533
- local win_name
534
- win_name=$(slugify "$prompt")
535
- tmux new-session -d -s "$project" -n "$win_name" -c "$path"
536
- queue_claude_start "=${project}:${win_name}" "$prompt" "$path"
537
- else
538
- tmux new-session -d -s "$project" -c "$path"
539
- fi
540
-
541
- # 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"
542
733
  tmux set-environment -t "$project" MUX_PROJECT_PATH "$path" 2>/dev/null || true
543
734
  setup_session_hooks "$project" "$path"
544
735
 
545
- if in_tmux; then
546
- 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
547
755
  else
548
- 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
549
762
  fi
550
763
  fi
551
764
  }
@@ -566,17 +779,17 @@ cmd_new() {
566
779
  local win_name win_path
567
780
  win_name=$(slugify "$prompt")
568
781
 
569
- if has_active_claude "$session"; then
570
- win_path=$(create_worktree "$session" "$win_name") || win_path="$path"
571
- else
572
- win_path="$path"
573
- 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}"
574
789
 
575
790
  tmux new-window -n "$win_name" -c "$win_path"
576
- if [[ "$win_path" == "$MUX_WORKTREES_DIR/"* ]]; then
577
- tmux set-window-option -t "=${session}:${win_name}" @mux_worktree 1 2>/dev/null || true
578
- tmux set-window-option -t "=${session}:${win_name}" @wt_healthy 1 2>/dev/null || true
579
- 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
580
793
  queue_claude_start "=${session}:${win_name}" "$prompt" "$win_path"
581
794
  else
582
795
  if has_active_claude "$session"; then
@@ -1023,6 +1236,146 @@ cmd_worktree() {
1023
1236
  esac
1024
1237
  }
1025
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
+
1026
1379
  cmd_setup() {
1027
1380
  echo "=== mux setup ==="
1028
1381
  echo ""
@@ -1306,6 +1659,7 @@ main() {
1306
1659
  dx) shift; cmd_dx "$@" ;;
1307
1660
  portal) shift; cmd_portal "${1:-}" ;;
1308
1661
  worktree) shift; cmd_worktree "$@" ;;
1662
+ qa) shift; cmd_qa "$@" ;;
1309
1663
  setup) cmd_setup ;;
1310
1664
  help) cmd_help ;;
1311
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 │