e3d05df341
交付警察擋回來是對的:前一顆只驗了「我自己造的假 transcript」。
改用本機一條 2068 行的真 session(26 個真實回合終止點)重驗,當場多找到兩個問題:
① 主詞是「你」的下一步,被當成我的宣告。
舊閘在 26 個真實回合裡擋了 2 次,兩次咬的都是我在交代 leo 該做什麼:
「下一步還是那一個動作:你把 feat/... 併進 main」
「## 你下一步(兩招,先便宜的)」
⇒ 這是全新的第四類誤攔,我原本一向都沒列到。
修法是主詞檢查(誰要動手),只掛在「下一步」這條 alternative 上;
用 finditer 逐個檢查前 8 字有沒有第二人稱,蓋得到「你的下一步」這種
單字 lookbehind 蓋不到的變體。「你點頭我就做」主詞本來就是我,不受影響。
② 票號從「宣告句附近」放寬成「整段」,把閘變鈍了。
真數據:26 個真實回合有 20 個是靠「文中某處剛好有票號」放行的——
而報告幾乎一定會提到票號 ⇒ 這道閘在實務上等於不會響。
當初放寬是因為票號常寫在行內 code 裡,剝掉就找不到。
⇒ 改成**等長**替換(蓋成同樣長度的哨兵而非刪除),位移就能對回原文,
locality 與「行內 code 裡的票號也算數」兩件同時成立。
收緊後的判定分佈:21 no-declaration / 3 dispatched / 2 ticket-referenced
(原本是 20 ticket-referenced / 3 no-declaration / 3 dispatched)。
真 transcript 實測:舊閘擋 2 次(兩次都是誤攔)→ 新閘擋 0 次。
測試 33 向(+6):舊版 21/33 → 新版 33/33。
版號 0.3.7 → 0.3.8。
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
327 lines
18 KiB
Bash
Executable File
327 lines
18 KiB
Bash
Executable File
#!/bin/sh
|
||
# factory-idle-guard.sh — Stop hook:**工頭停工要被擋下,不是被提醒。**
|
||
#
|
||
# 🔴 2026-08-16 大改。leo 的原話就是這道閘的規格:
|
||
# 「做了一個又一個 hook,**你要閃過還是會閃過**,你應該讓原本那個可以改好。」
|
||
# 「拿 n8n 來說,所有的 agent **缺了一個 trigger**,你是按下 trigger 的人,但你不按。
|
||
# 所以警察不應該是催你按就好了,而是**會問你下一步是什麼,你回覆後就要交出
|
||
# 你已經派工的憑證**,不拿票就沒完成觸發。」
|
||
#
|
||
# ⇒ 所以本檔改的是**量測對象**與**強度**,不是再加一支新 hook。
|
||
#
|
||
# ── 舊版為什麼從來沒響過(2026-08-16 查證,兩層失效疊在一起)──────────
|
||
#
|
||
# ① `[ "$RUNNING" -gt 0 ] && exit 0`
|
||
# 有任何 agent 在跑就閉嘴 ⇒ **它量的是工廠,不是工頭**。
|
||
# ② 池子查的是 `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,會擋):**宣告了下一步,卻沒有按下任何 trigger。**
|
||
# trigger = ① 這個回合真的呼叫過 Agent/Task
|
||
# (no-ticket-no-dispatch 已在那條路上驗票號 ⇒ 這裡不必重複驗)
|
||
# ② 收尾文字裡有 `#票號` 指涉(=已經有人在做)
|
||
# ③ 收尾文字裡有 `⏸ 等:<在等什麼>`(=它現在不該做,且說明了在等什麼)
|
||
# ④ 回合收尾在動作上(最後一個動作之後沒有文字)
|
||
#
|
||
# 副閘(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)}"
|
||
PAYLOAD=$(cat 2>/dev/null || echo '{}')
|
||
|
||
# ── 主閘:宣告了下一步,有沒有交出派工憑證?────────────────────────
|
||
VERDICT=$(printf '%s' "$PAYLOAD" | python3 -c '
|
||
import sys, json, os, re
|
||
|
||
try:
|
||
d = json.load(sys.stdin)
|
||
except Exception:
|
||
print("SKIP:bad-payload"); raise SystemExit
|
||
|
||
tp = d.get("transcript_path") or ""
|
||
if not tp or not os.path.exists(tp):
|
||
print("SKIP:no-transcript"); raise SystemExit # 讀不到就別亂擋
|
||
|
||
rows = []
|
||
try:
|
||
with open(tp) as f:
|
||
for line in f:
|
||
line = line.strip()
|
||
if line:
|
||
try: rows.append(json.loads(line))
|
||
except Exception: pass
|
||
except Exception:
|
||
print("SKIP:unreadable"); raise SystemExit
|
||
|
||
# 這個回合=最後一則「真的來自使用者」的訊息之後(工具結果不算)
|
||
start = 0
|
||
for i, r in enumerate(rows):
|
||
if r.get("type") == "user":
|
||
c = (r.get("message") or {}).get("content")
|
||
blocks = c if isinstance(c, list) else [{"type": "text"}]
|
||
if not any(isinstance(b, dict) and b.get("type") == "tool_result" for b in blocks):
|
||
start = i
|
||
turn = rows[start:]
|
||
|
||
# 依序攤平這個回合的 assistant 區塊:("tool", 名字) / ("text", 內容)
|
||
seq = []
|
||
dispatched = False
|
||
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":
|
||
if b.get("name") in ("Agent", "Task"):
|
||
dispatched = True
|
||
seq.append(("tool", b.get("name") or ""))
|
||
elif b.get("type") == "text":
|
||
seq.append(("text", b.get("text") or ""))
|
||
|
||
if dispatched:
|
||
print("OK:dispatched"); raise SystemExit # 出路① 按了 trigger ⇒ 放行
|
||
|
||
# 🔴 只看「最後一個動作**之後**」的文字(2026-08-23 修)
|
||
# 病灶是「回合終止在宣告上」⇒ 該看的是那個終止動作本身。
|
||
# 舊版取最後一則文字,但那則文字後面可能還有 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
|
||
|
||
if not hit:
|
||
print("OK:no-declaration"); 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::" + 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。**
|
||
|
||
你寫的是:
|
||
「…${QUOTE}…」
|
||
|
||
leo 2026-08-16(本閘的規格):
|
||
「所有的 agent **缺了一個 trigger**,你是按下 trigger 的人,**但你不按**。
|
||
所以警察不應該是催你按就好了,而是**會問你下一步是什麼,
|
||
你回覆後就要交出你已經派工的憑證**,不拿票就沒完成觸發。」
|
||
|
||
━━ 為什麼這次是擋不是提醒 ━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||
2026-08-16 一天內同一個病發作四次,**每一次都是這個形狀**:
|
||
回合終止在「輸出文字」⇒「下一步是 X」寫在那段文字裡
|
||
⇒ 在結構上 X 永遠落在回合結束之後。**不是忘記,是把 X 寫進了終結回合的動作裡。**
|
||
而說出意圖會消解掉做它的壓力——一份清楚的計畫**讀起來像進度**。
|
||
|
||
━━ 現在怎麼過這道閘(四條,程式碼裡都真的有對應的分支)━━━━━━━━━
|
||
① **現在就按 trigger**:這個回合直接呼叫 Agent/Task 派出去。
|
||
沒有票 → 先 \`scripts/ticket where <關鍵字>\` 搜該掛哪張,
|
||
再 \`ticket say <owner/repo#N> -F <檔>\`,然後帶【工單】派工。
|
||
② **它已經在別人手上**:把票號寫進這段收尾文字(例:「已派給 inkstone/ISEP#30」)。
|
||
有 \`#數字\` 就放行——那不是「我等下做」,是「已經有人在做」。
|
||
③ **它其實不該現在做**:寫一行說明它在等什麼,格式就是這一個標記——
|
||
|
||
⏸ 等:<在等什麼>
|
||
|
||
例:\`⏸ 等:leo 親手跑 scripts/github-arm.sh 解保險(D20 人閘)\`
|
||
\`⏸ 等:arcrun-rag 那條 PR 先併,前置沒解除\`
|
||
🔴 這個標記**要刻意寫才會有**,寫了就留痕——它取代不了做事,
|
||
但它讓「現在不該做」跟「宣告了不做」在機器上分得開。
|
||
④ **這個回合收尾在動作上**:最後一個工具呼叫之後不要再補一句宣告。
|
||
|
||
🔴 **不准只是把那句話刪掉再送一次。** 刪掉宣告=那件事從此沒人記得,
|
||
比宣告了沒做更糟——它連痕跡都不留。要嘛按 trigger,要嘛用 ③ 留下 ⏸ 記號。
|
||
MSG
|
||
exit 2 ;;
|
||
*)
|
||
note "✅ 放行" "$VERDICT" ;;
|
||
esac
|
||
|
||
# ── 副閘:真的全廠停工(零 agent + 池子有票)──────────────────────
|
||
RUNNING=0
|
||
for d in /private/tmp/claude-501/*/*/tasks; do
|
||
[ -d "$d" ] || continue
|
||
n=$(find "$d" -name '*.output' -newermt '-60 seconds' 2>/dev/null | wc -l | tr -d ' ')
|
||
RUNNING=$((RUNNING + n))
|
||
done
|
||
[ "$RUNNING" -gt 0 ] && exit 0
|
||
|
||
TOKEN=$(git -C "$PROJ" remote get-url gitea 2>/dev/null | sed -E 's|.*//[^:]+:([^@]+)@.*|\1|') || TOKEN=""
|
||
[ -z "$TOKEN" ] && exit 0
|
||
|
||
# 🔴 org 是 inkstone,不是 Leo(2026-08-13 搬遷;舊值讓本閘靜默失效到 08-16)
|
||
TODO=$(
|
||
for repo in mira arcrun-rag Arcrun InkStoneCo; do
|
||
curl -s --max-time 6 -H "Authorization: token $TOKEN" \
|
||
"https://git.uncle6.me/api/v1/repos/inkstone/$repo/issues?state=open&labels=s/todo&limit=50" 2>/dev/null \
|
||
| python3 -c 'import json,sys
|
||
try:
|
||
d=json.load(sys.stdin)
|
||
print(len(d) if isinstance(d,list) else 0)
|
||
except Exception: print(0)' 2>/dev/null
|
||
done | awk '{s+=$1} END {print s+0}'
|
||
)
|
||
[ "${TODO:-0}" -eq 0 ] && exit 0
|
||
|
||
cat >&2 <<MSG
|
||
🏭 稼動率警察:**全廠停工**——零 agent 在跑,而任務池還有 ${TODO} 張可開工的票。
|
||
|
||
leo 2026-08-10:「你的任務是**維護 loop**,你要去拿任務派任務,
|
||
**全工廠停工你要發現,這是警訊**。」
|
||
|
||
【現在該做的,不是回報,是派工】
|
||
1. 撈池子當場撈:\`scripts/ticket where <關鍵字>\`(或 labels=s/todo)
|
||
2. 照 CP 排序:\`system-dev/docs/3-specs/critical-paths/ship.md\` 是有序的六步
|
||
3. 一次派 2–4 件平行跑;同一個 repo 不要塞兩條(會搶工作區)
|
||
4. agent 死於環境錯誤(SSL/API)⇒ **那是要重派的訊號,不是完工**
|
||
MSG
|
||
exit 0
|