Skip to content

Commit b91ccfe

Browse files
1.44.0: uninstall stops deleting the user's hooks, and check 20 stops trusting a prefix list
uninstall.sh decided hook ownership by directory prefix, so anybody who kept their own scripts in ~/.claude/hooks -- the conventional place, which vstack installs into rather than owns -- lost every settings.json entry pointing at them, while the scripts stayed on disk and the tool printed "vstack hooks ... removed". install.sh already matched on the basenames this repo ships; the two halves disagreed about what ownership means and only the install half was ever checked. Check 45 now runs a real install-then-uninstall under a throwaway HOME and asserts both directions. Check 20 matched only three ~/ prefixes, so /push telling the model to run ~/.100xprompt/hooks/pre-push.sh went unseen for the whole life of the check written to catch exactly that. It now reads every ~/-rooted path; foreign ones must be declared with a reason. Turning it on surfaced five references across four commands, all repaired here. Five of fifteen commands were reference documents in a foreign template with no instruction to the assistant anywhere in the body, documenting six argument interfaces backed by scripts this repo has never installed. All five rewritten against tools that exist. Found by audit, not by reading: MEESEEKS M-7 over the 28 skills, NOOBNOOB N-2 over the 15 commands, JAGUAR J-1 driving a clean install and uninstall under throwaway HOMEs, MORTY M-8 on the command rewrites. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 75c2f27 commit b91ccfe

14 files changed

Lines changed: 311 additions & 466 deletions

File tree

