Claude Code's persistent memory is one of those features that feels obviously correct on day one and quietly metastasizes by month six. Every session, the agent writes another feedback_*.md, project_*.md, or reference_*.md into the project's memory directory. None of those files ever get deleted automatically. Rules that started as "things I want Claude to remember" eventually become rules canonicalized into a real spec, a CONTRIBUTING file, or a standards doc — but the original memory file stays around, ungoverned, drifting slowly out of sync with the canonical truth.
On 2026-04-29, that drift caught up with a multi-month engineering workspace. This post is about the incident, the recovery, and the four-layer secret-leak defense built during the same session because the cleanup surfaced an adjacent problem.
This is a design problem, not a "user wrote bad prompts" problem. Claude's memory system is undertooled for projects that last more than a few weeks.
Two terms used throughout this post:
- The Library — an open-source meta-system the author is building on top of Claude Code. It's a skill suite plus an MCP server that adds session continuity, knowledge management, and project-management tracking to the base Claude Code experience. Status: early/experimental — not yet fit for general use, but the architectural pieces (skills, hooks, vault, PM adapter) exist and run in production for the author's workspace. Repo: github.com/sevenbelowllc/the-library.
- The Library Reading Room — the project-side companion: a repository (or a directory at the project root) that holds the canonical written artifacts a project depends on — specs, standards, implementation plans, and session checkpoints. The Reading Room is where a rule lives once it has been promoted out of agent memory; the agent reads from it, the team writes to it, and audits cite it. Folder paths like
library-reading-room/specs/andlibrary-reading-room/standards/referenced below are the author's instance of this layout.
What memory poisoning looked like
The project in question is a multi-tenant compliance platform with five independently versioned repositories in one workspace. Each repo has a CLAUDE.md. There is also a global ~/.claude/CLAUDE.md, and an auto-managed memory directory at ~/.claude/projects/<slug>/memory/ driven by The Library's MMU (Memory Management Unit) hooks.
A pre-recovery audit of that memory directory found:
- 92 markdown files in a single project's memory dir. Cumulative growth, never pruned.
- 17
feedback_*.mdfiles tagged "HARD RULE" with no DEC-### or spec-section citation. Each one a free-floating policy claim that the agent would treat as load-bearing on every prompt. - 49 files that duplicated content already canonicalized in
library-reading-room/specs/orlibrary-reading-room/standards/. The canonical version had moved on; the memory copy hadn't. - A handful of aspirational notes shaped like
DEC-AI-001..015 still need to be recorded— IOUs the system had been writing to itself for weeks. - Multiple superseded checkpoint files from the same day's work, each one labeled "this supersedes the previous checkpoint" without the previous checkpoints actually being deleted.
The functional consequences were predictable in retrospect:
- Rule drift. A "HARD RULE" memory file said one thing; the canonical spec said something subtly different. Claude weighted the memory file higher because it was loaded into the system prompt every turn.
- Context pollution. The MEMORY.md index alone ran past 80 entries, each with a one-line summary. Roughly 15–20% of every prompt's context budget was being consumed before the user said anything.
- Secret-leak risk. Several
feedback_*files contained operational fragments — partial paths, environment-variable names, snippets of.env.example. One file had a literal placeholder that looked enough like a real token that gitleaks would have flagged it had anyone tried to commit it. - Decision fatigue for the agent. With 17 uncited HARD RULES, the agent spent measurable tokens hedging and cross-referencing rules that contradicted each other.
The triggering moment was a session crash. The system prompt had grown large enough that a long tool-call result tipped the conversation past the working window, and Claude began to lose coherence mid-task.
Why this is a Claude Code design problem
Claude Code's memory system has hooks for prune, validate, aggregate, conflict, and age — The Library exposes these as the library:memory skill — but it has no --promote operation and no --bulk-archive. Once a memory has been canonicalized into a spec or a standards doc, nothing in the system flushes the source memory. The agent will keep loading both forever.
That is the entire bug. Memory is monotonic. Canonicalization is a manual side channel. Drift is the default.
You can prune by hand, and most engineers will the first few times they notice the directory growing. After a month or two of feature work, the prune-by-hand discipline collapses, because the cost of reviewing 80 memory files is higher than the cost of one more drift incident. Then the drift incident happens.
Opinionated claim: Claude Code's default memory behavior is undertooled for multi-month projects. A team using it past the 30-day mark needs a discipline (or, better, tooling) that flushes memory the moment a rule is canonicalized.
The recovery — staged process
The recovery used five stages. Stage 0 took ten minutes; stages 1–4 took about three hours total.
Stage 0 — Forensic backup
Before deleting anything, the entire memory tree plus the project-scoped CLAUDE.md files were tarballed with a sha256 manifest:
mkdir -p memory-poisoning-backup-2026-04-29/{tarball,snapshots,claude-md-workspace}
tar czf memory-poisoning-backup-2026-04-29/tarball/memory-pre-cleanup.tar.gz \
~/.claude/projects/<slug>/memory \
~/.claude/CLAUDE.md \
workspace/*/CLAUDE.md
shasum -a 256 memory-poisoning-backup-2026-04-29/tarball/*.tar.gz \
> memory-poisoning-backup-2026-04-29/tarball/SHA256SUMS
The point of the backup is psychological as much as technical: every subsequent delete is reversible, so the triage proceeds at a pace that wouldn't be safe otherwise.
Stage 1 — PARKLOT Jira project
A new Jira project, PARKLOT, was created to hold tickets for any memory file that wasn't safely deletable but also wasn't load-bearing right now. This is the standard "we need this thought, just not in the agent's working memory" escape valve. Items that would have been kept in memory under the old regime — "remember this preference for next time" — became PARKLOT tickets the agent can pull on demand.
Stage 2 — Orchestrator directory delete
A short-lived orchestrator scratch directory had accumulated 14 ad-hoc *.md files from a parallel-agent wave. None were referenced by any spec or active checkpoint. The directory was deleted wholesale.
Stage 3 — Library memory triage
The the-library repo's own memory was reviewed first because it sets the example for the rest of the workspace. Five files were deleted after their content was relocated into either a spec section under library-reading-room/specs/ or a standards doc under library-reading-room/standards/. Each deletion left a one-line pointer in MEMORY.md for traceability, then the pointer itself was pruned once the index was regenerated.
Stage 4 — Main project memory triage
This was the bulk of the work: 92 files classified one by one in a verdict matrix saved alongside the backup at memory-poisoning-backup-2026-04-29/STAGE-4-MEMORY-MATRIX.md. The result was 92 files in, 1 file out (the active resume checkpoint). Forensic backup preserved.
The matrix approach
Every file received exactly one of four verdicts.
| Verdict | Meaning | Action |
|---|---|---|
| DUP | Canonical version already exists in specs/ or standards/. Memory file is a stale copy. |
Delete. Leave a pointer in MEMORY.md only if the path isn't obvious. |
| DELETE | Stale, superseded, or aspirational ("still need to record DEC-AI-001"). No canonical home, no value. | Delete outright. |
| REVIEW | Load-bearing rule with no canonical home. Drift risk if left in memory; data loss if deleted. | File a Jira ticket — author a DEC, extend a standard, or agree to delete. |
| KEEP | Active resume state, non-derivable user preference, or operational state (last checkpoint, env config). | Keep in memory. Must be re-justified at the next audit. |
The numbers from the run:
- DUP: 49 files. Mostly the
feedback_*.mdHARD RULES that had been promoted into spec sections months earlier and never flushed. - DELETE: 28 files. Superseded checkpoints, orchestrator scratch, aspirational IOUs.
- REVIEW: 14 files. Each became a PARKLOT ticket with a 14-day SLA: author the DEC, extend the standard, or accept deletion.
- KEEP: 1 file. The current session's resume checkpoint.
The matrix is mechanical enough that it could be run by a subagent, given a list of canonical specs to compare against. That is the next piece of tooling work and the motivation for LIBRARY-14, below.
The four-layer secret-leak defense
Memory poisoning was the headline problem; secret leakage was the parallel one. The same audit that surfaced the duplicate HARD RULES surfaced operational fragments that didn't belong in memory either. "Claude keeps leaking secrets" had been a recurring concern for weeks. The cleanup session was the right time to fix the class of bug, not just the instance.
The fix is defense in depth across four layers. None of them alone is sufficient.
Layer 1 — Doctrine
A new section §8 Secret Handling was added to global ~/.claude/CLAUDE.md. It says, plainly:
- Where secrets live. Local credentials live in Bitwarden (this user's preferred password manager). Runtime secrets live in GCP Secret Manager. CI secrets live in Buildkite. Test fixtures and
.env.examplefiles use the literal patternPLACEHOLDER_<SYSTEM>_<TYPE>per project DEC-055, never provider-prefixed fakes likesk_test_…orghp_…(those trip gitleaks). - Six hard rules for what Claude must never do. No echoing secret values to stdout. No writing secret values to files. No
--no-verifyto bypass pre-commit hooks. No pipingbw getoropoutput into other commands. Nocat .envorprintenv | grep. Nogit pushwithout an explicit override.
Doctrine without enforcement is a wish. Layers 2–4 enforce it.
Layer 2 — PreToolUse hooks
Two hooks were added under ~/.claude/hooks/ and wired into the harness via ~/.claude/settings.json:
{
"hooks": {
"PreToolUse": [
{ "matcher": "Write|Edit",
"command": "~/.claude/hooks/secret-scan.sh" },
{ "matcher": "Bash",
"command": "~/.claude/hooks/bash-secret-guard.sh" }
]
}
}
secret-scan.sh regex-blocks high-confidence secret patterns in any Write or Edit content before it hits disk:
# representative patterns, not exhaustive
sk_(live|test)_[A-Za-z0-9]{16,}
ghp_[A-Za-z0-9]{36}
AKIA[0-9A-Z]{16}
eyJ[A-Za-z0-9_-]+\.eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+ # JWT shape
-----BEGIN (RSA |EC |OPENSSH |)PRIVATE KEY-----
bash-secret-guard.sh blocks four classes of unsafe Bash invocations:
- Secret exfiltration.
cat .env,printenv,echo $SECRET,bw getpiped to anything,opCLI calls. - Hook bypass.
--no-verifyongit commitorgit push. Anygit -c core.hooksPath=…with a non-default path. - Push without override.
git pushis rejected unlessCLAUDE_ALLOW_PUSH=1is set in the call environment. The user retains the unrestricted ability to push from their own shell — only the agent's invocations are gated. - Prod terraform. Any
terraform applyagainst a path matchinggcp/environments/prod/. (Per project memory, prod doesn't even exist yet; this is a tripwire for the day someone names a directory wrong.)
Layer 3 — Global git template + gitignore
Tool-call hooks fire on the agent. Git hooks fire on the user too. Both matter.
git config --global init.templateDir ~/.git-template
git config --global core.excludesFile ~/.gitignore_global
~/.git-template/hooks/pre-commit runs gitleaks on staged content and explicitly blocks any staged file matching .env* (excluding .env.example). ~/.gitignore_global excludes .env, .env.local, *.pem, *.key, service-account*.json, and a handful of other shapes. Every new git init and every git clone from this point forward picks up the protection automatically.
Layer 4 — Retro-install
Because the template only takes effect on new clones, the existing 66 repositories on disk needed retro-fitting. A short script symlinked each repo's .git/hooks/pre-commit to the global template's pre-commit:
for repo in $(find ~/workdir -type d -name .git); do
ln -sf ~/.git-template/hooks/pre-commit "$repo/hooks/pre-commit"
done
Five repos already had husky or other pre-commit chains in place. Those were flagged for manual integration of the gitleaks step rather than overwritten — the retrofit script refuses to clobber existing hooks.
The total time for layers 1–4 was about ninety minutes.
Future work — productized in a Library spec
The recovery process surfaced a missing core feature. The Library's library:memory skill is being extended (ticket LIBRARY-14, scoped in specs/2026-04-10-the-library-design.md §4.2) with three new operations:
--promote— auto-flush a memory file the moment its content is canonicalized into a spec or standard. The promotion is detected by a content-similarity check against the canonical sources; on match, the memory file is deleted and a one-line pointer is left inMEMORY.md.--bulk-archive— sweep memory files older than 14 days into the vault's raw-archive tree. Archived files remain searchable but stop loading into the agent's working context.--reprocess-archive— periodically re-classify the archive against the current canonical specs. Anything that has since been canonicalized gets its pointer rewritten; anything that has become stale gets its archive entry retired.
The vision is straightforward: future Claude memory should self-clean on canonicalization, not accumulate forever. Drift becomes the exception rather than the default.
Lessons
- Persistent memory is debt. Treat it the way you treat code comments: mostly delete, only keep when non-derivable. The good parts of an LLM's memory are operational state and personal preference. The bad parts are rules that should have been promoted into a spec.
- Every "HARD RULE" memory file without a citation is a future drift incident. If the rule is real, it deserves a DEC-### or a section in a standards doc. If the rule isn't real enough to deserve that, it isn't real enough to live in the system prompt.
- Auto-flush on canonicalization is not optional for a multi-month AI agent workflow. Manual prune discipline collapses past the 30-day mark.
- Defense in depth for secrets. Doctrine in CLAUDE.md, tool-call hooks on the agent, git hooks on the user, global gitignore as the last line. None alone is sufficient: the agent can be talked out of doctrine, hooks can be misconfigured, and gitignore won't save you from a
git add -f. - Hooks fire on the agent, not on you. This is the asymmetry that makes the design work. The user retains full control of their own shell — they can
cat .env,git push, whatever — but Claude's invocations are gated by tripwires the user wrote. The agent and the user have different threat models, and the harness should reflect that. - Backup before triage. A forensic tarball with sha256 verification took ten minutes. It made every subsequent delete safe enough to do quickly.
Closing
The recovery process is the interesting part of this story, not the incident. Memory poisoning is what happens when a feature with no garbage collector meets a workflow that lasts longer than a sprint. The fix is partly cultural — write specs, cite them, prune aggressively — and partly tooling: the agent should help with the prune, because the agent is the one creating the memory in the first place.
LIBRARY-14 is filed. The four-layer secret defense is live. The matrix at STAGE-4-MEMORY-MATRIX.md is reusable for the next project that hits this. If you've run a Claude Code workflow past the three-month mark and have your own memory-poisoning story — or, better, a working solution — the comments are open.
