今天要解的問題

Day17 把六個 skill 的範本放出來之後,今天回頭處理 Day13 留下的那張清單。Day13 的結尾是「上面七項還沒排先後,明天還沒想好要先動哪一塊」,結果一放就是五天。

今天其實不是從那張清單開始的。起點是一個很小的問題:Inbox/_reviews/ 裡累積了七份 inbox-triage 的決策檔,我想確認它們會不會被推上 git、什麼時候會清。查完、寫完清理腳本、要 push 的時候,被擋了下來:

 ! [rejected]        main -> main (non-fast-forward)

遠端多了一個 commit,要先合併。但合併會碰到 TODO.md 和 log/2026-09.md,這兩個檔在本機都有另一個 session 還沒 commit 的變更。照 Day13 寫的協定,Claude 不能自己 stash 也不能自己 pull,只能停下來問我。我選了「先把那批 commit 掉」,它代勞、再合併、再 push。

這一段花掉的不是磁碟也不是時間,是我的注意力:我本來只是要一個清理腳本。

趁這次把用 git 同步十天以來的四個困擾也一起攤開:

  1. obsidian-git 要在意很多 conflict。 可能是因為 log、TODO.md 這類檔幾乎每個動作都會碰,同步性修改太多。
  2. 好處是手機可以直接跟 vault 互動。 之前逛展的時候這樣用,蠻實用的。
  3. 本機所有 session 都直接在 main 上跑,會互相卡住,不舒服。 我不確定是不是每個 session 都該開 worktree。之前問過 Claude,它說 vault 才 20–30MB,可做可不做,因為還是有些成本。
  4. 用 git repo 的好處是 GitHub 網頁本身就能當閱讀介面,很方便。

今天做了三件事:

  1. 每個 session 都開 worktree,並用 hook 擋住在主目錄寫檔(主線)
  2. 收件腳本加一本帳,避免兩台電腦重複收同一個檔
  3. 過期的 triage 決策檔用腳本清

想法與取捨

「20–30MB 可做可不做」答錯了問題

那個回答算的是磁碟成本,而磁碟從來不是重點。今天實際發生的事才是成本:

  1. 一個 session 要 push,被遠端的新 commit 擋下
  2. 要合併,得先處理工作區裡另一個 session 留下的未 commit 變更
  3. 協定規定這種情況不能自己硬上,只能停下來問人
  4. 我裁決、它代勞 commit、合併、push

每個 session 有自己的 worktree 的話,這四步都不存在:各自的分支各自 commit,合併時有衝突是 git 大聲報出來,log 月檔在 Day13 已經掛了 merge=union。

worktree 真正的成本,Day13 列過,今天再確認一次:Obsidian 看不到 worktree 裡的編輯,要等推回 main 才會出現;合併後的 worktree 會留在那裡,要有人清。

每次都開,還是看情況開

Claude 第一版的建議是按「會不會寫」來分:查詢和討論留在 main,單檔小修留在 main,會產出頁面或批次寫入的 skill 才開 worktree。

我沒採用。我要的是每次 session 一定都開,不論大小任務。

我當下沒有另外給理由。回頭看,今天卡住我的那個 session,開頭也只是問一個問題而已——照「看情況」的分法,它會被歸在留在 main 的那一類。

保留 Obsidian 自動 pull,還是關掉

Day13 發現 Obsidian 的 obsidian-git 外掛每 30 分鐘會自己 git pull 一次,這正是協定裡規定「需要獨佔」的操作,卻由一個不讀 CLAUDE.md 的程式定時執行。Claude 這次的建議是把桌面端的自動 pull 關掉。

我想保留。

兩件事放在一起反而對得上:如果沒有任何 session 在主目錄寫檔,主目錄就只剩兩個寫入者——Obsidian 的自動 pull,和我自己在 Obsidian 裡打的字。自動 pull 從「違反協定的那個人」變成「主目錄唯一的更新機制」。

所以收尾的路徑定成這樣:session 在 worktree 裡 commit,然後直接

git push origin HEAD:main

fast-forward 就過,不用開 PR。被拒代表 main 有新的 commit,那就先把 main 併進分支、解完衝突再推一次。永遠不在主目錄跑 merge 或 pull,主目錄等 Obsidian 自己追上。

主目錄只留給 Obsidian 看,每個 Claude session 在自己的 worktree 裡寫,收尾直接推到遠端 main,再由 Obsidian 自動 pull 拉回主目錄

寫進 CLAUDE.md 就好,還是要 hook 擋

Claude 列了由軟到硬的幾層:

層做法能不能真的擋住
規則CLAUDE.md 寫「開工先開 worktree」不能,靠遵守
提醒session 啟動時 hook 把提醒塞進 context不能,但每次都會看到
硬擋在主目錄寫檔時 hook 直接拒絕能

它原本建議先做規則和合併路徑,跑一兩週看卡住的次數有沒有歸零,再決定要不要加硬擋。我選了三層加上合併路徑一次做完。

Day13 留下的教訓是 Bash 改檔完全沒有護欄——hook 只掛在 Write 和 Edit 上。這次把 Bash 也掛上去,但 Bash 的指令是自由文字,只能用 pattern 去猜「這行會不會改檔」,沒辦法完整覆蓋,這點是知道的。

手機的好處來自哪裡

我以為「手機能互動」是 obsidian-git 帶來的。Claude 翻了 git 歷史之後發現不是:逛展那次走的是雲端 session 開分支、發 PR、合併進來,整個歷史裡沒有任何一筆 obsidian-git 自動 commit 的訊息。

所以這個好處實際兌現的路徑是「git 當底層,加上雲端 session」,obsidian-git 在手機上扮演的是讀。雲端 session 本來就是獨立分支,跟 worktree 是同一個形狀,不需要另外處理。

收件重複:查 git 歷史,還是另外記一本帳

開 worktree 之後我想到一個新問題。我的擷取流程是手機把內容寫進一個轉運夾,/inbox-triage 開頭的腳本把轉運夾的檔搬進 vault 的 Inbox/。如果:

  • 一台電腦做到一半,還沒 push
  • 另一台電腦也做到一半,還沒 push
  • 原始檔還留在轉運夾裡(搬走這件事還沒同步到另一台)

那同一個檔會被收兩次,兩邊各自分類,結果可能不一樣。

直覺的解法是「搬之前查一下 git 有沒有處理過」。Claude 去翻了歷史:

total ever-added Inbox files:        7

這七個全是決策檔。時間戳命名的 Inbox 檔從來沒有被 commit 過——收件、分類、commit 在同一個 session 內完成,入庫的只有分類之後改了名的檔;被判「刪除」的更是連痕跡都沒有。查 git 歷史這條路走不通,需要一本明確的帳。

帳本的鍵一開始想用檔名的時間戳,後來改成檔案內容的雜湊:時間戳撞在同一秒時腳本會加 -2 後綴,另一台會把第二個檔誤判成重複;內容雜湊沒有這個問題,兩台機器算出來一定一樣。

另一個考慮過的做法是「只讓一台電腦負責收件」,競態直接消失。沒做,因為另一台會失去「剛擷取就馬上分類」這件事。有了帳本之後這條變成可做可不做。

帳本只在兩邊都看得到時才有用,所以還有第二個動作:收完檔立刻 commit 並 push,不等分類做完。Claude 第一次講這件事時我覺得太抽象,它改成三行指令我才懂:

git add Inbox/
git commit -m "inbox: drain N 檔"
git push -u origin HEAD

「做到一半沒 push」的窗口從整個 session 縮成幾秒。副作用是 Inbox 檔從此會進 git 歷史,之後搬到別的資料夾時 git 會記成 rename,溯源反而比以前完整。

過期的決策檔:排出 git,還是定期刪

回到今天的起點。決策檔原本的設計是「預設不刪,留作稽核軌跡」,所以會永久累積。兩個方向:

  • 加進 .gitignore:不上 git,但兩台機器各自留一份
  • 留在 git,由巡檢 skill 定期刪

我選後者,而且要用腳本執行,確保每次的判斷一致。保留期跟 log 月檔用同一個尺度:檔名日期在本月或上個月的留著,更早的刪。

刪之前有一道閘門:log 裡要有日期不早於該檔的 triage 紀錄,代表後續處理真的跑過。這道閘門有一個已知的弱點——它證明的是「那天之後有跑過某一次處理」,不是「這一份被處理過」。log 條目沒帶決策檔的檔名,決策檔裡的「決定」欄也不可靠(有一份欄位全空,但實際上是在對話裡處理完的)。

我接受這個弱點。決策檔只是草稿,沒處理的原始檔本身還在 Inbox/,下次會重新列出來。

我另外問了一件事:月初的時候,會不會因為 log 換月,抓不到上個月底的紀錄?不會。log 沒有「歸檔」這個動作,舊月檔原地不動,腳本掃的是所有月檔而不是只看當月。

實作

判斷「是不是主目錄」:.git 是目錄還是檔案

worktree 和主目錄的差別,在檔案系統上就看得出來:主目錄的 .git 是一個資料夾,worktree 的 .git 是一個指回去的檔案。

def repo_dotgit(start: str):
    """Nearest .git (file or dir) walking up from `start`; None when not inside a repo."""
    try:
        p = Path(start).resolve()
    except Exception:
        return None
    for d in [p, *p.parents]:
        g = d / ".git"
        if g.exists():
            return g
    return None


def in_main_checkout(start: str) -> bool:
    g = repo_dotgit(start)
    if g is None or not g.is_dir():
        return False
    return (g.parent / ".claude" / "hooks" / "write_guard.py").is_file()

從要寫入的那個路徑往上找最近的 .git,而不是看 hook 腳本自己放在哪。這樣一個在 worktree 裡的 session 如果想寫主目錄的檔,一樣會被擋。最後一行的判斷是後來補的,見下面第一個坑。

Write 和 Edit 用目標路徑判斷,Bash 用當下的工作目錄判斷,再加一個 pattern:

BASH_MUTATING = re.compile(
    r"(?<![0-9&>])>{1,2}(?!&)(?!\s*/dev/null)"          # > or >> redirect (not 2>&1, not >/dev/null)
    r"|\btee\b"
    r"|\b(sed|perl)\s+(-[a-zA-Z]*i|--in-place)"         # sed -i / perl -pi
    r"|(^|[;&|]\s*|\$\(\s*)(mv|rm|cp|mkdir|touch|rmdir|ln|truncate)\s"
    r"|\bgit(\s+-C\s+\S+)?\s+(add|rm|mv|commit|merge|pull|rebase|stash|reset|restore|"
    r"switch|checkout|cherry-pick|revert|apply|am|clean)\b"
)

第一行是重導向,要排除 2>&1 和 >/dev/null 這兩種不會改檔的寫法。git status、git fetch、git log 這些唯讀的不在清單裡,主目錄還是可以查東西。

        # Rule 0b: Bash in the main checkout must not mutate files / index / working tree
        if tool == "Bash":
            cmd = tool_input.get("command", "") or ""
            if cmd.strip() and in_main_checkout(cwd) and BASH_MUTATING.search(cmd):
                deny(
                    f"Refusing file-mutating Bash in the MAIN checkout (cwd: {cwd}). {ENTER_WT} "
                    "Read-only commands (git status/log/fetch, ls, cat, grep, dry-run scripts) "
                    "are still allowed here."
                )
            sys.exit(0)

拒絕訊息裡直接寫下一步該做什麼(先 fetch 再開 worktree),被擋的那個 session 不用回頭翻規則。

測了 17 個案例。主目錄裡的 cat >> log、git commit、git -C <path> pull、sed -i、ls && rm 都被擋;git status、git fetch、2>/dev/null、2>&1 | head、git worktree list 放行;同樣的 cat >> log 和 git commit 在 worktree 裡放行。

session 啟動時的提醒

另一支 hook 掛在 SessionStart,它的輸出會進到 Claude 的 context。在 worktree 裡啟動時長這樣:

[vault-session] 已在 worktree `worktree-default-and-drain-ledger`(branch: worktree-worktree-default-and-drain-ledger)。收尾:commit → `git push origin HEAD:main`;被拒則先 sync_with_base_branch 再推。
[vault-session] 現有 worktree 1 個(已合併者由 /vault-maintain I6 清理):
  - worktree-default-and-drain-ledger [worktree-worktree-default-and-drain-ledger] 有未 commit 變更(本 session)

在主目錄啟動時,第一行會換成「你在主 checkout,先 fetch 再開 worktree」。第二段順便解掉 Day13 的第七項:留下來的 worktree 每次開工都會被列出來,附上狀態。

這個 session 自己先照做

規則是在這個 session 裡寫的,所以它自己先進 worktree,改完之後用新的路徑收尾:

   a00c9a2..03cdeef  HEAD -> main

這是第一筆在 worktree 裡完成、用這條路徑收尾的改動。

收件的帳本

搬檔之前先把所有看得到的帳本合起來:

LEDGER_ALL=$(mktemp)
trap 'rm -f "$LEDGER_ALL"' EXIT
if [ -f "$LEDGER" ]; then
  cat "$LEDGER" >> "$LEDGER_ALL"
fi
if [ "$GIT_OK" = "1" ]; then
  if ! git -C "$VAULT_ROOT" fetch --quiet origin 2>/dev/null; then
    echo "WARN: git fetch 失敗——帳本只查本機,另一台剛收過的檔可能重複搬入。" >&2
  fi
  for ref in HEAD $(git -C "$VAULT_ROOT" for-each-ref --format='%(refname)' refs/remotes/ 2>/dev/null); do
    git -C "$VAULT_ROOT" show "$ref:$LEDGER_REL" >> "$LEDGER_ALL" 2>/dev/null || true
  done
fi
seen_before() { grep -qF -- "$1" "$LEDGER_ALL"; }
content_sha() {
  git hash-object -- "$1" 2>/dev/null || shasum -a 1 -- "$1" | cut -d' ' -f1
}

查的範圍是本機的帳本、目前分支的、加上所有遠端分支的。因為每個 session 都在自己的分支上,另一台剛收的檔很可能還沒合進 main,只查 main 會漏。fetch 失敗不中止,但會大聲講。

迴圈裡對每個檔先算雜湊,命中就刪掉不搬:

  blob=$(content_sha "$f")

  # 帳本命中 → 過期副本,刪除不搬
  if seen_before "$blob"; then
    DUPS="$DUPS$(basename -- "$f")  (sha1 $blob 已在帳本)
"
    DUP_N=$((DUP_N + 1))
    if [ "$DRY_RUN" = "0" ]; then
      rm -f -- "$f"
    fi
    IFS='
'
    continue
  fi
  printf '%s\n' "$blob" >> "$LEDGER_ALL"   # 同批次內容重複也擋

帳本檔跟 log 一樣掛 merge=union,兩邊各自追加時合併不會衝突。

為了驗證,用兩個拋棄式的 repo 模擬兩台機器:A 的轉運夾有兩個檔,B 的轉運夾有同樣那兩個(模擬「搬走」還沒同步過來)再加一個新的。A 先跑,B 後跑:

已搬入 1 個檔到 Inbox/:
  2026-09-30_303030.md  ->  Inbox/2026-09-30_303030.md
DUP:2 個檔已在帳本(另一台已收過),已刪除 iCloud 過期副本:
  2026-09-30_101010.md  (sha1 37b56e17bbf5dcaef3c1b3e54590a79613947d1e 已在帳本)
  2026-09-30_202020.md  (sha1 cf53fccf422a0b4f435e0eb9a8368d0e20d52414 已在帳本)

B 正確刪掉兩個重複的,只收一個新的。

同一次測試裡 B 的 push 失敗了,腳本印出警告但沒有中止。原因是測試讓兩邊用同一條分支,B 落後 A 一個 commit。真實流程每個 session 的分支名字不同,push 是建一條新的遠端分支,不會撞。這個失敗留在測試輸出裡沒有修,因為它正好是「共用分支」模式下該有的行為。

收件帳本的時序:A 電腦收檔後把內容雜湊寫進帳本並立刻 push;B 電腦收檔前先 fetch 帳本,發現同樣的雜湊已存在,刪掉轉運夾裡的過期副本

決策檔的清理腳本

核心是兩道判斷,順序不能反:

        if month_index(d) >= cutoff_month:
            row["reason"] = "within retention window"
            kept.append(row)
            continue
        if not any(t >= d for t in processed):
            row["reason"] = "no triage log entry dated >= review date (Phase 2 never ran?)"
            unprocessed.append(row)
            continue
        row["reason"] = "expired and processed"
        expired.append(row)

先看保留期,再看 log 有沒有對應紀錄。沒有紀錄的不論多舊都跳過並回報。日期取自檔名而不是檔案的修改時間,因為另一台機器 checkout 時修改時間會變。

預設只列不刪:

[prune-triage-reviews] today=2026-09-30 keep_months=1 mode=dry-run latest_triage_log=2026-09-29
[prune-triage-reviews] kept 4 | unprocessed-skipped 0 | expired 3
  DEL?  Inbox/_reviews/triage-review-2026-05-28.md
  DEL?  Inbox/_reviews/triage-review-2026-06-15.md
  DEL?  Inbox/_reviews/triage-review-2026-07-06.md
[prune-triage-reviews] dry-run: nothing deleted. Re-run with --apply to delete 3 file(s).

另外測了三個邊界:月初當天跑(上個月的全保留)、決策檔建在月底但隔月第一天才處理(紀錄落在下個月的 log,照樣抓得到)、跨年(月份用 year * 12 + month 算,沒有 12 月到 1 月的斷點)。

這支腳本今天只建好、沒有真的執行刪除。當時工作區還有別的 session 的東西,批次刪除要獨佔。

踩到的坑

自己寫的 hook 擋到自己要寫的另一個 repo

in_main_checkout 的第一版只有一個條件:.git 是資料夾就算主目錄。

這個判斷對任何 git repo 都成立。而這篇文章要寫進部落格的 repo——那也是一個 .git 是資料夾的地方。準備動筆時才發現,照這個判斷,寫文章這個動作會被 vault 的 hook 擋下來。

修法是加一個身份確認:往上找到的那個 repo 根目錄,要帶著這支 guard 本身才算是 vault。

    return (g.parent / ".claude" / "hooks" / "write_guard.py").is_file()

寫守門規則時很容易只想著「要擋的那個地方」,忘了這支腳本會對這台電腦上所有的寫入動作都跑一次。

剛開的 worktree 被判成「已合併,可清」

SessionStart 的第一版輸出裡,這個 session 自己所在的 worktree 被標成:

  - worktree-default-and-drain-ledger [...] 已併入 main,可清(本 session)

「已合併」的判斷是 git branch --merged origin/main,意思是這條分支的頂端已經在 main 的歷史裡。一個剛開、還沒有任何 commit 的 worktree,頂端就是 main 本身,當然符合。

放著不管的話,別的 session 跑清理時會把一個正在工作中的 worktree 當成可以刪的。補了兩個條件:工作區要是乾淨的,而且建立要超過 24 小時;移除時不加強制參數,有未 commit 變更的 git 自己會拒絕。輸出的標籤也改成先看有沒有未 commit 的變更。

log 的同一個 hunk 裡混著兩個 session 的條目

第一次 commit 清理腳本時,log/2026-09.md 已經有另一個 session 追加的一筆(還沒 commit),我這次又在它後面追加了一筆。兩筆緊鄰,git 把它們視為同一個 hunk,沒辦法只 stage 後半。

協定說 commit 只帶自己的東西。最後的做法是自己組出「應該被 stage 的那個版本」,直接寫進 index(暫存檔的路徑這裡簡化了):

git show HEAD:log/2026-09.md > staged.md
tail -n 7 log/2026-09.md >> staged.md
git update-index --cacheinfo 100644,$(git hash-object -w staged.md),log/2026-09.md

取 HEAD 的版本、接上自己那七行、算出 blob、塞進 index。工作區的檔案完全沒動,另一個 session 的那筆還留在那裡等它自己 commit。

這是共用主目錄才會遇到的問題。每個 session 在自己的 worktree 裡,log 檔只會有自己追加的東西。

在 worktree 裡,一條很長的指令被拒絕

進 worktree 之後,Claude 想用一條很長的 shell 指令一次建好幾個檔、改幾個檔,被拒絕了:

this command is too complex to verify that it stays inside the worktree. Refusing to run it

worktree 隔離的 session 會檢查指令有沒有跑出自己的範圍,太複雜的指令它無法確認,就直接不執行。後來改成一個檔一個檔寫,測試則寫成一支腳本再用一行指令去跑。

Day13 說 Bash 改檔「完全沒有護欄」,在 worktree 模式下這句話不完全成立了——工具本身多了一層。

之後要觀察的事

今天動了不少東西,有些要跑一陣子才知道對不對。

1. 卡住的次數有沒有歸零。 這是整件事的目的。先跑兩週。

2. TODO.md 和各域的 index.md 手動解衝突的次數。 log 有 union,這兩種沒有(它們不是純追加,union 會把兩個版本都塞進去而且不報錯)。今天 TODO.md 的合併是自動成功的,但那是因為兩邊改的行剛好不相鄰。一個月後看實際次數,再決定要不要做「index 從各頁的摘要重新生成」的腳本。

3. Obsidian 看不到 worktree 裡的編輯。 要等推回 main、再等自動 pull,最多 30 分鐘。我還不知道這個延遲在實際使用時會不會煩。

4. 兩段式的工作。 inbox-triage 是先產出決策檔、等我填完、再執行。這兩段會是不同的 session,第二段必須回到同一個 worktree 接著做,不能開新的。我另一台電腦上的排程任務也會遇到這個:它跑完第一段就結束了,沒有人回答「這個 worktree 要留還是刪」。

5. Bash 的 pattern 誤擋和漏擋。 那個正規表達式是猜的。哪天擋到不該擋的、或漏掉該擋的,要回來調。

6. 遠端分支會累積。 收件腳本每次都會 push 當前分支,等於每個 session 在遠端留一條。巡檢 skill 加了一條規則去列已合併的分支,但還沒真的跑過清理。

7. 手機上的 obsidian-git 吃不吃 merge=union。 Claude 的推測是手機版用的 git 實作可能不支援這個設定,如果是的話,手機 pull 的時候 log 會出真的衝突。這點沒有驗證過,只是推測。手機目前只用來讀,所以就算是真的,影響是 pull 失敗要回電腦處理,不會壞資料。

8. 三個地方的 obsidian-git 設定現在各自獨立。 它的設定檔每次開 Obsidian 都會變,最近一次巡檢把它排出 git 了。之後要改任何一項,兩台電腦加手機要各改一次。

9. 決策檔清理的閘門。 如果哪天真的誤刪了一份沒處理的,做法是讓 triage 在 log 條目裡帶上決策檔的檔名,腳本改成逐檔比對。現在先不做。

小結

回頭對 Day13 那七項:「宣告領域沒有跨 session 的頻道」因為每個 session 都有自己的 worktree 而不再需要;「Bash 改檔沒有護欄」補了一層,但不完整;「Obsidian 本身也是一個 session」換了一個角度,主目錄整個讓給它;「worktree 會留下來」有了啟動時的清單和巡檢規則。union 相關的幾項今天沒動。

今天完成的:

  • CLAUDE.md 新增「每個 session 都在 worktree 裡工作」,原本的八條協定降為共用主目錄時的備案
  • 兩支 hook:啟動時提醒、在主目錄寫檔或跑改檔指令時拒絕。17 個案例測過
  • 收件腳本加帳本,收完立刻 commit 並 push。用兩個拋棄式 repo 模擬兩台機器測過
  • 決策檔的清理腳本,建好、測過三個日期邊界,還沒執行刪除
  • 巡檢 skill 多兩條規則:過期決策檔、殘留的 worktree 與遠端分支

我原本以為十天來的不舒服是 git 衝突太多。攤開來看,衝突早在切 log 月檔和掛 union 的時候就處理掉大半了,真正一直在發生的是所有 session 踩在同一個目錄上。


本文同步發表於 kiwi-walk.com:https://kiwi-walk.com/blogs/engineer/ironman-2026-day18-worktree-by-default/