今天要解的問題
前面七天都在做 coffee-review,今天換一個專案:llm-wiki,我的第二大腦。
Day1 盤點時沒把它列進去,但它從四月中就開始長了(最早的設計文件是 04-12,vault 重構的第一筆 log 是 04-15),到今天連 .claude/ 的 skill 和模板一起算是 764 個 markdown,其中 727 個是內容。所有的設計決策、踩過的坑、讀過的東西都沉在裡面。明天會正式介紹它的設計概念,今天先講一件更急的事:它的同步方式今天整個換掉了。
順序是反的,先講搬家才講它是什麼。但搬家這件事有時效性,而且過程裡的三個坑跟「AI 維護的文件會漂」這個主題直接相關,值得趁熱記下來。
這個 vault 原本放在 iCloud Drive,在 Mac 和 Windows 兩台機器之間同步。翻自己的操作 log,iCloud 的事故從五月就開始了:
2026-05 books/raw 5 個資料夾 + 1 個 wiki 頁全部 I/O error
2026-05 coffee/index.md 缺的 4 個 wiki 頁「目前 iCloud online-only 讀不到」
2026-05 待修:根目錄 log 2.md 為 iCloud 同步衝突檔,需手動合併回 log.md
2026-06 根因修復:settings.json 讀取失敗 = iCloud 逐出本地的 dataless placeholder
2026-07 孤兒衝突副本「進階肌力訓練解剖聖經 1.md」本尊失蹤
兩種症狀反覆出現。一是檔案脫水:iCloud 把較少用的檔案逐出本地只留佔位檔,讀取時 macOS 報 I/O error、Windows 報 EUNKNOWN,要用 brctl download 或 attrib +P -U 拉回來才讀得到。二是衝突副本:兩端改同一個檔就多一個 name 2.md,內容悄悄分岔。
而且 iCloud 對雲端已存在的檔名有記憶。log 2.md 那次沒辦法直接 rename 回 log.md——改了幾秒內就被還原,最後是走「備份到 vault 外 → 刪掉 → 重建」繞過去的。
我為了它長出了一套免疫系統
比事故本身更值得記錄的,是 vault 為了對抗 iCloud 長出來的東西:
- CLAUDE.md 有一整段「環境注意(iCloud 跨平台)」,每個 session 都會被載入,內容是脫水怎麼救、為什麼不准 rename、備份放哪
- 一支 Stop hook 每回合結束掃一次全 vault,找
name N.md這個衝突副本簽名 vault-maintainskill 有一個「iCloud 韌性」章節,自動修復清單裡有一項叫dehydrated,處理方式是「回拉」- 一條明文規定:絕不 rename,需要改名時走備份→刪→重建
這些全都不是知識管理,是在繞過儲存層的缺陷。
而且查資料時發現,「AI 高頻編輯 + 雲端佔位檔」這個組合的風險比我以為的高。Claude Code 官方 repo 有一個帶 data-loss 標籤、附可重現步驟的 issue(#32637)。情境是 macOS 把檔案卸載成 0-byte 佔位檔之後,AI 讀到空檔、複製出空的副本,然後對原資料夾下 rm -rf;刪除再經由 iCloud 傳播到另一端,最後靠 Time Machine 才救回來。那個 issue 現在已經關閉,但它說明我那些 I/O error 不是無害的雜訊:同一份佔位檔,人讀到會報錯,程式讀到可能是一個看起來合法的空檔案。
所以決定把 vault 搬出 iCloud,改用 Obsidian Sync。
換掉一個問題,換來另一個
搬完之後檢查 Claude Code 設定還在不在,新位置底下只有內容資料夾和 .obsidian。.claude/ 整個沒跟過來——16 個 skill、4 支 hook、模板、兩份 settings,45 個檔案一個都沒有。

這不是「少了點方便功能」。這個 vault 的內容規範(wiki 頁必須有 frontmatter、根目錄不准放腳本、log 不准分岔)是靠 PreToolUse hook 在物理層擋下來的,不是靠自律。.claude/ 不在,等於 16 個自訂指令全部失效、護欄全部消失,而且不會有任何錯誤訊息——它只是安靜地什麼都不做。
苦惱就在這裡:iCloud 會把檔案弄壞,但至少什麼都同步;Obsidian Sync 不會弄壞檔案,但有一整類檔案它根本不碰。
想法與取捨
先確認這是不是硬限制
第一個要回答的問題是:Obsidian Sync 能不能被設定成同步 .claude?
官方文件沒有模糊空間:
Files and folders beginning with a
.are treated as hidden and excluded from sync. The only exception is the vault’s configuration folder (.obsidian), which does sync.
同樣被排除的還有 .git、.vscode、.idea。Selective sync 能調的只有「哪些檔案類型」和「排除哪些資料夾」,沒有任何開關可以把被排除的 dot 資料夾加回來。論壇上「Sync hidden files and folders as well」是一條長年的 feature request,至今未實作。
所以這是硬限制,不是我漏找設定。先確認這件事很重要——如果只是個 toggle,後面三個方案全部不用想。
(查資料時看到有人回報 .docx 開了「Show all file types」還是不同步。追下去發現那是另一回事:Show all file types 是 Obsidian 的顯示層設定,跟 Sync 傳不傳無關,真正要開的是 Sync 設定裡的 Sync all other types。兩個名字很像的設定,管的是完全不同的東西。)
三個方向
方案 A:真檔改非 dot 名 + 本地 symlink
llm-wiki/
_claude/ ← 真實檔案,非 dot,Obsidian Sync 正常帶走
.claude -> _claude/ ← 每台機器「本地」各自建,不同步
Obsidian 同步 _claude/ 的內容,.claude 只是每台機器自己的指標。Claude Code 是檔案系統層級讀取,吃 symlink 沒問題。社群插件 Claude Skill Sync 就是把這個模式自動化的,所以這條路走得通。
代價是 _claude/ 變成一個普通的 vault 資料夾,16 個 SKILL.md 會進 Obsidian 的搜尋、graph、quick switcher。.sh / .py / .ps1 / .json 不會出現(Obsidian 預設不顯示未知副檔名),所以污染只有 markdown,大約 23 個檔。可以用 Excluded files 擋掉搜尋和 graph,但檔案總管裡還是看得到——沒有乾淨解。
還有一個沒驗證過的變體:把真檔藏進 .obsidian/claude/。.obsidian 是唯一會同步的 dot 資料夾,而 Obsidian 不會把它當筆記索引,理論上零污染。但 Vault configuration sync 是分項 toggle,未知子資料夾可能一項都不屬於,我沒辦法從單機驗證,所以只能列為「要做一次真實兩機測試才能信」。
方案 B:只有 .claude 走 git
.claude/.git 是 dot 資料夾裡的 dot 資料夾,Obsidian 完全看不到,所以跟 Obsidian Sync 零衝突。skill 是 code 不是 notes,有 diff 可看、改壞可 revert,這個場景 git 划算。
但有一個一定會爆的地雷:.claude/settings.local.json 是 Claude Code 會自動寫入的檔案——每按一次「允許」某個權限它就長一行。兩台機器各自累積,就是每次 pull 都在解一個你從來沒手動編輯過的檔案的衝突。
方案 C:整個 vault 改用 git、停用 Obsidian Sync
這個做法我一開始是反對的,理由是 git 和 Obsidian Sync 會同時改工作目錄:Sync 在背景即時寫檔,git 在前景做 checkout / reset,兩邊都在改檔案而彼此不知道對方存在。典型災難是 git checkout 改了 50 個檔,Sync 同時把其中一半的舊版推回來,兩套的衝突解決機制都認為自己贏了。
這點 Obsidian 官方也明文講過,只是對象是雲端硬碟:
We do not recommend using Obsidian Sync alongside cloud storage services (e.g. iCloud, Dropbox, OneDrive, Google Drive) as this can cause conflicts.
但那個反對只在兩者並存時成立。一旦決定把 Obsidian Sync 整個關掉,前提就不在了,反對本身也跟著失效。
選 C 的理由

關鍵不是 git 比較好,而是它讓整個問題消失:git 不特別對待 dot 資料夾。.claude 就是一個普通目錄,不需要改名、不需要 symlink、不需要處理索引污染、不需要研究檔案類型 toggle、不需要煩惱 .obsidian/claude/ 到底會不會同步。方案 A 的所有髒東西一次歸零。
git 唯一輸的地方是手機。iOS 沙盒不給背景 daemon,Obsidian 在手機上官方只支援 Obsidian Sync 與 iCloud Drive 兩種同步,git 要走 Working Copy 或官方標註 highly unstable 的行動版外掛。確認沒有手機讀寫需求之後,這個弱點就不算數了——但如果哪天要加 iPhone,這會是整個架構最先被迫重新設計的地方。
而且這個 vault 的體質剛好適合 git:48M、784 個檔案,其中 727 個是 markdown。最大宗的 social-cards/ 佔 26M,但那是圖卡產生器的輸出,CLAUDE.md 自己就寫「不視為 vault 內容,可重跑」——完美的 gitignore 候選,砍掉之後 repo 只剩 22M。
一個附帶的決定:不開 auto-commit
選了 git 之後還有一個取捨:要不要讓 Obsidian Git 自動 commit?
結論是只開 auto-pull,不開 auto-commit。理由不是怕 commit 太多,而是這個 vault 經常由 Claude 編修——git 在這個情境最大的價值就是「commit 前先看 diff」這道審閱關卡。開了 auto-commit,AI 的修改不經審閱就進歷史,等於把剛換來的好處自己丟掉。
代價是「要記得 commit」變成人工紀律。auto-pull 補掉了另一半(忘記 pull 就在舊版上改),那是兩者之中比較難補救的一半。
實作
先放 .gitattributes,再 git add
順序很重要:
* text=auto eol=lf
*.ps1 text eol=crlf
*.bat text eol=crlf
*.cmd text eol=crlf
hook 的 dispatcher 是 #!/bin/sh 開頭的 shell script。Windows 上 git 預設 core.autocrlf=true,checkout 時會把 LF 轉成 CRLF,shebang 就變成 #!/bin/sh\r,這個檔案拉回 macOS 執行會直接報 bad interpreter: /bin/sh^M。
.gitattributes 的優先級高於 core.autocrlf,所以鎖在 repo 裡比叫每台機器各自去設 git config 可靠得多——新機器 clone 下來就是對的,不需要任何人記得做什麼。
.ps1 要反過來留 CRLF。那兩支腳本的檔頭有一行註解說明為什麼它們刻意只用 ASCII:Windows PowerShell 5.1 mis-decodes non-ASCII .ps1 without a BOM。編碼這塊本來就脆,git 再疊一層換行轉換,不明確鎖住遲早出事。
.gitignore 的四類
# 可重跑的產出物
social-cards/
# 機器本地狀態
.claude/settings.local.json
.obsidian/workspace*.json
# 密鑰
.env.local
# 依賴/暫存
node_modules/
.venv/
第二類是重點。settings.local.json 前面講過;.obsidian/workspace.json 則是每次調整版面就會變,純粹是機器本地的視窗狀態,不 ignore 的話兩台機器會互相覆蓋對方的 UI 佈局。
另外這個 repo 含 finance/ 的真實資產數字和 personal/,所以 GitHub 上必須是 private,沒有例外。
進 git 前先清 iCloud 留下的屍體
git init 之前掃了一次衝突副本,.obsidian/ 裡有 89 個 workspace N.json,全是 iCloud 時代同步衝突的殘骸。這些如果直接 commit 就永遠留在 git 歷史裡了。清掉之後 .obsidian/ 從 1.9M 降到 288K。
好消息是內容區(727 個 markdown)掃出來 0 個衝突副本,遷移本身是乾淨的。
順手拆掉免疫系統
既然 iCloud 走了,為它長出來的東西也該收掉。vault-maintain 的自動修復清單原本有這一項:
dehydrated → 回拉(macOS brctl download;Windows attrib +P -U)
整個「iCloud 韌性」章節改寫成「git 韌性」,內容從「讀檔遇 I/O error 先回拉再讀」變成「讀檔報 I/O error 不再是預期行為,當成真的錯誤查」。這個反轉很重要——留著舊規則的話,未來真的出現 I/O error 時會被當成例行公事繞過去,而不是當成 bug 追。
「絕不 rename」那條也拿掉了。git 認得 rename,檔案可以直接改名。
但有一樣東西留著:Stop hook 掃 name N.md 的那段。git 不會產生這種檔案,所以掃到就代表有非 git 來源的副本混進來——成本極低,繼續當安全網。拆免疫系統跟留一個哨兵不衝突。
log.md 的結構性弱點
還有一個切到 git 之後才會暴露的問題:根目錄的 log.md 是一個 97KB、195 筆、append-only 的單一大檔。兩台機器都往檔尾 append,在 git 裡就是每次都撞在同一行。這不是使用習慣問題,是檔案結構本身的弱點。
所以切成月檔,log/2026-04.md 到 log/2026-09.md 六個檔。切完之後只有當月檔會被寫,歷史月檔實質凍結。
切檔腳本沒有只比對行數,而是逐筆確認每個 entry 的原始文字都出現在輸出裡:
joined = "\n".join((outdir / f"{m}.md").read_text() for m in sorted(by_month))
missing = [b[:60] for _, _, b in entries if b not in joined]
assert not missing
順帶發現原檔的排序是亂的——前半段新到舊、後半段舊到新,兩種方向混在同一個檔案裡。月檔統一成升冪,跟 append-only 的語意對齊。
切檔要連帶改的東西比想像中多:CLAUDE.md 的 Log 格式段、根目錄允許清單、10 個 skill 的 log 寫入規則,以及 hook 新增一條規則擋下「重建根目錄 log.md」。
踩到的坑
一、文件說它可攜,程式碼說它不是
搬家前的 CLAUDE.md 裡有這麼一句:「四支守門腳本都從自身位置推導 vault root(parents[2] / $PSScriptRoot 往上兩層),搬家不需再改。」
這句話寫得很有自信,而且合理——它描述的正是一個可攜設計該有的樣子。我一開始相信了,準備直接往下做。

打開檔案才發現不是這樣:兩支 .ps1 都是寫死的絕對路徑,$PSScriptRoot 在整個檔案裡一次都沒有出現。
而且這個錯誤的後果是靜默的。Windows 端一開 session,Stop hook 會去掃那個已經被 CLAUDE.md 明文宣告「已凍結、永遠不要讀寫」的舊目錄;PreToolUse 的根目錄規則則因為 $parent -ieq $VAULT_ROOT 永遠不成立而完全不擋——看起來一切正常,實際上護欄是關的。
修法很短:
$VAULT_ROOT = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent
真正的教訓不在這一行。這句 CLAUDE.md 是前一次 session 寫下的,描述的是意圖而不是現況——當時大概真的打算四支都改,Python 那兩支改了,PowerShell 那兩支沒有。文件和程式碼之間沒有任何機制保證一致,而 AI 寫的文件又特別流暢、特別像真的,不打開檔案就不會發現。
二、dispatcher 會 fail-silent
準備 Windows 端接手的時候,回頭看了一次 dispatcher:
case "$(uname -s)" in
Darwin) exec python3 "$DIR/write_guard.py" ;;
MINGW*|MSYS*|CYGWIN*) exec powershell ... ;;
*) exit 0 ;;
esac
最後那行 *) exit 0 的意思是:uname -s 認不得的平台,什麼都不做,安靜地成功。WSL 回傳 Linux、環境換個 shell、Git Bash 沒裝——任何一種都會讓 hook 看起來裝好了但實際上完全沒作用。而新機器正是最容易撞到這個的場景。
改法是補上 Linux 分支、Windows 分支加 pwsh 與 Python 退路,最後認不得的平台改成在 stderr 印警告:
echo "[vault-guard] no usable interpreter for $(uname -s); write guard SKIPPED" >&2
exit 0
原則是 fail-open 但絕不 fail-silent:guard 自己壞掉時不該擋住使用者寫檔(所以還是 exit 0),但必須講出來。
這跟第一個坑其實是同一件事的兩個面向——護欄類工具最糟的失效模式不是壞掉,是看起來還在運作。
三、我自己算錯的 Windows 路徑長度
Windows 的 MAX_PATH 是 260,而這個 vault 有大量中文檔名,所以 clone 之前先量了一下:
git ls-files | awk '{print length($0), $0}' | sort -rn | head -5
結果最長 616,加上 clone 前綴就是 640——遠遠爆掉。當下的判斷是要開 core.longpaths。
但數字大得不太對勁。回頭看 git ls-files 的輸出,非 ASCII 路徑是被八進位轉義印出來的:
"coffee/raw/\346\257\217\345\200\213\344\272\272..."
一個中文字被展開成 \346\257\217 這樣 12 個字元。616 量的是轉義後的表示法,不是真實檔名長度。改用 core.quotepath=false 再數實際字元,真實最長是 99,加上前綴共 125,離 260 還很遠。完全不需要 longpaths。
同一個坑還讓我誤報了第二件事:檢查檔名有沒有 Windows 非法字元時,grep " 命中了一堆檔案——那些引號是 git 自己加的,不是檔名的一部分。真實結果是 0 個。
教訓是 git ls-files 的輸出不是原始檔名。而且錯的方向剛好是「看起來更危險」,很容易順著它去做一個不必要的修復。
小結
vault 從 iCloud 經過 Obsidian Sync,最後落在 git 單一同步來源(GitHub private repo)。.claude/ 45 個檔進了版控,兩支 .ps1 的寫死路徑改成 $PSScriptRoot 推導,dispatcher 不再 fail-silent,log.md 切成月檔,Obsidian Git 裝好並設定成「只自動 pull、不自動 commit」。
為 iCloud 長出來的免疫系統也拆掉了大半——brctl download、attrib +P -U、「絕不 rename」、「備份→刪→重建」全部作廢,只留下 Stop hook 掃衝突副本那一段當哨兵。
Mac 端的 hook 跑過實測:根目錄寫 .py 擋下、重建 log.md 擋下、log 2.md 擋下、正常 raw 檔放行。
但要誠實講一件事:Windows 端到現在還沒 clone,而 PowerShell 那條分支至今一次都沒有真的執行過。它剛被改過,而改它的機器上沒有 PowerShell 可以驗。今天三個坑裡有兩個都是「看起來正常但其實沒作用」,所以在真的跑過之前,我不打算假設它是對的。
明天要正式介紹 llm-wiki 本身——它為什麼長成現在這樣、raw 到 wiki 的反壓縮紀律、以及為什麼規範要用 hook 擋而不是寫在文件裡(今天的第一個坑就是答案的一部分)。
Windows 那邊的驗證則是排在介紹之後、不會跳過的一步,指令已經備好:
echo '{"tool_name":"Write","tool_input":{"file_path":"'"$PWD"'/log.md","content":"x"}}' \
| sh .claude/hooks/dispatch-pretooluse.sh; echo "exit=$?"
預期 exit=2 並印出 guard 訊息。拿到 exit=0 又沒有輸出,就是護欄沒在跑。
本文同步發表於 kiwi-walk.com:https://kiwi-walk.com/blogs/engineer/ironman-2026-day08-sync-dotfolder-icloud-to-git/
