· Software Engineering
A Rule Is Not the Last Step
A while back I argued that a request is not a rule. A convention written in CLAUDE.md has to share the context window with everything else, so move it into the linter, where it is enforced rather than read.
I still think that is right. There is one more step, and it makes life easier.
Every Markdown file I write puts one sentence on each line. This post is written that way, and so was the last one.
I have been writing that rule down for something like ten years. I put it in contributing guides, and in a note near the top of a README. That is prose about how to write prose, and it was as effective as it sounds. None of it ever made the rule happen. A small tool I wrote is what finally did.
This week I wired that tool into the agent. This post is about where I put it. It is neither a request nor a check that has to pass.
Three places a convention can live
| Where it lives | What it costs | How it fails |
|---|---|---|
In CLAUDE.md | Context and attention | Silently, by being ignored |
| In a check that has to pass | A round trip | Loudly, straight away |
| In a hook | Nothing | Silently, by doing nothing |
You can use more than one of these at the same time. I would still pick one, because I want to decide on purpose how much enforcement a convention deserves.
My last post was about getting from the first row to the second. That is still the move most worth making.
Look at the third row though, and at the column on the right. The document and the hook fail in the same way. Both of them fail without telling you. Only the middle row tells you when something is wrong, and if you move past it you are back to a rule that fails in silence.
What it looks like
Claude Code will run a command of yours after it edits a file. Here is the whole thing, in .claude/settings.local.json:
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "jq -r '.tool_input.file_path // .tool_response.filePath' | { read -r file; case \"$file\" in \"$CLAUDE_PROJECT_DIR\"/*.md) markdown-style format \"$file\";; esac; } 2>/dev/null || true",
"statusMessage": "Formatting markdown"
}
]
}
]
}
}The part that confused me first is the use of jq. The hook does not receive the file path as an argument or as an environment variable. It receives the whole event as JSON on standard input, something like this:
{
"session_id": "abc123",
"tool_name": "Edit",
"tool_input": { "file_path": "/path/to/README.md", "content": "..." },
"tool_response": { "success": true }
}So the first thing the command has to do is read the path back out of that JSON. That is all that jq is used for. Any tool that can read a field off standard input would do.
The rest of the command runs left to right. read -r file catches that path. The case checks it is a Markdown file inside this project, which keeps the hook off every other file the agent touches. markdown-style format "$file" rewrites it in place. And 2>/dev/null || true throws away any error output and forces a success, so a formatter that crashes can never block the work.
Claude Code passes CLAUDE_PROJECT_DIR to the hook, and it contains the root of the project you are working in. That is what keeps the pattern out of anyone's home directory. Watch the quoting around it. The variable is quoted so it matches as literal text, and the /*.md is left bare so it stays a pattern. Get that backwards and you either match nothing or match far too much.
That is it. It is ten lines of configuration, and most of it is there to get the file path into the formatter.
Who has to answer for the result
It would be fair to point out that I have just described a linter. markdown-style has a lint subcommand that reports violations and a format subcommand that rewrites them, and I could have put the first one in CI and been done. It is the same binary and the same rules, and that is the middle row of the table. What changes between that row and the one below it is who has to answer for the result.
With a check that has to pass, the agent has to respond to the result. The agent writes the file, runs whatever verification step you have, reads the failure, and fixes it. That loop works, and it was the point of the last post. It also has a cost. It costs tokens for the failure and the fix. It costs time for the round trip. And it gives the agent one more chance to fix the wrong thing, because a failure is still text that has to be interpreted.
With a hook there is nothing for the agent to respond to. The formatting happens between the edit landing on disk and the agent finding out how it went. The agent does not have to meet the rule. The rule is applied to whatever the agent wrote.
In practice that means the agent writes a line like this:
The cache is checked first. A miss falls through to the database.And this is what is on disk a moment later:
The cache is checked first.
A miss falls through to the database.You might think the agent learns the style this way. It does not. It is told that a formatter touched the file, but not what the formatter did, and by the next session it remembers none of this.
What happens instead is more useful. Every Markdown file in the repository is now already in the house style, so whatever reads them next sees nothing but correct examples. The convention is in the files rather than in anyone's memory. That is just as well, because the files are the only thing in this arrangement that lasts.
What you give up
Look again at 2>/dev/null || true. That is me deciding that a formatter which cannot run should never interrupt the work. It also means a hook that has quietly stopped working looks exactly like a hook that is working. There is no red line in the terminal. The Markdown slowly stops being formatted, and I notice months later, if at all.
That is fine for this rule. If my paragraphs go unsplit for a while, nothing happens. I want them split, but I have never worried about the ones that were not.
The question is not whether it is important, but whether you could live without being told it stopped. Formatting is easy to live without. A rule you would want to hear about is better as a check that has to pass, because then it tells you.
I decided this once, and by the time the hook runs there is nothing left to decide. A request can be overridden when it is wrong for this one file. A hook will not notice that this one file is different. For sentence breaks I am happy to never have that conversation again. For something I might want to argue with, I would not be.
There is one small thing to do, and it takes a minute. Nothing will tell you when the hook fails, so watch it work once before you trust it. Edit a file that should be caught and a file that should not, and look at both afterwards. A hook that does nothing at all is the normal starting state, and nothing in your setup will tell you that you are in it.
Take care of your tools
Wiring this up was ten lines of configuration and a minute of checking. It was cheap because the hard part was already done. I had argued out my opinions about Markdown years ago and put them into a tool that takes a file path. I did not build that for an agent. I built it because I was tired of splitting my own paragraphs by hand.
That is the part worth copying. When something new turns up and starts writing Markdown in your repository, you already have a tool to point it at.
I ended the last post with a line a friend of mine used to repeat until it became a joke. Take care of your tools and your tools will take care of you. He was talking about shell aliases and slow test suites, years before any of this existed. It keeps being right.