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
@@ -1,20 +1,164 @@
1
1
  #!/bin/bash
2
- # Worktree health check — single source of truth.
2
+ # Worktree health check — single source of truth for the worktree lifecycle:
3
+ # provision (env files at spawn), verify (health checks + preflight), reap
4
+ # (lane containers at removal).
3
5
  #
4
- # Called three ways:
6
+ # Called five ways:
5
7
  # 1. CC SessionStart hook (no args) — uses $PWD, exits silently outside worktrees
6
8
  # 2. mux worktree health (args: proj_path wt_path) — checks a specific worktree
7
9
  # 3. mux worktree refresh (args: proj_path wt_path --refresh) — forces an
8
10
  # infra re-sync even when nothing is stale. Refresh delegates here so
9
11
  # the authored-record carve-out (sync_claude_infra) lives in exactly
10
12
  # one place — never a wholesale rm -rf of .claude/.
13
+ # 4. mux worktree preflight (args: proj_path --preflight) — pre-spawn source
14
+ # check: every declared provision file exists in MAIN and every declared
15
+ # `check:` command passes. Run BEFORE parallel lane spawns; exit 1 on any
16
+ # failure so orchestrators can gate the spawn.
17
+ # 5. worktree removal (args: proj_path wt_path --reap) — stops and removes
18
+ # lane-scoped docker containers (exact compose-project-label match on the
19
+ # worktree dir name; containers only, never volumes). Both removal paths
20
+ # (bin/mux and the pane-exited worktree-cleanup.sh hook) delegate here.
21
+ # Always exits 0 — a failed reap never blocks worktree removal, but it
22
+ # reports loudly so a leak is never silent.
23
+ #
24
+ # Per-project declaration: .mux-worktree-provision at the project root.
25
+ # - one gitignored file path per line (copied main → worktree at creation,
26
+ # freshness-checked on later runs; a locally-modified worktree copy is
27
+ # NEVER overwritten)
28
+ # - `check: <command>` lines are preflight checks (run from the project
29
+ # root, time-boxed). Probes should verify identity/freshness, not bare
30
+ # liveness — a leftover stack answers a bare port probe.
31
+ # - `#` comments and blank lines ignored. No file → every mode no-ops.
11
32
  #
12
33
  # Self-heals all fixable issues. Positive confirmation for every check.
34
+ # Reports name file paths, hashes, and mtimes only — never env-file CONTENTS
35
+ # (health output lands in session logs; provisioned files may hold secrets).
13
36
  # Exit codes: 0 = healthy (possibly after auto-fix), 1 = unfixable issue
14
37
 
