You already live in the terminal with Claude Code. This is the gap between it works and it works like a senior engineer next to you: ablation, real permissions, verification hooks, and orchestration you can paste.
You already know the loop. You’re in the terminal, claude is running, you’ve got tests and git muscle memory. The tool works. But there’s a gap between “it works” and “it works like a senior engineer sitting next to you,” and that gap isn’t about typing better prompts. It’s about how you build the harness around the model.
This is the intermediate guide. The spine of it comes from a Boris Cherny interview — he built Claude Code — plus a pile of my own scar tissue. Where he makes forward-looking claims about what Opus 5 will do, I’ll flag those as per the interview, not gospel. Everything else here is real, current, and pasteable.
Let’s start with the thing nobody wants to hear: your CLAUDE.md is probably making the model worse.

Prompt debt and the ablation workflow
Every rule you add to CLAUDE.md feels like insurance. “Always run the linter.” “Never use any.” “Prefer functional components.” Each line is a small, reasonable request. The problem is that they compound into a fog. The model spends attention parsing your 400-line rulebook instead of reading the actual code in front of it. You’ve traded reasoning for compliance.
I call it prompt debt. Same shape as tech debt — cheap to add, expensive to service, invisible until it’s slowing everything down. The model gets measurably dumber as the context fills with instructions it has to hold in its head on every single turn.
Here’s a bloated CLAUDE.md I’ve actually seen (trimmed, but you get the flavor):
# Project Rules
- Always use TypeScript strict mode.
- Never use `any`. Use `unknown` and narrow.
- Prefer `const` over `let`. Never use `var`.
- Use functional React components, never class components.
- Always destructure props.
- Use named exports, not default exports.
- Run `npm run lint` before every commit.
- Run `npm run test` before every commit.
- Use 2-space indentation.
- Prefer early returns over nested ifs.
- Always write JSDoc comments for public functions.
- Use `async/await`, never raw `.then()` chains.
- Prefer `map`/`filter`/`reduce` over `for` loops.
- When adding a component, add a Storybook story.
- Use Tailwind, no inline styles, no CSS modules.
- Import order: react, external, internal, relative.
- ... (37 more lines)
Most of that is stuff a linter enforces, a formatter enforces, or the model already does by default because it read your existing code. You’re spending context budget to tell Claude things your eslintrc already knows. Worse, half of it is aspirational — rules the codebase itself violates — so now the model is confused about which reality to trust.
The fix is ablation. Same discipline as removing a variable from an experiment to see if it mattered.
- Delete the
CLAUDE.md(or move it aside —mv CLAUDE.md CLAUDE.md.bak). - Run raw. Give Claude a normal task with zero project instructions.
- Watch it stumble. Note the specific, repeatable failures. Not “it didn’t feel right” — actual wrong moves. It used the wrong test command. It put the file in the wrong directory. It reached for a package you’ve banned.
- Add back only those. One line per failure that actually recurred.
What survives ablation is usually short:
# Project
Monorepo. Packages in `packages/*`, app in `apps/web`.
- Test: `bun test` (NOT npm — this repo uses Bun).
- Typecheck a package: `bun run --filter <pkg> typecheck`.
- DB client is generated — run `bun run db:generate` after schema edits or types break.
- Auth lives in `packages/auth`. Don't reimplement session logic elsewhere.
Four facts the model can’t discover on its own and would waste a turn learning the hard way. Everything else got cut because the linter, the formatter, or Claude’s defaults already covered it. This file earns its place in context. The 45-line one didn’t.
There’s an experimental probe worth knowing about: CLAUDE_CODE_SIMPLE=1. It’s undocumented and experimental — I’m mentioning it so you can spot the effect, not because it’s a stable API. Run Claude with a stripped-down system prompt and watch how it behaves with less scaffolding. It’s a good gut-check on how much of your setup is load-bearing versus how much is you not trusting the model. Treat it as a science experiment, not a config setting. It may change or vanish.
The mental model: CLAUDE.md is not documentation. It’s a patch file for the model’s mistakes. If a line isn’t fixing an observed, repeatable mistake, it’s debt.
Unhobbling: exit criteria, real tools, real permissions
The single worst prompt pattern is the step-by-step recipe. “First open the file, then find the function, then change line 40, then run the test, then…” You’ve turned a reasoning engine into a very expensive for loop, and the moment reality diverges from your script — the function moved, the test needs a flag — it either derails or asks you what to do.
Flip it. Give the exit criteria, not the steps.
Bad:
Open src/api/users.ts, find getUser, add a caching layer using the
existing Redis client, then update the tests in users.test.ts, then
run npm test to check.
Good:
getUser hits the DB on every call. Add caching so repeat calls within
60s don't touch the DB. Done when: a new test proves the second call
inside the window doesn't query, and the full suite passes.
The second version lets the model find the Redis client, decide where the cache lives, and — critically — know when it’s finished without you refereeing every move. “Done when” is the most valuable phrase in your vocabulary. It converts a babysitting session into a delegation.
But exit criteria only work if the model can actually reach the exit. That means real tools and a permission scope wide enough to move without a prompt every three seconds. The default “ask me before every command” mode is training wheels. For work you trust, define your permissions explicitly in .claude/settings.json:
{
"permissions": {
"allow": [
"Bash(bun test:*)",
"Bash(bun run typecheck:*)",
"Bash(bun run lint:*)",
"Bash(git add:*)",
"Bash(git commit:*)",
"Bash(git diff:*)",
"Bash(git status)",
"Read(./**)",
"Edit(./src/**)"
],
"deny": [
"Bash(git push:*)",
"Bash(rm -rf:*)",
"Bash(curl:*)",
"Read(./.env)",
"Read(./**/*.pem)"
]
}
}
Now Claude can run the test loop, stage, and commit without interrupting you — but it can’t push, can’t nuke a directory, can’t read your secrets, and can’t phone home with curl. The deny list is your seatbelt; the allow list is what makes autonomy actually feel autonomous. Scope it per-project. A throwaway prototype gets a loose leash; the repo that deploys to prod gets a tight one.
The other half of unhobbling is context plumbing. If you’re pasting logs, API responses, or DB schemas into the chat, you’re doing the model’s data-gathering by hand — and paying for it in context every turn. Wire the source in directly with MCP:
# Give Claude live access to your Postgres, conceptually:
claude mcp add postgres -- npx -y @some/postgres-mcp-server --url "$DATABASE_URL"
# Or a docs/knowledge source, an issue tracker, a browser, etc.
claude mcp add my-docs -- node ./scripts/docs-mcp-server.js
Now instead of you copy-pasting a schema dump, Claude queries the schema when it needs it and forgets it when it doesn’t. The context stays clean and the data stays fresh. Pasting is a snapshot that rots the moment you hit enter; MCP is a live wire. Once your tools are the interface, the model stops asking you to be its hands.
Verification-led prompting: the biggest lever
If you take one thing from this post, take this. The largest quality jump doesn’t come from a smarter prompt. It comes from giving the model a way to check its own work and refusing to let it declare victory until the check passes.
Models are optimists. Left alone, Claude will write code, glance at it, decide it looks right, and tell you it’s done. Sometimes it is. Sometimes the test never ran. The fix isn’t to trust harder — it’s to make “done” mean “the machine verified it,” structurally, so the model can’t route around it.
Hooks are how you enforce that. A PostToolUse hook fires after Claude edits a file; a Stop hook fires when it tries to end the turn. Wire either one to your test/lint command and make a non-zero exit block the model.
Here’s a Stop hook that runs the suite and refuses to let Claude finish if it’s red:
{
"hooks": {
"Stop": [
{
"matcher": "",
"hooks": [
{
"type": "command",
"command": "bun test > /dev/null 2>&1 || { echo 'Tests failing — fix before stopping.' >&2; exit 2; }"
}
]
}
]
}
}
When the tests fail, the hook exits non-zero with a reason, the harness feeds that back into the loop, and Claude keeps working instead of handing you broken code with a confident summary. It literally cannot stop on red. That one hook changes the character of the whole tool — it turns an optimist into something that grinds until the referee says pass.
You can do the same on PostToolUse for tighter loops — lint every edit the moment it lands:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "bun run lint --fix 2>&1 | tail -5"
}
]
}
]
}
}
The output goes back to Claude, so if the lint reports a real error, the model sees it immediately instead of at the end when its context has moved on.
For the times you want verification on demand rather than automatic, make it a slash command. Drop this in .claude/commands/verify-then-continue.md:
---
allowed-tools: Bash(bun test:*), Bash(bun run typecheck:*), Bash(bun run lint:*), Read
description: Run the full verification suite; only continue if everything is green.
---
Verify the current state before doing anything else.
Run these and report the result of each:
1. `bun run typecheck`
2. `bun run lint`
3. `bun test`
If ALL pass: summarize what's green in two lines, then continue with:
$ARGUMENTS
If ANYTHING fails: STOP. Do not continue with the task. Show me the exact
failing output and your plan to fix it. Wait for the fixes to pass before
touching $ARGUMENTS.
Now /verify-then-continue add the rate limiter to the login route runs your gates first and only proceeds from a known-good state. The $ARGUMENTS slot carries whatever you type after the command. It’s a checkpoint you can invoke by muscle memory.
The extreme version of this idea: a UI rewrite that drags for weeks because “does it look right?” isn’t a thing a terminal agent can answer. So you give it eyes. Screenshot the rendered page, pixel-diff against the reference, and feed the diff score back as the pass/fail signal. Suddenly the model has a real referee for a visual task, and the loop closes. The lesson generalizes: any task you can turn into a pass/fail check, you can hand off. Any task you can’t, you’re stuck babysitting. Most of the skill of using this tool well is finding the check.
Orchestration: algebra for agents
One Claude in one context window is a soloist. The real power move is composition — running many of them, each with a clean context, and combining the results. Cherny frames this as algebra for agents, and the mechanics are already in the tool.
The first primitive is the subagent via the Task tool. When you tell Claude to “use a subagent to investigate X,” it spawns a child with its own fresh context window, that child does the grepping and reading and reasoning, and only the conclusion comes back to your main thread. Your main context stays clean. This is how you research a sprawling question without drowning your working session in 40 file reads you’ll never look at again.
You can formalize the useful ones as custom agents in .claude/agents/. Here’s a reviewer:
---
name: test-auditor
description: Audits test coverage and quality for a changed area. Use after
implementing a feature to find gaps and weak assertions.
tools: Read, Grep, Glob, Bash(bun test:*)
---
You audit tests. You do NOT write production code.
Given a changed file or feature:
1. Find the tests that cover it (Grep/Glob).
2. Run them and confirm they pass.
3. Report: what's tested, what's NOT tested, and any assertion that's a
snapshot rather than a behavior contract (a test that would pass even
if the logic were subtly wrong).
4. Output a short prioritized list of missing cases. No fluff.
Now the main agent can delegate “audit the tests on this” to a specialist with a narrow tool set and a single job, and get back a tight list instead of a wandering essay.
The second primitive is fan-out. When you’ve got the same operation to run across many files, don’t do it serially in one context — spray it across parallel claude -p workers and summarize at the end. Here’s a real script that runs a headless worker per file and then a summarizer pass:
#!/usr/bin/env bash
set -euo pipefail
# Fan out: one headless Claude per changed file, capped concurrency.
FILES=$(git diff --name-only main...HEAD -- '*.ts' '*.tsx')
OUTDIR=$(mktemp -d)
MAX_PARALLEL=4
run_worker() {
local file="$1" out="$2"
claude -p "Review $file for security issues only: injection, missing
authz checks, unsafe deserialization, secrets in code. If none, reply
exactly 'CLEAN'. Otherwise list findings as '- <file>:<line> <issue>'." \
--allowedTools "Read Grep" \
> "$out" 2>/dev/null || echo "WORKER FAILED: $file" > "$out"
}
export -f run_worker
echo "$FILES" | while read -r f; do
[ -z "$f" ] && continue
run_worker "$f" "$OUTDIR/$(echo "$f" | tr / _).txt" &
while [ "$(jobs -r | wc -l)" -ge "$MAX_PARALLEL" ]; do wait -n; done
done
wait # barrier: nothing proceeds until every worker is done
# Summarize: one final worker reads all the worker outputs.
cat "$OUTDIR"/*.txt | claude -p "These are per-file security review outputs.
Ignore every file that said CLEAN. Produce one consolidated, deduped,
severity-ranked list of real findings. If everything was CLEAN, say so." \
--allowedTools "Read"
rm -rf "$OUTDIR"
Each worker gets its own context, so file #30 isn’t polluted by files #1 through #29. They run four at a time. Then a single summarizer collapses the pile into one ranked list. This is the pattern behind every “review the whole PR” or “migrate all the files” job that’s too big for one window — map with -p workers, reduce with a summarizer. Composition, not a longer prompt.
Per the interview, this is the direction the whole tool is heading: with Opus 5, Cherny expects a single agent to reliably drive fleets of subagents on genuinely large migrations with much less hand-holding. Treat that as a forecast, not a spec sheet — but the plumbing to build it exists today.
The proof it’s not vapor: a ~100k-line rewrite of a Bun subsystem from Zig to Rust, done in around 11 days, with the existing test suite as the referee. That only works because the tests were the ground truth — the agent could churn through the port and the suite told it, mechanically, whether each chunk was still correct. Same lesson as the last section, at scale: the referee is what makes the delegation safe. No test suite, no 11-day rewrite. Just 11 days of hope.
Loops versus routines
There are two shapes of repeated work and they want different tools.
A loop is local and interactive-adjacent: “keep running the tests and fixing until green,” “reformat every file in this dir,” “watch this and re-run on change.” It lives in your terminal, in your session, right now. You drive it, or a small script drives it, and when it’s done it’s done.
A routine is background and recurring: “every night, check for dependency updates and open a PR,” “every morning, triage new issues,” “weekly, scan for dead code.” It should not depend on you having a terminal open. It’s a scheduled job that spins up a fresh Claude, does a bounded task, reports, and exits.
Don’t confuse them. Running your nightly maintenance as an interactive loop means it dies when you close the laptop. Running your inner dev loop as a cron job means you can’t react to it. Match the shape.
For the routine side, the wiring is a scheduler plus a headless claude -p with a tight, verifiable job. A maintenance agent looks roughly like this:
#!/usr/bin/env bash
# nightly-maint.sh — run from cron/launchd, NOT from an interactive session.
set -euo pipefail
cd /Users/me/project
git fetch origin && git checkout -b "maint/$(date +%F)" origin/main
claude -p "Maintenance pass. Do ONLY safe, mechanical work:
- Update patch/minor deps that don't break \`bun test\`.
- Delete provably dead code (no imports, not exported publicly).
- Fix lint warnings that autofix cleanly.
Done when: \`bun test\` and \`bun run typecheck\` both pass. If anything
can't be done safely, skip it and note why. Do NOT touch major versions.
Do NOT change public APIs." \
--allowedTools "Read Edit Bash(bun:*) Bash(git add:*) Bash(git commit:*)"
# Verification gate lives OUTSIDE the model. Trust nothing.
if bun test && bun run typecheck; then
git push origin HEAD && gh pr create --fill --label automated
else
echo "Maintenance pass failed verification — no PR opened." >&2
exit 1
fi
The model does the fiddly work; the shell script is the referee and the safety rail. It opens a PR only when the gates pass — you review the diff in the morning like any other PR. The agent never gets to merge itself.
One design idea worth stealing here: the Abstraction Police. It’s a routine whose entire job is to patrol for premature abstraction — the three-parameter config object that has exactly one caller, the “flexible” plugin system with one plugin, the generic wrapper that wraps one thing. Left alone, agents love to build speculative infrastructure; it pattern-matches as good engineering. A recurring agent pointed at “find abstractions with a single consumer and flag them for inlining” keeps the codebase honest between human reviews. You’re using one agent’s tendency to over-build as the thing another agent hunts. Sic the model on its own worst habit.
Context management at scale
Your context window is a workbench, not a filing cabinet. The skill is keeping only what the current task needs on it.
Two tools, two different jobs, and people mix them up constantly:
/clearwipes the context. Gone. Use it when you switch tasks — finished the auth feature, now you’re on the billing bug. The auth conversation is pure noise for billing, and worse than noise, it’s tokens the model re-reads every turn and stale facts it might act on. Clear it. Start clean./compactsummarizes the context down and keeps going. Use it mid-task when a single long job has filled the window but you still need the thread of it — the decisions made, the approach, the state. You lose the verbatim history but keep the gist.
Rule of thumb: switching tasks → /clear. Same task, running long → /compact. If you find yourself /compact-ing five times on one task, that’s a smell — the task is too big for one window and wants to be broken up or fanned out.
The third move, and the one that separates people who get this: spawn a subagent to keep the main context clean. When you need to answer a question that requires reading a dozen files — “how does the payment flow actually work end to end?” — don’t do it in your main thread and bloat it with twelve file dumps. Send a subagent to investigate and bring back a paragraph. Your main context receives the conclusion, not the raw material. The best long sessions I have are ones where the main thread stays surprisingly short because every deep dive got delegated and only the answers came home.
Plan mode plays into this too. Before a big change, drop into plan mode and let Claude lay out the approach without touching anything. You read the plan, correct the misunderstandings while they’re cheap, and only then let it execute. A wrong plan caught in plan mode costs a sentence. A wrong plan caught after 15 file edits costs a /clear and your afternoon.
The empirical mindset
Here’s the frame that ties it together, and it’s the one that actually levels people up: your harness is an experiment, not a config.
Everything in this post — the ablated CLAUDE.md, the permission scope, the verification hooks, the fan-out scripts, the routines — is a hypothesis about how to get better work out of the model. And hypotheses get tested, kept, or thrown out based on results, not on how clever they felt when you wrote them. Delete a CLAUDE.md line and see if quality drops. Add a hook and see if the “done but broken” rate falls. Measure. The people who plateau are the ones who set up a config once and treat it as settled.
A corollary that costs people the most: retry your old failures. That refactor Claude botched three months ago? The model’s better now. The migration you gave up on because it kept losing the thread? Try it again with a fan-out and a test-suite referee. Your mental map of “things AI can’t do” is a photograph of a moving target, and it’s already out of date. Half the “it can’t do that” beliefs I carry turn out to be “it couldn’t do that in March.” Re-run the experiment.
The last trap is a senior-engineer trap specifically, so it’ll get you: seniors over-specify. Years of code review and mentoring junior devs trained you to hand off with exhaustive detail, because a junior human needs the guardrails and gets lost without them. You bring that instinct to Claude and you strangle it — you write the step-by-step recipe, you pre-decide the approach, you leave no room for the model to find a better path than the one you’d have taken. The model often does know a cleaner route. Give it the exit criteria and the tools and a referee, then get out of the way. Trust, but verify — mechanically, with a hook, not by reading over its shoulder.
That’s the whole game at this level. Cut the prompt debt. Widen the leash but keep the seatbelt. Make “done” mean verified. Compose small agents instead of writing bigger prompts. And treat every bit of it as an experiment you’re still running — because the model underneath you keeps getting better, and the only way to find your new ceiling is to keep pushing on it.
Now go delete two-thirds of your CLAUDE.md. I’ll wait.