CLAUDE.md loads twice on the nested_traversal path when two paths reach the same file
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
- Run
mkdir -p /tmp/repro/sub && printf 'hi\n' > /tmp/repro/sub/note.txt. - Write about 27 KB of filler text to
/tmp/repro/sub/CLAUDE.md. Include the tokenUNIQUEMARKER-Q7Xone time. - Run
ln -s /tmp/repro/sub/CLAUDE.md ~/.claude/CLAUDE.md. - Change to
/tmp/repro. - 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.mdsymlinked tosub/CLAUDE.mdalso loads twice, and costs 12,942 extra tokens. - A real root
CLAUDE.mdwhosesub/CLAUDE.mdsymlinks 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.
3 Comments
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?
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
ariccioaccount, 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.mdwas attached again asnested_memorywithout 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:~/.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.claude -pfrom the home directory with one Read under~/.claude/: one matching attachment followed. As read in the bundle, the soleseedMemoryFilecall 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 -pon 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 userCLAUDE.mdbelow 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,
claudeMdExcludescannot 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.
CLAUDE.md,.claude/CLAUDE.md,CLAUDE.local.mdand 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.loadedNestedMemoryPaths[path]is set orreadFileState.has(path).seedMemoryFilefor nonvirtual loaded-memory entries. The method recordsstartupMemorySeed: trueinreadFileStateand setsloadedNestedMemoryPaths[path] = true.startupMemorySeed, together with their corresponding loaded-path keys. The helper does not recreate seeds that are already absent.stripSeededFromContextclears theseededFromContextflag in copied entries; it does not remove those entries or theirstartupMemorySeedflag. 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
seedMemoryFilecall is in interactiveSessionController.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
Readof~/.claude/settings.json|| 2 | yes | 5 min | the result of a
Readof a file under~/.claude/plans/|| 3 | yes | 13 min | the result of a
Readof a file under~/.claude/plans/|| 4 | yes | 73 min | the turn after an
edited_text_fileattachment for a file under~/.claude/plans/(created withWrite, then appended to from a shell) || 5 | none so far | | the interval contained shell reads,
Edit,Writeand 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-levelCLAUDE.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 aLoaded <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.mdis 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:
~/.claude/CLAUDE.md, and any small file under~/.claude/, for example~/.claude/notes.txt.cd ~ && claude~/.claude/notes.txtwith the Read tool. The session transcript shows no matchingnested_memoryattachment./compact~/.claude/notes.txtwith the Read tool again.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_memoryattachment.Headless, no compaction needed. This is the recorded invocation, with the file path generalised:
The measured headless run's transcript contains one matching
nested_memoryattachment. 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>
jqandrgpasses over the local session transcripts (records of typeattachmentwithattachment.type == "nested_memory", andsystemrecords with subtypecompact_boundary), and from one purpose-made headless run.rg -a) over the installed 2.1.278 binary.ariccioaccount. 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:
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>
Confirming and extending this from production usage — the same
nested_traversaldedup defect, but on a symlinked.claude/rules/directory rather than a singleCLAUDE.mdfile, and quantified at scale rather than in a single repro.Setup: several project subdirectories'
.claude/ruleswere symlinks to the workspace root's.claude/rules/(so each project inherited the shared rule set). When a subdirectory's.claude/rulesis a symlink to an ancestor'srules/, the firstReadinside that subdirectory re-delivers every unconditional rule file as nested memory under the resolved path — exactly this issue'ssub/CLAUDE.mdcase, 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/ruleswith 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.