create-claude-cabinet 0.44.0 → 0.45.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 +5 -0
- package/lib/cli.js +51 -6
- package/lib/copy.js +56 -10
- package/lib/mux-setup.js +1 -0
- package/package.json +1 -1
- package/templates/cabinet/checklist-stats-schema.md +104 -0
- package/templates/cabinet/checkpoint-protocol.md +17 -5
- package/templates/cabinet/qa-dimensions-template.yaml +7 -0
- package/templates/cabinet/watchtower-contracts.md +38 -0
- package/templates/engagement/pib-db-patches/pib-db-lib.mjs +4 -1
- package/templates/hooks/action-completion-gate.sh +17 -0
- package/templates/hooks/watchtower-session-start.sh +80 -5
- package/templates/mux/__tests__/claude-carveout.fixture.sh +136 -0
- package/templates/mux/__tests__/claude-carveout.test.mjs +38 -0
- package/templates/mux/__tests__/mux-fail-loud.fixture.sh +254 -0
- package/templates/mux/__tests__/mux-fail-loud.test.mjs +41 -0
- package/templates/mux/__tests__/worktree-dirty-check.fixture.sh +184 -0
- package/templates/mux/__tests__/worktree-dirty-check.test.mjs +35 -0
- package/templates/mux/bin/mux +212 -60
- package/templates/mux/config/worktree-cleanup.sh +55 -9
- package/templates/mux/config/worktree-dirty-check.sh +128 -0
- package/templates/mux/config/worktree-session-health.sh +62 -35
- package/templates/scripts/__tests__/qa-handoff-aging.e2e.test.mjs +108 -0
- package/templates/scripts/__tests__/qa-handoff-gate.test.mjs +335 -0
- package/templates/scripts/__tests__/resolve-project.test.mjs +144 -0
- package/templates/scripts/__tests__/ring-state-ownership.test.mjs +228 -0
- package/templates/scripts/pib-db-lib.mjs +4 -1
- package/templates/scripts/pib-db.mjs +4 -1
- package/templates/scripts/validate-memory.mjs +6 -2
- package/templates/scripts/watchtower-build-context.mjs +12 -8
- package/templates/scripts/watchtower-lib.mjs +265 -2
- package/templates/scripts/watchtower-migrate-keys.mjs +305 -0
- package/templates/scripts/watchtower-queue.mjs +226 -1
- package/templates/scripts/watchtower-ring1.mjs +19 -3
- package/templates/scripts/watchtower-ring2.mjs +4 -2
- package/templates/scripts/watchtower-ring3-close.mjs +92 -88
- package/templates/skills/audit/SKILL.md +25 -6
- package/templates/skills/audit/phases/checklist-pruning.md +108 -0
- package/templates/skills/briefing/SKILL.md +12 -1
- package/templates/skills/cabinet/SKILL.md +2 -2
- package/templates/skills/collab-consultant/SKILL.md +1 -1
- package/templates/skills/debrief/SKILL.md +33 -3
- package/templates/skills/debrief/phases/checklist-feedback.md +10 -3
- package/templates/skills/debrief/phases/qa-handoff-sweep.md +78 -0
- package/templates/skills/engagement-create/SKILL.md +1 -1
- package/templates/skills/engagement-help/SKILL.md +1 -1
- package/templates/skills/execute/SKILL.md +1 -1
- package/templates/skills/execute/phases/post-impl-checklist.md +18 -0
- package/templates/skills/execute-group/SKILL.md +76 -24
- package/templates/skills/inbox/SKILL.md +30 -7
- package/templates/skills/orient/SKILL.md +100 -6
- package/templates/skills/orient/phases/checklist-status.md +12 -0
- package/templates/skills/plan/SKILL.md +14 -6
- package/templates/skills/qa-handoff/SKILL.md +132 -5
- package/templates/skills/session-handoff/SKILL.md +165 -0
- package/templates/skills/setup-accounts/SKILL.md +1 -1
- package/templates/skills/unwrap/SKILL.md +1 -1
- package/templates/skills/verify/SKILL.md +2 -2
- package/templates/skills/watchtower/SKILL.md +19 -1
- package/templates/watchtower/queue/items/item.json.schema +9 -0
- package/templates/workflows/deliberative-audit.js +3 -0
- package/templates/workflows/execute-group-complete.js +93 -16
- package/templates/workflows/execute-group-implement.js +164 -19
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// Guard test for the shared worktree dirty-detection seam (act:04bdcc6b).
|
|
2
|
+
//
|
|
3
|
+
// Drives worktree-dirty-check.fixture.sh, which builds a throwaway git repo
|
|
4
|
+
// + worktrees under mktemp with HOME overridden — it never touches the real
|
|
5
|
+
// ~/.mux/worktrees, ~/.config/mux, or any live worktree.
|
|
6
|
+
//
|
|
7
|
+
// Covers:
|
|
8
|
+
// - `mux worktree refresh` preserves uncommitted .claude/plans/ edits
|
|
9
|
+
// (authored-record carve-out; artifacts-of-thought)
|
|
10
|
+
// - modified tracked .mcp.json => dirty in BOTH bin/mux and the
|
|
11
|
+
// pane-exited cleanup hook (one implementation, same verdict)
|
|
12
|
+
// - lockfile-only churn => clean in both paths
|
|
13
|
+
// - missing/failed helper degrades to fail-DIRTY (never deletes)
|
|
14
|
+
|
|
15
|
+
import { test } from 'node:test';
|
|
16
|
+
import assert from 'node:assert';
|
|
17
|
+
import { spawnSync } from 'node:child_process';
|
|
18
|
+
import { fileURLToPath } from 'node:url';
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
|
|
21
|
+
const fixture = path.join(
|
|
22
|
+
path.dirname(fileURLToPath(import.meta.url)),
|
|
23
|
+
'worktree-dirty-check.fixture.sh'
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
test('worktree dirty-detection + refresh carve-out (sandboxed fixture suite)', () => {
|
|
27
|
+
const res = spawnSync('bash', [fixture], { encoding: 'utf8', timeout: 120_000 });
|
|
28
|
+
const output = `${res.stdout}\n${res.stderr}`;
|
|
29
|
+
assert.strictEqual(
|
|
30
|
+
res.status,
|
|
31
|
+
0,
|
|
32
|
+
`fixture suite reported failures:\n${output}`
|
|
33
|
+
);
|
|
34
|
+
assert.match(output, /RESULT: \d+ passed, 0 failed/);
|
|
35
|
+
});
|
package/templates/mux/bin/mux
CHANGED
|
@@ -234,38 +234,16 @@ create_worktree() {
|
|
|
234
234
|
}
|
|
235
235
|
}
|
|
236
236
|
|
|
237
|
-
#
|
|
238
|
-
#
|
|
239
|
-
#
|
|
240
|
-
#
|
|
241
|
-
#
|
|
242
|
-
#
|
|
243
|
-
#
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
git -C "$wt_path" update-index --assume-unchanged "$f" 2>/dev/null || true
|
|
248
|
-
done
|
|
249
|
-
if [[ -d "$proj_path/.claude" ]]; then
|
|
250
|
-
# Refresh infra subdirs from main, one top-level entry at a time, never
|
|
251
|
-
# the authored-record dirs (leave the worktree's HEAD-checked-out copies).
|
|
252
|
-
local entry name
|
|
253
|
-
for entry in "$proj_path"/.claude/*; do
|
|
254
|
-
[[ -e "$entry" ]] || continue
|
|
255
|
-
name=$(basename "$entry")
|
|
256
|
-
[[ "$name" == "plans" || "$name" == "methodology" ]] && continue
|
|
257
|
-
rm -rf "$wt_path/.claude/$name"
|
|
258
|
-
cp -R "$entry" "$wt_path/.claude/$name"
|
|
259
|
-
done
|
|
260
|
-
# Hide infra from the worktree's git status, but keep authored records
|
|
261
|
-
# visible so new design docs created in a worktree show up and commit.
|
|
262
|
-
local wt_gitdir
|
|
263
|
-
wt_gitdir=$(git -C "$wt_path" rev-parse --git-dir 2>/dev/null)
|
|
264
|
-
if [[ -n "$wt_gitdir" ]]; then
|
|
265
|
-
mkdir -p "$wt_gitdir/info"
|
|
266
|
-
{ echo '.claude/*'; echo '!.claude/plans/'; echo '!.claude/methodology/'; } >> "$wt_gitdir/info/exclude"
|
|
267
|
-
fi
|
|
268
|
-
fi
|
|
237
|
+
# .claude/ infra is copied into the worktree (not symlinked — a symlink
|
|
238
|
+
# makes CC resolve through to the main repo path, leaking it into every
|
|
239
|
+
# Read/Edit/Write) by the health check below, which runs with --refresh.
|
|
240
|
+
# The authored/infra classification lives in exactly one place —
|
|
241
|
+
# worktree-session-health.sh: ANY tracked .claude/ file is authored project
|
|
242
|
+
# record (plans, methodology, rules, cabinet docs, anything committed) that
|
|
243
|
+
# stays exactly as `git worktree add` checked it out and is never frozen
|
|
244
|
+
# with assume-unchanged, so worktree edits commit; only gitignored infra
|
|
245
|
+
# (skills, agents, settings copies) is copied from main and hidden from
|
|
246
|
+
# status. See .claude/rules/artifacts-of-thought.md.
|
|
269
247
|
for f in .mcp.json .claudeignore; do
|
|
270
248
|
[[ -f "$proj_path/$f" ]] && ln -sf "$proj_path/$f" "$wt_path/$f"
|
|
271
249
|
done
|
|
@@ -285,9 +263,10 @@ create_worktree() {
|
|
|
285
263
|
|
|
286
264
|
worktree_registry_add "$project" "$task_slug" "$branch_name" "$wt_path"
|
|
287
265
|
|
|
288
|
-
#
|
|
266
|
+
# Populate .claude/ infra (--refresh forces sync_claude_infra) and validate
|
|
267
|
+
# the worktree we just created.
|
|
289
268
|
local health_output health_ok
|
|
290
|
-
health_output=$(worktree_health_check "$proj_path" "$wt_path" 2>&1) && health_ok=1 || health_ok=0
|
|
269
|
+
health_output=$(worktree_health_check "$proj_path" "$wt_path" --refresh 2>&1) && health_ok=1 || health_ok=0
|
|
291
270
|
if [[ "$health_ok" -eq 0 ]]; then
|
|
292
271
|
echo "⚠ Worktree health issues:" >&2
|
|
293
272
|
echo "$health_output" >&2
|
|
@@ -299,8 +278,10 @@ create_worktree() {
|
|
|
299
278
|
}
|
|
300
279
|
|
|
301
280
|
worktree_health_check() {
|
|
281
|
+
# Extra args pass through (e.g. --refresh forces an infra re-sync).
|
|
302
282
|
local proj_path="$1" wt_path="$2"
|
|
303
|
-
|
|
283
|
+
shift 2
|
|
284
|
+
"${HOME}/.config/mux/worktree-session-health.sh" "$proj_path" "$wt_path" "$@" 2>&1
|
|
304
285
|
}
|
|
305
286
|
|
|
306
287
|
worktree_cleanup() {
|
|
@@ -325,11 +306,25 @@ worktree_cleanup() {
|
|
|
325
306
|
fi
|
|
326
307
|
fi
|
|
327
308
|
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
309
|
+
# Shared dirty detection — single source of truth in
|
|
310
|
+
# ~/.config/mux/worktree-dirty-check.sh (the tmux pane-exited cleanup hook
|
|
311
|
+
# delegates to the same helper, so both paths reach the same verdict from
|
|
312
|
+
# one implementation). Fail-DIRTY: a missing or failing helper classifies
|
|
313
|
+
# the worktree dirty — never clean on the strength of a failed check.
|
|
314
|
+
local dirty_check="${MUX_CONFIG_DIR}/worktree-dirty-check.sh"
|
|
315
|
+
local dirty_line="" kv has_commits="?" has_uncommitted="?" verdict="dirty"
|
|
316
|
+
if [[ -x "$dirty_check" ]]; then
|
|
317
|
+
dirty_line=$("$dirty_check" "$wt_path" "$proj_path" 2>/dev/null) || dirty_line=""
|
|
318
|
+
fi
|
|
319
|
+
for kv in $dirty_line; do
|
|
320
|
+
case "$kv" in
|
|
321
|
+
commits=*) has_commits="${kv#commits=}" ;;
|
|
322
|
+
uncommitted=*) has_uncommitted="${kv#uncommitted=}" ;;
|
|
323
|
+
verdict=*) verdict="${kv#verdict=}" ;;
|
|
324
|
+
esac
|
|
325
|
+
done
|
|
331
326
|
|
|
332
|
-
if [[ "$
|
|
327
|
+
if [[ "$verdict" == "clean" ]]; then
|
|
333
328
|
git -C "$proj_path" worktree remove "$wt_path" 2>/dev/null || rm -rf "$wt_path"
|
|
334
329
|
git -C "$proj_path" branch -d "$branch_name" 2>/dev/null || true
|
|
335
330
|
worktree_registry_remove "$project" "$task_slug"
|
|
@@ -340,8 +335,9 @@ worktree_cleanup() {
|
|
|
340
335
|
|
|
341
336
|
echo ""
|
|
342
337
|
echo "Session '${task_slug}' has work to merge:"
|
|
343
|
-
|
|
344
|
-
[[ "$
|
|
338
|
+
# Counts may be "?" when the dirty-check could not determine them (fail-DIRTY).
|
|
339
|
+
[[ "$has_uncommitted" != "0" ]] && echo " ${has_uncommitted} uncommitted file(s)"
|
|
340
|
+
[[ "$has_commits" != "0" ]] && echo " ${has_commits} commit(s) ahead"
|
|
345
341
|
echo ""
|
|
346
342
|
echo "What do you want to do?"
|
|
347
343
|
echo " 1) Merge to main branch"
|
|
@@ -354,7 +350,7 @@ worktree_cleanup() {
|
|
|
354
350
|
|
|
355
351
|
case "$choice" in
|
|
356
352
|
1)
|
|
357
|
-
if [[ "$has_uncommitted"
|
|
353
|
+
if [[ "$has_uncommitted" != "0" ]]; then
|
|
358
354
|
git -C "$wt_path" add -A
|
|
359
355
|
git -C "$wt_path" commit -m "mux: work from ${task_slug} session"
|
|
360
356
|
fi
|
|
@@ -373,7 +369,7 @@ worktree_cleanup() {
|
|
|
373
369
|
fi
|
|
374
370
|
;;
|
|
375
371
|
2)
|
|
376
|
-
if [[ "$has_uncommitted"
|
|
372
|
+
if [[ "$has_uncommitted" != "0" ]]; then
|
|
377
373
|
git -C "$wt_path" add -A
|
|
378
374
|
git -C "$wt_path" commit -m "mux: parked work from ${task_slug} session"
|
|
379
375
|
fi
|
|
@@ -406,7 +402,7 @@ worktree_cleanup() {
|
|
|
406
402
|
}
|
|
407
403
|
|
|
408
404
|
queue_claude_start() {
|
|
409
|
-
local target="$1" prompt="$2" win_path="$3"
|
|
405
|
+
local target="$1" prompt="$2" win_path="$3" auto_orient="${4:-1}"
|
|
410
406
|
# Resolve window index immediately (names can be unreliable in background)
|
|
411
407
|
local sess win_idx
|
|
412
408
|
sess=$(tmux display-message -t "$target" -p '#{session_name}' 2>/dev/null)
|
|
@@ -414,11 +410,18 @@ queue_claude_start() {
|
|
|
414
410
|
local stable="${sess}:${win_idx}"
|
|
415
411
|
tmux set-window-option -t "$stable" @mux_claude 1 2>/dev/null || true
|
|
416
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).
|
|
417
418
|
(
|
|
418
419
|
sleep 5
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
420
|
+
if [[ "$auto_orient" == "1" ]]; then
|
|
421
|
+
tmux send-keys -t "$stable" "/orient-quick" Enter
|
|
422
|
+
sleep 2
|
|
423
|
+
fi
|
|
424
|
+
[[ -n "$prompt" ]] && tmux send-keys -t "$stable" -l "$prompt"
|
|
422
425
|
) &
|
|
423
426
|
disown
|
|
424
427
|
}
|
|
@@ -606,6 +609,26 @@ create_work_worktree() {
|
|
|
606
609
|
return 1
|
|
607
610
|
}
|
|
608
611
|
|
|
612
|
+
# Can this desk get an isolated worktree at all? Requires a registered mux
|
|
613
|
+
# project whose directory is a git repository. When isolation is impossible
|
|
614
|
+
# BY DESIGN (unregistered desk, non-git project dir), callers fall through to
|
|
615
|
+
# the main checkout LOUDLY — a visible warning naming why — instead of dying.
|
|
616
|
+
# die is reserved for real worktree-creation failures on isolation-capable
|
|
617
|
+
# desks. On failure, echoes the human-readable reason; on success, nothing.
|
|
618
|
+
worktree_isolation_capable() {
|
|
619
|
+
local project="$1" proj_path
|
|
620
|
+
proj_path=$(project_path "$project" 2>/dev/null) || proj_path=""
|
|
621
|
+
if [[ -z "$proj_path" ]]; then
|
|
622
|
+
echo "this desk isn't a registered mux project"
|
|
623
|
+
return 1
|
|
624
|
+
fi
|
|
625
|
+
if ! git -C "$proj_path" rev-parse --git-dir >/dev/null 2>&1; then
|
|
626
|
+
echo "project directory isn't a git repository: ${proj_path}"
|
|
627
|
+
return 1
|
|
628
|
+
fi
|
|
629
|
+
return 0
|
|
630
|
+
}
|
|
631
|
+
|
|
609
632
|
# --- Subcommands ---
|
|
610
633
|
|
|
611
634
|
cmd_picker() {
|
|
@@ -793,9 +816,18 @@ cmd_new() {
|
|
|
793
816
|
queue_claude_start "=${session}:${win_name}" "$prompt" "$win_path"
|
|
794
817
|
else
|
|
795
818
|
if has_active_claude "$session"; then
|
|
796
|
-
local win_name win_path
|
|
819
|
+
local win_name win_path skip_reason
|
|
797
820
|
win_name="window-$(date +%s | tail -c 5)"
|
|
798
|
-
|
|
821
|
+
if skip_reason=$(worktree_isolation_capable "$session"); then
|
|
822
|
+
# Never fall back to main — a second session landing next to a live
|
|
823
|
+
# Claude is the exact collision worktrees exist to prevent.
|
|
824
|
+
win_path=$(create_worktree "$session" "$win_name") \
|
|
825
|
+
|| die "Couldn't create a worktree for '${win_name}' — refusing to run work on the main checkout. Try: mux worktree ls"
|
|
826
|
+
else
|
|
827
|
+
# Isolation impossible by design — fall through loudly, never silently.
|
|
828
|
+
echo "⚠ No worktree isolation for this window (${skip_reason}) — opening on the main checkout." >&2
|
|
829
|
+
win_path="$path"
|
|
830
|
+
fi
|
|
799
831
|
tmux new-window -n "$win_name" -c "$win_path"
|
|
800
832
|
if [[ "$win_path" == "$MUX_WORKTREES_DIR/"* ]]; then
|
|
801
833
|
tmux set-window-option -t "=${session}:${win_name}" @mux_worktree 1 2>/dev/null || true
|
|
@@ -1219,16 +1251,16 @@ cmd_worktree() {
|
|
|
1219
1251
|
[[ -d "$wt_path" ]] || die "Worktree not found: $wt_path"
|
|
1220
1252
|
[[ -d "$proj_path/.claude" ]] || die "Main repo has no .claude/"
|
|
1221
1253
|
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
#
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
if
|
|
1228
|
-
|
|
1229
|
-
|
|
1254
|
+
# Delegate to the shared health script's carve-out-aware sync (the
|
|
1255
|
+
# worktree_health_check → worktree-session-health.sh consolidation).
|
|
1256
|
+
# Never wholesale `rm -rf .claude/` here — that destroys uncommitted
|
|
1257
|
+
# edits to the authored project record (any tracked .claude/ file).
|
|
1258
|
+
# See .claude/rules/artifacts-of-thought.md.
|
|
1259
|
+
if worktree_health_check "$proj_path" "$wt_path" --refresh; then
|
|
1260
|
+
echo "Refreshed .claude/ infra from main repo (authored records preserved)."
|
|
1261
|
+
else
|
|
1262
|
+
echo "Refreshed .claude/ infra, but health issues remain — see above." >&2
|
|
1230
1263
|
fi
|
|
1231
|
-
echo "Refreshed .claude/ from main repo."
|
|
1232
1264
|
;;
|
|
1233
1265
|
*)
|
|
1234
1266
|
echo "Usage: mux worktree [ls|cleanup|remove|health|refresh] [project] [task]"
|
|
@@ -1376,6 +1408,94 @@ qa_cmd_clear() {
|
|
|
1376
1408
|
echo "Cleared queued QA handoffs for ${project}."
|
|
1377
1409
|
}
|
|
1378
1410
|
|
|
1411
|
+
# --- Session handoff (session-handoff skill, phase 2) ---
|
|
1412
|
+
#
|
|
1413
|
+
# Open the NEXT session in a NEW window on the MAIN checkout, seeded with an
|
|
1414
|
+
# operator-approved prompt. This is the FORWARD handoff (close one session →
|
|
1415
|
+
# start the next), distinct from `mux qa dispatch`'s BACKWARD handoff (route a
|
|
1416
|
+
# merge to the live QA station). /session-handoff persists the seed to disk and
|
|
1417
|
+
# only calls this on approval, so a launch failure can't lose the seed.
|
|
1418
|
+
#
|
|
1419
|
+
# Why a new window on MAIN (not a worktree): the seed tells the next session to
|
|
1420
|
+
# run /orient then `mux qa drain`, and acting on drained QA (e2e / publish /
|
|
1421
|
+
# deploy) needs the main checkout. The window is named by work-context, NEVER
|
|
1422
|
+
# by the prompt (cmd_new's prompt path slugifies prompt→name + forces a
|
|
1423
|
+
# worktree — the wrong path; we deliberately don't use it).
|
|
1424
|
+
#
|
|
1425
|
+
# Descriptor JSON: { project (mux desk), project_path (repo root),
|
|
1426
|
+
# name (window name), seed_prompt }
|
|
1427
|
+
cmd_handoff() {
|
|
1428
|
+
require_python
|
|
1429
|
+
require_tmux
|
|
1430
|
+
local descriptor="${1:-}"
|
|
1431
|
+
[[ -f "$descriptor" ]] || die "mux handoff: descriptor file not found: ${descriptor}"
|
|
1432
|
+
|
|
1433
|
+
local project ppath name prompt
|
|
1434
|
+
project=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get("project",""))' "$descriptor" 2>/dev/null)
|
|
1435
|
+
ppath=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get("project_path",""))' "$descriptor" 2>/dev/null)
|
|
1436
|
+
name=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get("name",""))' "$descriptor" 2>/dev/null)
|
|
1437
|
+
prompt=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get("seed_prompt",""))' "$descriptor" 2>/dev/null)
|
|
1438
|
+
[[ -n "$prompt" ]] || die "mux handoff: descriptor missing 'seed_prompt'"
|
|
1439
|
+
[[ -n "$name" ]] || name="next-session"
|
|
1440
|
+
|
|
1441
|
+
# `project` should be the mux DESK (tmux session). Desk short-names often
|
|
1442
|
+
# differ from the repo dir name, so if it isn't a live session but the
|
|
1443
|
+
# descriptor's project_path matches a desk's MUX_PROJECT_PATH, remap to that
|
|
1444
|
+
# desk (same fallback as qa dispatch).
|
|
1445
|
+
if [[ -z "$project" ]] || ! tmux has-session -t "=$project" 2>/dev/null; then
|
|
1446
|
+
if [[ -n "$ppath" ]]; then
|
|
1447
|
+
local desk mp
|
|
1448
|
+
while IFS= read -r desk; do
|
|
1449
|
+
mp=$(tmux show-environment -t "$desk" MUX_PROJECT_PATH 2>/dev/null | sed 's/^MUX_PROJECT_PATH=//')
|
|
1450
|
+
if [[ "$mp" == "$ppath" ]]; then project="$desk"; break; fi
|
|
1451
|
+
done < <(tmux list-sessions -F '#{session_name}' 2>/dev/null)
|
|
1452
|
+
fi
|
|
1453
|
+
fi
|
|
1454
|
+
[[ -n "$project" ]] && tmux has-session -t "=$project" 2>/dev/null \
|
|
1455
|
+
|| die "mux handoff: desk '${project:-?}' isn't open — seed not launched (it's saved; open the desk and paste it)."
|
|
1456
|
+
|
|
1457
|
+
# Main checkout path: prefer the descriptor's project_path, else the desk's
|
|
1458
|
+
# registered project dir, else the current dir.
|
|
1459
|
+
local main_path="$ppath"
|
|
1460
|
+
[[ -n "$main_path" ]] || main_path=$(project_path "$project" 2>/dev/null) || main_path="$PWD"
|
|
1461
|
+
|
|
1462
|
+
# Worktree, not main: the seeded session is a SECOND session on this desk,
|
|
1463
|
+
# so mux's core isolation rule applies — main stays window 1's standing
|
|
1464
|
+
# station. Two sessions sharing the main checkout sweep each other's
|
|
1465
|
+
# in-progress edits into unrelated commits (the 1adc5ef incident). Tracked
|
|
1466
|
+
# .claude/ files commit normally from worktrees since the 40cc831
|
|
1467
|
+
# carve-out, so the authoring work a seed carries is safe there. The
|
|
1468
|
+
# seeded session merges to main when done; main-only tail work (dogfood
|
|
1469
|
+
# reinstall, propagation, publish) happens post-merge or is dispatched to
|
|
1470
|
+
# window 1, consistent with "QA drains belong to window 1".
|
|
1471
|
+
local win_path where skip_reason
|
|
1472
|
+
if skip_reason=$(worktree_isolation_capable "$project"); then
|
|
1473
|
+
win_path=$(create_worktree "$project" "$name") \
|
|
1474
|
+
|| die "mux handoff: couldn't create a worktree for '${name}' — refusing to seed a second session onto the main checkout. Seed is saved; try: mux worktree ls"
|
|
1475
|
+
where="a fresh worktree of ${project}"
|
|
1476
|
+
else
|
|
1477
|
+
# Isolation impossible by design (non-git / unregistered) — fall through
|
|
1478
|
+
# loudly, never silently.
|
|
1479
|
+
echo "⚠ No worktree isolation for this handoff (${skip_reason}) — opening on the main checkout." >&2
|
|
1480
|
+
win_path="$main_path"
|
|
1481
|
+
where="${project}'s main checkout (no isolation: ${skip_reason})"
|
|
1482
|
+
fi
|
|
1483
|
+
|
|
1484
|
+
# -P -F gives an unambiguous session:index target (window names can
|
|
1485
|
+
# collide — option-setting by NAME hits the first match, act:c6f6bfd1).
|
|
1486
|
+
# queue_claude_start with auto_orient=0: inject only the seed; the seed
|
|
1487
|
+
# itself runs /orient.
|
|
1488
|
+
local target
|
|
1489
|
+
target=$(tmux new-window -t "=$project" -n "$name" -c "$win_path" -P -F '#{session_name}:#{window_index}')
|
|
1490
|
+
[[ -n "$target" ]] || die "mux handoff: couldn't open a window on ${project} — seed is saved; open the desk and paste it."
|
|
1491
|
+
if [[ "$win_path" == "$MUX_WORKTREES_DIR/"* ]]; then
|
|
1492
|
+
tmux set-window-option -t "$target" @mux_worktree 1 2>/dev/null || true
|
|
1493
|
+
tmux set-window-option -t "$target" @wt_healthy 1 2>/dev/null || true
|
|
1494
|
+
fi
|
|
1495
|
+
queue_claude_start "=$target" "$prompt" "$win_path" 0
|
|
1496
|
+
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)."
|
|
1497
|
+
}
|
|
1498
|
+
|
|
1379
1499
|
cmd_setup() {
|
|
1380
1500
|
echo "=== mux setup ==="
|
|
1381
1501
|
echo ""
|
|
@@ -1617,22 +1737,53 @@ cmd_resume() {
|
|
|
1617
1737
|
require_tmux
|
|
1618
1738
|
in_tmux || die "You need to be in a desk first. Try: mux <project-name>"
|
|
1619
1739
|
[[ -n "$session_id" ]] || die "Usage: mux resume <session-id>"
|
|
1740
|
+
# The raw id flows into a branch name, a worktree path, and a send-keys
|
|
1741
|
+
# line — refuse anything that isn't id-shaped before it gets there.
|
|
1742
|
+
[[ "$session_id" =~ ^[0-9a-fA-F-]{8,}$ ]] \
|
|
1743
|
+
|| die "That doesn't look like a Claude session id: '${session_id}'. Expected hex-and-dashes (8+ chars), e.g. a UUID."
|
|
1620
1744
|
|
|
1621
1745
|
local session
|
|
1622
1746
|
session=$(tmux display-message -p '#{session_name}')
|
|
1623
1747
|
local path
|
|
1624
1748
|
path=$(project_path "$session" 2>/dev/null || tmux display-message -p '#{pane_current_path}')
|
|
1625
1749
|
|
|
1626
|
-
local win_name win_path
|
|
1750
|
+
local win_name win_path wt_path skip_reason
|
|
1627
1751
|
win_name="resume-$(echo "$session_id" | cut -c1-8)"
|
|
1628
1752
|
|
|
1629
1753
|
if has_active_claude "$session"; then
|
|
1630
|
-
|
|
1754
|
+
if skip_reason=$(worktree_isolation_capable "$session"); then
|
|
1755
|
+
wt_path="${MUX_WORKTREES_DIR}/${session}-${win_name}"
|
|
1756
|
+
if [[ -d "$wt_path" ]]; then
|
|
1757
|
+
# The resume-<id8> slug is deterministic — a second resume of the
|
|
1758
|
+
# same session REUSES its existing worktree (it holds that session's
|
|
1759
|
+
# prior work). Only an invalid leftover at that path is an error.
|
|
1760
|
+
if git -C "$wt_path" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
|
1761
|
+
win_path="$wt_path"
|
|
1762
|
+
else
|
|
1763
|
+
die "Worktree path ${wt_path} already exists but isn't a valid git worktree — refusing to resume on the main checkout. Try: rm -rf '${wt_path}' && mux resume ${session_id}"
|
|
1764
|
+
fi
|
|
1765
|
+
else
|
|
1766
|
+
# Never fall back to main — that would dump the resumed session onto
|
|
1767
|
+
# the clean station next to a live Claude.
|
|
1768
|
+
win_path=$(create_worktree "$session" "$win_name") \
|
|
1769
|
+
|| die "Couldn't create a worktree for '${win_name}' — refusing to run work on the main checkout. Try: mux worktree ls"
|
|
1770
|
+
fi
|
|
1771
|
+
else
|
|
1772
|
+
# Isolation impossible by design — fall through loudly, never silently.
|
|
1773
|
+
echo "⚠ No worktree isolation for this resume (${skip_reason}) — opening on the main checkout." >&2
|
|
1774
|
+
win_path="$path"
|
|
1775
|
+
fi
|
|
1631
1776
|
else
|
|
1777
|
+
# No active Claude on this desk — resuming on the main checkout is the
|
|
1778
|
+
# intended landing (nothing to isolate from). By design, not a fallback.
|
|
1632
1779
|
win_path="$path"
|
|
1633
1780
|
fi
|
|
1634
1781
|
|
|
1635
1782
|
tmux new-window -n "$win_name" -c "$win_path"
|
|
1783
|
+
if [[ "$win_path" == "$MUX_WORKTREES_DIR/"* ]]; then
|
|
1784
|
+
tmux set-window-option -t "=${session}:${win_name}" @mux_worktree 1 2>/dev/null || true
|
|
1785
|
+
tmux set-window-option -t "=${session}:${win_name}" @wt_healthy 1 2>/dev/null || true
|
|
1786
|
+
fi
|
|
1636
1787
|
queue_claude_resume "=${session}:${win_name}" "$session_id" "$win_path"
|
|
1637
1788
|
}
|
|
1638
1789
|
|
|
@@ -1660,6 +1811,7 @@ main() {
|
|
|
1660
1811
|
portal) shift; cmd_portal "${1:-}" ;;
|
|
1661
1812
|
worktree) shift; cmd_worktree "$@" ;;
|
|
1662
1813
|
qa) shift; cmd_qa "$@" ;;
|
|
1814
|
+
handoff) shift; cmd_handoff "$@" ;;
|
|
1663
1815
|
setup) cmd_setup ;;
|
|
1664
1816
|
help) cmd_help ;;
|
|
1665
1817
|
-h|--help) cmd_help ;;
|
|
@@ -25,11 +25,32 @@ branch_name="mux/${WINDOW}"
|
|
|
25
25
|
|
|
26
26
|
[[ -d "$wt_path" ]] || exit 0
|
|
27
27
|
|
|
28
|
-
# Check for real changes
|
|
29
|
-
|
|
30
|
-
|
|
28
|
+
# Check for real changes via the shared dirty detection — single source of
|
|
29
|
+
# truth in worktree-dirty-check.sh (`mux worktree cleanup` delegates to the
|
|
30
|
+
# same helper, so both paths reach the same verdict from one implementation).
|
|
31
|
+
#
|
|
32
|
+
# FAIL-DIRTY: this hook silently DELETES worktrees, so a failed or missing
|
|
33
|
+
# check must never authorize a deletion. If the helper is absent, errors, or
|
|
34
|
+
# its output is unparseable, we classify the worktree dirty and fall through
|
|
35
|
+
# to the note-leaving branch. Every fallible step is guarded so that under
|
|
36
|
+
# set -e this degrades to fail-DIRTY rather than aborting silently.
|
|
37
|
+
DIRTY_CHECK="${HOME}/.config/mux/worktree-dirty-check.sh"
|
|
38
|
+
dirty_line=""
|
|
39
|
+
if [[ -x "$DIRTY_CHECK" ]]; then
|
|
40
|
+
dirty_line=$("$DIRTY_CHECK" "$wt_path" "$proj_path" 2>/dev/null) || dirty_line=""
|
|
41
|
+
fi
|
|
42
|
+
has_commits="?"
|
|
43
|
+
has_uncommitted="?"
|
|
44
|
+
verdict="dirty"
|
|
45
|
+
for kv in $dirty_line; do
|
|
46
|
+
case "$kv" in
|
|
47
|
+
commits=*) has_commits="${kv#commits=}" ;;
|
|
48
|
+
uncommitted=*) has_uncommitted="${kv#uncommitted=}" ;;
|
|
49
|
+
verdict=*) verdict="${kv#verdict=}" ;;
|
|
50
|
+
esac
|
|
51
|
+
done
|
|
31
52
|
|
|
32
|
-
if [[ "$
|
|
53
|
+
if [[ "$verdict" == "clean" ]]; then
|
|
33
54
|
# Clean — silently remove
|
|
34
55
|
# Clean up project identity symlink
|
|
35
56
|
projects_dir="$HOME/.claude/projects"
|
|
@@ -40,18 +61,41 @@ if [[ "$has_commits" -eq 0 ]] && [[ "$has_uncommitted" -eq 0 ]]; then
|
|
|
40
61
|
git -C "$proj_path" branch -d "$branch_name" 2>/dev/null || true
|
|
41
62
|
python3 "$MUX_LIB" worktree-remove "$SESSION" "$WINDOW" 2>/dev/null || true
|
|
42
63
|
else
|
|
43
|
-
# Dirty — create urgent watchtower inbox item + sticky note
|
|
64
|
+
# Dirty — create urgent watchtower inbox item + sticky note.
|
|
65
|
+
# Counts may be "?" when the check could not determine them (fail-DIRTY).
|
|
44
66
|
detail=""
|
|
45
|
-
[[ "$has_uncommitted"
|
|
46
|
-
|
|
67
|
+
if [[ "$has_uncommitted" != "0" && "$has_uncommitted" != "?" ]]; then
|
|
68
|
+
detail="${has_uncommitted} uncommitted"
|
|
69
|
+
fi
|
|
70
|
+
if [[ "$has_commits" != "0" && "$has_commits" != "?" ]]; then
|
|
71
|
+
detail="${detail:+$detail, }${has_commits} commit(s)"
|
|
72
|
+
fi
|
|
73
|
+
if [[ -z "$detail" ]]; then
|
|
74
|
+
detail="changes the dirty-check could not verify (treated as dirty)"
|
|
75
|
+
fi
|
|
47
76
|
|
|
48
77
|
# Watchtower inbox item (if watchtower is installed)
|
|
49
78
|
WATCHTOWER_QUEUE="${HOME}/.claude-cabinet/watchtower/scripts/watchtower-queue.mjs"
|
|
79
|
+
WATCHTOWER_LIB="${HOME}/.claude-cabinet/watchtower/scripts/watchtower-lib.mjs"
|
|
50
80
|
if [[ -f "$WATCHTOWER_QUEUE" ]]; then
|
|
51
81
|
NODE_BIN="${WATCHTOWER_NODE_PATH:-$(command -v node 2>/dev/null || true)}"
|
|
52
82
|
if [[ -n "$NODE_BIN" ]]; then
|
|
53
83
|
"$NODE_BIN" --input-type=module -e "
|
|
54
84
|
import { createItem, listPending } from '${WATCHTOWER_QUEUE}';
|
|
85
|
+
// project must be the watchtower config key, not the mux desk name —
|
|
86
|
+
// /inbox and the rings group by config key; desk-name keying
|
|
87
|
+
// ('cabinet' vs 'claude-cabinet') filed items nobody could find.
|
|
88
|
+
// The desk name is preserved in its own field.
|
|
89
|
+
// Dynamic import with fallback: this script (mux-setup) and the lib
|
|
90
|
+
// (/watchtower install) ship on different installers — a static named
|
|
91
|
+
// import against an older lib without the export is an ESM hard
|
|
92
|
+
// failure that would kill the whole filing silently. Degrade to the
|
|
93
|
+
// old desk-name keying instead; the key migration re-keys it later.
|
|
94
|
+
let resolveProjectIdentity, loadConfig;
|
|
95
|
+
try { ({ resolveProjectIdentity, loadConfig } = await import('${WATCHTOWER_LIB}')); } catch {}
|
|
96
|
+
let config = null;
|
|
97
|
+
try { config = loadConfig?.() ?? null; } catch {}
|
|
98
|
+
const identity = resolveProjectIdentity?.('${proj_path}', config) ?? null;
|
|
55
99
|
const existing = listPending({ category: 'worktree-unmerged' });
|
|
56
100
|
const isDup = existing.some(i =>
|
|
57
101
|
i.evidence?.branch === '${branch_name}' &&
|
|
@@ -59,8 +103,10 @@ else
|
|
|
59
103
|
);
|
|
60
104
|
if (!isDup) {
|
|
61
105
|
createItem({
|
|
62
|
-
project: '${SESSION}',
|
|
63
|
-
project_path: '${proj_path}',
|
|
106
|
+
project: identity?.name || '${SESSION}',
|
|
107
|
+
project_path: identity?.path || '${proj_path}',
|
|
108
|
+
desk: '${SESSION}',
|
|
109
|
+
...(identity ? {} : { project_unresolved: true }),
|
|
64
110
|
filed_by: 'pane-close',
|
|
65
111
|
category: 'worktree-unmerged',
|
|
66
112
|
urgency: 'urgent',
|