今天要解的問題
我的 llm-wiki vault 已經決定不再寫 log/YYYY-MM.md 這種操作紀錄月檔,紀錄改交給 git 的 commit 和 PR,為什麼要停掉寫在〈上 git 一個月後,停掉照抄過來的 log 月檔〉。那篇只列了要動的四個地方,就說之後再做,這篇就是動手的部分。

要動的四處裡,大部分是改文字:約 12 支 skill 收尾的「append 當月 log」要改掉、CLAUDE.md 的 Log 格式段要換掉。
但有兩件不是改文字就可以:
- triage review 的刪除閘門:清理腳本是靠 log 裡的 triage 條目來判斷一份 review「處理過沒」,之後沒有 log 檔,所以需要切換。
- 擋住舊習慣:我希望切過去之後就馬上有東西擋著,不要哪天又有 skill 或舊分支偷偷寫回月檔,而我很久之後才發現。
第一件只改指示和資料,真正花時間的是第二件:一個 hook,前後被審查退回三次,最後縮成只剩一條字串比對。
技術背景
issue 工作流
這次會來來回回這麼多次,跟我用的工作流很有關係。我把開 issue、寫計畫、實作、審查這一套做成 skill,放在公開的 sean2249/skills(同一天在談的取捨見 issue 工作流決策),這次是 vault 第一張完整走完整條開發流程的 issue:
- 寫計畫:
issue-enhance把 issue 寫成計畫。計畫動到 hook 這類高風險的路徑,就自動跑 Grill:開幾個全新 context 的審查員,各從一個角度挑問題,有爭議的變成勾選題交給我選。 - 計畫查核:再開一個全新 context 的 subagent,拿計畫去對程式碼,查事實有沒有寫錯。
- 我核准之後,
issue-implement才開始實作。 - 審查:code review 和 security review 最多跑三輪。第 3 輪還有中度以上的問題,就不再補丁,停手交回我重選做法;我改了做法,審查從第 1 輪重新算。
下面講到的「退回」,都是第 4 步這條規則觸發的,不是臨時決定的。
hook: 警衛
hook 可以想成門口的警衛:Claude 每提出一個命令,警衛就先檢查一次,而且是在我按下允許之前。順序是:
- Claude 提出命令
- PreToolUse hook 檢查,可以直接擋下
- 我按允許
- 命令執行
這個 vault 的警衛只有一位,住在總公司,也就是主 checkout。我平常每個 session 都開在自己的 worktree(同一個 repo 開好幾個工作目錄,做法見 Day18),等於是分公司。實際查下來,在 worktree 裡開的 session,啟動時都被告知「你在主 checkout」,因為 Claude Code 的 $CLAUDE_PROJECT_DIR 指向的是主 checkout。
好處是 session 改不鬆自己的規則;代價是分公司(session)改了規則書,警衛不知道,改 hook 要 merge 進 main、主 checkout 拉到之後才算上線。

Claude Code hooks:PreToolUse 在工具執行前觸發。
Write/Edit 與 Bash
Claude 寫檔有兩種方式,對警衛來說差很多:
| 面向 | Write/Edit | Bash |
|---|---|---|
| 交給 hook 的 | 一個檔案路徑欄位 | 一整條 shell 命令 |
| hook 要做的 | 比對字串 | 自己讀懂 shell |
| 讀錯的後果 | 幾乎不會讀錯 | 讀錯就漏 |
最後上線的 hook 只看 Write/Edit,就是建在這個差別上。
heredoc 與 shlex
第一版的 hook 為了讀懂 Bash 命令,用了兩個工具。這兩段最後都拿掉了,但不先懂它們,就看不懂為什麼會一直漏。
heredoc 是寫在命令裡的一整段多行文字:<<'EOF' 表示從下一行起、到單獨一行 EOF 為止,都是這個命令的輸入。Claude 寫多行 commit 訊息時很常用:
git commit -F - <<'EOF'
build: 新增藝評頁
- 產出 art/wiki/xxx.md
EOF
heredoc 裡的文字是資料,不是要執行的命令。所以 commit 訊息裡就算「提到」某個指令,也不能當成真的要執行,第一步就要先把它拿出來另外放。
shlex 是 Python 內建的模組,照 shell 的規則把命令切成一個個詞,看得懂引號,也會把 &&、|、> 切成獨立的符號:
git commit -m "art: x" 2>&1 | tail -3
→ git / commit / -m / art: x / 2 / >& / 1 / | / tail / -3
引號裡的 art: x 保持一整塊,這就是 commit 標題;| 把它分成兩個命令;2 >& 1 是重導向,要拿掉,不然會被當成 commit 的路徑(審查第 1 輪就抓到這個)。主要提到這個是因為我底層是用 Python 撰寫。
git 與 repo 設定
我原本以為 git status 就只是讀檔,但 repo 自己的設定可以讓 git 在清點工作區時,會先跑某個外部程式(fsmonitor、clean filter 這類設定)。當 hook 叫 git 清點,就等於在我按允許之前,先跑了 repo 設定的程式。
這些設定只在本機,不會 push 到 GitHub,clone 也不會帶過來;現實上機率很低,風險來自下載別人的 repo、agent 被網頁誘導改了設定,或 Claude 自己出錯。
symlink
資料夾裡可以放一個 symlink,像是指向別處的路標。警衛為了確認一個路徑真正在哪,就會跟著路標走;路標要是指向別台電腦,警衛就在我按允許之前連出去了。
想法與取捨
停寫這一半
這一半只改指示和資料
什麼時候停:merge 當下就停。「不趕快改的話,中間會有很多的檔案需要溯及既往回來。」
review 怎麼清:原本的三個選項,全都在問「怎麼證明 Phase 2 跑過」。我一開始只能回「需要更多的資訊」,看完資料後才想說:「如果那個檔案不存在、或只看最新的,會有什麼問題?」這個問題生出了第四個選項:Phase 2 處理完就在同一個 commit 把 review 刪掉,根本不需要證明。

