打開一個 monorepo 的根目錄 CLAUDE.md,往下滾了十秒還沒到底。

裡面有後端的命名慣例、前端的元件規則、資料庫遷移的注意事項、五個團隊各自的 lint 偏好。寫的時候很有成就感,看起來什麼都交代到了。問題是你今天只想改 packages/api 裡的一個路由。那八百行裡有七百行跟這件事沒關係,而它們每個 session 開場就進了 context。

官方的說法比我客氣一點,但意思一樣。預設值是為小專案調的。repo 一大,那些預設會拿跟任務無關的指令和檔案讀取把 context window 填滿,燒掉 token,也讓 Claude 表現變差。

所以這篇要講的每一件事,都是同一個動作的新版本:從「盡量把知識塞給它」改成「只給它這次要用到的那一層」。設定與行為都對照官方 large-codebases 指南寫成,我沒有在百萬行的 repo 上實跑過整套,版本相關的行為我會標出來。

舊做法:靠一份檔案交代所有事

一份根目錄 CLAUDE.md 只有兩種結局。要嘛它長成百科全書,涵蓋每個子系統的慣例,代價是每次都付那份 context;要嘛它為了不要太長而寫得很通用,結果變成「請遵守良好的程式設計實務」這種沒人會照做的句子。

新做法是分層。根目錄那份只放跨整個 repo 都成立的東西:程式碼標準、commit 慣例、目錄結構。每個子目錄自己一份,只放那一區的技術棧慣例。

1
2
3
4
5
6
7
<!-- 根目錄 CLAUDE.md -->
This is a monorepo with three packages under packages/:
- packages/api: Node.js REST API with Express, TypeScript, PostgreSQL
- packages/web: React frontend with Vite, TailwindCSS
- packages/shared: shared TypeScript utilities

Run commands from the package directory, not the monorepo root.
1
2
3
4
5
<!-- packages/api/CLAUDE.md -->
- Run tests: `npm test` (uses Vitest)
- Database migrations: `npm run migrate`
API routes are in src/routes/. Database queries use Knex in src/db/.
Never write raw SQL strings in route handlers.

載入規則是這樣:啟動時載入工作目錄與每一層父目錄的 CLAUDE.md,子目錄的那些等 Claude 真的讀到那裡才按需載入。這些檔案要 commit 進 repo,讓同事一起繼承,而且照官方建議,改動走 PR review,把它當文件變更管。

順便提一個容易忘的維護動作:換大版本模型之後回頭檢查這些規則。當初為了繞開舊模型某個毛病寫的規則,例如強迫它一次只改一個檔案,在新模型上可能只剩額外成本,該刪就刪。

換個目錄啟動,載入的東西整組不一樣

這件事是全篇的地基,也是我看到最多人漏掉的一個槓桿。你在哪裡下 claude 這個指令,同時決定了三件事:它能碰哪些檔案、開場載入哪些 CLAUDE.md、哪一份專案設定生效。

從哪啟動 檔案存取 啟動時載入的 CLAUDE.md
repo 根目錄 每個檔案 只有根目錄那份,子目錄的等讀到才載
某個子目錄 只有那棵子樹,除非你另外授權 該目錄那份,加上所有祖先目錄的

想像新人第一天報到。把全公司十二個部門的作業規範一次塞給他,他不會變得更懂規矩,只會不知道哪條輪到自己。帶他去他要待的那一層,把那層的規則和公司級的規則給他,這才是分層載入在做的事。

接著是那個陷阱,官方文件寫得很明白,但一不小心就會踩:

.claude/settings.json 的專案設定只從你啟動的那個目錄載入,不會像 CLAUDE.md 那樣從父目錄繼承

換句話說,放在 repo 根的 .claude/settings.json,只有你從根目錄啟動時才生效。你在 packages/api/claude,那份根設定完全沒被讀進來。這也是為什麼官方範例會叫你把 deny 規則在根目錄再放一份。

.claude/settings.local.json 是唯一例外:從 v2.1.211 起,放在 repo 根的這一份,不管你從哪個子目錄啟動都會載入。v2.1.211 之前它也只從啟動目錄載入。

