Skip to content

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 .env files and your test folder, and tells it why.
  • A test gate that runs npm test each 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.

You’ll need

  • Claude Code installed and signed in. Day 1 walks you through it.
  • Node.js 22 or newer. The hooks are small Node scripts, and the practice project needs Node too.
  • A project with a test command: the practice project from Day 2, a fresh kit, or your own.

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.

0 of 7 steps done0%
  1. 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 .env files and to the test/ 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

    Terminal
    npm test
    echo $?

    For Windows

    PowerShell
    npm test
    $LASTEXITCODE

    For “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-2 folder that holds package.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-3 folder that holds package.json and .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 test or pytest, 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.ps1 launcher, so npm test never ran and the number printed after it means nothing. Type npm.cmd in place of npm today, as in npm.cmd test. Or allow local scripts for your user once, then run npm test again:

    PowerShell
    Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser
    A 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 || true at 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.

  2. 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 the hooks key 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 matcher Edit|Write means the Edit or the Write tool, matched exactly. Stop takes no matcher: it runs every time Claude finishes a reply.

    • command plus args starts node directly 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.
    • timeout is in seconds. Without it, a command hook may run for up to 10 minutes.
    • statusMessage is 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 block decision with the end of the test output as its reason, which keeps Claude working. If Claude is already carrying on because of this hook (stop_hook_active is 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-hooks line 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 chmod step and no jq. Claude Code runs node, 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.json and the two hook scripts. Skip scripts/check-hooks.mjs, the package.json change and the practice CLAUDE.md: they are written for Acme Quotes. Then make three edits:

    1. In run-tests.mjs, set TEST_COMMAND to your test command, for example 'pytest'.
    2. In protect-files.mjs, set LOCKED_FOLDERS to the folders (or single files) Claude must not edit, written relative to the project root with forward slashes, such as ['tests/', 'migrations/'].
    3. 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:

    Terminal
    node -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 hooks object. Add PreToolUse and Stop as keys inside it, next to the events you already have. If an event is already there, add the new matcher group to its list.

  3. 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:

    Terminal
    npm run check-hooks

    To 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

    Terminal
    echo '{"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
    $LASTEXITCODE

    For “My own project”

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

    Terminal
    echo '{"stop_hook_active":false}' | node .claude/hooks/run-tests.mjs
    echo $?

    For Windows

    PowerShell
    '{"stop_hook_active":false}' | node .claude/hooks/run-tests.mjs
    $LASTEXITCODE

    Check it worked

    In the practice project, after two header lines from npm, npm run check-hooks ends with:

    You should see
    PASS  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 see
    Blocked: .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.json is still the old one. Save the Day 3 package.json from step 2, or add the check-hooks line to its scripts yourself.

    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 decision on the last check: npm test fails 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.

  4. 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 /exit and 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.

    Terminal
    claude

    If 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.json and the scripts it runs before you accept.

    Then open the hooks list:

    In Claude Code
    /hooks

    The list is read-only. To change or remove a hook, you edit the settings file.

    Check it worked

    /hooks lists one hook under PreToolUse and one under Stop, each labelled as coming from project settings. Select one to see what it runs (node and 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 /exit and start claude again in the project folder.
    • Run claude doctor in 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.json in 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 claude again in the project folder and accept the trust question this time. If the session is still open, leave it with /exit first.

  5. 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 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.
    Paste into Claude Code
    Now 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.js for 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 see
    Blocked: .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 .env file exists. Check from a second terminal:

    For macOS, Linux and WSL

    Terminal
    test -e .env && echo "found .env" || echo "no .env"

    It prints no .env.

    For Windows

    PowerShell
    Test-Path .env

    It 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 the args field from Claude Code 2.1.139, so run claude update if yours is older. Then delete the .env file it made, or undo the edit with /rewind.

  6. 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.js in your editor and find the line const discountCents = Math.round((subtotalCents * discountPercent) / 100);. Change / 100 to / 10 and 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 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.

    When Claude finishes the README, the Stop hook runs npm test (you may catch Running the tests by the spinner). A test fails, so the hook blocks the stop and hands Claude the failing output. The guard won’t let it edit test/, so the fix has to go in src/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 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 then finds the discount line in src/quote.js, puts / 100 back, and finishes once the tests pass. Check it yourself: npm test passes again, and the sample quote is back to a total of EUR 3,326.40.

    Terminal
    npm run quote -- design:10 dev:24 --discount 10

    If 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 see
    npm 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_COMMAND at 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 timeout in .claude/settings.json (seconds) and TIME_LIMIT_MS in run-tests.mjs (milliseconds) together. Keep TIME_LIMIT_MS a 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 test through the system shell, which needs Node’s folder on your PATH. Open a new terminal after installing Node, check that npm test works there, then start Claude Code from that terminal.

  7. 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

    Terminal
    claude --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:

    PowerShell
    Set-Content -Path .claude\hooks-off.json -Value '{"disableAllHooks": true}' -Encoding ascii
    claude --settings .claude\hooks-off.json

    The next time you start claude without 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": true in 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. No Blocked: 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 /rewind and choose Restore code.

    If it didn’t work

    The edit is still blocked.

    This session didn’t start with the flag. Leave with /exit and run the command above again, from the project folder.

Finish · 7 of 7 steps to go

Claude now checks its own work

Two rules now hold every time: Claude can’t edit your secrets or your tests, and it can’t finish on a failing test without being sent back to fix it. CLAUDE.md explains the rules, and the hooks make sure they stick. When you next write a rule you’d never want broken, ask whether it belongs in a hook.

What you’ll have checked

  • The guard blocks edits to .env and to test/, and the transcript shows its reason.
  • With a test failing, Claude is sent back after its reply and finishes once npm test passes.
  • /hooks lists the PreToolUse and Stop hooks from project settings.
  • npm run check-hooks ends with 6 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.

We email you when a new lesson is published. 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 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 .

Independent resource, not affiliated with Anthropic. HorizonLux wrote this lesson; Claude Code is made by Anthropic.