June 28, 2026 · updated August 14, 2026
Teaching Claude to Code Like Me
After 20+ years of writing code, I've got a pretty good sense of what I like and what works. So when I started having Claude write code alongside me, I wanted to make sure it built things the way I would — not the generic average of everything on the internet, but my way.
The only way to get there was to write it all down: a rules file Claude reads automatically on every task. That turned out to be the valuable part. A lot of what I'd carried around as "this just feels cleaner" had to get concrete enough for a machine to actually follow, and hand-waving doesn't compile.
Updated August 2026: the rules below are current, and everything from "The rules weren't reaching the writers" down is new. That's the part I'd want to read if I were you: what happened when I finally checked whether any of this was being followed, and what I built once it turned out the answer was "not reliably."
It starts big-picture, in CLAUDE.md — here's the relevant parts:
# System Protocols
## Workflow & Planning
Complex features → design spec first. Small fixes/features → code directly.
Commit per logical unit without asking; terse subject + a "why" body when the change isn't obvious. Straight to main is fine in solo
repos.
## Testing
Test-first by default: failing test, then code — bugfixes included (regression test). Spikes exempt — throw away before commit.
Unit tests are the primary investment: they exert design pressure. Hard to unit test = badly factored — that friction is the signal.
Integration tests prove wiring, not logic: real queries, migrations, serialization round-trips — where mocks lie. Fewer, deliberate.
Small E2E suite, kept green religiously. Broad and flaky is worse than absent; it gets ignored.
Test the public surface, not internals — that's what survives refactoring.
Mock only at system boundaries (network, clock, filesystem, randomness); prefer real collaborators. Many mocks = the unit does too much.
Coverage is a diagnostic, never a target.
## Lint & Format Gates
Mechanical constraints live in tooling, not these files: if a regex can check it, prose is the wrong home — an unenforced rule drifts.
Every project gets a formatter and linter, pinned exact; global hooks run the lint + secrets guards in every repo, and project-gates.sh
offers missing setup at SessionStart.
Two opt-outs, never one: `claude.gates false` (lint) and `claude.secrets false` — silencing style must not silently stop credential
scanning.
The guard checks staged files only; it reports and stops, never fixes.
Prove a new gate fails before trusting it — linters lie about exit codes.
## Architectural Principles
Modular and DRY (modularity wins ties). Tolerate duplication until the shape is known — extract on the third occurrence, not the second.
Parameter + conditional in shared code = wrong abstraction: inline into callers, delete unneeded branches, re-extract.
Prefer functional paradigms: pure functions, immutability, composition over inheritance.
Parse, don't validate: decode untrusted input at the boundary into a type carrying the guarantee. No check-and-discard.
Illegal states unrepresentable where practical.
YAGNI: abstraction, layers, config only for a present need, never speculation. Match rigor to value; don't gold-plate. Exempt: what makes
change easy (refactoring, tests, CI) and what adds no complexity today. Before building ahead, imagine the later refactor — usually no
more expensive.
Chesterton's Fence: understand why something exists before removing it. Complexity you didn't write is not a mandate to refactor.
Precedence when principles conflict: correctness > YAGNI > type rigor — "where practical" bends before YAGNI does. Script-scale work:
YAGNI wins by default.
Default to boring tech: when a dep is warranted, prefer mature (docs, track record, community) over new/shiny — maturity, not age.
Pike's 5 Rules: measure, don't assume bottlenecks. Fancy algorithms are slow when n is small (it usually is) and buggy — simple data
structures.
Data dominates: well-organized data makes algorithms self-evident.
Then the nitty-gritty, in general.md:
# General code style
Cross-language principles. Language rules stack on top; CLAUDE.md holds architecture (functional paradigms, YAGNI, Pike, DRY).
## Mindset
* optimize for *readability over writability* — the reader is often you in six months; do the hard thinking at write-time so reading is
cheap
* prefer *simple* (cheap to reason about correctly) over *easy* (cheap to write) — "hard and simple" beats "easy and complex"
* minimize accidental complexity (what the code imposes); spend the budget on the problem itself
* code is read in small chunks — structure every unit to fit in working memory: small, labeled, composable pieces
* keep code continuously clean — fix small messes before they rot
## Functions
* one concept per function; do exactly what the name says, nothing more — if the name feels dumb to type, it shouldn't be a function
* build a vocabulary of small, composable functions
* aim short; split when over ~20 lines or high cognitive complexity — but never split a single concept just to hit a number
* entry points (`main`, scripts) may stay long once pure logic is factored out — locality wins there
* limit heterogeneous positional params (≤3, hard max 4); beyond that use named/keyword args — meaningful names over meaningless positions
* prefer explicit inputs and outputs over hidden state mutation — you understand a unit by what goes in and comes out
## Control flow & shape
* flatten — deep nesting is a smell; use guard clauses / early returns, and isolate unavoidably deep logic in its own function
* keep conditionals short; don't mix `&&` and `||` in one test — extract or split
* prefer declarative (*what*) over imperative (*how*); prefer `map`/`filter`/`reduce` (pure, produce output) over `forEach`/loops
(side-effecting)
* break long call/method chains into well-named intermediate variables or helpers
* favor familiar, consistent patterns (least surprise); don't reach for exotic syntax/sugar that taxes the reader
## State & effects
* treat data as immutable — code as if you can copy but not change it (mutate during construction, then freeze)
* no action-at-a-distance: no global mutable state; behavior should be understandable by reading locally
* isolate side effects (I/O) at the edges; keep core logic pure
* keep variable liveness short — declare near first use, minimize the span; avoid long-lived cross-function mutable vars (use an object or
refactor)
* pair acquire with release by *returning* the release — `open`/`attach`/`subscribe`/`lock` hands back its own undo, so there's no "am I
open" flag to keep in sync and each acquisition releases independently
## Naming
* name by purpose — never meaningless names (`value`, `data`, `temp`)
* functions are verbs; variables are nouns; pluralize collections; single-letter names only in tight iteration
* use visually distinct names (avoid `i`/`j`, `item`/`items` confusion); never shadow
* descriptive names don't excuse bad design — and over-long names are bloat too
* sort alphabetically wherever order is otherwise arbitrary
## Abstractions
* build composable, *trustworthy* abstractions — ones that obey the laws they imply (consistent equality, no surprising special cases)
* a leaky or misleading abstraction or type explodes cognitive load; a lawful one reduces it
## Errors
* errors should never pass silently, unless explicitly silenced
* throw to let the caller decide — don't swallow at the point of occurrence
* catch only the specific condition you can handle; re-throw the rest
* don't return empty/`undefined` for an essential missing value — throw
## Comments & docs
* comment the *why*, not the *what* — if a "what" feels needed, first make the code clearer (hard math/algorithms/perf excepted)
* comment the non-obvious: anything a reader would ask about, anything you had to re-derive, non-specific catch blocks, and especially a
hack you hadn't time to refactor
* don't omit comments out of staleness-fear — names go stale too; prefer assertions over comments documenting assumptions
* colocate docs with what they describe; read nearby commentary before editing
* cite sources (say what a URL is *for*); mark temporary/workaround code with removal criteria; flag non-obvious cross-file coupling on both
sides
* generate API docs from source, don't hand-maintain a parallel copy; tests are documentation
## Testing
* CLAUDE.md holds the strategy (test-first, regression tests, mock boundaries); these are code-level additions
* a bug is a signal of excess complexity — fix the root cause and structure, not just the symptom
* set up mocks/spies in the test that uses them (locality over DRY)
## Hygiene
* no dead or vestigial code (unused imports, params, variables)
* no stray debug output in committed code (`console.log`, `print`); error logging is fine
* LF line endings
## Tooling
* the formatter owns formatting — don't hand-format around it or fight its output; if it's wrong, fix the config, not the file
* a suppression (`noqa`, `swift-format-ignore`, `eslint-disable`) names the one rule it suppresses and states the invariant that earns it
("cleanup only", "source literal compiled into the binary") — narrowest scope that works; never bare, never file-wide where a line will do
* if the same rule needs suppressing everywhere, the rule is wrong for this project — turn it off in the config, once, with the reason
* stdout is a tool's product in a CLI entry point and debug noise everywhere else; suppress the print rule at the site, not repo-wide
Scoped by language
The two files above are the always-on layer — Claude reads them on every programming task, whatever I'm working on. But most
of what I care about is language-specific, and I don't want the Python rules sitting in the context while I'm editing a
stylesheet. So the language rules live in their own files under ~/.claude/rules/, each gated by a
paths: glob in its frontmatter. Claude Code only pulls a file in when I actually touch something that matches,
which keeps the context lean and the token bill down as a nice side effect, but mostly just keeps the guidance relevant to
the code in front of it. Here's the Python one:
---
paths:
- "**/*.py"
- "**/*.pyi"
---
* always use uv
* add .venv to .gitignore
* annotate every function signature — all params + return (incl. `-> None`); leave inferred locals unannotated
* avoid `Any` — prefer precise types, protocols, or generics; assume a strict checker (mypy/pyright)
* modern typing: built-in generics (`list[str]`, `dict[str, int]`) and `X | None`, not `List`/`Optional`
* prefer dataclasses / TypedDict over loose dicts for structured data
* `_`-prefix marks module-private; `__all__` names a module's public surface
They stack, too. TypeScript inherits the JavaScript and object-shape rules by sharing their globs, then layers its own on
top — so opening a .ts file loads the general principles, the JS rules, and these, and not a word about Python
or CSS:
---
paths:
- "**/*.ts"
- "**/*.mts"
- "**/*.cts"
---
* inherits js.md/objects.md (shared globs); TS-only additions below
* never `any` — default to `unknown`, then narrow
* strongly type everything; assume strict; precise types over loose
* lean on inference for locals; annotate boundaries (exported signatures, public APIs)
* never cast (`as`, `<T>`, `!`) to paper over a type — narrow, guard, or fix the source type instead
* never `@ts-ignore`; suppress only when the type is genuinely wrong/invalid, and then use `@ts-expect-error` with a comment explaining why
* keep types modular and colocated: function-arg types above the function, module-internal types at module level, app-wide types at top
level
* prefer optional params (`x?: T`) over `x: T | undefined` — same effect, less noise
None of this is law. It's a set of defaults I trust enough to follow without re-litigating every time — which is the whole point. And to be clear, this is a post about teaching Claude, not about my semantics: I know full well that half of these are religious wars with no winner. I won't argue any of them with you — unless you're picking up the bar tab.
The rules weren't reaching the writers
For a while I thought all this was working. Then I went poking through some repos Claude and I had built together — a TypeScript library, a couple of Firefox extensions, some audio DSP experiments — and found semicolons everywhere. My JS rules forbid them, plainly, at the top of the file. Whole stretches of code read like the rules didn't exist, because for the agents that wrote them, they didn't.
The path-scoping above has a catch I hadn't thought through: rules load for the session that touches a matching file. Claude
Code can spawn subagents to parallelize implementation work, and a subagent handed "implement task 4" starts with a fresh
context — it writes TypeScript having never seen the TypeScript rules. Worse, the planning session that produced task 4
never opened a .ts file either, since a plan is a markdown doc with TypeScript inside it. So the plan
specified semicolons and the subagent transcribed them faithfully. I measured it across two repos: 57 subagent JS/TS file
writes, zero rule injections. Not "sometimes missed." Zero.
The fix is a hook. Claude Code runs registered commands on harness events, and a PreToolUse hook fires before every tool call with the tool's payload on stdin — no model judgment involved, which is exactly the point. A skill or a "remember the rules!" reminder would be another probabilistic gate on the same perception that already failed. Delivery problems want mechanism, not persuasion.
{
"hooks": {
"PreToolUse": [
{
"matcher": "Write|Edit|NotebookEdit|Bash",
"hooks": [
{
"type": "command",
"command": "~/.claude/python/bin/python3 ~/.claude/hooks/rules_inject.py 2>/dev/null || true",
"statusMessage": "Checking code rules..."
}
]
}
]
}
}
The hook matches the file about to be written against the rules globs and injects anything this agent hasn't been told yet.
The one harness fact worth writing down: a subagent shares its parent's session_id,
transcript_path, and prompt_id. Only agent_id tells them apart; key the
dedupe on anything else and a parent that's already been told the rules silently starves every child of them.
def state_key(payload: Mapping[str, object]) -> str:
"""Identify the agent about to write, not the session it belongs to.
A subagent's payload carries agent_id and agent_type; the main loop's does
not. Everything else — session_id, transcript_path, prompt_id — is shared
between them, so any of those as a key merges a parent and its children into
one seen-list and silently starves the children.
"""
for field in ("agent_id", "transcript_path", "session_id"):
value = payload.get(field)
if isinstance(value, str) and value:
return value
return "unknown"
That closed the gap for 47 minutes. The next look found a second bypass: nothing says a file gets written with the Write
tool. cat > app.ts <<'EOF' in a shell command is a TypeScript write too, and it sailed straight past a
Write|Edit matcher — hence the Bash in the matcher above. The hook now reads shell commands for
write idioms (redirections, tee, sed -i), stripping heredoc bodies first, because a heredoc is
data and its stray apostrophes kill a tokenizer. The tests say it better than prose can:
def test_a_redirection_target_is_a_written_path(self) -> None:
self.assertEqual(bash_written_paths("cat > src/app.ts <<'EOF'\nconst x = 1\nEOF"), ["src/app.ts"])
def test_tee_targets_are_written(self) -> None:
self.assertEqual(bash_written_paths("printf 'x = 1' | tee a.py b.py"), ["a.py", "b.py"])
def test_sed_in_place_names_its_file(self) -> None:
self.assertEqual(bash_written_paths("sed -i 's/a/b/' src/app.py"), ["src/app.py"])
def test_a_heredoc_body_is_not_scanned_for_writes(self) -> None:
command = "cat > real.ts <<'EOF'\nconst s = \"don't pipe > fake.py\"\nEOF"
self.assertEqual(bash_written_paths(command), ["real.ts"])
If a regex can check it, prose is the wrong home
Here's the part that actually changed my thinking. Of all the repos I audited, exactly one had stayed clean, and it wasn't the one with the best prompts — it was the one with a formatter and linter config committed in the repo. A rule that lives in prose is a suggestion to a model. A rule that lives in a linter is a fact about whether the commit lands.
So the mechanical rules moved out of the rules files and into tooling: oxfmt and oxlint for
web code, ruff for Python,
swift-format for Swift, each pinned exact so a version bump can't
reformat files I didn't touch, plus vnu for HTML validity and
stylelint for CSS, which each earn their own section below. A pre-commit guard runs the
checks on staged files only, and it reports and stops — it never rewrites anything, because a hook that reformats under you
changes what gets committed after you reviewed it. For repos that had already drifted there's a ratchet script that records
existing violations as per-file ignores, so every other file is enforced from the first commit. Clean a file and
re-run it; the entry shrinks. Blocking commits on old debt teaches you exactly one thing, and that thing is
--no-verify.
Then the gotcha that earned its own line in my CLAUDE.md: prove a new gate fails before trusting it.
swift format lint prints every violation as a warning and exits 0. Zero, on a file it just rejected. Only
--strict makes it exit nonzero, so a guard wired the obvious way reports success forever and nobody ever finds
out:
# --strict is load-bearing, not decoration. Without it `lint` prints every violation
# as a *warning* and still exits 0, so a guard wired the obvious way reports success
# on a file it just rejected. Verified both ways before this shipped.
if [ ${#swift_files[@]} -gt 0 ] && [ -f .swift-format ]; then
if resolve_swift_format; then
run_check "swift format lint --strict" "${SWIFT_FMT[@]}" lint --strict "${swift_files[@]}"
else
missing "swift-format" "install Xcode 16+ or 'brew install swift-format'"
fi
fi
Prose drifts from configs even when you wrote both. My ruff config wasn't selecting the no-stray-print rule my own general.md demands, and it was enforcing one that forbade the well-named intermediate variables the rules explicitly ask for. When a rule lives in two places, one of them is already wrong. The gates now have a test suite — 134 assertions, several of them against the tools themselves rather than my wrappers — so the next lying exit code fails a test instead of silently disarming every guard on the machine.
HTML needed one more layer, because a formatter is not a validator: oxfmt will make invalid markup perfectly consistent. So
the guard now runs vnu, the W3C's own checker, on staged HTML — opt-in
per repo, switched on by committing a .vnu-filter. Pointing it at this site found two real errors repeated
across every page. One was a meta tag missing its required content attribute; fine, easy. The other is the
reason to bother: the syntax highlighter restarts line numbering in every code block, so the pages you're reading carried
id="line-1" about 130 times. Generated markup is where a validator earns its keep. No amount of care in a
template shows you what it emits at scale, and no formatter cares.
The filter file is the part I'd steal. It's a list of regexes naming messages vnu should drop, and a line of plain prose matches nothing — so the file documents its own suppressions, right where the next failing commit will point. Here's the second of this site's two entries:
One regex per line; vnu drops any message it matches. Lines that are not
regexes match nothing, so this prose is inert and lives here rather than in a
README nobody opens next to a failing commit.
2. Content-Security-Policy resource warnings. An artifact of checking a file
instead of a URL: with no origin to resolve against, 'self' in the meta CSP
can never match /styles.css, so vnu reports every same-origin asset as
blocked. Verified against the live URL through the same Nu engine — zero CSP
messages there. Drop this line and the gate fails on every page it ships.
.*Resource violates Content Security Policy.*
One deliberate asymmetry: the other gates key on file extensions, but this one only offers itself when a tracked
.html file opens with a doctype. Most HTML in a repo is a fragment (a template partial, a mail body, a test
fixture), and vnu is right to reject those as not-a-document, so the extension alone would make the offer wrong more often
than right.
Gating repos that don't exist yet
One hole left: all of that only worked in repos where I'd remembered to run the installers. New repo, fresh clone, repo I
forgot about — no gate. Git's fix for this feels almost too easy:
core.hooksPath points every repo on the machine at one hooks directory. Mine
now points at a dispatcher: nineteen hook names symlinked to one script that chains to whatever the repo already had (LFS
hooks, husky setups, my own guard fragments) and runs the lint guard on top. The guard checks nothing in a repo with no
formatter config, which is what makes gating literally every repo safe, and a one-line
git config claude.gates false opts a repo out.
One trap in there, discovered the honest way: git rev-parse --git-path hooks/... honors
core.hooksPath. My first test run had the old installer resolving paths with it, and it cheerfully wrote the
guard into the global dispatcher directory — a directory that didn't exist yet — instead of into the repo under
test. Every hooks-path lookup now goes through --git-common-dir, and the test suite asserts the hijack against
git itself, so if git ever changes this behavior I hear about it from a failing test and not from a repo quietly losing its
hooks:
name="$(basename "$0")"
# Not --git-path: it honors core.hooksPath, which now points back at this very
# directory. The common dir is immune, and correct from a linked worktree too.
common="$(git rev-parse --path-format=absolute --git-common-dir 2>/dev/null)" || exit 0
repo_hook="$common/hooks/$name"
if [ "$name" != "pre-commit" ]; then
# exec so stdin and arguments flow through untouched — pre-push reads stdin,
# commit-msg takes the message file as $1, post-checkout is where LFS lives.
[ -x "$repo_hook" ] && exec "$repo_hook" "$@"
exit 0
fi
The second gotcha didn't show up in a test run. It showed up in production, quietly:
core.hooksPath governs more than your worktrees. A push to a file:// remote runs receive-pack on
this same machine, so the bare repo I push this machine's backups to started resolving its hooks through the dispatcher too,
and its post-receive hook (a sweep for the ._ sidecar files macOS scatters on SMB shares) simply
stopped running. Present, correct, never consulted. Nothing failed. A hook name with no symlink in the dispatcher isn't
"unhandled" — it's disabled everywhere, for every repo on the machine, bare ones included. That's why the count above is
nineteen: the five receive-side names are in the set now. The fix came with one subtlety worth knowing: git streams ref
updates into these hooks on stdin, and a dispatcher that exits without draining leaves git writing into a closed pipe, which
sprays noise over an otherwise clean push. So the dispatcher drains stdin when there's nothing to chain to, and the tests
cover the whole family, down to asserting that a bare repo with no hooks at all still pushes cleanly.
The fragment pattern is worth stealing even without the global part. Guards live as numbered executables in
.git/hooks/pre-commit.d/ — a secrets scanner at 10, a docs-freshness check at 20, lint at 30 — with a tiny
dispatcher running them in order, so installing one guard never clobbers another and a credential leak blocks before a
missing doc does. I found that one out the hard way too: two guard installers, one hook file, last writer wins.
A formatter is not a validator
Out of curiosity I pasted this very page into the
W3C validator and got back a wall of red. Something like 130 duplicate
id attributes, plus a <meta> missing an attribute the spec requires, repeated across
eighteen files. oxfmt had landed that same morning and had already made every page consistent. It was immaculately,
consistently invalid.
My first thought was that oxfmt was doing something wrong, and it wasn't. The trailing slash it writes on
<meta ... /> is valid HTML5, and the validator only mentions it at info level. The duplicate ids were
mine: Pygments restarts its line numbering at 1 for every code block, so a page with eight snippets emits
id="line-1" eight times. A formatter has no opinion about that. It makes markup consistent, and consistent is
not the same as legal.
So HTML got its own gate, running vnu, the actual engine behind
validator.w3.org rather than a re-implementation, so the thing blocking my commit and the thing judging my site can never
disagree about what "valid" means. The interesting part was picking the trigger. My first instinct was "this repo has
.html files", which is wrong in a way that would have made the gate useless: most HTML on disk is a fragment. A
template partial, an email body, a test fixture. vnu is right to reject those, so a gate keyed on the extension
would have blocked honest work in half my repos. It keys on a doctype instead:
# Only whole documents. A repo that validates its pages almost always holds
# fragments too, and vnu is right to reject a template partial as not-a-document.
html_documents=()
for f in ${html_files[@]+"${html_files[@]}"}; do
IFS= read -r first < "$f" || continue
case "$first" in
'<!'[Dd][Oo][Cc][Tt][Yy][Pp][Ee]' '[Hh][Tt][Mm][Ll]*) html_documents+=("$f") ;;
esac
done
I found that one by rolling it out, not by thinking about it. My DSP box keeps three real manual pages next to fifteen
content fragments, and gating it the obvious way would have blocked the next edit to any of the fifteen. Sixteen repos have
the gate now. Nine were already clean, the rest were not, and none of what turned up was cosmetic. A Firefox sidebar I use
every day had no <title> at all and no lang on its <html> element, which
is exactly the sort of thing a screen reader announces and I would never have noticed on my own.
The same argument, one language over
With HTML gated I figured CSS was covered, because my editor already yells about stylesheet typos. Then I looked at what it
actually yells about: property names. Misspell color and the language server flags it; misspell the
value and nothing happens. color: notacolor parses fine, oxfmt formats display: flexx as
happily as it formats valid CSS, and the browser drops the whole declaration at runtime without a word. Same hole as the
HTML one, one language over, and quieter about it.
The obvious move was vnu again. The binary was already on the machine for the HTML gate and it has a
--css flag, so this looked free. It's a trap: vnu's CSS validator is the old Jigsaw engine, and it rejects
container queries, native nesting, :has(), cqi units, and @starting-style as invalid.
Seven wrong errors on one modern stylesheet, every one of them a feature this site actually uses. So CSS got
stylelint instead, stylelint-config-standard pinned exact like everything
else, and the one rule this whole gate exists for: declaration-property-value-no-unknown. A repo opts in by
committing a .stylelintrc.json, the same marker pattern as .vnu-filter.
Prove a new gate fails before trusting it, says my own CLAUDE.md, so before anything real got staged I committed a proof file carrying both bad declarations from the first paragraph. The commit bounced exactly the way it should:
--- stylelint ---
proof-gate.css
2:12 ✖ Unknown value "notacolor" for property "color" declaration-property-value-no-unknown
3:14 ✖ Unknown value "flexx" for property "display" declaration-property-value-no-unknown
✖ 2 problems (2 errors, 0 warnings)
lint guard: staged files fail this project's formatter or linter — see above.
Fix them, then re-stage. The guard never rewrites files itself.
Then the repo-wide check, which is where it got personal. Three decimal alpha values the standard config wants written as
percentages, three unquoted font-family names, and 28 complaints about pygments.css — which is generated, so it
went in ignoreFiles the same way .oxfmtrc.json already excludes it. Pygments' output is Pygments'
problem.
One rule didn't survive the rollout. no-descending-specificity flagged thirteen places in this site's main
stylesheet, and every one of them was this pattern:
& main {
& > h1:first-child {
margin-top: 0;
}
}
h1 {
font-size: clamp(2rem, 10.5vw, 3.157rem);
}
The nested rule zeroes a margin; the flat one sets a font size. Different properties, no cascade conflict, nothing to fix. The rule is a source-order heuristic, and a stylesheet organized the way mine are (nested, selectors following the page) trips it on every one of these. A rule that only ever flags correct code isn't enforcement, it's guidance — so it's off, in the global config, with the reason written down next to the seven rules already off for failing the same test.
The kicker: the guard blocked the very commit that installed all this. oxfmt objected to the indentation of the stylelint config I'd just written and to the quote style of my font-name fix, so the commit landed on the second try, cleaner than I wrote it. The gates gate the gates. I'd worry if they didn't.
The guard I'd installed least was the one I could least afford to miss
Then I counted. Thirty-three repos on this machine have my commits from after the first guard existed: the lint guard in
eighteen, plus every repo through
core.hooksPath. The docs guard in ten. The secrets guard, the one that scans staged changes for credentials, in
twelve.
Twelve. The only guard I'd given reliable delivery was the one whose failure costs least. A style violation is fixed by the next commit; a credential that reaches a remote is public whatever you do next, and no later commit takes it back. I'd written "every gate asks, none auto-installs" into the design two weeks earlier and it seemed principled at the time, but that rule exists to protect you from noise, and that reason doesn't transfer to this guard at all.
So it moved into the dispatcher, ahead of the lint guard. One wrinkle worth stealing: it got its own off switch.
claude.gates false already silenced the lint guard and it would have been easy to reuse. Don't. Someone
silencing a chatty formatter in a noisy repo should never discover, months later, that they also turned off credential
scanning. Different costs, different switches.
Wiring it up meant pulling the guard out of the installer that had been carrying it as a heredoc, and that's where it got
interesting. The guard matches on my name, my handle, and my email, because the repos with an agent-written issue store are
full of text nobody hand-reviews before it lands in history. The design doc had a line about this: the guard must never be
committed, precisely because it names the identifiers it protects. That's why it lived untracked in
.git/hooks. And the file I'd just extracted was sitting in my skills repo, staged, about to be committed.
Nothing was pushed and that repo is private, so this is a near miss and not a story about a leak. The fix is the part I keep chewing on. That constraint was never really about the guard, it was about the patterns, and conflating the two is what had forced the guard to live untracked in the first place. Which is why it could never be shared with anyone, and why it had no delivery mechanism, and therefore why it was installed in twelve repos out of thirty-three. One muddled sentence in a design doc, three consequences downstream. The identifiers now load from an untracked file the guard reads at runtime, and the guard itself is generic and safe to commit anywhere. A test greps it for my name, so a future edit that inlines one fails as a leak rather than passing as a style nit.
That refactor introduced a fresh way to fail open, too, and it's a good one:
# The emptiness test is load-bearing: `grep -qE ""` matches every line, so an
# unset ALLOW_RE would clear every finding and pass the guard on everything.
if [ -n "$ALLOW_RE" ] && printf '%s' "$content" | grep -qiE "$ALLOW_RE"; then
continue
fi
The allowlist clears lines that legitimately carry my handle, like the GitHub URLs in a README. It used to be a constant.
Now it comes from a file that might not exist, and an empty pattern handed to grep -qE matches every line on
earth. Without that -n test the guard would have skipped every finding in every repo and exited zero forever,
which is the most cheerful way a security check can possibly die.
My favorite part: the guard blocked its own commit three times. Once on my test fixtures, which contain real AWS key shapes
because a test proving the guard blocks has to contain something it blocks. Then twice on its own documentation, because a
comment explaining which patterns look like credentials is unavoidably a comment full of things that look like credentials.
Shell scripts are deliberately not exempt from that check, since an exported key in a .sh is how credentials
really escape. So I reworded the comments and left the rule alone. The guard was right and I was the one being sloppy, which
is more or less the entire reason to build it.
The theme under all of it: a rule that depends on being seen fails silently, and so does a guard that depends on being
installed. Next is probably a CI layer, since local hooks don't survive --no-verify or somebody else's clone,
and a scheduled audit that re-checks recent commits against the rules — enforcement without detection of failed
enforcement is the same trap one level up. This is living documentation, so it'll keep growing as Claude and I figure each
other out.
Influences
Some things that've influenced my thinking over the years: