The Day My CLAUDE.md Ate a Third of Every Session
Every new session started 60% full before I typed a word. The culprit was my own rules file. Splitting it took a day, three of Claude's mistakes, a loading test, and two new lints.
The Setup
For a few weeks I had been hitting the compaction limit in Claude Code after only a handful of messages. Worse, a brand-new session showed itself as roughly 60 percent full after my very first message. I assumed it was the conversations getting long. It was not.
I asked Claude to explain it, and the first thing it did was measure what gets loaded before a session starts. The answer was sitting in my own project: CLAUDE.md had grown to nearly 300 KB. Well over a hundred sections. A list of pipeline commands for every library and museum I ingest, one line each with a long comment. Every Android build-guard rule. Every origin story for every bug I had ever fixed. All of it, every session, before any work began.
The first measurement, before anything changed. Shares are of a 200,000-token window; the exact bytes and the token estimates behind them are one tap below each table:
| Item | Size | Share of a 200k window |
|---|---|---|
| Project CLAUDE.md (123 sections) | about 290 KB | about a third |
| MEMORY.md index (127 memory files) | about 19 KB | a few percent |
| Tool schemas, MCP instructions, skill list, system prompt | n/a | roughly a tenth |
| Total before the first user message | about half |
Exact measurements
| Item | Size | Approx. tokens |
|---|---|---|
| Project CLAUDE.md (123 sections, 207 pipeline command lines) | 288,225 bytes | ~71,000 |
| MEMORY.md index (127 memory files at the time) | 18,833 bytes | ~5,000 |
| Tool schemas, MCP instructions, skill list, system prompt | n/a | roughly 20,000 to 30,000 |
| Total before the first user message | roughly 100,000 of a 200,000 window |
Ranking the sections showed where the bytes were. Two sections were a third of the file on their own:
| Section | Size | Share of the file |
|---|---|---|
| Ingest pipeline reminder (one line per library or venue) | 46 KB | 16% |
| Android app | 35 KB | 12% |
| Analytics Rules | 7 KB | 2% |
| Push notification permission rules | 5 KB | 2% |
| Python script argument convention (an 80-row table) | 5 KB | 2% |
| Silent failure rule | 5 KB | 2% |
| Source configuration (source_configs table) | 5 KB | 2% |
| The remaining 116 sections, average | under 2 KB each | under 1% each |
Exact measurements
| Section | Bytes | Approx. tokens |
|---|---|---|
| Ingest pipeline reminder (one line per library or venue) | 45,891 | ~11,472 |
| Android app | 35,011 | ~8,752 |
| Analytics Rules | 7,056 | ~1,764 |
| Push notification permission rules | 5,356 | ~1,339 |
| Python script argument convention (an 80-row table) | 5,031 | ~1,257 |
| Silent failure rule | 4,983 | ~1,245 |
| Source configuration (source_configs table) | 4,546 | ~1,136 |
| The remaining 116 sections, average | ~1,600 each | ~400 each |
The reason it had grown that way was also my own doing. Four of my rules said that every learning, every mistake, and every "going forward" commitment must be written into CLAUDE.md in addition to a memory file. I had built a file that could only grow.
The First Cut
The quick win was obvious once measured. The pipeline command list moved into its own file that gets grepped on demand. The Android rules moved into a nested CLAUDE.md inside the Android folder, which Claude Code only loads when it touches that directory. An eighty-row table saying the same thing about every script collapsed into two sentences. The root file dropped by about a third with nothing deleted.
The three files after the cut held 287,184 bytes against the original 288,225; the difference is the collapsed table, a few rewritten policy rules, and the new file headers:
| File | Before | After | Loaded when |
|---|---|---|---|
| Root CLAUDE.md | about 290 KB, a third of the window | about 190 KB, a quarter of the window | every session |
| Android-Kids-activities/CLAUDE.md (nested) | did not exist | about 53 KB | only when a file under that directory is read |
| Kids-activities-app-support/pipeline-commands.md | in root | about 47 KB | only when grepped |
| MEMORY.md index | about 19 KB | about 19 KB | every session |
Exact measurements
| File | Before | After | Loaded when |
|---|---|---|---|
| Root CLAUDE.md | 288,225 bytes (~71k tokens) | 187,824 bytes (~47k tokens) | every session |
| Android-Kids-activities/CLAUDE.md (nested) | did not exist | 52,790 bytes (~13k tokens) | only when a file under that directory is read |
| Kids-activities-app-support/pipeline-commands.md | in root | 46,570 bytes (~12k tokens) | only when grepped |
| MEMORY.md index | 18,833 bytes | 19,169 bytes | every session |
Before doing any of it I asked the question I ask on every non-trivial change: have you thought about all the scenarios? That question turned up things the plan had missed. Two skills wrote new sources into the pipeline section by name and would have written to the wrong file. A memory file would have re-taught the old location every session. My PreCompact hook, the one that refuses to compact until a session note is saved, meant auto-compaction never ran at all, so hitting the limit felt abrupt because the session simply stalled. And the folder is not a git repository, so a bad edit had no undo. I backed the file up first.
Two guards came out of that pass. The fetcher lint got a new check that fails when a fetcher has no entry in the commands file, because a file that becomes the only registry of something needs a drift guard the day it is created. And a hook now warns whenever a rules file is written above a size budget.
My Idea: One File Per Platform
The first cut was good but not enough. I suggested going further: separate rule files for iOS, Android, the pipeline, and the web, so a session loads only the rules for what it is working on. Claude liked the idea and produced a bucketing of the remaining sections by heading keyword.
This was that first pass over the 181 KB root. The cross-platform row is the one that turned out to be wrong:
| Keyword bucket (first pass) | Size | Share of the root |
|---|---|---|
| Pipeline | 81 KB | 45% |
| Skill and process rules | 41 KB | 23% |
| iOS | 34 KB | 19% |
| Cross-platform / must-know-before-any-read | 15 KB | 8% |
| Web and blog | 6 KB | 3% |
| Android leftovers | 1 KB | 1% |
Exact measurements
| Keyword bucket (first pass) | Size | Approx. tokens |
|---|---|---|
| Pipeline | 81 KB | ~20,927 |
| Skill and process rules | 41 KB | ~10,641 |
| iOS | 34 KB | ~8,768 |
| Cross-platform / must-know-before-any-read | 15 KB | ~4,070 |
| Web and blog | 6 KB | ~1,559 |
| Android leftovers | 1 KB | ~495 |
I asked the scenarios question again. This time Claude's own answer opened with "my previous answer had two mistakes." The keyword sort had filed rules that bind two platforms, like the series start-date rule and the event-field rule that iOS and Android must both follow, under a single platform. And "move process rules into the skills" was only half right: a rule that triggers a skill cannot live inside the skill, because the skill is not loaded until it runs. The corrected design kept a smaller root of cross-platform contracts and "before you act" rules, and it raised a question nobody could answer by reasoning: when does a per-platform file actually load?
The Loading Test
Claude Code supports a rules folder where a file can declare which paths it belongs to, and the docs say such a file loads when Claude reads a matching file. Claude wrote three marker files, one scoped to the iOS folder, one to the pipeline folder, and one unscoped as a control. Then it ran the test inside subagents, because a headless session could not authenticate from inside the desktop app, and I ran the two checks only a real fresh session could answer.
Every step, where it ran, and what was visible afterwards (Claude Code 2.1.81):
| Step | Where it ran | Action | Markers visible after |
|---|---|---|---|
| Startup | subagent, and a fresh top-level session | nothing | unscoped marker only (top-level); none (subagent) |
| Bash read | subagent | sed on an iOS file | none |
| Read tool | subagent | same iOS file | iOS marker |
| Bash read | subagent | sed on a pipeline file | still only the iOS marker |
| Read tool | subagent | same pipeline file | iOS and pipeline markers |
| Read tool | subagent | a file outside every scope | none |
| Parent session | main session after both agents | nothing | none |
| /compact | fresh top-level session, after loading the iOS marker | compaction | unscoped marker active; iOS marker dropped |
The findings changed the design:
- Only the normal file reader triggers a scoped file. Reading a file through the terminal, which is how Claude had read almost everything that week, loads nothing.
- A subagent keeps what it loads to itself. The main session never saw the markers the agents loaded.
- Scoped files are dropped at compaction. Only the unscoped control and the root file came back after
/compact. - The rules folder is recognised on my version, and a folder name with a space works in the glob.
So automatic loading became a bonus, not the mechanism. The root file now opens with a table saying which file to read before which kind of work, that the read must be repeated after any compaction, and that any subagent must be told to read it too.
One correction along the way was mine and small, but it went into memory anyway: Claude kept calling the morning's work "yesterday's move." All of this happened in one afternoon. There is no clock between turns, and the length of a conversation is not evidence that a day has passed.
The Split
I asked for the classification table before anything moved. Claude sorted every section by hand into destinations, saved the mapping as a file the split script would consume, and showed me the judgment calls it wanted me to be able to overrule. I made two changes. I asked for the files to be named CLAUDE_IOS.md, CLAUDE_ANDROID.md and so on, which works inside the rules folder even though it would not at the project root. And I asked for analytics to get its own file, because analytics cuts across every platform and had been sitting in the iOS bucket.
The hand classification, after the analytics file was added, is what the split script consumed:
| Destination | Sections | Size |
|---|---|---|
| Root, action rules that fire before any file is read | 5 | 5 KB |
| Root, cross-platform contracts | 9 | 19 KB |
| Root, working habits | 21 | 24 KB |
| Root, pointers to the other files | 2 | 2 KB |
| CLAUDE_PIPELINE.md | 46 | 77 KB |
| CLAUDE_IOS.md | 16 | 32 KB |
| CLAUDE_ANALYTICS.md (a topic file, my suggestion) | 4 | 11 KB |
| CLAUDE_WEB.md | 3 | 5 KB |
| CLAUDE_ANDROID.md (plus the ten sections already in the nested file) | 1 | 1 KB |
| Into the blog skill | 3 | 5 KB |
| Into the save-session skill | 2 | 4 KB |
| Deleted as a duplicate of the global CLAUDE.md | 1 | under 1 KB |
| Root after the split, before pointers and headers | 37 | about 50 KB, a quarter of what it was |
Exact measurements
| Destination | Sections | Bytes |
|---|---|---|
| Root, action rules that fire before any file is read | 5 | 4,934 |
| Root, cross-platform contracts | 9 | 18,693 |
| Root, working habits | 21 | 24,095 |
| Root, pointers to the other files | 2 | 2,402 |
| CLAUDE_PIPELINE.md | 46 | 77,430 |
| CLAUDE_IOS.md | 16 | 32,287 |
| CLAUDE_ANALYTICS.md (a topic file, my suggestion) | 4 | 10,547 |
| CLAUDE_WEB.md | 3 | 4,646 |
| CLAUDE_ANDROID.md (plus the ten sections already in the nested file) | 1 | 969 |
| Into the blog skill | 3 | 4,866 |
| Into the save-session skill | 2 | 4,483 |
| Deleted as a duplicate of the global CLAUDE.md | 1 | 499 |
| Root after the split, before pointers and headers | 37 | 50,124 |
The split itself ran in one pass and verified that every original section landed exactly once. The root file ended the day at under 60 KB, down from nearly 300 KB that morning. A sanity read caught one slip: the root's title line and its first heading had been glued together because the split lost a newline. Fixed before I saw it, but it went on the list.
Where everything ended up, measured at the end of the day. Every one of the 113 original sections is present exactly once across these files, verified by heading:
| File | Sections | Size | Share of the window | Loaded when |
|---|---|---|---|---|
| Root CLAUDE.md | 39 | 54 KB | about 7% | every session |
| .claude/rules/CLAUDE_PIPELINE.md | 46 | 79 KB | about 10% | pipeline work |
| .claude/rules/CLAUDE_ANDROID.md | 11 | 52 KB | about 7% | Android work |
| .claude/rules/CLAUDE_IOS.md | 16 | 31 KB | about 4% | iOS work |
| .claude/rules/CLAUDE_ANALYTICS.md | 4 | 18 KB | about 2% | analytics work on any platform |
| .claude/rules/CLAUDE_WEB.md | 3 | 6 KB | under 1% | web and blog deploys |
| Kids-activities-app-support/pipeline-commands.md | 207 command lines | 47 KB | about 6% | when grepped |
| MEMORY.md index | 131 memory files | 20 KB | about 3% | every session |
Exact measurements
| File | Sections | Bytes | Approx. tokens | Loaded when |
|---|---|---|---|---|
| Root CLAUDE.md | 39 | 54,414 | ~13,600 | every session |
| .claude/rules/CLAUDE_PIPELINE.md | 46 | 79,195 | ~19,800 | pipeline work |
| .claude/rules/CLAUDE_ANDROID.md | 11 | 52,046 | ~13,000 | Android work |
| .claude/rules/CLAUDE_IOS.md | 16 | 31,178 | ~7,800 | iOS work |
| .claude/rules/CLAUDE_ANALYTICS.md | 4 | 18,423 | ~4,600 | analytics work on any platform |
| .claude/rules/CLAUDE_WEB.md | 3 | 5,837 | ~1,500 | web and blog deploys |
| Kids-activities-app-support/pipeline-commands.md | 207 command lines | 46,570 | ~11,600 | when grepped |
| MEMORY.md index | 131 memory files | 20,105 | ~5,000 | every session |
And what that means per session. The floor dropped for everyone; the ceiling, a session that touches every platform, is roughly unchanged:
| Session type | Rules loaded now | Before today | Window freed at start |
|---|---|---|---|
| General or planning work | 54 KB | 288 KB | about 30% |
| Pipeline ingestion | 54 + 79 = 133 KB | 288 KB | about 20% |
| iOS work | 54 + 31 = 85 KB | 288 KB | about 25% |
| Android work | 54 + 52 = 106 KB | 288 KB | about 23% |
| Web work | 54 + 6 = 60 KB | 288 KB | about 28% |
An analytics change on any platform adds the 18 KB analytics file, about 2 percent of the window, on top of that platform's row. It is a topic file, not an iOS one, which an earlier version of this chart got wrong by listing it only for iOS.
Exact measurements
| Session type | Rules loaded now | Before today | Tokens saved at start |
|---|---|---|---|
| General or planning work | 54 KB | 288 KB | ~58,000 |
| Pipeline ingestion | 54 + 79 = 133 KB | 288 KB | ~39,000 |
| iOS work | 54 + 31 = 85 KB | 288 KB | ~51,000 |
| Android work | 54 + 52 = 106 KB | 288 KB | ~45,000 |
| Web work | 54 + 6 = 60 KB | 288 KB | ~57,000 |
On a 200,000-token window, a general session now starts at roughly 30 percent full instead of roughly 60.
Where Claude Got It Wrong: The Android Analytics Rules
After the split Claude listed what it had deliberately left for later, including that the web and SwiftUI subsections inside the analytics rules were still together. I read that and asked: what about Android?
There was no Android subsection because the Android analytics rules had never been in the analytics section at all. They were two bullets inside the old Android section, so the split carried them into the Android file only. A second block, the cross-platform checklist for adding a new analytics parameter, was inside the iOS push-notification rules, so it went to the iOS file only. The practical effect: an iOS session adding an event would never have seen the rule that the reference CSV must be updated in the same change, and an Android session reading the analytics file would never have seen the rule to copy iOS event names exactly.
Claude moved both blocks into the analytics file and left pointers behind. Its own explanation was the useful part: this was the same class of mistake as the keyword sort. It had classified by section, and analytics rules were hiding inside sections about other things.
The gap was real bytes, not phrasing. This is how the three files changed when the rules moved:
| File | Before the move | After the move |
|---|---|---|
| CLAUDE_ANALYTICS.md | 12 KB | 18 KB, after the lint found one more hidden checklist |
| CLAUDE_ANDROID.md | 54 KB | 52 KB |
| CLAUDE_IOS.md | 34 KB | 31 KB |
Exact measurements
| File | Before the move | After the move |
|---|---|---|
| CLAUDE_ANALYTICS.md | 12,172 bytes | 17,744 bytes (18,423 after the lint added one more hidden checklist) |
| CLAUDE_ANDROID.md | 54,052 bytes | 52,046 bytes |
| CLAUDE_IOS.md | 33,638 bytes | 31,178 bytes |
The Guards
Then I asked the question that ends most of my sessions: what can we do so none of this happens again, not just the last one, all of it? The honest answer was that every mistake that day was the same kind, checking the shape but not the content, and every one happened with the relevant rule already in context. Advisory rules did not prevent any of them. So each became a named check:
- A rule-file lint. It fails on a glued title, a rule file without a paths header (which would load every session and undo the split), a file over budget, a file not named in the root's read-first table, an import of a rule file into the root, a nested platform
CLAUDE.mdthat would double-load, duplicate headings, and stale paths. It warns when analytics keywords appear in any other rule file. On its very first run it found one more analytics checklist hiding inside the cross-platform parity rule. - A session-note lint. Earlier that day a transcript append had silently never run because a validation inside an
&&chain failed while a trailing echo still printed "appended," and the command log then landed inside a quoted block. The checker verifies the note's live structure, which required switching to four-backtick fences so a quoted command body cannot close the outer fence. - A hook that runs both. It fires after any tool call and checks whatever rule file, skill, or session note changed in the last two minutes, feeding failures straight back to Claude before the turn ends.
What each check caught on its first run:
| Guard | What it caught on its first run |
|---|---|
| Fetcher lint, new Check 6 (every fetcher needs a line in the commands file) | 3 fetchers flagged, all false positives from a too-narrow match on --source; widened to --library and --site, then 0 |
| Reference sweep after the Android move | 19 Gradle guard strings and 13 Python, doc, and skill comments still citing old paths (32 total) |
| Split verification by heading | 113 of 113 sections landed exactly once; 1 glued title line, fixed |
| Rule-file lint (9 checks) | 1 more analytics checklist hiding inside the cross-platform parity rule; 5 legitimate keyword mentions left as warnings |
| Session-note lint | 3 legacy-format notes flagged as unverifiable; the first note in the new format passed |
| Size hook on every CLAUDE*.md at 120 KB | fires on the old 186 KB root, passes every current file |
The learnings-routing rules changed too. Memory files are now the primary store. The root gets an entry only for a standing rule, kept short, with a link to the memory for the story. The file that could only grow now has a budget and a reason to stay small.
Learnings
- Measure the fixed load before blaming the conversation. A rules file is read whole into every session; its size is the floor under every session, and compaction restores it in full.
- A rule only helps if it is read at the right time. Scoped rule files load on one kind of read, not from the terminal, not in subagents, and not after compaction. Make the read explicit and treat auto-loading as a bonus.
- Sort rules by content, never by heading. Cross-platform rules and topic rules hide as bullets inside sections about something else. A keyword grep across every file is the only classification that works.
- Keep "before you act" rules where every session sees them. Deploys, deletes, and builds do not involve reading a file, so a platform file would load too late or never.
- Every mistake becomes a check the same day. Advisory rules were in context for all of them and prevented none. The lint that came out of the last mistake found the next one on its first run.
- Ask the scenarios question twice. The first time it found the skills and the hook. The second time it made Claude say "my previous answer had two mistakes." Both times it changed the design.