15
- proj_path="${1:-}"
16
- wt_path="${2:-}"
17
- force_refresh="${3:-}"
38
+ mode="health"
39
+ force_refresh=""
40
+ positional=()
41
+ for arg in "$@"; do
42
+ case "$arg" in
43
+ --refresh) force_refresh="--refresh" ;;
44
+ --preflight) mode="preflight" ;;
45
+ --reap) mode="reap" ;;
46
+ *) positional+=("$arg") ;;
47
+ esac
48
+ done
49
+ proj_path="${positional[0]:-}"
50
+ wt_path="${positional[1]:-}"
51
+
52
+ PROVISION_FILE=".mux-worktree-provision"
53
+
54
+ # Portable time-box (macOS ships no `timeout`): run "$@" but kill it after
55
+ # $1 seconds. Returns the command's exit code, or non-zero if killed.
56
+ run_with_timeout() {
57
+ local secs="$1"; shift
58
+ "$@" &
59
+ local pid=$!
60
+ ( sleep "$secs"; kill -9 "$pid" 2>/dev/null ) &
61
+ local watchdog=$!
62
+ local rc=0
63
+ wait "$pid" 2>/dev/null || rc=$?
64
+ kill "$watchdog" 2>/dev/null
65
+ wait "$watchdog" 2>/dev/null
66
+ return "$rc"
67
+ }
68
+
69
+ # Emit the declared provision entries, one per line, prefixed "file<TAB>" or
70
+ # "check<TAB>". Single parser — provisioning, preflight, and tests all read
71
+ # the declaration through this one shape.
72
+ parse_provision_file() {
73
+ local list="$1"
74
+ [[ -f "$list" ]] || return 0
75
+ local line
76
+ while IFS= read -r line || [[ -n "$line" ]]; do
77
+ line="${line%%$'\r'}"
78
+ [[ -z "$line" || "$line" == \#* ]] && continue
79
+ if [[ "$line" == check:* ]]; then
80
+ printf 'check\t%s\n' "$(echo "${line#check:}" | sed 's/^ *//')"
81
+ else
82
+ printf 'file\t%s\n' "$line"
83
+ fi
84
+ done < "$list"
85
+ }
86
+
87
+ # --reap: remove this lane's docker containers before the worktree dir (the
88
+ # compose-project-name source) disappears. Exact label match only — a
89
+ # substring/name match could kill a sibling lane's live stack. Containers
90
+ # only; volumes are never touched (a lane's database state outlives the reap
91
+ # decision). Fail-open for the removal, loud on anything left behind.
92
+ reap_lane_containers() {
93
+ local wp="$1" name ids left
94
+ name=$(basename "$wp" | tr '[:upper:]' '[:lower:]')
95
+ if ! [[ "$name" =~ ^[a-z0-9][a-z0-9_-]*$ ]]; then
96
+ echo "⚠ reap: '$name' is not a valid compose project slug — skipped"
97
+ return 0
98
+ fi
99
+ command -v docker >/dev/null 2>&1 || return 0
100
+ if ! run_with_timeout 5 docker info >/dev/null 2>&1; then
101
+ echo "⚠ reap: docker daemon unreachable — lane containers for '${name}' not verified (may leak)"
102
+ return 0
103
+ fi
104
+ ids=$(run_with_timeout 10 docker ps -aq --filter "label=com.docker.compose.project=${name}" 2>/dev/null) || ids=""
105
+ [[ -n "$ids" ]] || return 0
106
+ # shellcheck disable=SC2086 — ids is a docker-generated id list
107
+ run_with_timeout 30 docker rm -f $ids >/dev/null 2>&1 || true
108
+ left=$(run_with_timeout 10 docker ps -aq --filter "label=com.docker.compose.project=${name}" 2>/dev/null) || left=""
109
+ if [[ -n "$left" ]]; then
110
+ echo "⚠ reap INCOMPLETE for compose project '${name}' — still running: ${left}"
111
+ else
112
+ echo "✓ reaped lane container(s) for compose project '${name}' (containers only, volumes kept)"
113
+ fi
114
+ return 0
115
+ }
116
+
117
+ # --preflight: verify the SOURCE side before N parallel lanes derive from it.
118
+ # Declared files must exist in main; declared checks must pass. This is not
119
+ # the per-worktree freshness check (that runs in health mode) — it answers
120
+ # "is main's environment sound enough to spawn lanes against?".
121
+ run_preflight() {
122
+ local sp="$1" fail=0 kind value
123
+ local list="$sp/$PROVISION_FILE"
124
+ if [[ ! -f "$list" ]]; then
125
+ echo "✓ preflight: no $PROVISION_FILE declared in $sp — nothing to verify"
126
+ exit 0
127
+ fi
128
+ while IFS=$'\t' read -r kind value; do
129
+ case "$kind" in
130
+ file)
131
+ if [[ -f "$sp/$value" ]]; then
132
+ echo "✓ preflight: present in main: $value"
133
+ else
134
+ echo "✗ preflight: declared provision file missing in main: $value"
135
+ fail=1
136
+ fi
137
+ ;;
138
+ check)
139
+ if ( cd "$sp" && run_with_timeout 30 bash -c "$value" >/dev/null 2>&1 ); then
140
+ echo "✓ preflight: check passed: $value"
141
+ else
142
+ echo "✗ preflight: check FAILED (or timed out): $value"
143
+ fail=1
144
+ fi
145
+ ;;
146
+ esac
147
+ done < <(parse_provision_file "$list")
148
+ exit "$fail"
149
+ }
150
+
151
+ case "$mode" in
152
+ preflight)
153
+ [[ -n "$proj_path" ]] || { echo "usage: worktree-session-health.sh <proj_path> --preflight" >&2; exit 2; }
154
+ run_preflight "$proj_path"
155
+ ;;
156
+ reap)
157
+ [[ -n "$wt_path" ]] || { echo "usage: worktree-session-health.sh <proj_path> <wt_path> --reap" >&2; exit 2; }
158
+ reap_lane_containers "$wt_path"
159
+ exit 0
160
+ ;;
161
+ esac
18
162
 
