Compare commits

..

3 Commits

Author SHA1 Message Date
Leo ba444d1210 戳記要證明「看過」,不是「跑過」(inkstone/ISEP#72 → comment 4873)
2026-08-27 實犯:`ticket where … >/dev/null 2>&1` 之後直接 `ticket new`——
戳記寫成功了,命中的 72 張一眼沒看,於是開出 arcrun-rag#147,
而第一名 arcrun-rag#104 講的是同一件事,已經開了 13 天。

⇒ 舊戳記證明的是「這個程序被執行過」,而「有沒有看」在 stdout 那一端,
  閘本來完全碰不到。

做法:把 stdout 那一端變成機械事實,不是文字判斷。
- `fd_is_devnull(fd)`:fstat 問得出來的事實。刻意只認 /dev/null 這一種
  (寫進檔案讀得回來、pipe 有下游,只有 /dev/null 物理上找不回來)。
- `where` 把 `shown` 記進戳記;順便替前 3 名補內文摘要——今天的實害正是
  「光看標題看不出是同一條線」(#104 的標題完全沒提 ingest)。
- `new` 的閘一之二:命中 >0 且 shown 為假就擋,並把那份被丟掉的清單
  交到眼前;只要 stderr 不是 /dev/null,這一次的擋就記成「看過了」,
  重下一模一樣的指令就會過——成本落在「看」,不落在「寫」。
- 刻意排在閘二**之前**:否則第一次就帶 `--not-a-comment "理由"` 的人
  永遠看不到候選清單,理由是閉著眼睛寫的。
- 舊格式戳記(沒有 shown 欄位)當成沒看過(fail-closed)。

不該擋的(測試覆蓋):沒命中就不吵、看過了就不吵、`--not-a-comment`
既有欄位照舊、不判斷理由寫得好不好(leo 2026-08-17 已證偽文字層判準)。

測試 17/17:scripts/test-ticket-where-seen-guard.sh(離線,不打真實 Gitea、
不留測試票;離開碼 2=擋、1=放行走到網路才炸)。docs/TESTING.md 新增 A13。
plugin.json 升到 0.8.0——版本沒動=沒有人吃得到。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 21:17:35 +08:00
claude-code a4eb17df48 Merge pull request '補上「有動作但主線沒動」那一格的閘(inkstone/ISEP#30)' (#78) from feat/gate-catches-recon-without-output into main 2026-08-27 12:32:17 +00:00
Leo 35e927ef55 補上「有動作、不宣告」那一格:主線閒置警察(inkstone/ISEP#30)
空手警察判「有沒有動作」、稼動率警察判「有沒有那句話」,兩支中間留了一格:
**有動作、就是不宣告下一步** ⇒ 兩支都放行。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>
2026-08-27 20:28:55 +08:00
8 changed files with 753 additions and 15 deletions
+2 -2
View File
@@ -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.8.0",
"keywords": [
"inkstone",
"guardrails",
+19
View File
@@ -53,6 +53,24 @@ bash scripts/test-ticket-api-bypass-guard.sh
`⏭️ SKIP`,不算失敗,但也不算驗過)。
**失敗**:任何一條不符,特別看「不該擋」那幾條——誤攔比漏擋更該修。
### A13 — 搜尋戳記要證明「看過」,不是「跑過」:17 條
```
bash scripts/test-ticket-where-seen-guard.sh
```
**該看到**`通過 17 條,失敗 0 條`。全程離線(`TICKET_HOST` 指到連不上的位址:
離開碼 2 =被閘擋、離開碼 1 =閘全放行走到網路才炸),**不打真實 Gitea、不留測試票**。
**它在守什麼**inkstone/ISEP#72 → comment 48732026-08-27 實犯):
`ticket where … >/dev/null` 之後 `ticket new`——戳記寫成功了,命中的 72 張一眼沒看,
於是開出 `arcrun-rag#147`,而第一名 `arcrun-rag#104` 是同一件事、已經開了 13 天。
**失敗**
- 「★」那兩條紅 ⇒ 今天這個形狀會再發生一次(尤其第二條:先寫好 `--not-a-comment`
理由就能閉著眼睛開票)
- 「不該擋」3 條任一紅 ⇒ **誤攔**,這比漏擋嚴重——沒命中也吵、看過了還吵,
人就學會忽略它
- 最後那組 `fd_is_devnull` 紅 ⇒ 判準從「fstat 問得出來的事實」滑回猜文字
### A11 — 討論串裡的任務要長成子票:22 條
```
bash scripts/test-comment-carries-task-guard.sh
@@ -265,6 +283,7 @@ B2(信標那行)/B3(setup 輸出)/B4(閘的訊息)三個畫面
| A2 版本三處一致 | 總管 | ✅ |
| A3 打 tag 閘 | 總管 | ✅ 3/3 |
| **A4 新增 Gitea 東西側門閘** | 總管 | ✅ 24/242026-08-27inkstone/ISEP#72 |
| **A13 戳記證明看過不是跑過** | 總管 | ✅ 17/172026-08-27inkstone/ISEP#72→4873 |
| A5 搜尋跨 repo | 總管 | ✅ |
| A6 標籤對齊+冪等 | 總管 | ✅ 14 repo,第二次 0/0 |
| **A9 人閘警察管路** | 總管 | ✅ 14/142026-08-26 |
+8 -4
View File
@@ -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-23inkstone/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 只是把「要不要」改寫成「不確定是否符合期待」就閃過上一支閘。 | 🛑 擋 |
+4
View File
@@ -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"
}
]
},
+291
View File
@@ -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 是量出來的,見「門檻怎麼定的」)
#
# 🔴 兩個「不算乾回合」的設計,是為了不誤攔:
# - **02 個 tool call 的回合完全不計數**。回答 leo 一個問題、順手 grep 兩下,
# 那是對話,不是空轉;而「零動作」已經有空手警察在管。
# - **推進證據採極寬認定**(下面任何一種都算)。寧可漏攔,不可誤攔——
# 永遠在響的警報等於訓練人忽略警報,那比沒有警報更糟。
#
# 推進證據(任何一種成立 ⇒ 這回合有推進,計數歸零):
# ① 派工/驅動別人:AgentTaskSendMessagespawn_task
# ② 產出東西:WriteEditMultiEditNotebookEditArtifact
# ③ 寫進外部系統:工具名含 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 份真 transcriptInkStoneCo 專案,**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} 個動作)
這一格是空手警察與稼動率警察中間的漏洞——
「沒動作」有人管、「宣告了下一步卻沒派工」有人管,
但**一直在查、一直在讀、什麼都沒交出去**,兩支都放行。
🔴 只要不寫「下一步」三個字,就沒有任何閘抓得到。這支抓的就是它。
本閘**一個字都不讀**。它只認這五種「推進」,任何一種成立就歸零:
① 派工/驅動別人 AgentTaskSendMessage
② 產出東西 WriteEditArtifact
③ 寫進外部系統 建票/留言/推工作流/部署
④ Bash 寫入 git commitpush、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
+187
View File
@@ -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 <transcript> <block spec>...
# 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 <session> <transcript> <期望 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 "④ 驅動既有 agentSendMessage)第 $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 "⑯ 乾回合 $i4"
done
append_turn "$tr" "${DRY[@]}"; fire s-idle "$tr" 2 "⑰ 乾回合 44 ⇒ **擋下**"
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 "⑱ 純唯讀乾回合 $i4"
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 "㉔ 乾回合 $i4(工作區沒動)"
done
# 用 heredocsed 改檔——工具名是 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 "㉖ 之後乾回合 $i4(證明上一格真的歸零過)"
done
append_turn "$tr" "${DRY[@]}"; fire s-git "$tr" 2 "㉗ 乾回合 44 ⇒ **擋下**"
PROJ_OVERRIDE=""
echo
printf '結果:%s 通過 / %s 失敗(共 %s 條)\n' "$PASS" "$FAIL" "$N"
[ "$FAIL" -eq 0 ] || exit 1
+116
View File
@@ -0,0 +1,116 @@
#!/bin/bash
# 「戳記要證明看過,不是跑過」的測試(inkstone/ISEP#72 → comment 4873
#
# 這支測的是 scripts/ticket 的閘一之二:搜尋跑了,但輸出被丟進 /dev/null 時,
# `ticket new` 要擋下來,並把那份被丟掉的候選清單交到眼前。
#
# 怎麼在「不真的開票」的前提下分辨擋/放行:
# TICKET_HOST 指到一個連不上的位址(127.0.0.1:9
# 離開碼 2 = 閘擋下(die)
# 離開碼 1 = 閘全部放行,走到網路那一層才炸(Connection refused
# 全程離線、不打真實 Gitea、不產生任何票。
cd "$(dirname "$0")/.." || exit 1
T=scripts/ticket
STAMP=/tmp/.ticket-where-ok
TMP=$(mktemp -d)
trap 'rm -rf "$TMP"' EXIT
PASS=0; FAIL=0
SAVED=""; [ -f "$STAMP" ] && SAVED=$(cat "$STAMP")
rm -f "$STAMP"
cat > "$TMP/body.md" <<'MD'
## 目標
測試用內文。
## 驗收條件
離開碼 2 = 被擋;離開碼 1 = 放行(走到網路才炸)。
## deliverable 類型
code
MD
TITLE="身為要開票的人,我要先看過命中的舊票,我才不會又開一張一樣的"
# stamp <n> <shown|none> <age_sec>
stamp() {
python3 - "$1" "$2" "$3" <<'PY'
import json, sys, time
n, shown, age = int(sys.argv[1]), sys.argv[2], float(sys.argv[3])
st = {"at": time.time() - age, "kws": ["ingest", "queue"], "n": n,
"top": ["inkstone/arcrun-rag#104"],
"top_detail": [{"ref": "inkstone/arcrun-rag#104", "score": 2,
"title": "它把我整個 repo 的一萬多個檔案排進佇列",
"labels": ["Human"],
"excerpt": "這些庫都早就萃好了,所以它要辨識這個庫已經有 wiki"}]}
if shown != "none":
st["shown"] = (shown == "true")
json.dump(st, open("/tmp/.ticket-where-ok", "w"))
PY
}
# run <want_rc> <說明> [額外參數...]
run() {
want=$1; what=$2; shift 2
out=$(TICKET_HOST=http://127.0.0.1:9 GITEA_TOKEN=x \
python3 "$T" new ISEP -F "$TMP/body.md" --title "$TITLE" "$@" 2>&1)
got=$?
LAST_OUT="$out"
if [ "$got" = "$want" ]; then PASS=$((PASS+1)); printf ' ✅ '; else FAIL=$((FAIL+1)); printf ' ❌ '; fi
printf 'want=%s got=%s %s\n' "$want" "$got" "$what"
}
has() { # has <字串> <說明>
if printf '%s' "$LAST_OUT" | grep -q "$1"; then PASS=$((PASS+1)); printf ' ✅ '
else FAIL=$((FAIL+1)); printf ' ❌ '; fi
printf '訊息裡有「%s」 %s\n' "$1" "$2"
}
echo "── 該擋 ──"
rm -f "$STAMP"
run 2 "完全沒搜過(既有閘一,回歸)"
stamp 3 true 3600
run 2 "戳記過期(既有閘一,回歸)"
stamp 72 false 10
run 2 "★ 今天的重演:where 的輸出被丟進 /dev/null,命中 72 張一眼沒看"
has "沒有到任何人眼前" "擋下的理由講的是「沒看」不是「沒搜」"
has "arcrun-rag#104" "擋下的訊息把被丟掉的候選清單交到眼前"
has "這些庫都早就萃好了" "連內文摘要都給了(光看標題看不出是同一條線)"
stamp 72 false 10
run 2 "★ 閉著眼睛先寫好理由也繞不過(--not-a-comment 在前面就被擋)" --not-a-comment "這是新的一條線"
stamp 72 none 10
run 2 "舊格式戳記(沒有 shown 欄位)→ fail-closed,當成沒看過" --not-a-comment "x"
stamp 72 true 10
run 2 "命中卻沒給理由(既有閘二,回歸)"
echo
echo "── 不該擋(誤攔比漏擋更該修)──"
stamp 72 true 10
run 1 "看過了、也給了理由 → 放行(走到網路才炸)" --not-a-comment "這是新的一條線"
stamp 0 false 10
run 1 "沒有命中任何票 → 沒東西可看,不要吵"
stamp 0 none 10
run 1 "沒命中 + 舊格式戳記 → 一樣不吵"
echo
echo "── 擋完之後:那一次的擋本身就是「把清單交到眼前」 ──"
stamp 72 false 10
run 2 "第一次:被擋" --not-a-comment "這是新的一條線"
run 1 "第二次下一模一樣的指令 → 放行(不必多打任何字)" --not-a-comment "這是新的一條線"
echo
echo "── fd_is_devnull:判準是 fstat 問出來的事實,不是猜文字 ──"
u() { # u <want> <說明> <shell 片段>
got=$(eval "$3")
if [ "$got" = "$2" ]; then PASS=$((PASS+1)); printf ' ✅ '; else FAIL=$((FAIL+1)); printf ' ❌ '; fi
printf 'want=%s got=%s %s\n' "$2" "$got" "$1"
}
PROBE="import importlib.util as u,sys; from importlib.machinery import SourceFileLoader as L; s=u.spec_from_loader('t',L('t','scripts/ticket')); m=u.module_from_spec(s); s.loader.exec_module(m); print(m.fd_is_devnull(1), file=sys.stderr)"
u "stdout 丟進 /dev/null → True" "True" "python3 -c \"\$PROBE\" >/dev/null 2>&1; python3 -c \"\$PROBE\" 2>&1 >/dev/null"
u "stdout 接到 pipeClaude Code 就是這種)→ False" "False" "python3 -c \"\$PROBE\" 2>&1 | cat"
u "stdout 寫進一般檔案(讀得回來)→ False" "False" "python3 -c \"\$PROBE\" 2>&1 >\"$TMP/o.txt\""
rm -f "$STAMP"
[ -n "$SAVED" ] && printf '%s' "$SAVED" > "$STAMP"
echo
echo "通過 $PASS 條,失敗 $FAIL"
[ "$FAIL" = 0 ] || exit 1
+126 -9
View File
@@ -29,6 +29,7 @@ leo 2026-08-16 三句話,本工具就是它們的機械化:
import json
import os
import re
import stat
import subprocess
import sys
import time
@@ -71,7 +72,9 @@ def check_identity(body, what):
規約全文:docs/governance/dispatch-and-reply-format.md §3""")
HOST = "https://git.uncle6.me"
# TICKET_HOST 只給測試用(指到一個連不上的位址,就能在不真的開票的前提下
# 驗「閘放行了沒」——閘擋下=離開碼 2 且有 🚫;閘放行=走到網路那一層才炸)。
HOST = os.environ.get("TICKET_HOST") or "https://git.uncle6.me"
ORG = "inkstone"
STAMP_DIR = "/tmp"
STAMP_TTL = 30 * 60 # 戳記 30 分鐘失效——搜過就要趁記憶還熱的時候開
@@ -138,10 +141,65 @@ def parse_ref(s):
return m.group(1), m.group(2), int(m.group(3))
def api_soft(path):
"""讀一筆,失敗就回 None(不 die)。給「錦上添花」的資訊用,不影響任何判斷。"""
try:
url = f"{HOST}/api/v1{path}"
req = urllib.request.Request(
url, headers={"Authorization": f"token {token()}"})
return json.load(urllib.request.urlopen(req, timeout=20))
except SystemExit:
raise
except Exception:
return None
def stamp_path():
return os.path.join(STAMP_DIR, ".ticket-where-ok")
# ── 「跑過」與「看過」的分界(inkstone/ISEP#72 → comment 4873)────────────
#
# 2026-08-27 實錯(本段的來由,leo 當場點破):
# python3 scripts/ticket where 卡片 沒上雲 收檔 佇列 送達 ingest >/dev/null 2>&1
# python3 scripts/ticket new arcrun-rag -F … --title "…" → ✅ #147 已開
# 而重跑同一個搜尋、這次看輸出:**第一名就是 arcrun-rag#104**,同一件事,開了 13 天。
#
# ⇒ 舊戳記證明的是「這個程序被執行過」,不是「這個人看過結果」。
# 而「有沒有看」在 stdout 那一端——閘本來完全碰不到。
#
# 這支函式就是把那一端變成**機械事實**:一個 fd 是不是 /dev/null
# 是 fstat 問得出來的,不是對文字的猜測(本 repo 心法第 1 條:封動作不封文字)。
#
# 🔴 刻意只認 /dev/null 這一種,不擴大到「重導到檔案」「接到 pipe」:
# 寫進檔案還讀得回來、接進 pipe 還有下游,只有 /dev/null 是**物理上找不回來**。
# 多認一種都會開始誤攔(心法第 2 條:永遠在響的警報等於沒有警報)。
def fd_is_devnull(fd):
try:
s = os.fstat(fd)
if not stat.S_ISCHR(s.st_mode):
return False # 一般檔案/pipe/socket:內容至少送到了讀得回來的地方
return s.st_rdev == os.stat(os.devnull).st_rdev
except Exception:
return True # 問不出來就當「沒被看見」——閘不准因為內部錯誤靜默放行
def excerpt(body, n=220):
t = re.sub(r"\s+", " ", body or "").strip()
return (t[:n] + "…") if len(t) > n else (t or "(沒有內文)")
def candidate_lines(detail):
"""把候選票印成同一個樣子——`where` 印它,`new` 擋下來時也印它(同一份東西)。"""
out = []
for d in detail:
out.append(f" [{d.get('score', '?')}] {d['ref']} {d.get('labels', [])}")
out.append(f" {(d.get('title') or '')[:70]}")
if d.get("excerpt"):
out.append(f" ↳ {d['excerpt'][:170]}")
return out
# ── where ────────────────────────────────────────────────────────────────
def cmd_where(argv):
if not argv:
@@ -158,18 +216,39 @@ def cmd_where(argv):
seen[ref] = {"score": 1, "it": it}
hits = sorted(seen.values(), key=lambda x: -x["score"])
detail = []
for h in hits[:12]:
it = h["it"]
detail.append({"ref": it["repository"]["full_name"] + "#" + str(it["number"]),
"score": h["score"], "title": it["title"] or "",
"labels": [l["name"] for l in it.get("labels", [])]})
# 前 3 名補內文摘要。2026-08-27 的實害正是「光看標題看不出是同一條線」:
# arcrun-rag#104 的標題是「它把我整個 repo 的一萬多個檔案排進佇列」,
# 而它票頭第一段講的就是「這些庫都早就萃好了…直接 ingest」——同一件事。
# 拿不到就安靜跳過(純加分,不影響任何判斷)。
for d in detail[:3]:
o, r, n = parse_ref(d["ref"])
got = api_soft(f"/repos/{o}/{r}/issues/{n}")
if got:
d["excerpt"] = excerpt(got.get("body"))
print(f"🔍 搜尋:{' '.join(kws)} → 命中 {len(hits)} 張 open 票\n")
if not hits:
print(" (沒有命中——換幾個講法再試一次。真的沒有,才輪到開新票)")
for h in hits[:12]:
it, labels = h["it"], [l["name"] for l in h["it"].get("labels", [])]
print(f" [{h['score']}] {it['repository']['full_name']}#{it['number']} {labels}")
print(f" {it['title'][:70]}")
for line in candidate_lines(detail):
print(line)
# 🔴 這行是本閘的全部:輸出有沒有可能到得了人眼前,是 fstat 問得出來的事實。
shown = not fd_is_devnull(1)
with open(stamp_path(), "w") as f:
json.dump({"at": time.time(), "kws": kws, "n": len(hits),
"top": [h["it"]["repository"]["full_name"] + "#" + str(h["it"]["number"])
for h in hits[:12]]}, f)
json.dump({"at": time.time(), "kws": kws, "n": len(hits), "shown": shown,
"top": [d["ref"] for d in detail], "top_detail": detail}, f)
if not shown:
# stdout 被丟進 /dev/null。訊息改走 stderr(它常常還活著);
# 就算兩邊都被丟掉也沒關係——戳記已經記下 shown=false`new` 那端會擋。
print("\n⚠️ 這次搜尋的輸出被丟進 /dev/null,等於沒有人看過。"
"\n `ticket new` 會擋下來並把上面這份清單再交到你眼前一次。", file=sys.stderr)
print(f"""
── 決定要做什麼 ───────────────────────────────────────────────
@@ -221,9 +300,47 @@ leo 原話:「**寫開票前先去搜尋要開在哪裡,不然你永遠會
if time.time() - st["at"] > STAMP_TTL:
die(f"🚫 搜尋戳記已過期(超過 {STAMP_TTL // 60} 分鐘)。重跑一次 ticket where")
# 閘一之二:搜尋的結果**到得了人眼前**了沒(inkstone/ISEP#72 → comment 4873
#
# 🔴 這道閘要擋的不是「沒搜」,是「搜了但沒看」——今天實犯:
# `ticket where … >/dev/null 2>&1` 之後直接 `new`,命中的 72 張一眼都沒看,
# 而第一名 arcrun-rag#104 就是同一件事(開了 13 天)。
#
# ⚠️ 這一段刻意放在閘二**之前**:否則第一次就帶 `--not-a-comment "理由"`
# 的人永遠不會看到候選清單——理由是閉著眼睛寫的,那道閘等於沒有。
#
# 成本落在「看」不落在「寫」:擋下來的訊息**本身就是那份被丟掉的輸出**,
# 看完重下同一個指令就會過。不要求多打任何一個字,也不判斷理由寫得好不好
# leo 2026-08-17 已證偽文字層判準:8 次誤攔、0 次正確攔截)。
if st["n"] > 0 and not st.get("shown"):
lines = candidate_lines(st.get("top_detail") or []) or \
[" " + r for r in (st.get("top") or [])]
# 只要 stderr 不是 /dev/null,這一次的擋就已經把清單交到眼前了 → 記進戳記。
# 兩邊都被丟掉時**不記**(fail-closed):下次還是擋,不會靜默放行。
if not fd_is_devnull(2):
st["shown"] = True
with open(stamp_path(), "w") as f:
json.dump(st, f)
die("""🚫 搜尋跑過了,但那份輸出沒有到任何人眼前(stdout 是 /dev/null)。
leo 2026-08-27:「**同一個 session 開兩個一樣的任務就算了,新開票沒搜尋就隨便動手開**」
實錯(同日):`ticket where … >/dev/null` → 開出 arcrun-rag#147
而命中的第一名 arcrun-rag#104 講的就是同一件事,已經開了 13 天。
⇒ **戳記證明的是「這個程序被執行過」,不是「這個人看過結果」。**
命中的 {n} 張,前幾張在這裡——這就是剛才被丟掉的那一份:
{lines}
看完覺得真的都不是同一條線 → **重下一次一模一樣的指令就會過**,不必多打任何字。
(判準是「輸出有沒有進得了 /dev/null 以外的地方」,不看你寫了什麼理由。)""".format(
n=st["n"], lines="\n".join(lines)))
# 閘二:搜到了東西,就要說明為什麼不是貼進去
if st["n"] > 0 and "--not-a-comment" not in argv:
top = "\n".join(" " + t for t in st["top"][:8])
top = "\n".join(candidate_lines((st.get("top_detail") or [])[:8])) or \
"\n".join(" " + t for t in st["top"][:8])
die(f"""🚫 剛才那次搜尋命中 {st['n']} 張 open 票,你卻要開新的。
命中的前幾張: