Day 14 of 52 · about 25 min · Hooks
Make Claude check its own work with hooks and tests
A CLAUDE.md rule is advice Claude usually follows. A hook runs every time its event fires, whatever Claude decides, so the check really runs.
You’ll finish with
- A guard that blocks edits to
.envandtest/ - A test gate that sends Claude back when tests fail
You’ll need
- Claude Code set up (Day 1) and Node.js 22+
- The practice project with passing tests, or your own with a test command
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.
The steps
One step at a time. When a step’s check matches, tick it and the next one opens.
Step 1 of 4: Add the hooks pack
Save the settings and both hook scripts, then test them..claude/settings.json- PreToolUse: matcher "Edit|Write"
- Runs
protect-files.mjsbefore every Edit or Write. - Stop: no matcher
- Runs
run-tests.mjswhen Claude finishes a reply. - args: ["${CLAUDE_PROJECT_DIR}/.claude/hooks/…"]
commandisnode: it starts directly, no shell, on any system.- timeout: 120
- Seconds before Claude Code cancels the hook.
Strict JSON: one comment or trailing comma breaks the file. Save each file at the path shown. If you already have a
.claude/settings.json, add thehookskey to it rather than replacing it..claude/settings.jsonJSON{ "$schema": "https://json.schemastore.org/claude-code-settings.json", "permissions": { "allow": [ "Bash(npm test *)", "Bash(npm run quote *)" ], "ask": [ "Bash(git push *)", "Edit(/data/rates.json)" ], "deny": [ "Read(./.env)" ] }, "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" } ] } ] } }.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/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/, ''); }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 ?? '' }; }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" } }New: the
check-hooksline.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.
Terminal or PowerShellnpm run check-hooksCheck it worked
The check feeds both scripts sample input and ends with:
You should see6 of 6 hook checks passed
If it didn’t work
The last check fails with a block decision
npm testalready fails. Get the tests passing first.Step 2 of 4: Load the hooks in Claude Code
Start Claude Code, accept the trust question, confirm with /hooks.TerminalRun: claude
What happens: Accept the trust question: until then, hooks are held back.
You type: /hooks
Hooks run with your permissions: read them before trusting someone else’s project. Check it worked
/hookslists onePreToolUseand oneStophook from project settings. Esc closes it.Step 3 of 4: Watch the guard block an edit
Ask Claude to write a .env file and watch the guard stop it.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.In order:
Claude
Claude calls Write
Hook
protect-files.mjs reads the call
JSON on stdin
If the file is .env or in test/
Exit 2: blocked; Claude gets the reason
If it’s another file
Exit 0: normal permissions decide
The guard sees Edit and Write, not shell commands. Check it worked
Claude reports the block with this reason, and no
.envfile exists: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.
Step 4 of 4: Watch the test gate send Claude back
Break the code, then give Claude an unrelated task.In
src/quote.js, on thediscountCentsline, change/ 100to/ 10and save. Then give Claude this task: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.In order:
Claude
Claude finishes its reply
Hook
run-tests.mjs runs npm test
If the tests pass
Claude stops
If they fail
A block sends Claude back with the failures
If they still fail after that retry
Claude stops and you see a warning
stop_hook_activetells the script it already sent Claude back, so it never loops.Check it worked
Claude doesn’t stop after the README edit. The hook’s message 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 restores
/ 100and stops oncenpm testpasses.If it didn’t work
Claude fixed the bug before finishing
Fine: the hook ran and let it stop. To see the block, break the line again and ask a question instead.
Finish · 4 of 4 steps to go
Claude checks its own work
What you’ll have checked
- The guard blocked the
.envedit - The Stop hook sent Claude back until
npm testpassed
Lesson updates by email
Get lesson updates by email
Leave your email and we’ll tell you when we add or update a lesson.
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 14
How do I turn hooks off for one session?
Start `claude --settings` with `{"disableAllHooks": true}`, inline or as a file. You can’t switch off a single hook: delete its entry.
Sources
Checked against the official Claude Code docs on .
Independent resource, not affiliated with Anthropic. HorizonLux wrote this lesson; Claude Code is made by Anthropic.