Scoping a repo too big for the window
By the end you can
- Choose a starting directory deliberately, knowing what it decides about access, memory and settings
- Layer per-directory CLAUDE.md files and exclude the ones you never work in
- Write Read deny rules for checked-in generated code, and state what they do not cover
- Configure sparse worktrees and cross-package access so a large tree costs less on disk and in context
- Confirm what actually loaded instead of assuming the configuration took effect
The repository has nine packages and you work in one. Claude starts, and the window already holds conventions for the eight you have never opened. Your first question sends it reading through a vendored SDK somebody checked in four years ago. Nothing malfunctioned. The failure mode of this level is not danger — it is confident waste, and it begins before you type.
The default settings are tuned for a smaller repo
Claude Code works at any size, but as the codebase grows, the defaults tuned for smaller projects can fill the context window with instructions and file reads unrelated to the task, costing tokens and degrading Claude's performance. — Monorepos and large repos
Read that as a statement about configuration, not about the model. Lesson 3.1 counted what a window is spent on; this is the same accounting in a tree where the defaults stop holding. The settings below are independent — apply the ones your repository needs.
Where you start decides almost everything
One decision does most of the work, and you make it in the shell before any settings file exists:
Where you launch
claudedetermines which files Claude can read and edit without an additional permission grant, which CLAUDE.md files load into context at startup, and which project settings apply. — Monorepos and large repos
| Start from | File access | CLAUDE.md loaded at launch | Use when |
|---|---|---|---|
| Repository root | Every file | Root only; subdirectory files load on demand when Claude reads there | Tasks span multiple packages or subsystems |
| A subdirectory | That subtree only, until you grant more | That directory's plus every ancestor's | Work is scoped to one package or subsystem |
Start in packages/api/ and Claude loads that package's CLAUDE.md and the
root one, with nothing from packages/web/ in context. Start at the root and you
get the root file at launch, then each subdirectory's file on demand as Claude
reads files there. On-demand loading happens either way — what differs is its
reach. From the root it can pull in any package in the tree; from
packages/api/ it cannot leave that subtree, because nothing outside it is
readable without a further grant.
Neither is better. Root is right when the change genuinely spans packages, a subdirectory when it does not. What is wrong is choosing by habit and then being surprised by what loaded.
Layer CLAUDE.md by directory, then exclude the rest
Lesson 6.1 established the load order and Lesson 6.6 argued that a long instruction file makes results worse. At this scale the argument turns structural: one root file either grows to cover every subsystem or stays too generic to help. The documented split is two levels — repository-wide rules at the root, area conventions in each package or subsystem directory — committed so teammates inherit them, each maintained by whoever owns that directory. The size target is per file, not per repository:
target under 200 lines per CLAUDE.md file. Longer files consume more context and reduce adherence. — Memory
Refusing other people's files is claudeMdExcludes, covered in Lesson 6.3. Two
of its properties matter more here. It is static:
The exclusion list is static, not a per-task switch. To focus on one package today and another tomorrow, start Claude from that package's directory instead of editing exclusions. — Monorepos and large repos
And managed policy CLAUDE.md files cannot be excluded, so organization-wide
instructions always apply
(Settings reference). If a
line you keep fighting comes from managed policy, no exclusion list removes it.
Block reads you will never want
Instructions are one cost; file reads are the other. Most of the reading you want to prevent is already prevented:
Claude's content searches respect
.gitignoreby default, so paths already listed there, such asnode_modules/,dist/, andbuild/, stay out of search results without additional configuration. — Monorepos and large repos
So deny rules earn most of their keep on what is checked in: a vendored SDK,
committed generated code, a directory of golden snapshots. Be exact about what
.gitignore bought you, though. It keeps a path out of search results; it does
not keep Claude from opening the file. Grep skips gitignored files, and "to
search a gitignored file, Claude passes its path directly"
(Tools reference). That is why
the documentation's own example still denies dist/ and build/ — directories
it named one paragraph earlier as typically gitignored.
{
"permissions": {
"deny": [
"Read(./**/dist/**)",
"Read(./**/build/**)",
"Read(./**/*.generated.*)",
"Read(./vendor/**)"
]
}
}Now the part that decides whether you use these honestly. How far a Read deny
rule reaches — which Bash file commands it covers, which redirect targets, what
it does to Edit and Write on the same path, and the version each of those
arrived in — is Lesson 10.5's table, and its limit is that lesson's subject: the
rule is a check Claude Code runs before a tool call, so it never sees the open
that happens inside a subprocess a permitted command started. One limit belongs
here, because it is the one a large tree hits daily:
A Bash search such as
grep -rorfindover a directory that contains denied files still includes them in its output. — Monorepos and large repos
A deny rule is a read-cost control, not a boundary. Needing an actual boundary — secrets rather than noise — is Level 10's problem, and a deny rule alone is not its answer.
One asymmetry worth keeping, because it surprises people in a tree with a large gitignore. Grep skips gitignored files; Glob does not:
Glob doesn't respect
.gitignoreby default, so it finds gitignored files alongside tracked ones. — Tools reference
A file search can surface what a content search cannot.
Sparse worktrees and cross-package access
--worktree starts a session in a new git worktree and by default checks out
the whole repository. worktree.sparsePaths uses git sparse-checkout to write
only the listed directories plus root-level files, so worktrees start faster and
cost less disk. Paths are relative to the repository root regardless of where
you launched.
{
"worktree": {
"sparsePaths": [".claude", "packages/api", "packages/shared"],
"symlinkDirectories": ["node_modules"]
}
}Root-level files such as package.json and lock files come along
automatically. Root-level directories do not, which makes the first entry the
one people leave out:
Root-level directories are not, so include
.claudein the list if you want the repository root's.claude/settings.json,.claude/rules/, or.claude/skills/available inside the worktree. — Monorepos and large repos
Omit it and the worktree has no repository-root .claude/ at all — no root
settings.json, no rules/, no root-level skills/ — and that root file is
exactly where a worktree session's project settings come from, as the next
section shows. symlinkDirectories points each worktree's node_modules/ at
the main copy rather than duplicating it. Worktree lifecycle — base branch, what
a fresh checkout is missing, what cleanup deletes — is Lesson 11.4; here they
are a disk-and-context setting only.
Sparse checkout leaves a mark on the repository itself. Git needs
extensions.worktreeConfig in the shared .git/config while a sparse worktree
exists, and Claude Code removes that entry once the last worktree goes — but
only the entry it added itself. A value you set by hand it never removes, so on
a repository where you enabled worktree config yourself the cleanup is still
yours. It did not always clean up at all: "Before v2.1.207, the entry remained
after the last worktree was removed, and go-git-based tools such as tea failed
to open the repository until you ran
git config --unset extensions.worktreeConfig"
(Monorepos and large repos).
When a tool that worked yesterday stops being able to open the repository after
you finish with sparse worktrees, that unset is the fix — reach for it before
looking for the change you made to the tool.
Reaching outside your starting directory has two doors, and they are not equivalent:
| Added with | File access | Loads CLAUDE.md and rules | Loads skills |
|---|---|---|---|
permissions.additionalDirectories | Yes | Never | Never |
--add-dir flag or /add-dir | Yes | Only with CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD set | Yes |
That table reads as a feature in both directions. additionalDirectories edits
packages/shared from packages/api without inheriting the shared package's
instructions; --add-dir picks up a sibling's skills when you want them. The
environment variable has no effect on directories listed in the setting.
The trap: settings do not layer the way memory does
CLAUDE.md layers down the tree, so it is a short step to assume
.claude/settings.json does too. It does not:
Project settings in
.claude/settings.jsonaren't inherited from parent directories the way CLAUDE.md files are. — Monorepos and large repos
Each subdirectory's settings file must stand alone. Deny rules written once at
the root do not reach a session started in packages/api/.
The second half is worse, because it moves the ground under a configuration that was working:
The
sparsePathsandsymlinkDirectoriessettings are read from your starting directory before the worktree is created. After creation, the session's working directory is the worktree root, not the subdirectory you launched from. — Monorepos and large repos
So deny rules living only in packages/api/.claude/settings.json stop applying
the moment that session works from a worktree, where project settings load from
the worktree root's copy of the repository root's file. The documented fix is a
second copy: permission rules and hooks you need inside worktrees go in the
repository root's .claude/settings.json, duplicated rather than inherited.
A related edge, since deny patterns are paths: a relative pattern such as
Read(./vendor/**) anchors at the session's current working directory, not at
the repository root (Permissions).
If you launch from several different subdirectories, write those rules with
//-absolute paths.
Prove it loaded
Every setting here is silent when it works and silent when it does not. The check is one command:
To confirm which files loaded, run
/contextand check the list under Memory files. — Monorepos and large repos
That list is the difference between "I excluded the other team's package" and
"the exclusion pattern matched". Exclusion patterns are matched against absolute
file paths, so a relative-style pattern needs a leading **/ to match anywhere
in the tree (Settings reference);
one written without it matches nothing and says nothing. Presence or absence in
the Memory files list is the only signal you get. Lesson 3.2 made /context
the instrument panel for the window. Here it is the acceptance test for your
configuration, run once after every edit to one of these files.
Checkpoint
-
You work almost entirely in
packages/api/, but today's change updates a type inpackages/shared/and every call site inapiandweb. Where do you start Claude, and what does that decision cost compared with the other option?Show answerAnswer
Start at the repository root. The change genuinely spans packages, which is the case the root is for: from there every file is readable and editable without a further grant. Starting in
packages/api/would give you that subtree only, andpackages/shared/andpackages/web/would each needpermissions.additionalDirectoriesor--add-dirbefore Claude could touch a call site.The cost is context and settings. Only the root
CLAUDE.mdloads at launch, but on-demand loading can then reach any package in the tree as Claude reads there, so conventions for the eight packages you never open can arrive uninvited — frompackages/api/that on-demand reach cannot leave the subtree. And where you launch determines which project settings apply; settings do not layer down the tree the wayCLAUDE.mddoes, so deny rules, sparse paths and anything else living inpackages/api/.claude/settings.jsonare not what governs a root session. -
A teammate adds
Read(./**/*.generated.*)to the repository root's settings and reports that Claude can no longer open generated files. You then watch it print the contents of one. Name two ways that happens without either of you being wrong.Show answerAnswer
Both of you are describing a rule that works — you watched the content come out of a door the rule does not guard. First: it was not a
Readcall. A deny rule is a check Claude Code runs before a tool call, so a Bash search such asgrep -rorfindover a directory that contains denied files still includes them in its output, and the check never sees the open that happens inside a subprocess a permitted command started.Readwas not bypassed;Readwas not what ran.Second: the rule was not in force in that session. Project settings in
.claude/settings.jsonare not inherited from parent directories, so a rule written once at the repository root does not reach a session started inpackages/api/. And the pattern is relative:Read(./**/*.generated.*)anchors at the session's current working directory, not at the repository root, so from another starting directory it can match nothing at all. Write such rules with//-absolute paths if you launch from several directories. -
Your
packages/api/.claude/settings.jsoncarries sparse paths and deny rules, and both work — until you run the same session with--worktreeand the deny rules stop applying. What changed, and which file has to hold them instead?Show answerAnswer
The working directory changed underneath the configuration.
sparsePathsandsymlinkDirectoriesare read from your starting directory before the worktree is created, which is exactly why the sparse checkout still came out right and misled you. After creation, the session's working directory is the worktree root, notpackages/api/— so project settings load from the worktree root's copy of the repository root's.claude/settings.json, and your deny rules only ever existed in the package's file.They have to be in the repository root's
.claude/settings.json, duplicated rather than inherited — that is the documented fix for permission rules and hooks you need inside worktrees. Two follow-ons: include.claudeinsparsePaths, or the worktree has no repository-root.claude/at all and the copy you just wrote is not there either; and if the patterns are relative, they now anchor at the worktree root, so use//-absolute paths. -
Which single command would have told you, before any of the above, that the file you edited had no effect at all?
Show answerAnswer
/context, reading the list under Memory files. It is the documented way to confirm which files loaded, and it is the only signal you get: every setting in this lesson is silent when it works and silent when it does not. That list is the difference between "I excluded the other team's package" and "the exclusion pattern matched".The silent no-op it is built to catch: exclusion patterns are matched against absolute file paths, so a relative-style pattern needs a leading
**/to match anywhere in the tree — written without it, it matches nothing and says nothing. Run/contextonce after every edit to one of these files rather than assuming the edit took.