create-claude-cabinet 0.43.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 +57 -9
- package/lib/copy.js +56 -10
- package/lib/mux-setup.js +2 -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 +581 -93
- package/templates/mux/config/help.txt +2 -0
- 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 -7
- 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/cabinet-record-keeper/SKILL.md +6 -1
- package/templates/skills/cc-upgrade/SKILL.md +0 -1
- 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 -2
- 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/investigate/SKILL.md +0 -2
- 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 -7
- package/templates/skills/qa-handoff/SKILL.md +243 -25
- 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
|
@@ -5,6 +5,12 @@
|
|
|
5
5
|
# a state summary, then outputs it as hookSpecificOutput for Claude's
|
|
6
6
|
# additionalContext.
|
|
7
7
|
#
|
|
8
|
+
# Also runs the frontier-model watchdog: the SessionStart payload (stdin
|
|
9
|
+
# JSON, per the CC hook contract) carries the session's `model` id. If
|
|
10
|
+
# ~/.claude/cc-registry.json designates a frontierModel and this session
|
|
11
|
+
# runs a different model, a loud warning is prepended to the injected
|
|
12
|
+
# context. Visibility only — never blocks anything.
|
|
13
|
+
#
|
|
8
14
|
# If watchtower is not installed (no config.json), exits silently.
|
|
9
15
|
# If the context builder produces no output, exits silently.
|
|
10
16
|
#
|
|
@@ -13,16 +19,85 @@
|
|
|
13
19
|
|
|
14
20
|
command -v jq >/dev/null 2>&1 || exit 0
|
|
15
21
|
|
|
22
|
+
# Hook payload arrives on stdin (.tool_input-style JSON; never an env var).
|
|
23
|
+
# Guard against interactive invocation where stdin is a tty.
|
|
24
|
+
PAYLOAD=""
|
|
25
|
+
if [ ! -t 0 ]; then
|
|
26
|
+
PAYLOAD=$(cat)
|
|
27
|
+
fi
|
|
28
|
+
|
|
16
29
|
WATCHTOWER_DIR="${HOME}/.claude-cabinet/watchtower"
|
|
17
30
|
PROJECT_PATH="$(pwd)"
|
|
18
31
|
|
|
19
|
-
#
|
|
20
|
-
|
|
21
|
-
|
|
32
|
+
# --- Frontier-model watchdog -------------------------------------------------
|
|
33
|
+
# Canonical match rule lives in the orient skill (templates/skills/orient/
|
|
34
|
+
# SKILL.md, "Frontier-Model Watchdog") — this is a reference implementation
|
|
35
|
+
# of that rule, not a second definition:
|
|
36
|
+
# - key starting with "claude-" AND containing a digit => exact model ID,
|
|
37
|
+
# exact case-insensitive match required
|
|
38
|
+
# - anything else => family alias, case-insensitive containment
|
|
39
|
+
# - absent/empty/whitespace key => silent no-op ('' would match everything
|
|
40
|
+
# and silence the watchdog while appearing configured)
|
|
41
|
+
FRONTIER_WARNING=""
|
|
42
|
+
SESSION_MODEL=""
|
|
43
|
+
if [ -n "${PAYLOAD}" ]; then
|
|
44
|
+
SESSION_MODEL=$(printf '%s' "${PAYLOAD}" | jq -r '.model // empty' 2>/dev/null)
|
|
45
|
+
fi
|
|
46
|
+
REGISTRY="${HOME}/.claude/cc-registry.json"
|
|
47
|
+
FRONTIER_KEY=""
|
|
48
|
+
if [ -f "${REGISTRY}" ]; then
|
|
49
|
+
FRONTIER_KEY=$(jq -r '.frontierModel // empty' "${REGISTRY}" 2>/dev/null | tr -d '[:space:]')
|
|
50
|
+
fi
|
|
51
|
+
if [ -n "${FRONTIER_KEY}" ] && [ -z "${SESSION_MODEL}" ]; then
|
|
52
|
+
# A key is configured but the payload exposed no model id (field absent,
|
|
53
|
+
# renamed, or reshaped by a future CC release). Say so instead of going
|
|
54
|
+
# silent — silence here is indistinguishable from "model matches".
|
|
55
|
+
FRONTIER_WARNING="ℹ FRONTIER WATCHDOG: a frontier model is designated (${FRONTIER_KEY}) but the SessionStart payload exposed no session model id — the early-boundary check was SKIPPED, not passed. The /orient watchdog phase remains the boundary."
|
|
56
|
+
fi
|
|
57
|
+
if [ -n "${SESSION_MODEL}" ] && [ -n "${FRONTIER_KEY}" ]; then
|
|
58
|
+
key_lc=$(printf '%s' "${FRONTIER_KEY}" | tr '[:upper:]' '[:lower:]')
|
|
59
|
+
model_lc=$(printf '%s' "${SESSION_MODEL}" | tr '[:upper:]' '[:lower:]')
|
|
60
|
+
# Session model ids may carry a bracketed runtime suffix (e.g.
|
|
61
|
+
# claude-fable-5[1m]); strip it before exact comparison — the suffix is
|
|
62
|
+
# session configuration, not model identity.
|
|
63
|
+
model_base_lc="${model_lc%%\[*}"
|
|
64
|
+
matched=0
|
|
65
|
+
case "${key_lc}" in
|
|
66
|
+
claude-*[0-9]*)
|
|
67
|
+
# Exact model ID — require identity against the suffix-stripped id.
|
|
68
|
+
[ "${model_base_lc}" = "${key_lc}" ] && matched=1
|
|
69
|
+
;;
|
|
70
|
+
*)
|
|
71
|
+
# Family alias — containment.
|
|
72
|
+
case "${model_lc}" in
|
|
73
|
+
*"${key_lc}"*) matched=1 ;;
|
|
74
|
+
esac
|
|
75
|
+
;;
|
|
76
|
+
esac
|
|
77
|
+
if [ "${matched}" -eq 0 ]; then
|
|
78
|
+
FRONTIER_WARNING="⚠ FRONTIER WATCHDOG: this session runs ${SESSION_MODEL}; your designated frontier model is ${FRONTIER_KEY} — switch with /model or relaunch. Surface this warning to the user as the FIRST line of any briefing. (Visibility only; nothing is blocked. Update the key with: npx create-claude-cabinet --frontier-model <model>)"
|
|
79
|
+
fi
|
|
80
|
+
fi
|
|
81
|
+
# -----------------------------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
# No config → watchtower not installed → still emit a frontier warning if
|
|
84
|
+
# one fired (the hook only registers on watchtower installs, but a torn-down
|
|
85
|
+
# config should not eat the watchdog), otherwise exit silently.
|
|
86
|
+
CONTEXT=""
|
|
87
|
+
if [ -f "${WATCHTOWER_DIR}/config.json" ]; then
|
|
88
|
+
# Build context. Suppress stderr to avoid noise on missing files.
|
|
89
|
+
CONTEXT=$(node "${WATCHTOWER_DIR}/scripts/watchtower-build-context.mjs" --project-path "${PROJECT_PATH}" 2>/dev/null)
|
|
22
90
|
fi
|
|
23
91
|
|
|
24
|
-
|
|
25
|
-
|
|
92
|
+
if [ -n "${FRONTIER_WARNING}" ]; then
|
|
93
|
+
if [ -n "${CONTEXT}" ]; then
|
|
94
|
+
CONTEXT="${FRONTIER_WARNING}
|
|
95
|
+
|
|
96
|
+
${CONTEXT}"
|
|
97
|
+
else
|
|
98
|
+
CONTEXT="${FRONTIER_WARNING}"
|
|
99
|
+
fi
|
|
100
|
+
fi
|
|
26
101
|
|
|
27
102
|
# Empty context → nothing to inject
|
|
28
103
|
if [ -z "${CONTEXT}" ]; then
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# Sandboxed AC verification for act:14305d16 — the .claude/ authored/infra
|
|
3
|
+
# carve-out generalized from a static plans/methodology pair to "any tracked
|
|
4
|
+
# .claude/ file is authored; only gitignored infra is disposable"
|
|
5
|
+
# (.claude/rules/artifacts-of-thought.md).
|
|
6
|
+
#
|
|
7
|
+
# Drives templates/mux/config/worktree-session-health.sh directly against a
|
|
8
|
+
# throwaway repo + worktree under mktemp with HOME overridden — never touches
|
|
9
|
+
# the real ~/.mux/worktrees, ~/.config/mux, or any live worktree.
|
|
10
|
+
set -uo pipefail
|
|
11
|
+
|
|
12
|
+
REPO_ROOT="$(cd "$(dirname "$0")/../../.." && pwd)"
|
|
13
|
+
SANDBOX=$(mktemp -d /tmp/cc-carveout-test.XXXXXX)
|
|
14
|
+
FAKE_HOME="$SANDBOX/home"
|
|
15
|
+
PROJ="$SANDBOX/proj"
|
|
16
|
+
WT="$SANDBOX/wt"
|
|
17
|
+
HEALTH="$REPO_ROOT/templates/mux/config/worktree-session-health.sh"
|
|
18
|
+
DIRTY="$REPO_ROOT/templates/mux/config/worktree-dirty-check.sh"
|
|
19
|
+
|
|
20
|
+
pass=0; fail=0
|
|
21
|
+
ok() { echo " PASS: $1"; pass=$((pass+1)); }
|
|
22
|
+
bad() { echo " FAIL: $1"; fail=$((fail+1)); }
|
|
23
|
+
assert() { # assert <desc> <cmd...>
|
|
24
|
+
local desc="$1"; shift
|
|
25
|
+
if "$@" >/dev/null 2>&1; then ok "$desc"; else bad "$desc"; fi
|
|
26
|
+
}
|
|
27
|
+
run_health() { HOME="$FAKE_HOME" bash "$HEALTH" "$PROJ" "$WT" "$@" 2>&1; }
|
|
28
|
+
|
|
29
|
+
mkdir -p "$FAKE_HOME/.local/share/mux/wt-health" "$FAKE_HOME/.claude/projects"
|
|
30
|
+
trap 'rm -rf "$SANDBOX"' EXIT
|
|
31
|
+
|
|
32
|
+
# --- fixture main repo --------------------------------------------------------
|
|
33
|
+
# Tracked (= authored): plans/, methodology/, rules/, cabinet/c.md.
|
|
34
|
+
# Gitignored (= infra): skills/ entirely; cabinet/_briefing.md inside the
|
|
35
|
+
# otherwise-tracked cabinet/ (the mixed-dir case).
|
|
36
|
+
git init -q "$PROJ"
|
|
37
|
+
git -C "$PROJ" config user.email t@t.t; git -C "$PROJ" config user.name t
|
|
38
|
+
mkdir -p "$PROJ/.claude/plans" "$PROJ/.claude/rules" "$PROJ/.claude/cabinet" "$PROJ/.claude/skills"
|
|
39
|
+
printf '%s\n' '.claude/skills/' '.claude/cabinet/_briefing*.md' > "$PROJ/.gitignore"
|
|
40
|
+
echo plan > "$PROJ/.claude/plans/p.md"
|
|
41
|
+
echo rule > "$PROJ/.claude/rules/r.md"
|
|
42
|
+
echo cabinet-doc > "$PROJ/.claude/cabinet/c.md"
|
|
43
|
+
echo briefing-v1 > "$PROJ/.claude/cabinet/_briefing.md"
|
|
44
|
+
echo skill-v1 > "$PROJ/.claude/skills/s.md"
|
|
45
|
+
echo code > "$PROJ/file.txt"
|
|
46
|
+
git -C "$PROJ" add -A && git -C "$PROJ" commit -qm init
|
|
47
|
+
MAIN_SLUG=$(echo "$PROJ" | sed 's|[/.]|-|g')
|
|
48
|
+
mkdir -p "$FAKE_HOME/.claude/projects/$MAIN_SLUG"
|
|
49
|
+
|
|
50
|
+
git -C "$PROJ" worktree add -q "$WT" -b mux/carveout HEAD
|
|
51
|
+
|
|
52
|
+
frozen() { git -C "$WT" ls-files -v .claude/ | grep -c '^h '; }
|
|
53
|
+
excl() { cat "$(git -C "$WT" rev-parse --git-dir)/info/exclude" 2>/dev/null; }
|
|
54
|
+
|
|
55
|
+
echo "== T1: health run leaves every tracked .claude/ file unfrozen (AC1)"
|
|
56
|
+
run_health --refresh >"$SANDBOX/t1.out"
|
|
57
|
+
assert "zero tracked .claude/ files carry the assume-unchanged bit" \
|
|
58
|
+
test "$(frozen)" -eq 0
|
|
59
|
+
assert "tracked rules/ file shows H in ls-files -v" \
|
|
60
|
+
bash -c "git -C '$WT' ls-files -v .claude/rules/r.md | grep -q '^H '"
|
|
61
|
+
assert "tracked cabinet/ file shows H in ls-files -v" \
|
|
62
|
+
bash -c "git -C '$WT' ls-files -v .claude/cabinet/c.md | grep -q '^H '"
|
|
63
|
+
assert "gitignored skills/ infra copied from main" \
|
|
64
|
+
grep -qx skill-v1 "$WT/.claude/skills/s.md"
|
|
65
|
+
assert "gitignored briefing inside mixed cabinet/ copied from main" \
|
|
66
|
+
grep -qx briefing-v1 "$WT/.claude/cabinet/_briefing.md"
|
|
67
|
+
|
|
68
|
+
echo "== T2: exclude negations are derived from the index, not a static pair"
|
|
69
|
+
assert "exclude hides untracked infra (.claude/*)" \
|
|
70
|
+
bash -c "excl() { cat \"\$(git -C '$WT' rev-parse --git-dir)/info/exclude\"; }; excl | grep -qxF '.claude/*'"
|
|
71
|
+
for d in plans rules cabinet; do
|
|
72
|
+
assert "exclude negates tracked dir $d/" \
|
|
73
|
+
bash -c "cat \"\$(git -C '$WT' rev-parse --git-dir)/info/exclude\" | grep -qxF '!.claude/$d/'"
|
|
74
|
+
done
|
|
75
|
+
assert "no negation for pure-infra skills/" \
|
|
76
|
+
bash -c "! cat \"\$(git -C '$WT' rev-parse --git-dir)/info/exclude\" | grep -qxF '!.claude/skills/'"
|
|
77
|
+
run_health >"$SANDBOX/t2.out"
|
|
78
|
+
assert "second run does not duplicate managed exclude lines" \
|
|
79
|
+
bash -c "[ \"\$(cat \"\$(git -C '$WT' rev-parse --git-dir)/info/exclude\" | grep -cxF '.claude/*')\" -eq 1 ]"
|
|
80
|
+
|
|
81
|
+
echo "== T3: legacy frozen worktree heals (the 2026-06-11 trap)"
|
|
82
|
+
git -C "$WT" update-index --assume-unchanged .claude/rules/r.md
|
|
83
|
+
echo trapped-edit > "$WT/.claude/rules/r.md"
|
|
84
|
+
assert "precondition: frozen edit invisible to git status" \
|
|
85
|
+
bash -c "! git -C '$WT' status --porcelain | grep -q rules/r.md"
|
|
86
|
+
run_health >"$SANDBOX/t3.out"
|
|
87
|
+
assert "health run clears the bit" test "$(frozen)" -eq 0
|
|
88
|
+
assert "edit to tracked rules/ file now visible to git" \
|
|
89
|
+
bash -c "git -C '$WT' status --porcelain | grep -q 'M .claude/rules/r.md'"
|
|
90
|
+
|
|
91
|
+
echo "== T4: --refresh never overwrites tracked, locally-modified files (AC2)"
|
|
92
|
+
echo main-side-edit > "$PROJ/.claude/rules/r.md" # uncommitted main edit
|
|
93
|
+
echo skill-v2 > "$PROJ/.claude/skills/s.md" # infra changed in main
|
|
94
|
+
echo briefing-v2 > "$PROJ/.claude/cabinet/_briefing.md"
|
|
95
|
+
run_health --refresh >"$SANDBOX/t4.out"
|
|
96
|
+
assert "worktree's modified tracked rules/ file survives refresh" \
|
|
97
|
+
grep -qx trapped-edit "$WT/.claude/rules/r.md"
|
|
98
|
+
assert "tracked cabinet doc untouched by refresh" \
|
|
99
|
+
grep -qx cabinet-doc "$WT/.claude/cabinet/c.md"
|
|
100
|
+
assert "pure-infra skills/ refreshed to main's version" \
|
|
101
|
+
grep -qx skill-v2 "$WT/.claude/skills/s.md"
|
|
102
|
+
assert "mixed-dir gitignored briefing refreshed to main's version" \
|
|
103
|
+
grep -qx briefing-v2 "$WT/.claude/cabinet/_briefing.md"
|
|
104
|
+
|
|
105
|
+
echo "== T5: freshness check ignores tracked files (no refresh churn loop)"
|
|
106
|
+
sleep 1; touch "$PROJ/.claude/rules/r.md" "$PROJ/.claude/plans/p.md"
|
|
107
|
+
out=$(run_health)
|
|
108
|
+
assert "newer tracked main files do not count as stale infra" \
|
|
109
|
+
grep -q 'current with main repo' <<<"$out"
|
|
110
|
+
|
|
111
|
+
echo "== T6: new doc beside tracked files is visible + dirty"
|
|
112
|
+
echo new-rule > "$WT/.claude/rules/new-rule.md"
|
|
113
|
+
assert "new untracked rules/ doc appears in git status" \
|
|
114
|
+
bash -c "git -C '$WT' status --porcelain --untracked-files=all | grep -q 'new-rule.md'"
|
|
115
|
+
out=$(bash "$DIRTY" "$WT" "$PROJ")
|
|
116
|
+
assert "dirty-check counts it as authored record ($out)" \
|
|
117
|
+
grep -q 'verdict=dirty' <<<"$out"
|
|
118
|
+
rm "$WT/.claude/rules/new-rule.md"
|
|
119
|
+
git -C "$WT" checkout -q -- .claude/rules/r.md
|
|
120
|
+
|
|
121
|
+
echo "== T7: infra-only worktree is clean (churn never blocks cleanup)"
|
|
122
|
+
out=$(bash "$DIRTY" "$WT" "$PROJ")
|
|
123
|
+
assert "worktree with only copied infra => clean ($out)" \
|
|
124
|
+
grep -q 'verdict=clean' <<<"$out"
|
|
125
|
+
|
|
126
|
+
echo "== T8: pure-infra deletions in main reconcile on refresh"
|
|
127
|
+
rm "$PROJ/.claude/skills/s.md"; echo skill-new > "$PROJ/.claude/skills/s2.md"
|
|
128
|
+
run_health --refresh >"$SANDBOX/t8.out"
|
|
129
|
+
assert "deleted main infra file removed from worktree" \
|
|
130
|
+
test ! -e "$WT/.claude/skills/s.md"
|
|
131
|
+
assert "new main infra file arrives in worktree" \
|
|
132
|
+
grep -qx skill-new "$WT/.claude/skills/s2.md"
|
|
133
|
+
|
|
134
|
+
echo ""
|
|
135
|
+
echo "RESULT: $pass passed, $fail failed (sandbox: $SANDBOX)"
|
|
136
|
+
exit $fail
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// Guard test for the .claude/ authored/infra carve-out (act:14305d16).
|
|
2
|
+
//
|
|
3
|
+
// Drives claude-carveout.fixture.sh, which builds a throwaway repo +
|
|
4
|
+
// worktree under mktemp with HOME overridden — it never touches the real
|
|
5
|
+
// ~/.mux/worktrees, ~/.config/mux, or any live worktree.
|
|
6
|
+
//
|
|
7
|
+
// The rule under test (.claude/rules/artifacts-of-thought.md): ANY tracked
|
|
8
|
+
// .claude/ file is authored project record; only gitignored infra is
|
|
9
|
+
// disposable. Covers:
|
|
10
|
+
// - no tracked .claude/ file ever carries the assume-unchanged bit
|
|
11
|
+
// - legacy frozen worktrees heal (the bit that hid an edit to
|
|
12
|
+
// .claude/rules/enforcement-pipeline.md on 2026-06-11)
|
|
13
|
+
// - --refresh never overwrites a tracked, locally-modified .claude/ file
|
|
14
|
+
// - exclude negations derive from the index, not a static plans/methodology pair
|
|
15
|
+
// - pure-infra entries still reconcile deletions; mixed dirs copy only
|
|
16
|
+
// their gitignored files
|
|
17
|
+
|
|
18
|
+
import { test } from 'node:test';
|
|
19
|
+
import assert from 'node:assert';
|
|
20
|
+
import { spawnSync } from 'node:child_process';
|
|
21
|
+
import { fileURLToPath } from 'node:url';
|
|
22
|
+
import path from 'node:path';
|
|
23
|
+
|
|
24
|
+
const fixture = path.join(
|
|
25
|
+
path.dirname(fileURLToPath(import.meta.url)),
|
|
26
|
+
'claude-carveout.fixture.sh'
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
test('.claude/ carve-out: tracked = authored, gitignored = infra (sandboxed fixture suite)', () => {
|
|
30
|
+
const res = spawnSync('bash', [fixture], { encoding: 'utf8', timeout: 120_000 });
|
|
31
|
+
const output = `${res.stdout}\n${res.stderr}`;
|
|
32
|
+
assert.strictEqual(
|
|
33
|
+
res.status,
|
|
34
|
+
0,
|
|
35
|
+
`fixture suite reported failures:\n${output}`
|
|
36
|
+
);
|
|
37
|
+
assert.match(output, /RESULT: \d+ passed, 0 failed/);
|
|
38
|
+
});
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# Sandboxed AC verification for act:fe6aab6f (mux fail-loud sweep).
|
|
3
|
+
#
|
|
4
|
+
# Everything runs under a mktemp dir with HOME overridden AND a private
|
|
5
|
+
# throwaway tmux server (tmux -L mux-test-$$ -f /dev/null) — it never touches
|
|
6
|
+
# the real ~/.mux/worktrees, ~/.config/mux, or the operator's live tmux
|
|
7
|
+
# server (the test runner itself may be running inside one). The test server
|
|
8
|
+
# is killed in cleanup even on assertion failure.
|
|
9
|
+
set -uo pipefail
|
|
10
|
+
|
|
11
|
+
REPO_ROOT="$(cd "$(dirname "$0")/../../.." && pwd)"
|
|
12
|
+
SANDBOX=$(mktemp -d /tmp/cc-mux-failloud-test.XXXXXX)
|
|
13
|
+
FAKE_HOME="$SANDBOX/home"
|
|
14
|
+
PROJ="$SANDBOX/proj"
|
|
15
|
+
WT_DIR="$FAKE_HOME/.mux/worktrees"
|
|
16
|
+
MUX="$REPO_ROOT/templates/mux/bin/mux"
|
|
17
|
+
|
|
18
|
+
pass=0; fail=0
|
|
19
|
+
ok() { echo " PASS: $1"; pass=$((pass+1)); }
|
|
20
|
+
bad() { echo " FAIL: $1"; fail=$((fail+1)); }
|
|
21
|
+
assert() { # assert <desc> <cmd...>
|
|
22
|
+
local desc="$1"; shift
|
|
23
|
+
if "$@" >/dev/null 2>&1; then ok "$desc"; else bad "$desc"; fi
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
# --- private throwaway tmux server -------------------------------------------
|
|
27
|
+
# Never the operator's live server: dedicated socket, no config, killed on exit.
|
|
28
|
+
unset TMUX TMUX_PANE
|
|
29
|
+
SOCK="mux-test-$$"
|
|
30
|
+
TMX() { tmux -L "$SOCK" -f /dev/null "$@"; }
|
|
31
|
+
cleanup() { TMX kill-server 2>/dev/null || true; rm -rf "$SANDBOX"; }
|
|
32
|
+
trap cleanup EXIT
|
|
33
|
+
|
|
34
|
+
# --- sandbox home -------------------------------------------------------------
|
|
35
|
+
mkdir -p "$FAKE_HOME/.config/mux" "$WT_DIR" "$FAKE_HOME/.local/share/mux/wt-health" \
|
|
36
|
+
"$FAKE_HOME/.claude/projects"
|
|
37
|
+
cp "$REPO_ROOT/templates/mux/config/worktree-session-health.sh" "$FAKE_HOME/.config/mux/"
|
|
38
|
+
cp "$REPO_ROOT/templates/mux/config/worktree-dirty-check.sh" "$FAKE_HOME/.config/mux/"
|
|
39
|
+
chmod +x "$FAKE_HOME/.config/mux/"*.sh
|
|
40
|
+
|
|
41
|
+
# Stub muxlib.py — answers only what cmd_new/cmd_resume/create_worktree ask.
|
|
42
|
+
# project-path mimics the real muxlib: prints the path, or exits 1 when the
|
|
43
|
+
# project isn't registered (STUB_PROJ_PATH empty/unset).
|
|
44
|
+
cat > "$FAKE_HOME/.config/mux/muxlib.py" <<'PY'
|
|
45
|
+
import sys, os
|
|
46
|
+
cmd = sys.argv[1] if len(sys.argv) > 1 else ""
|
|
47
|
+
if cmd == "project-path":
|
|
48
|
+
p = os.environ.get("STUB_PROJ_PATH", "")
|
|
49
|
+
if p:
|
|
50
|
+
print(p)
|
|
51
|
+
else:
|
|
52
|
+
sys.exit(1)
|
|
53
|
+
elif cmd == "project-names":
|
|
54
|
+
print("sess")
|
|
55
|
+
elif cmd in ("worktree-add", "worktree-remove"):
|
|
56
|
+
pass
|
|
57
|
+
elif cmd == "worktree-is-active":
|
|
58
|
+
print("yes")
|
|
59
|
+
elif cmd == "narrate-check":
|
|
60
|
+
print("yes")
|
|
61
|
+
PY
|
|
62
|
+
echo '{"active":[]}' > "$FAKE_HOME/.config/mux/worktrees.json"
|
|
63
|
+
echo '{}' > "$FAKE_HOME/.config/mux/projects.json"
|
|
64
|
+
|
|
65
|
+
# --- fixture projects ----------------------------------------------------------
|
|
66
|
+
# .claude/rules/ is tracked (= authored record), .claude/skills/ is gitignored
|
|
67
|
+
# (= disposable infra) — lets T3 assert the creation-path carve-out.
|
|
68
|
+
git init -q "$PROJ"
|
|
69
|
+
git -C "$PROJ" config user.email t@t.t; git -C "$PROJ" config user.name t
|
|
70
|
+
echo code > "$PROJ/file.txt"
|
|
71
|
+
mkdir -p "$PROJ/.claude/rules" "$PROJ/.claude/skills"
|
|
72
|
+
echo '.claude/skills/' > "$PROJ/.gitignore"
|
|
73
|
+
echo rule > "$PROJ/.claude/rules/r.md"
|
|
74
|
+
echo skill > "$PROJ/.claude/skills/s.md"
|
|
75
|
+
git -C "$PROJ" add -A && git -C "$PROJ" commit -qm init
|
|
76
|
+
MAIN_SLUG=$(echo "$PROJ" | sed 's|[/.]|-|g')
|
|
77
|
+
mkdir -p "$FAKE_HOME/.claude/projects/$MAIN_SLUG"
|
|
78
|
+
|
|
79
|
+
EMPTY_PROJ="$SANDBOX/empty-proj" # git repo, NO commits → worktree add must fail
|
|
80
|
+
git init -q "$EMPTY_PROJ"
|
|
81
|
+
|
|
82
|
+
PLAIN_DIR="$SANDBOX/plain-dir" # registered project that is not a git repo
|
|
83
|
+
mkdir -p "$PLAIN_DIR"
|
|
84
|
+
|
|
85
|
+
# --- test desk on the private server -------------------------------------------
|
|
86
|
+
HOME="$FAKE_HOME" TMX new-session -d -s sess -x 200 -y 50 -c "$PROJ" \
|
|
87
|
+
|| { echo "FATAL: could not start private tmux server"; exit 1; }
|
|
88
|
+
SOCKET_PATH=$(TMX display-message -p -t sess '#{socket_path}')
|
|
89
|
+
SERVER_PID=$(TMX display-message -p -t sess '#{pid}')
|
|
90
|
+
PANE_ID=$(TMX display-message -p -t sess:0 '#{pane_id}')
|
|
91
|
+
|
|
92
|
+
# Run mux "inside" the private server: TMUX points at the test socket so
|
|
93
|
+
# in_tmux passes and every bare `tmux` call lands on the throwaway server.
|
|
94
|
+
run_mux() { # run_mux <stub-proj-path> <args...>
|
|
95
|
+
local stub="$1"; shift
|
|
96
|
+
env HOME="$FAKE_HOME" STUB_PROJ_PATH="$stub" \
|
|
97
|
+
TMUX="${SOCKET_PATH},${SERVER_PID},0" TMUX_PANE="$PANE_ID" \
|
|
98
|
+
bash "$MUX" "$@"
|
|
99
|
+
}
|
|
100
|
+
win_count() { TMX list-windows -t sess 2>/dev/null | wc -l | tr -d ' '; }
|
|
101
|
+
win_opt() { TMX show-options -wv -t "sess:$1" "$2" 2>/dev/null; }
|
|
102
|
+
|
|
103
|
+
ID_A="aaaaaaaa-1111-2222-3333-444444444444"
|
|
104
|
+
ID_B="bbbbbbbb-1111-2222-3333-444444444444"
|
|
105
|
+
ID_C="cccccccc-1111-2222-3333-444444444444"
|
|
106
|
+
ID_D="dddddddd-1111-2222-3333-444444444444"
|
|
107
|
+
ID_E="eeeeeeee-1111-2222-3333-444444444444"
|
|
108
|
+
ID_F="ffffffff-1111-2222-3333-444444444444"
|
|
109
|
+
|
|
110
|
+
echo "== T0: no surviving silent fallbacks in bin/mux (AC1)"
|
|
111
|
+
assert "zero '|| win_path=\"\$path\"' fallbacks remain" \
|
|
112
|
+
bash -c "! grep -q '|| win_path=\"\\\$path\"' '$MUX'"
|
|
113
|
+
|
|
114
|
+
echo "== T1: cmd_resume input guard (id flows into branch/path/send-keys)"
|
|
115
|
+
before=$(win_count)
|
|
116
|
+
out=$(run_mux "$PROJ" resume abc 2>&1); rc=$?
|
|
117
|
+
assert "id shorter than 8 chars dies ($rc)" test "$rc" -ne 0
|
|
118
|
+
out=$(run_mux "$PROJ" resume 'not$a-valid;id!!' 2>&1); rc=$?
|
|
119
|
+
assert "non-hex id dies ($rc)" test "$rc" -ne 0
|
|
120
|
+
assert "guard failures open no window" test "$(win_count)" -eq "$before"
|
|
121
|
+
|
|
122
|
+
echo "== T2: no active Claude => by-design main landing (stays working)"
|
|
123
|
+
before=$(win_count)
|
|
124
|
+
out=$(run_mux "$PROJ" resume "$ID_A" 2>&1); rc=$?
|
|
125
|
+
assert "resume with no active Claude exits 0 ($rc)" test "$rc" -eq 0
|
|
126
|
+
assert "window resume-aaaaaaaa opened" test "$(win_count)" -eq $((before + 1))
|
|
127
|
+
assert "main landing carries NO @mux_worktree flag" \
|
|
128
|
+
test -z "$(win_opt resume-aaaaaaaa @mux_worktree)"
|
|
129
|
+
|
|
130
|
+
# Activate the desk: a non-shell pane command makes has_active_claude true.
|
|
131
|
+
TMX new-window -t sess -d -n fakeclaude 'sleep 300'
|
|
132
|
+
|
|
133
|
+
echo "== T3: active Claude + git project => worktree + window options (AC3)"
|
|
134
|
+
out=$(run_mux "$PROJ" resume "$ID_B" 2>&1); rc=$?
|
|
135
|
+
assert "resume exits 0 ($rc)" test "$rc" -eq 0
|
|
136
|
+
assert "worktree created" test -d "$WT_DIR/sess-resume-bbbbbbbb"
|
|
137
|
+
assert "@mux_worktree returns 1 (AC3)" \
|
|
138
|
+
test "$(win_opt resume-bbbbbbbb @mux_worktree)" = "1"
|
|
139
|
+
assert "@wt_healthy set alongside (·wt indicator not red)" \
|
|
140
|
+
test "$(win_opt resume-bbbbbbbb @wt_healthy)" = "1"
|
|
141
|
+
|
|
142
|
+
echo "== T3b: creation-path carve-out — tracked .claude/ never frozen (act:14305d16)"
|
|
143
|
+
WT_B="$WT_DIR/sess-resume-bbbbbbbb"
|
|
144
|
+
assert "no tracked .claude/ file carries the assume-unchanged bit" \
|
|
145
|
+
bash -c "! git -C '$WT_B' ls-files -v .claude/ | grep -q '^h '"
|
|
146
|
+
assert "gitignored .claude/skills/ infra copied into the worktree" \
|
|
147
|
+
test -f "$WT_B/.claude/skills/s.md"
|
|
148
|
+
assert "tracked .claude/rules/ file checked out, not clobbered" \
|
|
149
|
+
bash -c "grep -qx rule '$WT_B/.claude/rules/r.md'"
|
|
150
|
+
|
|
151
|
+
echo "== T4: re-resume of the same id REUSES the existing worktree"
|
|
152
|
+
TMX kill-window -t sess:resume-bbbbbbbb 2>/dev/null
|
|
153
|
+
out=$(run_mux "$PROJ" resume "$ID_B" 2>&1); rc=$?
|
|
154
|
+
assert "second resume of same id exits 0 ($rc)" test "$rc" -eq 0
|
|
155
|
+
assert "existing worktree reused (no uniquified sibling)" \
|
|
156
|
+
bash -c "! ls -d '$WT_DIR'/sess-resume-bbbbbbbb-* 2>/dev/null | grep -q ."
|
|
157
|
+
assert "reused window flagged @mux_worktree=1" \
|
|
158
|
+
test "$(win_opt resume-bbbbbbbb @mux_worktree)" = "1"
|
|
159
|
+
assert "reused window flagged @wt_healthy=1" \
|
|
160
|
+
test "$(win_opt resume-bbbbbbbb @wt_healthy)" = "1"
|
|
161
|
+
|
|
162
|
+
echo "== T5: slug path occupied by an INVALID dir => die naming the path"
|
|
163
|
+
mkdir -p "$WT_DIR/sess-resume-cccccccc"
|
|
164
|
+
before=$(win_count)
|
|
165
|
+
out=$(run_mux "$PROJ" resume "$ID_C" 2>&1); rc=$?
|
|
166
|
+
assert "invalid leftover dies ($rc)" test "$rc" -ne 0
|
|
167
|
+
assert "die message names the colliding path" \
|
|
168
|
+
grep -q "sess-resume-cccccccc" <<<"$out"
|
|
169
|
+
assert "die message offers a recovery command" grep -q "rm -rf" <<<"$out"
|
|
170
|
+
assert "nothing opened in main" test "$(win_count)" -eq "$before"
|
|
171
|
+
|
|
172
|
+
echo "== T6: real worktree-creation failure => die, nothing opens (AC2)"
|
|
173
|
+
before=$(win_count)
|
|
174
|
+
out=$(run_mux "$EMPTY_PROJ" resume "$ID_D" 2>&1); rc=$?
|
|
175
|
+
assert "cmd_resume: forced creation failure dies ($rc)" test "$rc" -ne 0
|
|
176
|
+
assert "cmd_resume: die message refuses main checkout" \
|
|
177
|
+
grep -q "refusing to run work on the main checkout" <<<"$out"
|
|
178
|
+
assert "cmd_resume: nothing opened in main" test "$(win_count)" -eq "$before"
|
|
179
|
+
out=$(run_mux "$EMPTY_PROJ" new 2>&1); rc=$?
|
|
180
|
+
assert "cmd_new no-prompt: forced creation failure dies ($rc)" test "$rc" -ne 0
|
|
181
|
+
assert "cmd_new no-prompt: die message refuses main checkout" \
|
|
182
|
+
grep -q "refusing to run work on the main checkout" <<<"$out"
|
|
183
|
+
assert "cmd_new no-prompt: nothing opened in main" test "$(win_count)" -eq "$before"
|
|
184
|
+
|
|
185
|
+
echo "== T7: isolation impossible BY DESIGN => loud fall-through, not die"
|
|
186
|
+
before=$(win_count)
|
|
187
|
+
out=$(run_mux "$PLAIN_DIR" resume "$ID_E" 2>&1); rc=$?
|
|
188
|
+
assert "non-git project dir: resume still works ($rc)" test "$rc" -eq 0
|
|
189
|
+
assert "non-git project dir: visible warning names why" \
|
|
190
|
+
grep -q "isn't a git repository" <<<"$out"
|
|
191
|
+
assert "non-git project dir: window opened on main" test "$(win_count)" -eq $((before + 1))
|
|
192
|
+
assert "non-git landing carries NO @mux_worktree flag" \
|
|
193
|
+
test -z "$(win_opt resume-eeeeeeee @mux_worktree)"
|
|
194
|
+
before=$(win_count)
|
|
195
|
+
out=$(run_mux "" resume "$ID_F" 2>&1); rc=$?
|
|
196
|
+
assert "unregistered desk: resume still works ($rc)" test "$rc" -eq 0
|
|
197
|
+
assert "unregistered desk: visible warning names why" \
|
|
198
|
+
grep -q "isn't a registered mux project" <<<"$out"
|
|
199
|
+
assert "unregistered desk: window opened" test "$(win_count)" -eq $((before + 1))
|
|
200
|
+
|
|
201
|
+
echo "== T8: cmd_new no-prompt success path still isolates + flags"
|
|
202
|
+
before=$(win_count)
|
|
203
|
+
out=$(run_mux "$PROJ" new 2>&1); rc=$?
|
|
204
|
+
assert "cmd_new no-prompt exits 0 ($rc)" test "$rc" -eq 0
|
|
205
|
+
assert "window opened" test "$(win_count)" -eq $((before + 1))
|
|
206
|
+
new_wt=$(ls -d "$WT_DIR"/sess-window-* 2>/dev/null | head -1)
|
|
207
|
+
assert "worktree created for the new window" test -n "$new_wt"
|
|
208
|
+
if [[ -n "$new_wt" ]]; then
|
|
209
|
+
wname=$(basename "$new_wt"); wname="${wname#sess-}"
|
|
210
|
+
assert "new window flagged @mux_worktree=1" \
|
|
211
|
+
test "$(win_opt "$wname" @mux_worktree)" = "1"
|
|
212
|
+
assert "new window flagged @wt_healthy=1" \
|
|
213
|
+
test "$(win_opt "$wname" @wt_healthy)" = "1"
|
|
214
|
+
fi
|
|
215
|
+
|
|
216
|
+
echo "== T9: mux handoff seeds the next session into a WORKTREE, not main"
|
|
217
|
+
# Design intent (worktree-to-worktree handoffs): the seeded session is a
|
|
218
|
+
# second session on the desk — the shared-main commit-sweep class (1adc5ef)
|
|
219
|
+
# is what isolation prevents. Safe since the 40cc831 carve-out.
|
|
220
|
+
DESC="$SANDBOX/seed.json"
|
|
221
|
+
cat > "$DESC" <<JSON
|
|
222
|
+
{ "project": "sess", "project_path": "$PROJ", "name": "seeded-next",
|
|
223
|
+
"seed_prompt": "continue the thing" }
|
|
224
|
+
JSON
|
|
225
|
+
before=$(win_count)
|
|
226
|
+
out=$(run_mux "$PROJ" handoff "$DESC" 2>&1); rc=$?
|
|
227
|
+
assert "handoff exits 0 ($rc)" test "$rc" -eq 0
|
|
228
|
+
assert "window opened" test "$(win_count)" -eq $((before + 1))
|
|
229
|
+
assert "worktree created for the seeded session" test -d "$WT_DIR/sess-seeded-next"
|
|
230
|
+
assert "seeded window flagged @mux_worktree=1" \
|
|
231
|
+
test "$(win_opt seeded-next @mux_worktree)" = "1"
|
|
232
|
+
assert "seeded window flagged @wt_healthy=1" \
|
|
233
|
+
test "$(win_opt seeded-next @wt_healthy)" = "1"
|
|
234
|
+
assert "launch message says worktree" grep -q "fresh worktree" <<<"$out"
|
|
235
|
+
assert "launch message does not claim main checkout" \
|
|
236
|
+
bash -c "! grep -q \"main checkout\" <<<'$out'"
|
|
237
|
+
|
|
238
|
+
echo "== T9b: handoff without isolation falls through LOUDLY to main"
|
|
239
|
+
DESC2="$SANDBOX/seed2.json"
|
|
240
|
+
cat > "$DESC2" <<JSON
|
|
241
|
+
{ "project": "sess", "project_path": "$PLAIN_DIR", "name": "seeded-plain",
|
|
242
|
+
"seed_prompt": "x" }
|
|
243
|
+
JSON
|
|
244
|
+
before=$(win_count)
|
|
245
|
+
out=$(run_mux "$PLAIN_DIR" handoff "$DESC2" 2>&1); rc=$?
|
|
246
|
+
assert "non-git project handoff exits 0 ($rc)" test "$rc" -eq 0
|
|
247
|
+
assert "window opened" test "$(win_count)" -eq $((before + 1))
|
|
248
|
+
assert "loud fall-through names why" grep -q "No worktree isolation" <<<"$out"
|
|
249
|
+
assert "main landing carries NO @mux_worktree" \
|
|
250
|
+
test -z "$(win_opt seeded-plain @mux_worktree)"
|
|
251
|
+
|
|
252
|
+
echo ""
|
|
253
|
+
echo "RESULT: $pass passed, $fail failed (sandbox: $SANDBOX)"
|
|
254
|
+
exit $fail
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// Guard test for the mux fail-loud sweep (act:fe6aab6f).
|
|
2
|
+
//
|
|
3
|
+
// Drives mux-fail-loud.fixture.sh, which runs cmd_new / cmd_resume against a
|
|
4
|
+
// PRIVATE throwaway tmux server (tmux -L mux-test-$$ -f /dev/null) plus a
|
|
5
|
+
// sandboxed git repo with HOME overridden — it never touches the operator's
|
|
6
|
+
// live tmux server, ~/.mux/worktrees, or ~/.config/mux. The test server is
|
|
7
|
+
// killed in cleanup even when assertions fail.
|
|
8
|
+
//
|
|
9
|
+
// Covers:
|
|
10
|
+
// - zero `|| win_path="$path"` silent fallbacks remain in bin/mux
|
|
11
|
+
// - forced worktree-creation failure on cmd_new (no-prompt) and cmd_resume
|
|
12
|
+
// exits non-zero and opens nothing in main
|
|
13
|
+
// - successful `mux resume` sets @mux_worktree=1 AND @wt_healthy=1
|
|
14
|
+
// - re-resume of the same id reuses the deterministic resume-<id8>
|
|
15
|
+
// worktree; an invalid leftover at that path dies naming it
|
|
16
|
+
// - by-design main landings stay working: no-active-Claude resume,
|
|
17
|
+
// unregistered desks, and non-git project dirs fall through LOUDLY
|
|
18
|
+
// - cmd_resume rejects non-id-shaped input before it reaches branch
|
|
19
|
+
// names / paths / send-keys
|
|
20
|
+
|
|
21
|
+
import { test } from 'node:test';
|
|
22
|
+
import assert from 'node:assert';
|
|
23
|
+
import { spawnSync } from 'node:child_process';
|
|
24
|
+
import { fileURLToPath } from 'node:url';
|
|
25
|
+
import path from 'node:path';
|
|
26
|
+
|
|
27
|
+
const fixture = path.join(
|
|
28
|
+
path.dirname(fileURLToPath(import.meta.url)),
|
|
29
|
+
'mux-fail-loud.fixture.sh'
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
test('mux fail-loud sweep: cmd_new/cmd_resume (sandboxed tmux fixture suite)', () => {
|
|
33
|
+
const res = spawnSync('bash', [fixture], { encoding: 'utf8', timeout: 120_000 });
|
|
34
|
+
const output = `${res.stdout}\n${res.stderr}`;
|
|
35
|
+
assert.strictEqual(
|
|
36
|
+
res.status,
|
|
37
|
+
0,
|
|
38
|
+
`fixture suite reported failures:\n${output}`
|
|
39
|
+
);
|
|
40
|
+
assert.match(output, /RESULT: \d+ passed, 0 failed/);
|
|
41
|
+
});
|