稼動率警察改成擋「宣告」,不擋「提到宣告」
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 <noreply@anthropic.com>
This commit is contained in:
+157
-58
@@ -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 <<MSG
|
||||
🏭 稼動率警察:**你宣告了下一步,但這個回合沒有按下 trigger。**
|
||||
|
||||
@@ -147,19 +237,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 + 池子有票)──────────────────────
|
||||
|
||||
Reference in New Issue
Block a user