- Introduction
- Asking Is Not Enforcing
- From Instruction to Lint Rule
- What a Linter Cannot Check
- Errors Block, Warnings Inform
- Three Gates
- Why the Pipeline Matters More With Agents
- One Command for Every Project
- Conclusion
Introduction
In my post about setting up Claude Code, I wrote about the global CLAUDE.md: the instruction file where I keep the rules I would otherwise repeat in every session. No any, named exports only, explicit return types, no leftover console.log.
That file made a big difference. The agent follows those rules most of the time. But most of the time is not a standard. A standard is something that holds every time, and an instruction file cannot promise that. The model reads it, weighs it against everything else in the context, and usually gets it right.
In this post, I will show how I turn those instructions into checks that hold every time: an ESLint configuration that encodes my rules, git hooks that run it before anything is committed, and a CI pipeline that runs it before anything is merged.
Asking Is Not Enforcing
An instruction file is a request. A linter is a check. The difference matters more than it seems.
When the context gets long, instructions from the start of the session lose weight. When a task is complicated, the model spends its attention on the problem and not on whether the function has an explicit return type. And when the agent copies a pattern from existing code that breaks my rules, the existing code often wins over the instruction.
None of this is a flaw I expect to go away. It is the nature of a model that reads instructions instead of executing them. So I stopped treating the instruction file as the place where my standards are enforced and started treating it as the place where they are explained. The enforcing happens somewhere else.
From Instruction to Lint Rule
Most of my style rules are mechanical enough that a linter can check them. I maintain my own ESLint configuration, @rkulik/eslint-config, and almost every line in the code style section of my CLAUDE.md has a counterpart in it:
Instruction in CLAUDE.md |
ESLint rule | Severity |
|---|---|---|
Avoid any |
@typescript-eslint/no-explicit-any |
warning |
Avoid leftovers like console.log |
no-console |
warning |
Avoid leftovers like debugger |
no-debugger |
error |
| Named exports; no default exports | import-x/no-default-export |
error |
| Use arrow functions | prefer-arrow-functions/prefer-arrow-functions |
error |
| Do not destructure function parameters | destructuring/in-params |
error |
| Always declare explicit function return types | @typescript-eslint/explicit-function-return-type |
error |
Mark class members public/private |
@typescript-eslint/explicit-member-accessibility |
error |
Use import type for type-only imports |
@typescript-eslint/consistent-type-imports |
error |
| No implicit returns | arrow-body-style set to always |
error |
No braceless if/else |
curly set to all |
error |
Blank line before return |
@stylistic/padding-line-between-statements |
error |
Formatting is covered too. The config runs Prettier through ESLint, so a formatting difference is a lint error like any other.
The table only works in one direction, though. My CLAUDE.md holds the general rules, the ones that shape almost every line the agent writes. The ESLint config holds far more than that. For a TypeScript file in a React project, 122 rules are active, from the order of imports to unhandled promises (@typescript-eslint/no-floating-promises) to the rules of hooks. None of them are in the instruction file. Listing them all would cost context in every session for rules that matter in a handful of lines. The agent either writes code that passes them anyway, or it learns about them from the lint output and fixes the line.
You might ask why the general rules stay in CLAUDE.md at all if the linter checks them anyway. There are two reasons.
The first is cost. If the agent knows the rule, the first draft is usually right. If it only learns about the rule from a lint error, every violation costs a round of fixing. For a rule that affects every function, that adds up quickly. The instruction makes the right thing likely, the linter catches what slips through.
The second is reach. My CLAUDE.md is global, so it applies in every project I open. My ESLint config does not. In a client project with its own linting setup, for example, the instruction file is the only place where my rules exist at all.
What a Linter Cannot Check
Some of my rules have no lint counterpart, and they never will:
- Function names should start with a verb.
- Names are spelled out:
event, note. - Comments explain a non-obvious why, never what.
These are judgment calls. A linter can check whether a comment exists, but not whether it restates the line below it. These rules stay in the instruction file, and they are what I look for when I review the code myself.
Errors Block, Warnings Inform
Two rows in the table are warnings, not errors: no-explicit-any and no-console. That is deliberate. Sometimes an any is the honest type, for example at the boundary to an untyped library. And a console.log can be exactly right in a CLI tool.
A warning does not fail anything. ESLint prints it, exits with code 0, and the commit goes through. So these two rules do not make the wrong thing impossible. They make it visible: in the editor, in the lint output the agent reads, and in the diff I review. Whether the any stays is decided in review, not by the linter.
I am fine with that split. Errors are for rules where I cannot think of a good exception. Warnings are for rules where I can, and where I would rather look at the case than have the agent work around a hard rule with a disable comment.
If you want warnings to block as well, ESLint has a flag for that. --max-warnings 0 makes the run fail as soon as there is a single warning:
{
"scripts": {
"lint": "eslint . -c eslint.config.js --max-warnings 0"
}
}
That is a decision for each project, not for the shared config, which is why my config leaves it out.
Three Gates
The same checks run in three places. Each one catches what the one before it missed.
The Agent’s Own Loop
The first gate is the agent itself. My spec-driven workflow tells the agent to typecheck and run the tests after every task. Because the lint script is part of the project, the agent can and usually does run it too. This is the cheapest place to catch a problem: the code is still in the agent’s context, and the fix is immediate.
But it is still the agent deciding to run a check. That makes it a habit, not a gate.
Git Hooks
The second gate is local and does not depend on anyone’s habits. I use Husky to run checks on git events:
# .husky/pre-commit
pnpm run lint
# .husky/pre-push
pnpm run test
# .husky/commit-msg
pnpm dlx commitlint --edit $1
When the agent commits, the pre-commit hook runs the linter. If it fails, the commit fails, and the agent gets the lint output as the error. It reads it, fixes the code, and commits again. I do not have to be involved at all.
The Pipeline
The last gate is a CI pipeline, for example with GitHub Actions, that runs on every pull request and every push to the main branch. The flow is short:
- Check out the code and install the dependencies from the lockfile, so the pipeline uses exactly the versions that were committed.
- Run the same
lintscript the pre-commit hook runs. There is exactly one definition of what “clean” means, and it lives in the project. - Typecheck the project.
- Run the full test suite.
- Build the project.
If any step fails, the pipeline is red, and a pull request with a red pipeline does not get merged.
I wrote about the same idea for PHP a while ago, in my post on running PHP-CS-Fixer with GitHub Actions. The tools are different, but the principle is the same: the standard lives in a config file, and the pipeline enforces it.
Why the Pipeline Matters More With Agents
Git hooks have one weakness: they can be skipped. git commit --no-verify bypasses them, and so does a fresh clone where nobody ran the install script. An agent that is stuck on a failing hook might reach for exactly that flag, because from its point of view it solves the problem.
The pipeline cannot be skipped. It runs on a machine the agent does not control, and it reports to a place where I look before I merge. That makes it the only gate I fully trust. The hooks are there to keep the feedback loop short. The pipeline is there to make sure nothing gets through.
There is another benefit. A failing pipeline is not a dead end for an agent. With the GitHub CLI, it can read the failed job’s log with gh run view --log-failed, fix the problem, and push again once I tell it to. The pipeline’s output becomes input for the next round, just like a failing hook.
One Command for Every Project
All of this only works if every project has it. Setting it up by hand each time is exactly the kind of repetitive work I hand to the agent, so I wrote a small skill for it. /setup-tooling installs ESLint with my config, Prettier with my Prettier config, Husky and commitlint, writes the hooks, and finishes by running the linter once. Anything it finds in existing code gets reported, not silently fixed.
A new project gets the same standards as every other project with one command, and the agent that will write most of the code sets up the checks for its own output.
Conclusion
Instructions and checks do different jobs. The instruction file tells the agent what good code looks like in my projects, which makes the first draft better. The linter, the hooks, and the pipeline make sure that whatever the draft looks like, nothing that breaks a hard rule gets merged.
If you work with a coding agent, go through your instruction file line by line and ask for each rule: could a tool check this? If yes, add the lint rule, and decide whether it should block or inform. Keep the rules a tool cannot check in the instruction file, and keep reviewing those yourself.