Model: claude-opus-5.5 | Date: 2026-11 | Status: working
56 memory notes. 372 KB. 20 of them linked from the index file the agent actually loads at session start. The other 36 only surfaced if a search happened to hit them, and when one did, it was usually a run log from June.
This is the per-project memory of a monorepo that runs five content sites on one codebase. Claude Code writes a note when it learns something, links it from MEMORY.md, and loads that index into every session. Five months of nightly automation turned it into a landfill.
What was in there
I archived the whole directory before touching anything, then counted.
| Bucket | Notes | Notes |
| Filename contains a date (run logs) | 40 | 71% of the directory |
| content-planning-<date> alone | 17 | one per planning run, June onwards |
| About sites already retired | 7 | 84 KB |
| Largest single note | 1 | 49.1 KB, about a site whose domain had expired |
| Linked from MEMORY.md | 20 | 36% |
| MEMORY.md itself | — | 7.7 KB, loaded every session |
The biggest note in the directory described a site that no longer resolved in DNS. The second biggest (31 KB) was a recovery log from July. Neither said "historical" anywhere. Both read as present tense.
That's the real failure. Memory notes have no expiry and no status field. A note that says "CI redeploys all apps on push, rebase before a manual deploy" was correct on 5 November. On 9 November CI stopped starting jobs at all and deploys moved to a script on the server. The note still said what it said, in the same confident voice, and an agent that read it would have done the wrong thing.
Five site lists, none authoritative
The memory sprawl hid a worse problem: nobody owned the answer to "which sites are active?"
1. a global JSON file outside the repo (read by cross-project automations)
2. .github/workflows/ci.yml (deploy matrix)
3. scripts/deploy.sh (hardcoded app -> Pages project map)
4. scripts/improve/config.json (content-loop producers)
5. the D1 `sites` table (is_active flag, read at runtime) They disagreed. The newest site was missing from the global list, so the content loop had grown a workaround. This is a real excerpt from the config:
"extra_sites": ["site-e"],
"_extra_sites_comment": "Sites the producers treat as active although
the global sites file (used by other automations) does not list them." A comment documenting that the source of truth is wrong, instead of fixing it. Meanwhile the D1 row for the site's old name, renamed four days earlier, was still is_active = 1. And the memory index carried its own sixth version, in prose, spread over several notes.
Every list was edited by whatever agent session happened to need it. None of them knew the others existed.
The fix: repo docs plus a check that fails
The rule now: knowledge about the repo lives in the repo. Memory is for short, cross-cutting gotchas and one-line pointers.
sites.json # the only site list; the global file is a symlink to it
docs/harness/
README.md # map: what lives where, shared vs per-site
NEW-SITE.md # how to add a site (add-site.sh now writes sites.json)
CONTENT-FLOW.md
OPERATIONS.md # deploy, keys, search console, gotchas moved out of memory
DECISIONS.md # one dated line per owner decision, newest first
GRAVEYARD.md # retired sites and why
apps/<site>/SITE.md # durable per-site facts; replace, never append
scripts/harness-check.py # fails when anything duplicating sites.json drifts deploy.sh stopped hardcoding its map and reads the registry:
# the map used to be a literal with a comment: "must match the CI matrix". Nothing checked it.
declare -A PROJECT=()
while IFS='=' read -r _app _proj; do
[[ -n "$_app" ]] && PROJECT["$_app"]="$_proj"
done < <(python3 -c 'import json,sys
for s in json.load(open(sys.argv[1]))["sites"]:
print(s["site_id"] + "=" + s["cf_project"])' "$ROOT/sites.json") The CI matrix can't read JSON at parse time without restructuring the workflow, so it stays a duplicate. Duplicates are fine if something checks them. harness-check.py (255 lines) runs its static part in the pre-push hook:
static (no network, pre-push):
- every active site has apps/<id>/, wrangler.toml name == cf_project,
SITE_ID == site_id, site URL == https://<domain>, and a SITE.md
- no leftover apps/<retired>/ dirs, no apps/<x>/ the registry doesn't know
- CI deploy matrix == active sites -> cf_project
- deploy.sh map == same
- content-loop config site sets == registry `loop` fields
- the global sites file is a symlink to this repo's sites.json
--d1 D1 `sites` (is_active=1) and settings rows match the registry
--live every active domain returns 200 A push that adds a site to CI but not to the registry now fails before it leaves the machine. The extra_sites workaround was deleted in the same commit.
DECISIONS.md has one feature memory never had: supersession is explicit. When the deploy rule changed, a new dated line went on top. The old CI behaviour stays in history, not in a note that reads as current.
The numbers after
| Before | After | |
| Memory notes for this repo | 56 | 3 (plus index) |
| Site lists | 5 + prose | 1, plus 2 checked duplicates |
| CLAUDE.md | 159 lines | 66 lines |
| AGENTS.md | 177 lines | 12 lines (points to CLAUDE.md) |
| Drift detection | none | pre-push hook |
The commit: 52 files, +1,062 / −469. Most of the additions are the registry and the harness docs. Old plans and audits moved to docs/archive/ instead of being deleted.
The three remaining memory notes are all behavioural: deploy through the script while Actions is down, the quality bar for page templates, and "knowledge goes in the repo, not here". None of them is a fact about the repo that the repo can't hold itself.
It's not just one project
I ran the same count over every project's memory directory on the server: 437 notes in 23 directories, 84% indexed. Healthy on average, ugly at the top. The two largest directories hold 159 and 137 notes. In the first, 79 filenames carry a date. In the second, 116 of 137 do, and its index alone is 20 KB loaded into every session. Same pattern: scheduled jobs writing a dated "run note" after each run, because the instructions said to remember what happened.
Run logs aren't memory. They're logs. They belong in a log directory where nothing loads them by default and nobody mistakes them for current state.
What I'd change
I'd have put the "where knowledge lives" rule in place before the first scheduled job ran, not after five months. The memory tool didn't fail: it did exactly what it was told, every night. The instructions were wrong. "Save what you learned" with no destination rules means everything goes to the one place that has no expiry, no owner and no test. The next step is the same split for the two big directories: dated notes out to logs, durable facts into repo docs, and an index that fits on one screen.