Files
ISEP/hooks/factory-idle-guard.sh
Leo e3d05df341 再修兩類:主詞是 leo 的下一步、票號放寬把閘變鈍——都是真 transcript 量出來的
交付警察擋回來是對的:前一顆只驗了「我自己造的假 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>
2026-08-23 17:54:17 +08:00

327 lines
18 KiB
Bash
Executable File
Raw Permalink 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
# ── 把「引用性內容」蓋掉:引用 != 主張 ──────────────────────────
# 🔴 **等長**替換(蓋成同樣長度的哨兵),不是刪掉,有兩個理由:
# ① 刪掉會把外層句構攪碎——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**:這個回合直接呼叫 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