.claude-plugin/marketplace.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
"plugins": [
1111
{
1212
"name": "vstack",
13-
"version": "1.43.0",
13+
"version": "1.44.0",
1414
"source": "./claude",
1515
"description": "28 skills that fire without a slash command, 14 agents, 15 commands, and the session hook that routes situations to skills. Most skills are ported from pstack and Superpowers — see claude/skills/ATTRIBUTION.md for per-skill source and license.",
1616
"category": "workflow"

.claude/verify.sh

Lines changed: 79 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -991,10 +991,19 @@ fi
991991
# machine only because a pre-vstack copy happened to survive there, so the command was broken for
992992
# every other human on earth and nothing noticed for months.
993993
#
994-
# This is the same defect class as the stale `orchestrate.md` prose and installed tree
995-
# disagreeing just pointing the other way. Check 12 counts things; nothing read the paths back.
994+
# This is the same defect class as the stale `orchestrate.md` -- prose and installed tree
995+
# disagreeing -- just pointing the other way. Check 12 counts things; nothing read the paths back.
996996
# A referenced path either maps to something in this repo that install.sh copies, or it is
997-
# runtime state; anything else is a promise the installer does not keep.
997+
# runtime state, or it is declared foreign below; anything else is a promise the installer
998+
# does not keep.
999+
#
1000+
# The first version of this extractor matched only ~/.claude, ~/.config/agents and ~/.conductor.
1001+
# That is an allow-list wearing a scanner's clothes: `/push` told the model to run
1002+
# `~/.100xprompt/hooks/pre-push.sh`, a path belonging to an entirely different tool's template,
1003+
# and the regex never saw it because it did not begin with one of the three blessed prefixes.
1004+
# The check written to catch a command pointing at a script nobody installs could not catch a
1005+
# command pointing at a script nobody installs, one namespace over. It now reads every ~/-rooted
1006+
# path and a foreign one must be declared, with a reason, rather than pass by being unmatched.
9981007
# Every ~ below is single-quoted on purpose. An unquoted tilde in a `case` pattern or a `${x#...}`
9991008
# prefix is expanded to $HOME before the match, so the first draft of this check compared
10001009
# "~/.claude/CLAUDE.md" against "/Users/<me>/.claude/CLAUDE.md", matched nothing, and reported
@@ -1016,6 +1025,19 @@ runtime_path(){ # paths that exist at runtime but are created by Claude Code or
10161025
esac
10171026
return 1
10181027
}
1028+
# Paths outside every namespace this repo installs into. Each entry states why it is here; a
1029+
# bare addition with no reason is how the ~/.100xprompt reference would have been waved through
1030+
# a second time. "It appears in the tree" is not a reason -- the reason has to be that the path
1031+
# belongs to something the user supplies and the docs are right to name.
1032+
# shellcheck disable=SC2088 # match patterns, not paths -- see runtime_path above
1033+
external_path(){
1034+
case "$1" in
1035+
# The repo checkout itself. Docs name it because the installer is run from it; install.sh
1036+
# cannot create the directory it is being run out of.
1037+
'~/Projects/vstack'|'~/Projects/vstack/'*) return 0 ;;
1038+
esac
1039+
return 1
1040+
}
10191041
# shellcheck disable=SC2088 # match patterns, not paths -- see runtime_path above
10201042
src_for(){ # installed path -> the repo file install.sh copies there, or empty if unmapped
10211043
case "$1" in
@@ -1034,14 +1056,15 @@ errs=""
10341056
while IFS= read -r ref; do
10351057
[ -n "$ref" ] || continue
10361058
runtime_path "$ref" && continue
1059+
external_path "$ref" && continue
10371060
src=$(src_for "$ref")
10381061
if [ -z "$src" ]; then
1039-
errs="$errs\n$ref: no install.sh rule puts anything there"
1062+
errs="$errs\n$ref: no install.sh rule puts anything there, and it is not declared foreign"
10401063
elif [ ! -e "$src" ]; then
10411064
errs="$errs\n$ref: install.sh would copy $src, which does not exist in this repo"
10421065
fi
10431066
done <<EOF
1044-
$(grep -rhoE '~/\.(claude|config/agents|conductor)[A-Za-z0-9._/-]*' \
1067+
$(grep -rhoE '~/[A-Za-z0-9._/-]+' \
10451068
README.md claude/commands claude/agents claude/skills 2>/dev/null \
10461069
| sed 's#[.,:;)`"]*$##; s#/$##' | sort -u)
10471070
EOF
@@ -2609,6 +2632,57 @@ else
26092632
skip "dispatch counter join, both directions" "jq not installed"
26102633
fi
26112634
2635+
# --- 45. uninstall drops vstack's own settings entries and keeps the user's --------------------
2636+
# uninstall.sh decided hook ownership by directory prefix: any entry whose command started with
2637+
# ~/.claude/hooks was treated as vstack's. That directory is the conventional place for a user's
2638+
# own hooks, and vstack installs into it rather than owning it, so a stranger who kept personal
2639+
# scripts there had every hook entry pointing at them deleted -- while the scripts themselves
2640+
# stayed on disk, and the tool printed "vstack hooks, overrides and unedited policy keys
2641+
# removed". A destructive step reporting a narrower scope than the one it performed.
2642+
#
2643+
# install.sh had the right signal the whole time: the basenames this repo ships, matched with
2644+
# endswith("/hooks/" + name). Two halves of one repo disagreed about what ownership means and
2645+
# only the install half was ever checked. Both directions here, because a fix that removes
2646+
# nothing passes the user's half trivially.
2647+
if command -v jq >/dev/null; then
2648+
c45_home=$(mktemp -d)
2649+
c45_errs=""
2650+
c45_hook="$c45_home/.claude/hooks/not-vstacks.sh"
2651+
c45_sl="$c45_home/.claude/not-vstacks-statusline.sh"
2652+
mkdir -p "$c45_home/.claude/hooks"
2653+
printf '#!/bin/bash\nexit 0\n' > "$c45_hook"; chmod +x "$c45_hook"
2654+
printf '#!/bin/bash\nexit 0\n' > "$c45_sl"; chmod +x "$c45_sl"
2655+
jq -n --arg h "$c45_hook" --arg s "$c45_sl" \
2656+
'{theme:"dark",statusLine:{type:"command",command:$s},
2657+
hooks:{Notification:[{hooks:[{type:"command",command:$h}]}]}}' \
2658+
> "$c45_home/.claude/settings.json"
2659+
if HOME="$c45_home" ./install.sh >/dev/null 2>&1 \
2660+
&& HOME="$c45_home" ./uninstall.sh --yes >/dev/null 2>&1; then
2661+
jq -e --arg h "$c45_hook" '[.hooks.Notification[]?.hooks[]?.command] | index($h) != null' \
2662+
"$c45_home/.claude/settings.json" >/dev/null 2>&1 \
2663+
|| c45_errs="$c45_errs\nthe user's own Notification hook is gone: uninstall removed an entry it does not own"
2664+
jq -e --arg s "$c45_sl" '(.statusLine.command? // "") == $s' \
2665+
"$c45_home/.claude/settings.json" >/dev/null 2>&1 \
2666+
|| c45_errs="$c45_errs\nthe user's own statusLine is gone: uninstall removed a key it does not own"
2667+
for c45_f in claude/hooks/*.sh; do
2668+
[ -e "$c45_f" ] || continue
2669+
c45_b=$(basename "$c45_f")
2670+
if jq -e --arg b "$c45_b" '[.. | .command? // empty] | any(endswith("/hooks/" + $b))' \
2671+
"$c45_home/.claude/settings.json" >/dev/null 2>&1; then
2672+
c45_errs="$c45_errs\n$c45_b is still wired after uninstall -- vstack left its own hook behind"
2673+
fi
2674+
done
2675+
else
2676+
c45_errs="$c45_errs\ninstall.sh or uninstall.sh failed under a throwaway HOME"
2677+
fi
2678+
rm -rf "$c45_home"
2679+
[ -z "$c45_errs" ] \
2680+
&& ok "uninstall keeps foreign settings, drops its own" \
2681+
|| bad "uninstall keeps foreign settings, drops its own" "$(printf '%b' "$c45_errs")"
2682+
else
2683+
skip "uninstall keeps foreign settings, drops its own" "jq not installed"
2684+
fi
2685+
26122686
echo
26132687
# Accounting. Every declared check must have reported either a result or a skip. A check
26142688
# that throws a shell error mid-body, or is wrapped in a conditional with no else, silently

CHANGELOG.md

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,63 @@ Versions follow [semver](https://semver.org). The version lives in two manifests
44
`.claude-plugin/marketplace.json` and `claude/.claude-plugin/plugin.json`, and check 13 of
55
`.claude/verify.sh` fails when they disagree.
66

7+
## 1.44.0 — 2026-08-24
8+
9+
**`uninstall.sh` deleted the user's own hook entries and reported that it had removed vstack's.**
10+
Ownership was decided by directory prefix: any entry in `settings.json` whose command started with
11+
`~/.claude/hooks` was treated as vstack's. That directory is the conventional place for a person's
12+
own hook scripts, and vstack installs into it rather than owning it. So a stranger who kept
13+
personal hooks there ran `uninstall.sh --yes` and lost every entry pointing at them, while the
14+
scripts themselves stayed on disk and the tool printed `cleaned ... (vstack hooks, overrides and
15+
unedited policy keys removed)`. A destructive step reporting a narrower scope than the one it
16+
performed, which is worse than the deletion: the operator has no reason to go looking.
17+
18+
The correct signal was already in this repo. `install.sh` derives the basenames vstack ships and
19+
matches `endswith("/hooks/" + name)`; the merge half had it right and the removal half never
20+
asked. `.statusLine` had the same defect for the same reason. Both now match on shipped filenames.
21+
Recoverable in the old behaviour only via `$BK/pre-uninstall/`, which the tool never mentioned.
22+
23+
Found by exercising a real install-then-uninstall under a throwaway `HOME` with a foreign hook
24+
seeded first, not by reading the jq. Reading it is how it passed review the first time.
25+
26+
**Check 45, `uninstall keeps foreign settings, drops its own`.** Both directions against the real
27+
scripts under a temp `HOME`: the user's `Notification` entry and their `statusLine` must survive,
28+
and every hook this repo ships must be gone. A fix that removes nothing passes the user's half
29+
trivially, which is why the second direction is not optional. Row 45 reverts the one line to the
30+
prefix test and the check goes red naming itself.
31+
32+
**Check 20's extractor was an allow-list wearing a scanner's clothes.** It matched only
33+
`~/.claude`, `~/.config/agents` and `~/.conductor`. `/push` told the model to run
34+
`~/.100xprompt/hooks/pre-push.sh`, another tool's template path, in a command this repo installs.
35+
The check written to catch exactly that could not see it, because the string did not begin with
36+
one of three blessed prefixes. The check that exists because `/bootstrap` pointed at a script
37+
nobody installs was blind to `/push` pointing at a script nobody installs, one namespace over.
38+
39+
It now reads every `~/`-rooted path. A path outside the installed namespaces has to be declared in
40+
`external_path()` with a reason; today that list has one entry, the repo checkout itself. Turning
41+
it on surfaced five references across four commands. Row 20 only ever mutated inside a blessed
42+
prefix, so it proved the half that already worked; **row 20b** adds the foreign-namespace lane.
43+
44+
**Five of fifteen commands were reference documents wearing a command's frontmatter.**
45+
`push.md`, `observability.md`, `deploy.md`, `release.md` and `security.md` shipped in the initial
46+
commit from a foreign template (`## Usage` and `## Implementation` sections, tool tables, code
47+
samples) with no imperative instruction to the assistant anywhere in the body, and were never
48+
touched again. Between them they documented six argument interfaces (`/deploy vercel`,
49+
`/release patch`, `/security network`, `/security full` and others) whose `case` statements lived
50+
inside fenced blocks describing hypothetical standalone scripts at `~/.local/bin/deploy` and
51+
`~/.local/bin/release` that this repo has never installed, plus `~/nuclei-templates`. All five are
52+
now numbered instructions against tools that exist: `push` runs the real gate and names the
53+
`vstack trust` step, `deploy` defers to `bin/deploy-auto.sh`, `release` defers to the
54+
`release-manager` subagent it duplicated, `security` marks its external scanners as user-supplied.
55+
`observability.md` also had a PostHog JS snippet fenced as `bash`, so it failed `bash -n`.
56+
57+
**`doctor --drift` filed vstack's own logs under "presumed yours".**
58+
`vstack-delegation-log.jsonl` and `vstack-replay-log.jsonl` are written by shipped hooks and were
59+
absent from `RUNTIME_TOP`, so on any machine that had actually used vstack, doctor told the
60+
operator its own output might be a stranger's leftover. The names are now derived from the shipped
61+
hooks rather than listed by hand. The replay log arrived in 1.43.0 and a hand-kept list would
62+
have gone stale the same afternoon. Cosmetic: `DRIFT` was never set by it.
63+
764
## 1.43.0 — 2026-08-24
865

966
**`vstack-delegation-log.jsonl` recorded only per-Stop aggregate counts, never which subagent ran

README.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ Two directory pairs differ only by a leading dot, and the difference is the whol
2323
| path | what it is |
2424
|---|---|
2525
| `claude/` | the **shipped payload** — skills, subagents, commands, hooks, installed to `~/.claude/` |
26-
| `.claude/verify.sh` | **this repository's own gate**, 44 checks; not shipped to anyone |
26+
| `.claude/verify.sh` | **this repository's own gate**, 45 checks; not shipped to anyone |
2727
| `conductor/` | payload copied to `~/.conductor/` |
2828
| `.conductor/` | this repository's own workspace config |
2929
| `tests/` | the suites: the falsifiability harness, the install matrix, trigger and baseline tests |
@@ -61,8 +61,8 @@ cd ~/Projects/vstack && ./install.sh
6161
Pin a release rather than tracking `main`:
6262

6363
```bash
64-
curl -fsSL https://raw.githubusercontent.com/itsvedantkumar/vstack/v1.43.0/bootstrap.sh -o bootstrap.sh
65-
VSTACK_REF=v1.43.0 bash bootstrap.sh # installs that tag, not main
64+
curl -fsSL https://raw.githubusercontent.com/itsvedantkumar/vstack/v1.44.0/bootstrap.sh -o bootstrap.sh
65+
VSTACK_REF=v1.44.0 bash bootstrap.sh # installs that tag, not main
6666
```
6767

6868
The curl one-liner above always runs `./setup-machine.sh` first, which installs the tools this
@@ -151,14 +151,14 @@ reaches for `unslop`, reviewing TypeScript reaches for `typescript-best-practice
151151

152152
## Checks that can fail
153153

154-
The gate is 44 checks. `tests/gate-falsifiability.sh` breaks the repository once per check, at
154+
The gate is 45 checks. `tests/gate-falsifiability.sh` breaks the repository once per check, at
155155
least once and more where a check can fail in more than one way, requires the gate to go red
156156
naming that check, restores the tree byte for byte, and fails if anything was left behind.
157157
**Check 16 fails if any check has no mutation row**, so a check cannot be added without proof it
158158
can fail.
159159

160160
```bash
161-
./.claude/verify.sh # 44 checks
161+
./.claude/verify.sh # 45 checks
162162
VSTACK_FALSIFY_ROWS=27 ./tests/gate-falsifiability.sh # one row
163163
git clone . /tmp/vstack-check && cd /tmp/vstack-check && ./tests/gate-falsifiability.sh
164164
```

bin/doctor

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,16 @@ if [ "${1:-}" = "--drift" ]; then
184184
# Symlinks are skipped on purpose: plugin- and Claude-Code-bundled skills (skill-creator,
185185
# pdf, docx, pptx, xlsx) link in from elsewhere and are expected.
186186
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'
187+
# Everything above is Claude Code's own. vstack's hooks write log files into the same
188+
# directory, and those were landing in the "presumed yours" bucket -- doctor telling the
189+
# operator that vstack's own output might be a stranger's leftover. Derived from the shipped
190+
# hooks rather than listed by hand, because the replay log was added in 1.43.0 and a hand-kept
191+
# list would have gone stale that same afternoon. If a hook writes it, doctor knows it.
192+
for _rt in "$REPO"/claude/hooks/*.sh; do
193+
[ -e "$_rt" ] || continue
194+
_rtn=$(grep -ohE 'vstack-[a-z-]+\.jsonl' "$_rt" 2>/dev/null | sort -u | tr '\n' ' ')
195+
[ -n "$_rtn" ] && RUNTIME_TOP="$RUNTIME_TOP $_rtn"
196+
done
187197
stale=""
188198
for p in "$CDIR"/* "$CDIR"/.[!.]*; do
189199
[ -e "$p" ] || continue

claude/.claude-plugin/plugin.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "vstack",
3-
"version": "1.43.0",
3+
"version": "1.44.0",
44
"description": "Skills that fire on the situation instead of a slash command, plus the subagents, commands, and session hook that make them fire. Verification gates, parallel fan-out, code review, and writing discipline.",
55
"author": {
66
"name": "Vedant Kumar"

claude/commands/deploy.md

Lines changed: 32 additions & 93 deletions
Original file line numberDiff line numberDiff line change
@@ -1,97 +1,36 @@
11
---
22
name: deploy
3-
description: One-command deploy to Vercel/Cloudflare/Railway with automatic checks.
3+
description: Deploy to production (Vercel, Cloudflare, Railway, Fly) with verification.
44
---
55

6-
# Deploy Command
7-
8-
## Pre-deploy Checks
9-
10-
1. Typecheck
11-
2. Lint
12-
3. Tests
13-
4. Build
14-
15-
## Commands
16-
17-
```bash
18-
# Vercel (auto-detects)
19-
/deploy vercel
20-
21-
# Cloudflare Workers
22-
/deploy cf
23-
24-
# Railway
25-
/deploy railway
26-
27-
# Fly.io
28-
/deploy fly
29-
```
30-
31-
## Implementation
32-
33-
```bash
34-
#!/bin/bash
35-
# ~/.local/bin/deploy
36-
set -e
37-
38-
PLATFORM=$1
39-
shift
40-
41-
echo "Running pre-deploy checks..."
42-
43-
# Typecheck
44-
npm run typecheck 2>&1 | tail -5 || { echo "Typecheck failed"; exit 1; }
45-
46-
# Lint
47-
npm run lint 2>&1 | tail -5 || { echo "Lint failed"; exit 1; }
48-
49-
# Tests
50-
npm test -- --passWithNoTests 2>&1 | tail -5 || { echo "Tests failed"; exit 1; }
51-
52-
# Build
53-
npm run build 2>&1 | tail -10 || { echo "Build failed"; exit 1; }
54-
55-
echo "Checks passed. Deploying to $PLATFORM..."
56-
57-
case $PLATFORM in
58-
vercel)
59-
vercel --prod "$@"
60-
;;
61-
cf|cloudflare)
62-
npx wrangler deploy "$@"
63-
;;
64-
railway)
65-
railway up "$@"
66-
;;
67-
fly)
68-
fly deploy "$@"
69-
;;
70-
*)
71-
echo "Unknown platform: $PLATFORM"
72-
exit 1
73-
;;
74-
esac
75-
```
76-
77-
## Quick Deploy
78-
79-
```bash
80-
# Production
81-
npm run deploy
82-
83-
# Preview
84-
npm run deploy:preview
85-
```
86-
87-
## package.json
88-
89-
```json
90-
{
91-
"scripts": {
92-
"deploy": "deploy vercel",
93-
"deploy:preview": "vercel",
94-
"deploy:cf": "deploy cf"
95-
}
96-
}
97-
```
6+
Deploy to production. **$ARGUMENTS**
7+
8+
Deployment is handled by the `deploy-auto` command, which auto-detects your deployment platform (Vercel, Cloudflare Workers, Railway, or Fly.io) and runs the full verification gate before deploying.
9+
10+
1. **Check that the project is deployable.** The deploy-auto helper lives at `~/.config/agents/bin/deploy-auto.sh` (installed by vstack's `install.sh`) or is inlined below if not present.
11+
12+
2. **Run deploy-auto.sh** with the project directory:
13+
```bash
14+
~/.config/agents/bin/deploy-auto.sh "$PWD"
15+
```
16+
The script will:
17+
a. Run `.claude/verify.sh` if it exists. If verification fails, stop and report the failure.
18+
b. Auto-detect the deployment target: if `vercel.json` or `.vercel/` exists, use Vercel; if `wrangler.toml` or `wrangler.jsonc` exists, use Cloudflare Workers.
19+
c. Run the deploy command for that platform.
20+
d. Health-check the resulting URL (HTTP status code).
21+
e. Report the deployment URL and health status.
22+
23+
3. **If deploy-auto.sh is not found,** run verification and auto-detect inline:
24+
```bash
25+
bash ./.claude/verify.sh && \
26+
if [ -f vercel.json ] || [ -d .vercel ]; then
27+
vercel deploy --prod
28+
elif [ -f wrangler.toml ] || [ -f wrangler.jsonc ]; then
29+
npx wrangler deploy
30+
else
31+
echo "No deployment config found (vercel.json, .vercel/, wrangler.toml, or wrangler.jsonc)"
32+
exit 1
33+
fi
34+
```
35+
36+
See `deploy-auto.md` for the full details.

0 commit comments

Comments
 (0)