Tired of writing the same warnings every time
I've been using Claude Code on FitLog, a personal workout-tracking app built with Expo / Supabase / Zustand / NativeWind / React Native Reusables. After a while I noticed the same mistakes kept happening:
- It would casually read
.env - It tried to edit
ios/andandroid/directly, even though Expo manages them - It modified the original
components/ui/files generated by Reusables
Each time, I added another "please don't do X" to my prompts. The warnings kept piling up, but whether they were followed was basically luck. The longer the instructions got, the more the important parts got buried.
Around that time, OpenAI published a piece on "harness engineering" that was making the rounds, so I took the opportunity to set up a proper harness for the project from scratch.
To be honest: the original page returned a 403 and I couldn't read it. This article is not a commentary on the original — it's a record of applying three principles I picked up from summaries:
- Give the agent a map, not a manual
- Enforce invariants; don't dictate implementation
- Disclose details progressively, when needed
The cause: every rule was a "please"
Looking back, the problem was how the rules were written. "Things that must never happen" and "things I'd prefer" sat side by side in my prompts and CLAUDE.md, written in exactly the same tone.
Natural-language instructions to an LLM are all, fundamentally, requests. As the context grows, some get skipped. But an accident like reading .env is game over even if it happens once. I had been placing rules that needed deterministic enforcement in a place where compliance was probabilistic.
Following "enforce invariants, don't dictate implementation," I sorted the rules into two buckets:
| Category | Examples | Where it lives |
|---|---|---|
| Invariants (breaking them = incident) | Accessing .env*, hand-editing ios//android/, modifying components/ui/ originals |
Mechanically blocked by hooks |
| Recommendations (breaking them = fixable) | Naming conventions, directory layout, commit granularity | Listed as "recommended" in CLAUDE.md |
The fix: a 48-line map + 4 hooks + 5 skills
Keep CLAUDE.md down to a 48-line "map"
CLAUDE.md now contains only the stack, common commands, the list of invariants, and a "where to look" table — everything else is a link into docs/. It ended up at 48 lines.
## Where to Look
| What you need | Where |
|---|---|
| Screens & navigation | docs/architecture.md |
| DB schema & RLS | docs/db-schema.md |
| How to use UI components | docs/reusables-guide.md |
| Naming & coding conventions | docs/conventions.md |
| Hooks & skills, and why they exist | docs/harness.md |
docs/ has six files: architecture / db-schema / reusables-guide / conventions / harness / design-docs. Instead of making the agent read everything every time, the structure lets it navigate there when needed — my attempt at progressive disclosure.
Enforce invariants with PreToolUse hooks and exit 2
I wrote four hooks: protect-env, protect-native, format (silently skips if Prettier isn't installed), and a Stop hook that summarizes git diff --stat as a system message.
The core one, protect-env, looks like this (excerpt):
#!/bin/bash
# .claude/hooks/protect-env.sh
input=$(cat)
cmd=$(echo "$input" | jq -r '.tool_input.command // .tool_input.file_path // ""')
# .env.example is allowed
if echo "$cmd" | grep -qE '\.env\.example'; then exit 0; fi
if echo "$cmd" | grep -qE '(^|[ /"'"'"'])\.env(\.|[ "'"'"']|$)'; then
echo "BLOCKED: access to .env* is forbidden." >&2
echo "For variable names, see .env.example instead." >&2
exit 2
fi
exit 0
The key point: don't just stop with exit 2 — use stderr to tell the agent what to do instead. protect-native suggests "use an Expo Config Plugin instead of editing ios/ directly"; the Reusables guard says "wrap the component instead of changing the original." A blocked Claude picks its next action, so showing it a way forward gets it back on track faster.
Patterns that leak through staging rather than reading — like git add .env — are caught by the same hook.
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash|Read|Edit|Write",
"hooks": [
{ "type": "command", "command": ".claude/hooks/protect-env.sh" },
{ "type": "command", "command": ".claude/hooks/protect-native.sh" }
]
}
]
}
}
A hook you haven't tested is a hook you can't trust, so I prepared ten sample JSON inputs (six that should block, four that should pass) and verified the exit codes directly in the shell:
$ ./test-hooks.sh
[BLOCK] cat .env -> exit 2 OK
[BLOCK] git add .env -> exit 2 OK
[ALLOW] cat .env.example -> exit 0 OK
[ALLOW] ls src/components -> exit 0 OK
...
10/10 passed
Vary how skills are invoked by blast radius
Five skills: /commit, /review, /deploy, /cleanup, /new-screen. /commit encodes the commit-message conventions (Korean, no trailing period, type: description format, git add . forbidden). /deploy encodes "EAS preview build only after tests and tsc pass."
Anything with a large blast radius, like deploys, gets disable-model-invocation: true so the model can't call it autonomously — it's user-invocation only.
What went wrong, and what's still unverified
- Couldn't read the original. The 403 meant I designed from summaries. Not pretending to know what you haven't read applies to blog posts and to AI work alike.
- Claude guess-interpreted a broken README. During setup, it filled in a README full of typos and truncated sentences with "it probably means this" and kept going. If the map itself is broken, a harness won't prevent misreading.
- Hooks untested in a real session. The standalone tests passed 10/10, but I haven't yet confirmed they fire as intended mid-session.
Takeaways
- Keep CLAUDE.md a short map; push details into
docs/and let the agent follow links - Split rules into "enforced" vs. "delegated." Only incident-grade rules get a hook with exit 2; everything else stays a recommendation
- When you block, use stderr to point at the alternative — recovery is faster
- Test hooks against sample inputs and check exit codes before relying on them
- Don't start with hooks for everything; promote a rule to a hook only after the same mistake repeats
Rather than stacking up more "pleases," guard the few places where a breach would actually hurt — mechanically. That trade-off alone made my prompts shorter and a lot quieter.
Top comments (3)
A rule in the prompt is paid again at each turn, because the whole prompt goes out with every request. A hook runs outside the context, so the invariant costs nothing per turn. That is the cheap half of the argument.
The two-bucket split is the cleanest framing of this I've seen — hooks for what must never happen, and something else entirely for the stuff that needs judgment.
I've been building in that second bucket (small open-source plugin in this space, so grain of salt): hooks enforce invariants beautifully, but they can't carry the why — which approach was already tried and rejected, who asked for what, what was decided in an earlier session. What I've found useful is keeping those decision records attached to the project and surfacing the relevant ones before each edit, so the agent reads the history instead of re-learning it. Hooks stop the agent from doing the wrong thing; a decision ledger stops it from re-deciding what's already been decided.
Returning the alternative command in stderr on exit 2 saves a lot of wasted tokens. When a hook just exits 2 without a pointer, models usually assume the command had a syntax error and start trying bypass variants like base64 or subshells to read the file. Telling it to check .env.example instead gives it a valid substitute on the very next turn.