From da9bd53cae6d58eb2dba26981b5e43443617df9a Mon Sep 17 00:00:00 2001 From: Claude Code Date: Fri, 28 Aug 2026 01:04:16 +0000 Subject: [PATCH] =?UTF-8?q?=E6=B2=92=E4=BA=BA=E6=9C=83=E5=8F=AB=E7=9A=84?= =?UTF-8?q?=E4=BA=8B=E6=9C=83=E8=87=AA=E5=B7=B1=E5=8F=AB=EF=BC=8B=E8=AE=80?= =?UTF-8?q?=E4=B8=8D=E5=AE=8C=E7=9A=84=E5=BF=85=E8=AE=80=E6=AA=94=E8=A6=81?= =?UTF-8?q?=E8=A2=AB=E6=95=B4=E7=90=86=EF=BC=88inkstone/ISEP#93=E3=80=81in?= =?UTF-8?q?kstone/ISEP#89=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 兩張票放同一條分支:都是「該發生卻不會自己發生的事」,觸發點都在 session 邊界。 ## inkstone/ISEP#93 —— 逾期和掛著沒人接的事會主動叫 - scripts/isep-nag 撈三種沒人會叫的事(逾期 milestone/等 leo 的票/掉在地上的棒子) - scripts/isep-notify 發 Telegram,而且**發不出去的時候不會安靜** - hooks/overdue-nag-guard.sh SessionStart 跑一次(不輪詢、不 fan-out、不掛 Actions) - hooks/tests/overdue-nag.test.sh 35 條,全程離線 實跑撈得出票上點名的那五個逾期 milestone(08-24 三個、08-26 兩個)。 沒東西可報時會說「查過了,沒有」——安靜跟壞掉長得一模一樣。 🔴 工單補的那個限制(今天實測出來的)已經處理: 「能不能發得出去」取決於這個 session 載到的 ISEP 是哪一版 (prod-write-guard v0.10.0 才認得出 notify_leo 不是部署,而 hook 註冊路徑 在 session 啟動當下就寫死了)。所以 isep-notify 會**先拿真的要送的那一則 去問這個 session 註冊的那支閘**,把判定寫成檔(誰都查得到),然後: · 放行 ⇒ 送,並驗內層 data.data.ok(外層 200 不算送到) · 會擋 ⇒ **不繞路**,改貼回票上並把原文印在眼前 用 git 歷史裡的真跡(c263866 那一版閘)測過「會擋」那條路。 ⛔ 實測發現通道本身現在是斷的:實例上找不到 notify_leo 工作流(404)。 不是閘、不是網路、不是金鑰。詳情與修法寫在 docs/TESTING.md 最後一段。 退路兩次都走通了(inkstone/ISEP#93 comment 5182、5184)。 ## inkstone/ISEP#89 —— wiki 太長時有人整理 - scripts/wiki-compress audit/plan/apply/verify/bench 五個動詞 - hooks/wiki-size-guard.sh SessionStart 點名太長的檔;寫檔時擋「沒走流程的壓縮」 - hooks/tests/wiki-compress.test.sh 25 條,全程離線、不碰真的 wiki 設計上最重要的一條:**只搬不改**,正文一個字都不動。 「合併同類、濃縮成一行」要重寫正文,而重寫的當下沒有人會發現弄丟了什麼。 所以機器只做「搬 + 目錄 + 標 ×N/↻」,合併留給人。 拿現在的 mistakes.md 複本實壓過(不動真的 wiki): 7,681 行 → 1,199 行 260 條一條都沒少(verify 用內文雜湊逐條對帳) 80 個查詢命中率 80/80,定位成本 2,956 → 131 行,**快 22.5 倍**(bench) verify 反向測過:真的弄丟一條時它抓得到(抓不到的對帳表比沒有更糟)。 ## 盤點數字 在這棵樹上當場數的,不是拿上一版加減推的: ls hooks/*.sh | wc -l → 55(was 53) grep -c '"command":' hooks.json → 71(was 68) plugin.json/README/hooks-inventory 三處同步改。 **版本號沒動**(0.11.0),待總管定版。 --- .claude-plugin/plugin.json | 2 +- README.md | 2 +- docs/TESTING.md | 89 +++++ docs/hooks-inventory.md | 17 +- hooks/hooks.json | 12 + hooks/overdue-nag-guard.sh | 109 ++++++ hooks/tests/overdue-nag.test.sh | 190 ++++++++++ hooks/tests/wiki-compress.test.sh | 186 ++++++++++ hooks/wiki-size-guard.sh | 183 +++++++++ scripts/isep-nag | 393 ++++++++++++++++++++ scripts/isep-notify | 416 +++++++++++++++++++++ scripts/wiki-compress | 598 ++++++++++++++++++++++++++++++ 12 files changed, 2190 insertions(+), 7 deletions(-) create mode 100755 hooks/overdue-nag-guard.sh create mode 100755 hooks/tests/overdue-nag.test.sh create mode 100755 hooks/tests/wiki-compress.test.sh create mode 100755 hooks/wiki-size-guard.sh create mode 100755 scripts/isep-nag create mode 100755 scripts/isep-notify create mode 100755 scripts/wiki-compress diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 9fadbbc..fa05106 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "isep", - "description": "InkStone Environment Plugin —— leo 的 Claude Code 環境唯一真相源:53 支機械閘(68 條註冊,白話盤點見 docs/hooks-inventory.md)、7 支 slash command、2 支 skill、38 支腳本,外加治理規範與標籤真相源。本機與雲端裝同一份,沒有子集。", + "description": "InkStone Environment Plugin —— leo 的 Claude Code 環境唯一真相源:55 支機械閘(71 條註冊,白話盤點見 docs/hooks-inventory.md)、7 支 slash command、2 支 skill、38 支腳本,外加治理規範與標籤真相源。本機與雲端裝同一份,沒有子集。", "version": "0.11.0", "keywords": [ "inkstone", diff --git a/README.md b/README.md index 38c7fb0..14adf03 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ | | 數量 | 是什麼 | |---|---|---| -| `hooks/` | 48 支 + `hooks.json` | 全部機械閘(PreToolUse/Stop/SubagentStop/SessionStart/PostToolUse 共 59 條註冊;`ISEP#60` 移除 `claim-verify-police.sh`/`subagent-claim-worksheet.sh` 這對自造機制,改由 Gitea 原生三格承接見 `ISEP#59`) | +| `hooks/` | 55 支 + `hooks.json` | 全部機械閘(PreToolUse/Stop/SubagentStop/SessionStart/PostToolUse/UserPromptSubmit 共 71 條註冊)。**數字一律當場數**:`ls hooks/*.sh \| wc -l` 與 `grep -c '"command":' hooks/hooks.json`(冒號不能省),白話盤點見 `docs/hooks-inventory.md` | | `commands/` | 7 支 | `/wiki-recall` `/ship-check` `/cp-write` … | | `skills/` | 2 支 | | | `scripts/` | 23 支 | `ticket`/`github-arm.sh`/`gitea-bootstrap.sh` … | diff --git a/docs/TESTING.md b/docs/TESTING.md index 3b7763b..677e70c 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -165,6 +165,65 @@ bash hooks/tests/prod-write-guard.test.sh hooks/prod-write-guard.sh - 「卡點二」2 條任一紅 ⇒ 「談論它」又被當成「執行它」(同款第八次), 或是剝了內文之後連真的部署都放行了 +### A16 — 沒人會叫的事會自己叫:35 條 +``` +bash hooks/tests/overdue-nag.test.sh +``` +**該看到**:`通過 35 條,失敗 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 群紅 ⇒ **發不出去卻靜默**。「送出成功」跟「送到了」是兩件事 +- ㉖㉗㉘㉙ 紅 ⇒ 這個 session 的閘是哪一版變回「假設」。㉘ 特別重要: + **閘說會擋就不准從 python 這條路繞過去**——繞得過的閘等於不存在 +- ㉛ 紅 ⇒ 探針把 `/tmp/.prod-write-ok` 燒掉了(那是總管單次用完即丟的授權) +- ㉟ 紅 ⇒ 每條 subagent 開工都吵 leo 一次 + +> 🔴 **跑測試前先確認 `CLAUDE_CODE_CHILD_SESSION` 沒有殘留**:這支閘刻意放行子 session, +> 在一條 subagent 裡跑,F 群會全綠而且是**假綠**。測試檔自己會把它清掉。 + +### A17 — 壓 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 標籤 模組化 @@ -362,6 +421,9 @@ B2(信標那行)/B3(setup 輸出)/B4(閘的訊息)三個畫面 | **A15 pr-verdict --dry-run** | 總管 | ✅(2026-08-28) | | **A14 回覆自己說出拖了多久** | 總管 | ✅ 20/20(2026-08-28,inkstone/ISEP#63) | | **A15 發通知不等於部署** | 總管 | ✅ 37/37(2026-08-28,inkstone/ISEP#63) | +| **A16 沒人會叫的事會自己叫** | 總管 | ✅ 35/35(2026-08-28,inkstone/ISEP#93) | +| **A16b Telegram 真的送達** | **leo** | ⛔ **驗不了——通道本身斷了**(見下) | +| **A17 壓 wiki 不准弄丟東西** | 總管 | ✅ 25/25+真跡實壓(2026-08-28,inkstone/ISEP#89) | | A5 搜尋跨 repo | 總管 | ✅ | | A6 標籤對齊+冪等 | 總管 | ✅ 14 repo,第二次 0/0 | | **A9 人閘警察管路** | 總管 | ✅ 14/14(2026-08-26) | @@ -373,3 +435,30 @@ 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 整批換新時定義消失,後來補推回去)——**看起來又斷了一次**。 + +修法要有人把 `mira` repo 的 `workflows/notify-leo.yaml` 重新 push 上那台實例。 +**那是動線上實例,subagent 不能做**(`prod-write-guard`),要總管或 leo。 + +📌 **這條斷了不代表 `#93` 沒交付**:本次的設計前提就是「**不假設發得出去**」—— +實測那兩次的退路都走通了,訊息貼回了 `inkstone/ISEP#93` +(`#issuecomment-5182`、`#issuecomment-5184`),並把原文印在眼前。 +**發不出去而靜默,等於沒做;發不出去但講出來並留下退路,就是這支的規格。** +通道修好之後不必改任何程式碼,重跑 `python3 scripts/isep-nag --notify` 即可補驗這一格。 diff --git a/docs/hooks-inventory.md b/docs/hooks-inventory.md index d7f7a86..f781140 100644 --- a/docs/hooks-inventory.md +++ b/docs/hooks-inventory.md @@ -1,4 +1,4 @@ -# 52 支閘,白話盤點表 +# 55 支閘,白話盤點表 > 回應 `inkstone/InkStoneCo#40`:「如果加入了,我應該可以白話文看到 hooks 的內容?」 > 這份表就是那個「白話文」——不用點開任何 `.sh` 檔,一行看懂一支閘在管什麼。 @@ -7,16 +7,14 @@ ## 一句話結論 -`hooks/` 底下有 **53 個 `.sh` 檔**,`hooks.json` 實際掛上 **68 條註冊**(同一支閘常被多種情境同時掛上); -`hooks/` 底下有 **53 個 `.sh` 檔**,`hooks.json` 實際掛上 **68 條註冊**(同一支閘常被多種情境同時掛上); +`hooks/` 底下有 **55 個 `.sh` 檔**,`hooks.json` 實際掛上 **71 條註冊**(同一支閘常被多種情境同時掛上); 其中 **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` = 55;`grep -c '"command":' hooks/hooks.json` = 71。 > ⚠️ **冒號不能省**:`grep -c '"command"'`(沒冒號)會連 `"type": "command"` 一起數到,回 **120**。 > 本頁 2026-08-27 之前寫的是沒冒號那版——**照著它跑會拿到一個跟本頁不符的數字**。 > **一份會偷偷過期的盤點表,跟沒有盤點表差不多**——見本頁最後「怎麼跟實況對帳」那段。 @@ -41,6 +39,12 @@ > 📌 **`0.7.0`(`inkstone/ISEP#30`,2026-08-27)進來一支**:`mainline-idle-guard.sh`(F 組,Stop)。 > 48→**49** 支、59→**60** 條,兩個數字都是加完之後當場數出來的(指令同上)。 > +> 📌 **`inkstone/ISEP#93` + `#89`(2026-08-28)進來兩支**: +> `overdue-nag-guard.sh`(E 組,SessionStart)與 `wiki-size-guard.sh`(E 組 + B 組,兩個事件)。 +> 53→**55** 支、68→**71** 條——**兩個數字都是在這棵樹上當場數出來的**,不是拿上一版加減推的 +> (本頁自己上面就記著「不能靠加減推算、只能實數」)。順手拿掉一組重複貼上的行 +> (「53 個 `.sh` 檔…」與那條 `grep` 指令各被貼了兩次)。 + > 📌 **`0.10.0`(`inkstone/ISEP#81`,2026-08-28)進來一支**:`pr-verdict-guard.sh`(F 組,Stop)。 > 51→**52** 支、64→**65** 條,兩個數字都是加完之後當場數出來的(指令同上)。 > 順手改掉一個過期的數字:描述欄長期寫「27 支腳本」,實數是 **34** @@ -90,6 +94,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 組)——它同時守著「下指令」跟「寫檔案」兩種情境,詳見下方「重複掛載」一節。 @@ -129,6 +134,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 注入) | +| `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) diff --git a/hooks/hooks.json b/hooks/hooks.json index 4896338..b63b449 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -110,6 +110,10 @@ { "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/pending-changes-retired.sh" + }, + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/hooks/wiki-size-guard.sh" } ] }, @@ -227,6 +231,14 @@ { "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/scripts/countdown-milestone-refresh.sh" + }, + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/hooks/overdue-nag-guard.sh" + }, + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/hooks/wiki-size-guard.sh" } ] } diff --git a/hooks/overdue-nag-guard.sh b/hooks/overdue-nag-guard.sh new file mode 100755 index 0000000..a70b397 --- /dev/null +++ b/hooks/overdue-nag-guard.sh @@ -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 diff --git a/hooks/tests/overdue-nag.test.sh b/hooks/tests/overdue-nag.test.sh new file mode 100755 index 0000000..6ba03be --- /dev/null +++ b/hooks/tests/overdue-nag.test.sh @@ -0,0 +1,190 @@ +#!/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" +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" + +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 ] diff --git a/hooks/tests/wiki-compress.test.sh b/hooks/tests/wiki-compress.test.sh new file mode 100755 index 0000000..f27342f --- /dev/null +++ b/hooks/tests/wiki-compress.test.sh @@ -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 ] diff --git a/hooks/wiki-size-guard.sh b/hooks/wiki-size-guard.sh new file mode 100755 index 0000000..5857b7b --- /dev/null +++ b/hooks/wiki-size-guard.sh @@ -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 < + 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 diff --git a/scripts/isep-nag b/scripts/isep-nag new file mode 100755 index 0000000..c40ebd6 --- /dev/null +++ b/scripts/isep-nag @@ -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 把時鐘定住(測試用) + +環境變數: + 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:])) diff --git a/scripts/isep-notify b/scripts/isep-notify new file mode 100755 index 0000000..cc83b8e --- /dev/null +++ b/scripts/isep-notify @@ -0,0 +1,416 @@ +#!/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 comment_on_ticket(ref, text, gate_fact): + """把那則發不出去的訊息貼回票上。回 (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" % (ref, text)).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 = "\n".join([ + IDENTITY, + "", + "🔴 **這則本來要用 Telegram 發給 leo,但這個 session 發不出去**——所以貼在這裡。", + "", + gate_report(gate_fact), + "", + "---", + "", + text, + ]) + 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) + 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:])) diff --git a/scripts/wiki-compress b/scripts/wiki-compress new file mode 100755 index 0000000..854f99d --- /dev/null +++ b/scripts/wiki-compress @@ -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-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 <壓縮前> <壓縮後> `", + ">", + "> `×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:]))