#!/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 在跑就閉嘴 ⇒ **它量的是工廠,不是工頭**。 # 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 索引/本檔。) # # ③ 結尾 `exit 0`——純提醒。leo 2026-08-16:「**提醒可以忽略,deny 不能**。」 # # ── 現在量什麼 ───────────────────────────────────────────────── # # 主閘(exit 2,會擋):**宣告了下一步,卻沒有派工憑證。** # 憑證 = 這個回合真的呼叫過 Agent/Task(而那條路上已有 no-ticket-no-dispatch # 在驗票號)⇒ **「有派工」本身就蘊含「有票」**,不必在這裡重複驗票。 # 也接受:宣告句裡直接寫明它在誰手上(帶 `#數字` 的票號指涉)。 # # 副閘(exit 0,只提醒):真的全廠停工(零 agent + 池子有票)。org 已修。 # # 🔴 **不擋純對話回合**:沒有宣告下一步就不觸發。 # 這是刻意的——永遠在響的警報等於訓練人忽略它(見 branch-holds.md 的同款教訓)。 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"); raise SystemExit tp = d.get("transcript_path") or "" if not tp or not os.path.exists(tp): print("SKIP"); 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"); 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:] 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 elif b.get("type") == "text": blocks_text.append(b.get("text") or "") if dispatched: print("OK"); raise SystemExit # 按了 trigger ⇒ 放行 # 🔴 只看**最後一則**文字,不看整個回合(2026-08-16 第一次實跑就誤攔,修正) # 病灶是「回合終止在宣告上」⇒ 該看的是那個終止動作本身。 # 掃整個回合會在「我這回合稍早說要讀 X、然後真的讀了」這種句子上開火—— # 那是**已完成事項的敘述**,不是未兌現的意圖。誤攔會訓練人忽略警報, # 而那比沒有警報更糟(同 branch-holds.md 的教訓)。 text = blocks_text[-1] if blocks_text else "" # 引用 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("DECLARED_NO_TRIGGER::" + text[max(0, hit.start()-60): hit.end()+120].replace("\n", " ")[:200]) ' 2>/dev/null || echo SKIP) case "$VERDICT" in DECLARED_NO_TRIGGER::*) QUOTE=$(printf '%s' "$VERDICT" | sed 's/^DECLARED_NO_TRIGGER:://') cat >&2 <\` 搜該掛哪張, 再 \`ticket say -F <檔>\`,然後帶【工單】派工。 ② **它已經在別人手上**:把票號寫進那句話(例:「已派給 #44 comment 2761」)。 有票號指涉就放行——那不是「我等下做」,是「已經有人在做」。 ③ **它其實不該現在做**:改寫那句話,說明它在等什麼(前置沒解除/是 leo 的閘), 不要寫成「下一步我要做 X」。 🔴 **不准只是把那句話刪掉再送一次。** 刪掉宣告=那件事從此沒人記得, 比宣告了沒做更糟——它連痕跡都不留。 MSG exit 2 ;; 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