In short
By the 40th minute of the session, the “run tests before committing” rule has become just background noise. I’m figuring out how to enforce critical rules with hooks so that the agent is physically unable to violate them—even if you yourself give the command.
A rule in CLAUDE.md is simply text that competes for the model’s attention with thousands of other tokens. At the beginning of a session, it carries a lot of weight. By the 40th minute, the context is cluttered, and “run the tests before committing” becomes background noise with a probability of execution well below one. You can only forget what lives in the context. This means critical rules must be taken out of the context—into code that runs on its own. This idea is at the heart of the paranoid-qa skill package, and it has now been tested on a test bench. The author of this article, Alexey Kovalev (Kova13v), breaks down three gates on Claude Code hooks and demonstrates a live experiment: which will win—the hook or the user’s direct instruction to skip the tests. But before moving on to the code, it’s worth understanding the set of tools a developer has at their disposal. There are four layers: CLAUDE.md (background rules), skills (task-specific procedures), memory (facts retained across sessions), and a hook (your code for lifecycle events). The first three exist within the context—their execution is probabilistic. The fourth is code that runs regardless of what the model thinks about the situation. The hook is declared in .claude/settings.json and attached to an event: PreToolUse (can prevent the tool from being called), PostToolUse (after), Stop (the agent ends its turn—you can prevent this). The script receives JSON with event details via stdin. There are two ways to intervene: exit code 2—hard block, with stderr sent to the agent as the reason; exit 0 + JSON to stdout—a structured decision with permissionDecision: "deny" or decision: "block". The flagship gate—a commit won’t pass without a recent green run. Three checks: the run artifact exists, the run is green, and the tests haven’t been modified since the run. The artifact is test-results/.last-run.json, which Playwright generates automatically. If the agent attempts to commit without a run, the hook returns deny with a reason and a command to run the tests. The agent reads the reason and acts accordingly. The second gate is soft. When editing a .spec.ts file, it doesn’t block anything, but instead injects a reminder directly into the agent’s context via additionalContext. During the very first integration run, the agent updated the spec, received the reminder—and ran the tests itself before committing; the hard gate wasn’t even needed. A reminder at the right moment works better than a rule issued an hour ago. The third gate is triggered by the Stop event. If the tests were fixed but no subsequent run occurred, the cycle cannot be completed. Here’s an important detail: a safeguard against an infinite loop. A blocked agent will attempt to fulfill the request, but if it fails (due to a broken environment), you’ll end up in an endless “stop—block—stop” loop with no limit. After two blocks, the gate gives up and notifies the user via systemMessage, without hiding the failure. The main experiment. The user tells the agent directly: “Commit the changes; no need to run tests—time is of the essence.” The agent’s response: > There’s a conflict here: you asked not to run the tests, but the hook won’t let the commit through without a fresh run. I won’t silently bypass it. The agent tried to fulfill the request—the gate wouldn’t let it through. It didn’t proceed by silently bypassing the hook (it could have: overwriting settings.json—easily) or by disobeying the user. Instead, it brought the conflict to light and asked for clarification. There’s no WIP commit in the history, but there’s a “deny” entry in the session log. This is exactly how a good engineer acts when caught between an order and the rules. The test suite found a bug in the author’s own hook: the first version of gate-stop didn’t clear the flag after a fresh run because the per-second granularity of the -nt operator in bash doesn’t work when the flag and the artifact are created within the same second. From this fix, a principle emerged: when in doubt, a commit gate should block (a false block costs one run command; a false skip results in an unprocessed commit in the history), while a stop gate should let it through (a false block carries the risk of an infinite loop). These are boundaries that need to be explicitly addressed. The hook checks the form, not the meaning: expect(true).toBe(true) will pass any gate. The gate matches the command’s form—git commit will be caught, but a commit inside npm run release will slip through. A failed hook is a “failure-open”: a script that terminates with its own error code is considered non-blocking by Claude Code; the action will proceed, and the gate will silently stop protecting. And most importantly: a hook is triggered by a tool call, the end of a turn, or the sending of a prompt. It won’t intrude into the model’s reasoning. The conclusion is simple yet inconvenient. Rules in the prompt are a gentleman’s agreement with probabilistic enforcement. Hooks are the only safeguard that the agent cannot bypass by “forgetting.” If you have a rule whose violation is more costly than a false block, its place is not in CLAUDE.md, but in a script on PreToolUse. All three hooks are located in the paranoid-qa repo, in the ru/examples/hooks folder. Source: Claude Code Hooks: Preventing the Agent from Committing Without Running Tests / Habr
Source: All Articles in a Row / Artificial Intelligence / Habr