Skip to content

Commit d19ccf3

Browse files
Let install.sh delete, and teach doctor to look the other way
install.sh has never been able to remove a key. The comment above the merge said retired keys were del()ed explicitly below; no such code was ever written. So every key this repo shipped and later dropped stayed in the live settings file forever. It bit twice, and both times the fix was a hand edit on one machine, which fixes nothing for anybody else. There is now a RETIRED list the merge delpaths, and the merge is proven against a planted sandbox key: it goes, while forceLoginMethod and remote.defaultEnvironmentId survive. Superset is gone with it. That notifier was stapled to five hook events of every install, including two events that existed only to feed it, and it is a tool this setup no longer uses. A stranger installing vstack should not inherit hooks for someone else's software. Check 11 asserts it stays gone rather than trusting the next reader to notice. doctor --drift only ever walked repo to machine, so it could see a file that had vanished and never a file that should not be there. That is how orchestrate.md outlived its own deletion, and how a pre-vstack bootstrap script sat in ~/.claude/scripts propping up a command that was broken for everyone else. It now reads settings.json keys back and classifies extras, and lists files under ~/.claude that the repo does not ship. Files split by git history rather than by guess: one the repo used to ship is a delete that never landed, one it never had is yours and not doctor's business. Two wrong answers on the way there, both instructive. rev-list --objects takes a pathspec but walks every reachable object, so it answers yes for paths that never existed. And `git log | grep -q` under pipefail returns 141 on a match, because grep exits first and git takes SIGPIPE — which inverted the test and filed every leftover as a user file. verify.sh 22/22, gate-falsifiability.sh 23/23, doctor --drift clean.
1 parent f0f345a commit d19ccf3

4 files changed

Lines changed: 132 additions & 23 deletions

File tree

.claude/verify.sh

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -171,7 +171,8 @@ if command -v jq >/dev/null; then
171171
# otherwise clobber each other's scratch files mid-check.
172172
md=$(mktemp -d)
173173
out=$(printf '{}\n' > "$md/a.json"; cp claude/settings.json "$md/b.json";
174-
jq -s --arg h "/tmp/hooks" --arg n "true" "$prog" "$md/a.json" "$md/b.json" 2>&1)
174+
jq -s --arg h "/tmp/hooks" --argjson retired '["probe_retired_key"]' \
175+
"$prog" "$md/a.json" "$md/b.json" 2>&1)
175176
if printf '%s' "$out" | jq -e . >/dev/null 2>&1; then
176177
ok "settings merge program"
177178
else
@@ -266,12 +267,20 @@ if command -v jq >/dev/null; then
266267
if [ -z "$prog" ]; then
267268
errs="$errs\ncould not extract the hook rebuild program from install.sh"
268269
else
269-
for ev in SessionStart UserPromptSubmit PostToolUse Stop PostToolUseFailure SessionEnd PermissionRequest; do
270+
for ev in SessionStart UserPromptSubmit PostToolUse Stop PostToolUseFailure; do
270271
printf '%s' "$prog" | grep -qE "^ *$ev: *\[" || errs="$errs\n$ev: missing from install.sh hook rebuild"
271272
done
272-
# The notify hook is what reaches the phone. It is wired to five of the seven events.
273-
nn=$(printf '%s' "$prog" | grep -cF 'command:$n')
274-
[ "$nn" -eq 5 ] || errs="$errs\nnotify wired to $nn sites in install.sh, expected 5"
273+
# The user lane wires the same five events as the project lane and nothing more. It used to
274+
# wire seven, because SessionEnd and PermissionRequest existed only to reach a third-party
275+
# notifier (Superset) that this setup no longer uses. Installing vstack should not staple a
276+
# foreign tool's script to five hook events on a stranger's machine, so the notifier is gone
277+
# and this asserts it stays gone rather than trusting the next reader to notice.
278+
for dead in SessionEnd PermissionRequest; do
279+
printf '%s' "$prog" | grep -qE "^ *$dead: *\[" \
280+
&& errs="$errs\n$dead: back in the install.sh hook rebuild — it exists only to serve a notifier this setup dropped"
281+
done
282+
grep -qi 'superset' install.sh \
283+
&& errs="$errs\ninstall.sh still references the retired Superset notifier"
275284
fi
276285

