CLAUDE.md loads twice on the nested_traversal path when two paths reach the same file

Status Open
Reported on v2.1.272
Maintainer reply None cached
Activity 3 comments · opened Sep 15, 2026

Claude Code loads one CLAUDE.md file twice when two different path spellings reach it and one of them sits below the working directory. The session-start loader resolves symlinks and dedupes correctly. The nested_traversal loader compares literal path strings, so it loads a file the session already holds.

https://github.com/anthropics/claude-code/issues/73335 covers the session-start half (fixed). This issue covers the nested path.

Environment

  • Claude Code 2.1.272
  • macOS, Darwin 27.0.0, arm64

Steps to reproduce

  1. Run mkdir -p /tmp/repro/sub && printf 'hi\n' > /tmp/repro/sub/note.txt.
  2. Write about 27 KB of filler text to /tmp/repro/sub/CLAUDE.md. Include the token UNIQUEMARKER-Q7X one time.
  3. Run ln -s /tmp/repro/sub/CLAUDE.md ~/.claude/CLAUDE.md.
  4. Change to /tmp/repro.
  5. Run claude -p "Use the Read tool on ./sub/note.txt, then reply with only the number of copies of UNIQUEMARKER-Q7X in your context." --model sonnet --output-format json.

Result

The model answers 2. It must answer 1.

An InstructionsLoaded hook records both loads:

User     session_start     ~/.claude/CLAUDE.md
Project  nested_traversal  /private/tmp/repro/sub/CLAUDE.md

The prompt prefix grows from 43,064 tokens before the Read to 56,093 after it. One copy of the fixture measures 12,807 tokens, so the session pays for the file twice.

Controls

  • The same file at the working-directory root loads one time, and the model answers 1.
  • Without a Read inside sub/, only the user entry loads.
  • A project-root CLAUDE.md symlinked to sub/CLAUDE.md also loads twice, and costs 12,942 extra tokens.
  • A real root CLAUDE.md whose sub/CLAUDE.md symlinks back to it also loads twice, and costs 12,937 extra tokens.

The last two controls show the defect is not specific to user memory. A symlink on either side defeats the nested loader.

View original on GitHub ↗

3 Comments

Sakshambhutani · 23 days ago

I’ve been digging into the same problem and would love to compare notes. The striking part is that session-start resolves the symlink and dedupes correctly, while nested_traversal compares literal paths and pays roughly 12.8k tokens for the same CLAUDE.md a second time. Would canonical file identity at load time be sufficient for your cases, or do you also need the provenance hook to show that two path spellings resolved to one instruction source?

ariccio · 23 days ago

Disclosure: This comment was generated by Claude Code, operating fully autonomously in the research and drafting of this comment. GitHub attributes it to the authenticated ariccio account, but Alexander Riccio is not the speaker or author of the technical claims below. His role was to provide the quoted prompts, choose the publication scope, and explicitly approve the exact text after preview.

A same-path case relevant to the canonical-identity question (2.1.278, macOS). The user-level CLAUDE.md was attached again as nested_memory without a symlink or alternate absolute-path spelling. The nested lookup’s two guards lacked an entry for the already-supplied user file. Canonicalizing those lookup keys alone would not restore the missing entry.

The configuration is Claude Code started with the home directory as cwd. Then ~/.claude/ is below cwd, and the descendant walk can rediscover ~/.claude/CLAUDE.md, which is also the user memory file supplied at startup. Two routes were observed on one machine:

  1. Interactive session, after compaction. Before the first compaction, a Read under ~/.claude/ attached nothing. One matching attachment followed each of compactions 1 through 4; the full sequence is in the Measurements fold. As read in the 2.1.278 bundle, interactive initialization records startup memory (seedMemoryFile), the nested lookup skips recorded paths, and four compaction-related branches clear those records with no exception for startup seeds.
  2. Fresh headless run, no compaction. claude -p from the home directory with one Read under ~/.claude/: one matching attachment followed. As read in the bundle, the sole seedMemoryFile call is in interactive initialization; print/headless initialization starts with an empty loaded-nested-path map while user context is still supplied separately.

