Format, lint and test as a hook, not as a plea in the prompt
Move "run the formatter and the tests before you finish" out of the prompt and into two hooks: format on the write, verify at the stop. One script, wired six ways.
The line you keep writing into the prompt
We had a general instruction in our memory file to run format and tests before wrapping up. We also used to copy-paste it into prompts whenever we were planning to do some changes that are of higher stakes. But, just like with everything else, we didn't follow it perfectly sometimes and the only effect of this was that we saw it in a pull request, during CI run or from a teammate after they were brave enough to point it out to us. It's not about the model being sloppy, it's about that "did the tests pass" is a question a shell script answers with certainty for free — right now we're paying for it in context and in luck. We are asking here, we don't know. So we think that moving these three things (formatting the file you just wrote, linting the changed files and running the tests before you finish) out of prompts makes sense:
format the file that was just written
lint what changed
run the tests before claiming the work is done
Format at the write, verify at the stop
These three of them are split between two different hook events because they have different requirements. Let's take the format one:
It doesn't need to have a reply channel as it's just a side effect (you write a file and it gets formatted) and nobody is informed about it, so it's perfect to be placed in the after-write hook which is also cheap and happens often
Now, let's focus on the other two:
They both are about letting the model know sth and reacting based on that. The value of these is bound to what the model sees and does before wrapping up, so they need to go into the stop hook; they're also kinda slow so we'd rather avoid running them 12 times in a scenario when you change 12 files
Also, the most natural place for the lint and tests checks is the very moment you were writing about in the prompt's "before you finish" part. In other words, the stop event is the moment when you run a hook in a harness, and "before you finish" is not a figure of speech there — it's an event with a name
We can also keep the linter off the after-write event for another reason — two out of six harnesses don't support returning its output to the model from there:
The Copilot CLI's exit 2 in
postToolUseis displayed to the user as a warning, not to the agentKimi Code's
PostToolUseis an observation-only thing so it can't modify the turn
The stop event, on the other hand, is supported by all of them — five allow you to set up a hook that's stopping the stop and the Cursor instead auto-submits whatever you put in the hook as the next message (effectively stopping the stop too)
One script, two modes
So we can create a single script with two modes — one for the after-write event and another for the stop event. It will run the repository's existing checks and return a standardised output (nothing on success, the original tool's output on failure):
scripts/hooks/check.sh format # wire to the after-write event
scripts/hooks/check.sh verify # wire to the stop eventHere's the actual script at the end of the lesson, there are a few crucial decisions behind it that make it actually usable in a real repository:
Using
git diff --name-only HEADplus untracked files to determine what changed instead of relying on the event payload (every harness uses a different JSON structure to represent the event; here's how it looks like for example in Claude Code, Codex and Copilot):Claude Code —
tool_input.file_pathCodex — an
apply_patchcall with its arguments intool_input.commandCopilot —
toolArgs, typedunknown, shape left to the tool
git diff --name-only HEAD plus untracked files is a standardised interface that all of them understand; it's more sensible too, as it'll consider the file you wrote 3 tool runs earlier
Just keep in mind that we're limited by the quality of .gitignore, so if you don't have one in your repo you might end up in the situation we did, where our script accidentally included every .js file under node_modules and the linter reported an old TODO from a dependency we used
The script itself isn't altering the output of the linter — if it fails, it fails with its own message; but we need to trim it as all the hook outputs are subject to character limits (Claude Code cuts them after 10k characters, Copilot caps a
postToolUseadditionalContextat 10 KB) and we don't need 100 lines of stack trace at the end of it (we'll see only the last 40 anyway)If the linter hasn't been installed locally in a repo, the script returns 0 and the gate doesn't do anything for months — this is not good, so we need to make sure that any non-zero exit code results in the failure. Here's what the failure looks like if we were to run the script on a machine with the linter not installed:
Lint failed. Fix this before you finish:
npm error npx canceled due to missing packages and no YES option: ["[email protected]"]It's good to be loud in the places where you already know there's a problem, and hooks are permissive by default almost everywhere so it's this place that you can be loud in. Same reason why the script falls back to stderr when it can't find jq — without that, a failing check would print nothing and exit 0, and the harness would read it as a pass
The loop, and the guard for it
Now, let's say we have a gate secured with a stop hook. It does its thing and at some point, the model decides to stop the run. But our stop hook is blocking so it does more work and this work leads to another stop which runs the hook again. There's a built-in limit to this in Claude Code, Copilot CLI and Cursor:
Claude Code and Copilot CLI override the hook and terminate the turn after 8 consecutive blocks
Cursor has an automatic follow-up limit set to 5 by default
In Codex, Gemini and Kimi we didn't find any mention of such a limit so we might want to introduce one ourselves. The simplest solution is to stop the script as soon as we know it's already extending the turn; four out of six have a flag in their stop payload called stop_hook_active for that purpose (we don't need a JSON parser to access it):
case $(printf '%s' "$payload" | tr -d ' \t\n') in
*'"stop_hook_active":true'*) exit 0 ;;
esacAnd thanks to this, the model will get a single retry with the failure; if it's not resolved during the turn, it'll be closed and you can take over. That's what we want, not a compromise
The reason text is a prompt, not a log line
The most important thing though is that the output of the script should be treated as the model's next instruction, not as a log message. Let's test it — we ran the stop hook against an intentionally broken test runner, four lines that print a failure and exit 1, with the script sitting in .claude/hooks/ for that run:
Stop hook feedback:
[$CLAUDE_PROJECT_DIR/.claude/hooks/check.sh verify]: The tests are failing. Fix this before you finish:
FAIL greet() > returns a greeting
expected 'hello world', received 'hello'
1 failing, 3 passedThe model gets the feedback, word by word, saying that we should fix it before finishing and showing the output of the fake test runner
The turn continues and the model lists the directory, reads the test runner and realises it's a stub that always fails regardless of what code you write. That's why it refused to patch it: "Editing
greet.jsto return"hello world"would produce the exact same failure output — the hook would keep firing."
So:
The model treats the failure text as an instruction and does what you tell it to, so make sure your instructions are true; it's not good if it confidently does sth wrong because your check was lying
The model examines things before doing sth so make sure your checks are based on actual state, not just sound smart
In Claude Code
We have set up 2 hooks in .claude/settings.json which run the same script with different arguments on different events:
PostToolUse, matched onEdit|Write, calls it with theformatargumentStopcalls it with theverifyargument
{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{ "type": "command", "command": "$CLAUDE_PROJECT_DIR/scripts/hooks/check.sh format" }
]
}
],
"Stop": [
{
"hooks": [
{ "type": "command", "command": "$CLAUDE_PROJECT_DIR/scripts/hooks/check.sh verify" }
]
}
]
}
}Both of these are set to use exit-2 mode which works really well for both of these scenarios. In the case of Stop, it is because using exit 2 we can tell Claude not to end the turn and instead hand it stderr as an instruction, which is where the transcript you've seen above comes from. In the case of PostToolUse, despite the fact that the write has already been performed, running exit 2 will still send stderr to Claude (as per the docs), which is perfect in case the formatting script has some problems.
On the other hand, it's important to note that exit 1 gets treated as a non-blocking error and the action continues, that's why you might have seen people complaining about hooks not working, using this value.
And finally, if your test suite is really slow, you can set "async": true which will run the hook in the background, giving Claude the output during the next turn. If you also set "asyncRewake": true, it will even wake Claude on exit 2 in case the session has been idle. Neither of these will be able to block anything or make any decisions though, they are all about reporting.
So if for example you were to use a permission rule in the if field which allows you to define such syntax, like "if": "Edit(*.ts)", then this hook would be executed only when the tool call matches that rule — an Edit on a .ts file — so a change to the README wouldn't start it at all.
But the problem is that as the formatter modifies the file after the tool has returned its result, which means that at the moment of writing the file's contents on disk are different than what Claude remembers writing. In the 2.1.220 binary there's even a dedicated guard against this case, and its message names the culprit: "File has been modified since read, either by the user or by a linter. Read it again before attempting to write it." But in our run Claude read the file back before proceeding with the edit and both steps were green, so the only penalty was an additional read, rather than losing the turn.
So just to sum up, we'd keep the PostToolUse hook to formatting and nothing more. Anything that restructures code the model is about to edit again is asking for that round trip on every single write.
In Codex CLI
The most important thing is that the events are not being changed from Claude Code, so you need to use the same event names as there. These hooks are located here: <your-repo>/.codex/hooks.json. You can also define [[hooks.PostToolUse]] tables directly in your .codex/config.toml, but you can have only one type of these per layer, otherwise Codex will merge them both and show a warning on startup.
For example, here's how you can wire a PostToolUse hook to run a code style check as well as a Stop hook with its own timeout to run the tests:
{
"hooks": {
"PostToolUse": [
{
"matcher": "apply_patch|Edit|Write",
"hooks": [
{ "type": "command", "command": "\"$(git rev-parse --show-toplevel)\"/scripts/hooks/check.sh format" }
]
}
],
"Stop": [
{
"hooks": [
{ "type": "command", "command": "\"$(git rev-parse --show-toplevel)\"/scripts/hooks/check.sh verify", "timeout": 120 }
]
}
]
}
}The changes to files are represented as
apply_patch, so it's thetool_namein the hook's payload, but you can also use this hook forEditandWriteevents as well (which are aliases for it)If you're using a script in your hook, make sure to define its path based on the repository's root directory, not relative from
.codex/hooks/, the docs say plainly that Codex can be started from a subdirectoryThe default exit-2 mode works at both of these events. The other route at
Stopisdecision: "block"with a reason, and that doesn't stop the run either — it tells Codex to create a new continuation prompt with your reason as its text, which behaves like a user prompt you typed yourselfThe timeout is in seconds and defaults to 600, so you can even set up long tests; still worth setting explicitly so you notice when it drifts
Lastly, a non-managed command hook has to be reviewed and trusted before it runs. Codex stores this trust against the current hash of the hook, so any new or changed hook will require a review and will be skipped until you approve it via
/hookscommand. Also, if you don't trust the project-level.codex/layer, only the user-level hooks will be loaded and you lose the repo's
In GitHub Copilot CLI
Locations for repo-level hook configs are JSON files in .github/hooks/, or hooks key inside .github/copilot/settings.json. For example, here's a config with a postToolUse command hook for the editing tools and an agentStop verify hook with increased timeout:
{
"version": 1,
"hooks": {
"postToolUse": [
{
"type": "command",
"matcher": "edit|create|str_replace_editor|apply_patch",
"bash": "./scripts/hooks/check.sh format"
}
],
"agentStop": [
{
"type": "command",
"bash": "./scripts/hooks/check.sh verify --report=block",
"timeoutSec": 120
}
]
}
}Under this config, there are two common mistakes people make:
The first one is about the matchers. It's a regex compiled as
^(?:PATTERN)$and tested againsttoolName, and under a camelCase event that's the runtime name — Copilot's editing tools are all lowercase:edit,create,str_replace_editor,apply_patch.Edit|Writeis Claude's vocabulary; it applies on the PascalCasePreToolUseform, which switches to Claude's matcher semantics instead of the plain regex ruleThe second one is about the timeout.
timeoutSecdefaults to 30, which is not sufficient for an actual test suite, and the expiry behaviour is fail-open, so the check just silently stops existingIn order for the agentStop hook to work as intended (blocking), you need to set
--report=blockin its config. It's important to understand that atpostToolUseandagentStopthe exit status won't do the job: returning 2 raises a warning for the human and the flow moves forward. The model needs to get feedback via JSON on stdout ({"decision": "block", "reason": "..."}in case of agentStop — if you decide it to block, you must provide a reason which will be used as prompt to start a new turn), and in the worst case, the CLI overrides the hook and ends the turn after 8 consecutive blocks, but the script already stands down earlier usingstop_hook_activeThat's why for example postToolUse with the formatting step works fine with this default behaviour, because when the formatter fails it's information for the human, not the model
We also noticed today that repo-level hooks weren't being triggered in a fresh temp directory in -p mode; probably because of folder trust — the docs state that only admin policy hooks are always active despite of the folder trust status, which implies other types of hooks depend on it. So to sum up, run /env in the directory you actually work in and have a look at the output to see what hooks the session got; don't assume CI runners will just get them.
In Cursor
We can define our own hooks in the <project>/.cursor/hooks.json file, and Cursor gives the write moment its own event so we don't need a matcher on a general one. Like this:
{
"version": 1,
"hooks": {
"afterFileEdit": [
{ "command": "./scripts/hooks/check.sh format", "timeout": 60 }
],
"stop": [
{ "command": "./scripts/hooks/check.sh verify --report=followup", "timeout": 120, "loop_limit": 1 }
]
}
}Then we need to remember that the stop hook needs --report=followup. That will result in Cursor's stop not stopping anything but taking followup_message from stdout and posting it as the next message; exactly the same effect, just another route. The loop_limit set to 1 in the config above (the default is 5) means that it'll be run only once as it's enough if the check can either pass or ask for your help.
Remember though that the path is relative and depends on where you define the hook so if you create a project-level hooks file, it runs from the project root, if you do it in ~/.cursor/, it runs from ~/.cursor/ — very common mistake we see when people move their working hook from ~/.cursor/ to the repository.
But as we can see above, by default the failClosed option is set to false so even if the hook returns an error, it'll allow you to proceed. We'd say it's good to keep it this way (so that you don't get blocked because your formatter isn't installed for example) but it makes this pair a convention, rather than a security measure.
Also, in the 2026.07.23-e383d2b release of Cursor, the stop hook wasn't being called at all if you run the agent in print mode, as we've noticed. What we did:
We attached plain logging to
sessionStart,afterAgentResponseandstopWe ran the agent with
-pand--trustOnly
sessionStartwrote anything, so hooks were loaded andstopsimply never fired
So verify your stop gate in an interactive session, and don't count on it covering cursor-agent -p runs or cloud agents, which skip a longer list of events again.
In Gemini CLI
Google uses different names for events, but there's no need to convert them by hand. There's a CLI subcommand, gemini hooks migrate --from-claude, that turns a .claude/settings.json into the equivalent .gemini/settings.json. Here's the output of that tool fed the Claude Code block from this lesson — vendor output, not our own creation, with its ten empty event arrays left out here.
{
"hooks": {
"AfterTool": [
{
"matcher": "replace|write_file",
"hooks": [
{ "command": "$GEMINI_PROJECT_DIR/scripts/hooks/check.sh format", "type": "command" }
]
}
],
"AfterAgent": [
{
"hooks": [
{ "command": "$GEMINI_PROJECT_DIR/scripts/hooks/check.sh verify", "type": "command" }
]
}
]
}
}As you can see it's very similar, with a few differences. Google uses their own tool identifiers: replace and write_file, not Edit and Write — that's what the converter rewrote. And the default timeout is 60 000 milliseconds, which is 1 minute; every other harness on this page counts seconds.
AfterAgent is the stop event, and there the default exit-2 mode is what you want — it rejects the response and triggers an automatic retry turn, in which the model is shown the contents of stderr as the feedback prompt. It also passes stop_hook_active, so you don't need to modify your existing script guard.
But at AfterTool it doesn't work the same way: it hides the tool result and sends your stderr as the replacement, so the message about the successful write is lost. Which is fine for a formatter that only speaks when it's broken; if you want it to report on a successful write, use hookSpecificOutput.additionalContext — it appends instead.
Finally — it's always good to check which Google CLI you're on. The hooks reference carries a banner: "Unpaid tier and Google One users: Gemini CLI will be replaced by Antigravity CLI on June 18th." And that has already happened: a session on an individual account today failed before it started, throwing an IneligibleTierError pointing at Antigravity. Antigravity is an independent project with a single hooks.json in the customisation root — .agents/hooks.json. Its top-level keys are names of hooks, and under each hook name there are PreToolUse, PostToolUse, PreInvocation, PostInvocation and Stop arrays.
The example that comes with Antigravity is a lint-checker invoking a ./scripts/lint.sh shell script, so it's a well-trodden path; but what's important is that it uses the directory containing hooks.json as the working dir, so you need to adjust the path in this recipe.
In Kimi Code CLI
Hooks are defined in a flat TOML array under ~/.kimi-code/config.toml (or $KIMI_CODE_HOME, which is useful if you want to test a config without touching the real one). Every rule takes the form of a single table with the event as one of its fields, see below.
[[hooks]]
event = "PostToolUse"
matcher = "Edit|Write"
command = "./scripts/hooks/check.sh format"
timeout = 60
[[hooks]]
event = "Stop"
command = "./scripts/hooks/check.sh verify"
timeout = 120So we get two rules: PostToolUse matched on Edit|Write running the format check, and Stop running the verification, each with its own time limit.
The timeouts are in seconds, between 1 and 600 inclusive, with a default value of 30 — which is too low for an actual test suite, so either set it or the stop gate will start hitting it as the suite grows. Hitting the timeout doesn't fail closed either, the check is just skipped.
You can block on only three events here:
Stop— the obvious one, and the script's default exit-2 mode is exactly what it reads: the reason goes to standard error and gets appended so the model can continue its line of thoughtPreToolUseUserPromptSubmit
PostToolUse isn't one of them. The check still runs there, it just can't alter the flow of the turn — it's an observer, which is why the format hook still works, because it's a side effect. It doesn't make sense to place a linter there though.
After every change to the file, run kimi doctor. Each rule must contain only these four fields; add any other key and the entire config file becomes unreadable to the CLI. Mistype an event name and you'll get the list of all possible ones:
hooks[1].event: Invalid option: expected one of "PreToolUse"|"PostToolUse"|"PostToolUseFailure"|
"PermissionRequest"|"PermissionResult"|"UserPromptSubmit"|"Stop"|"StopFailure"|"Interrupt"|
"SessionStart"|"SessionEnd"|"SubagentStart"|"SubagentStop"|"PreCompact"|"PostCompact"|"Notification"The problem is that the documentation gives one location and it's the user level, with no project-level equivalent — so we can commit the check script to the repository, but not the configuration. That means every member of the team needs to add these two tables to their own config file, and on a fresh machine there's no gate at all until somebody remembers. So we'd keep check.sh in the repository and make it as minimal as possible: it's the only half you can actually share.
Watch it fail before you trust it
And last but not least — don't rely on the hook until you see it failing. If you've never seen it being run, it's speculation; if you've never seen it failing, it's worse. Just:
Do a thing that breaks sth (e.g. leave a
TODOthat your linter doesn't like, or create a failing test), make any request and see the turn doesn't finishFix it, make another request and see the turn finishes normally
Should take you around 2 minutes to get from "I could create a gate with this hook" to "Hm, so I can actually have a gate here"
Same for the can't-run path — make your script unable to run somewhere (by renaming the linter binary locally, for example) and see that it complains
What stays in the prompt
And that's it in terms of what's left to be put into the prompt. Anything that requires any form of judgement (like telling the model to include failure scenarios in tests rather than happy paths) is not sth exit 2 can decide, and if you try to make it do it with a hook, your team will disable it
Hooks are perfect for replacing the sentences that contain a yes-or-no question and a command that answers it. After moving these three we can have a leaner and more powerful memory file
scripts/hooks/check.sh#!/bin/sh
# scripts/hooks/check.sh — run this repo's own checks from a harness hook.
#
# check.sh format format whatever the working tree touched
# check.sh verify lint the same files, then run the tests
#
# Silent when everything passes. When something fails, the output goes back to
# the model: on stderr with exit 2 by default, or as a JSON decision on stdout
# with --report=block or --report=followup, depending on what your harness reads.
set -u
# The only lines you should need to change. Changed files are appended to
# FORMAT and LINT; VERIFY runs on its own.
FORMAT="npx --no-install prettier --write"
LINT="npx --no-install eslint --max-warnings 0"
VERIFY="npm test --silent"
EXTENSIONS="ts tsx js jsx"
MAX_LINES=40
mode=verify
report=exit2
for arg in "$@"; do
case $arg in
--report=*) report=${arg#--report=} ;;
*) mode=$arg ;;
esac
done
payload=$(cat)
# Stand down while the turn is already being continued. Half of these harnesses
# cap the loop themselves; the rest will happily go round until you notice.
case $(printf '%s' "$payload" | tr -d ' \t\n') in
*'"stop_hook_active":true'*) exit 0 ;;
esac
changed_files() {
{ git diff --name-only --diff-filter=d HEAD 2>/dev/null
git ls-files --others --exclude-standard 2>/dev/null; } | sort -u |
while IFS= read -r file; do
[ -f "$file" ] || continue
for ext in $EXTENSIONS; do
case $file in *.$ext) printf '%s\n' "$file"; break ;; esac
done
done
}
report_failure() {
reason=$(printf '%s\n\n%s' "$1" "$2")
# The JSON modes need jq. Dropping back to stderr keeps a missing dependency
# loud — going quiet here is the one thing this script exists to prevent.
if [ "$report" != exit2 ] && ! command -v jq >/dev/null 2>&1; then
report=exit2
fi
case $report in
block) jq -nc --arg r "$reason" '{decision: "block", reason: $r}' ;;
followup) jq -nc --arg r "$reason" '{followup_message: $r}' ;;
*) printf '%s\n' "$reason" >&2; exit 2 ;;
esac
exit 0
}
run() {
label=$1
shift
output=$("$@" 2>&1) && return 0
report_failure "$label" "$(printf '%s\n' "$output" | tail -n $MAX_LINES)"
}
files=$(changed_files)
case $mode in
format) [ -n "$files" ] && run "Formatting failed, so these files are not formatted:" $FORMAT $files ;;
verify) [ -n "$files" ] && run "Lint failed. Fix this before you finish:" $LINT $files
run "The tests are failing. Fix this before you finish:" $VERIFY ;;
esac
exit 0