Skip to content

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

ProblemTool
One giant root CLAUDE.md that covers every subsystemPer-directory CLAUDE.md files
CLAUDE.md files from packages you never touchclaudeMdExcludes
Claude opening build output, generated or vendored codeRead rules in permissions.deny
Grepping the whole tree to find a definitionA code intelligence plugin
Worktrees that check out the entire repositoryworktree.sparsePaths
Working across sibling packages or other repos--add-dir or additionalDirectories
Area-specific procedures that should load only when relevantPer-directory skills
Dozens of drifting CLAUDE.md filesA 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 fromFile accessCLAUDE.md at launchBest for
Repository rootEverythingRoot only; subdirectory files load on demandWork that crosses packages
A subdirectoryThat subtree, until you grant moreThat directory's plus every ancestor'sWork inside one package

Note: .claude/settings.json is 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 Stop hook 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.mdRule in .claude/rules/
LivesNext to the codeCentrally in the root .claude/
LoadsAt launch from that directory, or on demandWhen Claude works on a file matching the rule's paths: glob
Choose whenOwners maintain their own areaYou 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.json at 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 as Read(//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 .claude if 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 billing and another dashboard, 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.
  • symlinkDirectories links each worktree's node_modules/ back to the main checkout instead of duplicating it.
  • Sparse checkout needs extensions.worktreeConfig in the shared .git/config while 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 as tea until you ran git config --unset extensions.worktreeConfig.

Note: sparsePaths and symlinkDirectories are 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 viaCLAUDE.md and rulesSkills
additionalDirectories settingNeverNever
--add-dir or /add-dirOnly with CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD=1Yes
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 with additionalDirectories).

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.