Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8e7e265589 | |||
| e0557334bc | |||
| d4547bfec3 | |||
| 2f43ecc346 | |||
| 1b95388a0f | |||
| e3d05df341 | |||
| a594decb78 | |||
| 5789f0a917 | |||
| b1f399f8b9 | |||
| a446ad6d1d | |||
| 41c56acd32 |
@@ -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"
|
||||
|
||||
@@ -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.4",
|
||||
"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",
|
||||
|
||||
@@ -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/14(2026-08-26) |
|
||||
| **A10 人閘警察準度** | 總管 | ✅ 9/9,連跑三次(2026-08-26),A 群誤攔 0 |
|
||||
| A7 plugin 裝得起來 | 總管 | ✅ |
|
||||
| **A8 新 session 閘會觸發** | 總管 | 見本版 release note |
|
||||
| **B1–B5 雲端** | **leo** | 還沒跑(機器碰不到 Cloud environment) |
|
||||
|
||||
+21
-3
@@ -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)
|
||||
|
||||
| 閘名 | 對你意味著什麼 | 動作 |
|
||||
@@ -84,7 +102,7 @@
|
||||
|---|---|---|
|
||||
| `empty-handed-stop-guard.sh` | 這一輪 AI**一個動作都沒做**卻想停下來(等你回覆),就擋下並告訴它「你的命令就是完整授權,不用再等第二次確認」。 | 🛑 擋(至多攔一次) |
|
||||
| `worklist-guard.sh` | AI 自己列過的待辦清單裡還有沒做完的步驟,卻想收工寫報告,就擋下,逼它做完剩下的步驟。 | 🛑 擋 |
|
||||
| `factory-idle-guard.sh` | AI 該去派工卻沒派(工頭停工),就擋下要它交出「已經派工的憑證」,不是隨口說一句「我會催」就算數。 | 🛑 擋 |
|
||||
| `factory-idle-guard.sh` | AI 該去派工卻沒派(工頭停工),就擋下要它交出「已經派工的憑證」,不是隨口說一句「我會催」就算數。**過閘有四條路:現在就派工/把票號寫進那句話/寫一行 `⏸ 等:<在等什麼>`/這一輪收尾在動作上。** 2026-08-23(inkstone/ISEP#30)修好「引用被當成主張」——貼原始碼、引用它自己的訊息、否認自己有下一步,都不再被咬。 | 🛑 擋 |
|
||||
| `browser-verify-guard.sh` | 這一輪 AI 宣稱「前端驗過了」,卻沒有真的用瀏覽器工具載入過,就擋下——`curl` 抓到 HTML 不算驗過。 | 🛑 擋 |
|
||||
| `self-drive-police.sh` | AI 想停下來問你「早就決定過的事」(用固定句型判斷,例如「要不要 X」「下一步做什麼」「這交給你」)就擋下,反問它查過 wiki/查過派工表了沒。 | 🛑 擋 |
|
||||
| `self-drive-judge.sh` | 跟上面同一件事,但改用小模型(haiku)判斷「換句話說」的請示句——防止 AI 只是把「要不要」改寫成「不確定是否符合期待」就閃過上一支閘。 | 🛑 擋 |
|
||||
|
||||
Executable
+261
@@ -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-<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
|
||||
+182
-59
@@ -13,27 +13,72 @@
|
||||
#
|
||||
# ① `[ "$RUNNING" -gt 0 ] && exit 0`
|
||||
# 有任何 agent 在跑就閉嘴 ⇒ **它量的是工廠,不是工頭**。
|
||||
# 2026-08-16 一天四次停工**全部發生在有 agent 在跑的時候**:
|
||||
# 產線忙著做 A,總管宣告了 B,然後沒有人做 B。工廠沒停,停的是按 trigger 的人。
|
||||
#
|
||||
# ② 池子查的是 `repos/Leo/$repo`——**org 在 2026-08-13 搬到 `inkstone`**。
|
||||
# 實測:`Leo/arcrun-rag` 回 `not found`(被當成 0),`inkstone/arcrun-rag` 有 16 張。
|
||||
# ⇒ 池子恆為 0 ⇒ 第 53 行直接 exit 0。**這道閘等於不存在。**
|
||||
# (同族第四顆搬遷遺留座標:`ARCRUN_SHIP_NS`/`ARCRUN_REPO`/MEMORY.md 索引/本檔。)
|
||||
#
|
||||
# ② 池子查的是 `repos/Leo/$repo`——**org 在 2026-08-13 搬到 `inkstone`**(已修)。
|
||||
# ③ 結尾 `exit 0`——純提醒。leo 2026-08-16:「**提醒可以忽略,deny 不能**。」
|
||||
#
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
# 🔴 2026-08-23 第四輪(inkstone/ISEP#30):**這道閘在懲罰誠實與謹慎。**
|
||||
#
|
||||
# 雲端驗收連續三次被它擋下,三次都不是宣告意圖:
|
||||
# ① 「那句是等你,不是我的下一步」 ← 在**否認**自己有下一步
|
||||
# ② 「這不是『下一步我要做 X』」 ← **引用**閘自己的訊息來說明沒在做
|
||||
# ③ 貼出閘自己的**正則原始碼**來舉報這個 bug ← code block 裡的**引用**
|
||||
#
|
||||
# 真兇有四個,而且沒有一個是「例外沒列夠」:
|
||||
#
|
||||
# (a)**DECL 會匹配裸的「下一步」三個字。** 舊式 `下一步(我|就是|是)?[::]?\s*`
|
||||
# 每一節都是可選的 ⇒ 整條 alternative 退化成「文中出現『下一步』」。
|
||||
# ⇒ 只要**提到**這道閘在講什麼,就會被這道閘咬。
|
||||
# 修法=**要求它後面真的接一個動作動詞**。這是**收緊**(要求更多才算命中),
|
||||
# 方向與「再加幾個關鍵字例外」相反——例外清單會越加越長、越長越誤攔。
|
||||
#
|
||||
# (b)**「引用」被當成「主張」。** 舊版只剝 `>` 引言與長「」引號,
|
||||
# 不認 markdown 的 ```code fence``` 與行內 `code`。
|
||||
# ⇒ 貼原始碼舉報 bug 的人,會被他要舉報的那支閘擋下。
|
||||
# 修法=把引用性標記整段換成**哨兵字元**(不是刪掉):
|
||||
# - 刪掉會把外層句構攪碎(2026-08-17 就是這樣漏掉「回『規劃』我就派人」)
|
||||
# - 換成哨兵則**內層的宣告消失、外層的句構留著** ⇒ 兩個方向同時修好
|
||||
#
|
||||
# (c)**看錯了「回合的收尾」。** 舊版取 `blocks_text[-1]`(最後一則文字),
|
||||
# 但那則文字後面可能還有 tool_use ⇒ 宣告其實已經被兌現了。
|
||||
# 修法=只看**最後一個動作之後**的文字。**收尾在動作上 ⇒ 根本不觸發。**
|
||||
#
|
||||
# (d)**訊息承諾的出路,程式碼裡沒有。** 舊訊息教人走
|
||||
# 「選項③:改寫那句話,說明它在等什麼」,
|
||||
# 但放行分支只有「有 Agent/Task」與「附近有 #票號」兩條 ⇒
|
||||
# 唯一走得通的路是**不寫那三個字**,而那正是同一則訊息 🔴 明文禁止的動作。
|
||||
# 修法=給選項③一個**真的存在**的機械形式:`⏸ 等:<在等什麼>`。
|
||||
# 它是**白名單標記**(要刻意寫才會有),不是黑名單,不會被誤觸;
|
||||
# 而且**留痕**——滿足「不准只是把那句話刪掉再送一次」。
|
||||
#
|
||||
# ⚠️ **刻意的取捨(寫下來,下一輪不要當成 bug 又改回去)**:
|
||||
# - `#票號` 改成搜「整段收尾文字」而不是宣告句附近 ±N 字。
|
||||
# 理由:票號常寫在行內 `code` 裡,剝完就找不到了 ⇒ 誤攔。
|
||||
# **誤攔的代價高於漏攔**——誤攔會訓練人忽略警報,那比沒有警報更糟。
|
||||
# - 「下一步」後面不吃逗號(「下一步,我去補 X」會漏)。
|
||||
# 理由:吃了逗號,「不是我的下一步,我剛剛已經做完了」就會被咬(過去式當未來式)。
|
||||
# - 「身為…我要…我才…」(本 repo 規定的 User Story 票名格式)整段換哨兵。
|
||||
# 票名是**被命名的物件**,不是我對下一步的主張。
|
||||
#
|
||||
# ── 現在量什麼 ─────────────────────────────────────────────────
|
||||
#
|
||||
# 主閘(exit 2,會擋):**宣告了下一步,卻沒有派工憑證。**
|
||||
# 憑證 = 這個回合真的呼叫過 Agent/Task(而那條路上已有 no-ticket-no-dispatch
|
||||
# 在驗票號)⇒ **「有派工」本身就蘊含「有票」**,不必在這裡重複驗票。
|
||||
# 也接受:宣告句裡直接寫明它在誰手上(帶 `#數字` 的票號指涉)。
|
||||
# 主閘(exit 2,會擋):**宣告了下一步,卻沒有按下任何 trigger。**
|
||||
# trigger = ① 這個回合真的呼叫過 Agent/Task
|
||||
# (no-ticket-no-dispatch 已在那條路上驗票號 ⇒ 這裡不必重複驗)
|
||||
# ② 收尾文字裡有 `#票號` 指涉(=已經有人在做)
|
||||
# ③ 收尾文字裡有 `⏸ 等:<在等什麼>`(=它現在不該做,且說明了在等什麼)
|
||||
# ④ 回合收尾在動作上(最後一個動作之後沒有文字)
|
||||
#
|
||||
# 副閘(exit 0,只提醒):真的全廠停工(零 agent + 池子有票)。org 已修。
|
||||
# 副閘(exit 0,只提醒):真的全廠停工(零 agent + 池子有票)。
|
||||
#
|
||||
# 🔴 **不擋純對話回合**:沒有宣告下一步就不觸發。
|
||||
# 這是刻意的——永遠在響的警報等於訓練人忽略它(見 branch-holds.md 的同款教訓)。
|
||||
#
|
||||
# 🔴 **不設 `stop_hook_active` 逃生門**(與 empty-handed-stop-guard 不同)。
|
||||
# leo 2026-08-16:「提醒可以忽略,deny 不能。」
|
||||
# 之所以敢這樣,是因為出路③現在**真的存在** ⇒ 不可能把人鎖死在無解迴圈裡。
|
||||
#
|
||||
# 迴歸測試:`hooks/tests/factory-idle-guard.test.sh`(23 向,誤攔與漏攔兩個方向都測)
|
||||
set -eu
|
||||
|
||||
PROJ="${CLAUDE_PROJECT_DIR:-$(pwd)}"
|
||||
@@ -46,11 +91,11 @@ import sys, json, os, re
|
||||
try:
|
||||
d = json.load(sys.stdin)
|
||||
except Exception:
|
||||
print("SKIP"); raise SystemExit
|
||||
print("SKIP:bad-payload"); raise SystemExit
|
||||
|
||||
tp = d.get("transcript_path") or ""
|
||||
if not tp or not os.path.exists(tp):
|
||||
print("SKIP"); raise SystemExit # 讀不到就別亂擋
|
||||
print("SKIP:no-transcript"); raise SystemExit # 讀不到就別亂擋
|
||||
|
||||
rows = []
|
||||
try:
|
||||
@@ -61,7 +106,7 @@ try:
|
||||
try: rows.append(json.loads(line))
|
||||
except Exception: pass
|
||||
except Exception:
|
||||
print("SKIP"); raise SystemExit
|
||||
print("SKIP:unreadable"); raise SystemExit
|
||||
|
||||
# 這個回合=最後一則「真的來自使用者」的訊息之後(工具結果不算)
|
||||
start = 0
|
||||
@@ -73,63 +118,132 @@ for i, r in enumerate(rows):
|
||||
start = i
|
||||
turn = rows[start:]
|
||||
|
||||
# 依序攤平這個回合的 assistant 區塊:("tool", 名字) / ("text", 內容)
|
||||
seq = []
|
||||
dispatched = False
|
||||
blocks_text = []
|
||||
for r in turn:
|
||||
if r.get("type") != "assistant":
|
||||
continue
|
||||
for b in (r.get("message") or {}).get("content") or []:
|
||||
if not isinstance(b, dict):
|
||||
continue
|
||||
if b.get("type") == "tool_use" and b.get("name") in ("Agent", "Task"):
|
||||
dispatched = True
|
||||
if b.get("type") == "tool_use":
|
||||
if b.get("name") in ("Agent", "Task"):
|
||||
dispatched = True
|
||||
seq.append(("tool", b.get("name") or ""))
|
||||
elif b.get("type") == "text":
|
||||
blocks_text.append(b.get("text") or "")
|
||||
seq.append(("text", b.get("text") or ""))
|
||||
|
||||
if dispatched:
|
||||
print("OK"); raise SystemExit # 按了 trigger ⇒ 放行
|
||||
print("OK:dispatched"); raise SystemExit # 出路① 按了 trigger ⇒ 放行
|
||||
|
||||
# 🔴 只看**最後一則**文字,不看整個回合(2026-08-16 第一次實跑就誤攔,修正)
|
||||
# 🔴 只看「最後一個動作**之後**」的文字(2026-08-23 修)
|
||||
# 病灶是「回合終止在宣告上」⇒ 該看的是那個終止動作本身。
|
||||
# 掃整個回合會在「我這回合稍早說要讀 X、然後真的讀了」這種句子上開火——
|
||||
# 那是**已完成事項的敘述**,不是未兌現的意圖。誤攔會訓練人忽略警報,
|
||||
# 而那比沒有警報更糟(同 branch-holds.md 的教訓)。
|
||||
text = blocks_text[-1] if blocks_text else ""
|
||||
# 舊版取最後一則文字,但那則文字後面可能還有 tool_use——
|
||||
# 「我這回合說要讀 X、然後真的讀了」會被當成未兌現的意圖。誤攔會訓練人忽略警報。
|
||||
tail = []
|
||||
for kind, val in reversed(seq):
|
||||
if kind == "tool":
|
||||
break
|
||||
tail.append(val)
|
||||
raw = "\n".join(reversed(tail))
|
||||
|
||||
if not raw.strip():
|
||||
print("OK:ended-on-action"); raise SystemExit # 出路④ 收尾在動作上 ⇒ 沒有懸空的宣告
|
||||
|
||||
# 出路③:明講在等什麼(在剝標記**之前**檢查,理由本身常寫在引號/code 裡)
|
||||
if re.search(r"⏸\s*等\s*[::]\s*\S{2,}", raw):
|
||||
print("OK:waiting-declared"); raise SystemExit
|
||||
|
||||
# ── 把「引用性內容」蓋掉:引用 != 主張 ──────────────────────────
|
||||
# 🔴 **等長**替換(蓋成同樣長度的哨兵),不是刪掉,有兩個理由:
|
||||
# ① 刪掉會把外層句構攪碎——2026-08-17 就是這樣漏掉「回『規劃』我就派人」。
|
||||
# 蓋成哨兵則內層的宣告消失、外層的句構留著,誤攔與漏攔一起修好。
|
||||
# ② 等長 ⇒ 位移可以原封不動對回 raw ⇒ 底下的「票號要在宣告句附近」
|
||||
# 可以在**原文**上量。否則票號寫在行內 `code` 裡會被自己剝掉而找不到,
|
||||
# 逼得只能全篇搜——那會把閘變鈍(2026-08-23 拿真 transcript 量到:
|
||||
# 全篇搜的話 26 個真實回合有 20 個是靠「文中某處有票號」放行的)。
|
||||
S = "※"
|
||||
def blank(m):
|
||||
return S * (m.end() - m.start())
|
||||
text = raw
|
||||
text = re.sub(r"```.*?```", blank, text, flags=re.S) # markdown 圍欄 code
|
||||
text = re.sub(r"~~~.*?~~~", blank, text, flags=re.S)
|
||||
text = re.sub(r"`[^`\n]*`", blank, text) # 行內 code
|
||||
text = re.sub(r"^[ \t]*>.*$", blank, text, flags=re.M) # 整段引言(leo 的原話)
|
||||
for _ in range(3): # 引號(含巢狀),由內而外
|
||||
text = re.sub(r"[「『][^「」『』]*[」』]", blank, text)
|
||||
# 本 repo 規定的 User Story 票名:是被命名的物件,不是我對下一步的主張
|
||||
text = re.sub(r"身為[^\n]{0,40}?我要[^\n]{0,80}?我才[^\n]{0,60}", blank, text)
|
||||
assert len(text) == len(raw) # 位移要能對回原文
|
||||
|
||||
# ── 宣告下一步的句型 ──────────────────────────────────────────
|
||||
# 🔴 每一條都**必須**接到一個動作動詞(A)才算命中。
|
||||
# 舊版少了這個要求 ⇒ 裸的「下一步」「接下來我」就開火 ⇒ 討論這道閘的人被這道閘擋。
|
||||
A = r"(?:去|來|做|派|審|跑|查|補|建|寫|改|推|驗|清|落|盤|整理|處理|部署|測)"
|
||||
N = r"[^。!?;,,\n]{0,16}" # 同一句、不跨逗號
|
||||
DECL = re.compile("|".join([
|
||||
# 🔴 主詞是「你」的下一步 != 我的下一步(2026-08-23 拿真 transcript 量出來的第四類誤攔)
|
||||
# 舊閘在 26 個真實回合終止點上擋了 2 次,兩次咬的都是我在**交代 leo 該做什麼**:
|
||||
# 「**下一步還是那一個動作**:**你**把 feat/... 併進 main」
|
||||
# 「## **你下一步**(兩招,先便宜的)」
|
||||
# ⇒ 這是**主詞檢查**(誰要動手),不是關鍵字例外。
|
||||
# 只掛在這條 alternative 上——「你點頭我就做」那條的主詞本來就是我,不受影響。
|
||||
r"(?P<nextstep>下一步[::]?\s*(?!你|妳|leo|Leo)(?:我|就是|是)?\s*" + N + A + r")",
|
||||
r"接下來我\s*" + N + A,
|
||||
r"我(?:現在|接著|等下|等一下)(?:就)?\s*" + N + A,
|
||||
r"我(?:要|會)(?:去|來)?\s*" + N + A,
|
||||
r"稍後(?:我|再)\s*" + N + A,
|
||||
r"之後我(?:會|要)\s*" + N + A,
|
||||
# 2026-08-17 leo 實撞的一族:**把請示寫成條件句**——
|
||||
# 沒問號、沒疑問詞,卻把動作的觸發權交回 leo。功能上是請示,句型上不像。
|
||||
r"回[「『]?[^」』\n]{0,12}[」』]?(?:我)?就\s*" + N + A,
|
||||
r"說一聲(?:我)?(?:就)?\s*" + N + A,
|
||||
r"你(?:點頭|說可以|確認)(?:了)?(?:我)?(?:就)?\s*" + N + A,
|
||||
r"(?:確認|核准|同意|批准)(?:過|了)?(?:之)?後(?:我)?(?:才|再|就)\s*" + N + A,
|
||||
# 空手停在別人身上(出路③ 的 ⏸ 標記是它唯一合法的形式)
|
||||
r"我(?:就)?(?:等|待)你",
|
||||
r"等你(?:說|回|點頭|確認)",
|
||||
r"我(?:就)?(?:不再|先不)(?:自己)?(?:動|做|派)",
|
||||
r"我(?:就)?(?:一次)?(?:落|派|做|補|審|清)(?:完|掉)?\s*[。,,]?\s*$",
|
||||
]))
|
||||
|
||||
SECOND = re.compile(r"你|妳|leo|Leo")
|
||||
hit = None
|
||||
for m in DECL.finditer(text):
|
||||
# 「下一步」這條要看主詞:前面 8 字裡出現第二人稱 ⇒ 那是**他的**下一步,不是我的
|
||||
if m.group("nextstep") and SECOND.search(text[max(0, m.start() - 8): m.start()]):
|
||||
continue
|
||||
hit = m
|
||||
break
|
||||
|
||||
# 引用 leo 的話不算我的宣告(整段引言/引號內)——它常含「下一步」等字樣
|
||||
text = re.sub(r"^\s*>.*$", "", text, flags=re.M)
|
||||
# 🔴 2026-08-17 修:舊版把「所有」引號內容都刪掉,包括我自己句子裡的關鍵詞。
|
||||
# leo 實撞:我寫「回『規劃』我就派人盤這份計畫」,`規劃` 被吃掉後變成
|
||||
# 「回我就派人盤這份計畫」,DECL 一個都不匹配 ⇒ 該攔的沒攔。
|
||||
# ⇒ 只刪「夠長的引言」(leo 的話通常成句),短引號是我自己的用詞,留著。
|
||||
text = re.sub(r"「[^」]{12,400}」", "", text)
|
||||
# 宣告下一步的句型(刻意收窄:只認「我接下來要做」,不認「現在的狀態是」)
|
||||
DECL = re.compile(
|
||||
r"(下一步(我|就是|是)?[::]?\s*(?!不是宣告)|接下來我|我(現在|接著|等下|等一下)(就)?(去|來|做|派|審|跑)"
|
||||
r"|我(要|會)(去|來)?(做|派|審|跑|查|補|建)|稍後(我|再)|之後我(會|要)"
|
||||
# 🔴 2026-08-17 leo 實撞補的一族:**把請示寫成條件句**——
|
||||
# 沒問號、沒疑問詞,卻把動作的觸發權交回 leo。功能上是請示,句型上不像。
|
||||
# 實例:「回『規劃』我就派人盤這份計畫」/「說一聲我就落」/「你點頭我就做」
|
||||
r"|回[「『]?[^」』\n]{0,12}[」』]?(我)?就|說一聲(我)?(就)?|你(點頭|說可以|確認)(了)?(我)?(就)?"
|
||||
r"|(確認|核准|同意|批准)(過|了)?(之)?後(我)?(才|再|就)"
|
||||
r"|我(就)?(等|待)你|等你(說|回|點頭|確認)"
|
||||
r"|我(就)?(不再|先不)(自己)?(動|做|派)"
|
||||
r"|我(就)?(一次)?(落|派|做|補|審|清)(完|掉)?[。,,]?\s*$)")
|
||||
hit = DECL.search(text)
|
||||
if not hit:
|
||||
print("OK"); raise SystemExit # 沒宣告 ⇒ 純對話回合,不擋
|
||||
print("OK:no-declaration"); raise SystemExit # 沒宣告 ⇒ 純對話回合,不擋
|
||||
|
||||
# 宣告句附近有票號指涉(=已經說明它在誰手上/哪張票)⇒ 放行
|
||||
seg = text[max(0, hit.start() - 200): hit.end() + 400]
|
||||
if re.search(r"#\d{1,5}", seg):
|
||||
print("OK"); raise SystemExit
|
||||
# 出路②:宣告句**附近**有票號指涉(=已經說明它在誰手上/哪張票)
|
||||
# 在 raw 上量(等長替換保證位移對得回去)⇒ 票號寫在行內 code 裡也算數,
|
||||
# 而「文章別處剛好提到某張票」不算——那不是在講這件事在誰手上。
|
||||
if re.search(r"#\d{1,5}", raw[max(0, hit.start() - 200): hit.end() + 400]):
|
||||
print("OK:ticket-referenced"); raise SystemExit
|
||||
|
||||
print("DECLARED_NO_TRIGGER::" + text[max(0, hit.start()-60): hit.end()+120].replace("\n", " ")[:200])
|
||||
' 2>/dev/null || echo SKIP)
|
||||
print("DECLARED_NO_TRIGGER::" + raw[max(0, hit.start()-60): hit.end()+120].replace("\n", " ")[:200])
|
||||
' 2>/dev/null || echo "SKIP:crash")
|
||||
|
||||
STAMP=$(date "+%Y-%m-%d %H:%M:%S" 2>/dev/null || echo "?")
|
||||
LOGDIR="$PROJ/.claude/hooks"
|
||||
|
||||
# 留痕(inkstone/InkStoneCo#48:閘只記「擋下」的話分母未知,回答不了「它有沒有在運作」)
|
||||
# ⇒ 擋下與放行都記。目錄不在就安靜跳過(測試環境/別的 repo 不該噴 redirect 錯誤到 stderr)。
|
||||
note() {
|
||||
if [ -d "$LOGDIR" ]; then
|
||||
printf '| %s | %s | %s |\n' "$STAMP" "$1" "$2" >> "$LOGDIR/factory-idle-guard.log" 2>/dev/null || true
|
||||
fi
|
||||
}
|
||||
|
||||
case "$VERDICT" in
|
||||
DECLARED_NO_TRIGGER::*)
|
||||
QUOTE=$(printf '%s' "$VERDICT" | sed 's/^DECLARED_NO_TRIGGER:://')
|
||||
note "⛔ 擋下" "$QUOTE"
|
||||
cat >&2 <<MSG
|
||||
🏭 稼動率警察:**你宣告了下一步,但這個回合沒有按下 trigger。**
|
||||
|
||||
@@ -147,19 +261,28 @@ leo 2026-08-16(本閘的規格):
|
||||
⇒ 在結構上 X 永遠落在回合結束之後。**不是忘記,是把 X 寫進了終結回合的動作裡。**
|
||||
而說出意圖會消解掉做它的壓力——一份清楚的計畫**讀起來像進度**。
|
||||
|
||||
━━ 現在怎麼過這道閘(擇一)━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
━━ 現在怎麼過這道閘(四條,程式碼裡都真的有對應的分支)━━━━━━━━━
|
||||
① **現在就按 trigger**:這個回合直接呼叫 Agent/Task 派出去。
|
||||
沒有票 → 先 \`scripts/ticket where <關鍵字>\` 搜該掛哪張,
|
||||
再 \`ticket say <owner/repo#N> -F <檔>\`,然後帶【工單】派工。
|
||||
② **它已經在別人手上**:把票號寫進那句話(例:「已派給 #44 comment 2761」)。
|
||||
有票號指涉就放行——那不是「我等下做」,是「已經有人在做」。
|
||||
③ **它其實不該現在做**:改寫那句話,說明它在等什麼(前置沒解除/是 leo 的閘),
|
||||
不要寫成「下一步我要做 X」。
|
||||
② **它已經在別人手上**:把票號寫進這段收尾文字(例:「已派給 inkstone/ISEP#30」)。
|
||||
有 \`#數字\` 就放行——那不是「我等下做」,是「已經有人在做」。
|
||||
③ **它其實不該現在做**:寫一行說明它在等什麼,格式就是這一個標記——
|
||||
|
||||
⏸ 等:<在等什麼>
|
||||
|
||||
例:\`⏸ 等:leo 親手跑 scripts/github-arm.sh 解保險(D20 人閘)\`
|
||||
\`⏸ 等:arcrun-rag 那條 PR 先併,前置沒解除\`
|
||||
🔴 這個標記**要刻意寫才會有**,寫了就留痕——它取代不了做事,
|
||||
但它讓「現在不該做」跟「宣告了不做」在機器上分得開。
|
||||
④ **這個回合收尾在動作上**:最後一個工具呼叫之後不要再補一句宣告。
|
||||
|
||||
🔴 **不准只是把那句話刪掉再送一次。** 刪掉宣告=那件事從此沒人記得,
|
||||
比宣告了沒做更糟——它連痕跡都不留。
|
||||
比宣告了沒做更糟——它連痕跡都不留。要嘛按 trigger,要嘛用 ③ 留下 ⏸ 記號。
|
||||
MSG
|
||||
exit 2 ;;
|
||||
*)
|
||||
note "✅ 放行" "$VERDICT" ;;
|
||||
esac
|
||||
|
||||
# ── 副閘:真的全廠停工(零 agent + 池子有票)──────────────────────
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -17,9 +17,32 @@ ROOT="${CLAUDE_PLUGIN_ROOT:-}"
|
||||
VER="$(sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' \
|
||||
"$ROOT/.claude-plugin/plugin.json" 2>/dev/null | head -1)"
|
||||
VER="${VER:-未知}"
|
||||
GATES="$(ls "$ROOT"/hooks/*.sh 2>/dev/null | wc -l | tr -d ' ')"
|
||||
# 🔴 數「真的被註冊的」,不是數目錄裡有幾個 .sh(leo 的雲端驗收 2026-08-23 抓到):
|
||||
# 舊寫法 `ls hooks/*.sh` 把 `pre-write-guard.template.sh`(樣板,不是閘)
|
||||
# 與兩支沒掛註冊的輔助檔一起算進去 ⇒ 報 45,實際註冊 42。
|
||||
# 這個數字是 leo 判斷「這個 session 有沒有閘」的唯一介面——**多報就是假綠**。
|
||||
# (查過歷史:本檔自 daa1674 建立以來只有那一版,沒有別的分支修過這段。)
|
||||
GATES="$(grep -oE 'hooks/[a-zA-Z0-9._-]+\.sh' "$ROOT/hooks/hooks.json" 2>/dev/null \
|
||||
| sort -u | wc -l | tr -d ' ')"
|
||||
case "$GATES" in
|
||||
''|*[!0-9]*|0)
|
||||
GATES="$(ls "$ROOT"/hooks/*.sh 2>/dev/null | grep -cv '\.template\.sh$' | tr -d ' ')" ;;
|
||||
esac
|
||||
|
||||
MSG="🟢 ISEP v${VER} 已載入(${GATES} 支閘在 ${ROOT})"
|
||||
# 🔴 講清楚「這一份是誰」(leo 2026-08-23 的雲端驗收卡在這裡整整一輪):
|
||||
# 同一台機器上 ISEP 可能有兩份——marketplace 裝的 plugin 快取,
|
||||
# 與 repo 裡 vendor 進去的複製本。**兩份都會印這一行**,而版本號一樣時分不出誰在說話。
|
||||
# 「閘到底從哪一份走」正是決定「另一份能不能拆」的唯一判準
|
||||
# ⇒ 讓路徑自己講:快取在 plugins/cache/ 底下,vendor 的在 repo 的 .claude/ 底下。
|
||||
# (查過歷史:本檔只有 daa1674 建立、b1f399f 改閘數兩輪,沒有人試過標示來源,也沒有被否決的方案。
|
||||
# KBDB 語意搜尋這段開發史:0 命中,該餵。)
|
||||
case "$ROOT" in
|
||||
*/plugins/cache/*) SRC="plugin(marketplace 裝的)" ;;
|
||||
*/.claude/isep*) SRC="vendor(repo 裡的複製本)" ;;
|
||||
*) SRC="來源不明" ;;
|
||||
esac
|
||||
|
||||
MSG="🟢 ISEP v${VER} 已載入(${GATES} 支閘|來源:${SRC}|${ROOT})"
|
||||
|
||||
printf '%s\n' "{
|
||||
\"systemMessage\": \"${MSG}\",
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
#!/usr/bin/env python3
|
||||
"""hooks/lib/push_target_dir.py -- shared helper (inkstone/ISEP#40 S7-style
|
||||
lib helper: source of truth for a small piece of logic, not a gate by
|
||||
itself; nothing here decides allow/block on its own).
|
||||
|
||||
What it answers: given a shell command string that somewhere invokes
|
||||
`git ... push ...`, which directory will that push actually execute in?
|
||||
|
||||
Why this exists (inkstone/ISEP#30 comment 3949, 脈絡見 InkStoneCo#57):
|
||||
main-and-prod-push-guard.sh's stamp_ok() used to read `git rev-parse
|
||||
--show-toplevel` from the *hook's own* cwd (= the session's real repo) as
|
||||
"HERE", and compare it against the repo path the caller wrote into the
|
||||
stamp ("WANT"). That works when the push target IS the repo the session is
|
||||
standing in. It can never work otherwise: a `cd <other-repo> && git push
|
||||
origin HEAD:main` changes the *push's* directory but not the hook's, so
|
||||
HERE stays the real repo forever while WANT is (correctly) the other repo
|
||||
-- the two can never match, no matter how carefully the caller follows the
|
||||
gate's own instructions. This isn't a bad judgment call; that scenario
|
||||
simply doesn't exist in the old model.
|
||||
|
||||
This module extracts the directory the push *actually* runs in, purely by
|
||||
tokenizing -- it never executes anything. Two sources, first one found
|
||||
wins (in the order a real shell would apply them):
|
||||
- a `cd <path>` chain preceding the push, scoped correctly across
|
||||
subshells: a `(` inherits the current directory from its parent at the
|
||||
moment it opens, but whatever a subshell `cd`s to does NOT leak back
|
||||
out to sibling commands after the matching `)` closes (this mirrors
|
||||
real bash: a subshell's cwd change is local to that subshell). This
|
||||
scoping is load-bearing, not cosmetic: without it, `(cd /repo-A &&
|
||||
true); git push origin main` would misattribute the later push (which
|
||||
really runs wherever the outer shell already was) to /repo-A, and a
|
||||
stale/legitimate stamp for /repo-A could then wrongly wave through a
|
||||
push into whatever the outer cwd actually is -- the exact "stamp
|
||||
opened for repo A also opens the door for repo B" shape 2026-08-11
|
||||
already burned us on once.
|
||||
- a `-C <path>` flag on the git invocation itself, which further wins
|
||||
over any `cd` chain (matches git's own precedence: `-C` sets the
|
||||
directory for that invocation regardless of the shell's cwd).
|
||||
|
||||
Multiple relative `cd`/`-C` hops are combined by plain string join here
|
||||
(no `..`/`~`/`$()` resolution) -- resolving the combined expression to a
|
||||
real, canonical, absolute path is left to the caller, which does it with a
|
||||
read-only `cd "<expr>" && pwd` in a throwaway subshell. That two-step split
|
||||
matters: this module only ever *parses*, so it stays side-effect-free even
|
||||
when fed a hostile or malformed command; only the caller's final `cd`
|
||||
touches the filesystem, and `cd` cannot execute anything, it can only fail
|
||||
to find a directory.
|
||||
|
||||
If no `cd`/`-C` applies (the push runs wherever the hook itself is, i.e.
|
||||
today's behaviour), or the command doesn't parse, prints nothing -- the
|
||||
caller falls back to its existing cwd-based resolution. That fallback
|
||||
direction is deliberately the *safe* one: on any parse ambiguity we hand
|
||||
back "unknown" rather than guess, and an unresolved HERE can only make the
|
||||
gate keep blocking (fail toward blocking), never open a door it wouldn't
|
||||
have opened before.
|
||||
|
||||
Usage:
|
||||
printf '%s' "$CMD" | python3 push_target_dir.py
|
||||
"""
|
||||
import shlex
|
||||
import sys
|
||||
|
||||
_SEPARATORS = {";", "&&", "||", "|", "&", "\n"}
|
||||
|
||||
|
||||
def _join(base, path):
|
||||
"""Combine a cwd-so-far (`base`, or None if unknown/hook-cwd) with a
|
||||
`cd`/`-C` argument written in the command. Absolute paths and `~`
|
||||
replace the base outright; anything else is appended textually --
|
||||
normalizing `..`/`.` is intentionally left to the caller's real `cd`."""
|
||||
if not path:
|
||||
return base
|
||||
if path == "-" or path.startswith("$"):
|
||||
# `cd -` (previous dir) and `$VAR`/`$(...)` expansions can't be
|
||||
# resolved by tokenizing alone -- treat as "unknown" rather than
|
||||
# guess wrong, which keeps the caller on its safe fallback path.
|
||||
return None
|
||||
if path.startswith("/") or path.startswith("~"):
|
||||
return path
|
||||
if base is None:
|
||||
return path
|
||||
return base.rstrip("/") + "/" + path
|
||||
|
||||
|
||||
def _classify(tokens):
|
||||
if not tokens:
|
||||
return ("other", None)
|
||||
if tokens[0] == "cd" and len(tokens) > 1:
|
||||
return ("cd", tokens[1])
|
||||
if tokens[0] == "git" and "push" in tokens[1:]:
|
||||
return ("push", tokens)
|
||||
return ("other", None)
|
||||
|
||||
|
||||
def _events(cmd):
|
||||
"""Tokenize cmd into (kind, value) events in source order: 'enter'/
|
||||
'exit' for parens (subshell boundaries), 'cd'/'push'/'other' for
|
||||
statements split on the usual shell separators. Returns [] on any
|
||||
quoting error -- caller then falls back to cwd-based resolution."""
|
||||
try:
|
||||
lexer = shlex.shlex(cmd, posix=True, punctuation_chars=True)
|
||||
lexer.whitespace_split = True
|
||||
toks = list(lexer)
|
||||
except ValueError:
|
||||
return []
|
||||
|
||||
events = []
|
||||
seg = []
|
||||
|
||||
def flush():
|
||||
if seg:
|
||||
# _classify's "push" branch returns `tokens` by reference; copy
|
||||
# before clear() below, or the event's tuple would observe the
|
||||
# list emptied out from under it (aliasing, not a value copy).
|
||||
events.append(_classify(seg[:]))
|
||||
seg.clear()
|
||||
|
||||
for tok in toks:
|
||||
if tok == "(":
|
||||
flush()
|
||||
events.append(("enter", None))
|
||||
elif tok == ")":
|
||||
flush()
|
||||
events.append(("exit", None))
|
||||
elif tok in _SEPARATORS:
|
||||
flush()
|
||||
else:
|
||||
seg.append(tok)
|
||||
flush()
|
||||
return events
|
||||
|
||||
|
||||
def find_push_target(cmd):
|
||||
events = _events(cmd)
|
||||
if not events:
|
||||
return ""
|
||||
|
||||
stack = [None] # cwd-so-far per paren depth; None = "same as hook cwd"
|
||||
result = None
|
||||
saw_push = False
|
||||
|
||||
for kind, val in events:
|
||||
if kind == "enter":
|
||||
stack.append(stack[-1]) # child subshell inherits current dir
|
||||
elif kind == "exit":
|
||||
if len(stack) > 1:
|
||||
stack.pop() # subshell's own cd's don't leak out
|
||||
elif kind == "cd":
|
||||
stack[-1] = _join(stack[-1], val)
|
||||
elif kind == "push":
|
||||
saw_push = True
|
||||
c_path = None
|
||||
toks = val
|
||||
for j, t in enumerate(toks):
|
||||
if t == "-C" and j + 1 < len(toks):
|
||||
c_path = toks[j + 1]
|
||||
break
|
||||
if t.startswith("-C") and len(t) > 2:
|
||||
c_path = t[2:]
|
||||
break
|
||||
result = _join(stack[-1], c_path) if c_path else stack[-1]
|
||||
|
||||
if not saw_push:
|
||||
return ""
|
||||
return result or ""
|
||||
|
||||
|
||||
def main():
|
||||
cmd = sys.stdin.read()
|
||||
sys.stdout.write(find_push_target(cmd))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -98,7 +98,32 @@ stamp_ok() {
|
||||
# 等於一把萬用鑰匙——正是 08-11 那次穿透的形狀(替 A repo 開的門 B repo 也走得過)。
|
||||
# 而且 `.claude/settings.local.json` 裡真的放行過 `touch /tmp/.main-push-ok`。
|
||||
# ⇒ 現在**空內容一律不算數**:要嘛寫得出 repo 路徑且對得上,要嘛不放行。
|
||||
HERE=$(git rev-parse --show-toplevel 2>/dev/null || printf '')
|
||||
#
|
||||
# 🔴 2026-08-23(inkstone/ISEP#30 comment 3949,脈絡 InkStoneCo#57):
|
||||
# `HERE` 原本一律讀 hook 自己的 cwd(=session 站著的那個 repo)。
|
||||
# 只要要推的 repo **不是**「session 站著的那個 repo」——例如指令自己
|
||||
# `cd <別的 repo> && git push` 或 `git -C <別的 repo> push`——HERE 永遠是
|
||||
# 總管的真身,而總管替目標 repo 開的 WANT 永遠對不上,這道閘就**永遠沒辦法
|
||||
# 合法通過**。不是判斷錯,是這個情境在舊模型裡根本不存在(照閘的指示做
|
||||
# 戳記,戳記內容天生就贏不了)。
|
||||
# 改法:先看指令本身有沒有把 push 的執行目錄改掉
|
||||
# (lib/push_target_dir.py——純 tokenize,不執行任何指令,
|
||||
# 對 `cd A && cd B && git push` 這種多層鏈與 `(cd A && …); git push` 這種
|
||||
# 子殼會不會外洩都做了範圍化,理由見該檔檔頭);解得出來就 `cd` 進那個
|
||||
# 目錄(唯讀操作,`cd` 本身不會執行任何東西)問 git 那裡的 toplevel 是誰;
|
||||
# 解不出來(沒有 cd/-C,或指令太怪解析失敗)才退回舊行為=hook 自己的 cwd。
|
||||
# 🔴 綁 repo+單次用完即丟兩條性質完全沒有鬆動:這裡只是把「現在人在哪個
|
||||
# repo」問得更準,比對邏輯(下面兩行)一個字沒動。
|
||||
_push_target_dir="$(dirname "$0")/lib/push_target_dir.py"
|
||||
_target_expr=""
|
||||
if [ -f "$_push_target_dir" ]; then
|
||||
_target_expr=$(printf '%s' "$CMD" | python3 "$_push_target_dir" 2>/dev/null || printf '')
|
||||
fi
|
||||
if [ -n "$_target_expr" ]; then
|
||||
HERE=$(cd "$_target_expr" 2>/dev/null && git rev-parse --show-toplevel 2>/dev/null || printf '')
|
||||
else
|
||||
HERE=$(git rev-parse --show-toplevel 2>/dev/null || printf '')
|
||||
fi
|
||||
WANT=$(head -1 "$STAMP" 2>/dev/null || printf '')
|
||||
[ -n "$WANT" ] || return 1
|
||||
[ -n "$HERE" ] || return 1
|
||||
@@ -132,7 +157,16 @@ if printf '%s' "$CMD" | grep -qE '(^|[;&|(`]|&&|\|\|)[[:space:]]*git([[:space:]]
|
||||
# git checkout origin/main --detach; git push origin refs/tags/v0.3.3
|
||||
# ⇒ **紅線寫得越細,命中關鍵字的機率越高**(leo 2026-08-17 的觀察,
|
||||
# 文字層封路必敗)。這裡改成判動作的目標,不是判字面。
|
||||
_push_seg=$(printf '%s' "$CMD" | sed -E 's/.*git[[:space:]]+(-[^[:space:]]+[[:space:]]+)*push//' | sed -E 's/[;&|].*//')
|
||||
# 2026-08-23(順著 inkstone/ISEP#30 comment 3949 補測時自己抓到的洞,不在原票範圍
|
||||
# 但屬於同一支閘、同一段邏輯,且直接讓下面「反向不准鬆」的驗證跑不過,所以一併修):
|
||||
# `(git push origin HEAD:main)`——單純用括號包住整條指令——舊版會整段放行,
|
||||
# 跟 HERE/戳記完全無關,**連目的地判斷本身都沒觸發**。
|
||||
# 成因:截斷 refspec 尾巴只切 `;`/`&`/`|` 三種字元,沒算到 `)`——
|
||||
# 於是「HEAD:main)」被當成一個 token,`${_tok##*:}` 剝完冒號還剩「main)」,
|
||||
# 跟 `^main$` 對不上 ⇒ 判定成「看不出目標」⇒ 整段放行。加 `)` 進截斷字元。
|
||||
# git 的 refspec/分支名語法本來就不允許出現 `)`,所以在這裡截斷永遠安全,
|
||||
# 不會誤傷任何合法的推送目標。
|
||||
_push_seg=$(printf '%s' "$CMD" | sed -E 's/.*git[[:space:]]+(-[^[:space:]]+[[:space:]]+)*push//' | sed -E 's/[;&|)].*//')
|
||||
_dest=""
|
||||
_seen_remote=0
|
||||
_saw_refspec=0
|
||||
|
||||
+92
@@ -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 ]
|
||||
Executable
+165
@@ -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 ]
|
||||
Executable
+201
@@ -0,0 +1,201 @@
|
||||
#!/usr/bin/env bash
|
||||
# factory-idle-guard.sh 的迴歸測試(inkstone/ISEP#30)。
|
||||
#
|
||||
# 來由:2026-08-23 雲端驗收連續三次被這道閘誤攔,三次都不是宣告意圖——
|
||||
# ① 「那句是等你,不是我的下一步」 ← 在否認自己有下一步
|
||||
# ② 「這不是『下一步我要做 X』」 ← 引用閘自己的訊息來說明沒在做
|
||||
# ③ 貼出閘自己的正則原始碼來舉報這個 bug ← code block 裡的引用也被當成宣告
|
||||
# 而閘的訊息教人走「選項③:改寫那句話,說明它在等什麼」,程式碼裡卻沒有那條分支。
|
||||
#
|
||||
# 本檔的兩個方向都要有證據(誤攔與漏攔都要測):
|
||||
# A 群「該放行」——上面三種誤攔案例 + 既有放行路徑不准變鬆的回歸
|
||||
# B 群「該擋」 ——真的宣告了未來動作、且這回合沒按下任何 trigger
|
||||
# C 群「訊息承諾的出路真的走得通」——訊息裡列的每一條,各一個實例
|
||||
#
|
||||
# 用法:hooks/tests/factory-idle-guard.test.sh [hooks/factory-idle-guard.sh 的路徑]
|
||||
# 🔴 全程在一個乾淨的 TMP 底下造假 transcript,跑完自己清;不碰任何真 repo、不打網路。
|
||||
# (CLAUDE_PROJECT_DIR 指向沒有 gitea remote 的 TMP ⇒ 副閘拿不到 token,直接 exit 0,
|
||||
# 所以本檔測的一律是主閘。)
|
||||
|
||||
set -u
|
||||
HOOK="${1:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/factory-idle-guard.sh}"
|
||||
TMP=$(mktemp -d)
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
|
||||
PASS=0; FAIL=0
|
||||
N=0
|
||||
|
||||
# mk <transcript 路徑> <block spec>...
|
||||
# block spec:`T:<文字>` = assistant 的文字段;`U:<工具名>` = assistant 的 tool_use
|
||||
mk() {
|
||||
python3 - "$@" <<'PY'
|
||||
import json, sys
|
||||
path, specs = sys.argv[1], sys.argv[2:]
|
||||
rows = [{"type": "user", "message": {"content": [{"type": "text", "text": "【工單】去辦"}]}}]
|
||||
blocks = []
|
||||
for s in specs:
|
||||
k, v = s.split(":", 1)
|
||||
blocks.append({"type": "text", "text": v} if k == "T"
|
||||
else {"type": "tool_use", "name": v, "id": "t1", "input": {}})
|
||||
rows.append({"type": "assistant", "message": {"content": blocks}})
|
||||
with open(path, "w") as f:
|
||||
for r in rows:
|
||||
f.write(json.dumps(r, ensure_ascii=False) + "\n")
|
||||
PY
|
||||
}
|
||||
|
||||
# t <期望 exit> <說明> <block spec>...
|
||||
t() {
|
||||
want="$1"; desc="$2"; shift 2
|
||||
N=$((N+1))
|
||||
tr="$TMP/tr-$N.jsonl"
|
||||
mk "$tr" "$@"
|
||||
out=$(printf '{"transcript_path":"%s"}' "$tr" \
|
||||
| CLAUDE_PROJECT_DIR="$TMP" 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
|
||||
}
|
||||
|
||||
echo "── A 群:該放行(2026-08-23 雲端三次誤攔的原形狀)─────────────────"
|
||||
|
||||
t 0 "① 否認自己有下一步(『那句是等你,不是我的下一步』)" \
|
||||
'T:我看完了。那句是等你,不是我的下一步。'
|
||||
|
||||
t 0 "① 長版:連同被否認的原句一起引用" \
|
||||
'T:你貼的那行「回覆後我就繼續」是等你,不是我的下一步。'
|
||||
|
||||
t 0 "② 引用閘自己的訊息來說明沒在做" \
|
||||
'T:這不是『下一步我要做 X』,是在說明那道閘認錯了對象。'
|
||||
|
||||
t 0 "③ code block 裡貼閘自己的正則原始碼(舉報 bug)" \
|
||||
'T:誤攔的真兇是這段:
|
||||
```
|
||||
r"(下一步(我|就是|是)?[::]?\s*(?!不是宣告)|接下來我|我(現在|接著|等下|等一下)(就)?(去|來|做|派|審|跑)"
|
||||
```
|
||||
它連裸的「下一步」三個字都吃。'
|
||||
|
||||
t 0 "③ 行內 code 貼同一段正則" \
|
||||
'T:問題出在 `接下來我` 這個 alternative 沒有要求後面接動詞。'
|
||||
|
||||
t 0 "引用 leo 的話(blockquote)含宣告字樣" \
|
||||
'T:> 下一步我去把那支閘修好,你不要插手。
|
||||
|
||||
以上是他八月十六日的原話,我引來說明這道閘的規格。'
|
||||
|
||||
t 0 "User Story 票名(身為…我要…我才…)不是宣告" \
|
||||
'T:我把票名寫成:身為總管,我要把散落的分支收掉整理好,我才不會每天重數一次。'
|
||||
|
||||
# ↓ 這兩向不是想出來的,是拿本機一條 2068 行的真 transcript 跑出來的:
|
||||
# 舊閘在 26 個真實回合終止點上擋了 2 次,兩次咬的都是「我在交代 leo 該做什麼」。
|
||||
t 0 "④ 主詞是 leo 的下一步(『你下一步去把 X 併進 main』)" \
|
||||
'T:## 你下一步(兩招,先便宜的)
|
||||
|
||||
你下一步去把 feat/isep-dynamic-install 併進 main,然後開一個全新雲端 session。'
|
||||
|
||||
t 0 "④ 同上,交代對方要跑的動作" \
|
||||
'T:前三題不適用。下一步還是那一個動作:你把那條分支併進 main,再跑一次看信標印什麼。'
|
||||
|
||||
t 0 "④ 『你的下一步』——主詞在對方身上(單字 lookbehind 蓋不到的變體)" \
|
||||
'T:我把兩條路都寫清楚了。你的下一步去把那條分支併進 main,再開一個新 session 看信標。'
|
||||
|
||||
t 2 "④ 的反面:主詞換回我自己 ⇒ 照樣擋" \
|
||||
'T:我把兩條路都寫清楚了。我的下一步去把那條分支併掉。'
|
||||
|
||||
echo
|
||||
echo "── A 群:既有放行路徑,不准變鬆(迴歸)─────────────────────────"
|
||||
|
||||
t 0 "純對話回合,沒有任何宣告" \
|
||||
'T:這道閘的問題在於它把引用當成宣告,量錯了對象。'
|
||||
|
||||
t 0 "宣告了,但這個回合真的呼叫了 Agent(=按下 trigger)" \
|
||||
'T:下一步我去把 sdd-guard 的誤攔補上。' 'U:Agent'
|
||||
|
||||
t 0 "宣告了,但這個回合真的呼叫了 Task" \
|
||||
'T:接下來我會去補測試。' 'U:Task'
|
||||
|
||||
t 0 "宣告句帶票號指涉(已經在別人手上)" \
|
||||
'T:下一步我去把那支閘補好——已經派給 inkstone/ISEP#30,證據在 comment 3970。'
|
||||
|
||||
t 0 "宣告之後真的動手了(文字後面還有工具呼叫)" \
|
||||
'T:先讀原始碼,接下來我會去補測試。' 'U:Read'
|
||||
|
||||
echo
|
||||
echo "── B 群:該擋(真的宣告了未來動作,且這回合沒按下任何 trigger)────"
|
||||
|
||||
t 2 "『下一步我去把 X 補上』,無派工無票號" \
|
||||
'T:我看完了。下一步我去把那支閘的誤攔補上。'
|
||||
|
||||
t 2 "『接下來我會去補測試』" \
|
||||
'T:目前狀況清楚了。接下來我會去補測試。'
|
||||
|
||||
t 2 "把請示寫成條件句(2026-08-17 leo 實撞那族)" \
|
||||
'T:回「規劃」我就派人盤這份計畫。'
|
||||
|
||||
t 2 "『我等你確認』——空手停在等人身上" \
|
||||
'T:我等你確認。'
|
||||
|
||||
t 2 "『我現在就去跑一遍』" \
|
||||
'T:我現在就去跑一遍那批測試。'
|
||||
|
||||
t 2 "『確認後我才動』" \
|
||||
'T:核准後我才去改那支閘。'
|
||||
|
||||
t 0 "票號寫在行內 code 裡也算數(等長替換才量得到)" \
|
||||
'T:下一步我去把那支閘補好——已派給 `inkstone/ISEP#30`。'
|
||||
|
||||
t 2 "票號離宣告句太遠(文章別處提到而已)⇒ 不算它在別人手上" \
|
||||
'T:早上那批已經在 inkstone/ISEP#30 收掉了。
|
||||
|
||||
(中略:這裡是一大段跟那張票無關的驗收敘述,用來把距離拉開。'"$(printf 'x%.0s' $(seq 1 320))"')
|
||||
|
||||
下一步我去把那支閘的誤攔補上。'
|
||||
|
||||
echo
|
||||
echo "── C 群:訊息承諾的每一條出路,都要真的走得通 ─────────────────"
|
||||
|
||||
t 0 "出路①:這個回合直接呼叫 Agent 派出去" \
|
||||
'T:下一步我去把那支閘補好。' 'U:Agent'
|
||||
|
||||
t 0 "出路②:把票號寫進那句話" \
|
||||
'T:下一步我去把那支閘補好(已派給 inkstone/ISEP#30)。'
|
||||
|
||||
t 0 "出路③:改寫那句話,用 ⏸ 等: 說明它在等什麼" \
|
||||
'T:⏸ 等:leo 親手跑 scripts/github-arm.sh 解保險(D20 人閘,不是我能自己按的)。
|
||||
|
||||
在那之前我等你確認,這件事我不自己動。'
|
||||
|
||||
t 0 "出路③:宣告 + 標記同時存在也放行(標記說明了它在等什麼)" \
|
||||
'T:那批 workflow 要重打 bundle,下一步我去重打。⏸ 等:arcrun-rag 那條 PR 先併,前置沒解除。'
|
||||
|
||||
t 0 "出路④:這個回合收尾在動作上(最後一個工具呼叫之後沒有文字)" \
|
||||
'T:下一步我去把那支閘補好。' 'U:Edit'
|
||||
|
||||
echo
|
||||
echo "── 出路③ 的反面:沒寫標記就照樣擋(不准靠刪掉那句話矇混)───────"
|
||||
|
||||
t 2 "同一句話拿掉 ⏸ 等: 標記 ⇒ 擋" \
|
||||
'T:那批 workflow 要重打 bundle。我等你確認,這件事我不自己動。'
|
||||
|
||||
echo
|
||||
echo "── 壞輸入不准亂擋(fail-open)────────────────────────────────"
|
||||
|
||||
n_bad=0
|
||||
for payload in '{}' '{"transcript_path":"/nonexistent/xx.jsonl"}' 'not-json'; do
|
||||
N=$((N+1))
|
||||
printf '%s' "$payload" | CLAUDE_PROJECT_DIR="$TMP" sh "$HOOK" >/dev/null 2>&1; rc=$?
|
||||
if [ "$rc" -eq 0 ]; then
|
||||
printf ' ✅ 壞輸入 %-34s ⇒ exit=0\n' "$payload"; PASS=$((PASS+1))
|
||||
else
|
||||
printf ' ❌ 壞輸入 %-34s ⇒ exit=%s(期望 0)\n' "$payload" "$rc"; FAIL=$((FAIL+1))
|
||||
fi
|
||||
n_bad=$((n_bad+1))
|
||||
done
|
||||
|
||||
echo
|
||||
echo "────── 通過 $PASS / 失敗 $FAIL (共 $N 向)"
|
||||
[ "$FAIL" -eq 0 ]
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
#!/usr/bin/env bash
|
||||
# 跨 repo 戳記實測(inkstone/ISEP#30 comment 3949,脈絡 inkstone/InkStoneCo#57)
|
||||
#
|
||||
# 補的是什麼:hooks/tests/main-and-prod-push-guard.test.sh 那八向都只在單一 repo
|
||||
# (測試腳本自己所在的 repo)裡驗證,從沒測過「站在 A、要推 B 的 main」這個形狀
|
||||
# ——而這正是 2026-08-21 真的撞到、讓戳記永遠對不上的那個情境。這支專門補這塊。
|
||||
#
|
||||
# 用法:main-and-prod-push-guard-cross-repo.test.sh <要測的 hook 絕對路徑>
|
||||
# 路徑務必給絕對路徑——測試會 cd 進臨時建立的 A/B repo 再呼叫它,相對路徑
|
||||
# 到那時就對不上了(自己撞過一次:exit=127 command not found)。
|
||||
set -u
|
||||
G="$1"
|
||||
STAMP=/tmp/.main-push-ok
|
||||
WORK=$(mktemp -d)
|
||||
trap 'rm -rf "$WORK"; rm -f "$STAMP"' EXIT
|
||||
|
||||
for d in A B; do
|
||||
git init -q -b main "$WORK/$d"
|
||||
git -C "$WORK/$d" config user.email t@t.com
|
||||
git -C "$WORK/$d" config user.name t
|
||||
echo x > "$WORK/$d/f.txt"
|
||||
git -C "$WORK/$d" add f.txt
|
||||
git -C "$WORK/$d" commit -q -m init
|
||||
done
|
||||
A="$WORK/A"; B="$WORK/B"
|
||||
|
||||
pass=0; fail=0
|
||||
t() { # t <說明> <cwd> <指令> <期望 exit>
|
||||
local desc="$1" cwd="$2" cmd="$3" want="$4"
|
||||
local rc
|
||||
rc=$(cd "$cwd" && CLAUDE_CODE_CHILD_SESSION=1 python3 -c '
|
||||
import json, subprocess, sys
|
||||
p = subprocess.run(["bash", sys.argv[2]],
|
||||
input=json.dumps({"tool_name": "Bash",
|
||||
"tool_input": {"command": sys.argv[1]}}),
|
||||
capture_output=True, text=True)
|
||||
print(p.returncode)
|
||||
' "$cmd" "$G")
|
||||
if [ "$rc" = "$want" ]; then printf ' ✅ %-58s exit=%s\n' "$desc" "$rc"; pass=$((pass+1))
|
||||
else printf ' ❌ %-58s exit=%s(期望 %s)\n' "$desc" "$rc" "$want"; fail=$((fail+1)); fi
|
||||
}
|
||||
|
||||
echo "── 2026-08-21 實撞的原形狀:站在 A,要推 B 的 main ──"
|
||||
rm -f "$STAMP"
|
||||
t "沒戳記 → 擋" "$A" "cd $B && git push origin HEAD:main" 2
|
||||
git -C "$B" rev-parse --show-toplevel > "$STAMP"
|
||||
t "替 B 開的戳記 → 推 B 的 main 該放行(舊版在此情境永遠擋,這是本票要修的洞)" \
|
||||
"$A" "cd $B && git push origin HEAD:main" 0
|
||||
|
||||
echo "── 反向不准鬆:替 A 開的戳記,不能拿去放行推 B(08-11 那次穿透的形狀)──"
|
||||
git -C "$A" rev-parse --show-toplevel > "$STAMP"
|
||||
t "替 A 開的戳記 → 拿去推 B 的 main 必須仍被擋" \
|
||||
"$A" "cd $B && git push origin HEAD:main" 2
|
||||
rm -f "$STAMP"
|
||||
|
||||
echo "── git -C 語法要吃到同一套判斷 ──"
|
||||
git -C "$B" rev-parse --show-toplevel > "$STAMP"
|
||||
t "替 B 開戳記,用 git -C B push" "$A" "git -C $B push origin main" 0
|
||||
rm -f "$STAMP"
|
||||
|
||||
echo "── 08-11 原始穿透的形狀:子殼裡的 cd 不能外洩到殼外 ──"
|
||||
git -C "$A" rev-parse --show-toplevel > "$STAMP"
|
||||
t "子殼裡 cd 去 B 但沒在殼內推;殼外站著 A 真的推 → 符合 A 的戳記,放行" \
|
||||
"$A" "(cd $B && true); git push origin HEAD:main" 0
|
||||
rm -f "$STAMP"
|
||||
git -C "$A" rev-parse --show-toplevel > "$STAMP"
|
||||
t "子殼裡 cd 去 B 且在殼內真的推 → 目標是 B,戳記是 A,必須擋" \
|
||||
"$A" "(cd $B && git push origin HEAD:main)" 2
|
||||
rm -f "$STAMP"
|
||||
|
||||
echo "── 順手抓到、一併修的洞:純括號包住整條指令,不准繞過目的地判斷 ──"
|
||||
t "(git push origin HEAD:main) 沒有任何戳記 → 必須擋(舊版在此整段放行)" \
|
||||
"$A" "(git push origin HEAD:main)" 2
|
||||
|
||||
echo "── 同 repo(session 站著的那個)舊行為原封不動 ──"
|
||||
rm -f "$STAMP"
|
||||
t "站在 A 推 A 自己的 main,沒戳記 → 擋" "$A" "git push origin HEAD:main" 2
|
||||
git -C "$A" rev-parse --show-toplevel > "$STAMP"
|
||||
t "站在 A 推 A 自己的 main,替 A 開戳記 → 放行" "$A" "git push origin HEAD:main" 0
|
||||
rm -f "$STAMP"
|
||||
|
||||
echo "── 舊有行為一條都不能壞 ──"
|
||||
t "推 feature branch 放行" "$A" "git push origin feat/xyz" 0
|
||||
t "推 tag 放行" "$A" "git push origin refs/tags/v1.0.0" 0
|
||||
t "只是提到 main 的 gh pr create,放行" "$A" "gh pr create --base main --title t" 0
|
||||
|
||||
echo "── subagent 沒戳記,即使 cd 去別的 repo 也照擋 ──"
|
||||
rm -f "$STAMP"
|
||||
t "subagent 站在 A、cd 去 B 推 main,沒戳記仍擋" "$A" "cd $B && git push origin HEAD:main" 2
|
||||
|
||||
echo "── 單次用完即丟、900 秒逾時:換到跨 repo 場景一樣要成立 ──"
|
||||
git -C "$B" rev-parse --show-toplevel > "$STAMP"
|
||||
t "第一次:替 B 開戳記推 B → 放行" "$A" "cd $B && git push origin HEAD:main" 0
|
||||
t "第二次:同一枚戳記(已用掉)再推一次 → 應該擋" "$A" "cd $B && git push origin HEAD:main" 2
|
||||
rm -f "$STAMP"; touch "$STAMP"
|
||||
t "touch 出的空戳記 → 推 B 的 main 仍應擋(08-12 補的洞不能被本次改動重開)" \
|
||||
"$A" "cd $B && git push origin HEAD:main" 2
|
||||
rm -f "$STAMP"
|
||||
git -C "$B" rev-parse --show-toplevel > "$STAMP"
|
||||
touch -t "$(date -v-16M +%Y%m%d%H%M.%S 2>/dev/null || date -d '-16 minutes' +%Y%m%d%H%M.%S)" "$STAMP" 2>/dev/null
|
||||
t "16 分鐘前開的戳記 → 已過期,推 B 應擋" "$A" "cd $B && git push origin HEAD:main" 2
|
||||
rm -f "$STAMP"
|
||||
|
||||
echo "────── 通過 $pass / 失敗 $fail"
|
||||
[ "$fail" = 0 ]
|
||||
@@ -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 就是這一條)。
|
||||
|
||||
Reference in New Issue
Block a user