Skip to content

About

Holds your coding agent to your team's written coding rules. A stop hook for Claude Code, Codex, Cursor and ten more agents that asks Jev whether each changed piece breaks a rule, and hands the piece back to the agent.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

56 Commits

Folders and files

Repository files navigation

stop-rules

stop-rules holds your coding agent to your team's written coding rules. When the agent finishes a turn, it cuts the code changed since the last check into pieces, one git diff hunk each, and asks Clef, Cloudflare's decision model on Workers AI, one yes or no question per piece and per rule, with 25 lines of the surrounding file so the question can be answered. If a rule is likely broken, it hands that piece of code back to the agent so it fixes it before you see it. Clef is the default judge; OpenAI's gpt-6-luna is the other one, see Which judge.

The stop-rules demo: a Claude Code window where the agent ends its turn, the Stop hook fires, and Jev and GPT-6 Luna check the same change side by side; Jev answers in 0.20 s, Luna in 2.64 s, the code goes back, the agent fixes it, and the second check is clean

examples/demo/README.md explains the demo, where every number on it comes from, and the commands that reproduce the checks. It was recorded on 22 September 2026, when Jev, the scoring service Clef replaced, was the default judge; the flow is the same with Clef.

Cutting by git diff hunk is the default, and it needs nothing installed. A team that would rather have one whole function per piece switches to tree-sitter with init --cut functions, which is 10 languages and a few megabytes of grammar files. Both are in docs/TUNING.md.

Using a coding agent? Point it at AGENT-SETUP.md and let it do the install.

Quick start, just you

git clone https://github.com/haystackeditor/stop-rules
node stop-rules/bin/stop-rules.mjs init --dir /path/to/your/repo

No npm install, no build: bin/stop-rules.mjs is committed and ready to run.

init finds which coding agents your repo already uses, copies itself into .stop-rules/stop-rules.mjs there, writes a starter .stop-rules.md, and wires the hook into each agent's own config file. That is one file of 390 KB and no grammar files at all, because cutting by git diff hunk parses nothing. It says so:

  cutting by git diff hunk, no grammar files needed (.stop-rules is 0.4 MB)
  run init --cut functions to cut by whole function with tree-sitter

Then:

cd /path/to/your/repo
printf %s "$CLOUDFLARE_API_TOKEN" | node .stop-rules/stop-rules.mjs login \
  --cloudflare-token-stdin --cloudflare-account <your account id>
node .stop-rules/stop-rules.mjs login --check

If CLOUDFLARE_ACCOUNT_ID and CLOUDFLARE_API_TOKEN are already set in your shell, the names wrangler reads, skip the first login: they are used as they are.

Edit .stop-rules.md so it says what your team cares about, then commit .stop-rules.md, the .stop-rules/ folder and the agent config files. Your token is not in any of them: it lives in ~/.config/stop-rules/cloudflare-token, mode 0600, beside the account id in cloudflare-account.

.stop-rules/ holds the checker, one file. Commit it and teammates and cloud agents get the check with nothing to install.

If you ran init --cut functions, that folder also holds the parser and one grammar file per language your repo uses, so it is a few megabytes. Commit those too. If you would rather not have binaries in git, leave the grammars out and each person runs init again on their own machine; a language whose grammar is missing is reported as not checked and nothing else breaks.

Quick start, a team

One person deploys a small server that holds the Cloudflare API token. Nobody else needs it.

  1. Deploy the server. DEPLOY.md has a row per cloud, fastest first, and the values every one of them needs.

  2. Point the repo at it, once, and commit the file it writes:

    node /path/to/stop-rules/bin/stop-rules.mjs init --dir /path/to/your/repo \
      --team https://your-endpoint.example.com
  3. Every teammate runs one line, once per machine:

    printf %s "$STOP_RULES_TOKEN" | node .stop-rules/stop-rules.mjs login --token-stdin

Share the team token through your password manager, not in a repo or a chat.

You bring your own Cloudflare account

Clef runs on Workers AI in your own Cloudflare account, and Cloudflare bills it: $0.24 per million input tokens for clef, $0.09 for clef-flash, and Clef writes no output tokens. You need the account id, which the dashboard shows on the account's home page, and an API token for that account that can use Workers AI. We do not provide either and there is no offline mode. In team mode both sit on your server, so developers only need the team token. A repo on the OpenAI judge needs an OpenAI key instead, in the same places.

Which judge

Two services can score the pieces. Clef is the default: Cloudflare's decision model on Workers AI, asked for a probability per piece and per rule, one piece per call with every rule as a question, at most 64 to a call. It comes in two sizes: clef (27B, the default) and clef-flash (9B, cheaper, and poorly calibrated for this question: at a bar of 0.5 it caught 2 of the 32 real breaks below). The other is OpenAI's gpt-6-luna through the Responses API with strict JSON output. Everything else is the same with either judge: the cutting, the 25 lines around each change, the bar, the cache, the report, the hooks and team mode.

Workers AI reads only the first 2,048 tokens of the piece a call sends Clef, about 5 to 9 KB of diff depending on the code, drops the rest without saying so, and still answers. stop-rules never uses such an answer. The 25 lines around a piece ride along only when the piece and the lines come to at most 5,120 bytes, which Clef is sure to read whole; otherwise the piece goes on its own and the run says so. A piece over 9,216 bytes is split in two before it is sent. After every call, the tool checks the token count Workers AI reports: when it shows that Clef stopped at 2,048 tokens of the piece, the answer is thrown away and the piece is split in two and asked again. A piece that cannot be split any further is reported as not checked, larger than Clef reads, never as clean.

