diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index dfd01e9..c294200 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -8,7 +8,7 @@ "plugins": [ { "name": "isep", - "description": "InkStone Environment Plugin —— leo 的 Claude Code 環境唯一真相源:43 支機械閘(53 條註冊,白話盤點見 docs/hooks-inventory.md)、7 支 slash command、2 支 skill、27 支腳本,外加治理規範與標籤真相源。本機與雲端裝同一份,沒有子集。", + "description": "InkStone Environment Plugin —— leo 的 Claude Code 環境唯一真相源:46 支機械閘(56 條註冊,白話盤點見 docs/hooks-inventory.md)、7 支 slash command、2 支 skill、27 支腳本,外加治理規範與標籤真相源。本機與雲端裝同一份,沒有子集。", "author": { "name": "Leo", "url": "https://uncle6.me" diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 16f4d8f..274023b 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "isep", - "description": "InkStone Environment Plugin —— leo 的 Claude Code 環境唯一真相源:43 支機械閘(53 條註冊,白話盤點見 docs/hooks-inventory.md)、7 支 slash command、2 支 skill、27 支腳本,外加治理規範與標籤真相源。本機與雲端裝同一份,沒有子集。", + "description": "InkStone Environment Plugin —— leo 的 Claude Code 環境唯一真相源:46 支機械閘(56 條註冊,白話盤點見 docs/hooks-inventory.md)、7 支 slash command、2 支 skill、27 支腳本,外加治理規範與標籤真相源。本機與雲端裝同一份,沒有子集。", "version": "0.3.8", "keywords": [ "inkstone", diff --git a/docs/TESTING.md b/docs/TESTING.md index 40d718e..97ab691 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -60,6 +60,30 @@ python3 scripts/ticket where 標籤 模組化 ISEP 的叫 `origin`,於是這道閘在新 repo 等於不存在) - 結果只有單一 repo ⇒ 搜尋沒有跨 repo,等於沒搜 +### A9 — 人閘警察的管路:該擋的擋、壞掉不會卡住 session +``` +bash hooks/tests/ask-user-question-guard.test.sh +``` +**該看到**:`14/14 通過`。**不打網路、不花錢**(判官用替身)。 +**失敗**: +- A 群(該放行)任何一條紅 ⇒ **誤攔**,這比漏擋嚴重——它會讓真人閘的問題送不到 leo +- ⑤⑥⑦ 任一條紅 ⇒ fail-open 壞了:判官掛掉會變成「問不出去」,等於一支閘癱瘓整個 session +- ⑩b 紅 ⇒ 訊息被 shell 展開了(2026-08-26 真的犯過:`cat >&2 < 回應 `inkstone/InkStoneCo#40`:「如果加入了,我應該可以白話文看到 hooks 的內容?」 > 這份表就是那個「白話文」——不用點開任何 `.sh` 檔,一行看懂一支閘在管什麼。 @@ -7,10 +7,16 @@ ## 一句話結論 -`hooks/` 底下有 **43 個 `.sh` 檔**,`hooks.json` 實際掛上 **53 條註冊**(同一支閘常被多種情境同時掛上); +`hooks/` 底下有 **46 個 `.sh` 檔**,`hooks.json` 實際掛上 **56 條註冊**(同一支閘常被多種情境同時掛上); 其中 **3 支檔案存在但沒被掛上**(2 支是待人填的空範本、1 支是刻意留著沒開的止血帶,見下面「未生效」表)。 下面按「你會在什麼時候撞到它」分組,一支一行。 +> 🔴 **這兩個數字上一版是錯的(2026-08-26 實際數過才發現)**:本頁原本寫「43 個檔、53 條註冊」, +> 而當時真實是 **45 個檔、55 條註冊**——中間有兩支閘進來時沒有回頭改這裡。 +> 現在的寫法是實際數出來的: +> `ls hooks/*.sh | wc -l` = 46;`hooks.json` 展開後的 command 條目 = 56。 +> **一份會偷偷過期的盤點表,跟沒有盤點表差不多**——見本頁最後「怎麼跟實況對帳」那段。 + --- ## 怎麼讀這張表 @@ -71,6 +77,18 @@ | `irreversible-dispatch-guard.sh` | 派工單裡出現「刪分支」「drop table」「rm -rf」這類不可逆動作,卻沒寫「先停下來等回覆才執行」就擋下。 | 🛑 擋 | | `no-ticket-no-dispatch.sh` | 派工單裡沒有寫工單號(`【工單】owner/repo#N`),或那張票已經關閉/根本不存在,就擋下——沒有票號的工作沒有人追得到進度。 | 🛑 擋 | +## D2. AI 想開口問你問題的當下(PreToolUse / AskUserQuestion) + +| 閘名 | 對你意味著什麼 | 動作 | +|---|---|---| +| `ask-user-question-guard.sh` | AI 要跳出來問你一個問題的**那一刻**先攔一下,用小模型(haiku)照「四題公式」判這題該不該打擾你:**花錢/不可逆/跨專案結構/品味方向/只有你做得到**——命中任何一題就放行(那本來就該問你),四題全否(純技術實作選擇、問「要不要開始」)就擋回去要它自己裁。**同一個問題只擋一次**,它重送就過得去,所以判錯不會害你收不到問題;判官掛掉/沒網路也一律放行。 | 🛑 擋(同一題至多一次) | + +> **為什麼要有這一組**(leo 2026-08-26:「今天已經好幾次問我,**為什麼 hooks 沒有攔下來?**」): +> 在這之前 `AskUserQuestion` 在 `hooks.json` 裡出現 **0 次**,一支閘都沒掛。 +> F 組那兩支自走警察(`self-drive-police` / `self-drive-judge`)判準一樣, +> 但它們掛在「收工」那一刻——**問題早就送到你眼前了**,事後再問 AI「你查過了嗎」已經來不及。 +> 這一組補的是**時機**,不是判準。 + ## E. 每個對話一開始(SessionStart) | 閘名 | 對你意味著什麼 | 動作 | diff --git a/hooks/ask-user-question-guard.sh b/hooks/ask-user-question-guard.sh new file mode 100755 index 0000000..249522f --- /dev/null +++ b/hooks/ask-user-question-guard.sh @@ -0,0 +1,261 @@ +#!/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 diff --git a/hooks/hooks.json b/hooks/hooks.json index a187fae..a4b3f78 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -169,6 +169,15 @@ "command": "${CLAUDE_PLUGIN_ROOT}/hooks/no-ticket-no-dispatch.sh" } ] + }, + { + "matcher": "AskUserQuestion", + "hooks": [ + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/hooks/ask-user-question-guard.sh" + } + ] } ], "SessionStart": [ diff --git a/hooks/tests/ask-user-question-guard.live.test.sh b/hooks/tests/ask-user-question-guard.live.test.sh new file mode 100755 index 0000000..d1da551 --- /dev/null +++ b/hooks/tests/ask-user-question-guard.live.test.sh @@ -0,0 +1,92 @@ +#!/usr/bin/env bash +# ask-user-question-guard.sh 的**判官準度**測試(inkstone/InkStoneCo#55)。 +# +# 🔴 這支**真的會叫 haiku**(每題一次呼叫,共 9 題)。離線那支測管路,這支測判斷。 +# 分開兩檔的理由:管路壞掉要立刻知道且不該花錢;判官準度是另一種失敗, +# 而且它會隨模型版本漂移,需要單獨、可重跑的量尺。 +# +# 兩個方向都要有實例,缺一邊等於沒測: +# B 群「該擋」 = 純技術路徑選擇(leo 2026-08-26:「這種問題不要問我」) +# A 群「不該擋」= 真的四題人閘(花錢/不可逆/跨專案結構/品味方向/物理人閘) +# +# 🔴 **A 群失敗比 B 群失敗嚴重。** 誤擋一個真人閘 = 讓總管替 leo 決定他的品味, +# 比漏擋一個技術題更糟。所以本檔把 A 群失敗單獨列出來報。 +# +# 用法:hooks/tests/ask-user-question-guard.live.test.sh [hook 路徑] + +set -u +HOOK="${1:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/ask-user-question-guard.sh}" + +PASS=0; FAIL=0; N=0; AFAIL=0 + +payload() { + python3 - "$@" <<'PY' +import json, sys +q, opts = sys.argv[1], sys.argv[2:] +print(json.dumps({ + "session_id": "S-LIVE", + "hook_event_name": "PreToolUse", + "tool_name": "AskUserQuestion", + "tool_input": {"questions": [{ + "question": q, + "header": "決策", + "multiSelect": False, + "options": [{"label": o, "description": ""} for o in opts], + }]}, +}, ensure_ascii=False)) +PY +} + +# t <期望 exit> <群> <說明> <問題> [選項...] +t() { + want="$1"; grp="$2"; desc="$3"; shift 3 + N=$((N+1)) + rm -f /tmp/.ask-gate-warned-* 2>/dev/null || true # 每題都要真的走到判官 + out=$(payload "$@" | 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" | grep -m1 '判官的話' | sed 's/^/ /' + FAIL=$((FAIL+1)) + [ "$grp" = "A" ] && AFAIL=$((AFAIL+1)) + fi +} + +echo "── A 群:不該擋(真人閘,leo 要被問到)───────────────────────" + +t 0 A "①④ 品味/方向:總圖那棵樹要不要預設收合(leo 08-26 當場裁的就是這種)" \ + '總圖那棵樹,同一層節點超過 20 個的時候,要不要預設收合起來?' '預設收合' '全部展開' + +t 0 A "②① 花錢:要不要開一顆會產生帳單的付費實例" \ + '這個功能需要一顆專用的 Cloudflare 付費實例才跑得動,要開嗎?每月大約會多一筆帳單。' '開' '不開' + +t 0 A "③② 不可逆:刪掉 stage 上那批資料重灌" \ + '要把 stage 上那批舊資料整批刪掉重灌嗎?刪了拿不回來。' '刪掉重灌' '保留' + +t 0 A "④③ 跨專案結構:ISEP 的閘要不要拆回各子 repo 各自維護" \ + 'ISEP 的閘目前是全機共用一份。要不要改成各子 repo 各自維護一份自己的?這會改掉 repo 之間的邊界。' '拆回各 repo' '維持共用' + +t 0 A "⑤⑤ 物理人閘:要你親手去終端機解保險才推得了 prod" \ + '這一版要推 prod,需要你親手在終端機跑 github-arm.sh 解保險,我沒辦法代跑。現在推嗎?' '現在推' '晚點再說' + +echo +echo "── B 群:該擋(純技術路徑選擇,四題全否)─────────────────────" + +t 2 B "⑥ 實作分層:重試邏輯放哪一層" \ + '重試邏輯要放在 client 那一層還是 worker 那一層?' '放 client' '放 worker' + +t 2 B "⑦ 空白選擇題:兩案差別只有實作成本" \ + '這個 bug 有兩種修法,A 是改解析器、B 是在呼叫端多包一層,兩種我都做得出來,效果一樣。你想要哪一個?' 'A 改解析器' 'B 呼叫端包一層' + +t 2 B "⑧ 請示句:已經交代過的事還在問要不要開始" \ + '那我現在開始修那支 hook 嗎?' '開始' '先等等' + +t 2 B "⑨ 內部命名/擺放位置(用戶看不到)" \ + '新的測試檔要放在 hooks/tests/ 底下還是 scripts/ 底下?檔名要叫 guard.test.sh 還是 test-guard.sh?' 'hooks/tests/' 'scripts/' + +rm -f /tmp/.ask-gate-warned-* 2>/dev/null || true + +echo +echo "══ $PASS/$N 通過${FAIL:+,$FAIL 個失敗}${AFAIL:+(其中 $AFAIL 個是 A 群誤攔——這種最嚴重)} ══" +[ "$FAIL" -eq 0 ] diff --git a/hooks/tests/ask-user-question-guard.test.sh b/hooks/tests/ask-user-question-guard.test.sh new file mode 100755 index 0000000..95bd908 --- /dev/null +++ b/hooks/tests/ask-user-question-guard.test.sh @@ -0,0 +1,165 @@ +#!/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" < /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> <判官替身> <說明> +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 <`」兩行 + **變成空白**。閘照擋 exit 2,所以測試若只看離開碼**完全看不出來**。 + +原因: 寫成 `cat >&2 <