Stop Telling AI to Run Lint & Tests - Use Hooks Instead
Don't write 'run lint' in AGENTS.md. One hook saves tokens while maintaining code quality.
Introduction
In a previous post, If You're Hesitant About Using Claude Code, I introduced a method for managing code quality by writing the following in AGENTS.md:
Post-task TODO
- Run ./gradlew build to verify there are no build issues
- Run ./gradlew lint to ensure all lint rules are followed
- Run ./gradlew test to verify tests passThis approach works. Claude finishes its task and runs the build, lint, and tests in order.
The problem is cost. The process of Claude "reading", "executing", and "interpreting the results" of these commands consumes tokens every time. Nearly 1k tokens are burned per task. Even when lint passes cleanly with zero violations.
I recently found a better way. With hook-based processing, the normal case costs virtually zero tokens. This post is an after-service update on that method.
The Problem with the Old Approach
Here's how the old approach works:
Claude finishes writing code
→ Reads "Post-task TODO" from AGENTS.md (tokens consumed)
→ Runs ./gradlew build (tokens consumed)
→ Interprets results (tokens consumed)
→ Runs ./gradlew lint (tokens consumed)
→ Interprets results (tokens consumed)
→ Runs ./gradlew test (tokens consumed)
→ Interprets results (tokens consumed)
→ Responds "All passed" (tokens consumed)
This entire flow runs every time, even when there are no issues. Even when there are zero lint violations and all tests pass, tokens are still consumed. You're paying money just to confirm everything is fine.
What Are Hooks?
Claude Code supports a Hook system that automatically executes shell commands in response to specific events. The main events are:
| Hook | When It Runs |
|---|---|
PreToolUse | Before a tool call (can block) |
PostToolUse | After a tool call completes |
UserPromptSubmit | When user submits a prompt |
Stop | When Claude finishes its response |
SubagentStop | When a subagent task completes |
Notification | When a notification is sent |
The key is the Stop hook. It runs automatically when Claude has completed all work and finished its response.
For more details on hooks, see the official documentation.
Switching to Hook-Based Processing
Add the following to {project_directory}/.claude/settings.json:
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "./gradlew spotlessApply detekt"
}
]
}
]
}
}That's it. Now spotlessApply and detekt run automatically every time Claude finishes a task.
Why Is This Better?
The key is exit code handling.
Lint tools use exit codes properly. 0 means success, anything else means there's an issue. Claude Code acts based on this exit code.
Normal Case (exit code 0)
Claude finishes writing code
→ Stop hook fires
→ ./gradlew spotlessApply detekt runs
→ exit code 0 (success)
→ Done. Claude does nothing.
Token consumption = 0. Hooks are shell commands that run outside of Claude, so if they succeed, Claude has no reason to intervene.
Problem Case (exit code != 0)
Claude finishes writing code
→ Stop hook fires
→ ./gradlew spotlessApply detekt runs
→ exit code 1 (failure)
→ Claude reads the output log and fixes the issues
Claude only intervenes when there's a problem. spotlessApply automatically fixes formatting issues, and only the code quality issues caught by detekt that can't be auto-fixed are handled by Claude.
Comparison
| Aspect | Old (AGENTS.md) | Hook-Based |
|---|---|---|
| Normal case tokens | ~1k tokens | 0 tokens |
| Error case tokens | ~1k+ tokens | Only error-fixing tokens |
| Auto-fix | Claude fixes directly | spotlessApply fixes first |
| Execution guarantee | Claude may forget | Always executes |
The last row is subtly important. Even with instructions in AGENTS.md, Claude occasionally skips running lint. Hooks are enforced at the system level, so they can never be skipped.
"Is the Hook Not Working?" Don't Panic
After setting up hooks for the first time, you might panic. I did.
Claude finishes its task and... nothing happens. "Is the hook not working?" You start checking the settings, double-checking JSON syntax... But it's actually working correctly.
When the exit code is 0 (success), Claude produces no output at all. The hook runs silently and finishes silently. This is normal behavior.
Output only appears when there's a failure - that's when Claude reads the log and starts fixing things. In other words, no reaction means everything is working.
If you want to verify hooks are working, intentionally introduce a lint violation to trigger a failure case.
Going Further: PostToolUse Hooks
Beyond the Stop hook, PostToolUse hooks enable more granular control. For example, if you want formatting applied immediately after file edits:
{
"hooks": {
"PostToolUse": [
{
"matcher": "tool == 'Edit' || tool == 'Write'",
"hooks": [
{
"type": "command",
"command": "./gradlew spotlessApply"
}
]
}
],
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "./gradlew detekt"
}
]
}
]
}
}This applies formatting every time a file is modified, and runs static analysis once when all work is done. Adjust to fit your project's needs.
Conclusion
The takeaway is simple:
- Writing "run lint" and "run tests" in AGENTS.md consumes tokens every time.
- Switching to hooks means zero cost for normal cases, with Claude only intervening when there are issues.
- System-level enforcement means no missed executions.
The difference compounds as your project grows and task count increases. If you're running dozens of tasks a day, saving ~1k tokens each time adds up to meaningful savings.
If you've been using the approach from my previous post, I recommend switching to hook-based processing.