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
166 lines
6.9 KiB
Bash
Executable File
166 lines
6.9 KiB
Bash
Executable File
#!/usr/bin/env bash
|
||
# ask-user-question-guard.sh 的離線迴歸測試(inkstone/InkStoneCo#55)。
|
||
#
|
||
# 這一檔測的是**管路**:抓不抓得到那個動作、擋的時候 exit 幾、
|
||
# 判官掛掉會不會把 session 卡死、同一題會不會擋第二次。
|
||
# **判官的準度另外一檔測**(`ask-user-question-guard.live.test.sh`,那支真的叫 haiku)。
|
||
#
|
||
# 判官在這裡用替身(`ISEP_ASK_JUDGE_CMD` 指向一支印固定 JSON 的假腳本),
|
||
# 所以本檔 **不打網路、不花錢、跑得完**。
|
||
#
|
||
# 兩個方向都要有證據:
|
||
# A 群「該放行」——非 AskUserQuestion 的動作、判官說 allow、判官壞掉、豁免戳記、第二次重送
|
||
# B 群「該擋」 ——判官說 block(純技術路徑選擇)
|
||
# C 群「訊息承諾的出路真的走得通」——重送與豁免戳記各一個實例
|
||
#
|
||
# 用法:hooks/tests/ask-user-question-guard.test.sh [hook 路徑]
|
||
|
||
set -u
|
||
HOOK="${1:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/ask-user-question-guard.sh}"
|
||
TMP=$(mktemp -d)
|
||
trap 'rm -rf "$TMP"' EXIT
|
||
|
||
PASS=0; FAIL=0; N=0
|
||
|
||
# 判官替身:吃 stdin、印一段固定的裁決
|
||
mkstub() {
|
||
cat > "$TMP/judge-$1.sh" <<EOF
|
||
#!/bin/sh
|
||
cat > /dev/null
|
||
printf '%s' '$2'
|
||
EOF
|
||
chmod +x "$TMP/judge-$1.sh"
|
||
printf '%s' "$TMP/judge-$1.sh"
|
||
}
|
||
|
||
STUB_BLOCK=$(mkstub block '{"decision":"block","gate":"none","reason":"純技術路徑選擇,四題全否"}')
|
||
STUB_ALLOW=$(mkstub allow '{"decision":"allow","gate":"④","reason":"UI 取捨是品味題"}')
|
||
STUB_EMPTY=$(mkstub empty '')
|
||
STUB_JUNK=$(mkstub junk 'Error: model overloaded')
|
||
STUB_NEVER="$TMP/judge-does-not-exist.sh"
|
||
|
||
# payload <問題> [選項...]
|
||
payload() {
|
||
python3 - "$@" <<'PY'
|
||
import json, sys
|
||
q, opts = sys.argv[1], sys.argv[2:]
|
||
print(json.dumps({
|
||
"session_id": "S-TEST",
|
||
"hook_event_name": "PreToolUse",
|
||
"tool_name": "AskUserQuestion",
|
||
"tool_input": {"questions": [{
|
||
"question": q,
|
||
"header": "決策",
|
||
"multiSelect": False,
|
||
"options": [{"label": o, "description": o + " 的說明"} for o in opts],
|
||
}]},
|
||
}, ensure_ascii=False))
|
||
PY
|
||
}
|
||
|
||
# t <期望 exit> <判官替身> <說明> <payload JSON>
|
||
t() {
|
||
want="$1"; judge="$2"; desc="$3"; body="$4"
|
||
N=$((N+1))
|
||
out=$(printf '%s' "$body" | ISEP_ASK_JUDGE_CMD="$judge" sh "$HOOK" 2>&1); rc=$?
|
||
if [ "$rc" -eq "$want" ]; then
|
||
printf ' ✅ %s\n' "$desc"; PASS=$((PASS+1))
|
||
else
|
||
printf ' ❌ %s —— 期望 exit=%s,實得 exit=%s\n' "$desc" "$want" "$rc"
|
||
printf '%s\n' "$out" | sed -n '1,6p' | sed 's/^/ /'
|
||
FAIL=$((FAIL+1))
|
||
fi
|
||
}
|
||
|
||
clean() { rm -f /tmp/.ask-gate-warned-* /tmp/.ask-ok-S-TEST 2>/dev/null || true; }
|
||
|
||
echo "── A 群:該放行 ────────────────────────────────────────────────"
|
||
clean
|
||
|
||
t 0 "$STUB_BLOCK" "① 不是 AskUserQuestion 的動作,一律不管(連判官都不叫)" \
|
||
'{"tool_name":"Bash","tool_input":{"command":"ls"}}'
|
||
|
||
t 0 "$STUB_BLOCK" "② tool_input 是空的 → 沒東西可判,放行" \
|
||
'{"tool_name":"AskUserQuestion","session_id":"S-TEST","tool_input":{}}'
|
||
|
||
t 0 "$STUB_BLOCK" "③ 整包不是合法 JSON → fail-open" \
|
||
'this is not json at all'
|
||
|
||
clean
|
||
t 0 "$STUB_ALLOW" "④ 真人閘之一(品味/方向):樹要不要捲——判官 allow,leo 該收到" \
|
||
"$(payload '總圖那棵樹,同層節點超過 20 個時要不要預設收合?' '預設收合' '全部展開')"
|
||
|
||
clean
|
||
t 0 "$STUB_EMPTY" "⑤ 判官回空(掛了/沒網路)→ fail-open,不准把問題卡住" \
|
||
"$(payload '這個 worker 要不要改用 D1 存執行紀錄?' 'D1' 'KV')"
|
||
|
||
clean
|
||
t 0 "$STUB_JUNK" "⑥ 判官回的不是 JSON → fail-open" \
|
||
"$(payload '這個 worker 要不要改用 D1 存執行紀錄?' 'D1' 'KV')"
|
||
|
||
clean
|
||
t 0 "$STUB_NEVER" "⑦ 判官根本不存在(claude 不在 PATH)→ fail-open" \
|
||
"$(payload '這個 worker 要不要改用 D1 存執行紀錄?' 'D1' 'KV')"
|
||
|
||
clean
|
||
touch /tmp/.ask-ok-S-TEST
|
||
t 0 "$STUB_BLOCK" "⑧ 明示豁免戳記在 → 放行(C 群:訊息裡承諾的出路)" \
|
||
"$(payload '這題我確定是人閘,先放我過' 'A' 'B')"
|
||
if [ -f /tmp/.ask-ok-S-TEST ]; then
|
||
printf ' ❌ ⑧b 豁免戳記用完沒被消掉(會變成永久開關)\n'; FAIL=$((FAIL+1))
|
||
else
|
||
printf ' ✅ ⑧b 豁免戳記用完就消失,不是永久開關\n'; PASS=$((PASS+1))
|
||
fi
|
||
N=$((N+1))
|
||
|
||
echo
|
||
echo "── B 群:該擋(純技術路徑選擇,四題全否)─────────────────────"
|
||
clean
|
||
|
||
TECH="$(payload '重試邏輯要放在 client 還是 worker 那一層?' '放 client' '放 worker')"
|
||
t 2 "$STUB_BLOCK" "⑨ 判官 block → exit 2,問題不會送到 leo 面前" "$TECH"
|
||
|
||
# 訊息內容檢查:要點名 leo 的原話與四題公式,且要給得出出路
|
||
# (先清戳記,否則這次會走「同一題只擋一次」那條路而沒有訊息)
|
||
clean
|
||
msg=$(printf '%s' "$TECH" | ISEP_ASK_JUDGE_CMD="$STUB_BLOCK" sh "$HOOK" 2>&1 >/dev/null || true)
|
||
N=$((N+1))
|
||
if printf '%s' "$msg" | grep -q '為什麼 hooks 沒有攔下來' \
|
||
&& printf '%s' "$msg" | grep -q '四題' \
|
||
&& printf '%s' "$msg" | grep -q '重送'; then
|
||
printf ' ✅ ⑩ 擋下的訊息帶了:leo 原話+四題公式+重送的出路\n'; PASS=$((PASS+1))
|
||
else
|
||
printf ' ❌ ⑩ 擋下的訊息缺了「leo 原話/四題公式/出路」其中一項\n'
|
||
printf '%s\n' "$msg" | sed -n '1,8p' | sed 's/^/ /'
|
||
FAIL=$((FAIL+1))
|
||
fi
|
||
|
||
# 迴歸:訊息裡的反引號一度被 shell 當成命令替換執行掉
|
||
# (原本寫 `cat >&2 <<EOF`,於是 `system-dev/wiki/` 真的被當指令跑,
|
||
# stderr 冒出 "is a directory",而 wiki 路徑與豁免指令在訊息裡雙雙變成空白
|
||
# ——閘還是擋了,但**它教人怎麼解的那兩行是空的**,等於沒有出路。)
|
||
N=$((N+1))
|
||
if printf '%s' "$msg" | grep -q 'system-dev/wiki/' \
|
||
&& printf '%s' "$msg" | grep -q 'touch /tmp/.ask-ok-S-TEST' \
|
||
&& ! printf '%s' "$msg" | grep -q 'is a directory'; then
|
||
printf ' ✅ ⑩b 訊息原文照印:反引號沒有被當成命令執行,出路那兩行沒有變空白\n'; PASS=$((PASS+1))
|
||
else
|
||
printf ' ❌ ⑩b 訊息被 shell 展開了(wiki 路徑/豁免指令消失,或冒出 shell 錯誤)\n'
|
||
printf '%s\n' "$msg" | sed -n '1,20p' | sed 's/^/ /'
|
||
FAIL=$((FAIL+1))
|
||
fi
|
||
|
||
echo
|
||
echo "── C 群:訊息承諾的出路真的走得通 ─────────────────────────────"
|
||
# 上面兩次已經把戳記寫下去了,同一題原封不動重送 → 必須放行
|
||
t 0 "$STUB_BLOCK" "⑪ 同一題原封不動重送 → 放行(判官誤判不會讓 leo 永遠收不到)" "$TECH"
|
||
|
||
clean
|
||
DIFFQ="$(payload '要不要把這批舊資料刪掉?這是不可逆的' '刪掉' '留著')"
|
||
t 2 "$STUB_BLOCK" "⑫ 換一題(不同雜湊)→ 戳記不共用,仍然會判" "$DIFFQ"
|
||
clean
|
||
|
||
echo
|
||
echo "══ $PASS/$N 通過${FAIL:+,$FAIL 個失敗} ══"
|
||
[ "$FAIL" -eq 0 ]
|