The alert wasn’t an alert. It was a diff review, and the diff was .gitignore: seven lines removed, seven added, swapping four blanket-ignored .claude/* entries for two narrow un-ignores. Small enough to skim. Small enough to almost ship without checking what else lived in that directory.

The fix for this one was supposed to be a one-hook job. On July 21, a serial leading-wildcard scan (LIKE '%...') crawled six cache tables against the shared live prod DB and wedged it for about five minutes, throwing errors on every customer /teams page. The fix was a PreToolUse hook, .claude/hooks/block-prod-db-scan.sh, dependency-free, no ops-tools/node/python required. It inspects any Bash/Write/Edit/MultiEdit payload for a prod-DB connection marker (company_DB_*/company_CACHE_*, DSN=leagueapp..., pyodbc, sqlcmd, Invoke-Sqlcmd) combined with a leading-wildcard LIKE, and blocks it. Straightforward. The problem wasn’t the hook. It was where it had to live to actually apply to everyone.

Why one file couldn’t stay personal

The hook only fires if it’s wired into .claude/settings.json. That file had been fully gitignored repo-wide since an earlier fix, which meant every developer’s settings were local and private, drifting independently. Ship the new guardrail the “safe” way, just paste it into my own settings.local.json, and it would protect exactly one clone: mine. Everyone else’s next git pull would come down clean, no hook, no protection, and no signal that anything was missing.

So the real fix wasn’t the hook script. It was un-ignoring settings.json so the wiring itself could be shared and version-controlled. That’s the .gitignore diff:

-.claude/settings.local.json
-.claude/settings.json
-.claude/mcp.json
-.claude/*.lock
...
.claude/*
!.claude/skills/
+!.claude/hooks/
+!.claude/settings.json

That’s when I actually looked at what settings.json already wired in, instead of assuming it was blank because it had never been tracked before.

The wrong turn

My first move was to just add the new hook entry to settings.json and push it. Fast, minimal diff, done. I ran git ls-tree -r --name-only origin/develop -- .claude to confirm what was already tracked on develop before I committed, mostly as a formality.

It wasn’t a formality. Two other hooks were already sitting in .claude/hooks/ on develop: check-branch.sh, a guard that blocks Edit/Write directly on master/develop, and check-learnings.sh. Neither was referenced anywhere in a tracked settings.json, because there wasn’t one. Each developer’s settings.local.json was the only thing wiring those hooks in, and it had drifted per-clone since the earlier fix gitignored the shared file. I checked .claude across all ten clones:

clone-1 hooks/ skills/ settings.local.json 3.6K
clone-2 hooks/ skills/ settings.local.json 1.0K
clone-3 hooks/ skills/ settings.local.json 549B
clone-4 hooks/ skills/ settings.json 261B settings.local.json 3.6K

Sizes ranging from 549 bytes to 3.6K on files with the same nominal purpose. If I’d committed a settings.json that only wired the new prod-DB hook, that file would become the tracked source of truth the moment anyone pulled it, and check-branch.sh would quietly stop firing for them, on develop and master both, the exact branches it exists to protect. I’d have traded one incident risk for a second, worse one, and shipped it as a fix.

Shipping it atomically

The real change had three hooks going in together, not one: branch guard, pre-commit installer, and the new DB scan guardrail, all wired through the newly-tracked settings.json. Before pushing, I rehearsed the migration in a spare clone to see what a real developer’s next pull would actually do, and confirmed it throws a merge conflict on their local settings.json/settings.local.json, not a silent overwrite. That’s a feature: it forces the 30-second cleanup instead of skipping it.

Then we wrote the message that actually goes out, instead of a changelog entry nobody reads:

FYI, I made some updates to help our development rules stay in sync across developers. This will cause your next pull to throw an error. Run the following prompt to get your local setup back in sync.

Paste-ready prompt attached, filed as its own follow-up for the two remaining local-config cleanups. The guardrail now self-propagates: anyone who cloned since the earlier gitignore change gets the branch guard back, and anyone who clones fresh gets all three hooks by default, no manual step to forget.

The lesson wasn’t about the wildcard scan or the hook logic. It was that .claude/settings.json isn’t a config file, it’s a dependency graph, and every hook anyone’s ever wired into it is a transitive dependent of whatever you touch next. Checking git diff on the file you’re changing tells you nothing about the files that already live next to it.