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
|
@@ -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
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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"
|