#!/usr/bin/env bash # mainline-idle-guard.sh — 主線閒置警察(inkstone/ISEP#30 → comment 4851) # # ── 補的是哪一格 ──────────────────────────────────────────────── # leo 2026-08-27:「**你為什麼會停工什麼都沒派?不是停下來會有 hook 嗎?**」 # # 總管的行為 空手警察 稼動率警察 # ───────────────────────────────────────────────────── # 沒動作、沒宣告 🛑 擋 — # 有動作、有宣告下一步 放行 🛑 擋 # **有動作、不宣告** **放行** **放行** ← 這一格 # # 第三列就是當天實際發生的:連續數個回合在查 git、讀票、跑測試、複驗 subagent 的 # 宣稱——**十幾個 tool call,兩支閘都放行**,而主線一個 subagent 都沒派。 # **只要不寫「下一步」三個字,兩支閘都抓不到。** # # ── 為什麼不是再加一組句型 ────────────────────────────────────── # 紅線就寫在票上:不准用文字層判準。leo 2026-08-17 已經證偽過那條路 # (文字層的閘當日 8 次誤攔、0 次正確攔截,而且**紅線寫得越細越容易誤攔**)。 # 本閘從頭到尾**不讀任何一個字**——它只看「這個回合做了什麼工具呼叫」與 # 「工作區的狀態有沒有變」。措辭隨它變,這條路物理上不通。 # # ── 判準:偵察 ≠ 產出 ────────────────────────────────────────── # 乾回合(dry turn)= ① 這回合 ≥3 個 tool call(**有在用力**) # + ② 沒有任何一種「推進證據」(見下) # 連續 4 個乾回合 ⇒ 擋一次。(4 是量出來的,見「門檻怎麼定的」) # # 🔴 兩個「不算乾回合」的設計,是為了不誤攔: # - **0–2 個 tool call 的回合完全不計數**。回答 leo 一個問題、順手 grep 兩下, # 那是對話,不是空轉;而「零動作」已經有空手警察在管。 # - **推進證據採極寬認定**(下面任何一種都算)。寧可漏攔,不可誤攔—— # 永遠在響的警報等於訓練人忽略警報,那比沒有警報更糟。 # # 推進證據(任何一種成立 ⇒ 這回合有推進,計數歸零): # ① 派工/驅動別人:Agent/Task/SendMessage/spawn_task # ② 產出東西:Write/Edit/MultiEdit/NotebookEdit/Artifact # ③ 寫進外部系統:工具名含 create/push/update/send/reply/publish/deploy/submit… # ④ Bash 白名單:git commit/push/merge/tag、scripts/ticket、wrangler deploy… # ⑤ **工作區狀態變了**:HEAD 動了,或 `git status --porcelain` 的內容變了 # ⇒ 這一條是關鍵:用 heredoc/sed/腳本改檔(本環境很常見)不經過 Edit 工具, # 光看工具名會漏;看**結果**就不會。 # # 🔴 ③④ 是**只用來放行的白名單**,永遠不用來擋。 # 白名單漏了一項=少放行一次(有 ⑤ 兜底),黑名單漏了一項=誤攔一次。 # 方向不對稱,所以只准往放行的方向做字面比對。 # # ── 為什麼是「連續」而不是「這一回合」 ────────────────────────── # 票上第 3 條驗收:「這回合就是在回答一個不需要派工的問題 ⇒ 放行」。 # 單一回合沒有推進,本來就可能完全正當(在複驗、在讀票、在回答)。 # **不正當的是那個狀態持續下去。** 所以量的是連續乾回合數,不是單回合。 # ⇒ 這同時滿足另一條紅線:不會變成「每回合都必須派工」。 # # ── 門檻怎麼定的:拿真 transcript 量,不是拍腦袋 ──────────────── # 重放本機 6 份真 transcript(InkStoneCo 專案,**1965 個真實回合**), # 逐回合餵給這道閘,數它會響幾次: # # 起始門檻 3 → 4 次(92a75156 兩次、602ea4a1 一次、9e0144f1 一次) # 起始門檻 4 → **1 次**(92a75156 第 311 回合) # # 門檻 3 多出來的那三次,看下去都落在「leo 正在連問問題、我逐題查證回答」的段落 # ——那正是票上第 3 條驗收明文保護的情境(**閘不能懲罰誠實回答問題**)。 # 門檻 4 剩下的那一次,落在連續 10 個回合(308–317)只查不產出的那一段, # 那一段本來就同時被 delivery-police 與 wiki-first-police 連續攔了五次。 # ⇒ **1/1965 = 0.05%**。取 4。 # # ⚠️ 重放時一律把 `stop_hook_active` 當成 false(最壞情況)。真實環境裡那幾個 # 回合有不少是被別的 Stop 閘擋出來的 ⇒ 實際會響得比這個數字更少。 # # ── 響過就退讓(不當那個永遠在響的警報)──────────────────────── # 擋一次之後:計數歸零,**門檻加倍**(3 → 6 → 12 → 24…)。 # 真的在空轉會被早早抓到;真的是一段長時間的正當偵察,警報會自己越來越稀。 # 另外 `stop_hook_active` 為真(已被別的 Stop 閘擋過)時完全不計數。 # ⇒ 不可能把人鎖死:擋完就放行,再送一次即可。 # # ── 內部錯誤不准靜默放行 ──────────────────────────────────────── # Stop 閘 fail-closed 會把 session 鎖死,而誤攔是本 repo 的第一級缺陷 # ⇒ 內部錯誤走 exit 0,但**留痕**:log 記 ⚠️、stderr 也印一行。 # 「不准靜默」是靠留痕滿足的,不是靠擋下。 # # 迴歸測試:hooks/tests/mainline-idle-guard.test.sh(含「不該擋」的案例) set -u PROJ="${CLAUDE_PROJECT_DIR:-$(pwd)}" PAYLOAD=$(cat 2>/dev/null || echo '{}') # ⑤ 工作區狀態指紋:HEAD + 未提交變更的內容摘要。 # 不是 git repo 就給空字串(這層證據不可用,其餘四層照跑)。 GITSTATE="" if git -C "$PROJ" rev-parse --git-dir >/dev/null 2>&1; then GITSTATE="$(git -C "$PROJ" rev-parse HEAD 2>/dev/null || echo '-')|$( git -C "$PROJ" status --porcelain 2>/dev/null | shasum 2>/dev/null | cut -c1-16 )" fi VERDICT=$(printf '%s' "$PAYLOAD" | GITSTATE="$GITSTATE" python3 -c ' import json, os, re, sys def out(s): print(s); raise SystemExit try: d = json.load(sys.stdin) except Exception: out("SKIP:bad-payload") # 已被別的 Stop 閘擋過這一輪 ⇒ 不計數、不擋(避免疊擋) if d.get("stop_hook_active"): out("SKIP:already-nudged") tp = d.get("transcript_path") or "" if not tp or not os.path.exists(tp): out("SKIP:no-transcript") sid = d.get("session_id") or "nosid" sid = re.sub(r"[^A-Za-z0-9_.-]", "_", sid)[:64] state_path = os.path.join(os.environ.get("MAINLINE_IDLE_STATE_DIR", "/tmp"), ".mainline-idle-%s.json" % sid) 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: out("SKIP:unreadable") # 這個回合=最後一則「真的來自使用者」的訊息之後(工具結果不算) 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:] names, bash_cmds = [], [] for r in turn: if r.get("type") != "assistant": continue for b in (r.get("message") or {}).get("content") or []: if isinstance(b, dict) and b.get("type") == "tool_use": n = b.get("name") or "" names.append(n) if n == "Bash": cmd = (b.get("input") or {}).get("command") or "" if isinstance(cmd, str): bash_cmds.append(cmd) tools = len(names) # ── 推進證據(白名單:只用來放行)──────────────────────────── DISPATCH = {"Agent", "Task", "SendMessage"} PRODUCE = {"Write", "Edit", "MultiEdit", "NotebookEdit", "Artifact"} # 寫進外部系統的 MCP/內建工具,用動詞尾綴認(漏了只是少放行一次) WRITE_VERB = re.compile( r"(create|push|update|send|reply|post|publish|deploy|submit|spawn_task|" r"write|delete|upload|assign|comment|tag_resource|run_workflow)", re.I) BASH_WRITE = re.compile( r"git\s+(commit|push|merge|tag|cherry-pick|revert|am|apply)" r"|scripts/ticket|(^|\s)ticket\s+(say|new|assign|label|close|move)" r"|wrangler\s+(deploy|publish|d1|kv|r2|secret)" r"|gh\s+(issue|pr|release)\s+(create|comment|edit|close)" r"|npm\s+publish|ship\.mjs|github-arm\.sh") why = "" if any(n in DISPATCH for n in names): why = "dispatch" elif any(n in PRODUCE for n in names): why = "produce" elif any(WRITE_VERB.search(n) for n in names): why = "external-write" elif any(BASH_WRITE.search(c) for c in bash_cmds): why = "bash-write" gitstate = os.environ.get("GITSTATE", "") # ── 狀態檔 ──────────────────────────────────────────────── st = {} try: with open(state_path) as f: st = json.load(f) except Exception: st = {} # 起始門檻。預設 4 是拿真 transcript 量出來的(見檔頭「門檻怎麼定的」段), # 環境變數只是為了讓那個量測可以重跑,不是給人隨手調鬆的旋鈕。 try: BASE = max(2, int(os.environ.get("MAINLINE_IDLE_THRESHOLD", "4"))) except Exception: BASE = 4 streak = int(st.get("streak", 0) or 0) threshold = int(st.get("threshold", BASE) or BASE) last_rows = int(st.get("rows", -1) or -1) last_git = st.get("git", None) def save(verdict): try: with open(state_path, "w") as f: json.dump({"streak": streak, "threshold": threshold, "rows": len(rows), "git": gitstate}, f) except Exception: pass out(verdict) # 第一次見到這個 session:只立基準,不評分(沒有可比的前一格) if last_rows < 0: save("SKIP:baseline") # 同一個回合被叫第二次(transcript 沒長)⇒ 不重複計數 if len(rows) <= last_rows: save("SKIP:same-turn") # ⑤ 工作區狀態變了 ⇒ 有產出(heredoc/sed/腳本改檔都吃得到) if not why and gitstate and last_git is not None and gitstate != last_git: why = "worktree-changed" if why: streak = 0 save("OK:%s:%d" % (why, tools)) # 沒有推進證據,但這回合幾乎沒用力(≤2 個動作)⇒ 那是對話,不計數 if tools < 3: save("OK:light-turn:%d" % tools) streak += 1 if streak >= threshold: fired_at = streak fired_threshold = threshold streak = 0 threshold = threshold * 2 # 響過就退讓:3 → 6 → 12 → 24… save("IDLE::%d::%d::%d" % (fired_at, fired_threshold, tools)) save("OK:dry-turn:%d/%d" % (streak, threshold)) ' 2>/dev/null) || VERDICT="CRASH" [ -n "${VERDICT:-}" ] || VERDICT="CRASH" STAMP=$(date "+%Y-%m-%d %H:%M:%S" 2>/dev/null || echo "?") LOGDIR="$PROJ/.claude/hooks" note() { if [ -d "$LOGDIR" ]; then printf '| %s | %s | %s |\n' "$STAMP" "$1" "$2" >> "$LOGDIR/mainline-idle-guard.log" 2>/dev/null || true fi } case "$VERDICT" in IDLE::*) N=$(printf '%s' "$VERDICT" | cut -d: -f3) T=$(printf '%s' "$VERDICT" | cut -d: -f5) C=$(printf '%s' "$VERDICT" | cut -d: -f7) note "⛔ 擋下" "連續 ${N} 個乾回合(門檻 ${T}),本回合 ${C} 個動作、零推進" cat >&2 < -F <檔>\`) ——查證只有落回票上才是資產,留在 transcript 裡下一個 session 就沒了 • 該別人做的**現在派出去**(帶【工單】一行票號) • 自己該落地的**現在落地**(改檔/commit) ━━ 如果這幾個回合的偵察本來就是正當的 ━━━━━━━━━━━━━━━━━━ 直接再送一次即可,本閘已經歸零,而且**門檻已加倍**(下次要 $((T*2)) 個乾回合才會再響)。 🔴 不必為了過這道閘去做一件假的動作——**假派工比不派更糟。** MSG exit 2 ;; CRASH|SKIP:bad-payload) # fail-closed 的精神在「不准靜默」:Stop 閘擋下內部錯誤會鎖死 session, # 所以放行,但一定留痕(log + stderr),讓「這道閘壞了」查得到。 note "⚠️ 內部錯誤" "$VERDICT" printf '⚠️ mainline-idle-guard 內部錯誤(%s)——本回合未評分,已記入 log。\n' "$VERDICT" >&2 exit 0 ;; *) note "✅ 放行" "$VERDICT" exit 0 ;; esac