Files
intotheeast-com/docs/solutions/conventions/claude-md-content-tiering.md
T
m038andClaude Opus 5 cdae34a706 docs(solutions): capture the CLAUDE.md content-tiering convention
Four rounds of CLAUDE.md reduction (255 -> 305 -> 179 -> 74 lines) turned
up one consistent finding: every stale fact was a *description* of code or
config, never a rule. Two had been written by Claude days earlier.

Documents the operational test ("does this line change what Claude does on
a task where it wouldn't otherwise open the relevant file?"), the tiering
table, why gotchas are the one category that cannot move to a read-on-demand
docs/exceptions/, invariants-over-enumerations, and how to tell when a
reduction pass has hit the floor.

Also surfaces the docs/solutions frontmatter fields in CLAUDE.md's
entry-point table so the store is greppable by module, not just browsable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-24 22:26:25 +02:00

204 lines
14 KiB
Markdown

---
title: CLAUDE.md content tiering — rules stay, descriptions move out
date: 2026-07-24
category: conventions
module: documentation
problem_type: convention
component: documentation
severity: medium
applies_when:
- "Deciding whether new content belongs in CLAUDE.md or a docs/ subfolder"
- "CLAUDE.md has grown and needs a reduction pass"
- "Writing a rule that references specific file paths, bundle names, or other enumerable facts"
- "Extracting descriptive content out of CLAUDE.md into docs/reference or docs/guides"
tags: [claude-md, documentation-conventions, context-management, staleness, tiering, agent-instructions]
---
# CLAUDE.md content tiering — rules stay, descriptions move out
## Context
`CLAUDE.md` at the root of this repo is loaded into every single session, before any file is opened. It had grown to 255 lines of mixed content: rules, stack version numbers, plugin role tables, `make` command tables, folder maps, template hierarchies, and descriptions of how the asset pipeline worked. Nobody had ever asked whether a line earned its place in permanent context.
Four rounds of work over one session took it to 74 lines. The interesting part was not the size reduction — it was what the audits revealed about *which kinds of sentences go stale*, and the fact that the first honest audit made the file **bigger**.
| Round | Commit | Lines | What happened |
|---|---|---|---|
| 1 | `2fbfc88` | 255 → **305** | Audit scored the file 76/100, fixed 4 stale facts, and *added* genuinely missing sections (testing, dev commands, plugin patches) |
| 2 | `ed6e43a` | 305 → **179** | Descriptive content extracted to `docs/` |
| 3 | `839a4d0` | 179 → **74** (17,057 → 8,544 chars) | Rules-only cut; created `docs/reference/testing.md`, grew `README.md` |
| — | `9ec2349` | +52 | `docs/working/README.md` added; the plan-status *rule* stayed in CLAUDE.md, the *explanation* moved out |
| 4 | `285e615` | 74 → **74** | Build-output rule restated as an invariant. 3 lines → 3 lines, 156 chars saved. Not a size change — a staleness fix |
The four stale facts from round 1, verbatim from `2fbfc88`'s commit body:
- `active_trip: japan-korea-2026` — the committed value was `/trips/denmark-2026` and **no `japan-korea` trip folder existed**
- `Admin2 v2.0.10` — installed version was `v2.0.12`
- `make demo-load` described as italy-only — the Makefile loops over every fixture under `user/docs/demo/trips/`
- the `user/` gitignore claim omitted the three un-ignored site-owned plugins and the secret/`env/` exclusions
## Guidance
### 1. Apply the operational test to every line
> **Does this line change what Claude does on a task where it wouldn't otherwise open the relevant file?**
If no, it is a *description* — move it to `docs/`. Claude reads the code anyway; prose about the code just drifts alongside it.
Corollary: **version numbers are pure drift with no behavioral payload.** `Grav 2.0.7`, `Admin2 v2.0.12`, and the GPM-channel paragraph were all dropped. What survived is version-free:
> The site is Grav (flat-file PHP CMS, no database) in Docker, with content and theme in the `user/` submodule.
"No database" stays because it *does* change behavior — an agent that believes there is a database goes looking for migrations, an ORM, and a query layer that do not exist.
### 2. Tier content by when it gets read
| Content | Home | Why |
|---|---|---|
| Rules, gotchas, invariants | `CLAUDE.md` | Worthless unless already in context |
| How the code works | `docs/reference/` | Claude reads the code anyway; prose drifts |
| How to do a task | `docs/guides/` | Read at task start, on demand |
| A trap already hit, with symptoms | `docs/solutions/` | Retrieved by symptom, indexed by frontmatter |
| Setup, folder map, commands | `README.md` | For humans; Claude has the Makefile |
CLAUDE.md keeps a six-row entry-point table pointing at each destination — the routing is a rule, the content behind it is not.
### 3. Gotchas are the one category that cannot be extracted
Every other content type has a natural trigger that opens the file:
| Type | Trigger that gets it read |
|---|---|
| Description | Agent opens the code |
| Procedure | Agent starts the task |
| Incident write-up | Agent recognizes a symptom |
| **Gotcha / exception** | **none — it must already be in context** |
A file you only open once you suspect an exception exists is a file you open **too late**. A proposed `docs/exceptions/` directory was therefore recommended against. Supporting arithmetic: the whole rules surface is ~40 lines / ~2,200 tokens, so a second file saves ~1k tokens while adding a lookup step, and `docs/solutions/` (indexed by `module` / `tags` / `problem_type`) already fills the read-on-demand role for "have we hit this before?".
### 4. State invariants, not enumerations
An enumerated list is falsified by the next addition, silently. An inverted statement of the same fact survives it. This is what `285e615` did — same three lines, no size change, but now staleness-proof.
### 5. Verify the destination before extracting
Every extraction target was confirmed to already exist and already cover the topic:
- pointer bumps and worktree mechanics → `docs/solutions/architecture-patterns/dual-repo-submodule-workflow.md` (already covered them)
- the `user/env/<host>/` override tree → `docs/guides/deploy-cycle.md` (already covered it)
- source→output asset table → `docs/reference/architecture.md` → "Asset pipeline" (section added to receive it, lines 69-82)
- test-suite descriptions → `docs/reference/testing.md` (**created**, 67 lines — no destination existed)
- folder map + `make` tables → `README.md` (179 → 227 lines)
Nothing extracted became homeless. Related fix in the same pass: `docs/working/git-sync-notes.md` pointed at "CLAUDE.md §1", a section number that no longer existed after renumbering — **cross-references into an instruction file must point at stable headings, never numbers.**
### 6. Know when to stop
At 74 lines the section sizes were even — Hard rules 9, Dev environment 8, Content and trips 7, Two shared partials 7, Dual-repo submodule 7, Testing 7, Working docs 7, intro + entry-point table 15. No fat pocket remained. Roughly 8 more lines *could* have gone (the `travel-memories` :8082 port, a parenthetical Twig-recompile aside, tightening two bullets) for ~250 tokens out of ~2,200 — while deleting actual rules.
**The trim is strongly positive while what leaves is descriptions, and turns negative once only rules remain.** Round 3 therefore ended with a "we're at the floor" verdict plus one robustness fix (`285e615`), not another cut.
## Why This Matters
**Every stale fact found across all four rounds was a description of code or config. Not one was a rule.** Two of them had been written by Claude itself days earlier. Descriptions drift because the code moves and the prose does not; rules do not drift because they encode intent rather than state. The tiering above is not an aesthetic preference — it is the only conclusion the evidence supports.
**A wrong path in an always-loaded file is worse than an absent one.** CLAUDE.md claimed the map engine lived at `js/src/maplibre-utils.js`. That file does not exist. The real path is `user/themes/intotheeast/js/maplibre-utils.js` — a hand-authored source sitting *next to* the generated bundles in `js/`, imported by `js/src/map.js` as `../maplibre-utils.js`. The wrong path survived rounds 1 and 2 (`2fbfc88` line 76, `ed6e43a` line 64) and was only fixed in `839a4d0`.
An absent fact makes an agent go look. A wrong fact makes it act confidently in the wrong place. Here the wrong place was `js/map.js` — a minified esbuild bundle. The failure mode is a hand-edit that survives until the next `make build-assets` silently reverts it.
This is also the decisive argument against `docs/exceptions/`: **the maplibre-utils mistake happened because the path was wrong, not because it was missing.** Had that rule lived in `docs/exceptions/assets.md`, the bundle would have been hand-edited with the agent never knowing the file existed.
**What survived the cut is the sanity check on the criterion.** A rule stays when being wrong about it is expensive *and* the correct behavior is not derivable from reading a file:
- the Admin plugin slug is `admin2`, not `admin` — nothing in the tree announces this before you've already guessed wrong
- `plugins.txt` is hand-maintained; installing a plugin via Admin does **not** update it
- once `user/env/<hostname>/` exists on a server, Grav's Admin writes **all** config there — system *and* plugin — and env wins, so server config must be read from both trees
- `active_trip` is a **route** (`/trips/denmark-2026`), not a bare slug
- never re-add a `pageconfig.parent` to `post-form.md` — a static parent overrides the `active_trip`-derived write target and reintroduces a silent-desync bug
- the standalone `/dailies`, `/map`, `/stats`, `/stories` trip views were deleted 2026-07-04 and must not be re-created or linked
Each of those is a landmine an agent steps on *before* it has cause to open the relevant file.
## When to Apply
- Auditing or editing any always-loaded instruction file — `CLAUDE.md`, `AGENTS.md`, system prompts, agent definitions
- When a stale fact is found in an instruction file: fix it, then ask why that *category* of sentence was there at all
- Before adding a line to `CLAUDE.md` — run the operational test first, and route to the tiering table if it fails
- Before writing an enumerated list of files, paths, plugins, or bundles into an instruction file — try inverting it into an invariant and verify the inverted form against the actual directory listing
- Before extracting content out of an instruction file — confirm the destination exists and covers the topic, or create it in the same commit
- When tempted to create a new read-on-demand directory for exceptions or gotchas — don't; they only work in-context
- When a reduction pass stops finding descriptions and starts deleting rules — stop and record a floor verdict instead of cutting further
## Examples
### Enumerated list → invariant (`285e615`)
**Before** — 3 lines, falsified by adding a fifth bundle:
```markdown
- **Never hand-edit build output**, and know which files those are — sources and outputs
share folders under `user/themes/intotheeast/` (all paths below are relative to it).
`make build-assets` is mandatory after editing any source, and it writes:
- **Generated (never edit):** `js/main.js`, `js/map.js`, `js/feed-actions.js`,
`js/trip-publish.js`, `js/post/`, `css-compiled/`, `fonts/`, and
`templates/partials/weather-icons.html.twig`.
- **Hand-authored sources:** everything in `js/src/`, plus `js/maplibre-utils.js` and
`js/nav.js` (which sit *next to* the bundles in `js/`), `css/style.css`,
`css/tokens.css`, and `scripts/gen-weather-icons.js`.
```
**After** — 3 lines, 156 chars shorter, still true after the next bundle is added:
```markdown
- **Never hand-edit build output** — sources and outputs share folders under
`user/themes/intotheeast/` (paths below are relative to it), so know which is which.
Run `make build-assets` after editing any source.
- Everything in `js/` is **generated** *except* `js/src/`, `js/maplibre-utils.js` and `js/nav.js`.
- `css-compiled/` and `fonts/` are generated (sources: `css/style.css`, `css/tokens.css`);
so is `templates/partials/weather-icons.html.twig` (source: `scripts/gen-weather-icons.js`).
```
Verification that made this safe: `ls js/` returns exactly the 4 bundles + `post/` + `maplibre-utils.js` + `nav.js` + `src/`. The inverted form is exactly true today and stays true as bundles are added. The full enumerated source→output table now lives in `docs/reference/architecture.md` → "Asset pipeline", where drift is cheap because the table is read next to the code it describes.
### Description → extracted; rule → kept
**Before** (round 1 addition, later cut) — a description of the build, in permanent context:
```markdown
**`make build-assets` is mandatory after editing anything in
`user/themes/intotheeast/js/src/`.** Sources live in `js/src/`; esbuild writes the
committed bundles — `js/main.js`, `js/map.js`, `js/feed-actions.js`,
`js/trip-publish.js`, `js/post/`, and the CSS extracted into `css-compiled/`.
**Never hand-edit those.** By contrast `css/style.css` and `css/tokens.css` are
hand-authored sources, not build outputs. `build-assets` runs as your host UID
(`--user`) so the outputs in the bind-mounted `user/` tree are not root-owned.
```
**After** — the `--user` mechanism and the esbuild pipeline moved to `docs/reference/architecture.md` line 71; only the never-edit rule and the source/output discriminator remain in `CLAUDE.md`.
### Wrong path → right path (`839a4d0`)
```diff
-The engine is `MapUtils.initEntryMap(opts)` in `js/src/maplibre-utils.js`.
+the engine is `MapUtils.initEntryMap(opts)` in `js/maplibre-utils.js`
+(a hand-authored file, imported by `js/src/map.js`)
```
`js/src/maplibre-utils.js` never existed. The parenthetical is not padding — it is the whole reason the rule is in an always-loaded file: `js/` is the bundle directory, so a hand-authored source living there is exactly the fact an agent cannot infer.
### Rule stays, explanation leaves (`9ec2349`)
The plan-status convention needed both a machine-actionable rule and a human-readable explanation of the five states. They went to different files:
- `CLAUDE.md` keeps the one-line rule — every plan needs a `**Status:**` line immediately after its title, plus what to surface when asked what's open, plus set `✅ Complete (YYYY-MM-DD)` before closing a session
- `docs/working/README.md` (52 lines) holds the explanation of the states, the directory layout, and the human-facing reference
Same convention, split by *when each half needs to be in context*.
## Related
- [`docs/README.md`](../../README.md) — the existing "always-loaded rules → CLAUDE.md" vs "stable facts → reference/" split that this learning sharpens into an actionable test
- [`docs/working/plans/2026-06-21-documentation-restructure.md`](../../working/plans/2026-06-21-documentation-restructure.md) — the prior restructure that created the extraction destinations (`reference/architecture.md` and siblings) this pass relied on and re-applied
- [`docs/solutions/integration-issues/stale-grav-version-blocks-api-plugin-install.md`](../integration-issues/stale-grav-version-blocks-api-plugin-install.md) — sibling instance of version numbers rotting, in the deploy-config domain rather than the instruction-file domain
- [`docs/reference/architecture.md`](../../reference/architecture.md) → "Asset pipeline" — where the enumerated source→output table now lives