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.
- package/lib/CLAUDE.md +22 -0
- package/lib/cli.js +100 -5
- package/lib/metadata.js +20 -0
- package/package.json +1 -1
- package/templates/CLAUDE.md +212 -12
- package/templates/cabinet/watchtower-contracts.md +85 -0
- package/templates/cabinet/worktree-invocation-contract.md +35 -0
- package/templates/engagement/pib-db-patches/pib-db-lib.mjs +53 -9
- package/templates/mux/__tests__/mux-fail-loud.fixture.sh +4 -5
- package/templates/mux/__tests__/worktree-lifecycle.fixture.sh +214 -0
- package/templates/mux/__tests__/worktree-lifecycle.test.mjs +44 -0
- package/templates/mux/__tests__/worktree-status-clean.fixture.sh +245 -0
- package/templates/mux/__tests__/worktree-status-clean.test.mjs +89 -0
- package/templates/mux/bin/mux +44 -28
- package/templates/mux/config/worktree-cleanup.sh +57 -12
- package/templates/mux/config/worktree-dirty-check.sh +72 -2
- package/templates/mux/config/worktree-session-health.sh +401 -17
- package/templates/scripts/__tests__/assessment-recalibration.test.mjs +387 -0
- package/templates/scripts/__tests__/batch-disposition.test.mjs +19 -0
- package/templates/scripts/__tests__/branch-diverged-reconcile.test.mjs +374 -0
- package/templates/scripts/__tests__/completion-review-reconcile.test.mjs +173 -0
- package/templates/scripts/__tests__/cross-ring-reader.test.mjs +12 -4
- package/templates/scripts/__tests__/detector-registry.test.mjs +152 -0
- package/templates/scripts/__tests__/draft-surfacing.test.mjs +433 -0
- package/templates/scripts/__tests__/generated-state-classification.test.mjs +228 -0
- package/templates/scripts/__tests__/mirror-parity.test.mjs +54 -0
- package/templates/scripts/__tests__/resolve-project-slug.test.mjs +186 -0
- package/templates/scripts/__tests__/ring3-attribution.test.mjs +267 -0
- package/templates/scripts/__tests__/ring3-completion-filter.test.mjs +176 -0
- package/templates/scripts/__tests__/ring3-coverage-debt.test.mjs +377 -0
- package/templates/scripts/__tests__/ring3-last-session-pointer.test.mjs +80 -0
- package/templates/scripts/audit-coherence-check.mjs +6 -2
- package/templates/scripts/load-triage-history.js +23 -2
- package/templates/scripts/merge-findings.js +9 -2
- package/templates/scripts/pib-db-lib.mjs +55 -7
- package/templates/scripts/watchtower-cross-ring-reader.mjs +20 -3
- package/templates/scripts/watchtower-inbox-assessment.mjs +186 -35
- package/templates/scripts/watchtower-lib.mjs +268 -5
- package/templates/scripts/watchtower-queue.mjs +197 -2
- package/templates/scripts/watchtower-ring1.mjs +490 -69
- package/templates/scripts/watchtower-ring2.mjs +175 -3
- package/templates/scripts/watchtower-ring3-close.mjs +519 -69
- package/templates/skills/audit/SKILL.md +6 -3
- package/templates/skills/cabinet-anthropic-insider/SKILL.md +29 -8
- package/templates/skills/cabinet-process-therapist/SKILL.md +45 -3
- package/templates/skills/inbox/SKILL.md +29 -1
- package/templates/skills/session-handoff/SKILL.md +6 -8
- 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
|
+
});
|
package/templates/mux/bin/mux
CHANGED
|
@@ -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"
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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
|
|
632
|
-
#
|
|
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):
|
|
1646
|
-
#
|
|
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"
|
|
1729
|
-
echo "✓ Next session launched: window '${name}' on ${where}, seeded (
|
|
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
|
|
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
|
|
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 ===
|
|
102
|
-
i.evidence?.worktree_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 ||
|
|
107
|
-
project_path: identity?.path ||
|
|
108
|
-
desk:
|
|
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:
|
|
113
|
-
title:
|
|
114
|
-
summary:
|
|
115
|
-
context_anchor: 'git log main
|
|
116
|
-
evidence: { branch:
|
|
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 -
|
|
118
|
-
authored=$(grep -
|
|
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
|
|