create-claude-cabinet 0.48.0 → 0.49.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/CLAUDE.md +22 -0
- package/lib/cli.js +100 -5
- package/lib/metadata.js +20 -0
- package/package.json +1 -1
- package/templates/CLAUDE.md +212 -12
- package/templates/cabinet/watchtower-contracts.md +85 -0
- package/templates/cabinet/worktree-invocation-contract.md +35 -0
- package/templates/engagement/pib-db-patches/pib-db-lib.mjs +53 -9
- package/templates/mux/__tests__/mux-fail-loud.fixture.sh +4 -5
- package/templates/mux/__tests__/worktree-lifecycle.fixture.sh +214 -0
- package/templates/mux/__tests__/worktree-lifecycle.test.mjs +44 -0
- package/templates/mux/__tests__/worktree-status-clean.fixture.sh +245 -0
- package/templates/mux/__tests__/worktree-status-clean.test.mjs +89 -0
- package/templates/mux/bin/mux +44 -28
- package/templates/mux/config/worktree-cleanup.sh +57 -12
- package/templates/mux/config/worktree-dirty-check.sh +72 -2
- package/templates/mux/config/worktree-session-health.sh +401 -17
- package/templates/scripts/__tests__/assessment-recalibration.test.mjs +387 -0
- package/templates/scripts/__tests__/batch-disposition.test.mjs +19 -0
- package/templates/scripts/__tests__/branch-diverged-reconcile.test.mjs +374 -0
- package/templates/scripts/__tests__/completion-review-reconcile.test.mjs +173 -0
- package/templates/scripts/__tests__/cross-ring-reader.test.mjs +12 -4
- package/templates/scripts/__tests__/detector-registry.test.mjs +152 -0
- package/templates/scripts/__tests__/draft-surfacing.test.mjs +433 -0
- package/templates/scripts/__tests__/generated-state-classification.test.mjs +228 -0
- package/templates/scripts/__tests__/mirror-parity.test.mjs +54 -0
- package/templates/scripts/__tests__/resolve-project-slug.test.mjs +186 -0
- package/templates/scripts/__tests__/ring3-attribution.test.mjs +267 -0
- package/templates/scripts/__tests__/ring3-completion-filter.test.mjs +176 -0
- package/templates/scripts/__tests__/ring3-coverage-debt.test.mjs +377 -0
- package/templates/scripts/__tests__/ring3-last-session-pointer.test.mjs +80 -0
- package/templates/scripts/audit-coherence-check.mjs +6 -2
- package/templates/scripts/load-triage-history.js +23 -2
- package/templates/scripts/merge-findings.js +9 -2
- package/templates/scripts/pib-db-lib.mjs +55 -7
- package/templates/scripts/watchtower-cross-ring-reader.mjs +20 -3
- package/templates/scripts/watchtower-inbox-assessment.mjs +186 -35
- package/templates/scripts/watchtower-lib.mjs +268 -5
- package/templates/scripts/watchtower-queue.mjs +197 -2
- package/templates/scripts/watchtower-ring1.mjs +490 -69
- package/templates/scripts/watchtower-ring2.mjs +175 -3
- package/templates/scripts/watchtower-ring3-close.mjs +519 -69
- package/templates/skills/audit/SKILL.md +6 -3
- package/templates/skills/cabinet-anthropic-insider/SKILL.md +29 -8
- package/templates/skills/cabinet-process-therapist/SKILL.md +45 -3
- package/templates/skills/inbox/SKILL.md +29 -1
- package/templates/skills/session-handoff/SKILL.md +6 -8
- package/templates/watchtower/config.json.template +2 -1
|
@@ -44,7 +44,11 @@ function today() {
|
|
|
44
44
|
// 6 — added projects.tags (symmetric with actions.tags at v2)
|
|
45
45
|
// 7 — added client-facing copy columns on actions + projects (4 each)
|
|
46
46
|
// 8 — added engagement_events.visibility (client|internal, default internal)
|
|
47
|
-
|
|
47
|
+
// 9 — audit finding ids run-prefixed (run_id || '/' || id) — same change
|
|
48
|
+
// as BASE v7 (act:4ec70792); numbered 9 here because the patch owns
|
|
49
|
+
// 7-8. Keep base and patch bumped in the same release
|
|
50
|
+
// (engagement-setup's base-ahead guard enforces it).
|
|
51
|
+
export const SCHEMA_VERSION = 9;
|
|
48
52
|
|
|
49
53
|
// Each entry: { version, sql }. A single version may have multiple SQL
|
|
50
54
|
// statements (e.g. column add + index). Statements run in array order;
|
|
@@ -53,8 +57,8 @@ export const SCHEMA_VERSION = 8;
|
|
|
53
57
|
// gate — try/catch is a safety net for pre-pragma DBs.
|
|
54
58
|
//
|
|
55
59
|
// NOTE on version numbering (base vs patch):
|
|
56
|
-
// Base (work-tracking): v1-
|
|
57
|
-
// Patch (engagement): v1-
|
|
60
|
+
// Base (work-tracking): v1-v7 (v6 = client columns, v7 = audit id prefix)
|
|
61
|
+
// Patch (engagement): v1-v9 (v5 = engagement_events, v6 = projects.tags, v7 = client columns, v9 = audit id prefix)
|
|
58
62
|
// The same physical columns appear at different version numbers because
|
|
59
63
|
// base and patch have different migration histories. The try/catch on
|
|
60
64
|
// "duplicate column" makes the overlap safe — a DB that already has
|
|
@@ -86,6 +90,10 @@ const MIGRATIONS = [
|
|
|
86
90
|
{ version: 7, sql: "ALTER TABLE projects ADD COLUMN client_generated_at TEXT" },
|
|
87
91
|
{ version: 7, sql: "ALTER TABLE projects ADD COLUMN client_generated_status TEXT" },
|
|
88
92
|
{ version: 8, sql: "ALTER TABLE engagement_events ADD COLUMN visibility TEXT NOT NULL DEFAULT 'internal' CHECK(visibility IN ('client','internal'))" },
|
|
93
|
+
// v9: prefix legacy audit finding ids with their run id (base v7 twin —
|
|
94
|
+
// act:4ec70792). Idempotent via the NOT LIKE guard. skipOn: a db that
|
|
95
|
+
// predates the audit tables has nothing to prefix.
|
|
96
|
+
{ version: 9, sql: "UPDATE audit_findings SET id = run_id || '/' || id WHERE run_id IS NOT NULL AND run_id != '' AND id NOT LIKE run_id || '/%'", skipOn: /no such table: audit_findings/i },
|
|
89
97
|
];
|
|
90
98
|
|
|
91
99
|
export function migrate(db) {
|
|
@@ -102,7 +110,11 @@ export function migrate(db) {
|
|
|
102
110
|
if (m.version <= current) continue;
|
|
103
111
|
try { db.exec(m.sql); applied++; }
|
|
104
112
|
catch (e) {
|
|
105
|
-
|
|
113
|
+
const msg = e.message || '';
|
|
114
|
+
// Per-entry skip condition (explicit, never a general widening):
|
|
115
|
+
// e.g. the v9 audit-id prefix on a db that predates audit tables.
|
|
116
|
+
if (m.skipOn && m.skipOn.test(msg)) continue;
|
|
117
|
+
if (!/already exists|duplicate column/i.test(msg)) throw e;
|
|
106
118
|
}
|
|
107
119
|
}
|
|
108
120
|
db.pragma(`user_version = ${SCHEMA_VERSION}`);
|
|
@@ -438,37 +450,69 @@ export function ingestFindings(db, { runDir }) {
|
|
|
438
450
|
const timestamp = data.meta?.timestamp || new Date().toISOString();
|
|
439
451
|
const dateStr = timestamp.slice(0, 10);
|
|
440
452
|
|
|
453
|
+
// Same-run re-ingest refreshes the run row; cross-run id collisions are
|
|
454
|
+
// structurally impossible now that run ids carry the date
|
|
455
|
+
// (run-<YYYY-MM-DD>-<HH-MM-SS>, minted by merge-findings).
|
|
441
456
|
db.prepare(`
|
|
442
|
-
INSERT
|
|
457
|
+
INSERT INTO audit_runs (id, date, timestamp, trigger, finding_count)
|
|
443
458
|
VALUES (?, ?, ?, ?, ?)
|
|
459
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
460
|
+
date = excluded.date, timestamp = excluded.timestamp,
|
|
461
|
+
trigger = excluded.trigger, finding_count = excluded.finding_count
|
|
444
462
|
`).run(runId, dateStr, timestamp, data.meta?.trigger || 'manual', data.findings?.length || 0);
|
|
445
463
|
|
|
446
|
-
//
|
|
464
|
+
// Stored finding ids are RUN-PREFIXED (<runId>/<member-NNNN>): member ids
|
|
465
|
+
// restart every run, and the old bare-id INSERT OR REPLACE silently
|
|
466
|
+
// destroyed prior runs' rows AND their triage columns on collision
|
|
467
|
+
// (act:4ec70792, demonstrated live 2026-07-13). The upsert updates
|
|
468
|
+
// CONTENT columns only — the four triage columns (triage_status,
|
|
469
|
+
// triage_notes, triaged_at, fix_description) are never in the SET list,
|
|
470
|
+
// so a same-run re-ingest can never reset an operator's dispositions.
|
|
471
|
+
// Try the full shape with deliberation columns first; fall back to the
|
|
447
472
|
// legacy shape for existing databases that haven't added them yet.
|
|
448
473
|
let insert;
|
|
449
474
|
let hasDeliberationCols = true;
|
|
450
475
|
try {
|
|
451
476
|
insert = db.prepare(`
|
|
452
|
-
INSERT
|
|
477
|
+
INSERT INTO audit_findings
|
|
453
478
|
(id, run_id, cabinet_member, severity, title, description, assumption,
|
|
454
479
|
evidence, question, file, line, suggested_fix, auto_fixable, type,
|
|
455
480
|
status, annotations, rebuttal)
|
|
456
481
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
482
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
483
|
+
run_id = excluded.run_id, cabinet_member = excluded.cabinet_member,
|
|
484
|
+
severity = excluded.severity, title = excluded.title,
|
|
485
|
+
description = excluded.description, assumption = excluded.assumption,
|
|
486
|
+
evidence = excluded.evidence, question = excluded.question,
|
|
487
|
+
file = excluded.file, line = excluded.line,
|
|
488
|
+
suggested_fix = excluded.suggested_fix,
|
|
489
|
+
auto_fixable = excluded.auto_fixable, type = excluded.type,
|
|
490
|
+
status = excluded.status, annotations = excluded.annotations,
|
|
491
|
+
rebuttal = excluded.rebuttal
|
|
457
492
|
`);
|
|
458
493
|
} catch {
|
|
459
494
|
hasDeliberationCols = false;
|
|
460
495
|
insert = db.prepare(`
|
|
461
|
-
INSERT
|
|
496
|
+
INSERT INTO audit_findings
|
|
462
497
|
(id, run_id, cabinet_member, severity, title, description, assumption,
|
|
463
498
|
evidence, question, file, line, suggested_fix, auto_fixable, type)
|
|
464
499
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
500
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
501
|
+
run_id = excluded.run_id, cabinet_member = excluded.cabinet_member,
|
|
502
|
+
severity = excluded.severity, title = excluded.title,
|
|
503
|
+
description = excluded.description, assumption = excluded.assumption,
|
|
504
|
+
evidence = excluded.evidence, question = excluded.question,
|
|
505
|
+
file = excluded.file, line = excluded.line,
|
|
506
|
+
suggested_fix = excluded.suggested_fix,
|
|
507
|
+
auto_fixable = excluded.auto_fixable, type = excluded.type
|
|
465
508
|
`);
|
|
466
509
|
}
|
|
467
510
|
|
|
468
511
|
let count = 0;
|
|
469
512
|
for (const f of (data.findings || [])) {
|
|
513
|
+
const storedId = String(f.id).startsWith(`${runId}/`) ? f.id : `${runId}/${f.id}`;
|
|
470
514
|
const base = [
|
|
471
|
-
|
|
515
|
+
storedId, runId, f['cabinet-member'], f.severity, f.title,
|
|
472
516
|
f.description || null, f.assumption || null, f.evidence || null,
|
|
473
517
|
f.question || null, f.file || null, f.line || null,
|
|
474
518
|
f.suggestedFix || null, f.autoFixable ? 1 : 0, f.type || 'finding'
|
|
@@ -266,11 +266,10 @@ assert "a worktree window opened" test "$(win_count)" -eq $((before + 1))
|
|
|
266
266
|
seedf=$(ls -t "$SEED_DIR"/sess-*.txt 2>/dev/null | head -1)
|
|
267
267
|
assert "a seed-prompt file was written" test -n "$seedf"
|
|
268
268
|
if [[ -n "$seedf" ]]; then
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
diff "$SANDBOX/expected-body.txt" "$SANDBOX/got-body.txt"
|
|
269
|
+
# No orient prefix: /orient is retired — the watchtower SessionStart hook
|
|
270
|
+
# loads state; the seed file carries the prompt VERBATIM.
|
|
271
|
+
assert "seed file carries the prompt verbatim, no injected preamble (quotes/\$/backticks intact)" \
|
|
272
|
+
diff "$SANDBOX/expected-body.txt" "$seedf"
|
|
274
273
|
fi
|
|
275
274
|
|
|
276
275
|
echo "== T12: the prompt is an ARG to claude, never executed as shell (auto-submit safety)"
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# Sandboxed AC verification for act:dc80380c — worktree lane lifecycle
|
|
3
|
+
# hygiene: spawn-time env provisioning, preflight, and container reaping
|
|
4
|
+
# (grp:wt-noise-immunity, Lane E).
|
|
5
|
+
#
|
|
6
|
+
# Drives templates/mux/config/worktree-session-health.sh directly against a
|
|
7
|
+
# throwaway repo + worktree under mktemp with HOME overridden — never touches
|
|
8
|
+
# the real ~/.mux/worktrees, ~/.config/mux, or any live worktree. Docker is
|
|
9
|
+
# PATH-shimmed with a recording fake: npm test must never depend on a live
|
|
10
|
+
# docker daemon (or reap anything real).
|
|
11
|
+
set -uo pipefail
|
|
12
|
+
|
|
13
|
+
REPO_ROOT="$(cd "$(dirname "$0")/../../.." && pwd)"
|
|
14
|
+
SANDBOX=$(mktemp -d /tmp/cc-lifecycle-test.XXXXXX)
|
|
15
|
+
FAKE_HOME="$SANDBOX/home"
|
|
16
|
+
PROJ="$SANDBOX/proj"
|
|
17
|
+
WT="$SANDBOX/wt"
|
|
18
|
+
HEALTH="$REPO_ROOT/templates/mux/config/worktree-session-health.sh"
|
|
19
|
+
MUX_BIN="$REPO_ROOT/templates/mux/bin/mux"
|
|
20
|
+
CLEANUP_HOOK="$REPO_ROOT/templates/mux/config/worktree-cleanup.sh"
|
|
21
|
+
|
|
22
|
+
pass=0; fail=0
|
|
23
|
+
ok() { echo " PASS: $1"; pass=$((pass+1)); }
|
|
24
|
+
bad() { echo " FAIL: $1"; fail=$((fail+1)); }
|
|
25
|
+
assert() { # assert <desc> <cmd...>
|
|
26
|
+
local desc="$1"; shift
|
|
27
|
+
if "$@" >/dev/null 2>&1; then ok "$desc"; else bad "$desc"; fi
|
|
28
|
+
}
|
|
29
|
+
run_health() { HOME="$FAKE_HOME" bash "$HEALTH" "$PROJ" "$WT" "$@" 2>&1; }
|
|
30
|
+
|
|
31
|
+
mkdir -p "$FAKE_HOME/.local/share/mux/wt-health" "$FAKE_HOME/.claude/projects"
|
|
32
|
+
trap 'rm -rf "$SANDBOX"' EXIT
|
|
33
|
+
|
|
34
|
+
# --- fixture main repo --------------------------------------------------------
|
|
35
|
+
git init -q "$PROJ"
|
|
36
|
+
git -C "$PROJ" config user.email t@t.t; git -C "$PROJ" config user.name t
|
|
37
|
+
mkdir -p "$PROJ/platform" "$PROJ/e2e" "$PROJ/.claude/plans"
|
|
38
|
+
echo plan > "$PROJ/.claude/plans/p.md"
|
|
39
|
+
printf '%s\n' 'platform/.env' 'e2e/.env.local' > "$PROJ/.gitignore"
|
|
40
|
+
printf '%s\n' 'DB_URL=postgres://main' 'KEY=s3cret' > "$PROJ/platform/.env"
|
|
41
|
+
echo 'E2E=1' > "$PROJ/e2e/.env.local"
|
|
42
|
+
cat > "$PROJ/.mux-worktree-provision" <<'EOF'
|
|
43
|
+
# lane env provisioning (comments + blanks ignored)
|
|
44
|
+
|
|
45
|
+
platform/.env
|
|
46
|
+
e2e/.env.local
|
|
47
|
+
check: test -f platform/.env
|
|
48
|
+
EOF
|
|
49
|
+
echo code > "$PROJ/file.txt"
|
|
50
|
+
git -C "$PROJ" add -A && git -C "$PROJ" commit -qm init
|
|
51
|
+
MAIN_SLUG=$(echo "$PROJ" | sed 's|[/.]|-|g')
|
|
52
|
+
mkdir -p "$FAKE_HOME/.claude/projects/$MAIN_SLUG"
|
|
53
|
+
|
|
54
|
+
git -C "$PROJ" worktree add -q "$WT" -b mux/lane HEAD
|
|
55
|
+
|
|
56
|
+
echo "== T1: spawn-time provisioning — declared env files land without hand-copying (AC1)"
|
|
57
|
+
run_health --refresh >"$SANDBOX/t1.out"
|
|
58
|
+
assert "platform/.env provisioned into worktree" \
|
|
59
|
+
grep -qx 'KEY=s3cret' "$WT/platform/.env"
|
|
60
|
+
assert "e2e/.env.local provisioned into worktree" \
|
|
61
|
+
grep -qx 'E2E=1' "$WT/e2e/.env.local"
|
|
62
|
+
assert "provisioning reported positively" \
|
|
63
|
+
grep -q 'provisioned platform/.env from main' "$SANDBOX/t1.out"
|
|
64
|
+
assert "health output never prints env-file contents" \
|
|
65
|
+
bash -c "! grep -q 's3cret' '$SANDBOX/t1.out'"
|
|
66
|
+
assert "health run exits 0" run_health
|
|
67
|
+
|
|
68
|
+
echo "== T2: freshness — stale-but-untouched copy refreshes; identical reports current"
|
|
69
|
+
run_health >"$SANDBOX/t2a.out"
|
|
70
|
+
assert "identical copy reports current" \
|
|
71
|
+
grep -q 'platform/.env current with main' "$SANDBOX/t2a.out"
|
|
72
|
+
printf '%s\n' 'DB_URL=postgres://main' 'KEY=rotated' > "$PROJ/platform/.env"
|
|
73
|
+
run_health >"$SANDBOX/t2b.out"
|
|
74
|
+
assert "untouched worktree copy refreshed after main moved" \
|
|
75
|
+
grep -qx 'KEY=rotated' "$WT/platform/.env"
|
|
76
|
+
assert "refresh reported as stale-but-untouched" \
|
|
77
|
+
grep -q 'refreshed from main' "$SANDBOX/t2b.out"
|
|
78
|
+
|
|
79
|
+
echo "== T3: divergence — a locally-modified worktree copy is NEVER clobbered"
|
|
80
|
+
printf '%s\n' 'DB_URL=postgres://lane' 'PORT=5433' > "$WT/platform/.env"
|
|
81
|
+
printf '%s\n' 'DB_URL=postgres://main' 'KEY=rotated-again' > "$PROJ/platform/.env"
|
|
82
|
+
run_health >"$SANDBOX/t3.out"
|
|
83
|
+
assert "lane-local edit preserved (main's newer copy did not overwrite)" \
|
|
84
|
+
grep -qx 'PORT=5433' "$WT/platform/.env"
|
|
85
|
+
assert "divergence reported, not silently skipped" \
|
|
86
|
+
grep -q 'left alone' "$SANDBOX/t3.out"
|
|
87
|
+
|
|
88
|
+
echo "== T4: refusal — a declared file that is NOT gitignored fails loudly, no copy"
|
|
89
|
+
echo 'tracked/.env' >> "$PROJ/.mux-worktree-provision"
|
|
90
|
+
mkdir -p "$PROJ/tracked" && echo 'LEAK=1' > "$PROJ/tracked/.env"
|
|
91
|
+
run_health >"$SANDBOX/t4.out"
|
|
92
|
+
rc=$?
|
|
93
|
+
assert "health exits 1 on a non-gitignored declared file" test "$rc" -ne 0
|
|
94
|
+
assert "the non-ignored file was NOT copied" test ! -f "$WT/tracked/.env"
|
|
95
|
+
assert "refusal names the file and the reason" \
|
|
96
|
+
grep -q 'tracked/.env is NOT gitignored' "$SANDBOX/t4.out"
|
|
97
|
+
# restore declaration for later tests
|
|
98
|
+
grep -v 'tracked/.env' "$PROJ/.mux-worktree-provision" > "$PROJ/.p.tmp" && mv "$PROJ/.p.tmp" "$PROJ/.mux-worktree-provision"
|
|
99
|
+
|
|
100
|
+
echo "== T5: missing source in main fails loudly (never a silent skip)"
|
|
101
|
+
echo 'platform/.env.missing' >> "$PROJ/.mux-worktree-provision"
|
|
102
|
+
run_health >"$SANDBOX/t5.out"
|
|
103
|
+
rc=$?
|
|
104
|
+
assert "health exits 1 on a declared-but-missing source" test "$rc" -ne 0
|
|
105
|
+
assert "missing source named in output" \
|
|
106
|
+
grep -q 'source missing in main: platform/.env.missing' "$SANDBOX/t5.out"
|
|
107
|
+
grep -v '.env.missing' "$PROJ/.mux-worktree-provision" > "$PROJ/.p.tmp" && mv "$PROJ/.p.tmp" "$PROJ/.mux-worktree-provision"
|
|
108
|
+
|
|
109
|
+
echo "== T6: no declaration → provisioning is a silent no-op"
|
|
110
|
+
PROJ2="$SANDBOX/proj2"; WT2="$SANDBOX/wt2"
|
|
111
|
+
git init -q "$PROJ2"
|
|
112
|
+
git -C "$PROJ2" config user.email t@t.t; git -C "$PROJ2" config user.name t
|
|
113
|
+
mkdir -p "$PROJ2/.claude/plans"; echo plan > "$PROJ2/.claude/plans/p.md"
|
|
114
|
+
echo x > "$PROJ2/f.txt"; git -C "$PROJ2" add -A; git -C "$PROJ2" commit -qm init
|
|
115
|
+
MAIN2_SLUG=$(echo "$PROJ2" | sed 's|[/.]|-|g'); mkdir -p "$FAKE_HOME/.claude/projects/$MAIN2_SLUG"
|
|
116
|
+
git -C "$PROJ2" worktree add -q "$WT2" -b mux/lane2 HEAD
|
|
117
|
+
HOME="$FAKE_HOME" bash "$HEALTH" "$PROJ2" "$WT2" >"$SANDBOX/t6.out" 2>&1
|
|
118
|
+
assert "no-declaration health run exits 0" test $? -eq 0
|
|
119
|
+
assert "no provision lines emitted without a declaration" \
|
|
120
|
+
bash -c "! grep -q 'provision' '$SANDBOX/t6.out'"
|
|
121
|
+
|
|
122
|
+
echo "== T7: preflight — green when sources + checks pass, red on failure, gates via exit code"
|
|
123
|
+
HOME="$FAKE_HOME" bash "$HEALTH" "$PROJ" --preflight >"$SANDBOX/t7a.out" 2>&1
|
|
124
|
+
assert "preflight passes on sound main environment" test $? -eq 0
|
|
125
|
+
assert "preflight reports each file positively" \
|
|
126
|
+
grep -q 'present in main: platform/.env' "$SANDBOX/t7a.out"
|
|
127
|
+
assert "preflight runs declared check: lines" \
|
|
128
|
+
grep -q 'check passed: test -f platform/.env' "$SANDBOX/t7a.out"
|
|
129
|
+
echo 'check: false' >> "$PROJ/.mux-worktree-provision"
|
|
130
|
+
HOME="$FAKE_HOME" bash "$HEALTH" "$PROJ" --preflight >"$SANDBOX/t7b.out" 2>&1
|
|
131
|
+
assert "preflight exits 1 on a failing check" test $? -ne 0
|
|
132
|
+
assert "failing check named" grep -q 'check FAILED' "$SANDBOX/t7b.out"
|
|
133
|
+
grep -v '^check: false$' "$PROJ/.mux-worktree-provision" > "$PROJ/.p.tmp" && mv "$PROJ/.p.tmp" "$PROJ/.mux-worktree-provision"
|
|
134
|
+
mv "$PROJ/platform/.env" "$PROJ/platform/.env.bak"
|
|
135
|
+
HOME="$FAKE_HOME" bash "$HEALTH" "$PROJ" --preflight >"$SANDBOX/t7c.out" 2>&1
|
|
136
|
+
assert "preflight exits 1 on a missing declared file" test $? -ne 0
|
|
137
|
+
mv "$PROJ/platform/.env.bak" "$PROJ/platform/.env"
|
|
138
|
+
HOME="$FAKE_HOME" bash "$HEALTH" "$PROJ2" --preflight >"$SANDBOX/t7d.out" 2>&1
|
|
139
|
+
assert "preflight trivially passes with no declaration" test $? -eq 0
|
|
140
|
+
assert "no-declaration preflight says so positively" \
|
|
141
|
+
grep -q 'nothing to verify' "$SANDBOX/t7d.out"
|
|
142
|
+
|
|
143
|
+
# --- docker shim for reap tests -----------------------------------------------
|
|
144
|
+
# Records every invocation; serves canned container ids for EXACTLY the
|
|
145
|
+
# expected compose-project label and nothing else (sibling-lane survival).
|
|
146
|
+
SHIM_DIR="$SANDBOX/bin"; mkdir -p "$SHIM_DIR"
|
|
147
|
+
DOCKER_LOG="$SANDBOX/docker.log"
|
|
148
|
+
WT_NAME=$(basename "$WT" | tr '[:upper:]' '[:lower:]')
|
|
149
|
+
cat > "$SHIM_DIR/docker" <<EOF
|
|
150
|
+
#!/bin/bash
|
|
151
|
+
echo "\$@" >> "$DOCKER_LOG"
|
|
152
|
+
case "\$1" in
|
|
153
|
+
info) exit \${FAKE_DOCKER_INFO_RC:-0} ;;
|
|
154
|
+
ps)
|
|
155
|
+
for a in "\$@"; do
|
|
156
|
+
[[ "\$a" == "label=com.docker.compose.project=${WT_NAME}" ]] && { \
|
|
157
|
+
if [[ -f "$SANDBOX/reaped" ]]; then exit 0; fi; \
|
|
158
|
+
echo c0ffee1; echo c0ffee2; exit 0; }
|
|
159
|
+
done
|
|
160
|
+
exit 0 ;;
|
|
161
|
+
rm) touch "$SANDBOX/reaped"; exit 0 ;;
|
|
162
|
+
esac
|
|
163
|
+
exit 0
|
|
164
|
+
EOF
|
|
165
|
+
chmod +x "$SHIM_DIR/docker"
|
|
166
|
+
run_reap() { rm -f "$SANDBOX/reaped"; : > "$DOCKER_LOG"; HOME="$FAKE_HOME" PATH="$SHIM_DIR:$PATH" bash "$HEALTH" "$PROJ" "$WT" --reap 2>&1; }
|
|
167
|
+
|
|
168
|
+
echo "== T8: reap — exact compose-label match, containers removed, loud positive report (AC2)"
|
|
169
|
+
run_reap >"$SANDBOX/t8.out"
|
|
170
|
+
assert "reap exits 0" test $? -eq 0
|
|
171
|
+
assert "docker queried with the EXACT compose-project label (no glob)" \
|
|
172
|
+
grep -q -- "label=com.docker.compose.project=${WT_NAME}" "$DOCKER_LOG"
|
|
173
|
+
assert "no substring/name filter used (sibling lanes safe)" \
|
|
174
|
+
bash -c "! grep -q -- '--filter name=' '$DOCKER_LOG'"
|
|
175
|
+
assert "matched containers removed via rm -f" \
|
|
176
|
+
bash -c "grep -q '^rm -f c0ffee1 c0ffee2' '$DOCKER_LOG' || grep -q '^rm -f' '$DOCKER_LOG'"
|
|
177
|
+
assert "reap reported positively (containers only, volumes kept)" \
|
|
178
|
+
grep -q 'volumes kept' "$SANDBOX/t8.out"
|
|
179
|
+
assert "no volume-destroying verbs ever issued" \
|
|
180
|
+
bash -c "! grep -qE 'down|volume' '$DOCKER_LOG'"
|
|
181
|
+
|
|
182
|
+
echo "== T9: reap failure modes — docker absent / daemon down never block, never lie"
|
|
183
|
+
# "absent" = a PATH of core system dirs only (docker lives in /usr/local/bin
|
|
184
|
+
# or homebrew on macOS). If this machine has docker in a core dir, skip the
|
|
185
|
+
# absent case rather than touch a real daemon.
|
|
186
|
+
if PATH="/usr/bin:/bin" command -v docker >/dev/null 2>&1; then
|
|
187
|
+
ok "docker absent: SKIPPED (docker present in core PATH on this machine)"
|
|
188
|
+
else
|
|
189
|
+
HOME="$FAKE_HOME" PATH="/usr/bin:/bin" bash "$HEALTH" "$PROJ" "$WT" --reap >"$SANDBOX/t9a.out" 2>&1
|
|
190
|
+
assert "docker absent: reap exits 0 silently" test $? -eq 0
|
|
191
|
+
fi
|
|
192
|
+
rm -f "$SANDBOX/reaped"; : > "$DOCKER_LOG"
|
|
193
|
+
HOME="$FAKE_HOME" PATH="$SHIM_DIR:$PATH" FAKE_DOCKER_INFO_RC=1 bash "$HEALTH" "$PROJ" "$WT" --reap >"$SANDBOX/t9b.out" 2>&1
|
|
194
|
+
assert "daemon down: reap exits 0 (removal never blocked)" test $? -eq 0
|
|
195
|
+
assert "daemon down: reported loudly as unverified, not silent" \
|
|
196
|
+
grep -q 'daemon unreachable' "$SANDBOX/t9b.out"
|
|
197
|
+
assert "daemon down: no rm attempted" \
|
|
198
|
+
bash -c "! grep -q '^rm' '$DOCKER_LOG'"
|
|
199
|
+
|
|
200
|
+
echo "== T10: both removal paths delegate to the ONE reap implementation (drift guard)"
|
|
201
|
+
assert "bin/mux delegates removal-time reaping to health --reap" \
|
|
202
|
+
grep -q -- 'worktree-session-health.sh" "$proj_path" "$wt_path" --reap' "$MUX_BIN"
|
|
203
|
+
assert "bin/mux reaps on the clean-cleanup path" \
|
|
204
|
+
bash -c "grep -B3 'Worktree cleaned up (no changes)' '$MUX_BIN' | grep -q worktree_reap_containers || grep -A8 'verdict\" == \"clean\"' '$MUX_BIN' | grep -q worktree_reap_containers"
|
|
205
|
+
assert "pane-exited hook delegates to the same --reap mode" \
|
|
206
|
+
grep -q -- '--reap' "$CLEANUP_HOOK"
|
|
207
|
+
assert "contract doc references the preflight command" \
|
|
208
|
+
grep -q 'mux worktree preflight' "$REPO_ROOT/templates/cabinet/worktree-invocation-contract.md"
|
|
209
|
+
assert "contract doc references the provisioning declaration" \
|
|
210
|
+
grep -q '.mux-worktree-provision' "$REPO_ROOT/templates/cabinet/worktree-invocation-contract.md"
|
|
211
|
+
|
|
212
|
+
echo ""
|
|
213
|
+
echo "RESULT: ${pass} passed, ${fail} failed"
|
|
214
|
+
[[ "$fail" -eq 0 ]]
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// Guard test for the worktree lane lifecycle (act:dc80380c, Lane E of
|
|
2
|
+
// grp:wt-noise-immunity): spawn-time env provisioning from a per-project
|
|
3
|
+
// .mux-worktree-provision declaration, the pre-spawn preflight, and
|
|
4
|
+
// removal-time container reaping.
|
|
5
|
+
//
|
|
6
|
+
// Drives worktree-lifecycle.fixture.sh, which builds a throwaway repo +
|
|
7
|
+
// worktree under mktemp with HOME overridden and a PATH-shimmed recording
|
|
8
|
+
// fake docker — it never touches the real ~/.mux/worktrees, ~/.config/mux,
|
|
9
|
+
// any live worktree, or a real docker daemon.
|
|
10
|
+
//
|
|
11
|
+
// The rules under test:
|
|
12
|
+
// - declared gitignored env files land in a fresh worktree without
|
|
13
|
+
// hand-copying; freshness refreshes only a locally-UNTOUCHED copy
|
|
14
|
+
// (a lane's deliberate divergence is never clobbered)
|
|
15
|
+
// - a declared file that is not gitignored is refused loudly (secrets
|
|
16
|
+
// must never land as committable untracked files)
|
|
17
|
+
// - preflight verifies main's sources + declared checks, exit-code gated
|
|
18
|
+
// - reaping matches the EXACT compose-project label (sibling lanes
|
|
19
|
+
// survive), removes containers only (never volumes), fails open for
|
|
20
|
+
// the removal but never silently (docker absent/daemon down)
|
|
21
|
+
// - both removal paths (bin/mux + the pane-exited hook) delegate to the
|
|
22
|
+
// one implementation in worktree-session-health.sh --reap
|
|
23
|
+
|
|
24
|
+
import { test } from 'node:test';
|
|
25
|
+
import assert from 'node:assert';
|
|
26
|
+
import { spawnSync } from 'node:child_process';
|
|
27
|
+
import { fileURLToPath } from 'node:url';
|
|
28
|
+
import path from 'node:path';
|
|
29
|
+
|
|
30
|
+
const fixture = path.join(
|
|
31
|
+
path.dirname(fileURLToPath(import.meta.url)),
|
|
32
|
+
'worktree-lifecycle.fixture.sh'
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
test('worktree lane lifecycle: provision / preflight / reap (sandboxed fixture suite)', () => {
|
|
36
|
+
const res = spawnSync('bash', [fixture], { encoding: 'utf8', timeout: 120_000 });
|
|
37
|
+
const output = `${res.stdout}\n${res.stderr}`;
|
|
38
|
+
assert.strictEqual(
|
|
39
|
+
res.status,
|
|
40
|
+
0,
|
|
41
|
+
`fixture suite reported failures:\n${output}`
|
|
42
|
+
);
|
|
43
|
+
assert.match(output, /RESULT: \d+ passed, 0 failed/);
|
|
44
|
+
});
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# Sandboxed AC verification for act:c008862c (mux half) — the worktree
|
|
3
|
+
# "MERGE OR LOSE" false-positive class: mux-manufactured churn (identity
|
|
4
|
+
# symlink typechange, node_modules/pib.db symlinks, machine-written
|
|
5
|
+
# generated state) must never render as dirt or count as unmerged work,
|
|
6
|
+
# while authored files, real edits, and real commits ALWAYS surface.
|
|
7
|
+
#
|
|
8
|
+
# Mechanism note (verified live 2026-07-13): git reads info/exclude from the
|
|
9
|
+
# COMMON git dir only — the per-worktree worktrees/<name>/info/exclude was a
|
|
10
|
+
# file git never consulted, and exclude rules can never hide a TRACKED
|
|
11
|
+
# file's typechange. The implementation therefore (a) points a per-worktree
|
|
12
|
+
# core.excludesFile (extensions.worktreeConfig) at the managed exclude so it
|
|
13
|
+
# becomes live, and (b) holds skip-worktree on tracked identity files WHILE
|
|
14
|
+
# they are mux symlinks into main, clearing the bit the moment a real file
|
|
15
|
+
# returns. The dirty-check carries three independent guards so a deletion
|
|
16
|
+
# can never race the health heal: symlink-gated untracked exclusions (5),
|
|
17
|
+
# skip-worktree'd non-symlinks count dirty (5b), and hidden real files at
|
|
18
|
+
# identity paths count dirty (5c).
|
|
19
|
+
#
|
|
20
|
+
# Fully sandboxed: throwaway repos under mktemp, HOME + GIT_CONFIG_GLOBAL +
|
|
21
|
+
# XDG_CONFIG_HOME overridden, TMUX unset (never touches the operator's
|
|
22
|
+
# live window indicators). Dirty-check verdicts are asserted BEFORE the
|
|
23
|
+
# first health run wherever possible — the live exclude otherwise shadows
|
|
24
|
+
# the dirty-check's own exclusions and a revert would pass green
|
|
25
|
+
# (mutation-testing lesson from this lane's CP).
|
|
26
|
+
set -uo pipefail
|
|
27
|
+
unset TMUX
|
|
28
|
+
|
|
29
|
+
REPO_ROOT="$(cd "$(dirname "$0")/../../.." && pwd)"
|
|
30
|
+
SANDBOX=$(mktemp -d /tmp/cc-statusclean-test.XXXXXX)
|
|
31
|
+
FAKE_HOME="$SANDBOX/home"
|
|
32
|
+
PROJ="$SANDBOX/proj"
|
|
33
|
+
WT="$SANDBOX/wt"
|
|
34
|
+
HEALTH="$REPO_ROOT/templates/mux/config/worktree-session-health.sh"
|
|
35
|
+
DIRTY="$REPO_ROOT/templates/mux/config/worktree-dirty-check.sh"
|
|
36
|
+
|
|
37
|
+
export GIT_CONFIG_GLOBAL="$SANDBOX/gitconfig-global"
|
|
38
|
+
export XDG_CONFIG_HOME="$FAKE_HOME/.config"
|
|
39
|
+
|
|
40
|
+
pass=0; fail=0
|
|
41
|
+
ok() { echo " PASS: $1"; pass=$((pass+1)); }
|
|
42
|
+
bad() { echo " FAIL: $1"; fail=$((fail+1)); }
|
|
43
|
+
assert() { # assert <desc> <cmd...>
|
|
44
|
+
local desc="$1"; shift
|
|
45
|
+
if "$@" >/dev/null 2>&1; then ok "$desc"; else bad "$desc"; fi
|
|
46
|
+
}
|
|
47
|
+
run_health() { HOME="$FAKE_HOME" bash "$HEALTH" "$PROJ" "$WT" "$@" 2>&1; }
|
|
48
|
+
run_dirty() { HOME="$FAKE_HOME" bash "$DIRTY" "$WT" "$PROJ" 2>/dev/null; }
|
|
49
|
+
wt_status() { git -C "$WT" status --porcelain --untracked-files=all; }
|
|
50
|
+
|
|
51
|
+
mkdir -p "$FAKE_HOME/.local/share/mux/wt-health" "$FAKE_HOME/.claude/projects" "$XDG_CONFIG_HOME/git"
|
|
52
|
+
echo '.DS_Store' > "$XDG_CONFIG_HOME/git/ignore"
|
|
53
|
+
trap 'rm -rf "$SANDBOX"' EXIT
|
|
54
|
+
|
|
55
|
+
# --- fixture main repo: consumer-shaped ---------------------------------------
|
|
56
|
+
# Tracked: .mcp.json, .claudeignore, .claude/plans + .claude/cabinet docs
|
|
57
|
+
# (cabinet/ is the tracked-holding dir the generated state hides inside).
|
|
58
|
+
# NO project .gitignore coverage for .claude/ infra — the consumer case where
|
|
59
|
+
# the dead per-worktree exclude left everything visible.
|
|
60
|
+
git init -q "$PROJ"
|
|
61
|
+
git -C "$PROJ" config user.email t@t.t; git -C "$PROJ" config user.name t
|
|
62
|
+
mkdir -p "$PROJ/.claude/plans" "$PROJ/.claude/cabinet" "$PROJ/.claude/skills"
|
|
63
|
+
echo '{"mcpServers":{}}' > "$PROJ/.mcp.json"
|
|
64
|
+
echo 'node_modules' > "$PROJ/.claudeignore"
|
|
65
|
+
echo plan > "$PROJ/.claude/plans/p.md"
|
|
66
|
+
echo checkpoint-doc > "$PROJ/.claude/cabinet/checkpoint.md"
|
|
67
|
+
echo code > "$PROJ/file.txt"
|
|
68
|
+
printf '%s\n' 'node_modules/' > "$PROJ/.gitignore"
|
|
69
|
+
git -C "$PROJ" add -A && git -C "$PROJ" commit -qm init
|
|
70
|
+
echo skill > "$PROJ/.claude/skills/s.md" # untracked infra in main
|
|
71
|
+
mkdir -p "$PROJ/node_modules"
|
|
72
|
+
touch "$PROJ/pib.db"
|
|
73
|
+
MAIN_SLUG=$(echo "$PROJ" | sed 's|[/.]|-|g')
|
|
74
|
+
mkdir -p "$FAKE_HOME/.claude/projects/$MAIN_SLUG"
|
|
75
|
+
|
|
76
|
+
git -C "$PROJ" worktree add -q "$WT" -b mux/lane HEAD
|
|
77
|
+
|
|
78
|
+
# Simulate exactly what create_worktree manufactures (bin/mux :247-252).
|
|
79
|
+
for f in .mcp.json .claudeignore; do
|
|
80
|
+
rm -f "$WT/$f"; ln -s "$PROJ/$f" "$WT/$f"
|
|
81
|
+
done
|
|
82
|
+
ln -s "$PROJ/pib.db" "$WT/pib.db"
|
|
83
|
+
ln -s "$PROJ/node_modules" "$WT/node_modules"
|
|
84
|
+
|
|
85
|
+
echo "== T0: PRE-HEALTH — status shows the churn, but the dirty-check already classifies honestly"
|
|
86
|
+
st0=$(git -C "$WT" status --porcelain)
|
|
87
|
+
assert "typechange renders before any health run (precondition)" \
|
|
88
|
+
grep -q ' T .mcp.json' <<<"$st0"
|
|
89
|
+
echo '{"dismissed":[]}' > "$WT/.claude/cabinet/advisories-state.json"
|
|
90
|
+
echo newplan > "$WT/.claude/plans/new-doc.md"
|
|
91
|
+
d0=$(run_dirty)
|
|
92
|
+
assert "pre-health: authored doc counts, generated state does NOT (uncommitted=1)" \
|
|
93
|
+
grep -q 'uncommitted=1 verdict=dirty' <<<"$d0"
|
|
94
|
+
rm -f "$WT/.claude/plans/new-doc.md"
|
|
95
|
+
d0b=$(run_dirty)
|
|
96
|
+
assert "pre-health: typechange + symlinks + generated state alone read clean" \
|
|
97
|
+
grep -q 'commits=0 uncommitted=0 verdict=clean' <<<"$d0b"
|
|
98
|
+
rm -f "$WT/.claude/cabinet/advisories-state.json"
|
|
99
|
+
|
|
100
|
+
echo "== T1: fresh worktree shows clean git status after creation health run (AC4)"
|
|
101
|
+
run_health --refresh >"$SANDBOX/t1.out"; rc=$?
|
|
102
|
+
assert "creation-time health run exits 0" test "$rc" -eq 0
|
|
103
|
+
assert "git status is EMPTY (no typechange, no symlink churn, no infra)" \
|
|
104
|
+
test -z "$(wt_status)"
|
|
105
|
+
sw1=$(git -C "$WT" ls-files -v .mcp.json)
|
|
106
|
+
assert "skip-worktree bit held on .mcp.json" grep -q '^S ' <<<"$sw1"
|
|
107
|
+
excf=$(git -C "$WT" config --worktree --get core.excludesFile)
|
|
108
|
+
assert "per-worktree excludesFile is live (worktreeConfig)" \
|
|
109
|
+
test "$excf" = "$(git -C "$WT" rev-parse --path-format=absolute --git-dir)/info/exclude"
|
|
110
|
+
assert "health reports exclude liveness positively (read-back, not attempt)" \
|
|
111
|
+
grep -q 'per-worktree exclude rules live' "$SANDBOX/t1.out"
|
|
112
|
+
d1=$(run_dirty)
|
|
113
|
+
assert "dirty-check verdict: clean" grep -q 'verdict=clean' <<<"$d1"
|
|
114
|
+
|
|
115
|
+
echo "== T2: user-global ignore patterns survive the excludesFile displacement"
|
|
116
|
+
touch "$WT/.DS_Store"
|
|
117
|
+
assert ".DS_Store stays hidden in the worktree" test -z "$(wt_status)"
|
|
118
|
+
rm -f "$WT/.DS_Store"
|
|
119
|
+
|
|
120
|
+
echo "== T3: git add -A stages NOTHING on a churn-only worktree (the ELOOP class)"
|
|
121
|
+
git -C "$WT" add -A 2>/dev/null
|
|
122
|
+
assert "index unchanged after add -A" \
|
|
123
|
+
test -z "$(git -C "$WT" diff --cached --name-only)"
|
|
124
|
+
|
|
125
|
+
echo "== T4: authored uncommitted file still surfaces and still counts (AC2, mux side)"
|
|
126
|
+
echo newplan > "$WT/.claude/plans/new-doc.md"
|
|
127
|
+
st4=$(wt_status)
|
|
128
|
+
assert "authored plans doc visible in status" \
|
|
129
|
+
grep -q '.claude/plans/new-doc.md' <<<"$st4"
|
|
130
|
+
d4=$(run_dirty)
|
|
131
|
+
assert "dirty-check counts the authored doc (uncommitted=1, dirty)" \
|
|
132
|
+
grep -q 'uncommitted=1 verdict=dirty' <<<"$d4"
|
|
133
|
+
rm -f "$WT/.claude/plans/new-doc.md"
|
|
134
|
+
|
|
135
|
+
echo "== T5: generated state inside a tracked-holding dir is churn, not work (AC1, mux side)"
|
|
136
|
+
echo '{"dismissed":[]}' > "$WT/.claude/cabinet/advisories-state.json"
|
|
137
|
+
echo '{"fires":{}}' > "$WT/.claude/cabinet/checklist-stats.json"
|
|
138
|
+
run_health >/dev/null 2>&1
|
|
139
|
+
assert "generated state hidden from status (re-exclude beats the cabinet/ negation)" \
|
|
140
|
+
test -z "$(wt_status)"
|
|
141
|
+
d5=$(run_dirty)
|
|
142
|
+
assert "dirty-check verdict clean with only generated state present" \
|
|
143
|
+
grep -q 'verdict=clean' <<<"$d5"
|
|
144
|
+
|
|
145
|
+
echo "== T6: commits ahead still produce the dirty verdict (AC3, mux side)"
|
|
146
|
+
echo work >> "$WT/file.txt"
|
|
147
|
+
git -C "$WT" commit -qam "real work"
|
|
148
|
+
d6=$(run_dirty)
|
|
149
|
+
assert "dirty-check reports commits=1, verdict=dirty" \
|
|
150
|
+
grep -q 'commits=1 uncommitted=0 verdict=dirty' <<<"$d6"
|
|
151
|
+
git -C "$PROJ" merge -q mux/lane --no-edit 2>/dev/null
|
|
152
|
+
d6b=$(run_dirty)
|
|
153
|
+
assert "after merge to main the verdict returns to clean" \
|
|
154
|
+
grep -q 'verdict=clean' <<<"$d6b"
|
|
155
|
+
|
|
156
|
+
echo "== T7: a REAL .mcp.json edit is never frozen — bit clears at health, edit surfaces"
|
|
157
|
+
rm -f "$WT/.mcp.json"
|
|
158
|
+
echo '{"mcpServers":{"new":{}}}' > "$WT/.mcp.json"
|
|
159
|
+
run_health >/dev/null 2>&1
|
|
160
|
+
sw7=$(git -C "$WT" ls-files -v .mcp.json)
|
|
161
|
+
assert "skip-worktree bit CLEARED once the path holds a real file" grep -q '^H ' <<<"$sw7"
|
|
162
|
+
st7=$(git -C "$WT" status --porcelain)
|
|
163
|
+
assert "the real edit renders in status" grep -q '.mcp.json' <<<"$st7"
|
|
164
|
+
d7=$(run_dirty)
|
|
165
|
+
assert "dirty-check counts the real .mcp.json edit as work" grep -q 'verdict=dirty' <<<"$d7"
|
|
166
|
+
rm -f "$WT/.mcp.json"; ln -s "$PROJ/.mcp.json" "$WT/.mcp.json"
|
|
167
|
+
run_health >/dev/null 2>&1
|
|
168
|
+
|
|
169
|
+
echo "== T7b: mid-session symlink→real swap, NO health run — deletion must not race the heal"
|
|
170
|
+
rm -f "$WT/.mcp.json"
|
|
171
|
+
echo '{"mcpServers":{"midsession":{}}}' > "$WT/.mcp.json" # bit still set, porcelain blind
|
|
172
|
+
st7b=$(git -C "$WT" status --porcelain)
|
|
173
|
+
assert "hazard precondition: porcelain is EMPTY while the real edit hides behind the bit" \
|
|
174
|
+
test -z "$st7b"
|
|
175
|
+
d7b=$(run_dirty)
|
|
176
|
+
assert "dirty-check STILL reads dirty (skip-worktree'd non-symlink counted, 5b)" \
|
|
177
|
+
grep -q 'verdict=dirty' <<<"$d7b"
|
|
178
|
+
rm -f "$WT/.mcp.json"; ln -s "$PROJ/.mcp.json" "$WT/.mcp.json"
|
|
179
|
+
run_health >/dev/null 2>&1
|
|
180
|
+
|
|
181
|
+
echo "== T7c: a foreign-target symlink never earns skip-worktree"
|
|
182
|
+
rm -f "$WT/.mcp.json"; ln -s "$SANDBOX/elsewhere.json" "$WT/.mcp.json"
|
|
183
|
+
run_health >/dev/null 2>&1
|
|
184
|
+
sw7c=$(git -C "$WT" ls-files -v .mcp.json)
|
|
185
|
+
assert "foreign symlink: bit NOT held (H, typechange stays visible)" grep -q '^H ' <<<"$sw7c"
|
|
186
|
+
st7c=$(git -C "$WT" status --porcelain)
|
|
187
|
+
assert "foreign symlink renders as a typechange" grep -q ' T .mcp.json' <<<"$st7c"
|
|
188
|
+
rm -f "$WT/.mcp.json"; ln -s "$PROJ/.mcp.json" "$WT/.mcp.json"
|
|
189
|
+
run_health >/dev/null 2>&1
|
|
190
|
+
|
|
191
|
+
echo "== T9: root-anchored patterns — a NESTED authored .mcp.json is never hidden"
|
|
192
|
+
mkdir -p "$WT/packages/svc"
|
|
193
|
+
echo '{"mcpServers":{"svc":{}}}' > "$WT/packages/svc/.mcp.json"
|
|
194
|
+
run_health >/dev/null 2>&1
|
|
195
|
+
st9=$(wt_status)
|
|
196
|
+
assert "nested .mcp.json visible in status after a health rebuild" \
|
|
197
|
+
grep -q 'packages/svc/.mcp.json' <<<"$st9"
|
|
198
|
+
d9=$(run_dirty)
|
|
199
|
+
assert "nested .mcp.json counts as work (dirty)" grep -q 'verdict=dirty' <<<"$d9"
|
|
200
|
+
rm -rf "$WT/packages"
|
|
201
|
+
|
|
202
|
+
echo "== T10: a REAL untracked pib.db (symlink replaced) is work, even before the exclude rebuilds"
|
|
203
|
+
rm -f "$WT/pib.db"
|
|
204
|
+
echo 'sqlite-data' > "$WT/pib.db" # stale /pib.db exclude line still live
|
|
205
|
+
d10=$(run_dirty)
|
|
206
|
+
assert "pre-rebuild: hidden real pib.db counts dirty (5c)" grep -q 'verdict=dirty' <<<"$d10"
|
|
207
|
+
run_health >/dev/null 2>&1 # rebuild drops the /pib.db line (no symlink)
|
|
208
|
+
st10=$(wt_status)
|
|
209
|
+
assert "post-rebuild: real pib.db visible in status" grep -q 'pib.db' <<<"$st10"
|
|
210
|
+
d10b=$(run_dirty)
|
|
211
|
+
assert "post-rebuild: real pib.db still dirty (symlink gate off)" grep -q 'verdict=dirty' <<<"$d10b"
|
|
212
|
+
rm -f "$WT/pib.db"; ln -s "$PROJ/pib.db" "$WT/pib.db"
|
|
213
|
+
run_health >/dev/null 2>&1
|
|
214
|
+
|
|
215
|
+
echo "== T8: idempotence — repeat health runs, still clean, no duplicate exclude lines"
|
|
216
|
+
run_health >/dev/null 2>&1; run_health >/dev/null 2>&1
|
|
217
|
+
assert "status still empty after repeat runs" test -z "$(wt_status)"
|
|
218
|
+
ex_file="$(git -C "$WT" rev-parse --path-format=absolute --git-dir)/info/exclude"
|
|
219
|
+
assert "managed exclude lines not duplicated" \
|
|
220
|
+
bash -c "[ \"\$(grep -cxF '/pib.db' '$ex_file')\" -eq 1 ] && [ \"\$(grep -cxF '.claude/cabinet/advisories-state.json' '$ex_file')\" -eq 1 ]"
|
|
221
|
+
assert "marker blocks not duplicated" \
|
|
222
|
+
bash -c "[ \"\$(grep -c '>>> mux-managed worktree rules' '$ex_file')\" -eq 1 ] && [ \"\$(grep -c '>>> mux-managed user ignore copy' '$ex_file')\" -eq 1 ]"
|
|
223
|
+
assert "user ignore copy precedes managed rules (authored negations win)" \
|
|
224
|
+
bash -c "[ \"\$(grep -n 'mux-managed user ignore copy' '$ex_file' | head -1 | cut -d: -f1)\" -lt \"\$(grep -n 'mux-managed worktree rules' '$ex_file' | head -1 | cut -d: -f1)\" ]"
|
|
225
|
+
|
|
226
|
+
echo "== T11: core.bare guard — a bare-repo layout is never half-migrated to worktreeConfig"
|
|
227
|
+
BARE="$SANDBOX/bare.git"; BPROJ="$SANDBOX/bareproj"; BWT="$SANDBOX/barewt"
|
|
228
|
+
git init -q --bare "$BARE"
|
|
229
|
+
git clone -q "$BARE" "$SANDBOX/seed" 2>/dev/null
|
|
230
|
+
( cd "$SANDBOX/seed" && git config user.email t@t.t && git config user.name t \
|
|
231
|
+
&& mkdir -p .claude/plans && echo p > .claude/plans/p.md && echo x > f.txt \
|
|
232
|
+
&& git add -A && git commit -qm init && git push -q origin HEAD:main )
|
|
233
|
+
git -C "$BARE" worktree add -q "$BWT" main 2>/dev/null
|
|
234
|
+
mkdir -p "$BPROJ/.claude" # fake proj dir so the health flow reaches the rebuild
|
|
235
|
+
HOME="$FAKE_HOME" bash "$HEALTH" "$BPROJ" "$BWT" >"$SANDBOX/t11.out" 2>&1 || true
|
|
236
|
+
wtc=$(git -C "$BWT" config --get extensions.worktreeConfig 2>/dev/null) || wtc=""
|
|
237
|
+
assert "extensions.worktreeConfig NOT enabled on a bare shared config" test -z "$wtc"
|
|
238
|
+
assert "bare guard reported visibly, not silently" \
|
|
239
|
+
grep -q 'core.bare=true' "$SANDBOX/t11.out"
|
|
240
|
+
assert "git status still works in the bare repo's worktree" \
|
|
241
|
+
git -C "$BWT" status --porcelain
|
|
242
|
+
|
|
243
|
+
echo ""
|
|
244
|
+
echo "RESULT: ${pass} passed, ${fail} failed"
|
|
245
|
+
[[ "$fail" -eq 0 ]]
|