e0557334bc
leo 2026-08-26:「今天已經好幾次問我,為什麼 hooks 沒有攔下來?」 實查:總管問 leo 走的動作是 AskUserQuestion,而 hooks.json 裡它出現 0 次, 沒有任何 matcher。判準其實早就寫好了——self-drive-police / self-drive-judge 用的就是同一套四題公式——但那兩支只掛在 Stop 與 SubagentStop, 是回合結束後才跑的。問題早就送到他眼前了,事後再反問「你查過了嗎」, 成本已經轉嫁出去。判準對了,時機錯了。 新增 hooks/ask-user-question-guard.sh(PreToolUse / AskUserQuestion): - 觸發條件是那個動作本身,不是任何句型或關鍵字——全檔零個判擋用的正則, 換句話說閃不過去,講得謹慎也不會被多罰(leo 2026-08-17 文字層封路的檢驗) - 進來之後用四題公式的 haiku 判官分「該問 / 不該問」,命中任一題一律放行 - 同一個問題只擋一次(雜湊戳記):判官誤判時重送即過, leo 該收到的問題不會因為一支閘而永遠送不到 - 判官掛掉/沒網路/claude 不在 PATH 一律 fail-open,壞掉等於它不存在 版本 0.3.9 → 0.4.0。這不是儀式,是傳輸機制本身:產物按版本號分資料夾 (~/.claude/plugins/cache/inkstone/isep/<版本>/),版本沒動就不會長出新資料夾, 這支閘一個 session 都載入不到。docs/TESTING.md 開頭那段講的就是這件事, 而第一版我漏了——總管複驗時量出來的。 假設(沒有前例可循,先裁再記):跳 0.4.0 而不是 0.3.10。理由是這一版第一次 掛上 AskUserQuestion 這個事件面,是新能力不是修補;而且 0.3.10 在 plugin 快取目錄的 ls 裡會排到 0.3.1 旁邊,肉眼不好認。錯了打回,改號很便宜。 順手修掉自己寫出來的一個坑:訊息原本用沒加引號的 heredoc, 反引號被當命令執行,wiki 路徑與豁免指令兩行變成空白(閘照擋,只看離開碼看不出來)。 已收成 mistakes.md 一條,並由 ⑩b 這條測試守著。 實測: - hooks/tests/ask-user-question-guard.test.sh 14/14(離線,不花錢) - hooks/tests/ask-user-question-guard.live.test.sh 9/9 連跑三次(真的叫 haiku) A 群 5 條真人閘(花錢/不可逆/跨專案結構/品味方向/物理人閘)全部放行,誤攔 0 B 群 4 條純技術路徑選擇全部擋下 - 既有 7 支測試與改動前逐條對照,結果完全相同(沒有被我弄壞) 順手對帳:plugin.json 與 marketplace.json 的描述寫「43 支機械閘、53 條註冊」, 實際數過是 46/56(含本次新增這支)。docs/hooks-inventory.md 一併更正。 【工單】inkstone/InkStoneCo#55
262 lines
14 KiB
Bash
Executable File
262 lines
14 KiB
Bash
Executable File
#!/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-<session_id>
|
||
# 用掉就消失,不是永久開關。
|
||
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", "<session_id>"))
|
||
+ "\n"
|
||
)
|
||
'
|
||
exit 2
|
||
;;
|
||
esac
|
||
|
||
exit 0
|