Rules get skimmed. A CLAUDE.md line saying "always run the typecheck before telling me a change is done" holds for about a week, and then a long session fills up and a build that does not compile ships anyway.

The idea

A hook is a command your tooling runs at a fixed event, with no judgement involved. After a file is edited, run the typecheck. Before a commit is created, run the tests. Nobody decides whether the check is warranted this time, which is exactly the property you want, because the times it gets skipped are the times it would have caught something.

code
   you / the AI edit a file
            │
            ▼
   [ hook: pnpm typecheck ]        local, seconds, runs on every edit
            │ fails → the agent sees the error and fixes it now
            ▼
   you run: git commit
            │
   [ hook: pnpm test ]             local, ~30s, blocks a bad commit
            │
            ▼
   git push  ──►  GitHub Actions   clean machine, full build + tests
                       │           runs on what you pushed, not on your laptop
                       ▼
                  merge allowed

How it works

There are three places a check can live, and they trade speed against trustworthiness.

  • Editor or agent hooks. Claude Code runs shell commands on events like PostToolUse, configured in .claude/settings.json. A typecheck wired to file edits means it sees its own compile errors in the same session and fixes them without you noticing there was a problem.
  • Git hooks. Git runs a script of its own at moments like pre-commit and pre-push, and a pre-commit hook that runs the tests refuses to create the commit if they fail. Two things about the raw mechanism catch people out: the script has to be executable (chmod +x), and the .git/hooks folder it lives in is never committed and never cloned, so a hook you write by hand exists on one machine. Husky and Lefthook fix that second problem by different routes. Husky keeps the scripts in a committed .husky/ folder and points git at it, which makes git stop reading .git/hooks at all. Lefthook keeps a committed lefthook.yml and generates the hooks from it.
  • . GitHub Actions is the common one. It starts from a clone of what you actually pushed, which is the only place that catches "works on my machine" problems: a file you never committed, or a dependency you installed globally two months ago. On billing, checked 2026-08-13: public repositories run free on standard runners, private ones get 2,000 minutes a month on a free GitHub account, and Linux is the cheapest runner by some distance.

Speed decides where a check belongs. A check that takes one second belongs on every edit. Thirty seconds belongs on commit. Five minutes belongs in CI, where you are not sitting and waiting for it.

What to do

  1. Wire the typecheck first. It is the check an AI assistant benefits from most, turning a silent mistake into an error message the agent can act on in the same turn. In .claude/settings.json:

    json
    {
      "hooks": {
        "PostToolUse": [
          {
            "matcher": "Write|Edit",
            "hooks": [{ "type": "command", "command": "pnpm typecheck || exit 2" }]
          }
        ]
      }
    }

    The || exit 2 is load-bearing: status 2 is the one that hands the error back to Claude to fix, and any other failure code only prints to you. Use PostToolUse if your typecheck is quick. Anything slower belongs on Stop, which fires when Claude Code finishes responding and takes no matcher, so drop that key there. Stop also suits multi-file work: a typecheck after the first edit of a twelve-file rename reports errors about the eleven not changed yet, and an agent fixing those makes the session worse.

  2. Add a CI workflow that runs your build and your checks on every push, against a database created and destroyed with the job. Never point CI at your real one. As .github/workflows/ci.yml:

    yaml
    name: ci
    on:
      push: { branches: [main] }
      pull_request:
    jobs:
      check:
        runs-on: ubuntu-latest
        services:
          postgres:
            image: postgres:16
            env: { POSTGRES_PASSWORD: ci }
            ports: ['5432:5432']
            options: >-
              --health-cmd pg_isready --health-interval 10s
              --health-timeout 5s --health-retries 5
        env:
          DATABASE_URL: postgres://postgres:ci@localhost:5432/postgres
        steps:
          - uses: actions/checkout@v7
          - uses: pnpm/action-setup@v6
          - uses: actions/setup-node@v7
            with: { node-version: 22, cache: pnpm }
          - run: pnpm install --frozen-lockfile
          - run: pnpm check

    The services block is the throwaway database and DATABASE_URL points at it. Deliberately absent is a stored secret holding your production connection string: once one exists, every workflow file in the repository can read it, and a test that empties a table empties a real one. Those pins are the current majors on 2026-08-13 and will age. pnpm/action-setup has to come after the checkout and takes its version from the packageManager field in your package.json, failing outright if you have not set one.

  3. Leave everything beyond those two manual until it has burned you twice. The typecheck and CI pay for themselves on any project. Automating a check nobody has ever needed adds delay and teaches you to ignore output.

Where it breaks

A check that fails often for reasons that do not matter gets bypassed, and once people learn the bypass they use it for the failures that did matter. That bypass is git commit --no-verify, worth knowing before the 11pm demo when a flaky test is in your way. It also means a local hook is a speed bump rather than a gate: the gate is CI, which you cannot skip from your own terminal.

Hooks cannot judge. They can tell you the code compiles. They cannot tell you the feature is the right one, that the copy is honest, or that the page looks correct on a phone. Anything where being wrong is expensive still needs a person looking at it. And hooks run shell commands, so a hook is code with your permissions: review one before you install it, particularly a shared config from a repository you did not write.