Memory Outside the Tree

The symlink trick from The Exomind looked clean — versioned memory in the repo, portable via git. It also broke autonomous operation. The fix: reverse the symlink and let the kernel resolve the path before the protected-path check sees it.

Luminous data-orb tethered just outside a dark architectural cube — cyberpunk Medellín aesthetic

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.

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 — engineer the layer below them. The path-protection check isn't a Claude Code bug. It's a deliberate constraint protecting users from autonomous-write footguns. The check is hardcoded prefix-based 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. Confirmed by GitHub issues #41615 and #43001 — the check runs BEFORE permission resolution. Nothing at the permissions layer can move it.

But the check evaluates the path AFTER the kernel resolves symlinks. That's the lever. The Exomind setup pointed canonical → in-repo, so the resolved path stayed inside the protected prefix. Reverse the direction and the resolved path lands outside the prefix entirely. The check has nothing to fire on.

Two symlink directions, one mechanism, opposite outcomes:

DirectionResolved pathProtected?Result
Exomind: canonical → in-repo<repo>/.claude/memory/foo.mdYESPrompt fires
Reverse: in-repo → claude-memory<repo>/claude-memory/foo.mdNONo prompt

The fix

Real storage at <repo>/claude-memory/. <repo>/.claude/memory is a symlink to ../claude-memory/. That's the entire architecture. No post-commit hook. No mirror. No canonical sync. Git tracks claude-memory/ directly.

Walked through:

  • Real storage: <repo>/claude-memory/ — outside the hardcoded .claude/ protected prefix. Tracked in git. Portable across machines. Editable directly without any prompt.
  • Write-target shim: <repo>/.claude/memory symlinked to ../claude-memory/. Required because Claude Code's auto-memory subsystem hardcodes <cwd>/.claude/memory/ as the write target — we can't change that via config. The symlink intercepts the write at the kernel level: when Claude Code writes to .claude/memory/foo.md, the kernel resolves the path to claude-memory/foo.md BEFORE the protected-path check sees the final destination. Resolved path is outside .claude/, check passes, no prompt.

The setup script

# tools/setup-memory-hook.sh — idempotent. Run once per machine after git clone.
# (Despite the name kept for backward compat, this no longer installs a hook.)

mkdir -p claude-memory                                 # ensure real storage exists
mkdir -p .claude                                       # ensure .claude/ exists
ln -sfn ../claude-memory .claude/memory                # the reverse symlink

# Strip any legacy memory-sync block from .git/hooks/post-commit (no longer needed —
# claude-memory/ IS the storage, git tracks it directly).

Two things to notice. The symlink is relative../claude-memory — so it survives a fresh clone on any machine without path rewriting. And the directory at the link target is created first, so the link doesn't dangle even on the empty initial state.

Cross-machine

tools/setup-memory-hook.sh is idempotent. Re-running it on an existing setup is a no-op. On a fresh clone, all you do is:

First-machine setup

# After git clone on a new machine:
git clone <repo>
cd <repo>
./tools/setup-memory-hook.sh

# Done. claude-memory/ comes from git, has all memory files. Symlink is recreated.
# No canonical pull. No rsync. No hook to install.

That's it. The kernel does the work the protected-path check can't see around. Memory storage lives openly at claude-memory/, git carries it across machines, and Claude Code's hardcoded write path is gracefully intercepted at the layer below the check.

Trade-offs

None for the use case. The previous architecture was a post-commit mirror, which had a real-time-sync trade-off (memory wasn't visible cross-machine until you committed). The reverse-symlink architecture removes that — every write to claude-memory/ is immediately the source of truth, and git pull on the other machine carries it across at the rate you commit. Same operational profile as any other file in the repo.

There's a small dependency: the architecture assumes the OS resolves symlinks before path-prefix checks. macOS and Linux both do. Windows behavior is more nuanced — symlinks require admin privileges by default and the resolution timing differs. If you're running Claude Code on Windows, this approach may need adaptation; the protected-path check might evaluate against the original path string rather than the resolved one.

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 refactored setup-qmd-hook.sh (now using marker-based block management so they coexist)
  • Run both setup scripts
  • Rename setup-memory-link.sh to setup-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

💬
Working with a team that wants to adopt AI-native workflows at scale? I help engineering teams build this capability — workflow design, knowledge architecture, team training, and embedded engineering. → AI-Native Engineering Consulting