Memory Outside the Tree
The symlink trick from The Exomind looked clean — versioned project memory in the repo, portable across machines via git. It also broke autonomous operation. Here's the architectural lesson and the post-symlink design that replaces it.
In The Exomind I described a setup for Claude Code project memory that I was proud of. Project memory lives at the canonical Claude Code location — ~/.claude/projects/<slug>/memory/ — but I wanted it versioned in git, portable across machines, backed up. So I symlinked it into the repo: ~/.claude/projects/<slug>/memory → <repo>/.claude/memory. A ten-line setup script (tools/setup-memory-link.sh) wired it up on every new machine. Memory writes go to canonical, the symlink redirects them into the repo, git carries them everywhere. Clean.
It worked. For a few months. Then I started running Claude Code in autonomous mode (the bypass-permissions setting on long agentic sessions) and discovered the trick was structurally broken in a way I hadn't seen before.
Every memory write triggered a permission prompt. Even with --dangerously-skip-permissions enabled.
This piece is the autopsy of why that happens, the architectural lesson, and the post-symlink design that replaces it across all my projects.
What was clever about the symlink (and why it broke)
The symlink trick is appealing because it lets you keep two competing properties at once:
- Claude Code expects memory at
~/.claude/projects/<slug>/memory/. The auto-memory subsystem writes there. You can't redirect that via CLAUDE.md instructions — the path is computed by the engine, not the model. - You want memory versioned in git. So the file content needs to live inside your repo, where commits and pushes can carry it across machines.
A symlink resolves both: the engine writes to the canonical path, the kernel redirects to the repo path. Two locations, one file. Beautiful in theory.
The break: when Claude Code's edit tool (or the auto-memory subsystem, which is internally an edit) writes to a file, it canonicalizes the path. The kernel resolves the symlink. The real path the write lands on is /Users/cocoloco/Projects/life/.claude/memory/MEMORY.md — which is inside the project working tree. And there's a path-protection check on writes inside the project working tree that exists for very good reasons: it prevents auto-edits from clobbering the project's own files even in YOLO mode. --dangerously-skip-permissions deliberately doesn't bypass it. That check is the safety net of last resort.
The symlink trick was, structurally, a fight against that safety net. It worked under normal supervised use because Vanja was approving prompts anyway. The moment autonomous mode came online, the safety net started firing on every memory write, and the trick collapsed.
The architectural lesson
Don't engineer your way past platform safety mechanisms — work with them, not around them. The path-protection check isn't a Claude Code bug. It's a deliberate constraint protecting users from autonomous-write footguns. The protection is a hardcoded prefix match on the in-tree .claude/ directory (alongside .git/, .vscode/, .idea/, .husky/) and fires regardless of permissions.allow, bypassPermissions mode, --dangerously-skip-permissions, or any PreToolUse auto-approve hook. The right response isn't to construct a clever filesystem indirection. The right response is to put the writes where the platform expects them — outside the project tree — and use a different layer for versioning, with the in-tree mirror sitting outside the protected prefix entirely.
The mistake was conflating two responsibilities into one mechanism. The symlink was trying to be both:
- The data path — where Claude Code's writes physically land
- The version-control hook — how those files get into git
Splitting those two responsibilities is the actual fix.
The post-symlink architecture
Memory lives at the canonical location. A git post-commit hook mirrors it into the repo for versioning. That's the entire change.
Specifically:
- Data path:
~/.claude/projects/<slug>/memory/— the canonical Claude Code location, OUTSIDE the project working tree. Memory writes happen here. No path-protection check fires because the path isn't inside the repo. Zero permission prompts. - Version control path:
<repo>/claude-memory/— a mirror, populated by the post-commit hook viarsync --delete. On every commit, the hook copies the canonical memory directory intoclaude-memory/and stages the changes. The mirror is a sibling of.claude/, deliberately NOT inside it — the protected-prefix check is a literal.claude/match, so a sibling directory passes through. Manual inspection works. The post-commitrsyncnever needs auth. Git history is preserved. One-way: edit memory at the canonical path;claude-memory/is regenerated on every commit and direct edits there get wiped. - Cross-machine sync: git carries the mirrored copy. On a new machine: clone, run the setup script, and one-time-pull the repo's mirrored memory back into the canonical location. Subsequent commits keep both sides in sync.
The hook itself
# Block injected into .git/hooks/post-commit by tools/setup-memory-hook.sh
# === BEGIN: memory-sync (managed by tools/setup-memory-hook.sh) ===
{
REPO_ROOT_HOOK="$(git rev-parse --show-toplevel 2>/dev/null)" || exit 0
SLUG_HOOK="$(echo "$REPO_ROOT_HOOK" | sed 's|/|-|g')"
SRC_HOOK="$HOME/.claude/projects/$SLUG_HOOK/memory"
DST_HOOK="$REPO_ROOT_HOOK/claude-memory"
if [ -d "$SRC_HOOK" ]; then
mkdir -p "$DST_HOOK"
rsync -a --delete "$SRC_HOOK/" "$DST_HOOK/" 2>/dev/null || true
cd "$REPO_ROOT_HOOK"
git add claude-memory/ 2>/dev/null || true
fi
} >/dev/null 2>&1
# === END: memory-sync ===Three things to notice. The block is wrapped in marker comments so a future re-run of the setup script (or a sibling script like the QMD reindex hook) can find and replace just this block without disturbing other hook content. Output is suppressed — commits should stay fast and silent unless something is wrong. The git add stages but doesn't commit — there's no auto-commit and therefore no risk of an infinite hook loop.
The setup script
tools/setup-memory-hook.sh is idempotent. Re-running it strips any previous memory-sync block and re-injects the current one. Safe to run on every new machine after clone. Coexists with other post-commit hooks (in my case, a QMD re-index hook) that follow the same marker-based pattern.
First-machine setup
# After git clone on a new machine:
./tools/setup-memory-hook.sh # install the post-commit hook
./tools/setup-qmd-hook.sh # install other hooks (idempotent)
# One-time: pull the repo's mirrored memory back into the canonical location
SLUG="$(echo "$PWD" | sed 's|/|-|g')"
mkdir -p "$HOME/.claude/projects/$SLUG/memory"
cp -a claude-memory/. "$HOME/.claude/projects/$SLUG/memory/" That's it. Memory lives at the canonical path outside the working tree, the hook mirrors it into claude-memory/ on every commit, git carries it across machines. Two protected zones avoided in one architecture: canonical sits outside the tree, and the mirror sits outside the protected .claude/ prefix.
What the new design loses
One thing: real-time sync. Under the symlink trick, every memory write landed in the repo immediately. Under the post-commit design, memory writes land in the canonical location immediately and propagate to the repo only when you commit. If you write memory in session A on machine 1, switch to machine 2 without committing, machine 2 will not have the new memory.
In practice this is essentially a non-issue for my workflow because I commit multiple times per session anyway. The cross-machine handoff happens at push time rather than at write time, and that delta is small. If you wanted to make it zero you could add a hook that auto-commits memory changes — I chose not to because auto-commits create noisy git histories and aren't worth the savings.
The rollout
I had the symlink trick installed in five repos by the time I noticed the autonomous-mode failure. Conversion is mechanical:
- Tear down the symlink at
~/.claude/projects/<slug>/memory - Restore that path as a real directory and copy current memory into it (the repo copy is the canonical truth at this moment, since the symlink had been writing there)
- Drop in
setup-memory-hook.sh+ the refactoredsetup-qmd-hook.sh(now using marker-based block management so they coexist) - Run both setup scripts
- Rename
setup-memory-link.shtosetup-memory-link.sh.deprecated— keep one cycle as documentation, then remove - Update CLAUDE.md (and README if applicable) to document the new pattern + the rationale for abandoning the old one
- Commit and push — the commit itself is the verification that the new architecture works under autonomous mode
The whole rollout across all five repos took under twenty minutes once the pattern was solid. The hook-installation logic was identical per repo; only the CLAUDE.md edits varied because each repo phrases its setup section slightly differently.
The meta-lesson
Documentation has a half-life. The Exomind article was correct when published — the symlink trick worked. By the time autonomous-mode use cases became dominant, the same technique had become an active anti-pattern. The article hadn't lied; the underlying conditions had shifted.
This is the price of writing publicly about practice rather than theory. The practice updates faster than the publishing schedule. The right response isn't to hide the old article — it's to write the next one when the underlying conditions shift, link them together, and let the reader see the iteration as part of the value.
If you're still running the symlink trick from The Exomind: tear it down. The post-commit hook replaces it cleanly, and your autonomous sessions stop bleeding permission prompts on memory writes.
Related Reading
- The Exomind (the original article — where the symlink trick was first documented)
- The Knowledge Base That Builds Itself
- The Cockpit
- Sinapt: Two Products, Not One
- KISS Your AI Workflow