Skip to content

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 .env and test/
  • 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.

0 of 4 steps done0%
  1. 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.mjs before every Edit or Write.
    Stop: no matcher
    Runs run-tests.mjs when Claude finishes a reply.
    args: ["${CLAUDE_PROJECT_DIR}/.claude/hooks/…"]
    command is node: 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 the hooks key 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-hooks line.

    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 PowerShell
    npm run check-hooks

    Check it worked

    The check feeds both scripts sample input and ends with:

    You should see
    6 of 6 hook checks passed

    If it didn’t work

    The last check fails with a block decision

    npm test already fails. Get the tests passing first.

  2. Step 2 of 4: Load the hooks in Claude Code

    Start Claude Code, accept the trust question, confirm with /hooks.
    Terminal

    Run: 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

    /hooks lists one PreToolUse and one Stop hook from project settings. Esc closes it.

  3. 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 Code
    I'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:

    1. Claude

      Claude calls Write

    2. 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 .env file exists:

    You should see
    Blocked: .env holds secrets, so Claude may not edit it. Change .env.example instead, or ask the user to edit .env by hand.
  4. 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 the discountCents line, change / 100 to / 10 and save. Then give Claude this task:

    Paste into Claude Code
    Add 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:

    1. Claude

      Claude finishes its reply

    2. 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_active tells 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 see
    npm 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 / 100 and stops once npm test passes.

    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

CLAUDE.md explains the rules; the hooks make them hold every time.

What you’ll have checked

  • The guard blocked the .env edit
  • The Stop hook sent Claude back until npm test passed

Lesson updates by email

Get lesson updates by email

Leave your email and we’ll tell you when we add or update a lesson.

We’ll email you when we add or update a lesson. We use your email only for this. Privacy policy

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.

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.