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.
- package/lib/cli.js +8 -12
- package/lib/engagement-setup.js +1 -1
- package/lib/mux-setup.js +1 -0
- package/package.json +1 -1
- package/templates/engagement/pib-db-patches/pib-db-schema.sql +1 -1
- package/templates/mux/bin/mux +397 -43
- package/templates/mux/config/help.txt +2 -0
- package/templates/mux/config/worktree-session-health.sh +66 -12
- package/templates/scripts/watchtower-build-context.mjs +4 -3
- package/templates/scripts/watchtower-lib.mjs +54 -0
- package/templates/scripts/watchtower-ring1.mjs +20 -0
- package/templates/scripts/watchtower-ring2.mjs +3 -2
- package/templates/scripts/watchtower-ring3-close.mjs +14 -6
- package/templates/scripts/watchtower-status.sh +11 -2
- package/templates/scripts/watchtower-validate.mjs +1 -0
- package/templates/skills/audit/SKILL.md +0 -1
- package/templates/skills/briefing/SKILL.md +5 -1
- package/templates/skills/cabinet-record-keeper/SKILL.md +6 -1
- package/templates/skills/cc-upgrade/SKILL.md +0 -1
- package/templates/skills/debrief/phases/methodology-capture.md +13 -0
- package/templates/skills/execute/SKILL.md +0 -1
- package/templates/skills/inbox/SKILL.md +6 -0
- package/templates/skills/investigate/SKILL.md +0 -2
- package/templates/skills/plan/SKILL.md +0 -1
- package/templates/skills/qa-handoff/SKILL.md +283 -0
- package/templates/skills/threads/SKILL.md +14 -8
- package/templates/skills/validate/phases/validators.md +34 -0
- package/templates/watchtower/queue/items/item.json.schema +2 -1
- package/templates/skills/decisions/SKILL.md +0 -13
- package/templates/skills/engagement/SKILL.md +0 -9
- package/templates/skills/engagement-add/SKILL.md +0 -7
- package/templates/skills/engagement-edit/SKILL.md +0 -7
- package/templates/skills/engagement-message/SKILL.md +0 -7
- package/templates/skills/engagement-progress/SKILL.md +0 -7
- package/templates/skills/engagement-status/SKILL.md +0 -7
- 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
|
|
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` +
|
|
@@ -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',
|
package/lib/engagement-setup.js
CHANGED
|
@@ -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
|
@@ -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),
|
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
|
|
@@ -227,19 +234,37 @@ create_worktree() {
|
|
|
227
234
|
}
|
|
228
235
|
}
|
|
229
236
|
|
|
230
|
-
# Copy .claude/ into worktree so CC sees worktree-local paths in
|
|
231
|
-
# system context
|
|
232
|
-
#
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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" ]]
|
|
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
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
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
|
-
|
|
514
|
-
|
|
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
|
-
|
|
533
|
-
|
|
534
|
-
|
|
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
|
|
546
|
-
|
|
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
|
-
|
|
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
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
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
|
-
|
|
577
|
-
|
|
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 │
|