Files
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

14 KiB

title, date, category, module, problem_type, component, severity, applies_when, tags
title date category module problem_type component severity applies_when tags
CLAUDE.md content tiering — rules stay, descriptions move out 2026-07-24 conventions documentation convention documentation medium
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
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:

- **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:

- **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:

**`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)

-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.