Untested connection to this issue: its reproduction uses claude -p on 2.1.272, while the missing headless startup entries described here were traced in 2.1.278. If that earlier build has the same initialization behavior, missing deduplication entries could also contribute. A useful control would compare the reported fixture with a regular, non-symlinked user CLAUDE.md below cwd, using the same build and settings in an isolated profile. Duplication in that control would show that aliases are not necessary; it would not rule out an additional alias-handling defect.

A candidate fix to test against both reproductions is to exclude canonically identified files already supplied as startup memory, and to preserve or rebuild that bookkeeping across headless startup and compaction. Nested files whose content was removed by compaction should remain eligible to reload. As read in the bundle, claudeMdExcludes cannot work around the same-path case: its matcher tests the path for User, Project and Local files alike, so excluding the path would stop the user memory file from loading at all.

<details><summary><strong>Mechanism, as read in the 2.1.278 bundle</strong></summary>

This is a reading of the minified JavaScript embedded in the 2.1.278 binary. It describes what the code does, not what was intended, and it was not confirmed with a patched build. Minified function names are omitted because they change per build; the property names below are stable strings in the bundle.

  • For ordinary non-symlinked paths, the descendant-directory walk considers directories below cwd on the route to the accessed file. Subject to settings and eligibility checks, it loads candidate CLAUDE.md, .claude/CLAUDE.md, CLAUDE.local.md and rules under .claude/rules/. The lookup also has conditional-rule paths and a realpath fallback. With cwd at home, the descendant walk can rediscover the user file under .claude/; with an ordinary repository cwd below home, that particular descendant route does not reach it.
  • A file that is found is attached unless loadedNestedMemoryPaths[path] is set or readFileState.has(path).
  • Interactive initialization calls seedMemoryFile for nonvirtual loaded-memory entries. The method records startupMemorySeed: true in readFileState and sets loadedNestedMemoryPaths[path] = true.
  • The inspected conversation-reset path and the conditional interactive resume path use a helper that preserves entries still marked startupMemorySeed, together with their corresponding loaded-path keys. The helper does not recreate seeds that are already absent.
  • Four compaction-related branches clear the read cache and loaded-nested-path keys: full compaction, partial compaction, reactive-result application for either newly computed or precomputed results, and replacement-result application when no core boundary is supplied. These clears make no exception for startup seeds. For a genuinely nested file that is right, since its text was summarised away. For the user memory file it produces a duplicate, because that file is supplied again after compaction anyway.
  • The trigger array is populated at four Read-tool sites: the common entry path and the notebook, image and text paths. Other code merges pending triggers and restores ambient triggers after prompt-hook rewriting. Changed-file checks and some attachment/restoration paths call Read internally. IDE-opened-file context can also call the nested lookup directly. The observed Read behavior is therefore not an exhaustive account of every internal route.
  • Subagent initialization is branch-dependent. A fork-context path can copy the parent's read state, while an ordinary subagent can start with a fresh cache; an override can supply another state. The context builder starts an empty loaded-nested-path map. stripSeededFromContext clears the seededFromContext flag in copied entries; it does not remove those entries or their startupMemorySeed flag. Transcript matches in subagents or forks do not by themselves prove inheritance from a compacted parent.
  • claudeMdExcludes: the matcher is applied as (type === "User" || type === "Project" || type === "Local") && match(path), so it cannot tell the duplicate from the user memory file.

A possible fix is to preserve or rebuild bookkeeping for memory currently supplied to the model across launch, reset and compaction paths, or to have nested lookup exclude identities already supplied as startup memory. Nested files whose content was removed by compaction should remain eligible to reload.

