Skip to content

Bug: plugin-defers-to-local 仲裁未比對 $0,本地安裝的 hook 會 defer 給自己 → 兩份都不執行 #9

Description

@sd0xdev

Plugin: sd0x-dev-flow 4.1.0(marketplace sd0xdev-marketplace
影響: 所有「同時把 hook 安裝到 .claude/hooks/ 且註冊進 .claude/settings.json」的專案(也就是跑過 /install-hooks/project-setup 的標準安裝)

症狀

Auto-loop 完全靜默失效,但沒有任何錯誤訊息:

  • .claude_review_state.json 停在 session-init 寫入的初始值,updated_at 整個 session 不再變動
  • code_review / doc_review / precommit 三個 receipt 永遠 executed: false
  • session_commit_scope.touched_files 永遠是 []
  • iteration_history.current_round 永遠 0,即使實際跑了十幾輪 review
  • /next-step 因此持續回報 P0 gate-missing-doc,且無論跑幾次 /codex-review-doc 都不會清除

根因

7 支 hook 開頭都有這個區塊(以 post-tool-review-state.sh:7-31 為例,其餘位置見下表):

# === Plugin-defers-to-local arbitration ===
_SELF_NAME="$(basename "$0")"
if [[ -n "${CLAUDE_PROJECT_DIR:-}" ]] \
   && [[ ! -f "${CLAUDE_PROJECT_DIR}/hooks/hooks.json" ]] \
   && [[ -x "${CLAUDE_PROJECT_DIR}/.claude/hooks/${_SELF_NAME}" ]]; then
  _SETTINGS_MATCH=false
  for _sf in "${CLAUDE_PROJECT_DIR}/.claude/settings.json" \
             "${CLAUDE_PROJECT_DIR}/.claude/settings.local.json"; do
    ...  # grep / jq 找 ".claude/hooks/${_SELF_NAME}"
  done
  if [[ "$_SETTINGS_MATCH" == "true" ]]; then
    exit 0  # Defer to local hook
  fi
fi

條件判斷的是「本地那份存在、且被註冊」,從來沒有比對執行中的 $0 是不是就是那份本地檔案basename "$0" 只用來取檔名,不用來辨識身分。

因此當本地副本被 settings.json 觸發執行時,它同樣通過三個條件(本地檔存在 ✓、可執行 ✓、settings 有註冊 ✓),於是 defer 給自己並 exit 0。plugin 副本被 hooks.json 觸發時也 defer。兩份都退讓,沒有任何一份真正執行。

註解寫的是 "avoid double-fire",實際行為是 zero-fire。

重現

專案已跑過 /install-hooks.claude/hooks/*.sh 存在且可執行、.claude/settings.json 有對應註冊):

P=~/.claude/plugins/cache/sd0xdev-marketplace/sd0x-dev-flow/4.1.0

cat > /tmp/payload.json <<'EOF'
{"session_id":"test","tool_name":"mcp__codex__codex-reply","tool_input":{"prompt":"x"},
 "tool_response":{"content":[{"type":"text","text":"## Document Review\n\nprobe\n"}]}}
EOF

# A. 設了 CLAUDE_PROJECT_DIR(= 真實 session 的樣子)
CLAUDE_PROJECT_DIR="$PWD" bash "$P/hooks/post-tool-review-state.sh" < /tmp/payload.json
#   → 無輸出,exit 0(靜默 defer)

# B. 不設 CLAUDE_PROJECT_DIR(仲裁條件第一項不成立,直接略過)
bash "$P/hooks/post-tool-review-state.sh" < /tmp/payload.json
#   → [Review State] MCP output carries a '## Document Review' header but ...(hook 真的跑了)

本地那份 .claude/hooks/post-tool-review-state.sh 與 plugin 這份 hash 相同,行為完全一致。

旁證session-init.sh 是 8 支裡唯一沒有這個仲裁區塊的,也是唯一實際有效運作的一支——.claude_review_state.json 之所以還有初始內容,就是它寫的。

受影響檔案(4.1.0)

Hook 仲裁區塊起始行
hooks/post-tool-review-state.sh 7
hooks/post-compact-auto-loop.sh 9
hooks/user-prompt-review-guard.sh 9
hooks/stop-guard.sh 13
hooks/post-skill-auto-loop.sh 14
hooks/pre-edit-guard.sh 16
hooks/post-edit-format.sh 17
hooks/session-init.sh —(無此區塊,未受影響)

7 支全部使用 _SELF_NAME="$(basename "$0")",沒有一支比對路徑。

影響評估

嚴重度高於表面症狀:這是 fail-openauto-loop.md § Enforcement 的設計是「hook 無法持久記錄轉換時,寫 fail-closed sidecar marker 並讓 stop-guard 使該 gate 失效」——但這個 bug 讓 hook 根本不執行,連 fail-closed 路徑都走不到。STOP_GUARD_MODE=strict 也不會擋,因為 stop-guard 本身同樣 defer 掉了。

結果是:使用者以為 gate 有 hook 在強制執行,實際上完全沒有,且沒有任何訊號。

建議修法

仲裁應該比對解析後的路徑,只有在「我不是本地那份」時才 defer:

_SELF_NAME="$(basename "$0")"
_SELF_DIR="$(cd "$(dirname "$0")" 2>/dev/null && pwd -P)" || _SELF_DIR=""
_LOCAL_DIR="$(cd "${CLAUDE_PROJECT_DIR}/.claude/hooks" 2>/dev/null && pwd -P)" || _LOCAL_DIR=""

if [[ -n "${CLAUDE_PROJECT_DIR:-}" ]] \
   && [[ ! -f "${CLAUDE_PROJECT_DIR}/hooks/hooks.json" ]] \
   && [[ -n "$_SELF_DIR" && -n "$_LOCAL_DIR" ]] \
   && [[ "$_SELF_DIR" != "$_LOCAL_DIR" ]] \
   && [[ -x "${_LOCAL_DIR}/${_SELF_NAME}" ]]; then
  ...  # 既有的 settings 比對與 defer
fi

pwd -P 解析 symlink,避免 .claude 是連結時誤判。_SELF_DIR 取不到時(極少數情況)保守選擇不 defer——寧可 double-fire 也不要 zero-fire,因為 double-fire 至少會被看見。

另建議加一道健檢:/claude-health/install-hooks 驗證階段實際觸發一次 hook,斷言 .claude_review_state.jsonupdated_at 有變動。純粹比對「檔案存在 + 已註冊」無法偵測這個 bug——本專案兩項都通過,但 hook 一次都沒跑。

暫時繞道

移除 .claude/settings.json 的 hooks 註冊(保留 .claude/hooks/*.sh 檔案本身),讓 plugin 自己的 hooks/hooks.json 驅動。_SETTINGS_MATCH 因此為 false,plugin 副本正常執行。已實測有效。

代價:之後再跑 /install-hooks 會把註冊加回來,bug 重現。

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions