A programmatic git ratchet system for automated agent guarding.
Project description
git-agent-ratchet
The rule didn't change. The cost of breaking it did.
A pre-commit hook pack that turns the polite suggestions in your AGENTS.md
into deterministic gates at commit time. Four small, ugly, single-purpose
scripts. They do not get clever. They just fail loudly when an agent does
the thing your file already told it not to do.
- Ratchet A --
no-duplicate-helpers. AST scan. Private helper names are not allowed to spread across more files than the recorded baseline. - Ratchet B --
deny-agent-chatter. Regex scan. Conversational preamble ("Sure, I can help with...","As an AI, ...","Now let me <!-- ratchet-allow: agent_chatter --> check the docs...") cannot ride into a commit. - Ratchet C --
anti-bypass. If a mutation lands on a protected ratchet config file withoutHUMAN_RATCHET_BYPASS_KEYin the environment, the commit dies. - Ratchet D --
max-file-lines. Per-file line counts may not grow past their recorded baseline. Split, don't sprawl.
The package itself runs all four of these against itself on every commit. If our own hooks fail on our own code, the change is wrong. That's the test.
Why this exists
Given enough runs, every agent eventually breaks a rule in your
AGENTS.md. Not maliciously. Not noticeably at first. It reads the file at
the start of the session -- you can watch it in the transcript -- and for
the first few turns the rules hold. Then it gets stuck on something, finds
a path that works, and the file becomes background. Three commits later
it's writing the helper you explicitly told it not to write, in the
directory you explicitly told it to check first.
The agents.md spec is a filename convention. It standardised the location so every tool (Codex, Cursor, Aider, Copilot, Zed, Claude Code) looks in the same place. That was worth shipping. But the spec is silent on enforcement, because enforcement is not what the spec is for. It is a README for agents, and READMEs ask politely.
The vendors know this. Anthropic's own docs say it out loud:
Settings rules are enforced by the client regardless of what Claude decides to do.
CLAUDE.mdinstructions shape Claude's behavior but are not a hard enforcement layer. If the instruction is something that must run at a specific point, such as before every commit or after each file edit, write it as a hook instead.
Cursor's Team Rules toggle is careful to add that "AI guidance should not
be your only security control." GitHub Copilot's recommended boilerplate
for copilot-instructions.md ends with a sentence telling the agent to
trust the file -- an instruction that would not need to exist if trust
were the default.
All three vendors are saying the same thing in different words: the file is context, weighted against everything else in the prompt. Length erodes adherence, conflicts resolve arbitrarily, and if you need a guarantee you leave the markdown layer and write a hook.
git-agent-ratchet is the hook layer.
What an enforced rule looks like
The pattern is three things, every time:
- The rule, in prose, in your
AGENTS.md-- so the agent knows what's expected and why. - A script that fails the commit when the rule is broken.
- A line in the file that names the gate -- so the agent knows the ratchet exists before it walks into it.
Concrete, copy-pasteable, this is what one entry in your AGENTS.md looks
like after you wire git-agent-ratchet:
### Duplicate helpers
Don't fork helpers. Check `libs/` first; if a near-miss exists, extend it.
Red-flag prefixes that historically get forked: `_run_*`, `_safe_*`,
`_load_*_or_default`, `_no_prompt_*`, `_retry_*`, `_atomic_*`.
Enforcement:
- `ratchet-no-duplicate-helpers` (this repo) -- AST scanner. Fails on any
module-level `def _name(...)` appearing in 2+ files outside `tests/`
when the count exceeds the baseline.
- Baseline lives at `config/ratchets/duplicates.json`. Allowed to shrink,
never grow.
Forbidden bypasses:
- Do not add an allowlist to the scanner.
- Do not edit the baseline JSON by hand -- Ratchet C will block it.
- Do not `--no-verify`.
The prose says what and why. The enforcement block names the code that runs. The bypasses block names the cheats the agent will reach for if you don't forbid them by name.
Install
Add the repo to your project's .pre-commit-config.yaml:
repos:
- repo: https://github.com/monk-eee/git-agent-ratchet
rev: v1.0.0
hooks:
- id: ratchet-no-duplicate-helpers
args:
- --baseline=config/ratchets/duplicates.json
- --dir=src/
- id: ratchet-deny-agent-chatter
files: \.(py|md|txt|go|js|ts|rs)$
- id: ratchet-anti-bypass
args:
- --enforce-files=AGENTS.md,.pre-commit-config.yaml,config/ratchets/duplicates.json,config/ratchets/file_lines.json
- id: ratchet-max-file-lines
args:
- --baseline=config/ratchets/file_lines.json
- --dir=src/
- --max=350
Then:
pre-commit install
pre-commit run --all-files
The first run of ratchet-no-duplicate-helpers against a real codebase
will scream. That is the point. It writes the current state as your
baseline and from that commit forward the count can only shrink. Each
cleanup commit extracts one duplicate into a shared module, swaps the
callers, and the hook rewrites the baseline JSON with the smaller count
and stages the diff into your commit.
A minimal working layout lives in
examples/downstream/ -- copy it, point
--dir at your package, and seed.
The three ratchets, in detail
Ratchet A -- ratchet-no-duplicate-helpers
Target failure mode. Agents fork local helper utilities (internal
string formatters, safe shell execution wrappers, atomic array appenders)
instead of traversing existing abstractions to reuse them. By the fifth
helper, the soft "check libs/ first" rule has slipped.
What it does. Walks the directory you point at with --dir,
dispatches each file to a language-specific extractor based on its
suffix, and groups the resulting "helper-shaped" names across files.
Any name that appears in two or more files outside tests/ is a
"duplicate". The total occurrence count is the metric; the baseline JSON
records it.
Supported languages:
| Language | Extensions | "Helper-shaped" means |
|---|---|---|
| Python | .py |
Top-level def / async def with a leading underscore (_foo, not __init__). AST-parsed. |
| TypeScript / JavaScript | .ts, .tsx, .js, .jsx, .mjs, .cjs |
Unexported top-level function declaration or const NAME = (...) => ... / function arrow. Regex, column-zero only. |
| C# | .cs |
Any line declaring a private (optionally static / async / etc.) method. Regex; constructors, fields, and properties excluded. |
Extractors live under git_agent_ratchet/ratchets/extractors/; adding a new language is a new module plus a registry entry.
Gate rule.
- Current count > baseline -> exit 1 with a per-name report on stderr.
- Current count < baseline -> rewrite the baseline JSON with the smaller count, save it, exit 0. Pre-commit re-stages the file into the current commit automatically.
- Current count = baseline -> exit 0.
- No baseline yet -> seed the file, exit 0.
Args.
| Flag | Default | Purpose |
|---|---|---|
--baseline |
config/ratchets/duplicates.json |
Path to the JSON registry. |
--dir |
src |
Directory to scan. |
--exclude |
tests, test, node_modules, bin, obj, .venv, venv, dist, build |
Repeatable. Path-segment names to skip. |
--lang |
all | Repeatable. Restrict scanning to one or more of python, typescript, csharp. |
Ratchet B -- ratchet-deny-agent-chatter
Target failure mode. Multi-turn agent operations leak conversational
preamble into source files: # Sure, let me implement that interface change for you:, // As an AI, I am tasked with..., Now let me check <!-- ratchet-allow: agent_chatter --> the docs directory:. One leak in a generated artefact and the file reads
as a chat transcript. Trust collapses.
What it does. Scans every file pre-commit hands it for the regex signatures in git_agent_ratchet/ratchets/agent_chatter.py:
| Signature | Catches |
|---|---|
(?i)(sure,\s)?i\scan\shelp\swith |
"Sure, I can help with modifying this code..." |
(?i)as\san\sai,\s(i\s)? |
"As an AI, I am tasked with..." |
(?i)i\shave\ssuccessfully\s(modified|updated) |
"I have successfully updated the workspace dependencies." |
(?i)now\slet\sme\scheck\sthe\s(docs|dir) |
"Now let me check the docs directory to confirm layout..." |
Gate rule. Any match anywhere -> exit 1 with the file, line number, which signature fired, and the offending text.
Extending it. When a CLI version bump introduces a new narration
pattern, add the regex to CHATTER_SIGNATURES and a regression test in
tests/test_agent_chatter_regressions.py in the same commit. Ship the
test without the regex and the next agent is blocked by your own change.
Ratchet C -- ratchet-anti-bypass
Target failure mode. When confronted with a rigid programmatic
constraint, an intelligent agent parses the files that define the rule
(like the baseline JSON, like .pre-commit-config.yaml, like the
AGENTS.md paragraph that names the ratchet) and mutates them to force
its broken commit to pass. The rule itself becomes the attack surface.
What it does.
- Normalises the list of staged filenames pre-commit passed in.
- Compares against
--enforce-files(comma-separated, repo-relative). - If nothing protected was touched, exit 0.
- If
HUMAN_RATCHET_BYPASS_KEYis set to a non-empty value, exit 0. The human asserted intent for this shell session. - Otherwise, scan the environment for known agent signatures
(
CURSOR_AGENT,CLAUDECODE,AIDER,COPILOT_AGENT, ...) and exit 1 with a clear "human operator must export the bypass key" message. The agent signal, when present, is named in the failure output so you know which tool tripped it.
Args.
| Flag | Required | Purpose |
|---|---|---|
--enforce-files |
yes | Comma-separated repo-relative paths to protect. |
Important. Ratchet C never logs the bypass key value, only its presence. The CI suite asserts this.
Ratchet D -- ratchet-max-file-lines
Target failure mode. Agents grow a single module instead of splitting
it. The 350-line soft rule in your AGENTS.md is the first casualty of a
five-turn refactor session: each turn adds "just one more helper", the
file passes 400 lines, then 600, then nobody can read it any more.
What it does. Walks the directory you point at with --dir, counts
the lines of every .py file, and records any file whose count exceeds
--max (default 350) in the baseline. The metric is the total overage
across all over-sized files. The baseline shrinks when you split a file
or contract one; it is structurally barred from growing.
Gate rule.
- Current overage > baseline -> exit 1 with the per-file diagnostic on stderr.
- Current overage < baseline -> rewrite the baseline JSON, save it, exit 0. Pre-commit re-stages the file automatically.
- Current overage = baseline -> exit 0.
- No baseline yet -> seed the file, exit 0.
Args.
| Flag | Default | Purpose |
|---|---|---|
--baseline |
config/ratchets/file_lines.json |
Path to the JSON registry. |
--dir |
src |
Directory to scan. |
--max |
350 |
Per-file line-count limit. |
--exclude |
tests, test |
Repeatable. Path-segment names to skip. |
The baseline registry
One JSON file per project, default config/ratchets/duplicates.json.
Shape (full schema in docs/spec.md):
{
"$schema": "https://git-agent-ratchet.org/schemas/v1.json",
"ratchet_meta": {
"repo_signature": "sha256:...",
"last_updated_by": "git-agent-ratchet-core"
},
"baselines": {
"duplicate_helpers": {
"metric_value": 3,
"items": [
{"name": "_safe_load_or_default", "occurrences": ["src/utils/io.py", "src/core/loader.py"]},
{"name": "_retry_backoff", "occurrences": ["src/net/http.py", "src/db/client.py"]},
{"name": "_run_command", "occurrences": ["scripts/deploy.py", "src/tasks/runner.py"]}
]
}
}
}
The invariant: for any ratchet R and any two consecutive commits,
C_{t+1} <= C_t. The registry is allowed to shrink (the hook does it for
you and stages the diff). It is structurally barred from growing without
a human bypass.
Do not edit this file by hand to make a commit pass. Ratchet C is watching it.
Direct CLI
Useful for debugging, CI scripting, or seeding a new baseline. Every hook
also has a long form under the unified git-agent-ratchet dispatcher:
# Ratchet A
git-agent-ratchet no-duplicate-helpers \
--dir src \
--baseline config/ratchets/duplicates.json
# Ratchet B (scan specific files)
git-agent-ratchet deny-agent-chatter path/to/file.py path/to/other.md
# Ratchet C (treat these paths as staged)
git-agent-ratchet anti-bypass \
--enforce-files AGENTS.md,.pre-commit-config.yaml \
AGENTS.md
# Ratchet D
git-agent-ratchet max-file-lines \
--dir src \
--max 350 \
--baseline config/ratchets/file_lines.json
Each subcommand prints the decision it made and why. There is no --quiet
flag. The point of a ratchet is to be loud.
How git-agent-ratchet and AGENTS.md work together
This package does not replace your AGENTS.md. It makes the rules in it
real.
The full pattern, sometimes called the HUMANS.md approach (after the article that prompted this repo):
- Audit every rule in your
AGENTS.md. For each, ask: What actually happens when the agent breaks this? - If the answer is "nothing", the rule is soft. Mark it soft in the file, in prose. The next agent and the next human both deserve to know it is currently on the honour system.
- Rank the soft rules by historical cost. Which one has already bitten you most? Build that ratchet first. A baseline regression check is the cheapest thing you can write and the easiest thing for the next agent to fail.
- Document the enforcement next to the rule. Same paragraph. Name the hook id, name the baseline file, name the script. The agent reads top-down; the gate should be visible before the agent gets to the part where it would otherwise walk through it.
- Forbid the bypasses by name. Allowlists, baseline-grows,
--no-verify,git add -Ainstead of explicit paths,git stashinstead of WIP commits. The agent will find these on its own; you might as well call them out before it does. - Stop writing rules with no plan to enforce them. If a rule is important enough to be in the file, it is important enough to either ratchet now or queue as the next ratchet.
This repo's own AGENTS.md follows this pattern. The
"Mechanical enforcement" table at the top maps every NON-NEGOTIABLE
rule to the hook id that enforces it. Rules that have no gate yet are
listed under "Known soft rules" in DEVELOPERS.md.
Development
Flat layout. Hatchling. uv. No src/.
# Setup
make setup # uv sync + pre-commit install
# Run the suite
make test # uv run pytest -q
make test-cov # with coverage
# Lint / format
make lint # ruff check + ruff format --check
make format # ruff check --fix + ruff format
# Dogfood: run all three ratchets against this repo
make ratchet
If make ratchet fails on a green tree, the change you just made is
wrong. That is the entire CI loop.
The full architectural contract lives in docs/spec.md. The agent-facing index is AGENTS.md. The bug log and roadmap are in docs/TODO.md.
FAQ
Why not just use README.md for both audiences? Different cold start. The README is human onboarding: story, badges, install quirks. AGENTS.md is for the machine about to write code in your repo in the next forty seconds and has no time for any of that. If one file works for you, use one file. This repo keeps them separate because the audiences answer different questions.
Won't this slow my commits down?
Ratchet A's AST scan is O(files) over your source tree and runs in
sub-second time on packages up to a few thousand modules. Ratchet B is a
regex pass over the staged set, capped at whatever pre-commit hands it.
Ratchet C is a string compare. Ratchet D is a line-count pass. None of
them call out over the network.
Does it work with Husky / lefthook / native git hooks instead of pre-commit?
The console scripts (ratchet-no-duplicate-helpers,
ratchet-deny-agent-chatter, ratchet-anti-bypass,
ratchet-max-file-lines) and the unified git-agent-ratchet CLI are
pure Python and have no pre-commit dependency at runtime. Wire them
into any hook runner that can execute a Python
console script. The bundled .pre-commit-hooks.yaml is provided because
that's the most common deployment, not because it's the only one.
What if I really do need an exception?
You are a human. Export HUMAN_RATCHET_BYPASS_KEY=<anything-non-empty>
in your shell, run the commit, unexport. Agents must not do this. If you
are an agent and you find yourself wanting to set this variable, you are
the failure mode the ratchet exists to catch -- stop and surface the
blocker to your operator instead.
Why JSON for the baseline and not YAML? The baseline is rewritten programmatically by the hook itself. JSON has one canonical serialisation; YAML has six and the agent will pick a different one each commit. We are trying to remove drift, not add it.
A new chatter pattern slipped through. What do I do?
Add the regex to CHATTER_SIGNATURES in
git_agent_ratchet/ratchets/agent_chatter.py,
add a regression test in tests/test_agent_chatter_regressions.py that
matches the new phrasing, and commit both in the same commit. Open an
issue if the pattern looks generalisable -- we want the signature table
to converge across the community, not fork per repo.
Credits
The pattern is older than the package. The framing -- "the file is context, the hook is the gate" -- is the consensus position from practitioners who got tired of agents quietly breaking the same rule on turn six of every session. The specific failure modes the three ratchets catch are scars from real codebases. They have names; the bypasses do too; both are written down here so the next agent has to walk past them on the way in.
Background reading and prior art:
- The agents.md spec -- https://agents.md
- HUMANS.md: my practical guide to making the agent do what you want, now with 100% more added sticks -- Lyndon Swan, 2026
- README, Don't AGENTS.md Me -- Josh Beckman
- When everything is important, nothing is -- OpenAI Harness team
- Anthropic's Claude Code memory docs (the "not a hard enforcement layer" passage)
License
MIT. See LICENSE.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file git_agent_ratchet-1.1.0.tar.gz.
File metadata
- Download URL: git_agent_ratchet-1.1.0.tar.gz
- Upload date:
- Size: 45.2 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
4c12f5b42ffd533f0c69ec2f4653eafa14f4cb8cf216f7e9957783087fb7ac80
|
|
| MD5 |
dbe8e3c81199b5353d6e4fb250c2d69e
|
|
| BLAKE2b-256 |
6f93abc61c83268a184bae2a52c6121202b80a50bdd66e353ea45a18604a5d29
|
Provenance
The following attestation bundles were made for git_agent_ratchet-1.1.0.tar.gz:
Publisher:
release.yml on monk-eee/git-agent-ratchet
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
git_agent_ratchet-1.1.0.tar.gz -
Subject digest:
4c12f5b42ffd533f0c69ec2f4653eafa14f4cb8cf216f7e9957783087fb7ac80 - Sigstore transparency entry: 1708471126
- Sigstore integration time:
-
Permalink:
monk-eee/git-agent-ratchet@88dd1e3189da279dba37bf611c495d7f0b23b2c9 -
Branch / Tag:
refs/tags/v1.1.0 - Owner: https://github.com/monk-eee
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@88dd1e3189da279dba37bf611c495d7f0b23b2c9 -
Trigger Event:
push
-
Statement type:
File details
Details for the file git_agent_ratchet-1.1.0-py3-none-any.whl.
File metadata
- Download URL: git_agent_ratchet-1.1.0-py3-none-any.whl
- Upload date:
- Size: 29.9 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
16dfbae7b89d76206f762fe30a415502300d2467813516b41d9e636622e39961
|
|
| MD5 |
9ee2403f45381b3898dade73ddb6b8a4
|
|
| BLAKE2b-256 |
488ed3a034a75fbb027162e90141fe1dce6c7af007da5fde7c223f03ee13041f
|
Provenance
The following attestation bundles were made for git_agent_ratchet-1.1.0-py3-none-any.whl:
Publisher:
release.yml on monk-eee/git-agent-ratchet
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
git_agent_ratchet-1.1.0-py3-none-any.whl -
Subject digest:
16dfbae7b89d76206f762fe30a415502300d2467813516b41d9e636622e39961 - Sigstore transparency entry: 1708471138
- Sigstore integration time:
-
Permalink:
monk-eee/git-agent-ratchet@88dd1e3189da279dba37bf611c495d7f0b23b2c9 -
Branch / Tag:
refs/tags/v1.1.0 - Owner: https://github.com/monk-eee
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@88dd1e3189da279dba37bf611c495d7f0b23b2c9 -
Trigger Event:
push
-
Statement type: