Notes
← All notes
·ai·11 min

把規則寫進 MEMORY.md 不夠:為什麼三週還是踩了 6 次同一個雷,最後靠 hook 才止血

三週前我把規則寫進 MEMORY.md 下的兩份 feedback 檔,預期再犯機率歸零,結果 30 天內又踩了 6 次。把同一條規則改寫成 PreToolUse hook 之後,工具層會直接 exit 2 拒絕 tool call——LLM 沒有「不執行 hook」這個選項。記錄為什麼「文字提醒」會穩定失效,以及怎麼判斷一條規則該停在 prose 還是該升級成工具門禁。

#claude-code#hook#codification#retrospective#beginner-friendly

三週前我把一條規則寫進 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)。