今天要解的問題
Day8、Day9 把 vault 從 iCloud 搬到 git,當時寫進 CLAUDE.md 的「git 工作紀律」防的是一件事:Mac 和 Windows 兩台機器往同一個檔案寫,靠「開工先 git pull」和「log 切成月檔」把衝突面壓小。那套規則的前提是一台機器一個人在寫。
這個前提這幾天已經不成立了。實際的用法變成同一台電腦同時開兩三個 Claude session,全部指向 E:\Projects\llm-wiki 同一個目錄:一個在跑藝評、一個在整理 others/、一個在改 CLAUDE.md。
9 月 23 日就撞上了一次。當時一個 session 剛做完 others/ 的扁平化(撤掉三個子域),正在跑 /vault-maintain others 驗證,工作區的變更突然整批不見。查 git log 才發現:另一個 session 把這些變更 commit 掉了,一筆是扁平化本身、一筆是它自己的睡眠 raw 筆記。
逐項對過之後沒有資料遺失:others/index.md 的條目只有一份、log/2026-09.md 三筆紀錄都在、那筆 commit 的 21 個檔案剛好是扁平化的範圍,沒夾帶別的。但沒出事的理由很脆弱——兩邊都用 append 寫 log,index 的修改時間又剛好錯開。換成兩邊都「讀整檔 → 改 → 寫回整檔」,就會有一邊的編輯無聲消失。
這才是問題的本體:共用同一個工作目錄時,git 幾乎不提供保護。 大家踩的是同一份檔案,不會有 merge conflict,只有 last-write-wins。跨機同步至少還有 conflict marker 當安全網,同機並行連那個都沒有。