想在 monorepo 裡切目錄又不想每次重燒 prompt cache 的話,另一篇/cd 的文章有完整流程;如果你的情況是好幾個獨立 repo 而不是單一大樹,那要看的是Virtual Monorepo 那篇

有些 CLAUDE.md 你永遠不需要,就別讓它載

從 repo 根啟動時,只要 Claude 讀到某個子目錄的檔案,那個目錄的 CLAUDE.md 就會載進來。別的團隊的 package、legacy 程式碼、vendored 進來的第三方子樹,這些你一輩子不會改,它們的指令也一輩子不需要出現在你的 context 裡。

1
2
3
4
// .claude/settings.local.json
{
"claudeMdExcludes": ["**/packages/web/**"]
}

pattern 是 glob,比對的是絕對路徑,所以相對風格的寫法要用 **/ 開頭才會在整棵樹匹配。幾個常用變化:"**/packages/*/CLAUDE.md" 排掉每個 package 的但保留根的、"**/packages/legacy-*/**" 連 rules 一起排、也可以直接寫單一檔案的絕對路徑。

還有幾件事會咬人。這個清單可以放在 user、project、local、managed 任一層,陣列會跨 scope 合併,所以團隊設 project 層預設、個人在 local 層追加,兩邊會疊起來。managed policy 的 CLAUDE.md 排不掉,組織層指令永遠生效。還有,它是靜態清單,不是給你每天切換專注範圍用的開關。今天想專心弄 A 套件、明天換 B,該做的是從那個 package 目錄啟動,不是改這份清單。

.claude/settings.local.json 這個檔案,Claude Code 只有在它自己存設定進去的時候,才會幫你加進 global gitignore。你手工建立的那份要自己加,不然遲早 commit 上去。

我自己的用法是:claudeMdExcludes 一律寫在 local,不進版控。因為「哪些 package 我不碰」是我的工作範圍,不是團隊的共識,把它 commit 上去等於幫別人決定他該不該看前端的慣例。要進版控的是那種真的沒人該讀的東西,例如 vendored 進來的第三方子樹。

擋掉該擋的,剩下的讓它不必讀

指令只是 context 的一半,另一半是檔案讀取,而它會隨 repo 長大。

.gitignore 裡的路徑本來就不會進搜尋結果,node_modules/dist/build/ 這些不用你操心。要處理的是已經 commit 進去的東西:vendored SDK、產生出來的程式碼。

1
2
3
4
5
6
7
8
9
10
11
// .claude/settings.json
{
"permissions": {
"deny": [
"Read(./**/dist/**)",
"Read(./**/build/**)",
"Read(./**/*.generated.*)",
"Read(./vendor/**)"
]
}
}

deny 規則管得到內建檔案工具,也管得到可辨識的 Bash 檔案指令,catheadgrepfind 把被擋的路徑當參數傳進去時都算。它管不到兩件事:不會把被擋的路徑從遞迴搜尋的輸出裡濾掉,也不管自己開檔的任意子程序。所以它是省 context 的工具,不是資安邊界。

相對 pattern 錨定在你啟動的目錄。會從不同子目錄啟動的人,要在 .claude/settings.local.json 裡改寫成 // 開頭的絕對路徑,例如 Read(//absolute/path/to/repo/vendor/**)

擋讀取是防守,還有進攻的版本。Claude 為了找一個 symbol 定義在哪、被誰呼叫,官方的說法是會花掉「大量的檔案讀取與 grep」。code intelligence plugin 把這件事換成問 language server:

1
/plugin install typescript-lsp@claude-plugins-official

官方 marketplace 有 TypeScript、Python、Go、Rust 等常見語言。跳出 Marketplace "claude-plugins-official" not found,就先 /plugin marketplace add anthropics/claude-plugins-official。如果它改說 plugin 找不到,代表你本機那份 marketplace 太舊。跑 /plugin marketplace update claude-plugins-official 再重試一次。要全 repo 生效而不只是你自己,加到 enabledPlugins 專案設定。

前置條件別忽略:每台開發機都要裝該語言的 language server binary,而且從官方 marketplace 安裝需要連得到 GitHub。網路受限的環境要改成從內部 Git host 或本機路徑加 marketplace。

worktree 從整棵樹縮到三個目錄

