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,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
|
+
});
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# Sandboxed AC verification for act:04bdcc6b.
|
|
3
|
+
# Everything runs under a mktemp dir with HOME overridden — never touches
|
|
4
|
+
# the real ~/.mux/worktrees, ~/.config/mux, or any live worktree.
|
|
5
|
+
set -uo pipefail
|
|
6
|
+
|
|
7
|
+
REPO_ROOT="$(cd "$(dirname "$0")/../../.." && pwd)"
|
|
8
|
+
SANDBOX=$(mktemp -d /tmp/cc-dirty-check-test.XXXXXX)
|
|
9
|
+
FAKE_HOME="$SANDBOX/home"
|
|
10
|
+
PROJ="$SANDBOX/proj"
|
|
11
|
+
WT_DIR="$FAKE_HOME/.mux/worktrees"
|
|
12
|
+
|
|
13
|
+
pass=0; fail=0
|
|
14
|
+
ok() { echo " PASS: $1"; pass=$((pass+1)); }
|
|
15
|
+
bad() { echo " FAIL: $1"; fail=$((fail+1)); }
|
|
16
|
+
assert() { # assert <desc> <cmd...>
|
|
17
|
+
local desc="$1"; shift
|
|
18
|
+
if "$@" >/dev/null 2>&1; then ok "$desc"; else bad "$desc"; fi
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
# --- sandbox home -----------------------------------------------------------
|
|
22
|
+
mkdir -p "$FAKE_HOME/.config/mux" "$WT_DIR" "$FAKE_HOME/.local/share/mux/wt-health" \
|
|
23
|
+
"$FAKE_HOME/.local/bin" "$FAKE_HOME/.claude/projects"
|
|
24
|
+
cp "$REPO_ROOT/templates/mux/config/worktree-dirty-check.sh" "$FAKE_HOME/.config/mux/"
|
|
25
|
+
cp "$REPO_ROOT/templates/mux/config/worktree-cleanup.sh" "$FAKE_HOME/.config/mux/"
|
|
26
|
+
cp "$REPO_ROOT/templates/mux/config/worktree-session-health.sh" "$FAKE_HOME/.config/mux/"
|
|
27
|
+
chmod +x "$FAKE_HOME/.config/mux/"*.sh
|
|
28
|
+
|
|
29
|
+
# Stub muxlib.py — answers only what the worktree code paths ask.
|
|
30
|
+
cat > "$FAKE_HOME/.config/mux/muxlib.py" <<'PY'
|
|
31
|
+
import sys, os
|
|
32
|
+
cmd = sys.argv[1] if len(sys.argv) > 1 else ""
|
|
33
|
+
if cmd == "worktree-is-active":
|
|
34
|
+
print("yes")
|
|
35
|
+
elif cmd == "project-path":
|
|
36
|
+
print(os.environ.get("STUB_PROJ_PATH", ""))
|
|
37
|
+
elif cmd == "project-names":
|
|
38
|
+
print("sess")
|
|
39
|
+
elif cmd == "narrate-check":
|
|
40
|
+
print("yes")
|
|
41
|
+
PY
|
|
42
|
+
# Stub manage-notes.py — log invocations.
|
|
43
|
+
cat > "$FAKE_HOME/.config/mux/manage-notes.py" <<'PY'
|
|
44
|
+
import sys, os
|
|
45
|
+
with open(os.path.join(os.environ["HOME"], "notes-log.txt"), "a") as f:
|
|
46
|
+
f.write(" ".join(sys.argv[1:]) + "\n")
|
|
47
|
+
PY
|
|
48
|
+
echo '{"active":[]}' > "$FAKE_HOME/.config/mux/worktrees.json"
|
|
49
|
+
echo '{}' > "$FAKE_HOME/.config/mux/projects.json"
|
|
50
|
+
|
|
51
|
+
# --- fixture main repo ------------------------------------------------------
|
|
52
|
+
# .claude/skills/ is gitignored INFRA (refreshed by file copy); plans/,
|
|
53
|
+
# methodology/, and rules/ are TRACKED = authored record (sync via git only).
|
|
54
|
+
git init -q "$PROJ"
|
|
55
|
+
git -C "$PROJ" config user.email t@t.t; git -C "$PROJ" config user.name t
|
|
56
|
+
mkdir -p "$PROJ/.claude/plans" "$PROJ/.claude/methodology" "$PROJ/.claude/rules" "$PROJ/.claude/skills"
|
|
57
|
+
echo '.claude/skills/' > "$PROJ/.gitignore"
|
|
58
|
+
echo plan > "$PROJ/.claude/plans/existing.md"
|
|
59
|
+
echo meth > "$PROJ/.claude/methodology/m.md"
|
|
60
|
+
echo rule > "$PROJ/.claude/rules/r.md"
|
|
61
|
+
echo skill-v1 > "$PROJ/.claude/skills/skill.md"
|
|
62
|
+
echo '{"mcpServers":{}}' > "$PROJ/.mcp.json"
|
|
63
|
+
echo lock-v1 > "$PROJ/package-lock.json"
|
|
64
|
+
echo code > "$PROJ/file.txt"
|
|
65
|
+
git -C "$PROJ" add -A && git -C "$PROJ" commit -qm init
|
|
66
|
+
MAIN_SLUG=$(echo "$PROJ" | sed 's|[/.]|-|g')
|
|
67
|
+
mkdir -p "$FAKE_HOME/.claude/projects/$MAIN_SLUG"
|
|
68
|
+
|
|
69
|
+
mkwt() { git -C "$PROJ" worktree add -q "$WT_DIR/sess-$1" -b "mux/$1" HEAD; }
|
|
70
|
+
RUN_ENV=(env HOME="$FAKE_HOME" STUB_PROJ_PATH="$PROJ")
|
|
71
|
+
MUX="$REPO_ROOT/templates/mux/bin/mux"
|
|
72
|
+
HELPER="$FAKE_HOME/.config/mux/worktree-dirty-check.sh"
|
|
73
|
+
CLEANUP_HOOK="$FAKE_HOME/.config/mux/worktree-cleanup.sh"
|
|
74
|
+
|
|
75
|
+
echo "== T1: mux worktree refresh preserves uncommitted authored records (AC1)"
|
|
76
|
+
mkwt task1
|
|
77
|
+
mkdir -p "$WT_DIR/sess-task1/.claude/plans"
|
|
78
|
+
echo precious > "$WT_DIR/sess-task1/.claude/plans/test-file.md" # new, uncommitted
|
|
79
|
+
echo edited > "$WT_DIR/sess-task1/.claude/plans/existing.md" # modified, uncommitted
|
|
80
|
+
echo skill-v2 > "$PROJ/.claude/skills/skill.md" # infra changed in main
|
|
81
|
+
"${RUN_ENV[@]}" bash "$MUX" worktree refresh sess task1 >"$SANDBOX/t1.out" 2>&1
|
|
82
|
+
assert "uncommitted new plans file survives refresh" test -f "$WT_DIR/sess-task1/.claude/plans/test-file.md"
|
|
83
|
+
assert "uncommitted plans edit survives refresh" grep -qx edited "$WT_DIR/sess-task1/.claude/plans/existing.md"
|
|
84
|
+
assert "infra (skills) actually refreshed from main" grep -qx skill-v2 "$WT_DIR/sess-task1/.claude/skills/skill.md"
|
|
85
|
+
assert "methodology untouched" grep -qx meth "$WT_DIR/sess-task1/.claude/methodology/m.md"
|
|
86
|
+
|
|
87
|
+
echo "== T2: helper verdicts (single implementation)"
|
|
88
|
+
mkwt task2 # modified tracked .mcp.json -> dirty (AC2)
|
|
89
|
+
echo '{"mcpServers":{"x":{}}}' > "$WT_DIR/sess-task2/.mcp.json"
|
|
90
|
+
out=$("$HELPER" "$WT_DIR/sess-task2" "$PROJ")
|
|
91
|
+
assert "modified .mcp.json => dirty ($out)" grep -q 'uncommitted=1 verdict=dirty' <<<"$out"
|
|
92
|
+
|
|
93
|
+
mkwt task3 # lockfile churn + .mcp.json typechange + untracked .claude infra
|
|
94
|
+
# + node_modules symlink (gitignore `node_modules/` matches only
|
|
95
|
+
# dirs, so the mux-made symlink shows `?? node_modules` — found
|
|
96
|
+
# live 2026-06-11: it made every mux worktree immortal) -> clean (AC3)
|
|
97
|
+
echo lock-rewritten > "$WT_DIR/sess-task3/package-lock.json"
|
|
98
|
+
rm "$WT_DIR/sess-task3/.mcp.json"; ln -s "$PROJ/.mcp.json" "$WT_DIR/sess-task3/.mcp.json"
|
|
99
|
+
mkdir -p "$WT_DIR/sess-task3/.claude/agents"; echo x > "$WT_DIR/sess-task3/.claude/agents/a.md"
|
|
100
|
+
ln -s "$PROJ/node_modules" "$WT_DIR/sess-task3/node_modules"
|
|
101
|
+
out=$("$HELPER" "$WT_DIR/sess-task3" "$PROJ")
|
|
102
|
+
assert "lockfile + infra + node_modules symlink churn => clean ($out)" grep -q 'verdict=clean' <<<"$out"
|
|
103
|
+
|
|
104
|
+
mkwt task4 # untracked authored record -> dirty
|
|
105
|
+
mkdir -p "$WT_DIR/sess-task4/.claude/plans"; echo new > "$WT_DIR/sess-task4/.claude/plans/new-doc.md"
|
|
106
|
+
out=$("$HELPER" "$WT_DIR/sess-task4" "$PROJ")
|
|
107
|
+
assert "untracked .claude/plans/ doc => dirty ($out)" grep -q 'verdict=dirty' <<<"$out"
|
|
108
|
+
|
|
109
|
+
# task4c/d: the authored set is ANY top-level .claude/ entry with tracked
|
|
110
|
+
# files, not a static plans/methodology list (act:14305d16 — the
|
|
111
|
+
# assume-unchanged trap fired on .claude/rules/enforcement-pipeline.md).
|
|
112
|
+
mkwt task4c # MODIFIED tracked .claude/rules/ file -> dirty
|
|
113
|
+
echo edited-rule > "$WT_DIR/sess-task4c/.claude/rules/r.md"
|
|
114
|
+
out=$("$HELPER" "$WT_DIR/sess-task4c" "$PROJ")
|
|
115
|
+
assert "modified tracked .claude/rules/ file => dirty ($out)" grep -q 'verdict=dirty' <<<"$out"
|
|
116
|
+
|
|
117
|
+
mkwt task4d # NEW untracked doc beside tracked .claude/rules/ files -> dirty
|
|
118
|
+
echo new-rule > "$WT_DIR/sess-task4d/.claude/rules/new-rule.md"
|
|
119
|
+
out=$("$HELPER" "$WT_DIR/sess-task4d" "$PROJ")
|
|
120
|
+
assert "untracked doc in tracked .claude/rules/ => dirty ($out)" grep -q 'verdict=dirty' <<<"$out"
|
|
121
|
+
|
|
122
|
+
# task4b: repo whose .claude/ has NO tracked files — without
|
|
123
|
+
# --untracked-files=all, porcelain collapses everything to one '?? .claude/'
|
|
124
|
+
# line and the authored record inside is invisible (deletable). CP3 finding,
|
|
125
|
+
# group 2026-06-10-1.
|
|
126
|
+
BARE_PROJ="$SANDBOX/bare-proj"
|
|
127
|
+
git init -q "$BARE_PROJ"
|
|
128
|
+
git -C "$BARE_PROJ" config user.email t@t.t; git -C "$BARE_PROJ" config user.name t
|
|
129
|
+
git -C "$BARE_PROJ" commit -q --allow-empty -m init
|
|
130
|
+
mkdir -p "$BARE_PROJ/.claude/plans"; echo new > "$BARE_PROJ/.claude/plans/hidden-doc.md"
|
|
131
|
+
out=$("$HELPER" "$BARE_PROJ" "$BARE_PROJ")
|
|
132
|
+
assert "authored record inside fully-untracked .claude/ => dirty ($out)" grep -q 'verdict=dirty' <<<"$out"
|
|
133
|
+
# ...while pure infra inside a fully-untracked .claude/ stays clean churn
|
|
134
|
+
BARE_PROJ2="$SANDBOX/bare-proj2"
|
|
135
|
+
git init -q "$BARE_PROJ2"
|
|
136
|
+
git -C "$BARE_PROJ2" config user.email t@t.t; git -C "$BARE_PROJ2" config user.name t
|
|
137
|
+
git -C "$BARE_PROJ2" commit -q --allow-empty -m init
|
|
138
|
+
mkdir -p "$BARE_PROJ2/.claude/agents"; echo x > "$BARE_PROJ2/.claude/agents/a.md"
|
|
139
|
+
out=$("$HELPER" "$BARE_PROJ2" "$BARE_PROJ2")
|
|
140
|
+
assert "pure infra inside fully-untracked .claude/ => clean ($out)" grep -q 'verdict=clean' <<<"$out"
|
|
141
|
+
|
|
142
|
+
out=$("$HELPER" "$SANDBOX/nonexistent" "$PROJ")
|
|
143
|
+
assert "broken worktree => fail-DIRTY ($out)" grep -q 'commits=? uncommitted=? verdict=dirty' <<<"$out"
|
|
144
|
+
|
|
145
|
+
echo "== T3: cleanup hook keeps dirty worktree (modified .mcp.json) (AC2)"
|
|
146
|
+
"${RUN_ENV[@]}" bash "$CLEANUP_HOOK" sess task2 >"$SANDBOX/t3.out" 2>&1
|
|
147
|
+
assert "worktree with real .mcp.json edit NOT deleted" test -d "$WT_DIR/sess-task2"
|
|
148
|
+
assert "sticky note left for dirty worktree" grep -q "task2" "$FAKE_HOME/notes-log.txt"
|
|
149
|
+
|
|
150
|
+
echo "== T4: cleanup hook silently removes lockfile-only worktree (AC3)"
|
|
151
|
+
"${RUN_ENV[@]}" bash "$CLEANUP_HOOK" sess task3 >"$SANDBOX/t4.out" 2>&1
|
|
152
|
+
assert "lockfile-churn-only worktree removed as clean" test ! -d "$WT_DIR/sess-task3"
|
|
153
|
+
|
|
154
|
+
echo "== T5: interactive mux worktree cleanup agrees (AC2 + AC3, same verdict)"
|
|
155
|
+
mkwt task5 # lockfile-only -> clean, auto-removed without prompt
|
|
156
|
+
echo lock-rewritten > "$WT_DIR/sess-task5/package-lock.json"
|
|
157
|
+
out=$("${RUN_ENV[@]}" bash "$MUX" worktree cleanup sess task5 </dev/null 2>&1)
|
|
158
|
+
assert "bin/mux: lockfile-only cleaned up as clean" grep -q "cleaned up (no changes)" <<<"$out"
|
|
159
|
+
assert "bin/mux: lockfile-only worktree removed" test ! -d "$WT_DIR/sess-task5"
|
|
160
|
+
|
|
161
|
+
mkwt task6 # modified .mcp.json -> dirty, prompts instead of deleting
|
|
162
|
+
echo '{"changed":true}' > "$WT_DIR/sess-task6/.mcp.json"
|
|
163
|
+
out=$(printf 'x\n' | "${RUN_ENV[@]}" bash "$MUX" worktree cleanup sess task6 2>&1) || true
|
|
164
|
+
assert "bin/mux: modified .mcp.json reported dirty" grep -q "has work to merge" <<<"$out"
|
|
165
|
+
assert "bin/mux: dirty worktree not deleted" test -d "$WT_DIR/sess-task6"
|
|
166
|
+
|
|
167
|
+
echo "== T6: missing helper degrades to fail-DIRTY (never deletes)"
|
|
168
|
+
mkwt task7 # truly clean worktree, but helper missing
|
|
169
|
+
mv "$HELPER" "$HELPER.disabled"
|
|
170
|
+
"${RUN_ENV[@]}" bash "$CLEANUP_HOOK" sess task7 >"$SANDBOX/t6.out" 2>&1
|
|
171
|
+
assert "hook without helper does NOT delete (fail-DIRTY)" test -d "$WT_DIR/sess-task7"
|
|
172
|
+
out=$(printf 'x\n' | "${RUN_ENV[@]}" bash "$MUX" worktree cleanup sess task7 2>&1) || true
|
|
173
|
+
assert "bin/mux without helper treats as dirty" grep -q "has work to merge" <<<"$out"
|
|
174
|
+
mv "$HELPER.disabled" "$HELPER"
|
|
175
|
+
|
|
176
|
+
echo "== T7: truly clean worktree still removed (baseline)"
|
|
177
|
+
mkwt task8
|
|
178
|
+
"${RUN_ENV[@]}" bash "$CLEANUP_HOOK" sess task8 >"$SANDBOX/t7.out" 2>&1
|
|
179
|
+
assert "clean worktree silently removed by hook" test ! -d "$WT_DIR/sess-task8"
|
|
180
|
+
|
|
181
|
+
echo ""
|
|
182
|
+
echo "RESULT: $pass passed, $fail failed (sandbox: $SANDBOX)"
|
|
183
|
+
[[ $fail -eq 0 ]] && rm -rf "$SANDBOX"
|
|
184
|
+
exit $fail
|