From a594decb78a9346e6a973e39a38dc74d9d4666b2 Mon Sep 17 00:00:00 2001 From: richblack Date: Sun, 23 Aug 2026 17:42:02 +0800 Subject: [PATCH] =?UTF-8?q?=E7=A8=BC=E5=8B=95=E7=8E=87=E8=AD=A6=E5=AF=9F?= =?UTF-8?q?=E6=94=B9=E6=88=90=E6=93=8B=E3=80=8C=E5=AE=A3=E5=91=8A=E3=80=8D?= =?UTF-8?q?=EF=BC=8C=E4=B8=8D=E6=93=8B=E3=80=8C=E6=8F=90=E5=88=B0=E5=AE=A3?= =?UTF-8?q?=E5=91=8A=E3=80=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 2026-08-23 雲端驗收連續三次被這道閘誤攔,三次都不是宣告意圖: ① 否認自己有下一步 ②引用閘自己的訊息 ③貼閘自己的正則原始碼舉報 bug。 而訊息教人走的「選項③:說明它在等什麼」,程式碼裡根本沒有那條分支 ——唯一走得通的路是不寫那三個字,正是同一則訊息明文禁止的動作。 四個真兇,沒有一個是「例外沒列夠」: (a) DECL 會匹配裸的「下一步」三個字(每一節都可選 ⇒ 退化成關鍵字) ⇒ 收緊:每一條 alternative 都必須接到動作動詞才算命中 (b) 只剝 > 引言與長「」,不認 code fence 與行內 code ⇒ 引用被當成主張 ⇒ 引用性標記整段換成哨兵(不是刪掉):內層宣告消失、外層句構留著 ——刪掉正是 08-17 漏掉「回『規劃』我就派人」的原因,兩個方向一起修 (c) 取 blocks_text[-1],但那則文字後面可能還有 tool_use ⇒ 宣告其實兌現了 ⇒ 只看「最後一個動作之後」的文字;收尾在動作上就不觸發 (d) 訊息承諾的出路只有兩條真的存在 ⇒ 出路③ 給一個機械形式 ⏸ 等:<在等什麼>(白名單標記,要刻意寫,留痕) 方向刻意與「再加幾個關鍵字例外」相反——例外清單會越加越長、越長越誤攔。 守 leo 的封路哲學:紅線寫得越細,命中關鍵字的機率越高 ⇒ 那些閘在懲罰謹慎。 順手:擋下與放行都留痕(InkStoneCo#48:只記擋下的話分母未知); log 目錄不在時安靜跳過,不再噴 redirect 錯誤到 stderr。 測試 hooks/tests/factory-idle-guard.test.sh 27 向,誤攔與漏攔兩個方向都測: 舊版 18/27(9 敗)→ 新版 27/27。 Co-Authored-By: Claude Opus 5 --- .claude-plugin/plugin.json | 2 +- docs/hooks-inventory.md | 2 +- hooks/factory-idle-guard.sh | 215 ++++++++++++++++++------- hooks/tests/factory-idle-guard.test.sh | 175 ++++++++++++++++++++ 4 files changed, 334 insertions(+), 60 deletions(-) create mode 100755 hooks/tests/factory-idle-guard.test.sh diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 9f9e594..beb0c70 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -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.6", + "version": "0.3.7", "keywords": [ "inkstone", "guardrails", diff --git a/docs/hooks-inventory.md b/docs/hooks-inventory.md index 7515009..e35a436 100644 --- a/docs/hooks-inventory.md +++ b/docs/hooks-inventory.md @@ -84,7 +84,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 只是把「要不要」改寫成「不確定是否符合期待」就閃過上一支閘。 | 🛑 擋 | diff --git a/hooks/factory-idle-guard.sh b/hooks/factory-idle-guard.sh index 5a8429a..6581034 100755 --- a/hooks/factory-idle-guard.sh +++ b/hooks/factory-idle-guard.sh @@ -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,108 @@ 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 + +# 出路②:票號指涉(=已經有人在做)。同樣在剝標記之前——票號常寫在行內 code 裡 +if re.search(r"#\d{1,5}", raw): + print("OK:ticket-referenced"); raise SystemExit + +# ── 把「引用性內容」換成哨兵:引用 != 主張 ──────────────────────── +# 換成哨兵而不是刪掉,是為了同時修好兩個方向: +# 內層的宣告消失(不再誤攔引用)+ 外層的句構留著(「回『X』我就…」照樣攔得到) +S = "※" # ※ +text = raw +text = re.sub(r"```.*?```", S, text, flags=re.S) # markdown 圍欄 code +text = re.sub(r"~~~.*?~~~", S, text, flags=re.S) +text = re.sub(r"`[^`\n]*`", S, text) # 行內 code +text = re.sub(r"^\s*>.*$", "", text, flags=re.M) # 整段引言(leo 的原話) +for _ in range(3): # 引號(含巢狀),由內而外 + text = re.sub(r"[「『][^「」『』]*[」』]", S, text) +# 本 repo 規定的 User Story 票名:是被命名的物件,不是我對下一步的主張 +text = re.sub(r"身為[^\n]{0,40}?我要[^\n]{0,80}?我才[^\n]{0,60}", S, text) + +# ── 宣告下一步的句型 ────────────────────────────────────────── +# 🔴 每一條都**必須**接到一個動作動詞(A)才算命中。 +# 舊版少了這個要求 ⇒ 裸的「下一步」「接下來我」就開火 ⇒ 討論這道閘的人被這道閘擋。 +A = r"(?:去|來|做|派|審|跑|查|補|建|寫|改|推|驗|清|落|盤|整理|處理|部署|測)" +N = r"[^。!?;,,\n]{0,16}" # 同一句、不跨逗號 +DECL = re.compile("|".join([ + r"下一步[::]?\s*(?:我|就是|是)?\s*" + N + A, + 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*$", +])) -# 引用 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 # 沒宣告 ⇒ 純對話回合,不擋 - -# 宣告句附近有票號指涉(=已經說明它在誰手上/哪張票)⇒ 放行 -seg = text[max(0, hit.start() - 200): hit.end() + 400] -if re.search(r"#\d{1,5}", seg): - print("OK"); raise SystemExit + print("OK:no-declaration"); raise SystemExit # 沒宣告 ⇒ 純對話回合,不擋 print("DECLARED_NO_TRIGGER::" + text[max(0, hit.start()-60): hit.end()+120].replace("\n", " ")[:200]) -' 2>/dev/null || echo SKIP) +' 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 <\` 搜該掛哪張, 再 \`ticket say -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 + 池子有票)────────────────────── diff --git a/hooks/tests/factory-idle-guard.test.sh b/hooks/tests/factory-idle-guard.test.sh new file mode 100755 index 0000000..81d11c7 --- /dev/null +++ b/hooks/tests/factory-idle-guard.test.sh @@ -0,0 +1,175 @@ +#!/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 ... +# 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> <說明> ... +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:我把票名寫成:身為總管,我要把散落的分支收掉整理好,我才不會每天重數一次。' + +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:核准後我才去改那支閘。' + +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 ]