35e927ef55
空手警察判「有沒有動作」、稼動率警察判「有沒有那句話」,兩支中間留了一格: **有動作、就是不宣告下一步** ⇒ 兩支都放行。2026-08-27 實際發生的就是這一格。 新閘 hooks/mainline-idle-guard.sh(Stop)判的是「有沒有推進」,不是「有沒有動作」: - 乾回合 = 這回合 ≥3 個 tool call,且沒有任何推進證據 - 推進證據五種(任一成立就歸零):派工/產出/寫進外部系統/Bash 寫入白名單/ **工作區狀態變了**(HEAD 或未提交變更的指紋,heredoc·sed 改檔也吃得到) - 連續 4 個乾回合擋一次,擋完歸零且**門檻加倍**(4→8→16) 一個字都不讀(守票上「不准文字層判準」那條紅線);③④ 的字面比對只用來放行, 永遠不用來擋——白名單漏一項是少放行一次,黑名單漏一項是誤攔一次。 門檻 4 是量出來的,不是拍腦袋:重放 6 份真 transcript、1965 個真實回合, 門檻 3 響 4 次、門檻 4 響 1 次(0.05%);門檻 3 多出來那三次都落在 「leo 連問問題、我逐題查證回答」的段落——那正是票上第 3 條驗收要保護的情境。 測試 hooks/tests/mainline-idle-guard.test.sh:61 條,A 群 15 條全是「不該擋」。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
292 lines
14 KiB
Bash
Executable File
292 lines
14 KiB
Bash
Executable File
#!/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 <<MSG
|
||
🛤️ 主線閒置警察:**連續 ${N} 個回合有動作、沒推進。**(本回合 ${C} 個動作)
|
||
|
||
這一格是空手警察與稼動率警察中間的漏洞——
|
||
「沒動作」有人管、「宣告了下一步卻沒派工」有人管,
|
||
但**一直在查、一直在讀、什麼都沒交出去**,兩支都放行。
|
||
🔴 只要不寫「下一步」三個字,就沒有任何閘抓得到。這支抓的就是它。
|
||
|
||
本閘**一個字都不讀**。它只認這五種「推進」,任何一種成立就歸零:
|
||
① 派工/驅動別人 Agent/Task/SendMessage
|
||
② 產出東西 Write/Edit/Artifact
|
||
③ 寫進外部系統 建票/留言/推工作流/部署
|
||
④ Bash 寫入 git commit/push、scripts/ticket、wrangler deploy
|
||
⑤ 工作區變了 HEAD 動了或有新的未提交變更(heredoc/sed 改檔也算)
|
||
|
||
━━ 現在做一件就好,不必補一段說明 ━━━━━━━━━━━━━━━━━━━━━━
|
||
• 查到的東西**寫回票**(\`scripts/ticket say <owner/repo#N> -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
|