Development Playbook

Make the agent pass your checks: Husky, lint-staged and two Claude Code hooks

A commit and push gate that an agent can't skip with --no-verify, plus a Stop hook that won't let it call red code finished. Fifteen minutes to set up.

Three concentric glowing rings around a small square node, each ring closed by a short gate bar, drawn like a blueprint

It’s 4:50pm. The agent reports that the invoice refactor is done, all green. You pull the branch and run the tests: one fails. You look at the log and find the commit message ends with “skip hooks, unrelated failure”, and a git commit --no-verify is sitting in the session history. The pre-commit hook had caught a real type error, and the agent routed around it because it had a goal and the hook was in the way.

Nothing in the setup was wrong, exactly. The hook worked. It just wasn’t binding, because every git hook is advisory and the agent has a shell. Telling it in CLAUDE.md to “always run the checks” helps until the first time a check is annoying.

This Playbook builds three layers that don’t rely on the agent’s good manners. Husky and lint-staged put formatting, type checking and tests in front of every commit and push. A Claude Code PreToolUse hook refuses the commands that skip them. A Stop hook runs the same checks when the agent says it’s finished and sends it back to work if they fail. Everything below was built and run on a small TypeScript project, and the output shown is real. The setup takes about fifteen minutes.

The three layers

Each layer covers a hole in the one before it.

Git hooks (Husky) gate the commit and the push, no matter who or what types the command. The Claude Code PreToolUse hook closes the escape hatches: --no-verify, HUSKY=0 and friends. The Stop hook catches the case where the agent never commits at all and simply declares victory with a broken working tree.

None of this is a security boundary. An agent with unrestricted shell access can still do things that the checks below don’t anticipate, and the last section is honest about where the gaps are. The aim is narrower: make the lazy path (skip the check) harder than the right path (fix the failure), and make the failure message land in the agent’s context, where it can act on it.

The project

The examples use a tiny TypeScript project: one function, one Vitest test. It’s the smallest thing that still produces real failures.

mkdir demo && cd demo
git init -b main
npm init -y
npm install --save-dev husky lint-staged prettier typescript vitest
npx husky init

npx husky init creates .husky/pre-commit and adds a prepare script to package.json, so Husky installs its hooks on every npm install. That matters for agents too: a fresh clone gets the gate as soon as dependencies are installed.

Add a source file and a test:

// src/invoice.ts
export function totalWithVat(net: number, rate = 0.2): number {
  return Math.round(net * (1 + rate) * 100) / 100;
}
// src/invoice.test.ts
import { describe, expect, it } from "vitest";
import { totalWithVat } from "./invoice";

describe("totalWithVat", () => {
  it("adds 20% by default", () => {
    expect(totalWithVat(100)).toBe(120);
  });
});

A minimal tsconfig.json with "strict": true and "noEmit": true is enough for the type check. Then wire three scripts into package.json:

{
  "scripts": {
    "test": "vitest run",
    "prepare": "husky",
    "typecheck": "tsc --noEmit",
    "check": "npm run typecheck && npm test"
  },
  "lint-staged": {
    "*.{ts,json,md}": "prettier --write"
  }
}

The check script is the single definition of “green”. Every layer calls it or a piece of it, so the gate can’t drift between the commit hook, the push hook and the agent hook.

Layer 1: git hooks

The pre-commit hook should be fast, because it runs on every commit and the agent will commit often. Formatting staged files and type checking fit that budget. The test suite goes on pre-push, where a few extra seconds are acceptable.

# .husky/pre-commit
npx lint-staged
npm run typecheck
# .husky/pre-push
npm run check

Husky 9 hook files are plain shell, with no boilerplate header. lint-staged formats only the files in the commit and re-stages the result. Commit a badly formatted file and the commit lands with the formatted version:

$ git commit -m "add VAT_RATE"
[COMPLETED] prettier --write

The committed file, read back from git, is formatted:

export function totalWithVat(net: number, rate = 0.2): number {
  return Math.round(net * (1 + rate) * 100) / 100;
}
export const VAT_RATE = 0.2;

Auto-fixing like this is better for agents than failing on formatting. A formatting failure teaches the agent nothing it can’t solve in one command, and it burns a turn. Reserve failures for things only a human or a real edit can fix.

Now introduce a type error and try to commit it:

