Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 672439bd95 | |||
| 5fca644b5a | |||
| 44f60ba0b8 | |||
| 79ee9e62d6 | |||
| 5ac06abc95 | |||
| 156d339308 | |||
| da9bd53cae |
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "isep",
|
||||
"description": "InkStone Environment Plugin —— leo 的 Claude Code 環境唯一真相源:55 支機械閘(72 條註冊,白話盤點見 docs/hooks-inventory.md)、7 支 slash command、2 支 skill、48 支腳本,外加治理規範與標籤真相源。本機與雲端裝同一份,沒有子集。",
|
||||
"version": "0.14.0",
|
||||
"description": "InkStone Environment Plugin —— leo 的 Claude Code 環境唯一真相源:60 支機械閘(82 條註冊,白話盤點見 docs/hooks-inventory.md)、7 支 slash command、2 支 skill、7 位有名字的工人(agents/,見 docs/governance/worker-roster.md)、48 支腳本,外加治理規範與標籤真相源。本機與雲端裝同一份,沒有子集。",
|
||||
"version": "0.15.0",
|
||||
"keywords": [
|
||||
"inkstone",
|
||||
"guardrails",
|
||||
|
||||
@@ -23,21 +23,34 @@
|
||||
|
||||
| | 數量 | 是什麼 |
|
||||
|---|---|---|
|
||||
| `hooks/` | 54 支 + `hooks.json` | 全部機械閘(PreToolUse/Stop/SubagentStop/SessionStart/PostToolUse 共 69 條註冊,2026-08-28 在樹上實數;`ISEP#60` 移除 `claim-verify-police.sh`/`subagent-claim-worksheet.sh` 這對自造機制,改由 Gitea 原生三格承接見 `ISEP#59`) |
|
||||
| `hooks/` | 54 支 + `hooks.json` | 全部機械閘(PreToolUse/Stop/SubagentStop/SessionStart/PostToolUse 共 71 條註冊。**一支一行的白話盤點在 `docs/hooks-inventory.md`,那裡才是這兩個數字的家**) |
|
||||
| `hooks/` | 60 支 + `hooks.json` | 全部機械閘(PreToolUse/Stop/SubagentStop/SessionStart/PostToolUse/UserPromptSubmit 共 82 條註冊)。**一支一行的白話盤點在 `docs/hooks-inventory.md`,那裡才是這兩個數字的家** |
|
||||
| `agents/` | 7 位 | **工人名單**(`inkstone/ISEP#86`)——派工時指名派給誰,規約見 `docs/governance/worker-roster.md` |
|
||||
| `commands/` | 7 支 | `/wiki-recall` `/ship-check` `/cp-write` … |
|
||||
| `skills/` | 2 支 | |
|
||||
| `scripts/` | 38 支 | `ticket`/`github-arm.sh`/`gitea-bootstrap.sh` …(頂層檔案,不含 `lib/` 等子目錄) |
|
||||
| `scripts/` | 48 支 | `ticket`/`roster`/`isep-nag`/`wiki-compress`/`github-arm.sh` …(頂層檔案,不含 `lib/` 等子目錄) |
|
||||
|
||||
> 🔴 **這四個數字要用數的,不要用推的**(2026-08-28:上一版寫 48/59/23,
|
||||
> 而真實是 54/71/38——三個都錯,錯了不知道多久)。重數一次:
|
||||
> 🔴 這五個數字**每次都要在自己的樹上實數**,不准沿用上一版、也不准用加減推
|
||||
> (`system-dev/wiki/mistakes.md`:那是 leo 的驗收介面,多報就是假綠):
|
||||
>
|
||||
> ```sh
|
||||
> ls hooks/*.sh | wc -l # hooks
|
||||
> grep -c '"command":' hooks/hooks.json # 註冊條數(冒號不能省,省了會數到 "type": "command")
|
||||
> ls agents/*.md | wc -l
|
||||
> ls commands/*.md | wc -l ; ls -d skills/*/ | wc -l
|
||||
> find scripts -maxdepth 1 -type f | wc -l # scripts(頂層)
|
||||
> python3 -c "import json;h=json.load(open('hooks/hooks.json'));h=h.get('hooks',h);\
|
||||
> print(sum(len(m.get('hooks',[])) for ev in h.values() for m in ev))" # 註冊條數
|
||||
> ls -p scripts | grep -v / | wc -l # scripts(只數檔案,不含子目錄)
|
||||
> ```
|
||||
>
|
||||
> 🔴 **光看數字對不對還不夠**——`hooks.json` 合錯的時候「語法合法、閘卻不見了」,
|
||||
> **不會有任何東西喊一聲**。合併過 `hooks.json` 之後一律再跑這一支,逐支點名:
|
||||
>
|
||||
> ```sh
|
||||
> python3 -c "
|
||||
> import json,collections
|
||||
> d=json.load(open('hooks/hooks.json'))
|
||||
> c=collections.Counter(h['command'].split('/')[-1] for ev in d['hooks'].values() for g in ev for h in g.get('hooks',[]))
|
||||
> print('總註冊', sum(c.values()))
|
||||
> for k in sorted(c): print(' ', k, c[k])
|
||||
> "
|
||||
> ```
|
||||
>
|
||||
> **這是 leo 的驗收介面**——多報就是假綠(`docs/hooks-inventory.md` 開頭記著同一個病)。
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
---
|
||||
name: arcrun-hand
|
||||
description: Arcrun 引擎的工人——workflow、零件、graph-executor、Cloudflare worker。動 inkstone/Arcrun 派它。
|
||||
---
|
||||
|
||||
你是 **arcrun-hand**,`inkstone/Arcrun` 這個 repo 的工人。
|
||||
|
||||
**負責 repo**:inkstone/Arcrun
|
||||
|
||||
## 開工前先讀
|
||||
|
||||
- 這個 repo 的 `CLAUDE.md` 與 `system-dev/wiki/`
|
||||
- `InkStoneCo/system-dev/docs/4-guides/arcrun-primer.md`(不認識 Arcrun 就一定要讀)
|
||||
- 要動 KBDB ⇒ 先讀 KBDB 的三張表鐵律,**永不加表、零 SQL、只走 API**
|
||||
|
||||
## 紅線
|
||||
|
||||
- **不准把 Arcrun 的東西做成 standalone Worker**——零件走 WASM/recipe,
|
||||
`code` 節點只用於局部整形,不是拿來重寫整條流程(那叫腹語術)。
|
||||
- **金鑰只寫 `{{credential.<名字>}}`**,真身由執行前回填;不准自建第二套傳遞法。
|
||||
- 動任何基礎設施詞(D1/資料表/migration/schema)之前,先問「**這個東西是誰的**」。
|
||||
- 不准部署 prod;stage 驗過再交。
|
||||
@@ -0,0 +1,20 @@
|
||||
---
|
||||
name: arcrun-rag-hand
|
||||
description: arcrun-rag 產品的工人——RAG 管線、bundle、installer、portal/console 前端。動 inkstone/arcrun-rag 派它。
|
||||
---
|
||||
|
||||
你是 **arcrun-rag-hand**,`inkstone/arcrun-rag` 這個 repo 的工人。
|
||||
|
||||
**負責 repo**:inkstone/arcrun-rag
|
||||
|
||||
## 開工前先讀
|
||||
|
||||
- 這個 repo 的 `CLAUDE.md`、`system-dev/wiki/status.md`
|
||||
- 出貨相關 ⇒ `skills/ship-check/SKILL.md`(兩條版本線、重打 bundle、purge CDN)
|
||||
|
||||
## 紅線
|
||||
|
||||
- **有前端就要測前端**——一個頁面的前後端是一體的,HTTP 200 不算驗過。
|
||||
- 對外網址要驗 custom domain・DNS・apiBase・CORS **四件**。
|
||||
- 改了 bundle 就要重打並 grep 複驗改動真的進去;**版本沒動 = 沒交付**。
|
||||
- 不准部署 prod(出 prod 要 leo 親手解保險)。
|
||||
@@ -0,0 +1,20 @@
|
||||
---
|
||||
name: collector-hand
|
||||
description: 桌面 collector/daemon 的工人——打包、自動更新、MS Store 出貨鏈。動 inkstone/arcrun-collector 派它。
|
||||
---
|
||||
|
||||
你是 **collector-hand**,`inkstone/arcrun-collector` 這個 repo 的工人。
|
||||
|
||||
**負責 repo**:inkstone/arcrun-collector
|
||||
|
||||
## 開工前先讀
|
||||
|
||||
- 這個 repo 的 `CLAUDE.md`
|
||||
- `skills/ship-check/SKILL.md`——**daemon 這條版本線 leo 是在桌面「檢查更新」看的**,
|
||||
跟雲端 portal 那條是兩條線
|
||||
|
||||
## 紅線
|
||||
|
||||
- **版本號是 leo 唯一的驗收介面**:改了就要升版,並把 DMG/zip 真的打開檢查。
|
||||
- 出貨(推 prod/送 Store)要 leo 親手解保險。
|
||||
- 產物與源碼同處(D95),不要另立產物 repo。
|
||||
@@ -0,0 +1,21 @@
|
||||
---
|
||||
name: inkstoneco-hand
|
||||
description: 頂層知識庫的工人——wiki、SDD、Critical Path、決策紀錄、跨 repo 索引。動 inkstone/InkStoneCo 派它。
|
||||
---
|
||||
|
||||
你是 **inkstoneco-hand**,`inkstone/InkStoneCo` 這個頂層 repo 的工人。
|
||||
|
||||
**負責 repo**:inkstone/InkStoneCo
|
||||
|
||||
## 開工前先讀
|
||||
|
||||
- 頂層 `CLAUDE.md`
|
||||
- `system-dev/wiki/status.md`、`mistakes.md`、`decisions-summary.md`、`credentials-map.md`
|
||||
- 要動 CP ⇒ 先跑 `/cp-write`;要動 SDD ⇒ 先跑 `/sdd-check`
|
||||
|
||||
## 紅線
|
||||
|
||||
- **頂層不寫業務程式碼。** 這一層只放跨專案的整理與判準。
|
||||
- **任何時刻只允許一份 `status: active` 的 SDD**;不准自行建 SDD。
|
||||
- 規格層的變更 ⇒ 開一張 Gitea 票(`Human` + 指派 `Leo` + `s/triage`),不要自己改規格。
|
||||
- 寫給 leo 看的 md 一律**巢狀 bullet**(Logseq outliner),禁表格平攤。
|
||||
@@ -0,0 +1,26 @@
|
||||
---
|
||||
name: isep-hand
|
||||
description: ISEP 的工人——機械閘、hook、slash command、plugin 打包。動 inkstone/ISEP 的東西派它。
|
||||
---
|
||||
|
||||
你是 **isep-hand**,`inkstone/ISEP` 這個 repo 的工人。
|
||||
|
||||
**負責 repo**:inkstone/ISEP
|
||||
|
||||
## 開工前先讀
|
||||
|
||||
- 這個 repo 的 `README.md` 與 `docs/hooks-inventory.md`(先知道**已經有哪些閘**,
|
||||
再決定要不要多一支——這個 repo 最常見的錯是「重造一支平行的閘」)
|
||||
- `docs/TESTING.md`(你的改動要照它既有的形狀附測試)
|
||||
- `system-dev/wiki/mistakes.md`、`system-dev/wiki/principles.md`
|
||||
- 要動派工/回覆格式 ⇒ `docs/governance/dispatch-and-reply-format.md`
|
||||
|
||||
## 紅線
|
||||
|
||||
- **不准用關鍵字黑名單當判準。** leo 2026-08-17 已證偽(當日 8 次誤攔、0 次正確攔截)。
|
||||
可用的兩種形狀:**要求某個東西在場**、**拿唯一識別碼往放行的方向比對**。
|
||||
- **誤攔比漏擋嚴重。** 一支天天誤擋的閘會被學會忽略,等於不存在。
|
||||
- 閘的訊息要講得出**出路**,而且出路要真的走得通(測試要驗那條出路)。
|
||||
- 改了會被載入的東西就要**升版**(`plugin update` 比的是版本號不是內容);
|
||||
版本號由總管定,你在 PR 說明寫「待總管定版」。
|
||||
- 盤點表(`docs/hooks-inventory.md`)的數字**在你的樹上自己數**,不准用加減推。
|
||||
@@ -0,0 +1,19 @@
|
||||
---
|
||||
name: mira-hand
|
||||
description: Mira 的工人——daemon、收件匣、通知管線。動 inkstone/mira 派它。
|
||||
---
|
||||
|
||||
你是 **mira-hand**,`inkstone/mira` 這個 repo 的工人。
|
||||
|
||||
**負責 repo**:inkstone/mira
|
||||
|
||||
## 開工前先讀
|
||||
|
||||
- 這個 repo 的 `CLAUDE.md` 與 `.claude/wiki/`
|
||||
- 跨 repo 的資料流 ⇒ `InkStoneCo/system-dev/docs/3-specs/journeys/`(驗收只認頭尾)
|
||||
|
||||
## 紅線
|
||||
|
||||
- **Mira ≠ Arcrun**,分層不准混。
|
||||
- 同步到線上走 **ssh(scp/rsync)**,**不准用 GitHub Actions**(那是當初被 flag 的形狀)。
|
||||
- 不准部署 prod。
|
||||
@@ -0,0 +1,21 @@
|
||||
---
|
||||
name: scout
|
||||
description: 跨 repo 的偵察兵——查現況、盤點、對帳、找出處。**唯讀**,不寫 code、不開 PR。
|
||||
---
|
||||
|
||||
你是 **scout**,跨 repo 的偵察兵。你的交付物是**帶出處的事實**,不是程式碼。
|
||||
|
||||
**負責 repo**:(跨 repo,唯讀)
|
||||
|
||||
## 開工前先讀
|
||||
|
||||
- 那張票的全文與每一則 comment(你要查的東西定義在那裡)
|
||||
- 相關 repo 的 `system-dev/wiki/`;查得到就先查,不要現搜
|
||||
- 要查知識庫 ⇒ 先 `kbdb_get_map` → `kbdb_get_index` → `kbdb_get_card`,三步不要跳
|
||||
|
||||
## 紅線
|
||||
|
||||
- **每一個事實宣稱都要帶出處**(檔案:行號/commit/comment 號/指令輸出)。
|
||||
說不出出處的那一句,改寫成「我沒查到」。
|
||||
- **「我這裡看不到」不等於「不存在」**;401/連不上是**讀不到**,不是不存在。
|
||||
- **不准改任何檔案、不准開 PR、不准 push。** 你交的是結論,貼回那張票。
|
||||
+163
-2
@@ -408,6 +408,68 @@ python3 scripts/debt-worklist list --top 5
|
||||
**失敗**:前幾名一眼看不出為什麼在那裡 ⇒ 那些權重就是錯的,去改 `score()`,
|
||||
**不要改成「總管覺得」**——這份清單的價值就在於它不靠記憶。
|
||||
|
||||
### A21 — 沒人會叫的事會自己叫:36 條
|
||||
```
|
||||
bash hooks/tests/overdue-nag.test.sh
|
||||
```
|
||||
**該看到**:`通過 36 條,失敗 0 條`。**全程離線**——資料走 `--fixture`、時鐘走 `--now`、
|
||||
網路用 `ISEP_NOTIFY_OFFLINE=1` 關掉。**不打 Gitea、不打實例、不留任何測試票。**
|
||||
|
||||
**它在守什麼**(`inkstone/ISEP#93`,2026-08-27 實查):一張票掛著等 leo 三天了、
|
||||
一個 milestone 逾期了、一根棒子躺著沒人接——**沒有任何東西為此叫過一聲**。
|
||||
當天有五個 milestone 逾期(08-24 三個、08-26 兩個),一聲都沒有。
|
||||
|
||||
**失敗**:
|
||||
- A 群任一紅 ⇒ 撈不出票上點名的那五個逾期 milestone,或訊息不是白話的(票上驗收 1)
|
||||
- ⑨⑩ 紅 ⇒ 沒東西可報時**安靜結束**(票上驗收 2)。**安靜跟壞掉長得一模一樣**
|
||||
- ⑪ 紅 ⇒ 撈不到資料時把「我沒查到」講成「沒有事情逾期」——那是最貴的一種假情報
|
||||
- **C 群任何一條紅 ⇒ 誤報**,這比漏報嚴重:每次開場都被念一串不相干的東西,
|
||||
人就學會跳過它,那時真的有事也叫不動他
|
||||
- D 群紅 ⇒ **發不出去卻靜默**。「送出成功」跟「送到了」是兩件事。
|
||||
㉒b 特別看:退路留言若只帶閘的判定、不帶**為什麼送不出去**,
|
||||
下一個人會去修閘——而斷點可能根本不在那裡(2026-08-28 第一版真的漏了這一格)
|
||||
- ㉖㉗㉘㉙ 紅 ⇒ 這個 session 的閘是哪一版變回「假設」。㉘ 特別重要:
|
||||
**閘說會擋就不准從 python 這條路繞過去**——繞得過的閘等於不存在
|
||||
- ㉛ 紅 ⇒ 探針把 `/tmp/.prod-write-ok` 燒掉了(那是總管單次用完即丟的授權)
|
||||
- ㉟ 紅 ⇒ 每條 subagent 開工都吵 leo 一次
|
||||
|
||||
> 🔴 **跑測試前先確認 `CLAUDE_CODE_CHILD_SESSION` 沒有殘留**:這支閘刻意放行子 session,
|
||||
> 在一條 subagent 裡跑,F 群會全綠而且是**假綠**。測試檔自己會把它清掉。
|
||||
|
||||
### A22 — 壓 wiki 不准弄丟東西:25 條
|
||||
```
|
||||
bash hooks/tests/wiki-compress.test.sh
|
||||
```
|
||||
**該看到**:`通過 25 條,失敗 0 條`。**全程離線**,只在 `mktemp` 目錄裡動檔案,
|
||||
**不碰任何真的 wiki**。
|
||||
|
||||
**它在守什麼**(`inkstone/ISEP#89`):`mistakes.md` 是「做新功能前讀一遍」等級的必讀檔,
|
||||
實測 **7,681 行 / 260 條**——**沒有人真的每次都從頭讀。讀不完的必讀檔,等於沒有。**
|
||||
|
||||
**失敗**:
|
||||
- ①② 紅 ⇒ 切條切錯了,後面三件全部失準。② 特別重要:`mistakes.md` 裡真的有
|
||||
**寫在 code fence 裡的 `##`**(第 534 行那種),把它當成一條就會拿不存在的東西去對帳
|
||||
- ⑤⑥ 紅 ⇒ 弄丟了東西卻沒被發現(票上驗收 2)
|
||||
- **⑦ 紅最嚴重**:那是**反向測**——真的弄丟一條時 `verify` 抓不到。
|
||||
**一個抓不到問題的對帳表,比沒有對帳表更糟**,因為它會被當成證據
|
||||
- ⑧⑨⑩ 紅 ⇒ 壓完查不到、或查得更慢(票上驗收 1)
|
||||
- ⑪–⑮ 紅 ⇒ 壓縮沒有票號、沒有交付紀錄(票上驗收 3:不准順手做完沒人知道)
|
||||
- **⑱⑲㉑ 任一紅 ⇒ 誤攔**,這比漏擋嚴重:一般編輯被擋、非 wiki 的檔被擋、
|
||||
新建檔案被擋,人就學會繞過它
|
||||
- ㉒ 紅 ⇒ 擋不只一次 ⇒ 會卡死
|
||||
- ㉕ 紅 ⇒ 都在門檻內還要講一次話,那行提示下次就沒人看了
|
||||
|
||||
📌 **真跡跑過**:拿**現在的 `mistakes.md` 複本**實壓一次(不動真的 wiki)——
|
||||
`7,681 行 → 1,199 行`、`260 條一條都沒少`(`verify` 逐條對帳)、
|
||||
`80 個查詢命中率 80/80,定位成本 2,956 行 → 131 行,快 22.5 倍`(`bench`)。
|
||||
指令:
|
||||
```
|
||||
cp <某個 wiki>/mistakes.md /tmp/w/ && python3 scripts/wiki-compress apply /tmp/w/mistakes.md --ticket inkstone/ISEP#89
|
||||
python3 scripts/wiki-compress verify /tmp/w/mistakes.md.before-compress /tmp/w/mistakes.md /tmp/w/mistakes-archive-*.md
|
||||
python3 scripts/wiki-compress bench /tmp/w/mistakes.md.before-compress /tmp/w/mistakes.md /tmp/w/mistakes-archive-*.md
|
||||
```
|
||||
|
||||
|
||||
### A5 — 開票前的搜尋是跨 repo 的
|
||||
```
|
||||
python3 scripts/ticket where 標籤 模組化
|
||||
@@ -442,11 +504,12 @@ bash hooks/tests/ask-user-question-guard.live.test.sh
|
||||
- 📌 這支會隨模型版本漂移,**是量尺不是一次性驗收**。改完判準要連跑三次都全綠才算數
|
||||
(2026-08-26 實測:第一版判準連兩次都在同一題漏擋,收緊 ③④ 定義後三次全綠)
|
||||
|
||||
### A11 — 派工單只剩票號:擋得住,也放得過,而且會注入共通規定
|
||||
### A11 — 派工單只剩票號(含**回覆那條路**):擋得住,也放得過,而且會注入共通規定
|
||||
```
|
||||
bash hooks/tests/dispatch-format-guard.test.sh
|
||||
```
|
||||
**該看到**:`19/19 通過`。**離線、不打網路、不花錢**——這支閘是純結構判斷,沒有語意判官,
|
||||
**該看到**:`52/52 通過`(`inkstone/ISEP#88` 之前是 33 條;本頁一度寫 19,那是更早的數字,
|
||||
**沒有人回頭改** —— 同一個病,只是換一欄)。**離線、不打網路、不花錢**——這支閘是純結構判斷,沒有語意判官,
|
||||
所以它不需要像 A10 那樣另開一支 live 測試量準度,**每次結果都一樣**。
|
||||
**失敗**:
|
||||
- A 群任何一條紅 ⇒ **誤攔**。合規的派工只有一行票號,擋掉它等於整台機器派不了工
|
||||
@@ -455,6 +518,58 @@ bash hooks/tests/dispatch-format-guard.test.sh
|
||||
它壞了不會有人立刻發現——派工照樣送出去,只是收工方**不知道要貼回原票**
|
||||
- ⑨ 紅 ⇒ 真跡放行了。那份測資是**真的發生過的那一次派工**(見 `hooks/tests/fixtures/README.md`)
|
||||
- ⑰ 紅 ⇒ 訊息被 shell 展開了(同 A9 ⑩b 那個病:閘照擋,但它教人怎麼解的那兩行變成空白)
|
||||
- **D 群⑲⑳㉑㉒ 紅 ⇒ `inkstone/ISEP#88` 的驗收條件沒過**:回覆一個正在跑的 subagent
|
||||
時夾帶指令沒被擋(⑲)、或擋了卻沒把那段內容原文印出來(㉑,出路是「貼上票」,
|
||||
找不到原文就得回頭自己翻)
|
||||
- **E 群㉔–㉙ 任一紅 ⇒ 有一條通往 subagent 的路又漏了**。那六條是雲端 session/trigger/
|
||||
`claude -p`——`ticket-api-bypass-guard.sh` 檔頭記過這一族的通病:「認動作的方式漏了一條路」
|
||||
- **F 群㉚–㉟ 任一紅 ⇒ 誤攔**。㉚特別重要:subagent 往上回報(`to: "main"`)是**交件**,
|
||||
擋它等於擋掉交件本身;㉟反過來,子 session 自己往下派工時規矩要照樣管它,不然是逃逸艙口
|
||||
|
||||
### A16 — 工人有名字:指名派工、沒有這個人、注入它的檔案:24 條
|
||||
```
|
||||
bash hooks/tests/roster-guard.test.sh
|
||||
```
|
||||
**該看到**:`24 通過 / 0 失敗`。**離線、不打網路、不花錢**——純結構判斷(名字在不在名單裡),
|
||||
沒有語意判官,每次結果一樣。
|
||||
|
||||
**它在守什麼**(`inkstone/ISEP#86`):「身為派工的人,我要工人有名字,
|
||||
我才知道票上這件事到底是誰做的。」開票當日實查:`/root/.claude/agents/` 不存在,
|
||||
派工派給的是**沒有名字的臨時工**——票上只看得到「有個 agent 做了」。
|
||||
|
||||
**失敗**:
|
||||
- ①②③ 紅 ⇒ 驗收 1 沒過:`scripts/roster` 列不出名單、或列了卻看不出各自管什麼
|
||||
- ④⑤ 任一紅 ⇒ **誤攔**,名單上的人被擋掉 = 整台機器派不了工。這比漏擋嚴重
|
||||
- ⑥⑦⑧ 紅 ⇒ 驗收 4 沒過:派一個不存在的名字時講不出「沒有這個人」
|
||||
- ⑨ 紅 ⇒ 擋了卻沒印名單,人不知道有誰可以派(閘的訊息要講得出出路)
|
||||
- ⑬ 紅 ⇒ 名單讀不到時**沒有 fail-open**。一份讀不到的名單不該讓整台機器停擺
|
||||
- ⑯⑰ 紅 ⇒ 注入壞了。那是「你是誰/先讀什麼/你的紅線」唯一的送達方式——
|
||||
它壞了不會有人立刻發現,派工照樣送出去,只是工人不知道自己是誰
|
||||
- ⑱ 紅 ⇒ 驗收 3 沒過:票上的【身份】欄吃不下工人名字(或把原本三個角色弄壞了)
|
||||
|
||||
### A17 — 未經調查不寫診斷:26 條
|
||||
```
|
||||
bash hooks/tests/diagnosis-evidence-guard.test.sh
|
||||
```
|
||||
**該看到**:`26 通過 / 0 失敗`。**全程離線**:不打 Gitea、不開任何測試票,
|
||||
戳記走 `ISEP_STAMP_DIR`(不碰 `/tmp` 的正式戳記)。
|
||||
|
||||
**它在守什麼**(`inkstone/ISEP#87`):「先派人查 → 拿到查的結果 → 才可以寫診斷。」
|
||||
未經調查的診斷寫在票上會長得像事實,**工人會照著它去驗證,而不是去查**。
|
||||
|
||||
**失敗**:
|
||||
- ①–⑦ 任一紅 ⇒ 驗收 1 沒過。⑥⑦特別看:那是**側門**(直接打 Gitea API),
|
||||
只封正門等於沒封
|
||||
- ② 紅 ⇒ 驗收 3 沒過:訊息說不出「你還沒派人查這件事」,變成籠統的「格式不對」
|
||||
- ⑧⑨ 紅 ⇒ 驗收 2 沒過(派過人查卻還在擋),或戳記從「按票分」滑成「按 session 分」
|
||||
——後者等於派過一次就永久解鎖
|
||||
- **⑩–⑫ 任一紅 ⇒ 驗收 4 沒過,這是誤攔**:轉述別人查到的東西(有出處)被擋,
|
||||
那會逼人把真的有出處的話也吞回去
|
||||
- ⑬ 紅 ⇒ 判準從結構滑回措辭。那一條**故意用一段連「根因/因為/應該是」都沒有的內容**,
|
||||
它一旦放行,代表閘開始靠關鍵字判斷了(leo 2026-08-17 已證偽那條路)
|
||||
- ⑭–⑲ 任一紅 ⇒ 誤攔(短回覆、唯讀、搜尋、關票被擋)
|
||||
- ㉑ 紅 ⇒ 鬼打牆:同一張票擋不只一次,人會學會忽略它
|
||||
- ㉓㉔ 紅 ⇒ payload 壞掉時擋住了。這支是 PreToolUse,fail-closed 會讓人做不了事
|
||||
|
||||
### A12 — 票上的每一則留言都認得出是誰寫的(兩道門)
|
||||
```
|
||||
@@ -608,10 +723,17 @@ B2(信標那行)/B3(setup 輸出)/B4(閘的訊息)三個畫面
|
||||
| **A16 跨 repo 版本清單** | 總管 | ✅ 21/21(2026-08-28,inkstone/ISEP#84) |
|
||||
| **A17 估多久/花多久/差多少** | 總管 | ✅ 23/23(2026-08-28,inkstone/ISEP#85) |
|
||||
| **A18 結案要記帳的閘** | 總管 | ✅ 22/22(2026-08-28,inkstone/ISEP#85) |
|
||||
| **A21 沒人會叫的事會自己叫** | 總管 | ✅ 36/36(2026-08-28,inkstone/ISEP#93) |
|
||||
| **A21b Telegram 真的送達** | **leo** | ⛔ **驗不了——通道本身斷了,而且修它是真人閘**(見下) |
|
||||
| **A22 壓 wiki 不准弄丟東西** | 總管 | ✅ 25/25+真跡實壓(2026-08-28,inkstone/ISEP#89) |
|
||||
| A5 搜尋跨 repo | 總管 | ✅ |
|
||||
| A6 標籤對齊+冪等 | 總管 | ✅ 14 repo,第二次 0/0 |
|
||||
| **A9 人閘警察管路** | 總管 | ✅ 14/14(2026-08-26) |
|
||||
| **A10 人閘警察準度** | 總管 | ✅ 9/9,連跑三次(2026-08-26),A 群誤攔 0 |
|
||||
| **A11 派工單只剩票號+回覆那條路(ISEP#88)** | 總管 | ✅ 52/52(2026-08-28) |
|
||||
| **A12 留言身份欄(兩道門)** | 總管 | ✅ 11/11(2026-08-28 複跑) |
|
||||
| **A16 工人有名字(ISEP#86)** | 總管 | ✅ 24/24(2026-08-28) |
|
||||
| **A17 未經調查不寫診斷(ISEP#87)** | 總管 | ✅ 26/26(2026-08-28) |
|
||||
| **A11 派工單只剩票號** | 總管 | ✅ 19/19(2026-08-27) |
|
||||
| **A12 留言身份欄(兩道門)** | 總管 | ✅ 11/11(2026-08-27) |
|
||||
| **A16 放行的門真的打得開** | 總管 | ✅ 17/17(2026-08-28,inkstone/ISEP#90) |
|
||||
@@ -624,3 +746,42 @@ B2(信標那行)/B3(setup 輸出)/B4(閘的訊息)三個畫面
|
||||
| **B1–B5 雲端** | **leo** | 還沒跑(機器碰不到 Cloud environment) |
|
||||
|
||||
**A8 與 B 全綠之前,這個 sprint 的里程碑不准關。**
|
||||
|
||||
---
|
||||
|
||||
## ⛔ 已知斷點:`notify_leo` 這條 Telegram 通道現在是斷的(2026-08-28 實測)
|
||||
|
||||
`inkstone/ISEP#93` 的驗收第 4 條是「**Telegram 真的收得到**(不是『送出成功』,
|
||||
是 leo 手機上真的出現)」。**這一格現在過不了,而且原因不在本次的程式碼。**
|
||||
|
||||
實測(`scripts/isep-notify`,閘判定 `pass`、UA 帶對之後):
|
||||
|
||||
```
|
||||
POST …/webhooks/named/leo/notify_leo/trigger
|
||||
→ HTTP 404 {"error":"找不到 workflow \"notify_leo\",請先執行 acr push"}
|
||||
```
|
||||
|
||||
⇒ **那台線上實例上沒有 `notify_leo` 這支工作流。** 不是閘擋的、不是網路、不是金鑰
|
||||
(這條路本來就不需要金鑰)。頂層 wiki `agent-memory.md` 記過同一件事發生過一次
|
||||
(2026-08-10,KV 整批換新時定義消失,後來補推回去)——**看起來又斷了一次**。
|
||||
|
||||
🔴 **總管 2026-08-28 用 MCP 直接問那台實例,範圍比這裡查到的更廣**:
|
||||
|
||||
```
|
||||
arcrun_get_workflow(notify_leo) → not_found
|
||||
arcrun_search_workflows(通知 leo Telegram) → 只回 ship_refresh_cdn 一支
|
||||
而且它的執行紀錄寫著「圖定義已失效」
|
||||
```
|
||||
|
||||
⇒ **不只 `notify_leo` 不見了,那台實例上的工作流定義是整批失效的。**
|
||||
|
||||
修法要有人把 `mira` repo 的 `workflows/notify-leo.yaml` 重新 push 上那台實例。
|
||||
**這不是「總管可以解的閘」——它比那更嚴格**:那台是 leo 的個人帳號,
|
||||
而 `leo21c-write-guard.sh`(leo 2026-08-20 立)明文禁止寫它
|
||||
⇒ **這是真人閘,總管也不能解,只有 leo 自己動得了。**
|
||||
|
||||
📌 **這條斷了不代表 `#93` 沒交付**:本次的設計前提就是「**不假設發得出去**」——
|
||||
實測那兩次的退路都走通了,訊息貼回了 `inkstone/ISEP#93`
|
||||
(`#issuecomment-5182`、`#issuecomment-5184`),並把原文印在眼前。
|
||||
**發不出去而靜默,等於沒做;發不出去但講出來並留下退路,就是這支的規格。**
|
||||
通道修好之後不必改任何程式碼,重跑 `python3 scripts/isep-nag --notify` 即可補驗這一格。
|
||||
|
||||
@@ -44,6 +44,58 @@
|
||||
|
||||
---
|
||||
|
||||
## 1.5 派給誰:從名單裡挑一個**有名字的工人**(inkstone/ISEP#86)
|
||||
|
||||
派工單只有票號,那「這是誰的活」寫在哪?——**不寫在派工單裡,寫在 `subagent_type`。**
|
||||
|
||||
```
|
||||
Task(subagent_type="isep-hand", prompt="【工單】inkstone/ISEP#86")
|
||||
▲ 名單上的名字 ▲ 派工單仍然只有這一行
|
||||
```
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| 名單在哪 | `agents/`,一個檔案一位工人(規約:`docs/governance/worker-roster.md`) |
|
||||
| 怎麼看 | `python3 scripts/roster list`/`show <名字>`/`which <owner/repo>` |
|
||||
| 機械閘 | `hooks/roster-guard.sh`——沒指名或名單上沒有這個名字就擋,並印出整份名單 |
|
||||
|
||||
**為什麼「你是誰」不能收進 §2 的共通規定**:共通規定是「每次都一樣」的東西,
|
||||
而「你是誰、你該讀哪些、你的紅線」**每個工人都不一樣**。所以它有自己的家(名單),
|
||||
由 `roster-guard.sh` 在派工當下注入——同樣不必有人記得寫。
|
||||
|
||||
---
|
||||
|
||||
## 1.6 回覆也是派工:**同一套規矩,同一支閘**(inkstone/ISEP#88)
|
||||
|
||||
派工單被壓成只剩票號之後,**回覆一個正在跑的 subagent 這條路沒有被管到**——
|
||||
那支閘掛在 `PreToolUse(Task|Agent)`,而回覆走的是別的工具,根本不經過那個攔截點。
|
||||
|
||||
實況(2026-08-27):第一次派工乾乾淨淨只有票號,中途回覆時又把一長串修改要求丟過去。
|
||||
**那些話票上一個字都沒有**,那條線被停掉或換人接手就消失。
|
||||
|
||||
⇒ **不另造一支平行的閘**:同一支 `dispatch-format-guard.sh`、同一個判斷函式,
|
||||
只是把所有通往 subagent 的路一次列全(表在 `hooks/lib/dispatch_parse.py` 的 `tool_channel()`):
|
||||
|
||||
| 路 | 裝話的欄位 |
|
||||
|---|---|
|
||||
| `Task`/`Agent` | `prompt` |
|
||||
| `SendMessage` | `message` |
|
||||
| 雲端 `create_session` | `prompt` |
|
||||
| 雲端 `send_message` | `text`/`message` |
|
||||
| 雲端 `create_trigger`/`update_trigger` | `prompt` |
|
||||
| 雲端 `fire_trigger` | `text` |
|
||||
| 雲端 `send_later` | `message` |
|
||||
| Bash `claude -p <prompt>` | 指令裡那段 prompt |
|
||||
|
||||
🔴 **新增一條路要加在那張表裡,不要再開一支閘。**
|
||||
「認動作的方式漏了一條路」是這一族的通病——`ticket-api-bypass-guard.sh` 的檔頭
|
||||
記過同一課(第一版只認大寫裸字 `POST`,`requests.post()` 與隱式 POST 全部漏掉)。
|
||||
|
||||
**刻意不管的方向**:subagent 往上回報(`SendMessage to: "main"`)=**交件不是派工**。
|
||||
擋它等於擋掉交件本身。
|
||||
|
||||
---
|
||||
|
||||
## 2. 共通規定(每一次派工由機器自動注入給收工方)
|
||||
|
||||
<!-- INJECT:BEGIN 這段之間的內容會被 dispatch-format-guard.sh 原文注入,改這裡=改所有派工 -->
|
||||
@@ -55,8 +107,10 @@
|
||||
3. **交件=貼回那張票**(`scripts/ticket say <owner/repo#N> -F <檔>`),
|
||||
不是只在對話裡回報。回覆第一行必須是身份欄,見下。
|
||||
4. **回覆第一行一律是**:
|
||||
`【身份】subagent/<owner/repo>/<你的分支>`
|
||||
角色三選一:`總管`/`subagent`/`leo`。
|
||||
`【身份】<你的名字>/<owner/repo>/<你的分支>`
|
||||
名字用機器注入給你的那個(`isep-hand`、`scout`…);沒有名字時才退回
|
||||
`總管`/`subagent`/`leo` 三選一。**票上要看得出是哪個工人做的**,
|
||||
而三條線並行時「subagent」這個字回答不了「是誰」。
|
||||
5. **不准 push 到 `main`/`master`**,也不准部署 prod。做在自己的分支上,交回分支名。
|
||||
6. **Gitea 的 org 是 `inkstone`**(不是 `Leo`);`gh` 打不到 Gitea;
|
||||
標籤是 `s/*` 不是 `status/*`。
|
||||
@@ -80,9 +134,13 @@
|
||||
【身份】subagent/inkstone/ISEP/feat/ticket-carries-the-task
|
||||
```
|
||||
|
||||
- 角色是**三選一的允許清單**:`總管`/`subagent`/`leo`
|
||||
- 角色是**允許清單**:`總管`/`subagent`/`leo`,**再加上 `agents/` 名單上的每一個工人名字**
|
||||
(`isep-hand`/`arcrun-hand`/`scout`…,inkstone/ISEP#86)。清單以外的名字一律擋。
|
||||
- 第二格是你動的 repo,第三格是分支(沒有就寫 `-`)
|
||||
|
||||
🔴 **有名字就用名字。** 三條線並行時三則「【身份】subagent/…」長得一模一樣,
|
||||
等於沒有身份——這正是 ISEP#86 要解的那件事。
|
||||
|
||||
### 為什麼
|
||||
|
||||
2026-08-27 實害:多條線並行時票上的留言看不出身份,
|
||||
@@ -106,8 +164,30 @@
|
||||
## 4. 這份規範自己怎麼被驗
|
||||
|
||||
```
|
||||
bash hooks/tests/dispatch-format-guard.test.sh 派工單閘:該擋的與不該擋的
|
||||
bash hooks/tests/reply-identity.test.sh 身份欄:正門與側門兩道
|
||||
bash hooks/tests/dispatch-format-guard.test.sh 派工單閘+**回覆那條路**:該擋的與不該擋的
|
||||
bash hooks/tests/reply-identity.test.sh 身份欄:正門與側門兩道
|
||||
bash hooks/tests/roster-guard.test.sh 工人名單:指名、沒有這個人、注入
|
||||
bash hooks/tests/diagnosis-evidence-guard.test.sh 未經調查不寫診斷
|
||||
```
|
||||
|
||||
測資裡放的是**真的發生過的那幾份違規派工單**,不是想像出來的例子。
|
||||
|
||||
---
|
||||
|
||||
## 5. 寫上票之前:**未經調查不寫診斷**(inkstone/ISEP#87)
|
||||
|
||||
順序只有一個方向:**先派人查 → 拿到查的結果 → 才可以寫診斷。**
|
||||
|
||||
> 「診斷寫完了,工人就不知道自己要做什麼——它會照著我的結論去驗證,而不是去查。
|
||||
> 而我不是那個 repo 的專家,它才是。」
|
||||
|
||||
機械閘 `hooks/diagnosis-evidence-guard.sh` 看三個**結構**訊號(一個關鍵字都沒有):
|
||||
|
||||
1. 這個 session 有沒有**派人查過那張票**(`investigate-first-stamp.sh` 的戳記)
|
||||
2. 這段話裡有沒有**走得過去的出處**(檔案:行號/commit/comment 號/票號/網址/實測輸出)
|
||||
3. 這段話**有沒有份量**(一句「收到」不可能是診斷)
|
||||
|
||||
①有 ⇒ 放行。①無+②有 ⇒ 放行(**轉述有出處不能被誤擋**)。三者皆缺 ⇒ 擋一次。
|
||||
|
||||
🔴 **「根因」「因為」「應該是」這類詞一律不准當判準**——leo 2026-08-17 已證偽文字層封路:
|
||||
當日 8 次誤攔、0 次正確攔截,而且**紅線寫得越細,命中關鍵字的機率越高 ⇒ 那些閘在懲罰謹慎**。
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
# `agents/` — 工人名單(inkstone/ISEP#86)
|
||||
|
||||
> leo 派工時派給的不能是「一個 agent」,要派給**有名字的人**。
|
||||
> 「票上看不出來這件事是誰做的」是這張票的來由。
|
||||
|
||||
**一個檔案 = 一個工人。** 每個檔案只回答四件事:
|
||||
|
||||
| 段 | 問題 | 為什麼一定要有 |
|
||||
|---|---|---|
|
||||
| `**負責 repo**` | 它管哪一個 repo | 派工時挑人的唯一判準;也是【身份】欄第二格 |
|
||||
| `## 開工前先讀` | 它要先讀什麼 | 這是**每個工人各自不同**的部分——共通規定補不了 |
|
||||
| `## 紅線` | 它不准做什麼 | 全機共通的紅線在共通規定裡;這裡只寫這個 repo 特有的 |
|
||||
| frontmatter `description` | 一句話它是誰 | `scripts/roster list` 印的就是這一行 |
|
||||
|
||||
## 為什麼是這個形狀,不是自己造一套
|
||||
|
||||
`agents/` 是 Claude Code plugin **原生**的 subagent 目錄,檔名(frontmatter 的 `name`)
|
||||
就是 `Task` 工具的 `subagent_type`。⇒ 「指名派給誰」不必發明新欄位、不必改派工單格式
|
||||
(派工單仍然只有 `【工單】` 一行,`dispatch-format-guard.sh` 的行為一個字都沒變)。
|
||||
|
||||
## 怎麼用
|
||||
|
||||
```
|
||||
python3 scripts/roster list # 名單:有哪幾個工人、各自管什麼
|
||||
python3 scripts/roster show isep-hand # 一個工人的全文(它開工前會讀到的東西)
|
||||
python3 scripts/roster names # 只印名字,給腳本用
|
||||
```
|
||||
|
||||
派工時把名字放進 `Task` 的 `subagent_type`;名字不在名單上,
|
||||
`hooks/roster-guard.sh` 會擋下來並說「**沒有這個人**」,同時把名單印出來。
|
||||
|
||||
## 加一個工人
|
||||
|
||||
在這個目錄新增一個 `.md`(照既有檔案的四段),然後:
|
||||
|
||||
```
|
||||
python3 scripts/roster list # 新的名字要出現
|
||||
bash hooks/tests/roster-guard.test.sh # 全綠
|
||||
```
|
||||
|
||||
**不必改任何 hook**——名單是資料,閘讀的是這個目錄。
|
||||
+43
-8
@@ -1,4 +1,4 @@
|
||||
# 54 支閘,白話盤點表
|
||||
# 60 支閘,白話盤點表
|
||||
|
||||
> 回應 `inkstone/InkStoneCo#40`:「如果加入了,我應該可以白話文看到 hooks 的內容?」
|
||||
> 這份表就是那個「白話文」——不用點開任何 `.sh` 檔,一行看懂一支閘在管什麼。
|
||||
@@ -7,16 +7,14 @@
|
||||
|
||||
## 一句話結論
|
||||
|
||||
`hooks/` 底下有 **55 個 `.sh` 檔**,`hooks.json` 實際掛上 **72 條註冊**(同一支閘常被多種情境同時掛上);
|
||||
`hooks/` 底下有 **55 個 `.sh` 檔**,`hooks.json` 實際掛上 **72 條註冊**(同一支閘常被多種情境同時掛上);
|
||||
`hooks/` 底下有 **60 個 `.sh` 檔**,`hooks.json` 實際掛上 **82 條註冊**(同一支閘常被多種情境同時掛上);
|
||||
其中 **3 支檔案存在但沒被掛上**(2 支是待人填的空範本、1 支是刻意留著沒開的止血帶,見下面「未生效」表)。
|
||||
下面按「你會在什麼時候撞到它」分組,一支一行。
|
||||
|
||||
> 🔴 **這兩個數字上一版是錯的(2026-08-26 實際數過才發現)**:本頁原本寫「43 個檔、53 條註冊」,
|
||||
> 而當時真實是 **45 個檔、55 條註冊**——中間有兩支閘進來時沒有回頭改這裡。
|
||||
> 現在的寫法是實際數出來的:
|
||||
> `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。
|
||||
> `ls hooks/*.sh | wc -l` = 60;`grep -c '"command":' hooks/hooks.json` = 82。
|
||||
> ⚠️ **冒號不能省**:`grep -c '"command"'`(沒冒號)會連 `"type": "command"` 一起數到,回 **120**。
|
||||
> 本頁 2026-08-27 之前寫的是沒冒號那版——**照著它跑會拿到一個跟本頁不符的數字**。
|
||||
> **一份會偷偷過期的盤點表,跟沒有盤點表差不多**——見本頁最後「怎麼跟實況對帳」那段。
|
||||
@@ -48,6 +46,38 @@
|
||||
> 📌 **`0.7.0`(`inkstone/ISEP#30`,2026-08-27)進來一支**:`mainline-idle-guard.sh`(F 組,Stop)。
|
||||
> 48→**49** 支、59→**60** 條,兩個數字都是加完之後當場數出來的(指令同上)。
|
||||
>
|
||||
> 📌 **`inkstone/ISEP#86`/`#87`/`#88`(2026-08-28)進來三支、+7 條**:
|
||||
> `roster-guard.sh`(D 組,`Task`+`Agent` 各一條)、
|
||||
> `diagnosis-evidence-guard.sh`(A 組,Bash)、
|
||||
> `investigate-first-stamp.sh`(H 組,`Agent|Task` 與 `SendMessage` 各一條),
|
||||
> 外加 `dispatch-format-guard.sh` 多掛兩處——Bash(接 `claude -p` 這條路)與
|
||||
> **新的「回覆 subagent」matcher**(`SendMessage` 與雲端 session/trigger 那幾支)。
|
||||
> 53→**56** 支、68→**75** 條,兩個數字都是**在這棵樹上當場數出來的**
|
||||
> (`ls hooks/*.sh | wc -l`/`grep -c '"command":' hooks/hooks.json`),
|
||||
> 不是拿上一版加減推的。順手改掉兩個過期的數字:`README.md` 的「48 支/59 條註冊/23 支腳本」
|
||||
> ——實數是 53/68/35(那是**併之前** main 上的實況),本版之後是 56/75/36。
|
||||
> **同一個病,只是換一個檔案。**
|
||||
>
|
||||
> 📌 **`inkstone/ISEP#93` + `#89`(2026-08-28)進來兩支、+3 條**:
|
||||
> `overdue-nag-guard.sh`(E 組,SessionStart)與 `wiki-size-guard.sh`(E 組 + B 組,兩個事件)。
|
||||
> 58→**60** 支、79→**82** 條——**兩個數字都是在「把 main 併進來之後」的樹上當場數出來的**,
|
||||
> 不是拿上一版加減推的(本頁自己上面就記著「不能靠加減推算、只能實數」)。
|
||||
>
|
||||
> 🔴 **這次併 `hooks.json` 學到一件比數字更重要的事**:它是 JSON,
|
||||
> **合錯了語法照樣合法、閘卻不見了,不會有任何東西喊一聲。**
|
||||
> 「同一個陣列位置兩邊各有一支」時(本次是 `roster-guard` vs `mainline-focus-guard`),
|
||||
> 正解是**展開成兩個項目**,不是二選一——選一邊就會讓一支真的閘靜靜消失。
|
||||
> ⇒ 合完一律再跑一次**逐支點名**(指令寫在 `README.md`「裝什麼」那段),
|
||||
> 並跟兩個父節點做集合對照:**從 main 掉了幾條、從自己這邊掉了幾條、冒出幾條**。
|
||||
> 本次三個答案都是 0。
|
||||
>
|
||||
> 📌 **順手抓到的漂移(每次併都抓到一個,這次是三個)**:
|
||||
> ① 本頁的「一句話結論」被貼成**三份**、標頭被貼成**兩份**(數字還互相打架);
|
||||
> ② `README.md` 的 `hooks/` 那列累積成**四列**,其中兩列是舊數字;
|
||||
> ③ `plugin.json` 描述欄寫「49 支腳本」,而用本頁寫死的那道指令
|
||||
> (`ls -p scripts | grep -v / | wc -l`,只數檔案)在合併後的樹上實數是 **48**。
|
||||
> **同一個病,第 N 次,只是換一欄。**
|
||||
|
||||
> 📌 **`0.10.0`(`inkstone/ISEP#81`,2026-08-28)進來一支**:`pr-verdict-guard.sh`(F 組,Stop)。
|
||||
> 51→**52** 支、64→**65** 條,兩個數字都是加完之後當場數出來的(指令同上)。
|
||||
> 順手改掉一個過期的數字:描述欄長期寫「27 支腳本」,實數是 **34**
|
||||
@@ -94,7 +124,7 @@
|
||||
| `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 用知識庫的**搜尋**找到一筆東西,然後把那筆東西的原始編號直接貼進票裡或派工單裡,當成「某個功能壞了」的證據——而它從沒用正規的檢索路徑去查過那筆——擋一次。你關心的是:不會再有人拿錯的方法量出一個錯的結論,然後退回一張其實已經做完的票。 | 🛑 擋(同一輪只擋一次) |
|
||||
|
||||
| `diagnosis-evidence-guard.sh` | 你想把一段有份量的東西寫上票,而這個 session **沒派人去查過那張票**、那段話裡也**沒有任何走得過去的出處**(檔案:行號/commit/comment 號/票號/網址/貼出來的實測輸出)就擋一次——未經調查的診斷寫在票上會長得像事實,工人會照著它去驗證而不是去查。派過人查、或把出處帶上,都放行;一句短回覆不管。 | 🛑 擋 |
|
||||
## B. AI 想寫檔案/改程式碼的當下(PreToolUse / Write·Edit·MultiEdit)
|
||||
|
||||
| 閘名 | 對你意味著什麼 | 動作 |
|
||||
@@ -107,6 +137,7 @@
|
||||
| `arcrun-intent-guard.sh` | AI 寫的 Arcrun workflow 語法不對就擋下,而且**直接把正確寫法回貼給它**(不是只罵它錯,是教它怎麼改)。 | 🛑 擋(教學型) |
|
||||
| `subagent-first-guard.sh` | 這個對話**從頭到尾都沒有派過任何 subagent**,AI 卻要自己動手改程式碼,就先擋一次,逼它想一想「這件事能不能交給別人做」。 | 🛑 擋 |
|
||||
| `mistake-needs-ticket-guard.sh` | AI 想往 `mistakes.md`(教訓紀錄)新增一條「機制可以防止」的教訓,卻沒附對應票號就擋下——沒有票號的教訓沒有人會回頭處理。 | 🛑 擋 |
|
||||
| `wiki-size-guard.sh` | AI 想**一口氣砍掉 wiki 檔一大半內容**(淨縮水超過 800 字且超過原本 45%)就擋一次——那不是一次編輯,那是一次壓縮,而**壓縮會弄丟東西,弄丟的當下沒有人會發現**。出路是走 `scripts/wiki-compress`:它逼你附票號、把壓掉了什麼寫進 `.compress-log.md`,並用內文雜湊**逐條對帳**證明沒弄丟。只是改字、加字、小修一律不碰;真要手改就在內容裡放 `wiki-compress-ok` 留痕。 | 🛑 擋(至多攔一次) |
|
||||
| `pending-changes-retired.sh` | AI 想寫東西進已經廢除的 `pending-changes.md` 檔案就擋下——這個檔案已停用,規格變更一律改開 Gitea 票。 | 🛑 擋 |
|
||||
|
||||
> `kbdb-api-wall-guard.sh` 在這裡也重複掛了一次(見 A 組)——它同時守著「下指令」跟「寫檔案」兩種情境,詳見下方「重複掛載」一節。
|
||||
@@ -126,6 +157,8 @@
|
||||
| `micromanage-guard.sh` | 派工單寫得太細(指名檔案函式、編號步驟、要求每做一項回報一次…)就擋下——subagent 該被當成有能力的同事,不是照抄劇本的工具。 | 🛑 擋 |
|
||||
| `irreversible-dispatch-guard.sh` | 派工單裡出現「刪分支」「drop table」「rm -rf」這類不可逆動作,卻沒寫「先停下來等回覆才執行」就擋下。 | 🛑 擋 |
|
||||
| `no-ticket-no-dispatch.sh` | 派工單裡沒有寫工單號(`【工單】owner/repo#N`),或那張票已經關閉/根本不存在,就擋下——沒有票號的工作沒有人追得到進度。 | 🛑 擋 |
|
||||
| `dispatch-format-guard.sh` | **派工單 = 票號,就這樣。** 票號以外還寫了別的東西(背景、驗收條件、紅線、交件方式、「這個 session 才知道的事」)就擋下,並告訴你那些內容該搬去哪:每次都一樣的 → 共通規定;這次才知道的 → **寫進那張票**。反過來,合規的派工它會**自動把共通規定注入**給收工方(交件方式、不准 push main、org 是 `inkstone`…),所以你不寫也不會漏。判準是「這一行是不是【工單】欄位」——**在不在**,不是寫了什麼,所以講得再謹慎也不會被多罰。純禮貌收尾(「謝謝」)不算違規;`(→ comment M)` 不論全形或半形括號都認得(ISEP#65 修過一次:CLAUDE.md 自己規定的合格寫法曾經被這支閘自己擋下)。**`ISEP#88` 起同一支閘也掛在「回覆 subagent」那條路上**(`SendMessage`、雲端 session/trigger、以及 Bash 的 `claude -p`)——回覆也是派工,同一套規矩;擋下來時會把那段內容**原文印出來**方便直接貼上票。subagent 往上回報(`to: "main"`)不管,那是交件不是派工。 | 🛑 擋(+注入) |
|
||||
| `roster-guard.sh` | 派工要**指名派給誰**:`Task` 的 `subagent_type` 得是 `agents/` 名單上的名字(`isep-hand`/`arcrun-hand`/`scout`…),沒指名或指了不存在的名字就擋下,並把整份名單印出來。指對了它會**自動把那位工人的檔案原文注入**給它——你是誰、負責哪個 repo、開工前先讀什麼、你的紅線是什麼(這些每個工人都不一樣,共通規定補不了)。名單是資料:加一個工人=在 `agents/` 加一個 `.md`,不必改任何 hook。 | 🛑 擋(+注入) |
|
||||
| `mainline-focus-guard.sh` | 派出去的票**不在「現在的主線」上**,就擋一次,並問你是「補收」(它本來就該掛在主線上)還是「跳線」(真的插件事)。背景:2026-08-27 實查有 **14 個 milestone 同時開著**,其中「Mira 現代化」同名活在 5 個 repo ⇒ SOP 說的「只做該 milestone 的事」在現場**沒有指涉對象**,等於不存在。現在主線是一個查得到的事實(`scripts/mainline`,答案唯一),這支就是那個事實的用處。🔴 **沒有標主線 ⇒ 一律放行**;多張票只要有一張在主線上就放行;查不到那張票掛在哪也放行(**讀不到 ≠ 不屬於**);**同一張票只擋一次**,重送就過。跳線會留一筆紀錄——不留痕的話,跳線與專注在機器上長得一模一樣。 | 🛑 擋(同一張票至多一次) |
|
||||
| `dispatch-format-guard.sh` | **派工單 = 票號,就這樣。** 票號以外還寫了別的東西(背景、驗收條件、紅線、交件方式、「這個 session 才知道的事」)就擋下,並告訴你那些內容該搬去哪:每次都一樣的 → 共通規定;這次才知道的 → **寫進那張票**。反過來,合規的派工它會**自動把共通規定注入**給收工方(交件方式、不准 push main、org 是 `inkstone`…),所以你不寫也不會漏。判準是「這一行是不是【工單】欄位」——**在不在**,不是寫了什麼,所以講得再謹慎也不會被多罰。純禮貌收尾(「謝謝」)不算違規;`(→ comment M)` 不論全形或半形括號都認得(ISEP#65 修過一次:CLAUDE.md 自己規定的合格寫法曾經被這支閘自己擋下)。 | 🛑 擋(+注入) |
|
||||
|
||||
@@ -148,6 +181,8 @@
|
||||
| `isep-presence-beacon.sh` | 對話一開始印一行 `🟢 ISEP vX.Y.Z 已載入(N 支閘|來源:…)`。**這行不是裝飾,是唯一能證明「這個 session 真的有閘」的東西**——它自己就住在 plugin 裡,看得到它就表示 plugin 載入了;某個 session 從頭到尾沒有這行,那個 session 是零閘狀態,先修 plugin 再做事。同一台機器可能同時有兩份 ISEP(marketplace 裝的、repo 裡 vendor 的),所以那行會講出這次是哪一份在說話。 | 📝 記錄(context 注入) |
|
||||
| `session-start-recall.sh` | 對話一開始就自動把「全局現況」(Gitea 各 repo 的票、KBDB 的藏書地圖)推到 AI 眼前,不必等它自己想到要查。 | 📝 記錄(context 注入) |
|
||||
| `scripts/mainline refresh` | (不是閘,是腳本)對話一開始把**現在的主線**那條 milestone 的進度與期限更新一次,好讓每回合眼前那一行講的是今天的數字。拿不到 Gitea 就原封不動——**寧可資料舊,不要把主線弄丟**。跑一次就結束,不輪詢。 | 📝 記錄 |
|
||||
| `overdue-nag-guard.sh` | 對話一開始就去 Gitea 撈**沒有人會叫的事**:逾期的 milestone、掛著等你的票(標「等了幾天」)、標著「有人在做」卻好幾天沒動的票;撈完用白話講出來,**有事就發 Telegram 給你**。沒東西可報時它會說「查過了,沒有」——**安靜跟壞掉長得一模一樣**。發不出去時不會靜默:它會先問這個 session 的 `prod-write-guard` 會不會擋(舊版把「發通知」誤認成「部署」),擋就改貼回票上並把原文印在眼前。只在開 session 時跑一次,**不輪詢、不掛排程**。 | 📝 記錄(context 注入 + Telegram) |
|
||||
| `wiki-size-guard.sh` | 對話一開始講出「哪幾個 wiki 檔已經沒有人讀得完了」(預設超過 1200 行就點名)。**讀不完的必讀檔,跟沒有那個檔的差別只在於它讓人以為有。** 另一半掛在寫檔上,見 B 組。 | 📝 記錄(context 注入) |
|
||||
| `skill-deploy-drift-guard.sh` | 如果「全機真正在用的 skill」跟「repo 裡版控的正本」內容對不上,就在開場講出來——避免用著一份沒人知道已經跟正本分家的舊拷貝。 | 📝 記錄 |
|
||||
|
||||
## F. AI 想結束這一輪、要收工的時候(Stop)
|
||||
@@ -187,7 +222,7 @@
|
||||
| `issue-status-autoflip.sh` | AI 一派工出去,就自動把對應的 Gitea 票改成「進行中(s/doing)」,不必等人手動改標籤。 | 📝 記錄(自動改票) |
|
||||
| `baton-handback-guard.sh` | 一條派工線做完了,就去看它那張票有沒有**指派給人/標籤有沒有說它卡在哪/有沒有寫下一步**,缺哪一格就當場說出來。你關心的是:不會再有票做完了卻沒人接手,躺在那裡沒人發現。 | 📝 記錄(提醒,不擋) |
|
||||
| `kbdb-evidence-stamp.sh` | AI 每次去知識庫拿資料,就記一筆「這是**搜尋**搜到的,還是**走正規檢索路徑**拿到的」。你關心的是:它等一下要拿這筆資料下結論時,有東西可以對照它走的是哪條路。 | 📝 記錄 |
|
||||
|
||||
| `investigate-first-stamp.sh` | 你真的**派人去查某張票**之後,按「session + 票號」留一個時間戳——給 `diagnosis-evidence-guard.sh` 判斷「這件事有沒有人查過」用。按票分而不是按 session 分:早上派人查了 A 票,不該等於解鎖了 B 票的診斷。 | 📝 記錄 |
|
||||
## I. 你會撞到但跟「派工/收工」無關的一支(Edit·MultiEdit)
|
||||
|
||||
| 閘名 | 對你意味著什麼 | 動作 |
|
||||
@@ -287,7 +322,7 @@ A1 缺人話、A2 表格有幽靈、C 全頁有幽靈。
|
||||
|
||||
**這次順便抓到的另一個落差**(🕐 **這一段是 2026-08-20 的快照,裡面每個數字都是那天的,不是現況**——
|
||||
留著是因為它是「兩份文件的數字不會自己跟上」這個病的第一個案例;現況數字看本頁標頭):
|
||||
`.claude-plugin/plugin.json` 的說明文字寫「55 支機械閘(52 條註冊)」,
|
||||
`.claude-plugin/plugin.json` 的說明文字寫「58 支機械閘(52 條註冊)」,
|
||||
`README.md` 也寫「42 支 + `hooks.json`」「共 52 條註冊」——但實測是 **43 支檔案、53 條註冊**。
|
||||
差 1 支、差 1 條,猜測是今天(2026-08-20)新增的 `leo21c-write-guard.sh`/`release-tag-guard.sh`/
|
||||
`ticket-api-bypass-guard.sh` 這批(檔頭日期都是今天)加了之後,兩份文件的數字沒有跟著更新。
|
||||
|
||||
Executable
+190
@@ -0,0 +1,190 @@
|
||||
#!/usr/bin/env bash
|
||||
# 管什麼: 要把一段有份量的東西寫上票,而這個 session **沒派人查過那張票**、
|
||||
# 那段話裡也**沒有任何走得過去的出處** → 擋一次。
|
||||
# 為什麼: 未經調查的診斷寫在票上就長得像事實,工人會照著它去驗證而不是去查——
|
||||
# 等於用權威把那個 repo 真正的專家的判斷力關掉。
|
||||
# 誤觸時怎麼關: 把出處補上(檔案:行號/commit/comment 號/票號/網址/貼實測輸出),
|
||||
# 或指令裡加 `evidence-ok`(留痕)。同一 session 同一張票只擋一次。
|
||||
#
|
||||
# diagnosis-evidence-guard.sh — 未經調查不寫診斷(PreToolUse: Bash;inkstone/ISEP#87)
|
||||
#
|
||||
# ━━ 來由(leo,inkstone/ISEP#87)━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
# 「身為派人去查的人,我要自己在拿到調查結果前寫不出診斷,
|
||||
# 我才不會用猜的結論把工人的判斷力關掉。」
|
||||
# 「**票裡寫未經調查的診斷**」是 2026-08-27 當天列出的錯之一。
|
||||
#
|
||||
# ⇒ 順序倒過來:**先派人查 → 拿到查的結果 → 才可以寫診斷。**
|
||||
# 我的診斷只能建立在別人查回來的東西上面,不能建立在我的印象上面。
|
||||
#
|
||||
# ━━ 這支跟既有那兩支的分界(三支不重疊)━━━━━━━━━━━━━━━━━━━━━
|
||||
# `subagent-first-guard.sh` 鐵律 2 的**前半**:要改 code 卻一次工都沒派過
|
||||
# `micromanage-guard.sh` 替對方決定**做法**(給指令照跑、指名檔案函式…)
|
||||
# **本支** 替對方決定**答案**(把還沒查證的結論寫進票)
|
||||
# 前兩支都不會在「寫東西上票」這個動作上開口,所以不會兩支同時開罵。
|
||||
#
|
||||
# ━━ 🔴 判準:三個結構訊號,一個關鍵字都沒有 ━━━━━━━━━━━━━━━━━━━
|
||||
# 票上寫死:「『根因』『因為』『應該是』這類詞**一律不准當判準**」
|
||||
# (leo 2026-08-17 已證偽文字層封路:當日 8 次誤攔、0 次正確攔截,
|
||||
# 而且**紅線寫得越細,命中關鍵字的機率越高 ⇒ 那些閘在懲罰謹慎**)。
|
||||
#
|
||||
# 三個訊號,全部是結構:
|
||||
# ① **這個 session 有沒有派人查過那張票**
|
||||
# —— `investigate-first-stamp.sh` 的戳記(在不在,不是寫什麼)
|
||||
# ② **這段話裡有沒有走得過去的出處**
|
||||
# —— 檔案:行號/commit/comment 號/票號/網址/檔案路徑/貼出來的實測輸出。
|
||||
# 方向是**放行**(認出來就放行),與黑名單相反:
|
||||
# 比對錯了只會**少擋一次**,不會多罰一次(同 pr-verdict-guard ③)。
|
||||
# ③ **這段話有沒有份量**(扣掉身份欄與空白後的字數,門檻 60 字,
|
||||
# `ISEP_DIAGNOSIS_FLOOR` 可調)
|
||||
# —— 一句「收到,我來看」(6 字)不可能是診斷。長度是結構,不是措辭。
|
||||
# 門檻怎麼定的:拿一段真的診斷(「根因是 KV 額度爆掉⋯⋯」)實量 **73 字**,
|
||||
# 拿最短的合理回覆實量 **6 字**——60 落在兩者之間,靠短的那邊。
|
||||
# **不是猜的,是量過的**(第一版寫 120,那段真的診斷就漏抓了)。
|
||||
#
|
||||
# 三個訊號的組合:①有 ⇒ 放行(查過了)。①無 + ②有 ⇒ 放行(轉述有出處,
|
||||
# 票上驗收條件 4 指明不能誤擋)。①無 + ②無 + ③夠長 ⇒ 擋一次。
|
||||
#
|
||||
# ━━ 誠實邊界(做不到的事就寫出來)━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
# · PreToolUse 只看得到指令文字與 `-F` 指到的檔(同 comment-carries-task-guard)。
|
||||
# · 本閘**認不出「這段話是不是診斷」**——沒有任何機器認得出來,
|
||||
# 而想認出來的那條路(句型比對)已經被證偽。它認的是
|
||||
# 「**一段有份量、卻沒有任何出處、也沒人去查過**」的東西。
|
||||
# 漏抓的情況:診斷寫得很短、或診斷裡剛好夾著一個無關的網址。
|
||||
# 這是刻意的取捨——**誤攔比漏擋嚴重**,一支天天誤擋的閘會被學會忽略。
|
||||
#
|
||||
# 逃生口:指令裡加 `evidence-ok`(會留在指令歷史上)。同 session 同票只擋一次。
|
||||
#
|
||||
# 測試:hooks/tests/diagnosis-evidence-guard.test.sh(離線、不打網路、不留測試票)
|
||||
set -uo pipefail
|
||||
|
||||
INPUT=$(cat)
|
||||
HERE="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)"
|
||||
|
||||
VERDICT=$(printf '%s' "$INPUT" | DEG_LIB="$HERE/lib" python3 -c '
|
||||
import json, os, re, sys
|
||||
sys.path.insert(0, os.environ.get("DEG_LIB", ""))
|
||||
try:
|
||||
import evidence
|
||||
except Exception:
|
||||
print("SKIP:no-lib"); raise SystemExit
|
||||
|
||||
try:
|
||||
d = json.load(sys.stdin)
|
||||
except Exception:
|
||||
print("SKIP:bad-payload"); raise SystemExit
|
||||
|
||||
cmd = (d.get("tool_input") or {}).get("command") or ""
|
||||
if not cmd:
|
||||
print("SKIP:no-cmd"); raise SystemExit
|
||||
if "evidence-ok" in cmd: # 逃生口(留痕)
|
||||
print("SKIP:escape"); raise SystemExit
|
||||
|
||||
sid = re.sub(r"[^A-Za-z0-9_.-]", "_", str(d.get("session_id") or "nosid"))[:64]
|
||||
|
||||
# ── ① 這是不是「把東西寫上票」的動作 ────────────────────────────────
|
||||
# 兩條路都要認(`ticket-api-bypass-guard.sh` 檔頭記過那一課:
|
||||
# 「認動作的方式漏了一條路」是這一族的通病)。
|
||||
target = None # (owner, repo, num) 或 ("", repo_or_?, "new")
|
||||
m = re.search(r"scripts/ticket\s+(\w+)", cmd) or re.search(r"\bticket\s+(say|new|decide|subtask)\b", cmd)
|
||||
verb = m.group(1) if m else ""
|
||||
if verb in ("say", "decide", "subtask"):
|
||||
r = re.search(r"([A-Za-z0-9_.-]+)/([A-Za-z0-9_.-]+)#(\d+)", cmd)
|
||||
target = (r.group(1), r.group(2), r.group(3)) if r else ("", "", "?")
|
||||
elif verb == "new":
|
||||
r = re.search(r"ticket\s+new\s+([\w.-]+)", cmd)
|
||||
target = ("inkstone", r.group(1) if r else "?", "new")
|
||||
else:
|
||||
is_post = re.search(r"\bPOST\b", cmd) or re.search(r"requests\.post|urlopen|\.post\(", cmd)
|
||||
c = re.search(r"/repos/([\w.-]+)/([\w.-]+)/issues/(\d+)/comments", cmd)
|
||||
n = re.search(r"/repos/([\w.-]+)/([\w.-]+)/issues\b", cmd)
|
||||
if is_post and c:
|
||||
target = (c.group(1), c.group(2), c.group(3))
|
||||
elif is_post and n:
|
||||
target = (n.group(1), n.group(2), "new")
|
||||
|
||||
if not target:
|
||||
print("SKIP:not-a-ticket-write"); raise SystemExit
|
||||
|
||||
owner, repo, num = target
|
||||
|
||||
# ── ② 這個 session 派過人去查那張票了嗎(戳記在不在)──────────────────
|
||||
stamp_dir = os.environ.get("ISEP_STAMP_DIR", "/tmp")
|
||||
if num not in ("new", "?"):
|
||||
if os.path.exists(os.path.join(stamp_dir, ".investigated-%s-%s_%s_%s"
|
||||
% (sid, owner, repo, num))):
|
||||
print("SKIP:investigated"); raise SystemExit
|
||||
|
||||
body, src = evidence.body_of(cmd)
|
||||
|
||||
# ── ③ 有出處嗎(放行方向的比對)──────────────────────────────────
|
||||
hits = evidence.sources(body)
|
||||
if hits:
|
||||
print("SKIP:has-source:%s" % hits[0][0]); raise SystemExit
|
||||
|
||||
# ── ④ 有份量嗎(長度是結構,不是措辭)────────────────────────────
|
||||
n_chars = evidence.substance(body)
|
||||
try:
|
||||
floor = int(os.environ.get("ISEP_DIAGNOSIS_FLOOR", "60"))
|
||||
except Exception:
|
||||
floor = 60
|
||||
if n_chars < floor:
|
||||
print("SKIP:too-short:%d" % n_chars); raise SystemExit
|
||||
|
||||
print("FIRE::" + json.dumps({"owner": owner, "repo": repo, "num": num,
|
||||
"chars": n_chars, "src": src, "sid": sid},
|
||||
ensure_ascii=False))
|
||||
' 2>/dev/null) || VERDICT="SKIP:hook-error"
|
||||
[ -n "${VERDICT:-}" ] || VERDICT="SKIP:empty"
|
||||
|
||||
case "$VERDICT" in
|
||||
FIRE::*) ;;
|
||||
*) exit 0 ;;
|
||||
esac
|
||||
|
||||
REF=$(printf '%s' "${VERDICT#FIRE::}" | python3 -c '
|
||||
import json, sys
|
||||
d = json.load(sys.stdin)
|
||||
print("%s/%s#%s" % (d["owner"], d["repo"], d["num"]) if d["num"] not in ("new", "?")
|
||||
else ("新的一張票(%s)" % d["repo"]))
|
||||
' 2>/dev/null)
|
||||
SID=$(printf '%s' "${VERDICT#FIRE::}" | python3 -c 'import json,sys; print(json.load(sys.stdin)["sid"])' 2>/dev/null)
|
||||
KEY=$(printf '%s' "$REF" | tr -c 'A-Za-z0-9' '_')
|
||||
WARNED="${ISEP_STAMP_DIR:-/tmp}/.diagnosis-guard-warned-$SID$KEY"
|
||||
[ -f "$WARNED" ] && exit 0 # 同一 session 同一張票只擋一次,不鬼打牆
|
||||
date +%s > "$WARNED" 2>/dev/null || true
|
||||
|
||||
DEG_REF="$REF" DEG_SID="$SID" python3 - <<'PY' >&2
|
||||
import os
|
||||
print("""🔎 這段要寫上 %s 的東西,**沒有人查過,也沒有任何出處**。
|
||||
|
||||
【inkstone/ISEP#87】「身為派人去查的人,我要自己在**拿到調查結果前寫不出診斷**,
|
||||
我才不會用猜的結論把工人的判斷力關掉。」
|
||||
|
||||
· 診斷寫完了,**工人就不知道自己要做什麼**——它會照著你的結論去驗證,而不是去查
|
||||
· **你不是那個 repo 的專家,它才是**。你的結論通常是猜的,
|
||||
但因為寫在票上,看起來就像事實
|
||||
|
||||
━━ 為什麼是現在擋你(三個結構訊號,一個關鍵字都沒有)━━━━━━━━━━━━
|
||||
① 這個 session **沒有派人去查這張票**(`investigate-first-stamp` 沒有戳記)
|
||||
② 這段話裡**找不到任何走得過去的出處**
|
||||
③ 而它**有份量**(不是一句「收到」)
|
||||
|
||||
━━ 兩條出路,選一條 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
A. **先派人去查**(順序倒過來,這是本票要的)
|
||||
派工單就一行:【工單】%s
|
||||
它查完貼回票上,你再寫診斷——那時①就成立了。
|
||||
|
||||
B. **這段本來就是轉述別人查到的,那就把出處帶上**(帶了就不會被擋)
|
||||
檔案:行號 hooks/lib/evidence.py:42
|
||||
commit 8e28041
|
||||
comment 號 inkstone/ISEP#87 → comment 4327
|
||||
網址/票號 https://git.uncle6.me/...
|
||||
實測輸出 用 ``` 圍欄把指令與輸出原樣貼上
|
||||
|
||||
🔴 出處不是格式要求,是**讀的人可以自己走過去確認**。
|
||||
「我記得是這樣」與「它就在那一行」的差別,只有這個分得出來。
|
||||
|
||||
真的有例外 → 指令裡加 `evidence-ok` 重送(會留在指令歷史上)。
|
||||
同一張票本 session 只擋這一次。""" % (os.environ.get("DEG_REF", ""), os.environ.get("DEG_REF", "")))
|
||||
PY
|
||||
exit 2
|
||||
@@ -59,6 +59,25 @@
|
||||
# touch /tmp/.dispatch-ok-<session_id>
|
||||
# 閘是絆一下逼你重想,不是把人關起來。
|
||||
#
|
||||
# ━━ 攔截點不只一個(inkstone/ISEP#88,2026-08-27 補)━━━━━━━━━━━━━━
|
||||
# 第一版只掛 `PreToolUse(Task|Agent)` ⇒ 只涵蓋「**新開**一個 subagent」。
|
||||
# **回覆一個正在跑的 subagent 走的是別的工具,根本不經過那個攔截點。**
|
||||
# 實況:第一次派工乾乾淨淨只有票號,中途回覆時又把一長串修改要求丟過去,
|
||||
# **那些話票上一個字都沒有**——那條線被停掉就消失,正是本閘要解的病。
|
||||
#
|
||||
# ⇒ 不另造一支平行的閘(票上寫死):同一支閘、同一個判斷函式,
|
||||
# 只是把所有通往 subagent 的路一次列全。那張表在
|
||||
# `hooks/lib/dispatch_parse.py` 的 `tool_channel()`——新增一條路加在那裡。
|
||||
#
|
||||
# 兩條路只有一個地方不同:
|
||||
# · `dispatch`(Task/Agent)沒有【工單】 ⇒ 放行,那是 no-ticket-no-dispatch 的地盤
|
||||
# · `reply`(SendMessage/雲端 session/`claude -p`…)沒有【工單】 ⇒ **擋**,
|
||||
# 因為那支閘沒掛在這些路上,不擋就是整條裸奔
|
||||
# 刻意不管的方向:subagent 往上回報(`SendMessage to: "main"`)=交件不是派工。
|
||||
#
|
||||
# 擋下來的時候會**把那段內容原文印出來**,方便直接貼上票——
|
||||
# 因為這條規則的出路就是「把它寫進票」,而不印出來就得請人回頭自己找。
|
||||
#
|
||||
# 測試:hooks/tests/dispatch-format-guard.test.sh(離線、不打網路、不花錢)
|
||||
set -u
|
||||
|
||||
@@ -68,24 +87,45 @@ HERE="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)"
|
||||
FACTS="$(printf '%s' "$INPUT" | python3 "$HERE/lib/dispatch_parse.py" dispatch 2>/dev/null)"
|
||||
[ -n "$FACTS" ] || exit 0 # parser 掛了 → fail-open(回到「沒有這支閘」的狀態)
|
||||
|
||||
STATUS="$(printf '%s' "$FACTS" | python3 -c '
|
||||
# 🔴 四個欄位一次讀完,不要每個欄位各開一支 python。
|
||||
# 這不是效率問題,是**正確性**問題:每一次 `python3 -c` 都是一個新行程,
|
||||
# 而每一次都用 `2>/dev/null` 吞掉錯誤 ⇒ 機器忙的時候只要有一次生不出行程,
|
||||
# 那個欄位就靜靜變成空字串,閘的行為跟著變——**而且不會有任何痕跡**。
|
||||
# 實測(2026-08-28,本 repo 的測試連跑 10 次):`SESSION` 偶爾拿不到,
|
||||
# 於是「明示豁免戳記」那條路 **10 次裡失敗 1 次**,症狀是「豁免了卻還是被擋」。
|
||||
# 一次讀完 ⇒ 要嘛四個都拿到、要嘛全空(全空=fail-open,回到沒有這支閘的狀態)。
|
||||
FIELDS="$(printf '%s' "$FACTS" | python3 -c '
|
||||
import sys, json
|
||||
try: print(json.load(sys.stdin).get("status", "skip"))
|
||||
except Exception: print("skip")
|
||||
try:
|
||||
d = json.load(sys.stdin)
|
||||
except Exception:
|
||||
d = {}
|
||||
print(d.get("status", "skip"))
|
||||
print(d.get("session_id", "") or "")
|
||||
print(len(d.get("violations", []) or []))
|
||||
print(d.get("channel", "dispatch") or "dispatch")
|
||||
' 2>/dev/null)"
|
||||
|
||||
STATUS=""; SESSION=""; NVIOL=""; CHANNEL=""
|
||||
{
|
||||
IFS= read -r STATUS || true
|
||||
IFS= read -r SESSION || true
|
||||
IFS= read -r NVIOL || true
|
||||
IFS= read -r CHANNEL || true
|
||||
} <<EOF
|
||||
$FIELDS
|
||||
EOF
|
||||
|
||||
[ "$STATUS" = "ok" ] || exit 0
|
||||
[ -n "$NVIOL" ] || NVIOL=0
|
||||
[ -n "$CHANNEL" ] || CHANNEL=dispatch
|
||||
|
||||
SESSION="$(printf '%s' "$FACTS" | python3 -c '
|
||||
import sys, json
|
||||
try: print(json.load(sys.stdin).get("session_id", ""))
|
||||
except Exception: print("")
|
||||
' 2>/dev/null)"
|
||||
|
||||
NVIOL="$(printf '%s' "$FACTS" | python3 -c '
|
||||
import sys, json
|
||||
try: print(len(json.load(sys.stdin).get("violations", [])))
|
||||
except Exception: print(0)
|
||||
' 2>/dev/null)"
|
||||
# 子 session(subagent)送出的**回覆**不管——它往上送的是交件不是派工
|
||||
# (見上面「刻意不管的方向」)。**只放掉 reply 這條路**:
|
||||
# subagent 自己再往下派工(Task)時,派工單的規矩照樣管它。
|
||||
if [ "$CHANNEL" = "reply" ] && [ "${CLAUDE_CODE_CHILD_SESSION:-}" = "1" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ── 明示豁免(留痕用,用掉就消失,不是永久開關)────────────────────────
|
||||
if [ "$NVIOL" != "0" ] && [ -n "$SESSION" ] && [ -f "/tmp/.dispatch-ok-$SESSION" ]; then
|
||||
@@ -99,9 +139,10 @@ if [ "$NVIOL" != "0" ]; then
|
||||
# (2026-08-26 ask-user-question-guard 真的犯過:訊息裡的反引號被當命令跑,
|
||||
# 閘照擋,但**它教人怎麼解的那兩行變成空白**,等於沒有出路。)
|
||||
MSG="$(cat <<'EOF'
|
||||
🚫 派工單 = 票號。就這樣。(leo 2026-08-27,inkstone/ISEP#30 comment 4327)
|
||||
🚫 __HEADLINE__(leo 2026-08-27,inkstone/ISEP#30 comment 4327)
|
||||
|
||||
__VIOLATIONS__
|
||||
__CARRY__
|
||||
|
||||
━━ 這些內容該去哪:問一句「**這句話換一張票還成立嗎?**」━━━━━━━━━━
|
||||
|
||||
@@ -145,11 +186,34 @@ for name in d.get("retired_seen", []):
|
||||
print("\n".join(lines))
|
||||
' 2>/dev/null)"
|
||||
|
||||
DFG_MSG="$MSG" DFG_VIOL="$VIOL_TEXT" DFG_SESSION="$SESSION" python3 -c '
|
||||
# 擋下來的時候把那段內容原文印出來——出路是「貼上票」,
|
||||
# 不印出來就得請人回頭自己找(inkstone/ISEP#88 驗收條件 1)。
|
||||
CARRY_TEXT="$(printf '%s' "$FACTS" | python3 -c '
|
||||
import sys, json
|
||||
try:
|
||||
t = (json.load(sys.stdin).get("extra_text") or "").strip()
|
||||
except Exception:
|
||||
t = ""
|
||||
if t:
|
||||
print("━━ 這段內容原文在這裡,直接貼上票 ━━━━━━━━━━━━━━━━━━━━━━━━\n")
|
||||
print(t)
|
||||
print()
|
||||
' 2>/dev/null)"
|
||||
|
||||
if [ "$CHANNEL" = "reply" ]; then
|
||||
HEADLINE="回覆也是派工:內容寫進票,訊息只給票號"
|
||||
else
|
||||
HEADLINE="派工單 = 票號。就這樣。"
|
||||
fi
|
||||
|
||||
DFG_MSG="$MSG" DFG_VIOL="$VIOL_TEXT" DFG_SESSION="$SESSION" \
|
||||
DFG_HEAD="$HEADLINE" DFG_CARRY="$CARRY_TEXT" python3 -c '
|
||||
import os, sys
|
||||
sys.stderr.write(
|
||||
os.environ["DFG_MSG"]
|
||||
.replace("__HEADLINE__", os.environ.get("DFG_HEAD", ""))
|
||||
.replace("__VIOLATIONS__", os.environ.get("DFG_VIOL", ""))
|
||||
.replace("__CARRY__", os.environ.get("DFG_CARRY", ""))
|
||||
.replace("__SESSION__", os.environ.get("DFG_SESSION", "") or "<session_id>")
|
||||
+ "\n"
|
||||
)
|
||||
@@ -157,6 +221,10 @@ sys.stderr.write(
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# 回覆這條路不注入共通規定——收工方早在第一次派工時就拿過了,
|
||||
# 每一則回覆再灌一次只是噪音(而噪音會讓人學會忽略整段注入)。
|
||||
[ "$CHANNEL" = "reply" ] && exit 0
|
||||
|
||||
# ── 合規 → 注入共通規定 ──────────────────────────────────────────────
|
||||
# 這是「派工單只剩票號」的另一半:那些每次都一樣的規定不必有人記得寫,機器每次都補。
|
||||
# 來源是 docs/governance/dispatch-and-reply-format.md 的 INJECT 區段——
|
||||
|
||||
@@ -60,6 +60,14 @@
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/search-is-not-proof-guard.sh"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/diagnosis-evidence-guard.sh"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/dispatch-format-guard.sh"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -114,6 +122,10 @@
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/pending-changes-retired.sh"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/wiki-size-guard.sh"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -153,6 +165,10 @@
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/dispatch-format-guard.sh"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/roster-guard.sh"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/mainline-focus-guard.sh"
|
||||
@@ -200,6 +216,10 @@
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/dispatch-format-guard.sh"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/roster-guard.sh"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/mainline-focus-guard.sh"
|
||||
@@ -210,6 +230,15 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "SendMessage|.*Claude_Code_Remote__(send_message|create_session|create_trigger|update_trigger|fire_trigger|send_later).*",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/dispatch-format-guard.sh"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "AskUserQuestion",
|
||||
"hooks": [
|
||||
@@ -243,6 +272,14 @@
|
||||
{
|
||||
"type": "command",
|
||||
"command": "\"${CLAUDE_PLUGIN_ROOT}\"/scripts/mainline refresh"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/overdue-nag-guard.sh"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/wiki-size-guard.sh"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -385,6 +422,10 @@
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/subagent-first-stamp.sh"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/investigate-first-stamp.sh"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/issue-status-autoflip.sh"
|
||||
@@ -394,6 +435,15 @@
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/baton-handback-guard.sh"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "SendMessage",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/investigate-first-stamp.sh"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"UserPromptSubmit": [
|
||||
|
||||
Executable
+46
@@ -0,0 +1,46 @@
|
||||
#!/bin/sh
|
||||
# investigate-first-stamp.sh — PostToolUse(Agent|Task|SendMessage):
|
||||
# 記下「這個 session 真的**派人去查過那張票**了」。
|
||||
#
|
||||
# 配 diagnosis-evidence-guard.sh 使用(形狀抄自 subagent-first-stamp.sh/kbdb-asked-stamp.sh
|
||||
# ——inkstone/ISEP#87 票上就是指定照那個形狀做)。只留時戳,不擋任何東西。
|
||||
#
|
||||
# 🔴 為什麼按「session + 票號」分開存,不是只按 session:
|
||||
# subagent-first-stamp 回答的是「這一輪有沒有派過工」(一個 session 一個答案)。
|
||||
# 本支要回答的是「**這件事**有沒有派人查過」——那是**每張票各自**的問題。
|
||||
# 只按 session 存的話,早上派過一次去查 A 票,下午就等於解鎖了 B 票的診斷,
|
||||
# 這道閘會在第二次派工之後完全失效。
|
||||
set -eu
|
||||
|
||||
INPUT=$(cat 2>/dev/null || echo '{}')
|
||||
HERE=$(cd "$(dirname "$0")" && pwd)
|
||||
|
||||
printf '%s' "$INPUT" | STAMP_LIB="$HERE/lib" python3 -c '
|
||||
import json, os, re, sys, time
|
||||
sys.path.insert(0, os.environ.get("STAMP_LIB", ""))
|
||||
try:
|
||||
import dispatch_parse
|
||||
except Exception:
|
||||
raise SystemExit
|
||||
try:
|
||||
d = json.load(sys.stdin)
|
||||
except Exception:
|
||||
raise SystemExit
|
||||
|
||||
sid = re.sub(r"[^A-Za-z0-9_.-]", "_", str(d.get("session_id") or "nosid"))[:64]
|
||||
channel, text = dispatch_parse.tool_channel(d)
|
||||
if not channel:
|
||||
raise SystemExit
|
||||
for ref in dispatch_parse.parse_dispatch(text)["refs"]:
|
||||
# ISEP_STAMP_DIR 只給測試用(不碰 /tmp 的正式戳記)——
|
||||
# 與 diagnosis-evidence-guard.sh 讀的是同一個變數,兩支要一起改。
|
||||
stamp_dir = os.environ.get("ISEP_STAMP_DIR", "/tmp")
|
||||
path = os.path.join(stamp_dir, ".investigated-%s-%s_%s_%s"
|
||||
% (sid, ref["owner"], ref["repo"], ref["num"]))
|
||||
try:
|
||||
with open(path, "w") as f:
|
||||
f.write(str(int(time.time())))
|
||||
except Exception:
|
||||
pass
|
||||
' 2>/dev/null || true
|
||||
exit 0
|
||||
+169
-10
@@ -59,6 +59,37 @@
|
||||
第一行就要認得出是誰寫的。角色是三選一的允許清單:`總管`/`subagent`/`leo`。
|
||||
2026-08-27 實害:總管寫的診斷被當成 subagent 的結論,而其中一則是錯的。
|
||||
|
||||
━━ 通往 subagent 的路不只一條(inkstone/ISEP#88)━━━━━━━━━━━━━━━━
|
||||
|
||||
第一版的閘只掛在 `PreToolUse(Task|Agent)`——那只涵蓋「**新開**一個 subagent」。
|
||||
**回覆一個正在跑的 subagent 走的是別的工具,根本不經過那個攔截點。**
|
||||
實況(2026-08-27 本 session):第一次派工乾乾淨淨只有票號,
|
||||
中途回覆時又把一長串修改要求直接丟過去,**那些話票上一個字都沒有**。
|
||||
那條線被停掉或換人接手,它們就消失——正是壓縮派工單原本要解決的問題。
|
||||
|
||||
⇒ 本檔不再只認一種 payload 形狀。`tool_channel()` 把所有通往 subagent 的路
|
||||
收成一張表(見該函式),一次列全:
|
||||
|
||||
Task / Agent 新開一個 subagent → prompt
|
||||
SendMessage 回覆正在跑的那個 → message
|
||||
*Claude_Code_Remote__create_session 開一個新的雲端 session → prompt
|
||||
*Claude_Code_Remote__send_message 送訊息進某個 session → text / message
|
||||
*Claude_Code_Remote__create_trigger 排程派工(未來會醒) → prompt
|
||||
*Claude_Code_Remote__update_trigger 改排程派工的內容 → prompt
|
||||
*Claude_Code_Remote__fire_trigger 當場點燃排程派工 → text
|
||||
*Claude_Code_Remote__send_later 排一則訊息給自己 → message
|
||||
Bash `claude -p <prompt>` 用 CLI 開一個新 session → 指令裡那段 prompt
|
||||
|
||||
🔴 這張表就是 `ticket-api-bypass-guard.sh` 檔頭記過的那一課的同款:
|
||||
「認動作的方式漏了一條路」是這一族的通病(那支第一版只認大寫裸字 `POST`,
|
||||
於是 `requests.post()` 與 urllib 的隱式 POST 全部漏掉)。
|
||||
⇒ 新增一條路要加在 `tool_channel()`,**不要另造一支平行的閘**。
|
||||
|
||||
兩條路的分界只有一個(其餘判準完全共用,這是刻意的):
|
||||
· `dispatch`(Task/Agent)沒有【工單】 ⇒ **skip**,那是 no-ticket-no-dispatch 的地盤
|
||||
· `reply`(其餘全部)沒有【工單】 ⇒ **違規**,因為那支閘沒有掛在這些路上,
|
||||
不擋就等於這條路整條裸奔
|
||||
|
||||
用法:
|
||||
import dispatch_parse
|
||||
r = dispatch_parse.parse_dispatch(prompt_text)
|
||||
@@ -69,6 +100,7 @@
|
||||
"""
|
||||
import json
|
||||
import re
|
||||
import shlex
|
||||
import sys
|
||||
|
||||
# ── 派工單的字彙表:**只有一個欄位**。這是允許清單,不是黑名單 ──────────────
|
||||
@@ -206,6 +238,22 @@ def dispatch_violations(parsed):
|
||||
return v
|
||||
|
||||
|
||||
def identity_roles():
|
||||
"""合法的身份角色 = 三個固定角色 + **名單上的工人名字**(inkstone/ISEP#86)。
|
||||
|
||||
leo:「票上的紀錄要看得出是哪個工人做的。」`subagent` 這個字回答不了「是誰」——
|
||||
多條線並行時,三個 subagent 的留言長得一模一樣。
|
||||
名單讀不到就退回三個固定角色(fail-open:名單壞掉不該讓人貼不了留言)。
|
||||
"""
|
||||
try:
|
||||
import os
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
import roster
|
||||
return tuple(IDENTITY_ROLES) + tuple(roster.names())
|
||||
except Exception:
|
||||
return tuple(IDENTITY_ROLES)
|
||||
|
||||
|
||||
def parse_identity(body):
|
||||
"""交件回覆的【身份】欄。回 (ok, detail)。"""
|
||||
for raw in (body or "").split("\n"):
|
||||
@@ -218,12 +266,105 @@ def parse_identity(body):
|
||||
if not val:
|
||||
return False, "【身份】後面是空的"
|
||||
role = re.split(r"[//]", val)[0].strip()
|
||||
if role not in IDENTITY_ROLES:
|
||||
return False, "角色「%s」不在 %s 之內" % (role or "(空)", "/".join(IDENTITY_ROLES))
|
||||
allowed = identity_roles()
|
||||
if role not in allowed:
|
||||
return False, "角色「%s」不在 %s 之內" % (role or "(空)", "/".join(allowed))
|
||||
return True, val
|
||||
return False, "內文是空的"
|
||||
|
||||
|
||||
# ── 通往 subagent 的路:一張表,一次列全(inkstone/ISEP#88)────────────────
|
||||
# 新增一條路加在這裡。**不要另造一支平行的閘**——票上寫死了:
|
||||
# 「要嘛把攔截點加掛上去,要嘛讓兩條路共用同一個判斷函式。」
|
||||
#
|
||||
# 值是「這個工具的哪幾個欄位裝著要送給 subagent 的話」,依序取第一個有內容的。
|
||||
_DISPATCH_TOOLS = {
|
||||
"Task": ("prompt",),
|
||||
"Agent": ("prompt",),
|
||||
}
|
||||
_REPLY_TOOLS = {
|
||||
"SendMessage": ("message",),
|
||||
"create_session": ("prompt",),
|
||||
"send_message": ("text", "message"),
|
||||
"create_trigger": ("prompt",),
|
||||
"update_trigger": ("prompt",),
|
||||
"fire_trigger": ("text",),
|
||||
"send_later": ("message",),
|
||||
}
|
||||
# `claude -p <prompt>` = 用 CLI 開一個新 session。判準是**指令名**(結構),
|
||||
# 不是指令內容裡有沒有某個詞——所以一段剛好含有「【工單】」字樣的 echo 不會被誤認。
|
||||
_CLAUDE_CLI_RE = re.compile(r"(?:^|[;&|]\s*|\s)claude\s")
|
||||
_CLI_PRINT_FLAGS = ("-p", "--print")
|
||||
# 這些旗標後面跟的是它自己的值,不是 prompt
|
||||
_CLI_VALUE_FLAGS = {"--model", "-m", "--append-system-prompt", "--system-prompt",
|
||||
"--allowedTools", "--permission-mode", "--output-format",
|
||||
"--input-format", "--session-id", "--resume", "--add-dir",
|
||||
"--mcp-config", "--settings", "--agents"}
|
||||
|
||||
|
||||
def _claude_cli_prompt(command):
|
||||
"""從一段 shell 指令裡把 `claude -p <prompt>` 的 prompt 取出來。取不到回 ""。"""
|
||||
if not command or not _CLAUDE_CLI_RE.search(command):
|
||||
return ""
|
||||
try:
|
||||
toks = shlex.split(command)
|
||||
except Exception:
|
||||
return ""
|
||||
for i, t in enumerate(toks):
|
||||
if t.rsplit("/", 1)[-1] != "claude":
|
||||
continue
|
||||
rest = toks[i + 1:]
|
||||
if not any(f in rest for f in _CLI_PRINT_FLAGS):
|
||||
return "" # 沒有 -p ⇒ 不是一次性的 prompt 執行
|
||||
skip = False
|
||||
for j, tok in enumerate(rest):
|
||||
if skip:
|
||||
skip = False
|
||||
continue
|
||||
if tok in _CLI_VALUE_FLAGS:
|
||||
skip = True
|
||||
continue
|
||||
if tok.startswith("-"):
|
||||
continue
|
||||
return tok # 第一個位置參數 = prompt
|
||||
return ""
|
||||
return ""
|
||||
|
||||
|
||||
def tool_channel(payload):
|
||||
"""回 (channel, text)。channel ∈ {"dispatch", "reply", ""}。
|
||||
|
||||
"" = 這個工具呼叫不是在驅動任何 subagent,本閘管不到。
|
||||
"""
|
||||
name = payload.get("tool_name") or ""
|
||||
ti = payload.get("tool_input") or {}
|
||||
if not isinstance(ti, dict):
|
||||
return "", ""
|
||||
short = name.rsplit("__", 1)[-1]
|
||||
|
||||
if name == "Bash" or short == "Bash":
|
||||
return ("reply", _claude_cli_prompt(ti.get("command") or ""))
|
||||
|
||||
# 🔴 方向很重要:本規則管的是「**派工的人**送出去的話」。
|
||||
# subagent 往上回報(`to: "main"`)是**交件**,不是派工——
|
||||
# 擋它等於擋掉交件本身。交件的規矩由 baton-handback-guard/
|
||||
# reply-identity-guard 管,不在這裡。
|
||||
if (name == "SendMessage" or short == "SendMessage") and \
|
||||
str(ti.get("to") or "").strip().lower() == "main":
|
||||
return "", ""
|
||||
|
||||
for table, channel in ((_DISPATCH_TOOLS, "dispatch"), (_REPLY_TOOLS, "reply")):
|
||||
keys = table.get(name) or table.get(short)
|
||||
if not keys:
|
||||
continue
|
||||
for k in keys:
|
||||
v = ti.get(k)
|
||||
if isinstance(v, str) and v.strip():
|
||||
return channel, v
|
||||
return channel, ""
|
||||
return "", ""
|
||||
|
||||
|
||||
# ── CLI ──────────────────────────────────────────────────────────────────
|
||||
def _main():
|
||||
mode = sys.argv[1] if len(sys.argv) > 1 else "dispatch"
|
||||
@@ -237,21 +378,39 @@ def _main():
|
||||
print(json.dumps({"status": "skip", "why": "未知模式 %s" % mode}))
|
||||
return
|
||||
|
||||
prompt = (payload.get("tool_input") or {}).get("prompt") or ""
|
||||
parsed = parse_dispatch(prompt)
|
||||
|
||||
# 連【工單】都沒有 => 那是 no-ticket-no-dispatch.sh 的地盤,本閘閉嘴。
|
||||
# 兩支閘同時對同一件事開口,收工方會拿到兩份互相打架的教學。
|
||||
if not parsed["has_ticket_marker"]:
|
||||
print(json.dumps({"status": "skip", "why": "沒有【工單】,交給 no-ticket-no-dispatch"}))
|
||||
channel, text = tool_channel(payload)
|
||||
if not channel:
|
||||
print(json.dumps({"status": "skip", "why": "不是通往 subagent 的路"}))
|
||||
return
|
||||
if channel == "reply" and not (text or "").strip():
|
||||
print(json.dumps({"status": "skip", "why": "這條路這次沒有帶任何話"}))
|
||||
return
|
||||
|
||||
parsed = parse_dispatch(text)
|
||||
|
||||
if not parsed["has_ticket_marker"]:
|
||||
if channel == "dispatch":
|
||||
# 連【工單】都沒有 => 那是 no-ticket-no-dispatch.sh 的地盤,本閘閉嘴。
|
||||
# 兩支閘同時對同一件事開口,收工方會拿到兩份互相打架的教學。
|
||||
print(json.dumps({"status": "skip",
|
||||
"why": "沒有【工單】,交給 no-ticket-no-dispatch"}))
|
||||
return
|
||||
# reply 這條路上**沒有**那支閘(它只掛 Task|Agent)⇒ 不擋就整條裸奔。
|
||||
violations = [{"code": "回覆沒有票號",
|
||||
"detail": "回覆也是派工:內容寫進票,訊息只給【工單】owner/repo#N"}]
|
||||
extra_text = (text or "").strip()
|
||||
else:
|
||||
violations = [{"code": c, "detail": d} for c, d in dispatch_violations(parsed)]
|
||||
extra_text = "\n".join(r.rstrip() for _, r in parsed["extra"]).strip()
|
||||
|
||||
print(json.dumps({
|
||||
"status": "ok",
|
||||
"violations": [{"code": c, "detail": d} for c, d in dispatch_violations(parsed)],
|
||||
"channel": channel,
|
||||
"violations": violations,
|
||||
"refs": parsed["refs"],
|
||||
"retired_seen": parsed["retired_seen"],
|
||||
"extra_lines": parsed["extra_lines"],
|
||||
"extra_text": extra_text,
|
||||
"session_id": payload.get("session_id") or "",
|
||||
}, ensure_ascii=False))
|
||||
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
#!/usr/bin/env python3
|
||||
"""hooks/lib/evidence.py — 「這段要寫上票的內容,帶不帶得出出處?」(inkstone/ISEP#87)
|
||||
|
||||
依 inkstone/ISEP#40 S7 的慣例:`lib/` 裡的東西是共用零件,本身不決定 allow/block,
|
||||
判決由呼叫它的閘做(`hooks/diagnosis-evidence-guard.sh`)。
|
||||
|
||||
━━ 規則本身(leo,inkstone/ISEP#87)━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
「身為派人去查的人,我要自己在拿到調查結果前寫不出診斷,
|
||||
我才不會用猜的結論把工人的判斷力關掉。」
|
||||
|
||||
票上原文的兩個壞法:
|
||||
· 診斷寫完了,**工人就不知道自己要做什麼**——它會照著我的結論去驗證,而不是去查
|
||||
· **我不是那個 repo 的專家,它才是**。我的結論通常是猜的,
|
||||
但因為寫在票上,看起來就像事實
|
||||
|
||||
⇒ 順序倒過來:**先派人查 → 拿到查的結果 → 才可以寫診斷。**
|
||||
|
||||
━━ 🔴 為什麼判準不能是「找出診斷的句型」━━━━━━━━━━━━━━━━━━━━
|
||||
票上寫死:「『根因』『因為』『應該是』這類詞**一律不准當判準**」。
|
||||
leo 2026-08-17 已證偽文字層封路:當日 **8 次誤攔、0 次正確攔截**,而且方向穩定——
|
||||
**紅線寫得越細,命中關鍵字的機率越高 ⇒ 那些閘在懲罰謹慎。**
|
||||
|
||||
所以本檔**沒有任何「命中某個詞就違規」的比對**。它只做一件事:
|
||||
**認出「出處」在不在**——而且是**往放行的方向**做字面比對
|
||||
(認出來 ⇒ 放行;認不出來 ⇒ 交給閘去看別的結構訊號)。
|
||||
這是 `pr-verdict-guard.sh` ③ 用過的形狀:識別碼有限且唯一,
|
||||
比對錯了只會**少擋一次**,不會多罰一次。
|
||||
|
||||
━━ 什麼算「出處」(七種,全部是可以走過去看的東西)━━━━━━━━━━━━━━
|
||||
檔案:行號 / commit sha / comment 號 / 票號 / 網址 /
|
||||
檔案路徑(帶目錄與副檔名)/ 圍欄裡的實測輸出
|
||||
|
||||
共同點:**讀的人可以自己走過去確認**。這正是「轉述別人查到的東西」與
|
||||
「我猜的」之間唯一機械分得出來的差別(票上驗收條件 4:有出處的轉述不能被誤擋)。
|
||||
|
||||
用法:
|
||||
import evidence
|
||||
hits = evidence.sources(text) # [(種類, 命中的字串), ...]
|
||||
body = evidence.body_of(command) # 從一段 shell 指令裡把「要貼上去的內文」挖出來
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
|
||||
# ── 七種出處。**方向是放行**:認出來就代表這段話走得回它的來源 ──────────────
|
||||
_SOURCE_PATTERNS = [
|
||||
("檔案:行號", re.compile(r"[\w./-]+\.\w{1,6}:\d+")),
|
||||
("commit", re.compile(r"\b[0-9a-f]{7,40}\b")),
|
||||
("comment 號", re.compile(r"(?:#issuecomment-|comment\s*)\d{2,}", re.I)),
|
||||
("票號", re.compile(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+#\d+")),
|
||||
("網址", re.compile(r"https?://\S{4,}")),
|
||||
("檔案路徑", re.compile(r"(?<![\w/])[\w.-]+/[\w./-]*\w\.\w{1,6}(?![\w])")),
|
||||
("實測輸出", re.compile(r"^\s*(?:```|~~~)", re.M)),
|
||||
]
|
||||
|
||||
# 身份欄本身不算出處——它說的是「誰寫的」,不是「這句話哪來的」。
|
||||
_IDENTITY_LINE_RE = re.compile(r"^【身份】.*$", re.M)
|
||||
|
||||
|
||||
def sources(text):
|
||||
"""回 [(種類, 命中的字串)]。空清單 = 這段話裡沒有任何走得過去的出處。"""
|
||||
if not text:
|
||||
return []
|
||||
body = _IDENTITY_LINE_RE.sub("", text)
|
||||
out = []
|
||||
for kind, pat in _SOURCE_PATTERNS:
|
||||
m = pat.search(body)
|
||||
if m:
|
||||
out.append((kind, m.group(0)[:60]))
|
||||
return out
|
||||
|
||||
|
||||
def substance(text):
|
||||
"""扣掉身份欄與空白之後還剩多少字。
|
||||
|
||||
**長度是結構訊號,不是措辭**:一句「收到,我來看」不可能是診斷;
|
||||
診斷天生是一段有因果的敘述。用它把閘收窄,是為了不要天天誤擋
|
||||
——`.claude/branch-holds.md` 檔頭那句:**永遠在響的警報,等於訓練人忽略這個警報。**
|
||||
"""
|
||||
if not text:
|
||||
return 0
|
||||
body = _IDENTITY_LINE_RE.sub("", text)
|
||||
return len(re.sub(r"\s+", "", body))
|
||||
|
||||
|
||||
# ── 從一段 shell 指令裡把「要貼上去的內文」挖出來 ────────────────────────────
|
||||
# 做法與 comment-carries-task-guard.sh 相同:有 `-F <檔>` 就讀那個檔,
|
||||
# 否則就拿指令文字本身當內文(PreToolUse 只看得到指令,這是這一層的天花板)。
|
||||
# 🔴 一定要把「真正要貼上去的那段字」跟指令的鷹架分開。第一版沒分,
|
||||
# 結果 `curl … https://git.uncle6.me/api/v1/…/comments -d '{"body":"<沒出處的診斷>"}'`
|
||||
# **永遠放行**——因為指令裡的**那個 URL 自己就被當成出處了**。
|
||||
# 同一個病 `ticket-api-bypass-guard.sh` 檔頭也記過:認錯了要看的東西,
|
||||
# 閘就在驗一個跟規則無關的欄位。
|
||||
_FARG_RE = re.compile(r"(?:^|\s)(?:-F|--file)\s+([^\s'\"]+)")
|
||||
_BODY_FLAG_RE = re.compile(r"(?:^|\s)--body[\s=]+(['\"])(.*?)\1", re.S)
|
||||
_JSON_BODY_RE = re.compile(r"""['\"]body['\"]\s*:\s*['\"](.*?)['\"]\s*[,}]""", re.S)
|
||||
|
||||
|
||||
def body_of(command):
|
||||
"""回 (內文, 來源)。來源 ∈ {"file", "--body", "json", "command"}。
|
||||
|
||||
依序試四種:`-F <檔>` → `--body <字串>` → payload 裡的 `body` 欄 → 整段指令。
|
||||
前三種都是「這段字才是要貼上票的東西」,指令的其餘部分(網址、token、旗標)
|
||||
**不算內文**——它們不是誰查來的,把它們算成出處等於這道閘自己拆自己。
|
||||
"""
|
||||
if not command:
|
||||
return "", "command"
|
||||
m = _FARG_RE.search(command)
|
||||
if m:
|
||||
path = m.group(1)
|
||||
try:
|
||||
if os.path.isfile(path):
|
||||
with open(path, encoding="utf-8", errors="ignore") as f:
|
||||
return f.read(), "file"
|
||||
except Exception:
|
||||
pass
|
||||
m = _BODY_FLAG_RE.search(command)
|
||||
if m:
|
||||
return m.group(2), "--body"
|
||||
m = _JSON_BODY_RE.search(command)
|
||||
if m:
|
||||
return m.group(1).replace("\\n", "\n"), "json"
|
||||
return command, "command"
|
||||
@@ -0,0 +1,111 @@
|
||||
#!/usr/bin/env python3
|
||||
"""hooks/lib/roster.py — 工人名單的**唯一讀取點**(inkstone/ISEP#86)。
|
||||
|
||||
依 inkstone/ISEP#40 S7 的慣例:`lib/` 裡的東西是共用零件,本身不決定 allow/block,
|
||||
判決由呼叫它的閘做(`hooks/roster-guard.sh`、`scripts/roster`、
|
||||
`hooks/lib/dispatch_parse.py` 的身份欄)。
|
||||
|
||||
━━ 規則本身(leo,inkstone/ISEP#86)━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
「身為派工的人,我要工人有名字,我才知道票上這件事到底是誰做的。」
|
||||
|
||||
實查(開票當日):`/root/.claude/agents/` 不存在、`InkStoneCo/.claude/agents/`
|
||||
只有一支內建的說明用 agent ⇒ **派工派給的是一個沒有名字的臨時工**。
|
||||
後果三件:票上看不出誰做的/每次派工都要重新交代它是誰(而派工單只准寫票號)/
|
||||
同一個 repo 這次派的跟上次派的沒有任何連續性。
|
||||
|
||||
━━ 為什麼名單住在 `agents/`,不是自己造一份 YAML ━━━━━━━━━━━━━━━━
|
||||
`agents/` 是 Claude Code plugin **原生**的 subagent 目錄,frontmatter 的 `name`
|
||||
就是 `Task` 工具的 `subagent_type`。⇒ 「指名派給誰」用的是既有欄位,
|
||||
**派工單格式一個字都不用改**(仍然只有一行 `【工單】`)。
|
||||
這也是本 repo 的慣例:不要重造輪子(見 CLAUDE.md 2.6「為什麼是 issue 不是 Notion」)。
|
||||
|
||||
━━ 檔案格式(四段,缺一不可)━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
---
|
||||
name: isep-hand ← 派工時寫進 subagent_type 的那個字
|
||||
description: 一句話它是誰 ← `roster list` 印的就是這一行
|
||||
---
|
||||
**負責 repo**:inkstone/ISEP ← 挑人的判準,也是【身份】欄第二格
|
||||
## 開工前先讀 ← 每個工人各自不同,共通規定補不了
|
||||
## 紅線 ← 只寫這個 repo 特有的
|
||||
|
||||
用法:
|
||||
import roster
|
||||
ws = roster.load() # [{'name','description','repo','path','body'}]
|
||||
w = roster.find("isep-hand") # 找不到回 None
|
||||
roster.names() # ['arcrun-hand', ...]
|
||||
"""
|
||||
import os
|
||||
import re
|
||||
|
||||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
# hooks/lib/ → hooks/ → plugin root
|
||||
_DEFAULT_ROOT = os.path.abspath(os.path.join(_HERE, "..", ".."))
|
||||
|
||||
_NAME_RE = re.compile(r"^name:\s*(.+?)\s*$", re.M)
|
||||
_DESC_RE = re.compile(r"^description:\s*(.+?)\s*$", re.M)
|
||||
# 「**負責 repo**:inkstone/ISEP」——粗體記號與全形冒號都當可選,
|
||||
# 免得改一次排版就讓名單讀不到 repo(那會靜默失效,比擋錯更難發現)。
|
||||
_REPO_RE = re.compile(r"\*{0,2}負責\s*repo\*{0,2}\s*[::]\s*([^\s((]+)")
|
||||
|
||||
|
||||
def roster_dir():
|
||||
"""名單目錄。`ISEP_ROSTER_DIR` 只給測試用(不碰真的名單)。"""
|
||||
d = os.environ.get("ISEP_ROSTER_DIR")
|
||||
if d:
|
||||
return d
|
||||
root = os.environ.get("CLAUDE_PLUGIN_ROOT") or _DEFAULT_ROOT
|
||||
return os.path.join(root, "agents")
|
||||
|
||||
|
||||
def load(directory=None):
|
||||
"""讀出整份名單。讀不到目錄就回空清單(**不丟例外**——
|
||||
呼叫它的閘要能在名單不在場時 fail-open,不能把 session 鎖死)。"""
|
||||
d = directory or roster_dir()
|
||||
out = []
|
||||
try:
|
||||
files = sorted(f for f in os.listdir(d) if f.endswith(".md"))
|
||||
except Exception:
|
||||
return out
|
||||
for fn in files:
|
||||
path = os.path.join(d, fn)
|
||||
try:
|
||||
with open(path, encoding="utf-8", errors="ignore") as f:
|
||||
text = f.read()
|
||||
except Exception:
|
||||
continue
|
||||
head, _, body = text.partition("---\n")[2].partition("\n---")
|
||||
if not head: # 沒有 frontmatter ⇒ 不是工人檔
|
||||
continue
|
||||
m = _NAME_RE.search(head)
|
||||
if not m:
|
||||
continue
|
||||
dm = _DESC_RE.search(head)
|
||||
rm = _REPO_RE.search(body)
|
||||
out.append({
|
||||
"name": m.group(1).strip(),
|
||||
"description": (dm.group(1).strip() if dm else ""),
|
||||
"repo": (rm.group(1).strip() if rm else ""),
|
||||
"path": path,
|
||||
"body": body.strip(),
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def names(directory=None):
|
||||
return [w["name"] for w in load(directory)]
|
||||
|
||||
|
||||
def find(name, directory=None):
|
||||
n = (name or "").strip()
|
||||
if not n:
|
||||
return None
|
||||
for w in load(directory):
|
||||
if w["name"] == n:
|
||||
return w
|
||||
return None
|
||||
|
||||
|
||||
def one_liner(w):
|
||||
repo = w.get("repo") or "(跨 repo)"
|
||||
return "%-18s %-26s %s" % (w["name"], repo, w.get("description", ""))
|
||||
Executable
+109
@@ -0,0 +1,109 @@
|
||||
#!/usr/bin/env bash
|
||||
# overdue-nag-guard.sh — SessionStart:**開工的時候,先把沒人會叫的事叫出來**
|
||||
# (inkstone/ISEP#93)
|
||||
#
|
||||
# ── 為什麼是 SessionStart,不是排程 ────────────────────────────────────
|
||||
# 票上寫死:「第一版做成手動可跑的腳本 + 開 session 時順手跑一次,
|
||||
# **不依賴 Gitea 的自動通知機制**」。理由兩個,都不是偷懶:
|
||||
# ① 機器帳號 `claude-code` 在 `bots` 團隊,`/orgs/inkstone/hooks` 回 403 ——**裝不了**
|
||||
# ② ISEP v0.6.0 §8.2:「壞掉的形式是『以為有人在跑』,比『沒人跑』貴得多」
|
||||
# 而頂層 `CLAUDE.md` 的 flag 紅線直接禁掉排程輪詢與事件 fan-out。
|
||||
# ⇒ 這支的觸發點是**人開工的那一刻**,不是時鐘。
|
||||
#
|
||||
# ── 這支不是閘,是**鬧鐘** ────────────────────────────────────────────
|
||||
# 它不擋任何東西(永遠 exit 0)。它做三件事:
|
||||
# ① 撈:`scripts/isep-nag` 去問 Gitea(逾期 milestone/等 leo 的票/掉在地上的棒子)
|
||||
# ② 講:把白話報告注入眼前——連「查過了,沒有」也要講出來
|
||||
# ③ 叫:發 Telegram 給 leo。**發不出去的時候不會安靜**(見下)
|
||||
#
|
||||
# ── 🔴 「發不出去」是每個 session 各自不同的事實 ──────────────────────
|
||||
# 2026-08-28 實測:能不能發 Telegram 取決於**這個 session 載到的 ISEP 是哪一版**——
|
||||
# `prod-write-guard` v0.10.0 才認得出「發通知」不是「部署工作流」,
|
||||
# 而 **hook 的註冊路徑是 session 啟動當下寫死的** ⇒ 更新 plugin 之後要開新 session。
|
||||
#
|
||||
# ⇒ 所以本支**不假設發得出去**:`isep-notify` 會先拿真的要送的那一則去問
|
||||
# `$CLAUDE_PLUGIN_ROOT/hooks/prod-write-guard.sh`(=這個 session 註冊的那一份,
|
||||
# 沒有第二種可能),把判定寫成檔,然後
|
||||
# · 放行 ⇒ 送,並驗**內層** `data.data.ok`(外層 200 不算送到)
|
||||
# · 會擋 ⇒ **不繞路**,改貼回 `inkstone/ISEP#93`,並把整段印在眼前
|
||||
# **發不出去而靜默,等於沒做**——所以判定與退路都會出現在注入的文字裡。
|
||||
set -uo pipefail
|
||||
|
||||
ROOT="${CLAUDE_PLUGIN_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}"
|
||||
NAG="$ROOT/scripts/isep-nag"
|
||||
NOTIFY="$ROOT/scripts/isep-notify"
|
||||
[ -x "$NAG" ] || [ -f "$NAG" ] || exit 0
|
||||
|
||||
STATE="${ISEP_NAG_STATE_DIR:-$HOME/.claude/isep-nag}"
|
||||
export ISEP_NOTIFY_STATE_DIR="${ISEP_NOTIFY_STATE_DIR:-$STATE}"
|
||||
COOLDOWN="${ISEP_NAG_NOTIFY_COOLDOWN:-21600}" # 預設 6 小時叫一次,不洗版
|
||||
TICKET="${ISEP_NAG_TICKET:-inkstone/ISEP#93}"
|
||||
BUDGET="${ISEP_NAG_TIMEOUT:-25}"
|
||||
mkdir -p "$STATE" 2>/dev/null || true
|
||||
|
||||
# 子 session 不叫:subagent 開工不是 leo 開工,鬧鐘響給沒有手的人聽是噪音。
|
||||
[ "${CLAUDE_CODE_CHILD_SESSION:-}" = "1" ] && exit 0
|
||||
|
||||
# ── ① 撈 ──────────────────────────────────────────────────────────────
|
||||
# 逾時/沒網路/沒 token 都不准讓開場卡住——這支是鬧鐘,不是安全閘。
|
||||
# ISEP_NAG_FIXTURE 只給測試用:讓整條路徑(撈→講→叫)離線跑得完,不打 Gitea。
|
||||
ARGS=()
|
||||
[ -n "${ISEP_NAG_FIXTURE:-}" ] && ARGS=(--fixture "$ISEP_NAG_FIXTURE")
|
||||
# 🔴 一次算完長版+短版(--both)。分兩次呼叫=打兩輪 Gitea=開場多等一輪,
|
||||
# 而這裡是 leo 每次開工都會經過的關鍵路徑。
|
||||
BOTH=$(timeout "$BUDGET" python3 "$NAG" "${ARGS[@]+"${ARGS[@]}"}" --both 2>/dev/null) || BOTH=""
|
||||
SEP='␟--isep-nag-short--␟'
|
||||
REPORT=$(printf '%s' "$BOTH" | sed -n "1,/$SEP/p" | sed '$d')
|
||||
SHORT=$(printf '%s' "$BOTH" | sed -n "/$SEP/,\$p" | sed '1d')
|
||||
[ -n "$REPORT" ] || REPORT="🔔 催辦員這次沒跑完(逾時 ${BUDGET}s 或拿不到 Gitea)。
|
||||
⇒ **這不等於「沒有事情逾期」**,這是「我沒查到」。手動補一次:python3 scripts/isep-nag"
|
||||
|
||||
# ── ② 叫(有東西才叫,而且有冷卻)────────────────────────────────────
|
||||
NOTE=""
|
||||
STAMP="$STATE/last-notify"
|
||||
now=$(date +%s 2>/dev/null || echo 0)
|
||||
last=0
|
||||
[ -f "$STAMP" ] && last=$(stat -c %Y "$STAMP" 2>/dev/null || stat -f %m "$STAMP" 2>/dev/null || echo 0)
|
||||
case "$now$last" in *[!0-9]*) now=0; last=0 ;; esac
|
||||
|
||||
if [ "${ISEP_NAG_NO_NOTIFY:-}" = "1" ]; then
|
||||
NOTE="(ISEP_NAG_NO_NOTIFY=1,這次不發 Telegram)"
|
||||
elif [ -z "$SHORT" ]; then
|
||||
NOTE="(撈不到東西,沒有可發的內容——這不是「沒事」,是「沒查到」)"
|
||||
elif printf '%s' "$SHORT" | grep -q "查過了,沒有"; then
|
||||
# 🔴 「沒事」不發 Telegram,但**眼前照樣講**。
|
||||
# 每隔幾小時發一則「一切正常」,leo 會學會不看它——那時真的有事也叫不動他。
|
||||
NOTE="(三類都乾淨,不打擾 leo;但上面那段一定講出來)"
|
||||
elif [ "$now" -gt 0 ] && [ $((now - last)) -lt "$COOLDOWN" ]; then
|
||||
NOTE="($(( (COOLDOWN - (now - last)) / 60 )) 分鐘前才叫過,這次不重複發)"
|
||||
else
|
||||
NOTE=$(timeout 60 python3 "$NOTIFY" --text "$SHORT" --fallback-ticket "$TICKET" 2>&1) \
|
||||
&& touch "$STAMP" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# ── ③ 講(不管上面走哪條路,都要有話)────────────────────────────────
|
||||
python3 - "$REPORT" "$NOTE" <<'PY'
|
||||
import json, sys
|
||||
report, note = sys.argv[1], sys.argv[2]
|
||||
head = report.splitlines()[0] if report else "🔔 催辦員"
|
||||
n = sum(report.count(k) for k in ("⏰ **逾期", "🙋 **在等你", "🧤 **棒子"))
|
||||
sysmsg = "🔔 催辦員查過了(%s 類有事)" % n if n else "🔔 催辦員查過了:三類都乾淨"
|
||||
|
||||
ctx = "\n".join([
|
||||
report,
|
||||
"",
|
||||
"── 通知這一段 ──",
|
||||
note or "(沒有通知動作)",
|
||||
"",
|
||||
"🔴 這段是 ISEP 的催辦員(inkstone/ISEP#93)自己發的,不是你想起來要查的。",
|
||||
"它只在**你開 session 的那一刻**跑一次——**CC 沒在跑的那段時間補不到**,",
|
||||
"那一段要 leo 決定要不要開 Gitea 的通知權限(機器帳號現在是 403)。",
|
||||
"",
|
||||
"要重跑:`python3 scripts/isep-nag`(`--json` 看全部、`--notify` 再發一次)。",
|
||||
])
|
||||
print(json.dumps({
|
||||
"systemMessage": sysmsg,
|
||||
"hookSpecificOutput": {"hookEventName": "SessionStart", "additionalContext": ctx},
|
||||
}, ensure_ascii=False))
|
||||
PY
|
||||
exit 0
|
||||
Executable
+186
@@ -0,0 +1,186 @@
|
||||
#!/usr/bin/env bash
|
||||
# 管什麼: 派工(帶【工單】的 Task/Agent)沒有指名工人、或指了名單上沒有的名字 → 擋;
|
||||
# 指對了就把那位工人的檔案原文注入給它(它是誰、先讀什麼、紅線)。
|
||||
# 為什麼: 派工派給「一個 agent」=票上看不出這件事是誰做的,也沒有任何連續性。
|
||||
# 誤觸時怎麼關: `python3 scripts/roster list` 挑一個名字放進 subagent_type;
|
||||
# 真的沒有合適的人 → touch /tmp/.roster-ok-<session_id> 放行一次(用完即丟)。
|
||||
#
|
||||
# roster-guard.sh — PreToolUse(Task|Agent):**工人要有名字**(inkstone/ISEP#86)
|
||||
#
|
||||
# ━━ 來由(leo,inkstone/ISEP#86)━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
# 「身為派工的人,我要工人有名字,我才知道票上這件事到底是誰做的。」
|
||||
#
|
||||
# 實查(開票當日):`/root/.claude/agents/` 不存在、`InkStoneCo/.claude/agents/`
|
||||
# 只有一支內建的說明用 agent ⇒ 派工派給的是**沒有名字的臨時工**。後果三件:
|
||||
# · 票上只看得到「有個 agent 做了」,看不出是誰
|
||||
# · 每次派工都要重新交代它是誰、該讀什麼——**而派工單只准寫票號**
|
||||
# (`dispatch-format-guard.sh`,ISEP v0.5.0)
|
||||
# · 同一個 repo 這次派的跟上次派的沒有任何連續性
|
||||
#
|
||||
# ━━ 這支跟 dispatch-format-guard.sh 的分界(兩支不會同時開口)━━━━━━━━
|
||||
# dispatch-format-guard 管**派工單的內文**(只准有【工單】那一行)。
|
||||
# 本支管**派給誰**——那是 `Task` 的 `subagent_type` 欄,**不在內文裡**。
|
||||
# ⇒ 兩者正交:加上這道閘,派工單格式一個字都不用改。
|
||||
#
|
||||
# 邊界同 dispatch-format-guard:派工單裡**沒有**【工單】時本閘閉嘴,
|
||||
# 那是 `no-ticket-no-dispatch.sh` 的地盤(沒票的東西還輪不到談派給誰)。
|
||||
#
|
||||
# ━━ 為什麼過得了「封動作不封文字」的檢驗(leo 2026-08-17)━━━━━━━━━
|
||||
# 判準是「這個名字**在不在名單裡**」——一次字面比對,方向是**放行**
|
||||
# (在名單裡 ⇒ 放行)。沒有任何「命中某個詞就違規」的比對。
|
||||
# 名字是唯一識別碼、集合有限且可枚舉,正是 v0.11.0 那支閘用的形狀。
|
||||
#
|
||||
# ━━ 這支同時做兩件事 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
# ① **擋**:沒指名/指了不存在的名字 → exit 2,並把整份名單印出來
|
||||
# ② **注入**:指對了 → exit 0,把那位工人的檔案原文送給它
|
||||
# (慣例同 dispatch-format-guard.sh 的共通規定注入)
|
||||
# ⇒ 「你是誰、開工前先讀什麼、你的紅線」**不必寫進派工單**,機器每次都補。
|
||||
# 這正是 ISEP#86 說的那件共通規定補不了的事——它每個工人都不一樣。
|
||||
#
|
||||
# ━━ fail-open 的界線 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
# 名單目錄讀不到/解析器掛了 → **放行**(回到「沒有這支閘」的狀態)。
|
||||
# 一份讀不到的名單不該讓整台機器派不了工。
|
||||
#
|
||||
# 逃生口(留痕、用完即丟):touch /tmp/.roster-ok-<session_id>
|
||||
#
|
||||
# 測試:hooks/tests/roster-guard.test.sh(離線、不打網路、不花錢)
|
||||
set -u
|
||||
|
||||
INPUT="$(cat)"
|
||||
HERE="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)"
|
||||
|
||||
FACTS="$(printf '%s' "$INPUT" | ROSTER_LIB="$HERE/lib" python3 -c '
|
||||
import json, os, sys
|
||||
sys.path.insert(0, os.environ.get("ROSTER_LIB", ""))
|
||||
try:
|
||||
import roster
|
||||
except Exception:
|
||||
print(json.dumps({"status": "skip", "why": "roster lib 不在"})); raise SystemExit
|
||||
try:
|
||||
d = json.load(sys.stdin)
|
||||
except Exception:
|
||||
print(json.dumps({"status": "skip", "why": "payload 不是 JSON"})); raise SystemExit
|
||||
|
||||
ti = d.get("tool_input") or {}
|
||||
prompt = ti.get("prompt") or ti.get("message") or ""
|
||||
# 邊界:沒有【工單】 ⇒ no-ticket-no-dispatch 的地盤,本閘閉嘴
|
||||
if "【工單】" not in prompt:
|
||||
print(json.dumps({"status": "skip", "why": "沒有【工單】"})); raise SystemExit
|
||||
|
||||
# 名字住在哪一欄:subagent_type 是 Task 的原生欄位;其餘是別的 surface 的別名。
|
||||
picked = ""
|
||||
for k in ("subagent_type", "agent_type", "agent", "persona", "name"):
|
||||
v = ti.get(k)
|
||||
if isinstance(v, str) and v.strip():
|
||||
picked = v.strip(); break
|
||||
|
||||
ws = roster.load()
|
||||
if not ws:
|
||||
print(json.dumps({"status": "skip", "why": "名單是空的"})); raise SystemExit
|
||||
|
||||
hit = roster.find(picked) if picked else None
|
||||
print(json.dumps({
|
||||
"status": "ok",
|
||||
"picked": picked,
|
||||
"known": bool(hit),
|
||||
"session_id": d.get("session_id") or "",
|
||||
"roster": [roster.one_liner(w) for w in ws],
|
||||
"card": (hit or {}).get("body", ""),
|
||||
"card_name": (hit or {}).get("name", ""),
|
||||
"card_repo": (hit or {}).get("repo", ""),
|
||||
}, ensure_ascii=False))
|
||||
' 2>/dev/null)"
|
||||
[ -n "$FACTS" ] || exit 0 # 解析器掛了 → fail-open
|
||||
|
||||
get() { printf '%s' "$FACTS" | python3 -c "
|
||||
import sys, json
|
||||
try: print(json.load(sys.stdin).get('$1', '') or '')
|
||||
except Exception: print('')
|
||||
" 2>/dev/null; }
|
||||
|
||||
[ "$(get status)" = "ok" ] || exit 0
|
||||
|
||||
SESSION="$(get session_id)"
|
||||
KNOWN="$(get known)"
|
||||
|
||||
if [ "$KNOWN" != "True" ]; then
|
||||
# 明示豁免(留痕用,用掉就消失,不是永久開關)
|
||||
if [ -n "$SESSION" ] && [ -f "/tmp/.roster-ok-$SESSION" ]; then
|
||||
rm -f "/tmp/.roster-ok-$SESSION"
|
||||
exit 0
|
||||
fi
|
||||
PICKED="$(get picked)"
|
||||
ROSTER_TEXT="$(printf '%s' "$FACTS" | python3 -c '
|
||||
import sys, json
|
||||
try: rows = json.load(sys.stdin).get("roster", [])
|
||||
except Exception: rows = []
|
||||
print("\n".join(" " + r for r in rows))
|
||||
' 2>/dev/null)"
|
||||
|
||||
MSG="$(cat <<'EOF'
|
||||
🚫 __WHO__(inkstone/ISEP#86:「**我要工人有名字,我才知道票上這件事到底是誰做的**」)
|
||||
|
||||
━━ 名單上有這幾位 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
__ROSTER__
|
||||
|
||||
看某一位的全文:python3 scripts/roster show <名字>
|
||||
這個 repo 該派給誰:python3 scripts/roster which <owner/repo>
|
||||
|
||||
━━ 怎麼派 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
把名字放進 Task 的 `subagent_type`。**派工單本身不用改**——
|
||||
它仍然只有一行【工單】(`dispatch-format-guard.sh` 沒有變)。
|
||||
指對名字之後,本閘會把那位工人的檔案原文注入給它:
|
||||
它是誰、開工前先讀什麼、它的紅線是什麼。
|
||||
⇒ **這些不必寫進派工單**,也不可能寫進共通規定(每個工人都不一樣)。
|
||||
|
||||
━━ 名單上真的沒有合適的人 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
加一個工人 = 在 `agents/` 加一個 `.md`(照既有檔案的四段:
|
||||
frontmatter 的 name/description、`**負責 repo**`、`## 開工前先讀`、`## 紅線`)。
|
||||
**不必改任何 hook**——名單是資料,閘讀的是那個目錄。
|
||||
規約:docs/governance/worker-roster.md
|
||||
|
||||
真的有例外 → `touch /tmp/.roster-ok-__SESSION__` 後重送(用掉就消失,會留在指令歷史上)。
|
||||
EOF
|
||||
)"
|
||||
if [ -z "$PICKED" ]; then
|
||||
WHO="這次派工**沒有指名工人**(\`subagent_type\` 是空的)"
|
||||
else
|
||||
WHO="名單上**沒有「$PICKED」這個人**"
|
||||
fi
|
||||
RG_MSG="$MSG" RG_WHO="$WHO" RG_ROSTER="$ROSTER_TEXT" RG_SESSION="$SESSION" python3 -c '
|
||||
import os, sys
|
||||
sys.stderr.write(
|
||||
os.environ["RG_MSG"]
|
||||
.replace("__WHO__", os.environ.get("RG_WHO", ""))
|
||||
.replace("__ROSTER__", os.environ.get("RG_ROSTER", ""))
|
||||
.replace("__SESSION__", os.environ.get("RG_SESSION", "") or "<session_id>")
|
||||
+ "\n"
|
||||
)
|
||||
'
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# ── 指名正確 → 把那位工人的檔案原文注入 ──────────────────────────────
|
||||
printf '%s' "$FACTS" | python3 -c '
|
||||
import json, sys
|
||||
try:
|
||||
d = json.load(sys.stdin)
|
||||
except Exception:
|
||||
raise SystemExit
|
||||
body = (d.get("card") or "").strip()
|
||||
if not body:
|
||||
raise SystemExit
|
||||
print(json.dumps({
|
||||
"hookSpecificOutput": {
|
||||
"hookEventName": "PreToolUse",
|
||||
"additionalContext":
|
||||
"【自動注入:你是誰】%s(負責 %s)\n"
|
||||
"(來源 agents/%s.md——派工單只給票號是刻意的,"
|
||||
"「你是誰、先讀什麼、你的紅線」由機器補,不必寫進派工單)\n\n%s"
|
||||
% (d.get("card_name", ""), d.get("card_repo", "") or "跨 repo",
|
||||
d.get("card_name", ""), body),
|
||||
}
|
||||
}, ensure_ascii=False))
|
||||
' 2>/dev/null
|
||||
|
||||
exit 0
|
||||
Executable
+171
@@ -0,0 +1,171 @@
|
||||
#!/usr/bin/env bash
|
||||
# diagnosis-evidence-guard.sh + investigate-first-stamp.sh 的迴歸測試(inkstone/ISEP#87)
|
||||
#
|
||||
# 票上的四條驗收條件,一條一組:
|
||||
# 1 開一張還沒有人查過的票,在裡面寫「根因是 X」 → 要被擋一次
|
||||
# 2 派人查過、結論貼回票上了,再寫診斷 → 放行
|
||||
# 3 擋的訊息要講得出「你還沒派人查這件事」,不是「格式不對」
|
||||
# 4 只是轉述別人查到的東西(有出處) → **不能被誤擋**
|
||||
#
|
||||
# 🔴 全程離線:不打 Gitea、不開任何測試票、不碰 /tmp 的正式戳記
|
||||
# (戳記走 `ISEP_STAMP_DIR`)。每次結果一樣——純結構判斷,沒有語意判官。
|
||||
#
|
||||
# 🔴 這支閘**沒有任何關鍵字比對**(票上明令:「根因」「因為」「應該是」不准當判準)。
|
||||
# 所以下面「該擋」的案例裡有沒有出現那些詞完全不影響結果——
|
||||
# ⑬ 就是拿一段**一個那種詞都沒有**的內容,證明判準不是措辭。
|
||||
#
|
||||
# 用法:hooks/tests/diagnosis-evidence-guard.test.sh [hook 路徑]
|
||||
set -u
|
||||
unset CLAUDE_CODE_CHILD_SESSION
|
||||
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ROOT="$(cd "$HERE/../.." && pwd)"
|
||||
HOOK="${1:-$ROOT/hooks/diagnosis-evidence-guard.sh}"
|
||||
STAMP="$ROOT/hooks/investigate-first-stamp.sh"
|
||||
TMP=$(mktemp -d); trap 'rm -rf "$TMP"' EXIT
|
||||
export ISEP_STAMP_DIR="$TMP"
|
||||
|
||||
PASS=0; FAIL=0; N=0
|
||||
|
||||
bash_payload() {
|
||||
python3 - "$1" "$2" <<'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
|
||||
}
|
||||
|
||||
# t <期望 exit> <說明> <session> <指令> [必須出現的字串]
|
||||
t() {
|
||||
want="$1"; desc="$2"; sid="$3"; cmd="$4"; must="${5:-}"
|
||||
N=$((N+1))
|
||||
err=$(bash_payload "$sid" "$cmd" | bash "$HOOK" 2>&1 >/dev/null); rc=$?
|
||||
ok=1
|
||||
[ "$rc" -eq "$want" ] || ok=0
|
||||
if [ -n "$must" ] && ! printf '%s' "$err" | 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' "$err" | sed -n '1,8p' | sed 's/^/ /'
|
||||
FAIL=$((FAIL+1))
|
||||
fi
|
||||
}
|
||||
|
||||
# 派一次工去查某張票(=讓戳記出現)
|
||||
dispatch() {
|
||||
python3 - "$1" "$2" <<'PY' | bash "$STAMP"
|
||||
import json, sys
|
||||
print(json.dumps({"session_id": sys.argv[1], "tool_name": "Task",
|
||||
"tool_input": {"prompt": "【工單】%s" % sys.argv[2]}},
|
||||
ensure_ascii=False))
|
||||
PY
|
||||
}
|
||||
|
||||
# ── 測資:三份內文 ────────────────────────────────────────────────
|
||||
# ① 沒查過的診斷(0 個出處)
|
||||
cat > "$TMP/diagnosis.md" <<'EOF'
|
||||
【身份】總管/inkstone/ISEP/-
|
||||
根因是 KV 額度爆掉,所以執行紀錄寫不進去;我判斷應該改用另一顆資料庫來存,
|
||||
這樣就不會再撞到額度上限了。這件事拖了很久一直沒解,先照這個方向做下去。
|
||||
EOF
|
||||
# ② 同一段話,但帶著出處(轉述別人查到的)
|
||||
cat > "$TMP/relayed.md" <<'EOF'
|
||||
【身份】總管/inkstone/ISEP/-
|
||||
scout 查回來的:寫入那段在 hooks/lib/evidence.py:42,額度是在那裡爆的,
|
||||
出處 inkstone/Arcrun#142 → comment 4327。我只是把它的結論搬過來,沒有自己加判斷。
|
||||
EOF
|
||||
# ③ 一段**一個「根因/因為/應該是」都沒有**的長內容,同樣沒有出處
|
||||
cat > "$TMP/no-keyword.md" <<'EOF'
|
||||
【身份】總管/inkstone/ISEP/-
|
||||
那個匯入流程跑不動是設定檔的問題,換掉那個欄位就會通了,我看過一次有印象。
|
||||
先照這個方向改,改完應該就不會再出現同樣的狀況了,不用另外再查。
|
||||
EOF
|
||||
# ④ 短回覆
|
||||
SHORT="收到,我來看"
|
||||
|
||||
echo "── 驗收 1+3:沒人查過、也沒出處 ⇒ 擋,且說得出「還沒派人查」──"
|
||||
|
||||
t 2 "① 貼一段沒人查過的診斷到票上 ⇒ 擋" \
|
||||
S-A "python3 scripts/ticket say inkstone/ISEP#87 -F $TMP/diagnosis.md"
|
||||
t 2 "② 訊息講得出「這個 session 沒有派人去查這張票」(不是籠統的「格式不對」)" \
|
||||
S-B "python3 scripts/ticket say inkstone/ISEP#87 -F $TMP/diagnosis.md" \
|
||||
'沒有派人去查這張票'
|
||||
t 2 "③ 訊息要點名是哪一張票(人才知道該派誰去查什麼)" \
|
||||
S-C "python3 scripts/ticket say inkstone/ISEP#87 -F $TMP/diagnosis.md" 'inkstone/ISEP#87'
|
||||
t 2 "④ 訊息要給兩條出路:先派人查/把出處帶上" \
|
||||
S-D "python3 scripts/ticket say inkstone/ISEP#87 -F $TMP/diagnosis.md" '先派人去查'
|
||||
t 2 "⑤ 開新票也一樣(新票天生沒人查過,`ticket new`)" \
|
||||
S-E "python3 scripts/ticket new ISEP -F $TMP/diagnosis.md --title 身為X我要Y我才Z"
|
||||
t 2 "⑥ 側門:直接打 Gitea API POST 留言 ⇒ 同樣擋(認動作的方式不准漏一條路)" \
|
||||
S-F "curl -X POST -H 'Authorization: token T' -d '{\"body\":\"根因是 KV 額度爆掉,所以執行紀錄寫不進去;我判斷應該改用另一顆資料庫來存,這樣就不會再撞到額度上限了,這件事拖了很久一直沒解,先照這個方向做下去不要再查了\"}' https://git.uncle6.me/api/v1/repos/inkstone/ISEP/issues/87/comments"
|
||||
t 2 "⑦ 側門:隱式 POST(requests.post)也要認得" \
|
||||
S-G "python3 -c \"requests.post(url + '/repos/inkstone/ISEP/issues/87/comments', json={'body': '根因是 KV 額度爆掉,所以執行紀錄寫不進去;我判斷應該改用另一顆資料庫來存,這樣就不會再撞到額度上限了,這件事拖了很久一直沒解,先照這個方向做下去不要再查了'})\""
|
||||
|
||||
echo "── 驗收 2:派人查過 ⇒ 放行 ─────────────────────────────────"
|
||||
|
||||
dispatch S-H inkstone/ISEP#87
|
||||
t 0 "⑧ 同一 session 派過人去查 #87 ⇒ 同一段診斷放行" \
|
||||
S-H "python3 scripts/ticket say inkstone/ISEP#87 -F $TMP/diagnosis.md"
|
||||
|
||||
dispatch S-I inkstone/ISEP#86
|
||||
t 2 "⑨ 派的是**別張票**(#86)⇒ #87 仍然沒人查過 ⇒ 照擋(戳記按票分,不是按 session)" \
|
||||
S-I "python3 scripts/ticket say inkstone/ISEP#87 -F $TMP/diagnosis.md"
|
||||
|
||||
echo "── 驗收 4:轉述有出處 ⇒ 不能被誤擋 ─────────────────────────"
|
||||
|
||||
t 0 "⑩ 帶檔案:行號+票號+comment 號的轉述 ⇒ 放行" \
|
||||
S-J "python3 scripts/ticket say inkstone/ISEP#87 -F $TMP/relayed.md"
|
||||
|
||||
for pair in "網址:https://git.uncle6.me/inkstone/ISEP/pulls/97" \
|
||||
"commit:8e28041" \
|
||||
"檔案路徑:hooks/lib/dispatch_parse.py"; do
|
||||
kind="${pair%%:*}"; token="${pair#*:}"
|
||||
printf '【身份】總管/inkstone/ISEP/-\n那段行為我核對過了,寫在 %s,跟票上寫的不一樣,所以下一步要照那邊的實況走,先不要動別的地方。\n' \
|
||||
"$token" > "$TMP/one-source.md"
|
||||
t 0 "⑪ 出處只有一個「$kind」也算數 ⇒ 放行" \
|
||||
"S-K-$kind" "python3 scripts/ticket say inkstone/ISEP#87 -F $TMP/one-source.md"
|
||||
done
|
||||
|
||||
printf '【身份】總管/inkstone/ISEP/-\n實測輸出如下,這是它自己跑出來的,不是我推的:\n```\n$ bash hooks/tests/x.sh\n通過 20 條,失敗 0 條\n```\n' > "$TMP/fenced.md"
|
||||
t 0 "⑫ 貼實測輸出(圍欄)也算出處 ⇒ 放行" \
|
||||
S-L "python3 scripts/ticket say inkstone/ISEP#87 -F $TMP/fenced.md"
|
||||
|
||||
echo "── 判準不是措辭(本閘沒有關鍵字比對)────────────────────────"
|
||||
|
||||
t 2 "⑬ 一個「根因/因為/應該是」都沒有的內容,照樣擋(判準是結構不是詞)" \
|
||||
S-M "python3 scripts/ticket say inkstone/ISEP#87 -F $TMP/no-keyword.md"
|
||||
|
||||
echo "── 不該擋(誤攔比漏擋嚴重)──────────────────────────────────"
|
||||
|
||||
t 0 "⑭ 一句短回覆(「$SHORT」)⇒ 不可能是診斷,放行" \
|
||||
S-N "python3 scripts/ticket say inkstone/ISEP#87 --body '$SHORT'"
|
||||
t 0 "⑮ GET 撈票(唯讀)⇒ 放行" \
|
||||
S-O "curl -s https://git.uncle6.me/api/v1/repos/inkstone/ISEP/issues/87"
|
||||
t 0 "⑯ ticket where(搜尋)⇒ 不是寫票,放行" \
|
||||
S-P "python3 scripts/ticket where 診斷 調查 順序"
|
||||
t 0 "⑰ ticket close(關票)⇒ 不是寫內文,放行" \
|
||||
S-Q "python3 scripts/ticket close inkstone/ISEP#87 --deliverable https://git.uncle6.me/x"
|
||||
t 0 "⑱ 一般指令(ls)⇒ 一律不管" S-R "ls -la"
|
||||
t 0 "⑲ 逃生口 evidence-ok 在指令裡 ⇒ 放行(留痕)" \
|
||||
S-S "python3 scripts/ticket say inkstone/ISEP#87 -F $TMP/diagnosis.md # evidence-ok"
|
||||
|
||||
echo "── 只擋一次,不鬼打牆 ──────────────────────────────────────"
|
||||
|
||||
t 2 "⑳ 第一次 ⇒ 擋" S-T "python3 scripts/ticket say inkstone/ISEP#87 -F $TMP/diagnosis.md"
|
||||
t 0 "㉑ 同一 session 同一張票第二次 ⇒ 放行(閘是絆一下,不是把人關起來)" \
|
||||
S-T "python3 scripts/ticket say inkstone/ISEP#87 -F $TMP/diagnosis.md"
|
||||
t 2 "㉒ 同一 session **換一張票** ⇒ 仍然擋(不是一次就永久解鎖)" \
|
||||
S-T "python3 scripts/ticket say inkstone/Arcrun#142 -F $TMP/diagnosis.md"
|
||||
|
||||
echo "── 內部壞掉不准把人鎖死 ────────────────────────────────────"
|
||||
|
||||
t 0 "㉓ 整包不是合法 JSON ⇒ fail-open" S-U ''
|
||||
N=$((N+1))
|
||||
err=$(printf 'not json at all' | bash "$HOOK" 2>&1 >/dev/null); rc=$?
|
||||
if [ "$rc" -eq 0 ]; then printf ' ✅ ㉔ payload 不是 JSON ⇒ fail-open\n'; PASS=$((PASS+1))
|
||||
else printf ' ❌ ㉔ payload 壞掉時擋住了(exit=%s)\n' "$rc"; FAIL=$((FAIL+1)); fi
|
||||
|
||||
echo
|
||||
printf '結果:%s 通過 / %s 失敗(共 %s 條)\n' "$PASS" "$FAIL" "$N"
|
||||
[ "$FAIL" -eq 0 ] || exit 1
|
||||
@@ -32,6 +32,19 @@ print(json.dumps({"session_id": "S-TEST", "hook_event_name": "PreToolUse",
|
||||
PY
|
||||
}
|
||||
|
||||
# reply_payload <tool_name> <裝話的欄位名> <內容> [額外欄位 JSON]
|
||||
# ISEP#88:通往 subagent 的路不只 Task/Agent 一條,每一條裝話的欄位還不一樣。
|
||||
reply_payload() {
|
||||
python3 - "$1" "$2" "$3" "${4:-{\}}" <<'RP'
|
||||
import json, sys
|
||||
tool, key, text, extra = sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4]
|
||||
ti = {key: text}
|
||||
ti.update(json.loads(extra))
|
||||
print(json.dumps({"session_id": "S-TEST", "hook_event_name": "PreToolUse",
|
||||
"tool_name": tool, "tool_input": ti}, ensure_ascii=False))
|
||||
RP
|
||||
}
|
||||
|
||||
# t <期望 exit> <說明> <payload JSON>
|
||||
t() {
|
||||
want="$1"; desc="$2"; body="$3"
|
||||
@@ -222,6 +235,90 @@ else
|
||||
printf ' ❌ ⑱ 訊息沒有點名違反哪一條\n'; FAIL=$((FAIL+1))
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "── D 群:回覆也是派工(inkstone/ISEP#88)────────────────────────"
|
||||
clean
|
||||
|
||||
# 驗收 1:回覆一個正在跑的 subagent,訊息裡夾一段修改要求 ⇒ 擋
|
||||
t 2 "⑲ **驗收 1**:SendMessage 夾了一段修改要求 ⇒ 擋" \
|
||||
"$(reply_payload SendMessage message '順便把 hooks.json 也改一下,改完重跑一次測試貼給我' '{"to":"isep-hand"}')"
|
||||
|
||||
# 驗收 2:內容貼上票之後,回覆只給票號 ⇒ 放行
|
||||
t 0 "⑳ **驗收 2**:內容貼上票之後,回覆只有票號 ⇒ 放行" \
|
||||
"$(reply_payload SendMessage message '【工單】inkstone/ISEP#88 → comment 5001' '{"to":"isep-hand"}')"
|
||||
|
||||
# 驗收 1 的後半:擋的時候要把那段內容原文印出來(不然人得回頭自己找)
|
||||
N=$((N+1))
|
||||
rmsg=$(printf '%s' "$(reply_payload SendMessage message '去 main 分支重驗那三支 hook,把輸出貼回來' '{"to":"scout"}')" \
|
||||
| bash "$HOOK" 2>&1 >/dev/null)
|
||||
if printf '%s' "$rmsg" | grep -q '直接貼上票' \
|
||||
&& printf '%s' "$rmsg" | grep -q '去 main 分支重驗那三支 hook'; then
|
||||
printf ' ✅ ㉑ 擋下來時把那段內容**原文**印出來,可以直接貼上票\n'; PASS=$((PASS+1))
|
||||
else
|
||||
printf ' ❌ ㉑ 沒有把原文印出來(出路要求「寫進票」,找不到原文就得回頭自己翻)\n'
|
||||
printf '%s\n' "$rmsg" | sed -n '1,10p' | sed 's/^/ /'
|
||||
FAIL=$((FAIL+1))
|
||||
fi
|
||||
|
||||
N=$((N+1))
|
||||
if printf '%s' "$rmsg" | grep -q '回覆也是派工'; then
|
||||
printf ' ✅ ㉒ 訊息說得出這是「回覆」那條路,不是照抄派工單那段\n'; PASS=$((PASS+1))
|
||||
else
|
||||
printf ' ❌ ㉒ 回覆被擋時給的是派工單的說法,人會找不到自己違反了什麼\n'; FAIL=$((FAIL+1))
|
||||
fi
|
||||
|
||||
# 真跡重演(驗收 4):那兩則回覆的原文只活在總管的對話裡,拿不回來;
|
||||
# 這裡改用**同一天真的寫進派工單的那幾段內容**(fixtures/README.md 記著來歷),
|
||||
# 從**回覆這條路**再送一次——形狀一樣:一大段這次才知道的事實,票上一個字都沒有。
|
||||
for fx in real-content-isep60 real-content-arcrun142 real-content-arcrunrag104; do
|
||||
t 2 "㉓ 真跡($fx)從回覆這條路送出去 ⇒ 一樣擋" \
|
||||
"$(reply_payload SendMessage message "$(cat "$FIX/$fx.txt")" '{"to":"isep-hand"}')"
|
||||
done
|
||||
|
||||
echo "── E 群:通往 subagent 的路要列全(一條都不能漏)──────────────"
|
||||
|
||||
t 2 "㉔ 雲端開新 session(create_session 的 prompt)" \
|
||||
"$(reply_payload mcp__Claude_Code_Remote__create_session prompt '去把那個 worker 修好,順便升版')"
|
||||
t 2 "㉕ 送訊息進某個 session(send_message 的 text)" \
|
||||
"$(reply_payload mcp__Claude_Code_Remote__send_message text '改一下剛剛那段,然後重跑測試')"
|
||||
t 2 "㉖ 排程派工(create_trigger 的 prompt)" \
|
||||
"$(reply_payload mcp__Claude_Code_Remote__create_trigger prompt '每天早上去檢查 PR 有沒有結論,有的話處理掉')"
|
||||
t 2 "㉗ 當場點燃排程派工(fire_trigger 的 text)" \
|
||||
"$(reply_payload mcp__Claude_Code_Remote__fire_trigger text '這次順便把 bundle 也重打一次')"
|
||||
t 2 "㉘ 排一則訊息給自己(send_later 的 message)" \
|
||||
"$(reply_payload mcp__Claude_Code_Remote__send_later message '記得回頭把那三支 hook 的測試補齊')"
|
||||
t 2 "㉙ 用 CLI 開一個新 session(Bash 的 claude -p)" \
|
||||
'{"session_id":"S-TEST","tool_name":"Bash","tool_input":{"command":"claude -p '"'"'去把 arcrun 那個 worker 修一修'"'"'"}}'
|
||||
|
||||
echo "── F 群:這幾條**不該**被這支閘碰(誤攔比漏擋嚴重)─────────────"
|
||||
|
||||
t 0 "㉚ subagent 往上回報(SendMessage to: main)=交件不是派工 ⇒ 不管" \
|
||||
"$(reply_payload SendMessage message '做完了,分支是 feat/x,測試 26/26' '{"to":"main"}')"
|
||||
t 0 "㉛ 一般的 Bash 指令裡剛好有【工單】字樣(判準是指令名不是內容)⇒ 不管" \
|
||||
'{"session_id":"S-TEST","tool_name":"Bash","tool_input":{"command":"echo 【工單】inkstone/ISEP#88 順便改一下那個檔 > /tmp/x.md"}}'
|
||||
t 0 "㉜ claude 但沒有 -p(開互動 session,不是一次性派工)⇒ 不管" \
|
||||
'{"session_id":"S-TEST","tool_name":"Bash","tool_input":{"command":"claude --version"}}'
|
||||
t 0 "㉝ 回覆是空的(這條路這次沒帶任何話)⇒ 不管" \
|
||||
"$(reply_payload SendMessage message '' '{"to":"isep-hand"}')"
|
||||
|
||||
N=$((N+1))
|
||||
child=$(printf '%s' "$(reply_payload SendMessage message '順便把那個檔也改一下' '{"to":"peer"}')" \
|
||||
| CLAUDE_CODE_CHILD_SESSION=1 bash "$HOOK" 2>&1 >/dev/null); crc=$?
|
||||
if [ "$crc" -eq 0 ]; then
|
||||
printf ' ✅ ㉞ 子 session 送出的回覆不管(它送的是交件,不是派工)\n'; PASS=$((PASS+1))
|
||||
else
|
||||
printf ' ❌ ㉞ 子 session 的回覆被擋了(exit=%s)——那會擋掉交件本身\n' "$crc"; FAIL=$((FAIL+1))
|
||||
fi
|
||||
|
||||
N=$((N+1))
|
||||
kid=$(printf '%s' "$(payload '【工單】inkstone/ISEP#88
|
||||
順便把 hooks.json 也改一下')" | CLAUDE_CODE_CHILD_SESSION=1 bash "$HOOK" 2>&1 >/dev/null); krc=$?
|
||||
if [ "$krc" -eq 2 ]; then
|
||||
printf ' ✅ ㉟ 但子 session **自己往下派工**(Task)時,派工單的規矩照樣管它\n'; PASS=$((PASS+1))
|
||||
else
|
||||
printf ' ❌ ㉟ 子 session 派工被整個放掉了(exit=%s)——那是一個逃逸艙口\n' "$krc"; FAIL=$((FAIL+1))
|
||||
fi
|
||||
|
||||
clean
|
||||
echo
|
||||
echo "══ $PASS/$N 通過${FAIL:+,$FAIL 個失敗} ══"
|
||||
|
||||
Executable
+211
@@ -0,0 +1,211 @@
|
||||
#!/usr/bin/env bash
|
||||
# 催辦員的迴歸測試(inkstone/ISEP#93)
|
||||
#
|
||||
# 對應票上那四條驗收:
|
||||
# 1 撈得出逾期的 milestone,而且訊息是白話的 → A 群
|
||||
# 2 沒東西可報時要說「查過了,沒有」 → B 群
|
||||
# 3 開一個新 session 要自己跑過一次 → E 群(hook 的注入)
|
||||
# 4 Telegram 真的收得到 → D 群只驗得了「發不出去不會安靜」
|
||||
# (「leo 手機上真的出現」機器驗不了,那一格由人驗,見 docs/TESTING.md)
|
||||
#
|
||||
# 另外兩群是這支的**主要失效模式**:
|
||||
# C 群 不該叫的不要叫 —— 誤報比漏報更該修:每次開場都被念一串不相干的東西,
|
||||
# 人就學會跳過它,那時真的有事也叫不動(本 repo 心法第 2 條)
|
||||
# D 群 發不出去不會安靜 —— 「送出成功」跟「送到了」是兩件事
|
||||
#
|
||||
# 🔴 全程離線:資料走 --fixture、時鐘走 --now、網路用 ISEP_NOTIFY_OFFLINE=1 關掉。
|
||||
# 不打 Gitea、不打實例、不留任何測試票。
|
||||
set -u
|
||||
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ROOT="$(cd "$HERE/../.." && pwd)"
|
||||
NAG="$ROOT/scripts/isep-nag"
|
||||
export NOTIFY_PATH="$ROOT/scripts/isep-notify"
|
||||
NOTIFY="$ROOT/scripts/isep-notify"
|
||||
HOOK="$ROOT/hooks/overdue-nag-guard.sh"
|
||||
|
||||
TMP=$(mktemp -d)
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
export ISEP_NOTIFY_STATE_DIR="$TMP/state"
|
||||
export ISEP_NAG_STATE_DIR="$TMP/state"
|
||||
export ISEP_NOTIFY_OFFLINE=1
|
||||
mkdir -p "$ISEP_NOTIFY_STATE_DIR"
|
||||
|
||||
# 🔴 明著清掉:測試常常跑在一條 subagent 裡,而 hook 刻意放行子 session
|
||||
# ⇒ 不清掉的話 E 群會全綠,而且是**假綠**(countdown-guard.test.sh 撞過同一個坑)。
|
||||
export CLAUDE_CODE_CHILD_SESSION=
|
||||
|
||||
PASS=0; FAIL=0
|
||||
ok(){ printf ' ✅ %s\n' "$1"; PASS=$((PASS+1)); }
|
||||
no(){ printf ' ❌ %s —— %s\n' "$1" "$2"; FAIL=$((FAIL+1)); }
|
||||
has(){ printf '%s' "$1" | grep -q -- "$2"; }
|
||||
|
||||
# 定住的時鐘:2026-08-28 00:00 UTC(=台北 08:00),跟票上那天同一天
|
||||
NOW=$(python3 -c 'import datetime as d;print(int(d.datetime(2026,8,28,0,0,tzinfo=d.timezone.utc).timestamp()))')
|
||||
|
||||
# ── 測資:票上點名的那五個逾期 milestone,加上一整組「不該叫」的對照 ──────
|
||||
cat > "$TMP/full.json" <<'JSON'
|
||||
{"milestones": [
|
||||
{"repo":"inkstone/arcrun-rag","title":"不必開瀏覽器,也不怕反悔","due_on":"2026-08-24T23:59:59Z","open_issues":2,"closed_issues":0},
|
||||
{"repo":"inkstone/Arcrun","title":"App 打開來就是 Arcrun,而且回得去","due_on":"2026-08-24T23:59:59Z","open_issues":3,"closed_issues":1},
|
||||
{"repo":"inkstone/ISEP","title":"把管理這條線做對","due_on":"2026-08-24T23:59:59Z","open_issues":14,"closed_issues":1},
|
||||
{"repo":"inkstone/Arcrun","title":"AI 問得到內文,不是只查到檔名","due_on":"2026-08-26T23:59:59Z","open_issues":1,"closed_issues":1},
|
||||
{"repo":"inkstone/Arcrun","title":"寫進去,就查得到","due_on":"2026-08-26T23:59:59Z","open_issues":1,"closed_issues":6},
|
||||
{"repo":"inkstone/ISEP","title":"還沒到期的","due_on":"2026-09-30T23:59:59Z","open_issues":9,"closed_issues":0},
|
||||
{"repo":"inkstone/ISEP","title":"逾期但票都關完了","due_on":"2026-08-01T23:59:59Z","open_issues":0,"closed_issues":7},
|
||||
{"repo":"inkstone/ISEP","title":"沒設期限的","due_on":null,"open_issues":5,"closed_issues":0}
|
||||
],
|
||||
"issues": [
|
||||
{"number":138,"title":"App 沒辦法說「我要接哪些外部事件」","updated_at":"2026-08-17T00:00:00Z",
|
||||
"repository":{"full_name":"inkstone/Arcrun"},"labels":[{"name":"Human"}],"assignees":[]},
|
||||
{"number":51,"title":"leo21c 上有一批舊資源還在","updated_at":"2026-08-20T00:00:00Z",
|
||||
"repository":{"full_name":"inkstone/InkStoneCo"},"labels":[],"assignees":[{"login":"leo"}]},
|
||||
{"number":38,"title":"辨識碼信在 stage 寄不出去","updated_at":"2026-08-12T00:00:00Z",
|
||||
"repository":{"full_name":"inkstone/arcrun-rag"},"labels":[{"name":"s/doing"},{"name":"p/high"}],"assignees":[]},
|
||||
{"number":999,"title":"昨天才動過的 s/doing——還在做,不要叫","updated_at":"2026-08-27T00:00:00Z",
|
||||
"repository":{"full_name":"inkstone/ISEP"},"labels":[{"name":"s/doing"}],"assignees":[]},
|
||||
{"number":998,"title":"這是一個 PR,pr-verdict-guard 在管","updated_at":"2026-08-01T00:00:00Z",
|
||||
"repository":{"full_name":"inkstone/ISEP"},"labels":[{"name":"s/review"}],"assignees":[],
|
||||
"pull_request":{"merged":false}},
|
||||
{"number":997,"title":"舊 org 的同一張票(重複,不是兩件事)","updated_at":"2026-08-01T00:00:00Z",
|
||||
"repository":{"full_name":"Leo/arcrun-rag"},"labels":[{"name":"s/doing"}],"assignees":[]},
|
||||
{"number":996,"title":"沒有狀態標籤,也沒人等——不是誰的事","updated_at":"2026-07-01T00:00:00Z",
|
||||
"repository":{"full_name":"inkstone/ISEP"},"labels":[],"assignees":[]}
|
||||
]}
|
||||
JSON
|
||||
echo '{"milestones": [], "issues": []}' > "$TMP/empty.json"
|
||||
|
||||
R=$(python3 "$NAG" --fixture "$TMP/full.json" --now "$NOW" 2>&1)
|
||||
S=$(python3 "$NAG" --fixture "$TMP/full.json" --now "$NOW" --short 2>&1)
|
||||
J=$(python3 "$NAG" --fixture "$TMP/full.json" --now "$NOW" --json 2>&1)
|
||||
|
||||
echo "── A 群:撈得出票上那五個逾期 milestone,而且是白話 ──────────────"
|
||||
has "$R" "逾期的 milestone(5 個)" && ok "① 五個都撈到了(票上驗收 1)" \
|
||||
|| no "① 五個都撈到了" "實得:$(printf '%s' "$R" | head -3 | tr '\n' ' ')"
|
||||
n=0
|
||||
for t in "不必開瀏覽器" "App 打開來就是 Arcrun" "把管理這條線做對" "AI 問得到內文" "寫進去,就查得到"; do
|
||||
has "$R" "$t" && n=$((n+1))
|
||||
done
|
||||
[ "$n" = 5 ] && ok "② 五個名字一個不漏" || no "② 五個名字一個不漏" "只對上 $n 個"
|
||||
has "$R" "逾期 3 天" && has "$R" "逾期 1 天" \
|
||||
&& ok "③ 逾期幾天算得對(08-24→3 天、08-26→1 天)" \
|
||||
|| no "③ 逾期幾天算得對" "實得:$(printf '%s' "$R" | grep -o '逾期 [0-9]* 天' | tr '\n' ' ')"
|
||||
has "$R" "還有 14 張票沒關" && ok "④ 講得出「還有幾張票沒關」(這才是他要判斷的)" \
|
||||
|| no "④ 還有幾張票沒關" "沒出現"
|
||||
has "$R" "對你意味著" && ok "⑤ 每一段都講「這對你意味什麼」(白話鐵律)" \
|
||||
|| no "⑤ 對你意味著" "整份報告沒有一句在講後果"
|
||||
has "$R" "等了 11 天" && ok "⑥ 等 leo 的票標出等了幾天(規則五:不標天數=不會被催)" \
|
||||
|| no "⑥ 等了幾天" "實得:$(printf '%s' "$R" | grep -o '等了 [0-9]* 天' | tr '\n' ' ')"
|
||||
has "$S" "回「看」" && ok "⑦ 短版給得出一個可以回的詞(leo 回一個詞就能處理)" \
|
||||
|| no "⑦ 可回的詞" "短版:$S"
|
||||
[ "$(printf '%s' "$S" | wc -l)" -le 6 ] && ok "⑧ 短版夠短(手機一眼掃完)" \
|
||||
|| no "⑧ 短版夠短" "$(printf '%s' "$S" | wc -l) 行"
|
||||
|
||||
echo "── B 群:沒東西可報時,要說「查過了,沒有」────────────────────────"
|
||||
E=$(python3 "$NAG" --fixture "$TMP/empty.json" --now "$NOW" 2>&1)
|
||||
ES=$(python3 "$NAG" --fixture "$TMP/empty.json" --now "$NOW" --short 2>&1)
|
||||
has "$E" "查過了,沒有" && ok "⑨ 長版說「查過了,沒有」(票上驗收 2)" || no "⑨ 長版" "實得:$E"
|
||||
has "$ES" "查過了,沒有" && ok "⑩ 短版也說(安靜跟壞掉長得一模一樣)" || no "⑩ 短版" "實得:$ES"
|
||||
BAD=$(TICKET_HOST=http://127.0.0.1:9 GITEA_TOKEN_CLAUDE_CODE= GITEA_TOKEN= \
|
||||
CLAUDE_PROJECT_DIR="$TMP" python3 "$NAG" --now "$NOW" 2>&1)
|
||||
has "$BAD" "這不等於" && ok "⑪ 撈不到時講「我沒查到」,不講成「沒有事情逾期」" \
|
||||
|| no "⑪ 撈不到≠沒事" "實得:$(printf '%s' "$BAD" | tr '\n' ' ')"
|
||||
|
||||
echo "── C 群:不該叫的不要叫(誤報比漏報更該修)──────────────────────"
|
||||
has "$R" "還沒到期的" && no "⑫ 還沒到期的不叫" "叫了" || ok "⑫ 還沒到期的不叫"
|
||||
has "$R" "逾期但票都關完了" && no "⑬ 票關完的不叫" "叫了" || ok "⑬ 逾期但票都關完了不叫"
|
||||
has "$R" "沒設期限的" && no "⑭ 沒期限的不叫" "叫了" || ok "⑭ 沒設期限的不是節拍器"
|
||||
has "$R" "昨天才動過" && no "⑮ 才動過的不叫" "叫了" || ok "⑮ 3 天內動過的 s/doing 不算掉棒"
|
||||
has "$R" "pr-verdict-guard 在管" && no "⑯ PR 不重複點名" "叫了" || ok "⑯ PR 交給 pr-verdict-guard,不重複"
|
||||
has "$R" "舊 org 的同一張票" && no "⑰ 舊 org 的重複票不叫" "叫了" || ok "⑰ 只看 inkstone,不重複計舊 org"
|
||||
has "$R" "不是誰的事" && no "⑱ 沒標籤沒人等的不叫" "叫了" || ok "⑱ 沒標籤又沒人等的不叫"
|
||||
printf '%s' "$J" | python3 -c 'import json,sys;json.load(sys.stdin)' 2>/dev/null \
|
||||
&& ok "⑲ --json 是合法 JSON(全清單在這裡,長版才封頂)" || no "⑲ --json" "不是合法 JSON"
|
||||
|
||||
echo "── D 群:發不出去不會安靜(票上驗收 4 的機器可驗那一半)──────────"
|
||||
OUT=$(python3 "$NOTIFY" --text "測試訊息 ABC" 2>&1); RC=$?
|
||||
[ "$RC" = 1 ] && ok "⑳ 兩條路都沒走通 ⇒ 離開碼 1(不是靜靜地成功)" \
|
||||
|| no "⑳ 離開碼" "實得 $RC"
|
||||
has "$OUT" "測試訊息 ABC" && ok "㉑ 原文照抄在輸出裡(看到的人才有辦法代發)" \
|
||||
|| no "㉑ 原文照抄" "實得:$OUT"
|
||||
has "$OUT" "leo 的手機上沒有出現這則" && ok "㉒ 明著說 leo 沒收到(不是「送出成功」)" \
|
||||
|| no "㉒ 明著說沒收到" "實得:$OUT"
|
||||
# 🔴 「為什麼發不出去」要跟著訊息一起留下來。2026-08-28 第一版漏了這一格:
|
||||
# 閘明明 pass,退路留言裡卻只看得到 pass,真正的斷點(實例上沒有那支工作流)
|
||||
# 一個字都沒進票 ⇒ 下一個人會去修閘,而斷點根本不在那裡。
|
||||
FB=$(python3 - <<'PYX'
|
||||
import importlib.machinery, importlib.util, os
|
||||
ld = importlib.machinery.SourceFileLoader("n", os.environ["NOTIFY_PATH"])
|
||||
sp = importlib.util.spec_from_loader("n", ld); m = importlib.util.module_from_spec(sp)
|
||||
ld.exec_module(m)
|
||||
fact = {"verdict": "pass", "version": "0.11.0", "guard": "/x/prod-write-guard.sh",
|
||||
"confidence": "high", "source": "測試", "reason": "閘放行"}
|
||||
body = m.fallback_body("要發的原文 XYZ", fact,
|
||||
"通道本身斷了:實例上找不到 notify_leo 工作流")
|
||||
ok = ("找不到 notify_leo" in body # 真正的斷點有進票
|
||||
and "要發的原文 XYZ" in body # 原文有進票
|
||||
and body.splitlines()[0].startswith("【身份】")) # 身份欄在第一行
|
||||
print("HAS_WHY" if ok else "NO_WHY:" + body[:120])
|
||||
PYX
|
||||
)
|
||||
[ "$FB" = "HAS_WHY" ] && ok "㉒b 退路留言帶著「為什麼發不出去」,不是只帶閘的判定" \
|
||||
|| no "㉒b 退路帶原因" "實得:$FB"
|
||||
|
||||
echo "── E 群:閘的版本是查得到的事實,不是假設 ────────────────────────"
|
||||
G=$(python3 "$NOTIFY" --gate 2>&1)
|
||||
has "$G" "這個 session 的通知閘" && ok "㉓ 問得出這個 session 的閘怎麼判" || no "㉓ --gate" "$G"
|
||||
has "$G" "ISEP 版本:v" && ok "㉔ 講得出版本(「哪一版」就是這件事的全部)" || no "㉔ 版本" "$G"
|
||||
has "$G" "問的是哪一份" && ok "㉕ 講得出問的是哪一個檔(同機可能有兩份)" || no "㉕ 路徑" "$G"
|
||||
|
||||
# 舊版的閘(v0.10.0 之前):拿真跡來測——git 歷史裡第一版 prod-write-guard
|
||||
OLD="$TMP/oldplug"
|
||||
mkdir -p "$OLD/hooks/lib" "$OLD/.claude-plugin"
|
||||
if git -C "$ROOT" show c263866:hooks/prod-write-guard.sh > "$OLD/hooks/prod-write-guard.sh" 2>/dev/null; then
|
||||
git -C "$ROOT" show c263866:.claude-plugin/plugin.json > "$OLD/.claude-plugin/plugin.json" 2>/dev/null
|
||||
OG=$(ISEP_NOTIFY_GUARD="$OLD/hooks/prod-write-guard.sh" python3 "$NOTIFY" --gate 2>&1)
|
||||
has "$OG" '\*\*block\*\*' && ok "㉖ 舊版的閘會擋——**用真跡測的**(c263866 那一版)" \
|
||||
|| no "㉖ 舊版會擋" "實得:$(printf '%s' "$OG" | tr '\n' ' ')"
|
||||
has "$OG" "開一個新的 session" && ok "㉗ 擋的時候說得出解法(更新 plugin + 開新 session)" \
|
||||
|| no "㉗ 說得出解法" "$OG"
|
||||
OB=$(ISEP_NOTIFY_GUARD="$OLD/hooks/prod-write-guard.sh" python3 "$NOTIFY" --text "被擋的訊息" 2>&1)
|
||||
has "$OB" "不繞路" && ok "㉘ 閘說會擋就不送——**不從 python 這條路繞過去**" \
|
||||
|| no "㉘ 不繞路" "$OB"
|
||||
has "$OB" "被擋的訊息" && ok "㉙ 被擋也照樣把原文吐出來(擋≠靜默)" || no "㉙ 原文" "$OB"
|
||||
else
|
||||
printf ' ⏭️ SKIP ㉖㉗㉘㉙:拿不到 c263866 的舊版閘(淺 clone?)\n'
|
||||
fi
|
||||
|
||||
NG=$(ISEP_NOTIFY_GUARD="$TMP/沒有這支.sh" python3 "$NOTIFY" --gate 2>&1)
|
||||
has "$NG" '\*\*unknown\*\*' && ok "㉚ 找不到閘 ⇒ 誠實回 unknown(不謊稱 pass)" \
|
||||
|| no "㉚ unknown" "$NG"
|
||||
|
||||
touch /dev/null
|
||||
STAMP_BAK=""
|
||||
if [ -f /tmp/.prod-write-ok ]; then STAMP_BAK=1; fi
|
||||
if [ -z "$STAMP_BAK" ]; then
|
||||
touch /tmp/.prod-write-ok
|
||||
SG=$(python3 "$NOTIFY" --gate 2>&1)
|
||||
[ -f /tmp/.prod-write-ok ] \
|
||||
&& ok "㉛ 戳記在場時不探針——**探一次就把總管那次授權燒掉了**" \
|
||||
|| no "㉛ 不燒戳記" "探針把 /tmp/.prod-write-ok 吃掉了"
|
||||
rm -f /tmp/.prod-write-ok
|
||||
else
|
||||
printf ' ⏭️ SKIP ㉛:這台機器上本來就有 /tmp/.prod-write-ok,不動它\n'
|
||||
fi
|
||||
|
||||
echo "── F 群:開 session 自己跑過一次(票上驗收 3)────────────────────"
|
||||
IN='{"hook_event_name":"SessionStart","session_id":"t"}'
|
||||
H=$(printf '%s' "$IN" | ISEP_NAG_NO_NOTIFY=1 ISEP_NAG_FIXTURE="$TMP/full.json" \
|
||||
ISEP_NAG_TIMEOUT=40 bash "$HOOK" 2>/dev/null)
|
||||
printf '%s' "$H" | python3 -c 'import json,sys;json.load(sys.stdin)' 2>/dev/null \
|
||||
&& ok "㉜ SessionStart 吐得出合法 JSON(吐壞的會被 harness 丟掉)" \
|
||||
|| no "㉜ 合法 JSON" "實得:$(printf '%s' "$H" | head -c 200)"
|
||||
has "$H" "additionalContext" && ok "㉝ 真的把報告注入眼前(不是印在誰都看不到的地方)" \
|
||||
|| no "㉝ 注入" "$H"
|
||||
has "$H" "catch" && no "㉞ 不該有例外堆疊" "有" || ok "㉞ 沒有噴例外"
|
||||
CH=$(printf '%s' "$IN" | CLAUDE_CODE_CHILD_SESSION=1 bash "$HOOK" 2>/dev/null)
|
||||
[ -z "$CH" ] && ok "㉟ 子 session 不叫(subagent 開工不是 leo 開工)" \
|
||||
|| no "㉟ 子 session 不叫" "實得:$CH"
|
||||
|
||||
echo
|
||||
printf '通過 %d 條,失敗 %d 條\n' "$PASS" "$FAIL"
|
||||
[ "$FAIL" = 0 ]
|
||||
Executable
+181
@@ -0,0 +1,181 @@
|
||||
#!/usr/bin/env bash
|
||||
# roster-guard.sh 的迴歸測試(inkstone/ISEP#86)
|
||||
#
|
||||
# 票上的四條驗收條件,一條一組:
|
||||
# 1 列出名單 → 看得到有哪幾個工人、各自管什麼 (scripts/roster list)
|
||||
# 2 派工要能指名派給誰 (subagent_type 認得出來)
|
||||
# 3 票上的紀錄看得出是哪個工人做的 (【身份】欄吃得下工人名字)
|
||||
# 4 派一個名單裡沒有的名字 → 要講得出「沒有這個人」
|
||||
#
|
||||
# 另外兩群:**不該擋**(誤攔比漏擋嚴重)、**注入**(指對名字就把它的檔案原文送過去)。
|
||||
#
|
||||
# 🔴 全程離線,不打網路、不花錢、每次結果一樣(純結構判斷,沒有語意判官)。
|
||||
#
|
||||
# 用法:hooks/tests/roster-guard.test.sh [hook 路徑]
|
||||
set -u
|
||||
unset CLAUDE_CODE_CHILD_SESSION # 殘留會讓別的閘行為不同;本閘不看它,但別讓環境有變數
|
||||
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ROOT="$(cd "$HERE/../.." && pwd)"
|
||||
HOOK="${1:-$ROOT/hooks/roster-guard.sh}"
|
||||
TMP=$(mktemp -d); trap 'rm -rf "$TMP"' EXIT
|
||||
|
||||
PASS=0; FAIL=0; N=0
|
||||
|
||||
# payload <prompt> <subagent_type> [tool_name]
|
||||
payload() {
|
||||
python3 - "$1" "$2" "${3:-Task}" <<'PY'
|
||||
import json, sys
|
||||
print(json.dumps({"session_id": "S-ROSTER", "hook_event_name": "PreToolUse",
|
||||
"tool_name": sys.argv[3],
|
||||
"tool_input": {"prompt": sys.argv[1], "subagent_type": sys.argv[2]}},
|
||||
ensure_ascii=False))
|
||||
PY
|
||||
}
|
||||
|
||||
# t <期望 exit> <說明> <payload> [必須出現的字串] [ISEP_ROSTER_DIR]
|
||||
t() {
|
||||
want="$1"; desc="$2"; body="$3"; must="${4:-}"; rdir="${5:-}"
|
||||
N=$((N+1))
|
||||
if [ -n "$rdir" ]; then
|
||||
err=$(printf '%s' "$body" | ISEP_ROSTER_DIR="$rdir" bash "$HOOK" 2>&1 >/dev/null); rc=$?
|
||||
else
|
||||
err=$(printf '%s' "$body" | bash "$HOOK" 2>&1 >/dev/null); rc=$?
|
||||
fi
|
||||
ok=1
|
||||
[ "$rc" -eq "$want" ] || ok=0
|
||||
if [ -n "$must" ] && ! printf '%s' "$err" | 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' "$err" | sed -n '1,6p' | sed 's/^/ /'
|
||||
FAIL=$((FAIL+1))
|
||||
fi
|
||||
}
|
||||
|
||||
clean() { rm -f /tmp/.roster-ok-S-ROSTER 2>/dev/null || true; }
|
||||
clean
|
||||
|
||||
echo "── 驗收 1:列出名單 ────────────────────────────────────────────"
|
||||
|
||||
N=$((N+1))
|
||||
out=$(python3 "$ROOT/scripts/roster" list 2>&1)
|
||||
if printf '%s' "$out" | grep -q 'isep-hand' && printf '%s' "$out" | grep -q 'inkstone/ISEP' \
|
||||
&& printf '%s' "$out" | grep -q '負責 repo'; then
|
||||
printf ' ✅ ① `roster list` 印得出「有哪幾個工人、各自管什麼」\n'; PASS=$((PASS+1))
|
||||
else
|
||||
printf ' ❌ ① `roster list` 沒有名字或沒有負責的 repo\n'; printf '%s\n' "$out" | sed 's/^/ /'
|
||||
FAIL=$((FAIL+1))
|
||||
fi
|
||||
|
||||
N=$((N+1))
|
||||
if python3 "$ROOT/scripts/roster" show isep-hand 2>&1 | grep -q '## 紅線'; then
|
||||
printf ' ✅ ② `roster show` 印得出那位工人的紅線(它開工時會讀到的東西)\n'; PASS=$((PASS+1))
|
||||
else
|
||||
printf ' ❌ ② `roster show` 沒有印出紅線段\n'; FAIL=$((FAIL+1))
|
||||
fi
|
||||
|
||||
N=$((N+1))
|
||||
if [ "$(python3 "$ROOT/scripts/roster" which inkstone/ISEP 2>/dev/null)" = "isep-hand" ]; then
|
||||
printf ' ✅ ③ `roster which <repo>` 答得出這個 repo 該派給誰\n'; PASS=$((PASS+1))
|
||||
else
|
||||
printf ' ❌ ③ `roster which inkstone/ISEP` 沒有回 isep-hand\n'; FAIL=$((FAIL+1))
|
||||
fi
|
||||
|
||||
echo "── 驗收 2:派工要能指名派給誰(不該擋)────────────────────────"
|
||||
|
||||
for who in isep-hand arcrun-hand arcrun-rag-hand inkstoneco-hand mira-hand collector-hand scout; do
|
||||
t 0 "④ 指名 $who ⇒ 放行" "$(payload '【工單】inkstone/ISEP#86' "$who")"
|
||||
done
|
||||
|
||||
t 0 "⑤ Agent 這條路也認得(不是只認 Task)" \
|
||||
"$(payload '【工單】inkstone/ISEP#86' 'scout' 'Agent')"
|
||||
|
||||
echo "── 驗收 4:名單上沒有的名字 → 講得出「沒有這個人」──────────────"
|
||||
|
||||
t 2 "⑥ 派 general-purpose(就是「沒有名字的臨時工」)⇒ 擋,並說沒有這個人" \
|
||||
"$(payload '【工單】inkstone/ISEP#86' 'general-purpose')" '沒有「general-purpose」這個人'
|
||||
|
||||
t 2 "⑦ 完全沒指名(subagent_type 是空的)⇒ 擋" \
|
||||
"$(payload '【工單】inkstone/ISEP#86' '')" '沒有指名工人'
|
||||
|
||||
t 2 "⑧ 打錯字(isep-hands)⇒ 擋。名字是唯一識別碼,不做模糊比對" \
|
||||
"$(payload '【工單】inkstone/ISEP#86' 'isep-hands')" '沒有「isep-hands」這個人'
|
||||
|
||||
t 2 "⑨ 擋下來的時候要把整份名單印出來(不然人不知道有誰可以派)" \
|
||||
"$(payload '【工單】inkstone/ISEP#86' 'nobody')" 'arcrun-rag-hand'
|
||||
|
||||
echo "── 不該擋(誤攔比漏擋嚴重)──────────────────────────────────"
|
||||
|
||||
t 0 "⑩ 沒有【工單】⇒ 閉嘴(那是 no-ticket-no-dispatch 的地盤,不准兩支閘同時開口)" \
|
||||
"$(payload '去查一下那個 worker 為什麼沒更新' 'general-purpose')"
|
||||
|
||||
t 0 "⑪ 不是派工的動作(Bash)⇒ 一律不管" \
|
||||
'{"session_id":"S-ROSTER","tool_name":"Bash","tool_input":{"command":"ls"}}'
|
||||
|
||||
t 0 "⑫ 整包不是合法 JSON ⇒ fail-open" 'this is not json at all'
|
||||
|
||||
t 0 "⑬ 名單目錄是空的 ⇒ fail-open(讀不到的名單不該讓整台機器派不了工)" \
|
||||
"$(payload '【工單】inkstone/ISEP#86' 'general-purpose')" '' "$TMP"
|
||||
|
||||
clean
|
||||
touch /tmp/.roster-ok-S-ROSTER
|
||||
t 0 "⑭ 明示豁免戳記在 ⇒ 放行一次" \
|
||||
"$(payload '【工單】inkstone/ISEP#86' 'general-purpose')"
|
||||
N=$((N+1))
|
||||
if [ -f /tmp/.roster-ok-S-ROSTER ]; then
|
||||
printf ' ❌ ⑮ 豁免戳記用完沒被消掉(會變成永久開關)\n'; FAIL=$((FAIL+1))
|
||||
else
|
||||
printf ' ✅ ⑮ 豁免戳記用完就消失,不是永久開關\n'; PASS=$((PASS+1))
|
||||
fi
|
||||
clean
|
||||
|
||||
echo "── 注入:指對名字就把那位工人的檔案原文送過去 ──────────────────"
|
||||
|
||||
N=$((N+1))
|
||||
inj=$(printf '%s' "$(payload '【工單】inkstone/ISEP#86' 'isep-hand')" | bash "$HOOK" 2>/dev/null)
|
||||
if printf '%s' "$inj" | python3 -c '
|
||||
import sys, json
|
||||
c = json.load(sys.stdin)["hookSpecificOutput"]["additionalContext"]
|
||||
assert "isep-hand" in c, "沒有名字"
|
||||
assert "inkstone/ISEP" in c, "沒有負責的 repo"
|
||||
assert "開工前先讀" in c, "沒有「開工前先讀」"
|
||||
assert "紅線" in c, "沒有紅線"
|
||||
' 2>/dev/null; then
|
||||
printf ' ✅ ⑯ 指對名字 ⇒ 注入「你是誰/負責哪個 repo/開工前先讀/紅線」四件\n'; PASS=$((PASS+1))
|
||||
else
|
||||
printf ' ❌ ⑯ 沒有注入,或缺了其中一件\n'; printf '%s\n' "$inj" | sed -n '1,4p' | sed 's/^/ /'
|
||||
FAIL=$((FAIL+1))
|
||||
fi
|
||||
|
||||
N=$((N+1))
|
||||
inj2=$(printf '%s' "$(payload '【工單】inkstone/Arcrun#142' 'arcrun-hand')" | bash "$HOOK" 2>/dev/null)
|
||||
if printf '%s' "$inj2" | grep -q 'arcrun-hand' && ! printf '%s' "$inj2" | grep -q 'isep-hand'; then
|
||||
printf ' ✅ ⑰ 注入的是**那一位**的檔案,不是整份名單\n'; PASS=$((PASS+1))
|
||||
else
|
||||
printf ' ❌ ⑰ 注入的內容不是指名的那一位\n'; FAIL=$((FAIL+1))
|
||||
fi
|
||||
|
||||
echo "── 驗收 3:票上的紀錄看得出是哪個工人做的(身份欄)──────────────"
|
||||
|
||||
N=$((N+1))
|
||||
if python3 - "$ROOT" <<'PY' 2>/dev/null
|
||||
import sys, os
|
||||
sys.path.insert(0, os.path.join(sys.argv[1], "hooks", "lib"))
|
||||
import dispatch_parse as dp
|
||||
ok, _ = dp.parse_identity("【身份】isep-hand/inkstone/ISEP/feat/x")
|
||||
assert ok, "工人名字不被身份欄接受"
|
||||
ok2, _ = dp.parse_identity("【身份】總管/inkstone/ISEP/-")
|
||||
assert ok2, "原本的三個角色被弄壞了"
|
||||
bad, _ = dp.parse_identity("【身份】某某某/inkstone/ISEP/-")
|
||||
assert not bad, "亂寫的名字被放行了"
|
||||
PY
|
||||
then
|
||||
printf ' ✅ ⑱ 【身份】欄吃得下工人名字,原本三個角色照舊,亂寫的仍然擋\n'; PASS=$((PASS+1))
|
||||
else
|
||||
printf ' ❌ ⑱ 身份欄沒有吃下工人名字(或把原本的三個角色弄壞了)\n'; FAIL=$((FAIL+1))
|
||||
fi
|
||||
|
||||
echo
|
||||
printf '結果:%s 通過 / %s 失敗(共 %s 條)\n' "$PASS" "$FAIL" "$N"
|
||||
[ "$FAIL" -eq 0 ] || exit 1
|
||||
Executable
+186
@@ -0,0 +1,186 @@
|
||||
#!/usr/bin/env bash
|
||||
# wiki 壓縮的迴歸測試(inkstone/ISEP#89)
|
||||
#
|
||||
# 對應票上那三條驗收:
|
||||
# 1 壓完之後同一件事查得到、而且查得比壓之前快 → C 群(bench)
|
||||
# 2 壓縮前後不能弄丟任何一條教訓(逐條對帳) → B 群(verify)
|
||||
# 3 壓縮這件事本身要有票、有交付紀錄 → D 群(--ticket + .compress-log.md)
|
||||
#
|
||||
# 另外兩群是這套的主要失效模式:
|
||||
# A 群 切條要切對 —— 切錯的話後面三件全部失準(尤其 code fence 裡的 `#`)
|
||||
# E 群 閘:大幅刪減要擋、正常編輯不准擋 —— **誤攔比漏擋更該修**
|
||||
#
|
||||
# 🔴 全程離線、只在 mktemp 目錄裡動檔案,不碰任何真的 wiki。
|
||||
set -u
|
||||
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ROOT="$(cd "$HERE/../.." && pwd)"
|
||||
WC="$ROOT/scripts/wiki-compress"
|
||||
HOOK="$ROOT/hooks/wiki-size-guard.sh"
|
||||
|
||||
TMP=$(mktemp -d)
|
||||
trap 'rm -rf "$TMP" /tmp/.wiki-size-guard-wctest*' EXIT
|
||||
# 🔴 目錄結構要跟真的一樣(`<專案>/system-dev/wiki`),不然 F 群測到的是
|
||||
# 「hook 找不到 wiki 目錄所以閉嘴」,那是假綠——它跟「都在門檻內」長得一模一樣。
|
||||
W="$TMP/system-dev/wiki"; mkdir -p "$W"
|
||||
|
||||
PASS=0; FAIL=0
|
||||
ok(){ printf ' ✅ %s\n' "$1"; PASS=$((PASS+1)); }
|
||||
no(){ printf ' ❌ %s —— %s\n' "$1" "$2"; FAIL=$((FAIL+1)); }
|
||||
has(){ printf '%s' "$1" | grep -q -- "$2"; }
|
||||
|
||||
# ── 測資:一份小而完整的假 wiki(含所有會出事的形狀)──────────────────
|
||||
{
|
||||
echo "# 假的 mistakes"
|
||||
echo
|
||||
echo "> 前言,壓縮後要留在最上面。"
|
||||
echo
|
||||
for i in $(seq 1 40); do
|
||||
d=$(printf '2026-0%d-%02d' $(( (i % 3) + 6 )) $(( (i % 27) + 1 )))
|
||||
echo
|
||||
echo "## 第 $i 條:獨特關鍵詞 KEY$i 的教訓($d)"
|
||||
echo
|
||||
echo "**症狀**:某某東西壞了 $i。票:inkstone/ISEP#$((100 + i))"
|
||||
echo
|
||||
echo '```sh'
|
||||
echo "# 這一行是程式碼註解,不是標題 $i"
|
||||
echo "## 這一行也是,切條的時候不准把它當成一條"
|
||||
echo '```'
|
||||
echo
|
||||
for _ in $(seq 1 12); do echo "正文填充 $i:這一段是為了讓每條有足夠的行數。"; done
|
||||
done
|
||||
echo
|
||||
echo "## 自承重複的那一條:這是同款第三次(2026-08-20)"
|
||||
echo
|
||||
echo "同一個病又犯一次。票:inkstone/ISEP#999"
|
||||
} > "$W/mistakes.md"
|
||||
cp "$W/mistakes.md" "$TMP/orig.md"
|
||||
|
||||
echo "── A 群:切條要切對(切錯的話後面三件全部失準)──────────────────"
|
||||
A=$(python3 "$WC" audit "$W" 2>&1)
|
||||
has "$A" "41 條" && ok "① 切出 41 條(40 + 自承重複那條)" \
|
||||
|| no "① 切出 41 條" "實得:$(printf '%s' "$A" | grep mistakes)"
|
||||
has "$A" "這一行也是" && no "② code fence 裡的 ## 不算一條" "算進去了" \
|
||||
|| ok "② code fence 裡的 ## 不算一條(mistakes.md 真的有這種行)"
|
||||
has "$A" "自承是同款" && ok "③ 認得出「自承是同款第 N 次」(人下的判斷比機器猜的準)" \
|
||||
|| no "③ 自承重複" "沒偵測到"
|
||||
|
||||
echo "── B 群:不能弄丟任何一條(票上驗收 2)──────────────────────────"
|
||||
AP=$(WIKI_COMPRESS_THRESHOLD=400 python3 "$WC" apply "$W/mistakes.md" \
|
||||
--ticket inkstone/ISEP#89 2>&1)
|
||||
has "$AP" "✅ 壓完了" && ok "④ 壓得動" || no "④ 壓得動" "$AP"
|
||||
V=$(python3 "$WC" verify "$W/mistakes.md.before-compress" "$W/mistakes.md" \
|
||||
"$W"/mistakes-archive-*.md 2>&1)
|
||||
has "$V" "一條都沒弄丟" && ok "⑤ 逐條對帳:一條都沒弄丟" || no "⑤ 逐條對帳" "$V"
|
||||
diff -q "$TMP/orig.md" "$W/mistakes.md.before-compress" >/dev/null \
|
||||
&& ok "⑥ 壓縮前的原檔完整留著(.before-compress,錯了還原得回去)" \
|
||||
|| no "⑥ 原檔留底" "跟壓縮前不一樣"
|
||||
|
||||
# 真的弄丟一條 ⇒ verify 一定要抓到。**抓不到的對帳表比沒有更糟。**
|
||||
cp "$W/mistakes.md" "$TMP/tampered.md"
|
||||
(python3 - "$W" "$TMP/tampered.md" <<'PY'
|
||||
import glob, os, re, sys
|
||||
w, tam = sys.argv[1], sys.argv[2]
|
||||
# 從某個 archive 裡整條刪掉一條,模擬「壓縮的時候弄丟了」
|
||||
for f in sorted(glob.glob(os.path.join(w, "mistakes-archive-*.md"))):
|
||||
t = open(f, encoding="utf-8").read()
|
||||
parts = re.split(r"(?m)^## ", t)
|
||||
if len(parts) > 2:
|
||||
open(f + ".broken", "w", encoding="utf-8").write(
|
||||
parts[0] + "## " + "## ".join(parts[2:]))
|
||||
print(f + ".broken")
|
||||
break
|
||||
PY
|
||||
) >/dev/null
|
||||
BROKEN=$(ls "$W"/mistakes-archive-*.md.broken 2>/dev/null | head -1)
|
||||
if [ -n "$BROKEN" ]; then
|
||||
KEEP=$(ls "$W"/mistakes-archive-*.md 2>/dev/null | grep -v '\.broken$' \
|
||||
| grep -v "$(basename "${BROKEN%.broken}")" || true)
|
||||
# shellcheck disable=SC2086
|
||||
V2=$(python3 "$WC" verify "$W/mistakes.md.before-compress" "$W/mistakes.md" \
|
||||
"$BROKEN" $KEEP 2>&1); RC2=$?
|
||||
has "$V2" "弄丟了" && [ "$RC2" = 1 ] \
|
||||
&& ok "⑦ **真的弄丟一條時 verify 抓得到**(反向測過,離開碼 1)" \
|
||||
|| no "⑦ 反向測" "沒抓到(rc=$RC2):$(printf '%s' "$V2" | tr '\n' ' ')"
|
||||
else
|
||||
printf ' ⏭️ SKIP ⑦:這次沒有 archive 可以拿來破壞\n'
|
||||
fi
|
||||
|
||||
echo "── C 群:查得到,而且查得更快(票上驗收 1)──────────────────────"
|
||||
B=$(python3 "$WC" bench "$W/mistakes.md.before-compress" "$W/mistakes.md" \
|
||||
"$W"/mistakes-archive-*.md 2>&1)
|
||||
has "$B" "查得更快" && ok "⑧ 命中率沒掉,而且更快" || no "⑧ 更快" "$B"
|
||||
BEF=$(printf '%s' "$B" | grep -o '壓縮前.*平均 [0-9]* 行' | grep -o '[0-9]*' | tail -1)
|
||||
AFT=$(printf '%s' "$B" | grep -o '壓縮後.*平均 [0-9]* 行' | grep -o '[0-9]*' | tail -1)
|
||||
[ -n "$BEF" ] && [ -n "$AFT" ] && [ "$AFT" -lt "$BEF" ] \
|
||||
&& ok "⑨ 定位成本真的變小($BEF → $AFT 行)" \
|
||||
|| no "⑨ 定位成本" "前=$BEF 後=$AFT"
|
||||
has "$(cat "$W/mistakes.md")" "KEY7" && ok "⑩ 壓完的原檔裡查得到舊條目(目錄留了指標)" \
|
||||
|| no "⑩ 目錄留指標" "KEY7 在壓縮後的原檔裡不見了"
|
||||
|
||||
echo "── D 群:壓縮這件事本身要有票、有交付紀錄(票上驗收 3)────────────"
|
||||
NT=$(python3 "$WC" apply "$W/mistakes.md" 2>&1); RCN=$?
|
||||
[ "$RCN" != 0 ] && ok "⑪ 沒給 --ticket 就不准壓" || no "⑪ 沒票不准壓" "居然壓了"
|
||||
BAD=$(python3 "$WC" apply "$W/mistakes.md" --ticket 89 2>&1); RCB=$?
|
||||
[ "$RCB" != 0 ] && ok "⑫ 裸號不算票號(跨 repo 會撞號)" || no "⑫ 裸號" "收了"
|
||||
[ -f "$W/.compress-log.md" ] && ok "⑬ 留下交付紀錄 .compress-log.md" \
|
||||
|| no "⑬ 交付紀錄" "沒有這個檔"
|
||||
L=$(cat "$W/.compress-log.md" 2>/dev/null)
|
||||
has "$L" "inkstone/ISEP#89" && ok "⑭ 紀錄裡寫著票號(誰批准壓的查得到)" || no "⑭ 票號" "$L"
|
||||
has "$L" "對帳指令" && ok "⑮ 紀錄裡寫著對帳指令(下一個人跑得起來)" || no "⑮ 對帳指令" "$L"
|
||||
|
||||
echo "── E 群:閘——大幅刪減要擋,正常編輯不准擋 ──────────────────────"
|
||||
mk(){ printf '{"hook_event_name":"PreToolUse","session_id":"wctest%s","tool_name":"%s","tool_input":%s}' "$1" "$2" "$3"; }
|
||||
run(){ rm -f /tmp/.wiki-size-guard-wctest*; out=$(printf '%s' "$1" | bash "$HOOK" 2>&1); printf '%s|%s' "$?" "$(printf '%s' "$out" | tr '\n' ' ')"; }
|
||||
|
||||
BIG=$(python3 -c 'print("這是一段很長的舊內容。" * 200)')
|
||||
SMALL="剩這一句。"
|
||||
R=$(run "$(mk 1 Edit "$(python3 -c "
|
||||
import json,sys
|
||||
print(json.dumps({'file_path':'$W/mistakes.md','old_string':'''$BIG''','new_string':'$SMALL'}, ensure_ascii=False))")")")
|
||||
case "$R" in 2\|*) ok "⑯ 砍掉一大段 wiki ⇒ 擋一次" ;; *) no "⑯ 該擋" "實得 $R" ;; esac
|
||||
has "$R" "只搬不改" && ok "⑰ 擋的訊息說得出出路(三步指令)" || no "⑰ 出路" "$R"
|
||||
|
||||
R=$(run "$(mk 2 Edit "$(python3 -c "
|
||||
import json
|
||||
print(json.dumps({'file_path':'$W/mistakes.md','old_string':'這是一句舊的話,長度普通。','new_string':'這是一句新的話,長度也普通,改了幾個字。'}, ensure_ascii=False))")")")
|
||||
case "$R" in 0\|*) ok "⑱ 一般編輯不擋(誤攔比漏擋更該修)" ;; *) no "⑱ 不該擋" "實得 $R" ;; esac
|
||||
|
||||
R=$(run "$(mk 3 Edit "$(python3 -c "
|
||||
import json
|
||||
print(json.dumps({'file_path':'/somewhere/src/app.ts','old_string':'x'*3000,'new_string':'y'}, ensure_ascii=False))")")")
|
||||
case "$R" in 0\|*) ok "⑲ 不是 wiki 的檔不管(別的檔縮水是它們自己的事)" ;; *) no "⑲ 非 wiki" "實得 $R" ;; esac
|
||||
|
||||
R=$(run "$(mk 4 Edit "$(python3 -c "
|
||||
import json,sys
|
||||
print(json.dumps({'file_path':'$W/mistakes.md','old_string':'''$BIG''','new_string':'剩這一句。 wiki-compress-ok'}, ensure_ascii=False))")")")
|
||||
case "$R" in 0\|*) ok "⑳ wiki-compress-ok 留痕式豁免走得通" ;; *) no "⑳ 豁免" "實得 $R" ;; esac
|
||||
|
||||
R=$(run "$(mk 5 Write "$(python3 -c "
|
||||
import json
|
||||
print(json.dumps({'file_path':'$W/從來沒有過的新檔.md','content':'新檔案的一點內容'}, ensure_ascii=False))")")")
|
||||
case "$R" in 0\|*) ok "㉑ 新建檔案不算壓縮" ;; *) no "㉑ 新檔" "實得 $R" ;; esac
|
||||
|
||||
# 至多擋一次:同一個 session 連兩次,第二次要放行(不然會卡死)
|
||||
PAY=$(mk 6 Edit "$(python3 -c "
|
||||
import json
|
||||
print(json.dumps({'file_path':'$W/mistakes.md','old_string':'''$BIG''','new_string':'剩這一句。'}, ensure_ascii=False))")")
|
||||
rm -f /tmp/.wiki-size-guard-wctest6
|
||||
printf '%s' "$PAY" | bash "$HOOK" >/dev/null 2>&1; first=$?
|
||||
printf '%s' "$PAY" | bash "$HOOK" >/dev/null 2>&1; second=$?
|
||||
[ "$first" = 2 ] && [ "$second" = 0 ] \
|
||||
&& ok "㉒ 至多擋一次(被擋兩次的閘會被想辦法繞過)" \
|
||||
|| no "㉒ 至多擋一次" "第一次 $first、第二次 $second"
|
||||
|
||||
echo "── F 群:SessionStart 講出來哪幾個檔沒人讀得完 ──────────────────"
|
||||
S=$(printf '{"hook_event_name":"SessionStart","source":"startup"}' \
|
||||
| CLAUDE_PROJECT_DIR="$TMP" WIKI_COMPRESS_THRESHOLD=50 bash "$HOOK" 2>/dev/null)
|
||||
printf '%s' "$S" | python3 -c 'import json,sys;json.load(sys.stdin)' 2>/dev/null \
|
||||
&& ok "㉓ 吐得出合法 JSON" || no "㉓ 合法 JSON" "實得:$(printf '%s' "$S" | head -c 200)"
|
||||
has "$S" "沒人讀得完" && ok "㉔ 真的點名超過門檻的檔" || no "㉔ 點名" "$S"
|
||||
S2=$(printf '{"hook_event_name":"SessionStart","source":"startup"}' \
|
||||
| CLAUDE_PROJECT_DIR="$TMP" WIKI_COMPRESS_THRESHOLD=99999 bash "$HOOK" 2>/dev/null)
|
||||
[ -z "$S2" ] && ok "㉕ 都在門檻內就閉嘴(沒事還講話=下次沒人看)" || no "㉕ 沒事閉嘴" "$S2"
|
||||
|
||||
echo
|
||||
printf '通過 %d 條,失敗 %d 條\n' "$PASS" "$FAIL"
|
||||
[ "$FAIL" = 0 ]
|
||||
Executable
+183
@@ -0,0 +1,183 @@
|
||||
#!/usr/bin/env bash
|
||||
# wiki-size-guard.sh — 讀不完的必讀檔要被講出來;壓它的時候不准沒人知道
|
||||
# (inkstone/ISEP#89)
|
||||
#
|
||||
# ── 為什麼要有這一支 ──────────────────────────────────────────────────
|
||||
# leo 2026-08-27 說那天「**失去記憶**」。而記憶失效的其中一個原因是:
|
||||
# **東西還在,只是沒人讀得完。** `mistakes.md` 實測 7,681 行、260 條,
|
||||
# 而它是「做新功能前讀一遍」等級的必讀檔。**讀不完的必讀檔,等於沒有。**
|
||||
#
|
||||
# 既有三支管的是別的事,一支都接不到這裡:
|
||||
# · `wiki-secret-scan.sh` 寫進去的內容安不安全
|
||||
# · `wiki-first-search.sh` 有沒有先查 wiki
|
||||
# · `wiki-first-police.sh` 收工前有沒有查過
|
||||
# **沒有任何一支管「太大了」。**
|
||||
#
|
||||
# ── 兩個事件,是同一件事的兩半 ────────────────────────────────────────
|
||||
# ① SessionStart ── 講:哪幾個檔已經沒有人讀得完了(📝 記錄,不擋)
|
||||
# ② Write|Edit|MultiEdit ── 擋:**大幅刪減 wiki 檔**卻沒走正式流程 ⇒ 擋一次
|
||||
#
|
||||
# 只有 ① = 又一個會被忽略的提醒。
|
||||
# 只有 ② = 罰它做一件沒人告訴它該做的事。
|
||||
#
|
||||
# ── 🔴 ② 擋的判準是「動作」不是「措辭」(leo 2026-08-17)────────────────
|
||||
# 「自然語言的變體是無限的,blacklist 永遠追不完……封路哲學之所以有效,
|
||||
# 是因為它封的是**動作**——動作有限且可枚舉。」
|
||||
# 本支比的是**字元數**:wiki 檔的內容淨縮水超過門檻 ⇒ 那就是一次壓縮,
|
||||
# 不管它在 commit 訊息裡怎麼講。加字、改字、小修一律不碰。
|
||||
#
|
||||
# 為什麼壓縮要擋:**壓縮會弄丟東西,而弄丟的當下沒有人會發現**(票上原話)。
|
||||
# 走 `scripts/wiki-compress` 有兩件手寫做不到的事——
|
||||
# · `--ticket` 逼票號,並把「這一版壓掉了什麼」寫進 `.compress-log.md`(留痕)
|
||||
# · `verify` 用內文雜湊**逐條對帳**,「沒弄丟」是算出來的,不是宣稱的
|
||||
#
|
||||
# 出口(留痕式,同 `wiki-secret-ok` 的慣例):真的要手改,
|
||||
# 在寫入的內容裡放一個 `wiki-compress-ok`,並在 commit 說明理由。
|
||||
set -uo pipefail
|
||||
|
||||
ROOT="${CLAUDE_PLUGIN_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}"
|
||||
TOOL_PY="$ROOT/scripts/wiki-compress"
|
||||
INPUT="$(cat)"
|
||||
|
||||
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 '')
|
||||
|
||||
# ══ ① SessionStart:講出來哪幾個檔已經沒人讀得完 ═══════════════════════
|
||||
if [ "$EVENT" = "SessionStart" ]; then
|
||||
[ -f "$TOOL_PY" ] || exit 0
|
||||
PROJ="${CLAUDE_PROJECT_DIR:-$PWD}"
|
||||
DIRS=""
|
||||
for d in "$PROJ/system-dev/wiki" "$PROJ/.claude/wiki" \
|
||||
"$PROJ/InkStoneCo/system-dev/wiki"; do
|
||||
[ -d "$d" ] && DIRS="$DIRS $d"
|
||||
done
|
||||
[ -n "$DIRS" ] || exit 0
|
||||
# shellcheck disable=SC2086
|
||||
# 🔴 不要寫成 `OUT=$(…) || OUT=""`:`audit` 有東西可報時**刻意回離開碼 1**,
|
||||
# 加上 pipefail,那個 `||` 會把剛拿到的結果整個清成空的
|
||||
# ⇒ **有 7 個檔爆表,而這一段一聲不吭**。(2026-08-28 寫的時候當場踩到。)
|
||||
OUT=$(python3 "$TOOL_PY" audit $DIRS 2>/dev/null | grep '🔴 太長' | head -6 || true)
|
||||
[ -n "$OUT" ] || exit 0
|
||||
python3 - "$OUT" <<'PY'
|
||||
import json, sys
|
||||
rows = [r for r in sys.argv[1].split("\n") if r.strip()]
|
||||
msg = "📚 wiki 有 %d 個檔沒人讀得完了" % len(rows)
|
||||
ctx = "\n".join([
|
||||
"📚 **這幾個 wiki 檔已經超過『讀得完』的門檻**(inkstone/ISEP#89):",
|
||||
"",
|
||||
] + rows + [
|
||||
"",
|
||||
"🔴 讀不完的必讀檔,跟沒有那個檔的差別只在於**它讓人以為有**。",
|
||||
"leo 2026-08-27 說那天「失去記憶」——東西都還在,只是沒人讀得完。",
|
||||
"",
|
||||
"要整理:`python3 scripts/wiki-compress plan <檔>` 看怎麼壓,",
|
||||
"然後 `apply <檔> --ticket owner/repo#N`(**只搬不改**,正文一個字都不動),",
|
||||
"壓完一定要 `verify` 逐條對帳。**壓縮這件事本身要有票**,不准順手做完沒人知道。",
|
||||
])
|
||||
print(json.dumps({"systemMessage": msg,
|
||||
"hookSpecificOutput": {"hookEventName": "SessionStart",
|
||||
"additionalContext": ctx}},
|
||||
ensure_ascii=False))
|
||||
PY
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ══ ② Write|Edit|MultiEdit:大幅刪減 wiki 檔 ⇒ 擋一次 ═══════════════════
|
||||
VERDICT=$(printf '%s' "$INPUT" | python3 -c '
|
||||
import json, os, re, sys
|
||||
|
||||
try:
|
||||
d = json.load(sys.stdin)
|
||||
except Exception:
|
||||
print("SKIP:bad-json"); raise SystemExit
|
||||
|
||||
ti = d.get("tool_input") or {}
|
||||
path = ti.get("file_path") or ""
|
||||
# 只管 wiki 底下的 .md。別的檔案縮水是它們自己的事。
|
||||
if not path.endswith(".md") or not re.search(r"(^|/)(wiki)/", path):
|
||||
print("SKIP:not-wiki"); raise SystemExit
|
||||
|
||||
def size(s):
|
||||
return len(re.sub(r"\s+", "", s or ""))
|
||||
|
||||
# 留痕式出口:內容裡放了 wiki-compress-ok ⇒ 放行(同 wiki-secret-ok 的慣例)
|
||||
blob = json.dumps(ti, ensure_ascii=False)
|
||||
if "wiki-compress-ok" in blob:
|
||||
print("SKIP:opt-out"); raise SystemExit
|
||||
|
||||
before = after = 0
|
||||
if "content" in ti: # Write:跟磁碟上現有的比
|
||||
try:
|
||||
with open(path, encoding="utf-8", errors="replace") as f:
|
||||
before = size(f.read())
|
||||
except Exception:
|
||||
print("SKIP:new-file"); raise SystemExit # 新檔不是壓縮
|
||||
after = size(ti.get("content"))
|
||||
else: # Edit / MultiEdit
|
||||
edits = ti.get("edits") or [ti]
|
||||
for e in edits:
|
||||
if not isinstance(e, dict):
|
||||
continue
|
||||
before += size(e.get("old_string"))
|
||||
after += size(e.get("new_string"))
|
||||
|
||||
# 判準:**淨縮水的量**要夠大,比例也要夠大。兩個都要,才不會抓到小修。
|
||||
# · 至少砍掉 800 個非空白字元 —— 改錯字、刪一段話都到不了
|
||||
# · 而且砍掉的超過原本的 45% —— 「重寫一段」通常增減相抵
|
||||
if before < 800:
|
||||
print("SKIP:too-small"); raise SystemExit
|
||||
cut = before - after
|
||||
if cut < 800 or cut < before * 0.45:
|
||||
print("SKIP:not-a-compression"); raise SystemExit
|
||||
print("BLOCK:%s:%d:%d" % (os.path.basename(path), before, cut))
|
||||
' 2>/dev/null || echo "SKIP:crash")
|
||||
|
||||
case "$VERDICT" in
|
||||
BLOCK:*) ;;
|
||||
*) exit 0 ;;
|
||||
esac
|
||||
|
||||
# 至多擋一次:被擋兩次的閘會被想辦法繞過,而繞過的閘等於不存在。
|
||||
SID=$(printf '%s' "$INPUT" | python3 -c '
|
||||
import sys, json
|
||||
try: print(json.load(sys.stdin).get("session_id", "") or "x")
|
||||
except Exception: print("x")
|
||||
' 2>/dev/null || printf 'x')
|
||||
STAMP="/tmp/.wiki-size-guard-$SID"
|
||||
[ -f "$STAMP" ] && exit 0
|
||||
touch "$STAMP" 2>/dev/null || true
|
||||
|
||||
FILE=$(printf '%s' "$VERDICT" | cut -d: -f2)
|
||||
BEFORE=$(printf '%s' "$VERDICT" | cut -d: -f3)
|
||||
CUT=$(printf '%s' "$VERDICT" | cut -d: -f4)
|
||||
|
||||
cat >&2 <<MSG
|
||||
📚 這是一次 wiki 壓縮,不是一次編輯(inkstone/ISEP#89)
|
||||
|
||||
檔案:$FILE
|
||||
這一筆會砍掉 $CUT 個字(原本 $BEFORE 個)——超過一半。
|
||||
|
||||
🔴 **壓縮會弄丟東西,而弄丟的當下沒有人會發現。**(票上原話)
|
||||
所以壓縮不能順手做,它要留得下痕跡、而且要能逐條對帳。
|
||||
|
||||
改走這條(三步,機器會幫你證明沒弄丟):
|
||||
|
||||
python3 scripts/wiki-compress plan <檔>
|
||||
python3 scripts/wiki-compress apply <檔> --ticket owner/repo#N
|
||||
python3 scripts/wiki-compress verify <檔>.before-compress <檔> <搬去的檔...>
|
||||
|
||||
它跟手改的差別只有兩件,但這兩件正是手改做不到的:
|
||||
· **--ticket 逼票號**,並把「這一版壓掉了什麼」寫進 \`.compress-log.md\`
|
||||
· **verify 用內文雜湊逐條對帳**——「沒弄丟」是算出來的,不是你說的
|
||||
|
||||
它的做法是**只搬不改**:舊的整條原封不動搬去 archive,原檔留一份目錄。
|
||||
實測 \`mistakes.md\` 7,681 行 → 1,199 行,**260 條一條都沒少,查詢快 22.5 倍**。
|
||||
|
||||
真的要手改(例如只是刪掉一段過期的說明):
|
||||
在寫入的內容裡放一個 \`wiki-compress-ok\`,並在 commit 說明理由。
|
||||
本閘**至多擋一次**,不會卡死。
|
||||
MSG
|
||||
exit 2
|
||||
Executable
+393
@@ -0,0 +1,393 @@
|
||||
#!/usr/bin/env python3
|
||||
"""isep-nag — 把「沒有人會叫的事」叫出來(inkstone/ISEP#93)
|
||||
|
||||
━━ 這支解的問題 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
有些事情**沒有人會叫**:一張票掛著等 leo 三天了、一個 milestone 逾期了、
|
||||
一根棒子交回來躺著沒人接。它們不會壞掉、不會噴錯,**只是靜靜地過期**。
|
||||
|
||||
leo 整天上課,一天看兩眼。**不叫,就等於這件事不會發生。**
|
||||
|
||||
━━ 🔴 刻意不做什麼(這不是簡化,是判斷)━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
**不做排程輪詢、不做 webhook fan-out、不掛 Gitea Actions。**
|
||||
· 頂層 `CLAUDE.md`「避免再被 GitHub flag 的硬規則」:禁一事件 fan-out 到多 repo
|
||||
· `issue-handle` skill:「**有事才讀**」,換成 Gitea 也不放寬
|
||||
· ISEP v0.6.0 §8.2:第一版不依賴 runner——「壞掉的形式是『以為有人在跑』,
|
||||
比『沒人跑』貴得多」
|
||||
⇒ 本支只在**兩個時機**跑:開 session 時順手一次、人要看的時候手動一次。
|
||||
|
||||
**已知限制,誠實寫在這裡**:它只在 CC 醒著的時候會叫。
|
||||
「CC 整個沒在跑」的那一段補不到——那要 leo 決定要不要開 Gitea 的通知權限
|
||||
(機器帳號 `claude-code` 在 `bots` 團隊,`/orgs/…/hooks` 回 403,裝不了)。
|
||||
**但腳本版本身是完整可用的,不是半成品。**
|
||||
|
||||
━━ 撈三種東西 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
① 逾期的 milestone 期限過了、票還沒關 ⇒ 那個版本交不出來了
|
||||
② 掛著等 leo 的票 `Human` 或指派給 leo,**標等了幾天**
|
||||
③ 棒子掉在地上的票 `s/doing`/`s/review` 卻很多天沒動 ⇒ 沒有人在做
|
||||
|
||||
每一條都用白話寫(`CLAUDE.md` 規則五):一眼看懂、代號附一句人話、
|
||||
講「這對你意味什麼」而不是系統內部狀態。
|
||||
|
||||
🔴 **沒東西可報時會說「查過了,沒有」**,不會安靜結束——
|
||||
安靜跟壞掉長得一模一樣,而分不出來的東西沒有人敢信。
|
||||
|
||||
━━ 用法 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
isep-nag 撈一次,白話印出來
|
||||
isep-nag --json 同一份資料的機器格式
|
||||
isep-nag --notify 撈完順便發 Telegram(走 isep-notify,發不出去有退路)
|
||||
isep-nag --short 只印 Telegram 版(很短,leo 手機上看的那則)
|
||||
isep-nag --both 長版+分隔線+短版,一次算完(開場的 hook 用這個)
|
||||
isep-nag --fixture <檔> 用假資料跑(離線測試用,完全不碰網路)
|
||||
isep-nag --now <epoch> 把時鐘定住(測試用)
|
||||
|
||||
環境變數:
|
||||
ISEP_NAG_REPOS 只看這幾個 repo(空白分隔)。預設:整個 org 撈一次
|
||||
ISEP_NAG_ORG 預設 inkstone
|
||||
ISEP_NAG_STALE_DAYS 「幾天沒動算掉棒」,預設 3
|
||||
TICKET_HOST Gitea 位址,預設 https://git.uncle6.me
|
||||
"""
|
||||
import concurrent.futures as cf
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
# leo 看的是台北時間。容器多半跑在 UTC ⇒ 不指定就會印出一個他要自己換算的數字。
|
||||
try:
|
||||
from zoneinfo import ZoneInfo
|
||||
TAIPEI = ZoneInfo("Asia/Taipei")
|
||||
except Exception:
|
||||
TAIPEI = timezone(timedelta(hours=8))
|
||||
|
||||
HOST = os.environ.get("TICKET_HOST") or "https://git.uncle6.me"
|
||||
ORG = os.environ.get("ISEP_NAG_ORG") or "inkstone"
|
||||
STALE_DAYS = int(os.environ.get("ISEP_NAG_STALE_DAYS") or 3)
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
# 「等 leo」的兩個訊號:掛 Human 標籤,或指派給他本人。
|
||||
HUMAN_LABEL = "Human"
|
||||
LEO_LOGINS = {"leo", "leo21c", "Leo"}
|
||||
# 「有人領了」的狀態標籤——掛著這個卻很久沒動,就是棒子掉在地上。
|
||||
BATON_LABELS = {"s/doing", "s/review"}
|
||||
|
||||
|
||||
def token():
|
||||
for env in ("GITEA_TOKEN_CLAUDE_CODE", "GITEA_TOKEN"):
|
||||
v = os.environ.get(env)
|
||||
if v:
|
||||
return v
|
||||
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)
|
||||
return None
|
||||
|
||||
|
||||
def api(path, tok):
|
||||
url = "%s/api/v1%s" % (HOST, path)
|
||||
req = urllib.request.Request(url, headers={"Authorization": "token %s" % tok})
|
||||
try:
|
||||
return json.load(urllib.request.urlopen(req, timeout=25))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def ts(s):
|
||||
"""Gitea 的時間字串 → epoch。看不懂就回 None(不猜)。"""
|
||||
if not s:
|
||||
return None
|
||||
try:
|
||||
return int(datetime.fromisoformat(s.replace("Z", "+00:00"))
|
||||
.astimezone(timezone.utc).timestamp())
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def days(a, b):
|
||||
return max(0, int((a - b) // 86400))
|
||||
|
||||
|
||||
# ══ 一、把資料撈回來(或從 fixture 讀)═══════════════════════════════════
|
||||
def collect(fixture=None):
|
||||
"""回 {"milestones": [...], "issues": [...]}。撈不到就回空的,不編。"""
|
||||
if fixture:
|
||||
with open(fixture) as f:
|
||||
return json.load(f)
|
||||
|
||||
tok = token()
|
||||
if not tok:
|
||||
return {"milestones": [], "issues": [], "error":
|
||||
"拿不到 Gitea token(GITEA_TOKEN_CLAUDE_CODE 沒設,remote 也沒帶憑證)"}
|
||||
|
||||
repos = os.environ.get("ISEP_NAG_REPOS", "").split()
|
||||
if not repos:
|
||||
rows = api("/orgs/%s/repos?limit=50" % ORG, tok) or []
|
||||
repos = [r.get("full_name") for r in rows if r.get("full_name")]
|
||||
|
||||
# 🔴 平行撈,因為這支掛在 SessionStart 上——那是 leo 每次開工的關鍵路徑。
|
||||
# 循序版實測 16 秒(16 個 repo + 5 頁票),**開場等 16 秒沒有人會忍受它,
|
||||
# 而被忍不了的東西下一步就是被關掉**。平行之後落在 3 秒上下。
|
||||
milestones = []
|
||||
with cf.ThreadPoolExecutor(max_workers=8) as pool:
|
||||
jobs = {pool.submit(api, "/repos/%s/milestones?state=open" % f, tok): f
|
||||
for f in repos}
|
||||
for job in cf.as_completed(jobs):
|
||||
full = jobs[job]
|
||||
try:
|
||||
rows = job.result() or []
|
||||
except Exception:
|
||||
continue
|
||||
for m in rows:
|
||||
m = dict(m or {})
|
||||
m["repo"] = full
|
||||
milestones.append(m)
|
||||
|
||||
# 票用**一支跨 repo 的搜尋**撈,不是每個 repo 打一次:
|
||||
# 16 個 repo × 一次 = 16 通 API,那個形狀就開始像輪詢了。
|
||||
# 而且只撈**這三種標籤**(Gitea 的 labels= 是 OR)——不是撈回全部再自己篩:
|
||||
# 全撈是 5 頁,篩過是 1–2 頁。
|
||||
# ⚠️ 已知取捨:**沒掛 Human 標籤、只是被指派給 leo 的票,這條路撈不到。**
|
||||
# 真相源是標籤(`labels.yaml`),指派是輔助訊號 ⇒ 寧可漏掉那個邊角,
|
||||
# 也不要為它把開場多花 13 秒。要撈全部就設 ISEP_NAG_ALL_ISSUES=1。
|
||||
want = ",".join(sorted({HUMAN_LABEL} | BATON_LABELS))
|
||||
qs = "" if os.environ.get("ISEP_NAG_ALL_ISSUES") == "1" else \
|
||||
"&labels=%s" % urllib.parse.quote(want)
|
||||
issues = []
|
||||
for page in range(1, 6):
|
||||
rows = api("/repos/issues/search?state=open&type=issues&limit=50&page=%d%s"
|
||||
% (page, qs), tok) or []
|
||||
if not rows:
|
||||
break
|
||||
issues.extend(rows)
|
||||
if len(rows) < 50:
|
||||
break
|
||||
return {"milestones": milestones, "issues": issues}
|
||||
|
||||
|
||||
# ══ 二、找出「該叫的事」═══════════════════════════════════════════════════
|
||||
def findings(data, now):
|
||||
over, waiting, dropped = [], [], []
|
||||
|
||||
for m in data.get("milestones") or []:
|
||||
due = ts(m.get("due_on"))
|
||||
if not due or due >= now:
|
||||
continue # 沒設期限的不是節拍器;還沒到期的不叫
|
||||
open_n = int(m.get("open_issues") or 0)
|
||||
if open_n == 0:
|
||||
continue # 票都關完了,只是沒人按關閉鈕——不吵
|
||||
over.append({
|
||||
"repo": m.get("repo") or "?",
|
||||
"title": m.get("title") or "(沒有名字)",
|
||||
"due": (m.get("due_on") or "")[:10],
|
||||
"late_days": days(now, due),
|
||||
"open": open_n,
|
||||
"closed": int(m.get("closed_issues") or 0),
|
||||
})
|
||||
over.sort(key=lambda x: -x["late_days"])
|
||||
|
||||
for i in data.get("issues") or []:
|
||||
if i.get("pull_request"):
|
||||
continue # PR 有 pr-verdict-guard 在管,不重複點名
|
||||
full = (i.get("repository") or {}).get("full_name") or ""
|
||||
# 🔴 只看設定的那個 org。跨 repo 搜尋端點會把**舊 org 的同名票**一起回來
|
||||
# (實測:`Leo/Arcrun#85` 與 `inkstone/Arcrun#85` 是同一件事的兩份),
|
||||
# 照單全收會讓「26 張掉棒」裡有一半是重複——**灌水的清單等於沒有清單**。
|
||||
if ORG and not full.startswith(ORG + "/"):
|
||||
continue
|
||||
labels = {(l or {}).get("name") for l in (i.get("labels") or [])}
|
||||
who = {(a or {}).get("login") for a in (i.get("assignees") or [])}
|
||||
upd = ts(i.get("updated_at")) or now
|
||||
row = {
|
||||
"ref": "%s#%s" % ((i.get("repository") or {}).get("full_name") or "?",
|
||||
i.get("number")),
|
||||
"title": (i.get("title") or "").strip(),
|
||||
"quiet_days": days(now, upd),
|
||||
"labels": sorted(labels),
|
||||
"url": i.get("html_url") or "",
|
||||
}
|
||||
if HUMAN_LABEL in labels or (who & LEO_LOGINS):
|
||||
waiting.append(row)
|
||||
elif (labels & BATON_LABELS) and row["quiet_days"] >= STALE_DAYS:
|
||||
dropped.append(row)
|
||||
|
||||
waiting.sort(key=lambda x: -x["quiet_days"])
|
||||
dropped.sort(key=lambda x: -x["quiet_days"])
|
||||
return {"overdue_milestones": over, "waiting_on_leo": waiting,
|
||||
"dropped_batons": dropped}
|
||||
|
||||
|
||||
# ══ 三、講出來(白話)═════════════════════════════════════════════════════
|
||||
MAX_ROWS = int(os.environ.get("ISEP_NAG_MAX_ROWS") or 8)
|
||||
BOTH_SEP = "␟--isep-nag-short--␟" # --both 的分隔線(不會出現在正文裡)
|
||||
|
||||
|
||||
def _tail(rows, shown):
|
||||
"""超過封頂的部分收成一行。**不是藏起來,是講出來還有幾張。**"""
|
||||
rest = len(rows) - shown
|
||||
return [" · …另外還有 %d 張(`isep-nag --json` 看全部)" % rest] if rest > 0 else []
|
||||
|
||||
|
||||
def _waited(d):
|
||||
"""0 天講「今天剛掛上」——「等了 0 天」讀起來像壞掉的計數器。"""
|
||||
return "今天剛掛上" if d == 0 else "等了 %d 天" % d
|
||||
|
||||
|
||||
def _clip(s, n):
|
||||
s = (s or "").replace("\n", " ").strip()
|
||||
return s if len(s) <= n else s[:n - 1] + "…"
|
||||
|
||||
|
||||
def full_report(f, now, err=None):
|
||||
"""給眼前看的長版。**沒東西也一定有話講。**"""
|
||||
when = datetime.fromtimestamp(now, timezone.utc).astimezone(TAIPEI).strftime("%Y-%m-%d %H:%M")
|
||||
out = ["🔔 催辦員查了一次(%s)——這些事沒有人會叫,所以它叫(inkstone/ISEP#93)" % when]
|
||||
if err:
|
||||
out += ["", "🔴 撈不到資料:%s" % err,
|
||||
" ⇒ **這不等於「沒有事情逾期」**,這是「我沒查到」。兩件事不要講成同一句。"]
|
||||
return "\n".join(out)
|
||||
|
||||
n = sum(len(v) for v in f.values())
|
||||
if n == 0:
|
||||
out += ["", "✅ **查過了,沒有。** 三類都乾淨:",
|
||||
" · 沒有逾期還開著票的 milestone",
|
||||
" · 沒有掛著等你的票",
|
||||
" · 沒有超過 %d 天沒動的 s/doing/s/review" % STALE_DAYS]
|
||||
return "\n".join(out)
|
||||
|
||||
if f["overdue_milestones"]:
|
||||
out += ["", "⏰ **逾期的 milestone(%d 個)**——期限過了,票還開著,"
|
||||
"代表那個版本交不出來了:" % len(f["overdue_milestones"])]
|
||||
for m in f["overdue_milestones"][:MAX_ROWS]:
|
||||
out.append(" · %s 「%s」——%s 到期,**逾期 %d 天**,還有 %d 張票沒關"
|
||||
% (m["repo"], m["title"], m["due"], m["late_days"], m["open"]))
|
||||
out += _tail(f["overdue_milestones"], MAX_ROWS)
|
||||
out.append(" ⇒ 對你意味著:不是改期限,就是把票搬走。**放著它只會繼續爛。**")
|
||||
|
||||
if f["waiting_on_leo"]:
|
||||
out += ["", "🙋 **在等你的票(%d 張)**——只有你按得下去,"
|
||||
"沒人替得了:" % len(f["waiting_on_leo"])]
|
||||
for i in f["waiting_on_leo"][:MAX_ROWS]:
|
||||
out.append(" · %s **%s**:%s"
|
||||
% (i["ref"], _waited(i["quiet_days"]), _clip(i["title"], 42)))
|
||||
out += _tail(f["waiting_on_leo"], MAX_ROWS)
|
||||
out.append(" ⇒ 對你意味著:回一個詞就能推動。不回,這幾件永遠不會發生。")
|
||||
|
||||
if f["dropped_batons"]:
|
||||
out += ["", "🧤 **棒子掉在地上(%d 張)**——標著「有人在做」,"
|
||||
"但超過 %d 天沒動:" % (len(f["dropped_batons"]), STALE_DAYS)]
|
||||
for i in f["dropped_batons"][:MAX_ROWS]:
|
||||
out.append(" · %s **靜了 %d 天**(%s):%s"
|
||||
% (i["ref"], i["quiet_days"], "/".join(i["labels"]),
|
||||
_clip(i["title"], 38)))
|
||||
out += _tail(f["dropped_batons"], MAX_ROWS)
|
||||
out.append(" ⇒ 對你意味著:這些不是在做,是沒人接。要嘛派人,要嘛改回 s/todo。")
|
||||
return "\n".join(out)
|
||||
|
||||
|
||||
def short_report(f, now, err=None):
|
||||
"""Telegram 版:leo 在手機上一眼掃完、回一個詞。**寧短勿長。**"""
|
||||
if err:
|
||||
return "[總管] 🔴 催辦員查不到資料(%s)。這不是「沒事」,是「沒查到」。" % _clip(err, 60)
|
||||
a, b, c = (len(f["overdue_milestones"]), len(f["waiting_on_leo"]),
|
||||
len(f["dropped_batons"]))
|
||||
if a + b + c == 0:
|
||||
return "[總管] ✅ 查過了,沒有:沒有逾期的 milestone、沒有在等你的票、沒有掉在地上的棒子。"
|
||||
|
||||
lines = ["[總管] 🔔 有 %d 件沒人叫的事:" % (a + b + c)]
|
||||
if a:
|
||||
top = f["overdue_milestones"][0]
|
||||
lines.append("⏰ %d 個 milestone 逾期,最久的是「%s」(%s,逾期 %d 天,%d 張票沒關)"
|
||||
% (a, _clip(top["title"], 20), top["repo"].split("/")[-1],
|
||||
top["late_days"], top["open"]))
|
||||
if b:
|
||||
top = f["waiting_on_leo"][0]
|
||||
lines.append("🙋 %d 張票在等你,最久的 %s %s:%s"
|
||||
% (b, top["ref"], _waited(top["quiet_days"]),
|
||||
_clip(top["title"], 26)))
|
||||
if c:
|
||||
top = f["dropped_batons"][0]
|
||||
lines.append("🧤 %d 張標著「有人在做」卻沒人動,最久 %d 天:%s"
|
||||
% (c, top["quiet_days"], top["ref"]))
|
||||
lines.append("回「看」我就把完整清單貼出來。")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# ══ 四、CLI ══════════════════════════════════════════════════════════════
|
||||
def main(argv):
|
||||
fixture = now = None
|
||||
want_json = short = do_notify = both = False
|
||||
ticket = "inkstone/ISEP#93"
|
||||
i = 0
|
||||
while i < len(argv):
|
||||
a = argv[i]
|
||||
if a == "--json":
|
||||
want_json = True
|
||||
elif a == "--short":
|
||||
short = True
|
||||
elif a == "--both":
|
||||
# 開場的 hook 用這個:長版+短版**一次算完**。
|
||||
# 分兩次呼叫=打兩輪 Gitea=開場多等 5 秒,而那是 leo 每次開工的關鍵路徑。
|
||||
both = True
|
||||
elif a == "--notify":
|
||||
do_notify = True
|
||||
elif a == "--fixture":
|
||||
i += 1; fixture = argv[i]
|
||||
elif a == "--now":
|
||||
i += 1; now = int(argv[i])
|
||||
elif a == "--fallback-ticket":
|
||||
i += 1; ticket = argv[i]
|
||||
elif a in ("-h", "--help"):
|
||||
print(__doc__); return 0
|
||||
else:
|
||||
print("不認得的參數:%s(--help 看用法)" % a, file=sys.stderr)
|
||||
return 2
|
||||
i += 1
|
||||
|
||||
now = now if now is not None else int(time.time())
|
||||
try:
|
||||
data = collect(fixture)
|
||||
except Exception as e:
|
||||
data = {"milestones": [], "issues": [], "error": "撈的時候炸了:%s" % e}
|
||||
err = data.get("error")
|
||||
f = findings(data, now)
|
||||
|
||||
if both:
|
||||
print(full_report(f, now, err))
|
||||
print(BOTH_SEP)
|
||||
print(short_report(f, now, err))
|
||||
elif want_json:
|
||||
print(json.dumps({"now": now, "error": err, "findings": f},
|
||||
ensure_ascii=False, indent=2))
|
||||
elif short:
|
||||
print(short_report(f, now, err))
|
||||
else:
|
||||
print(full_report(f, now, err))
|
||||
|
||||
if do_notify:
|
||||
# 🔴 走 isep-notify,不自己打 HTTP:那支才會先問閘、發不出去才有退路。
|
||||
# 在這裡自己 urlopen 一次,就等於多開一條沒有退路的路。
|
||||
p = subprocess.run(
|
||||
[sys.executable, os.path.join(HERE, "isep-notify"),
|
||||
"--text", short_report(f, now, err), "--fallback-ticket", ticket],
|
||||
capture_output=True, text=True)
|
||||
sys.stderr.write(p.stdout + p.stderr)
|
||||
return p.returncode
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv[1:]))
|
||||
Executable
+429
@@ -0,0 +1,429 @@
|
||||
#!/usr/bin/env python3
|
||||
"""isep-notify — 發一則 Telegram 給 leo,而且**發不出去的時候不會安靜**
|
||||
(inkstone/ISEP#93)
|
||||
|
||||
━━ 這支存在的理由(2026-08-28 實測出來的限制)━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
通道本身早就有(`notify_leo`,一條 curl、不需金鑰,見頂層 wiki `agent-memory.md`)。
|
||||
問題不在通道,在**閘**:
|
||||
|
||||
· `prod-write-guard.sh` 擋「打到線上實例的寫入型請求」,而發一則 Telegram
|
||||
在它眼裡跟「部署一個工作流上線」長得一模一樣(同一個 named webhook 家族路徑)
|
||||
· v0.10.0 已經修好——只放行 `notify_leo` 這**一個名字**
|
||||
· **但 hook 的註冊路徑是 session 啟動當下寫死的** ⇒ 更新 plugin 之後,
|
||||
要開一個**新** session 才會載到新那份
|
||||
|
||||
⇒ 所以「能不能發得出去」是**每個 session 各自不同的事實**,不是全域設定。
|
||||
這支的工作就是:**先去問這個 session 的閘,再決定怎麼辦**,而不是先射再說。
|
||||
|
||||
🔴 **發不出去而靜默,等於沒做。** 三條出路,一條都不能省:
|
||||
① 先問閘(`--gate`):把「這個 session 載到的是哪一版、它會不會擋」變成查得到的事實
|
||||
② 閘說會擋 ⇒ **不送**(不繞路,見下),改走退路:貼回票上 + 大聲印在眼前
|
||||
③ 送出去了也要驗**內層** `data.data.ok`——外層 200 不算送到(wiki 記過這個坑)
|
||||
|
||||
━━ 為什麼閘說會擋就不送,而不是「反正 python 打得出去」━━━━━━━━━━━━━━━
|
||||
這支用 urllib 打 HTTP,`prod-write-guard` 掛在 `Bash` 上——**它看不到這支**。
|
||||
也就是說:不問閘就送,一定送得出去。**而那正是不能做的事。**
|
||||
|
||||
leo 2026-08-11:「hook 要不讓它對正式環境做任何推送,不是某種推送」
|
||||
leo(同期):「人就會學會繞過它,那它就等於不存在」
|
||||
|
||||
一支「繞過閘也照送」的工具,會讓那道閘對整條 python 路徑失效。
|
||||
⇒ 本支**自己去問那道閘**,閘說不行就不行。這比 shell 那條路更嚴,不是更鬆。
|
||||
|
||||
━━ 用法 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
isep-notify --gate 只印閘的判定(不送、不碰網路)
|
||||
isep-notify --text "…" 送一則
|
||||
isep-notify --text-file <檔> 內文放檔案(長訊息用)
|
||||
isep-notify --text "…" --fallback-ticket inkstone/ISEP#93
|
||||
送不出去時貼回那張票
|
||||
isep-notify --text "…" --dry-run 全流程走一遍,只是不真的送
|
||||
|
||||
離開碼:0 = leo 拿得到了(Telegram 送達,或退路留在票上)
|
||||
1 = 兩條路都沒走通(這時 stderr 一定有一段紅字,不會安靜)
|
||||
|
||||
環境變數(都有預設,測試才需要動):
|
||||
ISEP_NOTIFY_URL notify_leo 的 trigger 網址
|
||||
ISEP_NOTIFY_IDENTITY 貼票時的【身份】欄
|
||||
ISEP_NOTIFY_STATE_DIR 閘判定快取的位置(預設 ~/.claude/isep-nag)
|
||||
ISEP_NOTIFY_GUARD 指定要問哪一支 prod-write-guard(測試用)
|
||||
ISEP_NOTIFY_OFFLINE=1 不打任何網路(測試用)
|
||||
"""
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
NOTIFY_URL = os.environ.get(
|
||||
"ISEP_NOTIFY_URL",
|
||||
"https://arcrun-cypher-executor.leo21c.workers.dev"
|
||||
"/webhooks/named/leo/notify_leo/trigger",
|
||||
)
|
||||
IDENTITY = os.environ.get("ISEP_NOTIFY_IDENTITY", "【身份】總管/inkstone/ISEP/-")
|
||||
STATE_DIR = os.environ.get("ISEP_NOTIFY_STATE_DIR") or os.path.join(
|
||||
os.path.expanduser("~"), ".claude", "isep-nag")
|
||||
GITEA_HOST = os.environ.get("TICKET_HOST") or "https://git.uncle6.me"
|
||||
PROD_STAMP = "/tmp/.prod-write-ok"
|
||||
GATE_FACT = "gate-verdict.json"
|
||||
|
||||
|
||||
# ══ 一、這個 session 的閘是哪一份、哪一版 ═══════════════════════════════
|
||||
#
|
||||
# 🔴 這一段就是工單說的「**把『這個 session 的閘是哪一版』變成它自己查得到的事實**」。
|
||||
# 三個來源,可信度由高到低——**而且一定把來源講出來**,
|
||||
# 因為「我問到的那份」跟「這個 session 真的註冊的那份」可能不是同一個檔。
|
||||
def find_guard():
|
||||
"""回 (guard 路徑, plugin 根目錄, 來源說明, 可信度)。找不到回 (None, ...)。"""
|
||||
cands = []
|
||||
forced = os.environ.get("ISEP_NOTIFY_GUARD")
|
||||
if forced:
|
||||
# 測試用:直接指定要問哪一支(測「舊版會擋」時把歷史版本擺進來)
|
||||
return forced, os.path.dirname(os.path.dirname(forced)), "ISEP_NOTIFY_GUARD 指定", "high"
|
||||
root = os.environ.get("CLAUDE_PLUGIN_ROOT")
|
||||
if root:
|
||||
# hook 裡跑:這就是**這個 session 真的註冊的那一份**,沒有第二種可能
|
||||
cands.append((root, "CLAUDE_PLUGIN_ROOT(這個 session 註冊的那一份)", "high"))
|
||||
remembered = _read_fact()
|
||||
if remembered and remembered.get("plugin_root"):
|
||||
cands.append((remembered["plugin_root"],
|
||||
"SessionStart 當時記下來的那一份", "high"))
|
||||
here = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
cands.append((here, "本腳本自己所在的那一份(不一定是 session 註冊的)", "low"))
|
||||
|
||||
for r, why, conf in cands:
|
||||
g = os.path.join(r, "hooks", "prod-write-guard.sh")
|
||||
if os.path.isfile(g):
|
||||
return g, r, why, conf
|
||||
return None, None, "找不到任何 prod-write-guard.sh", "none"
|
||||
|
||||
|
||||
def plugin_version(root):
|
||||
try:
|
||||
with open(os.path.join(root, ".claude-plugin", "plugin.json")) as f:
|
||||
return json.load(f).get("version") or "未知"
|
||||
except Exception:
|
||||
return "未知"
|
||||
|
||||
|
||||
def probe_gate(url=None):
|
||||
"""拿**這一支真的要送的請求**去問閘:你會不會擋?
|
||||
|
||||
回一個 dict,永遠有 verdict:
|
||||
pass 閘看過了,放行
|
||||
block 閘會擋(=這個 session 載到的是舊版,或白名單被改窄了)
|
||||
unknown 問不出來(沒有閘、戳記在場、腳本壞了)——**不會謊稱 pass**
|
||||
"""
|
||||
url = url or NOTIFY_URL
|
||||
guard, root, why, conf = find_guard()
|
||||
fact = {
|
||||
"at": int(time.time()),
|
||||
"url": url,
|
||||
"guard": guard,
|
||||
"plugin_root": root,
|
||||
"source": why,
|
||||
"confidence": conf,
|
||||
"version": plugin_version(root) if root else "未知",
|
||||
}
|
||||
if not guard:
|
||||
fact.update(verdict="unknown", reason="這個環境找不到 ISEP 的 prod-write-guard"
|
||||
"(沒有閘 ⇒ 沒有東西被繞過)")
|
||||
return fact
|
||||
|
||||
# 🔴 戳記在場就**不探針**:`prod-write-guard` 的戳記是**單次、用完即丟**的,
|
||||
# 探一次就把總管剛按下去的那一次授權燒掉了。
|
||||
# (而戳記在場本來就代表「現在允許寫」⇒ 送得出去,不必問。)
|
||||
if os.path.exists(PROD_STAMP):
|
||||
fact.update(verdict="unknown",
|
||||
reason="/tmp/.prod-write-ok 在場:探針會把它燒掉(單次用完即丟),"
|
||||
"所以不問。戳記在場=現在允許寫入")
|
||||
return fact
|
||||
|
||||
payload = json.dumps({
|
||||
"tool_name": "Bash",
|
||||
"tool_input": {
|
||||
"command": ("curl -s -X POST %s -H 'Content-Type: application/json' "
|
||||
"-d '{\"text\":\"[總管] gate probe\"}'" % url),
|
||||
},
|
||||
})
|
||||
try:
|
||||
p = subprocess.run(["bash", guard], input=payload, capture_output=True,
|
||||
text=True, timeout=30)
|
||||
except Exception as e:
|
||||
fact.update(verdict="unknown", reason="問不動那支閘:%s" % e)
|
||||
return fact
|
||||
|
||||
if p.returncode == 0:
|
||||
fact.update(verdict="pass", reason="閘看過這一則通知,放行")
|
||||
elif p.returncode == 2:
|
||||
fact.update(verdict="block",
|
||||
reason="閘會擋。這個 session 載到的 ISEP 是 v%s——"
|
||||
"它的 prod-write-guard 還認不出 notify_leo 不是部署"
|
||||
% fact["version"],
|
||||
gate_says=(p.stderr or "").strip()[:400])
|
||||
else:
|
||||
fact.update(verdict="unknown",
|
||||
reason="那支閘回了離開碼 %d(既不是放行也不是擋)" % p.returncode)
|
||||
return fact
|
||||
|
||||
|
||||
def _fact_path():
|
||||
return os.path.join(STATE_DIR, GATE_FACT)
|
||||
|
||||
|
||||
def _read_fact():
|
||||
try:
|
||||
with open(_fact_path()) as f:
|
||||
return json.load(f)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def remember_fact(fact):
|
||||
"""把判定寫成檔:之後任何一支腳本(含手動跑的)都查得到,不必自己再探一次。"""
|
||||
try:
|
||||
os.makedirs(STATE_DIR, exist_ok=True)
|
||||
with open(_fact_path(), "w") as f:
|
||||
json.dump(fact, f, ensure_ascii=False, indent=2)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def gate_report(fact):
|
||||
icon = {"pass": "🟢", "block": "🔴", "unknown": "🟡"}.get(fact["verdict"], "🟡")
|
||||
lines = [
|
||||
"%s 這個 session 的通知閘:**%s**" % (icon, fact["verdict"]),
|
||||
" ISEP 版本:v%s" % fact.get("version", "未知"),
|
||||
" 問的是哪一份:%s" % (fact.get("guard") or "(找不到)"),
|
||||
" 來源可信度:%s(%s)" % (fact.get("confidence"), fact.get("source")),
|
||||
" 理由:%s" % fact.get("reason", ""),
|
||||
]
|
||||
if fact["verdict"] == "block":
|
||||
lines += [
|
||||
"",
|
||||
" ⇒ **這個 session 發不出 Telegram**,不是網路問題,是閘的版本問題。",
|
||||
" ⇒ 解法(兩步,缺一不可):",
|
||||
" claude plugin update isep@inkstone # 拿到 v0.10.0 以上",
|
||||
" **開一個新的 session** # 註冊路徑是啟動當下寫死的",
|
||||
" ⇒ 在那之前,本支一律走退路(貼回票上+印在眼前),不會安靜。",
|
||||
]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# ══ 二、真的送 ═══════════════════════════════════════════════════════════
|
||||
def send_telegram(text):
|
||||
"""回 (ok, 說明)。**內層 data.data.ok 才算送到**——外層 200 不算。"""
|
||||
if os.environ.get("ISEP_NOTIFY_OFFLINE") == "1":
|
||||
return False, "ISEP_NOTIFY_OFFLINE=1:刻意不打網路"
|
||||
body = json.dumps({"text": text}).encode()
|
||||
# 🔴 一定要帶 User-Agent(2026-08-28 實撞):不帶的話 urllib 送出的是
|
||||
# `Python-urllib/3.x`,**Cloudflare 直接回 403 error code 1010**(bad user agent)。
|
||||
# 症狀非常會騙人——wiki 說「一條 curl、不需任何金鑰」是對的,
|
||||
# curl 自己帶 UA,所以同一個網址用 curl 通、用 python 不通
|
||||
# ⇒ 會被誤讀成「通道壞了」或「這個 session 沒權限」,而兩個都不是。
|
||||
req = urllib.request.Request(
|
||||
NOTIFY_URL, data=body, method="POST",
|
||||
headers={"Content-Type": "application/json",
|
||||
"User-Agent": "isep-notify/1.0 (+inkstone/ISEP#93)"})
|
||||
try:
|
||||
raw = urllib.request.urlopen(req, timeout=30).read().decode()
|
||||
except urllib.error.HTTPError as e:
|
||||
return False, "HTTP %s:%s" % (e.code, e.read().decode()[:200])
|
||||
except Exception as e:
|
||||
return False, "連不上:%s" % e
|
||||
try:
|
||||
d = json.loads(raw)
|
||||
except Exception:
|
||||
return False, "回應不是 JSON:%s" % raw[:200]
|
||||
# 🔴 wiki 記過這個坑:外層 success=true、內層 404 也發生過
|
||||
inner = ((d.get("data") or {}).get("data") or {})
|
||||
if inner.get("ok") is True:
|
||||
return True, "送到了(message_id=%s)" % inner.get("message_id")
|
||||
if "找不到 workflow" in raw or "not found" in raw.lower():
|
||||
# 🔴 這一種要分開講:**閘沒擋、網路也通,是那台實例上根本沒有這支工作流**
|
||||
# (wiki `agent-memory.md` 記過一次:2026-08-10 KV 整批換新時定義消失)。
|
||||
# 講成「發不出去」會讓人去修錯的地方——修閘、修 token,而斷點不在那裡。
|
||||
return False, ("通道本身斷了:實例上找不到 `notify_leo` 工作流。"
|
||||
"**這不是閘擋的、也不是網路問題**——要有人把它重新 push 上去"
|
||||
"(`mira` repo `workflows/notify-leo.yaml`)。原文:%s" % raw[:200])
|
||||
return False, "外層通了但**內層沒 ok**:%s" % raw[:300]
|
||||
|
||||
|
||||
# ══ 三、退路:貼回票上 ═══════════════════════════════════════════════════
|
||||
def gitea_token():
|
||||
for env in ("GITEA_TOKEN_CLAUDE_CODE", "GITEA_TOKEN"):
|
||||
v = os.environ.get(env)
|
||||
if v:
|
||||
return v
|
||||
root = os.environ.get("CLAUDE_PROJECT_DIR") or os.getcwd()
|
||||
host = GITEA_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)
|
||||
return None
|
||||
|
||||
|
||||
def fallback_body(text, gate_fact, why=None):
|
||||
"""退路留言的內文。**獨立成一支就是為了測得到**——這段的內容才是這條退路的價值。
|
||||
|
||||
🔴 **為什麼發不出去**一定要在裡面,不是只寫閘的判定。
|
||||
2026-08-28 第一版漏了這一格:閘明明 pass,票上卻只看得到 pass,
|
||||
而真正的斷點(實例上找不到 `notify_leo` 工作流)**一個字都沒進票**
|
||||
⇒ 下一個人會去修閘,而斷點根本不在那裡。
|
||||
"""
|
||||
return "\n".join([
|
||||
IDENTITY,
|
||||
"",
|
||||
"🔴 **這則本來要用 Telegram 發給 leo,但這個 session 發不出去**——所以貼在這裡。",
|
||||
"",
|
||||
"**送不出去的原因**:%s" % (why or "(沒有記錄,這本身就是個 bug)"),
|
||||
"",
|
||||
gate_report(gate_fact),
|
||||
"",
|
||||
"---",
|
||||
"",
|
||||
text,
|
||||
])
|
||||
|
||||
|
||||
def comment_on_ticket(ref, text, gate_fact, why=None):
|
||||
"""把那則發不出去的訊息貼回票上。回 (ok, 說明)。
|
||||
|
||||
🔴 **同一則不重複貼**:內容雜湊存在狀態目錄,24 小時內一樣就跳過。
|
||||
票被洗版的下場跟發不出去一樣——leo 會學會不看它。
|
||||
"""
|
||||
m = re.match(r"^([\w.-]+)/([\w.-]+)#(\d+)$", (ref or "").strip())
|
||||
if not m:
|
||||
return False, "票的寫法是 owner/repo#N,你給的是:%s" % ref
|
||||
owner, repo, num = m.group(1), m.group(2), m.group(3)
|
||||
|
||||
key = hashlib.sha256(("%s|%s|%s" % (ref, text, why or "")).encode()).hexdigest()[:16]
|
||||
seen = os.path.join(STATE_DIR, "commented-%s" % key)
|
||||
if os.path.exists(seen) and time.time() - os.path.getmtime(seen) < 24 * 3600:
|
||||
return True, "24 小時內已經貼過一模一樣的內容,跳過(不洗版)"
|
||||
|
||||
if os.environ.get("ISEP_NOTIFY_OFFLINE") == "1":
|
||||
return False, "ISEP_NOTIFY_OFFLINE=1:刻意不打網路"
|
||||
tok = gitea_token()
|
||||
if not tok:
|
||||
return False, "拿不到 Gitea token(GITEA_TOKEN_CLAUDE_CODE 沒設,remote 也沒帶憑證)"
|
||||
|
||||
body = fallback_body(text, gate_fact, why)
|
||||
url = "%s/api/v1/repos/%s/%s/issues/%s/comments" % (GITEA_HOST, owner, repo, num)
|
||||
req = urllib.request.Request(
|
||||
url, data=json.dumps({"body": body}).encode(), method="POST",
|
||||
headers={"Authorization": "token %s" % tok,
|
||||
"Content-Type": "application/json"})
|
||||
try:
|
||||
r = json.load(urllib.request.urlopen(req, timeout=40))
|
||||
except urllib.error.HTTPError as e:
|
||||
return False, "Gitea %s:%s" % (e.code, e.read().decode()[:200])
|
||||
except Exception as e:
|
||||
return False, "連不上 Gitea:%s" % e
|
||||
try:
|
||||
os.makedirs(STATE_DIR, exist_ok=True)
|
||||
open(seen, "w").close()
|
||||
except Exception:
|
||||
pass
|
||||
return True, "已貼到 %s(%s)" % (ref, r.get("html_url") or "")
|
||||
|
||||
|
||||
# ══ 四、CLI ══════════════════════════════════════════════════════════════
|
||||
def notify(text, fallback_ticket=None, dry_run=False):
|
||||
"""回 (離開碼, 給人看的報告)。**任何一條路都會留下一段話,不會安靜。**"""
|
||||
fact = probe_gate()
|
||||
remember_fact(fact)
|
||||
out = [gate_report(fact), ""]
|
||||
|
||||
if dry_run:
|
||||
out.append("🧪 --dry-run:到此為止,什麼都沒送。要送的內容是:")
|
||||
out.append("")
|
||||
out.append(text)
|
||||
return 0, "\n".join(out)
|
||||
|
||||
if fact["verdict"] == "block":
|
||||
out.append("⛔ 閘說會擋 ⇒ **本支不繞路**,直接走退路。")
|
||||
sent, why = False, "閘會擋,而繞過去等於拆了那道閘 ⇒ 沒有嘗試送出"
|
||||
else:
|
||||
sent, why = send_telegram(text)
|
||||
out.append(("✅ Telegram:%s" if sent else "❌ Telegram:%s") % why)
|
||||
|
||||
if sent:
|
||||
return 0, "\n".join(out)
|
||||
|
||||
# ── 退路 ──────────────────────────────────────────────────────────
|
||||
if fallback_ticket:
|
||||
ok, detail = comment_on_ticket(fallback_ticket, text, fact, why)
|
||||
out.append(("✅ 退路(貼回票上):%s" if ok else "❌ 退路(貼回票上):%s") % detail)
|
||||
else:
|
||||
ok, detail = False, "沒有給 --fallback-ticket,沒有票可以貼"
|
||||
out.append("⚠️ 退路(貼回票上):%s" % detail)
|
||||
|
||||
out += [
|
||||
"",
|
||||
"🔴 **leo 的手機上沒有出現這則。** 原文照抄在下面,"
|
||||
"看到這段的人有義務把它講出去:",
|
||||
"",
|
||||
text,
|
||||
]
|
||||
return (0 if ok else 1), "\n".join(out)
|
||||
|
||||
|
||||
def main(argv):
|
||||
text, ticket, dry, want_json = None, None, False, False
|
||||
mode = "notify"
|
||||
i = 0
|
||||
while i < len(argv):
|
||||
a = argv[i]
|
||||
if a == "--gate":
|
||||
mode = "gate"
|
||||
elif a == "--text":
|
||||
i += 1; text = argv[i]
|
||||
elif a == "--text-file":
|
||||
i += 1
|
||||
with open(argv[i]) as f:
|
||||
text = f.read()
|
||||
elif a == "--fallback-ticket":
|
||||
i += 1; ticket = argv[i]
|
||||
elif a == "--dry-run":
|
||||
dry = True
|
||||
elif a == "--json":
|
||||
want_json = True
|
||||
elif a in ("-h", "--help"):
|
||||
print(__doc__)
|
||||
return 0
|
||||
else:
|
||||
print("不認得的參數:%s(--help 看用法)" % a, file=sys.stderr)
|
||||
return 2
|
||||
i += 1
|
||||
|
||||
if mode == "gate":
|
||||
fact = probe_gate()
|
||||
remember_fact(fact)
|
||||
print(json.dumps(fact, ensure_ascii=False, indent=2) if want_json
|
||||
else gate_report(fact))
|
||||
return 0
|
||||
|
||||
if not text or not text.strip():
|
||||
print("🔴 沒有內容可以發(--text 或 --text-file)", file=sys.stderr)
|
||||
return 2
|
||||
if not text.lstrip().startswith("["):
|
||||
text = "[總管] " + text.lstrip() # 署名鐵律:text 開頭必署名
|
||||
|
||||
code, report = notify(text, ticket, dry)
|
||||
print(report, file=(sys.stdout if code == 0 else sys.stderr))
|
||||
return code
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv[1:]))
|
||||
Executable
+94
@@ -0,0 +1,94 @@
|
||||
#!/usr/bin/env python3
|
||||
"""roster — 工人名單(inkstone/ISEP#86)
|
||||
|
||||
python3 scripts/roster list 名單:有哪幾個工人、各自管什麼
|
||||
python3 scripts/roster show <名字> 一個工人的全文(它開工時會讀到的東西)
|
||||
python3 scripts/roster names 只印名字,一行一個(給腳本用)
|
||||
python3 scripts/roster which <owner/repo> 這個 repo 該派給誰
|
||||
|
||||
leo(ISEP#86):「身為派工的人,我要工人有名字,我才知道票上這件事到底是誰做的。」
|
||||
|
||||
名單是**資料**(`agents/*.md`),不是程式:加一個工人=加一個檔,不改任何 hook。
|
||||
派工時把名字放進 `Task` 的 `subagent_type`;名字不在名單上,
|
||||
`hooks/roster-guard.sh` 會擋下來並說「沒有這個人」。
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "hooks", "lib"))
|
||||
try:
|
||||
import roster as R
|
||||
except Exception as e: # pragma: no cover
|
||||
print("🔴 讀不到 hooks/lib/roster.py:%s" % e, file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def cmd_list():
|
||||
ws = R.load()
|
||||
if not ws:
|
||||
print("🔴 名單是空的(找不到 %s 底下任何工人檔)" % R.roster_dir(), file=sys.stderr)
|
||||
return 1
|
||||
print("工人名單(%d 位) 來源:%s\n" % (len(ws), R.roster_dir()))
|
||||
print(" %-18s %-26s %s" % ("名字", "負責 repo", "它是誰"))
|
||||
print(" " + "─" * 100)
|
||||
for w in ws:
|
||||
print(" " + R.one_liner(w))
|
||||
print("\n派工:把「名字」放進 Task 的 subagent_type;派工單本身仍然只有一行【工單】。")
|
||||
print("看某一位的全文:python3 scripts/roster show <名字>")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_show(name):
|
||||
w = R.find(name)
|
||||
if not w:
|
||||
print("🚫 名單上沒有「%s」這個人。現有的是:\n %s"
|
||||
% (name, "\n ".join(R.names()) or "(空)"), file=sys.stderr)
|
||||
return 2
|
||||
print("# %s\n" % w["name"])
|
||||
print("負責 repo:%s" % (w["repo"] or "(跨 repo)"))
|
||||
print("一句話 :%s" % w["description"])
|
||||
print("檔案 :%s\n" % w["path"])
|
||||
print(w["body"])
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_which(repo):
|
||||
hits = [w for w in R.load() if w["repo"].lower() == (repo or "").strip().lower()]
|
||||
if not hits:
|
||||
print("🚫 名單上沒有負責「%s」的人。現有的是:" % repo, file=sys.stderr)
|
||||
for w in R.load():
|
||||
print(" " + R.one_liner(w), file=sys.stderr)
|
||||
return 2
|
||||
for w in hits:
|
||||
print(w["name"])
|
||||
return 0
|
||||
|
||||
|
||||
def main():
|
||||
argv = sys.argv[1:]
|
||||
if not argv or argv[0] in ("-h", "--help", "help"):
|
||||
print(__doc__)
|
||||
return 0
|
||||
verb, rest = argv[0], argv[1:]
|
||||
if verb == "list":
|
||||
return cmd_list()
|
||||
if verb == "names":
|
||||
for n in R.names():
|
||||
print(n)
|
||||
return 0
|
||||
if verb == "show":
|
||||
if not rest:
|
||||
print("用法:scripts/roster show <名字>", file=sys.stderr)
|
||||
return 1
|
||||
return cmd_show(rest[0])
|
||||
if verb == "which":
|
||||
if not rest:
|
||||
print("用法:scripts/roster which <owner/repo>", file=sys.stderr)
|
||||
return 1
|
||||
return cmd_which(rest[0])
|
||||
print("不認得的動詞「%s」。\n%s" % (verb, __doc__), file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Executable
+598
@@ -0,0 +1,598 @@
|
||||
#!/usr/bin/env python3
|
||||
"""wiki-compress — 讀不完的必讀檔,等於沒有(inkstone/ISEP#89)
|
||||
|
||||
━━ 這支解的問題 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
wiki 一直長大,沒有人會回頭整理。`mistakes.md` 是「必讀」等級的檔案,
|
||||
實測 **7,680 行、168 條**——**沒有人真的每次都從頭讀。**
|
||||
|
||||
leo 2026-08-27 說那天「失去記憶」。東西還在,只是**沒人讀得完**。
|
||||
|
||||
已經有的三支管的是別的事:`wiki-secret-scan`(寫進去安不安全)、
|
||||
`wiki-first-search`/`wiki-first-police`(有沒有先查)。**沒有任何一支管「太大了」。**
|
||||
|
||||
━━ 🔴 最重要的設計決定:**只搬不改** ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
票上第 2 條驗收是「**不能弄丟任何一條教訓**(要能逐條對帳)」。
|
||||
|
||||
⇒ 所以本支**不改寫任何一條的正文**,一個字都不動。它只做三件事:
|
||||
① 產生**目錄**(每條一行:標題+日期+票號+去哪了)放回原檔開頭
|
||||
② 把舊的整條**原封不動搬**到 `<檔名>-archive-YYYY-MM.md`
|
||||
③ 把「同一件事的第 N 次」在目錄裡標成 `×N`,**而不是把它們合併掉**
|
||||
|
||||
「合併同類、濃縮成一行」聽起來更漂亮,但那要重寫正文——
|
||||
**而重寫的當下沒有人會發現弄丟了什麼**(票上原話)。
|
||||
只搬不改的好處是:`verify` 可以用**內文雜湊**逐條對帳,
|
||||
「沒弄丟」不是宣稱,是算出來的。
|
||||
|
||||
━━ 五個動詞 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
wiki-compress audit [<wiki 目錄或檔>...] 哪幾個檔太大了、大在哪
|
||||
wiki-compress plan <檔> 要怎麼壓:同類幾群、可搬幾條
|
||||
wiki-compress apply <檔> --ticket owner/repo#N
|
||||
真的壓(只搬不改),並留交付紀錄
|
||||
wiki-compress verify <壓縮前的檔> <壓縮後的檔> [搬去的檔...]
|
||||
逐條對帳:有沒有弄丟(票上驗收 2)
|
||||
wiki-compress bench <壓縮前的檔> <壓縮後的檔> [搬去的檔...]
|
||||
量「查得比壓之前快」(票上驗收 1)
|
||||
|
||||
🔴 `apply` **一定要 --ticket**(票上驗收 3:壓縮這件事本身要有票、有交付紀錄,
|
||||
不准順手做完沒人知道)。它會把「哪一版壓掉了什麼」寫進 `.compress-log.md`。
|
||||
"""
|
||||
import argparse
|
||||
import datetime as dt
|
||||
import hashlib
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
|
||||
THRESHOLD = int(os.environ.get("WIKI_COMPRESS_THRESHOLD") or 1200) # 行
|
||||
LOG_NAME = ".compress-log.md"
|
||||
FENCE = re.compile(r"^\s*(```|~~~)")
|
||||
H2 = re.compile(r"^##\s+(?!#)(.+?)\s*$")
|
||||
DATE = re.compile(r"(20\d\d)[-/](\d\d)[-/]?(\d\d)?")
|
||||
TICKET = re.compile(r"\b([\w.-]+/[\w.-]+#\d+)\b")
|
||||
|
||||
|
||||
# ══ 一、把一個 wiki 檔切成「條」═══════════════════════════════════════════
|
||||
#
|
||||
# 🔴 一定要跳過 code fence。`mistakes.md` 第 534 行是 ```` # 兩邊各跑一次 ````
|
||||
# ——那是**程式碼註解**,不是標題。照抄 grep '^#' 會把它當成一條,
|
||||
# 然後 verify 會拿一個不存在的東西去對帳,全盤失準。
|
||||
def split_entries(text):
|
||||
"""回 (前言, [條...])。每條 = {"title","body","line","date","tickets","hash"}。"""
|
||||
lines = text.split("\n")
|
||||
fenced = False
|
||||
marks = []
|
||||
for i, ln in enumerate(lines):
|
||||
if FENCE.match(ln):
|
||||
fenced = not fenced
|
||||
continue
|
||||
if fenced:
|
||||
continue
|
||||
m = H2.match(ln)
|
||||
if m:
|
||||
marks.append((i, m.group(1).strip()))
|
||||
|
||||
pre = "\n".join(lines[:marks[0][0]]) if marks else text
|
||||
out = []
|
||||
for n, (i, title) in enumerate(marks):
|
||||
end = marks[n + 1][0] if n + 1 < len(marks) else len(lines)
|
||||
body = "\n".join(lines[i:end]).rstrip()
|
||||
out.append({
|
||||
"title": title,
|
||||
"body": body,
|
||||
"line": i + 1,
|
||||
"lines": end - i,
|
||||
"date": entry_date(title, body),
|
||||
"tickets": sorted(set(TICKET.findall(body))),
|
||||
"hash": body_hash(body),
|
||||
})
|
||||
return pre, out
|
||||
|
||||
|
||||
def body_hash(body):
|
||||
"""對帳用的指紋:把空白正規化之後雜湊。
|
||||
|
||||
正規化空白(而不是逐字元比對)是為了讓「搬檔時尾端多/少一個換行」
|
||||
這種**無意義的差異**不要被報成「弄丟了」——誤報會讓對帳表沒人看。
|
||||
真的改了字,雜湊一定不同。
|
||||
"""
|
||||
return hashlib.sha256(re.sub(r"\s+", " ", body).strip().encode()).hexdigest()[:16]
|
||||
|
||||
|
||||
def entry_date(title, body):
|
||||
"""條的日期:標題裡的優先(那是人寫的),沒有才往內文找第一個。"""
|
||||
for src in (title, body[:1500]):
|
||||
m = DATE.search(src or "")
|
||||
if m:
|
||||
y, mo, d = m.group(1), m.group(2), m.group(3) or "01"
|
||||
try:
|
||||
return dt.date(int(y), int(mo), int(d)).isoformat()
|
||||
except ValueError:
|
||||
return "%s-%s-01" % (y, mo)
|
||||
return ""
|
||||
|
||||
|
||||
# ══ 二之〇、自承重複:**這一條自己說它是同一件事的第 N 次** ══════════════
|
||||
#
|
||||
# 為什麼另外做這一格(實測數字,不是設計偏好):
|
||||
# `mistakes.md` 260 條,用**標題相似度**分群只找得到 1 對——
|
||||
# 因為同一個病每次都被寫成完全不同的句子(那正是它一犯再犯的原因)。
|
||||
# 但其中 **68 條的內文自己寫著「同款第三次」「又犯一次」「同一個病」**。
|
||||
# ⇒ 人下的判斷比機器算的相似度準得多。**照抄人已經寫下的結論,不要重新猜一遍。**
|
||||
#
|
||||
# 這 68 條就是「合併成一條、記發生過幾次」最該下手的地方,
|
||||
# 但**合併要人來做**(要重寫正文,機器一動就會弄丟東西)⇒ 本支只把它們點名出來。
|
||||
SELF_DUP = re.compile(r"同款|第[一二三四五六七八九十百0-9]+次|又犯|再犯|又一次|"
|
||||
r"同一個病|同一形狀|同樣的錯|重蹈")
|
||||
|
||||
|
||||
def self_declared_repeat(e):
|
||||
"""回它自己講的那句話(沒有就回 None)。**引它的原話,不要自己下結論。**"""
|
||||
for src in (e["title"], e["body"][:900]):
|
||||
m = SELF_DUP.search(src or "")
|
||||
if m:
|
||||
i = max(0, m.start() - 12)
|
||||
return re.sub(r"\s+", " ", (src[i:m.end() + 10]).strip())
|
||||
return None
|
||||
|
||||
|
||||
# ══ 二、同類分群(標 ×N,不合併)═════════════════════════════════════════
|
||||
STOP = set("的了是在有和與及對於把被就都也還很更最一個這那我你他它們不沒要會能"
|
||||
"MISTAKE mistake 方法 為什麼 怎麼".split())
|
||||
|
||||
|
||||
def tokens(title):
|
||||
t = re.sub(r"[((].*?[))]", " ", title)
|
||||
t = re.sub(r"[^\w一-鿿]+", " ", t)
|
||||
out = set()
|
||||
for w in t.split():
|
||||
if re.fullmatch(r"[A-Za-z0-9_.-]+", w):
|
||||
if len(w) > 2 and w not in STOP:
|
||||
out.add(w.lower())
|
||||
else:
|
||||
for i in range(len(w) - 1): # 中文用 bigram
|
||||
bg = w[i:i + 2]
|
||||
if bg not in STOP:
|
||||
out.add(bg)
|
||||
return out
|
||||
|
||||
|
||||
def cluster(entries, thresh=0.42):
|
||||
"""同一件事的第 N 次 ⇒ 分到同一群。回 [[索引...]],單獨一條的也算一群。"""
|
||||
toks = [tokens(e["title"]) for e in entries]
|
||||
parent = list(range(len(entries)))
|
||||
|
||||
def find(x):
|
||||
while parent[x] != x:
|
||||
parent[x] = parent[parent[x]]
|
||||
x = parent[x]
|
||||
return x
|
||||
|
||||
for a in range(len(entries)):
|
||||
for b in range(a + 1, len(entries)):
|
||||
ta, tb = toks[a], toks[b]
|
||||
if not ta or not tb:
|
||||
continue
|
||||
j = len(ta & tb) / float(len(ta | tb))
|
||||
# 共用票號=同一條線,門檻放寬(票號是人下的判斷,比字面相似可靠)
|
||||
same_ticket = bool(set(entries[a]["tickets"]) & set(entries[b]["tickets"]))
|
||||
if j >= thresh or (same_ticket and j >= thresh * 0.6):
|
||||
parent[find(a)] = find(b)
|
||||
groups = {}
|
||||
for i in range(len(entries)):
|
||||
groups.setdefault(find(i), []).append(i)
|
||||
return sorted(groups.values(), key=lambda g: (-len(g), g[0]))
|
||||
|
||||
|
||||
# ══ 三、audit ════════════════════════════════════════════════════════════
|
||||
def iter_wiki_files(paths):
|
||||
for p in paths:
|
||||
if os.path.isdir(p):
|
||||
for name in sorted(os.listdir(p)):
|
||||
if name.endswith(".md") and not name.startswith("."):
|
||||
yield os.path.join(p, name)
|
||||
elif os.path.isfile(p):
|
||||
yield p
|
||||
|
||||
|
||||
def cmd_audit(args):
|
||||
rows = []
|
||||
for f in iter_wiki_files(args.paths):
|
||||
text = open(f, encoding="utf-8", errors="replace").read()
|
||||
_, entries = split_entries(text)
|
||||
groups = [g for g in cluster(entries) if len(g) > 1] if entries else []
|
||||
rows.append({
|
||||
"file": f,
|
||||
"lines": text.count("\n") + 1,
|
||||
"entries": len(entries),
|
||||
"dup_groups": len(groups),
|
||||
"dup_entries": sum(len(g) for g in groups),
|
||||
"self_dup": sum(1 for e in entries if self_declared_repeat(e)),
|
||||
})
|
||||
rows.sort(key=lambda r: -r["lines"])
|
||||
over = [r for r in rows if r["lines"] > THRESHOLD]
|
||||
|
||||
print("📚 wiki 體檢(門檻 %d 行——超過就沒有人會從頭讀)" % THRESHOLD)
|
||||
print()
|
||||
for r in rows:
|
||||
flag = "🔴 太長" if r["lines"] > THRESHOLD else " "
|
||||
print("%s %-46s %6d 行 / %4d 條%s"
|
||||
% (flag, os.path.basename(r["file"]), r["lines"], r["entries"],
|
||||
",%d 條自承是同款的第 N 次" % r["self_dup"] if r["self_dup"] else ""))
|
||||
print()
|
||||
if not over:
|
||||
print("✅ 查過了,沒有超過門檻的檔。")
|
||||
return 0
|
||||
print("⇒ %d 個檔超過門檻。**這不是風格問題**——一個沒有人讀得完的必讀檔,"
|
||||
"跟沒有那個檔的差別只在於它讓人以為有。" % len(over))
|
||||
print("⇒ 下一步:`wiki-compress plan <檔>` 看怎麼壓;壓之前先開票(票上驗收 3)。")
|
||||
return 1
|
||||
|
||||
|
||||
# ══ 四、plan ═════════════════════════════════════════════════════════════
|
||||
def plan(path, before=None, keep=None, threshold=None):
|
||||
"""算出「哪幾條留下、哪幾條搬走」。
|
||||
|
||||
🔴 預設政策=**搬到原檔掉到門檻以下為止**(最新的留下、最舊的先搬)。
|
||||
|
||||
為什麼不是「早於某個日期就搬」(第一版是那樣寫的,實測就被打臉):
|
||||
`mistakes.md` 260 條裡有 205 條是最近一個月的 ⇒ 用 30 天當線只搬得動 40 條,
|
||||
壓完還是 6,000 行 ⇒ **一樣沒有人讀得完,等於白壓。**
|
||||
而這件事的目的是「讓必讀檔可讀」,那就該讓**可讀**本身當判準。
|
||||
"""
|
||||
text = open(path, encoding="utf-8", errors="replace").read()
|
||||
pre, entries = split_entries(text)
|
||||
groups = cluster(entries)
|
||||
limit = threshold or THRESHOLD
|
||||
|
||||
# 新舊排序:有日期的照日期,沒日期的照它在檔裡的位置(這批 wiki 都是新的在上面)
|
||||
order = sorted(range(len(entries)),
|
||||
key=lambda i: (entries[i]["date"] or "0000-00-00", -i),
|
||||
reverse=True)
|
||||
|
||||
if before:
|
||||
stay = {i for i in range(len(entries))
|
||||
if not entries[i]["date"] or entries[i]["date"] >= before}
|
||||
else:
|
||||
n_keep = keep if keep is not None else None
|
||||
if n_keep is None:
|
||||
# 目錄一條一行 + 檔頭固定幾行;一直加到再加就超過門檻為止
|
||||
budget = limit - (len(entries) + 14)
|
||||
n_keep, used = 0, 0
|
||||
for i in order:
|
||||
if used + entries[i]["lines"] > budget:
|
||||
break
|
||||
used += entries[i]["lines"]
|
||||
n_keep += 1
|
||||
stay = set(order[:max(0, n_keep)])
|
||||
|
||||
movable = [entries[i] for i in range(len(entries)) if i not in stay]
|
||||
return {"text": text, "pre": pre, "entries": entries, "groups": groups,
|
||||
"stay": stay, "movable": movable, "limit": limit,
|
||||
"cutoff": before or "(門檻政策:壓到 %d 行以下)" % limit}
|
||||
|
||||
|
||||
def cmd_plan(args):
|
||||
p = plan(args.file, args.before, args.keep)
|
||||
entries, groups = p["entries"], p["groups"]
|
||||
dups = [g for g in groups if len(g) > 1]
|
||||
selfs = [e for e in entries if self_declared_repeat(e)]
|
||||
stay_lines = sum(entries[i]["lines"] for i in p["stay"])
|
||||
print("📐 壓縮計畫:%s" % args.file)
|
||||
print(" 現況:%d 行 / %d 條" % (p["text"].count("\n") + 1, len(entries)))
|
||||
print(" 政策:%s" % p["cutoff"])
|
||||
print(" 搬走:%d 條 → <檔名>-archive-YYYY-MM.md(**原封不動,一個字都不改**)"
|
||||
% len(p["movable"]))
|
||||
print(" 留下:%d 條、%d 行 + 目錄 %d 行 = 約 %d 行"
|
||||
% (len(p["stay"]), stay_lines, len(entries) + 14,
|
||||
stay_lines + len(entries) + 14))
|
||||
print()
|
||||
print(" ↻ **自承是同款第 N 次的有 %d 條**(占 %.0f%%)——"
|
||||
% (len(selfs), 100.0 * len(selfs) / max(1, len(entries))))
|
||||
print(" 這批是「合併成一條、記發生過幾次」最該下手的地方,")
|
||||
print(" 但**合併要人來做**:機器一改正文就會弄丟東西,所以本支只點名。")
|
||||
for e in selfs[:6]:
|
||||
print(" · %s" % e["title"][:54])
|
||||
print(" 它自己說:…%s…" % (self_declared_repeat(e) or "")[:52])
|
||||
if len(selfs) > 6:
|
||||
print(" · …另外還有 %d 條" % (len(selfs) - 6))
|
||||
if dups:
|
||||
print()
|
||||
print(" ×N 標題就長得像的(%d 群):" % len(dups))
|
||||
for g in dups[:5]:
|
||||
print(" ×%d %s" % (len(g), entries[g[0]]["title"][:52]))
|
||||
print()
|
||||
print("⇒ 真的要壓:wiki-compress apply %s --ticket owner/repo#N" % args.file)
|
||||
return 0
|
||||
|
||||
|
||||
# ══ 五、apply(只搬不改)═════════════════════════════════════════════════
|
||||
def cmd_apply(args):
|
||||
p = plan(args.file, args.before, args.keep)
|
||||
entries, groups = p["entries"], p["groups"]
|
||||
if not entries:
|
||||
print("🔴 這個檔切不出任何一條(沒有 `## ` 標題),不動它。", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
where = {} # 條的索引 → 它搬去哪個檔(留下的是 None)
|
||||
buckets = {}
|
||||
stem, _ = os.path.splitext(args.file)
|
||||
for idx, e in enumerate(entries):
|
||||
if idx not in p["stay"]:
|
||||
month = (e["date"] or "")[:7] or "undated"
|
||||
target = "%s-archive-%s.md" % (stem, month)
|
||||
buckets.setdefault(target, []).append(idx)
|
||||
where[idx] = target
|
||||
else:
|
||||
where[idx] = None
|
||||
|
||||
gid = {}
|
||||
for n, g in enumerate(groups):
|
||||
for i in g:
|
||||
gid[i] = n
|
||||
|
||||
# ── 目錄:每條一行。這一行就是票上說的「原位置留一份目錄」──────────
|
||||
idx_lines = []
|
||||
for n, g in enumerate(groups):
|
||||
head = entries[g[0]]
|
||||
mark = "×%d " % len(g) if len(g) > 1 else ""
|
||||
for k, i in enumerate(g):
|
||||
e = entries[i]
|
||||
dest = where[i]
|
||||
loc = "本檔" if dest is None else "→ `%s`" % os.path.basename(dest)
|
||||
tick = (" " + "/".join(e["tickets"][:2])) if e["tickets"] else ""
|
||||
rep = "↻ " if self_declared_repeat(e) else ""
|
||||
pre = mark if k == 0 else " ↳ "
|
||||
idx_lines.append("- %s%s`%s` %s(%s,%d 行)%s%s"
|
||||
% (pre, rep, e["hash"], e["title"], e["date"] or "沒寫日期",
|
||||
e["lines"], loc, tick))
|
||||
del head
|
||||
|
||||
stamp = dt.datetime.now().strftime("%Y-%m-%d")
|
||||
header = [
|
||||
p["pre"].rstrip(),
|
||||
"",
|
||||
"---",
|
||||
"",
|
||||
"## 📇 目錄(%s 壓縮,票:%s)" % (stamp, args.ticket),
|
||||
"",
|
||||
"> 這份目錄是機器產生的(`scripts/wiki-compress`)。**每一條的正文一個字都沒改**——",
|
||||
"> 舊的整條原封不動搬到 archive,留下的還在本檔。要對帳跑:",
|
||||
"> `wiki-compress verify <壓縮前> <壓縮後> <archive...>`",
|
||||
">",
|
||||
"> `×N` = 標題就長得像的同一群。`↻` = **這一條自己寫著它是同款第 N 次**",
|
||||
"> ——那是它自己下的判斷,不是機器猜的。要合併就從 `↻` 這批開始,**由人合併**。",
|
||||
"",
|
||||
] + idx_lines + ["", "---", ""]
|
||||
|
||||
kept = [entries[i]["body"] for i in range(len(entries)) if where[i] is None]
|
||||
new_text = "\n".join(header) + "\n\n".join(kept) + "\n"
|
||||
|
||||
if args.dry_run:
|
||||
print("🧪 --dry-run,什麼都沒寫。壓完會是:")
|
||||
print(" %s:%d 行 → %d 行(目錄 %d 條)"
|
||||
% (args.file, p["text"].count("\n") + 1, new_text.count("\n") + 1,
|
||||
len(idx_lines)))
|
||||
for t, ids in sorted(buckets.items()):
|
||||
print(" %s:+%d 條" % (os.path.basename(t), len(ids)))
|
||||
return 0
|
||||
|
||||
# ── 先把搬走的寫出去,再改原檔(順序不能反:中途死掉也不會弄丟)──────
|
||||
for target, ids in sorted(buckets.items()):
|
||||
chunk = ["# %s(自 %s 搬出,%s)" % (os.path.basename(target),
|
||||
os.path.basename(args.file), stamp),
|
||||
"",
|
||||
"> 原封不動搬過來的,**一個字都沒改**。票:%s" % args.ticket,
|
||||
"> 回原檔看目錄:`%s`" % os.path.basename(args.file),
|
||||
""]
|
||||
chunk += [entries[i]["body"] for i in ids]
|
||||
old = ""
|
||||
if os.path.exists(target):
|
||||
old = open(target, encoding="utf-8").read().rstrip() + "\n\n"
|
||||
with open(target, "w", encoding="utf-8") as f:
|
||||
f.write(old + "\n\n".join(chunk) + "\n")
|
||||
|
||||
backup = args.file + ".before-compress"
|
||||
with open(backup, "w", encoding="utf-8") as f:
|
||||
f.write(p["text"])
|
||||
with open(args.file, "w", encoding="utf-8") as f:
|
||||
f.write(new_text)
|
||||
|
||||
# ── 交付紀錄(票上驗收 3:不准順手做完沒人知道)────────────────────
|
||||
log = os.path.join(os.path.dirname(os.path.abspath(args.file)) or ".", LOG_NAME)
|
||||
with open(log, "a", encoding="utf-8") as f:
|
||||
f.write("\n## %s — %s(票:%s)\n\n" % (stamp, os.path.basename(args.file),
|
||||
args.ticket))
|
||||
f.write("- 壓縮前:%d 行 / %d 條\n" % (p["text"].count("\n") + 1, len(entries)))
|
||||
f.write("- 壓縮後:%d 行(目錄 %d 條 + 留下 %d 條)\n"
|
||||
% (new_text.count("\n") + 1, len(idx_lines), len(kept)))
|
||||
for t, ids in sorted(buckets.items()):
|
||||
f.write("- 搬出 %d 條 → `%s`\n" % (len(ids), os.path.basename(t)))
|
||||
f.write("- 對帳指令:`wiki-compress verify %s %s %s`\n"
|
||||
% (os.path.basename(backup), os.path.basename(args.file),
|
||||
" ".join(os.path.basename(t) for t in sorted(buckets))))
|
||||
f.write("- 🔴 正文未改動(只搬不改);逐條對帳結果見上面那條指令\n")
|
||||
|
||||
print("✅ 壓完了。%d 行 → %d 行"
|
||||
% (p["text"].count("\n") + 1, new_text.count("\n") + 1))
|
||||
for t, ids in sorted(buckets.items()):
|
||||
print(" 搬出 %d 條 → %s" % (len(ids), t))
|
||||
print(" 壓縮前的原檔留在:%s" % backup)
|
||||
print(" 交付紀錄:%s" % log)
|
||||
print()
|
||||
print("⇒ 現在跑對帳(**沒跑過就不算壓完**):")
|
||||
print(" wiki-compress verify %s %s %s"
|
||||
% (backup, args.file, " ".join(sorted(buckets))))
|
||||
return 0
|
||||
|
||||
|
||||
# ══ 六、verify(票上驗收 2:不能弄丟任何一條)════════════════════════════
|
||||
def cmd_verify(args):
|
||||
before = open(args.before, encoding="utf-8", errors="replace").read()
|
||||
_, b_entries = split_entries(before)
|
||||
|
||||
after_files = [args.after] + list(args.also)
|
||||
found = {}
|
||||
for f in after_files:
|
||||
if not os.path.exists(f):
|
||||
print("🔴 找不到 %s" % f, file=sys.stderr)
|
||||
return 2
|
||||
_, es = split_entries(open(f, encoding="utf-8", errors="replace").read())
|
||||
for e in es:
|
||||
found.setdefault(e["hash"], []).append((f, e["title"]))
|
||||
|
||||
missing = [e for e in b_entries if e["hash"] not in found]
|
||||
dupes = {h: v for h, v in found.items() if len(v) > 1}
|
||||
|
||||
print("🧾 逐條對帳:%s → %s" % (args.before, "+".join(after_files)))
|
||||
print(" 壓縮前 %d 條 / 壓縮後(含搬出去的)%d 條"
|
||||
% (len(b_entries), sum(len(v) for v in found.values())))
|
||||
print()
|
||||
if missing:
|
||||
print("🔴 **弄丟了 %d 條**(下面每一條在壓縮後找不到內文一模一樣的):" % len(missing))
|
||||
for e in missing[:40]:
|
||||
print(" · 第 %d 行 `%s` %s" % (e["line"], e["hash"], e["title"][:60]))
|
||||
if len(missing) > 40:
|
||||
print(" · …另外還有 %d 條" % (len(missing) - 40))
|
||||
print()
|
||||
print("⇒ **這次壓縮不合格。** 票上第 2 條驗收就是這件事。")
|
||||
print("⇒ 原檔還在 `%s.before-compress`,把它還原回去再查。" % args.after)
|
||||
return 1
|
||||
print("✅ **一條都沒弄丟。** %d 條全部在壓縮後的檔裡找得到內文一模一樣的。"
|
||||
% len(b_entries))
|
||||
if dupes:
|
||||
print(" ⚠️ 有 %d 條同時出現在兩個檔(搬了但原檔沒清):" % len(dupes))
|
||||
for h, v in list(dupes.items())[:8]:
|
||||
print(" · %s:%s" % (v[0][1][:40], "、".join(f for f, _ in v)))
|
||||
return 0
|
||||
|
||||
|
||||
# ══ 七、bench(票上驗收 1:查得比壓之前快)═══════════════════════════════
|
||||
#
|
||||
# 「快」要能量,不然它只是感覺。這裡量的是**定位成本**:
|
||||
# 從檔頭讀到「確定答案在哪一條」為止,你要掃過幾行。
|
||||
# 壓縮前:沒有目錄 ⇒ 只能一路掃到命中的那一行 ⇒ 成本 = 命中行號
|
||||
# 壓縮後:檔頭有目錄 ⇒ 在目錄裡命中就停 ⇒ 成本 = 目錄裡的行號
|
||||
# 同時量**命中率**——「查得到」是「查得快」的前提,快而查不到是更糟的結果。
|
||||
def probes(entries, cap=80):
|
||||
"""從每條的標題挑一個**在全檔獨一無二**的詞當查詢。挑不到就跳過那條。
|
||||
|
||||
🔴 **要在整份檔上均勻取樣**,不能只取前 cap 條。
|
||||
第一版就是取前 80 條——而那批正好是最新的、壓縮後留在原檔最上面的,
|
||||
**等於只考它最擅長的題目**,量出來的倍數是假的。
|
||||
"""
|
||||
titles = [e["title"] for e in entries]
|
||||
step = max(1, len(entries) // cap)
|
||||
sample = entries[::step]
|
||||
out = []
|
||||
for e in sample:
|
||||
best = None
|
||||
cands = re.findall(r"[一-鿿]{4,10}|[A-Za-z][\w.-]{4,}", e["title"])
|
||||
for c in sorted(cands, key=len, reverse=True):
|
||||
if sum(1 for t in titles if c in t) == 1:
|
||||
best = c
|
||||
break
|
||||
if best:
|
||||
out.append((best, e["hash"]))
|
||||
if len(out) >= cap:
|
||||
break
|
||||
return out
|
||||
|
||||
|
||||
def first_hit_line(path, needle):
|
||||
with open(path, encoding="utf-8", errors="replace") as f:
|
||||
for n, ln in enumerate(f, 1):
|
||||
if needle in ln:
|
||||
return n
|
||||
return None
|
||||
|
||||
|
||||
def cmd_bench(args):
|
||||
before = open(args.before, encoding="utf-8", errors="replace").read()
|
||||
_, b_entries = split_entries(before)
|
||||
qs = probes(b_entries)
|
||||
if not qs:
|
||||
print("🔴 這個檔挑不出可以拿來查的詞(標題太相似)。", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
files = [args.after] + list(args.also)
|
||||
hit_b = hit_a = 0
|
||||
cost_b = cost_a = 0
|
||||
worse = []
|
||||
for q, _h in qs:
|
||||
cb = first_hit_line(args.before, q)
|
||||
ca = None
|
||||
for f in files:
|
||||
c = first_hit_line(f, q)
|
||||
if c is not None and (ca is None or c < ca):
|
||||
ca = c
|
||||
if cb:
|
||||
hit_b += 1; cost_b += cb
|
||||
if ca:
|
||||
hit_a += 1; cost_a += ca
|
||||
if cb and ca and ca > cb:
|
||||
worse.append((q, cb, ca))
|
||||
|
||||
n = len(qs)
|
||||
print("⏱ 查得比壓之前快嗎?(%d 個查詢,每條各出一題)" % n)
|
||||
print()
|
||||
print(" 命中率 壓縮前 %d/%d → 壓縮後 %d/%d" % (hit_b, n, hit_a, n))
|
||||
print(" 定位成本(要從檔頭掃過幾行才確定答案在哪):")
|
||||
print(" 壓縮前 平均 %.0f 行" % (cost_b / max(1, hit_b)))
|
||||
print(" 壓縮後 平均 %.0f 行" % (cost_a / max(1, hit_a)))
|
||||
if hit_a and hit_b:
|
||||
ratio = (cost_b / hit_b) / max(1e-9, cost_a / hit_a)
|
||||
print(" ⇒ **快 %.1f 倍**" % ratio)
|
||||
print()
|
||||
ok = True
|
||||
if hit_a < hit_b:
|
||||
print("🔴 **有 %d 個查詢壓縮後查不到了。** 查得快但查不到=更糟。" % (hit_b - hit_a))
|
||||
ok = False
|
||||
if worse:
|
||||
print(" ⚠️ 有 %d 個查詢變慢了(前 5 個):" % len(worse))
|
||||
for q, cb, ca in worse[:5]:
|
||||
print(" · 「%s」 %d → %d 行" % (q, cb, ca))
|
||||
if ok and hit_a == hit_b:
|
||||
print("✅ **同一件事查得到,而且查得更快**(票上驗收 1)。")
|
||||
return 0 if ok else 1
|
||||
|
||||
|
||||
def main(argv):
|
||||
ap = argparse.ArgumentParser(prog="wiki-compress", description=__doc__,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
sub = ap.add_subparsers(dest="cmd", required=True)
|
||||
|
||||
a = sub.add_parser("audit"); a.add_argument("paths", nargs="*", default=["."])
|
||||
a.set_defaults(fn=cmd_audit)
|
||||
|
||||
p = sub.add_parser("plan"); p.add_argument("file")
|
||||
p.add_argument("--before", help="早於這個日期的搬走(預設:壓到門檻以下為止)")
|
||||
p.add_argument("--keep", type=int, help="最新的幾條留在原檔")
|
||||
p.set_defaults(fn=cmd_plan)
|
||||
|
||||
ap2 = sub.add_parser("apply"); ap2.add_argument("file")
|
||||
ap2.add_argument("--ticket", required=True,
|
||||
help="owner/repo#N —— 壓縮這件事本身要有票(票上驗收 3)")
|
||||
ap2.add_argument("--before")
|
||||
ap2.add_argument("--keep", type=int)
|
||||
ap2.add_argument("--dry-run", action="store_true")
|
||||
ap2.set_defaults(fn=cmd_apply)
|
||||
|
||||
v = sub.add_parser("verify"); v.add_argument("before"); v.add_argument("after")
|
||||
v.add_argument("also", nargs="*")
|
||||
v.set_defaults(fn=cmd_verify)
|
||||
|
||||
b = sub.add_parser("bench"); b.add_argument("before"); b.add_argument("after")
|
||||
b.add_argument("also", nargs="*")
|
||||
b.set_defaults(fn=cmd_bench)
|
||||
|
||||
args = ap.parse_args(argv)
|
||||
if args.cmd == "audit" and not args.paths:
|
||||
args.paths = ["."]
|
||||
if args.cmd == "apply" and not re.match(r"^[\w.-]+/[\w.-]+#\d+$", args.ticket):
|
||||
print("🔴 --ticket 的寫法是 owner/repo#N(裸號跨 repo 會撞號)", file=sys.stderr)
|
||||
return 2
|
||||
return args.fn(args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main(sys.argv[1:]))
|
||||
Reference in New Issue
Block a user