diff --git a/CLAUDE.md b/CLAUDE.md index 29aa8cf..c6b706f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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) | | 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) | -| 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) | The site is Grav (flat-file PHP CMS, no database) in Docker, with content and theme in the `user/` submodule. diff --git a/docs/solutions/conventions/claude-md-content-tiering.md b/docs/solutions/conventions/claude-md-content-tiering.md new file mode 100644 index 0000000..1ecddd7 --- /dev/null +++ b/docs/solutions/conventions/claude-md-content-tiering.md @@ -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//` 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//` 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