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 +6 -3
- package/lib/mux-setup.js +1 -0
- package/package.json +1 -1
- package/templates/mux/bin/mux +369 -33
- package/templates/mux/config/help.txt +2 -0
- package/templates/skills/audit/SKILL.md +0 -1
- package/templates/skills/cabinet-record-keeper/SKILL.md +6 -1
- package/templates/skills/cc-upgrade/SKILL.md +0 -1
- package/templates/skills/execute/SKILL.md +0 -1
- package/templates/skills/investigate/SKILL.md +0 -2
- package/templates/skills/plan/SKILL.md +0 -1
- package/templates/skills/qa-handoff/SKILL.md +110 -19
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
|
|
401
|
-
//
|
|
402
|
-
|
|
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
package/templates/mux/bin/mux
CHANGED
|
@@ -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
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
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
|
-
|
|
532
|
-
|
|
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
|
-
|
|
551
|
-
|
|
552
|
-
|
|
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
|
|
564
|
-
|
|
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
|
-
|
|
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
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
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
|
-
|
|
595
|
-
|
|
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 │
|
|
@@ -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
|
-
|
|
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: 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
|
|
|
@@ -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
|
|
10
|
-
|
|
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
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
'
|
|
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:
|
|
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 (
|
|
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
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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.
|