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.
Files changed (53) hide show
  1. package/README.md +4 -4
  2. package/lib/cli.js +26 -0
  3. package/lib/engagement-server-setup.js +34 -9
  4. package/lib/migrate-from-omega.js +13 -1
  5. package/lib/mux-setup.js +33 -9
  6. package/lib/watchtower-setup.js +210 -0
  7. package/package.json +5 -1
  8. package/templates/cabinet/_cabinet-member-template.md +8 -3
  9. package/templates/cabinet/advisories-state-schema.md +34 -7
  10. package/templates/cabinet/composition-patterns.md +4 -3
  11. package/templates/cabinet/skill-output-conventions.md +35 -1
  12. package/templates/cabinet/watchtower-contracts.md +89 -1
  13. package/templates/engagement/pib-db-patches/pib-db-lib.mjs +10 -1
  14. package/templates/mux/__tests__/mux-fail-loud.fixture.sh +44 -0
  15. package/templates/mux/__tests__/station-liveness.fixture.sh +234 -0
  16. package/templates/mux/__tests__/station-liveness.test.mjs +47 -0
  17. package/templates/mux/bin/mux +281 -55
  18. package/templates/scripts/__tests__/advisor-pass.test.mjs +238 -0
  19. package/templates/scripts/__tests__/advisories.test.mjs +262 -0
  20. package/templates/scripts/__tests__/batch-disposition.test.mjs +137 -0
  21. package/templates/scripts/__tests__/feedback-outbox-flush.test.mjs +232 -0
  22. package/templates/scripts/__tests__/qa-handoff-gate.test.mjs +68 -0
  23. package/templates/scripts/__tests__/ring-state-ownership.test.mjs +108 -3
  24. package/templates/scripts/__tests__/ring2-thread-context.test.mjs +189 -0
  25. package/templates/scripts/__tests__/ring3-dedup.test.mjs +387 -0
  26. package/templates/scripts/__tests__/routine-dispatch.test.mjs +312 -0
  27. package/templates/scripts/watchtower-advisories.mjs +305 -0
  28. package/templates/scripts/watchtower-build-context.mjs +110 -11
  29. package/templates/scripts/watchtower-lib.mjs +177 -1
  30. package/templates/scripts/watchtower-queue.mjs +146 -1
  31. package/templates/scripts/watchtower-ring1.mjs +129 -9
  32. package/templates/scripts/watchtower-ring2.mjs +118 -21
  33. package/templates/scripts/watchtower-ring3-close.mjs +466 -49
  34. package/templates/scripts/watchtower-routines.mjs +358 -0
  35. package/templates/scripts/watchtower-status.sh +1 -1
  36. package/templates/skills/audit/SKILL.md +5 -1
  37. package/templates/skills/briefing/SKILL.md +342 -234
  38. package/templates/skills/cabinet-anthropic-insider/SKILL.md +14 -6
  39. package/templates/skills/cabinet-historian/SKILL.md +14 -11
  40. package/templates/skills/cabinet-system-advocate/SKILL.md +22 -21
  41. package/templates/skills/cabinet-user-advocate/SKILL.md +13 -7
  42. package/templates/skills/cc-publish/SKILL.md +105 -19
  43. package/templates/skills/debrief/SKILL.md +127 -12
  44. package/templates/skills/execute/SKILL.md +6 -0
  45. package/templates/skills/inbox/SKILL.md +67 -6
  46. package/templates/skills/orient/SKILL.md +69 -47
  47. package/templates/skills/plan/SKILL.md +8 -0
  48. package/templates/skills/qa-drain/SKILL.md +119 -0
  49. package/templates/skills/session-handoff/SKILL.md +175 -6
  50. package/templates/skills/triage-audit/SKILL.md +6 -0
  51. package/templates/skills/watchtower/SKILL.md +46 -1
  52. package/templates/watchtower/config.json.template +3 -1
  53. package/templates/watchtower/queue/items/item.json.schema +1 -1
@@ -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
- tmux send-keys -t "$stable" "cd '${win_path}' && claude" Enter
413
- # auto_orient=1 (default): inject the canned /orient-quick before the prompt
414
- # the standard "start a session" sequence. auto_orient=0: inject ONLY the
415
- # prompt, for callers whose prompt orchestrates its own orient (e.g.
416
- # `mux handoff`, whose seed runs /orient then the seeded work thread —
417
- # QA drains stay with window 1's standing station, never the seed).
418
- (
419
- sleep 5
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
- tmux send-keys -t "$stable" "/orient-quick" Enter
422
- sleep 2
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
- [[ -n "$prompt" ]] && tmux send-keys -t "$stable" -l "$prompt"
425
- ) &
426
- disown
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 handoff dispatch (qa-handoff-protocol.md, stage 2) ---
458
+ # --- Desk dispatch: QA handoffs + declared routines (stage 2) ---
440
459
  #
441
- # The push model. /qa-handoff packages a just-merged worktree into an inbox
442
- # item, then calls `mux qa dispatch <descriptor>`. We route that handoff to
443
- # the desk's MAIN window (window 1 — the permanent main session that owns
444
- # post-merge QA / publish / deploy):
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 /qa-handoff writes per merge:
460
- # { project, project_path, item_id, merged_commit, what, pickup_prompt }
461
- # mux only reads project / pickup_prompt / item_id / what / merged_commit.
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, so we
492
- # queue instead of injecting (better dark than wrong — the indicator and the
493
- # inject path only fire on a positively verified state). The idle/busy
494
- # markers track Claude Code's footer and may need tuning across CC versions;
495
- # `mux qa status` surfaces the detected state so any drift is debuggable, not
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
- return
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 send-keys -t "=$win" -l "$prompt"
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. Three cases, in order:
569
- # - a live non-worktree Claude already exists reuse it (no-op)
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) and qa_launch_fresh (the single launch path) no forked logic.
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 claude
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
- claude=$(tmux show-window-option -t "=$station" -v @mux_claude 2>/dev/null)
579
- [[ "$claude" == "1" ]] && return 0
580
- qa_launch_fresh "$station" "" "$project"
581
- return 0
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=str(d.get("merged_commit","?"))[:8]
1461
+ sha=d.get("merged_commit")
1370
1462
  iid=d.get("item_id","?")
1371
- print(f" - {what} [merged {sha}] {iid}")' "$f" 2>/dev/null
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
- # Pop the oldest queued handoff and print its pickup prompt to stdout. The
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. Removing the
1380
- # descriptor decrements the badge.
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
- oldest=$(ls -1tr "${MUX_QA_DIR}/${project}"/*.json 2>/dev/null | head -1)
1386
- if [[ -z "$oldest" ]]; then
1387
- echo "No QA handoffs queued for ${project}."
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
- fi
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) ---