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>
This commit is contained in:
@@ -8,7 +8,7 @@ Rules, gotchas, and entry points — the things that must change what you do *be
|
|||||||
| Domain vocabulary — Trip, Entry, Story, Active Trip | [`CONCEPTS.md`](CONCEPTS.md) |
|
| Domain vocabulary — Trip, Entry, Story, Active Trip | [`CONCEPTS.md`](CONCEPTS.md) |
|
||||||
| Doing something operational — posting, GPX, switching trips, local setup, deploying | [`docs/guides/`](docs/guides/) |
|
| Doing something operational — posting, GPX, switching trips, local setup, deploying | [`docs/guides/`](docs/guides/) |
|
||||||
| Test suite layout and conventions | [`docs/reference/testing.md`](docs/reference/testing.md) |
|
| Test suite layout and conventions | [`docs/reference/testing.md`](docs/reference/testing.md) |
|
||||||
| A bug or workflow trap already hit and written up | [`docs/solutions/`](docs/solutions/) — check when working in a documented area |
|
| A bug or workflow trap already hit and written up | [`docs/solutions/`](docs/solutions/) — grep the `module`/`tags`/`problem_type` frontmatter; check when working in a documented area |
|
||||||
| Folder map, prerequisites, the full `make` command list | [`README.md`](README.md) |
|
| Folder map, prerequisites, the full `make` command list | [`README.md`](README.md) |
|
||||||
|
|
||||||
The site is Grav (flat-file PHP CMS, no database) in Docker, with content and theme in the `user/` submodule.
|
The site is Grav (flat-file PHP CMS, no database) in Docker, with content and theme in the `user/` submodule.
|
||||||
|
|||||||
@@ -0,0 +1,203 @@
|
|||||||
|
---
|
||||||
|
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
|
||||||
Reference in New Issue
Block a user