19
163
  if [[ -z "$wt_path" ]]; then
20
164
  # SessionStart mode — detect from environment
@@ -39,19 +183,179 @@ fails=()
39
183
  # created beside them stay visible and commit. The managed lines are rebuilt
40
184
  # on every run, so the negation set follows what's actually committed (and
41
185
  # the old static plans/methodology pair migrates automatically).
42
- exclude_claude_dir() {
43
- local wp="$1" gd
44
- gd=$(git -C "$wp" rev-parse --git-dir 2>/dev/null) || return 0
186
+ #
187
+ # LIVENESS (act:c008862c): git reads info/exclude from the COMMON git dir
188
+ # only a linked worktree's worktrees/<name>/info/exclude is a file git
189
+ # never consults, so these rules were silently dead in every mux worktree
190
+ # (hidden only where the project's own .gitignore overlapped). The fix:
191
+ # enable extensions.worktreeConfig and point this worktree's
192
+ # core.excludesFile at the managed file. That displaces the user's own
193
+ # excludesFile (any scope) for this worktree, so its patterns are copied
194
+ # into the managed file each rebuild — BEFORE the managed rules, so the
195
+ # authored-record negations always win over a user hide-pattern.
196
+ #
197
+ # The managed rules also hide, per worktree only (never main's status):
198
+ # - machine-written generated state inside otherwise-authored .claude/
199
+ # dirs (advisories-state.json, checklist-stats.json, verification/) plus
200
+ # the /verify progress journal (e2e/.verify-progress.jsonl) —
201
+ # re-excluded AFTER the negations, later lines win. Names must stay in
202
+ # step with the shell GENERATED_STATE_RE in worktree-dirty-check.sh and
203
+ # the lib classification in watchtower-lib.mjs (Lane A; guarded by the
204
+ # drift test in worktree-status-clean.test.mjs).
205
+ # - the mux identity symlinks when untracked — ROOT-ANCHORED (/.mcp.json:
206
+ # an unanchored pattern would hide a nested authored copy at any depth)
207
+ # and emitted ONLY while the path is actually a symlink into main, so a
208
+ # real user-created file at the same path is never hidden (a lazily
209
+ # created real pib.db is real work, not churn). The TRACKED case
210
+ # (typechange) is exclude-immune; hide_identity_symlink_typechange owns
211
+ # it. node_modules stays unanchored and unconditional — nested
212
+ # node_modules is never authored record.
213
+ # (formerly exclude_claude_dir — renamed when it outgrew .claude/)
214
+ rebuild_worktree_excludes() {
215
+ local wp="$1" sp="$2" gd
216
+ gd=$(git -C "$wp" rev-parse --path-format=absolute --git-dir 2>/dev/null) || return 0
45
217
  [[ -n "$gd" ]] || return 0
46
218
  mkdir -p "$gd/info"
47
219
  local ex="$gd/info/exclude"
48
220
  touch "$ex"
49
- { grep -vE '^(\.claude/\*?|!\.claude/.*)$' "$ex" 2>/dev/null || true; } > "$ex.tmp"
50
- echo '.claude/*' >> "$ex.tmp"
51
- git -C "$wp" ls-files '.claude/' 2>/dev/null \
52
- | awk -F/ 'NF>2 {print "!.claude/" $2 "/"} NF==2 {print "!" $0}' \
53
- | sort -u >> "$ex.tmp"
221
+ # Strip marker-fenced managed blocks + legacy unmarked managed lines.
222
+ # The fenced blocks are always the file's TAIL, so an orphaned open
223
+ # marker (hand-edit) can only eat managed content, never user lines.
224
+ awk '
225
+ /^# >>> mux-managed/ {skip=1; next}
226
+ /^# <<< mux-managed/ {skip=0; next}
227
+ skip {next}
228
+ /^(\.claude\/\*?|!\.claude\/.*)$/ {next}
229
+ /^\/?(\.mcp\.json|\.claudeignore|pib\.db)$/ {next}
230
+ /^node_modules$/ {next}
231
+ /^\.claude\/(cabinet\/(advisories-state|checklist-stats)\.json|verification\/)$/ {next}
232
+ /^e2e\/\.verify-progress\.jsonl$/ {next}
233
+ {print}
234
+ ' "$ex" 2>/dev/null > "$ex.tmp" || true
235
+ # User excludesFile copy FIRST (displaced by our core.excludesFile wire;
236
+ # scopes local > global > system, then the XDG default — never the
237
+ # worktree scope, which is our own value and would self-copy).
238
+ local user_ignore="" scope
239
+ for scope in --local --global --system; do
240
+ user_ignore=$(git -C "$wp" config "$scope" --get --type=path core.excludesFile 2>/dev/null) && [[ -n "$user_ignore" ]] && break
241
+ user_ignore=""
242
+ done
243
+ [[ -n "$user_ignore" ]] || user_ignore="${XDG_CONFIG_HOME:-$HOME/.config}/git/ignore"
244
+ if [[ -f "$user_ignore" ]]; then
245
+ { echo '# >>> mux-managed user ignore copy (rebuilt each health run; do not hand-edit)'
246
+ cat "$user_ignore"
247
+ echo '# <<< mux-managed user ignore copy'; } >> "$ex.tmp"
248
+ fi
249
+ {
250
+ echo '# >>> mux-managed worktree rules (rebuilt each health run; hand edits to these patterns are rebuilt away)'
251
+ echo '.claude/*'
252
+ git -C "$wp" ls-files '.claude/' 2>/dev/null \
253
+ | awk -F/ 'NF>2 {print "!.claude/" $2 "/"} NF==2 {print "!" $0}' \
254
+ | sort -u
255
+ echo '.claude/cabinet/advisories-state.json'
256
+ echo '.claude/cabinet/checklist-stats.json'
257
+ echo '.claude/verification/'
258
+ echo 'e2e/.verify-progress.jsonl'
259
+ local f
260
+ for f in .mcp.json .claudeignore pib.db; do
261
+ if [[ -L "$wp/$f" && "$(readlink "$wp/$f")" == "$sp/$f" ]]; then
262
+ echo "/$f"
263
+ fi
264
+ done
265
+ echo 'node_modules'
266
+ echo '# <<< mux-managed worktree rules'
267
+ } >> "$ex.tmp"
54
268
  mv "$ex.tmp" "$ex"
269
+ enable_worktree_excludes "$wp" "$gd"
270
+ }
271
+
272
+ # Make the managed exclude file the one git actually reads for THIS worktree.
273
+ # Linked worktrees only (main's .git/info/exclude is already live). Every
274
+ # skip/degradation path reports — a worktree silently staying on the old
275
+ # dead-exclude behavior is the exact failure this fix exists to close. The
276
+ # report is emitted once per run (EXCLUDES_REPORTED) because the rebuild
277
+ # runs at more than one check site.
278
+ enable_worktree_excludes() {
279
+ local wp="$1" gd="$2" common cur
280
+ common=$(git -C "$wp" rev-parse --path-format=absolute --git-common-dir 2>/dev/null) || return 0
281
+ [[ -n "$common" && "$gd" != "$common" ]] || return 0
282
+ [[ -z "${EXCLUDES_REPORTED:-}" ]] || {
283
+ # Later same-run rebuild — keep config in step, skip the duplicate note.
284
+ cur=$(git -C "$wp" config --worktree --get core.excludesFile 2>/dev/null) || cur=""
285
+ [[ "$cur" == "$gd/info/exclude" ]] || git -C "$wp" config --worktree core.excludesFile "$gd/info/exclude" 2>/dev/null || true
286
+ return 0
287
+ }
288
+ EXCLUDES_REPORTED=1
289
+ # worktreeConfig relocation hazards: core.worktree / core.bare in the
290
+ # SHARED config must be relocated before enabling the extension — a
291
+ # half-migration bricks every worktree (verified live: bare=true +
292
+ # worktreeConfig => 'fatal: this operation must be run in a work tree').
293
+ # mux never creates these layouts; skip with a visible note, never brick.
294
+ if git -C "$wp" config --get core.worktree >/dev/null 2>&1; then
295
+ passes+=("⚠ per-worktree excludes NOT enabled (core.worktree set in shared config) — status may show mux churn; dirty-check stays immune")
296
+ return 0
297
+ fi
298
+ if git -C "$wp" config --get --type=bool core.bare 2>/dev/null | grep -qx true; then
299
+ passes+=("⚠ per-worktree excludes NOT enabled (core.bare=true in shared config) — status may show mux churn; dirty-check stays immune")
300
+ return 0
301
+ fi
302
+ cur=$(git -C "$wp" config --worktree --get core.excludesFile 2>/dev/null) || cur=""
303
+ if [[ "$cur" != "$gd/info/exclude" ]]; then
304
+ git -C "$wp" config extensions.worktreeConfig true 2>/dev/null || true
305
+ git -C "$wp" config --worktree core.excludesFile "$gd/info/exclude" 2>/dev/null || true
306
+ fi
307
+ # Positive confirmation from READ-BACK, never from the write's exit code.
308
+ if [[ "$(git -C "$wp" config --worktree --get core.excludesFile 2>/dev/null)" == "$gd/info/exclude" ]]; then
309
+ passes+=("✓ per-worktree exclude rules live (core.excludesFile wired)")
310
+ else
311
+ passes+=("⚠ per-worktree excludes NOT live (git too old for worktreeConfig?) — status may show mux churn; dirty-check stays immune")
312
+ fi
313
+ }
314
+
315
+ # mux replaces tracked identity files (.mcp.json, .claudeignore) with
316
+ # symlinks to main's copies. gitignore/exclude rules NEVER apply to tracked
317
+ # files, so the typechange (` T`) rendered in every worktree's status for the
318
+ # session's life — and `git add -A` would stage the symlink (the ELOOP
319
+ # class). skip-worktree is the per-worktree index bit for exactly this state:
320
+ # hidden from status, unstageable by add -A, invisible to main and siblings.
321
+ #
322
+ # Discipline (the assume-unchanged lesson from the .claude/ carve-out):
323
+ # - the bit is granted ONLY while the path is a symlink resolving into
324
+ # MAIN's checkout — a foreign-target symlink stays visible;
325
+ # - it is CLEARED whenever the path holds a real file, so a genuine edit
326
+ # is never frozen invisible past the next health run. Between health
327
+ # runs (SessionStart / creation / refresh) a mid-session symlink→real
328
+ # swap IS hidden from porcelain — that window is closed on the deletion
329
+ # side by worktree-dirty-check.sh, which counts any skip-worktree'd
330
+ # path that is no longer a symlink as dirty (fail-DIRTY);
331
+ # - a failed CLEAR while a real file sits behind the bit is a FAILURE,
332
+ # not a shrug — that is exactly the frozen-edit state;
333
+ # - success is claimed from the update-index exit, never the attempt.
334
+ #
335
+ # Known symptom to recognize (pre-existing either way, verified): if MAIN
336
+ # changes .mcp.json after the worktree branched, `git merge main` aborts
337
+ # citing "local changes" while status shows clean. Recovery:
338
+ # git update-index --no-skip-worktree .mcp.json
339
+ # git checkout -- .mcp.json && git merge main
340
+ # (the next health run re-symlinks and re-hides).
341
+ hide_identity_symlink_typechange() {
342
+ local wp="$1" sp="$2" f hidden=0
343
+ for f in .mcp.json .claudeignore; do
344
+ git -C "$wp" ls-files --error-unmatch "$f" >/dev/null 2>&1 || continue
345
+ if [[ -L "$wp/$f" && "$(readlink "$wp/$f")" == "$sp/$f" ]]; then
346
+ if git -C "$wp" update-index --skip-worktree "$f" 2>/dev/null; then
347
+ hidden=1
348
+ fi
349
+ else
350
+ if ! git -C "$wp" update-index --no-skip-worktree "$f" 2>/dev/null; then
351
+ if git -C "$wp" ls-files -v -- "$f" 2>/dev/null | grep -q '^S '; then
352
+ fails+=("⚠ could not clear skip-worktree on $f — a real edit may be hidden from status. Run: git update-index --no-skip-worktree $f")
353
+ fi
354
+ fi
355
+ fi
356
+ done
357
+ [[ "$hidden" -eq 1 ]] && passes+=("✓ identity symlink typechange hidden from status (skip-worktree; cleared whenever a real file returns)")
358
+ return 0
55
359
  }
56
360
 
57
361
  # Clear git's assume-unchanged bit on every tracked .claude/ file — tracked
@@ -103,7 +407,7 @@ if [[ -L "$wt_path/.claude" ]]; then
103
407
  unprotect_authored_records "$wt_path"
104
408
  git -C "$wt_path" checkout -- .claude/ 2>/dev/null || true
105
409
  sync_claude_infra "$proj_path" "$wt_path"
106
- exclude_claude_dir "$wt_path"
410
+ rebuild_worktree_excludes "$wt_path" "$proj_path"
107
411
  passes+=("✓ .claude/ auto-fixed: was symlink, now local copy (path isolation restored)")
108
412
  else
109
413
  fails+=("⚠ .claude/ is a symlink and main repo has no .claude/ to copy from")
@@ -111,19 +415,23 @@ if [[ -L "$wt_path/.claude" ]]; then
111
415
  elif [[ -d "$wt_path/.claude" ]]; then
112
416
  # Heal existing worktrees: un-freeze authored records + migrate the exclude.
113
417
  unprotect_authored_records "$wt_path"
114
- exclude_claude_dir "$wt_path"
418
+ rebuild_worktree_excludes "$wt_path" "$proj_path"
115
419
  passes+=("✓ .claude/ is a local copy (path isolation intact)")
116
420
  elif [[ -d "$proj_path/.claude" ]]; then
117
421
  mkdir -p "$wt_path/.claude"
118
422
  unprotect_authored_records "$wt_path"
119
423
  git -C "$wt_path" checkout -- .claude/ 2>/dev/null || true
120
424
  sync_claude_infra "$proj_path" "$wt_path"
121
- exclude_claude_dir "$wt_path"
425
+ rebuild_worktree_excludes "$wt_path" "$proj_path"
122
426
  passes+=("✓ .claude/ auto-fixed: was missing, copied from main repo")
123
427
  else
124
428
  fails+=("⚠ .claude/ missing and main repo has no .claude/ either")
125
429
  fi
126
430
 
431
+ # 1b. Tracked identity files replaced by mux symlinks must not render as a
432
+ # typechange (exclude rules can't touch tracked files — skip-worktree).
433
+ hide_identity_symlink_typechange "$wt_path" "$proj_path"
434
+
127
435
  # 2. node_modules linked for MCP dependencies
128
436
  if [[ -d "$proj_path/node_modules" ]]; then
129
437
  if [[ -e "$wt_path/node_modules" ]]; then
@@ -157,7 +465,7 @@ if [[ -d "$proj_path/.claude" ]] && [[ -d "$wt_path/.claude" ]] && [[ ! -L "$wt_
157
465
  done | wc -l | tr -d ' ')
158
466
  if [[ "$stale_count" -gt 0 || "$force_refresh" == "--refresh" ]]; then
159
467
  sync_claude_infra "$proj_path" "$wt_path"
160
- exclude_claude_dir "$wt_path"
468
+ rebuild_worktree_excludes "$wt_path" "$proj_path"
161
469
  unprotect_authored_records "$wt_path"
162
470
  passes+=("✓ .claude/ infra refreshed ($stale_count stale file(s); authored records preserved)")
163
471
  else
@@ -165,6 +473,82 @@ if [[ -d "$proj_path/.claude" ]] && [[ -d "$wt_path/.claude" ]] && [[ ! -L "$wt_
165
473
  fi
166
474
  fi
167
475
 
476
+ # 5. Declared env-file provisioning (spawn-time copy + freshness on later
477
+ # runs). Rules, in order, per declared file:
478
+ # - source missing in main → FAIL (loud; the declaration says
479
+ # the project needs it)
480
+ # - destination not gitignored → FAIL, refuse to copy (a declared
481
+ # secrets file must never land as a
482
+ # committable untracked file)
483
+ # - absent in worktree → copy from main (auto-fix)
484
+ # - identical to main → current
485
+ # - differs, untouched since provision → refresh from main (auto-fix; the
486
+ # recorded provisioned-at hash
487
+ # proves the lane never edited it)
488
+ # - differs, locally modified (or no → LEFT ALONE, reported. Lanes may
489
+ # provisioning record) diverge env on purpose (ports);
490
+ # never clobber — the same rule the
491
+ # .claude/ carve-out encodes.
492
+ provision_env_files() {
493
+ local sp="$1" wp="$2"
494
+ local list="$sp/$PROVISION_FILE"
495
+ [[ -f "$list" ]] || return 0
496
+ local gd hash_store kind rel src dst src_hash dst_hash prov_hash
497
+ gd=$(git -C "$wp" rev-parse --git-dir 2>/dev/null) || gd=""
498
+ hash_store=""
499
+ if [[ -n "$gd" ]]; then
500
+ mkdir -p "$gd" 2>/dev/null
501
+ hash_store="$gd/mux-provision-hashes"
502
+ touch "$hash_store" 2>/dev/null || hash_store=""
503
+ fi
504
+ record_hash() { # rel hash — last write wins on re-provision
505
+ [[ -n "$hash_store" ]] || return 0
506
+ { grep -v "^$1 " "$hash_store" 2>/dev/null || true; } > "$hash_store.tmp"
507
+ printf '%s\t%s\n' "$1" "$2" >> "$hash_store.tmp"
508
+ mv "$hash_store.tmp" "$hash_store"
509
+ }
510
+ lookup_hash() { # rel → provisioned-at hash (empty if none)
511
+ [[ -n "$hash_store" ]] || return 0
512
+ { grep "^$1 " "$hash_store" 2>/dev/null || true; } | cut -f2 | head -1
513
+ }
514
+ while IFS=$'\t' read -r kind rel; do
515
+ [[ "$kind" == "file" ]] || continue
516
+ src="$sp/$rel"
517
+ dst="$wp/$rel"
518
+ if [[ ! -f "$src" ]]; then
519
+ fails+=("⚠ provision: source missing in main: $rel (declared in $PROVISION_FILE)")
520
+ continue
521
+ fi
522
+ if ! git -C "$wp" check-ignore -q -- "$rel" 2>/dev/null; then
523
+ fails+=("⚠ provision: $rel is NOT gitignored — refusing to copy (a declared env file must be ignored; fix .gitignore or the declaration)")
524
+ continue
525
+ fi
526
+ src_hash=$(git hash-object "$src" 2>/dev/null) || src_hash=""
527
+ if [[ ! -f "$dst" ]]; then
528
+ mkdir -p "$(dirname "$dst")"
529
+ cp "$src" "$dst"
530
+ [[ -n "$src_hash" ]] && record_hash "$rel" "$src_hash"
531
+ passes+=("✓ provisioned $rel from main")
532
+ continue
533
+ fi
534
+ dst_hash=$(git hash-object "$dst" 2>/dev/null) || dst_hash=""
535
+ if [[ -n "$src_hash" && "$dst_hash" == "$src_hash" ]]; then
536
+ [[ -n "$(lookup_hash "$rel")" ]] || record_hash "$rel" "$src_hash"
537
+ passes+=("✓ provision: $rel current with main")
538
+ continue
539
+ fi
540
+ prov_hash=$(lookup_hash "$rel")
541
+ if [[ -n "$prov_hash" && "$dst_hash" == "$prov_hash" ]]; then
542
+ cp "$src" "$dst"
543
+ [[ -n "$src_hash" ]] && record_hash "$rel" "$src_hash"
544
+ passes+=("✓ provision: $rel refreshed from main (was stale, locally untouched)")
545
+ else
546
+ passes+=("✓ provision: $rel differs from main — left alone (locally modified or no provisioning record; never clobbered)")
547
+ fi
548
+ done < <(parse_provision_file "$list")
549
+ }
550
+ provision_env_files "$proj_path" "$wt_path"
551
+
168
552
  # Cache status + update tmux indicator color
169
553
  cache_dir="$HOME/.local/share/mux/wt-health"
170
554
  mkdir -p "$cache_dir"