diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 05acc48..4630b7f 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "isep", - "description": "InkStone Environment Plugin —— leo 的 Claude Code 環境唯一真相源:48 支機械閘(59 條註冊,白話盤點見 docs/hooks-inventory.md)、7 支 slash command、2 支 skill、27 支腳本,外加治理規範與標籤真相源。本機與雲端裝同一份,沒有子集。", - "version": "0.6.1", + "description": "InkStone Environment Plugin —— leo 的 Claude Code 環境唯一真相源:49 支機械閘(60 條註冊,白話盤點見 docs/hooks-inventory.md)、7 支 slash command、2 支 skill、27 支腳本,外加治理規範與標籤真相源。本機與雲端裝同一份,沒有子集。", + "version": "0.7.0", "keywords": [ "inkstone", "guardrails", diff --git a/docs/hooks-inventory.md b/docs/hooks-inventory.md index 991e3b0..7e28048 100644 --- a/docs/hooks-inventory.md +++ b/docs/hooks-inventory.md @@ -1,4 +1,4 @@ -# 48 支閘,白話盤點表 +# 49 支閘,白話盤點表 > 回應 `inkstone/InkStoneCo#40`:「如果加入了,我應該可以白話文看到 hooks 的內容?」 > 這份表就是那個「白話文」——不用點開任何 `.sh` 檔,一行看懂一支閘在管什麼。 @@ -7,15 +7,15 @@ ## 一句話結論 -`hooks/` 底下有 **48 個 `.sh` 檔**,`hooks.json` 實際掛上 **59 條註冊**(同一支閘常被多種情境同時掛上); +`hooks/` 底下有 **49 個 `.sh` 檔**,`hooks.json` 實際掛上 **60 條註冊**(同一支閘常被多種情境同時掛上); 其中 **3 支檔案存在但沒被掛上**(2 支是待人填的空範本、1 支是刻意留著沒開的止血帶,見下面「未生效」表)。 下面按「你會在什麼時候撞到它」分組,一支一行。 > 🔴 **這兩個數字上一版是錯的(2026-08-26 實際數過才發現)**:本頁原本寫「43 個檔、53 條註冊」, > 而當時真實是 **45 個檔、55 條註冊**——中間有兩支閘進來時沒有回頭改這裡。 > 現在的寫法是實際數出來的: -> `ls hooks/*.sh | wc -l` = 48;`grep -c '"command":' hooks/hooks.json` = 59。 -> ⚠️ **冒號不能省**:`grep -c '"command"'`(沒冒號)會連 `"type": "command"` 一起數到,回 **118**。 +> `ls hooks/*.sh | wc -l` = 49;`grep -c '"command":' hooks/hooks.json` = 60。 +> ⚠️ **冒號不能省**:`grep -c '"command"'`(沒冒號)會連 `"type": "command"` 一起數到,回 **120**。 > 本頁 2026-08-27 之前寫的是沒冒號那版——**照著它跑會拿到一個跟本頁不符的數字**。 > **一份會偷偷過期的盤點表,跟沒有盤點表差不多**——見本頁最後「怎麼跟實況對帳」那段。 > @@ -29,6 +29,9 @@ > 🔴 **`0.6.1`(2026-08-27)補回表格漏掉的兩列**:`isep-presence-beacon.sh`(E 組)、 > `milestone-due-guard.sh`(A 組)。**標頭寫 48 支的同時,下面的表格只列得出 46 支** > ——數字是對的,人話少了兩行。發現的方法與防它再犯的指令,見下面「落差偵測」第二組 `comm`。 +> +> 📌 **`0.7.0`(`inkstone/ISEP#30`,2026-08-27)進來一支**:`mainline-idle-guard.sh`(F 組,Stop)。 +> 48→**49** 支、59→**60** 條,兩個數字都是加完之後當場數出來的(指令同上)。 --- @@ -121,6 +124,7 @@ | `empty-handed-stop-guard.sh` | 這一輪 AI**一個動作都沒做**卻想停下來(等你回覆),就擋下並告訴它「你的命令就是完整授權,不用再等第二次確認」。 | 🛑 擋(至多攔一次) | | `worklist-guard.sh` | AI 自己列過的待辦清單裡還有沒做完的步驟,卻想收工寫報告,就擋下,逼它做完剩下的步驟。 | 🛑 擋 | | `factory-idle-guard.sh` | AI 該去派工卻沒派(工頭停工),就擋下要它交出「已經派工的憑證」,不是隨口說一句「我會催」就算數。**過閘有四條路:現在就派工/把票號寫進那句話/寫一行 `⏸ 等:<在等什麼>`/這一輪收尾在動作上。** 2026-08-23(inkstone/ISEP#30)修好「引用被當成主張」——貼原始碼、引用它自己的訊息、否認自己有下一步,都不再被咬。 | 🛑 擋 | +| `mainline-idle-guard.sh` | AI **連續 4 個回合一直在查、一直在讀,卻什麼都沒交出去**(沒派工、沒改檔、沒寫回票)就擋一次。補的是上面兩支中間那一格:「沒動作」空手警察管、「說了下一步卻沒派」稼動率警察管,**而「有動作、但就是不說下一步」以前兩支都放行**。這支**一個字都不讀**,只看有沒有派工/產出/工作區變動。響過一次門檻就加倍(4→8→16),不會變成一直在響的警報。 | 🛑 擋(響過就退讓) | | `browser-verify-guard.sh` | 這一輪 AI 宣稱「前端驗過了」,卻沒有真的用瀏覽器工具載入過,就擋下——`curl` 抓到 HTML 不算驗過。 | 🛑 擋 | | `self-drive-police.sh` | AI 想停下來問你「早就決定過的事」(用固定句型判斷,例如「要不要 X」「下一步做什麼」「這交給你」)就擋下,反問它查過 wiki/查過派工表了沒。 | 🛑 擋 | | `self-drive-judge.sh` | 跟上面同一件事,但改用小模型(haiku)判斷「換句話說」的請示句——防止 AI 只是把「要不要」改寫成「不確定是否符合期待」就閃過上一支閘。 | 🛑 擋 | diff --git a/hooks/hooks.json b/hooks/hooks.json index 231e559..4d39582 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -233,6 +233,10 @@ { "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/factory-idle-guard.sh" + }, + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/hooks/mainline-idle-guard.sh" } ] }, diff --git a/hooks/mainline-idle-guard.sh b/hooks/mainline-idle-guard.sh new file mode 100755 index 0000000..a5aef99 --- /dev/null +++ b/hooks/mainline-idle-guard.sh @@ -0,0 +1,291 @@ +#!/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 diff --git a/hooks/tests/mainline-idle-guard.test.sh b/hooks/tests/mainline-idle-guard.test.sh new file mode 100755 index 0000000..9d2905c --- /dev/null +++ b/hooks/tests/mainline-idle-guard.test.sh @@ -0,0 +1,187 @@ +#!/usr/bin/env bash +# mainline-idle-guard.sh 的迴歸測試(inkstone/ISEP#30 → comment 4851) +# +# 兩個方向都要有證據: +# A 群「不該擋」——回答問題、輕量回合、任何一種推進證據、未達門檻、響過之後退讓 +# B 群「該擋」 ——連續乾回合(有動作、零推進)踩到門檻 +# C 群「訊息承諾的出路真的走得通」——擋完歸零、門檻加倍 +# +# 用法:hooks/tests/mainline-idle-guard.test.sh [hooks/mainline-idle-guard.sh 的路徑] +# 🔴 全程在乾淨的 TMP 底下造假 transcript 與假狀態檔,跑完自己清; +# 不碰任何真 repo、不打網路、不寫 /tmp 的正式狀態檔(走 MAINLINE_IDLE_STATE_DIR)。 +set -u +HOOK="${1:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/mainline-idle-guard.sh}" +TMP=$(mktemp -d) +trap 'rm -rf "$TMP"' EXIT + +PASS=0; FAIL=0; N=0 + +# append_turn ... +# block spec:`T:<文字>` = assistant 文字段;`U:<工具名>` = tool_use; +# `B:<指令>` = Bash tool_use(帶 command) +append_turn() { + python3 - "$@" <<'PY' +import json, sys +path, specs = sys.argv[1], sys.argv[2:] +blocks = [] +for s in specs: + k, v = s.split(":", 1) + if k == "T": + blocks.append({"type": "text", "text": v}) + elif k == "B": + blocks.append({"type": "tool_use", "name": "Bash", "id": "t", "input": {"command": v}}) + else: + blocks.append({"type": "tool_use", "name": v, "id": "t", "input": {}}) +with open(path, "a") as f: + f.write(json.dumps({"type": "user", + "message": {"content": [{"type": "text", "text": "繼續"}]}}, + ensure_ascii=False) + "\n") + f.write(json.dumps({"type": "assistant", "message": {"content": blocks}}, + ensure_ascii=False) + "\n") +PY +} + +# fire <期望 exit> <說明> [extra-json] +fire() { + sid="$1"; tr="$2"; want="$3"; desc="$4"; extra="${5:-}" + N=$((N+1)) + payload=$(printf '{"session_id":"%s","transcript_path":"%s"%s}' "$sid" "$tr" "$extra") + out=$(printf '%s' "$payload" \ + | CLAUDE_PROJECT_DIR="${PROJ_OVERRIDE:-$TMP}" \ + MAINLINE_IDLE_STATE_DIR="$TMP" bash "$HOOK" 2>&1); rc=$? + if [ "$rc" -eq "$want" ]; then + printf ' ✅ %s\n' "$desc"; PASS=$((PASS+1)) + else + printf ' ❌ %s —— 期望 exit=%s,實得 exit=%s\n' "$desc" "$want" "$rc" + printf '%s\n' "$out" | sed -n '1,6p' | sed 's/^/ /' + FAIL=$((FAIL+1)) + fi +} + +# 造一個新 session 的 transcript,並吃掉第一次的 baseline +new_session() { + sid="$1"; tr="$TMP/$sid.jsonl"; : > "$tr" + append_turn "$tr" 'U:Read' + printf '{"session_id":"%s","transcript_path":"%s"}' "$sid" "$tr" \ + | CLAUDE_PROJECT_DIR="${PROJ_OVERRIDE:-$TMP}" MAINLINE_IDLE_STATE_DIR="$TMP" \ + bash "$HOOK" >/dev/null 2>&1 + echo "$tr" +} + +DRY=(U:Read U:Grep B:git\ status U:Read) # 4 個動作、零推進 = 乾回合 + +echo "── A 群:不該擋 ────────────────────────────────────────────" + +tr=$(new_session s-light) +fire s-light "$tr" 0 "① 第一次見到這個 session(立基準,不評分)" + +# 票上第 3 條驗收:回答一個不需要派工的問題 ⇒ 放行(且**不計數**) +for i in 1 2 3 4 5; do + append_turn "$tr" 'U:Read' 'U:Grep' "T:查了一下,答案是 X。" + fire s-light "$tr" 0 "② 回答問題的輕量回合(2 個動作)第 $i 次——連 5 次都不該擋" +done + +tr=$(new_session s-dispatch) +for i in 1 2 3 4; do + append_turn "$tr" "${DRY[@]}" 'U:Agent' + fire s-dispatch "$tr" 0 "③ 有派工(Agent)第 $i 次 ⇒ 永遠歸零" +done + +tr=$(new_session s-send) +for i in 1 2 3 4; do + append_turn "$tr" "${DRY[@]}" 'U:SendMessage' + fire s-send "$tr" 0 "④ 驅動既有 agent(SendMessage)第 $i 次" +done + +tr=$(new_session s-write) +for i in 1 2 3 4; do + append_turn "$tr" "${DRY[@]}" 'U:Edit' + fire s-write "$tr" 0 "⑤ 有產出(Edit)第 $i 次" +done + +tr=$(new_session s-ticket) +for i in 1 2 3 4; do + append_turn "$tr" 'U:Read' 'U:Grep' 'B:scripts/ticket say inkstone/ISEP#30 -F /tmp/x.md' + fire s-ticket "$tr" 0 "⑥ 寫回票(scripts/ticket say)第 $i 次" +done + +tr=$(new_session s-mcp) +for i in 1 2 3 4; do + append_turn "$tr" "${DRY[@]}" 'U:mcp__x__kbdb_create_record' + fire s-mcp "$tr" 0 "⑦ 寫進外部系統(kbdb_create_record)第 $i 次" +done + +tr=$(new_session s-under) +for i in 1 2 3; do + append_turn "$tr" "${DRY[@]}"; fire s-under "$tr" 0 "⑧ 第 $i 個乾回合——未達門檻(4),不擋" +done + +tr=$(new_session s-active) +append_turn "$tr" "${DRY[@]}" +fire s-active "$tr" 0 "⑩ stop_hook_active(已被別的 Stop 閘擋過)⇒ 不計數" ',"stop_hook_active":true' +append_turn "$tr" "${DRY[@]}" +fire s-active "$tr" 0 "⑪ 同上,第 2 次仍不擋(不會被別的閘的擋下推著累積)" ',"stop_hook_active":true' + +tr=$(new_session s-dup) +append_turn "$tr" "${DRY[@]}" +fire s-dup "$tr" 0 "⑫ 同一個回合被叫兩次(transcript 沒長)——第 1 次" +fire s-dup "$tr" 0 "⑬ 同上第 2 次:不重複計數" +fire s-dup "$tr" 0 "⑭ 同上第 3 次:仍不擋(重複呼叫不該累積成擋下)" + +fire s-bad "$TMP/does-not-exist.jsonl" 0 "⑮ 讀不到 transcript ⇒ 放行(不亂擋)" + +echo "── B 群:該擋 ──────────────────────────────────────────────" + +tr=$(new_session s-idle) +for i in 1 2 3; do + append_turn "$tr" "${DRY[@]}"; fire s-idle "$tr" 0 "⑯ 乾回合 $i/4" +done +append_turn "$tr" "${DRY[@]}"; fire s-idle "$tr" 2 "⑰ 乾回合 4/4 ⇒ **擋下**" + +tr=$(new_session s-idle2) +for i in 1 2 3; do + append_turn "$tr" 'U:Read' 'U:Read' 'U:Read'; fire s-idle2 "$tr" 0 "⑱ 純唯讀乾回合 $i/4" +done +append_turn "$tr" 'U:Bash' 'U:Bash' 'U:Read'; fire s-idle2 "$tr" 2 "⑲ 唯讀 Bash 也算乾回合 ⇒ **擋下**" + +echo "── C 群:響過就退讓(訊息裡承諾的出路真的存在)───────────────" + +# 接續 s-idle:它剛在 ⑰ 被擋過 ⇒ 計數歸零、門檻 4→8 +tr="$TMP/s-idle.jsonl" +for i in 1 2 3 4 5 6 7; do + append_turn "$tr" "${DRY[@]}" + fire s-idle "$tr" 0 "⑳ 擋過之後第 $i 個乾回合——門檻已加倍(4→8),不該再響" +done +append_turn "$tr" "${DRY[@]}" +fire s-idle "$tr" 2 "㉑ 第 8 個乾回合 ⇒ 再擋一次(門檻 8)" + +# 量測用的旋鈕要真的有效(檔頭那份實測靠它重跑) +export MAINLINE_IDLE_THRESHOLD=2 +tr=$(new_session s-th) +append_turn "$tr" "${DRY[@]}"; fire s-th "$tr" 0 "㉒ MAINLINE_IDLE_THRESHOLD=2:第 1 個乾回合" +append_turn "$tr" "${DRY[@]}"; fire s-th "$tr" 2 "㉓ 同上第 2 個 ⇒ 擋(證明門檻可被量測腳本改)" +unset MAINLINE_IDLE_THRESHOLD + +echo "── D 群:工作區狀態變了就算推進(heredoc/sed 改檔吃得到)──────" + +REPO="$TMP/repo"; mkdir -p "$REPO" +git -C "$REPO" init -q 2>/dev/null +git -C "$REPO" config user.email t@t; git -C "$REPO" config user.name t +echo a > "$REPO/a.txt"; git -C "$REPO" add -A; git -C "$REPO" commit -qm init +PROJ_OVERRIDE="$REPO" +tr=$(new_session s-git) +for i in 1 2 3; do + append_turn "$tr" "${DRY[@]}"; fire s-git "$tr" 0 "㉔ 乾回合 $i/4(工作區沒動)" +done +# 用 heredoc/sed 改檔——工具名是 Bash、指令不在白名單,只有狀態指紋看得出來 +echo "changed by heredoc" >> "$REPO/a.txt" +append_turn "$tr" "${DRY[@]}"; fire s-git "$tr" 0 "㉕ 第 4 個乾回合,但工作區變了 ⇒ 算推進,不擋" +for i in 1 2 3; do + append_turn "$tr" "${DRY[@]}"; fire s-git "$tr" 0 "㉖ 之後乾回合 $i/4(證明上一格真的歸零過)" +done +append_turn "$tr" "${DRY[@]}"; fire s-git "$tr" 2 "㉗ 乾回合 4/4 ⇒ **擋下**" +PROJ_OVERRIDE="" + +echo +printf '結果:%s 通過 / %s 失敗(共 %s 條)\n' "$PASS" "$FAIL" "$N" +[ "$FAIL" -eq 0 ] || exit 1