log 的條列去哪:寫成 commit 訊息的格式規則,原本 log 裡的條列改寫進 commit body,讓 git 真的接手紀錄。
計畫階段的來回
Grill 兩輪、計畫查核一輪,每一關都抓到上一關沒抓到的
- Grill 第 1 輪:第一版計畫用 regex 掃整條命令,會擋到自己的測試命令、
gh pr create --body,甚至 commit body。審查員還把「commit 格式要不要用 hook 擋」拆成三題丟回給我(下面的直接擋、Windows、拆不拆)。 - Grill 第 2 輪:查到 hook 是從主 checkout 跑的,不是 worktree。這讓上線順序變成「merge、主 checkout 拉到、才算上線」,也讓新 hook 在實作的 worktree 裡根本測不到。
- 計畫查核:抓到 Grill 沒抓到的事實錯,像是 git 會把中文路徑印成八進位跳脫(細節在踩到的坑)。
要不要擋
想要硬限制,所以一路選了 hook
擋不擋新寫入:要擋。「這個東西可能是我在很之後才會發現,所以希望切換過去的話,就要馬上用個限制把它擋起來。」
commit 格式要不要也擋:我追問了一句「commit msg 需要 hook 嗎」。選項有三個:git 的 commit-msg hook、Claude Code 的 PreToolUse hook、只寫在 CLAUDE.md。我選 Claude 的 hook,它只管 Claude 在 Bash 下的 git commit,我在 Obsidian 按的 commit 和腳本自己的 commit 都不經過它。用 git 的 commit-msg hook 等於我需要額外設定,CLAUDE.md 則是軟限制。
先警告還是直接擋:直接擋。「警告我可能會忽略掉,但直接擋的話,這種硬限制會是比較明確的格式,會讓我發現。」
Windows 怎麼做:Windows 那台的 PowerShell 版 hook 把解析交給 Python,解析只寫一份。兩個系統各有最佳解,但跑兩份實作,邏輯很容易兩邊不一樣,沒注意到的地方甚至會漂掉;而且我一次只在一台電腦工作,每次都要到另一台驗證,非常麻煩。
拆不拆:本來選拆成兩次上線,停寫那一半先上、hook 那一半後上,hook 可以單獨 revert。順序不能反過來,不然 hook 會在 skill 還寫著「append log」的時候就開始擋。後來我改口「我覺得很像合在一起一次痛完會比較好一點」,但停寫那一半已經先上線了,所以實際上還是分兩次。
三次退回
規則都一樣,變的只是警衛怎麼知道包裹裡有什麼
hook 裡跟這次有關的是兩條規則:規則 5 擋寫月檔,規則 6 檢查 commit 格式。三次退回之前要先講清楚:規則本身都沒變,規則 6 一直都是「包裹裡有內容頁,標籤就要寫 build: 這類開頭」。卡住的地方都是同一件事:警衛怎麼知道包裹裡裝了什麼。每次我換了做法,審查就從第 1 輪重算,hook 那一半前後審了四個範圍、每個三輪。
第一次:讀工作區會執行程式
規則 6 要知道 git add -A、commit -a 會帶哪些檔,命令上沒寫,就只能叫 git 去讀工作區,也就是前面「git 與 repo 設定」講的,在我按允許之前先跑了 repo 設定的程式。三輪審查每輪補一個,下一輪就找到下一個。
這裡我一開始把兩種「安全」混在一起,後來才拆開:
| 面向 | 對電腦安全 | 攔得住 |
|---|---|---|
| 在問 | 警衛會不會啟動別人藏的程式 | 寫錯開頭擋不擋得下 |
| 只讀 index | 拿掉這整類風險 | 保留大部分 |
| 完全不跑 git | 最安全 | 攔得最少 |
| 繼續清點、一個個封 | 清點本身就是風險 | 攔得最多 |

選之前我先問了寫錯開頭的後果是什麼。沒有任何程式會讀 commit 標題,Claude 也只在被要求查歷史時才讀,寫錯只是查歷史時漏一筆,紀錄本身還在。所以我選了完全不跑 git、只看命令文字:「我可以接受,攔截率是最弱的,但是我會希望我的電腦是安全的。」
第二次:走路徑會碰到網路
不跑 git 了,但還是要把命令裡的路徑算成真正的位置,一碰到檔案系統,就可能被 symlink 或掛載點帶到網路上。三輪審查又各找到一種新的走法。hook 從 269 行長到 888 行,多出來的幾乎都在處理「看的時候不要被帶走」。
我請一個全新 context 的 subagent 評估拿掉規則 6 會怎樣:影響很小。沒有程式讀 commit 標題、skill 已經寫明開頭,10 月最後一週的不合格率是 0%;真正的缺口反而是 70% 的內容 commit body 是空的,而這條規則本來就不管 body。
所以我把它拿掉了:「我放棄規則六,是因為它似乎帶給我的效益沒有很高。」補償是 /vault-maintain 每週列一次開頭寫錯或 body 空的 commit,CLAUDE.md 再加一行「不經 skill 的內容 commit 也要 operation 前綴與 body」。
第三次:解析 shell 對不上
剩下規則 5,Write/Edit 那半只是比字串,但 Bash 那半要 hook 自己讀懂 shell 語法。三輪審查每輪都找到一種新的讀錯寫法(引號、註解、巢狀的 $(...) 這類),這次是漏擋,跟安全無關。
選項有三個:
- 拿掉 Bash 那半:只管 Write/Edit,Bash 寫月檔交給 lint 事後抓
- 保留:把漏洞寫成已知限制,不再追
- 看不懂就擋:漏擋變少,但會多誤擋
我選了第一個。代價是 Bash 寫月檔不再即時擋,要等 lint 事後抓到。

回頭看,三次是同一個形狀一路往下一層:讀工作區、走檔案路徑、解析 shell,每補一個洞,就露出下一層。
實作
commit 訊息格式
CLAUDE.md 的 Log 格式段,整段換成 commit 訊息格式:
{operation}[(scope)]: {標題}
- 條列操作內容(原本寫進 log 的那些)
- 動到任一域的
raw/、wiki/、logs/、域根目錄.md、learning/或hubs/的內容操作,標題開頭限定ingest、add、update、triage、enrich、build、render、lint、compile、cards、cover,可以帶 scope(build(art): …)。 - 同一個 commit 也動到
.claude/、.github/或CLAUDE.md的建造型 commit 不受限。 - 不經 skill 的內容 commit(手動修、docs)也一樣要 operation 前綴與 body,沒有 hook 擋,由
/vault-maintain每週報告列出來。
這段就是 log 月檔的替代品:查歷史改成只比對 commit 標題。
review 的刪除
inbox-triage 的 Phase 2 收尾,在同一個 commit 裡刪掉 review:
- 本份 review:`git rm`。
- 日期更早的 review:它列的每個 Inbox 檔,要嘛也列在本份 review 裡,要嘛已不在 `Inbox/`,才 `git rm`(它已被本份取代);否則保留,在報告列出檔名與原因。
刪掉的 review 都還在 git 裡,commit body 記下它最後一版的 SHA,要還原就 git show <sha>:log/triage-reviews/<檔名>。原本的清理腳本(153 行)連同 /vault-maintain 的 I5 一起拿掉。
lint 保底
lint 加了三條檢查,I9 提醒殘留的 review,跟月檔有關的是另外兩條:E8 是 log/ 出現 2026-10 之後的月檔,W8 是這條分支上有 commit 動到舊月檔:
# E8 a monthly file after the last one written
for p in sorted((v.root / "log").glob("*.md")):
m = re.fullmatch(r"(\d{4}-\d{2})\.md", p.name)
if m and m.group(1) > LAST_LOG_MONTH:
out.append(finding("E8", v.rel(p), ...))
# W8 commits on this branch touching the frozen months (merges excluded: resolving a conflict is fine)
# ...
shas = subprocess.run(["git", "-c", "core.quotepath=false", "-C", str(v.root), "log", "--no-merges",
"--format=%h", "origin/main..HEAD", "--", "log/20*.md"], ...).stdout.split()
實際跑起來,這兩條各觸發一次的樣子:
E8 log/2026-11.md monthly log file after 2026-10 found: log/2026-11.md. ...
W8 log/ 1 commit(s) on this branch touch log/20*.md (compared with origin/main <sha>); ...
hook 不再擋 Bash 之後,Bash 寫進月檔就是靠這兩條在 PR 前抓到。
最後的 hook
擋寫月檔的規則只剩 Write/Edit,而且只用字串:
def text_path(text: str, base: str | None) -> str | None:
"""`text` as an absolute, normalised path string (forward slashes), computed without touching
the filesystem. None when it can't be known from the text (relative with no base)."""
t = text.replace("\\", "/")
# ...
if not os.path.isabs(t):
if base is None:
return None
t = base.rstrip("/") + "/" + t
return os.path.normpath(t).replace("\\", "/")
def check_log_write(fp: str, cwd: str) -> None:
"""Rule 5, Write/Edit side."""
# ...
roots = session_roots(cwd)
root = is_log_month(text_path(fp, text_path(cwd, None) if cwd else None), roots)
if root is not None and not merge_in_progress(root):
deny_log_month(fp)
Write/Edit 給的路徑接上 session 所在的目錄、正規化,再看它是不是 <repo 根>/log/YYYY-MM.md:不 resolve、不展開 symlink、不開檔。merge 進行中的判斷也不跑 git,直接讀 .git 裡有沒有 MERGE_HEAD 這類檔。拿掉 Bash 那半之後,Windows 的 PowerShell hook 也不再呼叫 Python。
write_guard.py 從第二次退回時的 888 行、第三次退回時的 676 行,縮到 502 行;案例表從 64 列降到 38 列,審查後補到 42 列。這個範圍的三輪審查,第 3 輪沒有剩下的問題。
每週報告
拿掉規則 6 的補償,是把原本給它用的分類邏輯留下來,變成 --replay 每週報告:
$ python3 .claude/hooks/write_guard.py --replay "$(git rev-list -1 --before='7 days ago' origin/main)..origin/main"
content commits 70: bad prefix 1, empty body 56; skipped (scripts / obsidian-git) 0
一週 70 個內容 commit,開頭寫錯 1 個,body 空的有 56 個,這才是真正的缺口(前面的 70% 是改之前 subagent 的估算,這裡是改完後實際跑出來的數字)。
踩到的坑
自證的 review
改寫 review 的處理方式時發現
原本想把清理腳本的閘門改成查 git:有對應的 triage: commit 就算處理過。結果產出 review 的那個 commit 也是 triage: 開頭、日期就是當天,每份 review 都會被自己證明「處理過」。
worktree 裡的 hook
Grill 第 2 輪查到
hook 從主 checkout 跑,實作時新的 hook 在實作用的 worktree 裡根本不生效,只能靠案例表直接呼叫 worktree 那份 dispatcher 來測。說明文件原本還寫著「worktree 內同樣生效」,是反的,一起改掉。
擋到自己的 regex
Grill 第 1 輪,加上實作時兩次
對整條命令跑 regex 的舊規則,實作時兩次擋下我自己的命令:一次是改案例表的命令,一次是開變更說明的命令,因為命令文字裡有「推到 main」的測試字串。第一版計畫的 regex 也一樣,會擋到自己的測試命令和 commit body。正好說明為什麼要先把 heredoc 拿出來、再用 shlex 切詞。
中文路徑的八進位
計畫查核抓到
git 預設會把中文路徑印成八進位跳脫:
"art/wiki/\344…"
第一段變成 "art,就判斷不出是內容路徑,改成 -c core.quotepath=false -z 才正常。這是第一版 commit 格式規則呼叫 git 的地方,第一次退回之後就整段拿掉了。
heredoc 佔位符
案例表第一次跑就抓到
shlex 的 punctuation_chars 會把 heredoc 佔位符前面的 << 切開,-F - 就讀不到訊息,佔位符改成不帶 << 才好。這段程式最後也整段拿掉了。
計畫漏列的 log 指示
實作時 grep 找到
計畫盤點了 15 處 log 指示,實作時 grep 又找到三處漏列的,一起改成寫 commit。
小結
vault 現在不寫 log 月檔了:操作紀錄在 commit 標題和 body,triage review 處理完就刪,hook 只擋 Write/Edit 寫月檔,其他的交給 lint 和每週報告事後抓。
三次退回,我選的都是攔得比較少的那邊,但強化電腦安全。這跟一開始「完全不會翻 log 月檔」是同一條線:紀錄本來就很少被讀,不值得為了攔得更多,去冒執行程式的風險。
另外,工作流的「第 3 輪還有中度問題就交回」,並不分這條規則是屏障還是提示;第三次退回那條,本來就寫著「是提示,不是屏障」,漏擋對它來說其實可以接受。審查的嚴重度,也許該跟規則本身的定位掛鉤。之後有空再來調整