把規則寫進 MEMORY.md 不夠:為什麼三週還是踩了 6 次同一個雷,最後靠 hook 才止血
三週前我把規則寫進 MEMORY.md 下的兩份 feedback 檔,預期再犯機率歸零,結果 30 天內又踩了 6 次。把同一條規則改寫成 PreToolUse hook 之後,工具層會直接 exit 2 拒絕 tool call——LLM 沒有「不執行 hook」這個選項。記錄為什麼「文字提醒」會穩定失效,以及怎麼判斷一條規則該停在 prose 還是該升級成工具門禁。
三週前我把一條規則寫進 MEMORY.md 下的兩份 feedback 檔,預期再犯機率歸零,結果 30 天內又踩了 6 次同一個雷。把同條規則改寫成 PreToolUse hook 之後當場被擋——因為 LLM 沒有「不執行 hook」這個選項,這跟同一支 hook 上原本就在跑、自始至今 0 違規的「main-branch 直接 Edit」門禁是同一個結構。
這篇不是「hook 比 MEMORY.md 好」這種廢話。是記錄為什麼寫得很清楚的 prose 規則會穩定失效——以及第三次踩同一個雷之後,怎麼判斷該停在 prose 還是該升級成工具門禁。
雷的長相
我的 Claude Code 工作流長這樣:每個 ticket 都開 worktree branch,main 永遠不直接收 commit。但「session 在 worktree A,Edit 卻寫到 worktree B 或 main canonical」這個雷,三週踩了 6 次。
具體模式是這樣:
- session 進到 worktree
claude-design-sysprompt-audit,要編輯一份 wiki 檔。 - Edit 用的絕對路徑是
/Users/yclee/Diary/AI/...——這是 main tree root 的路徑,不是 worktree root。 - 新 wiki 檔加上
_index.md的修改全部寫到 main,worktree 根本沒看到。 - 收工要開 PR 才發現:worktree 是乾淨的,main 反而髒了。Phase 0 rollback。
或是更隱晦的版本:
- session 在 worktree 裡,要改
~/.claude/projects/-Users-yclee-Diary/memory/MEMORY.md。 - 這個路徑長得像「全域 MEMORY.md」,但其實是 symlink,realpath 指到
/Users/yclee/Diary/claude-config/memory/MEMORY.md——main canonical。 - Edit 成功,MEMORY.md 下的檔案更新了,但更新落在 main,不是 worktree。
兩個版本根本原因一樣:worktree session 寫檔時,絕對路徑沒驗證過是不是 worktree root 的後代。
第一次的解法(後來證明沒用)
第一次踩雷後(2026-04-15),我做了「正確答案」:寫了一份 feedback 檔(掛在 MEMORY.md 下),記錄三個 case,明確寫出規則。
**Write/Edit path-root verification (hard rule under worktree session):**
In a worktree session, before Write/Edit, the absolute path prefix MUST be
the current worktree root (known via session env or `pwd`), not the main
tree root.
幾天後又因為 symlink 版本踩雷,我又寫了第二份 feedback 檔:
Before editing any file under `~/.claude/`, first `ls -la <path>`
to confirm whether it is a symlink and the pointed-to location.
兩份 feedback 都掛到 MEMORY.md,每次 session 都會載入。我以為這樣就夠了。
為什麼沒用:機率裝置不會被「寫得更清楚」治好
三週後跑 /pattern-mine(一個我寫來掃跨 session retro 的 skill),結果跳出一個 cluster:「worktree session 寫檔走 symlink / canonical-checkout 路徑」、N=6、30 天內。第二份 feedback 寫完之後還是再犯了 4 次。
我盯著這個數字盯了一陣子。先想到的解釋全都不成立:
- 「feedback 檔太長沒讀完」——不對,每次 session 開始的 routing 一定會載入 MEMORY.md,MEMORY.md 上明確指向兩份 feedback。
- 「寫得不夠清楚」——讀過幾次都覺得寫得很清楚,case 也都列出來了。
- 「LLM 比較笨」——下午會犯,半夜也會犯,跟模型狀態無關。
最後我認了一個更難承認的解釋:LLM 是機率裝置,prose 規則只能降低犯錯機率,不能歸零。10 次裡會有 1 次跳過某條規則,30 天累積足以讓「降到很低」的 6 次再犯出現。
關鍵差異:規則之前其實已經有一條 hook 在運作——pre-edit-branch-check.sh,擋的是「main 上直接 Edit」這件事。從那條 hook 上線之後,main 上 Edit 的違規次數是 0。
不是因為「不要在 main 上 Edit」這條規則寫得比較清楚。是因為它從 prose 升級成 exit 2。
升級成 hook 的版本
PreToolUse hook 在 Claude Code 裡是 shell script,回傳 exit 2 + stderr 訊息會被 Claude Code 收下並拒絕該次 tool call。LLM 沒有「不執行 hook」的選項,這跟 prose 規則最大的差別在這裡。
在原本的 pre-edit-branch-check.sh 上加兩塊:realpath 解析、跨 worktree 門禁。
# Resolve realpath so symlinks (e.g., ~/.claude/projects/... → Diary canonical
# checkout) collapse before the repo walk. New file (path doesn't exist yet)?
# Resolve parent dir then reattach basename.
file_path_real=""
if [ -e "$file_path" ]; then
file_path_real="$(realpath "$file_path" 2>/dev/null || true)"
elif [ -e "$(dirname "$file_path")" ]; then
parent_real="$(realpath "$(dirname "$file_path")" 2>/dev/null || true)"
[ -n "$parent_real" ] && file_path_real="$parent_real/$(basename "$file_path")"
fi
[ -z "$file_path_real" ] && file_path_real="$file_path"
這段把 symlink 路徑攤平。~/.claude/projects/...memory/MEMORY.md realpath 之後會變 /Users/yclee/Diary/claude-config/memory/MEMORY.md——也就是 main canonical 的路徑——後面的 main-branch 檢查就會擋下來。
第二塊是跨 worktree 門禁:
# Cross-worktree gate.
# If the session's CWD belongs to a non-main worktree, the target's realpath
# MUST be under that worktree root.
session_cwd="$(printf '%s' "$input" | jq -r '.cwd // empty')"
[ -z "$session_cwd" ] && session_cwd="$PWD"
session_repo="$(git -C "$session_cwd" rev-parse --show-toplevel 2>/dev/null || true)"
session_repo_real="$(realpath "$session_repo" 2>/dev/null || echo "$session_repo")"
session_branch="$(git -C "$session_repo" branch --show-current 2>/dev/null || echo "")"
if [ -n "$session_repo_real" ] && [ -n "$session_branch" ] && [ "$session_branch" != "main" ]; then
case "$file_path_real" in
"$session_repo_real"|"$session_repo_real"/*) ;;
*)
cat >&2 <<EOF
Edit blocked — target resolves outside the active worktree.
EOF
exit 2
;;
esac
fi
邏輯很短:拿 session CWD 推 worktree root,比對目標檔案的 realpath,不在 worktree 裡就 exit 2。只在 session 自己不是 main 的時候才啟動——session 在 main 的話,原本的 main-branch 檢查已經會擋。
部署之後第一個觸發案例不用等:hook 上線當下我自己第一次 Edit MEMORY.md 下的檔案,用的就是 ~/.claude/projects/... 那條 symlink 路徑——當場被自己寫的 hook 擋下來,再 cd 進 worktree 用本地路徑重 Edit。這個體驗本身就很有說明性:連寫 hook 的人都會犯這條規則,prose 自然擋不住;而 hook 不需要「我以後不會犯」的承諾——同一支 hook 上的 main-branch 門禁從上線到現在 0 違規,是這個結構在 production 跑了一段時間的證據。
怎麼判斷一條規則該升級
不是每條規則都該變 hook。Hook 多了會變成噪音、也會增加維護成本。我現在用三個訊號決定升不升:
| 訊號 | 含意 |
|---|---|
| 同一條規則 30 天內再犯 ≥ 3 次 | prose 已經穩定失效,差異不在「寫得不夠好」 |
| 違規可以從 tool input 100% 偵測 | hook 寫得出來,不需要語意推斷 |
| 違規後修復成本 > 5 分鐘 | 攔在前面比事後 rollback 划算 |
三個都中才升級。我那條雷的 case:6 次再犯(中),絕對路徑 + git rev-parse 就能判斷(中),每次踩雷平均要 phase 0 rollback 或手動 cherry-pick 補回 worktree(中),所以升級。
反例:「commit message 要寫 why 不要只寫 what」這條規則,三個訊號裡只中第一個(會犯),但「what vs why」沒辦法用 shell 偵測,所以留在 prose 是對的。
還沒解的部分
一個誠實的限制要交代:hook 只擋得住能從 tool input 推得出來的違規。前面表格裡的反例就屬於這類——「commit message 要寫 why 不要只寫 what」沒辦法用 shell 表達式判斷意圖,這種需要語意理解的規則 hook 寫不出來,還是只能靠 reviewer agent + prose 規則接。
如果你也跑類似的 multi-agent 工作流,建議的順序是:先寫規則進 MEMORY.md 下的 feedback 檔、跑兩三週、看再犯次數——再犯 ≥ 3 次再考慮升級 hook。一上來就把每條規則都做成 hook,會把工具層做得太重,反而難維護。
完整的 hook 行為矩陣(7 種輸入 × 對應 exit code)和 reviewer agent 的判斷規則寫在付費版 → (link TBD)。