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.
Files changed (48) hide show
  1. package/lib/CLAUDE.md +22 -0
  2. package/lib/cli.js +100 -5
  3. package/lib/metadata.js +20 -0
  4. package/package.json +1 -1
  5. package/templates/CLAUDE.md +212 -12
  6. package/templates/cabinet/watchtower-contracts.md +85 -0
  7. package/templates/cabinet/worktree-invocation-contract.md +35 -0
  8. package/templates/engagement/pib-db-patches/pib-db-lib.mjs +53 -9
  9. package/templates/mux/__tests__/mux-fail-loud.fixture.sh +4 -5
  10. package/templates/mux/__tests__/worktree-lifecycle.fixture.sh +214 -0
  11. package/templates/mux/__tests__/worktree-lifecycle.test.mjs +44 -0
  12. package/templates/mux/__tests__/worktree-status-clean.fixture.sh +245 -0
  13. package/templates/mux/__tests__/worktree-status-clean.test.mjs +89 -0
  14. package/templates/mux/bin/mux +44 -28
  15. package/templates/mux/config/worktree-cleanup.sh +57 -12
  16. package/templates/mux/config/worktree-dirty-check.sh +72 -2
  17. package/templates/mux/config/worktree-session-health.sh +401 -17
  18. package/templates/scripts/__tests__/assessment-recalibration.test.mjs +387 -0
  19. package/templates/scripts/__tests__/batch-disposition.test.mjs +19 -0
  20. package/templates/scripts/__tests__/branch-diverged-reconcile.test.mjs +374 -0
  21. package/templates/scripts/__tests__/completion-review-reconcile.test.mjs +173 -0
  22. package/templates/scripts/__tests__/cross-ring-reader.test.mjs +12 -4
  23. package/templates/scripts/__tests__/detector-registry.test.mjs +152 -0
  24. package/templates/scripts/__tests__/draft-surfacing.test.mjs +433 -0
  25. package/templates/scripts/__tests__/generated-state-classification.test.mjs +228 -0
  26. package/templates/scripts/__tests__/mirror-parity.test.mjs +54 -0
  27. package/templates/scripts/__tests__/resolve-project-slug.test.mjs +186 -0
  28. package/templates/scripts/__tests__/ring3-attribution.test.mjs +267 -0
  29. package/templates/scripts/__tests__/ring3-completion-filter.test.mjs +176 -0
  30. package/templates/scripts/__tests__/ring3-coverage-debt.test.mjs +377 -0
  31. package/templates/scripts/__tests__/ring3-last-session-pointer.test.mjs +80 -0
  32. package/templates/scripts/audit-coherence-check.mjs +6 -2
  33. package/templates/scripts/load-triage-history.js +23 -2
  34. package/templates/scripts/merge-findings.js +9 -2
  35. package/templates/scripts/pib-db-lib.mjs +55 -7
  36. package/templates/scripts/watchtower-cross-ring-reader.mjs +20 -3
  37. package/templates/scripts/watchtower-inbox-assessment.mjs +186 -35
  38. package/templates/scripts/watchtower-lib.mjs +268 -5
  39. package/templates/scripts/watchtower-queue.mjs +197 -2
  40. package/templates/scripts/watchtower-ring1.mjs +490 -69
  41. package/templates/scripts/watchtower-ring2.mjs +175 -3
  42. package/templates/scripts/watchtower-ring3-close.mjs +519 -69
  43. package/templates/skills/audit/SKILL.md +6 -3
  44. package/templates/skills/cabinet-anthropic-insider/SKILL.md +29 -8
  45. package/templates/skills/cabinet-process-therapist/SKILL.md +45 -3
  46. package/templates/skills/inbox/SKILL.md +29 -1
  47. package/templates/skills/session-handoff/SKILL.md +6 -8
  48. 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
- export const SCHEMA_VERSION = 8;
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-v6 (v6 = client columns)
57
- // Patch (engagement): v1-v7 (v5 = engagement_events, v6 = projects.tags, v7 = client columns)
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
- if (!/already exists|duplicate column/i.test(e.message || '')) throw e;
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 OR REPLACE INTO audit_runs (id, date, timestamp, trigger, finding_count)
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
- // Try the full INSERT with deliberation columns first; fall back to the
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 OR REPLACE INTO audit_findings
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 OR REPLACE INTO audit_findings
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
- f.id, runId, f['cabinet-member'], f.severity, f.title,
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
- assert "auto_orient=1 prepends the orient instruction as line 1" \
270
- bash -c "head -1 '$seedf' | grep -qx 'Run /orient-quick first to load state, then:'"
271
- tail -n +3 "$seedf" > "$SANDBOX/got-body.txt"
272
- assert "multi-line prompt body preserved byte-identical (quotes/\$/backticks intact)" \
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 ]]