--worktree 會在新的 git worktree 裡開 session,改動跟主 checkout 隔離。預設它 checkout 整個 repo,大 repo 就是慢加上佔硬碟,而且每個 worktree 一份 node_modules

1
2
3
4
5
6
7
// .claude/settings.json
{
"worktree": {
"sparsePaths": [".claude", "packages/api", "packages/shared"],
"symlinkDirectories": ["node_modules"]
}
}

sparsePaths 走 git sparse-checkout,路徑相對 repo 根,不管你從哪個子目錄啟動。symlinkDirectories 讓每個 worktree 的 node_modules/ symlink 回主 repo 那份,不重複佔空間。

三個容易踩的點。第一,只列目錄,不要列個別檔案。根層級的檔案例如 package.json、lock file 一定會 checkout,但根層級的目錄不會。所以你想在 worktree 裡用到 repo 根的 .claude/settings.json.claude/rules/.claude/skills/,就得把 .claude 自己列進去。這一條漏掉的話,worktree 裡的 Claude 會突然沒有你的技能和規則,而症狀看起來很像它變笨了。

第二,一個 session 裡所有 worktree 共用同一份 sparsePaths。這在subagent 跑在 worktree 裡的時候會咬人:A subagent 要 packages/api/、B 要 packages/web/,兩個都得列上去。

第三個是 git 副作用。sparse checkout 需要 git 在 repo 共用的 .git/configextensions.worktreeConfig。Claude Code 會在最後一個 worktree 移除後清掉它,但只清它自己加的,你手動設的它不動。v2.1.207 之前這個 entry 會殘留。go-git 系的工具例如 tea 會因此打不開 repo,得手動 git config --unset extensions.worktreeConfig 才救回來。

還有一個順序問題值得記:sparsePathssymlinkDirectories 是在 worktree 建立之前、從你的啟動目錄讀的。建立之後,session 的工作目錄就變成 worktree 根了。所以其他你想在 worktree 內生效的設定,權限規則、hooks 那些,要放在 repo 根.claude/settings.json。worktree 裡讀到的就是那份的 checkout 副本。

跨 package 有兩條路,而它們的行為不一樣

packages/api/ 啟動,你就只碰得到那棵子樹。改一個 shared 裡的共用型別加上所有呼叫端,就需要授權 sibling 目錄。兩條路:

1
2
// packages/api/.claude/settings.json
{ "permissions": { "additionalDirectories": ["../shared", "../web"] } }
1
claude --add-dir ../shared

檔案讀寫兩者都給,但該目錄的 CLAUDE.md、rules 與 skills 會不會載入,取決於你用哪一條路

加入方式 載入 CLAUDE.md 與 rules 載入 skills
additionalDirectories 設定 永不 永不
--add-dir flag 或 /add-dir 只有加下面那個環境變數才會
1
CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD=1 claude --add-dir ../shared

那個環境變數對 additionalDirectories 列出的目錄無效,只作用在 --add-dir 加進來的。相對路徑對照你啟動 Claude 的目錄解析。

這張表解釋了一個常見的困惑:明明給了 sibling package 的存取權,Claude 卻完全不知道那邊的慣例。因為你用的是設定檔那條路,而它設計上就不載指令,只給檔案權限。

兩條路我會這樣選:改共用型別這種「動到別人家」的任務,用 --add-dir 開一次性的 session,對方的 skills 會跟著進來,需要連 CLAUDE.md 和 rules 一起載就補上那個環境變數,改完就收;如果是每天都要碰的固定組合,才寫進 additionalDirectories。設定檔那條路的好處剛好是它不載 skills 與 rules,長期掛著也不會把 context 越養越肥。

常駐的知識,跟按需載入的知識

CLAUDE.md 的性質是常駐,skills 的性質是按需。同一份知識放在哪裡,成本差很多。

任何子目錄都能在自己的 .claude/skills/ 定義 skill:

1
mkdir -p packages/api/.claude/skills/api-testing
1
2
3
4
---
name: api-testing
description: Testing patterns for the API package. Use when writing or modifying tests in packages/api/.
---

