More and more repositories have an AGENTS.md: the project’s rules, written for the AI agents that edit it. “Money is an integer number of cents.” “Only src/payments/ talks to the payment provider.” “Images are never latest.” That file has two problems. Nothing checks that the rules were followed, so the agent obeys them when it remembers. And every rule is paid for in tokens, in every conversation, forever.
Linters solved this for people a long time ago. The catch is that a linter rule is written in a linter’s language, and the rules in AGENTS.md are written in English, by whoever knows the project. I wanted to keep the English as the source of truth and get the linter for free. Tinstar is that, in Rust.
A law is a check with examples
A law is a YAML file in .tinstar/laws/. It keeps the rule as people wrote it, where it came from, how to check it, and code it must accept and reject:
id: no-console-logkind: patternstatement: Code under src/ logs through logger, never console.log.source: AGENTS.md#loggingmessage: console.log bypasses loggerhint: Import { logger } from the project logger module and call logger.info(...).severity: warninglanguage: typescriptfiles: ["src/**"]rule: pattern: console.log($$$ARGS)fix: logger.info($$$ARGS)examples: valid: - logger.info("coupon", code, discount); invalid: - console.log("coupon", code, discount);There are three kinds, because the rules in a real AGENTS.md aren’t only about code:
pattern: code, on the syntax tree, with ast-grep rules. TypeScript, Python, Rust, Go, Java, C/C++, YAML, HCL, Bash and more.config: assertions on YAML and JSON documents, such as Kubernetes manifests, GitHub Actions workflows orpackage.json. “Every container declares resource limits” is a path and a condition.layout: files and folders. “No.envis committed”, “every feature folder has a test”, “migrations are namedYYYYMMDD_name.sql”.
The examples are what make a law trustworthy. tinstar test runs every law against them, so a law that doesn’t do what its statement says never reaches the project. Rules that can’t be checked from the code (“a discount never exceeds 30% of the order”) stay in AGENTS.md, for people and agents. That’s fine: the point is to take out of the prompt what a machine can check.
One engine, four places
The engine (tinstar-core) knows nothing about terminals or JSON-RPC. Each file is parsed once and the tree is shared by every law of its language, so adding laws doesn’t add parses. The front-ends are thin and report the same diagnostics:
- CLI and CI.
tinstar checkfor people,--format jsonfor tools,--format agentfor one compact line per problem. The exit status fails CI when a law withseverity: errorbreaks, and a GitHub Action (uses: matpdev/tinstar@v0.1.0) installs it and shows each broken law on its line in the pull request. - Language server.
tinstar lspunderlines broken laws as you type, with the rule in plain language and a link to the law. A law with afixbecomes a quick fix. The whole project is checked from disk, so the problems in Kubernetes manifests and CI workflows show up too, in files nobody opened. - Agent hook. In Claude Code, a
PostToolUsehook runstinstar hook claude-codeafter every edit. If a law broke, the agent is told which one, where, how to fix it and the rule it came from, and it fixes it before going on. When nothing broke, it says nothing and costs nothing. - MCP server. More on this below.
The language server was the part with the most design in it. One thread owns the state and handles messages in order, so there are no locks. Checks run on worker threads and come back tagged with the document version: a result for an older version is dropped instead of cancelled (“latest wins”). Keystrokes are debounced to one check per pause. Editing a law swaps in a new Arc<Lawbook>, so running checks finish with the old book and never see half-loaded laws.
From AGENTS.md to laws
Writing ast-grep rules by hand is exactly the work nobody wants to do, so tinstar compile asks an LLM. It sends each section of the document, with a short picture of the project, and asks for laws. What comes back is never trusted. Every draft is tested twice:
- Against its own examples. A law that doesn’t load, or gets an example wrong, goes back to the LLM with the error. After three attempts it gives up, and a law that comes back with the same error twice isn’t asked for again.
- Against the project. This is the noise check, and it came from a real failure.
I tried it on a large repository: the Zed editor’s, a big Rust codebase with its own AGENTS.md. For one of its rules, the LLM wrote a law about unwrap() that passed all its examples and fired 15,000 times in the project. Another one, about WeakEntity, fired 14,000 times. Both were correct on their examples and useless on real code: they were much broader than the rule.
The fix is to use the project itself as a test. A project mostly follows its own rules, so a law that fires more than 25 times is probably wrong. It goes back to the LLM with the numbers (how many hits are in test files, how many are in Rust’s inline tests after #[cfg(test)], which folders) and a sample of the code it caught. The LLM can narrow it, confirm it with a reason (a project can have many old violations), or admit the rule can’t be told apart by syntax. A law it can’t settle is set aside with the rejected drafts and is never offered for approval.
Only laws that pass reach .tinstar/laws/pending/, and nothing there is enforced until a person runs tinstar approve. On the demo project, DeepSeek drafts 8 to 9 of its 9 laws in about 11 seconds, for about 35,000 tokens.
Spending tokens once
The whole pitch is “tokens are spent once”, so the compiler had to be cheap too. Three things did most of the work:
- Triage. One cheap call over the whole document sets aside sections with nothing checkable, such as process or intent.
- Prompt caching. The stable part of the prompt (the law format and the project picture) comes first and is marked for the provider’s cache. The first section runs alone to warm it, then the rest run four at a time.
- A ledger.
.tinstar/compiled.jsonkeeps a fingerprint of each section and what it gave. Running again only sends the sections whose text changed.
On Zed’s AGENTS.md, a run went from 164–189k input tokens to 115–157k, with about 90% of those served from the cache. Running again with nothing changed costs zero tokens and takes 0.03 seconds.
Using the editor’s own model (MCP)
tinstar compile needs an API key, and not everyone works that way. Plenty of people already pay for a model inside their editor: Copilot, Claude, DeepSeek, a local one. So tinstar mcp is an MCP server with four tools. The agent reads the document’s sections, drafts a law, and calls tinstar_check_law, which compiles it, runs the examples and the noise check, and says what to fix. tinstar_save_law checks it again before writing it to pending/. The model is whatever the editor uses. The guarantees stay with tinstar.
That’s the video above: Zed’s agent, with DeepSeek, turned the demo’s AGENTS.md into nine laws that catch exactly the nine planted violations.
It also showed the limit of letting a model write the laws. In one run the agent made the logging law an error instead of a warning, and left out the fix, so the editor had no quick fix to offer. Neither is a bug in the checker. They are judgment calls, and that’s why laws wait in pending/ for a person. I fixed the law by hand, and I changed tinstar_check_law to point out a missing fix when the rewrite is mechanical.
What I learned
- Don’t trust a generated rule because it passes its examples. Run it on real code: a project that mostly follows its own rules is a free test set, and the number of hits is a better signal than any example.
- When the LLM is wrong, give it numbers and a sample, not a verdict. “It fires 15,000 times, most of them in test code, here are ten” got narrower laws back. “This law is too broad” didn’t.
- Make the guarantee independent of the model. Whichever model drafts the law, through an API key or the editor’s agent, it goes through the same checks and the same human approval.
- In a language server, dropping stale results is simpler than cancelling work, and it is enough.
Tinstar is early: it has three kinds of law, the CLI, the language server (Zed), the Claude Code hook, the MCP server and a GitHub Action. Version 0.1.0 has prebuilt binaries for Linux, macOS and Windows:
curl -fsSL https://raw.githubusercontent.com/matpdev/tinstar/main/install.sh | shThe code is on GitHub, under MIT. Next up are Dockerfile and import rules, VS Code, SARIF output for GitHub’s code scanning, and a study I want to run: the same agent tasks with and without tinstar, counting violations, tokens and self-corrections. What rule in your AGENTS.md do you think a checker could never catch?