"""Hold every AGENTS.md to a size ratchet.
Agent guides are loaded into agent context: the root file in every session for
every task, each component guide whenever an agent works in that area. Every
line is paid for repeatedly, by every agent, forever. Unbounded guides are how a
repo ends up with rules nobody reads and pointers nobody maintains.
Budgets are a ratchet. When a file shrinks, lower its budget in the same PR.
Never raise a budget to admit detail that has a home one level down:
root AGENTS.md cross-component rules and the index, nothing else
component AGENTS.md that component's detail
.github/agent-docs/*.md occasional reference an agent can be pointed to
Component guides are the pressure valve for the root file; .github/agent-docs/ is the
pressure valve for the component guides. There is always somewhere to put detail
that is cheaper than the file you are editing.
"""
from __future__ import annotations
import subprocess
import sys
import tempfile
from pathlib import Path
BUDGETS: dict[str, tuple[int, int]] = {
"AGENTS.md": (180, 18_000),
".github/AGENTS.md": (45, 4_500),
"app/AGENTS.md": (170, 11_500),
"backend/AGENTS.md": (350, 39_000),
"desktop/macos/AGENTS.md": (560, 47_000),
"desktop/windows/AGENTS.md": (127, 6_950),
"omi/firmware/AGENTS.md": (30, 1_500),
"web/admin/AGENTS.md": (25, 1_500),
"web/app/AGENTS.md": (55, 2_400),
"docs/AGENTS.md": (34, 1_309),
}
SKIP_PARTS = {"node_modules", ".build", ".git"}
FAILURE_HINT = (
"AGENTS.md files are loaded into agent context every session they apply to.\n"
"Move detail down a level instead of growing the file:\n"
" root AGENTS.md -> the matching component AGENTS.md\n"
" component AGENTS.md -> .github/agent-docs/<topic>.md, linked from its index row\n"
"Do not raise a budget to admit detail that has a home one level down.\n"
"See .github/agent-docs/doc-maintenance.md."
)
def measure(data: bytes) -> tuple[int, int]:
text = data.decode("utf-8")
lines = text.count("\n") + (0 if data.endswith(b"\n") else 1)
return lines, len(data)
def check_file(path: Path, budget: tuple[int, int], label: str) -> list[str]:
max_lines, max_bytes = budget
lines, size = measure(path.read_bytes())
errors = []
if lines > max_lines:
errors.append(f"{label}: {lines} lines exceeds budget of {max_lines}")
if size > max_bytes:
errors.append(f"{label}: {size} bytes exceeds budget of {max_bytes}")
return errors
def ignored_paths(repo: Path) -> set[Path]:
"""Directories and files git ignores, as absolute paths.
The scan walks the working tree, but the contract is about the tree CI checks
out. A developer machine also holds gitignored siblings — `.claude/worktrees/`
is a documented multi-worktree pattern — and each of those contains its own
copy of the repo's tracked AGENTS.md files. Walking into them reports a file
that is neither new nor in the pushed diff, and the only way past it is
`--no-verify`, which drops every other pre-push guard too.
Returns an empty set when git cannot answer, so the check degrades to the
static SKIP_PARTS list rather than failing.
"""
try:
completed = subprocess.run(
[
"git", "-C", str(repo), "ls-files", "--others", "--ignored",
"--exclude-standard", "--directory", "-z",
],
capture_output=True,
text=True,
timeout=60,
check=True,
)
except (OSError, subprocess.SubprocessError):
return set()
return {
(repo / entry).resolve()
for entry in completed.stdout.split("\0")
if entry
}
def discover(repo: Path) -> list[Path]:
ignored = ignored_paths(repo)
def is_ignored(path: Path) -> bool:
resolved = path.resolve()
return any(
resolved == candidate or candidate in resolved.parents
for candidate in ignored
)
return sorted(
p for p in repo.rglob("AGENTS.md")
if not SKIP_PARTS.intersection(p.parts) and not is_ignored(p)
)
def self_test() -> None:
with tempfile.TemporaryDirectory() as tmp:
f = Path(tmp) / "AGENTS.md"
f.write_text("# ok\n")
assert check_file(f, (10, 100), "t") == [], "lean file must pass"
f.write_text("x\n" * 11)
assert any("lines" in e for e in check_file(f, (10, 10_000), "t")), (
"over-lines must fail"
)
f.write_text("y" * 101)
assert any("bytes" in e for e in check_file(f, (10_000, 100), "t")), (
"over-bytes must fail"
)
with tempfile.TemporaryDirectory() as tmp:
repo = Path(tmp)
try:
subprocess.run(
["git", "init", "-q", str(repo)],
capture_output=True, timeout=60, check=True,
)
except (OSError, subprocess.SubprocessError):
return
(repo / ".gitignore").write_text(".claude/\n")
(repo / "AGENTS.md").write_text("# tracked\n")
worktree = repo / ".claude" / "worktrees" / "agent-abc" / ".github"
worktree.mkdir(parents=True)
(worktree / "AGENTS.md").write_text("# copy inside an ignored sibling worktree\n")
found = {p.relative_to(repo).as_posix() for p in discover(repo)}
assert found == {"AGENTS.md"}, (
f"ignored worktree copies must not be discovered, got {sorted(found)}"
)
def main() -> int:
self_test()
repo = Path(__file__).resolve().parents[2]
errors: list[str] = []
found = {p.relative_to(repo).as_posix() for p in discover(repo)}
for rel in sorted(found - BUDGETS.keys()):
errors.append(
f"{rel}: new AGENTS.md has no budget. Add one to BUDGETS in "
f"{Path(__file__).name}, set to its current size."
)
for rel in sorted(BUDGETS.keys() - found):
errors.append(f"{rel}: has a budget but no longer exists. Remove its entry.")
for rel in sorted(found & BUDGETS.keys()):
errors.extend(check_file(repo / rel, BUDGETS[rel], rel))
if errors:
print("AGENTS.md size ratchet failed:\n", file=sys.stderr)
for e in errors:
print(f" {e}", file=sys.stderr)
print(f"\n{FAILURE_HINT}", file=sys.stderr)
return 1
print(f"ok: {len(found)} AGENTS.md files within budget")
return 0
if __name__ == "__main__":
sys.exit(main())