Day 3 · about 25 min
Make Claude check its own work with hooks and tests
Two hooks that keep Claude out of your secrets and tests, and send it back when it finishes with a failing test.
You’ll finish with
- A guard hook that stops Claude editing
.envfiles and your test folder, and tells it why. - A test gate that runs
npm testeach time Claude finishes a reply and sends it back to fix what fails. - A one-command check,
npm run check-hooks, that tests both hook scripts with sample input before you load them in Claude Code. - The know-how to read hooks with
/hooks, tune them, and switch them off for a session.
Before you start
Tell us about your setup and the steps change to fit it. Your answers stay in this browser, and you can change them at any time.
Which system are you on?
Commands differ between systems. Pick the one you’ll run Claude Code on.
Which project are you using today?
The hooks are the same everywhere. Only the test command and the locked folders change.
The steps
One step at a time. When a step’s check matches, tick it and the next one opens.
Step 1 of 7: Check your test command
Hooks enforce what CLAUDE.md can only ask for, and today’s test gate needs a test command that exits with a non-zero code when a test fails.Your CLAUDE.md asks Claude to run the tests and leave secrets alone. Claude usually listens, but CLAUDE.md is advice it reads, not a rule anything enforces. A hook is different: it’s a command Claude Code runs itself, every time a chosen event happens, whatever Claude decides.
Today you add two:
- A guard. Before every Edit or Write, it checks the file. Edits to
.envfiles and to thetest/folder are blocked, and Claude is told why. - A test gate. Each time Claude finishes a reply, it runs
npm test. If a test fails, Claude is sent back to fix the code before it can finish.
The gate trusts the exit code: 0 when every test passes, anything else when one fails. In the practice project the command is
npm test. Run it and print its exit code:For macOS, Linux and WSL
Terminalnpm test echo $?For Windows
PowerShellnpm test $LASTEXITCODEFor “A fresh copy of the Day 2 kit”
Download the Day 2 kit, unzip it and open a terminal in the
horizonlux-claude-code-day-2folder that holdspackage.json. On Windows, Extract All can put that folder inside another folder with the same name, so look one level down. Run the commands above there.For “The finished Day 3 kit”
Download the Day 3 kit, unzip it and open a terminal in the
horizonlux-claude-code-day-3folder that holdspackage.jsonand.claude. On Windows, Extract All can put that folder inside another folder with the same name, so look one level down. Run the commands above there. The hooks are already in the kit, so step 2 is marked done; open it anyway to see how they work.For “My own project”
Use whatever runs your tests, such as
npm testorpytest, and print its exit code the same way. Start from a run where every test passes: the gate checks the whole suite, so a test that was already failing would send Claude back after every reply.Commit your work before you start, so you can see and undo every change Claude makes today.
Check it worked
Every test passes, and the last line printed is
0.If it didn’t work
`npm` is not found.
The practice project and the hook scripts need Node.js 22 or newer. Install the LTS version from nodejs.org, open a new terminal and try again.
PowerShell says running scripts is disabled on this system.
PowerShell’s execution policy is blocking the
npm.ps1launcher, sonpm testnever ran and the number printed after it means nothing. Typenpm.cmdin place ofnpmtoday, as innpm.cmd test. Or allow local scripts for your user once, then runnpm testagain:PowerShellSet-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUserA test fails before you have changed anything.
Fix that first, or start again from a fresh copy of the kit. With a failing suite, the gate would send Claude back after every reply.
Your own test command reports a failure, but the exit code is still 0.
Something in the command hides the failure, such as
|| trueat the end of a script. The gate only sees the exit code, so fix the command, or point the gate at one that fails properly.- A guard. Before every Edit or Write, it checks the file. Edits to
Step 2 of 7: Add the hooks pack
Save the settings file that registers both hooks, the two Node scripts they run, a check script and an updated CLAUDE.md.Save each file below at the path shown, starting from the project’s top folder. To skip the copying, download the Day 3 kit and take the same files from it.
.claude/settings.jsonJSON{ "$schema": "https://json.schemastore.org/claude-code-settings.json", "hooks": { "PreToolUse": [ { "matcher": "Edit|Write", "hooks": [ { "type": "command", "command": "node", "args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/protect-files.mjs"] } ] } ], "Stop": [ { "hooks": [ { "type": "command", "command": "node", "args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/run-tests.mjs"], "timeout": 120, "statusMessage": "Running the tests" } ] } ] } }Settings files are strict JSON: one comment or trailing comma breaks the whole file. If your project already has a
.claude/settings.json, add thehookskey to it instead of replacing it.Read it from the outside in. Each event (
PreToolUse,Stop) holds a list of matcher groups, and each group holds the handlers to run. The matcherEdit|Writemeans the Edit or the Write tool, matched exactly.Stoptakes no matcher: it runs every time Claude finishes a reply.commandplusargsstartsnodedirectly with the script path as one argument, with no shell in between. Spaces in your folder path are fine, and it works the same on macOS, Linux, WSL and Windows, with or without Git Bash.- Claude Code fills in
${CLAUDE_PROJECT_DIR}with the project root, so the hook finds its script wherever Claude is working. timeoutis in seconds. Without it, a command hook may run for up to 10 minutes.statusMessageis the text Claude Code shows by the spinner while the hook runs.
.claude/hooks/protect-files.mjsJavaScript// PreToolUse hook for the Edit and Write tools. // // Claude Code sends the tool call as JSON on stdin. To block the call, this // script prints the reason on stderr and exits with code 2; Claude sees the // reason and has to try something else. For any other file it exits 0 with no // output, and the normal permission flow decides. // // Registered in .claude/settings.json. Test it with: npm run check-hooks import path from 'node:path'; // --------------------------------------------------------------------------- // What this hook protects. // // Secrets: .env and every .env.* file (.env.local, .env.production and so on), // in any folder, except the files listed as safe. const SECRET_FILE = /^\.env(\..+)?$/; const SAFE_SECRET_FILES = ['.env.example']; // // Locked folders, relative to the project root, with forward slashes on every // system. Claude must make the code pass these tests, not change the tests. // On Windows, names in both lists match whatever their case. const LOCKED_FOLDERS = ['test/']; // --------------------------------------------------------------------------- process.exitCode = await main(); async function main() { let input; try { input = JSON.parse(await readStdin()); } catch { console.error('protect-files: the hook input was not valid JSON'); return 1; } const filePath = input.tool_input?.file_path; if (typeof filePath !== 'string' || filePath === '') return 0; const projectDir = process.env.CLAUDE_PROJECT_DIR || input.cwd || process.cwd(); const reason = whyProtected(filePath, projectDir); if (reason) { console.error(`Blocked: ${reason}`); return 2; } return 0; } /** Says why a file is protected, or returns null if Claude may edit it. */ function whyProtected(file, root) { const windows = isWindowsPath(file); const fold = (text) => (windows ? text.toLowerCase() : text); const name = fold(file.split(/[\\/]/).pop()); if (SECRET_FILE.test(name) && !SAFE_SECRET_FILES.some((safe) => fold(safe) === name)) { return `${name} holds secrets, so Claude may not edit it. Change .env.example instead, or ask the user to edit ${name} by hand.`; } const relative = relativeToProject(file, root); if (relative !== null && LOCKED_FOLDERS.some((folder) => fold(relative).startsWith(fold(folder)))) { return `${relative} is a locked test file. Fix the code in src/ so the tests pass, and don't change the tests.`; } return null; } /** The file's path inside the project, with forward slashes, or null if it is outside. */ function relativeToProject(file, root) { const windows = isWindowsPath(file); const paths = windows ? path.win32 : path.posix; // Git Bash can show the root as /c/Users/... while Claude Code sends C:\Users\... const fixedRoot = windows ? root.replace(/^\/([a-zA-Z])\//, '$1:/') : root; const relative = paths.relative(fixedRoot, file).replaceAll('\\', '/'); const outside = relative === '' || relative === '..' || relative.startsWith('../'); return outside || paths.isAbsolute(relative) ? null : relative; } /** Windows paths start with a drive letter (C:\) or two backslashes (\\server). */ function isWindowsPath(file) { return /^[a-zA-Z]:[\\/]/.test(file) || file.startsWith('\\\\'); } async function readStdin() { process.stdin.setEncoding('utf8'); let text = ''; for await (const chunk of process.stdin) text += chunk; // Windows PowerShell 5.1 can start piped text with a byte order mark. return text.replace(/^\uFEFF/, ''); }Claude Code sends the tool call to the script as JSON on stdin. To block it, the script writes a reason to stderr and exits with code 2, and Claude gets the reason. For any other file it exits 0 with no output, and the normal permission flow decides. What it protects is listed at the top.
.claude/hooks/run-tests.mjsJavaScript// Stop hook: runs the tests each time Claude finishes a reply. // // - Tests pass: exit 0 with no output, and Claude stops as normal. // - Tests fail: print {"decision":"block","reason":...} so Claude keeps // working, with the end of the test output as the reason. // - Tests still fail on a turn this hook already extended (stop_hook_active // is true): let Claude stop, and warn the user with {"systemMessage":...}. // Without this check Claude could loop on a failure it can't fix. // // Registered in .claude/settings.json. Test it with: npm run check-hooks import { spawnSync } from 'node:child_process'; const TEST_COMMAND = 'npm test'; const TAIL_LINES = 40; // how much test output to pass back to Claude const TIME_LIMIT_MS = 100_000; // below the 120 second timeout in settings.json process.exitCode = await main(); async function main() { let input; try { input = JSON.parse(await readStdin()); } catch { console.error('run-tests: the hook input was not valid JSON'); return 1; } const projectDir = process.env.CLAUDE_PROJECT_DIR || input.cwd || process.cwd(); const run = spawnSync(TEST_COMMAND, { cwd: projectDir, shell: true, // so `npm` resolves on Windows too encoding: 'utf8', timeout: TIME_LIMIT_MS, maxBuffer: 10 * 1024 * 1024, }); if (run.status === 0) return 0; const output = [run.stdout, run.stderr, run.error?.message].filter(Boolean).join('\n'); const tail = output.trimEnd().split(/\r?\n/).slice(-TAIL_LINES).join('\n').slice(-6000); if (input.stop_hook_active === true) { print({ systemMessage: `${TEST_COMMAND} still fails after Claude tried to fix it. Claude has stopped; run ${TEST_COMMAND} to see the failures.`, }); } else { print({ decision: 'block', reason: [ `${TEST_COMMAND} failed, so the task isn't finished.`, 'Fix the code in src/ until the tests pass. The tests are locked: fix the code, not the tests.', '', `Last lines of the ${TEST_COMMAND} output:`, tail || '(no output)', ].join('\n'), }); } return 0; } function print(json) { process.stdout.write(`${JSON.stringify(json)}\n`); } async function readStdin() { process.stdin.setEncoding('utf8'); let text = ''; for await (const chunk of process.stdin) text += chunk; // Windows PowerShell 5.1 can start piped text with a byte order mark. return text.replace(/^\uFEFF/, ''); }When the tests fail, the script prints a
blockdecision with the end of the test output as itsreason, which keeps Claude working. If Claude is already carrying on because of this hook (stop_hook_activeis true) and the tests still fail, it lets Claude stop and shows you a warning instead, so it can never loop.scripts/check-hooks.mjsJavaScript// Runs both hook scripts with sample input, the way Claude Code would, and // checks what they do. Nothing here calls Claude or changes any file. // // npm run check-hooks import { spawnSync } from 'node:child_process'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; const root = path.resolve(fileURLToPath(new URL('..', import.meta.url))); const protectFiles = path.join(root, '.claude', 'hooks', 'protect-files.mjs'); const runTests = path.join(root, '.claude', 'hooks', 'run-tests.mjs'); const checks = [ { name: 'blocks edits to .env', run: () => editFile(protectFiles, 'Write', path.join(root, '.env')), expect: blocked, }, { name: 'allows edits to src/quote.js', run: () => editFile(protectFiles, 'Edit', path.join(root, 'src', 'quote.js')), expect: allowed, }, { name: 'blocks edits to test/quote.test.js', run: () => editFile(protectFiles, 'Write', path.join(root, 'test', 'quote.test.js')), expect: blocked, }, { name: 'allows edits to .env.example', run: () => editFile(protectFiles, 'Write', path.join(root, '.env.example')), expect: allowed, }, { name: 'blocks test files given as Windows paths', run: () => editFile(protectFiles, 'Write', 'C:\\acme-quotes\\test\\quote.test.js', 'C:\\acme-quotes'), expect: blocked, }, { name: 'lets Claude stop when the tests pass', run: () => runHook(runTests, { ...baseInput(root, 'Stop'), stop_hook_active: false }, root), expect: allowed, }, ]; let passed = 0; for (const check of checks) { const problem = check.expect(check.run()); if (problem) { console.log(`FAIL ${check.name}: ${problem}`); } else { passed += 1; console.log(`PASS ${check.name}`); } } console.log(`${passed} of ${checks.length} hook checks passed`); process.exitCode = passed === checks.length ? 0 : 1; /** Blocked means exit code 2 with a reason on stderr. */ function blocked(result) { if (result.status !== 2) return `expected exit code 2, got ${result.status}${why(result)}`; if (!result.stderr.trim()) return 'expected a reason on stderr'; return null; } /** Allowed means exit code 0 and nothing on stdout. */ function allowed(result) { if (result.status !== 0) return `expected exit code 0, got ${result.status}${why(result)}`; if (result.stdout.trim()) return `expected no output, got ${describe(result.stdout)}`; return null; } /** The most useful line of stderr: the first one that names an error, else the first line. */ function why(result) { const lines = result.stderr.split(/\r?\n/).map((line) => line.trim()).filter(Boolean); const line = lines.find((text) => /error/i.test(text)) ?? lines[0]; return line ? `. ${line}` : ''; } /** A short description of unexpected stdout, such as a block decision from run-tests. */ function describe(stdout) { try { const json = JSON.parse(stdout); if (json.decision === 'block') return `a block decision: ${String(json.reason).split('\n')[0]}`; } catch { // Not JSON: show the start of it instead. } return stdout.trim().split(/\r?\n/)[0].slice(0, 120); } function editFile(hook, toolName, filePath, projectDir = root) { return runHook( hook, { ...baseInput(projectDir, 'PreToolUse'), tool_name: toolName, tool_input: toolName === 'Write' ? { file_path: filePath, content: 'sample' } : { file_path: filePath, old_string: 'a', new_string: 'b', replace_all: false }, tool_use_id: 'toolu_check', }, projectDir, ); } function baseInput(projectDir, event) { return { session_id: 'check-hooks', transcript_path: path.join(root, 'check-hooks.jsonl'), cwd: projectDir, permission_mode: 'default', hook_event_name: event, }; } function runHook(hook, input, projectDir) { const result = spawnSync(process.execPath, [hook], { input: JSON.stringify(input), encoding: 'utf8', env: { ...process.env, CLAUDE_PROJECT_DIR: projectDir }, timeout: 120_000, }); return { status: result.status, stdout: result.stdout ?? '', stderr: result.stderr ?? '' }; }Feeds both hooks sample input and checks what they do. It never calls Claude or changes a file. You use it in step 3.
package.jsonJSON{ "name": "acme-quotes", "version": "1.0.0", "private": true, "description": "Prices a job for a small services business from a rate card. The practice project for the HorizonLux Claude Code Challenge.", "type": "module", "scripts": { "test": "node --test", "quote": "node src/cli.js", "check-hooks": "node scripts/check-hooks.mjs" }, "engines": { "node": ">=22" } }Only the
check-hooksline is new.CLAUDE.mdMarkdown# Acme Quotes A command-line quote calculator for a small services business. It prices hours per role from a rate card, takes off an optional discount, adds VAT and prints the total. Plain Node.js with no dependencies. ## Commands - `npm test`: run every test in `test/` with Node's built-in test runner. - `npm run quote -- design:10 dev:24 --discount 10`: print a sample quote. - Needs Node 22 or newer. Ask before adding any npm package. ## Code rules - `src/quote.js` stays pure: no file, network or console access. Input and output belong in `src/cli.js`. - ES modules with `import`/`export`. Relative imports keep the `.js` extension. - Money is whole cents (integers) inside `src/quote.js`. Only `formatMoney` turns cents into text. - Round to the cent with `Math.round` at each step: each line, the discount, the VAT. - The discount comes off before VAT is added. - Throw an `Error` with a message the user can act on. `src/cli.js` prints it and exits with code 1. - 2-space indentation, single quotes, semicolons. ## Before you say a task is done 1. Run `npm test`. Show the pass and fail counts from the output. 2. If you touched pricing, run `npm run quote -- design:10 dev:24 --discount 10`. The total is EUR 3,326.40 unless the task was meant to change it. 3. New behaviour in `src/quote.js` needs a test. `test/` is locked (see Hooks), so put the test you would add in your reply and let the user add it. ## Don't edit by hand - `data/rates.json`: prices are the owner's call. Change them only when asked, and list what changed. - `.env`: holds secrets. Don't open, print or commit it. `.env.example` is safe to edit. ## Hooks `.claude/settings.json` runs two hooks. They enforce the rules above, so work with them: - `.claude/hooks/protect-files.mjs` blocks edits to `.env` files and to anything in `test/`. The tests are locked: when one fails, fix the code in `src/`, not the test. - `.claude/hooks/run-tests.mjs` runs `npm test` each time you finish a reply. If it fails, you are asked to keep going. Read the failures it shows and fix the code. - The hooks only see the Edit and Write tools. Don't change `.env` or `test/` with shell commands either. - After changing a hook, run `npm run check-hooks`. It ends with `6 of 6 hook checks passed`.New: the Hooks section at the end, and item 3 of the done list. If you rewrote your CLAUDE.md, add just those parts. The hooks enforce the rules; CLAUDE.md tells Claude why, so it works with them rather than around them.
Note: There is no
chmodstep and nojq. Claude Code runsnode, which reads the script itself, and Node parses the JSON input without any extra tools.For “My own project”
In your own project, copy
.claude/settings.jsonand the two hook scripts. Skipscripts/check-hooks.mjs, thepackage.jsonchange and the practice CLAUDE.md: they are written for Acme Quotes. Then make three edits:- In
run-tests.mjs, setTEST_COMMANDto your test command, for example'pytest'. - In
protect-files.mjs, setLOCKED_FOLDERSto the folders (or single files) Claude must not edit, written relative to the project root with forward slashes, such as['tests/', 'migrations/']. - In both scripts, the messages tell Claude to fix the code in
src/. Change that to suit your project.
Then add a short Hooks section to your own CLAUDE.md: which files are locked, and that the tests run each time Claude finishes.
Check it worked
The settings file parses as JSON. This command prints
settings.json is valid JSON:Terminalnode -e "JSON.parse(require('fs').readFileSync('.claude/settings.json', 'utf8')); console.log('settings.json is valid JSON')"If it didn’t work
The check prints a `SyntaxError` instead.
The message gives a line and column. It is almost always a trailing comma, a missing comma between two entries, or a
//comment. Copy the file again with its copy button.Your project already had hooks in `.claude/settings.json`.
Keep a single
hooksobject. AddPreToolUseandStopas keys inside it, next to the events you already have. If an event is already there, add the new matcher group to its list.Step 3 of 7: Test the hooks by hand
Feed both hook scripts sample input the way Claude Code does, so you know they work before Claude Code runs them.Claude Code talks to a command hook through stdin, stderr, stdout and the exit code, so you can test one without Claude at all. The check script runs six cases for you:
Terminalnpm run check-hooksTo see what Claude Code sees, pipe one sample tool call into the guard yourself and print the exit code. Claude Code always sends a full path; a short one is fine here, because the guard checks the file name.
For macOS, Linux and WSL
Terminalecho '{"tool_name":"Write","tool_input":{"file_path":".env","content":"x"}}' | node .claude/hooks/protect-files.mjs echo $?For Windows
PowerShell'{"tool_name":"Write","tool_input":{"file_path":".env","content":"x"}}' | node .claude/hooks/protect-files.mjs $LASTEXITCODEFor “My own project”
npm run check-hooksis written for the practice project, so skip it. Run the guard test above, then test the gate. With your tests passing, it prints nothing and exits with 0:For macOS, Linux and WSL
Terminalecho '{"stop_hook_active":false}' | node .claude/hooks/run-tests.mjs echo $?For Windows
PowerShell'{"stop_hook_active":false}' | node .claude/hooks/run-tests.mjs $LASTEXITCODECheck it worked
In the practice project, after two header lines from npm,
npm run check-hooksends with:You should seePASS blocks edits to .env PASS allows edits to src/quote.js PASS blocks edits to test/quote.test.js PASS allows edits to .env.example PASS blocks test files given as Windows paths PASS lets Claude stop when the tests pass 6 of 6 hook checks passed
The hand test prints the guard’s reason, then the exit code
2:You should seeBlocked: .env holds secrets, so Claude may not edit it. Change .env.example instead, or ask the user to edit .env by hand. 2
If it didn’t work
npm says there is no `check-hooks` script.
package.jsonis still the old one. Save the Day 3package.jsonfrom step 2, or add thecheck-hooksline to itsscriptsyourself.A line starts with `FAIL`.
The text after the colon says what went wrong:
Cannot find module: a hook script is missing from.claude/hooks/, or it has a different name.expected no output, got a block decisionon the last check:npm testfails right now. Get the tests passing first; the gate needs a green suite to start from.
In PowerShell, `echo $?` prints `True` or `False`.
That is PowerShell’s success flag, not the exit code. Use
$LASTEXITCODE, as in the Windows commands above.The hand test prints `protect-files: the hook input was not valid JSON`.
The sample changed on the way in, usually from retyped quotes. Copy it with the copy button. Command Prompt treats quotes differently, so use PowerShell or a Bash shell for this test.
Step 4 of 7: Load the hooks in Claude Code
Start Claude Code in the project, accept the trust question if it appears, and confirm both hooks with /hooks.Start Claude Code in the project folder. If it is already running there, leave with
/exitand start it again. Claude Code reloads hooks by itself when a settings file changes, but it loads CLAUDE.md when it starts, so a fresh start also picks up the new Hooks section.TerminalclaudeIf Claude Code asks whether you trust this folder, say yes. Until you do, it holds back hooks from every settings file. Hooks run with your own user permissions, so in a project you did not write, read
.claude/settings.jsonand the scripts it runs before you accept.Then open the hooks list:
In Claude Code/hooksThe list is read-only. To change or remove a hook, you edit the settings file.
Check it worked
/hookslists one hook underPreToolUseand one underStop, each labelled as coming from project settings. Select one to see what it runs (nodeand the script path) and which settings file defines it. Press Esc to close the list.If it didn’t work
`/hooks` does not list them.
- Check that every file from step 2 is saved, then leave with
/exitand startclaudeagain in the project folder. - Run
claude doctorin a terminal. A settings file Claude Code can’t read is listed there with its path. Fix it as in step 2. - Check that the file is
.claude/settings.jsonin the project folder you started Claude Code from, not in a subfolder.
The list says `Only hooks from managed settings run here`.
Your organisation’s policy only allows its own hooks, so project hooks won’t run on this machine. Ask whoever manages Claude Code for your team, or follow along on a personal machine.
You said no to the trust question.
Start
claudeagain in the project folder and accept the trust question this time. If the session is still open, leave it with/exitfirst.- Check that every file from step 2 is saved, then leave with
Step 5 of 7: Watch the guard block an edit
Ask Claude to write to .env and to a test file, and see the guard stop both edits before they happen.Paste these one at a time. Each says it is a test, so Claude tries the edit instead of politely refusing because CLAUDE.md says no.
Paste into Claude CodeI'm testing my new hooks. Try to create a .env file in the project root containing the line ACME_API_KEY=practice-only, then tell me exactly what happened.Paste into Claude CodeNow try to add a one-line comment at the top of test/quote.test.js, and tell me what happened.The guard runs before Claude Code asks for your permission, in every permission mode, so these edits never reach a permission prompt.
For “My own project”
In your own project, swap
test/quote.test.jsfor a file in one of your locked folders.Check it worked
Both edits are blocked. Claude says a hook stopped it, and the transcript shows the guard’s reason (press Ctrl+O for the full transcript if it is cut short):
You should seeBlocked: .env holds secrets, so Claude may not edit it. Change .env.example instead, or ask the user to edit .env by hand. Blocked: test/quote.test.js is a locked test file. Fix the code in src/ so the tests pass, and don't change the tests.
And no
.envfile exists. Check from a second terminal:For macOS, Linux and WSL
Terminaltest -e .env && echo "found .env" || echo "no .env"It prints
no .env.For Windows
PowerShellTest-Path .envIt prints
False.If it didn’t work
Claude refuses without trying, because CLAUDE.md says not to touch `.env` or the tests.
That is CLAUDE.md doing its job. Reply
Please try the edit anyway, so I can see the hook block it.Claude tries to write the file with a shell command instead.
Press Esc to stop it, or choose No if Claude Code asks. The guard only watches the Edit and Write tools, so a shell command can still change any file. The Day 3 CLAUDE.md asks Claude not to go around the hooks this way.
The edit went through, or you see a `PreToolUse hook error` notice.
The guard didn’t run, or it crashed, and a hook that fails with any exit code other than 2 lets the action go ahead. Run the step 3 checks in the terminal you start Claude Code from. If they pass, run
claude --version: these hooks use theargsfield from Claude Code 2.1.139, so runclaude updateif yours is older. Then delete the.envfile it made, or undo the edit with/rewind.Step 6 of 7: Watch the test gate send Claude back
Break the code yourself, give Claude an unrelated task, and watch the Stop hook keep Claude working until the tests pass.First, plant a bug. Open
src/quote.jsin your editor and find the lineconst discountCents = Math.round((subtotalCents * discountPercent) / 100);. Change/ 100to/ 10and save, so every discount is ten times too big.Run
npm test: one test now fails,takes the discount off before adding VAT.Now give Claude a small task that has nothing to do with the bug. The prompt says the tests can be skipped on purpose: that is exactly the kind of shortcut the gate is there to catch.
Paste into Claude CodeAdd a short Roles section to README.md that lists each role in data/rates.json with its hourly price. It's a docs-only change, so you can skip the tests. Tell me when you're done.When Claude finishes the README, the Stop hook runs
npm test(you may catchRunning the testsby the spinner). A test fails, so the hook blocks the stop and hands Claude the failing output. The guard won’t let it edittest/, so the fix has to go insrc/quote.js. If the tests still fail after that try, the hook lets Claude stop and warns you instead.For “My own project”
In your own project, break one line that a test covers and note what you changed, then give Claude any small task. If you would rather not break anything, skip the bug: the hook runs your tests when Claude finishes and, with them passing, lets Claude stop.
Check it worked
Claude doesn’t stop after the README edit. The transcript shows the Stop hook’s message, which begins:
You should seenpm test failed, so the task isn't finished. Fix the code in src/ until the tests pass. The tests are locked: fix the code, not the tests.
Claude then finds the discount line in
src/quote.js, puts/ 100back, and finishes once the tests pass. Check it yourself:npm testpasses again, and the sample quote is back to a total of EUR 3,326.40.Terminalnpm run quote -- design:10 dev:24 --discount 10If it didn’t work
Claude ran the tests itself and fixed the bug before it finished.
That is fine: the hook still ran at the end, saw the tests pass and let Claude stop. To see the block, plant the bug again and ask Claude a question instead of giving it a task, such as
What does formatMoney do?Claude stops and asks what to do, and you see a warning that `npm test` still fails.
The hook sends Claude back once per reply. The second time, it lets Claude stop and warns you instead, so it can’t loop:
You should seenpm test still fails after Claude tried to fix it. Claude has stopped; run npm test to see the failures.
Reply
Yes, please fix the failing test.Claude Code says a Stop hook blocked too many times in a row.
That is Claude Code’s own safety cap: after a Stop hook blocks eight times in a row with no tool call in between, it ends the turn. This script checks
stop_hook_active, so it should never get there. If you write your own Stop hook, give it the same check.Every reply now pauses while the tests run.
Stop fires each time Claude finishes a reply, not only when a task is done. With fast tests you won’t notice. With slow ones, point
TEST_COMMANDat a quicker subset, or turn the hooks off for that session (step 7).Your tests need longer than about 100 seconds.
The script stops a test run after 100 seconds and reports it as a failure, so Claude is sent back even when nothing is broken. Raise
timeoutin.claude/settings.json(seconds) andTIME_LIMIT_MSinrun-tests.mjs(milliseconds) together. KeepTIME_LIMIT_MSa little lower, so the script reports the slow run to Claude before Claude Code cancels the hook.On Windows, the test output from the hook says `npm` isn’t recognised.
The hook runs
npm testthrough the system shell, which needs Node’s folder on your PATH. Open a new terminal after installing Node, check thatnpm testworks there, then start Claude Code from that terminal.Step 7 of 7: Turn the hooks off when you need to
Optional: start one session with every hook off, and learn the few rules that keep hooks safe and quick.This step is optional. Some sessions don’t need the gate, like a quick question while your tests are red on purpose. You can start Claude Code with every hook off for that one run, without touching the project’s settings:
For macOS, Linux and WSL
Terminalclaude --settings '{"disableAllHooks": true}'For Windows
PowerShell versions pass quotes inside an argument differently, so put the setting in a small file and pass its path instead:
PowerShellSet-Content -Path .claude\hooks-off.json -Value '{"disableAllHooks": true}' -Encoding ascii claude --settings .claude\hooks-off.jsonThe next time you start
claudewithout the flag, both hooks are back. A few things to keep in mind as you write your own:- Exit code 2 is the only code that blocks on its own. Exit code 1 with no JSON output counts as a hook error: Claude Code shows a notice and the action goes ahead. To stop Claude, exit with 2, as the guard does, or exit 0 and print a JSON decision, as the test gate does.
- There is no off switch for one hook. To drop one, delete its entry from the settings file. To turn all hooks off for a while, set
"disableAllHooks": truein a settings file. - Keep hooks quick. The guard runs before every edit and the gate after every reply, so a slow hook slows every turn.
- Hooks run as you. They have your full user permissions, so read the hooks in any project you didn’t write before you trust the folder.
Check it worked
In the session with hooks off, ask Claude to add a comment to
test/quote.test.js. NoBlocked:message appears: Claude Code treats it like any other edit and, depending on your permission mode, asks you first or goes ahead. Decline it, or undo it with/rewindand choose Restore code.If it didn’t work
The edit is still blocked.
This session didn’t start with the flag. Leave with
/exitand run the command above again, from the project folder.
Finish · 7 of 7 steps to go
Claude now checks its own work
What you’ll have checked
- The guard blocks edits to
.envand totest/, and the transcript shows its reason. - With a test failing, Claude is sent back after its reply and finishes once
npm testpasses. /hookslists thePreToolUseandStophooks from project settings.npm run check-hooksends with6 of 6 hook checks passed.
New lessons by email
Get the next lesson by email
This is the latest lesson. Leave your email and we’ll send you the next one when it’s live.
Claude Code for your team
Want Claude Code set up across your team?
HorizonLux sets up and repairs Claude Code for teams: CLAUDE.md rules, hooks that enforce them and reviewed MCP servers. Book a free call to talk it through.
Related
Questions
Questions about Day 3
What is the difference between exit code 1 and exit code 2 in a hook?
Exit code 2 is the only code that blocks on its own. In a PreToolUse hook it stops the tool call, and whatever the script wrote to stderr goes to Claude as the reason. Exit code 1, the usual failure code elsewhere, counts as a non-blocking hook error when the script prints no JSON: Claude Code shows a notice and the action goes ahead. A guard that exits with 1 guards nothing. A hook can also block by exiting 0 and printing a JSON decision, which is how the test gate sends Claude back.
Why not just write "run the tests" in CLAUDE.md?
Keep it there too. CLAUDE.md is context Claude reads and usually follows, but nothing enforces it, and on a long task it can slip. A hook is a command Claude Code runs itself at a fixed point, so the tests run every time Claude finishes, whatever Claude decided. They work best together: CLAUDE.md explains the rule and the hook enforces it.
Do these hooks work on Windows without Git Bash?
Yes. Both hooks start node directly through the args field, with no shell in between, so they don’t depend on Git Bash, PowerShell or jq. The test gate then runs npm test through the system shell, which is Command Prompt on Windows. You only need Node.js on your PATH.
What if my tests take minutes?
The gate runs after every reply, so a slow suite makes every reply slow. Point TEST_COMMAND in run-tests.mjs at a quick subset, such as your unit tests, and run the full suite yourself or in CI. If the subset still needs more than about 100 seconds, raise timeout in settings.json (in seconds) and TIME_LIMIT_MS in the script together.
Can hooks be dangerous?
They can do anything you can. Command hooks run with your full user permissions, so a hook in a cloned repository can read, change or delete your files. In an interactive session, Claude Code holds back settings-file hooks until you accept the folder’s trust question, so read .claude/settings.json and the scripts it runs before you accept. A claude -p run treats the folder as trusted and runs them without asking.
How do I turn hooks off?
For one run, start Claude Code with claude --settings and a JSON string or file that sets disableAllHooks to true; step 7 shows both. For longer, set "disableAllHooks": true in a settings file. To remove a single hook, delete its entry from the settings file: there is no switch for one hook. Hooks your organisation sets in managed settings stay on either way.
Does the guard stop Claude changing .env with a shell command?
No. It only watches the Edit and Write tools, so a shell command could still write the file. The Day 3 CLAUDE.md asks Claude not to go around the hooks, and in Manual permission mode Claude Code asks you before most shell commands run. For a firmer rule, add permission deny rules as well; the permissions page in the Claude Code docs explains them.
Sources
Checked against the official Claude Code docs on .
- Hooks reference
- Automate actions with hooks
- Settings files and precedence
- CLI reference
- Commands
- Configure permissions
- Choose a permission mode
- Best practices for Claude Code
- How Claude remembers your project
- Interactive mode
- Checkpointing
- Claude Code changelog
Independent resource, not affiliated with Anthropic. HorizonLux wrote this lesson; Claude Code is made by Anthropic.