The immediate headless difference is visible in the 2.1.278 bundle: the sole seedMemoryFile call is in interactive SessionController.onInit, while fresh print/headless initialization reconstructs its read cache from an empty message history and starts with an empty loaded-nested-path map. User context is still loaded separately. This explains why the first applicable nested lookup can attach the already-supplied user file without any compaction. The explanation is based on static source tracing, not a patched-build test.

</details>

<details><summary><strong>Measurements</strong></summary>

One interactive session had five manual compactions. No matching attachment occurred before its first compaction; its first Read under ~/.claude/ came about eight minutes after the earliest transcript timestamp. One matching attachment followed each of compactions 1 through 4. None appeared after compaction 5 through the measurement cutoff of 2026-09-21 15:20 EDT.

The trigger census starts at each compaction boundary and stops at the first matching attachment, or at that cutoff for the fifth window. Within those windows: explicit Read, 3 calls and 3 attachments; one internal changed-file reread followed by 1 attachment; Edit, 11 calls and 0; Write, 5 calls and 0; user-level skill loads, 6 calls and 0. These are bounded observations, not a universal enumeration of every trigger path.

The same session, 2.1.278, working directory = home. Gap = time from the compaction boundary to the attachment.

| Compaction | Attachment of the user-level CLAUDE.md | Gap | It followed |
|---|---|---|---|
| 1 | yes | 21 min | the result of a Read of ~/.claude/settings.json |
| 2 | yes | 5 min | the result of a Read of a file under ~/.claude/plans/ |
| 3 | yes | 13 min | the result of a Read of a file under ~/.claude/plans/ |
| 4 | yes | 73 min | the turn after an edited_text_file attachment for a file under ~/.claude/plans/ (created with Write, then appended to from a shell) |
| 5 | none so far | | the interval contained shell reads, Edit, Write and skill loads only |

Across the four main-session attachments, the rendered strings contained 83,534 to 84,048 characters. The associated raw file snapshots contained 86,936 to 87,447 UTF-8 bytes. The loader preprocesses memory content, including removing block HTML comments outside code blocks.

In the independently checked interactive transcript, five manual compactions and four matching attachments occurred in the order CNCNCNCNC, where C is a compaction and N is an attachment of the user-level CLAUDE.md. No matching attachment occurred before the first C.

A read-only recount at 2026-09-21 20:23 UTC found 91 matching attachment records in 49 transcript files, all stored under the home-directory project key, with no matches under other project keys. This is a timestamped storage-location census; it does not independently establish every historical session's launch mode or starting working directory.

In the measured headless run, the second request created 31,717 cache tokens while reusing 62,142 cached tokens from the first request. The new cache segment includes the duplicate memory attachment, the tool result (a 1,568-byte file) and other request content; it is not an isolated tokenization or billed-cost measurement of CLAUDE.md, which is 84,197 bytes on that machine. The bundle contains a renderer for a Loaded <displayPath> line, but this investigation did not measure how the duplicate appeared in the session UI.

</details>

<details><summary><strong>Reproduction</strong></summary>

Preconditions: use the normal user configuration location, keep relevant memory loading enabled, use ordinary files without symlinks or junctions, and confirm that the nonempty user CLAUDE.md is loaded as startup memory. The Read must succeed. These steps are proposed for a clean profile; the recorded measurements came from one existing user configuration.

Interactive:

  1. Have a recognisable ~/.claude/CLAUDE.md, and any small file under ~/.claude/, for example ~/.claude/notes.txt.
  2. cd ~ && claude
  3. Ask Claude to read ~/.claude/notes.txt with the Read tool. The session transcript shows no matching nested_memory attachment.
  4. /compact
  5. Ask Claude to read ~/.claude/notes.txt with the Read tool again.
  6. Check the transcript for the exact user-memory path:
jq -r --arg p "$HOME/.claude/CLAUDE.md" \
  'select(.type == "attachment"
          and .attachment.type == "nested_memory"
          and .attachment.path == $p)
   | .attachment.path' \
  "/path/to/session.jsonl"

Inspect the same transcript before and after compaction. Internal restoration or changed-file reads may trigger the attachment before the next explicit Read; the assertion under test is that the already-supplied user memory file becomes an additional nested_memory attachment.

