Find out which CLAUDE.md and rules files Claude Code really loads: an InstructionsLoaded audit you can run today
Two small scripts log every instruction file a session pulls in, why it loaded and what it costs, then flag the rules that never fire. About 15 minutes to set up.
Part of the hooks reading guide

A CLAUDE.md starts at 20 lines. A year later it has 240, a rules directory with nine files, an import of the commands doc and a note about release notes that applied to one afternoon in March. Every session pays for all of it before the first prompt is typed, and the agent follows the long version a little less closely than it followed the short one.
The usual advice is to prune, which is fine and unactionable. Prune what? Claude Code can tell you, because it fires an event every time an instruction file enters the context. That event is InstructionsLoaded, and it carries the file, the scope and the reason it loaded. Log it for a few days and the question changes from “what feels bloated” to “which files load every time, what do they weigh, and which ones have never fired”.
By the end of this piece there are two scripts in .claude/hooks/. One appends a line to a log for every instruction file loaded. The other turns the log into a table, lists rules that exist but never loaded, and can fail a check when the always-on part grows past a token budget you pick. Setup takes about 15 minutes. The first result shows up in your next session.
What the event gives you
Claude Code fires InstructionsLoaded when a CLAUDE.md or .claude/rules/*.md file is loaded into context. Some files load at session start. Others load lazily: a nested CLAUDE.md when Claude touches a file in that directory, a rule with paths: frontmatter when a matching file is read.
The hook can’t block anything. The docs say it runs asynchronously and that Claude Code discards its JSON output, so a buggy audit script can’t break a session. That makes it a safe thing to leave on.
The input is the common hook fields plus six of its own:
file_path absolute path of the instruction file
memory_type "User", "Project", "Local" or "Managed"
load_reason "session_start", "nested_traversal", "path_glob_match", "include" or "compact"
globs the paths: patterns, for path_glob_match only
trigger_file_path the file whose access caused a lazy load
parent_file_path the file that imported this one, for include loads
The load reason is the useful part. Anything marked session_start or include costs you in every session, whatever you ask. Anything marked nested_traversal or path_glob_match costs you only when Claude goes near the files it’s about.
Step 1: log every load
Create .claude/hooks/log-instructions.py. It reads the event from stdin, adds the file’s size on disk and appends one JSON line to .claude/logs/instructions.jsonl.
#!/usr/bin/env python3
"""InstructionsLoaded hook: append one JSON line per loaded instruction file."""
import json, os, sys, time
event = json.load(sys.stdin)
path = event.get("file_path", "")
try:
size = os.path.getsize(path)
except OSError:
size = 0
record = {
"ts": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
"session": event.get("session_id"),
"file": path,
"scope": event.get("memory_type"),
"reason": event.get("load_reason"),
"trigger": event.get("trigger_file_path"),
"parent": event.get("parent_file_path"),
"globs": event.get("globs"),
"bytes": size,
}
log_dir = os.path.join(os.environ.get("CLAUDE_PROJECT_DIR", event["cwd"]), ".claude", "logs")
os.makedirs(log_dir, exist_ok=True)
with open(os.path.join(log_dir, "instructions.jsonl"), "a", encoding="utf-8") as f:
f.write(json.dumps(record) + "\n")
Register it in .claude/settings.json. There’s no matcher here, so it fires for every load reason. A matcher on this event filters on load_reason, so "matcher": "path_glob_match|nested_traversal" would log only the lazy loads.
{
"hooks": {
"InstructionsLoaded": [
{
"hooks": [
{
"type": "command",
"command": "python3 \"$CLAUDE_PROJECT_DIR/.claude/hooks/log-instructions.py\""
}
]
}
]
}
}
Then keep the log out of git. It records absolute paths from your machine, and it’s yours, not the team’s:
echo ".claude/logs/" >> .gitignore
On Windows, python3 is often python or py. Use whichever your shell resolves, in the command string.
Step 2: see it work
A small fixture makes the output easy to read. The repository below is a C# API and a TypeScript front end, with a root CLAUDE.md that imports a commands doc, three rules, and a nested CLAUDE.md under src/api:
CLAUDE.md imports @docs/commands.md
docs/commands.md
.claude/rules/release-notes.md no paths: loads at launch
.claude/rules/api-conventions.md paths: src/api/**/*.cs
.claude/rules/web-conventions.md paths: src/web/**/*.ts
src/api/CLAUDE.md
src/api/OrdersHandler.cs
src/web/cart.ts
Start a session that reads one API file:
claude -p "Read src/api/OrdersHandler.cs and tell me the class name. One word answer." </dev/null
The log now holds five lines. Trimmed to the fields that matter (paths shortened, ts, session and scope dropped):
{"file": "CLAUDE.md", "reason": "session_start", "bytes": 173}
{"file": "docs/commands.md", "reason": "include", "parent": "CLAUDE.md", "bytes": 62}
{"file": ".claude/rules/release-notes.md", "reason": "session_start", "bytes": 95}
{"file": "src/api/CLAUDE.md", "reason": "nested_traversal", "trigger": "src/api/OrdersHandler.cs", "bytes": 53}
{"file": ".claude/rules/api-conventions.md", "reason": "path_glob_match", "trigger": "src/api/OrdersHandler.cs", "globs": ["src/api/**/*.cs"], "bytes": 134}
Three files loaded before the agent did anything. Two more arrived in the same second as the Read of OrdersHandler.cs, and both name that file as the trigger. The web rule never appeared, because nothing under src/web was touched. That’s path scoping doing what it says.
Step 3: turn the log into a table
One session is an anecdote. After a week of normal work the log is a picture of your project. The second script, .claude/hooks/instructions-report.py, reads it, compares it with the instruction files on disk and prints a summary:
#!/usr/bin/env python3
"""Summarise .claude/logs/instructions.jsonl against the instruction files on disk."""
import json, pathlib, sys
from collections import defaultdict
root = pathlib.Path(sys.argv[1] if len(sys.argv) > 1 else ".").resolve()
log = root / ".claude" / "logs" / "instructions.jsonl"
rows = [json.loads(l) for l in log.read_text().splitlines() if l.strip()] if log.exists() else []
def short(f):
try:
return str(pathlib.Path(f).relative_to(root))
except ValueError:
return f # user-level or managed file outside the repo
by_file = defaultdict(list)
for r in rows:
by_file[r["file"]].append(r)
on_disk = {str(p.resolve()) for p in [*root.rglob("CLAUDE.md"), *(root / ".claude" / "rules").glob("*.md")]
if "node_modules" not in p.parts}
print(f"{len(rows)} logged loads\n")
print(f"{'file':44} {'reason':18} {'loads':>5} {'~tokens':>8}")
for f in sorted(by_file, key=lambda f: -by_file[f][0]["bytes"] * len(by_file[f])):
rs = by_file[f]
reasons = ",".join(sorted({r["reason"] for r in rs}))
tokens = rs[0]["bytes"] // 4 # rough: about 4 bytes per token for English prose
print(f"{short(f):44} {reasons:18} {len(rs):>5} {tokens:>8}")
never = sorted(on_disk - set(by_file))
if never:
print("\nOn disk but never loaded in the logged sessions:")
for f in never:
print(" " + short(f))
# Optional budget: python3 instructions-report.py . 2000 (exit 1 if eager files exceed ~2000 tokens)
if len(sys.argv) > 2:
eager = {r["file"]: r["bytes"] for r in rows if r["reason"] in ("session_start", "include")}
total = sum(eager.values()) // 4
print(f"\nEager load: ~{total} tokens across {len(eager)} files (budget {sys.argv[2]})")
sys.exit(1 if total > int(sys.argv[2]) else 0)
The token figure is bytes divided by four. That’s a rough rule for English prose, not a tokeniser, and code-heavy files will come out lower than the truth. Use it to rank files against each other, not to bill anyone.
Three short sessions on the fixture in all (one reading an API file, one reading a web file, one asking for 2+2), plus two rule files that nothing matches, give this:
python3 .claude/hooks/instructions-report.py .
12 logged loads
file reason loads ~tokens
CLAUDE.md session_start 3 43
.claude/rules/release-notes.md session_start 3 23
docs/commands.md include 3 15
.claude/rules/api-conventions.md path_glob_match 1 33
.claude/rules/web-conventions.md path_glob_match 1 33
src/api/CLAUDE.md nested_traversal 1 13
On disk but never loaded in the logged sessions:
.claude/rules/orphan.md
.claude/rules/terraform.md
The sort puts the files that cost the most across all sessions first. Real projects will show the pattern better than a fixture can: a handful of eager files at the top, loading in every session, and a long tail of scoped rules that fire now and then.
Reading the result
The table answers three questions, each with a different action.
First, what loads every time? The session_start and include rows are your fixed cost. The docs say to keep each CLAUDE.md under about 200 lines, and they’re explicit that imports don’t help: imported files load at launch, so @docs/commands.md costs exactly what pasting it in would. If a file near the top of the table is only relevant to part of the codebase, it belongs in a path-scoped rule.
Second, what never loaded? A scoped rule absent from the log is either dead or aimed at the wrong paths. In the fixture, terraform.md is scoped to infra/**/*.tf and there’s no infra directory, so it can never fire. A file that matches nothing is a typo in a glob or a leftover from a deleted directory. Check it before deleting it, and check that its globs still describe the repository.
Third, what loads for the wrong reason? Rules with no paths: field load at launch. A rule that was meant to be scoped but isn’t shows up as session_start. Frontmatter that fails to parse produces the same result: the docs say Claude Code then ignores the frontmatter and loads the rule as if it had no paths. claude --debug shows the parse error, but the log shows the symptom without you having to look for it.
Fix one and watch the number move
Take release-notes.md from the fixture. It says to keep a changelog entry per merged PR, which matters when someone edits the changelog and at no other time. It has no paths:, so it loads in every session. Scope it:
---
paths:
- "CHANGELOG.md"
---
# Release notes
Always keep a changelog entry per merged PR. Use the Keep a Changelog format.
With a fresh log, a session that asks 2+2 before the change prints this, using the optional budget argument:
python3 .claude/hooks/instructions-report.py . 40
4 logged loads
file reason loads ~tokens
CLAUDE.md session_start 1 43
.claude/rules/release-notes.md session_start 1 23
docs/commands.md include 1 15
.claude/rules/orphan.md session_start 1 0
On disk but never loaded in the logged sessions:
.claude/rules/api-conventions.md
.claude/rules/terraform.md
.claude/rules/web-conventions.md
src/api/CLAUDE.md
Eager load: ~83 tokens across 4 files (budget 40)
The script exits 1, because 83 is over the budget of 40. After the edit, with a fresh log and the same prompt:
3 logged loads
file reason loads ~tokens
CLAUDE.md session_start 1 43
docs/commands.md include 1 15
.claude/rules/orphan.md session_start 1 0
Eager load: ~59 tokens across 3 files (budget 40)
release-notes.md is gone from the launch set, and the eager total drops from about 83 to about 59 tokens. It still exits 1, which is the budget doing its job. (The orphan.md stub is a one-line rule with no paths:. It loads in every session and weighs almost nothing, which is the shape a forgotten rule tends to have.)
The fixture is tiny, so the savings are tiny. On a real repository the same move applies to the 60-line section on database migrations that every session carries and one in twenty uses. Your own table shows whether the numbers look like that.
Make it yours
Audit across every project. Put the hook in ~/.claude/settings.json with an absolute path to the script and write the log to a fixed location under your home directory instead of CLAUDE_PROJECT_DIR. User-scope files such as ~/.claude/CLAUDE.md then show up with scope set to User, and the report script prints their full path because they sit outside the repository. Those are the files that tax every project at once, so they’re worth a look.
Log only the lazy loads. Add "matcher": "path_glob_match|nested_traversal" to see which scoped rules fire and what triggers them, without the noise of the launch set. That’s the setting to use if the question is only whether the path globs are right.
Track compaction. The docs list a fifth reason, compact, which fires when instruction files reload after a compaction. The project-root CLAUDE.md is re-read from disk and re-injected after /compact, while nested files and scoped rules load again on demand. That behaviour is documented, not observed here, but the same log would show it, and it’s worth checking if long sessions seem to forget a rule that only loads lazily.
Turn the budget into a team habit. The exit 1 makes the report usable in a pre-push hook or a Makefile target. A CI job can’t use it, because CI doesn’t run your sessions and so has no log to read. If a team wants a static check instead, count the lines and bytes of the files that have no paths: and fail on that. It’s cruder, but it needs no agent.
What goes wrong
The log records cost, not value. A file that loads in every session might be the most useful thing in the repository, and the table can’t tell you. A rule that never loaded might guard something that only matters at release time. Treat the table as a list of files to look at, and read each one before changing it.
The sample is only as good as your sessions. Three days of bug fixes in one service says nothing about the rules for the other service. Keep the log running until it has covered the kinds of work you do, and note that a rule can sit unused through a quiet month and then matter.
Eager rules can’t be measured by use. The hook sees that a file entered the context, not whether the agent obeyed it or even needed it. If the question is whether an instruction changes behaviour, the answer is an experiment: remove it, run the same task a few times and compare. The log narrows down which instructions are worth that experiment.
The log has paths in it. It contains absolute paths on your machine and the names of files the agent touched. Keep it out of git, as above, and don’t paste it into a public issue without a look.
Parts of this are an estimate. Bytes over four is rough, and the hook sees the file as it is on disk, which for a rule includes frontmatter that Claude Code strips before loading it. For ranking files against each other it’s close enough. For a precise token count, use /context in an interactive session, which lists the memory files loaded at launch.
When not to bother: a repository with one 40-line CLAUDE.md and no rules directory has nothing to find. The audit pays off once there are several files, a few imports and at least one rule somebody wrote months ago.
The first 15 minutes
- Save the logging script to
.claude/hooks/log-instructions.pyand register it underInstructionsLoadedin.claude/settings.json. - Add
.claude/logs/to.gitignore. - Start a session, read a file in a subdirectory that has its own
CLAUDE.mdand check that.claude/logs/instructions.jsonlhas lines withsession_startand a lazy reason. - Work normally for a few days, then run the report script and look at the top three rows and the never-loaded list first.
- Scope or delete one file. Run a session, run the report again and check that the eager total moved.