create-claude-cabinet 0.45.0 → 0.46.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/README.md +4 -4
- package/lib/cli.js +26 -0
- package/lib/engagement-server-setup.js +34 -9
- package/lib/migrate-from-omega.js +13 -1
- package/lib/mux-setup.js +33 -9
- package/lib/watchtower-setup.js +210 -0
- package/package.json +5 -1
- package/templates/cabinet/_cabinet-member-template.md +8 -3
- package/templates/cabinet/advisories-state-schema.md +34 -7
- package/templates/cabinet/composition-patterns.md +4 -3
- package/templates/cabinet/skill-output-conventions.md +35 -1
- package/templates/cabinet/watchtower-contracts.md +89 -1
- package/templates/engagement/pib-db-patches/pib-db-lib.mjs +10 -1
- package/templates/mux/__tests__/mux-fail-loud.fixture.sh +44 -0
- package/templates/mux/__tests__/station-liveness.fixture.sh +234 -0
- package/templates/mux/__tests__/station-liveness.test.mjs +47 -0
- package/templates/mux/bin/mux +281 -55
- package/templates/scripts/__tests__/advisor-pass.test.mjs +238 -0
- package/templates/scripts/__tests__/advisories.test.mjs +262 -0
- package/templates/scripts/__tests__/batch-disposition.test.mjs +137 -0
- package/templates/scripts/__tests__/feedback-outbox-flush.test.mjs +232 -0
- package/templates/scripts/__tests__/qa-handoff-gate.test.mjs +68 -0
- package/templates/scripts/__tests__/ring-state-ownership.test.mjs +108 -3
- package/templates/scripts/__tests__/ring2-thread-context.test.mjs +189 -0
- package/templates/scripts/__tests__/ring3-dedup.test.mjs +387 -0
- package/templates/scripts/__tests__/routine-dispatch.test.mjs +312 -0
- package/templates/scripts/watchtower-advisories.mjs +305 -0
- package/templates/scripts/watchtower-build-context.mjs +110 -11
- package/templates/scripts/watchtower-lib.mjs +177 -1
- package/templates/scripts/watchtower-queue.mjs +146 -1
- package/templates/scripts/watchtower-ring1.mjs +129 -9
- package/templates/scripts/watchtower-ring2.mjs +118 -21
- package/templates/scripts/watchtower-ring3-close.mjs +466 -49
- package/templates/scripts/watchtower-routines.mjs +358 -0
- package/templates/scripts/watchtower-status.sh +1 -1
- package/templates/skills/audit/SKILL.md +5 -1
- package/templates/skills/briefing/SKILL.md +342 -234
- package/templates/skills/cabinet-anthropic-insider/SKILL.md +14 -6
- package/templates/skills/cabinet-historian/SKILL.md +14 -11
- package/templates/skills/cabinet-system-advocate/SKILL.md +22 -21
- package/templates/skills/cabinet-user-advocate/SKILL.md +13 -7
- package/templates/skills/cc-publish/SKILL.md +105 -19
- package/templates/skills/debrief/SKILL.md +127 -12
- package/templates/skills/execute/SKILL.md +6 -0
- package/templates/skills/inbox/SKILL.md +67 -6
- package/templates/skills/orient/SKILL.md +69 -47
- package/templates/skills/plan/SKILL.md +8 -0
- package/templates/skills/qa-drain/SKILL.md +119 -0
- package/templates/skills/session-handoff/SKILL.md +175 -6
- package/templates/skills/triage-audit/SKILL.md +6 -0
- package/templates/skills/watchtower/SKILL.md +46 -1
- package/templates/watchtower/config.json.template +3 -1
- package/templates/watchtower/queue/items/item.json.schema +1 -1
package/templates/mux/bin/mux
CHANGED
|
@@ -409,21 +409,40 @@ queue_claude_start() {
|
|
|
409
409
|
win_idx=$(tmux display-message -t "$target" -p '#{window_index}' 2>/dev/null)
|
|
410
410
|
local stable="${sess}:${win_idx}"
|
|
411
411
|
tmux set-window-option -t "$stable" @mux_claude 1 2>/dev/null || true
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
412
|
+
|
|
413
|
+
if [[ -n "$prompt" ]]; then
|
|
414
|
+
# Prompt-bearing launch: hand the prompt to the CLI as its INITIAL-PROMPT
|
|
415
|
+
# argument (`claude "<prompt>"` — interactive session, submits on startup).
|
|
416
|
+
# This auto-submits with zero keystroke timing, and is structurally safe:
|
|
417
|
+
# if `cd && claude` fails (path gone, claude off PATH, crash on boot) the
|
|
418
|
+
# prompt is just an unused argument — it is NEVER executed as shell, the
|
|
419
|
+
# way a paste+Enter into a fallen-back shell prompt would be. The prompt
|
|
420
|
+
# is staged in a per-window file and read by the PANE's shell via
|
|
421
|
+
# $(cat …), so multi-line/quote-heavy content never passes through tmux
|
|
422
|
+
# key parsing. auto_orient=1 prepends the orient instruction INTO the same
|
|
423
|
+
# first message (one message, model-driven orient); auto_orient=0 trusts
|
|
424
|
+
# the prompt to orchestrate its own orient (e.g. `mux handoff`, whose seed
|
|
425
|
+
# runs /orient then the seeded thread — QA drains stay with window 1).
|
|
426
|
+
local seed_dir="${HOME}/.local/share/mux/seed-prompts"
|
|
427
|
+
mkdir -p "$seed_dir" 2>/dev/null || true
|
|
428
|
+
local pf="${seed_dir}/${sess}-${win_idx}.txt"
|
|
420
429
|
if [[ "$auto_orient" == "1" ]]; then
|
|
421
|
-
|
|
422
|
-
|
|
430
|
+
printf 'Run /orient-quick first to load state, then:\n\n%s' "$prompt" > "$pf"
|
|
431
|
+
else
|
|
432
|
+
printf '%s' "$prompt" > "$pf"
|
|
423
433
|
fi
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
434
|
+
tmux send-keys -t "$stable" "cd '${win_path}' && claude \"\$(cat '${pf}')\"" Enter
|
|
435
|
+
else
|
|
436
|
+
# Empty-prompt launch (e.g. QA station relaunch): start plain. On the
|
|
437
|
+
# auto_orient path inject the canned /orient-quick after startup — a
|
|
438
|
+
# benign payload (a harmless command-not-found if it ever lands in a
|
|
439
|
+
# shell), the long-proven behavior, deliberately left unchanged.
|
|
440
|
+
tmux send-keys -t "$stable" "cd '${win_path}' && claude" Enter
|
|
441
|
+
if [[ "$auto_orient" == "1" ]]; then
|
|
442
|
+
( sleep 5; tmux send-keys -t "$stable" "/orient-quick" Enter ) &
|
|
443
|
+
disown
|
|
444
|
+
fi
|
|
445
|
+
fi
|
|
427
446
|
}
|
|
428
447
|
|
|
429
448
|
queue_claude_resume() {
|
|
@@ -436,12 +455,15 @@ queue_claude_resume() {
|
|
|
436
455
|
tmux send-keys -t "$stable" "cd '${win_path}' && claude --resume ${session_id}" Enter
|
|
437
456
|
}
|
|
438
457
|
|
|
439
|
-
# --- QA
|
|
458
|
+
# --- Desk dispatch: QA handoffs + declared routines (stage 2) ---
|
|
440
459
|
#
|
|
441
|
-
# The push model
|
|
442
|
-
#
|
|
443
|
-
#
|
|
444
|
-
#
|
|
460
|
+
# The push model, and the desk's SINGLE dispatch path. Two producers feed it:
|
|
461
|
+
# /qa-handoff packages a just-merged worktree into an inbox item
|
|
462
|
+
# (qa-handoff-protocol.md), and watchtower's routine engine
|
|
463
|
+
# (watchtower-routines.mjs) fires declared interactive routines — both then
|
|
464
|
+
# call `mux qa dispatch <descriptor>`. We route the prompt to the desk's
|
|
465
|
+
# MAIN window (window 1 — the permanent main session that owns post-merge
|
|
466
|
+
# QA / publish / deploy and the desk's standing routines):
|
|
445
467
|
#
|
|
446
468
|
# verified-idle Claude → inject the pickup prompt (send-keys)
|
|
447
469
|
# bare shell (no Claude) → launch a fresh Claude with the prompt
|
|
@@ -454,11 +476,17 @@ queue_claude_resume() {
|
|
|
454
476
|
# of truth; the ·N badge is a pure projection of it, recomputed at every
|
|
455
477
|
# mutation and on desk open, so a tmux restart can't desync the two.
|
|
456
478
|
|
|
457
|
-
MUX_QA_DIR="${HOME}/.local/share/mux/qa-handoff"
|
|
479
|
+
MUX_QA_DIR="${MUX_QA_DIR:-${HOME}/.local/share/mux/qa-handoff}"
|
|
458
480
|
|
|
459
|
-
# A descriptor is the small JSON
|
|
460
|
-
# { project, project_path, item_id,
|
|
461
|
-
#
|
|
481
|
+
# A descriptor is the small JSON a producer writes per dispatch:
|
|
482
|
+
# { project, project_path, item_id, what, pickup_prompt[, merged_commit] }
|
|
483
|
+
# (merged_commit is qa-handoff-only; routine descriptors omit it.) mux only
|
|
484
|
+
# reads project / pickup_prompt / item_id / what / merged_commit.
|
|
485
|
+
#
|
|
486
|
+
# Layout: <desk>/<item_id>.json is queued (counts toward the ·N badge);
|
|
487
|
+
# <desk>/in-flight/<item_id>.json is drained-awaiting-verdict (badge-dark,
|
|
488
|
+
# visible in `mux qa status`). The gate exit in watchtower-queue.mjs deletes
|
|
489
|
+
# the descriptor from either location when a verdict/dismissal lands.
|
|
462
490
|
|
|
463
491
|
qa_count() {
|
|
464
492
|
find "${MUX_QA_DIR}/${1}" -maxdepth 1 -name '*.json' 2>/dev/null | wc -l | tr -d ' '
|
|
@@ -486,14 +514,37 @@ qa_main_window() {
|
|
|
486
514
|
return 1
|
|
487
515
|
}
|
|
488
516
|
|
|
517
|
+
# Positive Claude-liveness verification for a pane whose @mux_claude marker
|
|
518
|
+
# is ABSENT. The marker only exists on windows mux itself launched into
|
|
519
|
+
# (v0.44+), so absence is NOT death evidence — a Claude started by hand, or
|
|
520
|
+
# before the marker scheme, is live but unmarked. Returns 0 only on positive
|
|
521
|
+
# proof: the foreground process looks like Claude's runtime AND the visible
|
|
522
|
+
# pane shows Claude Code's UI footer. Anything ambiguous returns 1 — callers
|
|
523
|
+
# must then refuse to inject, never launch over the pane.
|
|
524
|
+
# Process-name set: claude/node/bun, plus digit-led names — the macOS native
|
|
525
|
+
# installer execs a versioned binary, so a REAL Claude pane reports
|
|
526
|
+
# pane_current_command as the bare version string (e.g. "2.1.169"; live
|
|
527
|
+
# finding, dec-c1dbcd8b QA). The name is only the weak pre-filter; the
|
|
528
|
+
# footer match is the positive verification.
|
|
529
|
+
pane_is_live_claude() {
|
|
530
|
+
local win="$1" cmd snap
|
|
531
|
+
cmd=$(tmux display-message -t "=$win" -p '#{pane_current_command}' 2>/dev/null)
|
|
532
|
+
case "$cmd" in
|
|
533
|
+
claude*|node*|bun*|[0-9]*) ;;
|
|
534
|
+
*) return 1 ;;
|
|
535
|
+
esac
|
|
536
|
+
snap=$(tmux capture-pane -t "=$win" -p -S -30 2>/dev/null)
|
|
537
|
+
printf '%s' "$snap" | grep -qiE 'esc to interrupt|· *[0-9]+ tokens|Running…|Compacting|Thinking…|\? for shortcuts|\? for help'
|
|
538
|
+
}
|
|
539
|
+
|
|
489
540
|
# Classify the main window's pane:
|
|
490
541
|
# claude-idle | claude-busy | shell | other | no-window
|
|
491
|
-
# Conservative by design: anything ambiguous resolves to claude-busy
|
|
492
|
-
# queue instead of injecting (better dark than wrong — the
|
|
493
|
-
# inject path only fire on a positively verified state).
|
|
494
|
-
# markers track Claude Code's footer and may need tuning across
|
|
495
|
-
# `mux qa status` surfaces the detected state so any drift is
|
|
496
|
-
# silent.
|
|
542
|
+
# Conservative by design: anything ambiguous resolves to claude-busy or
|
|
543
|
+
# other, so we queue instead of injecting (better dark than wrong — the
|
|
544
|
+
# indicator and the inject path only fire on a positively verified state).
|
|
545
|
+
# The idle/busy markers track Claude Code's footer and may need tuning across
|
|
546
|
+
# CC versions; `mux qa status` surfaces the detected state so any drift is
|
|
547
|
+
# debuggable, not silent.
|
|
497
548
|
qa_pane_state() {
|
|
498
549
|
local project="$1" win cmd is_claude snap
|
|
499
550
|
win=$(qa_main_window "$project") || { echo "no-window"; return; }
|
|
@@ -502,10 +553,20 @@ qa_pane_state() {
|
|
|
502
553
|
|
|
503
554
|
if [[ "$is_claude" != "1" ]]; then
|
|
504
555
|
case "$cmd" in
|
|
505
|
-
zsh|bash|sh|fish|login|-zsh|-bash|-sh) echo "shell" ;;
|
|
506
|
-
*) echo "other" ;;
|
|
556
|
+
zsh|bash|sh|fish|login|-zsh|-bash|-sh) echo "shell"; return ;;
|
|
507
557
|
esac
|
|
508
|
-
|
|
558
|
+
# No marker but not a shell either: this may be a live pre-marker Claude
|
|
559
|
+
# (act:ca5ac156 — keystrokes injected into its input box looked like
|
|
560
|
+
# "mux new isn't working"). Verify positively and BACKFILL the marker so
|
|
561
|
+
# every later check sees it; otherwise it's an unknown program — report
|
|
562
|
+
# "other" and never treat the pane as launchable.
|
|
563
|
+
if pane_is_live_claude "$win"; then
|
|
564
|
+
tmux set-window-option -t "=$win" @mux_claude 1 2>/dev/null || true
|
|
565
|
+
is_claude=1
|
|
566
|
+
else
|
|
567
|
+
echo "other"
|
|
568
|
+
return
|
|
569
|
+
fi
|
|
509
570
|
fi
|
|
510
571
|
|
|
511
572
|
snap=$(tmux capture-pane -t "=$win" -p -S -30 2>/dev/null)
|
|
@@ -542,11 +603,27 @@ qa_enqueue() {
|
|
|
542
603
|
}
|
|
543
604
|
|
|
544
605
|
# Inject into an idle Claude composer. C-u clears any half-typed input first
|
|
545
|
-
# so we never prepend to whatever was sitting in the box.
|
|
606
|
+
# so we never prepend to whatever was sitting in the box. The caller has
|
|
607
|
+
# already positively verified this pane is a live, idle Claude (qa_pane_state)
|
|
608
|
+
# — this helper does not re-gate; it only delivers the keystrokes.
|
|
609
|
+
#
|
|
610
|
+
# Delivery is via a tmux paste buffer with bracketed-paste mode (-p), not
|
|
611
|
+
# `send-keys -l`: a literal send treats embedded newlines in a multi-line
|
|
612
|
+
# pickup prompt as Enter and submits the prompt mid-way. Bracketed paste makes
|
|
613
|
+
# the TUI insert the whole block as one unit; the trailing Enter submits it.
|
|
614
|
+
# -d deletes the buffer after pasting; the per-call buffer name avoids
|
|
615
|
+
# collisions, and any failure path falls back to the literal send.
|
|
546
616
|
qa_inject() {
|
|
547
|
-
local win="$1" prompt="$2"
|
|
617
|
+
local win="$1" prompt="$2" buf="mux-inject-$$-$RANDOM"
|
|
548
618
|
tmux send-keys -t "=$win" C-u 2>/dev/null || true
|
|
549
|
-
tmux
|
|
619
|
+
if printf '%s' "$prompt" | tmux load-buffer -b "$buf" - 2>/dev/null; then
|
|
620
|
+
tmux paste-buffer -p -d -b "$buf" -t "=$win" 2>/dev/null || {
|
|
621
|
+
tmux delete-buffer -b "$buf" 2>/dev/null || true
|
|
622
|
+
tmux send-keys -t "=$win" -l "$prompt"
|
|
623
|
+
}
|
|
624
|
+
else
|
|
625
|
+
tmux send-keys -t "=$win" -l "$prompt"
|
|
626
|
+
fi
|
|
550
627
|
tmux send-keys -t "=$win" Enter
|
|
551
628
|
}
|
|
552
629
|
|
|
@@ -565,20 +642,35 @@ qa_launch_fresh() {
|
|
|
565
642
|
# checkout that receives post-merge QA handoffs). Idempotent, and the keystone
|
|
566
643
|
# of the standing-station model: because a clean station is guaranteed, every
|
|
567
644
|
# `mux … "prompt"` can safely route its work to a worktree and let the merge
|
|
568
|
-
# hand back to the station.
|
|
569
|
-
# - a live
|
|
645
|
+
# hand back to the station. Four cases, in order:
|
|
646
|
+
# - the main window holds a live Claude — marked, or unmarked-but-verified
|
|
647
|
+
# (qa_pane_state backfills the marker) → reuse it (no-op)
|
|
570
648
|
# - the main window is a bare shell (window 1's Claude died) → relaunch there
|
|
649
|
+
# - the main window runs some OTHER program → refuse LOUDLY; never type
|
|
650
|
+
# launch keystrokes into a pane that isn't positively a shell (the
|
|
651
|
+
# act:ca5ac156 injection bug — a live pre-marker Claude got the launch
|
|
652
|
+
# sequence typed into its input box)
|
|
571
653
|
# - no non-worktree window at all (defensive) → create window 1, launch there
|
|
572
654
|
# Never touches worktree windows. Reuses qa_main_window (live-station-preferring
|
|
573
|
-
# resolution)
|
|
655
|
+
# resolution), qa_pane_state (the single pane classifier), and qa_launch_fresh
|
|
656
|
+
# (the single launch path) — no forked logic.
|
|
574
657
|
ensure_main_station() {
|
|
575
|
-
local project="$1" path="$2" station
|
|
658
|
+
local project="$1" path="$2" station state cmd
|
|
576
659
|
station=$(qa_main_window "$project" 2>/dev/null || true)
|
|
577
660
|
if [[ -n "$station" ]]; then
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
661
|
+
state=$(qa_pane_state "$project")
|
|
662
|
+
case "$state" in
|
|
663
|
+
claude-idle|claude-busy)
|
|
664
|
+
return 0 ;;
|
|
665
|
+
shell)
|
|
666
|
+
qa_launch_fresh "$station" "" "$project"
|
|
667
|
+
return 0 ;;
|
|
668
|
+
*)
|
|
669
|
+
cmd=$(tmux display-message -t "=$station" -p '#{pane_current_command}' 2>/dev/null)
|
|
670
|
+
printf "mux: main window %s is running '%s' — can't verify it's Claude or a shell, so no station was launched there (close that program or start claude yourself)\n" \
|
|
671
|
+
"$station" "${cmd:-unknown}" >&2
|
|
672
|
+
return 0 ;;
|
|
673
|
+
esac
|
|
582
674
|
fi
|
|
583
675
|
tmux new-window -t "=$project" -c "$path" 2>/dev/null || true
|
|
584
676
|
station=$(qa_main_window "$project" 2>/dev/null || true)
|
|
@@ -1366,30 +1458,163 @@ qa_cmd_list() {
|
|
|
1366
1458
|
python3 -c 'import json,sys
|
|
1367
1459
|
d=json.load(open(sys.argv[1]))
|
|
1368
1460
|
what=d.get("what","(no summary)")
|
|
1369
|
-
sha=
|
|
1461
|
+
sha=d.get("merged_commit")
|
|
1370
1462
|
iid=d.get("item_id","?")
|
|
1371
|
-
|
|
1463
|
+
tag=f" [merged {str(sha)[:8]}]" if sha else ""
|
|
1464
|
+
print(f" - {what}{tag} {iid}")' "$f" 2>/dev/null
|
|
1372
1465
|
done < <(ls -1tr "${MUX_QA_DIR}/${project}"/*.json 2>/dev/null)
|
|
1466
|
+
local inflight_n
|
|
1467
|
+
inflight_n=$(find "${MUX_QA_DIR}/${project}/in-flight" -maxdepth 1 -name '*.json' 2>/dev/null | wc -l | tr -d ' ')
|
|
1468
|
+
[[ "$inflight_n" -gt 0 ]] && echo "${inflight_n} in flight (drained, awaiting verdict — restored on next drain if no verdict lands)"
|
|
1373
1469
|
echo ""
|
|
1374
1470
|
echo "Drain the oldest into this session: mux qa drain"
|
|
1375
1471
|
}
|
|
1376
1472
|
|
|
1377
|
-
#
|
|
1473
|
+
# Cross-check a dispatch descriptor's inbox item against the watchtower
|
|
1474
|
+
# queue (act:796fe6dc — the dispatch queue and the inbox drift BOTH ways).
|
|
1475
|
+
# Echoes: pending | not-pending | missing | unknown | no-watchtower.
|
|
1476
|
+
# "unknown" (unparseable item) errs toward offering the handoff — the
|
|
1477
|
+
# recipient gate sorts it out; only a positively non-pending item is a ghost.
|
|
1478
|
+
qa_item_status() {
|
|
1479
|
+
local item_id="$1"
|
|
1480
|
+
local wt_items="${WATCHTOWER_DIR:-$HOME/.claude-cabinet/watchtower}/queue/items"
|
|
1481
|
+
[[ -d "$wt_items" ]] || { echo "no-watchtower"; return; }
|
|
1482
|
+
local f="${wt_items}/${item_id}.json"
|
|
1483
|
+
[[ -f "$f" ]] || { echo "missing"; return; }
|
|
1484
|
+
local st
|
|
1485
|
+
st=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get("status",""))' "$f" 2>/dev/null || true)
|
|
1486
|
+
case "$st" in
|
|
1487
|
+
pending) echo "pending" ;;
|
|
1488
|
+
"") echo "unknown" ;;
|
|
1489
|
+
*) echo "not-pending" ;;
|
|
1490
|
+
esac
|
|
1491
|
+
}
|
|
1492
|
+
|
|
1493
|
+
qa_descriptor_item_id() {
|
|
1494
|
+
python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get("item_id") or "")' "$1" 2>/dev/null || true
|
|
1495
|
+
}
|
|
1496
|
+
|
|
1497
|
+
# Offer the oldest queued handoff and print its pickup prompt to stdout. The
|
|
1378
1498
|
# window-1 Claude session runs this (via the Bash tool) and acts on the
|
|
1379
|
-
# prompt it gets back — the prompt IS the instruction.
|
|
1380
|
-
#
|
|
1499
|
+
# prompt it gets back — the prompt IS the instruction.
|
|
1500
|
+
#
|
|
1501
|
+
# Hardened against two-way dispatch/inbox drift (act:796fe6dc):
|
|
1502
|
+
# - Descriptors whose inbox item is no longer pending are GHOSTS (the
|
|
1503
|
+
# verdict was filed out-of-band) — skipped and removed, never offered.
|
|
1504
|
+
# - The offered descriptor moves to in-flight/ instead of rm -f, so a
|
|
1505
|
+
# session that dies without filing a verdict doesn't eat the handoff:
|
|
1506
|
+
# the next drain restores still-pending in-flight entries to the queue.
|
|
1507
|
+
# The gate exit (resolveItem/dismiss/supersede in watchtower-queue.mjs)
|
|
1508
|
+
# clears the in-flight entry when the verdict lands.
|
|
1509
|
+
# - When the dispatch queue is empty, falls back to walking the inbox for
|
|
1510
|
+
# pending dispatched-category items (qa-handoff, routine), so the pull
|
|
1511
|
+
# path works even when the push path was never used (or its descriptor
|
|
1512
|
+
# was lost).
|
|
1381
1513
|
qa_cmd_drain() {
|
|
1382
1514
|
local project="${1:-$(tmux display-message -p '#{session_name}' 2>/dev/null)}"
|
|
1383
1515
|
[[ -n "$project" ]] || die "mux qa drain: no project (run inside a desk or pass a name)"
|
|
1516
|
+
local qdir="${MUX_QA_DIR}/${project}" inflight="${MUX_QA_DIR}/${project}/in-flight"
|
|
1517
|
+
local f iid st
|
|
1518
|
+
|
|
1519
|
+
# 1. Sweep in-flight: a prior drain parked these. Verdict filed → done,
|
|
1520
|
+
# drop. Still pending (or un-checkable) → restore to the queue so this
|
|
1521
|
+
# drain can re-offer it — drained work can't silently vanish.
|
|
1522
|
+
if [[ -d "$inflight" ]]; then
|
|
1523
|
+
for f in "$inflight"/*.json; do
|
|
1524
|
+
[[ -f "$f" ]] || continue
|
|
1525
|
+
iid=$(qa_descriptor_item_id "$f")
|
|
1526
|
+
if [[ -z "$iid" ]]; then mv -f "$f" "${qdir}/"; continue; fi
|
|
1527
|
+
st=$(qa_item_status "$iid")
|
|
1528
|
+
case "$st" in
|
|
1529
|
+
not-pending|missing) rm -f "$f" ;;
|
|
1530
|
+
*) mv -f "$f" "${qdir}/" ;;
|
|
1531
|
+
esac
|
|
1532
|
+
done
|
|
1533
|
+
fi
|
|
1534
|
+
|
|
1535
|
+
# 2. Walk the queue oldest-first, skipping resolved ghosts.
|
|
1384
1536
|
local oldest
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1537
|
+
while :; do
|
|
1538
|
+
# `|| true`: under pipefail an empty glob makes ls fail and would
|
|
1539
|
+
# silently kill the whole script mid-drain.
|
|
1540
|
+
oldest=$(ls -1tr "${qdir}"/*.json 2>/dev/null | head -1 || true)
|
|
1541
|
+
[[ -z "$oldest" ]] && break
|
|
1542
|
+
iid=$(qa_descriptor_item_id "$oldest")
|
|
1543
|
+
if [[ -n "$iid" ]]; then
|
|
1544
|
+
st=$(qa_item_status "$iid")
|
|
1545
|
+
case "$st" in
|
|
1546
|
+
not-pending)
|
|
1547
|
+
echo "… skipped ${iid}: already resolved in the inbox (ghost dispatch removed)" >&2
|
|
1548
|
+
rm -f "$oldest"; continue ;;
|
|
1549
|
+
missing)
|
|
1550
|
+
echo "… skipped ${iid}: no matching inbox item (stale dispatch removed)" >&2
|
|
1551
|
+
rm -f "$oldest"; continue ;;
|
|
1552
|
+
esac
|
|
1553
|
+
fi
|
|
1554
|
+
python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get("pickup_prompt",""))' "$oldest" 2>/dev/null || true
|
|
1555
|
+
mkdir -p "$inflight"
|
|
1556
|
+
mv -f "$oldest" "${inflight}/"
|
|
1557
|
+
qa_refresh_indicator "$project"
|
|
1388
1558
|
return 0
|
|
1389
|
-
|
|
1390
|
-
python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get("pickup_prompt",""))' "$oldest" 2>/dev/null
|
|
1391
|
-
rm -f "$oldest"
|
|
1559
|
+
done
|
|
1392
1560
|
qa_refresh_indicator "$project"
|
|
1561
|
+
|
|
1562
|
+
# 3. Dispatch queue is dry — fall back to the inbox itself: any pending
|
|
1563
|
+
# dispatched-category item (qa-handoff or routine) for this desk is real
|
|
1564
|
+
# debt even if no descriptor was ever dispatched (or it was lost). Match
|
|
1565
|
+
# by desk name, project name, or the desk's MUX_PROJECT_PATH. Oldest
|
|
1566
|
+
# first; surface only. qa-handoffs before routines: QA debt outranks a
|
|
1567
|
+
# missed routine.
|
|
1568
|
+
local wt_items="${WATCHTOWER_DIR:-$HOME/.claude-cabinet/watchtower}/queue/items"
|
|
1569
|
+
if [[ -d "$wt_items" ]]; then
|
|
1570
|
+
local ppath
|
|
1571
|
+
ppath=$(tmux show-environment -t "=$project" MUX_PROJECT_PATH 2>/dev/null | sed 's/^MUX_PROJECT_PATH=//' || true)
|
|
1572
|
+
if python3 -c '
|
|
1573
|
+
import json, sys, os, glob
|
|
1574
|
+
items_dir, desk, ppath = sys.argv[1], sys.argv[2], sys.argv[3]
|
|
1575
|
+
cands = []
|
|
1576
|
+
for f in glob.glob(os.path.join(items_dir, "*.json")):
|
|
1577
|
+
try:
|
|
1578
|
+
d = json.load(open(f))
|
|
1579
|
+
except Exception:
|
|
1580
|
+
continue
|
|
1581
|
+
if d.get("category") not in ("qa-handoff", "routine") or d.get("status") != "pending":
|
|
1582
|
+
continue
|
|
1583
|
+
if not (d.get("desk") == desk or d.get("project") == desk
|
|
1584
|
+
or (ppath and d.get("project_path") == ppath)):
|
|
1585
|
+
continue
|
|
1586
|
+
cands.append(d)
|
|
1587
|
+
if not cands:
|
|
1588
|
+
sys.exit(3)
|
|
1589
|
+
cands.sort(key=lambda d: (0 if d.get("category") == "qa-handoff" else 1,
|
|
1590
|
+
d.get("filed_at", "")))
|
|
1591
|
+
d = cands[0]
|
|
1592
|
+
iid = str(d.get("id", ""))
|
|
1593
|
+
title = str(d.get("title", ""))
|
|
1594
|
+
path = os.path.join(items_dir, iid + ".json")
|
|
1595
|
+
more = ""
|
|
1596
|
+
if len(cands) > 1:
|
|
1597
|
+
more = " (%d more pending in the inbox)" % (len(cands) - 1)
|
|
1598
|
+
if d.get("category") == "qa-handoff":
|
|
1599
|
+
print("Pending QA handoff found in the watchtower inbox with no dispatch entry"
|
|
1600
|
+
" (the push path was missed or its descriptor was lost): %s -- %s."
|
|
1601
|
+
" Read the full item at %s and run the /qa-drain recipient-gate pickup on it:"
|
|
1602
|
+
" verify what the worktree could not, then exit through the gate"
|
|
1603
|
+
" (resolveItem with a structured qa_verdict; dismiss/supersede require typed"
|
|
1604
|
+
" reasons). It cannot leave the inbox silently.%s" % (iid, title, path, more))
|
|
1605
|
+
else:
|
|
1606
|
+
print("Pending routine found in the watchtower inbox with no dispatch entry"
|
|
1607
|
+
" (the push path was missed or its descriptor was lost): %s -- %s."
|
|
1608
|
+
" Read the full item at %s, then read the routine script it names"
|
|
1609
|
+
" (evidence.script, relative to project_path) and run it as the"
|
|
1610
|
+
" conversation script. When done, resolve the item via watchtower-queue"
|
|
1611
|
+
" resolveItem (resolution_type acted-on).%s" % (iid, title, path, more))
|
|
1612
|
+
' "$wt_items" "$project" "$ppath" 2>/dev/null; then
|
|
1613
|
+
return 0
|
|
1614
|
+
fi
|
|
1615
|
+
fi
|
|
1616
|
+
|
|
1617
|
+
echo "No QA handoffs queued for ${project}."
|
|
1393
1618
|
}
|
|
1394
1619
|
|
|
1395
1620
|
qa_cmd_status() {
|
|
@@ -1398,14 +1623,15 @@ qa_cmd_status() {
|
|
|
1398
1623
|
printf 'QA handoff — %s\n' "$project"
|
|
1399
1624
|
printf ' window 1 pane: %s\n' "$(qa_pane_state "$project")"
|
|
1400
1625
|
printf ' queued: %s\n' "$(qa_count "$project")"
|
|
1626
|
+
printf ' in-flight: %s\n' "$(find "${MUX_QA_DIR}/${project}/in-flight" -maxdepth 1 -name '*.json' 2>/dev/null | wc -l | tr -d ' ')"
|
|
1401
1627
|
}
|
|
1402
1628
|
|
|
1403
1629
|
qa_cmd_clear() {
|
|
1404
1630
|
local project="${1:-$(tmux display-message -p '#{session_name}' 2>/dev/null)}"
|
|
1405
1631
|
[[ -n "$project" ]] || die "mux qa clear: no project (run inside a desk or pass a name)"
|
|
1406
|
-
rm -f "${MUX_QA_DIR}/${project}"/*.json 2>/dev/null || true
|
|
1632
|
+
rm -f "${MUX_QA_DIR}/${project}"/*.json "${MUX_QA_DIR}/${project}/in-flight"/*.json 2>/dev/null || true
|
|
1407
1633
|
qa_refresh_indicator "$project"
|
|
1408
|
-
echo "Cleared queued QA handoffs for ${project}."
|
|
1634
|
+
echo "Cleared queued QA handoffs for ${project} (including in-flight)."
|
|
1409
1635
|
}
|
|
1410
1636
|
|
|
1411
1637
|
# --- Session handoff (session-handoff skill, phase 2) ---
|