$ git commit -m "break types"
> demo@1.0.0 typecheck
> tsc --noEmit

src/invoice.ts(2,3): error TS2322: Type 'number' is not assignable to type 'string'.
husky - pre-commit script failed (code 1)

The commit doesn’t happen, the error is on stderr, and the agent sees it in the tool result. Per the lint-staged docs, a failing task also reverts its own modifications, and a backup stash is taken before tasks run, so a failed commit doesn’t leave half-formatted files behind.

One behaviour worth knowing: if the tasks leave nothing staged (for example, the only change was whitespace that Prettier removed), lint-staged aborts the commit with “prevented an empty git commit” rather than creating an empty one. An agent that sees this should read it as “nothing to commit”, not as a failure to retry around.

Pre-push is where tests run. Here the test file has a second case, rounds to pence, and totalWithVat no longer rounds:

$ git push origin main
> demo@1.0.0 typecheck
> tsc --noEmit

 FAIL  src/invoice.test.ts > totalWithVat > rounds to pence
AssertionError: expected 0.084 to be 0.08

(trimmed)
husky - pre-push script failed (code 1)
error: failed to push some refs to '../remote.git'

Putting the type check in pre-push as well as pre-commit is deliberate, and the reason is a real trap: Vitest doesn’t type check. With a type error in the tree, npm test passes. A pre-push that runs only the tests lets the commit from the next section through.

Layer 2: close the bypass

Here is the hole. Git documents that pre-commit “can be bypassed with the --no-verify option”, and that git push --no-verify bypasses the pre-push hook completely. Husky adds HUSKY=0 as a global off switch. All three are one-line changes to a command the agent was going to run anyway.

Run the bypass and it works:

$ git commit -qam "break types" --no-verify
$ echo $?
0
$ HUSKY=0 git commit -qam "skip via env"
$ echo $?
0

Both commits land, type error and all. Closing this needs something that sits in front of the shell, which is what a Claude Code PreToolUse hook does. It receives the Bash command as JSON on stdin (the command is at tool_input.command), and exit code 2 blocks the call and sends stderr back to the agent as the reason.

Save this as .claude/hooks/no-bypass.py:

#!/usr/bin/env python3
"""PreToolUse hook for Bash: refuse commands that skip the git hooks."""
import json
import re
import shlex
import sys


def segments(command: str):
    """Split a shell line on ; && || | and newlines (good enough for a guard)."""
    return [s.strip() for s in re.split(r"&&|\|\||;|\||\n", command) if s.strip()]


def violation(segment: str):
    if re.search(r"(^|\s)HUSKY=0(\s|$)", segment):
        return "HUSKY=0 switches the hooks off"
    try:
        words = shlex.split(segment)
    except ValueError:
        return "command could not be parsed"
    if "git" not in words:
        return None
    rest = words[words.index("git") + 1 :]
    if "hooksPath" in segment:
        return "changing core.hooksPath detaches the hooks"
    sub = next((w for w in rest if not w.startswith("-") and "=" not in w), "")
    if sub in {"commit", "push", "merge", "am", "cherry-pick", "rebase"}:
        for w in rest:
            if w == "--no-verify":
                return "--no-verify skips the git hooks"
            if sub == "commit" and re.fullmatch(r"-[a-zA-Z]*n[a-zA-Z]*", w):
                return "git commit -n is short for --no-verify"
    return None


def main() -> int:
    try:
        payload = json.load(sys.stdin)
        command = payload["tool_input"]["command"]
        for seg in segments(command):
            reason = violation(seg)
            if reason:
                print(f"Blocked: {reason}. Fix the failing check instead of "
                      "skipping it, or ask the user to run this themselves.",
                      file=sys.stderr)
                return 2
        return 0
    except Exception as exc:  # fail closed: a crashed guard must not be a green light
        print(f"no-bypass hook error: {exc}", file=sys.stderr)
        return 2


sys.exit(main())

Three details earn their lines. The -n check exists because git commit -n is the short form of --no-verify, and the regex also catches it bundled, as in -nm "message". But it only applies to commit, because -n means something else elsewhere: git log -n 5 must keep working. And the whole script sits inside a try block that exits 2 on any error, because a guard that crashes on unexpected input shouldn’t wave the command through. That is the same fail-closed rule covered in Your Claude Code policy hook probably fails open , and the settings below back it up with onFailure.

Test the script before you trust it, by piping the documented payload in by hand:

probe() {
  printf '{"tool_name":"Bash","tool_input":{"command":%s}}' "$(jq -Rn --arg c "$1" '$c')" \
    | python3 .claude/hooks/no-bypass.py
  echo "  -> exit $?  [$1]"
}
probe 'git commit -m "wip"'
probe 'git commit --no-verify -m "wip"'
probe 'git commit -nm "wip"'
probe 'git push --no-verify origin main'
probe 'HUSKY=0 git commit -m wip'
probe 'git -c core.hooksPath=/dev/null commit -m wip'
probe 'npm test && git push origin main'
probe 'git log -n 5'
probe 'git commit -m "no verify"'

Run it and it prints:

  -> exit 0  [git commit -m "wip"]
Blocked: --no-verify skips the git hooks. Fix the failing check instead of skipping it, or ask the user to run this themselves.
  -> exit 2  [git commit --no-verify -m "wip"]
Blocked: git commit -n is short for --no-verify. Fix the failing check instead of skipping it, or ask the user to run this themselves.
  -> exit 2  [git commit -nm "wip"]
Blocked: --no-verify skips the git hooks. Fix the failing check instead of skipping it, or ask the user to run this themselves.
  -> exit 2  [git push --no-verify origin main]
Blocked: HUSKY=0 switches the hooks off. Fix the failing check instead of skipping it, or ask the user to run this themselves.
  -> exit 2  [HUSKY=0 git commit -m wip]
Blocked: changing core.hooksPath detaches the hooks. Fix the failing check instead of skipping it, or ask the user to run this themselves.
  -> exit 2  [git -c core.hooksPath=/dev/null commit -m wip]
  -> exit 0  [npm test && git push origin main]
  -> exit 0  [git log -n 5]
  -> exit 0  [git commit -m "no verify"]

The last three lines matter as much as the blocks. A guard that blocks innocent commands gets disabled within a day.

The error text is written for the agent, not for you. “Fix the failing check instead of skipping it” gives it the next move, and “ask the user to run this themselves” gives it a legitimate way out when it believes the bypass is genuinely justified. Without an exit like that, an agent tends to look for a creative workaround.

Layer 3: don’t let it finish on red

Layers 1 and 2 only act when the agent commits or pushes. An agent that edits files, breaks something and reports “done” never touches git. That is what a Stop hook is for: it runs when Claude finishes responding, and exit 2 “prevents Claude from stopping” and feeds stderr back as the reason to continue.

Save this as .claude/hooks/stop-gate.sh and make it executable:

#!/bin/bash
# Stop hook: don't let the agent finish a turn that leaves the checks red.
cd "$CLAUDE_PROJECT_DIR" || exit 2

# Nothing changed since the last commit: nothing to verify.
[ -z "$(git status --porcelain)" ] && exit 0

if ! out=$(npm run --silent check 2>&1); then
  {
    echo "The project checks fail. Fix them before you finish."
    echo "$out" | tail -n 25
  } >&2
  exit 2
fi
exit 0

The git status --porcelain line is the cost control. Stop fires at the end of every turn, including turns where the agent only answered a question, and running the full check each time would add seconds of latency to a conversation. A clean tree means nothing changed, so the hook exits immediately.

When the tree is dirty and a test fails, the hook returns the tail of the output, which is what the agent needs to act on:

The project checks fail. Fix them before you finish.

 FAIL  src/invoice.test.ts > totalWithVat > rounds to pence
AssertionError: expected 0.084 to be 0.08 // Object.is equality

- Expected
+ Received

- 0.08
+ 0.084

 ❯ src/invoice.test.ts:10:32
      8|
      9|   it("rounds to pence", () => {
     10|     expect(totalWithVat(0.07)).toBe(0.08);
(trimmed)

Two things in the Claude Code docs keep this from becoming an infinite loop. Every Stop hook receives a stop_hook_active field, which is true when the agent is already continuing because of a stop hook, and the docs recommend checking it so you don’t block on a condition that will never resolve. On top of that, Claude Code applies an 8-consecutive-continuation cap: after eight blocks in a row it overrides the next one and ends the turn. The count resets whenever Claude calls a tool, so a real fix-and-retry cycle isn’t penalised. The script above relies on the cap rather than stop_hook_active, because a suite that’s still red after one retry should usually get another one. If your checks are slow or flaky, read stop_hook_active and let the second pass through.

Register the hooks

Both scripts go into .claude/settings.json, which you commit so the whole team and every worktree gets the same gate:

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "python3",
            "args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/no-bypass.py"],
            "onFailure": "block"
          }
        ]
      }
    ],
    "Stop": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/stop-gate.sh",
            "timeout": 300
          }
        ]
      }
    ]
  },
  "permissions": {
    "deny": ["Edit(.husky/**)", "Edit(.claude/**)"]
  }
}