想法與取捨
不禁止並行,而是分兩條路處理
最省事的做法是規定「一次只開一個 session」。沒走這條,因為並行已經是實際的用法,規定一個不會被遵守的規則等於沒規定。
剩下兩條路,今天兩條都動了:
| 路線 | 衝突的形式 | 代價 |
|---|---|---|
| 共用工作區+協定 | 靜默覆蓋 | 幾乎全靠紀律 |
| 每個 session 一個 worktree | merge conflict | 合併時要處理,log 必撞 |
第一條是寫一份協定進 CLAUDE.md,承認 git 擋不住,把能擋的東西列出來。第二條是讓每個 session 各自有自己的目錄和分支(本機用 git worktree,雲端 session 本來就是推一條分支回來),衝突就從「靜默覆蓋」變回「合併時大聲停下來」。
兩條不互斥。worktree 是比較好的形狀,但不是每次開 session 都會記得開;協定是沒開 worktree 時的下限。
協定的第一版:寫錯了唯一的硬護欄
協定第一版有八條:開工宣告領域、改既有檔用 Edit 不用 Write、log 只用 shell append、commit 只帶自己碰過的路徑、看到不是自己改的檔放著不動、pull/stash/reset 這類操作要獨佔、--apply 批次修復要獨佔、撞到 index.lock 等待不要刪。
其中第二條被當成全篇唯一有工具背書的護欄:Claude Code 的 Edit 要求先讀過檔案,而且 harness 會追蹤檔案狀態,別的 session 在你讀完之後改過同一檔,Edit 會直接失敗。第一版的寫法是「Write 會繞過這層保護」。
收尾前對整份協定跑了一次 review,回來 7 個問題,最嚴重的一個是這條的前提錯了兩處:
- Write 一樣要求先讀過。它的問題不是「繞過」,是整檔替換——成功的那一次會把你沒看過的併發編輯一起丟掉。
- 真正完全沒有護欄的是用 Bash 改檔:
sed、heredoc、cat >、python 腳本。它不觸發 harness 的狀態檢查,也不觸發 vault 自己的 PreToolUse hook,因為 hook 的 matcher 只寫了Write|Edit。
第二點的影響是整份協定等於落空:這個 vault 的日常操作是 Bash 優先的(改檔常常就是一行 sed 或一段 heredoc),所以協定承諾的「唯一硬護欄」在實際操作裡從來不會啟動。
同一輪 review 還指出另一個結構問題:第 5、6、7 條都要求 agent 分辨「哪些 dirty 檔是我的、哪些是別人的」,但沒給它任何判斷依據,context 壓縮之後更是完全分不出來。
7 個問題修了 4 個,沒修的其中兩個留在「之後可能遇到的問題」那段。
log 用 union,其他檔案不用
worktree 那條路有一個必然的代價:log/YYYY-MM.md 是 append-only 的月檔,每個分支都往檔尾加一筆,合併時一定撞在同一個位置。這不是偶發,是每次必撞。
git 內建的 union merge driver 就是為這種檔案存在的:兩邊新增的行都保留,不停下來問。
只套在 log/*.md,不擴到 wiki 頁、index、TODO。union 不做語意判斷,內容頁要是兩邊改了同一句,它會把兩個版本都塞進去而且不報錯——把響亮的衝突換成安靜的錯誤,正好是整件事想避免的方向。
實作
規則 1:baseline 差集
review 指出「分不出誰的變更」之後,補上的機制很簡單:session 起手第一件事,把當下的 git status --porcelain 存下來。
git status --porcelain > "$SCRATCHPAD/git-baseline.txt"
$SCRATCHPAD 在這裡只是示意。第一版協定裡真的寫了一個 $CLAUDE_SCRATCHPAD 環境變數,review 時才發現那是自己編出來的、根本不存在;改成「每個 session 的環境說明會給出實際路徑」,路徑由 session 自己帶進來。檔案一定放在 vault 外面,因為 vault 根目錄禁止寫這類雜檔。
收尾要 commit 時,判準就變成可以執行的東西:
git status --porcelain − git-baseline.txt = 這次 session 造成的變更
baseline 裡本來就有的檔,一律不碰、不順手 commit。這篇文章的 session 開頭存的 baseline 就有兩行——.obsidian/plugins/obsidian-git/data.json 的一個設定變動、Inbox 一份還沒讀完的書摘——都不是這次的工作,所以都不會出現在這次的 commit 裡。
規則 2:三種改法,三種護欄強度
重寫後的規則 2 不再是「Edit 好、Write 壞」,而是三段對照:
2. **跨 session 期間改既有檔一律用 Edit 工具。** 三種改法的護欄強度完全不同,別搞混:
- **Edit**:要求先讀過,且 harness 追蹤檔案狀態——別的 session 在你讀完後動過同一檔,Edit 會**失敗擋下來**。而且它是錨點比對,錨點被改動時一定大聲失敗。這是本節唯一有工具背書的護欄。
- **Write**:同樣要求先讀過(未讀過就覆蓋既有檔會失敗),但它是整檔替換——**成功的那次**會把你沒看過的併發編輯一起丟掉。只用來建新檔。
- **Bash 改檔(`sed` / heredoc / `cat >` / python 腳本)**:**完全沒有護欄**。不觸發 harness 的狀態檢查,也不觸發本 vault 的 PreToolUse hook(`.claude/settings.json` 的 matcher 是 `"Write|Edit"`,Bash 不在內)。單 session 時這是效率選擇,多 session 並行時這是靜默覆蓋的主要來源。
例外只有一個:`log/YYYY-MM.md` 走 shell append(見下條),因為 append 的碰撞面比整檔讀寫小得多。
「Bash 不在內」指的是這段 hook 設定:
"PreToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "sh \"$CLAUDE_PROJECT_DIR/.claude/hooks/dispatch-pretooluse.sh\""
}
]
}
]
Day8 講過這支 hook 會物理擋下「根目錄寫 .py」「wiki 頁缺 summary」這類錯誤。它擋得很確實,但只擋走 Write 和 Edit 進來的寫入。同一件事用 cat > wiki/xxx.md 做,hook 完全看不到。
規則 6、8:把「不准」改成「什麼條件下可以」
第一版有兩條規則寫得太死,照做會卡住。
規則 6 原本把「切分支」整個列為需要獨佔的操作,但在 main 上要開一條新分支是日常流程。修正是把 checkout -b 拆出來(節錄):
6. **會改寫工作區的 git 操作需獨佔**:`git pull`、`git stash`、`git checkout -- .`、`git reset --hard`、`git checkout {既有分支}`。這些會直接改掉別人手上的檔案,而對方完全不知情。
- **`git checkout -b {新分支}` 不在此列**——它在 HEAD 建分支、不動工作區也不動未 commit 的變更,隨時可做。
- 「獨佔」的可操作判準:baseline 為空(工作區只有你自己的東西),或你已向使用者確認沒有其他 session 在跑。兩者都不成立時**不要自己硬上**,改為回報並問人。
規則 8 原本是「撞到 index.lock 就等,不准刪」。問題是 session crash 或被強制關掉時留下的 stale lock 不會自己消失,無條件禁刪等於 vault 的 git 永久卡死、沒有逃生口。改成有條件的刪:等超過一分鐘還在 → 查 lock 檔的 mtime → 確認系統上沒有 git 進程,兩者都成立才刪,並且在回報裡寫明原因。
log 月檔的 union
.gitattributes 加三行:
# log 月檔是 append-only 清單:多分支(worktree)各自往檔尾加時,
# 合併直接保留兩邊、不產生 conflict marker。只適用純追加檔,勿擴及內容頁。
log/*.md merge=union
加之前先在一個臨時 repo 驗過:兩條分支各往檔尾加一筆,merge 無衝突、兩筆都保留。再用 git check-attr 確認 TODO.md 沒被波及,是 unspecified。
踩到的坑
union 不保證時序:新條目被放回分叉點
worktree 那條分支合回 main 的時候,log 確實沒出 conflict marker,但合併結果長這樣(節錄 --remerge-diff):
- 未改:`engineer/wiki/llm-wiki-architecture-blog.md` 等已發布文章中的舊結構描述
-## [2026-09-25] update | .gitattributes 加 log 月檔 merge=union
-- `log/*.md merge=union`:worktree/多分支各自往 log 檔尾追加時,合併保留兩邊…
-- 僅限 append-only 的 log;內容頁不適用…
-- 已在 scratch repo 驗證:兩分支各加一筆 → merge 無衝突、兩筆皆保留
## [2026-09-23] update | CLAUDE.md 新增「多 session 同機協作」協定
union 的行為是:兩邊在同一個位置新增的東西,依序堆在那個位置。「那個位置」是兩條分支分叉時的檔尾,不是合併當下的檔尾。所以這條分支 09-25 寫的紀錄,被塞到了另一邊 09-23 那筆紀錄的前面,時序倒過來。
兩個細節:union 把兩邊共同的尾端空行去重了,所以兩筆紀錄之間少了一行空行(上面 - 開頭那段結束後直接接 ##);而且這不算錯,git 不會提示任何東西。最後是手動把這條分支的 09-25 條目移到檔尾,在合併 commit 的訊息裡寫明「log 由 union 自動合併,手動把本分支的 09-25 條目移至檔尾以維持時序」。
對 log 來說,順序錯了不會壞東西,但「append-only、時序往下讀」是這個檔案唯一的結構約定,每次合併都要人工檢查一次。
設定改了,舊分支吃不到
同一天還有一條雲端 session 的分支(藝評那條),是在加 union 之前就分出去的。它合入最新 main 的時候,log 照樣出了真的 conflict marker,合併 commit 的訊息裡留著:
# Conflicts:
# log/2026-09.md
回頭看那條分支最後一個 commit 的 .gitattributes,結尾只有:
*.svg text eol=lf
*.pdf binary
*.sqlite binary
沒有 log/*.md merge=union 那行。合併時 git 看的是當下工作區(也就是這條分支自己)的 .gitattributes,它不知道 main 上已經有了新規則。解法只能是手動解一次衝突;解完之後,這條分支已經帶進了新的 .gitattributes,之後的合併才會吃到 union。
換句話說:改 .gitattributes 對「已經在外面跑的分支」沒有追溯力。 在並行的情境下,這類改動要生效,得等所有進行中的分支都先把 main 合一次進去。
之後可能遇到的問題與走法
以下是今天處理完之後還看得到的缺口。前兩項是 review 明確列出、這次決定不修的;其餘是整理這篇時對照 repo 現況發現的。都還沒做,列出來的是可能的走法,不是結論。
1. 「宣告領域」沒有任何跨 session 的頻道
規則 1 要 session 開工先宣告「我這次會動 {域}/ 與 log/」,但各 session 的對話互相看不到,這句話只有在場的人看得到。修正後的協定已經把這個限制寫明(「宣告是講給使用者聽的」),但機制缺口還在。
可能的走法:
- claim 檔:session 開工時在
.git/底下(不進版控、也不算 vault 內容)寫一個session-claims/{id}檔,內容是要動的路徑;其他 session 起手時讀這個資料夾,看到重疊就先問人。問題是 crash 時 claim 不會被清掉,會跟 stale lock 一樣需要過期判準。 - 直接預設開 worktree:宣告的需求本身消失,衝突交給合併時的 git 處理。這是比較根本的走法,但前提是「每次都記得開 worktree」這件事也得有人或工具來保證。
2. 協定的標題只寫了 Windows 路徑
章節標題寫的是「共用 E:\Projects\llm-wiki 這一個工作目錄」。在 Mac 上跑的 session 讀到這句,有可能判定這節跟自己無關。走法很直接:標題改成不綁路徑的寫法,例如「共用同一個 vault 工作目錄」,路徑放在括號裡當例子。
3. Bash 改檔依然沒有護欄
規則 2 修正後只是寫清楚了 Bash 沒有護欄,並沒有讓它有護欄。可能的走法:
- 把 PreToolUse 的 matcher 擴到 Bash,在 hook 裡用字串比對抓
sed -i、> {vault 路徑}、cat >這類寫入。代價是誤判多:Bash 指令千變萬化,寫在 python 腳本裡的寫入根本看不到。 - 接受它,改在收尾端擋:commit 前一律用 baseline 差集檢查一次,差集裡出現自己沒打算動的檔就停下來。這不能防止覆蓋發生,但至少覆蓋不會被 commit 掉而無人察覺。
4. Obsidian 本身也是一個「session」
整理這篇時翻了 obsidian-git 外掛的設定:
"autoPullInterval": 30,
"autoPullOnBoot": true,
也就是只要 Obsidian 開著,它每 30 分鐘會自己跑一次 git pull,開啟時也會跑一次。規則 6 把 git pull 列為需要獨佔的操作,但 Obsidian 不讀 CLAUDE.md。如果 pull 帶進來的變更碰到某個 session 正在改的檔,對那個 session 來說就是一次「讀完之後檔案被別人改了」。
這種情況下 Edit 會擋下來(這正是它的價值),但 Bash 改檔不會。可能的走法:並行開多個 session 時把 auto pull 關掉、改成手動;或者在協定裡把 Obsidian 明列為一個永遠在跑的參與者,讓「確認沒有其他 session 在跑」這個判準把它算進去。
5. union 之外的熱點檔
union 只套在 log 上,但 vault 裡還有幾個「很多 session 都會碰」的檔:各域的 index.md(每次建頁都要加一行)、根目錄的 TODO.md(inbox-triage 固定往 ## FROM INBOX-TRIAGE 區段追加)。走 worktree 之後,這些檔在合併時會正常出衝突。
目前的判斷是讓它們照常衝突:它們不是純 append,union 塞兩份進去的後果比停下來問嚴重。另一個方向是把 index.md 變成可以重新生成的東西——它的每一行本來就是從各頁 frontmatter 的 summary 來的,衝突時不手動解,直接依 frontmatter 重建。這需要一支重建腳本,vault 目前沒有。
6. union 的時序問題會一直存在
上面踩到的「新條目被放回分叉點」不是一次性的,只要有兩條分支都寫了 log,合併後就可能順序錯亂。走法有兩種:接受它(log 的每筆都帶日期,讀的時候看日期就好);或在 /vault-maintain 加一項檢查,掃 log 月檔的 ## [YYYY-MM-DD] 標題是否單調遞增,不是就提醒。
7. worktree 會留下來
今天那個本機 worktree 在 .claude/worktrees/ 底下,分支合完之後它還留著、處於 detached HEAD,後來才手動清掉。.claude/ 是點開頭的資料夾,Obsidian 不會索引它,所以 worktree 裡的編輯在合回 main 之前,在 Obsidian 裡看不到;合完之後留下來的 worktree 也不會有人注意到。走法是收尾時固定跑一次 git worktree list,已經合併的就 git worktree remove。
小結
vault 現在對「同一台電腦同時開好幾個 session」有了兩層處理:共用工作區時照 CLAUDE.md 的八條協定走,其中 baseline 差集和 Edit 的狀態追蹤是真的有機制在擋的兩項;開 worktree 或雲端分支時,衝突回到 git 手上,log 月檔用 union 免掉每次必撞的那一種。
今天最有用的一件事其實是 review 把協定的前提打掉:第一版寫得很完整,但它唯一依賴的硬護欄,在這個 vault 的實際操作模式下從來不會啟動。寫規則時很容易假設「照規則做事的那個人」會用規則想像的方式做事。
上面七項還沒排先後,明天還沒想好要先動哪一塊。
本文同步發表於 kiwi-walk.com:https://kiwi-walk.com/blogs/engineer/ironman-2026-day13-multi-session-git/