277286
# Lane 3 — plugin marketplace. Deliberately narrow, and asserted as an exact set rather than

bin/doctor

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,109 @@ if [ "${1:-}" = "--drift" ]; then
106106
[ -f "$REPO/shell/claude-parity.zsh" ] && \
107107
check_item "shell/claude-parity.zsh" "$REPO/shell/claude-parity.zsh" "$HOME/.config/agents/shell/claude-parity.zsh"
108108

109+
# --- settings.json keys -------------------------------------------------------------------
110+
# The two blind spots below are why this whole section exists. --drift compared files it
111+
# could diff byte-for-byte and quietly skipped the two surfaces that cannot be compared that
112+
# way, so both rotted for months while it printed "no drift".
113+
#
114+
# First: ~/.claude/settings.json is a MERGE, never a copy, so it can never be diffed against
115+
# the repo. install.sh had no way to delete, which meant every key vstack ever shipped and
116+
# later dropped stayed in the live file forever. It happened twice. install.sh owns deletion
117+
# now (see RETIRED there), but that only strips keys someone remembered to list, so this
118+
# reads the live file back and names anything the repo does not ship.
119+
#
120+
# USER_SCOPE keys legitimately exist only on a machine: they are per-account or per-device
121+
# and have no business in a public repo. Anything outside both sets is unclassified — not
122+
# necessarily wrong, but nobody has decided what it is, which is exactly the state the dead
123+
# skillOverrides and sandbox blocks sat in.
124+
if command -v jq >/dev/null 2>&1 && [ -f "$HOME/.claude/settings.json" ]; then
125+
USER_SCOPE='["forceLoginMethod","remote","enabledPlugins","extraKnownMarketplaces","preferredNotifChannel","permissions","skipDangerousModePermissionPrompt","statusLine","hooks","feedbackSurveyState","hasCompletedOnboarding","lastOnboardingVersion","userID","oauthAccount","customApiKeyResponses","tipsHistory","promptQueueUseCount","installMethod","autoUpdates","theme"]'
126+
extra=$(jq -r --slurpfile repo "$REPO/claude/settings.json" --argjson us "$USER_SCOPE" '
127+
($repo[0] | keys) as $shipped
128+
| keys - $shipped - $us
129+
| .[]' "$HOME/.claude/settings.json" 2>/dev/null)
130+
if [ -n "$extra" ]; then
131+
echo "unclassified settings.json key(s) not shipped by the repo and not user-scope:"
132+
printf ' %s\n' $extra
133+
echo " decide each one: add it to claude/settings.json, to RETIRED in install.sh, or to USER_SCOPE here"
134+
DRIFT=1
135+
fi
136+
fi
137+
138+
# --- files the repo never shipped -----------------------------------------------------------
139+
# Second blind spot: --drift only ever walked repo -> machine, so it could see a file that
140+
# went missing and never a file that should not be there. A deleted orchestrate.md survived
141+
# in ~/.claude for months that way, and a pre-vstack bootstrap-claude-project.sh sat in
142+
# ~/.claude/scripts propping up a command that was broken for everyone else on earth.
143+
#
144+
# Reports only; never deletes. These are the user's files, and a health check that removes
145+
# things you did not ask it to remove is worse than the drift.
146+
#
147+
# Symlinks are skipped on purpose: plugin- and Claude-Code-bundled skills (skill-creator,
148+
# pdf, docx, pptx, xlsx) link in from elsewhere and are expected.
149+
RUNTIME_TOP='projects sessions session-env shell-snapshots tasks todos plans telemetry plugins cache chrome daemon downloads paste-cache jobs ide statsig backups scheduled-tasks history.jsonl stats-cache.json mcp-needs-auth-cache.json settings.json settings.local.json .credentials.json .last-update-result.json .last-cleanup .cc-writes CLAUDE.md statusline.sh hooks agents commands skills'
150+
stale=""
151+
for p in "$HOME"/.claude/* "$HOME"/.claude/.[!.]*; do
152+
[ -e "$p" ] || continue
153+
b=$(basename "$p")
154+
case " $RUNTIME_TOP " in *" $b "*) continue ;; esac
155+
stale="$stale $b"
156+
done
157+
for kind in hooks:'*.sh' agents:'*.md' commands:'*.md'; do
158+
d=${kind%%:*}; pat=${kind#*:}
159+
for p in "$HOME"/.claude/"$d"/$pat; do
160+
[ -e "$p" ] || continue
161+
[ -L "$p" ] && continue
162+
b=$(basename "$p")
163+
[ -e "$REPO/claude/$d/$b" ] || stale="$stale $d/$b"
164+
done
165+
done
166+
for p in "$HOME"/.claude/skills/*/; do
167+
[ -d "$p" ] || continue
168+
s=$(basename "$p")
169+
[ -L "${p%/}" ] && continue
170+
[ -d "$REPO/claude/skills/$s" ] || stale="$stale skills/$s"
171+
done
172+
# Two very different things end up in that list, and calling both "drift" would be wrong.
173+
# A file the repo USED to ship and later deleted is a genuine leftover: the deletion never
174+
# reached this machine, which is precisely how orchestrate.md outlived its removal. A file
175+
# the repo has never had at any commit is the user's own, and a health check has no standing
176+
# to call that an error. Git history is what separates them, so ask it instead of guessing.
177+
if [ -n "$stale" ]; then
178+
leftover=""; theirs=""
179+
for b in $stale; do
180+
case "$b" in
181+
hooks/*|agents/*|commands/*|skills/*) rel="claude/$b" ;;
182+
*) rel="claude/$b" ;;
183+
esac
184+
# Two traps here, both of which produced a confidently wrong answer:
185+
#
186+
# `git log`, not `rev-list --objects`. The latter accepts a pathspec but still walks every
187+
# reachable object, so it says "yes" for a path that has never existed and files all of
188+
# the user's own files under "the repo dropped this".
189+
#
190+
# And capture the output rather than piping into `grep -q`. Under `set -o pipefail` grep
191+
# exits the moment it matches, git takes SIGPIPE, and the pipeline reports 141 — on a
192+
# match. Piping inverted the test: every leftover was classified as the user's own file.
193+
hist=$(git -C "$REPO" log --all --oneline -- "$rel" 2>/dev/null)
194+
if [ -n "$hist" ]; then
195+
leftover="$leftover $b"
196+
else
197+
theirs="$theirs $b"
198+
fi
199+
done
200+
if [ -n "$leftover" ]; then
201+
echo "left over from a version this repo used to ship (the delete never reached this machine):"
202+
printf ' %s\n' $leftover
203+
echo " safe to remove — git history shows the repo dropped them"
204+
DRIFT=1
205+
fi
206+
if [ -n "$theirs" ]; then
207+
echo "note: in ~/.claude but never shipped by this repo, so presumed yours:"
208+
printf ' %s\n' $theirs
209+
fi
210+
fi
211+
109212
# Behind-remote is informational only — never fail solely for being behind.
110213
if git -C "$REPO" rev-parse --symbolic-full-name '@{u}' >/dev/null 2>&1; then
111214
git -C "$REPO" fetch -q 2>/dev/null || true

install.sh

Lines changed: 15 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -165,11 +165,17 @@ fi
165165
# paths (user scope has no $CLAUDE_PROJECT_DIR). The portable file is authoritative for
166166
# every key it ships — including enabledPlugins and skillOverrides, which is replaced
167167
# wholesale so overrides for deleted skills don't linger in the live file forever. Keys the
168-
# portable file never mentions (forceLoginMethod / remote / permissions) survive untouched;
169-
# retired top-level keys are del()ed explicitly below.
168+
# portable file never mentions (forceLoginMethod / remote / permissions) survive untouched.
169+
#
170+
# RETIRED is the part that used to be a lie. The comment here claimed retired keys were
171+
# del()ed "explicitly below" and no such code was ever written, so every key this repo has
172+
# ever shipped and later dropped stayed in the live file forever. It bit twice — a dead
173+
# skillOverrides block, then a sandbox block — and both times the fix was a manual edit on
174+
# one machine, which fixes nothing for anyone else. A merge that cannot delete is not a
175+
# merge, it is an accumulator. Add a key here when you remove it from claude/settings.json.
176+
RETIRED='["sandbox","skillOverridesLegacy","enabledMcpjsonServers","autoCompactEnabled"]'
170177
US="$HOME/.claude/settings.json"; back "$US"
171178
[ -f "$US" ] || { [ "$DRY" = 0 ] && echo '{}' > "$US"; }
172-
NOTIFY='[ -n "$SUPERSET_HOME_DIR" ] && [ -x "$SUPERSET_HOME_DIR/hooks/notify.sh" ] && SUPERSET_AGENT_ID=claude "$SUPERSET_HOME_DIR/hooks/notify.sh" || true'
173179
if [ "$DRY" = 0 ] && [ "$HAVE_JQ" = 0 ]; then
174180
# No jq: never hand-merge JSON. Write the portable settings only when there is nothing
175181
# to lose, otherwise leave the existing file untouched.
@@ -181,27 +187,23 @@ if [ "$DRY" = 0 ] && [ "$HAVE_JQ" = 0 ]; then
181187
fi
182188
elif [ "$DRY" = 0 ]; then
183189
tmp=$(mktemp)
184-
jq -s --arg h "$HOME/.claude/hooks" --arg n "$NOTIFY" '
190+
jq -s --arg h "$HOME/.claude/hooks" --argjson retired "$RETIRED" '
185191
((.[1] | del(.hooks)) as $portable
186192
| (.[0] * $portable)
187-
| .skillOverrides = ($portable.skillOverrides // {}))
193+
| .skillOverrides = ($portable.skillOverrides // {})
194+
| delpaths([$retired[] | [.]]))
188195
| .hooks = {
189196
SessionStart: [
190-
{ hooks: [ {type:"command", command:($h+"/inject-session-context.sh"), statusMessage:"context"} ] },
191-
{ hooks: [ {type:"command", command:$n} ] } ],
197+
{ hooks: [ {type:"command", command:($h+"/inject-session-context.sh"), statusMessage:"context"} ] } ],
192198
UserPromptSubmit: [
193199
{ hooks: [ {type:"command", command:($h+"/inject-session-context.sh")} ] } ],
194200
PostToolUse: [
195201
{ matcher:"Edit|Write|MultiEdit",
196202
hooks: [ {type:"command", command:($h+"/format.sh"), statusMessage:"format"} ] } ],
197203
Stop: [
198-
{ hooks: [ {type:"command", command:($h+"/verify-gate.sh")} ] },
199-
{ hooks: [ {type:"command", command:$n} ] } ],
204+
{ hooks: [ {type:"command", command:($h+"/verify-gate.sh")} ] } ],
200205
PostToolUseFailure: [
201-
{ matcher:"*", hooks: [ {type:"command", command:($h+"/failure-diagnose.sh")} ] },
202-
{ matcher:"*", hooks: [ {type:"command", command:$n} ] } ],
203-
SessionEnd: [ { hooks: [ {type:"command", command:$n} ] } ],
204-
PermissionRequest: [ { matcher:"*", hooks: [ {type:"command", command:$n} ] } ]
206+
{ matcher:"*", hooks: [ {type:"command", command:($h+"/failure-diagnose.sh")} ] } ]
205207
}
206208
| .statusLine = {type:"command", command:(($h|rtrimstr("/hooks")) + "/statusline.sh"), padding:0, refreshInterval:3}
207209
' "$US" "$SRC/claude/settings.json" > "$tmp"

secrets.env.example

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,7 @@
1010
export CLOUDFLARE_API_TOKEN=
1111
export CLOUDFLARE_ACCOUNT_ID=
1212

13-
# --- Transactional email ---
14-
1513
# --- Anthropic SDK key, for APPS you build ---
1614
# Deliberately NOT named ANTHROPIC_API_KEY: that name makes Claude Code bill API credits
1715
# instead of your subscription. The shell wrapper strips it, doctor checks it stays unset.
1816
export ANTHROPIC_SDK_API_KEY=
19-
20-
# --- Optional: Superset notifier (hooks post events here when set) ---
21-
export SUPERSET_HOME_DIR=

0 commit comments

Comments
 (0)