Compare commits

...

13 Commits

Author SHA1 Message Date
claude-code eb7c3656be PR 一定要有結論(inkstone/ISEP#81)(scripts/pr-verdict) 2026-08-28 00:27:42 +00:00
claude-code 16c03e46c1 PR 一定要有結論(inkstone/ISEP#81)(hooks/tests/pr-verdict-guard.test.sh) 2026-08-28 00:27:40 +00:00
claude-code 99abdf725b PR 一定要有結論(inkstone/ISEP#81)(hooks/pr-verdict-guard.sh) 2026-08-28 00:27:39 +00:00
claude-code accedb5898 PR 一定要有結論(inkstone/ISEP#81)(hooks/hooks.json) 2026-08-28 00:27:37 +00:00
claude-code 77a2032858 PR 一定要有結論(inkstone/ISEP#81)(docs/hooks-inventory.md) 2026-08-28 00:27:34 +00:00
claude-code be09353330 PR 一定要有結論(inkstone/ISEP#81)(docs/TESTING.md) 2026-08-28 00:27:32 +00:00
claude-code 81368f451f PR 一定要有結論(inkstone/ISEP#81)(.claude-plugin/plugin.json) 2026-08-28 00:27:30 +00:00
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
19 changed files with 2642 additions and 15 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 環境唯一真相源:49 支機械閘(60 條註冊,白話盤點見 docs/hooks-inventory.md)、7 支 slash command、2 支 skill、27 支腳本,外加治理規範與標籤真相源。本機與雲端裝同一份,沒有子集。",
"version": "0.7.0",
"description": "InkStone Environment Plugin —— leo 的 Claude Code 環境唯一真相源:53 支機械閘(68 條註冊,白話盤點見 docs/hooks-inventory.md)、7 支 slash command、2 支 skill、38 支腳本,外加治理規範與標籤真相源。本機與雲端裝同一份,沒有子集。",
"version": "0.11.0",
"keywords": [
"inkstone",
"guardrails",
+97
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,80 @@ bash scripts/test-baton-handback-guard.sh
- 「不該報」4 條任一紅 ⇒ 每條線收工都被念一次,警報會被學會忽略
- 「票已關」那條紅 ⇒ 棒子已經到終點還在催,那是最典型的假警報
### A14 — PR 一定要有結論:52 條
```
bash hooks/tests/pr-verdict-guard.test.sh
```
**該看到**`52 通過 / 0 失敗`。全程離線(走 `PR_VERDICT_FIXTURE`),
**不打 Gitea、不開測試 PR、不留任何測試票**;狀態檔走 `PR_VERDICT_STATE_DIR`
**它在守什麼**`inkstone/ISEP#81`2026-08-27 實查):最久的三個 open PR 躺了兩星期
`inkstone/arcrun-rag#91` 14 天、`inkstone/Arcrun#116` 14 天、`inkstone/Arcrun#104` 15 天),
而 v0.9.0 的 46 支閘**一支都沒有在管 PR**。票看起來是「已交付」,
東西卻沒進 main ⇒ 沒進版本 ⇒ **leo 手上永遠不會出現它**
**失敗**
- B 群(⑨⑩⑪,票上那三個真跡)任一紅 ⇒ 今天這個形狀會再發生一次
- C 群 ⑭⑰ 紅 ⇒ 驗收條件 1/3 沒過:「放著不管」抓不到,或「merge 了分支還在」放行
- **A 群任何一條紅 ⇒ 誤攔,這比漏擋嚴重**。②③特別重要:
指派給人/掛 Human 之後還在點名,就是 ISEP v0.6.0 divergence §B4 記過的那個坑
(「`s/review` 佇列非空即 block」⇒ 佇列本來就不會空 ⇒ **總管永遠停不下來**
- E 群 ㉙ 紅 ⇒ 閘訊息裡承諾的出路(「把它指派給那個人,本閘立刻不再點名它」)是假的
- D 群 ㉓ 紅 ⇒ 判準從「識別碼比對」滑成「只要提到票號就放行」,閘會被任何一句話關掉
> 📌 **真跡不只在 fixture 裡跑過**:同一支閘打真實 Gitea 也驗過,
> 一次點名 7 個沒有結論的 PR,含票上那三個(15/14/14 天)。
> 指令:`printf '{"session_id":"x","transcript_path":"/nonexistent"}' | bash hooks/pr-verdict-guard.sh`
> ——**唯讀,不改動任何 PR**。
### A15 — 給結論的那支工具自己說得出要打哪幾通 API
```
python3 scripts/pr-verdict list
python3 scripts/pr-verdict merge inkstone/ISEP#71 --dry-run
```
**該看到**`list` 印出 open PR 與「有沒有人被指派」;`merge --dry-run` 印出
`[dry-run] POST …/pulls/71/merge``[dry-run] DELETE …/branches/<分支>`**一通都不真的送出**。
**失敗**`--dry-run` 底下出現真實的寫入結果 ⇒ 這支工具沒辦法在按下去之前被檢查。
### 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 +357,11 @@ 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 PR 一定要有結論** | 總管 | ✅ 52/522026-08-28inkstone/ISEP#81)+真實 Gitea 唯讀重演 |
| **A15 pr-verdict --dry-run** | 總管 | ✅(2026-08-28 |
| **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 |
+21 -3
View File
@@ -1,4 +1,4 @@
# 49 支閘,白話盤點表
# 52 支閘,白話盤點表
> 回應 `inkstone/InkStoneCo#40`:「如果加入了,我應該可以白話文看到 hooks 的內容?」
> 這份表就是那個「白話文」——不用點開任何 `.sh` 檔,一行看懂一支閘在管什麼。
@@ -7,14 +7,16 @@
## 一句話結論
`hooks/` 底下有 **49 個 `.sh` 檔**`hooks.json` 實際掛上 **60 條註冊**(同一支閘常被多種情境同時掛上);
`hooks/` 底下有 **53 個 `.sh` 檔**`hooks.json` 實際掛上 **68 條註冊**(同一支閘常被多種情境同時掛上);
`hooks/` 底下有 **53 個 `.sh` 檔**`hooks.json` 實際掛上 **68 條註冊**(同一支閘常被多種情境同時掛上);
其中 **3 支檔案存在但沒被掛上**(2 支是待人填的空範本、1 支是刻意留著沒開的止血帶,見下面「未生效」表)。
下面按「你會在什麼時候撞到它」分組,一支一行。
> 🔴 **這兩個數字上一版是錯的(2026-08-26 實際數過才發現)**:本頁原本寫「43 個檔、53 條註冊」,
> 而當時真實是 **45 個檔、55 條註冊**——中間有兩支閘進來時沒有回頭改這裡。
> 現在的寫法是實際數出來的:
> `ls hooks/*.sh | wc -l` 49`grep -c '"command":' hooks/hooks.json` 60
> `ls hooks/*.sh | wc -l` 53`grep -c '"command":' hooks/hooks.json` 68
> `ls hooks/*.sh | wc -l` 53`grep -c '"command":' hooks/hooks.json` 68。
> ⚠️ **冒號不能省**`grep -c '"command"'`(沒冒號)會連 `"type": "command"` 一起數到,回 **120**。
> 本頁 2026-08-27 之前寫的是沒冒號那版——**照著它跑會拿到一個跟本頁不符的數字**。
> **一份會偷偷過期的盤點表,跟沒有盤點表差不多**——見本頁最後「怎麼跟實況對帳」那段。
@@ -26,12 +28,23 @@
> 一加一減之後檔數不變、註冊條數落在 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** 條,兩個數字都是加完之後當場數出來的(指令同上)。
>
> 📌 **`0.10.0``inkstone/ISEP#81`2026-08-28)進來一支**`pr-verdict-guard.sh`F 組,Stop)。
> 51→**52** 支、64→**65** 條,兩個數字都是加完之後當場數出來的(指令同上)。
> 順手改掉一個過期的數字:描述欄長期寫「27 支腳本」,實數是 **34**
> `ls -p scripts | grep -v / | wc -l`)——**同一個病,只是換一欄。**
---
@@ -63,6 +76,7 @@
| `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
@@ -125,6 +139,8 @@
| `worklist-guard.sh` | AI 自己列過的待辦清單裡還有沒做完的步驟,卻想收工寫報告,就擋下,逼它做完剩下的步驟。 | 🛑 擋 |
| `factory-idle-guard.sh` | AI 該去派工卻沒派(工頭停工),就擋下要它交出「已經派工的憑證」,不是隨口說一句「我會催」就算數。**過閘有四條路:現在就派工/把票號寫進那句話/寫一行 `⏸ 等:<在等什麼>`/這一輪收尾在動作上。** 2026-08-23inkstone/ISEP#30)修好「引用被當成主張」——貼原始碼、引用它自己的訊息、否認自己有下一步,都不再被咬。 | 🛑 擋 |
| `mainline-idle-guard.sh` | AI **連續 4 個回合一直在查、一直在讀,卻什麼都沒交出去**(沒派工、沒改檔、沒寫回票)就擋一次。補的是上面兩支中間那一格:「沒動作」空手警察管、「說了下一步卻沒派」稼動率警察管,**而「有動作、但就是不說下一步」以前兩支都放行**。這支**一個字都不讀**,只看有沒有派工/產出/工作區變動。響過一次門檻就加倍(4→8→16),不會變成一直在響的警報。 | 🛑 擋(響過就退讓) |
| `pr-verdict-guard.sh` | 收工時清點「還有哪些 PR 沒有結論」——open、**沒有人被指派**、也沒有「要求修改」review 的 PR,就擋一次並**點名是哪幾個**(含開了幾天與網址);已經 merge 但**分支還留著**的也一起點名(票上寫死「併完當場刪掉那條 branch」)。背景:2026-08-27 實查,最久的三個 open PR 躺了兩星期,而當時 46 支閘沒有一支在管 PR——票看起來「已交付」,東西卻沒進 main、沒進版本,**leo 手上永遠不會出現它**。🔴 **擋的是遺忘,不是等待**:指派給誰(或掛 Human)就立刻不再點名它;響過門檻就加倍(1→4→8→16,每個 PR 各自算)。搭配 `scripts/pr-verdict`mergerejectchanges 各一個動作)。 | 🛑 擋(響過就退讓) |
| `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 只是把「要不要」改寫成「不確定是否符合期待」就閃過上一支閘。 | 🛑 擋 |
@@ -151,6 +167,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
@@ -263,6 +280,7 @@ A1 缺人話、A2 表格有幽靈、C 全頁有幽靈。
| `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 裡指的是「同一種派工工具呼叫」,
+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"
}
]
}
@@ -237,6 +253,10 @@
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/mainline-idle-guard.sh"
},
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/pr-verdict-guard.sh"
}
]
},
@@ -275,6 +295,14 @@
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/unpushed-police.sh"
}
]
},
{
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/countdown-guard.sh"
}
]
}
],
"SubagentStop": [
@@ -325,6 +353,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": [
@@ -342,6 +379,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
+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 ""))
+394
View File
@@ -0,0 +1,394 @@
#!/usr/bin/env bash
# 管什麼: 收工那一刻清點「我這邊還有沒有 PR 沒給結論」——open 且沒人在管的 PR、
# 以及已經 merge 但**分支還留著**的 PR。有就擋一次並點名是哪幾個。
# 為什麼: 2026-08-27 實查,最久的三個 open PR 躺了兩星期(inkstone/arcrun-rag#91、
# inkstone/Arcrun#116、inkstone/Arcrun#104),而 v0.9.0 的 46 支閘沒有一支在管 PR。
# 票看起來「已交付」,但東西沒進 main ⇒ 沒進版本 ⇒ leo 手上永遠不會出現它。
# 誤觸時怎麼關: 直接再送一次即可(擋完就歸零,而且那幾個 PR 的門檻已加倍)。
# 真的在等別人 ⇒ 把 PR 指派給他(或掛 Human),本閘立刻不再點名它。
#
# pr-verdict-guard.sh — PR 收斂警察(Stopinkstone/ISEP#81
#
# ── 為什麼是 Stop 而不是 PreToolUse ──────────────────────────────────
# PR 堆積不是「某個動作做錯了」,是「某個動作從來沒發生」。
# 沒有發生的事情攔不到,只能在收工那一刻清點。同 worklist-guard.sh 的形狀。
# **刻意只掛 Stop,不掛 SubagentStop**:給 PR 結論是總管的事,
# subagent 手上沒有那個權(它連 main 都不准推)⇒ 掛上去只會變成對它的誤攔。
#
# ── 這支跟 baton-handback-guard.sh 是接力,不是重複 ────────────────
# baton 管「棒子有沒有交回來」(票的指派+tag+下一步三格)。
# 本支管「交回來之後,總管有沒有動它」——PR 是那根棒子的實體。
#
# ── 🔴 已知的坑:這支很容易長成「讓總管永遠停不下來」的那一支 ──────
# ISEP v0.6.0 的 divergence 文件 §B4 記過同款:
# 「`s/review` 佇列非空即 block 總管 stop」⇒ 佇列本來就不會空 ⇒ 永遠擋。
# ⇒ **要擋的是遺忘,不是等待。** 本閘用三層把「等待」全部放掉:
#
# ① 有人被指派(或掛 Human)⇒ 不點名。
# 指派是 Gitea 原生欄位、也是本 repo 既有的「現在誰該動」真相
# (見 baton-handback-guard 檔頭那張三格表)。棒子在誰手上是**事實**,
# 不是措辭 ⇒ 有人拿著 = 在等,不是忘了。
# ② 有一則 REQUEST_CHANGES 的 review ⇒ 不點名。
# 「要求修改」這個結論已經給了,球在對方腳下。
# ③ 這一回合碰過它 ⇒ 不點名(見下面「怎麼算碰過」)。
# ④ 點名過就退讓:那個 PR 的門檻 1 → 4 → 8 → 16…(每 PR 各自算)。
# ⇒ 不可能鎖死:擋完歸零,再送一次就過;真的長期不理才會再響。
#
# ── 怎麼算「碰過」:不讀任何一句話 ──────────────────────────────
# ① `updated_at` 跟上一次收工比變了 ⇒ 碰過(留言/改標籤/推 commitsubmit review
# 全部會動它)。這是**外部系統的事實**,跟我在對話裡怎麼寫無關。
# ② 上一次收工時還不存在的 PR ⇒ 剛開出來 ⇒ 碰過(不在它誕生的那一回合就開罵)。
# ③ 這一回合的 tool call 裡出現它的**識別碼**`owner/repo#N``/pulls/N`/它的網址)
# ⇒ 碰過。這一條是為了接住「結論寫在票上」——票被改了、PR 沒被改,
# ①②都看不到,但那正是本票規定的其中一種合法結論。
#
# 🔴 ③ 不是關鍵字黑名單(leo 2026-08-17 已證偽那條路:當日 8 次誤攔、0 次正確攔截)。
# 差別在方向與集合:黑名單拿**措辭**來**擋**(措辭無限,永遠追不完);
# 這裡拿**識別碼**來**放行**(識別碼是有限且唯一的,而且比對錯了只會少擋一次)。
# 本 repo 心法:只准往放行的方向做字面比對。
#
# ── merge 了但分支還在 = 結論只給了一半 ────────────────────────
# 票上寫死:「merge → 併完當場刪掉那條 branch」。所以某個 PR 從 open 清單消失時,
# 本閘去看它是不是 merged、head 分支還在不在;還在就一起點名。
# (只在「上次還 open、這次不見了」時查,平常一通 API 都不多打。)
#
# ── 內部錯誤不准靜默放行 ────────────────────────────────────────
# Stop 閘 fail-closed 會鎖死 session,而誤攔是本 repo 的第一級缺陷
# ⇒ 內部錯誤走 exit 0,但留痕(log stderr 各一行)。同 mainline-idle-guard.sh。
#
# 迴歸測試:hooks/tests/pr-verdict-guard.test.sh(離線,走 PR_VERDICT_FIXTURE
set -u
PROJ="${CLAUDE_PROJECT_DIR:-$(pwd)}"
PAYLOAD=$(cat 2>/dev/null || echo '{}')
# ── token:同 scripts/ticket 的做法(掃 remote 找帶憑證的,再退到環境變數)──
TOKEN=""
if [ -z "${PR_VERDICT_FIXTURE:-}" ]; then
for d in "$PROJ" "$PROJ/products"/* "$PROJ/matrix"/* "$PROJ/polaris"/*; do
[ -e "$d/.git" ] || continue
U=$(git -C "$d" remote -v 2>/dev/null | grep -m1 'git\.uncle6\.me' | grep '@' | awk '{print $2}') || true
if [ -n "${U:-}" ]; then
T=$(printf '%s' "$U" | sed -E 's|.*//[^:]+:([^@]+)@.*|\1|')
[ "$T" != "$U" ] && { TOKEN="$T"; break; }
fi
done
[ -n "$TOKEN" ] || TOKEN="${GITEA_TOKEN_CLAUDE_CODE:-${GITEA_TOKEN:-}}"
fi
# PR_VERDICT_DEBUG=1 時把內部錯誤原文放出來(平常吞掉,避免雜訊蓋掉閘的訊息)
ERRSINK=/dev/null
[ -n "${PR_VERDICT_DEBUG:-}" ] && ERRSINK=/dev/stderr
# 🔴 payload 走環境變數,不走 stdin——`python3 - <<PY` 的 stdin **就是腳本本身**
# 再從 sys.stdin 讀就會讀到自己的原始碼(第一版實撞,回 CRASH:bad-payload)。
VERDICT=$(GITEA_TOKEN_RESOLVED="$TOKEN" PR_VERDICT_PAYLOAD="$PAYLOAD" python3 - <<'PY' 2>"$ERRSINK"
import json, os, re, sys, time
import urllib.error, urllib.parse, urllib.request
HOST = os.environ.get("PR_VERDICT_HOST", "https://git.uncle6.me")
ORGS = [o.strip().lower() for o in
os.environ.get("PR_VERDICT_ORGS", "inkstone").split(",") if o.strip()]
FIXTURE = os.environ.get("PR_VERDICT_FIXTURE", "")
TOKEN = os.environ.get("GITEA_TOKEN_RESOLVED", "")
def out(s):
sys.stdout.write(s)
raise SystemExit
try:
d = json.loads(os.environ.get("PR_VERDICT_PAYLOAD") or "{}")
except Exception:
out("CRASH:bad-payload")
# 已被別的 Stop 閘擋過這一輪 ⇒ 不計數、不擋(避免疊擋,同 mainline-idle-guard
if d.get("stop_hook_active"):
out("SKIP:already-nudged")
sid = re.sub(r"[^A-Za-z0-9_.-]", "_", str(d.get("session_id") or "nosid"))[:64]
state_path = os.path.join(os.environ.get("PR_VERDICT_STATE_DIR", "/tmp"),
".pr-verdict-%s.json" % sid)
# ── 資料來源:正式打 Gitea,測試走 fixture(單一 JSON 檔或目錄下的 state.json)──
FX = None
if FIXTURE:
p = FIXTURE if os.path.isfile(FIXTURE) else os.path.join(FIXTURE, "state.json")
try:
with open(p) as f:
FX = json.load(f)
except Exception:
out("CRASH:bad-fixture")
def api(path, soft=True):
"""GET 一筆。fixture 模式完全不碰網路。失敗回 None(不影響該不該擋的核心判斷)。"""
if FX is not None:
return None
if not TOKEN:
return None
try:
req = urllib.request.Request(HOST + "/api/v1" + path,
headers={"Authorization": "token " + TOKEN})
return json.load(urllib.request.urlopen(req, timeout=12))
except urllib.error.HTTPError as e:
return {"__status__": e.code}
except Exception:
return None
# ── ① 撈 open PR(一通跨 repo 的呼叫)─────────────────────────────
if FX is not None:
pulls = FX.get("pulls", [])
else:
if not TOKEN:
out("SKIP:no-token")
pulls = api("/repos/issues/search?type=pulls&state=open&limit=50")
if pulls is None or isinstance(pulls, dict):
out("SKIP:api-unreachable")
def ref_of(pr):
return "%s#%s" % (pr.get("repository", {}).get("full_name", "?"), pr.get("number"))
live = {}
for pr in pulls:
full = (pr.get("repository") or {}).get("full_name") or ""
if "/" not in full:
continue
if ORGS and full.split("/", 1)[0].lower() not in ORGS:
continue # 只管我這邊的 org;舊 org 的鏡像不是我的責任
if (pr.get("pull_request") or {}).get("draft"):
continue # draft = 作者自己說「還沒好」,不是等我給結論
live[ref_of(pr)] = pr
# ── ② 讀上一次收工的快照 ─────────────────────────────────────────
st = {}
try:
with open(state_path) as f:
st = json.load(f)
except Exception:
st = {}
seen = st.get("seen") or {}
leftover = st.get("leftover") or {} # ref -> branchmerge 完分支還沒刪的
first_run = not seen and "seen" not in st
# ── ③ 這一回合的 tool call 裡提到了哪些識別碼(只用來放行)──────────
turn_blob = ""
tp = d.get("transcript_path") or ""
if tp and os.path.exists(tp):
try:
rows = []
with open(tp) as f:
for line in f:
line = line.strip()
if line:
try:
rows.append(json.loads(line))
except Exception:
pass
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
chunks = []
for r in rows[start:]:
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":
chunks.append(json.dumps(b.get("input") or {}, ensure_ascii=False))
turn_blob = "\n".join(chunks)
except Exception:
turn_blob = ""
def mentioned(ref):
"""這一回合的 tool call 有沒有指名這個 PR。識別碼比對,只用來放行。"""
if not turn_blob:
return False
full, num = ref.split("#")
owner, repo = full.split("/", 1)
pats = ["%s#%s" % (full, num), "%s#%s" % (repo, num),
"/pulls/%s" % num, "/pull/%s" % num]
return any(p in turn_blob for p in pats)
# ── ④ merge 完分支還在不在(只在「上次還 open、這次不見了」時查)────
def branch_alive(owner, repo, br):
if FX is not None:
return bool((FX.get("branches") or {}).get("%s/%s:%s" % (owner, repo, br)))
r = api("/repos/%s/%s/branches/%s" % (owner, repo, urllib.parse.quote(br, safe="")))
if isinstance(r, dict) and r.get("__status__") == 404:
return False
return isinstance(r, dict) and "name" in r
def pr_detail(ref):
if FX is not None:
return (FX.get("detail") or {}).get(ref)
full, num = ref.split("#")
owner, repo = full.split("/", 1)
r = api("/repos/%s/%s/pulls/%s" % (owner, repo, num))
return r if isinstance(r, dict) and "__status__" not in r else None
for ref in list(seen.keys()):
if ref in live:
continue
det = pr_detail(ref) # 不見了:是被 merge 還是被 close
if not det or not det.get("merged"):
continue
br = ((det.get("head") or {}).get("ref")) or ""
if not br:
continue
full = ref.split("#")[0]
owner, repo = full.split("/", 1)
if branch_alive(owner, repo, br):
leftover[ref] = br
for ref in list(leftover.keys()): # 已經刪掉的就從清單移除
full = ref.split("#")[0]
owner, repo = full.split("/", 1)
if not branch_alive(owner, repo, leftover[ref]):
leftover.pop(ref, None)
# ── ⑤ 逐個 open PR 判「有沒有人在管」──────────────────────────────
def reviews_of(ref):
if FX is not None:
return (FX.get("reviews") or {}).get(ref) or []
full, num = ref.split("#")
owner, repo = full.split("/", 1)
r = api("/repos/%s/%s/pulls/%s/reviews" % (owner, repo, num))
return r if isinstance(r, list) else []
try:
BASE = max(1, int(os.environ.get("PR_VERDICT_THRESHOLD", "1")))
except Exception:
BASE = 1
overdue, newseen = [], {}
for ref, pr in live.items():
prev = seen.get(ref) or {}
rec = {"updated_at": pr.get("updated_at"),
"quiet": int(prev.get("quiet", 0) or 0),
"threshold": int(prev.get("threshold", BASE) or BASE),
"fires": int(prev.get("fires", 0) or 0)}
newseen[ref] = rec
# 有人被指派/掛了 Human = 棒子在某個人手上 ⇒ 在等,不是忘了
if pr.get("assignees"):
rec["quiet"] = 0
continue
if any((l.get("name") or "").lower() == "human" for l in (pr.get("labels") or [])):
rec["quiet"] = 0
continue
# 上一次收工時還不存在 ⇒ 剛開出來,別在它誕生的那一回合就開罵
if not first_run and ref not in seen:
rec["quiet"] = 0
continue
# 這一回合真的動過它(外部事實)/指名過它(識別碼)
if prev.get("updated_at") and pr.get("updated_at") != prev.get("updated_at"):
rec["quiet"] = 0
continue
if mentioned(ref):
rec["quiet"] = 0
continue
rec["quiet"] += 1
if rec["quiet"] < rec["threshold"]:
continue
# 「要求修改」這個結論已經給了 ⇒ 球在對方腳下(只在要開罵時才多打這通)
if any((rv.get("state") or "").upper() == "REQUEST_CHANGES" for rv in reviews_of(ref)):
rec["quiet"] = 0
continue
rec["quiet"] = 0
rec["threshold"] = max(4, rec["threshold"] * 4 if rec["threshold"] == 1
else rec["threshold"] * 2)
rec["fires"] += 1
overdue.append((ref, pr))
try:
with open(state_path, "w") as f:
json.dump({"seen": newseen, "leftover": leftover, "ts": time.time()}, f)
except Exception:
pass
if not overdue and not leftover:
out("OK:clean:%d-open" % len(live))
def age_days(pr):
try:
t = time.mktime(time.strptime(pr.get("created_at", "")[:19], "%Y-%m-%dT%H:%M:%S"))
return max(0, int((time.time() - t) / 86400))
except Exception:
return -1
lines = []
if overdue:
lines.append("【沒有結論的 PR】(open、沒有人被指派、也沒有「要求修改」的 review)")
for ref, pr in sorted(overdue, key=lambda x: -age_days(x[1])):
dd = age_days(pr)
lines.append(" ❌ %-28s 開了 %s天 %s"
% (ref, dd if dd >= 0 else "?", (pr.get("title") or "")[:36]))
lines.append(" %s" % ((pr.get("pull_request") or {}).get("html_url") or ""))
if leftover:
lines.append("【merge 了,但分支還留著】(結論只給了一半)")
for ref, br in sorted(leftover.items()):
lines.append(" ❌ %-28s 分支 %s 還在" % (ref, br))
out("FIRE::" + json.dumps({"body": "\n".join(lines),
"n": len(overdue) + len(leftover)}, ensure_ascii=False))
PY
) || VERDICT="CRASH:hook-error"
[ -n "${VERDICT:-}" ] || VERDICT="CRASH:empty"
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/pr-verdict-guard.log" 2>/dev/null || true
fi
}
case "$VERDICT" in
FIRE::*)
BODY=$(printf '%s' "${VERDICT#FIRE::}" | python3 -c 'import json,sys; print(json.load(sys.stdin)["body"])')
N=$(printf '%s' "${VERDICT#FIRE::}" | python3 -c 'import json,sys; print(json.load(sys.stdin)["n"])')
note "⛔ 擋下" "$N 個 PR 沒有結論"
cat >&2 <<MSG
🔀 PR 收斂警察:**還有 ${N} 個 PR 沒有結論,現在不是收工的時候。**
$BODY
【inkstone/ISEP#81】「我交出去的 PR 沒有人去看它⋯⋯票看起來是『已交付』,
但東西沒有進 main,也就沒有進版本,**leo 手上永遠不會出現它**。」
━━ 「有結論」只有三種,缺一不可 ━━━━━━━━━━━━━━━━━━━━━━━━
merge → **併完當場刪掉那條 branch**
scripts/pr-verdict merge <owner/repo#N>
退回 → 理由**寫在票上**(不是寫在訊息裡),然後關掉 PR
scripts/pr-verdict reject <owner/repo#N> --ticket <owner/repo#N> -F <檔>
要求修改 → 修改要求**寫在票上**,並在 PR 上留一則 REQUEST_CHANGES
scripts/pr-verdict changes <owner/repo#N> --ticket <owner/repo#N> -F <檔>
撈一次現況:scripts/pr-verdict list
━━ 如果它本來就在等別人,不是被我忘了 ━━━━━━━━━━━━━━━━━━━━
**把它指派給那個人**(或掛 Human)——本閘立刻不再點名它。
指派是 Gitea 原生欄位,撈一次就看得到「現在誰該動」;
寫在對話裡的「我在等 X」會捲走,欄位不會。
這一條就是本閘跟「s/review 佇列非空即 block」的分界:
**擋的是遺忘,不是等待。**ISEP v0.6.0 divergence §B4 記過那個坑)
━━ 這幾個現在真的不該處理 ━━━━━━━━━━━━━━━━━━━━━━━━━━━
直接再送一次即可,本閘已經歸零,而且這幾個 PR 的門檻已加倍
(1 → 4 → 8 → 16…,每個 PR 各自算)。**不必為了過這道閘去做一件假的動作。**
MSG
exit 2 ;;
CRASH*)
note "⚠️ 內部錯誤" "$VERDICT"
printf '⚠️ pr-verdict-guard 內部錯誤(%s)——本回合未清點 PR,已記入 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
+220
View File
@@ -0,0 +1,220 @@
#!/usr/bin/env bash
# pr-verdict-guard.sh 的迴歸測試(inkstone/ISEP#81
#
# 兩個方向都要有證據:
# A 群「不該擋」——沒有 PR/有人指派/掛 Human/draft/不在 org/剛開出來/
# 這回合動過/這回合指名過/已有 REQUEST_CHANGES/已被別的閘擋過/
# merge 完分支已刪/fixture 壞掉
# B 群「該擋」 ——沒人管的 open PR、放著不管一回合、merge 了但分支還在、
# 以及**票上那三個躺了兩週的真跡**
# C 群「訊息承諾的出路真的走得通」——擋完歸零門檻加倍、指派下去立刻閉嘴
#
# 🔴 全程離線(PR_VERDICT_FIXTURE),不打 Gitea、不留任何測試票/測試 PR。
# 狀態檔走 PR_VERDICT_STATE_DIR,不碰 /tmp 的正式檔。
#
# 用法:hooks/tests/pr-verdict-guard.test.sh [hooks/pr-verdict-guard.sh 的路徑]
set -u
HOOK="${1:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/pr-verdict-guard.sh}"
TMP=$(mktemp -d)
trap 'rm -rf "$TMP"' EXIT
PASS=0; FAIL=0; N=0
NOW=$(date -u +%Y-%m-%dT%H:%M:%SZ)
OLD="2026-08-13T04:34:01Z"
# fixture <檔名> <PR 規格...>
# PR 規格:ref|updated|created|assignee|labels|draft
mkfx() { python3 - "$@" <<'PY'
import json, sys, os
path, specs = sys.argv[1], sys.argv[2:]
extra = {}
if os.path.exists(path):
extra = json.load(open(path))
pulls = []
for s in specs:
ref, upd, crt, asg, lbl, draft = (s.split("|") + [""] * 6)[:6]
full, num = ref.split("#")
pulls.append({
"number": int(num),
"title": "測試 PR " + ref,
"created_at": crt, "updated_at": upd,
"repository": {"full_name": full, "id": 1},
"assignees": [{"login": a} for a in asg.split(",") if a],
"labels": [{"name": n} for n in lbl.split(",") if n],
"pull_request": {"merged": False, "draft": draft == "draft",
"html_url": "https://git.uncle6.me/%s/pulls/%s" % (full, num)},
})
extra["pulls"] = pulls
json.dump(extra, open(path, "w"), ensure_ascii=False)
PY
}
# 加一筆「這個 PR 已經被 merge,head 分支叫 X」的細節,以及分支還在不在
mkdetail() { python3 - "$@" <<'PY'
import json, sys, os
path, ref, branch, alive = sys.argv[1:5]
d = json.load(open(path)) if os.path.exists(path) else {}
full = ref.split("#")[0]
d.setdefault("detail", {})[ref] = {
"merged": True, "head": {"ref": branch, "repo": {"full_name": full}}}
d.setdefault("branches", {})["%s:%s" % (full, branch)] = (alive == "alive")
json.dump(d, open(path, "w"), ensure_ascii=False)
PY
}
mkreview() { python3 - "$@" <<'PY'
import json, sys, os
path, ref, state = sys.argv[1:4]
d = json.load(open(path)) if os.path.exists(path) else {}
d.setdefault("reviews", {})[ref] = [{"state": state}]
json.dump(d, open(path, "w"), ensure_ascii=False)
PY
}
# 造一份「這一回合叫過這些工具、輸入長這樣」的 transcript
mktranscript() { python3 - "$@" <<'PY'
import json, sys
path = sys.argv[1]
with open(path, "w") as f:
f.write(json.dumps({"type": "user",
"message": {"content": [{"type": "text", "text": "繼續"}]}}) + "\n")
blocks = [{"type": "tool_use", "name": "Bash", "id": "t",
"input": {"command": c}} for c in sys.argv[2:]]
f.write(json.dumps({"type": "assistant", "message": {"content": blocks}},
ensure_ascii=False) + "\n")
PY
}
# fire <session> <fixture> <期望 exit> <說明> [transcript] [extra-json] [必須出現的字串]
fire() {
sid="$1"; fx="$2"; want="$3"; desc="$4"; tr="${5:-/nonexistent}"; extra="${6:-}"; must="${7:-}"
N=$((N+1))
payload=$(printf '{"session_id":"%s","transcript_path":"%s"%s}' "$sid" "$tr" "$extra")
out=$(printf '%s' "$payload" | PR_VERDICT_FIXTURE="$fx" PR_VERDICT_STATE_DIR="$TMP" \
CLAUDE_PROJECT_DIR="$TMP" bash "$HOOK" 2>&1 >/dev/null); rc=$?
ok=1
[ "$rc" -eq "$want" ] || ok=0
if [ -n "$must" ] && ! printf '%s' "$out" | grep -qF "$must"; then ok=0; fi
if [ "$ok" -eq 1 ]; then
printf ' ✅ %s\n' "$desc"; PASS=$((PASS+1))
else
printf ' ❌ %s —— 期望 exit=%s%s,實得 exit=%s\n' "$desc" "$want" \
"${must:+ 且訊息含「$must}" "$rc"
printf '%s\n' "$out" | sed -n '1,8p' | sed 's/^/ /'
FAIL=$((FAIL+1))
fi
}
echo "── A 群:不該擋 ────────────────────────────────────────────"
FX="$TMP/empty.json"; mkfx "$FX"
fire s-empty "$FX" 0 "① 一個 open PR 都沒有 ⇒ 放行"
FX="$TMP/assigned.json"; mkfx "$FX" "inkstone/Arcrun#104|$OLD|$OLD|Leo||"
fire s-asg "$FX" 0 "② 有人被指派(棒子在他手上=在等,不是忘了)⇒ 不點名"
FX="$TMP/human.json"; mkfx "$FX" "inkstone/Arcrun#104|$OLD|$OLD||Human,p/high|"
fire s-hum "$FX" 0 "③ 掛了 Human(等 leo 親手做)⇒ 不點名"
FX="$TMP/draft.json"; mkfx "$FX" "inkstone/Arcrun#104|$OLD|$OLD|||draft"
fire s-drf "$FX" 0 "④ draft(作者自己說還沒好)⇒ 不點名"
FX="$TMP/otherorg.json"; mkfx "$FX" "Leo/Arcrun#104|$OLD|$OLD|||"
fire s-org "$FX" 0 "⑤ 不在 inkstone org(舊 org 的鏡像)⇒ 不點名"
FX="$TMP/review.json"; mkfx "$FX" "inkstone/Arcrun#104|$OLD|$OLD|||"
mkreview "$FX" "inkstone/Arcrun#104" "REQUEST_CHANGES"
fire s-rev "$FX" 0 "⑥ 已經留了 REQUEST_CHANGES(結論給過了,球在對方腳下)⇒ 不點名"
FX="$TMP/active.json"; mkfx "$FX" "inkstone/Arcrun#104|$OLD|$OLD|||"
fire s-act "$FX" 0 "⑦ stop_hook_active(已被別的 Stop 閘擋過)⇒ 不疊擋" \
/nonexistent ',"stop_hook_active":true'
fire s-bad "$TMP/does-not-exist.json" 0 "⑧ fixture 讀不到 ⇒ 放行(Stop 閘不准把 session 鎖死)"
echo "── B 群:該擋 ──────────────────────────────────────────────"
# 票上那三個真跡(真實 ref、真實開票日)
FX="$TMP/real.json"
mkfx "$FX" \
"inkstone/arcrun-rag#91|$OLD|$OLD|||" \
"inkstone/Arcrun#116|2026-08-13T05:24:51Z|2026-08-13T05:24:51Z|||" \
"inkstone/Arcrun#104|2026-08-12T08:51:06Z|2026-08-12T08:51:06Z|||"
fire s-real "$FX" 2 "⑨ 真跡重演:三個躺了兩週的 PR ⇒ **擋下**" \
/nonexistent '' 'inkstone/arcrun-rag#91'
FX2="$TMP/real2.json"; cp "$FX" "$FX2"
fire s-real2 "$FX2" 2 "⑩ 同上,訊息要點名 inkstone/Arcrun#116" \
/nonexistent '' 'inkstone/Arcrun#116'
FX3="$TMP/real3.json"; cp "$FX" "$FX3"
fire s-real3 "$FX3" 2 "⑪ 同上,訊息要點名 inkstone/Arcrun#104" \
/nonexistent '' 'inkstone/Arcrun#104'
echo "── C 群:驗收條件 1–3(開著不管 → 擋;merge → 放行;分支要不見)──"
# 驗收 1:一個新 PR 出現(不在它誕生那回合開罵),下一回合放著不管 ⇒ 擋
FX="$TMP/ac.json"; mkfx "$FX"
fire s-ac "$FX" 0 "⑫ 基準回合:目前沒有 PR"
mkfx "$FX" "inkstone/ISEP#999|$NOW|$NOW|||"
fire s-ac "$FX" 0 "⑬ PR 剛開出來(上一回合還不存在)⇒ 不在它誕生的回合開罵"
fire s-ac "$FX" 2 "⑭ **驗收 1**:開著放著不管,收工 ⇒ 擋下,且點名它" \
/nonexistent '' 'inkstone/ISEP#999'
# 驗收 23merge 掉(PR 從 open 清單消失)且分支已刪 ⇒ 放行
mkfx "$FX" # #999 從 open 清單消失
mkdetail "$FX" "inkstone/ISEP#999" "feat/x" "gone"
fire s-ac "$FX" 0 "⑮ **驗收 23**:merge 掉且分支已不存在 ⇒ 放行"
# 驗收 3 的反面:merge 了但分支還在 = 結論只給了一半 ⇒ 擋
FX="$TMP/left.json"; mkfx "$FX" "inkstone/ISEP#998|$NOW|$NOW|||"
fire s-left "$FX" 2 "⑯ 先讓 #998 進到快照(沒人管 ⇒ 擋一次)" \
/nonexistent '' 'inkstone/ISEP#998'
mkfx "$FX" # 消失了
mkdetail "$FX" "inkstone/ISEP#998" "feat/y" "alive"
fire s-left "$FX" 2 "⑰ **驗收 3**merge 了但分支 feat/y 還在 ⇒ 擋下並點名分支" \
/nonexistent '' 'feat/y'
mkfx "$FX"; mkdetail "$FX" "inkstone/ISEP#998" "feat/y" "gone"
fire s-left "$FX" 0 "⑱ 分支刪掉之後 ⇒ 放行(清單會自己清乾淨)"
echo "── D 群:這一回合碰過它就不點名(不讀任何一句話)──────────────"
FX="$TMP/touch.json"; mkfx "$FX" "inkstone/ISEP#997|$OLD|$OLD|||"
fire s-t1 "$FX" 2 "⑲ 沒人管 ⇒ 擋一次(建立基準)"
# updated_at 動了 外部系統的事實(留言/推 commit/改標籤都會動它)
mkfx "$FX" "inkstone/ISEP#997|$NOW|$OLD|||"
for i in 1 2 3 4 5; do
fire s-t1 "$FX" 0 "⑳ updated_at 變了(這回合真的動過它)第 $i 次 ⇒ 不點名"
mkfx "$FX" "inkstone/ISEP#997|$NOW-$i|$OLD|||"
done
FX="$TMP/mention.json"; mkfx "$FX" "inkstone/ISEP#996|$OLD|$OLD|||"
TR="$TMP/tr.jsonl"
mktranscript "$TR" "python3 scripts/ticket say inkstone/ISEP#996 -F /tmp/x.md"
fire s-t2 "$FX" 0 "㉑ 這回合的 tool call 指名了 inkstone/ISEP#996(結論寫在票上)⇒ 不點名" "$TR"
for i in 1 2 3 4; do
fire s-t2 "$FX" 0 "㉒ 同上第 $i 次——指名過就不該累積成擋下" "$TR"
done
mktranscript "$TR" "python3 scripts/ticket say inkstone/ISEP#111 -F /tmp/x.md"
fire s-t2 "$FX" 2 "㉓ 換成指名**別的**票號 ⇒ 這個 PR 仍然沒被碰 ⇒ 擋"
echo "── E 群:響過就退讓(訊息裡承諾的出路真的存在)────────────────"
FX="$TMP/backoff.json"; mkfx "$FX" "inkstone/ISEP#995|$OLD|$OLD|||"
fire s-bo "$FX" 2 "㉔ 第 1 次 ⇒ 擋(門檻 1"
for i in 1 2 3; do
fire s-bo "$FX" 0 "㉕ 擋過之後第 $i 個回合——門檻已加倍(1→4),不該再響"
done
fire s-bo "$FX" 2 "㉖ 第 4 個回合 ⇒ 再擋一次(門檻 4)"
for i in 1 2 3 4 5 6 7; do
fire s-bo "$FX" 0 "㉗ 再擋過之後第 $i 個回合——門檻 4→8,不該響"
done
fire s-bo "$FX" 2 "㉘ 第 8 個回合 ⇒ 再擋(證明退讓是加倍不是無限)"
# 訊息說「把它指派給那個人,本閘立刻不再點名它」——這條出路要真的走得通
mkfx "$FX" "inkstone/ISEP#995|$OLD|$OLD|claude-code||"
for i in 1 2 3 4 5 6 7 8 9; do
fire s-bo "$FX" 0 "㉙ 指派下去之後第 $i 個回合 ⇒ 永遠不再點名(訊息承諾的出路成立)"
done
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
+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
+257
View File
@@ -0,0 +1,257 @@
#!/usr/bin/env python3
"""pr-verdict — 給 PR 一個結論,一個動作把該做的全做完(inkstone/ISEP#81)。
leo 的問題(票上原文):「我交出去的 PR 沒有人去看它⋯⋯票看起來是『已交付』,
但東西沒有進 main,也就沒有進版本,**leo 手上永遠不會出現它**。」
「有結論」只有三種,缺一不可:
pr-verdict merge <owner/repo#N> 併 **當場刪掉那條 branch**
pr-verdict reject <owner/repo#N> --ticket <票> -F <檔> 退回:理由寫**票上** + 關 PR
pr-verdict changes <owner/repo#N> --ticket <票> -F <檔> 要求修改:寫**票上** REQUEST_CHANGES
pr-verdict list 撈一次:誰還沒有結論
🔴 為什麼要有這支、而不是叫人記得多做一步:
「merge 完要刪 branch」「退回理由要寫在票上」都是**第二個動作**,
而所有掉棒的形狀都長一樣——第一個動作做了,第二個沒有。
把兩件事綁成一個指令,就沒有「忘了做第二件」這個狀態可以存在。
🔴 `--dry-run` 會把要打的每一通 API 印出來但不送出。
加在任何子指令上都有效;**收工前想確認自己按下去會發生什麼,用它。**
"""
import json
import os
import re
import subprocess
import sys
import urllib.error
import urllib.parse
import urllib.request
HOST = os.environ.get("PR_VERDICT_HOST", "https://git.uncle6.me")
DRY = False
def die(msg, code=2):
print(msg, file=sys.stderr)
sys.exit(code)
def token():
root = os.environ.get("CLAUDE_PROJECT_DIR") or os.getcwd()
host = HOST.split("//")[-1].rstrip("/")
try:
out = subprocess.run(["git", "-C", root, "remote", "-v"],
capture_output=True, text=True, timeout=20).stdout
except Exception:
out = ""
for line in out.splitlines():
if host in line:
m = re.search(r"//[^:/]+:([^@]+)@", line)
if m:
return m.group(1)
for env in ("GITEA_TOKEN_CLAUDE_CODE", "GITEA_TOKEN"):
if os.environ.get(env):
return os.environ[env]
die(f"🔴 拿不到 {host} 的 tokenremote 沒帶憑證,也沒有 GITEA_TOKEN_CLAUDE_CODE")
def api(path, payload=None, method=None, soft=False):
verb = method or ("POST" if payload is not None else "GET")
if DRY and verb != "GET":
print(f" [dry-run] {verb} {path}"
+ (f"\n {json.dumps(payload, ensure_ascii=False)}" if payload else ""))
return {}
url = path if path.startswith("http") else f"{HOST}/api/v1{path}"
data = json.dumps(payload).encode() if payload is not None else None
req = urllib.request.Request(
url, data=data, method=verb,
headers={"Authorization": f"token {token()}", "Content-Type": "application/json"})
try:
raw = urllib.request.urlopen(req, timeout=40).read()
return json.loads(raw) if raw.strip() else {}
except urllib.error.HTTPError as e:
if soft:
return {"__status__": e.code}
die(f"🔴 Gitea {e.code} on {verb} {path}{e.read().decode()[:300]}")
def parse_ref(s):
m = re.match(r"^([\w.-]+)/([\w.-]+)#(\d+)$", s.strip())
if not m:
die(f"🔴 寫法是 owner/repo#N,你給的是:{s}")
return m.group(1), m.group(2), int(m.group(3))
def read_body(argv, what):
if "-F" not in argv:
die(f"🚫 缺 -F <檔>{what}要**寫在票上**,不是寫在訊息裡。\n"
f" inkstone/ISEP#81:「退回理由寫在票上(寫票,不是寫訊息)」)")
p = argv[argv.index("-F") + 1]
try:
with open(p) as f:
return f.read().strip()
except Exception as e:
die(f"🔴 讀不到 {p}{e}")
def opt(argv, name, default=None):
return argv[argv.index(name) + 1] if name in argv else default
def say_on_ticket(ref, body):
"""把結論貼進**票**(不是貼進 PR 的討論串——PR 會隨自己沉掉,票才是那條線的家)。
內文第一行帶【總管】身份欄,對齊 reply-identity-guard 的規約
leo 2026-08-27:「subagent 回覆時要表明身份」)。
"""
owner, repo, num = parse_ref(ref)
if DRY:
print(f" [dry-run] POST /repos/{owner}/{repo}/issues/{num}/comments")
print(f" {body[:120]}…")
return
api(f"/repos/{owner}/{repo}/issues/{num}/comments", {"body": body})
# ── merge ────────────────────────────────────────────────────────────────
def cmd_merge(argv):
if not argv:
die("用法:pr-verdict merge <owner/repo#N> [--style merge|squash|rebase] [--dry-run]")
owner, repo, num = parse_ref(argv[0])
style = opt(argv, "--style", "merge")
pr = api(f"/repos/{owner}/{repo}/pulls/{num}")
branch = ((pr.get("head") or {}).get("ref")) or ""
same_repo = ((pr.get("head") or {}).get("repo") or {}).get("full_name") == f"{owner}/{repo}"
if not pr.get("merged"):
if pr.get("state") != "open":
die(f"🔴 {owner}/{repo}#{num} 不是 openstate={pr.get('state')})——沒得 merge。")
api(f"/repos/{owner}/{repo}/pulls/{num}/merge",
{"Do": style, "delete_branch_after_merge": True})
if not DRY:
print(f"✅ {owner}/{repo}#{num} 已 merge{style}")
else:
print(f"️ {owner}/{repo}#{num} 本來就是 merged,只補刪分支")
# 🔴 第二件事:**當場複驗分支真的不見了**。
# Gitea 的 delete_branch_after_merge 在某些設定下不會生效(保護分支、
# 跨 fork),而「我下了刪除的指令」不等於「它被刪掉了」——
# 本 repo 心法:交出去之前,我知不知道它能不能用。
if not branch:
print("⚠️ 拿不到 head 分支名,無法複驗——請自己確認分支已刪。")
return
if not same_repo:
print(f"️ head 在別的 repofork),本指令不跨 repo 刪分支:{branch}")
return
if DRY:
print(f" [dry-run] DELETE /repos/{owner}/{repo}/branches/{branch}(若還在)")
return
if _branch_alive(owner, repo, branch):
api(f"/repos/{owner}/{repo}/branches/{urllib.parse.quote(branch, safe='')}",
method="DELETE", soft=True)
if _branch_alive(owner, repo, branch):
die(f"🔴 分支 {branch} 刪不掉(保護分支?權限?)——**結論只給了一半**,"
f"pr-verdict-guard 會繼續點名它。")
print(f"✅ 分支 {branch} 已刪除(複驗過:查不到了)")
def _branch_alive(owner, repo, br):
r = api(f"/repos/{owner}/{repo}/branches/{urllib.parse.quote(br, safe='')}", soft=True)
return isinstance(r, dict) and "name" in r
# ── reject / changes ─────────────────────────────────────────────────────
def _verdict_with_reason(argv, kind):
if not argv:
die(f"用法:pr-verdict {kind} <owner/repo#N> --ticket <owner/repo#N> -F <檔> [--dry-run]")
owner, repo, num = parse_ref(argv[0])
ticket = opt(argv, "--ticket")
if not ticket:
die("🚫 缺 --ticket <owner/repo#N>:結論要寫在**票**上。\n"
" PR 的討論串會隨 PR 一起沉掉;票才是那條線的家。")
body = read_body(argv, "退回理由" if kind == "reject" else "修改要求")
pr = api(f"/repos/{owner}/{repo}/pulls/{num}")
head = f"{owner}/{repo}#{num}"
url = (pr.get("html_url") or "")
title = "退回" if kind == "reject" else "要求修改"
say_on_ticket(ticket, f"【總管】**PR {title}**{head} {url}\n\n{body}")
if not DRY:
print(f"✅ {title}理由已寫進票 {ticket}")
if kind == "reject":
api(f"/repos/{owner}/{repo}/issues/{num}", {"state": "closed"}, method="PATCH")
if not DRY:
print(f"✅ {head} 已關閉")
br = ((pr.get("head") or {}).get("ref")) or ""
same = ((pr.get("head") or {}).get("repo") or {}).get("full_name") == f"{owner}/{repo}"
if br and same and not DRY and _branch_alive(owner, repo, br):
api(f"/repos/{owner}/{repo}/branches/{urllib.parse.quote(br, safe='')}",
method="DELETE", soft=True)
print(f"✅ 分支 {br} 已刪除")
else:
# REQUEST_CHANGES**讓「已經給過結論」變成 Gitea 查得到的事實**,
# 這樣 pr-verdict-guard 就不會再把它當成被遺忘的 PR(=擋等待)。
api(f"/repos/{owner}/{repo}/pulls/{num}/reviews",
{"event": "REQUEST_CHANGES", "body": f"修改要求寫在 {ticket}"})
if not DRY:
print(f"✅ {head} 已留下 REQUEST_CHANGES review(閘看得到,不會再點名它)")
if DRY:
print("\n[dry-run] 以上一通都沒有送出。")
return
print("\n📌 兩件事是同一個動作——不會只寫了理由而 PR 還開著"
"reject),也不會留了 review 而票上查不到理由(changes)。")
# ── list ─────────────────────────────────────────────────────────────────
def cmd_list(argv):
orgs = [o.strip().lower() for o in
os.environ.get("PR_VERDICT_ORGS", "inkstone").split(",") if o.strip()]
rows = api("/repos/issues/search?type=pulls&state=open&limit=50")
import time
now = time.time()
n = 0
for pr in rows:
full = (pr.get("repository") or {}).get("full_name") or ""
if "/" not in full or (orgs and full.split("/", 1)[0].lower() not in orgs):
continue
try:
t = time.mktime(time.strptime(pr["created_at"][:19], "%Y-%m-%dT%H:%M:%S"))
age = int((now - t) / 86400)
except Exception:
age = -1
who = [a["login"] for a in (pr.get("assignees") or [])]
mark = "🕒" if who else "❌"
print(f"{mark} {full}#{pr['number']:<5} 開了 {age:>3} 天 "
f"{'指派=' + ','.join(who) if who else '沒有人被指派'} {pr['title'][:34]}")
n += 1
print(f"\n共 {n} 個 open PRorg{'/'.join(orgs) or '全部'})。"
f" ❌=沒有人在管,pr-verdict-guard 會點名它")
def main():
global DRY
argv = sys.argv[1:]
if "--dry-run" in argv:
DRY = True
argv.remove("--dry-run")
if not argv:
print(__doc__)
sys.exit(0)
cmd, rest = argv[0], argv[1:]
if cmd == "merge":
cmd_merge(rest)
elif cmd == "reject":
_verdict_with_reason(rest, "reject")
elif cmd == "changes":
_verdict_with_reason(rest, "changes")
elif cmd == "list":
cmd_list(rest)
else:
print(__doc__)
die(f"🔴 沒有這個子指令:{cmd}")
if __name__ == "__main__":
main()
+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 票,你卻要開新的。
命中的前幾張: