Claude Code harness
hooks that enforce what prompts only ask
Thirteen shell scripts sit on seven Claude Code hook events. They keep the agent out of my main checkout, make the main thread delegate, stop parallel workers clobbering one file, and refuse to end a turn until the code answers four standards. This is all of it, including where it falls short.

A coding agent is fast and confident, and it repeats the same mistakes. My harness is a set of shell scripts wired into Claude Code hook events. Each one exists because of a failure I kept seeing:
- Prose comments: narration of every step, JSDoc on self-evident functions, change notes left in the code.
- Bloat: wrapper layers, single-use helpers, a config object with one option.
- The 80% fix: a change that handles the reported input and misses the rest of the input space.
- Edits in my main checkout: I often work in the same repo at the same time, and a shared checkout means one of us overwrites the other.
- Parallel agents on one file: two workers write the same file and the second write lands on a stale read.
- Fix loops: worker after worker patches the same file, none of them holding the whole picture.
- A full main context: the orchestrating conversation fills with file dumps until the plan falls out of it.
A prompt asks. A hook runs on every matching event, gets the tool call as JSON on stdin, and answers with a decision the model cannot skip. The prompt still matters, because every deny message tells the model what to do next. The rule and the way out arrive in the same message.
The editor side of this setup, worktree pickers and Claude Code inside Neovim, is in the Neovim setup case study. This page covers the hooks.
Claude Code fires hook events at fixed points in a session. The harness uses seven. Every script lives in ~/.claude/hooks and is registered in the hooks block of ~/.claude/settings.json.
- SessionStart
- injects three notices: the four code standards, boss mode and the worktree rule. The Stop gate script also runs here and records a hash of every code file that is already dirty, so earlier edits are not blamed on this session.
- SubagentStart
- puts the same standards text into every subagent. subagent-standards.sh calls the session notice script, so the two texts never drift. worker-claim.sh marks the worker active, and the gate records a baseline for it.
- PreToolUse
- four guards. Each one can answer permissionDecision: "deny" with a reason, and the model reads that reason as the result of its call.
- PostToolUse
- prettier on the written file, the comment guard, and a usage check.
- Stop · SubagentStop
- the four-gate check on any turn that changed code. worker-release.sh frees the finished worker's file locks.
- Notification
- a push to my phone when Claude waits for permission or input, titled with the tmux session name.
A few settings matter as much as the hooks. includeCoAuthoredBy is false, so commits carry no co-author trailer. worktree.symlinkDirectories links node_modules, .venv and .cache into each new worktree, so a worktree does not reinstall. The same scripts serve a second agent harness: CLAUDE_HARNESS=pi only switches the wording to that harness’s tool names. No custom subagent types are defined. Boss mode uses the built-in Explore and general-purpose agents.
In boss mode the main conversation is an orchestrator, not a worker. boss-mode.sh runs before Bash, Read, Edit, Write, Grep, Glob, WebFetch, WebSearch and their siblings. In the main thread it denies all of them except Grep, Glob and small reads. A call that carries an agent_id comes from a subagent, and the script lets it through untouched.
- Context economy: a worker reads ten files and returns twenty lines. The main thread keeps the plan, not the dumps.
- Parallelism: independent units go out in one message, three to six at a time.
- Judgement stays central: planning, classification and the final answer need the whole conversation, and only the main thread has it.
The read exception: Read passes when the file has 300 lines or fewer, or when the call sets a limit of 300 or less. A larger file is denied with two ways forward, a bounded slice or an Explore worker that reports conclusions with file:line evidence.
PreToolUse · boss-mode.sh · Bash in the main thread → deny BOSS MODE: the main agent does not run Bash itself. You orchestrate, you do not execute. Delegate this to a subagent with the Agent tool. Give it the full standalone context it needs (absolute paths, the goal, the constraints, what to report back), because it starts with a fresh context and cannot see this conversation. - Investigation or reading: subagent_type "Explore", which returns conclusions instead of file dumps. - Edits, commands, builds, tests: subagent_type "general-purpose". - Independent units go in ONE message so they run in parallel. - Ask the worker for a compact report: findings, file:line, evidence. Never ask it to paste whole files back. Workers inherit every hook and all four code standards, so the gates still apply to their work. Escape hatch, only when the user explicitly asks for it: create the file ~/.claude/boss-off.
The escape hatch is the file ~/.claude/boss-off or CLAUDE_BOSS_OFF=1. A Bash command that touches ~/.claude/boss-off is always allowed, so the file can be created from the main thread. One honest gap: the session notice still lists Grep and Glob as denied, while the script allows them. The script is what runs.
Worktrees
worktree-guard.sh runs before Edit, Write and NotebookEdit. It asks git for the repo’s git directory and its common git directory. In a linked worktree they differ. In the main checkout they are the same, and the edit is denied. Files under ~/.claude, /tmp and /var/folders are always allowed.
Shell commands
repo-guard.sh runs before every Bash call. It splits the command on &&, ||, ; and |, follows cd from segment to segment, and sorts each segment: commit or push (also behind git -c key=value), mutating verbs such as sed -i, tee, mv and rm, mutating git subcommands such as reset, checkout, stash and rebase, and output redirects to anything outside /tmp and /dev. The target comes from git -C, an absolute path, the redirect file, or the current directory. A commit or push on main, master, develop or trunk is denied. Any mutation in a main checkout is denied.
$ cd ~/Documents/GitHub/abhiram-portfolio && git commit -m x PreToolUse · repo-guard.sh → deny Blocked: HEAD is on the protected branch main in /Users/abhiramshaji/Documents/GitHub/abhiram-portfolio. Create a feature branch (or call EnterWorktree) and commit there instead. Escape hatch if the user explicitly asked for main: CLAUDE_ALLOW_MAIN_COMMIT=1. $ cd ~/Documents/GitHub/abhiram-portfolio ; sed -i '' s/a/b/ README.md PreToolUse · repo-guard.sh → deny Blocked: /Users/abhiramshaji/Documents/GitHub/abhiram-portfolio is the main checkout, where the user may be editing in parallel, and this command mutates it (sed -i). Call EnterWorktree first (it creates an isolated worktree on its own branch) and rerun it there. Escape hatch if the user explicitly wants the main checkout: CLAUDE_ALLOW_MAIN_CHECKOUT=1.
Worker locks and fix loops
worker-lock.sh runs before every subagent edit. Each file gets a lock directory keyed by a hash of its path, per session. If another active worker wrote the file in the last 15 minutes, the edit is denied with PARALLEL EDIT BLOCKED and the worker is told to report the change it intended to the orchestrator instead. A mkdir lock serialises the check itself, so two workers cannot pass it at the same instant.
The same script counts writers. Three distinct workers on one file, or more than twelve writes, returns RE FIX LOOP DETECTED: hand the file to exactly one worker, briefed with the full history, and ask me if two successive workers still cannot converge.
Tests and bypasses
.guard-test.sh pipes 28 cases through the two guards: 22 shell commands and 6 file paths. It holds the cases a pattern parser gets wrong: echo 'git commit is blocked' and grep -rn 'rm -rf' are allowed, npm ci and tsc in main are allowed, a heredoc into main and a commit behind -c flags are denied. The run for this article: 28 PASS.
The bypasses are CLAUDE_ALLOW_MAIN_CHECKOUT=1 and CLAUDE_ALLOW_MAIN_COMMIT=1. They exist for the times I ask for work on main directly. repo-guard also honours them when they are written inline in the command, so the model can bypass with one prefix. The rule to use them only when I ask lives in the message, not in a lock.
The rule lives in ~/.claude/CLAUDE.md: never a prose comment. The only comment allowed is a one-word lowercase label above a block, two words only when hyphenated (// fetch-user). Blank lines between blocks carry the structure, and a better name beats any label.
Five exceptions may run longer, capped at one line: a landmine, a guard against a known upstream bug, a non-obvious ordering, unit or protocol constraint, a legally required notice, and tooling directives such as eslint-disable and @ts-expect-error.
/**
* Checks out the user and creates their subscription.
* @param input the checkout input
*/
export async function checkout(input: CheckoutInput) {
// Make sure the email and plan are present first
if (!input.email) throw new Error('email required')
if (!input.plan) throw new Error('plan required')
// Look up the user by their email address
const user = await db.user.findUnique({ where: { email: input.email } })
// Create the invoice in Stripe for this customer
const invoice = await stripe.invoices.create({ customer: user.stripeId })
// TODO: handle failed payments later
await db.subscription.create({ data: { userId: user.id, invoiceId: invoice.id } })
}export async function checkout(input: CheckoutInput) {
// validate
if (!input.email) throw new Error('email required')
if (!input.plan) throw new Error('plan required')
// fetch-user
const user = await db.user.findUnique({ where: { email: input.email } })
// charge
const invoice = await stripe.invoices.create({ customer: user.stripeId })
// persist
await db.subscription.create({ data: { userId: user.id, invoiceId: invoice.id } })
}comment-guard.sh runs after every Edit and Write on 31 source extensions, from .ts and .py to .sql, .css, .vue and .astro. It skips node_modules, dist, build, .next and vendor. One awk pass reads each line that starts with //, #, /* or *, strips the marker, and flags any comment of more than one word. A line opening with /** is always flagged, so JSDoc never passes. Tooling directives, URLs, shebangs, licence and copyright lines, and generated-code headers are exempt.
On a violation it prints to stderr and exits 2. For PostToolUse, exit 2 hands stderr back to the model as feedback. The write has already landed, so the guard corrects the file on the next step rather than preventing it.
PostToolUse · comment-guard.sh · exit 2, stderr goes back to the model COMMENT POLICY VIOLATION in src/checkout.ts src/checkout.ts:1 /** src/checkout.ts:2 * Checks out the user and creates their subscription. src/checkout.ts:3 * @param input the checkout input src/checkout.ts:6 // Make sure the email and plan are present first src/checkout.ts:9 // Look up the user by their email address src/checkout.ts:11 // Create the invoice in Stripe for this customer src/checkout.ts:13 // TODO: handle failed payments later Rule (~/.claude/CLAUDE.md): the only comment allowed is a 1 word lowercase label above a blank line separated block, 2 words only when hyphenated (// fetch-user). No prose, no sentences, no JSDoc, no banners, no TODOs, no change notes. Rewrite each line above as a 1 word block label or delete it. Keep it only if it is a genuine 1 line exception: landmine, upstream bug guard, non obvious ordering or unit, legal notice, or tooling directive.
The guard reads lines, not syntax. A // line inside a template string counts as a comment, which is why the code samples on this page are stored as arrays of strings. A genuine one-line exception of several words also trips it every time the file is written. The guard is stricter than the rule, on purpose, and the model is told when to keep the line anyway.
Four skills define what finished code means. They are peers: none is a subsection of another.
- sr-eng · robust
- correct across the whole input space, not the common case. Its companion skill robust-check treats "probably" as a verdict of NOT ROBUST.
- pure-semantic · semantic
- decisions by meaning, not keyword lists, regex classification or magic counts. Mechanical facts such as IDs, math and enums stay deterministic.
- structure · shape
- a file over 200 lines with more than one function becomes a folder of job-named mini files behind one entry file with the same public API. At most 5 files per folder. Never utils, helpers, misc, common or shared.
- anti-bloat · minimal
- native built-ins first, a pure function over a class, no single-use wrapper, config or interface, zero prose comments.
When they disagree the precedence is robust > semantic > structure > minimal. Minimal never cuts an input region or a semantic decision. Semantic never hands a stated constraint or a mechanical fact to a model. A structure split never changes behaviour or the public API.
sr-eng-gate.sh enforces them on Stop and SubagentStop. It lists the code files that are dirty in git plus every file comment-guard saw written, and keeps those whose hash changed since the session baseline. If nothing changed, the turn ends. Otherwise it measures changed files over 200 lines and touched folders with more than 5 code files. Tests, generated code, migrations, entry files such as index.ts, and framework-owned folders such as app, pages and public are not measured.
Then it reads the last assistant message. It needs four lines, each starting with a gate name and a colon followed by evidence, and the structure line must name every flagged path. If any line is missing it returns decision: "block" with a reason built from each skill’s final self check.
Stop · sr-eng-gate.sh → {"decision": "block"}
Code changed this session. Four gates are open. All are mandatory and INDEPENDENT.
None is a subsection of another: skipping one NEVER excuses skipping the rest, and no
gate may be answered by assertion. Answer every line with evidence.
GATE 1 robust (skill: sr-eng)
- Did robust-check return ROBUST, with every live input region closed by evidence?
… six more checks
GATE 2 semantic (skill: pure-semantic)
- Did I keep every stated constraint and mechanical fact, AI-ifying none of them?
… six more checks
GATE 3 structure (skill: structure)
- Is every changed code file at or under 200 lines, or split, or named in my evidence as one inseparable unit?
… five more checks
GATE 4 minimal (skill: anti-bloat)
- Does every abstraction have at least two real callers today?
… five more checks
Order: robust, then semantic, then structure, then minimal. NOT ROBUST means STOP, spawn a bigger picture
subagent, escalate only if 100% is physically impossible. Never ship the 80% fix.
Required format: your final message must contain one line per gate, each starting with the
gate name and a colon, followed by that gate's evidence on the same line:
sr-eng: <evidence>
pure-semantic: <evidence>
structure: <evidence, naming every flagged path above and whether it was split or why it is exempt>
anti-bloat: <evidence>The loop break: the gate fingerprints the change set and blocks one fingerprint at most twice. A pass is remembered for that fingerprint, so a later turn with no new edits is not asked again.
Final message of the turn that built this page → the gate passes and the turn ends sr-eng: every hook claim checked against the script source; .guard-test.sh 28 PASS; tsc and next build pass; no horizontal scroll at 1440 and 390. pure-semantic: no decision logic added; static copy plus SVG layout math, which is a mechanical fact. structure: page.tsx plus sections/ (5), diagrams/ (4 and lifecycle/ 4), panels/ (5); every file under 200 lines; nothing flagged. anti-bloat: no new dependency; diagrams are inline SVG on theme classes; one Terminal and one CodeCompare reused; zero prose comments.
Hooks block. Skills teach. A skill is a written procedure Claude loads on demand, and there are 100+ of them in this setup. The ones the harness leans on:
- sr-eng, pure-semantic, structure, anti-bloat
- the four standards, each with a final self check that the Stop gate quotes.
- robust-check
- an audit that maps the input space, builds a concrete failing input for each live region, and answers ROBUST or NOT ROBUST.
- comments-trim
- strips comments down to the policy. Comment-only edits, zero behaviour change, and every file read in full first, never a regex sweep.
- review-file-codex-judge
- one file: Claude drafts a briefing, two review subagents run in parallel, Codex argues the defence, and a judge keeps only the changes that are truly needed. Nothing is edited.
Three commands carry the git rules: /commit, /pr and /pr-merge. No Co-Authored-By trailer, never amend, never force-push or --no-verify, stage files by name, and no Test plan section. The PR body comes from the pr-help skill in seven parts: one sentence, what the code is for, before, after, a mermaid diagram, the change, and what changes for a user. No metaphors and no code jargon. /pr-merge matches the repo’s merge style and stops on a conflict or a failing check.
Prettier runs on every written file through npx --no-install prettier --write. The --no-install flag means it never downloads anything, and its errors are swallowed, so a repo without prettier is left alone. After every tool call usage.sh --check reads the status line’s snapshot for this session and, at 100% of the five-hour limit, posts an information message. It never blocks.
Claude to Codex. Codex runs as a stdio MCP server named codex, registered at user scope. Claude starts a thread with mcp__codex__codex and continues it with mcp__codex__codex-reply. The server binary is pinned to Codex 0.153.4 by absolute path, because 0.154.0 removed the mcp-server subcommand. The pin stays until upstream ships a stdio replacement.
Codex to Claude. ~/.codex/config.toml registers [mcp_servers.claude-code] with claude mcp serve, so a Codex session can call Claude Code’s tools. Codex’s developer instructions carry the same worktree rule: create or reuse an isolated worktree on its own branch before editing, and never touch main, master, develop or trunk unless asked.
Codex has two jobs. In review-file-codex-judge it is the developer’s lawyer: one call with sandbox: "read-only" and approval-policy: "never", arguing per finding whether the code is fine as written. In the build skills it takes the typecheck and validation fix loop, so Claude keeps planning while Codex works through compiler errors.
The honest part: the /codex-bridge command itself says to wire one direction at a time to avoid agent ping-pong, and both directions are wired. Nothing in the harness stops Claude calling Codex calling Claude. What keeps it in check today is how it is used, one read-only call by rule, not a loop. That is discipline, not a guard.
Hooks police how code is written: where, by whom, with what comments, at what size. They cannot see what gets added to the architecture. A new service, a new edge between two modules or a new package passes every hook if the code is tidy.
Architect covers that gap. The architecture lives in architect.md, drawn as boxes and arrows. Over MCP the agent calls check_change(from, to) and gets allowed, forbidden with a reason, unknown-component or undrawn-edge. The last two lead to propose_change, which blocks while a ghost node sits on the live canvas until I approve or reject in one click. get_architecture returns the current contract, list_edits and get_edit hand over an architecture I drew in advance, and await_proposal waits on a proposal that is still pending.
It is opt-in. The agent calls these tools because its instructions say to. No hook forces check_change before a write. More in the Architect case study and on GitHub.
The harness costs something every day. These are the costs, as they stand.
- Friction: a denied call costs a turn, and boss mode turns a one-line fix into a dispatch.
- Extra turns at the end: every change ends with four lines of evidence, and sometimes a block first.
- Fan-out is not free: each worker starts blind and needs a full brief. For a small task that is slower and uses more tokens than doing it directly.
- The bypasses exist: three env vars and one file. The model can type them. What stops it is the deny message and my instruction, not a lock.
- The guards are shell scripts: repo-guard parses commands with sed and grep, not a shell grammar. A write inside python -c, a script or cp is not seen, and while writing this page it denied four harmless commands: a quoted command string, an unexpanded $S path, a heredoc whose text held a > character, and an inline python script with a => arrow. The 28 tests cover known cases, not all of them.
- Evidence can be thin: the gate checks that four lines exist and name the flagged paths. It cannot check that the evidence is true.
- Comments are corrected, not prevented: comment-guard runs after the write, so a bad comment lands first and is fixed on the next step.
- Two-way Codex with no loop guard: as above, the ping-pong risk is handled by habit.
No metrics here, only what the setup itself guarantees. Each line can be checked against the scripts.
- Edits and mutating commands in a main checkout are denied at the tool call, and the 28 guard cases pass.
- A plain git commit or push on main, master, develop or trunk is denied unless a bypass is set, within the parser limits above.
- Claude adds no co-author trailer: includeCoAuthoredBy is false and the PR commands forbid one.
- Every code write in 31 extensions is checked against the comment policy.
- No turn that changed code ends without an answer for each of the four standards, and no change set is blocked more than twice.
- In boss mode the main thread never reads a file over 300 lines whole. Workers read and report back.
- Two workers cannot hold one file at once (a claim expires after 15 minutes or when the worker stops), and a third writer on one file stops the loop.
- This page was built under the harness: in a worktree, by a subagent, ending with the gate answer shown above.
The agent still makes the same mistakes. It just cannot ship them quietly.