Claude 在 packages/api/ 工作時載入這個,在 packages/web/ 工作時載入那邊的 component-patterns,互不干擾。不想按目錄擺、想按檔案樣式綁的話,用 paths: frontmatter 給 glob,例如一個資料庫遷移的 skill 綁 **/migrations/**,放在 repo 根也只在碰到匹配檔案時才載。

有一個規模問題要早點知道。Claude 是讀完每個被發現的 skill 的名稱與描述才挑,只有被挑中的那個才載入全文。而在場的 skill 有幾個,看你從哪啟動:

從子目錄啟動,是該目錄加上每一層父目錄到 repo 根,再加 user 與 enterprise 層。從 repo 根啟動,是根目錄的 skills,加上 session 期間 Claude 碰到的每個子目錄的 skills,可能累積到數百個。用 --add-dir 加進來的 sibling,它的 skills 也會載。

名稱一定會載入,但描述在數量多的時候會被截短,可能剛好砍掉 Claude 用來判斷的那幾個關鍵字。所以描述要短,而且把使用者請求裡真的會出現的字放最前面。官方範例的寫法是「writing or modifying tests in packages/api/」。不要寫成「本套件的測試相關最佳實務彙整」那種,關鍵字全在後半段,一被截就沒了。

想知道哪些 skill 根本沒人用,就開 OpenTelemetry 的 logs exporter。再設 OTEL_LOG_TOOL_DETAILS=1,讓 skill 名稱原文記錄而不是被遮蔽。skill_activated 事件的 skill.name 記每一次呼叫,invocation_trigger 記是指令、Claude 自己、還是巢狀 skill 觸發的。兩天前那篇接 OpenTelemetry 的教學有 exporter 的完整設定,這裡只是多開一個變數。

再往上一層,當 per-directory 檔案多到沒人管得動、慣例開始漂移,官方的建議是把東西搬出常駐的 CLAUDE.md。可以變成 skills,相關時才載;變成 plugin,讓平台團隊集中版控,plugin-name:skill-name 的 namespace 不會撞名;或者變成 MCP server,公司已經有 code search 或 RAG index 的話,就開成工具讓 Claude 查,不要讓它讀檔。

還有一個補洞的小招。搭一個 SessionStart hook 讀啟動目錄,查一份 commit 在 repo 裡的「路徑對應 plugin」表,把建議印到 stdout。新同事在不熟的區域啟動,第一句回覆就會被告知該裝哪個 plugin。

怎麼確認這些設定真的生效

一個指令:/context,看 Memory files 那一段,它會列出這個 session 實際載入了哪些 CLAUDE.md。改完 claudeMdExcludes 或換了啟動目錄之後,這是唯一能證明設定生效的地方,不要靠感覺判斷。

把整套組起來的樣子,官方文件的 packages/api/.claude/settings.json 範例是這樣:

1
2
3
4
5
6
7
8
9
10
{
"worktree": {
"sparsePaths": [".claude", "packages/api", "packages/shared"],
"symlinkDirectories": ["node_modules"]
},
"permissions": {
"additionalDirectories": ["../shared"],
"deny": ["Read(./**/dist/**)", "Read(./**/build/**)"]
}
}

packages/api/ 啟動的話,claudeMdExcludes 在這裡反而不需要,因為 sibling package 的 CLAUDE.md 本來就不在範圍內。它該放的地方是 repo 根的 .claude/settings.local.json,給你偶爾從根目錄啟動的那些 session 用。deny 規則則要在根目錄再放一份,worktree session 才吃得到。

這個轉變真正改變的是什麼

問「Claude 應該知道多少」,你會被推去寫更長的 CLAUDE.md、授權更多目錄、塞更大的 context,而每一步都在稀釋訊號。問「Claude 這次要用到哪一層」,你會被推去分層、排除、按需載入,而這些剛好也讓 token 帳單變小。省錢跟準確第一次站在同一邊。

真的要挑一件今天就做的,選啟動目錄。不用改任何設定檔,cd 到你這次要動的那個 package 再下 claude,然後跑 /context 對照一下 Memory files 少了什麼。看到差別之後再回來設 claudeMdExcludes,你會更清楚該排掉哪些。

參考來源:Set up Claude Code in a monorepo or large codebaseCode intelligence pluginsExclude specific CLAUDE.md filesWorktree settings