create-claude-cabinet 0.48.0 → 0.49.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 (48) hide show
  1. package/lib/CLAUDE.md +22 -0
  2. package/lib/cli.js +100 -5
  3. package/lib/metadata.js +20 -0
  4. package/package.json +1 -1
  5. package/templates/CLAUDE.md +212 -12
  6. package/templates/cabinet/watchtower-contracts.md +85 -0
  7. package/templates/cabinet/worktree-invocation-contract.md +35 -0
  8. package/templates/engagement/pib-db-patches/pib-db-lib.mjs +53 -9
  9. package/templates/mux/__tests__/mux-fail-loud.fixture.sh +4 -5
  10. package/templates/mux/__tests__/worktree-lifecycle.fixture.sh +214 -0
  11. package/templates/mux/__tests__/worktree-lifecycle.test.mjs +44 -0
  12. package/templates/mux/__tests__/worktree-status-clean.fixture.sh +245 -0
  13. package/templates/mux/__tests__/worktree-status-clean.test.mjs +89 -0
  14. package/templates/mux/bin/mux +44 -28
  15. package/templates/mux/config/worktree-cleanup.sh +57 -12
  16. package/templates/mux/config/worktree-dirty-check.sh +72 -2
  17. package/templates/mux/config/worktree-session-health.sh +401 -17
  18. package/templates/scripts/__tests__/assessment-recalibration.test.mjs +387 -0
  19. package/templates/scripts/__tests__/batch-disposition.test.mjs +19 -0
  20. package/templates/scripts/__tests__/branch-diverged-reconcile.test.mjs +374 -0
  21. package/templates/scripts/__tests__/completion-review-reconcile.test.mjs +173 -0
  22. package/templates/scripts/__tests__/cross-ring-reader.test.mjs +12 -4
  23. package/templates/scripts/__tests__/detector-registry.test.mjs +152 -0
  24. package/templates/scripts/__tests__/draft-surfacing.test.mjs +433 -0
  25. package/templates/scripts/__tests__/generated-state-classification.test.mjs +228 -0
  26. package/templates/scripts/__tests__/mirror-parity.test.mjs +54 -0
  27. package/templates/scripts/__tests__/resolve-project-slug.test.mjs +186 -0
  28. package/templates/scripts/__tests__/ring3-attribution.test.mjs +267 -0
  29. package/templates/scripts/__tests__/ring3-completion-filter.test.mjs +176 -0
  30. package/templates/scripts/__tests__/ring3-coverage-debt.test.mjs +377 -0
  31. package/templates/scripts/__tests__/ring3-last-session-pointer.test.mjs +80 -0
  32. package/templates/scripts/audit-coherence-check.mjs +6 -2
  33. package/templates/scripts/load-triage-history.js +23 -2
  34. package/templates/scripts/merge-findings.js +9 -2
  35. package/templates/scripts/pib-db-lib.mjs +55 -7
  36. package/templates/scripts/watchtower-cross-ring-reader.mjs +20 -3
  37. package/templates/scripts/watchtower-inbox-assessment.mjs +186 -35
  38. package/templates/scripts/watchtower-lib.mjs +268 -5
  39. package/templates/scripts/watchtower-queue.mjs +197 -2
  40. package/templates/scripts/watchtower-ring1.mjs +490 -69
  41. package/templates/scripts/watchtower-ring2.mjs +175 -3
  42. package/templates/scripts/watchtower-ring3-close.mjs +519 -69
  43. package/templates/skills/audit/SKILL.md +6 -3
  44. package/templates/skills/cabinet-anthropic-insider/SKILL.md +29 -8
  45. package/templates/skills/cabinet-process-therapist/SKILL.md +45 -3
  46. package/templates/skills/inbox/SKILL.md +29 -1
  47. package/templates/skills/session-handoff/SKILL.md +6 -8
  48. package/templates/watchtower/config.json.template +2 -1