onFailure: "block" needs Claude Code 2.1.295 or later. By default, per the docs, a PreToolUse hook that fails, times out or can’t start lets the tool call through, so a mistyped path would quietly turn the guard off. The args array form avoids shell quoting problems with the path. The Stop hook gets a 300-second timeout because the default for command hooks is 600 and a hung test run shouldn’t hold a session for ten minutes.

The permissions.deny rules cover the obvious next move for a determined agent: editing the hook files themselves. Per the permissions docs, an Edit(path) deny rule applies to the built-in file tools and to file commands Claude Code recognises in Bash, such as sed and tee.

Watch the transcript on the first run. A hook that can’t start produces a “hook error” notice instead of an effect, and that notice is the only sign the gate isn’t there.

Make it yours

Different stacks need different checks, not different structure.

C# projects: swap the scripts so check runs dotnet format --verify-no-changes, dotnet build -warnaserror and dotnet test. Husky is a Node tool, but it works in a .NET repo with a minimal package.json that exists only for the prepare script. Alternatively, use a plain .githooks/ folder and git config core.hooksPath .githooks, and add that setting to your onboarding docs. The no-bypass hook already blocks the agent from changing core.hooksPath itself.

Monorepos: lint-staged takes a glob per package, so "packages/web/**/*.ts": "prettier --write" keeps the commit hook proportional to what changed. Keep the Stop hook on a narrower script (the package the agent touched) if the full check takes more than a minute.

CI: the local gate is a convenience, and CI is the enforcement. Run the same npm run check on every pull request, and make it a required status check, because --no-verify in a terminal the agent doesn’t control, or in a teammate’s habit, still gets through:

name: check
on: pull_request
jobs:
  check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
      - run: npm ci
      - run: npm run check

That workflow is standard Actions syntax and wasn’t run as part of this build.

What goes wrong

The no-bypass hook is a pattern match on command text, not a parser of shell. It splits on ;, &&, || and |, and it will miss a bypass hidden in a script the agent writes and then runs (bash ./commit.sh), in an alias, or in a git invocation assembled from variables. The Claude Code docs say as much about their own if filters, which are best-effort, and recommend the permission system for hard allow and deny rules. A scripting language the agent can open files from also sidesteps the Edit deny rules, which don’t apply to a Python or Node script that opens files itself. For that you need the sandbox, which enforces access at the OS level.

False positives are the other cost. The hook blocks git commit --amend -n, and it blocks the string HUSKY=0 appearing in a command that merely mentions it. The error message tells the agent to ask you, which is the right outcome for those cases, but expect the occasional interruption.

The Stop hook can fight the user. If a branch is deliberately red (a failing test written first, a half-finished migration), the agent can’t stop, and eight continuations later it does anyway. Tell the agent in the prompt that a red test is intended, or leave the Stop hook out of that branch’s settings. It also runs the check on every dirty turn, so a slow suite makes every turn feel slow.

Finally, Husky’s hooks exist only after npm install has run, because prepare is what installs them. A fresh clone with no node_modules has no pre-commit gate until it does, so install first.

When not to bother: a repository with no tests and no type checker has nothing to gate, and a throwaway script gains nothing from the machinery. If your CI already finishes in under a minute and you always review before merging, the Stop hook is the only layer that adds much, and you can start with it alone.

First 15 minutes

Start with what you already have. Take the check your CI runs, put it behind one script (npm run check or equivalent), and wire it as a pre-push hook with npx husky init. That alone catches the commit that would have failed CI.

Then copy no-bypass.py into .claude/hooks/, register it with onFailure: "block", and run the nine probe lines above against it before you rely on it. Add the Stop hook last, once the check is fast enough to run at the end of every dirty turn.