#!/usr/bin/env bash # ask-user-question-guard.sh — PreToolUse(AskUserQuestion):**在「問出口的那一刻」攔住** # # 病根(leo 2026-08-26):「**今天已經好幾次問我,為什麼 hooks 沒有攔下來?**」 # 當天總管至少兩次把純技術路徑選擇丟給 leo,其中一次他當場說 # 「**這種問題不要問我,我要的是你解決了以後給我 prod**」——而**一支閘都沒響**。 # # 實查(2026-08-26,ISEP 0.3.8): # • 總管問 leo 走的動作就是 `AskUserQuestion` 這個工具 # • `hooks.json` 裡 `AskUserQuestion` 出現 **0 次**——它沒有任何 matcher,是裸的 # • `self-drive-police.sh` / `self-drive-judge.sh` 只掛在 `Stop` / `SubagentStop` # ⇒ 它們是**回合結束後**才跑的。問題送出去、leo 已經被打斷之後, # 再來反問「你查過了嗎」已經沒有意義——**成本早就轉嫁出去了**。 # # ⇒ 這支補的不是判準(判準跟自走警察同一套四題公式),補的是**時機**: # `Stop` 是事後補刀,`PreToolUse` 才是當下攔截。 # # ────────────────────────────────────────────────────────────────────── # 🔴 封的是動作,不是文字(leo 2026-08-17 的檢驗) # # leo 原話:「你在**文字層**封路⋯⋯『回「做」我就啓動』沒有問號,下次它會寫 # 『準備就緒』,再下次寫『待命中』。**自然語言的變體是無限的,blacklist 永遠追不完。** # 封路哲學之所以有效,是因為它封的是**動作**——動作有限且可枚舉,文字不是。」 # 當日實證:文字層的閘 **8 次誤攔、0 次正確攔截**,且方向穩定 # ——**紅線寫得越細,命中關鍵字的機率越高 ⇒ 那些閘在懲罰謹慎。** # # 本閘怎麼過這個檢驗: # • **觸發條件=那個動作本身**(`AskUserQuestion` 這一次呼叫), # 不是任何句型、關鍵字、問號、措辭。改寫成什麼樣子都一樣會進來。 # • **沒有任何 grep 黑名單**(全檔零個判擋用的正則)。換句話說閃不過去, # 講得謹慎也不會被多罰——這正是文字層閘的兩個病,本閘兩個都沒有。 # • 進來之後要分「該問 / 不該問」,靠的是**四題公式的語意判官** # (`self-drive-judge.sh` 已經在用的同一套 haiku 判官), # 它判的是「這題命不命中人閘」,不是「這句話長什麼樣」。 # # 🔴 不准把真人閘也擋掉(本閘存在的第二個前提) # 四題命中時 leo **要**被問——2026-08-26 當天他就當場裁了一題 UI 取捨(樹要不要捲)。 # **擋掉那種等於讓總管替他決定他的品味,比不擋更糟。** # 所以本閘有兩層保險: # ① 判官只在「四題全否」時才回 block;任何一題命中一律 allow # ② **同一個問題只擋一次**(雜湊戳記)——判官若誤判,重送即放行, # leo 該收到的問題絕不會因為一支閘而永遠送不到。 # (跟 `micromanage-guard.sh`/`subagent-first-guard.sh` 同一個慣例: # 閘是絆一下逼你重想,不是把人關起來。) # # 判準(跟 CLAUDE.md 規則二、`self-drive-judge.sh` 完全同一套,不另立第二套): # ① 花錢 ② 不可逆/難回收 ③ 跨專案結構 ④ 品味/方向 ⑤ 物理人閘(只有人做得到) # 命中任一 → 放行。四題全否 → 擋一次,逼它自己裁。 # # 失敗一律 fail-open(exit 0):判官掛掉/沒網路/`claude` 不在 PATH, # 都只會回到「今天的狀態」(沒有閘),不會變成「問不出去」。 # ⇒ 這支閘壞掉的最壞後果,等於它不存在,不會癱瘓任何 session。 # # 官方文件核實(2026-08-26 查 code.claude.com,不是憑記憶): # • `AskUserQuestion` **會**觸發 PreToolUse,matcher 寫工具名有效 # — https://code.claude.com/docs/en/agent-sdk/user-input # 原文:「Claude calls AskUserQuestion. The PreToolUse hook fires.」 # • `tool_input` 的真實結構=`{"questions":[{"question","header","options":[{"label","description"}],"multiSelect"}]}` # — 同頁「Question format」段 # • exit 2 在 PreToolUse = hard block,工具呼叫不會發生,無已知例外 # — https://code.claude.com/docs/en/hooks(Exit Codes 表) # • `AskUserQuestion` **在 subagent 裡不可用**(同頁 Limitations) # ⇒ 所以本閘只會在主 session 觸發;subagent 那一側仍由 # `self-drive-police.sh`/`self-drive-judge.sh` 的 SubagentStop 守著,兩者不重疊。 # # 測試: # hooks/tests/ask-user-question-guard.test.sh 離線、不花錢(判官用替身) # hooks/tests/ask-user-question-guard.live.test.sh 真的叫 haiku,量判官的準度 set -u # 遞迴保險:判官自己起的那個 claude 不准再觸發本閘 if [ "${SELF_DRIVE_JUDGE:-}" = "1" ]; then exit 0; fi input="$(cat)" # ── 只管 AskUserQuestion 這一個動作 ──────────────────────────────── tool_name="$(printf '%s' "$input" | python3 -c ' import sys, json try: print(json.load(sys.stdin).get("tool_name", "")) except Exception: print("") ' 2>/dev/null)" [ "$tool_name" = "AskUserQuestion" ] || exit 0 session_id="$(printf '%s' "$input" | python3 -c ' import sys, json try: print(json.load(sys.stdin).get("session_id", "")) except Exception: print("") ' 2>/dev/null)" # ── 把問題本體攤成純文字(給判官看,不給 grep 看)────────────────── # 刻意用「遞迴撈出所有字串」而不是寫死欄位名:AskUserQuestion 的 tool_input # schema 若哪天改欄位(questions/question/header/options/label/description…), # 這支不會變成靜默失效的空判官。 question_text="$(printf '%s' "$input" | python3 -c ' import sys, json def walk(node, out): if isinstance(node, str): s = node.strip() if s: out.append(s) elif isinstance(node, list): for x in node: walk(x, out) elif isinstance(node, dict): for k in ("question", "header", "label", "description", "text"): if k in node: walk(node[k], out) for k, v in node.items(): if k not in ("question", "header", "label", "description", "text"): walk(v, out) out = [] try: d = json.load(sys.stdin) walk(d.get("tool_input", {}), out) except Exception: pass seen = set() uniq = [x for x in out if not (x in seen or seen.add(x))] print("\n".join(uniq)) ' 2>/dev/null)" # 撈不到內容 ⇒ 沒東西可判 ⇒ 放行(fail-open) [ -z "$question_text" ] && exit 0 # ── 同一個問題只擋一次 ──────────────────────────────────────────── # 判官誤判時,總管重送同一題就過得去 ⇒ leo 該收到的問題不會被永久攔住。 stamp="/tmp/.ask-gate-warned-$(printf '%s' "$question_text" | shasum | cut -c1-12)" [ -f "$stamp" ] && exit 0 # ── 明示豁免(留痕用)────────────────────────────────────────────── # 真的緊急、或判官在誤攔而不想改寫問題時: # touch /tmp/.ask-ok- # 用掉就消失,不是永久開關。 if [ -n "$session_id" ] && [ -f "/tmp/.ask-ok-$session_id" ]; then rm -f "/tmp/.ask-ok-$session_id" exit 0 fi # ── 語意判官(跟 self-drive-judge.sh 同一套判準、同一個模型)────── judge_prompt="你是「人閘判官」。下面是一個 AI 助理(總管)**正要送給老闆 leo 的一個提問**。 判斷這個問題**該不該送到 leo 面前**。 判準只有一條——四題人閘公式。它問的事情是否命中下列任一: ① 花錢(付費、開資源、產生帳單、明顯多耗訂閱額度) ② 不可逆/難回收(刪資料、push 到 main、部署上線 prod、對外公開、跨 repo 搬遷) ③ 跨專案結構決策(**repo 與 repo 之間**的邊界:某個能力歸哪個 repo、改全機共用的框架、立一條新鐵律) 🔴 **單一服務/單一 repo 內部怎麼分層、程式碼怎麼組織,不算第③題**——那是實作,不是結構。 ④ 品味/方向(**leo 本人看得到、而且會有偏好的東西**:畫面長相、UI/UX 取捨、 要不要做某個功能、對外文案怎麼寫) 🔴 **內部的設計哲學、容錯策略、責任邊界、程式碼美感,不算第④題**——他看不到那些。 ⑤ 物理人閘(只有人做得到:貼憑證、終端機同意、平台上按批准、真人驗收) 命中任一 → decision=allow(該問)。 四題全否 → decision=block(這是它自己該裁的)。 🔴 判 allow 時要放寬,判 block 時要保守——**誤擋一個真人閘,比放過一個技術題更糟**。 拿不準、或這題同時有技術面與品味面 → 一律 allow。 典型的 block(四題全否,都是純技術路徑選擇): - 用哪個函式庫/哪種實作寫法/要不要抽共用模組 - 錯誤怎麼處理、重試幾次、超時設多久 - 檔案放哪個目錄、變數怎麼命名(純內部、用戶看不到) - 「A 方案和 B 方案我都可以做,你想要哪個?」而兩案的差別只有實作成本 - 「我發現一個 bug,要修嗎?」(修 bug 不是品味題) - 「要不要繼續/要不要我開始」(已經交代過的事) 典型的 allow: - 要不要開一個會產生帳單的資源、要不要換更貴的模型 - 要不要 push 到 main/部署到 prod/刪掉某批資料 - 某個能力該歸哪個 repo、要不要立一條新鐵律 - 畫面上這個東西要長什麼樣、要不要做這個功能、文案怎麼寫 - 需要你去終端機貼憑證/按批准 只輸出 JSON,不要任何其他文字。 輸出格式: {\"decision\":\"allow\"或\"block\",\"gate\":\"命中第幾題或 none\",\"reason\":\"一句正體中文;block 時要指出它在問什麼、以及四題為何全否\"} --- 它要問 leo 的問題 --- $question_text --- 結束 ---" # ⚠️ 判官必須跑在「中性目錄 + --safe-mode」: # 實測(self-drive-judge.sh 2026-08-05):在專案內跑 `claude -p` → 54 秒 # (內層把 SessionStart recall、全部 hook、全部 MCP 工具都載進去); # 換成 `cd /tmp` + `--safe-mode` → 8 秒。判官只需要判一段文字。 # `ISEP_ASK_JUDGE_CMD` 只為了讓離線測試塞得進一個判官替身(見 tests/),預設就是真判官。 JUDGE_CMD="${ISEP_ASK_JUDGE_CMD:-claude -p --safe-mode --model haiku --allowedTools ''}" verdict="$(printf '%s' "$judge_prompt" \ | (cd /tmp && SELF_DRIVE_JUDGE=1 sh -c "$JUDGE_CMD" 2>/dev/null))" # 判官掛了/回空/不是 JSON → fail-open [ -z "$verdict" ] && exit 0 parsed="$(printf '%s' "$verdict" | python3 -c ' import sys, json, re raw = sys.stdin.read() m = re.search(r"\{.*\}", raw, re.S) if not m: print("allow||"); raise SystemExit try: d = json.loads(m.group(0)) print((d.get("decision") or "allow") + "|" + (d.get("gate") or "") + "|" + (d.get("reason") or "")) except Exception: print("allow||") ' 2>/dev/null)" case "$parsed" in block\|*) rest="${parsed#block|}" reason="${rest#*|}" date +%s > "$stamp" # 訊息用 quoted heredoc(裡面有反引號,不能讓 shell 當命令替換執行過), # 佔位符交給 python 換掉——sed 對正體中文與特殊字元的跳脫太脆。 msg="$(cat <<'EOF' 🚦 人閘警察(PreToolUse/AskUserQuestion):**這一問四題全否,是你自己該裁的。** 判官的話:__REASON__ 【leo 2026-08-26】「**今天已經好幾次問我,為什麼 hooks 沒有攔下來?**」 「**這種問題不要問我,我要的是你解決了以後給我 prod。**」 【leo 2026-08-17】「你的命令**被當成『規劃的輸入』,而不是『執行的授權』**⋯⋯ 從它的『文化』看這是禮貌;**從你的系統看,這是違約。**」 ━━━ 不確定時走這條,不要停(CLAUDE.md 常駐授權那段)━━━ 1. 查 wiki(`system-dev/wiki/`,**語意搜尋優先於 grep**) 2. 套四題公式:① 花錢 ② 不可逆 ③ 跨專案結構 ④ 品味方向 3. 仍不確定 → **做出最合理的假設,把假設寫進 commit message 或票的留言,繼續走** ——第 3 步是關鍵:**假設之後不是問,是記錄。** leo 下課後 review 時一次看到所有假設,對的併、錯的打回。 這把同步的提問改造成非同步的問答,**不掛起整個 loop**。 ━━━ 這一問真的命中人閘?━━━ **把命中哪一題直接寫進問題裡再送一次**(例:「四題第②命中:這會刪掉線上資料」), 或 `touch /tmp/.ask-ok-__SESSION__` 後重送。 📌 **同一個問題本閘只擋一次**——原封不動重送就會過。 判官寧可放行也不誤擋,但它會錯;**它錯了不該讓 leo 收不到問題。** EOF )" ASK_MSG="$msg" ASK_REASON="$reason" ASK_SESSION="$session_id" python3 -c ' import os, sys sys.stderr.write( os.environ["ASK_MSG"] .replace("__REASON__", os.environ.get("ASK_REASON", "")) .replace("__SESSION__", os.environ.get("ASK_SESSION", "")) + "\n" ) ' exit 2 ;; esac exit 0