I continue improving my agentic software development workflow, and this time I decided to fix something that was annoying me for a while: the AI-generated code wasn’t always tested thoroughly, and some pieces of it weren’t tested at all. The obra’s Superpowers skills I use say to follow TDD, but plans and agents not always followed it, so I decided to stop asking nicely and start enforcing it.
Today I’ll show you why TDD fits LLMs so well and how I built a small guard with Claude Code hooks that blocks an agent from touching production code until it has seen a failing test. I’ll keep it conceptual, with a few short snippets, so you can build your own version for whatever language and test runner you use. Follow me.
Why TDD works best with LLMs
TDD (write a failing test, make it pass, refactor, repeat) has always been a good practice. But with LLMs it becomes almost a need, because:
It gives the agent a real finish line. I like to take a look to the Claude Code best practices now and then, as new models release. There have been a few thing that haven’t change since the beginning: “Claude stops when the work looks done”, so, for better results, “Give Claude a way to verify its work”. Without something it can verify, “looks done” is the only signal the agent has. A failing test is a clear and strong signal that keeps the agent iterating until it passes. That’s exactly the kind of loop LLMs are good at.
A test written after the code mirrors the code. When the agent writes the test after the implementation, it writes it by reading that implementation. So the test encodes what the code does, bugs included, instead of what it should do. It’s the same anchoring problem I described in why your AI agent shouldn’t review its own code. A test written first can only come from the spec, because there is no code to copy yet.
A test that never failed proves nothing. Models love writing tests that only check the code runs without throwing (that’s why rule 8 in my
CLAUDE.mdexists). Seeing the test fail first, for the right reason, is the proof that it actually tests something.Errors are better context than guesses. A failing test hands the model a concrete error message and a stack trace to reason about, instead of making it simulate the whole program in its head. And since the rule is to write only the code needed to make the test pass, it also keeps the agent from growing the scope of the task.
The problem: TDD was optional
My workflow runs on Superpowers. I define a spec from /braingstorming, that later becomes a plan, and that plan gets executed by the subagent-driven-development skill, which dispatches a fresh implementer subagent per task. TDD is part of the Superpowers philosophy, so I assumed it was happening. Then I read the implementer’s prompt template, and found this line:
write tests (following TDD if task says to)If task says to. So TDD depended on the wording of each task in the plan. And the skill that writes the plans only mentions TDD briefly. On top of that, implementer subagents don’t get the Superpowers context injected the way the main session does, so nothing reminds them of the TDD skill either. In the end, whether TDD happened or not depended on the plan’s wording and on the agent’s stochastic decisions that day.
My first fix was the obvious one: rules in my CLAUDE.md. They help, but they’re still instructions the model can drift away from under pressure. So when you need a deterministic approach, the only way forward is using hooks.
The solution: Claude Code hooks
Hooks are commands that Claude Code runs at specific points of its lifecycle. The two I care about here are:
PreToolUse, which runs before a tool call, and can deny it.PostToolUse, which runs after a tool call, and can see its output.
You wire them in your settings.json, with a matcher for the tools you want to intercept:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Edit|Write|MultiEdit",
"hooks": [{ "type": "command", "command": "python3 ~/.claude/hooks/tdd-guard/pre_edit.py" }]
}
],
"PostToolUse": [
{
"matcher": "Bash",
"hooks": [{ "type": "command", "command": "python3 ~/.claude/hooks/tdd-guard/post_bash.py" }]
}
]
}
}Each hook receives a JSON payload on stdin with the tool name, its input (the file being edited, the command being run, etc.), and for PostToolUse, its response. And when the tool call comes from a subagent, the payload also carries an agent_id (unique per dispatched subagent) and an agent_type (the subagent’s name). So a hook can know which agent is acting, and behave differently for each one.
To deny a tool call, a PreToolUse hook prints something like this, and the reason is shown to the agent so it knows what to do next:
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "TDD guard: production code is locked. You need a failing test first."
}
}How the guard works
The guard is made of two hooks that share a small state per agent:
- An observer (
PostToolUseonBash) that looks at every command the agent runs. When it’s a test run, it reads the result and updates the state. - A gate (
PreToolUseon the edit tools) that checks the state before any edit to production code, and denies it if the agent hasn’t earned it yet.
Test files are always editable (you need to be able to write the failing test in the first place). Config files, migrations, and docs are left alone too, because adding a dependency shouldn’t require a failing test. Only production code is gated.
A failing test opens the window
The core of the guard is a tiny state machine with a window that is either open or closed. A failing test run (RED) opens it, and a passing run (GREEN) closes it again:
def apply_test_run(state, outcome):
if outcome == RED:
return state.replace(window=OPEN)
if outcome == GREEN:
return state.replace(window=CLOSED)
return state # inconclusive runs change nothing
def is_edit_allowed(state):
return state.window == OPENNote that the window is per cycle, not per edit. One RED authorizes as many edits as the agent needs to reach GREEN, which is exactly what TDD allows. Once the suite is green, the next piece of behavior needs a new failing test. So the cycle costs one extra test run, not one per edit.
The state is stored per agent_id, so two implementers working in parallel never step on each other, and every new task starts with a closed window, because a new task means a new subagent with a new id.
Trust the summary line, not the exit code
The obvious way to detect RED would be the test command’s exit code. But a non-zero exit code doesn’t mean “a test failed”. It can also mean the code didn’t compile, the test setup crashed, or the command ran in the wrong environment. None of those are a RED: no test ever asserted anything. If the guard accepted them, an agent could unlock production code just by breaking the build.
So the observer reads the summary line of the test output instead, and only trusts it when it makes sense:
def parse_verdict(output):
summary = find_summary_line(output) # e.g. "5 tests, 1 failure"
if summary is None or summary.tests == 0:
return INCONCLUSIVE
if summary.failures > 0:
return RED
return GREENIn my case, I work with Elixir as programming language, and running the tests will always return something like 5 tests, 1 failure. That’s the summary line aimed for.
Refactor mode flips the requirement
There is one case where TDD doesn’t give you a failing test: refactoring. When you restructure code without changing its behavior, there’s nothing new to test, so there’s no RED to get.
Instead of adding an escape hatch, the guard flips the requirement. The agent declares the refactor with a small CLI command, and it must give a reason:
~/.claude/bin/tdd-guard refactor "extract the pricing rules into their own module"From that moment, production edits need a passing run instead of a failing one, so the agent restructures under a green suite, never a broken one. If a test goes red during the refactor, the guard drops back to requiring RED, which is correct either way: the agent either broke something, or started adding behavior. And the reason gets recorded, so a reviewer can later compare it with what the diff actually did. A “refactor” that adds three new public functions is not a refactor.
Only the implementer is guarded
The gate is only armed for one specific agent: a custom subagent I called sp-implementer. Both hooks check the agent_type first and return immediately for anything else, so my main session, the reviewers, and every other subagent are not affected at all.
That custom agent is a thin layer on top of the Superpowers implementer. It keeps the whole original prompt, and only adds what was missing: (1) TDD is mandatory whatever the plan says, (2) the TDD skill is preloaded, and (3) its prompt includes a short explanation of the guard (what a denial means, how to declare a refactor, and to report BLOCKED instead of working around it).
Now, the Superpowers template dispatches a general-purpose agent, and that template lives in the plugin cache, so any edit I made to it would be overwritten on the next update. Instead, I implemented two layers to make sure the right agent gets dispatched:
- A rule in my
CLAUDE.mdsaying thatsubagent-driven-developmentimplementers are always dispatched assp-implementer. That handles the normal path. - A third hook,
PreToolUseon theAgenttool, that denies any implementer dispatch with the wrong type and asks for a re-dispatch. That catches the case where the rule gets ignored.
Also, I added an extra rule to my CLAUDE.md attacking the root cause: when writing plans, every task that touches production code must include a step that writes the failing test and states the expected failure. Refactor-only tasks are marked as such. A plan that always uses TDD explicitly removes the “if task says to” condition from the equation.
If you want to take a look at the whole agent and the CLAUDE.md rules, you can check them in GitHub Gists. Note that as I said earlier, my hooks were written to support Elixir programming language, so some things (like the agent prompt) are tailored towards Elixir, but serves as an example for any other programming language. That gist also include the sp-task-reviewer agent I’ll explain next.
The journal and the reviewer
Blocking edits is only half of the job. The other half is being able to check, later, that TDD actually happened. So every test run, every allowed or denied edit, and every refactor declaration gets appended to a per-agent journal (a simple JSONL file), and a small CLI renders it for a human or an agent to read:
~/.claude/bin/tdd-guard report <agent-id>In Superpowers, each finished task is checked by a fresh task reviewer. I replaced it with another custom agent, sp-task-reviewer, that receives the implementer’s agent id and reads its journal before giving a verdict. So instead of trusting the implementer’s report (“I followed TDD, I promise”), the reviewer checks the evidence. For each task there are now three artifacts that must agree:
- The plan step that names the expected failure.
- The journal entry that shows a real RED with that failure in it.
- The diff with the production code that made it pass.
If you read my article about adversarial reviews, this is the same idea: keep the verification mechanical, and don’t let the author grade its own work.
Caveats
The guard defends against drift, not against a malicious agent. It’s built to stop a well-intentioned agent from sliding into test-after under pressure or by its own stochastic nature, so an agent that really wants to cheat could still get past it (for example, by faking a test output or by writing code through the shell with sed -i). Both would leave a trace in the journal for the reviewer, though. When in doubt, I made the decision to lean towards recording things rather than blocking them, because a guard that wrongly accuses an agent stops being trusted, and a guard nobody trusts ends up switched off.
Wrapping up
With this guard, my agents can’t skip the failing test anymore, and the journal lets me (and the reviewer) check that TDD actually happened instead of taking their word for it. If you want to build your own, start small: a hook that watches test runs, a hook that gates edits, and a bit of state per agent.
Happy coding!