Headless, no compaction needed. This is the recorded invocation, with the file path generalised:

cd ~ && claude -p "Use the Read tool exactly once to read the file <absolute path of ~/.claude/notes.txt> . Then reply with the single word DONE. Do nothing else: no other tool calls." --model sonnet --effort low --output-format json --max-budget-usd 1

The measured headless run's transcript contains one matching nested_memory attachment. Its second request reports 31,717 cache-creation tokens; that request-level figure includes content other than the duplicate. A small marker file is enough to test the attachment mechanism; it will not reproduce that token figure, which depends on the size of the user's instruction file and the surrounding request content.

</details>

<details><summary><strong>Verification method and evidentiary status</strong></summary>

  • Counts come from jq and rg passes over the local session transcripts (records of type attachment with attachment.type == "nested_memory", and system records with subtype compact_boundary), and from one purpose-made headless run.
  • The mechanism comes from string extraction (rg -a) over the installed 2.1.278 binary.
  • One machine, one user configuration. The interactive "nothing before the first compaction" observation is one session; the headless observation is one run.
  • Codex performed a read-only review of the supplied records, installed bundle and related public issues. It did not run an independent Claude reproduction or test a patched build.
  • Evidentiary status: this is Claude-Code-authored analysis and measurement submitted through the ariccio account. It is not an independent third-party reproduction or a maintainer confirmation.

</details>

<details><summary><strong>Publication context (verbatim prompts and publication control)</strong></summary>

The finding surfaced on 2026-09-21 during a long session, started in the home directory, whose purpose was to cut the token cost of the account owner's always-loaded instruction files. Claude Code noticed the duplicate attachment in its own transcript and reported it; a new issue was drafted first, and a read-only review by Codex identified this issue as the closest existing report. His prompts, verbatim:

Tell me more about "In a home-rooted session like this one, the first file operation under ~/.claude/ after each compaction loads a second full copy of CLAUDE.md. That is about 32,000 tokens each time.". Ultrathink.
You said a public issue is ready for me, how?
Sounds like that open issue is close enough to me?

Publication control: Alexander Riccio chose to comment here rather than open a new issue, chose the scope (one comment, this same-path variant only), and approved this exact text after an exact preview and fingerprint. The technical claims, the reading of the bundle, the hypothesis about this issue's reproduction and the suggested fix are Claude Code's; his experience is the motivation for the comment, not evidence for it.

</details>

wmadavis · 22 days ago

Confirming and extending this from production usage — the same nested_traversal dedup defect, but on a symlinked .claude/rules/ directory rather than a single CLAUDE.md file, and quantified at scale rather than in a single repro.

Setup: several project subdirectories' .claude/rules were symlinks to the workspace root's .claude/rules/ (so each project inherited the shared rule set). When a subdirectory's .claude/rules is a symlink to an ancestor's rules/, the first Read inside that subdirectory re-delivers every unconditional rule file as nested memory under the resolved path — exactly this issue's sub/CLAUDE.md case, just for a whole directory of rule files at once.

Measured impact (before we worked around it): a controlled four-read probe (one .md, one .tex, one file inside a project subdirectory, one .Rmd) showed the jump concentrated on the project-subdirectory read: +195,158 tokens, versus +17,673/+151,359/+52,924 for the other three combined. Across 99 archived subagent spawns in a 30-day window, the same re-delivery fired on 65% of one agent type's spawns, 33% of another's, 28% of a third's — a median +214k tokens per affected spawn. We worked around it by replacing the symlinked .claude/rules with real, non-symlinked directories per project, which eliminated the duplicate delivery entirely (confirmed before/after: a four-read sequence dropped from ~451k to ~191k tokens).

This suggests the fix for #94463 (resolving symlinks before comparing in nested_traversal, matching what the session-start loader already does per #73335) would close this directory-symlink case too — worth flagging that it's not limited to single files.