#!/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下一步[::]?\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 <\` 搜該掛哪張, 再 \`ticket say -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 <\`(或 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