Compare commits

..

3 Commits

Author SHA1 Message Date
Leo 8e7e265589 merge: 閘掛在收工後,而 leo 是在被問到的當下被打擾——補上那一刻的攔截(InkStoneCo#55)
leo 2026-08-26:「今天已經好幾次問我,為什麼 hooks 沒有攔下來?」

總管複驗過才併:
- 掛點:PreToolUse / matcher AskUserQuestion(自己從 hooks.json 解出來的)
- 封動作不封文字:整支 261 行零個判擋用的正則,決定 exit 2 的只有判官 verdict;
  判官掛掉/回空/不在 PATH 一律 fail-open
- 測試自己跑過 14/14(含「樹要不要捲」那種真人閘必須放行的案例)
- 版本 0.3.9 → 0.4.0:第一版忘了升版,而產物是按版本號分資料夾的
  ⇒ 不升版=新閘永遠不會被載入。退回補後已驗產物資料夾長得出來。

🔴 仍有一格是 report 不是 deliver:「新 session 真的會叫它」驗不到——
AskUserQuestion 官方明載 subagent 不可用,它撞不到自己這道閘。
那一格要 leo 開新 session 才驗得了。
2026-08-26 22:43:34 +08:00
claude-code e0557334bc 閘掛在「收工後」,而 leo 是在「被問到」的當下被打擾——補上那一刻的攔截
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
2026-08-26 22:36:08 +08:00
claude-code d4547bfec3 Merge pull request '信標自己講「這一份是誰」——vendor 還是 plugin 快取' (#55) from fix/beacon-says-which-copy into main 2026-08-23 13:03:37 +00:00
9 changed files with 618 additions and 5 deletions
+1 -1
View File
@@ -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"
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "isep",
"description": "InkStone Environment Plugin —— leo 的 Claude Code 環境唯一真相源:43 支機械閘(53 條註冊,白話盤點見 docs/hooks-inventory.md)、7 支 slash command、2 支 skill、27 支腳本,外加治理規範與標籤真相源。本機與雲端裝同一份,沒有子集。",
"version": "0.3.9",
"description": "InkStone Environment Plugin —— leo 的 Claude Code 環境唯一真相源:46 支機械閘(56 條註冊,白話盤點見 docs/hooks-inventory.md)、7 支 slash command、2 支 skill、27 支腳本,外加治理規範與標籤真相源。本機與雲端裝同一份,沒有子集。",
"version": "0.4.0",
"keywords": [
"inkstone",
"guardrails",
+26
View File
@@ -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 <<EOF` 沒加引號,
訊息裡的反引號被當命令執行,**閘照擋,但它教人怎麼解的那兩行變成空白**)
### A10 — 人閘警察的準度:四題公式判得準不準
```
bash hooks/tests/ask-user-question-guard.live.test.sh
```
🔴 **這支真的會叫 haiku**(9 題、每題一次呼叫,整支約 2 分鐘)。
**該看到**`9/9 通過`,且結尾的「A 群誤攔」計數是 **0**
**失敗**
- **A 群紅(誤攔真人閘)=最嚴重**:等於讓總管替 leo 決定他的品味。看到就停下來改判準,不要放著
- B 群紅 = 漏擋,判官把純技術題當成人閘。改 `ask-user-question-guard.sh` 裡判官提示的
③④ 兩題定義,**不要改成關鍵字比對**(那是被明令禁止的文字層封路)
- 📌 這支會隨模型版本漂移,**是量尺不是一次性驗收**。改完判準要連跑三次都全綠才算數
2026-08-26 實測:第一版判準連兩次都在同一題漏擋,收緊 ③④ 定義後三次全綠)
### A6 — 標籤對齊且冪等
```
bash scripts/gitea-labels-sync.sh
@@ -196,6 +220,8 @@ B2(信標那行)/B3(setup 輸出)/B4(閘的訊息)三個畫面
| A4 開票側門閘 | 總管 | ✅ 13/13 |
| A5 搜尋跨 repo | 總管 | ✅ |
| A6 標籤對齊+冪等 | 總管 | ✅ 14 repo,第二次 0/0 |
| **A9 人閘警察管路** | 總管 | ✅ 14/142026-08-26 |
| **A10 人閘警察準度** | 總管 | ✅ 9/9,連跑三次(2026-08-26),A 群誤攔 0 |
| A7 plugin 裝得起來 | 總管 | ✅ |
| **A8 新 session 閘會觸發** | 總管 | 見本版 release note |
| **B1B5 雲端** | **leo** | 還沒跑(機器碰不到 Cloud environment |
+20 -2
View File
@@ -1,4 +1,4 @@
# 43 支閘,白話盤點表
# 46 支閘,白話盤點表
> 回應 `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
| 閘名 | 對你意味著什麼 | 動作 |
+261
View File
@@ -0,0 +1,261 @@
#!/usr/bin/env bash
# ask-user-question-guard.sh — PreToolUseAskUserQuestion):**在「問出口的那一刻」攔住**
#
# 病根(leo 2026-08-26):「**今天已經好幾次問我,為什麼 hooks 沒有攔下來?**」
# 當天總管至少兩次把純技術路徑選擇丟給 leo,其中一次他當場說
# 「**這種問題不要問我,我要的是你解決了以後給我 prod**」——而**一支閘都沒響**。
#
# 實查(2026-08-26ISEP 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-openexit 0):判官掛掉/沒網路/`claude` 不在 PATH
# 都只會回到「今天的狀態」(沒有閘),不會變成「問不出去」。
# ⇒ 這支閘壞掉的最壞後果,等於它不存在,不會癱瘓任何 session。
#
# 官方文件核實(2026-08-26 查 code.claude.com,不是憑記憶):
# • `AskUserQuestion` **會**觸發 PreToolUsematcher 寫工具名有效
# — 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/hooksExit 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'
🚦 人閘警察(PreToolUseAskUserQuestion):**這一問四題全否,是你自己該裁的。**
判官的話:__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
+9
View File
@@ -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": [
+92
View File
@@ -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 ]
+165
View File
@@ -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" <<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 ]
+42
View File
@@ -35,3 +35,45 @@ Gitea 上**一個 release tag 都沒有**。
⇒ 「東西做出來了」與「有人在用它」是兩件事,而只有後者算交付。
⇒ 判準:**去執行環境查它有沒有被載入**(`claude plugin list` / `claude plugin details`),
不要從 repo 裡有什麼檔案去推論。
## ⚠️ MISTAKE: 判準寫在閘裡了,但那個閘掛在**做完之後**才跑的時機上
票: `inkstone/InkStoneCo#55`
日期: 2026-08-26
症狀: leo 一天內好幾次被丟純技術路徑選擇,當場問「**今天已經好幾次問我,
為什麼 hooks 沒有攔下來?**」,其中一次他直接說「這種問題不要問我,
我要的是你解決了以後給我 prod」。
實查: 總管問 leo 走的動作是 `AskUserQuestion` 這個工具,而
`hooks.json``AskUserQuestion` 出現 **0 次**——沒有任何 matcher,它是裸的。
判準其實早就寫好了(`self-drive-police.sh` / `self-drive-judge.sh` 用的就是四題公式),
但那兩支只掛在 `Stop``SubagentStop`
原因: **判準對了,時機錯了。** `Stop` 是回合結束後才跑——問題早就送到 leo 眼前、
他早就被打斷了,這時再反問 AI「你查過了嗎」,成本已經轉嫁出去了。
正確做法: 攔截點要長在**那個動作發生的那一刻**(`PreToolUse` / `AskUserQuestion`)。
新增 `hooks/ask-user-question-guard.sh`
🔴 **推廣**:以後看到「規則寫了卻沒被攔下來」,先問的不是「判準對不對」,
而是「**這支閘掛在哪個事件上、那個事件發生時傷害造成了沒有**」。
## ⚠️ MISTAKE: hook 訊息用沒加引號的 heredoc,反引號會被當成命令執行
票: `inkstone/InkStoneCo#55`
日期: 2026-08-26
症狀: `ask-user-question-guard.sh` 擋下之後,stderr 冒出
`line 218: system-dev/wiki/: is a directory`,而訊息裡
「去查 `system-dev/wiki/`」和「`touch /tmp/.ask-ok-<session_id>`」兩行
**變成空白**。閘照擋 exit 2,所以測試若只看離開碼**完全看不出來**。
原因: 寫成 `cat >&2 <<EOF`(heredoc 標記沒加引號)⇒ shell 會對內容做展開,
而本 repo 的 hook 訊息**慣例上大量使用反引號**標路徑與指令
⇒ 每一組反引號都被當成命令替換真的去執行。
正確做法: hook 的訊息一律用 `cat <<'EOF'`(標記加單引號)。
需要塞變數就留 `__PLACEHOLDER__`,事後用 python 換掉——
**不要用 sed**,正體中文加上訊息裡的 `/``&``\` 讓跳脫非常脆。
迴歸測試要**檢查訊息內容**,不能只檢查離開碼
`hooks/tests/ask-user-question-guard.test.sh` 的 ⑩b 就是這一條)。