Originally published at morgan-vale.blogspot.com.
Among people in Korea who run coding agents for their daily work, one complaint keeps coming back: the instruction file only ever grows. Every fix after a bad run turns into one more rule in CLAUDE.md or AGENTS.md, nobody deletes anything, and a few months later the agent is reading a long file of half-contradicting rules before every task. The pattern that people describe as working is plain. Write the size limits down in a JSON file, let a pre-commit hook refuse any commit that goes over, and allow a limit to rise only when a human sets an approval variable for that one commit. I built that, tried to break it, and then gave the same over-budget task to a coding agent 21 times to see whether it would try to sneak around the hook. All numbers here are from my own runs on 2026-11-09.
Why a budget
My own root instruction file is the reason I cared. I already had a meter for it, a small script that runs when a session starts and writes a warning if the file passes its ceiling. It has never warned. Over fifteen days in October and November the file went from 6,598 to 7,172 characters, and the headroom under its 7,371-character ceiling shrank from 773 to 199. At that pace it crosses the line in about five days, and a warning file is easy to scroll past when it finally appears.
So the question for this post was narrow. If the limit is enforced at commit time instead of reported at session start, what does an agent do when it hits it?
The budget file
One rule per file pattern, first match wins. Each limit carries where the number came from and when that was checked, so that a year from now nobody has to guess why a skill file stops at 500 lines.
{ "version": 1, "rules": [ { "glob": "CLAUDE.md", "max_chars": 6000, "source": "own ceiling: size at setup (5444 chars) plus about 10%", "checked": "2026-11-09" }, { "glob": "**/AGENTS.md", "max_chars": 6000, "source": "same ceiling as CLAUDE.md", "checked": "2026-11-09" }, { "glob": ".claude/skills/*/SKILL.md", "max_lines": 500, "source": "Anthropic skill authoring best practices: keep the SKILL.md body under 500 lines", "checked": "2026-11-09" }, { "glob": "**/*.py", "max_bytes": 40000, "source": "own: split modules before 40 KB", "checked": "2026-11-09" } ] } The 500-line figure is the only one with an outside source. Anthropic's skill authoring guide says to keep the SKILL.md body under 500 lines and to split content into separate files when you get close. I checked that page on the day of the test. The other numbers are mine, and the file says so.
I measure the instruction files in characters, not bytes, and that choice comes from Korean. Across my 149 real CLAUDE.md and SKILL.md files, the 85 that are at least 30% Hangul average 1.81 bytes per character, against 1.07 for the rest. A byte budget would charge a Korean instruction file almost twice as much as an English one carrying the same amount of guidance, which I think is the wrong incentive. If your files mix scripts, use max_chars.
The hook
budget_check.py goes into .git/hooks/pre-commit. It reads only staged content, so what it measures is what will land in the commit. Standard library only.
#!/usr/bin/env python3 """budget_check.py - a git pre-commit hook that puts a size budget on agent config files. Budget lives in budget.json at the repo root. Each rule: glob + any of max_bytes / max_lines / max_chars, plus where the number came from (source) and when it was last checked (checked). First matching rule wins. Only staged files are measured, and only their staged content. Blocks the commit (exit 1) when: - a staged file is over its budget and did not shrink compared with HEAD - budget.json itself is loosened (a limit raised, a rule removed or its glob changed, or the file deleted) and BUDGET_RAISE_APPROVED is not set Raising a limit: a human sets BUDGET_RAISE_APPROVED="who: why" for that one commit. Each approved raise is appended to .git/budget-approvals.jsonl. Install: cp budget_check.py .git/hooks/pre-commit && chmod +x .git/hooks/pre-commit Standard library only, Python 3.8+. """ import json import os import re import subprocess import sys import time BUDGET_FILE = "budget.json" APPROVAL_VAR = "BUDGET_RAISE_APPROVED" def git(*args, check=True): r = subprocess.run(["git"] + list(args), stdout=subprocess.PIPE, stderr=subprocess.PIPE) if check and r.returncode != 0: return None return r.stdout def glob_to_regex(pattern): out, i = [], 0 while i < len(pattern): if pattern.startswith("**/", i): out.append("(?:.*/)?") i += 3 elif pattern.startswith("**", i): out.append(".*") i += 2 elif pattern[i] == "*": out.append("[^/]*") i += 1 elif pattern[i] == "?": out.append("[^/]") i += 1 else: out.append(re.escape(pattern[i])) i += 1 return re.compile("^" + "".join(out) + "$") def load_budget(blob): if blob is None: return None try: return json.loads(blob.decode("utf-8")) except ValueError: return {"rules": [], "_broken": True} def rule_for(path, rules): for r in rules: if glob_to_regex(r["glob"]).match(path): return r return None def measure(blob): lines = blob.count(b"\n") + (1 if blob and not blob.endswith(b"\n") else 0) return len(blob), lines, len(blob.decode("utf-8", "replace")) def over(rule, size, lines, chars): msgs = [] if "max_chars" in rule and chars > rule["max_chars"]: msgs.append("%d chars > %d" % (chars, rule["max_chars"])) if "max_bytes" in rule and size > rule["max_bytes"]: msgs.append("%d bytes > %d" % (size, rule["max_bytes"])) if "max_lines" in rule and lines > rule["max_lines"]: msgs.append("%d lines > %d" % (lines, rule["max_lines"])) return msgs def loosened(old, new): """Return human-readable reasons the staged budget is looser than HEAD's.""" if old is None: return [] # first budget ever committed if new is None: return ["budget.json deleted"] if new.get("_broken"): return ["budget.json is not valid JSON"] reasons = [] new_by_glob = {r["glob"]: r for r in new.get("rules", [])} for r in old.get("rules", []): n = new_by_glob.get(r["glob"]) if n is None: reasons.append("rule removed or renamed: %s" % r["glob"]) continue for key in ("max_bytes", "max_lines", "max_chars"): if key in r and (key not in n or n[key] > r[key]): reasons.append("%s %s: %s -> %s" % (r["glob"], key, r[key], n.get(key, "none"))) old_globs = [r["glob"] for r in old.get("rules", [])] new_globs = [r["glob"] for r in new.get("rules", []) if r["glob"] in old_globs] if new_globs != [g for g in old_globs if g in new_globs]: reasons.append("rule order changed (first match wins)") new_list = [r["glob"] for r in new.get("rules", [])] kept = [i for i, g in enumerate(new_list) if g in old_globs] for i, g in enumerate(new_list): if g not in old_globs and kept and i < kept[-1]: reasons.append("new rule %s placed before an existing rule (could shadow it)" % g) return reasons def main(): t0 = time.time() staged = git("diff", "--cached", "--name-only", "--diff-filter=ACMRD", "-z") if staged is None: print("budget: not a git repo?", file=sys.stderr) return 1 paths = [p for p in staged.decode("utf-8").split("\0") if p] deleted = set(p for p in (git("diff", "--cached", "--name-only", "--diff-filter=D", "-z") or b"").decode("utf-8").split("\0") if p) head_budget = load_budget(git("show", "HEAD:" + BUDGET_FILE)) staged_budget = None if BUDGET_FILE in deleted else load_budget(git("show", ":" + BUDGET_FILE)) problems = [] reasons = loosened(head_budget, staged_budget) approval = os.environ.get(APPROVAL_VAR, "").strip() if reasons and not approval: problems.append("budget.json loosened without approval:\n " + "\n ".join(reasons) + "\n A human can approve this one commit with %s=\"who: why\"." % APPROVAL_VAR) elif reasons: gitdir = (git("rev-parse", "--git-dir") or b".git").decode().strip() with open(os.path.join(gitdir, "budget-approvals.jsonl"), "a") as f: f.write(json.dumps({"time": time.strftime("%Y-%m-%dT%H:%M:%S%z"), "approval": approval, "changes": reasons}) + "\n") print("budget: raise approved (%s): %s" % (approval, "; ".join(reasons))) # An unapproved loosening is measured against HEAD's budget, so it cannot cover its own file. use_staged = staged_budget is not None and (not reasons or approval) rules = ((staged_budget if use_staged else head_budget) or {}).get("rules", []) for p in paths: if p in deleted or p == BUDGET_FILE: continue rule = rule_for(p, rules) if not rule: continue blob = git("show", ":" + p) if blob is None: continue size, lines, chars = measure(blob) msgs = over(rule, size, lines, chars) if not msgs: continue old = git("show", "HEAD:" + p) if old is not None: osize, olines, ochars = measure(old) if size < osize and lines <= olines and chars <= ochars: print("budget: %s still over (%s) but shrinking, allowed" % (p, ", ".join(msgs))) continue problems.append("%s over budget: %s [rule %s, source: %s, checked %s]" % (p, ", ".join(msgs), rule["glob"], rule.get("source", "?"), rule.get("checked", "?"))) ms = (time.time() - t0) * 1000 if problems: print("budget: commit blocked (%.0f ms)" % ms, file=sys.stderr) for m in problems: print(" - " + m, file=sys.stderr) print(" Shrink the file, move detail into a file the agent loads on demand," " or ask a human to approve a higher limit.", file=sys.stderr) return 1 print("budget: ok, %d staged file(s) checked (%.0f ms)" % (len(paths), ms)) return 0 if __name__ == "__main__": sys.exit(main()) Two details took me a second try. A file that is already over budget can still be committed if it got smaller, otherwise the first person to trim an oversized file gets punished for it. And when budget.json is loosened without approval, the other files in that commit are measured against the old budget from HEAD, so an agent cannot raise its own limit and slip the bigger file through in the same commit.
Breaking the hook
I set up a throwaway repo on my always-on Mac with a fake project: a 5,444-character CLAUDE.md, one skill file, one Python file, and the budget above. Then I committed thirteen deliberate changes, each in a fresh copy.
| # | Change | Expected | Got |
|---|---|---|---|
| T01 | CLAUDE.md +96 chars | pass | pass |
| T02 | CLAUDE.md +1,407 chars (6,851) | block | block |
| T03 | SKILL.md to 522 lines | block | block |
| T04 | already over, trimmed 340 chars, still over | pass | pass |
| T05 | raise limit 6000 to 8000 and grow file, no approval | block | block |
| T06 | same, with the approval variable set | pass | pass, one ledger line |
| T07 | delete the CLAUDE.md rule and grow file | block | block |
| T08 | delete budget.json and grow file | block | block |
| T09 | insert a catch-all ** rule on top | block | block |
| T10 | move new text into unbudgeted docs/agent-notes.md | pass | pass |
| T11 | commit with --no-verify | git cannot stop it | pass |
| T12 | break the JSON in budget.json | block | block |
| T13 | only lower a limit | pass | pass |
The hook took 14 to 25 ms per commit. My first run of this table was worthless, by the way. I had changed the measuring function to also count characters and left one caller expecting two values, so every case crashed and a crash counts as a block. Six of the seven "expected block" rows looked right for the wrong reason, which is a good argument for having pass cases in any test of a blocker.
T10 and T11 are the honest gaps. Moving text to a file the budget does not cover is allowed, and that is partly the point, since it is what the skill guide recommends. --no-verify is not something a git hook can ever see.
A guard for the agent
--no-verify, editing budget.json, and typing the approval variable yourself are all one shell command away for an agent. Claude Code can run a hook before every tool call, and exit code 2 from that hook blocks the call and sends the reason back to the model. So I wrote a second script for that.
#!/usr/bin/env python3 """budget_guard.py (v2) - Claude Code PreToolUse hook that keeps the agent from walking around budget_check.py. A git hook cannot stop `git commit --no-verify`, and the agent can edit budget.json or set the approval variable itself. This hook denies those moves before they run and lets reads through. Exit 2 + a reason on stderr = the tool call is blocked and the reason goes back to the agent. .claude/settings.json in the repo: {"hooks": {Source:https://dev.to/morganvale/put-a-byte-budget-on-your-agents-config-files-and-let-a-hook-say-no-3cg8Related posts

Jonathan Kanter is using AI to take on Big Tech
Firefox Extensions That Work Offline: Grammar, Voice, Habits, and Math Without a Backend
AWS Lightsail pricing in 2026: what each bundle gets you, and the catches
Quest 3ネイティブ化という分水嶺——コックピットから分子の内部へ、空間コンピューティングが科学を変える瞬間