@@ -0,0 +1,89 @@
1
+ // Guard test for act:c008862c (mux half, Lane E of grp:wt-noise-immunity) —
2
+ // the worktree "MERGE OR LOSE" false-positive class.
3
+ //
4
+ // Drives worktree-status-clean.fixture.sh (throwaway repos under mktemp,
5
+ // HOME + GIT_CONFIG_GLOBAL + XDG_CONFIG_HOME overridden — never touches a
6
+ // live worktree or real git config).
7
+ //
8
+ // The rules under test:
9
+ // - a fresh mux worktree shows CLEAN git status: no .mcp.json/.claudeignore
10
+ // typechange (skip-worktree while the path is a mux symlink), no
11
+ // node_modules/pib.db symlink churn, no .claude/ infra (the per-worktree
12
+ // managed exclude is made LIVE via extensions.worktreeConfig +
13
+ // core.excludesFile — git never reads worktrees/<name>/info/exclude
14
+ // natively, which is why the old rules were silently dead)
15
+ // - `git add -A` stages nothing on a churn-only worktree (the ELOOP class)
16
+ // - authored .claude/ files and real commits still surface and still count
17
+ // (dirty-check: uncommitted/commits > 0 → dirty)
18
+ // - machine-written generated state (advisories-state.json,
19
+ // checklist-stats.json, verification/) inside tracked-holding .claude/
20
+ // dirs is churn, not work — hidden from status AND excluded from the
21
+ // dirty-check's authored re-count
22
+ // - a REAL .mcp.json edit is never frozen: the skip-worktree bit clears
23
+ // the moment the path holds a real file (the assume-unchanged lesson)
24
+ // - the user's global ignore patterns survive the excludesFile
25
+ // displacement (copied into the managed block each rebuild)
26
+
27
+ import { test } from 'node:test';
28
+ import assert from 'node:assert';
29
+ import { readFileSync } from 'node:fs';
30
+ import { spawnSync } from 'node:child_process';
31
+ import { fileURLToPath } from 'node:url';
32
+ import path from 'node:path';
33
+
34
+ const here = path.dirname(fileURLToPath(import.meta.url));
35
+ const fixture = path.join(here, 'worktree-status-clean.fixture.sh');
36
+ const repoRoot = path.resolve(here, '..', '..', '..');
37
+
38
+ test('worktree MERGE-OR-LOSE noise immunity: clean status, honest dirt (sandboxed fixture suite)', () => {
39
+ const res = spawnSync('bash', [fixture], { encoding: 'utf8', timeout: 120_000 });
40
+ const output = `${res.stdout}\n${res.stderr}`;
41
+ assert.strictEqual(
42
+ res.status,
43
+ 0,
44
+ `fixture suite reported failures:\n${output}`
45
+ );
46
+ assert.match(output, /RESULT: \d+ passed, 0 failed/);
47
+ });
48
+
49
+ // The generated-state name set is deliberately spelled in more than one
50
+ // implementation (bash can't import the JS lib): the health script's managed
51
+ // exclude block, the dirty-check's GENERATED_STATE_RE, and — once Lane A's
52
+ // half of act:c008862c lands — watchtower-lib.mjs's disposable-churn
53
+ // classification. Silent drift between them re-opens the MERGE-OR-LOSE
54
+ // false-positive class, so the agreement is enforced here rather than by a
55
+ // keep-in-step comment (the enforcement-pipeline lesson: a comment is the
56
+ // weakest layer). The lib leg self-arms: it is asserted only once the lib
57
+ // mentions the first name, so this test does not depend on lane ordering.
58
+ test('generated-state names agree across their sibling implementations', () => {
59
+ const NAMES = [
60
+ '.claude/cabinet/advisories-state.json',
61
+ '.claude/cabinet/checklist-stats.json',
62
+ '.claude/verification/',
63
+ 'e2e/.verify-progress.jsonl',
64
+ ];
65
+ const health = readFileSync(
66
+ path.join(repoRoot, 'templates/mux/config/worktree-session-health.sh'), 'utf8');
67
+ const dirty = readFileSync(
68
+ path.join(repoRoot, 'templates/mux/config/worktree-dirty-check.sh'), 'utf8');
69
+
70
+ for (const name of NAMES) {
71
+ assert.ok(health.includes(`'${name}'`),
72
+ `worktree-session-health.sh managed exclude block must carry ${name}`);
73
+ }
74
+ const reLine = dirty.match(/^GENERATED_STATE_RE='(.*)'$/m);
75
+ assert.ok(reLine, 'worktree-dirty-check.sh must define GENERATED_STATE_RE');
76
+ for (const short of ['advisories-state', 'checklist-stats', 'verification/', 'verify-progress']) {
77
+ assert.ok(reLine[1].includes(short),
78
+ `GENERATED_STATE_RE must cover ${short}`);
79
+ }
80
+
81
+ const lib = readFileSync(
82
+ path.join(repoRoot, 'templates/scripts/watchtower-lib.mjs'), 'utf8');
83
+ if (lib.includes('advisories-state')) {
84
+ for (const short of ['advisories-state', 'checklist-stats', 'verification', 'verify-progress']) {
85
+ assert.ok(lib.includes(short),
86
+ `watchtower-lib.mjs names generated state but is missing ${short} — the three sibling lists have drifted`);
87
+ }
88
+ }
89
+ });
@@ -284,6 +284,17 @@ worktree_health_check() {
284
284
  "${HOME}/.config/mux/worktree-session-health.sh" "$proj_path" "$wt_path" "$@" 2>&1
285
285
  }
286
286
 
287
+ # Reap the lane's docker containers BEFORE the worktree dir (the compose
288
+ # project name source) disappears. Single implementation in
289
+ # worktree-session-health.sh --reap (exact compose-label match, containers
290
+ # only, never volumes); the pane-exited worktree-cleanup.sh hook delegates to
291
+ # the same mode, so every removal path reaps from one implementation.
292
+ # Fail-open: a failed reap reports loudly but never blocks the removal.
293
+ worktree_reap_containers() {
294
+ local proj_path="$1" wt_path="$2"
295
+ "${HOME}/.config/mux/worktree-session-health.sh" "$proj_path" "$wt_path" --reap 2>&1 || true
296
+ }
297
+
287
298
  worktree_cleanup() {
288
299
  local project="$1" task_slug="$2"
289
300
  local proj_path
@@ -325,6 +336,7 @@ worktree_cleanup() {
325
336
  done
326
337
 
327
338
  if [[ "$verdict" == "clean" ]]; then
339
+ worktree_reap_containers "$proj_path" "$wt_path"
328
340
  git -C "$proj_path" worktree remove "$wt_path" 2>/dev/null || rm -rf "$wt_path"
329
341
  git -C "$proj_path" branch -d "$branch_name" 2>/dev/null || true
330
342
  worktree_registry_remove "$project" "$task_slug"
@@ -357,6 +369,7 @@ worktree_cleanup() {
357
369
  local main_branch
358
370
  main_branch=$(git -C "$proj_path" rev-parse --abbrev-ref HEAD)
359
371
  if git -C "$proj_path" merge "$branch_name" --no-edit 2>/dev/null; then
372
+ worktree_reap_containers "$proj_path" "$wt_path"
360
373
  git -C "$proj_path" worktree remove "$wt_path" 2>/dev/null || rm -rf "$wt_path"
361
374
  git -C "$proj_path" branch -d "$branch_name" 2>/dev/null || true
362
375
  worktree_registry_remove "$project" "$task_slug"
@@ -373,6 +386,7 @@ worktree_cleanup() {
373
386
  git -C "$wt_path" add -A
374
387
  git -C "$wt_path" commit -m "mux: parked work from ${task_slug} session"
375
388
  fi
389
+ worktree_reap_containers "$proj_path" "$wt_path"
376
390
  git -C "$proj_path" worktree remove "$wt_path" 2>/dev/null || rm -rf "$wt_path"
377
391
  worktree_registry_remove "$project" "$task_slug"
378
392
  echo "Branch '${branch_name}' parked."
@@ -384,6 +398,7 @@ worktree_cleanup() {
384
398
  read -r confirm
385
399
  case "$confirm" in
386
400
  y|Y)
401
+ worktree_reap_containers "$proj_path" "$wt_path"
387
402
  git -C "$proj_path" worktree remove --force "$wt_path" 2>/dev/null || rm -rf "$wt_path"
388
403
  git -C "$proj_path" branch -D "$branch_name" 2>/dev/null || true
389
404
  worktree_registry_remove "$project" "$task_slug"
@@ -402,7 +417,7 @@ worktree_cleanup() {
402
417
  }
403
418
 
404
419
  queue_claude_start() {
405
- local target="$1" prompt="$2" win_path="$3" auto_orient="${4:-1}"
420
+ local target="$1" prompt="$2" win_path="$3"
406
421
  # Resolve window index immediately (names can be unreliable in background)
407
422
  local sess win_idx
408
423
  sess=$(tmux display-message -t "$target" -p '#{session_name}' 2>/dev/null)
@@ -410,6 +425,9 @@ queue_claude_start() {
410
425
  local stable="${sess}:${win_idx}"
411
426
  tmux set-window-option -t "$stable" @mux_claude 1 2>/dev/null || true
412
427
 
428
+ # No orient injection: /orient is retired. Session-start state loading is
429
+ # the watchtower SessionStart hook's job (ambient injection); mux launches
430
+ # the session and hands it the prompt, nothing more.
413
431
  if [[ -n "$prompt" ]]; then
414
432
  # Prompt-bearing launch: hand the prompt to the CLI as its INITIAL-PROMPT
415
433
  # argument (`claude "<prompt>"` — interactive session, submits on startup).
@@ -419,29 +437,15 @@ queue_claude_start() {
419
437
  # way a paste+Enter into a fallen-back shell prompt would be. The prompt
420
438
  # is staged in a per-window file and read by the PANE's shell via
421
439
  # $(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).
440
+ # key parsing.
426
441
  local seed_dir="${HOME}/.local/share/mux/seed-prompts"
427
442
  mkdir -p "$seed_dir" 2>/dev/null || true
428
443
  local pf="${seed_dir}/${sess}-${win_idx}.txt"
429
- if [[ "$auto_orient" == "1" ]]; then
430
- printf 'Run /orient-quick first to load state, then:\n\n%s' "$prompt" > "$pf"
431
- else
432
- printf '%s' "$prompt" > "$pf"
433
- fi
444
+ printf '%s' "$prompt" > "$pf"
434
445
  tmux send-keys -t "$stable" "cd '${win_path}' && claude \"\$(cat '${pf}')\"" Enter
435
446
  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.
447
+ # Empty-prompt launch (e.g. QA station relaunch): start plain.
440
448
  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
449
  fi
446
450
  }
447
451
 
@@ -628,8 +632,8 @@ qa_inject() {
628
632
  }
629
633
 
630
634
  # Launch a fresh Claude in a bare-shell main window with the handoff as its
631
- # opening prompt. Reuses queue_claude_start (the same cd→claude→/orient-quick
632
- # →prompt sequence mux uses everywhere) — single source of truth for "start a
635
+ # opening prompt. Reuses queue_claude_start (the same cd→claude→prompt
636
+ # sequence mux uses everywhere) — single source of truth for "start a
633
637
  # Claude session with a prompt". C-u clears the shell line first.
634
638
  qa_launch_fresh() {
635
639
  local win="$1" prompt="$2" project="$3" path
@@ -1287,6 +1291,7 @@ cmd_worktree() {
1287
1291
  read -r confirm
1288
1292
  case "$confirm" in
1289
1293
  y|Y)
1294
+ worktree_reap_containers "$proj_path" "$wt_path"
1290
1295
  git -C "$proj_path" worktree remove --force "$wt_path" 2>/dev/null || rm -rf "$wt_path"
1291
1296
  git -C "$proj_path" branch -D "$branch_name" 2>/dev/null || true
1292
1297
  worktree_registry_remove "$project" "$task"
@@ -1295,6 +1300,20 @@ cmd_worktree() {
1295
1300
  *) echo "Cancelled." ;;
1296
1301
  esac
1297
1302
  ;;
1303
+ preflight)
1304
+ # Verify main's environment BEFORE parallel lane spawns: every declared
1305
+ # provision file exists in the main checkout and every declared
1306
+ # `check:` command passes (.mux-worktree-provision at the project root).
1307
+ # Exit code gates the spawn: 0 = sound, 1 = do not spawn lanes yet.
1308
+ local project="${2:-}"
1309
+ local proj_path
1310
+ if [[ -n "$project" ]]; then
1311
+ proj_path=$(project_path "$project") || die "Unknown project: $project"
1312
+ else
1313
+ proj_path=$(git rev-parse --show-toplevel 2>/dev/null) || die "Usage: mux worktree preflight <project> (or run inside a project)"
1314
+ fi
1315
+ "${HOME}/.config/mux/worktree-session-health.sh" "$proj_path" --preflight
1316
+ ;;
1298
1317
  health)
1299
1318
  local project="${2:-}" task="${3:-}"
1300
1319
  if [[ -n "$project" && -n "$task" ]]; then
@@ -1355,7 +1374,7 @@ cmd_worktree() {
1355
1374
  fi
1356
1375
  ;;
1357
1376
  *)
1358
- echo "Usage: mux worktree [ls|cleanup|remove|health|refresh] [project] [task]"
1377
+ echo "Usage: mux worktree [ls|cleanup|remove|health|refresh|preflight] [project] [task]"
1359
1378
  ;;
1360
1379
  esac
1361
1380
  }
@@ -1642,9 +1661,8 @@ qa_cmd_clear() {
1642
1661
  # merge to the live QA station). /session-handoff persists the seed to disk and
1643
1662
  # only calls this on approval, so a launch failure can't lose the seed.
1644
1663
  #
1645
- # Why a new window on MAIN (not a worktree): the seed tells the next session to
1646
- # run /orient then `mux qa drain`, and acting on drained QA (e2e / publish /
1647
- # deploy) needs the main checkout. The window is named by work-context, NEVER
1664
+ # Why a new window on MAIN (not a worktree): acting on drained QA (e2e /
1665
+ # publish / deploy) needs the main checkout. The window is named by work-context, NEVER
1648
1666
  # by the prompt (cmd_new's prompt path slugifies prompt→name + forces a
1649
1667
  # worktree — the wrong path; we deliberately don't use it).
1650
1668
  #
@@ -1716,8 +1734,6 @@ cmd_handoff() {
1716
1734
 
1717
1735
  # -P -F gives an unambiguous session:index target (window names can
1718
1736
  # collide — option-setting by NAME hits the first match, act:c6f6bfd1).
1719
- # queue_claude_start with auto_orient=0: inject only the seed; the seed
1720
- # itself runs /orient.
1721
1737
  local target
1722
1738
  target=$(tmux new-window -t "=$project" -n "$name" -c "$win_path" -P -F '#{session_name}:#{window_index}')
1723
1739
  [[ -n "$target" ]] || die "mux handoff: couldn't open a window on ${project} — seed is saved; open the desk and paste it."
@@ -1725,8 +1741,8 @@ cmd_handoff() {
1725
1741
  tmux set-window-option -t "$target" @mux_worktree 1 2>/dev/null || true
1726
1742
  tmux set-window-option -t "$target" @wt_healthy 1 2>/dev/null || true
1727
1743
  fi
1728
- queue_claude_start "=$target" "$prompt" "$win_path" 0
1729
- echo "✓ Next session launched: window '${name}' on ${where}, seeded (it will /orient, then pick up the seeded work thread; it merges to main when done — QA handoffs and main-only tail work stay with window 1)."
1744
+ queue_claude_start "=$target" "$prompt" "$win_path"
1745
+ echo "✓ Next session launched: window '${name}' on ${where}, seeded (watchtower loads its state at session start, then it picks up the seeded work thread; it merges to main when done — QA handoffs and main-only tail work stay with window 1)."
1730
1746
  }
1731
1747
 
1732
1748
  cmd_setup() {
@@ -57,11 +57,28 @@ if [[ "$verdict" == "clean" ]]; then
57
57
  wt_slug=$(echo "$wt_path" | sed 's|[/.]|-|g')
58
58
  [[ -L "$projects_dir/$wt_slug" ]] && rm -f "$projects_dir/$wt_slug"
59
59
 
60
+ # Reap the lane's docker containers BEFORE the worktree dir (the compose
61
+ # project name source) disappears — single implementation in
62
+ # worktree-session-health.sh --reap, same mode bin/mux's removal paths use.
63
+ # Guarded: fail-open under set -e, a failed reap never blocks the removal.
64
+ # This hook's stdout goes nowhere (tmux discards it), so an incomplete
65
+ # reap routes to the desk's sticky-note channel — a leak on the unattended
66
+ # path must never be silent.
67
+ HEALTH_CHECK="${HOME}/.config/mux/worktree-session-health.sh"
68
+ if [[ -x "$HEALTH_CHECK" ]]; then
69
+ reap_out=$("$HEALTH_CHECK" "$proj_path" "$wt_path" --reap 2>/dev/null) || reap_out=""
70
+ if [[ "$reap_out" == *"reap INCOMPLETE"* || "$reap_out" == *"daemon unreachable"* ]]; then
71
+ python3 "${HOME}/.config/mux/manage-notes.py" add "$SESSION" \
72
+ "⚠ Container reap incomplete for closed worktree '${WINDOW}'. Check: docker ps --filter label=com.docker.compose.project=$(basename "$wt_path" | tr '[:upper:]' '[:lower:]')" \
73
+ 2>/dev/null || true
74
+ fi
75
+ fi
76
+
60
77
  git -C "$proj_path" worktree remove "$wt_path" 2>/dev/null || rm -rf "$wt_path"
61
78
  git -C "$proj_path" branch -d "$branch_name" 2>/dev/null || true
62
79
  python3 "$MUX_LIB" worktree-remove "$SESSION" "$WINDOW" 2>/dev/null || true
63
80
  else
64
- # Dirty — create urgent watchtower inbox item + sticky note.
81
+ # Dirty — create watchtower inbox item + sticky note.
65
82
  # Counts may be "?" when the check could not determine them (fail-DIRTY).
66
83
  detail=""
67
84
  if [[ "$has_uncommitted" != "0" && "$has_uncommitted" != "?" ]]; then
@@ -74,12 +91,39 @@ else
74
91
  detail="changes the dirty-check could not verify (treated as dirty)"
75
92
  fi
76
93
 
94
+ # Earned-urgency split, mirroring ring1's worktree-unmerged registers
95
+ # (noise-immunity merge-tail reconciliation): data-loss urgency is earned
96
+ # by unmerged COMMITS; a fully-merged branch with only uncommitted files
97
+ # is a normal review nudge. An unverifiable commit count ("?") stays
98
+ # urgent — fail toward attention, never toward silence.
99
+ if [[ "$has_commits" == "0" ]]; then
100
+ wt_urgency="normal"
101
+ wt_unmerged="false"
102
+ wt_title="Worktree \"${branch_name}\" closed with uncommitted files"
103
+ wt_summary="Window closed with ${detail}. The branch itself is fully merged — review or commit the files. Run: mux worktree cleanup ${SESSION} ${WINDOW}"
104
+ else
105
+ wt_urgency="urgent"
106
+ wt_unmerged="true"
107
+ wt_title="Worktree \"${branch_name}\" closed with unmerged work"
108
+ wt_summary="Window closed with ${detail}. Merge to main or the work may be lost. Run: mux worktree cleanup ${SESSION} ${WINDOW}"
109
+ fi
110
+
77
111
  # Watchtower inbox item (if watchtower is installed)
78
112
  WATCHTOWER_QUEUE="${HOME}/.claude-cabinet/watchtower/scripts/watchtower-queue.mjs"
79
113
  WATCHTOWER_LIB="${HOME}/.claude-cabinet/watchtower/scripts/watchtower-lib.mjs"
80
114
  if [[ -f "$WATCHTOWER_QUEUE" ]]; then
81
115
  NODE_BIN="${WATCHTOWER_NODE_PATH:-$(command -v node 2>/dev/null || true)}"
82
116
  if [[ -n "$NODE_BIN" ]]; then
117
+ # All dynamic values reach node via ENV, never spliced into the JS
118
+ # source: an apostrophe in a window/desk/branch name was a silent
119
+ # SyntaxError (the '|| true' swallowed it — the urgent item never
120
+ # filed, only the sticky note survived), and a crafted name was JS
121
+ # injection. Same class ring1's CP3 commit closed for filenames
122
+ # (QA drain 2026-07-13). Only $HOME-derived import paths are spliced.
123
+ MUX_PROJ_PATH="$proj_path" MUX_SESSION="$SESSION" MUX_WINDOW="$WINDOW" \
124
+ MUX_BRANCH="$branch_name" MUX_WT_PATH="$wt_path" \
125
+ MUX_URGENCY="$wt_urgency" MUX_TITLE="$wt_title" MUX_SUMMARY="$wt_summary" \
126
+ MUX_UNMERGED="$wt_unmerged" \
83
127
  "$NODE_BIN" --input-type=module -e "
84
128
  import { createItem, listPending } from '${WATCHTOWER_QUEUE}';
85
129
  // project must be the watchtower config key, not the mux desk name —
@@ -95,25 +139,26 @@ else
95
139
  try { ({ resolveProjectIdentity, loadConfig } = await import('${WATCHTOWER_LIB}')); } catch {}
96
140
  let config = null;
97
141
  try { config = loadConfig?.() ?? null; } catch {}
98
- const identity = resolveProjectIdentity?.('${proj_path}', config) ?? null;
142
+ const env = process.env;
143
+ const identity = resolveProjectIdentity?.(env.MUX_PROJ_PATH, config) ?? null;
99
144
  const existing = listPending({ category: 'worktree-unmerged' });
100
145
  const isDup = existing.some(i =>
101
- i.evidence?.branch === '${branch_name}' &&
102
- i.evidence?.worktree_path === '${wt_path}'
146
+ i.evidence?.branch === env.MUX_BRANCH &&
147
+ i.evidence?.worktree_path === env.MUX_WT_PATH
103
148
  );
104
149
  if (!isDup) {
105
150
  createItem({
106
- project: identity?.name || '${SESSION}',
107
- project_path: identity?.path || '${proj_path}',
108
- desk: '${SESSION}',
151
+ project: identity?.name || env.MUX_SESSION,
152
+ project_path: identity?.path || env.MUX_PROJ_PATH,
153
+ desk: env.MUX_SESSION,
109
154
  ...(identity ? {} : { project_unresolved: true }),
110
155
  filed_by: 'pane-close',
111
156
  category: 'worktree-unmerged',
112
- urgency: 'urgent',
113
- title: 'Worktree \"${branch_name}\" closed with unmerged work',
114
- summary: 'Window closed with ${detail}. Run: mux worktree cleanup ${SESSION} ${WINDOW}',
115
- context_anchor: 'git log main..${branch_name} in ${wt_path}',
116
- evidence: { branch: '${branch_name}', worktree_path: '${wt_path}', window: '${WINDOW}' },
157
+ urgency: env.MUX_URGENCY,
158
+ title: env.MUX_TITLE,
159
+ summary: env.MUX_SUMMARY,
160
+ context_anchor: 'git log main..' + env.MUX_BRANCH + ' in ' + env.MUX_WT_PATH,
161
+ evidence: { branch: env.MUX_BRANCH, worktree_path: env.MUX_WT_PATH, window: env.MUX_WINDOW, unmerged: env.MUX_UNMERGED === 'true' },
117
162
  options: [
118
163
  { key: 'merge', label: 'Merge to main now' },
119
164
  { key: 'keep', label: 'Keep branch for later' },
@@ -64,6 +64,38 @@
64
64
  # changes also touch package.json (which counts as dirty); without this
65
65
  # exclusion every npm-touching worktree is immortal.
66
66
  #
67
+ # 5. UNTRACKED identity symlinks (`?? .mcp.json`, `?? .claudeignore`,
68
+ # `?? pib.db`) — in a project that doesn't track these files, the mux
69
+ # identity symlink shows as untracked instead of a typechange
70
+ # (act:c008862c). Same manufactured churn, same exclusion — but ONLY
71
+ # while the path is actually a symlink (checked live below): a REAL
72
+ # user-created file at the same path (a lazily created pib.db holding
73
+ # recorded actions, a hand-authored .mcp.json) is real work and counts
74
+ # as dirty. A MODIFIED tracked copy (` M`) still counts as dirty,
75
+ # exactly as in point 2.
76
+ #
77
+ # 5b. SKIP-WORKTREE'd paths are absent from porcelain BY DESIGN while
78
+ # they are mux symlinks (worktree-session-health.sh hides the
79
+ # typechange that way). If such a path now holds a REAL file (an
80
+ # atomic temp+rename replaced the symlink mid-session, before any
81
+ # health run could clear the bit), the hidden state may be a real
82
+ # edit — counted as dirty here so a clean-verdict deletion can never
83
+ # race the health heal (fail-DIRTY).
84
+ #
85
+ # 6. GENERATED STATE inside otherwise-authored .claude/ dirs — machine-
86
+ # written runtime state (advisories-state.json, checklist-stats.json,
87
+ # .claude/verification/) lives under top-level entries that also hold
88
+ # tracked files, so the authored re-count below would claim it as real
89
+ # work and a fully-merged worktree read "MERGE OR LOSE" (act:c008862c,
90
+ # live specimen 2026-07-12). Untracked generated state is excluded from
91
+ # the authored re-count; a TRACKED-and-modified copy still counts as
92
+ # dirty (fail-safe — untracking live consumers is operator-gated).
93
+ # The names must stay in step with watchtower-lib.mjs
94
+ # (authoredClaudeDirs / claudeChurnIsDisposable consumers) and the
95
+ # exclude block in worktree-session-health.sh — the noise-immunity
96
+ # program's merge tail diffs these lists (two implementations, one
97
+ # concept).
98
+ #
67
99
  # JS-SIDE PARALLEL: the watchtower ring scripts (.mjs) carry their own
68
100
  # porcelain dirty checks with their own exclusion sets. They are a parallel
69
101
  # implementation owned by a separate plan and intentionally NOT consolidated
@@ -85,6 +117,21 @@ fi
85
117
 
86
118
  # Known CC-infrastructure churn (see EXCLUSION CONTRACT above).
87
119
  INFRA_CHURN_RE='^\?\? \.claude/?$|^\?\? \.claude/|^.T \.mcp\.json$|^.T \.claudeignore$|^\?\? node_modules/?$|^[M ][M ] (.*/)?(package-lock\.json|yarn\.lock|pnpm-lock\.yaml|bun\.lockb)$'
120
+ # Point 5: untracked identity names are churn ONLY while the path is a live
121
+ # symlink — a real file there is real work (fail-DIRTY when in doubt).
122
+ for _f in .mcp.json .claudeignore pib.db; do
123
+ if [[ -n "$wt_path" && -L "$wt_path/$_f" ]]; then
124
+ INFRA_CHURN_RE="${INFRA_CHURN_RE}|^\?\? ${_f//./\\.}\$"
125
+ fi
126
+ done
127
+ # Machine-written generated state (EXCLUSION CONTRACT point 6) — excluded
128
+ # from BOTH counting passes (non-churn and the authored re-count); keep in
129
+ # step with watchtower-lib.mjs GENERATED_STATE_PATTERNS and
130
+ # worktree-session-health.sh (merge-tail list diff; drift test in
131
+ # worktree-status-clean.test.mjs). e2e/.verify-progress.jsonl sits outside
132
+ # .claude/, so the blanket untracked-.claude churn rule never covers it —
133
+ # it must be excluded here or a /verify-touched worktree is immortal.
134
+ GENERATED_STATE_RE='^\?\? (\.claude/(cabinet/(advisories-state|checklist-stats)\.json|verification/)|e2e/\.verify-progress\.jsonl$)'
88
135
  # Authored records re-included after the untracked-.claude exclusion
89
136
  # (ERE has no negative lookahead, so this is a second counting pass).
90
137
  # Any top-level .claude/ entry holding tracked files is authored; the static
@@ -114,10 +161,33 @@ if [[ -d "$wt_path" ]] && git -C "$wt_path" rev-parse --git-dir >/dev/null 2>&1;
114
161
  if [[ -z "$porcelain" ]]; then
115
162
  uncommitted=0
116
163
  else
117
- non_churn=$(grep -cvE "$INFRA_CHURN_RE" <<<"$porcelain") || true
118
- authored=$(grep -cE "$AUTHORED_RECORD_RE" <<<"$porcelain") || true
164
+ non_churn=$(grep -vE "$INFRA_CHURN_RE" <<<"$porcelain" | grep -cvE "$GENERATED_STATE_RE") || true
165
+ authored=$(grep -E "$AUTHORED_RECORD_RE" <<<"$porcelain" | grep -cvE "$GENERATED_STATE_RE") || true
119
166
  uncommitted=$(( ${non_churn:-0} + ${authored:-0} ))
120
167
  fi
168
+ # Point 5b: skip-worktree'd paths never reach porcelain — count any
169
+ # whose worktree path is NOT currently a symlink (a real file may be
170
+ # hiding behind the bit; the health heal unhides it, but a deletion
171
+ # must never race that heal).
172
+ sw_real=0
173
+ while IFS= read -r sw_line; do
174
+ sw_file="${sw_line#S }"
175
+ [[ -n "$sw_file" && ! -L "$wt_path/$sw_file" ]] && sw_real=$((sw_real+1))
176
+ done < <(git -C "$wt_path" ls-files -v 2>/dev/null | grep '^S ' || true)
177
+ # Point 5c: the UNTRACKED twin of 5b — a real file at an identity path
178
+ # that porcelain can't see because a stale managed-exclude line (written
179
+ # while the path was still a symlink) hides it until the next health
180
+ # rebuild. Real file + untracked + absent from porcelain = hidden work.
181
+ for _f in .mcp.json .claudeignore pib.db; do
182
+ if [[ -e "$wt_path/$_f" && ! -L "$wt_path/$_f" ]] \
183
+ && ! git -C "$wt_path" ls-files --error-unmatch "$_f" >/dev/null 2>&1 \
184
+ && ! grep -qE "^.. ${_f//./\\.}\$" <<<"$porcelain"; then
185
+ sw_real=$((sw_real+1))
186
+ fi
187
+ done
188
+ if [[ "$sw_real" -gt 0 ]]; then
189
+ uncommitted=$(( ${uncommitted:-0} + sw_real ))
190
+ fi
121
191
  fi
122
192
  fi
123
193