Large codebases and monorepos
Keep Claude Code focused in big repositories with layered CLAUDE.md files, read deny rules, code intelligence, sparse worktrees, scoped skills and plugins.
Claude Code copes with repositories of any size, but the defaults are tuned for small ones. In a large tree the context window fills with instructions for subsystems you are not touching and file reads that turn out to be irrelevant. That costs tokens and makes the answers worse. The fix is to scope Claude to the part of the codebase the task actually involves.
Everything on this page is independent; adopt whichever pieces fit. For each one I note whether it belongs in a committed file or stays personal to your machine. For general context-saving habits (such as pushing exploration into a subagent), see best practices; for rolling out a baseline to a whole organisation, see admin setup.
The toolkit at a glance
| Problem | Tool |
|---|---|
| One giant root CLAUDE.md that covers every subsystem | Per-directory CLAUDE.md files |
| CLAUDE.md files from packages you never touch | claudeMdExcludes |
| Claude opening build output, generated or vendored code | Read rules in permissions.deny |
| Grepping the whole tree to find a definition | A code intelligence plugin |
| Worktrees that check out the entire repository | worktree.sparsePaths |
| Working across sibling packages or other repos | --add-dir or additionalDirectories |
| Area-specific procedures that should load only when relevant | Per-directory skills |
| Dozens of drifting CLAUDE.md files | A plugin from an internal marketplace |
A running example
The examples use this layout. For a single-tree codebase, read packages/billing/ as src/billing/ or whatever your subsystem directory is.
platform/
CLAUDE.md
packages/
billing/
CLAUDE.md
.claude/skills/
src/
dashboard/
CLAUDE.md
.claude/skills/
src/
core/
CLAUDE.md
src/
Where you start Claude matters
The directory you launch claude from decides three things: which files Claude can touch without extra permission, which CLAUDE.md files load at launch, and which project settings apply.
| Launch from | File access | CLAUDE.md at launch | Best for |
|---|---|---|---|
| Repository root | Everything | Root only; subdirectory files load on demand | Work that crosses packages |
| A subdirectory | That subtree, until you grant more | That directory's plus every ancestor's | Work inside one package |
Note:
.claude/settings.jsonis not inherited from parent directories the way CLAUDE.md is. Each starting directory reads its own. See settings for exactly where Claude Code looks.
I default to starting in the package I am working on, and only drop to the root for genuinely cross-cutting changes.
Layered CLAUDE.md files
A single root file either bloats to cover everything or stays too vague to help. Split it. Claude Code loads every CLAUDE.md from the working directory and its ancestors at launch, and picks up subdirectory files on demand.
Two levels usually suffice: a root file with rules that apply everywhere, and one file per package or subsystem with that area's specifics. Commit them, and let each area's owners maintain their own.
Root CLAUDE.md:
Use pnpm workspaces; run scripts with `pnpm --filter <package>`.
Commit subjects start with the package name, e.g. `billing: handle zero-value invoices`.
Do not hand-edit anything under packages/*/src/__generated__/. Run `pnpm codegen` instead.
packages/billing/CLAUDE.md:
Money is always integer pence. Never use floats for amounts.
Stripe calls go through src/gateway/stripe.ts; do not import the SDK elsewhere.
Every new webhook handler needs an idempotency test.
Start in packages/billing/ and Claude sees both files and nothing from dashboard. Run /context and check Memory files to confirm what loaded. If a checked-in file has grown too big, /doctor will help trim it.
Keeping them honest over time:
- Review CLAUDE.md changes in pull requests like any other docs.
- Revisit after a major model release. Workarounds for an older model's weaknesses become dead weight.
- Add a
Stophook that receives the transcript path and proposes CLAUDE.md edits while the gap is fresh.
Per-directory CLAUDE.md or path-scoped rules?
Per-directory CLAUDE.md | Rule in .claude/rules/ | |
|---|---|---|
| Lives | Next to the code | Centrally in the root .claude/ |
| Loads | At launch from that directory, or on demand | When Claude works on a file matching the rule's paths: glob |
| Choose when | Owners maintain their own area | You want everything in one place, or a rule spans scattered paths |
Features overview compares these with skills too.
Excluding CLAUDE.md files
From the root, any subdirectory's CLAUDE.md might load during a session. claudeMdExcludes skips files by path or glob so they never load. It is a static list, good for other teams' code, legacy areas and vendored subtrees. For "today I am in billing, tomorrow dashboard", change starting directory instead.
For a personal list, use .claude/settings.local.json. Claude Code adds that file to your global gitignore when it writes a setting there, but if you create it by hand, gitignore it yourself. Patterns are globs matched against absolute paths, so begin relative-looking ones with **/.
{
"claudeMdExcludes": ["**/packages/dashboard/**"]
}
That skips every CLAUDE.md and rules file under dashboard while the root and other packages load normally. Other handy patterns:
"**/packages/*/CLAUDE.md": every package file, keeping the root."**/packages/deprecated-*/**": whole packages matching a name pattern, rules included."/Users/cam/code/platform/legacy/CLAUDE.md": one file by absolute path.
Managed policy CLAUDE.md files can never be excluded. claudeMdExcludes works in user, project, local or managed settings, and arrays merge across scopes, so a team default and personal additions combine. More in memory.
Cutting down file reads
Deny reads of generated and vendored code
Content search already respects .gitignore, so node_modules/, dist/ and the like are out of results without effort. For checked-in noise, such as committed generated clients or a vendored SDK, add Read deny rules:
{
"permissions": {
"deny": [
"Read(./**/dist/**/*)",
"Read(./**/coverage/**/*)",
"Read(./**/*.pb.ts)",
"Read(./**/third_party/**/*)"
]
}
}
Ending directory patterns in /**/* rather than /** blocks the contents but not the directory itself, so Claude can still ls dist or cd into it.
Where to put the rules:
- Whole team: commit to
.claude/settings.json, at the root if you start there or in each package's.claude/if you start in packages (remember, no inheritance). - Just you:
.claude/settings.local.jsonat the repository root. Since v2.1.211 it loads in every CLI session inside the repo, whatever the starting directory (with exceptions such as Windows, where the repository root is not used). Relative patterns still anchor at the session's working directory, so if you start in subdirectories write them as//-absolute paths, such asRead(//Users/cam/code/platform/**/third_party/**/*). - Enforced for everyone: managed settings.
Coverage: deny rules apply to the built-in file tools, to Bash file commands Claude Code recognises (cat, head, grep, find and so on) when a denied path is an argument, and to redirection targets like < file. Built-in Grep and Glob make a best effort to omit denied paths. A recursive Bash search over a directory containing denied files still shows them, and subprocesses that open files themselves are not covered. Full syntax is in permissions.
Code intelligence
Hunting a symbol's definition and callers by grep burns reads fast. A code intelligence plugin connects Claude to a language server so it can jump to definitions, find references and see type errors directly. The official marketplace has plugins for TypeScript, Python, Go, Rust and more:
/plugin install typescript-lsp@claude-plugins-official
If the marketplace is missing, run /plugin marketplace add anthropics/claude-plugins-official first. In VS Code or the desktop app, use the normal plugin install flow. To enable it for the whole repository, add it to the enabledPlugins project setting.
Each developer needs the language server binary installed. The official marketplace is hosted on GitHub, so restricted networks should add it from an internal Git host or local path. Code intelligence pairs naturally with exclusions and deny rules: those keep junk out, and the language server stops Claude reading through what remains.
Worktrees and cross-package access
Sparse worktrees
--worktree starts a session in a fresh git worktree, and by default that is a full checkout. worktree.sparsePaths uses git sparse-checkout to write only the listed directories plus root-level files:
{
"worktree": {
"sparsePaths": [".claude", "packages/billing", "packages/core"],
"symlinkDirectories": ["node_modules"]
}
}
Points to know:
- Paths are relative to the repository root, whichever directory you start in. List directories, not files.
- Root-level files (
package.json, lock files, base tsconfig) always come along; root-level directories do not, so include.claudeif you want the root's settings and rules inside the worktree. - All worktrees in a session share the list, which matters for subagents using worktree isolation: if one needs
billingand anotherdashboard, list both. - Commit shared paths in
.claude/settings.json; add personal ones in.claude/settings.local.json. The lists merge, so local can add paths but not remove them. symlinkDirectorieslinks each worktree'snode_modules/back to the main checkout instead of duplicating it.- Sparse checkout needs
extensions.worktreeConfigin the shared.git/configwhile a sparse worktree exists. Claude Code removes it after the last worktree goes, but only if it added it. Before v2.1.207 it was left behind, which broke go-git tools such asteauntil you rangit config --unset extensions.worktreeConfig.
Note:
sparsePathsandsymlinkDirectoriesare read from your starting directory before the worktree exists. Afterwards the session's working directory is the worktree root, so project settings come from the worktree's copy of the root.claude/settings.json. Put permission rules and hooks you need inside worktrees in the root file.
See the settings reference for every worktree key.
Reaching sibling packages or other repos
Starting in packages/billing/ limits Claude to that subtree. When a change needs a sibling (say a shared type in core), grant access. In packages/billing/.claude/settings.json:
{
"permissions": {
"additionalDirectories": ["../core"]
}
}
Relative paths resolve from the starting directory. For a one-off, pass a flag instead:
claude --add-dir ../core
Both give read and edit access. They differ in what else loads:
| Added via | CLAUDE.md and rules | Skills |
|---|---|---|
additionalDirectories setting | Never | Never |
--add-dir or /add-dir | Only with CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD=1 | Yes |
CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD=1 claude --add-dir ../core
The environment variable does nothing for additionalDirectories. Commit the setting for siblings everyone needs; use local settings or --add-dir for personal or one-off access.
Per-directory skills
Any directory can carry skills in its own .claude/skills/, committed with the code. They load on demand, so billing tooling costs nothing during dashboard work.
mkdir -p packages/billing/.claude/skills/billing-tests
packages/billing/.claude/skills/billing-tests/SKILL.md:
---
name: billing-tests
description: Writing or changing tests in packages/billing. Fixtures, Stripe mocks and money assertions.
---
## Layout
Tests sit beside the code as `*.spec.ts`. Integration tests live in `test/integration/`.
## Commands
- Unit: `pnpm --filter billing test`
- One file: `pnpm --filter billing test src/invoices/totals.spec.ts`
- Integration (needs Docker): `pnpm --filter billing test:int`
## Helpers
- `test/fixtures/customers.ts`: `ukCustomer()`, `euCustomer({ vatId })`
- `test/mocks/stripe.ts`: `mockStripe()` returns a fake with recorded calls
## Rules
- Assert money with `expectPence(actual, expected)`, never raw equality on floats.
- Each test seeds its own data inside a rolled-back transaction.
packages/dashboard/.claude/skills/ might hold a component-conventions skill instead. Work in billing and only billing's skills are candidates.
To scope by file pattern rather than location, use the paths frontmatter field with globs. A migrations skill in the root .claude/skills/ with paths set to **/migrations/** loads only when Claude touches migration files.
Keeping the skill list manageable
Claude chooses a skill by reading every discovered skill's name and description, then loads just the winner. Which skills are discovered depends on where you start:
- From a package: that directory, every ancestor up to the root, plus user and enterprise skills.
- From the root: root skills plus those of every subdirectory Claude touches, which can reach hundreds.
- With
--add-dir: the added directory's skills too (not withadditionalDirectories).
Names always load, but with many skills some lose their descriptions entirely, removing the keywords Claude matches on. Keep descriptions short and lead with the words a request would contain.
Put widely shared skills (PR conventions, deploy checklists) in the root .claude/skills/. If they need versioning or must work across repositories, package them as a plugin; plugin skills are namespaced plugin-name:skill-name so they never clash.
To find dead skills, enable the OpenTelemetry logs exporter with OTEL_LOG_TOOL_DETAILS=1 so names are recorded unredacted. The skill_activated event carries skill.name, and invocation_trigger tells you whether a command, Claude or another skill triggered it. See monitoring usage.
When layering stops scaling
At some size, per-directory files drift and nobody owns the root. That is a job for whoever maintains the repository's Claude Code setup. Move content out of always-loaded CLAUDE.md into things that load on demand:
- Skills for reference material.
- Plugins for versioned bundles of skills, hooks and commands owned by a platform team.
- MCP servers to expose an existing code search or RAG index, so Claude queries it rather than reading files.
Server-managed settings covers enforcing these centrally.
Pointing people at the right plugin
Someone starting Claude in an unfamiliar area has no idea which plugin its owners maintain. A SessionStart hook fixes that: plain stdout from it lands in Claude's context before the first prompt. Have a script read cwd from the hook input, look it up in a committed path-to-plugin map, and print a recommendation for Claude to pass on. The hooks guide shows how to register it.
Putting it together
Committed packages/billing/.claude/settings.json (self-contained, since there is no inheritance):
{
"worktree": {
"sparsePaths": [".claude", "packages/billing", "packages/core"],
"symlinkDirectories": ["node_modules"]
},
"permissions": {
"additionalDirectories": ["../core"],
"deny": ["Read(./**/dist/**/*)", "Read(./**/coverage/**/*)"]
}
}
Starting in packages/billing/ already keeps sibling CLAUDE.md files out of scope, so claudeMdExcludes is unnecessary here. Add it to the root's .claude/settings.local.json if you also start from the root.
Inside a worktree created from this session, the working directory is the worktree root and this file does not load. Siblings are reachable there anyway, but the deny rules need a copy in the root .claude/settings.json:
{
"permissions": {
"deny": ["Read(./**/dist/**/*)", "Read(./**/coverage/**/*)"]
}
}
Final shape:
platform/
CLAUDE.md
.claude/settings.json # deny rules for worktree sessions
packages/
billing/
CLAUDE.md
.claude/settings.json # worktree, additionalDirectories, deny
.claude/skills/billing-tests/SKILL.md
dashboard/
CLAUDE.md
.claude/skills/ui-conventions/SKILL.md
core/
CLAUDE.md
Starting in packages/billing/ you now get: root and billing CLAUDE.md (not dashboard's), read/write on billing and core, no reads of dist/ or coverage/, the billing-tests skill on demand, and lightweight worktrees with deny rules applied from the root file.
Changes that cross packages
Configuration controls what Claude sees; how you hand over the task matters too.
- Do the whole change in one session. Give Claude the shared edit and every call site together so its decisions stay consistent rather than being re-derived per package.
- Plan first. Use plan mode so Claude writes the plan to a file. Long sessions compact (see context window), and Claude Code re-injects the plan file after each compaction, so the plan outlives the conversation history.
Beyond that, add per-directory linters or type-checkers as hooks, and read costs before a wide rollout, since codebase size drives token usage.