Skip to content

Load nested CLAUDE.md files in place for Claude runs - #2075

Open
ppXD wants to merge 1 commit into
mainfrom
fix/load-nested-claude-memory-in-place
Open

ppXD wants to merge 1 commit into
mainfrom
fix/load-nested-claude-memory-in-place

Conversation

@ppXD

@ppXD ppXD commented Oct 6, 2026

Copy link
Copy Markdown
Owner

Summary

  • Nested memory loads in place. Under the settings pin, Claude 2.1.263 no longer attaches a subdirectory's CLAUDE.md, .claude/CLAUDE.md or unscoped rule when the run reads below it. An --add-dir naming that subdirectory does load all of it before the first request:

    • labelled as project instructions;
    • with what its rules import, a scoped rule's imports included;
    • with none of its settings.

    Each build walks below the cwd: breadth first, never through a link, never into .git, node_modules or a dot-directory. It adds every such directory after the workspace and its repositories, shallowest first, when all of them fit 16 directories and 32 KiB, imported bytes included. Past that none is added and the launch says so. A directory whose only memory is a scoped rule that imports a file counts too.

  • Containment and cost. Each directory the walk would add passes the same outside-link guard as a root (ClaudeWorkspaceMemory.Guard), as it is found.

    • One budget per build (ClaudeWorkspaceMemory.Budget.cs) covers the walk and every check: 65536 distinct lookups, 1048576 path components walked and 64 MiB read, with every path resolved once. PhysicalPath.File spends components from an Allowance.
    • Before the budget, a link target that repeats d/../ made one lookup cost about 15 ms, and a crafted tree held the synchronous build for minutes.
    • Roots are checked first. Past the budget, the walk stops, unchecked directories are left out, and one notice says so.
  • Which rules count. ClaudeRuleScope.Read mirrors the CLI's frontmatter parse.

    • A rule is classified from its first 4 KiB. When that head opens a fence it does not close, the rule is read on to the close, up to 4 MiB, so a long frontmatter no longer reads as unconditional. Past 4 MiB the rule is unclassified and the launch says so.
    • A rules entry that links outside the cwd counts for nothing, as the CLI skips it.
    • A tree with no nested memory builds the argv it built before.

Test plan

  • Unit: the full suite passes (11945 passed, 1 skipped). New cases:
    • imported bytes counted against the byte budget (32768/32769);
    • a scoped rule alone, with and without an import;
    • a frontmatter closing past 4 KiB, scoped and unscoped;
    • a frontmatter that never closes within 4 MiB;
    • a rule linked into a primary-repository sibling;
    • a 1000-directory and a 16-directory chain-of-links tree, each finishing under 30 s with the budget notice;
    • shared imports resolved once;
    • the budget pinned (Rule 8).
  • Unit, mutations (each restored by copy): each of these turns at least one new case red:
    • budget off;
    • imports uncounted;
    • scoped imports ignored;
    • classification from the head only;
    • a linked-outside rule counted;
    • roots checked after the walk;
    • memo off.
  • Integration (Postgres): RealHarnessWorkspaceMemoryTests 6/6.
  • E2E on macOS, real Claude 2.1.263 and Codex 0.142.2: RepositoryConfigE2ETests 9/9.
    • The nested-in-place arms now also require imp/ on the --add-dir. Its only memory is a scoped rule that imports imp/guide.md.
    • The import must be in the first request, and the rule's own text in no request.
    • Ignoring scoped-rule imports turns both rows red.
  • Review bench at the stack tip:
    • a 19990-directory linked tree: 294.5 s → 1.4 s;
    • 16 in-place directories importing 100 chain links: 23.1 s → 1.5 s.
  • CI root sandbox lane: floor 102, markers nested-in-place claude-code single-repo/multi-repo Confined and nested-over-budget claude-code single-repo Confined
  • CI non-root lane: 18 arms, marker [repo-config-e2e] ran non-root nested-in-place claude-code single-repo Standard uid=1654

The settings pin shuts the route by which the unpinned CLI attached a
subdirectory's memory once the run read a file below it, so since the
pin a nested CLAUDE.md, .claude/CLAUDE.md or unscoped rule never
reached the model. Against Claude 2.1.263 an --add-dir naming that
subdirectory loads all three in place before the first request,
labelled as project instructions, with what its rules import, a
scoped rule's imports included, and none of its settings.

Each build now walks below the cwd, breadth first, never through a
link and never into .git, node_modules or another dot-directory, and
collects every directory that holds such memory, or whose only memory
is scoped rules that import something. When all of them together fit
16 directories and 32 KiB, the files their memory imports counted
too, each rides the one variadic --add-dir after the workspace and its
repositories, shallowest first. Past that none does: a partial pick
would load arbitrary packages up front while the one the run works in
stays out, and the launch says so. The outside-link guard checks each
directory as the walk holds it, which is how its imports are counted,
so it reads at most 16 nested directories' memory per build beside
those of scoped rules alone; each that reaches outside the workspace
is left out by name. A directory whose path holds anything but
letters, digits and . _ @ + - / is not added, since the name rides the
argv. The walk stops at 32 levels and 20000 entries, and says so.

Those bounds left their sum open, and a link target may repeat d/../
any number of times, so one lookup through a long chain walked tens
of thousands of components: a thousand such links, or sixteen in-place
directories importing them, held the synchronous build for minutes.
One budget per build now covers the walk and every directory's check:
65536 distinct lookups, 1048576 path components walked and 64 MiB
read, each path resolved once. The workspace and its repositories are
checked first; past the budget the walk stops, every directory not yet
checked is left out, and the launch says so once.

Which rules count as unscoped mirrors how the pinned CLI reads paths:
the fence, YAML with its quote-and-untab retry, core-schema scalar
typing, the top-level comma split, brace expansion within the CLI's
budget, a trailing /** dropped and ** alone meaning unconditional. A
rule is classified from its first 4 KiB, read on to the fence's close
when that opens one, up to 4 MiB; past that it is unclassified and
said. A rules entry that links outside the cwd counts for nothing, as
the CLI skips it. A tree with no nested memory builds the argv it
built before.

The existing repository-config arms now expect sub/CLAUDE.md in the
first request. New arms pin against the real binary that nested
memory loads in place, single- and multi-repo, with its import, its
.claude/CLAUDE.md, its unscoped rule and a scoped rule's import; that
a scoped rule, a ~ import, node_modules, a dot-directory, a linked-out
CLAUDE.md, an outside import and hostile settings beside it do not;
and that one directory over the budget loads none. With the guard
limited to the roots, the CLI hands the linked-out file to the model.
@ppXD
ppXD force-pushed the fix/load-nested-claude-memory-in-place branch from a3230e4 to a07d31e Compare October 6, 2026 18:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant