Compare commits

...

13 Commits

Author SHA1 Message Date
claude-code a9e30fc6e7 Merge pull request '每一則回覆都自己說出拖了多久(inkstone/ISEP#63)' (#94) from feat/countdown-stamp into main 2026-08-28 00:10:10 +00:00
Leo 17a74d7d3a 每一則回覆都自己說出拖了多久(inkstone/ISEP#63 → comment 5106)
leo 2026-08-27:「前面說過每個回覆要戴上已經花了總時長,這為什麼沒出現?」
              「這應該寫在 ISEP,隨時看自己拖了多久」
重點在後面那句:不是要總管記得戴,是要它長在機器上。
總管當時答「我沒做,現在開始戴」——而那正是這條規則第一次失效的方式。

一支閘掛兩個事件,是同一件事的兩半(票上點名的失效模式就在這裡):
  UserPromptSubmit → 注入算好的那一行(模型不必自己算,也算不準)
  Stop            → 查核這一回合的回覆裡到底有沒有那一行,沒有就擋一次
只做前半=又一個會被忽略的提醒;只做後半=罰它做一件拿不到資料的事。

判準不是關鍵字黑名單,是「那個被要求的輸出元素在不在」——
whitelist-of-one:要求一個機器產生的標記在場,不是猜哪些字不該在場。
換講法照樣要帶標記,多寫什麼都不會觸發。

時長從這段對話的第一則訊息算起,理由寫在 hooks/lib/countdown.py 檔頭:
「任務」在機器上沒有起點,而 CLAUDE.md 規則三點七「一段對話=一個 release」
剛好讓對話起點就是這個交付的起點——這個數字沒有人要維護,也不會說謊。
resume 取較早的那個:接關不是重新開始。

連帶修好票上的兩個卡點(prod-write-guard):
  卡點一 發一則 Telegram 跟部署工作流在閘眼裡一模一樣 ⇒ 判準改看打的是哪一個
        named webhook(路徑形狀+名字),放行範圍只有 notify_leo 一個名字
  卡點二 連「把卡點寫進票裡」都被同一支閘擋(同款第八次)⇒ 剝掉內文再判,
        起始行保留,所以真的在部署的寫法照樣擋
        (修這支的過程又撞了一次同款——那就是這一格最好的證據)

測試:countdown 20/20、prod-write-guard 29→37/37,全程離線
     (時鐘定住、狀態走環境變數、不打網路)。
實測:起點 06:28 台北、現在 08:03 ⇒ 期望「已過 1 小時 35 分」,實得同一字串。

plugin.json 0.9.0 → 0.10.0(版本沒動=沒有人吃得到)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 00:03:38 +00:00
claude-code e0ac81b2e3 Merge pull request '補上「用錯的路去證明一件事」那一格的閘(inkstone/ISEP#30 → comment 4879)' (#80) from feat/gate-search-is-not-proof into main 2026-08-27 13:41:17 +00:00
Leo 7de1ad6be6 補上「用錯的路去證明一件事」那一格的閘(inkstone/ISEP#30 → comment 4879)
擋的是**證據的出處**,不是措辭。

判準(兩個條件同時成立才響):
  ① 要送出去的那份東西(票上的留言/派工單)裡,貼了一個值
     ——entry id 或 `kb://` 來源位址——而它這個 session 只在
     `kbdb_search` 的回應裡出現過
  ② 這個 session 從沒用產品檢索路徑(graph/wiki 內容/query)拿過那筆

為什麼不比對措辭:驗收條件第 4 條寫死不准,而 leo 2026-08-17 已經證偽過
文字層——那天 8 次誤攔、0 次正確攔截,且方向穩定:紅線寫得越細,命中
關鍵字的機率越高 ⇒ 那些閘在懲罰謹慎。值跟動作一樣有限且可枚舉,措辭不是。

🔴 `kbdb_search` 一點都沒有變難用:查 wiki、找 record_id、看某筆在不在,
全部照放。它只在「把搜尋輸出貼出去當產品檢索壞掉的證據」那一刻才響。

實測 31 條,A 群(不該擋)11 條、B 群(該擋)5 條、C 群訊息 6 條、
D 群登記處 9 條。真跡重演=inkstone/Arcrun#167 comment 4865 那則退回,
原文照貼會被擋;走過一次真路徑之後同一份留言就放行。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 21:38:17 +08:00
claude-code a001b10947 Merge pull request '戳記要證明「看過」,不是「跑過」(inkstone/ISEP#72)' (#79) from fix/stamp-proves-seen-not-run into main 2026-08-27 13:24:28 +00:00
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
claude-code 736a601589 Merge pull request '盤點表補回漏掉的兩列人話+補上驗得出這種漏的指令(inkstone/ISEP#75)' (#76) from docs/inventory-missing-rows into main 2026-08-27 10:39:59 +00:00
claude-code 1b64f3cc4e Merge pull request '跑測試不再偽造「有一筆推 main 在等你裁」(inkstone/ISEP#59)' (#77) from fix/tests-leave-no-forged-pending into main 2026-08-27 10:38:48 +00:00
Leo fa2f76177e docs(inventory): 反向那半補上真正驗得到幽靈的那一格(總管複驗退回)
總管複驗指出反向 comm 有 3 個輸出。實跑重現了,但逐個查過之後:
**三個都不是幽靈,三個都不該刪。**

  claim-verify-police.sh / subagent-claim-worksheet.sh
    → 全部 5 處都在內文,講的是「已於 ISEP#60 移除」。表格列(第一欄)
      命中數 = 0。刪掉它們等於刪掉「G 組為什麼是空的」這段歷史。
  github-arm.sh
    → 它存在,在 scripts/ 不在 hooks/;出現在 github-contact-guard 那一列的
      描述裡,是告訴人「怎麼解鎖」的逃生口。刪掉那一列就不再說得出怎麼過閘。

成因是那組 grep 拿掉了 `^\| \`` 錨點。有錨點=「這一行的第一欄就是這支閘」=
表格真的列了它;沒錨點=全頁所有反引號。**0 次正確攔截、3 次誤攔。**

但「頁面上出現根本不存在的檔名」確實該被抓到,只是不能靠文字判斷。
本次補上第三格 C:頁面提到的每個 *.sh,要嘛現在存在,要嘛 git 歷史裡存在過。
**「現在沒有但曾經有」=歷史;「從來沒有過」=幽靈。** 它問的是 git 不是用字。

順帶:標頭區那個 43 是 2026-08-20 快照段落裡的歷史數字,不是現況宣稱,
但原文用現在式讀起來像現況 ⇒ 加上「這一段每個數字都是那天的」標記。

實測(含兩個反向對照,證明新那格不是橡皮圖章):
  現況              A1[] A2[] C[]                48 檔 / 48 列
  表格種一支假閘    A2 與 C 都叫
  散文種一支假閘    A1/A2 抓不到、C 抓到          ← 只有 C 守得住這種
18 個測試檔 0 失敗;claude plugin validate 通過。

inkstone/ISEP#75(總管複驗 comment 4822)
2026-08-27 18:35:49 +08:00
Leo 950c3e1919 測試不再偽造「有一筆推 main 在等總管裁」(inkstone/ISEP#59)
現象(實測,不是推論):跑完 main-and-prod-push-guard 那三支測試之後

    $ git status --short
     M hooks/lib/__pycache__/strip_heredoc.cpython-314.pyc
     M pending-main-push/unnamed--ISEP.md
    ?? pending-main-push/unnamed--A.md

成因:這支閘擋下推 main 的同時,會把那次請求寫成
`<hooks 的上一層>/pending-main-push/<誰>--<repo>.md`,而三支測試的測資本來
就全是推 main。於是每跑一次測試,工作區就多/改幾筆**偽造的待裁決**。

兩個後果,後者比較貴:
  ① `git add -A` 很容易把它們帶進 commit(08-27 那次真的帶進去了,事後才拔掉)
  ② 總管的迴圈讀那個目錄,讀到的每一筆都該是真的在等他裁——
     測試每跑一次就偽造一筆 ⇒ **下一筆真的請求會混在雜訊裡**。
     這跟「永遠在響的警報」是同一個病。

改法(產品程式碼一行都沒動):
- 新增 hooks/tests/lib/hook-sandbox.sh:把整個 hooks/ 複製到暫存區再跑複本。
  閘算 pending 目錄的位置靠的是 `$0` 的上一層 ⇒ 請求寫進暫存區。
  **刻意不在閘上開一個「寫去哪」的環境變數**——那種開關同時是一條把紀錄關掉的路。
- 三支測試改測沙盒複本,並各補兩條斷言:
  ① repo 的 pending-main-push 一個位元都沒動
  ② 沙盒裡**真的有**留下請求(只驗 ① 的話,把留紀錄的功能整個關掉也會綠)
- pending-main-push/ 不再進版控(它是本機狀態不是原始碼),只留一份 README 說明規約;
  既有的兩筆與那顆被追蹤的 .pyc 一併 `git rm --cached`,檔案留在硬碟上不刪。

實測:
- 三支各自 10/10、19/19、13/13(原本 8/17/11,各多兩條新斷言)
- 把 `H` 改回真跡重跑 ⇒ 新斷言兩條都紅(11/13)+工作區又髒
  ⇒ 這兩條斷言真的抓得到它要抓的東西
- 全套 16 支測試檔跑完 0 失敗,`git status` 沒有多出任何一行
- `claude plugin validate .` ✔ Validation passed

沒有升版:這次只動測試與版控範圍,沒有任何閘的行為改變,
不需要靠新版本號送到任何人手上;請跟著總管下一個 release 一起出去。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 18:33:44 +08:00
Leo a671948fa5 docs(inventory): 補回表格漏掉的兩列人話,並補上驗得出這種漏的那組指令
標頭寫 48 支,下面的表格只列得出 46 支——缺的是
`isep-presence-beacon.sh`(E 組 SessionStart)與
`milestone-due-guard.sh`(A 組 PreToolUse/Bash)這兩列。
不是本次合併造成的,`origin/main` 上數同一個指令也是這兩支。

為什麼這個漏會活這麼久:本頁「落差偵測」只有一組 comm,比的是
`hooks.json`(驗有沒有註冊),驗不了「有沒有寫進這張人話表」。
所以本次同時補上第二組 comm(fs 檔名 vs 表格列出的閘名),
並把「怎麼跟實況對帳」第 1 條指到那一組。

順手修掉同一頁另外兩處自己會騙人的地方:
- 標頭的複驗指令 `grep -c '"command"'` 會連 `"type": "command"` 一起數,
  實跑回 118 不是 59;正確寫法要帶冒號。
- 「其餘 41 支」是 43 支閘那一版數的。改成拿掉數字而不是改成 46——
  改成 46 等於宣稱逐支重讀過源碼,而這次沒有。

版本 0.6.0 → 0.6.1(v0.6.0 已出 release,不升版沒有人吃得到這頁)。

實測:48 檔 vs 48 列,兩個方向的 comm 都空;
18 個測試檔全綠(0 失敗);claude plugin validate 通過。

inkstone/ISEP#59(comment 4779 第二件)
2026-08-27 18:24:20 +08:00
27 changed files with 2454 additions and 88 deletions
+1 -1
View File
@@ -8,7 +8,7 @@
"plugins": [
{
"name": "isep",
"description": "InkStone Environment Plugin —— leo 的 Claude Code 環境唯一真相源:46 支機械閘(56 條註冊,白話盤點見 docs/hooks-inventory.md)、7 支 slash command、2 支 skill、27 支腳本,外加治理規範與標籤真相源。本機與雲端裝同一份,沒有子集。",
"description": "InkStone Environment Plugin —— leo 的 Claude Code 環境唯一真相源:51 支機械閘(64 條註冊,白話盤點見 docs/hooks-inventory.md)、7 支 slash command、2 支 skill、27 支腳本,外加治理規範與標籤真相源。本機與雲端裝同一份,沒有子集。",
"author": {
"name": "Leo",
"url": "https://uncle6.me"
+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.0",
"description": "InkStone Environment Plugin —— leo 的 Claude Code 環境唯一真相源:52 支機械閘(67 條註冊,白話盤點見 docs/hooks-inventory.md)、7 支 slash command、2 支 skill、28 支腳本,外加治理規範與標籤真相源。本機與雲端裝同一份,沒有子集。",
"version": "0.10.0",
"keywords": [
"inkstone",
"guardrails",
+9
View File
@@ -7,3 +7,12 @@ cloud-env*.txt
# import 共用零件時 python 自己生的,不是原始碼
__pycache__/
*.pyc
# 推 main 被擋下時留的請求(main-and-prod-push-guard.sh 寫的)。
# 這是**本機當下的狀態**,不是原始碼:一份檔案的語意是「有一筆推 main 正在等總管裁」。
# 進版控會有兩個後果(inkstone/ISEP#59 comment 4779 第 ① 條,兩個都實際發生過):
# ① `git add -A` 把它帶進 commit ⇒ 別人 clone 下來就多一筆**別人的**待裁決
# ② 被追蹤的檔每次被覆寫都變成 ` M` ⇒ `git status` 永遠是髒的
# (寫成 `目錄/*` 而不是 `目錄/`:後者連目錄本身都排除,下一行的例外就永遠生效不了)
pending-main-push/*
!pending-main-push/README.md
+61
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
@@ -73,6 +91,46 @@ bash scripts/test-baton-handback-guard.sh
- 「不該報」4 條任一紅 ⇒ 每條線收工都被念一次,警報會被學會忽略
- 「票已關」那條紅 ⇒ 棒子已經到終點還在催,那是最典型的假警報
### A14 — 每一則回覆都自己說出拖了多久:20 條
```
bash hooks/tests/countdown-guard.test.sh
```
**該看到**`通過 20 條,失敗 0 條`。**全程離線**:時鐘用 `ISEP_COUNTDOWN_NOW` 定住、
狀態走 `ISEP_COUNTDOWN_STATE_DIR`,不打網路、不碰 `$HOME`
**它在守什麼**`inkstone/ISEP#63`leo 2026-08-27):
「前面說過每個回覆要戴上已經花了總時長,**這為什麼沒出現?**」
「這應該寫在 ISEP,隨時看自己拖了多久」——重點在後面那句:
**不是要總管記得戴,是要它長在機器上。** 總管當時答「現在開始戴」,
而那正是這條規則第一次失效的方式。
**判準**:一個新開的 session,什麼都沒被交代,回覆裡照樣有時長。
**失敗**
- A 群(注入)任何一條紅 ⇒ 那一行算錯了。**④ 特別看**:期望「已過 2 小時 13 分」,
實得就要是同一個字串——這格就是票上驗收第 2 條「數字對得上真實經過的時間」
- ⑤ 紅 ⇒ 連續幾則不是遞增(歸零或亂跳),票上驗收第 3 條
- ⑦ 紅 ⇒ 過了收工線卻悄悄換算成明天。**超時消失 = 這個東西的意義沒了**
- ⑨ 紅 ⇒ 沒有 milestone 快取時編了一個日期出來(寧可少一段,不准編)
- B 群(該擋)紅 ⇒ 「注入了但模型沒照做」這個主要失效模式沒有被接住
- **C 群(不該擋)任何一條紅 ⇒ 誤攔,這比漏擋嚴重**——每一回合都被念一次,
人就學會忽略它,那它就等於不存在(本 repo 心法第 2 條)
🔴 **跑測試前先確認 `CLAUDE_CODE_CHILD_SESSION` 沒有殘留**:本閘刻意放行子 session
subagent 的回覆不是給 leo 看的),所以在一條 subagent 裡跑,B 群會**全綠而且是假綠**。
測試檔自己會把它清掉——2026-08-28 第一次跑就撞到這個,才補上去的。
### A15 — 發通知不等於部署:37 條
```
bash hooks/tests/prod-write-guard.test.sh hooks/prod-write-guard.sh
```
**該看到**`通過 37 失敗 0`(原本 29 條,`inkstone/ISEP#63` 補 8 條)。
**失敗**
- 「卡點一」6 條任一紅 ⇒ 要嘛 leo 收不到 Telegram`notify_leo` 被當成部署擋掉),
要嘛白名單放太寬——**「別的 named webhook」「部署端點」兩條若變綠,等於這道閘被拆了**
- 「卡點二」2 條任一紅 ⇒ 「談論它」又被當成「執行它」(同款第八次),
或是剝了內文之後連真的部署都放行了
### A5 — 開票前的搜尋是跨 repo 的
```
python3 scripts/ticket where 標籤 模組化
@@ -265,6 +323,9 @@ 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 |
| **A14 回覆自己說出拖了多久** | 總管 | ✅ 20/202026-08-28inkstone/ISEP#63 |
| **A15 發通知不等於部署** | 總管 | ✅ 37/372026-08-28inkstone/ISEP#63 |
| A5 搜尋跨 repo | 總管 | ✅ |
| A6 標籤對齊+冪等 | 總管 | ✅ 14 repo,第二次 0/0 |
| **A9 人閘警察管路** | 總管 | ✅ 14/142026-08-26 |
+87 -6
View File
@@ -1,4 +1,4 @@
# 48 支閘,白話盤點表
# 52 支閘,白話盤點表
> 回應 `inkstone/InkStoneCo#40`:「如果加入了,我應該可以白話文看到 hooks 的內容?」
> 這份表就是那個「白話文」——不用點開任何 `.sh` 檔,一行看懂一支閘在管什麼。
@@ -7,14 +7,16 @@
## 一句話結論
`hooks/` 底下有 **48 個 `.sh` 檔**`hooks.json` 實際掛上 **59 條註冊**(同一支閘常被多種情境同時掛上);
`hooks/` 底下有 **52 個 `.sh` 檔**`hooks.json` 實際掛上 **67 條註冊**(同一支閘常被多種情境同時掛上);
其中 **3 支檔案存在但沒被掛上**(2 支是待人填的空範本、1 支是刻意留著沒開的止血帶,見下面「未生效」表)。
下面按「你會在什麼時候撞到它」分組,一支一行。
> 🔴 **這兩個數字上一版是錯的(2026-08-26 實際數過才發現)**:本頁原本寫「43 個檔、53 條註冊」,
> 而當時真實是 **45 個檔、55 條註冊**——中間有兩支閘進來時沒有回頭改這裡。
> 現在的寫法是實際數出來的:
> `ls hooks/*.sh | wc -l` 48`grep -c '"command"' hooks/hooks.json` 59
> `ls hooks/*.sh | wc -l` 52`grep -c '"command":' hooks/hooks.json` 67
> ⚠️ **冒號不能省**`grep -c '"command"'`(沒冒號)會連 `"type": "command"` 一起數到,回 **120**。
> 本頁 2026-08-27 之前寫的是沒冒號那版——**照著它跑會拿到一個跟本頁不符的數字**。
> **一份會偷偷過期的盤點表,跟沒有盤點表差不多**——見本頁最後「怎麼跟實況對帳」那段。
>
> 📌 **`0.6.0``inkstone/ISEP#59`2026-08-27)這兩個數字是併完之後當場數出來的**:
@@ -23,6 +25,19 @@
> 加上 `ISEP#72` 的 `reply-identity-guard.sh` 已在 main 上。
> 一加一減之後檔數不變、註冊條數落在 59——**這正是「不能靠加減推算、只能實數」的例子**:
> 上一版寫 58,就是把兩批改動的加減兜出來、沒有真的數過。
>
> 📌 **`0.10.0``inkstone/ISEP#63`2026-08-28)+1 支、+3 條**
> `countdown-guard.sh` 一支同時掛在 `UserPromptSubmit`(注入倒數)與 `Stop`(查核有沒有戴),
> 加上 SessionStart 多掛一條 `scripts/countdown-milestone-refresh.sh`(更新期限快取,不是閘)。
> **兩個事件是同一件事的兩半**:只注入 = 又一個會被忽略的提醒;只查核 = 罰它做一件拿不到資料的事。
> 這兩個數字是併之前當場數出來的,不是從上一版加減推出來的。
> 🔴 **`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** 條,兩個數字都是加完之後當場數出來的(指令同上)。
---
@@ -51,8 +66,10 @@
| `leo21c-write-guard.sh` | AI 想寫東西到你私人帳號 `leo21c` 就擋下——這個帳號只准你自己用,AI 的實驗一律走別的測試帳號。 | 🛑 擋 |
| `release-tag-guard.sh` | AI 想打版本 tag,但 `plugin.json` 裡寫的版本號跟 tag 對不上就擋下——避免「這裡說 0.2.0、那裡說 0.1.0」各說各話。 | 🛑 擋 |
| `ticket-api-bypass-guard.sh` | AI 想繞過 `scripts/ticket` 工具、直接打 API 新增 Gitea 東西(開新票/milestone/label/PR,會漏掉「新增前先搜過」這道檢查)就擋下——不管是隱式 POST(urllib 傳 data= 沒寫 method)還是小寫 `requests.post(...)`。 | 🛑 擋 |
| `milestone-due-guard.sh` | AI 想開一個新的里程碑(milestone)卻沒填期限就擋下,**填 `9999-01-01` 一樣擋**——那是「沒有期限」穿了一件期限的衣服,盤點時每一格看起來都有值,其實一格都沒有。只管「建里程碑」這個動作,查詢/改別的欄位都放行。 | 🛑 擋 |
| `reply-identity-guard.sh` | 直接打 Gitea API 貼留言到票上時,內文第一行沒有 `【身份】``總管``subagent``leo`)就擋下——票上多條線並行時,看不出哪一則是誰寫的。純讀取、走 `scripts/ticket` 正門、指令裡加 `reply-identity-ok` 都放行。 | 🛑 擋 |
| `comment-carries-task-guard.sh` | AI 要在票裡留一則「等某某上線才驗得了」這種**還沒做完的事**,卻沒有把它開成一張子票時,擋一次。留言沒人會回頭讀,票的 open/closed 撈一次就看得到。 | 🛑 擋(同一輪只擋一次) |
| `search-is-not-proof-guard.sh` | AI 用知識庫的**搜尋**找到一筆東西,然後把那筆東西的原始編號直接貼進票裡或派工單裡,當成「某個功能壞了」的證據——而它從沒用正規的檢索路徑去查過那筆——擋一次。你關心的是:不會再有人拿錯的方法量出一個錯的結論,然後退回一張其實已經做完的票。 | 🛑 擋(同一輪只擋一次) |
## B. AI 想寫檔案/改程式碼的當下(PreToolUse / Write·Edit·MultiEdit
@@ -103,6 +120,7 @@
| 閘名 | 對你意味著什麼 | 動作 |
|---|---|---|
| `isep-presence-beacon.sh` | 對話一開始印一行 `🟢 ISEP vX.Y.Z 已載入(N 支閘|來源:…)`。**這行不是裝飾,是唯一能證明「這個 session 真的有閘」的東西**——它自己就住在 plugin 裡,看得到它就表示 plugin 載入了;某個 session 從頭到尾沒有這行,那個 session 是零閘狀態,先修 plugin 再做事。同一台機器可能同時有兩份 ISEP(marketplace 裝的、repo 裡 vendor 的),所以那行會講出這次是哪一份在說話。 | 📝 記錄(context 注入) |
| `session-start-recall.sh` | 對話一開始就自動把「全局現況」(Gitea 各 repo 的票、KBDB 的藏書地圖)推到 AI 眼前,不必等它自己想到要查。 | 📝 記錄(context 注入) |
| `skill-deploy-drift-guard.sh` | 如果「全機真正在用的 skill」跟「repo 裡版控的正本」內容對不上,就在開場講出來——避免用著一份沒人知道已經跟正本分家的舊拷貝。 | 📝 記錄 |
@@ -113,6 +131,8 @@
| `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),不會變成一直在響的警報。 | 🛑 擋(響過就退讓) |
| `countdown-guard.sh` | **每一則回覆都自己說出「這件事已經花了多久/距今天收工線還剩多久」**,而且不必有人記得。一支閘掛兩個事件:你送出訊息時它先把算好的那一行放到 AI 眼前(`UserPromptSubmit`),AI 想收工時它再看這一回合的回覆裡到底有沒有那一行,沒有就擋一次要它補(`Stop`)。**只做前半 = 又一個會被忽略的提醒**,所以兩半綁在一起。已過時間從**這段對話的第一則訊息**算起(=一個 release 的起點);收工線是台北 16:00,過線後四小時內顯示「已超過」,不會把超時悄悄換算掉。子 session、純工具回合、已經提醒過一次的,全部放行。 | 🛑 擋(至多攔一次) |
| `browser-verify-guard.sh` | 這一輪 AI 宣稱「前端驗過了」,卻沒有真的用瀏覽器工具載入過,就擋下——`curl` 抓到 HTML 不算驗過。 | 🛑 擋 |
| `self-drive-police.sh` | AI 想停下來問你「早就決定過的事」(用固定句型判斷,例如「要不要 X」「下一步做什麼」「這交給你」)就擋下,反問它查過 wiki/查過派工表了沒。 | 🛑 擋 |
| `self-drive-judge.sh` | 跟上面同一件事,但改用小模型(haiku)判斷「換句話說」的請示句——防止 AI 只是把「要不要」改寫成「不確定是否符合期待」就閃過上一支閘。 | 🛑 擋 |
@@ -139,6 +159,7 @@
| `subagent-first-stamp.sh` | AI 真的派過工之後,留一個時間戳——給 `subagent-first-guard.sh` 判斷用。 | 📝 記錄 |
| `issue-status-autoflip.sh` | AI 一派工出去,就自動把對應的 Gitea 票改成「進行中(s/doing)」,不必等人手動改標籤。 | 📝 記錄(自動改票) |
| `baton-handback-guard.sh` | 一條派工線做完了,就去看它那張票有沒有**指派給人/標籤有沒有說它卡在哪/有沒有寫下一步**,缺哪一格就當場說出來。你關心的是:不會再有票做完了卻沒人接手,躺在那裡沒人發現。 | 📝 記錄(提醒,不擋) |
| `kbdb-evidence-stamp.sh` | AI 每次去知識庫拿資料,就記一筆「這是**搜尋**搜到的,還是**走正規檢索路徑**拿到的」。你關心的是:它等一下要拿這筆資料下結論時,有東西可以對照它走的是哪條路。 | 📝 記錄 |
## I. 你會撞到但跟「派工/收工」無關的一支(Edit·MultiEdit
@@ -177,7 +198,62 @@ $ comm -13 /tmp/fs_hooks.txt /tmp/registered_hooks.txt
(無輸出)
```
**這次順便抓到的另一個落差**`.claude-plugin/plugin.json` 的說明文字寫「42 支機械閘(52 條註冊)」,
🔴 **上面那兩行驗不出「表格漏列」**——它們比的是 `hooks.json`,不是這張表。
「有閘、但這頁沒有它那一行人話」要另外一組指令才看得見,就是下面這組
2026-08-27 就是靠它抓到本頁漏了 `isep-presence-beacon.sh``milestone-due-guard.sh` 兩列):
```
$ ls hooks/*.sh | xargs -n1 basename | sort > /tmp/inv_fs.txt
$ grep -oE '^\| `[a-zA-Z0-9_.-]+\.sh`' docs/hooks-inventory.md \
| grep -oE '[a-zA-Z0-9_.-]+\.sh' | sort -u > /tmp/inv_table.txt
# 有這支閘、但這頁沒有它那一列 → 要補人話
$ comm -23 /tmp/inv_fs.txt /tmp/inv_table.txt
(無輸出)
# 這頁列了、但檔案已經不在了 → 要刪那一列
$ comm -13 /tmp/inv_fs.txt /tmp/inv_table.txt
(無輸出)
```
🔴 **`^\| \`` 這個錨點不能拿掉**——它的意思是「**這一行的第一欄就是這支閘**」,
也就是「表格真的列了它」。把錨點拿掉、改成抓全頁所有反引號裡的 `*.sh`
反向那半會吐出 **3 個假警報**(2026-08-27 實跑):
```
claim-verify-police.sh ← 出現在「已於 ISEP#60 移除」那段**移除紀錄**裡
subagent-claim-worksheet.sh ← 同上
github-arm.sh ← 它在 `scripts/`,不在 `hooks/`;是 github-contact-guard
那一列裡告訴你「怎麼解鎖」的逃生口
```
**三個都不是幽靈,三個都不該刪**:刪掉前兩個等於刪掉「G 組為什麼是空的」這段歷史,
刪掉第三個等於讓那一列不再告訴你怎麼過閘。
⇒ **0 次正確攔截、3 次誤攔**——照這個訊號去改,會把好東西改壞。
那「頁面上出現了一個根本不存在的檔名」怎麼辦?**那要另外一格量,而且判準不是文字是事實**:
```
# C. 頁面提到的每個 *.sh,現在存在,或者 git 歷史裡存在過
$ for n in $(grep -oE '`[a-zA-Z0-9._-]+\.sh`' docs/hooks-inventory.md | tr -d '`' | sort -u); do
[ -f "hooks/$n" ] || [ -f "scripts/$n" ] && continue
[ -n "$(git log --all --oneline --diff-filter=AD -- "hooks/$n" "scripts/$n")" ] && continue
echo "$n"
done
(無輸出)
```
**「現在沒有、但曾經有」=歷史,合法;「從來沒有過」=打錯字或憑空捏造,才是幽靈。**
這一格問的是 git,不是問這頁的用字,所以它不會因為誰換個寫法就失效。
📌 **三格都要跑,而且它們量的是不同東西**:
A1 缺人話、A2 表格有幽靈、C 全頁有幽靈。
**標頭的 48/59 對不代表這頁沒漏**——補進 2 支真的、同時留著 2 支假的,數字照樣湊得到 48。
**數量相等不等於集合相等**,所以驗的一律是集合(`comm`),不是 `wc -l`。
**這次順便抓到的另一個落差**(🕐 **這一段是 2026-08-20 的快照,裡面每個數字都是那天的,不是現況**——
留著是因為它是「兩份文件的數字不會自己跟上」這個病的第一個案例;現況數字看本頁標頭):
`.claude-plugin/plugin.json` 的說明文字寫「42 支機械閘(52 條註冊)」,
`README.md` 也寫「42 支 `hooks.json`」「共 52 條註冊」——但實測是 **43 支檔案、53 條註冊**。
差 1 支、差 1 條,猜測是今天(2026-08-20)新增的 `leo21c-write-guard.sh``release-tag-guard.sh`
`ticket-api-bypass-guard.sh` 這批(檔頭日期都是今天)加了之後,兩份文件的數字沒有跟著更新。
@@ -196,6 +272,7 @@ $ comm -13 /tmp/fs_hooks.txt /tmp/registered_hooks.txt
| `arcrun-intent-guard.sh` | 2 次 | 一次守「所有寫檔案」,一次專門加強守「呼叫 Arcrun 部署/驗證工具」這個更精準的情境 |
| `micromanage-guard.sh` / `irreversible-dispatch-guard.sh` / `no-ticket-no-dispatch.sh` | 各 2 次 | 各自同時掛在 `Task` 與 `Agent` 兩個矩比對名稱上——這兩個名稱應該是同一種派工動作的新舊叫法,兩個都掛保證不漏接 |
| `worklist-guard.sh` / `self-drive-police.sh` / `self-drive-judge.sh` / `delivery-police.sh` / `wiki-first-police.sh` / `unpushed-police.sh` | 各 2 次 | 一次守「總管自己想收工」(Stop),一次守「subagent 交回工作」(SubagentStop)——同一套判準用在兩種角色身上 |
| `search-is-not-proof-guard.sh` | 3 次 | 拿搜尋結果當證據的那個動作,可能是「貼進票裡」(Bash),也可能是「寫進派工單發包出去」(`Task``Agent` 兩個名稱各掛一次),三種都要接住 |
❓ **這裡有一件我看不出來是刻意還是遺留、需要人判斷**:`micromanage-guard.sh``irreversible-dispatch-guard.sh``no-ticket-no-dispatch.sh`
三支都**同時**掛在 `Task` 跟 `Agent` 這兩個矩比對名稱上。如果這兩個名稱在目前版本的 Claude Code 裡指的是「同一種派工工具呼叫」,
@@ -232,12 +309,16 @@ $ comm -13 /tmp/fs_hooks.txt /tmp/registered_hooks.txt
- **`micromanage-guard.sh``irreversible-dispatch-guard.sh``no-ticket-no-dispatch.sh` 的雙重矩比對(`Task` + `Agent`)是刻意保險還是舊名稱沒清掉**——見上面「重複掛載」段落,我沒有把握單靠讀檔案判斷,需要看實際觸發紀錄或問總管。
- **`stage-before-prod-guard.sh` 跟 `main-and-prod-push-guard.sh` 的分工邊界**`main-and-prod-push-guard.sh` 的檔頭明講自己是在「補 `stage-before-prod-guard.sh` 的破口」(那支只認 3 個關鍵字,抓不到 `wrangler deploy`),但兩支都還掛著、都還在管「prod 出貨」這件事。這是「新的補洞、舊的continua」還是「舊的該退休了」,這份盤點表不下判斷,留給你在下一步的分類會議裡定奪。
除了以上兩點,其餘 41 支的行為都能從檔頭與源碼直接讀出,沒有「猜」的部分。
除了以上兩點,其餘支的行為都能從檔頭與源碼直接讀出,沒有「猜」的部分。
> 📌 這句話原本寫「其餘 **41** 支」——那是 43 支閘那一版(`v0.2.1`)數的,之後沒有人重跑過。
> 2026-08-27 把數字拿掉而不是改成 46:**改成 46 等於宣稱我逐支重讀過源碼,而我沒有。**
> 要恢復一個數字,就重跑一次上面「抽驗」那件事,然後把當天的日期寫上去。
---
## 這份表怎麼跟實況對帳(半年後怎麼發現漂移)
1. **有沒有新閘沒被收進這張表**:跑本文「落差偵測」段落的兩行 `comm` 指令,比對 `hooks/*.sh` 的檔名清單跟這張表列出的閘名清單(不是跟 `hooks.json`,那個只驗證有沒有註冊,驗不了有沒有寫進這張人話表
1. **有沒有新閘沒被收進這張表**:跑本文「落差偵測」段落**第二組**`inv_fs` vs `inv_table` 的兩行 `comm`**加第三格 C**,比對 `hooks/*.sh` 的檔名清單跟這張表列出的閘名清單。**不是第一組**——第一組比的是 `hooks.json`只驗證有沒有註冊,驗不了有沒有寫進這張人話表。2026-08-27 漏掉的那兩列,第一組是綠的、第二組才紅。**抓表格列的那組 `grep` 一定要帶 `^\| \`` 錨點**,理由與拿掉之後會冒出的 3 個假警報,就寫在那一段裡
2. **有沒有閘的行為跟這裡寫的不一樣了**:抽幾支重新讀一次源碼,跟這裡的「一句話」對一遍——就是本文「抽驗 5 支」做的事,可以照同樣方法定期重做。
3. **`.claude-plugin/plugin.json` 與 `README.md` 的數字**:這兩處各自寫了一次「幾支、幾條註冊」,前面已經抓到一次對不上(42/52 vs 實際 43/53)。這兩個數字沒有機制保證跟著 `hooks/` 目錄自動更新,是本表發現的第一個具體漂移案例。
+182
View File
@@ -0,0 +1,182 @@
#!/usr/bin/env bash
# countdown-guard.sh — 每一則回覆都自己說出「已經花了多久/還剩多久」
# inkstone/ISEP#63leo 2026-08-2708-28 兩次下令)
#
# ── 為什麼是一支閘,不是一句叮嚀 ──────────────────────────────────────
# leo 2026-08-27:「前面說過每個回覆要戴上已經花了總時長,**這為什麼沒出現?**」
# 「這應該寫在 ISEP,隨時看自己拖了多久」
# 總管當時答「我沒做,現在開始戴」——**而那正是這條規則第一次失效的方式**:
# 靠記性的規則,下一個 session 就沒有了(history-firstKBDB-firststage-first
# 全是同一個形狀)。⇒ 判準:**一個新開的 session,什麼都沒被交代,回覆裡照樣有時長。**
#
# ── 兩段,缺一不可(票上點名的失效模式就在這裡)───────────────────────
# 票上寫得很清楚:「**注入了但模型沒照做**是這類機制的主要失效模式」。
# 所以這支同時掛兩個事件,是**同一件事的兩半**:
#
# ① UserPromptSubmit ── 注入:把算好的那一行送到眼前(模型不必自己算、也算不準)
# ② Stop ── 查核:這一回合的回覆裡到底有沒有那一行;沒有就擋一次
#
# 只有 ① = 又一個會被忽略的提醒(本 repo 心法第 2 條明令不要做這種東西)。
# 只有 ② = 罰它做一件它拿不到資料的事。兩段合起來才是一條走得通的路。
#
# ── 🔴 這不是關鍵字黑名單(leo 2026-08-17 的診斷)────────────────────
# 「自然語言的變體是無限的,blacklist 永遠追不完……封路哲學之所以有效,
# 是因為它封的是**動作**——動作有限且可枚舉,文字不是。」
# 當日實測:文字層的閘 **8 次誤攔、0 次正確攔截**,且方向穩定。
#
# 本支查的**不是「有沒有講某些不該講的話」,而是「那個被要求的輸出元素在不在」**:
# 一個由機器產生、只有一個字元的標記(`⏱`),值是機器算的,模型抄過去。
# ⇒ 沒有「換個講法就閃過」的空間(換講法照樣要帶標記),
# 也沒有「講到某個詞就被咬」的誤攔(多寫什麼都不會觸發)。
# ⇒ 這是 whitelist-of-one**要求一個東西在場**,不是**猜哪些東西不該在場**。
#
# ── 誤攔的出口都先關掉(誤攔比漏擋更該修)────────────────────────────
# · 子 sessionsubagent)不查——它的回覆不是給 leo 看的
# · 這一回合沒有任何文字輸出(純工具回合)不查——沒有東西可以戴
# · `stop_hook_active` 為真=已經提醒過一次 ⇒ 放行,**至多擋一次,不會卡死**
# · 讀不到 transcript/內部出錯 ⇒ 放行(這支是節拍器,不是安全閘,
# 它壞掉不該讓整個 session 停下來)
set -uo pipefail
INPUT="$(cat)"
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
LIB="$HERE/lib/countdown.py"
[ -f "$LIB" ] || exit 0
EVENT=$(printf '%s' "$INPUT" | python3 -c '
import sys, json
try: print(json.load(sys.stdin).get("hook_event_name", "") or "")
except Exception: print("")
' 2>/dev/null || printf '')
# ══ ① 注入模式(UserPromptSubmit)═════════════════════════════════════
if [ "$EVENT" = "UserPromptSubmit" ]; then
printf '%s' "$INPUT" | ISEP_COUNTDOWN_LIB="$LIB" python3 -c '
import json, os, sys, importlib.util
spec = importlib.util.spec_from_file_location("countdown", os.environ["ISEP_COUNTDOWN_LIB"])
cd = importlib.util.module_from_spec(spec); spec.loader.exec_module(cd)
try: d = json.load(sys.stdin)
except Exception: d = {}
try:
stamp = cd.line(d.get("session_id") or "", d.get("transcript_path") or "")
except Exception:
raise SystemExit(0) # 算不出來就不出聲,不要讓它變成噪音
ctx = (
stamp + "\n\n"
"🔴 這一行是 ISEP 的倒數(inkstone/ISEP#63)。**把它原樣放在你這則回覆的最前面**,"
"數字不要自己重算、也不要改寫措辭——它是機器算的,你算的會漂。\n"
"leo 整天上課、一天只看兩眼:沒有這行,他就不知道『這件事還來得及嗎』。\n"
"收工前這一回合若沒有戴上它,Stop 那一半會擋一次要你補。"
)
print(json.dumps({
"hookSpecificOutput": {
"hookEventName": "UserPromptSubmit",
"additionalContext": ctx,
}
}, ensure_ascii=False))
' 2>/dev/null || true
exit 0
fi
# ══ ② 查核模式(Stop)═════════════════════════════════════════════════
# 子 session 不查:subagent 的回覆是交給總管的,不是給 leo 看的那一則。
[ "${CLAUDE_CODE_CHILD_SESSION:-}" = "1" ] && exit 0
LOG="${CLAUDE_PROJECT_DIR:-$PWD}/.claude/hooks/countdown-guard.log"
VERDICT=$(printf '%s' "$INPUT" | ISEP_COUNTDOWN_LIB="$LIB" python3 -c '
import json, os, sys, importlib.util
spec = importlib.util.spec_from_file_location("countdown", os.environ["ISEP_COUNTDOWN_LIB"])
cd = importlib.util.module_from_spec(spec); spec.loader.exec_module(cd)
try: d = json.load(sys.stdin)
except Exception:
print("SKIP:bad-json"); raise SystemExit
if d.get("stop_hook_active"):
print("SKIP:already-nudged"); raise SystemExit # 至多擋一次
tp = d.get("transcript_path") or ""
if not tp or not os.path.exists(tp):
print("SKIP:no-transcript"); raise SystemExit
rows = []
try:
with open(tp) as f:
for ln in f:
ln = ln.strip()
if ln:
try: rows.append(json.loads(ln))
except Exception: pass
except Exception:
print("SKIP:unreadable"); 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:]
texts = []
for r in turn:
if r.get("type") != "assistant":
continue
c = (r.get("message") or {}).get("content")
if isinstance(c, str):
texts.append(c); continue
if not isinstance(c, list):
continue
for b in c:
if isinstance(b, dict) and b.get("type") == "text":
texts.append(b.get("text") or "")
body = "\n".join(t for t in texts if t and t.strip())
if not body:
print("SKIP:no-text"); raise SystemExit # 純工具回合,沒有東西可以戴
if cd.MARKER in body:
print("OK"); raise SystemExit
try:
stamp = cd.line(d.get("session_id") or "", tp)
except Exception:
print("SKIP:no-stamp"); raise SystemExit
print("MISSING:" + stamp)
' 2>/dev/null || echo "SKIP:crash")
STAMP_LINE="${VERDICT#MISSING:}"
NOW=$(date "+%Y-%m-%d %H:%M:%S" 2>/dev/null || echo "?")
mkdir -p "$(dirname "$LOG")" 2>/dev/null || true
case "$VERDICT" in
MISSING:*)
printf '| %s | ⛔ 擋下 | 回覆沒有倒數 |\n' "$NOW" >> "$LOG" 2>/dev/null || true
{
printf '⏱ 倒數警察:**這則回覆沒有戴上倒數**inkstone/ISEP#63\n\n'
printf 'leo 2026-08-27:「前面說過每個回覆要戴上已經花了總時長,**這為什麼沒出現?**」\n'
printf ' 08-28:「先完成倒數計時器,**每一次回覆都發倒數**」\n\n'
printf '把這一行原樣放在回覆最前面,再送一次(數字已經幫你算好,不要自己重算):\n\n'
printf ' %s\n\n' "$STAMP_LINE"
printf '為什麼非戴不可:leo 整天上課、一天只看兩眼。沒有這行,他看不出\n'
printf '「這件事花了不該花的時間」——而那正是他要這個東西的全部理由。\n\n'
printf '本閘**至多擋一次**stop_hook_active 之後放行),不會卡死。\n'
} >&2
exit 2
;;
OK)
printf '| %s | ✅ 放行 | 有倒數 |\n' "$NOW" >> "$LOG" 2>/dev/null || true
exit 0
;;
*)
printf '| %s | ⚪ 略過 | %s |\n' "$NOW" "$VERDICT" >> "$LOG" 2>/dev/null || true
exit 0
;;
esac
+47
View File
@@ -52,6 +52,10 @@
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/reply-identity-guard.sh"
},
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/search-is-not-proof-guard.sh"
}
]
},
@@ -144,6 +148,10 @@
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/dispatch-format-guard.sh"
},
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/search-is-not-proof-guard.sh"
}
]
},
@@ -183,6 +191,10 @@
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/dispatch-format-guard.sh"
},
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/search-is-not-proof-guard.sh"
}
]
},
@@ -211,6 +223,10 @@
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/isep-presence-beacon.sh"
},
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/scripts/countdown-milestone-refresh.sh"
}
]
}
@@ -233,6 +249,10 @@
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/factory-idle-guard.sh"
},
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/mainline-idle-guard.sh"
}
]
},
@@ -271,6 +291,14 @@
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/unpushed-police.sh"
}
]
},
{
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/countdown-guard.sh"
}
]
}
],
"SubagentStop": [
@@ -321,6 +349,15 @@
}
]
},
{
"matcher": ".*kbdb_(search|get_record|query|graph_neighbors|get_map).*",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/kbdb-evidence-stamp.sh"
}
]
},
{
"matcher": "Agent|Task",
"hooks": [
@@ -338,6 +375,16 @@
}
]
}
],
"UserPromptSubmit": [
{
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/countdown-guard.sh"
}
]
}
]
}
}
+143
View File
@@ -0,0 +1,143 @@
#!/usr/bin/env bash
# 管什麼: 記下「這個 session 看到的每一筆 KBDB 資料,是從哪一條路拿到的」——搜尋出來的,還是走產品檢索路徑拿到的。
# 為什麼: 2026-08-27 inkstone/Arcrun#167 comment 4865,總管把 kbdb_search 的輸出當成「產品檢索壞了」的證據,退回一張已經做完的票、重派一條線。錯的不是那句話,是那條路。
# 誤觸時怎麼關: 這支不擋任何東西,只留檔。要停用就從 hooks.json 拿掉它(配套的 search-is-not-proof-guard 會跟著失效,見該檔)。
#
# kbdb-evidence-stamp.sh — 證據的出處登記處(PostToolUse: 各 kbdb_* 工具)
#
# ── 為什麼是「出處」而不是「措辭」───────────────────────────────────
# leo 2026-08-17 證偽過文字層:那天在文字層封路的閘 **8 次誤攔、0 次正確攔截**,
# 且方向穩定——紅線寫得越細,命中關鍵字的機率越高 ⇒ **那些閘在懲罰謹慎**。
# leo 原話:「自然語言的變體是無限的,blacklist 永遠追不完。
# 封路哲學之所以有效,是因為它封的是**動作**——動作有限且可枚舉。」
#
# ⇒ 所以本閘不看你怎麼說,看你**引用的那筆資料是從哪支工具掉出來的**。
# 資料值跟動作一樣是有限且可枚舉的:一個 entry id、一個 `kb://` 來源位址,
# 它要嘛出現在 kbdb_search 的回應裡,要嘛沒有。**這是事實,不是判讀。**
#
# ── 兩條路的分別(D81leo 2026-08-13 親自裁定)────────────────────
# `system-dev/docs/2-architecture/decisions/D81-vector-only-for-entity-normalize.md`
# Graph(三元組/entities → **找到該找的東西** ← 產品檢索路徑
# LLM Wiki(卡片文本) → **拉到對的內容** ← 產品檢索路徑
# 向量/關鍵字搜尋 → **協助 entities normalize****輔助,不是檢索主路徑**
#
# 🔴 **`kbdb_search` 本身完全合法**,這支不會讓它變難用——
# subagent-wiki-guard.sh 還明文要求「查任何東西之前先用它查 wiki」。
# leo 的話是「不能拿它當**產品檢索路徑**」,不是「不准呼叫」。
# ⇒ 本檔只登記出處,判斷留給 search-is-not-proof-guard.sh,而那支的觸發條件
# 窄到「你把搜尋結果的原始值貼進票或派工單裡當證據」才會響。
#
# ── 平台依據(查證過,不是推測)─────────────────────────────────────
# PostToolUse 的 stdin payload 帶得到工具的**輸出**
# `@anthropic-ai/claude-code/sdk.d.ts:54-59`
# export type PostToolUseHookInput = BaseHookInput & {
# hook_event_name: 'PostToolUse'; tool_name: string;
# tool_input: unknown; tool_response: unknown; };
# 實跑的那顆(Caskroom 2.1.220)內建說明同樣寫著
# `"tool_response": { "success": true } // PostToolUse only`。
#
# ── fail-closed:這支失效的時候要出聲,不准靜默 ──────────────────────
# `kbdb-asked-stamp.sh` 檔頭記著 leo 2026-08-08 的原話:
# 「你自己寫了 hook,結果**一次次改都沒產生用途**,問題太大」。
# ⇒ 所以 payload 裡**根本沒有 `tool_response` 這個欄位**時(=平台換了介面),
# 本檔走 exit 2 把話講到 stderr 上(PostToolUse 的 exit 2 不會擋工具,
# 只會把訊息交給 Claude 看)。**寧可吵一次,也不要變成一支活著卻沒作用的閘。**
# 「回應讀得到但裡面沒有指紋」是正常情況(搜尋 0 筆),不出聲。
set -uo pipefail
INPUT=$(cat)
OUT=$(printf '%s' "$INPUT" | python3 -c '
import json, os, re, sys
try:
d = json.load(sys.stdin)
except Exception:
print("SKIP"); raise SystemExit
tool = d.get("tool_name") or ""
sid = d.get("session_id") or "nosid"
# 這一族才登記;別的工具直接走人
if "kbdb_" not in tool:
print("SKIP"); raise SystemExit
# 🔴 D81 的兩條產品檢索路徑 vs 輔助工具。判斷只看工具名,不看內容。
WALKED = ("kbdb_get_record", "kbdb_graph_neighbors", "kbdb_query", "kbdb_get_map")
if any(w in tool for w in WALKED):
kind = "walked"
elif "kbdb_search" in tool:
kind = "search"
else:
print("SKIP"); raise SystemExit # 寫入類工具(create_*)不是證據來源
if "tool_response" not in d: # ← 平台換介面了,本閘等於死了
print("NOFIELD"); raise SystemExit
# 回應可能是 dict、list、字串、或 MCP 的 content blocks——一律壓成字串再找指紋。
# 這樣就不必猜它今天是哪一種包裝。
def flatten(x, acc):
if isinstance(x, str):
acc.append(x)
elif isinstance(x, dict):
for v in x.values(): flatten(v, acc)
elif isinstance(x, (list, tuple)):
for v in x: flatten(v, acc)
elif x is not None:
acc.append(str(x))
parts = []
flatten(d.get("tool_response"), parts)
try:
parts.append(json.dumps(d.get("tool_response"), ensure_ascii=False))
except Exception:
pass
blob = "\n".join(parts)
# ── 指紋只取兩類,兩類都是「值」不是「措辭」───────────────────────
# ① entry id`e_<uuid>`。36 字元的 UUID,實質上不會誤撞。
# ② 來源位址:`kb://…`。它在 metadata_json 那串裡,而**跳脫前後長得一樣**
# (貼進 markdown 會變成 \"…\",但 kb:// 那段本身不變)⇒ 貼過去也認得出來。
# 兩類都是可枚舉的資料值,正好對上 leo 8-17「動作有限且可枚舉,文字不是」。
ids = set(re.findall(r"\be_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b", blob))
srcs = set(re.findall(r"kb://[^\"\\\s,}\]]{6,}", blob))
fps = sorted(ids | srcs)
path = "/tmp/.kbdb-evidence-%s-%s" % (kind, sid)
if fps:
try:
with open(path, "a", encoding="utf-8") as f:
for x in fps:
f.write(x + "\n")
os.chmod(path, 0o600)
except Exception:
print("WRITEFAIL"); raise SystemExit
print("OK %s %d" % (kind, len(fps)))
' 2>/dev/null) || OUT="PYFAIL"
case "${OUT:-}" in
NOFIELD)
cat >&2 <<'EOF'
⚠️ kbdb-evidence-stamp 失效了:這次的 PostToolUse payload **沒有 `tool_response` 欄位**。
它靠這個欄位登記「這筆資料是從哪條路拿到的」,欄位不見 =
`search-is-not-proof-guard.sh` 從現在起**永遠不會響**(而且不會有人發現)。
依據(本閘寫的時候查證過):
@anthropic-ai/claude-code/sdk.d.ts:54-59 PostToolUseHookInput 帶 `tool_response`
⇒ 平台介面變了。開一張票去修這兩支,不要當作沒看到——
一支活著卻沒作用的閘,比沒有閘更糟:它會讓人以為這一格有人守。
EOF
exit 2 ;;
PYFAIL|WRITEFAIL)
cat >&2 <<'EOF'
⚠️ kbdb-evidence-stamp 這次沒登記成功(python3 或 /tmp 寫入失敗)。
後果:這一次的 kbdb 呼叫不會被 `search-is-not-proof-guard.sh` 看見。
不影響你手上的工作,但**這一格暫時沒人守**——請自己確認一次
「我等一下要下的結論,走的是不是我實際查過的那條路」。
EOF
exit 2 ;;
esac
exit 0
Binary file not shown.
+201
View File
@@ -0,0 +1,201 @@
#!/usr/bin/env python3
"""hooks/lib/countdown.py —— 算「這件事已經花了多久/還剩多久」的那一行。
這是 helper,不是閘(inkstone/ISEP#40 S7lib/ 底下的東西不算一支手寫的閘)。
兩個呼叫者共用它,所以時間的算法只有一份:
· UserPromptSubmit`countdown-guard.sh` 注入模式)—— 把這一行送到模型眼前
· Stop(同一支的查核模式)—— 擋下來時要把同一行原樣印出來給它抄
── 基準從哪一刻算起(inkstone/ISEP#63 要我自己判斷並說明理由)──────────
leo 的原話是「**隨時看自己拖了多久**」。「拖」指向的是**任務**,不是 session
但「任務」在機器上沒有起點——沒有任何欄位記著「我什麼時候接手這條線」。
可機械取得、又真的對應到一個交付的起點,只有一個:**這段對話的第一則訊息**。
而那正好就是總管的交貨單位:CLAUDE.md 規則三點七「**一段對話 一個 release**」。
⇒ 已過時間 = 這個 release 已經燒掉多久。**這個數字不需要任何人維護,也不會說謊。**
resume 的 session 取「transcript 第一列的時間」而不是「hook 第一次被叫到的時間」:
接關不是重新開始,昨天燒掉的兩小時仍然算在這個交付上。兩者都拿得到時取較早的。
── 第二個數字:今天的收工線 ──────────────────────────────────────────
leo 2026-08-28 07:40:「今天我整天上課……**下午四點左右**希望已經都完成了」。
⇒ 台北 16:00。過了 16:00 之後**不立刻換算到明天**——那會讓超時消失,
而超時正是他要看的東西。過線後四小時內顯示「已超過 X」,
台北 20:00 之後才滾到隔天(那時已經是下一個工作日的倒數了)。
── 第三個數字:主線 milestone 的期限(可選)────────────────────────────
從快取檔讀,**這支從不打網路**UserPromptSubmit 走在每一則訊息的關鍵路徑上,
在那裡打網路 = 每一句話都先等一次 HTTP。快取由 SessionStart 那支負責更新
`scripts/countdown-milestone-refresh.sh`),拿不到就整段不顯示,不編數字。
"""
import json
import os
import sys
from datetime import datetime, timedelta, timezone
TAIPEI = timezone(timedelta(hours=8))
MARKER = ""
def _now() -> datetime:
"""現在。測試用 ISEP_COUNTDOWN_NOWepoch 秒)把時鐘定住。"""
override = os.environ.get("ISEP_COUNTDOWN_NOW", "").strip()
if override:
try:
return datetime.fromtimestamp(float(override), tz=timezone.utc)
except Exception:
pass
return datetime.now(timezone.utc)
def _parse_ts(raw) -> "datetime | None":
if not isinstance(raw, str) or not raw.strip():
return None
s = raw.strip().replace("Z", "+00:00")
try:
dt = datetime.fromisoformat(s)
except Exception:
return None
return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc)
def transcript_start(path: str) -> "datetime | None":
"""transcript 第一列帶時間戳的那一列 = 這段對話真正的起點。"""
if not path or not os.path.exists(path):
return None
try:
with open(path) as f:
for line in f:
line = line.strip()
if not line:
continue
try:
row = json.loads(line)
except Exception:
continue
dt = _parse_ts(row.get("timestamp"))
if dt:
return dt
except Exception:
return None
return None
def state_dir() -> str:
d = os.environ.get("ISEP_COUNTDOWN_STATE_DIR", "").strip()
if not d:
d = os.path.join(os.path.expanduser("~"), ".claude", "isep-countdown")
try:
os.makedirs(d, exist_ok=True)
except Exception:
pass
return d
def session_start(session_id: str, transcript_path: str, now: datetime) -> datetime:
"""起點 = transcript 第一列與「第一次看到這個 session」兩者取較早。
第一次看到就落一個檔;transcript 沒有時間戳(或讀不到)時它就是唯一的憑據。
"""
cands = []
ts = transcript_start(transcript_path)
if ts:
cands.append(ts)
sid = "".join(c for c in (session_id or "") if c.isalnum() or c in "-_")[:80]
if sid:
p = os.path.join(state_dir(), sid + ".start")
seen = None
try:
with open(p) as f:
seen = datetime.fromtimestamp(float(f.read().strip()), tz=timezone.utc)
except Exception:
seen = None
if seen is None:
seen = min(cands) if cands else now
try:
with open(p, "w") as f:
f.write("%d" % seen.timestamp())
except Exception:
pass
cands.append(seen)
return min(cands) if cands else now
def deadline(now: datetime) -> datetime:
"""今天的收工線(台北 HH:MM)。過線後四小時內不滾,讓超時看得見。"""
hhmm = os.environ.get("ISEP_COUNTDOWN_DEADLINE", "16:00").strip() or "16:00"
try:
hh, mm = (int(x) for x in hhmm.split(":", 1))
except Exception:
hh, mm = 16, 0
local = now.astimezone(TAIPEI)
d = local.replace(hour=hh, minute=mm, second=0, microsecond=0)
if local >= d + timedelta(hours=4):
d += timedelta(days=1)
return d
def milestone() -> "tuple[str, datetime] | None":
"""主線 milestone 的期限。快取檔一行:`YYYY-MM-DD|<名稱>`。沒有就沒有。"""
raw = os.environ.get("ISEP_MILESTONE_DUE", "").strip()
name = os.environ.get("ISEP_MILESTONE_NAME", "").strip()
if not raw:
p = os.path.join(state_dir(), "milestone-due")
try:
with open(p) as f:
raw = f.read().strip()
except Exception:
return None
if "|" in raw:
raw, name = raw.split("|", 1)
raw, name = raw.strip(), name.strip()
if not raw:
return None
dt = _parse_ts(raw) or _parse_ts(raw + "T23:59:59+08:00")
if not dt:
return None
return (name or "主線", dt)
def human(delta: timedelta) -> str:
mins = int(abs(delta).total_seconds()) // 60
h, m = divmod(mins, 60)
if h and m:
return "%d 小時 %d" % (h, m)
if h:
return "%d 小時" % h
return "%d" % m
def line(session_id: str, transcript_path: str) -> str:
now = _now()
start = session_start(session_id, transcript_path, now)
dl = deadline(now)
parts = ["%s 已過 %s" % (MARKER, human(now - start))]
left = dl - now
if left.total_seconds() >= 0:
parts.append("距收工線(台北 %s)剩 %s" % (dl.strftime("%H:%M"), human(left)))
else:
parts.append("🔴 已超過收工線(台北 %s%s" % (dl.strftime("%H:%M"), human(left)))
ms = milestone()
if ms:
name, due = ms
gap = due - now
if gap.total_seconds() >= 0:
parts.append("主線 %s%s" % (name, human(gap)))
else:
parts.append("🔴 主線 %s 已逾期 %s" % (name, human(gap)))
return "".join(parts)
if __name__ == "__main__":
try:
payload = json.load(sys.stdin)
except Exception:
payload = {}
print(line(payload.get("session_id") or "", payload.get("transcript_path") or ""))
+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
+54
View File
@@ -41,6 +41,26 @@ try: print(json.load(sys.stdin).get("tool_input", {}).get("command", "") or "")
except Exception: print("")
' 2>/dev/null || printf '')
# ── 🔴 卡點二(inkstone/ISEP#63 → comment 5106):「執行它」vs「談論它」,第八次 ──
#
# 總管要把「本閘擋住了發通知」這件事**貼到票上**,而那則留言的內文**引用了**
# 本閘的判準(實例主機名、寫入旗標的字樣)⇒ **一則留言被自己擋下來。**
# 同款的第八次(`inkstone/InkStoneCo#23` 的標題已經寫著「第七次」)。
# ⇒ 本次修這支閘時**又撞了一次**:連「把修法寫進檔案」的那條指令都被擋,
# 因為它的內文引用了主機名。那就是這一格最好的證據。
#
# leo 2026-08-17 的診斷:文字層的閘那天 **8 次誤攔、0 次正確攔截**,且方向穩定——
# **紅線寫得越細,命中關鍵字的機率越高 ⇒ 那些閘在懲罰謹慎。**
#
# 那一段內文**是資料,不是指令**。這支 lib`github-contact-guard`
# `main-and-prod-push-guard``stage-before-prod-guard` 三支已經在用)只剝內文、
# **保留起始行**——所以真的在部署的寫法(起始行就帶著實例網址與寫入旗標)
# 仍然整條看得到、照樣擋。
#
# ⚠️ 繞法(把內文先寫成檔案、再用不含關鍵字的指令送出)本來就通,
# 但那正是 leo 說的「**人就會學會繞過它,那它就等於不存在**」⇒ 要修的是閘。
CMD=$(printf '%s' "$CMD" | python3 "$(dirname "$0")/lib/strip_""heredoc.py" 2>/dev/null || printf '%s' "$CMD")
# ── 總管戳記:**單次、15 分鐘失效** ────────────────────────────────────
#
# 🔴 這一版一開始寫成「15 分鐘內無限次放行」,而同一天 `main-and-prod-push-guard.sh`
@@ -220,6 +240,40 @@ case "$CMD_M" in
*curl*|*httpie*|*" http "*|*wget*)
case "$CMD" in
*workers.dev*|*arcrun.dev*)
# 🔴 卡點一(inkstone/ISEP#63 → comment 5106):發一則通知被當成部署擋下
#
# leo 2026-08-28 要的是「每小時一個版本,用 Telegram 通知我」。
# 通道是 `notify_leo` 工作流(wiki `agent-memory.md`:一條 curl、不需任何金鑰)。
# 而發一則 Telegram **一定是帶 body 的寫入型請求打到那台實例**
# ⇒ 在本閘眼裡它跟「部署一個工作流上線」長得一模一樣。
#
# 🔴 **這不是誤攔,是解析度不夠**:兩者打的是同一個 named webhook 家族路徑。
# 真正的差別在**打的是哪一個 named webhook**——
# `…/webhooks/named/<ns>/notify_leo/trigger` = 發一則訊息,什麼都沒改
# `…/webhooks/named`(無名字、無 /trigger) = 部署工作流定義
# `…/webhooks/named/<ns>/ship_refresh_cdn/trigger` 真的動線上狀態
#
# 判準因此看**目標的結構**(路徑形狀+工作流名字),不是「有沒有寫入旗標」:
# 指令裡打到實例的**每一個**網址都是白名單裡的通知型 trigger ⇒ 放行
# 只要有一個不是(含部署端點、含別的工作流)⇒ 照擋
# ⚠️ 放行的範圍刻意只有一個名字。**不放行整個 named webhook 家族**——
# 那會把部署一起放掉,等於拆了這道閘。
NOTIFY_ONLY=$(printf '%s' "$CMD" | python3 -c '
import re, sys
cmd = sys.stdin.read()
ALLOW = {"notify_leo"} # 通知型:只發訊息,不改線上任何狀態
urls = re.findall(r"https?://[^\s\"\x27<>]+", cmd)
inst = [u for u in urls if re.search(r"://[^/]*(workers\.dev|arcrun\.dev)", u)]
if not inst:
raise SystemExit(0) # 沒有實例網址 ⇒ 這裡不表態,交給下面的寫入語意判斷
PAT = re.compile(r"https?://[^/]+/webhooks/named/[^/]+/([A-Za-z0-9_-]+)/trigger/?$")
def notify(u):
m = PAT.match(u.rstrip(",;)"))
return bool(m) and m.group(1) in ALLOW
print("notify-only" if all(notify(u) for u in inst) else "")
' 2>/dev/null || printf '')
[ "$NOTIFY_ONLY" = "notify-only" ] && exit 0
# 🔴 2026-08-12 修誤攔:` -d ` 不只是 curl 的 --data**一堆唯讀工具也用 -d**
# `tr -d``cut -d=``sort -d``date -d``xargs -d``paste -d`…)。
# 實撞:總管要「唯讀」驗證一把金鑰能不能開 kbdb 的閘,指令第一行是
+198
View File
@@ -0,0 +1,198 @@
#!/usr/bin/env bash
# 管什麼: 把 kbdb_search 掉出來的原始值當證據,貼進票或派工單裡,而那筆資料從沒走過產品檢索路徑時,擋一次。
# 為什麼: 2026-08-27 inkstone/Arcrun#167 comment 4865——總管用 kbdb_search 的輸出判定「兩個缺陷都還在」,退回一張已經做完、有測試、東西在 main 的票,還重派一條線。半小時後自己更正(comment 4877):那條路本來就不會有那個欄位。
# 誤觸時怎麼關: 指令或派工單裡加 `search-is-evidence: <一句理由>` 重送(留痕);同一 session 只擋一次。
#
# search-is-not-proof-guard.sh — 你判斷的那條路,和你走的那條路,是同一條嗎?
# PreToolUse: Bash / Agent / Task
#
# ── 這一格為什麼原本沒有閘(inkstone/ISEP#30 comment 4879 的原話)──────
# 「所有現有的閘防的是**危險動作**:推 main、推 prod、寫金鑰、打真庫、開重複票。
# 而『用 kbdb_search 去證明某條路壞了』——不危險、不寫檔、不推 main。
# **它只是錯的方法。** ⇒ 閘的座標系裡沒有這一維。」
#
# ── 判準:出處,不是措辭 ────────────────────────────────────────────
# 🔴 驗收條件第 4 條寫死:**不要用關鍵字比對我的措辭**。
# leo 2026-08-17 已經證偽過文字層——那天 **8 次誤攔、0 次正確攔截**,
# 而且方向穩定:紅線寫得越細,命中關鍵字的機率越高 ⇒ **在懲罰謹慎**。
#
# 所以這支一個字都不看。它只問一件機械問題:
#
# 你正要送出去的這份東西裡,有沒有一個**只在 kbdb_search 的回應裡出現過**
# 的值(entry id / `kb://` 來源位址)?而那筆資料,這個 session 從來沒有
# 用產品檢索路徑(graph / wiki 內容)拿過?
#
# ⇒ 兩個條件都成立,才代表「我在拿輔助工具的輸出,當產品檢索路徑的證據」。
# 這是**資料的出處**,不是我的用詞。值跟動作一樣有限且可枚舉。
#
# ── D81:哪條才是產品檢索路徑(leo 2026-08-13 親自裁定)──────────────
# `system-dev/docs/2-architecture/decisions/D81-vector-only-for-entity-normalize.md`
# 「你會用 **graph 找到該找的東西**,用 **LLM wiki 去拉到對的內容**
# 所以全量向量化沒意義⋯⋯**向量的目的是用來協助把 entities normalize**。」
# ⇒ 主路徑=graph + wiki 內容;向量/關鍵字搜尋=**輔助**。
#
# 🔴 **這支不會讓 `kbdb_search` 變難用。** 它是合法的基本盤工具,
# subagent-wiki-guard.sh 還明文要求「查任何東西之前先用它查 wiki」。
# 查一筆資料在不在、找 record_id、翻 wiki 找判例——**全部照放**,
# 因為那些用途不會把 entry id 貼進票裡當證據。
#
# ── 真跡(本閘的規格,Arcrun#167 comment 4865 原文照貼)───────────────
# $ kbdb_search(q="小果 AFTEE 詐貸") → count 5
# { "id": "e_b77f23a1-b467-4aa4-883c-58a051ab8e66", … }
# "metadata_json": "{\"source\":\"kb://小果被AFTEE詐貸.pdf#8\", … }"
# ⇒ 那則退回裡**同時**有 entry id 和 `kb://` 位址,兩類指紋都命中,
# 而整個 session 沒有任何一次 graphwiki 內容的呼叫。
# ⇒ 半小時後 comment 4877:「上一則退回是錯的;真正的缺口是那支工具 MCP 沒有」。
# **那半小時裡,一條線被停掉、一張做完的票被退回、一份工單被重寫。**
#
# ── 誠實邊界(不補,補了就變成文字層)────────────────────────────────
# 1. **改寫過的證據抓不到**——「我看到出處還是 `../` 開頭」這種轉述沒有指紋。
# 但房規本來就要求貼原始輸出(規則四之一、派工鐵律之三),
# 而貼了原始輸出就會被抓到。**不貼的那條路由別的閘管,不歸這支。**
# 2. **只認 KBDB 這一族**。別的「錯的驗證路徑」(拿 portal 畫面當 MCP 的證據——
# 08-27 同一天的另一半)這支看不到,因為那不經過工具呼叫。
# 3. 想不出乾淨判準的地方就留白,不硬做。
# `.claude/branch-holds.md` 檔頭那句:**永遠在響的警報,等於訓練人忽略這個警報。**
set -uo pipefail
INPUT=$(cat)
SID=$(printf '%s' "$INPUT" | python3 -c "
import sys, json
try: print(json.load(sys.stdin).get('session_id','') or 'nosid')
except Exception: print('nosid')
" 2>/dev/null || echo nosid)
SEARCH="/tmp/.kbdb-evidence-search-$SID"
# 這個 session 從來沒用搜尋拿過任何東西 ⇒ 這支沒有立場說話,最便宜的出口放在最前面
[ -s "$SEARCH" ] || exit 0
WARNED="/tmp/.search-is-not-proof-warned-$SID"
[ -f "$WARNED" ] && exit 0 # 同一 session 只擋一次,不鬼打牆
# 🔴 env 要 export,不能掛在 `printf` 前面——那樣只餵到 printfpython3 收不到
export SEARCH_FILE="$SEARCH"
export WALKED_FILE="/tmp/.kbdb-evidence-walked-$SID"
HIT=$(printf '%s' "$INPUT" | python3 -c '
import json, os, re, sys
try:
d = json.load(sys.stdin)
except Exception:
raise SystemExit # 讀不到 payload 就不擋(那不是使用者的錯)
tool = d.get("tool_name") or ""
ti = d.get("tool_input") or {}
if not isinstance(ti, dict):
raise SystemExit
# ── ① 這是不是一個「往外送」的動作?──────────────────────────────
# 往外送 = 這份文字會變成別人的前提:貼進票、關票、記裁決、或發包給另一條線。
# 只在自己腦袋裡看搜尋結果,永遠不會走到這裡。
body, kind = "", ""
if tool in ("Agent", "Task"):
body = ti.get("prompt") or ""
kind = "派工單"
elif tool == "Bash":
cmd = ti.get("command") or ""
if not cmd:
raise SystemExit
outward = False
if re.search(r"\bticket\s+(say|close|decide|new|subtask|handback)\b", cmd):
outward = True
if re.search(r"issues/\d+(/comments)?\b", cmd) and re.search(r"\b(POST|PATCH)\b", cmd):
outward = True
if not outward:
raise SystemExit
body, kind = cmd, "票上的留言"
# 內文常常放在 -F <檔> 裡,指令本身看不到 ⇒ 跟 comment-carries-task-guard 同一招
m = re.search(r"[\s](?:-F|--file)[\s]+(\S+)", cmd)
if m and os.path.isfile(m.group(1)):
try:
body += "\n" + open(m.group(1), encoding="utf-8", errors="replace").read()
except Exception:
pass
else:
raise SystemExit
if not body:
raise SystemExit
# 逃生口(留痕:它會留在指令歷史/派工單上)
if "search-is-evidence:" in body:
raise SystemExit
def load(p):
try:
with open(p, encoding="utf-8") as f:
return {ln.strip() for ln in f if ln.strip()}
except Exception:
return set()
searched = load(os.environ["SEARCH_FILE"])
walked = load(os.environ["WALKED_FILE"])
# ── ② 只在搜尋裡出現過、真路徑沒走過的那些值 ─────────────────────
# 走過真路徑的(graph / wiki 內容 / query)從候選裡扣掉——
# **搜完再去走一次真路徑,本來就是對的做法,不該被罰。**
only_search = searched - walked
if not only_search:
raise SystemExit
# ── ③ 這些值有沒有真的被貼進要送出去的那份東西裡 ──────────────────
hits = sorted(v for v in only_search if v in body)
if not hits:
raise SystemExit
print(kind + "\t" + "\t".join(hits[:3]))
' 2>/dev/null) || exit 0
[ -n "${HIT:-}" ] || exit 0
KIND=$(printf '%s' "$HIT" | cut -f1)
VALS=$(printf '%s' "$HIT" | cut -f2- | tr '\t' '\n' | sed 's/^/ /')
date +%s > "$WARNED"
cat >&2 <<EOF
🧭 你正要把 **kbdb_search 掉出來的原始值**,當成證據送進${KIND}裡。
被貼過去的值(這個 session 只在 kbdb_search 的回應裡出現過):
$VALS
而這個 session **沒有用產品檢索路徑拿過這幾筆**——
graph\`kbdb_graph_neighbors\`)/wiki 內容(\`kbdb_get_record\`)/\`kbdb_query\` 一次都沒有。
【D81leo 2026-08-13 親自裁定】
「你會用 **graph 找到該找的東西**,用 **LLM wiki 去拉到對的內容**⋯⋯
**向量的目的是用來協助把 entities normalize**。」
⇒ 主路徑=graph + wiki 內容。**搜尋是輔助,不是產品檢索路徑。**
🔴 這不是說 \`kbdb_search\` 不能用——它是合法的基本盤工具,查 wiki 本來就該用它。
不能做的是**拿它的輸出,去斷定產品檢索那條路是好是壞**。
【它花掉過多少(inkstone/Arcrun#1672026-08-27)】
12:34 comment 4865 用 kbdb_search 的輸出判定「兩個缺陷都還在」→ 退回一張
**已經做完、有測試、東西在 main** 的票,並重派一條線
13:08 comment 4877 總管自己更正:「上一則退回是錯的;
真正的缺口是**那支工具 MCP 根本沒有**」
⇒ 真相是那次修的是另一支工具的欄位,\`kbdb_search\` 本來就不會有它。
**在用一條錯的路,反覆確認一件錯的事**——而真正的缺口一直沒被看到。
【送出前先回答這一題】
**你要下結論的那條路,和你剛才實際走的那條路,是同一條嗎?**
(08-27 那則退回自己就寫著:「我當時驗的是 portal 的來源顯示,
但這張票的驗收條件是 MCP client 問『原文在哪』。**兩條不同的路。**」)
【出路,挑一條】
① 走一次真的那條路再下結論 —— \`kbdb_graph_neighbors\` / \`kbdb_get_record\` / \`kbdb_query\`
走過之後同一筆資料就不再命中本閘(走過的會從候選裡扣掉)。
② **那條路的工具根本不存在** ⇒ **那個才是你要回報的東西**(4877 就是這樣收的)。
照實寫「產品檢索路徑上少一支工具」,不要改用搜尋去補位再下判斷。
③ 真的只是引用一筆資料、不是在斷定檢索好壞
⇒ 加 \`search-is-evidence: <一句理由>\` 重送(留痕)。
同一 session 只擋這一次。
EOF
exit 2
+224
View File
@@ -0,0 +1,224 @@
#!/usr/bin/env bash
# countdown-guard.sh 的迴歸測試(inkstone/ISEP#63 → comment 5106
#
# 三個方向都要有證據,對應票上那三條驗收:
# A 群「注入」—— 新 session 什麼都沒交代,那一行就在;數字對得上;連續呼叫遞增
# B 群「查核該擋」—— 回覆沒戴倒數 ⇒ 擋一次,且把算好的那一行印出來給它抄
# C 群「查核不該擋」—— 誤攔比漏擋更該修:純工具回合/子 session/已提醒過/
# transcript 讀不到,全部放行
#
# 用法:hooks/tests/countdown-guard.test.sh [hooks/countdown-guard.sh 的路徑]
# 🔴 全程離線:時鐘用 ISEP_COUNTDOWN_NOW 定住、狀態走 ISEP_COUNTDOWN_STATE_DIR
# 不打網路、不碰 $HOME、跑完自己清。
set -u
HOOK="${1:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/countdown-guard.sh}"
TMP=$(mktemp -d)
trap 'rm -rf "$TMP"' EXIT
export ISEP_COUNTDOWN_STATE_DIR="$TMP/state"
mkdir -p "$ISEP_COUNTDOWN_STATE_DIR"
PASS=0; FAIL=0
# 定住的時鐘:2026-08-28 08:00 台北 2026-08-28 00:00 UTC
T0=$(python3 -c 'import datetime as d;print(int(d.datetime(2026,8,28,0,0,tzinfo=d.timezone.utc).timestamp()))')
H=3600
ok(){ printf ' ✅ %s\n' "$1"; PASS=$((PASS+1)); }
no(){ printf ' ❌ %s —— %s\n' "$1" "$2"; FAIL=$((FAIL+1)); }
# mk_transcript <檔> <起始 epoch> —— 一則帶時間戳的使用者訊息,當作對話起點
mk_transcript(){
python3 - "$1" "$2" <<'PY'
import datetime as d, json, sys
p, ts = sys.argv[1], int(sys.argv[2])
iso = d.datetime.fromtimestamp(ts, d.timezone.utc).isoformat().replace("+00:00", "Z")
with open(p, "w") as f:
f.write(json.dumps({"type": "user", "timestamp": iso,
"message": {"content": [{"type": "text", "text": "開工"}]}}) + "\n")
PY
}
# add_turn <檔> <spec>... —— spec`T:<文字>` assistant 文字;`U:<工具>` tool_use
# `R:` 一列工具結果(不算新回合的分界)
add_turn(){
python3 - "$@" <<'PY'
import json, sys
path, specs = sys.argv[1], sys.argv[2:]
rows = []
blocks = []
for s in specs:
k, v = s.split(":", 1)
if k == "T":
blocks.append({"type": "text", "text": v})
elif k == "U":
blocks.append({"type": "tool_use", "name": v, "id": "t", "input": {}})
elif k == "R":
rows.append({"type": "user", "message": {"content": [
{"type": "tool_result", "tool_use_id": "t", "content": "ok"}]}})
with open(path, "a") as f:
f.write(json.dumps({"type": "user", "message": {"content": [
{"type": "text", "text": "繼續"}]}}, ensure_ascii=False) + "\n")
for r in rows:
f.write(json.dumps(r, ensure_ascii=False) + "\n")
if blocks:
f.write(json.dumps({"type": "assistant", "message": {"content": blocks}},
ensure_ascii=False) + "\n")
PY
}
# inject <session> <transcript> <now epoch> —— 跑注入模式,回傳 additionalContext
inject(){
printf '{"hook_event_name":"UserPromptSubmit","session_id":"%s","transcript_path":"%s"}' "$1" "$2" \
| ISEP_COUNTDOWN_NOW="$3" bash "$HOOK" 2>/dev/null \
| python3 -c 'import json,sys
try: print(json.load(sys.stdin)["hookSpecificOutput"]["additionalContext"])
except Exception: print("")'
}
# stop <session> <transcript> <now epoch> <extra json> —— 跑查核模式,回傳 "exit|訊息"
stop(){
# 🔴 明著把 CLAUDE_CODE_CHILD_SESSION 清掉:測試本身常常跑在一條 subagent 裡,
# 而那正是本閘刻意放行的情況 ⇒ 不清掉的話 B 群會全綠,而且是**假綠**
# 2026-08-28 第一次跑就撞到:B 群三條全 exit 0,因為跑測試的就是子 session)。
out=$(printf '{"hook_event_name":"Stop","session_id":"%s","transcript_path":"%s"%s}' "$1" "$2" "${4:-}" \
| ISEP_COUNTDOWN_NOW="$3" CLAUDE_CODE_CHILD_SESSION= CLAUDE_PROJECT_DIR="$TMP" \
bash "$HOOK" 2>&1); rc=$?
printf '%s|%s' "$rc" "$(printf '%s' "$out" | tr '\n' ' ')"
}
echo "── A 群:注入(每一則回覆眼前都有那一行)────────────────────────"
TR="$TMP/a1.jsonl"; mk_transcript "$TR" "$T0"
CTX=$(inject s-a1 "$TR" "$T0")
case "$CTX" in
*"⏱"*) ok "① 新 session 什麼都沒交代 ⇒ 注入的內容帶著標記" ;;
*) no "① 新 session 什麼都沒交代 ⇒ 注入的內容帶著標記" "實得:$CTX" ;;
esac
case "$CTX" in
*"已過 0 分"*) ok "② 剛開工 ⇒ 已過 0 分" ;;
*) no "② 剛開工 ⇒ 已過 0 分" "實得:$CTX" ;;
esac
# 台北 08:00 → 16:00 收工線還有 8 小時
case "$CTX" in
*"距收工線(台北 16:00)剩 8 小時"*) ok "③ 台北 08:00 ⇒ 距收工線剩 8 小時" ;;
*) no "③ 台北 08:00 ⇒ 距收工線剩 8 小時" "實得:$CTX" ;;
esac
# 票上驗收 2:數字要對得上真實經過的時間(期望 2 小時 13 分)
CTX=$(inject s-a1 "$TR" $((T0 + 2*H + 13*60)))
case "$CTX" in
*"已過 2 小時 13 分"*) ok "④ 過了 2h13m ⇒ 顯示「已過 2 小時 13 分」(期望=實得)" ;;
*) no "④ 過了 2h13m ⇒ 顯示「已過 2 小時 13 分」" "實得:$CTX" ;;
esac
# 票上驗收 3:連續幾則要遞增,不歸零、不亂跳
LAST=-1; SEQ_OK=1
for k in 0 1 3 5; do
CTX=$(inject s-a1 "$TR" $((T0 + k*H)))
got=$(printf '%s' "$CTX" | sed -n 's/.*已過 \([0-9]*\) 小時.*/\1/p')
[ -z "$got" ] && got=0
[ "$got" -ge "$LAST" ] || SEQ_OK=0
LAST="$got"
done
[ "$SEQ_OK" = 1 ] && ok "⑤ 連續四則(0/1/3/5 小時)⇒ 遞增,不歸零不亂跳" \
|| no "⑤ 連續四則 ⇒ 遞增" "$LAST 則往回跳了"
# resume:同一個 session 換一份較早起點的 transcript,起點取較早的那個
TR2="$TMP/a2.jsonl"; mk_transcript "$TR2" $((T0 - 5*H))
CTX=$(inject s-a1 "$TR2" "$T0")
case "$CTX" in
*"已過 5 小時"*) ok "⑥ resumetranscript 起點更早)⇒ 昨天燒掉的時間仍然算" ;;
*) no "⑥ resume ⇒ 起點取較早" "實得:$CTX" ;;
esac
# 過了收工線:台北 17:30 ⇒ 顯示超過,不准悄悄換算成明天
CTX=$(inject s-late "$TMP/a3.jsonl" $((T0 + 9*H + 30*60)))
case "$CTX" in
*"已超過收工線"*) ok "⑦ 台北 17:30 ⇒ 顯示「已超過收工線」(超時不許消失)" ;;
*) no "⑦ 台北 17:30 ⇒ 顯示已超過" "實得:$CTX" ;;
esac
# 台北 21:00(過線四小時後)⇒ 滾到隔天 16:00,剩 19 小時
CTX=$(inject s-next "$TMP/a4.jsonl" $((T0 + 13*H)))
case "$CTX" in
*"距收工線(台北 16:00)剩 19 小時"*) ok "⑧ 台北 21:00 ⇒ 滾到隔天,剩 19 小時" ;;
*) no "⑧ 台北 21:00 ⇒ 滾到隔天" "實得:$CTX" ;;
esac
# milestone:沒有快取就整段不出現(不准編一個日期出來)
case "$CTX" in
*"主線"*) no "⑨ 沒有 milestone 快取 ⇒ 那一段不出現" "實得:$CTX" ;;
*) ok "⑨ 沒有 milestone 快取 ⇒ 那一段不出現(不編數字)" ;;
esac
printf '2026-08-30|Sprint 8-28\n' > "$ISEP_COUNTDOWN_STATE_DIR/milestone-due"
CTX=$(inject s-ms "$TMP/a5.jsonl" "$T0")
case "$CTX" in
*"主線 Sprint 8-28 剩"*) ok "⑩ 有 milestone 快取 ⇒ 期限那一段出現" ;;
*) no "⑩ 有 milestone 快取 ⇒ 期限那一段出現" "實得:$CTX" ;;
esac
rm -f "$ISEP_COUNTDOWN_STATE_DIR/milestone-due"
echo "── B 群:查核——該擋 ────────────────────────────────────────────"
TR="$TMP/b1.jsonl"; mk_transcript "$TR" "$T0"
add_turn "$TR" 'U:Read' 'T:做完了,改了三個檔。'
R=$(stop s-b1 "$TR" $((T0 + 2*H)))
[ "${R%%|*}" = 2 ] && ok "⑪ 回覆沒戴倒數 ⇒ 擋" || no "⑪ 回覆沒戴倒數 ⇒ 擋" "實得 exit=${R%%|*}"
case "${R#*|}" in
*"已過 2 小時"*) ok "⑫ 擋下時把算好的那一行印出來給它抄(不是叫它自己算)" ;;
*) no "⑫ 擋下時印出算好的那一行" "實得:${R#*|}" ;;
esac
# 回合邊界:上一回合戴了、這一回合沒戴 ⇒ 還是要擋
TR="$TMP/b2.jsonl"; mk_transcript "$TR" "$T0"
add_turn "$TR" 'T:⏱ 已過 1 小時|距收工線剩 7 小時。上一回合戴過了。'
add_turn "$TR" 'U:Edit' 'T:這回合忘了戴。'
R=$(stop s-b2 "$TR" $((T0 + 2*H)))
[ "${R%%|*}" = 2 ] && ok "⑬ 只有上一回合戴 ⇒ 這一回合仍要擋(回合邊界對)" \
|| no "⑬ 只有上一回合戴 ⇒ 仍要擋" "實得 exit=${R%%|*}"
echo "── C 群:查核——不該擋(誤攔比漏擋更該修)──────────────────────"
TR="$TMP/c1.jsonl"; mk_transcript "$TR" "$T0"
add_turn "$TR" 'U:Read' 'T:⏱ 已過 2 小時|距收工線(台北 16:00)剩 6 小時 —— 做完了。'
R=$(stop s-c1 "$TR" $((T0 + 2*H)))
[ "${R%%|*}" = 0 ] && ok "⑭ 回覆戴了倒數 ⇒ 放行" || no "⑭ 回覆戴了倒數 ⇒ 放行" "實得 exit=${R%%|*}"
TR="$TMP/c2.jsonl"; mk_transcript "$TR" "$T0"
add_turn "$TR" 'T:上一回合。'
add_turn "$TR" 'U:Read' 'T:⏱ 已過 2 小時|這一回合戴了。'
R=$(stop s-c2 "$TR" $((T0 + 2*H)))
[ "${R%%|*}" = 0 ] && ok "⑮ 這一回合戴了、上一回合沒戴 ⇒ 放行" \
|| no "⑮ 這一回合戴了 ⇒ 放行" "實得 exit=${R%%|*}"
TR="$TMP/c3.jsonl"; mk_transcript "$TR" "$T0"
add_turn "$TR" 'U:Read' 'R:' 'U:Grep'
R=$(stop s-c3 "$TR" $((T0 + 2*H)))
[ "${R%%|*}" = 0 ] && ok "⑯ 純工具回合(沒有任何文字)⇒ 放行,沒有東西可以戴" \
|| no "⑯ 純工具回合 ⇒ 放行" "實得 exit=${R%%|*}"
TR="$TMP/c4.jsonl"; mk_transcript "$TR" "$T0"
add_turn "$TR" 'T:沒戴。'
R=$(stop s-c4 "$TR" $((T0 + 2*H)) ',"stop_hook_active":true')
[ "${R%%|*}" = 0 ] && ok "⑰ 已經提醒過一次(stop_hook_active)⇒ 放行,至多擋一次" \
|| no "⑰ stop_hook_active ⇒ 放行" "實得 exit=${R%%|*}"
TR="$TMP/c5.jsonl"; mk_transcript "$TR" "$T0"
add_turn "$TR" 'T:我是 subagent,交件給總管。'
out=$(printf '{"hook_event_name":"Stop","session_id":"s-c5","transcript_path":"%s"}' "$TR" \
| ISEP_COUNTDOWN_NOW=$((T0 + 2*H)) CLAUDE_CODE_CHILD_SESSION=1 \
CLAUDE_PROJECT_DIR="$TMP" bash "$HOOK" 2>&1); rc=$?
[ "$rc" = 0 ] && ok "⑱ 子 sessionsubagent)⇒ 放行,它的回覆不是給 leo 看的" \
|| no "⑱ 子 session ⇒ 放行" "實得 exit=$rc"
R=$(stop s-c6 "$TMP/does-not-exist.jsonl" $((T0 + 2*H)))
[ "${R%%|*}" = 0 ] && ok "⑲ transcript 讀不到 ⇒ 放行(節拍器壞掉不該卡死 session)" \
|| no "⑲ transcript 讀不到 ⇒ 放行" "實得 exit=${R%%|*}"
printf 'not json at all\n{{{\n' > "$TMP/c7.jsonl"
R=$(stop s-c7 "$TMP/c7.jsonl" $((T0 + 2*H)))
[ "${R%%|*}" = 0 ] && ok "⑳ transcript 是壞的 ⇒ 放行,不是擋" \
|| no "⑳ transcript 壞掉 ⇒ 放行" "實得 exit=${R%%|*}"
echo
echo "結果:通過 $PASS 條,失敗 $FAIL"
[ "$FAIL" -eq 0 ] || exit 1
+101
View File
@@ -0,0 +1,101 @@
# hooks/tests/lib/hook-sandbox.sh — 讓「會寫檔的閘」在測試時寫到暫存區,不寫進 repo。
# 不是獨立掛的閘(沒進 hooks.json),給 hooks/tests 與 scripts/test-* `source` 用。
#
# 🔴 為什麼需要這支(inkstone/ISEP#59 comment 4779 第 ① 條,實測重演過):
# main-and-prod-push-guard.sh 擋下「推 main」的同時,會把那次請求寫成一份
# `<hooks 的上一層>/pending-main-push/<誰>--<repo>.md`——**那份檔案的語意是
# 「有一筆推 main 正在等總管裁」**。而測試的測資本來就全是「推 main」,
# 於是每跑一次測試,工作區就多/改幾筆**偽造的待裁決**:
#
# $ git status --short # 跑完測試之後(修之前的實測)
# M pending-main-push/unnamed--ISEP.md ← 被測試覆寫(它是被追蹤的)
# ?? pending-main-push/unnamed--A.md ← 跨 repo 測試建的臨時 repo
#
# 兩個後果,後者比較貴:
# ① `git add -A` 很容易把它們帶進 commit(08-27 那次真的帶進去了,事後才拔掉)
# ② **永遠在響的警報**:總管的迴圈讀這個目錄,讀到的每一筆都該是真的在等他裁。
# 測試每跑一次就偽造一筆 ⇒ 下一筆真的請求會混在雜訊裡。
#
# 做法:把整個 `hooks/` 複製到暫存區再跑那支複本。閘算 `pending-main-push` 的位置
# 靠的是 `$0` 的上一層,所以複本會寫進暫存區,**產品程式碼一行都不用改**
# (不必為了測試在閘上多開一個「寫去哪」的開關——那種開關會變成關掉紀錄的路)。
#
# 用法:
# . "$(dirname "$0")/lib/hook-sandbox.sh"
# hook_sandbox "$REAL_HOOK" || exit 1
# G=$HOOK_SANDBOX_HOOK # 之後一律測 $G
# before=$(hook_sandbox_hostsum) # repo 那份 pending-main-push 的指紋
# ... 跑測試 ...
# hook_sandbox_assert "$before" # 回 0=乾淨且沙盒裡真的有留下請求
# hook_sandbox <真跡 hook 的路徑> → 建好沙盒,設三個變數,回 0/1
# HOOK_SANDBOX_HOOK 沙盒裡那支同名 hook(之後一律測這支)
# HOOK_SANDBOX 沙盒根(`pending-main-push` 會長在這底下)
# HOOK_SANDBOX_HOST 真跡 repo 的 pending-main-push(拿來比對有沒有被弄髒)
#
# 🔴 **刻意不用 `printf` 把路徑印出來讓呼叫端 `G=$(hook_sandbox …)` 接**——
# 命令替換跑在子殼裡,函式設的那三個變數**回不到呼叫端**。
# 寫這支的當下就這樣寫,第一次跑的結果是:`HOOK_SANDBOX` 在呼叫端是空的
# ⇒ hostsum 兩次都回 `NODIR`(相等)、沙盒的請求數當然是 0
# ⇒ **第一條斷言變成「拿空的比空的」的假綠**。
# 所以改成「設變數、不印」,而且 hook_sandbox_assert 開頭會擋空值(見下)。
hook_sandbox() {
_hs_real=$1
_hs_hooks=$(CDPATH= cd -- "$(dirname -- "$_hs_real")" && pwd) || return 1
HOOK_SANDBOX=$(mktemp -d) || return 1
cp -R "$_hs_hooks" "$HOOK_SANDBOX/hooks" || return 1
HOOK_SANDBOX_HOST="${_hs_hooks%/hooks}/pending-main-push"
HOOK_SANDBOX_HOOK="$HOOK_SANDBOX/hooks/$(basename "$_hs_real")"
[ -f "$HOOK_SANDBOX_HOOK" ] || return 1
}
# hook_sandbox_hostsum → 印出真跡 repo 那份 pending-main-push 的指紋(檔名+內容)
# 目錄不存在時印 NODIR——「本來就沒有」跟「空的」要分得出來。
hook_sandbox_hostsum() {
if [ -d "${HOOK_SANDBOX_HOST:-/nonexistent}" ]; then
find "$HOOK_SANDBOX_HOST" -type f 2>/dev/null | LC_ALL=C sort | while IFS= read -r _f; do
cksum "$_f" 2>/dev/null || printf 'UNREADABLE %s\n' "$_f"
done
else
printf 'NODIR\n'
fi
}
# hook_sandbox_assert <before 的指紋> → 印出兩行結果,全過回 0
# 🔴 兩件都要驗,只驗前者會養出「把紀錄關掉也算通過」的假綠:
# ① repo 那份 pending-main-push 一個位元都沒動(測試沒有偽造待裁決)
# ② 沙盒那份真的多了請求檔(**留請求這件事本身還在做**)
hook_sandbox_assert() {
_hs_before=$1
# fail-closed:沙盒沒建起來(或變數沒傳到這裡)時,下面兩條會變成「空的比空的」=假綠。
if [ -z "${HOOK_SANDBOX:-}" ] || [ -z "${HOOK_SANDBOX_HOST:-}" ]; then
printf ' ❌ %-58s\n' "沙盒變數是空的——這兩條斷言等於沒跑,當失敗處理"
HS_PASS=0; HS_FAIL=2; return 1
fi
_hs_after=$(hook_sandbox_hostsum)
_hs_rc=0; HS_PASS=0; HS_FAIL=0
if [ "$_hs_before" = "$_hs_after" ]; then
printf ' ✅ %-58s\n' "repo 的 pending-main-push 沒被測試碰過"; HS_PASS=$((HS_PASS+1))
else
printf ' ❌ %-58s\n' "repo 的 pending-main-push 被測試寫髒了"
printf ' before/after diff\n'
printf '%s\n' "$_hs_before" > "$HOOK_SANDBOX/.before"
printf '%s\n' "$_hs_after" > "$HOOK_SANDBOX/.after"
diff "$HOOK_SANDBOX/.before" "$HOOK_SANDBOX/.after" | sed 's/^/ /'
_hs_rc=1; HS_FAIL=$((HS_FAIL+1))
fi
_hs_n=$(find "$HOOK_SANDBOX/pending-main-push" -type f 2>/dev/null | wc -l | tr -d ' ')
if [ "${_hs_n:-0}" -gt 0 ]; then
printf ' ✅ %-58s%s 筆)\n' "請求有被留下來,只是留在沙盒裡" "$_hs_n"; HS_PASS=$((HS_PASS+1))
else
printf ' ❌ %-58s\n' "沙盒裡一筆請求都沒有——留請求的機制可能被關掉了"
_hs_rc=1; HS_FAIL=$((HS_FAIL+1))
fi
return $_hs_rc
}
hook_sandbox_cleanup() {
case "${HOOK_SANDBOX:-}" in
/*/*) rm -rf "$HOOK_SANDBOX" ;; # 只刪 mktemp 給的那種深路徑,不接受空值/根目錄
esac
}
@@ -9,10 +9,21 @@
# 路徑務必給絕對路徑——測試會 cd 進臨時建立的 A/B repo 再呼叫它,相對路徑
# 到那時就對不上了(自己撞過一次:exit=127 command not found)。
set -u
G="$1"
REAL="$1"
# 🔴 測沙盒裡的複本,不測真跡:這支閘擋下推 main 時會把請求寫進
# `<repo>/pending-main-push/`,而這裡每一條測資都是推 main
# ⇒ 直接測真跡,每跑一次就在 ISEP 的工作區偽造幾筆「還沒裁」
# (實測留下過 `pending-main-push/unnamed--A.md`——A 是這支自己建的臨時 repo,
# inkstone/ISEP#59 comment 4779 第 ① 條)。收尾會驗兩件(見 lib 檔頭)。
. "$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)/lib/hook-sandbox.sh"
hook_sandbox "$REAL" || { echo "❌ 沙盒建不起來"; exit 1; }
G=$HOOK_SANDBOX_HOOK
HOSTSUM_BEFORE=$(hook_sandbox_hostsum)
STAMP=/tmp/.main-push-ok
WORK=$(mktemp -d)
trap 'rm -rf "$WORK"; rm -f "$STAMP"' EXIT
trap 'rm -rf "$WORK"; rm -f "$STAMP"; hook_sandbox_cleanup' EXIT
for d in A B; do
git init -q -b main "$WORK/$d"
@@ -101,5 +112,9 @@ touch -t "$(date -v-16M +%Y%m%d%H%M.%S 2>/dev/null || date -d '-16 minutes' +%Y%
t "16 分鐘前開的戳記 → 已過期,推 B 應擋" "$A" "cd $B && git push origin HEAD:main" 2
rm -f "$STAMP"
echo "── 測試自己不准弄髒工作區(inkstone/ISEP#59)──"
hook_sandbox_assert "$HOSTSUM_BEFORE"
pass=$((pass+HS_PASS)); fail=$((fail+HS_FAIL))
echo "────── 通過 $pass 失敗 $fail"
[ "$fail" = 0 ]
+15 -1
View File
@@ -1,7 +1,17 @@
#!/usr/bin/env bash
# 八向實測 main-and-prod-push-guard.sh
# 放在檔案裡跑,因為測試字串本身會觸發「舊版」那支閘(第五次誤攔)。
G="$1" # 要測的 hook 路徑
REAL="$1" # 要測的 hook 路徑(真跡)
# 🔴 不要直接測真跡:這支閘擋下推 main 的同時會把請求寫進 `<repo>/pending-main-push/`
# 而這裡的測資全是推 main ⇒ 每跑一次就在工作區偽造幾筆「還沒裁」(inkstone/ISEP#59)。
# 改測沙盒裡的複本,閘照原樣跑,請求寫進暫存區。收尾會驗兩件(見 lib 檔頭)。
. "$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)/lib/hook-sandbox.sh"
hook_sandbox "$REAL" || { echo "❌ 沙盒建不起來"; exit 1; }
G=$HOOK_SANDBOX_HOOK
trap 'hook_sandbox_cleanup' EXIT
HOSTSUM_BEFORE=$(hook_sandbox_hostsum)
pass=0; fail=0
t() { # t <說明> <指令> <期望 exit>
@@ -25,5 +35,9 @@ t "直接推預設分支" 'git push origin ma'"in" 2
t "HEAD:預設分支" 'git push origin HEAD:ma'"in" 2
t "推 master" 'git push -q origin mas'"ter" 2
echo "── 測試自己不准弄髒工作區(inkstone/ISEP#59)──"
hook_sandbox_assert "$HOSTSUM_BEFORE"
pass=$((pass+HS_PASS)); fail=$((fail+HS_FAIL))
echo "────── 通過 $pass 失敗 $fail"
[ "$fail" = "0" ]
+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
+36
View File
@@ -68,6 +68,42 @@ t block "heredoc 之後的真指令(剝內文不能連指令一起剝)" \
EOF
$ACR_U --force"
# ══ inkstone/ISEP#63 → comment 5106:兩個卡點 ═══════════════════════════
#
# 卡點一:發一則 Telegram 給 leo`notify_leo`)跟「部署一個工作流上線」在本閘眼裡
# 長得一模一樣——兩者都是帶 body 的寫入型請求打到同一台實例。
# 判準改成看**打的是哪一個 named webhook**(路徑形狀+名字),
# 而放行的範圍只有一個名字,家族其他成員照擋。
# 卡點二:連「把這個卡點寫進票裡」都被同一支閘擋(同款第八次)——
# 內文引用了主機名與寫入旗標,而閘比對的是指令文字。
EXE="https://arcrun-cypher-executor.leo21c.wo""rkers.dev"
NAMED="$EXE/webhooks/named"
echo "── 卡點一:通知放得過,家族其他成員照擋 ──"
t pass "發一則 Telegram 給 leonotify_leo trigger" \
"curl -s -X POST $NAMED/leo/notify_leo/trigger $DASH_D '{\"text\":\"[總管] 進度\"}'"
t pass "同上,網址後面帶斜線" \
"curl -s -X POST $NAMED/leo/notify_leo/trigger/ --data '{\"text\":\"[總管] x\"}'"
t block "別的 named webhook(會動線上狀態)不在白名單裡" \
"curl -X POST $NAMED/leo/ship_refresh_cdn/trigger $DASH_D '{}'"
t block "部署端點(沒有名字、沒有 /trigger" \
"curl -X POST $NAMED $DASH_D @workflow.json"
t block "通知混著一筆真的寫入 ⇒ 整條照擋(白名單要每一個都符合)" \
"curl -X POST $NAMED/leo/notify_leo/trigger $DASH_D '{}' && curl -X POST $EXE/entries $DASH_D '{}'"
t block "名字只是前綴相同(notify_leo_deploy)不算通知" \
"curl -X POST $NAMED/leo/notify_leo_deploy/trigger $DASH_D '{}'"
echo "── 卡點二:談論它(內文引用)不是執行它 ──"
t pass "把卡點貼回票上,內文引用了實例網址與寫入旗標(本次實撞)" \
"B=\$(cat <<'EOF'
閘擋下來了:\`curl -X POST $NAMED\` 被當成部署。
EOF
); curl -X POST https://git.uncle6.me/api/v1/repos/inkstone/ISEP/issues/63/comments $DASH_D \"\$B\""
t block "真的在部署、只是把 payload 放進內文 ⇒ 起始行仍看得到,照擋" \
"curl -X POST $NAMED $DASH_D @- <<'EOF'
{\"name\":\"x\"}
EOF"
echo
echo "結果:通過 $PASS 失敗 $FAIL"
[ $FAIL -eq 0 ] || exit 1
+254
View File
@@ -0,0 +1,254 @@
#!/usr/bin/env bash
# search-is-not-proof-guard.sh kbdb-evidence-stamp.sh 的迴歸測試
# inkstone/ISEP#30 → comment 4879
#
# **離線、不打網路、不碰真庫、每次結果一樣。** 兩支都是純結構判斷,沒有語意判官。
#
# 🔴 這支測試的重點在 A 群,不在 B 群。
# ISEP 的第一級缺陷是**誤攔**——leo 2026-08-17 實證:在文字層封路的閘
# 8 次誤攔、0 次正確攔截。所以「該擋有擋到」只是及格,
# **「不該擋的一個都沒擋」才是這支閘能不能活下來的條件。**
#
# 用法:hooks/tests/search-is-not-proof-guard.test.sh [guard 路徑] [stamp 路徑]
set -u
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
HOOKS="$(cd "$HERE/.." && pwd)"
GUARD="${1:-$HOOKS/search-is-not-proof-guard.sh}"
STAMP="${2:-$HOOKS/kbdb-evidence-stamp.sh}"
SID="S-SINP-TEST"
SEARCH="/tmp/.kbdb-evidence-search-$SID"
WALKED="/tmp/.kbdb-evidence-walked-$SID"
WARNED="/tmp/.search-is-not-proof-warned-$SID"
TMPD="$(mktemp -d)"
trap 'rm -rf "$TMPD"; rm -f "$SEARCH" "$WALKED" "$WARNED"' EXIT
PASS=0; FAIL=0; N=0
# 真跡:Arcrun#167 comment 4865 裡真的被貼出去的那兩個值
REAL_ID="e_b77f23a1-b467-4aa4-883c-58a051ab8e66"
REAL_SRC="kb://小果被AFTEE詐貸.pdf#8"
clean() { rm -f "$SEARCH" "$WALKED" "$WARNED"; }
# seen <search|walked> <值...> —— 直接寫登記檔(模擬 stamp 已經跑過)
seen() {
f="/tmp/.kbdb-evidence-$1-$SID"; shift
for v in "$@"; do printf '%s\n' "$v" >> "$f"; done
}
# bash_payload <command>
bash_payload() {
python3 - "$SID" "$1" <<'PY'
import json, sys
print(json.dumps({"session_id": sys.argv[1], "hook_event_name": "PreToolUse",
"tool_name": "Bash", "tool_input": {"command": sys.argv[2]}},
ensure_ascii=False))
PY
}
# agent_payload <prompt>
agent_payload() {
python3 - "$SID" "$1" <<'PY'
import json, sys
print(json.dumps({"session_id": sys.argv[1], "hook_event_name": "PreToolUse",
"tool_name": "Agent", "tool_input": {"prompt": sys.argv[2]}},
ensure_ascii=False))
PY
}
# post_payload <tool_name> <tool_response JSON 字串> [有沒有 tool_response 欄位:yes|no]
post_payload() {
python3 - "$SID" "$1" "$2" "${3:-yes}" <<'PY'
import json, sys
sid, tool, resp, has = sys.argv[1:5]
d = {"session_id": sid, "hook_event_name": "PostToolUse",
"tool_name": tool, "tool_input": {}}
if has == "yes":
d["tool_response"] = json.loads(resp)
print(json.dumps(d, ensure_ascii=False))
PY
}
t() {
want="$1"; desc="$2"; body="$3"; hook="${4:-$GUARD}"
N=$((N+1))
err=$(printf '%s' "$body" | bash "$hook" 2>&1 >/dev/null); 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' "$err" | sed -n '1,6p' | sed 's/^/ /'
FAIL=$((FAIL+1))
fi
}
echo "── A 群:🔴 不該擋(誤攔是這個 repo 的第一級缺陷)────────────────"
clean
t 0 "① 這個 session 從沒用過 kbdb_search → 完全沒有立場說話" \
"$(bash_payload "scripts/ticket say inkstone/Arcrun#167 -F /tmp/x.md")"
clean; seen search "$REAL_ID" "$REAL_SRC"
t 0 "② 搜過,但只是自己看——沒有任何往外送的動作(git status" \
"$(bash_payload "git status --short")"
clean; seen search "$REAL_ID" "$REAL_SRC"
printf '總管:這一輪的三張票都關了,版本 1.4.59 已上 stage。\n' > "$TMPD/plain.md"
t 0 "③ 搜過,也貼了票,但**沒有引用任何搜尋出來的值**(最常見的正常情況)" \
"$(bash_payload "scripts/ticket say inkstone/ISEP#30 -F $TMPD/plain.md")"
clean; seen search "$REAL_ID"; seen walked "$REAL_ID"
printf '總管:這筆我用 kbdb_get_record 拉過了,id 是 %s\n' "$REAL_ID" > "$TMPD/walked.md"
t 0 "④ 搜完**又走了真路徑**才引用 → 這是對的做法,不准罰" \
"$(bash_payload "scripts/ticket say inkstone/Arcrun#167 -F $TMPD/walked.md")"
clean; seen search "$REAL_ID"
t 0 "⑤ 逃生口:指令裡寫了 search-is-evidence: 的理由" \
"$(bash_payload "scripts/ticket say inkstone/Arcrun#167 --body '$REAL_ID' search-is-evidence: 只是引用一筆資料的編號")"
clean; seen search "$REAL_ID"
t 0 "⑥ 讀那支閘的原始碼(值只是出現在指令裡,不是往外送)" \
"$(bash_payload "cat hooks/search-is-not-proof-guard.sh | grep $REAL_ID")"
clean; seen search "$REAL_ID"
t 0 "⑦ 撈票/搜票這些讀取動作,不是往外送" \
"$(bash_payload "curl -s -H 'Authorization: token X' 'https://git.uncle6.me/api/v1/repos/inkstone/Arcrun/issues/167'")"
clean; seen search "$REAL_SRC"
t 0 "⑧ 只在 wiki 裡查東西、然後改自己 repo 的檔(Write 不歸這支管)" \
"$(bash_payload "sed -i '' 's/a/b/' system-dev/wiki/status.md")"
clean; seen search "$REAL_ID"
printf '【工單】inkstone/ISEP#30\n' > "$TMPD/dispatch-ok.md"
t 0 "⑨ 合規派工單(只有票號,沒有引用搜尋值)" \
"$(agent_payload "$(cat "$TMPD/dispatch-ok.md")")"
clean; seen search "$REAL_ID"
t 2 "⑩ 第一次命中 → 擋(這一發是下一條的前置)" \
"$(bash_payload "scripts/ticket say inkstone/Arcrun#167 --body '$REAL_ID'")"
t 0 "⑩' 同一 session 第二次同樣的動作 → 放行,不鬼打牆" \
"$(bash_payload "scripts/ticket say inkstone/Arcrun#167 --body '$REAL_ID'")"
echo "── B 群:該擋(真跡重演)──────────────────────────────────────"
clean; seen search "$REAL_ID" "$REAL_SRC"
cat > "$TMPD/4865.md" <<EOF
# 🔴【總管複驗・退回】兩個缺陷都還在——我用 MCP 實打的
\`\`\`
\$ kbdb_search(q="小果 AFTEE 詐貸") → count 5
\`\`\`
\`\`\`json
{
"id": "$REAL_ID",
"content": "### 出處\n- \`../小果被AFTEE詐貸.pdf\` >> 提及 >> 小果被AFTEE詐貸"
}
\`\`\`
"metadata_json": "{\"source\":\"$REAL_SRC\",\"library\":\"youlinhsieh-test1\"}"
EOF
t 2 "⑪ 真跡重演:Arcrun#167 comment 4865 那則退回,原文照貼" \
"$(bash_payload "scripts/ticket say inkstone/Arcrun#167 -F $TMPD/4865.md")"
clean; seen search "$REAL_ID"
t 2 "⑫ 同一件事走派工單(重派一條線)" \
"$(agent_payload "【工單】inkstone/Arcrun#167
上一版沒修好,實測 $REAL_ID 那塊的出處還是 ../ 開頭")"
clean; seen search "$REAL_SRC"
t 2 "⑬ 只貼了 kb:// 來源位址(沒貼 id)也認得出來" \
"$(bash_payload "scripts/ticket close inkstone/Arcrun#167 --deliverable '$REAL_SRC'")"
clean; seen search "$REAL_ID"
t 2 "⑭ 直接打 Gitea API 貼留言(繞開 scripts/ticket" \
"$(bash_payload "curl -X POST -d '{\"body\":\"$REAL_ID 還是壞的\"}' 'https://git.uncle6.me/api/v1/repos/inkstone/Arcrun/issues/167/comments'")"
clean; seen search "$REAL_ID"; seen walked "e_00000000-0000-0000-0000-000000000000"
t 2 "⑮ 走過真路徑,但走的是**別筆**資料 → 這一筆仍然只有搜尋當來源" \
"$(bash_payload "scripts/ticket say inkstone/Arcrun#167 --body '$REAL_ID'")"
echo "── C 群:訊息本身講不講得出出路 ──────────────────────────────"
clean; seen search "$REAL_ID"
MSG=$(printf '%s' "$(bash_payload "scripts/ticket say inkstone/Arcrun#167 --body '$REAL_ID'")" | bash "$GUARD" 2>&1 >/dev/null)
for want in "D81" "kbdb_graph_neighbors" "search-is-evidence:" "4877" "同一條嗎"; do
N=$((N+1))
if printf '%s' "$MSG" | grep -q -- "$want"; then
printf ' ✅ 訊息裡有「%s」\n' "$want"; PASS=$((PASS+1))
else
printf ' ❌ 訊息裡找不到「%s」\n' "$want"; FAIL=$((FAIL+1))
fi
done
N=$((N+1))
if printf '%s' "$MSG" | grep -q '`kbdb_get_record`'; then
printf ' ✅ 反引號沒有被 shell 吃掉(heredoc 有跳脫)\n'; PASS=$((PASS+1))
else
printf ' ❌ 反引號被 shell 展開了\n'; FAIL=$((FAIL+1))
fi
echo "── D 群:登記處(stamp)──────────────────────────────────────"
clean
RESP=$(python3 -c "
import json
print(json.dumps({'ok':True,'data':{'entries':[{'id':'$REAL_ID','metadata_json':'{\"source\":\"$REAL_SRC\"}'}]}}, ensure_ascii=False))
")
t 0 "⑯ kbdb_search 的回應 → 登記進 search 檔" \
"$(post_payload "mcp__abc__kbdb_search" "$RESP")" "$STAMP"
N=$((N+1))
if grep -q "$REAL_ID" "$SEARCH" 2>/dev/null && grep -q "$REAL_SRC" "$SEARCH" 2>/dev/null; then
printf ' ✅ 兩類指紋(entry id kb:// 位址)都抓到了\n'; PASS=$((PASS+1))
else
printf ' ❌ 指紋沒抓到:%s\n' "$(cat "$SEARCH" 2>/dev/null)"; FAIL=$((FAIL+1))
fi
clean
# 🔴 MCP 的回應**不保證**是拆好的物件——常見的是包成 content blocks
# 整份 JSON 縮成一個字串塞在 text 裡(連引號都被跳脫過)。
# stamp 對兩種包裝都要認得,否則它在真的環境裡會靜默失效。
INNER=$(python3 -c "
import json
print(json.dumps({'ok':True,'data':{'entries':[{'id':'$REAL_ID','metadata_json':'{\"source\":\"$REAL_SRC\"}'}]}}, ensure_ascii=False))
")
WRAPPED=$(python3 -c "
import json,sys
print(json.dumps({'content':[{'type':'text','text':sys.argv[1]}]}, ensure_ascii=False))
" "$INNER")
t 0 "⑯' 同一份回應包成 MCP content blocks(字串裡的 JSON)→ 一樣認得出來" \
"$(post_payload "mcp__abc__kbdb_search" "$WRAPPED")" "$STAMP"
N=$((N+1))
if grep -q "$REAL_ID" "$SEARCH" 2>/dev/null && grep -q "$REAL_SRC" "$SEARCH" 2>/dev/null; then
printf ' ✅ content blocks 包裝下兩類指紋都抓到了\n'; PASS=$((PASS+1))
else
printf ' ❌ 被包裝擋住了:%s\n' "$(cat "$SEARCH" 2>/dev/null)"; FAIL=$((FAIL+1))
fi
clean
t 0 "⑰ kbdb_get_record 的回應 → 登記進 walked 檔" \
"$(post_payload "mcp__abc__kbdb_get_record" "$RESP")" "$STAMP"
N=$((N+1))
if grep -q "$REAL_ID" "$WALKED" 2>/dev/null && [ ! -s "$SEARCH" ]; then
printf ' ✅ 走過真路徑的記在 walked,沒有污染 search\n'; PASS=$((PASS+1))
else
printf ' ❌ 記錯檔了\n'; FAIL=$((FAIL+1))
fi
clean
t 0 "⑱ 不是 kbdb 的工具 → 什麼都不做" \
"$(post_payload "Bash" "$RESP")" "$STAMP"
clean
t 0 "⑲ 搜尋 0 筆(回應讀得到但沒有指紋)→ 正常,不出聲" \
"$(post_payload "mcp__abc__kbdb_search" '{"ok":true,"data":{"entries":[],"count":0}}')" "$STAMP"
clean
t 2 "⑳ fail-closedpayload 裡沒有 tool_response 欄位 → 出聲說自己失效了" \
"$(post_payload "mcp__abc__kbdb_search" "$RESP" "no")" "$STAMP"
clean
echo
printf '共 %d 條:✅ %d ❌ %d\n' "$N" "$PASS" "$FAIL"
[ "$FAIL" -eq 0 ] || exit 1
+16
View File
@@ -0,0 +1,16 @@
# `pending-main-push/` — 被擋下的推 main 請求(本機狀態,不進版控)
`hooks/main-and-prod-push-guard.sh` 擋下一次推 main 的同時,會把那次請求寫成
`<誰>--<哪個 repo>.md` 丟進這個目錄:repo/分支/逐筆 commit/改了哪些檔,**原始資料**。
> leo 2026-08-12:「**它會問你的意見,所以每個你叫起來的 subagent 都有名字。**」
> 閘做不到「同步問總管」(不同行程),做得到的是「**當場擋 + 留下是誰要求的**」。
## 規約
- **一份檔案 = 一筆還沒裁的請求。** 總管裁完就刪掉它——留著代表還沒裁。
- **這個目錄的內容不進版控**`.gitignore` 有一段寫了為什麼),只有本檔在版控裡。
- **測試不准在這裡留東西。** 推 main 的測資本來就都會觸發這支閘,
所以那幾支測試改成跑 `hooks/tests/lib/hook-sandbox.sh` 建的複本,請求寫進暫存區;
每支測完都會驗兩件:這裡沒被碰過、而且沙盒裡**真的有**留下請求
(只驗前者的話,「把留紀錄的功能關掉」也會綠——那是假綠)。
-43
View File
@@ -1,43 +0,0 @@
# 推 main 的請求:未署名
- repo/Users/youlinhsieh/Documents/tech_projects/ISEP
- 分支:fix/push-guard-target-not-substring
- 時間:2026-08-21 01:28:51
- 它想跑的指令:
```
git push -q origin master
```
## 還沒推上去的 commit(原始資料,不是轉述)
```
43c328d Merge pull request 'feat/milestone-must-have-due' (#50) from feat/milestone-must-have-due into main
bcb736e Merge pull request 'fix/worksheet-dedup-by-content' (#49) from fix/worksheet-dedup-by-content into main
6772ca6 每個里程碑都要有真的期限,9999 也擋
1b55512 待驗工作單改用宣稱內容去重,驗過的不再冒出來
1920d4c Merge pull request '身為 leo,我要雲端 clone 下來就有閘,我才不用先處理憑證' (#48) from fix/b4-real-probe into main
47ed778 改走「直接複製進薄殼 repo」,並修掉一支會偷跑指令的閘
03d9782 查了官方文件才發現:setup script 根本讀不到環境變數,而 exit 1 會鎖死 session
36d8e05 認證驗證加 timeout:掛住比失敗更糟
1356372 B4 的探針我自己沒撞過,實撞後發現它根本不會擋
d306158 Merge pull request '身為 leo,我要雲端 env 檔能直接產在桌面,我才不用去翻隱藏目錄' (#46) from feat/cloud-env-outdir into main
9099c3f make-cloud-env.sh:輸出路徑可指定,且不再對使用者指定的目錄動權限
5bceb03 v0.3.1
4e73b8b Merge pull request '身為 leo,我要雲端驗收步驟在閘死掉時真的變紅,我才不會再被三個綠燈騙一次' (#45) from fix/testing-b-section-discriminating into main
291787e TESTING.md B 段整段換掉——舊版在閘全滅時會回綠
3a95121 雲端零閘的兩個真因:setup 不自驗+沒有 release 撐版本號 (#44)
daa1674 雲端零閘的兩個真因:setup 從不驗證自己+沒有任何 release 撐版本號
c48495d Merge pull request 'fix(hooks): sdd-guard.sh 修「解析失敗仍照擋、且訊息洩漏 /nonexistent」' (#42) from fix/sdd-guard-path-resolution into main
8718658 fix(hooks): sdd-guard.sh 修「解析失敗仍照擋、且訊息洩漏 /nonexistent」(InkStoneCo#22
e6d183d Merge pull request '產生雲端 env 設定給 leo 貼(InkStoneCo#14' (#41) from feat/cloud-env-generator into main
f855d82 產生雲端 env 設定,不要 leo 自己拼湊(InkStoneCo#14
```
## 改了哪些檔
```
```
---
總管裁完請刪掉這個檔——留著代表「還沒裁」。
-23
View File
@@ -1,23 +0,0 @@
# 推 main 的請求:未署名
- repo/Users/youlinhsieh/Documents/tech_projects/InkStoneCo
- 分支:main
- 時間:2026-08-21 01:28:23
- 它想跑的指令:
```
git push -q origin master
```
## 還沒推上去的 commit(原始資料,不是轉述)
```
```
## 改了哪些檔
```
```
---
總管裁完請刪掉這個檔——留著代表「還沒裁」。
+69
View File
@@ -0,0 +1,69 @@
#!/usr/bin/env bash
# countdown-milestone-refresh.sh — 把「主線 milestone 的期限」寫進倒數用的快取
# inkstone/ISEP#63 的第二個基準)
#
# 為什麼是一支獨立腳本、而不是寫在 hook 裡:
# 倒數那一行走在 **UserPromptSubmit** 上,也就是每一則訊息的關鍵路徑。
# 在那裡打 HTTP = leo 每講一句話都先等一次網路。
# ⇒ hook 只讀快取(`hooks/lib/countdown.py` 從不打網路),
# 快取由本支在 **SessionStart** 更新一次。不是輪詢、不掛排程(守 D14 紅線)。
#
# 寫出來的檔:`$ISEP_COUNTDOWN_STATE_DIR/milestone-due`,一行 `YYYY-MM-DD|<名稱>`。
# 拿不到 token/拿不到期限 ⇒ **不寫、不報錯、不留舊資料以外的東西**:
# 倒數那一行寧可少一段,也不要顯示一個編出來的日期。
set -uo pipefail
HOST="${TICKET_HOST:-https://git.uncle6.me}"
TOKEN="${GITEA_TOKEN_CLAUDE_CODE:-${GITEA_TOKEN:-}}"
REPOS="${ISEP_COUNTDOWN_REPOS:-inkstone/InkStoneCo inkstone/ISEP}"
DIR="${ISEP_COUNTDOWN_STATE_DIR:-$HOME/.claude/isep-countdown}"
mkdir -p "$DIR" 2>/dev/null || exit 0
OUT="$DIR/milestone-due"
[ -n "$TOKEN" ] || exit 0
RAW=""
for repo in $REPOS; do
body=$(curl -s --max-time 4 -H "Authorization: token $TOKEN" \
"$HOST/api/v1/repos/$repo/milestones?state=open" 2>/dev/null) || continue
RAW="$RAW$body"$'\n'
done
printf '%s' "$RAW" | ISEP_OUT="$OUT" python3 -c '
import json, os, sys
from datetime import datetime, timezone
best = None
for chunk in sys.stdin.read().split("\n"):
chunk = chunk.strip()
if not chunk:
continue
try:
rows = json.loads(chunk)
except Exception:
continue
if not isinstance(rows, list):
continue
for m in rows:
due = (m or {}).get("due_on") or ""
if not due:
continue # 沒設期限的 milestone 不是節拍器
try:
dt = datetime.fromisoformat(due.replace("Z", "+00:00"))
except Exception:
continue
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
# 最近的那一個=現在真的在追的那一個
if best is None or dt < best[0]:
best = (dt, (m or {}).get("title") or "主線")
if best is None:
raise SystemExit(0)
dt, title = best
with open(os.environ["ISEP_OUT"], "w") as f:
f.write("%s|%s\n" % (dt.astimezone().strftime("%Y-%m-%d"), title))
' 2>/dev/null || true
exit 0
+17 -1
View File
@@ -4,7 +4,19 @@
# 開頭包一層讀取指令)都不准擋;推自己的分支、含子字串但目標不是 main/master
# 的分支(如 fix/custom-domain-setup)也不准擋。
cd "$(dirname "$0")/.." || exit 1
H=hooks/main-and-prod-push-guard.sh
# 🔴 測沙盒裡的複本,不測真跡(inkstone/ISEP#59 comment 4779 第 ① 條):
# 這支閘擋下推 main 的同時會把請求寫進 `<repo>/pending-main-push/`,那份檔案的
# 語意是「有一筆推 main 正在等總管裁」。這裡的測資本來就都是推 main
# ⇒ 直接測真跡的話,**每跑一次測試就偽造一筆待裁決**(實測:`unnamed--ISEP.md`
# 每跑一次被覆寫一次,而它當時還是被追蹤的 ⇒ `git status` 永遠是髒的)。
# cwd 仍然留在 repo 根——戳記比對問的是「人站在哪個 repo」,那件事沒有變。
. hooks/tests/lib/hook-sandbox.sh
hook_sandbox "$PWD/hooks/main-and-prod-push-guard.sh" || { echo "❌ 沙盒建不起來"; exit 1; }
trap 'hook_sandbox_cleanup' EXIT
HOSTSUM_BEFORE=$(hook_sandbox_hostsum)
H=$HOOK_SANDBOX_HOOK
PASS=0; FAIL=0
run(){ # $1=want $2=cmd
printf '%s' "{\"tool_name\":\"Bash\",\"tool_input\":{\"command\":$(python3 -c 'import json,sys;print(json.dumps(sys.argv[1]))' "$2")}}" \
@@ -31,6 +43,10 @@ run 0 'grep -n "git push" hooks/main-and-prod-push-guard.sh'
echo "── 真違規不能因為這次改動而漏擋:戳記過期/戳記綁錯 repo 一樣要擋 ──"
run 2 'git push gitea HEAD:main' # 沒有任何戳記檔時
echo "── 測試自己不准弄髒工作區(inkstone/ISEP#59)──"
hook_sandbox_assert "$HOSTSUM_BEFORE"
PASS=$((PASS+HS_PASS)); FAIL=$((FAIL+HS_FAIL))
echo
echo "$PASS/$((PASS+FAIL)) 通過"
[ "$FAIL" -eq 0 ]
+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 票,你卻要開新的。
命中的前幾張: