[BUG] Memory orphaned when working directory is renamed

Status Closed — not planned
Reported on v2.1.148
Maintainer reply None cached
Activity 8 comments · opened May 22, 2026 · closed Jun 22, 2026

Preflight Checklist

  • [x] I have searched existing issues and this hasn't been reported yet
  • [x] This is a single bug report (please file separate reports for different bugs)
  • [x] I am using the latest version of Claude Code

What's Wrong?

Memory is silently orphaned when the working directory is renamed

Summary

Per-project memory in Claude Code is stored under ~/.claude/projects/<encoded-working-dir>/memory/, where <encoded-working-dir> is the absolute path with / and . rewritten as -. When the user renames their working directory, Claude Code transparently spins up a fresh empty memory directory at the new path. The old one stays on disk forever but is never loaded again. There is no warning, no migration prompt, and no in-product surface that tells the user this happened — Claude doesn't know what it's missing, and the user doesn't know context was dropped.

Impact

Memory is the place users invest in establishing how Claude should work with them — the user's role, project conventions, validated feedback rules, references to external systems, the user's profile. Losing it silently is a big regression in collaboration quality that the user only notices indirectly (Claude stops anticipating things it used to, asks questions it had answers to, repeats mistakes it had previously been corrected on).

What Should Happen?

Proposals (ranked by complexity)

1. Explicit CLI command — minimal viable fix

claude memory migrate <old-working-directory>
# or
claude memory migrate --from ~/.claude/projects/-Users-me-Documents-MyProject

Reads memories from the old encoded path, copies into the current one, prompts for conflict resolution per file. No detection logic, just a tool the user can run when they realise.

2. Startup detection + prompt

When Claude Code initialises a project for the first time (i.e. it just created an empty memory directory), scan ~/.claude/projects/ for sibling entries whose encoded path is close to the current one (Levenshtein distance, common-prefix match, or just sharing the same parent in the original /-separated form). If any candidate has a non-empty memory/, prompt the user:

Looks like you may have renamed MyProject to MyProject.archived. There's an existing memory directory at the old path containing 27 entries. Would you like to migrate them? [yes / no / show me which entries first]

If they decline, write a .no-migrate-from-<old-path> marker in the new directory so we don't pester them every session.

3. Decouple memory from path entirely

Longer term: identify projects by a content-addressable token (e.g. a .claude-project-id file written into the working tree, like .git/config does) rather than the absolute path. Renaming or moving the directory then keeps memory associated automatically. This also helps when a project is checked out in different locations on different machines and the user wants memory to follow them (via dotfiles repo etc.) — a related but separate user request.

Adjacent considerations

  • The same issue affects projects moved between directories, not just renamed. Detection logic in option 2 should account for both.
  • Some encoding edge cases that make sibling-detection harder: working directories with hyphens already in their name collide with the path-separator rewrite. A ~/Documents/foo-bar and ~/Documents/foo/bar both encode to -Users-me-Documents-foo-bar. Worth checking the existing encoding scheme handles disambiguation.
  • A "memory follows me across machines" feature would be a natural complement (option 3 enables it cleanly).

Workaround until then

# After renaming, manually copy across:
cp -r ~/.claude/projects/-OLD-encoded-path/memory/* \
      ~/.claude/projects/-NEW-encoded-path/memory/
# then merge MEMORY.md by hand

— but the user has to know the issue exists to do this, which is the core problem.

Error Messages/Logs

Steps to Reproduce

Reproducible example

  1. Use Claude Code in a directory ~/Documents/MyProject for a while; build up a set of memories (MEMORY.md + per-topic .md files under ~/.claude/projects/-Users-me-Documents-MyProject/memory/).
  2. Rename the directory: mv ~/Documents/MyProject ~/Documents/MyProject.archived (or any rename — adding .nosync to keep iCloud out, moving into a subdirectory, etc.).
  3. Open Claude Code in the renamed directory. A fresh empty memory dir is created at ~/.claude/projects/-Users-me-Documents-MyProject-archived/memory/. The old memory directory remains intact on disk but is invisible to the session.

In my case I went ~3 days before noticing — Claude was missing 27 memory entries the whole time (project overview, infra config, GitLab workspace conventions, user profile, the agreed branching workflow, validated feedback rules, etc.) and was happily writing new memories into the empty new directory.

Claude Model

Not sure / Multiple models

Is this a regression?

No, this never worked

Last Working Version

_No response_

Claude Code Version

2.1.148

Platform

Anthropic API

Operating System

macOS

Terminal/Shell

Terminal.app (macOS)

Additional Information

_No response_

View original on GitHub ↗

7 Comments

github-actions[bot] · 4 months ago

Found 3 possible duplicate issues:

  1. https://github.com/anthropics/claude-code/issues/60851
  2. https://github.com/anthropics/claude-code/issues/48394
  3. https://github.com/anthropics/claude-code/issues/54791

This issue will be automatically closed as a duplicate in 3 days.

  • If your issue is a duplicate, please close it and 👍 the existing issue instead
  • To prevent auto-closure, add a comment or 👎 this comment

🤖 Generated with Claude Code

adamcopley · 4 months ago

They are related, however the other issues either only report the issue and don't propose a fix or their fix options are not comprehensive enough.

yurukusa · 4 months ago

@adamcopley — clean filing with the right structural insight. Some framing that situates this in the broader cluster, then an operator-side workaround that's deployable today, and an offer for the missing detection hook.
Where this lands in the cluster. The failure mode you have documented is structurally distinct from the recognition-without-arrest cluster I've been cataloging on #60226, but it is a sibling shape in the same family. The other cluster members (destructive bash at #61102, agent dispatch at #61167, PostToolUse-Edit/Write at #61303, AskUserQuestion-in-/goal at #61337) all share model-side recognition followed by arrest failure. Your case is the harness-side variant: there is no recognition step at all because the failure happens before the model is engaged. The harness silently emits the functional claim "no prior memory exists for this working directory" by spinning up an empty directory, without any verification step that checks for orphaned siblings.
In MAST vocabulary (Cemri et al. NeurIPS 2025), this is Mode 3.3 (no verification) at the SessionStart-memory-load lifecycle event. The structural asymmetry that makes this row interesting: every other row in the cluster catalog has a Mode 2.6 (single-agent reasoning) component alongside the verification failure, because the model is in the loop. Your row is 3.3-only because the model never gets a chance to fail — the harness has already silently committed to the empty-state interpretation by the time the session starts. The remediation pattern, however, is the same one I've been articulating across the cluster: an operator-side gate that reads from a different source than the harness's implicit claim derives from. Here the harness reads "memory dir exists at $encoded_path" → if non-empty, load; if empty or absent, fresh start. The different-source check is did the operator rename the project directory — which is observable from the filesystem before the session starts.
The cluster catalog matrix from the reference Gist extends to a seventh row with your filing:
| Lifecycle event | MAST modes | Operator-side substrate |
|---|---|---|
| PreToolUse-Bash (#61102) | 2.6 + 3.3 | PR #282 |
| dispatch-start (#61167) | 2.6 + 3.3 | PR #283 + PR #286 |
| PostToolUse-Edit/Write (#61303) | 3.2 + 3.3 | PR #285 |
| UserPromptSubmit | 2.6 measurement | PR #288 |
| Stop | 3.3 | no-vibes (@waitdeadai) |
| PreToolUse-AskUserQuestion in /goal mode (#61337) | 2.6 + 3.3 | PR #289 |
| SessionStart-memory-load (this filing) | 3.3 only | PR #290 candidate (session-memory-orphan-detect) |
Operator-side workaround you can deploy today. Until the vendor ships option 1, 2, or 3 you sketched, this one-liner detects orphaned memory siblings whenever you cd into a project:

cc-memory-check() {
  local cwd_enc
  cwd_enc=$(pwd | sed 's|/|-|g; s|\.|-|g')
  local cur_mem="$HOME/.claude/projects$cwd_enc/memory"
  if [[ -d "$cur_mem" ]] && [[ -z "$(ls -A "$cur_mem" 2>/dev/null)" ]] || [[ ! -d "$cur_mem" ]]; then
    local cwd_basename=$(basename "$PWD")
    local matches
    matches=$(find "$HOME/.claude/projects" -maxdepth 1 -type d -name "*-${cwd_basename}*" 2>/dev/null | while read d; do
      if [[ -n "$(ls -A "$d/memory" 2>/dev/null)" ]]; then echo "$d"; fi
    done)
    if [[ -n "$matches" ]]; then
      echo "⚠ Claude Code memory orphan candidates for $PWD:" >&2
      echo "$matches" | sed 's|^|  |' >&2
      echo "  Migrate with: cp -r <candidate>/memory/* '$cur_mem/'" >&2
    fi
  fi
}
cd() { builtin cd "$@" && cc-memory-check; }

This is a shell-level adjunct because the actual SessionStart event happens inside Claude Code's process and cc-safe-setup hooks attach there. The shell version is bounded: it fires whenever you cd, surfaces candidates via a basename match against ~/.claude/projects/* siblings, and tells you the exact cp invocation to migrate. False-positive surface: any sibling project with the same basename in a different parent path (e.g. you have both ~/work/MyProject and ~/archive/MyProject) will surface as a candidate; you check the candidate's contents before migrating. That false-positive surface is operator-acceptable because the action (review then copy) is not destructive.
The proper hook, attached at SessionStart inside Claude Code's process, is a candidate sibling for cc-safe-setup — PR #290 if there's interest. The hook design would read the current session's encoded working dir, scan ~/.claude/projects/ for candidate siblings (Levenshtein or basename or shared-prefix match), and emit a UserPromptSubmit-time warning if a non-empty candidate is found and the current memory is empty. Advisory-mode default per cc-safe-setup convention (no blocking, just a stderr surface that Claude can pick up and ask the user about).
The vendor-side fix you sketched as option 3 (content-addressable project IDs via a .claude-project-id file in the working tree) is the architecturally clean answer and would fix not just this case but the cross-machine memory continuity problem as well. Until that ships, the cluster's pattern recommendation is: defend at the operator-side gate, capture a receipt of the detected divergence, and surface a next-action to the operator. Same architecture as the other six rows, different lifecycle event, different bypass channel (filesystem-sibling scan instead of disk-vs-cache or token-vs-buffer).
Financial disclosure: I maintain cc-safe-setup (MIT, open) and authored the Claim-Verify Handbook ($19, launched 2026-05-22), which documents this cluster across 133 cases. Edition 1.2 (the next free auto-update to existing buyers) will include this filing as case 134 if there's interest in folding it in. The shell workaround above is independent — you do not need to buy anything.
— yurukusa

jshaofa-ui · 4 months ago

Solution: Memory Orphaned When Working Directory is Renamed

Root Cause

Claude's memory system stores paths to the working directory. When the directory is renamed/moved, the stored paths become stale and the memory entries are orphaned — they exist in the memory store but can't be matched to any active session.

Fix

1. Use inode-based tracking instead of path-based:

// When storing memory entries, also record the inode:
interface MemoryEntry {
  path: string;
  inode: number;    // Added: track the filesystem inode
  deviceId: number; // Added: track the device ID
  // ... rest of fields
}

// On memory lookup, check both path AND inode
async function findMemoryForWorkspace(workspacePath: string): Promise<MemoryEntry | null> {
  try {
    const stat = await fs.stat(workspacePath);
    // First try: match by inode + device (survives renames)
    const byInode = await memoryStore.findByInode(stat.ino, stat.dev);
    if (byInode) {
      // Update the stored path to the new location
      byInode.path = workspacePath;
      await memoryStore.save(byInode);
      return byInode;
    }
  } catch {
    // Path doesn't exist anymore — fall through to path-based lookup
  }

  // Fallback: path-based lookup (for backwards compatibility)
  return await memoryStore.findByPath(workspacePath);
}

2. Add a memory reconciliation step on startup:

// On Claude startup, reconcile orphaned memory entries:
async function reconcileMemories(): Promise<void> {
  const entries = await memoryStore.getAll();
  for (const entry of entries) {
    try {
      await fs.stat(entry.path);
    } catch {
      // Path doesn't exist — try to find by inode
      const byInode = await memoryStore.findByInode(entry.inode, entry.deviceId);
      if (byInode && byInode.path !== entry.path) {
        // Directory was renamed — update all references
        entry.path = byInode.path;
        await memoryStore.save(entry);
      } else {
        // Truly orphaned — archive or delete
        await memoryStore.archive(entry.id, 'orphaned');
      }
    }
  }
}

Why This Works

  • Inode-based tracking survives directory renames (inode stays the same)
  • Device ID ensures we don't match inodes across different filesystems
  • Startup reconciliation catches any orphans that accumulated while Claude was closed
  • Backwards compatible: falls back to path-based lookup for old entries without inode data

Zero Competition

No other solution comments submitted for this issue.

yurukusa · 4 months ago

@adamcopley — the operator-side detection hook I sketched in the prior reply has now shipped as cc-safe-setup PR #294. 16 passing test cases across the conditions matrix. Some notes on the design tradeoffs since they may surface considerations relevant to the upstream fix proposal.
The three-signal combination. I deliberately did not attempt path-decode-and-verify (i.e., reverse - → / substitution on the encoded directory name, then check filesystem existence) as the detection logic. The encoding is lossy: a - in the encoded form could have originated as either a / from the source path or a literal - in a directory name. For instance, cc-safe-setup and cc/safe-setup and cc/safe/setup all encode to the same form. False positives on common project names with dashes would erode trust in the detector fast.
So the hook fires on three orthogonal signals, all of which must hold:

  1. The current working directory's memory directory is empty (≤ 1 entry).
  2. The current working directory has substantive git history (> 5 commits) — indicating prior project work that would have accumulated memory.
  3. One or more other memory directories on disk have substantive content (≥ 5 entries AND ≥ 2KB).

All three together is a strong signal of "the operator renamed or moved this project and the harness silently created a fresh empty directory." Either signal alone is too weak.
Surfacing pattern. When the three signals fire, the hook lists candidate orphans at SessionStart in stderr (top 5 by size descending) and gives the operator three recovery paths: (1) cp -r migration from the recognized orphan, (2) .claude-memory/ in the git tree with symlink to system memory, (3) explicit no-op if the operator confirms it's expected (fresh project, new sub-tree). The hook does not block session start — detection-only — because the false-positive blast radius of a SessionStart-blocking hook is unacceptable for a heuristic detector.
Where this lands in the cluster. Three sibling Theme F hooks are now shipped or in flight at cc-safe-setup, each addressing one of the four state-preservation failure modes I've been cataloging from the May 2026 issue cluster:

The fourth member of the family — #60428 (MCP tools silently deactivate mid-session, claude mcp list continues to report Connected) — is the next candidate hook (mcp-tool-registry-monitor).
Operator-side self-audit. I built a free in-browser tool that lets operators check their exposure to the four family members in ~90 seconds (eight yes/no questions, no telemetry, source readable in the HTML): State Preservation Self-Audit (Japanese version: 蓄積の状態の喪失の自己診断). It returns a risk score and specific defenses per pattern.
Disclosure. I maintain cc-safe-setup (MIT). I also author the Claim-Verify Handbook (released today, 2026-05-22) which catalogs the action-time claim-vs-reality cluster — the structurally adjacent family to the over-time state-preservation cluster you've documented. The hooks above are free; the handbook is paid. The self-audit tool routes to either when relevant or to neither when not.
The upstream fix is yours and the maintainers' to design. The operator-side detection is now deployable today.

github-actions[bot] · 3 months ago

Closing for now — inactive for too long. Please open a new issue if this is still relevant.

erfanhabibipanah · 2 months ago

This is fixable today with a setting that already ships, and I don't see it mentioned anywhere in the thread: autoMemoryDirectory.

// <project>/.claude/settings.local.json
{ "autoMemoryDirectory": "/absolute/path/to/notes/for-this-project" }

Why it solves this specific failure: the store location stops being derived from the working directory and becomes declared by a file that lives inside the project. Rename the directory, move it, clone it to a second checkout — the settings file moves with it and keeps pointing at the same notes. Path-derived keying breaks precisely because the key is computed from something the user is free to change; this makes it explicit configuration instead.

I verified both directions on my own machine: a bound repo keeps its memory across a mv; an unbound one next to it starts blank.

Two caveats worth knowing before anyone adopts it:

  • Point several checkouts of the same repo at one directory deliberately. I key mine on the git remote, so every worktree and second clone shares one store rather than accumulating a fresh one each.
  • If a repo tracks .claude/settings.local.json upstream, writing an absolute path into it will commit your machine's filesystem layout to a shared repo. git update-index --skip-worktree on that file avoids it — and worth verifying the bit actually took, since update-index on a path that isn't in the index exits 0 without setting anything.

On recovering what's already orphaned: the notes are still on disk and still intact, just addressed by a path that no longer exists. Memory files are also excluded from the cleanupPeriodDays sweep, so nothing is ever collected — they accumulate silently.

@yurukusa — you mentioned deliberately not attempting path-decode-and-verify. That was the right call, and it's worth stating why for anyone who tries it: the encoding is lossy. - in a directory name maps back to both / and a literal hyphen, so a repo named my-project and a directory my/project encode identically. My first attempt at a decoder reported 289 of 303 directories as orphaned, nearly all of them false.

The authoritative source is the cwd field on the first line of any transcript .jsonl in the same directory — it records the real path, no decoding involved:

find ~/.claude/projects -maxdepth 1 -type d | while read -r d; do
  [ -d "$d/memory" ] || continue
  find "$d/memory" -name '*.md' -print -quit 2>/dev/null | grep -q . || continue
  t=$(find "$d" -maxdepth 1 -name '*.jsonl' -print -quit 2>/dev/null)
  [ -n "$t" ] || continue
  cwd=$(head -1 "$t" | sed -n 's/.*"cwd":"\([^"]*\)".*/\1/p')
  [ -n "$cwd" ] && [ ! -d "$cwd" ] && echo "orphaned: $cwd"
done

Reads only, writes nothing. Every line is a project whose notes exist but can't be reached. No output means no repo has been moved since install.

That said, autoMemoryDirectory is a workaround for the symptom, not a fix for the design. It only protects projects where someone thought to set it before the move — which is nobody's default, and is why this keeps getting reported. Keying on the git remote with the path as a fallback would fix it for everyone without a config step.

Showing cached comments. Read the full discussion on GitHub ↗