The OpenAI judge has two forms. The default is the review form: one call per change, with every piece of the change in it, each piece's diff and its 25 lines around it grouped under its file, then your rules. The model reviews the change and reports every rule the added lines break, as {"findings": [{"ruleId", "line", "reason", "confidence"}]}, where line is the offending added line quoted verbatim and confidence is sure, likely or unsure. The tool turns that into a score per piece and rule, sure 1.0, likely 0.75, unsure 0.5, not reported 0, so at the default bar of 0.6 a sure or likely finding counts and an unsure one does not. The agent is handed the quoted line and the model's one sentence reason under each rule, so it sees where:

   Rule: Do not silently swallow errors. ...
   Confidence: 1.00 (sure)
   Line: } catch {
   Why: The empty catch discards the error and returns `null` as though no failure information needs to be preserved.

A finding that names a rule your repo does not have, or quotes a line the change did not add (compared after trimming whitespace and a leading +), is refused on its own. It gets one line under "Findings rejected" in the report, is counted in check --json as stats.rejectedFindings, and the rest of the answer still counts. It never fails the run.

The other is the scores form, "form": "scores": one call per piece, one probability per rule, the same question Clef is asked. It is what the tool shipped first and it stays for anyone who wants it.

To switch a repo to OpenAI, run this and commit the .stop-rules.json it writes:

node .stop-rules/stop-rules.mjs init --judge openai
printf %s "$OPENAI_API_KEY" | node .stop-rules/stop-rules.mjs login --openai-key-stdin
node .stop-rules/stop-rules.mjs login --check

That puts "judge": {"kind": "openai", "form": "review", "model": "gpt-6-luna", "effort": "low"} in .stop-rules.json. {"kind": "clef"}, or no judge key at all, is Clef, and {"kind": "clef", "model": "clef-flash"} is the smaller Clef. effort is how long the OpenAI model reasons before it answers: none, low, medium or high, and low is the default. --judge, --model and --effort on any command beat the file for that run. The key comes from OPENAI_API_KEY, from OPENAI_API_KEY_FILE, or from the file login --openai-key-stdin writes next to the Cloudflare token, mode 0600. In team mode the server holds it instead, as OPENAI_API_KEY (see DEPLOY.md).

The judges never mix. The cache key holds the judge, the model, the effort and the form, so an answer from one is never read as an answer from another. Nothing switches judge on its own: a missing key, a rejected key, a model your key cannot use, or an answer that breaks the schema stops the run with one line, and the baseline stays where it was.

Clef against the judge it replaced. Measured on 2 October 2026 on 240 real agent written changes cut into 804 pieces, the six starter rules and 32 real breaks, one piece per call, on the same pieces for both, at the tool's default bar of 0.6 and at 0.5:

Judge Caught of 32 at 0.6 Plainly false flags at 0.6 Caught of 32 at 0.5 Plainly false flags at 0.5 AUC Median time per call
Clef (clef) 18 4 22 8 0.976 578 ms
Jev, the scoring service the tool used before 11 1 20 10 0.977

At the default bar Clef catches 7 more of the 32 than Jev did for 3 more plainly false flags. Both bars' counts use the first reviewer's rulings (the workbench's default v1-first labels), under which Jev had 10 plainly false flags at 0.5 and an AUC of 0.977; a second reviewer later settled Jev at 20 and 0.978 at 0.5 (the OpenAI table below), and Clef's flags have not had that second look. That run sent each piece's diff on its own with the older wording of the question; what ships also sends the 25 lines around each piece and names them in the question, which was measured with Jev (see "What the judge sees" below) and not yet with Clef. clef-flash caught 2 of the 32 at 0.5.

What the OpenAI trade looks like. Measured on 22 September 2026 on the same 240 changes (the six starter rules, 32 real breaks after the latest adjudication), at a bar of 0.5, the bar the comparison was run at, not the tool's default of 0.6, against Jev, which was the default judge then. Time and money are per change, the way a stop hook sees one turn: the time is the median of the calls one change needed, and the money is what all 240 changes cost. The flag counts in this table carry the second reviewer's rulings, so they are not comparable with the Clef figures above.

Judge Real breaks caught, of 32 Plainly false flags Arguable flags Flags not yet ruled on AUC Median time per change Dollars, 240 changes
Jev (replaced by Clef) 20 20 6 0 0.978 193 ms $0.027
gpt-6-luna, review form, effort low 31 40 21 0 0.976 2,643 ms $0.059
gpt-6-luna, review form, effort low, 25 lines around each piece 31 39 17 0 0.977 3,243 ms $0.095
gpt-6-luna, scores form, effort low 29 62 16 0 0.952 6,406 ms one call after another, 3,081 ms with a change's calls at once $0.147

How to read it. The review form at effort low caught 31 of 32 where Jev caught 20, and raised 40 plainly false flags against Jev's 20, twice as many, now that a second reviewer has ruled on every flag. It ranks about as well as Jev (AUC 0.976 against 0.978). Of the 31 real breaks it caught, the line it quoted was the line the reviewer had pointed at for 27. It invented a quote once in 198 findings. It is slower than Jev, about 2.6 seconds a change against 0.2, and costs about twice as much, 6 cents for all 240 changes. The scores form caught fewer, flagged more wrongly and cost more, because it asks once per piece.

The first review row was shown each change's whole diff; the second, the diff with 25 lines around each piece, which is what the tool sends. The review form at effort medium caught the same 31 with 42 plainly false flags, for $0.086 and 3,283 ms a change. Effort high was never measured.

On the demo turn in this repo's examples, measured with this build, five runs each with the answer cache deleted first, the whole check took a median 3.61 s on the broken turn with the review form, exit 2 all five times with the three right lines quoted, and 1.17 s on the repaired turn, exit 0 all five times. Jev, then the default, took 0.57 s and 0.55 s. The knobs and what each one costs are in docs/TUNING.md.

Which agents are supported

init writes the hook for every agent it finds in the repo. What happens next depends on what that agent's hook system can do.

Agent On a broken rule How well tested
Claude Code told to fix it ran for real, claude -p with the hook installed
Codex told to fix it simulated hook input only, matches its docs
Cursor told to fix it simulated hook input only, matches its docs
Gemini CLI told to fix it simulated hook input only, matches its docs
GitHub Copilot CLI told to fix it simulated hook input only, matches its docs
Factory Droid told to fix it simulated hook input only, matches its docs
Kiro told to fix it simulated hook input only, matches its docs
OpenCode told to fix it generated plugin file, type checked, never run in OpenCode
Amp told to fix it generated plugin file, type checked, never run in Amp
Pi told to fix it ran for real, pi --mode json with the extension installed
Aider told to fix it, as lint output simulated run only, matches its docs
Windsurf Cascade shown to you only simulated hook input only, matches its docs
Cline shown to you only simulated hook input only, see the note below
plain prints the report, exit 2 used by the OpenCode and Amp plugins and the Pi extension

Only Claude Code can run the check in the background and still reach the agent. Every other agent runs it as a normal blocking hook, which takes about a second.

Cline is the one case where the documentation disagrees with itself. Its hooks README documents the TaskComplete hook, the file it lives in and the JSON it must answer with, and the same README marks that event "coming soon!". The file is written to the documented contract, so if your Cline never runs it, that is why.

Pi loads a project extension only once you trust the folder. In the terminal it asks. pi --print and pi --mode json cannot ask, so pass --approve or save the decision once with /trust. Without either, Pi skips the extension and says nothing: measured on 22 September 2026 with Pi 0.87.1, an untrusted pi --print run over a change that breaks three rules exited 0 and never started the check.

Which languages it can cut into pieces

Every language, by default. A change is judged one piece at a time, and by default a piece is one git diff hunk, which needs no parser and no grammar file.

A piece can be one whole function instead, with --cut functions or "cut": "functions". That needs a parser, so these are the languages it has one for:

Language File extensions
TypeScript .ts, .mts, .cts
TSX .tsx, .jsx
JavaScript .js, .mjs, .cjs
Python .py
Go .go
Rust .rs
Ruby .rb
Java .java
Kotlin .kt, .kts
Swift .swift

In that mode any other file is cut by diff hunk instead, which works and is a little blunter. check --json says which files that happened to and why.

Writing good rules

.stop-rules.md is Markdown. Every top-level list item is one rule. Indented lines and nested bullets belong to the rule above them. Headings, paragraphs and code blocks are ignored, so you can paste an existing guidelines document and keep its structure.

Two things decide whether a rule works here:

  • If a linter can check it, use the linter, not a rule in this file.
  • A rule must be something a reviewer could judge from one piece of a change, without seeing the rest of the codebase.

Then write each rule as one checkable sentence that says what to do instead:

- Do not silently swallow errors. When code catches or receives an error it must rethrow
  it, return it to the caller, log it with enough context to debug, or store it on a
  result or record the caller can read. The break is a catch that drops the error and
  carries on as if nothing happened: an empty catch block, a catch that only says
  "ignore", or a top-level catch that exits without saying what failed. A test that
  catches an error it expects, to assert on it, is not covered.
- Do not leave stubs, placeholders, TODO implementations or fake data in code that is
  presented as finished. The break is a function that returns a canned value or throws
  "not implemented", a TODO where the real code should be, or sample data wired in as if
  it were real. A test double inside a test file is not covered, and an interface or type
  with no body is not a stub.

Rules that work less well are the vague ones ("write clean code") and the ones about things one piece of a change cannot show ("keep the service boundaries tidy").

The full checklist a coding agent follows when it writes or reviews rules is AGENT-SETUP.md, section 6. Three edits that were measured to matter, twice. First on the 96 saved agent sessions described below: rewording 14 of 48 rules in only these three ways, with nothing else changed, took the real breaks caught from 48 of 59 to 56 of 62 and the false alarms from 35 to 26. Then on 48 fresh Haiku sessions the wording had never seen, with the reworded rules live in the hook: 27 of 34 real breaks caught (79%) with 10 false alarms, against 34 of 58 (59%) with 10 false alarms for the original wording on the original Haiku sessions.

  • Say what the rule means for tests. "Never call fetch outside http.ts; a test may call it against a server the test starts." Most of the wrong flags we saw were on test files the rule had not thought about.
  • If a rule is about a layer, name the folder. "Files under lib/controllers/ must not build SQL" gives the judge a fact it can see in the path. "Controllers must not build SQL" makes it guess which files are controllers. Layer rules went from 8 false alarms to 3.
  • Turn a soft clause into a concrete ban. "The Money helpers do the arithmetic" caught 0 of 3 real breaks. "Never add, sum, multiply or take a percentage of cents values with plain arithmetic outside lib/money.rb; call Money.sum_cents, Money.percent_of instead" caught 3 of 3, and found 2 more nobody had noticed. The judge sees the ban, not the intent.

Which kinds of rule can be judged, measured

Measured with Jev, the scoring service Clef replaced; the kinds of rule a piece can and cannot show are the same for any judge that sees only the piece.

Six small projects were written for this, one each in Ruby, Python, Go, Rust, React and a near-empty TypeScript seed. Each had 8 team rules the agent never saw and 8 tasks, each task run once with Sonnet and once with Haiku in a real Claude Code session with the hook live: 96 sessions on 20 and 21 September 2026. A blind reader wrote the answer key before any score was looked at, and a second reviewer ruled on every flag outside the reader's files, so nothing below is a false alarm by default. Full write-up and every n in docs/TUNING.md.

The tool's own output at its own bar, all 96 sessions: the agents broke a rule 59 times unprompted, the tool pointed at 43 of them in the right file and 5 more in the wrong file, missed 11, and raised 18 flags a reviewer ruled not a break plus 10 arguable ones. The whole thing cost $0.07 in Jev calls. Haiku broke a rule in 30 of its 48 sessions, Sonnet in 11.

kind of rule rules real breaks caught missed false alarms arguable
never call X, use Y 24 35 30 5 5 3
layer or folder 8 7 7 0 9 2
something must be present 6 7 6 1 2 1
needs another file 6 5 5 0 0 3
a value's format 3 3 0 3 0 0
judgment 4 2 0 2 2 1

"Caught" here includes the 5 flagged under the right rule in the wrong file.

  • Works: "never call X, use Y instead", where X is a name the code shows. Most of the rules, most of the breaks, most of the catches, about one false alarm in seven flags. Our own example agrees: helper.diff scores 0.92 on the function that calls fetch and 0.10 on the one that calls the helper.
  • Careful: a rule about which layer or folder a file is in. Every break was caught, and there were more false alarms than catches. Every false alarm was a flag on a file outside the layer the rule restricts: a model, the composition root, a README. Name the folder the rule applies to and the judge has the fact it needs.
  • Careful: tests. 12 of the 28 wrong or arguable flags were on test files where the rule said nothing about tests. Say what the rule means for tests.
  • No evidence it works: a rule about a value's format ("timestamps are ISO 8601 in UTC"). 0 of 3 caught. Three is a small number, but it is all we have.
  • Does not work: a rule that needs the rest of the codebase, because a piece and the code around it is all the judge sees.
  • The honest headline. Where the code already follows the rules and the task extends it, the agents mostly followed the rules by copying what they saw (Rust: 2 breaks in 16 sessions). The tool earned its keep in two places: old code that already breaks the rules (Go: 17 breaks in 16 sessions, 16 pointed at), and new subsystems or a bare seed with nothing to copy from (39 breaks across the six projects' new-subsystem tasks, 33 pointed at).

How it works

  1. What changed. The working tree is written to a git tree object through a temporary index, so your own index and staged changes are never touched. That snapshot is diffed against the tree from the last check, so each turn only pays for new work.
  2. Pieces. The diff is cut into pieces, one git diff hunk each. Nothing is parsed, so every language is covered and a file that will not parse is still checked. A hunk with more than 30 added lines is cut right after the 30th, taking up to 3 trailing context lines with it. Lock files, minified bundles, data and log files, binaries and deletions are skipped. "cut": "functions" turns the parser on instead: a function, method, constructor or accessor is then always a piece of its own, whatever its size, everything else groups with its neighbours up to 40 added lines, and a file with no grammar falls back to diff hunks. "cut": "chunks" groups a file's hunks into pieces of up to 12,000 bytes, also with no parser. See docs/TUNING.md for what you gain and lose by switching.
  3. The code around each piece. A piece on its own is often too little to judge, so the diff that goes to Clef carries 25 unchanged lines above the change and 25 below, read out of the snapshot, as long as the whole comes to at most 5,120 bytes, which Clef is sure to read whole. In "cut": "functions" a piece that is one function carries that whole function after the change instead, and a piece that is not a function is widened only into lines no other piece of that file owns. This is only what Clef is shown: the report still hands the agent the piece.
  4. One yes or no score per rule. Every piece is asked about every rule: does the added code break this rule. Clef answers each claim with a probability. At or above the cutoff (0.6 by default) it is a finding. Each piece rides in a request of its own with every rule as a question, up to 64 to a request, four requests at a time. A piece Clef cannot read whole is split in two and asked again, as "Which judge" explains.
  5. Tell the agent. One entry per piece: the file, the line range, the name of the function when there is one, then every rule that piece broke with its score, and then the piece's diff once. The agent gets the code that broke the rule, not a line number to go and find.

What the judge sees

Measured on our harness on 20 September 2026 with Jev, the scoring service Clef replaced, at the then default bar of 0.5, over the same 240 real agent written changes, blind labelled and adjudicated, which hold 31 real breaks:

What Jev was shown Real breaks caught, of 31 Plainly false flags Flags nobody has ruled on
the piece alone, which is what it sent before 23 10 23
the piece with 25 lines around it, no parser 21 7 13
the whole function, with tree-sitter 21 10 not counted

The middle row is what ships. It catches two fewer and argues with you a lot less, it needs no parser, and at a bar of 0.55 it caught 16 with fewer doubtful flags than either of the others. The measurement was on hunk pieces, so in functions mode, where a piece can be two lines, the window of a piece that is not a function stops at the first line another piece owns. Without that clamp two import lines in a 19 line file scored 0.84 on the swallowed errors rule, on a neighbour's fault; docs/TUNING.md has that example in full. On a second, smaller set of real agent sessions it caught 2 of the 3 real breaks against 1 for the piece alone, and it found a missing doc comment that the piece alone missed. The cost of the extra lines: 7 of 1,358 clean pairs were newly flagged, and Jev's input tokens went from about $0.11 to $0.21 per 1,000 changes at its published price. With Clef the lines ride only when the piece and the lines fit in 5,120 bytes, so a big piece goes on its own; the run counts those pieces and the report says how many.

Two things we tried and did not build, so nobody spends the time again:

  • Asking Jev, the judge at the time, whether it needs more information. It answered "not enough" to 98% of the questions, and its answer had no relation to whether more context changed the score (AUC 0.27). There is no signal there to act on.
  • Widening only where that question asked for it. 2 to 4 times the calls, and no gain over widening every piece.

Also measured: sending the whole file when it is small looked good where it applied, but 57% of the real pieces come from files over 12 KB, so it does not apply often enough to build.

Because the claim and the piece text both changed, every cached answer from an older version is a miss: after upgrading, each piece is asked once more. See the changelog at the end.

Where the report comes out, per agent

Measured on 19 September 2026 by feeding each adapter the payload its own agent documents, and for Pi on 22 September 2026 the same way. When a rule is broken:

Agent Where the report goes Exit code
Claude Code, Codex, Gemini CLI, Factory Droid, Windsurf Cascade stderr 2
OpenCode, Amp, Pi, plain stdout and stderr, the same text on both 2
Aider stdout, as lint output 2
Cursor stdout, as JSON in followup_message 0
GitHub Copilot CLI, Kiro stdout, as JSON in reason, next to "decision": "block" 0
Cline stdout, as JSON in contextModification 0

Read that before you wrap the hook in anything. A wrapper that captures only stdout loses the entire report for the five agents that write it to stderr, and four of the fourteen deliver a report while exiting 0, so an exit code alone does not tell you whether the turn was clean. On a clean turn every adapter exits 0 and writes nothing, except Gemini CLI and Cursor, which write {}, and Cline, which writes {"cancel":false}.

check is the one to use in a script: it prints its report on stdout and exits 2 when a rule is broken.

Quiet is not the same as clean

It never nags twice: a finding the hook has already delivered in this repo is not delivered again. A second run over the same code costs nothing, because every answer is cached in the repo's git directory, keyed on the model, the exact claim and the exact text the judge was sent, which includes the lines around the change. Packing does not change that key, so re-packing never throws answers away. And every run has a hard ceiling on requests (240 by default, one piece per request, --max-calls), counted across retries and splits. When it runs out, the work left over is reported as "not checked".

That cuts both ways, and it is worth being plain about. The hook tells the agent once. If the agent decides the rule does not apply and changes nothing, the next turn is silent while the code still breaks the rule, so a quiet hook is not a clean codebase. In our own experiment on 19 September 2026, 3 of 16 sessions ended that way: the agent argued and changed nothing, the hook stayed quiet, and check --base HEAD still reported the finding. Across the six projects and 96 sessions of 20 and 21 September, 13 sessions ended that way. check --base <rev> is the honest gate, because it is read only, skips nothing it has already told an agent about, and reports the same finding every time you run it. It also has no way to mark a finding as accepted, so a false alarm keeps coming back there until you reword the rule or change the code.

The first line of a report is picked from the facts, so it never claims more than was done: nothing changed since the last check, the change was checked and no rule was broken, part of it was checked and part was not, or something changed and none of it could be checked. The last one, for example a repo in functions mode with no TypeScript grammar installed, is a could not run in hook mode: exit 1 with the reason, not silence, because silence reads as clean. Measured on 19 September 2026, in a repo whose .stop-rules.json says functions and that has no grammar files:

stop-rules: 1 file changed and none of it could be checked.
This does not say your code is clean. The reasons are below.

Not checked (1): no grammar installed for .ts, run stop-rules init again to add it

Why the default bar is 0.6, and how to pick yours

It is a default to look at, not a recommendation. "threshold" in .stop-rules.json, or --threshold, sets it to anything from 0 to 1.

How to pick your own bar, and why there is no single right number: the bar trades catches for noise, and where you want to sit on that trade depends on how much your team minds a wrong flag against a missed break. Measured on 96 agent sessions across six projects with well-worded rules (one piece per call, every flag ruled by a reviewer):

Bar Real breaks caught, of 62 Wrong flags Arguable flags
0.5 59 13 4
0.6 56 5 2
0.7 46 2 1

Going from 0.6 down to 0.5 buys 3 more catches for 8 more wrong flags. Going up to 0.7 loses 10 catches to remove 3. Below 0.5 is not worth it: the 93 extra flags between 0.4 and 0.5 held 1 real break. The numbers move with the model version and with your rules, so before you settle on a bar, run stop-rules score on a few of your own recent changes and look at where the real problems and the noise land.

The earlier measurement, on 240 real agent written changes with the original rule wordings, had a steeper trade: 0.5 caught 22 of 29 with 6 plainly false flags, and 0.6 caught 11 with 1. Well-worded rules (see "Writing good rules") are what flattened it, and a team whose rules are still rough may prefer 0.5. A team that would rather be told less puts "threshold": 0.7 in .stop-rules.json. A later scoring run of the same sample, with all six starter rules and no adjudication, gives lower counts and the same shape; both runs, every bar from 0.3 to 0.7, and every cut mode are in docs/TUNING.md. Run stop-rules score on your own code before you settle on a bar.

Not spamming Workers AI

Workers AI's rate limit is per Cloudflare account, so a whole team on one account shares it and so does every tool on your machine. Its limit for Clef has not been measured here. Three things keep this one polite:

  • One run keeps at most 4 requests in flight. On the OpenAI judge that is "inFlight" in the judge setting, 4 by default, 1 to 8.
  • Every stop-rules process on the machine shares 8 slots per judge, held as lock files in your cache folder. If they are all busy for a minute the run stops and says so, and the same change is checked on the next turn.
  • A 429 halves the in-flight ceiling, waits as long as Retry-After says, and earns one slot back after four answers in a row.

How accurate is it

Small samples, and worth knowing before you trust it. The sample is 240 real agent written changes from 104 public repositories, labelled blind by reviewers. There are 32 real breaks in it, across the six starter rules.

Every count in this section was measured with Jev, the scoring service Clef replaced, on the piece alone, before it was given the code around it. The comparison that led to the change is in "What the judge sees" above: at the default bar the wide form caught two fewer real breaks and raised three fewer plainly false flags. Clef's own count on this sample is in "Which judge". So read what follows as the shape of the thing on the same sample, not as this build's score.

  • At the defaults, one hunk per piece at 0.5, over the 172 of those changes that mode was scored on and the 21 real breaks in them: 14 of 21 caught, and 16 flags the reviewers did not agree with. On the same bar and the same 172 changes, whole functions caught 11 of 21 with 26 disputed flags, and chunks 9 of 21 with 6.
  • The adjudicated run of the same sample, four of the rules and 29 real breaks, is where the default bar comes from: one hunk per piece caught 22 of 29 at 0.5, with 6 plainly false flags and 5 arguable, and 11 of 29 at 0.6, with 1 plainly false flag.
  • Whole functions over all 240 changes and their 32 real breaks: 20 of 32 caught at 0.5 with 38 disputed flags, and 10 of 32 at 0.6 with 19. Of those 19, an adjudicator had earlier called 5 plainly not a break and 2 arguable; the other 12 nobody has ruled on. Chunks at 0.6 caught 9 of 32 with 1 disputed flag.
  • On planted examples, measured earlier: stubs 20 of 20, hardcoded test values 12 of 20, and 0 of 40 harmless look-alikes flagged.

Those come from one scoring run per cut mode. Asked seven times, a confident score moved by a hundredth or two and a borderline one moved from 0.45 to 0.80, so the counts near a bar would move a little in another run.

Either way it misses things: two thirds of the real breaks on the low count, a third on the high one. Scores also move between judges and model versions, so run stop-rules score on your own code and see. Every number above, per rule and per bar, is in docs/TUNING.md.

What leaves your machine

Sent to Cloudflare Workers AI for Clef, per request: one piece of your diff, the path of the file it came from, 25 unchanged lines of that file above and below each change when they fit (or, in functions mode, the whole function the change sits in), and the text of your rules. At most once per run, when a request comes near what Clef reads, the same rules are sent again with no code at all, which is how the tool measures what the rules cost and so tells whether Clef read the whole piece. Nothing else: no repo name, no history, no file the diff does not touch. In team mode the same request goes to your own server, which adds the Cloudflare token and forwards it.

Sent to OpenAI, when the judge is openai, per request: in the review form, every piece of the change with the same lines around it and the path of each file, your rules and the fixed instruction; in the scores form, the same things for one piece, plus a prompt_cache_key that is a hash of the instruction and your rules. Both send "store": false, which asks OpenAI not to keep the response. In team mode it goes to your server first, which forwards only those fields with its own key.

Kept on your machine and never committed, in <git dir>/stop-rules/: state.json (the baseline tree, which findings were delivered, per-session counters), cache.json (claim hashes and their scores), run.log (one JSON line per run, capped at 1 MB) and lock. The machine wide slots are small files in your cache folder ($XDG_CACHE_HOME/stop-rules/clef-slots, or ~/Library/Caches/stop-rules/clef-slots on a Mac, and openai-slots beside it for the OpenAI judge), each holding a process id and a time. Your token, key or account id lives in ~/.config/stop-rules/ with mode 0600. None of them is ever written into the repo, and the token and keys are never printed or included in an error message.

Commands

stop-rules init [--dir <repo>] [--agents a,b,c] [--team <url>] [--cut <mode>] [--judge <kind>] [--json]
stop-rules check [--dir <repo>] [--base <rev>] [--json]
stop-rules score [--dir <repo>] [--base <rev>] [--diff <file>] [--show-context] [--json]
stop-rules hook [--dir <repo>] --agent <name>
stop-rules team [--dir <repo>] <url>
stop-rules login --cloudflare-token-stdin [--cloudflare-account <id>] | --cloudflare-account <id>
                 | --openai-key-stdin | --token-stdin | --check [--dir <repo>]
stop-rules serve [--port n]
stop-rules baseline --reset [--dir <repo>]
  • init installs into a repo. --dir picks the repo, --agents overrides detection, --team switches the repo to team mode, --cut functions adds the parser and the grammars for the languages the repo uses and writes "cut": "functions" into .stop-rules.json, --judge openai (with --model and --effort if you want others than the defaults), or --judge clef --model clef-flash, writes the judge into .stop-rules.json, --json prints the same facts for an agent to read.
  • check runs the same pipeline in a terminal, for a pre-commit hook or CI. check prints its report on stdout and exits 2 when a rule is broken. Exit 0 clean, 2 findings, 1 could not run. With --base <rev> it diffs that revision against your working tree; without it, it uses the incremental baseline but never moves it, so it is safe to repeat.
  • score prints every piece with every rule's score and applies no cutoff, so you can see where your own code sits before you pick a bar. It changes nothing. --diff <file> scores a unified diff file instead of the working tree; a diff file has no file content, so there are no lines around a change to send and the output says so. --show-context prints exactly what the judge saw for each piece, which is the way to see the 25 lines for yourself. The header names the judge and its model.
  • hook is what the agents call. --agent says whose protocol to speak.
  • team writes the endpoint into .stop-rules.json. Commit that file.
  • login stores your Cloudflare API token and account id, your OpenAI key or your team token. Secrets are read from stdin so they never land in shell history; the account id, which is not a secret, is a flag value. --check calls your server's health route and makes one real call to the judge this repo is set to.
  • serve runs the team server on a laptop or a plain VM.
  • baseline --reset forgets what was checked, so the next run starts from HEAD.

Shared flags: --dir <path> (the repository to work on), --rules <path> (default <repo root>/.stop-rules.md), --cut <mode> (default hunks), --threshold <0..1> (default 0.6), --max-calls <n> (default 240, one piece per call, and 60 for the OpenAI review form, which asks once per change), --judge clef|openai, --model <name> (clef or clef-flash for Clef, any model for OpenAI), --effort <level> (the OpenAI judge only), --help, --version.

Which repository a command works on is one rule: --dir when you give it, and otherwise the repository that holds the folder you are in. The copy of stop-rules that init vendors into a repository works on that repository only; run it from somewhere else and it stops, names both repositories and tells you to pass --dir or change folder. Use the clone's bin/stop-rules.mjs to install into a different repository.

Settings, and tuning

.stop-rules.json in your repository root is the one place a team sets the knobs. It is committed and holds no secret. Every key is optional, and a flag beats the file.

{
  "endpoint": "https://stop-rules.your-team.example.com",
  "cut": "hunks",
  "threshold": 0.6,
  "maxCalls": 240,
  "judge": {"kind": "clef", "model": "clef"}
}

Those are the defaults for the knobs that have one, so a file like that changes nothing. judge is {"kind": "clef", "model": "clef"}, where model is clef or clef-flash, or {"kind": "openai", "form": "review", "model": "gpt-6-luna", "effort": "low", "inFlight": 4}, where form is review or scores; see Which judge. cut is hunks (one diff hunk per piece, no parser, the default), functions (tree-sitter, one whole function per piece) or chunks (a file's hunks grouped into pieces of up to 12,000 bytes, no parser). A key stop-rules does not know, a value of the wrong type or a value out of range stops the run with one line naming the key.

docs/TUNING.md goes through each knob: what it does, what happens when you turn it each way, real examples with the scores the live service gave them, what each setting caught and flagged on 240 real agent written changes, and what the tokens cost in money.

Environment: CLOUDFLARE_ACCOUNT_ID and CLOUDFLARE_API_TOKEN (or CLOUDFLARE_API_TOKEN_FILE) for Clef on your own Cloudflare account, OPENAI_API_KEY or OPENAI_API_KEY_FILE for your own OpenAI key, STOP_RULES_ENDPOINT and STOP_RULES_TOKEN for team mode. A variable that is set but empty is an error, not a shrug, and so is an account id that is not 32 hex characters.

Limits, honestly

  • You need a Cloudflare account with Workers AI and an API token for it, or an OpenAI key for the OpenAI judge.
  • Clef reads only the first 2,048 tokens of each piece it is sent, about 5 to 9 KB of diff. A bigger piece is split in halves until each half is read whole, which costs more calls, and a rule whose break spans the two halves can then be missed. A piece that cannot be split any further, such as one enormous line, is reported as not checked.
  • The OpenAI judge's answers can move between runs on a borderline piece. In the scores form, measured on an earlier version of the demo, asked eight times, cache cleared each time, about the fixed file in the demo, whose only doubtful line is a placeholder https://api.example.com URL, gpt-6-luna at effort low scored the stubs rule 0.95 four times and 0.05 or less four times. At effort medium it scored it 0.88 to 0.95 six times in six. Jev, then the default judge, scored it 0.18.
  • Each piece is judged on its own, with 25 lines of the same file around it and nothing from any other file, so rules about cross-file architecture or consistency across a codebase are weak.
  • Those 25 lines are read out of the new file whether or not the same change wrote them, so a line the change added just outside the piece is shown to the judge as if it had always been there. It is the code as it now stands, which is what the rule is about, but it is not a record of what changed. In functions mode the window of a piece that is not a function stops at the first line another piece owns, so there it never happens.
  • A piece that with those lines would come to more than 5,120 bytes goes to Clef without them (more than 60,000 bytes of request, for the OpenAI judge). The run says which piece, in the report, in check --json and in run.log.
  • A piece is one diff hunk, so the agent is handed the hunk the fault sits in and finds the exact line itself. A hunk can also start in the middle of a function, so what the agent is handed can have no head, even though the judge saw the lines around it. "cut": "functions" hands over the one function instead, and then a rule about how two functions fit together is not seen at all.
  • In functions mode a file in a language with no grammar here is cut by diff hunk, and a file that will not parse is reported as not checked, never checked half way. The default mode parses nothing, so neither case exists there.
  • Only Claude Code can run the check in the background. Everywhere else the hook blocks for about a second.
  • In claude -p (print mode) background hooks are killed when the process exits, so use the blocking form there: drop asyncRewake from the settings entry.
  • If a Claude Code user has disableAllHooks set in their settings, no hook runs at all and nothing tells you. We hit this ourselves.
  • The check reads added lines. A rule about something deleted, such as a test being removed, is only seen if the same change also adds lines to that file.
  • npx github:haystackeditor/stop-rules is untested until this repo is public, so we cannot say whether it works. There is no build script and no install script, so npm has nothing to rebuild, but the only path we have run is a clone plus node bin/stop-rules.mjs.

Changelog

  • 2 October 2026, Clef replaces Jev. The default judge is now Clef, Cloudflare's decision model on Workers AI, on your own Cloudflare account: CLOUDFLARE_ACCOUNT_ID and CLOUDFLARE_API_TOKEN, or login --cloudflare-token-stdin --cloudflare-account <id>. Jev is gone, with no fallback: "judge": {"kind": "jev"} is now an error, --jev-key-stdin, TYPESAFE_API_KEY and the STOP_RULES_JEV_* variables are no longer read, and a stored jev-key file is ignored. Clef takes one piece per call, so the default call ceiling is 240. The team server's route is POST /v1/clef and it needs STOP_RULES_CLOUDFLARE_ACCOUNT_ID and STOP_RULES_CLOUDFLARE_API_TOKEN in place of TYPESAFE_API_KEY; redeploy it and run stop-rules login --check in a repo. Cached answers were keyed on Jev, so the first run asks every piece once more.
  • 22 September 2026, a second judge. "judge": {"kind": "openai"} in .stop-rules.json scores the pieces with OpenAI's gpt-6-luna instead of Jev, by default in the review form: one call per change, each finding quoting its line. Jev stays the default, and a repo that sets nothing sees no change: its cached answers are still valid. The team server has a second route, POST /v1/responses, and an optional OPENAI_API_KEY. See "Which judge" above.
  • 22 September 2026, Pi. init finds a .pi folder and writes .pi/extensions/stop-rules.ts, which runs the check when Pi is about to settle and, when a rule is broken, hands the report to the model with one more turn. Run with Pi 0.87.1 and gpt-6-luna: the report named two broken rules, the model replaced the direct fetch and the TODO stub with httpGet and httpDelete, and the second check was clean. Pi needs the folder trusted, see "Which agents are supported".
  • 20 September 2026, the code around each piece. Jev now sees 25 unchanged lines above and below every change, or the whole function in functions mode, and the claim sentence says so. Both go into the cache key, so the first run after this upgrade asks every piece once more and costs what a first run costs. Nothing you have to do. score --show-context prints what Jev saw. The numbers behind it are in "What the judge sees" above.

Contributing

Everything in bin/ is a build output that is committed on purpose, so a clone needs no build: the bundle, the tree-sitter runtime and one wasm file per grammar. It must be committed together with any source change:

npm install
npm run typecheck
npm test
npm run compile      # compiles, bundles, refreshes bin/, checks the tables and the AWS template
npm run verify:bin   # fails if anything in bin/ is not a fresh copy

The script is called compile and not build on purpose: npm rebuilds a package installed from a repository when a build script exists, and the committed bin/ must be used as it is.

npm run compile also fails when VERSION in src/version.ts or SERVER_VERSION in src/server/handler.ts has drifted from the version in package.json, when a node type named in src/languages.ts is not in that grammar, or when the language table above is not the one in the code. Run npm run build:aws after changing the server, so the committed CloudFormation template keeps matching the code. npm run build:aws inlines the compiled server, so run it after npm run compile, then compile once more to check it.

npm test runs the one test suite there is, on a scripted fetch with no network: the Clef transport (Workers AI's answer shape, its errors, the check that Clef read the whole piece and the halving that follows), the engine's one piece per call, the judge settings, the Clef credentials and the team server's Clef route. Everything else is verified by running the thing, and the measurements that decided every default are under docs/measurements/.

License

MIT

About

Holds your coding agent to your team's written coding rules. A stop hook for Claude Code, Codex, Cursor and ten more agents that asks Jev whether each changed piece breaks a rule, and hands the piece back to the agent.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages