Files
ISEP/hooks/factory-idle-guard.sh
T
Leo a594decb78 稼動率警察改成擋「宣告」,不擋「提到宣告」
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>
2026-08-23 17:42:22 +08:00

303 lines
16 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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
# 出路②:票號指涉(=已經有人在做)。同樣在剝標記之前——票號常寫在行內 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*$",
]))
hit = DECL.search(text)
if not hit:
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: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**:這個回合直接呼叫 AgentTask 派出去。
沒有票 → 先 \`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,不是 Leo2026-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