Compare commits

...

46 Commits

Author SHA1 Message Date
claude-code 7df15f2e2b 定版 v0.16.0(.claude-plugin/plugin.json) 2026-08-28 01:31:27 +00:00
claude-code 7a3ce4e590 Merge pull request '沒人會叫的事會自己叫+讀不完的必讀檔要被整理(inkstone/ISEP#93、inkstone/ISEP#89)' (#106) from feat/nag-and-wiki-compress into main 2026-08-28 01:28:00 +00:00
Claude Code 672439bd95 把 main(v0.15.0,派工紀律三件 #105)併進來,逐支點名複驗過 hooks.json
## hooks.json:這次 git 自動合成功了,而那正是最危險的情況

`hooks.json` 是 JSON——**合錯了語法照樣合法、閘卻不見了,不會有任何東西喊一聲。**
所以「沒有衝突」不等於「沒掉東西」,一律再做兩件事:

① 逐支點名(指令已寫進 README「裝什麼」那段)→ 82 條,60 支各自幾條全部列出
② 跟**兩個父節點**做集合對照:

    merged 82 = main 79 + 我的 3
    從 main 掉了:0 條
    從我這邊掉了:0 條
    兩邊都沒有卻冒出來的:0 條

`#105` 三支與我的兩支同時在,位置也對:

    roster-guard.sh              PreToolUse  Agent / Task
    diagnosis-evidence-guard.sh  PreToolUse  Bash
    investigate-first-stamp.sh   PostToolUse Agent|Task / SendMessage
    overdue-nag-guard.sh         SessionStart
    wiki-size-guard.sh           SessionStart / PreToolUse Write|Edit|MultiEdit

(總管提醒的 `roster-guard` vs `mainline-focus-guard` 那種「同一陣列位置兩邊各一支」
 這次沒有再發生衝突,但兩支都在,各 2 條。)

## 三處文字衝突:兩邊都留,數字重數

- plugin.json     版本吃 main 的 **0.15.0**(總管已定版,**我沒有動它**);
                  描述欄補上 main 新增的「7 位有名字的工人」,三個數字重數
- README.md       吃 main 的新表格結構(多了 agents/commands/skills/scripts 四列),
                  數字重數;並把累積成**四列**的 `hooks/` 收成一列
- hooks-inventory 標頭被貼成兩份、「一句話結論」被貼成**三份**(數字還互相打架),
                  各收成一份;版本註記兩邊都留

## 盤點(全部在合併後的樹上實數,不是加減推的)

    ls hooks/*.sh | wc -l                    58 → 60
    grep -c '"command":' hooks/hooks.json    79 → 82
    ls agents/*.md | wc -l                        7
    ls -p scripts | grep -v / | wc -l            48
    未掛進 hooks.json 的 .sh                       3(與本頁原本寫的一致,重數過)

📌 抓到的漂移(每次併都有一個,這次三個):
   ① hooks-inventory 的標頭 ×2、一句話結論 ×3
   ② README 的 `hooks/` 列 ×4,其中兩列是舊數字
   ③ plugin.json 描述欄寫「49 支腳本」,而用本頁寫死的那道指令實數是 48

## 測試(合併後全部重跑,帶對參數)

hooks/tests 23 支全綠:ask-user-question 14、countdown 20、diagnosis-evidence 26、
dispatch-format 52、factory-idle 33、gate-ok 17、gitea-arm-check 16、
presence-beacon 14、cross-repo 19、main-and-prod-push 10、mainline-focus 24、
mainline-idle 61、milestone-account 22、**overdue-nag 36**、pr-verdict 52、
prod-write 37、reply-identity 11、roster 24、sdd 8、search-is-not-proof 31、
stage-before-prod 16、unpushed-police 10、**wiki-compress 25**

scripts/test-* 13 支全綠。
claude plugin validate . ✔ passed;check-version-consistency  0.15.0 = v0.15.0。
兩支新 SessionStart hook 併完實跑過,閘判定探針對 v0.15.0 的 prod-write-guard 回 pass。

🔴 跑之前清 `CLAUDE_CODE_CHILD_SESSION`;四支要帶參數
(三支帶 hook 絕對路徑、cross-repo 帶 hook 絕對路徑、gitea-arm-check 帶 repo 根目錄)
——不帶會紅,而那是假紅。
2026-08-28 01:25:49 +00:00
claude-code 5fca644b5a Merge pull request '工人有名字、診斷要有調查、回覆也只給票號(inkstone/ISEP#86、#87、#88)' (#105) from feat/named-workers-and-reply-gate into main 2026-08-28 01:20:12 +00:00
Claude 44f60ba0b8 merge main(v0.14.0,五批)into 派工紀律三件
hooks.json 兩處衝突:roster-guard 與 mainline-focus-guard 在同一個陣列位置,
兩支都是真的閘,展開成兩個項目保留(不是二選一)。
盤點在合併後重數:58 支 .sh/79 條註冊/49 支腳本/7 位具名工人。版本 0.15.0。
2026-08-28 01:20:00 +00:00
Claude Code 79ee9e62d6 把 main(v0.14.0,五批)併進來,解 5 處衝突,盤點數字在合併後的樹上重數
衝突全部**兩邊都留**,沒有丟掉任何一側:

- hooks.json      main 的 `scripts/mainline refresh` + 我的兩支,同一個
                  SessionStart 陣列裡並存
- plugin.json     版本吃 main 的 0.14.0(**沒有自己決定版本**),描述欄的
                  三個數字在合併後的樹上重數
- README.md       main 那格被貼成兩列(54 支/69 條 與 54 支/71 條,互相矛盾),
                  收成一列並填上實數
- hooks-inventory main 的 `mainline refresh` 那列 + 我的兩列;標頭與
                  「一句話結論」的數字重數
- TESTING.md      兩邊各自新增的格子都留。**我的兩格改號成 A21/A22**——
                  併之前兩邊都叫 A16/A17,撞號(這一頁本來就已經有
                  三組重複的 A14–A18,這次至少沒有再加一組)

盤點(`ls hooks/*.sh | wc -l`、`grep -c '"command":' hooks/hooks.json`、
`ls -p scripts | grep -v / | wc -l`,都在**合併後**的樹上跑):

    55 → 57 支 .sh
    72 → 75 條註冊
         47 支腳本

📌 順手抓到一個漂移:main 的 plugin.json 描述欄寫「48 支腳本」,
   而用 hooks-inventory 自己寫死的那道指令實數是 44(main)/47(合併後)。
   已記在 hooks-inventory 的版本註記裡——**同一個病,第 N 次,只是換一欄。**

📌 TESTING.md 的「已知斷點」那段補上總管用 MCP 查到的更廣範圍:
   不只 notify_leo 不見了,那台實例的工作流定義是**整批失效**;
   而且修它要寫 leo 的個人帳號 ⇒ `leo21c-write-guard.sh` 明文禁止
   ⇒ **那是真人閘,總管也不能解**。

測試(合併後全部重跑,帶對參數):
  我的兩套          overdue-nag 36/36、wiki-compress 25/25
  hooks/tests/*     ask-user-question 14、countdown 20、dispatch-format 33、
                    factory-idle 33、gate-ok 17、presence-beacon 14、
                    mainline-focus 24、mainline-idle 61、milestone-account 22、
                    pr-verdict 52、reply-identity 11、sdd 8、
                    search-is-not-proof 31、unpushed-police 10、
                    prod-write 37、stage-before-prod 16、
                    main-and-prod-push 10、cross-repo 19、gitea-arm-check 16
  scripts/test-*    13 支全綠(含 release-manifest 21、milestone-account 23、
                    debt-worklist 47、ticket-handoff-writeback 49)
  claude plugin validate . 通過;check-version-consistency 通過(0.14.0 = v0.14.0)
2026-08-28 01:15:54 +00:00
Leo 5ac06abc95 工人有名字+回覆也是派工+未經調查不寫診斷(inkstone/ISEP#86/#87/#88)
三張票同一族(誰在派、派給誰、派的內容住哪裡),做在同一條分支:

#86 工人名單:agents/ 七位有名字的工人+scripts/roster+hooks/roster-guard.sh
    派工用 Task 的 subagent_type 指名,派工單格式一個字都沒改;
    指對名字就把那位的檔案原文注入(你是誰/先讀什麼/你的紅線)。
    【身份】欄同時吃得下工人名字(原本三個角色照舊)。

#87 未經調查不寫診斷:hooks/diagnosis-evidence-guard.sh + investigate-first-stamp.sh
    三個結構訊號(派過人查沒/有沒有走得過去的出處/有沒有份量),
    一個關鍵字比對都沒有;轉述有出處不會被誤擋。

#88 回覆也是派工:不另造閘,把攔截點加掛上去。
    hooks/lib/dispatch_parse.py 的 tool_channel() 一次列全所有通往 subagent 的路
    (SendMessage/雲端 session・trigger/claude -p);擋下來時把那段內容原文印出來。
    subagent 往上回報(to: "main")=交件不是派工,刻意不管。

順手修掉一個真的會咬人的 flake:dispatch-format-guard 原本開四支 python 各讀一個欄位,
機器忙的時候某個欄位會靜靜變空字串(實測連跑 10 次有 1 次「豁免了卻還是被擋」)。
四個欄位改成一次讀完。

版本號待總管定(plugin.json 只更新了描述裡的數字,版本沒動)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZBu4Sa1cGntKFRBYNZ6xs
2026-08-28 01:15:09 +00:00
Claude Code 156d339308 退路留言要帶「為什麼發不出去」,不是只帶閘的判定(inkstone/ISEP#93)
2026-08-28 第一版漏了這一格,而它剛好就在實測時咬到:
閘判定 pass、Telegram 卻 404(實例上找不到 notify_leo 工作流),
退路留言貼上票之後**只看得到 pass**——真正的斷點一個字都沒進票。
⇒ 下一個人會去修閘,而斷點根本不在那裡。

把留言內文抽成 fallback_body()(獨立成一支就是為了測得到),
測試從「看原始碼有沒有那個字串」改成**真的產一份留言出來檢查**:
斷點原因、原文、身份欄三格都要在。36 條全綠。
2026-08-28 01:06:52 +00:00
claude-code 0a8bb2d9dd Merge pull request '跨 repo 版本清單+結案記帳(inkstone/ISEP#84、#85)' (#104) from feat/manifest-account-v2 into main 2026-08-28 01:04:55 +00:00
claude-code eb6c427421 跨 repo 版本清單+結案記帳(inkstone/ISEP#84、#85)(scripts/test-release-manifest.sh) 2026-08-28 01:04:22 +00:00
claude-code c6eb269551 跨 repo 版本清單+結案記帳(inkstone/ISEP#84、#85)(scripts/test-milestone-account.sh) 2026-08-28 01:04:20 +00:00
claude-code bc02c2645b 跨 repo 版本清單+結案記帳(inkstone/ISEP#84、#85)(scripts/release-manifest) 2026-08-28 01:04:18 +00:00
Claude Code da9bd53cae 沒人會叫的事會自己叫+讀不完的必讀檔要被整理(inkstone/ISEP#93、inkstone/ISEP#89)
兩張票放同一條分支:都是「該發生卻不會自己發生的事」,觸發點都在 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),待總管定版。
2026-08-28 01:04:16 +00:00
claude-code ca10e1db30 跨 repo 版本清單+結案記帳(inkstone/ISEP#84、#85)(scripts/milestone-account) 2026-08-28 01:04:16 +00:00
claude-code 5f6995b892 跨 repo 版本清單+結案記帳(inkstone/ISEP#84、#85)(hooks/tests/milestone-account-guard.test.sh) 2026-08-28 01:04:14 +00:00
claude-code 395d885ceb 跨 repo 版本清單+結案記帳(inkstone/ISEP#84、#85)(hooks/milestone-account-guard.sh) 2026-08-28 01:04:12 +00:00
claude-code a7ca29c180 跨 repo 版本清單+結案記帳(inkstone/ISEP#84、#85)(hooks/hooks.json) 2026-08-28 01:04:10 +00:00
claude-code ba30c49876 跨 repo 版本清單+結案記帳(inkstone/ISEP#84、#85)(docs/hooks-inventory.md) 2026-08-28 01:04:08 +00:00
claude-code 4eed944f68 跨 repo 版本清單+結案記帳(inkstone/ISEP#84、#85)(docs/TESTING.md) 2026-08-28 01:04:05 +00:00
claude-code b6405b2a5c 跨 repo 版本清單+結案記帳(inkstone/ISEP#84、#85)(README.md) 2026-08-28 01:04:03 +00:00
claude-code 521dda1b47 跨 repo 版本清單+結案記帳(inkstone/ISEP#84、#85)(.claude-plugin/plugin.json) 2026-08-28 01:04:01 +00:00
claude-code 90ed988d84 Merge pull request '舊票每天有固定管道被撈出來(inkstone/ISEP#83)' (#101) from feat/debt-worklist into main 2026-08-28 01:01:53 +00:00
claude-code d301810fb6 merge main(#98 下游做完時頂層跟著關),我的那節再讓一次編號
衝突只有 docs/TESTING.md 一列:#92 也用了 A19 ⇒ 我改成 A20,並把整節搬到 A19 後面。
(今天 main 上 A16、A17 各被佔兩次、A19 被佔兩次——編號撞是這條線的常態,
  讓號的成本比要求別人改小,所以一律我讓。)

plugin.json 的腳本數在合併後的樹上重數 40 → 43:
  main v0.13.0 那個「40」= `ls scripts | wc -l`(37 檔+3 目錄),
  合併後同一算法 = 43:+2 是本 PR,+1 是 #98 帶進來的 test-ticket-handoff-writeback.sh
  (#98 併進 main 時沒有回頭改這個數字)。版本號沒動(0.13.0)。
2026-08-28 00:58:49 +00:00
claude-code e575786be3 merge main(定版 v0.13.0),衝突只有 plugin.json 一行
版本號取 main 的 0.13.0(我不定版)。description 的腳本數在合併後的樹上重數:
  main 那個「40」= `ls scripts | wc -l`(37 個檔+3 個子目錄)
  合併後同一個算法 = 42(多的兩個是本 PR 的 debt-worklist 與 test-debt-worklist.sh)
  ⇒ 40 → 42。閘的兩個數字沒變(hooks/*.sh = 54、hooks.json "command": = 71,本 PR 沒新增 hook)
2026-08-28 00:55:30 +00:00
claude-code 5d6c4b5d63 Merge pull request '下游做完時頂層跟著關(inkstone/ISEP#92)' (#98) from feat/handoff-writeback-loose into main 2026-08-28 00:54:27 +00:00
claude-code 45adc10a52 併進 main(v0.12.0)並接上「主線唯一答案」(inkstone/ISEP#83)
衝突只有 docs/TESTING.md 一處,兩邊都留:
  main 的 A16(gate-ok)/A17(未推警察)/A18(信標)原樣保留,
  我那節改編號成 A19——main 上 A16、A17 已經被 #90#99 各佔一次,不動別人的。

接上 inkstone/ISEP#82 帶進來的 hooks/lib/mainline.py(不自己養第二套判斷):
  · 有標主線 ⇒ 只排除「屬於主線那一條」的票;掛在**別條** milestone 的仍算債
    (那條線現在沒有人在推,正是它躺著的原因)
  · 沒標主線 ⇒ 退回保守做法:掛 open milestone 的一律不領,並在清單上照實說沒標
  · 別條 milestone 已逾期 ⇒ +15「答應過人家的日期已經過了」

測試 40 → 47 條(+7 條全給主線這段),並把 ISEP_COUNTDOWN_STATE_DIR 也隔離:
不然這支測試的結果會取決於「這台機器現在標了哪條主線」。

盤點數字在合併後的樹上重數:
  hooks/*.sh = 54、hooks.json "command": = 71(本 PR 沒新增 hook,兩個都沒變)
  scripts 一層檔案 = 39(本 PR +2)⇒ plugin.json description 37→39
  版本號沒動(0.12.0,總管統一定版)
2026-08-28 00:51:06 +00:00
claude-code 87678a8a87 Merge pull request '定版 v0.13.0(現在的主線是哪一個,有唯一答案)' (#102) from chore/v0.13.0 into main 2026-08-28 00:49:13 +00:00
claude-code 62c2f3e664 定版 v0.13.0(.claude-plugin/plugin.json) 2026-08-28 00:48:59 +00:00
claude-code 07cb5e4e55 併進 main(#96 雲端接線/#100 v0.12.0/#99 主線唯一),解 TESTING.md 撞號(inkstone/ISEP#92)
唯一衝突在 docs/TESTING.md:兩條線各自加了一節、**都叫 A16**。
兩邊內容都留,我這一節改成 A19(main 已用掉 A16/A17/A18),
「目前狀態」表的那一列跟著搬到 A18 後面。

main 側本來就有的 A11/A12/A14/A15 重號**沒有動**——那是它兩條線各自編的,
不是這次造成的,動它會把 diff 炸開。

沒有碰的:.claude-plugin/plugin.json(維持 main 的 v0.12.0,版本由總管統一定)、
hooks/hooks.json、docs/hooks-inventory.md——這三個檔本線一個字都沒改,
自動合併拿到的就是 main 那份。

盤點數字在合併後的樹上重數(不是相加、不是抄別人的):
  hooks/*.sh 54 hooks.json 註冊 71 commands 7 skills 2 scripts 頂層 38
docs/hooks-inventory.md 寫的 54/71 **是對的**;README 寫的 48/59/23
**三個都錯**,已改成實數,並把「怎麼重數」的指令寫進去——
這種數字會漂是因為沒人知道它怎麼來的。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZBu4Sa1cGntKFRBYNZ6xs
2026-08-28 00:48:49 +00:00
claude-code 4014676813 Merge pull request '現在的主線是哪一個,有唯一答案(inkstone/ISEP#82)' (#99) from feat/mainline-milestone into main 2026-08-28 00:43:55 +00:00
claude-code 7871921741 把主線接進派工與注入(inkstone/ISEP#82)
- hooks.json     mainline-focus-guard 掛 Agent+Task(派工的兩個入口都要守);
                 SessionStart 多一條 `scripts/mainline refresh`(0.8s,不輪詢)
- hooks-inventory 54 支/71 條(當場數的)+補人話一列;順手修掉上一版重複的那一行,
                 並把「指向空氣」那格的假警報寫清楚(countdown-milestone-refresh 在 scripts/)
- TESTING        A16(24 條離線)+ A17(要網路的那半怎麼驗)
- plugin.json    描述數字對齊;版本不動,待總管定版
2026-08-28 00:40:05 +00:00
Claude Code 21971ea83e 現在的主線是哪一個,變成一個查得到的事實(inkstone/ISEP#82)
14 個 open milestone 同時亮著,其中「Mira 現代化」同名活在 5 個 repo,
所以 SOP 說的「那個 active milestone」在現場沒有指涉對象。

- hooks/lib/mainline.py        主線的唯一存放處(一個檔放得下一條),從不打網路
- scripts/mainline             show/list/set/clear/refresh/adopt/has
- hooks/mainline-focus-guard.sh 派了不在主線上的票 ⇒ 攔一次,問補收還是跳線
- hooks/lib/countdown.py       ⏱ 那一行的主線改讀「被標定的」,蓋過「期限最近」的猜測
- hooks/countdown-guard.sh     同一個注入點加第二行 🎯(ISEP#63 那半不動)
- 測試 24 條(離線)+ countdown 原有 20 條仍全綠
2026-08-28 00:39:50 +00:00
claude-code dbd7c8c874 Merge pull request '定版 v0.12.0(雲端的閘真的能用)' (#100) from chore/v0.12.0 into main 2026-08-28 00:39:06 +00:00
claude-code f9327de068 定版 v0.12.0(.claude-plugin/plugin.json) 2026-08-28 00:38:13 +00:00
claude-code 67f5f0fa5e 舊票每天有固定管道被撈出來(inkstone/ISEP#83)
leo 2026-08-27:「現在每天都在追新的,所以要用 Routine 去消化舊的,可以有多個條件」

兩條線各用各的判準(他當場訂正的那件事):
  緊急線=掛 open milestone 的,用「距離目標的遠近」排
  還債線=沒掛的那 188 張,用多條件排(事故/擋人/承諾/票齡/估工)← 本次做的

新增 scripts/debt-worklist(list/claim/forget):
  · 撈全 org 15 個 repo 的 open issue + open PR,逐一列出當作「我查了哪些」的證明
  · 扣掉掛 open milestone 的(緊急線在管)、等 leo 的(Human/human-exec/s/stage,
    那些要催不是要領,催辦是 inkstone/ISEP#93)、已領走的
  · 每張票旁邊印出「為什麼排這裡」的分項,排錯可以指著某一項說它給太多
  · 成包只認強訊號:互相指著、或 Gitea 原生相依(母子票)。
    第一版用單向引用成包,190 張裡 121 張黏成一坨——一包沒有人領得動
  · 空清單會明講「查詢跑過了,結果是零」,不默默結束
  · claim 之後那些票不再出現在清單上,並在隔天的清單頂端交代它們關了沒

hooks/session-start-recall.sh 加一段(不打網路,只看狀態檔日期):
  今天還沒撈過就說一句,並給出要跑的那一行。開 session 自動去撈=輪詢,紅線擋著。

測試 scripts/test-debt-worklist.sh:40 條全離線,票上四條驗收條件各一組。
真實資料唯讀實跑過:229 張 → 可領 188 張/185 包,領走兩張後隔天清單少掉它們。
2026-08-28 00:38:02 +00:00
claude-code 9b0afec0b2 Merge pull request '雲端的閘:修好三個穩定重現的接線缺陷,並診斷第四個(inkstone/ISEP#90)' (#96) from fix/cloud-wiring-isep90 into main 2026-08-28 00:37:30 +00:00
claude-code f3a41fba0e 下游做完時頂層跟著關(inkstone/ISEP#92)
票上的病:「開在頂層的票,下游做完了卻沒人回來關⋯⋯頂層票會永遠掛著,
而 leo 是看頂層的。」缺的三格是 journey 標籤、雙向連結的強制、完工回寫。

不重造既有的(票上明列):`ticket where` 戳記+側門閘(查票防重複)、
`comment-carries-task-guard`(留言藏任務)、Gitea 原生 issue dependency
(跨 repo 關聯的載體)——這一版全部長在它們上面。

四格,判準全部是機械事實(相依邊在不在、state 是什麼),沒有一格在猜文字:

① 雙向連結(驗收 1):`subtask`/新別名 `handoff` 一個動作同時掛原生相依
   + 在頂層票的時間軸貼一則指回下游的留言。相依邊只長在側欄,
   時間軸上什麼都沒有,跨 repo 時尤其看不出來。

② 完工回寫(驗收 2,最痛的那格):`ticket close` 關完子票立刻問 `/blocks`
   (誰在等我),對每張還開著的頂層票貼回寫留言;下游全關的那一張再
   指派回總管+改 s/review。**不自動關母票**——關票要有交付物、要有人看過,
   默默關掉跟默默留著是同一個病的兩面。

③ journey 標籤:`--journey <名>` 把 `j/<名>` 同時貼在母子兩端(只貼一端就
   聚不起來)。標籤不存在就擋下並指回 labels.yaml——要求標籤在場,
   不是猜哪些名字合法。labels.yaml 的 `j/` 段刻意留空:旅程怎麼切、叫什麼
   名字是方向題,不由工具代決。

④ 撈得出來(驗收 3、4):新動詞 `ticket loose` 掃 org 的 open 票,撈
   「有下游相依、下游全關、自己還開著」的頂層票。這是 ② 的補網——
   關票的側門(直接 PATCH state=closed)是刻意放行的,漏掉的由這張網撈回來。
   不是輪詢,掛在開場/收工對帳跑一次。

實跑(2026-08-28,唯讀,沒動任何票):245 張 open 票裡 17 張有下游相依,
其中 2 張是「下游都關了、自己還開著」——inkstone/arcrun-rag#136(已 s/review
且指派給 claude-code,正在被處理)與 inkstone/Arcrun#100(沒標籤、沒指派,
就是票上講的那種「永遠掛著」)。

測試:scripts/test-ticket-handoff-writeback.sh,49 條全綠,全程離線
(會打 API 的兩段把 api() 換成錄音機,不開票不關票不留測試票)。
回歸:A13 17/17、A4 24/24、A12 11/11 皆綠。
文件:docs/TESTING.md 新增 A16。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZBu4Sa1cGntKFRBYNZ6xs
2026-08-28 00:31:35 +00:00
claude-code b7b83bf243 併進 main(#97 pr-verdict),並在合併後的樹上重數盤點數字(inkstone/ISEP#90)
數字全部在合併後的樹上重數,沒有用相加推的:
· hooks/*.sh = 53、hooks.json 註冊 = 68 —— 與 main 已宣告的一致,不用動
· scripts/ 頂層 = **39**(git 追蹤的),plugin.json 原本寫 38 ⇒ 改成 39
  (本分支新增 scripts/gate-ok 一支)

🔴 這裡不能用 `ls scripts/ | wc -l`:它在這台機器回 40,因為多了一個**沒進版控的
`__pycache__/`**。那個數字會隨「有沒有人跑過 python」而變 ⇒ 用 git 追蹤的數目才穩定。

docs/hooks-inventory.md 標題也順手改:它寫「52 支閘」而自己第 10 行寫「53 個 .sh 檔」
——同一頁自相矛盾,標題是漏改的那半。

**version 一個字沒動**(0.11.0),由總管統一收斂。

合併後全部重跑(CLAUDE_CODE_CHILD_SESSION 有設與清掉各跑一次,結果相同):
pr-verdict-guard 52/52、countdown-guard 20/20、prod-write-guard 37/37、
gate-ok 17/17、unpushed-police 10/10、isep-presence-beacon 14/14、
main-and-prod-push 10/10+13/13、stage-before-prod 16/16+13/13、
sdd-guard 8/8、reply-identity 11/11、dispatch-format 33/33、
search-is-not-proof 31/31、mainline-idle 61/61、factory-idle 33/33、
ticket-api-bypass 24/24、ticket-where-seen 17/17、baton-handback 10/10、
comment-carries-task 22/22、github-contact 14/14、kbdb-api-wall 10/10、
release-tag 8/8、版本一致性 
2026-08-28 00:31:06 +00:00
claude-code 45b7b81075 Merge remote-tracking branch 'gitea/main' into fix/cloud-wiring-isep90 2026-08-28 00:29:10 +00:00
claude-code bebbbd2c11 Merge pull request 'PR 一定要有結論(inkstone/ISEP#81)' (#97) from feat/pr-verdict-v2 into main 2026-08-28 00:28:19 +00:00
claude-code 4322deb23a 補上 stat -f 診斷的最後一層:連離開碼都不可靠(inkstone/ISEP#90 ④)
總管自己驗這一格量到 exit=0,我量到 exit=1。**兩個都是真的**,
而分歧本身就是最後一塊拼圖:

**GNU 的 `-f` 是 `--file-system`,布林旗標、不接格式字串**
⇒ `%m` 不是格式,它被當成**另一個檔名運算元**
⇒ 離開碼取決於「cwd 裡有沒有一個叫 `%m` 的檔」:

    A. 沒有(一般情況)  → exit 1 ⇒ `||` 會跑   ⇒ 正確的秒數接在垃圾後面
    B. 剛好有            → exit 0 ⇒ `||` 不會跑 ⇒ 整包連一個數字都沒有

兩種情況閘的結果一樣:MT 都不是純數字、戳記都作廢。(實測 GNU coreutils 9.4,兩種都重現過)

🔴 教訓比原本寫的更尖銳:舊寫法的 `||` fallback 救不了,
**不是因為它沒跑,而是因為「跑不跑」根本不由這支腳本決定**——
它由「cwd 裡有沒有某個檔名」決定。
一個行為取決於 cwd 有沒有某個檔的判斷式,不管跑不跑都是壞的。
⇒ 所以修法不能只是「把順序反過來」,**每一步都要驗它是不是純數字**
(lib/mtime.sh 本來就是這樣寫的,現在把理由寫進去了)。

gate-ok 測試補第 ⑰ 條守這一層:cwd 裡有一個叫 `%m` 的檔時,file_mtime 仍要回純數字。
16 → 17 條,TESTING.md 的 A16 一併更新。
2026-08-28 00:27:51 +00:00
claude-code 3a1c3d4969 Merge remote-tracking branch 'origin/main' into fix/cloud-wiring-isep90
# Conflicts:
#	docs/TESTING.md
2026-08-28 00:25:04 +00:00
claude-code 617315e3dd 把四個雲端接線缺陷的診斷寫下來(inkstone/ISEP#90)
docs/governance/cloud-wiring.md:四個缺陷分成兩種病——
①④ 是「閘的判準寫的是地端才成立的假設」,②③ 是「同一個東西有兩份,雲端跑到舊的」。
每一格都附實測證據與修法,並標明還沒關掉的兩格住在哪張票(ISEP#67/真身 repo)。

TESTING.md 補 A14/A15/A16 三格:怎麼跑、該看到什麼、什麼算失敗。
三支閘被擋下的訊息也一併指向 scripts/gate-ok(原本的手打法保留當等價寫法)。
2026-08-28 00:14:01 +00:00
claude-code 3e83a4b4f3 修好「總管可以放行」那道門——它在 Linux(=每個雲端 session)上是焊死的(inkstone/ISEP#90 ④)
三支閘(prod-write-guard/main-and-prod-push-guard/stage-before-prod-guard)
判斷戳記新不新,都寫成這一行:

    MT=$(stat -f %m "$STAMP" 2>/dev/null || stat -c %Y "$STAMP" 2>/dev/null || echo 0)

macOS(BSD stat)上 `-f %m` 就是 mtime,對的。
**GNU coreutils 的 `-f` 是「顯示檔案系統資訊」**,而且它一邊回非零、
一邊往 stdout 吐一整段區塊 ⇒ `||` 接上的秒數被那段文字污染
⇒ 下一行的 `case "$NOW$MT" in *[!0-9]*) return 1` 必然命中
⇒ **在 Linux 上那三支閘的戳記永遠不會被接受。**

後果不是少一個便利功能:那三支閘都是「擋下來、但總管看過就能放行」,
而放行那道門在雲端打不開 ⇒ **它們在雲端等於純擋**,
總管照著閘自己印的指示做,做幾次都打不開,閘也不會告訴他門是壞的
(inkstone/InkStoneCo#99 那次「連續四次蓋不出戳記」就是這個形狀)。

· hooks/lib/mtime.sh:先 `-c %Y`(GNU)再 `-f %m`(BSD),**每一步都驗是不是純數字**
  ——這個 bug 的成因正是「命令失敗了卻還是印了東西」,只看離開碼會再被騙一次。
  四支用到的檔各自帶一份 inline fallback:這道門不能因為少一個檔案就再關上一次。
· scripts/gate-ok:把散在各閘訊息裡的七種逃生口收斂成一個名字、一種形狀
  (`gate-ok <閘名> [參數]`)。原因是逃生口原本是「臨時湊出來的 Bash 指令」,
  而臨時湊的指令沒有穩定形狀可以事先放行——settings.json 的 allow 只能逐條完全比對,
  多一個 `&&`、換一個 session id 就落在規則外。它蓋的戳記與閘原本認的完全同一個檔、
  同一種語意(單次、綁 repo、綁 session、有效期都沒動),換掉的只有「怎麼蓋」。

測試 hooks/tests/gate-ok.test.sh:16/16。既有的三支測試(29/16/10)不受影響。
拿 main 跑同一份:②⑤(門打得開)紅、⑥⑦⑧(門沒變寬)全綠
⇒ 這支補的正是既有測試從來沒驗過的那半邊。
2026-08-28 00:11:42 +00:00
claude-code adb7009b67 信標多報三件雲端會壞掉的事:版本落差/舊複本遮蔽正門/退役機制的殘骸(inkstone/ISEP#90 ②③)
信標原本只證明「有一份 plugin 載入了」,不證明「載入的是哪一份」——
2026-08-27 雲端載的是 0.3.9、main 是 0.9.0,中間差 7 個 release,而它照樣是綠的。
那個看不見的落差的實際後果:0.3.9 裡還活著兩支 v0.9.0 已整支刪掉的 hook,
於是 .claude/pending-verification/ 被清掉之後又長回來。

三格(都只報告、不擋、拿不到答案就閉嘴):
· 版本落差:匿名讀 ISEP main 的 plugin.json(D20 判準下屬於「讀」),快取 6 小時、
  逾時 6 秒。落後就講清楚差幾版與怎麼修,並指向 inkstone/ISEP#67。
· 舊複本遮蔽正門:plugin 的 scripts/* 在專案樹(含雲端的 $TOP/InkStoneCo/)裡
  有同名但**內容不同**的複本就點名。實例=InkStoneCo/scripts/ticket 的取 token
  邏輯還停在「只認名叫 gitea 的 remote」,而雲端的 Gitea 叫 origin
  ⇒ 一律死在「拿不到 gitea token」⇒ 人被逼去繞過正門直接打 API,
  而那正是 ticket-api-bypass-guard.sh 在防的事。
· 退役機制的殘骸:.claude/ 底下的目錄,若 plugin 的 hooks/scripts 一個字都沒提到
  ⇒ 產生它的東西已經不在這一份裡了。判準是「plugin 現在還認不認得它」,
  **不是關鍵字黑名單**(leo 2026-08-17 已證明那條路 8 次誤攔、0 次正確攔截)。

訊息改由 hooks/lib/beacon_report.py 序列化:三段報告含引號與換行,
用 shell 內插拼 JSON 一個引號就會讓整行信標消失——而那正是「零閘狀態」的長相。
fail-open:python 掛掉、檔案不見、網路不通,一律退回原本那一行乾淨的信標。

測試 hooks/tests/isep-presence-beacon.test.sh:14/14(全離線)。
第 ⑪ 條守的是寫這支時真的犯過的 bug(內層迴圈變數跟外層的 d 撞名,
第二個殘骸之後全部被靜靜跳過)——把那個 bug 放回去,該條立刻轉紅。
2026-08-28 00:05:15 +00:00
claude-code 352d7a0b81 未推警察改用「遠端有沒有這顆 commit」判斷,不再用「有沒有 upstream」(inkstone/ISEP#90 ①)
雲端 session 開出來的工作分支天生沒有 upstream,內容卻等於遠端 main
(薄殼 HEAD 081c547 = GitHub 遠端 main 081c547,同一顆)
⇒ 每個雲端 session、每次收工都被攔一次,08-27 一個 session 五次全是誤報。

三件:
· 判準改成問遠端(git ls-remote,快取 120 秒)。本機只答得準「有」,
  答「沒有」時才打網路——實測本 session 的 origin/main 落後遠端 4 天。
· 三態:遠端有/遠端沒有/問不到。**問不到一律不報**,不拿離線當罪證。
· 散落分支的基準也一起換成遠端實際那顆——基準過期會把已經在遠端 main 上的
  分支整批報成散落(包含雲端 session 自己那條)。

順手補雲端那格接線:`$CLAUDE_PROJECT_DIR` 在雲端是薄殼根,真身在 `$TOP/InkStoneCo/`。
舊的掃描清單四個路徑一個都不存在 ⇒ 真身有東西沒推,這支閘一輩子不會知道。

測試 hooks/tests/unpushed-police.test.sh:10/10(全離線,本機 bare repo 當遠端)。
拿舊版跑同一份:A 群 5 條全紅、B 群 4 條全綠 ⇒ 測試有鑑別力,不是改到寬鬆。
2026-08-28 00:00:36 +00:00
63 changed files with 9439 additions and 82 deletions
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "isep",
"description": "InkStone Environment Plugin —— leo 的 Claude Code 環境唯一真相源:53 支機械閘(68 條註冊,白話盤點見 docs/hooks-inventory.md)、7 支 slash command、2 支 skill、38 支腳本,外加治理規範與標籤真相源。本機與雲端裝同一份,沒有子集。",
"version": "0.11.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.16.0",
"keywords": [
"inkstone",
"guardrails",
+29 -2
View File
@@ -23,10 +23,37 @@
| | 數量 | 是什麼 |
|---|---|---|
| `hooks/` | 48 `hooks.json` | 全部機械閘(PreToolUseStopSubagentStopSessionStartPostToolUse 共 59 條註冊`ISEP#60` 移除 `claim-verify-police.sh``subagent-claim-worksheet.sh` 這對自造機制,改由 Gitea 原生三格承接見 `ISEP#59` |
| `hooks/` | 60 `hooks.json` | 全部機械閘(PreToolUseStopSubagentStopSessionStartPostToolUseUserPromptSubmit82 條註冊)。**一支一行的白話盤點在 `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/` | 23 支 | `ticket``github-arm.sh``gitea-bootstrap.sh` |
| `scripts/` | 48 支 | `ticket``roster``isep-nag``wiki-compress``github-arm.sh` …(頂層檔案,不含 `lib/` 等子目錄) |
> 🔴 這五個數字**每次都要在自己的樹上實數**,不准沿用上一版、也不准用加減推
> `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
> 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` 開頭記著同一個病)。
**不放**`.env`(金鑰,違 D36「金鑰只有一個家」)、`wiki/``docs/``_archive/`
——那些是**知識**不是**環境**。
+22
View File
@@ -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**——零件走 WASMrecipe
`code` 節點只用於局部整形,不是拿來重寫整條流程(那叫腹語術)。
- **金鑰只寫 `{{credential.<名字>}}`**,真身由執行前回填;不准自建第二套傳遞法。
- 動任何基礎設施詞(D1/資料表/migration/schema)之前,先問「**這個東西是誰的**」。
- 不准部署 prod;stage 驗過再交。
+20
View File
@@ -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 親手解保險)。
+20
View File
@@ -0,0 +1,20 @@
---
name: collector-hand
description: 桌面 collectordaemon 的工人——打包、自動更新、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。
+21
View File
@@ -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),禁表格平攤。
+26
View File
@@ -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`)的數字**在你的樹上自己數**,不准用加減推。
+19
View File
@@ -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**,分層不准混。
- 同步到線上走 **sshscp/rsync****不准用 GitHub Actions**(那是當初被 flag 的形狀)。
- 不准部署 prod。
+21
View File
@@ -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。** 你交的是結論,貼回那張票。
+414 -2
View File
@@ -154,6 +154,49 @@ bash hooks/tests/countdown-guard.test.sh
subagent 的回覆不是給 leo 看的),所以在一條 subagent 裡跑,B 群會**全綠而且是假綠**。
測試檔自己會把它清掉——2026-08-28 第一次跑就撞到這個,才補上去的。
### A16 — 現在的主線是哪一個:24 條
```
bash hooks/tests/mainline-focus-guard.test.sh
```
**該看到**`通過 24 條,失敗 0 條`。**全程離線**:狀態走 `ISEP_COUNTDOWN_STATE_DIR`
時鐘走 `ISEP_COUNTDOWN_NOW`、「那張票掛在哪」走 `ISEP_MAINLINE_FIXTURE`
並把 `TICKET_HOST` 指到一個連不上的位址——**任何一條真的走到網路,會在那裡當場失敗,
不會靜靜地變成假綠**。
**它在守什麼**`inkstone/ISEP#82`leo 2026-08-27):
「**14 個里程碑同時亮著卻不知道該看哪個**」。當天實查 14 個 open milestone
其中「Mira 現代化」這個名字同時活在 5 個 repo,5 個已經逾期
⇒ SOP 說的「只做**該** milestone 的事」**沒有指涉對象,等於不存在**。
**判準**:問「現在的主線是哪一個」,`scripts/mainline` 回一個答案,而且只有一個。
**失敗**
- A 群任一條紅 ⇒ 答案不唯一,或標了新的舊的還在——那就回到 14 個一起亮的狀態
- ⑧ 紅 ⇒ **目標宣告沒有自己出現**(票上驗收第 2 條),leo 又要自己去翻
- ⑨ 紅 ⇒ 主線的名字沒掛在被查核的 ⏱ 那一行上 ⇒ 它到不了 leo 眼前
(本票刻意不另立第二道會擋人的閘,就是靠這一格成立)
- ⑩ 紅 ⇒ 用「期限最近的那個」蓋過了被指定的主線。**猜的不准蓋過指定的**
- ⑬⑭ 紅 ⇒ 派了不在主線上的票沒被問一次,或問了卻沒給補收/跳線兩條路
- ⑯ 紅 ⇒ **鬼打牆**:同一張票被擋第二次。至多擋一次是硬要求
- **⑲–㉔(不該擋)任一條紅 ⇒ 誤攔,這比漏擋嚴重**。特別是 ⑲(沒有主線就一律放行,
票上驗收第 4 條)與 ㉒(問不到那張票掛在哪 ⇒ 放行)——
**「讀不到」不等於「不屬於」,把這兩件事講成同一句是最貴的錯**
📌 這一條線的另一半是**指令**,不是閘:`scripts/mainline``show``list``set`
`clear``refresh``adopt``has`)。要打網路的那幾個(list/set/adopt)不在測試裡跑,
因為它們**只有真的 Gitea 才驗得了**——驗法見 A17。
### A17 — 主線那支指令真的問得到 Gitea(要網路)
```
python3 scripts/mainline list
```
**該看到**:一份 open milestone 清單,**跨多個 repo**`ISEP` / `Arcrun` / `arcrun-rag` / `mira`…),
每一行左邊是可以直接貼給 `set``owner/repo#<milestone id>`
**失敗**
- `一個 open milestone 都沒讀到` ⇒ 這台機器拿不到 Gitea。**那是讀不到,不是沒有**
——不要因此標一個猜的主線
- 只有單一 repo ⇒ 沒有跨 repo,等於沒看到全局
### A15 — 發通知不等於部署:37 條
```
bash hooks/tests/prod-write-guard.test.sh hooks/prod-write-guard.sh
@@ -165,6 +208,268 @@ bash hooks/tests/prod-write-guard.test.sh hooks/prod-write-guard.sh
- 「卡點二」2 條任一紅 ⇒ 「談論它」又被當成「執行它」(同款第八次),
或是剝了內文之後連真的部署都放行了
### A16 — 一次交貨 = 一份清單(跨 repo 版本組合):21 條
```
bash scripts/test-release-manifest.sh
```
**該看到**`通過 21 條,失敗 0 條`。全程離線(`RELEASE_MANIFEST_FIXTURE`),
**不打 Gitea、不建任何真的 release**;清單寫在 `mktemp` 的目錄,不碰 repo 裡的 `releases/`
**它在守什麼**`inkstone/ISEP#84`):一次交貨常常同時動到好幾個 repo,
而「這一版 = 哪幾個 repo 的哪幾版」**沒有任何地方記著** ⇒ 退版只退了其中一個,
剩下的還停在新版 ⇒ **變成一組從來沒測過的組合**
**失敗**
- ⑨⑩ 紅 ⇒ 驗收 1 沒過:**版本號寫上去就算數**,沒有真的去 Gitea 抓過
(「寫上去」跟「存在」是兩件事,而清單的用處完全建立在後者)
- ⑱ 紅 ⇒ 驗收 3 沒過:**只退其中一個 repo 沒有被擋下來**,那正是票上那個病
- ⑯⑰⑲ 任一紅 ⇒ 驗收 2 沒過:退版沒有吐出整份清單的每一格
(包含「已經在目標版本、不必動」那幾格——列出來才證明沒有一格被漏掉)
- ⑫ 紅 ⇒ 凍結過的清單還能再加東西,「凍結」兩個字就沒有意義了
- ⑳㉑ 紅 ⇒ 驗收 4 沒過:leo 打開 `INDEX.md` 看不出「我現在手上是哪一版」
### A17 — 每次結案都留下估多久/花多久/差多少:23 條
```
bash scripts/test-milestone-account.sh
```
**該看到**`通過 23 條,失敗 0 條`。全程離線(`MILESTONE_ACCOUNT_FIXTURE` =一份假的
Gitea 回應表),時鐘用 `MILESTONE_ACCOUNT_NOW` 定住,帳本寫在 `mktemp` 目錄,
**不打 Gitea、不關任何真的 milestone**
**它在守什麼**`inkstone/ISEP#85`leo 2026-08-27 說那天「**拖時間**」):
拖了多久、比預計多拖多少、為什麼拖——**沒有任何數字**。偵測器早就有了
`mainline-idle-guard.sh``factory-idle-guard.sh`),**但沒有人把它算成帳**。
**失敗**
- ①②③④ 任一紅 ⇒ 驗收 1 沒過:三個數字不是自己算出來的
- ⑤⑥ 紅 ⇒ 驗收 2 沒過:差超過 ±25% 卻能不挑代號就結案
- ⑧ 紅 ⇒ 代號不再是封閉集合。**能統計的前提是分類有限**,這一格垮了統計就垮了
- ⑨ 紅 ⇒ **挑一個時間軸撐不住的代號也能過**,等於「採信我自己說的」,
而票上寫死證據一律從 Gitea 的時間軸取
- ⑦ 紅 ⇒ **誤攔**:只差 -6% 也逼人挑代號。這比漏擋嚴重——每次結案都被念,
人就學會繞過去
- ⑭⑮⑯⑰ 任一紅 ⇒ 驗收 3 沒過:累積之後看不出哪一種原因最常發生
- ⑱⑲ 紅 ⇒ 驗收 4 沒過:拿已經逾期的里程碑進去算不出數字
### A18 — 結案要記帳的那道閘:22 條
```
bash hooks/tests/milestone-account-guard.test.sh
```
**該看到**`通過 22 條,失敗 0 條`。離線(帳本走 `MILESTONE_ACCOUNT_LEDGER`),
純結構判斷、沒有語意判官,所以每次結果都一樣。
**失敗**
- **A 群任何一條紅 ⇒ 誤攔,這比漏擋嚴重。** ⑥⑦⑧⑪ 特別重要:
`echo` 一段指令、`cat` 這支閘自己、commit 訊息裡提到、把指令寫進文件的 heredoc 內文
——**四種都只是「談論它」,不是「執行它」**。這四格是本 repo 撞過很多次的同一個病
`strip_heredoc.py` 的檔頭記著兩次真跡)
- ⑨⑩ 紅 ⇒ 走正門 `scripts/milestone-account close` 反而被自己的閘擋下,
那道閘就等於封死了唯一的出路
- ⑭⑮⑯⑰⑱ 任一紅 ⇒ 漏擋:換個寫法(`python urllib`、前面串一個無害指令、
`--request` 長寫法、換一個 repo)就繞過去了
- ⑲ 紅 ⇒ 訊息沒有點名目標,人看完不知道要對哪一個里程碑動手
- ⑳㉑ 紅 ⇒ 「記過帳就放行」這個出路是假的,或者一記帳就把所有里程碑全開了
### A16 — 「總管可以放行」那道門真的打得開:17 條
```
bash hooks/tests/gate-ok.test.sh
```
**該看到**`通過 17 條,失敗 0 條`
**它在守什麼**inkstone/ISEP#90 ④,2026-08-28 實查):三支閘
`prod-write-guard``main-and-prod-push-guard``stage-before-prod-guard`
都是「擋下來、但**總管看過就能放行**」的設計,而**既有測試只驗了擋得住,
一條都沒驗過放得開**。於是這個 bug 活了很久:`stat -f %m` 在 GNU coreutils 上
是「檔案系統資訊」,它一邊回非零一邊吐一整段文字 ⇒ 秒數被污染 ⇒
**在 Linux(=每一個雲端 session)上戳記永遠不被接受** ⇒ 那三支閘在雲端等於純擋,
而閘不會告訴你門是壞的。
**失敗**
- ②⑤(門打得開)紅 ⇒ 逃生口又焊死了,那三支閘在雲端變回純擋
- ⑥⑦⑧(門沒變寬)任一紅 ⇒ **更嚴重**:綁 repo/有效期/空戳記那三條性質是
08-11、08-12 兩次真的被穿透之後才補上的,不准為了「好放行」而鬆掉
- ⑫ 紅 ⇒ `gate-ok` 在解不出 repo 時留下了一枚註定打不開的空戳記,
那會讓人以為門開了(而空戳記本身就是 08-12 那把萬用鑰匙的形狀)
- ⑰ 紅 ⇒ 判準又變回「行為取決於 cwd 裡有沒有一個叫 `%m` 的檔」。
這條是這個 bug 的最後一層:**GNU 的 `-f` 是布林旗標,`%m` 被當成另一個檔名運算元**
⇒ 有那個檔就 `exit 0``||` 連跑都不跑。所以修法不能只是「把順序反過來」,
**每一步都要驗它是不是純數字**
⚠️ 這支會真的寫 `/tmp` 的戳記檔(那是閘寫死的路徑)。**不要在「已經蓋好戳記正要推東西」
的當下跑它**——它會把那枚戳記洗掉。
### A17 — 未推警察不會對雲端的工作分支亂叫:10 條
```
bash hooks/tests/unpushed-police.test.sh
```
**該看到**`通過 10 條,失敗 0 條`。全離線(用本機 bare repo 當「遠端」)。
**它在守什麼**inkstone/ISEP#90 ①):雲端 session 開出來的工作分支天生沒有 upstream,
內容卻等於遠端 main ⇒ 舊判準「沒 upstream=從沒推過」讓**每個雲端 session、每次收工
都被攔一次**08-27 一個 session 五次全是誤報)。
**失敗**
- A 群(①—⑥,不該報)任一紅 ⇒ **誤攔**,比漏擋嚴重:永遠在響的警報=訓練人忽略它,
下一條真的失蹤的分支會混在雜訊裡
- B 群(⑦—⑩,該報)任一紅 ⇒ 為了不吵而改成放行了,那是把閘關掉不是修好
- 特別看 ④:問不到遠端(離線)**不准當成「你沒推」**
### A18 — 信標會講出雲端會壞掉的那三件事:14 條
```
bash hooks/tests/isep-presence-beacon.test.sh
```
**該看到**`通過 14 條,失敗 0 條`。全離線。
**它在守什麼**inkstone/ISEP#90 ②③):信標原本只證明「有一份 plugin 載入了」,
不證明「載入的是哪一份」——08-27 雲端載 0.3.9、main 0.9.0,差 7 個 release
而它照樣是綠的。
**失敗**
- ⑫⑬⑭(信標永遠在)任一紅 ⇒ **最嚴重**:那一行消失=leo 會判定這個 session 零閘
- ③ 紅 ⇒ 版本落差報不出來,`ISEP#67` 那個病又變回看不見
- ⑤⑦ 紅 ⇒ 舊複本遮蔽正門抓不到(`InkStoneCo/scripts/ticket` 那件)
- ⑥ 紅 ⇒ **誤攔**:同步過的複本被念,人就學會忽略它
- ⑪ 紅 ⇒ 內層迴圈變數撞名的回歸(同一個 `.claude` 下第二個殘骸會被靜靜跳過)
### A19 — 下游做完時頂層跟著關:49 條
```
bash scripts/test-ticket-handoff-writeback.sh
```
**該看到**`通過 49 條,失敗 0 條`。**全程離線**——`TICKET_HOST` 指到連不上的位址,
會打 API 的兩段(`_writeback``cmd_subtask`)把 `api()` 換成錄音機跑,
**不打真實 Gitea、不開票、不關票、不留任何測試票**
**它在守什麼**`inkstone/ISEP#92`):
「開在頂層的票,下游做完了卻沒人回來關⋯⋯頂層票會永遠掛著,**而 leo 是看頂層的**。」
四格對應票上的四條驗收條件:
- ①②③ 純函式(`is_loose``writeback_plan``journey_label`)——**判準本身**
不必開真票就驗得動
- ④⑤ `subtask``handoff` 的參數閘:該擋的擋、齊全的放得過
-**完工回寫的接線**(驗收第 2 條):關掉一張下游票之後,它到底對頂層票做了什麼
-**雙向連結的接線**(驗收第 1 條):兩張票互相看得到對方,不是靠人記得補
**失敗**
- ①「open + 從來沒有下游 → 不撈」紅 ⇒ **誤攔**`ticket loose` 會把每一張普通票
都列出來,那張表就變成雜訊,人學會忽略它(本 repo 心法第 2 條)
- ②「還有別的下游沒關 → 只記一筆」紅 ⇒ 母票會在下游還沒做完時被指派回總管,
假綠
- ⑦「沒有去關母票」變綠 ⇒ 回寫從「處理」滑成「自動關掉」。
**默默關掉跟默默留著是同一個病的兩面**——關票要有交付物、要有人看過
- ⑦「留言第一行有身份欄」紅 ⇒ 機器貼的留言看起來像某個人寫的,
下一個讀票的人會去找那個人(`reply-identity-guard` 管的是同一件事)
- ⑧「母子兩端都被貼」剩 1 ⇒ journey 只貼了一端,**聚類時撈得到一半**,
比完全沒貼更危險
- ⑤ 任何一條紅 ⇒ 誤攔,合規的交辦被擋掉等於這條路不能走
🔴 **這支測不到的那一格(要 leo 或總管接手)**`labels.yaml``j/`
**刻意是空的**——旅程怎麼切、叫什麼名字是方向題,不由工具代決。
在有人往那裡加第一條旅程之前,`--journey` 只會擋、不會貼。
機制驗過了,**資料還沒有**。
### A20 — 舊票每天有固定管道被撈出來:47 條
```
bash scripts/test-debt-worklist.sh
```
**該看到**`通過 47 條,失敗 0 條`。**全程離線**:資料走 `ISEP_DEBT_FIXTURE`
時鐘走 `ISEP_DEBT_NOW`、狀態走 `ISEP_DEBT_STATE_DIR`、主線走 `ISEP_COUNTDOWN_STATE_DIR`
——**不打 Gitea、不留測試票、不碰 `$HOME`**。
**它在守什麼**`inkstone/ISEP#83`leo 2026-08-27):
「現在每天都在追新的,所以**要用 Routine 去消化舊的**,可以有多個條件」。
全 org 229 張 open 票,每天在動的只有掛在 open milestone 上的那 56 張;
其餘的不是不重要,是**沒有任何機制會去拿它們**。
🔴 **兩條線各用各的判準,不是二選一**leo 當場訂正總管的誤解):
掛 milestone =緊急線,用「距離目標的遠近」排;沒掛的=還債線,用多條件排
(事故/擋人/承諾/票齡/估工)——後者就是 `scripts/debt-worklist`
**失敗**
- 「驗收 1」那組紅 ⇒ 清單沒有附「我查了哪些 repo」的證明。
**沒有證明的清單分不出「今天真的沒有」與「查詢壞了」**,而後者會靜靜地零產出
- 「驗收 2」紅 ⇒ 空清單默默結束。這是這支工具最貴的失效方式:
它看起來跑完了,於是沒有人再去看那 180 張
- 「驗收 3」紅 ⇒ 領走的票明天又冒出來,這份清單就會變成每天一樣的一坨,人就不看了
- 「成包」兩條紅 ⇒ 互相指著/母子票被拆開領,做到一半才發現卡在另一張
- **「單向引用不成包」紅 ⇒ 這是誤攔的那一面**:第一版用單向引用成包,
結果 190 張裡 121 張黏成一包(大家都會順手引用 hub 票)=**一包沒有人領得動**
- 「接上主線」那組紅 ⇒ 還債線又自己養了一套「哪個是 active」的判斷。
唯一答案在 `hooks/lib/mainline.py``inkstone/ISEP#82`),**兩份必然漂移**。
特別看「沒標主線」那兩條:**沒有答案時要退回保守**(掛 open milestone 的一律不領),
不准猜一條出來
- 最後一組(SessionStart)紅 ⇒ 要嘛沒有人會被提醒去撈,
要嘛那一段偷偷去打網路——**開 session 自動撈就是輪詢**,紅線擋著
**手動驗真實資料**(會打 Gitea,唯讀 GET):
```
python3 scripts/debt-worklist list --top 5
```
**該看到**15 個 repo 逐一列出 issue/PR 數,講得出**現在的主線是哪一條**
(或誠實說沒標),扣除數字講得出來(掛 open milestone 的幾張、等 leo 的幾張、已領走的幾張),
前幾名旁邊看得到「為什麼排這裡」的分項。
**失敗**:前幾名一眼看不出為什麼在那裡 ⇒ 那些權重就是錯的,去改 `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 標籤 模組化
@@ -199,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 群任何一條紅 ⇒ **誤攔**。合規的派工只有一行票號,擋掉它等於整台機器派不了工
@@ -212,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 的路又漏了**。那六條是雲端 sessiontrigger
`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 壞掉時擋住了。這支是 PreToolUsefail-closed 會讓人做不了事
### A12 — 票上的每一則留言都認得出是誰寫的(兩道門)
```
@@ -362,14 +720,68 @@ B2(信標那行)/B3(setup 輸出)/B4(閘的訊息)三個畫面
| **A15 pr-verdict --dry-run** | 總管 | ✅(2026-08-28 |
| **A14 回覆自己說出拖了多久** | 總管 | ✅ 20/202026-08-28inkstone/ISEP#63 |
| **A15 發通知不等於部署** | 總管 | ✅ 37/372026-08-28inkstone/ISEP#63 |
| **A16 跨 repo 版本清單** | 總管 | ✅ 21/212026-08-28inkstone/ISEP#84 |
| **A17 估多久/花多久/差多少** | 總管 | ✅ 23/232026-08-28inkstone/ISEP#85 |
| **A18 結案要記帳的閘** | 總管 | ✅ 22/222026-08-28inkstone/ISEP#85 |
| **A21 沒人會叫的事會自己叫** | 總管 | ✅ 36/362026-08-28inkstone/ISEP#93 |
| **A21b Telegram 真的送達** | **leo** | ⛔ **驗不了——通道本身斷了,而且修它是真人閘**(見下) |
| **A22 壓 wiki 不准弄丟東西** | 總管 | ✅ 25/25+真跡實壓(2026-08-28inkstone/ISEP#89 |
| A5 搜尋跨 repo | 總管 | ✅ |
| A6 標籤對齊+冪等 | 總管 | ✅ 14 repo,第二次 0/0 |
| **A9 人閘警察管路** | 總管 | ✅ 14/142026-08-26 |
| **A10 人閘警察準度** | 總管 | ✅ 9/9,連跑三次(2026-08-26),A 群誤攔 0 |
| **A11 派工單只剩票號+回覆那條路(ISEP#88)** | 總管 | ✅ 52/522026-08-28 |
| **A12 留言身份欄(兩道門)** | 總管 | ✅ 11/112026-08-28 複跑) |
| **A16 工人有名字(ISEP#86** | 總管 | ✅ 24/242026-08-28 |
| **A17 未經調查不寫診斷(ISEP#87)** | 總管 | ✅ 26/262026-08-28 |
| **A11 派工單只剩票號** | 總管 | ✅ 19/192026-08-27 |
| **A12 留言身份欄(兩道門)** | 總管 | ✅ 11/112026-08-27 |
| **A16 放行的門真的打得開** | 總管 | ✅ 17/172026-08-28inkstone/ISEP#90 |
| **A17 未推警察不誤攔雲端分支** | 總管 | ✅ 10/102026-08-28inkstone/ISEP#90 |
| **A18 信標會報雲端接線缺陷** | 總管 | ✅ 14/142026-08-28inkstone/ISEP#90 |
| **A19 下游做完頂層跟著關** | 總管 | ✅ 49/492026-08-28inkstone/ISEP#92 |
| **A20 舊票有固定管道被撈** | 總管 | ✅ 47/472026-08-28inkstone/ISEP#83)+真實 229 張唯讀實跑 |
| A7 plugin 裝得起來 | 總管 | ✅ |
| **A8 新 session 閘會觸發** | 總管 | 見本版 release note |
| **B1B5 雲端** | **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` 即可補驗這一格。
+195
View File
@@ -0,0 +1,195 @@
# 雲端 session 的閘為什麼跟地端不一樣(inkstone/ISEP#90
> leo 的目標一句話:**讓雲端 session 跟地端拿到同一組閘、同一組能用的工具。**
雲端不是「地端少幾支閘」,是**同一批閘在雲端的行為不一樣**。四個實例,
每一個都穩定重現,而且每一個都**不會自己喊痛**——這才是它們活這麼久的原因。
---
## 四個缺陷,兩種病
| | 缺陷 | 病 |
|---|---|---|
| ① | 未推警察每個雲端 session 都誤攔 | 判準在雲端**不成立** |
| ② | `scripts/ticket` 在雲端拿不到 token | 跑到的是**另一份**(舊複本) |
| ③ | 已刪除的機制在雲端重生 | 載到的是**另一版**0.3.9 vs 0.9.0 |
| ④ | 總管的「我確認過了」出口打不開 | 判準在雲端**不成立**(平台差異) |
①④ 是同一種病:**閘的判準寫的是地端才成立的假設**。
②③ 是同一種病:**同一個東西有兩份,而雲端跑到的是舊的那份**。
---
## ① 未推警察:判準是「有沒有 upstream」,而雲端的分支天生沒有
雲端 session 開出來的工作分支沒有 upstream,**內容卻等於遠端 main**
```
薄殼 HEAD = 081c547d90dc2d80692485af083ca1f71f2094f2
GitHub 遠端 main = 081c547d90dc2d80692485af083ca1f71f2094f2 ← 同一顆
```
⇒ 每個雲端 session、每次收工都被攔一次(2026-08-27 一個 session 五次全是誤報)。
**修法**:判準改成「**遠端有沒有這顆 commit**」(`git ls-remote`)。
本機的 remote-tracking ref 只答得準「有」——08-28 實測那份 `origin/main` 落後遠端 4 天
——所以答「沒有」的時候才打網路。三態:有/沒有/**問不到**;問不到一律不報。
**順手補的**`$CLAUDE_PROJECT_DIR` 在雲端是**薄殼根**,真身在 `$TOP/InkStoneCo/`
舊的掃描清單四個路徑在雲端一個都不存在 ⇒ **真身有東西沒推,這支閘一輩子不會知道。**
同一支閘在雲端既亂叫、又看不到該看的地方。
---
## ② `scripts/ticket`:跑到的是舊複本,不是 plugin 那一份
- ISEP 的 `scripts/ticket` **早在 2026-08-20 就修好了**(掃所有 remote 環境變數 fallback
- 但雲端 cwd 是真身,那裡有一份 `InkStoneCo/scripts/ticket` 的**舊複本**
取 token 邏輯還停在「只認名叫 `gitea` 的 remote」,而 `bootstrap.sh` 把 Gitea 設成 `origin`
- ⇒ 雲端一律死在「拿不到 gitea token」
🔴 **後果比「一支腳本壞了」嚴重**`scripts/ticket` 是開票/留言的**正門**
它一壞,人就繞過去直接打 Gitea API——**而那正是 `ticket-api-bypass-guard.sh` 在防的事**。
**一道閘把人逼去走它自己禁止的那條路,那道閘就是在製造違規。**
**ISEP 這半的修法**:信標在 session 開頭就點名「專案裡有 ISEP 腳本的舊複本,
而且**內容不同**」。判準不是檔名一樣,是**檔名一樣而內容不同**——同步過的複本不吵。
**真身那半(把那份複本同步或刪掉)不在 ISEP,要在 `inkstone/InkStoneCo` 修。**
---
## ③ 已刪除的機制在雲端重生
```
ISEP main .claude-plugin/plugin.json → 0.9.0
雲端實際載入 → 0.3.9 ← 差 7 個 release
```
0.3.9 裡還活著兩支在 v0.9.0 已整支刪除的 hook ⇒ `.claude/pending-verification/`
被清掉之後又長回來。**一個看不見的版本落差,會讓已經刪掉的機制在別人的工作區裡復活。**
**傳輸那半是 `inkstone/ISEP#67`**(新版到不到得了手上),本票不重複那件。
**ISEP 這半能做的是讓它不再看不見**:信標匿名讀 ISEP main 的 `plugin.json`
(D20 判準下屬於「讀」,不需開閘),落後就講清楚差幾版、怎麼重拍快照;
同時掃 `.claude/` 底下**這一份 plugin 的 hooks/scripts 一個字都沒提到**的目錄,
點名它們是殘骸。判準是「plugin 現在還認不認得它」,**不是關鍵字黑名單**
leo 2026-08-17 已證明那條路 8 次誤攔、0 次正確攔截)。
---
## ④ 「總管可以放行」的門,在雲端是焊死的
三支閘(`prod-write-guard``main-and-prod-push-guard``stage-before-prod-guard`
都是「擋下來、但**總管看過就能放行**」。它們判斷戳記新不新都用同一行:
```sh
MT=$(stat -f %m "$STAMP" 2>/dev/null || stat -c %Y "$STAMP" 2>/dev/null || echo 0)
```
macOSBSD stat)上 `-f %m` 就是 mtime,對的。
**GNU coreutils 的 `-f` 是「顯示檔案系統資訊」**,而且它**一邊回非零、一邊往 stdout
吐一整段區塊**
```
$ stat -f %m /tmp/.probe
stat: cannot read file system information for '%m': No such file or directory ← stderr
File: "/tmp/.probe" ← stdout
ID: 0 Namelen: 255 Type: ext2/ext3
```
`2>/dev/null` 吃掉錯誤訊息、`||` 把正確的秒數接在那堆垃圾**後面**
`case "$NOW$MT" in *[!0-9]*) return 1` 必然命中
**在 Linux(=每一個雲端 session)上,那三支閘的戳記永遠不會被接受。**
### 再往下一層:`-f` 根本不吃格式參數,所以連離開碼都不可靠
總管 2026-08-28 自己驗這一格時量到 `exit=0`,而我量到 `exit=1`
**兩個都是真的**,而分歧本身就是這個 bug 最後一塊拼圖:
**GNU 的 `-f` 是 `--file-system`,它是布林旗標、不接格式字串**
`%m` 不是格式,它被當成**另一個檔名運算元** ⇒ 離開碼取決於
「當前目錄裡有沒有一個叫 `%m` 的檔」:
```
$ cd /tmp/statprobe && touch .mt
# A. 沒有名為 %m 的檔(一般情況)
$ stat -f %m .mt ; echo "exit=$?"
stat: cannot read file system information for '%m': No such file or directory
File: ".mt" … ← 檔案系統資訊照樣印到 stdout
exit=1 ← `||` **會**跑 ⇒ 正確的秒數接在那堆垃圾後面
# B. 剛好有一個叫 %m 的檔
$ touch '%m' && stat -f %m .mt ; echo "exit=$?"
exit=0 ← `||` **不會**跑 ⇒ 整包連一個數字都沒有
```
(實測環境:`stat (GNU coreutils) 9.4`
**兩種情況下閘的結果一模一樣**——`MT` 都不是純數字,
`case "$NOW$MT" in *[!0-9]*) return 1` 都必然命中,戳記都作廢:
```
情況 A:非數字 ⇒ return 1 ⇒ 戳記作廢
情況 B:非數字 ⇒ return 1 ⇒ 戳記作廢
```
🔴 **這一層才是真正該記住的教訓**:舊寫法的 `||` fallback 之所以救不了,
**不是因為它沒跑,而是因為「跑不跑」根本不由這支腳本決定**
——它由「當前目錄裡有沒有一個叫 `%m` 的檔」決定。
一個**行為取決於 cwd 裡有沒有某個檔名**的判斷式,不管跑不跑都是壞的。
⇒ 所以 `lib/mtime.sh` 的修法不是「把順序反過來」而已,是
**每一步都驗它是不是純數字**:這個 bug 的成因正是「命令失敗了卻還是印了東西」,
**只看離開碼會再被騙一次**
📌 這一格的實害(總管 2026-08-28 原話):「我今天為了發一則通知,
用了兩種方式蓋 `prod-write-ok` 都無效,一度以為是權限問題。」
**閘不會告訴你門是壞的**,所以人會往錯的方向查(權限、classifier、設定),
而根因在閘自己身上。
🔴 **後果**:那三支閘在雲端**等於純擋**。總管照著閘自己印的指示做,
做幾次都打不開,**而閘不會告訴他門是壞的**。
**為什麼活這麼久**:既有的三支測試(29/16/10 條)**只驗了「擋得住」,
一條都沒驗過「放得開」**。⇒ 這正是「閘的另外一半從來沒被測過」的代價。
**修法**`hooks/lib/mtime.sh` —— 先 `-c %Y`GNU)再 `-f %m`BSD),
**每一步都驗它是不是純數字**(這個 bug 的成因正是「命令失敗了卻還是印了東西」,
只看離開碼會再被騙一次)。
### 附帶:逃生口收斂成一個入口 `scripts/gate-ok`
原本每支閘的門長得都不一樣,而且藏在被擋下的那則訊息裡:
```
touch /tmp/.prod-write-ok
git rev-parse --show-toplevel > /tmp/.main-push-ok
touch /tmp/.solo-ok-<session_id>
```
兩個後果:**記不住**(抄錯一個字門就打不開),以及**沒有穩定形狀可以事先放行**
——`.claude/settings.json` 的 allow 只能逐條完全比對(現場真的寫著
`Bash(touch /tmp/.prod-write-ok)` 這種一行),多一個 `&&`、換一個 session id
就落在規則之外,然後由權限層自己判斷。
`bash "$CLAUDE_PLUGIN_ROOT/scripts/gate-ok" <閘名> [參數]`**一個名字、一種形狀**
一條前綴規則涵蓋全部,以後新增閘不必再動一次設定。
🔴 **它沒有弱化任何一道閘**:蓋的是同一個檔、同一種語意——單次用完即丟、綁 repo、
綁 session、有效期全部沒動。換掉的只有「怎麼蓋」。
`hooks/tests/gate-ok.test.sh` 的 ⑥⑦⑧ 三條就是在守這件事(那三條性質是 08-11、08-12
兩次真的被穿透之後才補上的)。
---
## 還沒關掉的那兩格(不屬於 ISEP)
| 缺口 | 住在哪 |
|---|---|
| 雲端載到的版本追上 ISEP main | `inkstone/ISEP#67` |
| `InkStoneCo/scripts/ticket` 這份舊複本 | `inkstone/InkStoneCo`(真身) |
ISEP 這一側能做的是**讓它們不再是看不見的**:兩件現在都會在 session 開頭被信標點名。
+85 -5
View File
@@ -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 @@
【身份】subagentinkstone/ISEPfeat/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 次正確攔截,而且**紅線寫得越細,命中關鍵字的機率越高 ⇒ 那些閘在懲罰謹慎**。
+41
View File
@@ -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**——名單是資料,閘讀的是這個目錄。
+72 -11
View File
@@ -1,4 +1,4 @@
# 52 支閘,白話盤點表
# 60 支閘,白話盤點表
> 回應 `inkstone/InkStoneCo#40`:「如果加入了,我應該可以白話文看到 hooks 的內容?」
> 這份表就是那個「白話文」——不用點開任何 `.sh` 檔,一行看懂一支閘在管什麼。
@@ -7,16 +7,14 @@
## 一句話結論
`hooks/` 底下有 **53 個 `.sh` 檔**`hooks.json` 實際掛上 **68 條註冊**(同一支閘常被多種情境同時掛上);
`hooks/` 底下有 **53 個 `.sh` 檔**`hooks.json` 實際掛上 **68 條註冊**(同一支閘常被多種情境同時掛上);
`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 之前寫的是沒冒號那版——**照著它跑會拿到一個跟本頁不符的數字**。
> **一份會偷偷過期的盤點表,跟沒有盤點表差不多**——見本頁最後「怎麼跟實況對帳」那段。
@@ -34,6 +32,13 @@
> **兩個事件是同一件事的兩半**:只注入 = 又一個會被忽略的提醒;只查核 = 罰它做一件拿不到資料的事。
> 這兩個數字是併之前當場數出來的,不是從上一版加減推出來的。
> 📌 **`inkstone/ISEP#82`2026-08-28)+1 支、+3 條**`mainline-focus-guard.sh`
> 同時掛在 `Agent` 與 `Task`(派工的兩個入口都要守,只守一個等於沒守),
> 加上 SessionStart 多掛一條 `scripts/mainline refresh`(更新主線的進度/期限,不是閘)。
> **53→54 支、68→71 條,兩個數字是加完之後當場數出來的**(指令見本頁最後一段)。
> ⚠️ 同時修掉一行重複:上一版「一句話結論」那一句被貼了兩遍
> (53/68 那組數字本身是對的,只是印了兩次)。
> 🔴 **`0.6.1`2026-08-27)補回表格漏掉的兩列**`isep-presence-beacon.sh`E 組)、
> `milestone-due-guard.sh`A 組)。**標頭寫 48 支的同時,下面的表格只列得出 46 支**
> ——數字是對的,人話少了兩行。發現的方法與防它再犯的指令,見下面「落差偵測」第二組 `comm`。
@@ -41,11 +46,52 @@
> 📌 **`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` 與雲端 sessiontrigger 那幾支)。
> 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**
> `ls -p scripts | grep -v / | wc -l`)——**同一個病,只是換一欄。**
> 📌 **`inkstone/ISEP#85`2026-08-28)進來一支**`milestone-account-guard.sh`A 組,PreToolUseBash)。
> 53→**54** 支、68→**69** 條,兩個數字都是加完之後**在自己的樹上當場數出來的**
> (指令同上),**不是拿上一版加一**——`system-dev/wiki/mistakes.md` 記著這條:
> 那個數字是 leo 的驗收介面,多報就是假綠。
> 順手修掉一個真的漂移:上一版「一句話結論」那一句**被貼了兩次**(同一行連續出現兩遍),
> 而標頭寫 52、內文寫 53——**同一頁裡三個數字互相矛盾**,正是本頁自己警告過的那種病。
> 另外描述欄的「38 支腳本」是把 `kbdb-live-exam/``lib/``patches/` 三個**目錄**也數進去了;
> 用本頁寫死的那道指令(只數檔案)實數是 **39**。
---
## 怎麼讀這張表
@@ -74,10 +120,11 @@
| `release-tag-guard.sh` | AI 想打版本 tag,但 `plugin.json` 裡寫的版本號跟 tag 對不上就擋下——避免「這裡說 0.2.0、那裡說 0.1.0」各說各話。 | 🛑 擋 |
| `ticket-api-bypass-guard.sh` | AI 想繞過 `scripts/ticket` 工具、直接打 API 新增 Gitea 東西(開新票/milestone/label/PR,會漏掉「新增前先搜過」這道檢查)就擋下——不管是隱式 POST(urllib 傳 data= 沒寫 method)還是小寫 `requests.post(...)`。 | 🛑 擋 |
| `milestone-due-guard.sh` | AI 想開一個新的里程碑(milestone)卻沒填期限就擋下,**填 `9999-01-01` 一樣擋**——那是「沒有期限」穿了一件期限的衣服,盤點時每一格看起來都有值,其實一格都沒有。只管「建里程碑」這個動作,查詢/改別的欄位都放行。 | 🛑 擋 |
| `milestone-account-guard.sh` | AI 想直接打 API 把某個里程碑改成「已關閉」,卻**還沒留下「估多久/花多久/差多少/為什麼差」**就擋下。跟上一列是同一條線的兩端:那支管**開**里程碑要有真的期限,這支管**關**的時候要留下帳。走正門 `scripts/milestone-account close` 就會自己算(數字取自 Gitea 的 `created_at``due_on``closed_at`,不用手填),差超過 ±25% 才要你從**七個固定代號**裡挑一個,而且那個代號要有 Gitea 時間軸撐得住——**不採信自述**。純讀取/建里程碑/改期限/只是談論它/走正門,全部放行;真要手動關就在指令裡加 `milestone-account-ok`(留痕)。 | 🛑 擋 |
| `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
| 閘名 | 對你意味著什麼 | 動作 |
@@ -90,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 組)——它同時守著「下指令」跟「寫檔案」兩種情境,詳見下方「重複掛載」一節。
@@ -109,6 +157,9 @@
| `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`、雲端 sessiontrigger、以及 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 自己規定的合格寫法曾經被這支閘自己擋下)。 | 🛑 擋(+注入) |
## D2. AI 想開口問你問題的當下(PreToolUse / AskUserQuestion
@@ -129,6 +180,9 @@
|---|---|---|
| `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)
@@ -140,7 +194,7 @@
| `factory-idle-guard.sh` | AI 該去派工卻沒派(工頭停工),就擋下要它交出「已經派工的憑證」,不是隨口說一句「我會催」就算數。**過閘有四條路:現在就派工/把票號寫進那句話/寫一行 `⏸ 等:<在等什麼>`/這一輪收尾在動作上。** 2026-08-23inkstone/ISEP#30)修好「引用被當成主張」——貼原始碼、引用它自己的訊息、否認自己有下一步,都不再被咬。 | 🛑 擋 |
| `mainline-idle-guard.sh` | AI **連續 4 個回合一直在查、一直在讀,卻什麼都沒交出去**(沒派工、沒改檔、沒寫回票)就擋一次。補的是上面兩支中間那一格:「沒動作」空手警察管、「說了下一步卻沒派」稼動率警察管,**而「有動作、但就是不說下一步」以前兩支都放行**。這支**一個字都不讀**,只看有沒有派工/產出/工作區變動。響過一次門檻就加倍(4→8→16),不會變成一直在響的警報。 | 🛑 擋(響過就退讓) |
| `pr-verdict-guard.sh` | 收工時清點「還有哪些 PR 沒有結論」——open、**沒有人被指派**、也沒有「要求修改」review 的 PR,就擋一次並**點名是哪幾個**(含開了幾天與網址);已經 merge 但**分支還留著**的也一起點名(票上寫死「併完當場刪掉那條 branch」)。背景:2026-08-27 實查,最久的三個 open PR 躺了兩星期,而當時 46 支閘沒有一支在管 PR——票看起來「已交付」,東西卻沒進 main、沒進版本,**leo 手上永遠不會出現它**。🔴 **擋的是遺忘,不是等待**:指派給誰(或掛 Human)就立刻不再點名它;響過門檻就加倍(1→4→8→16,每個 PR 各自算)。搭配 `scripts/pr-verdict`mergerejectchanges 各一個動作)。 | 🛑 擋(響過就退讓) |
| `countdown-guard.sh` | **每一則回覆都自己說出「這件事已經花了多久/距今天收工線還剩多久」**,而且不必有人記得。一支閘掛兩個事件:你送出訊息時它先把算好的那一行放到 AI 眼前(`UserPromptSubmit`),AI 想收工時它再看這一回合的回覆裡到底有沒有那一行,沒有就擋一次要它補(`Stop`)。**只做前半 = 又一個會被忽略的提醒**,所以兩半綁在一起。已過時間從**這段對話的第一則訊息**算起(=一個 release 的起點);收工線是台北 16:00,過線後四小時內顯示「已超過」,不會把超時悄悄換算掉。子 session、純工具回合、已經提醒過一次的,全部放行。 | 🛑 擋(至多攔一次) |
| `countdown-guard.sh` | **每一則回覆都自己說出「這件事已經花了多久/距今天收工線還剩多久」**,而且不必有人記得。一支閘掛兩個事件:你送出訊息時它先把算好的那一行放到 AI 眼前(`UserPromptSubmit`),AI 想收工時它再看這一回合的回覆裡到底有沒有那一行,沒有就擋一次要它補(`Stop`)。**只做前半 = 又一個會被忽略的提醒**,所以兩半綁在一起。已過時間從**這段對話的第一則訊息**算起(=一個 release 的起點);收工線是台北 16:00,過線後四小時內顯示「已超過」,不會把超時悄悄換算掉。子 session、純工具回合、已經提醒過一次的,全部放行。**同一個注入點還帶第二行 `🎯 現在的主線是哪一個`**`inkstone/ISEP#82`)——那一行沒有另外立閘,因為主線的名字本來就掛在被查核的 ⏱ 那一行上。 | 🛑 擋(至多攔一次) |
| `browser-verify-guard.sh` | 這一輪 AI 宣稱「前端驗過了」,卻沒有真的用瀏覽器工具載入過,就擋下——`curl` 抓到 HTML 不算驗過。 | 🛑 擋 |
| `self-drive-police.sh` | AI 想停下來問你「早就決定過的事」(用固定句型判斷,例如「要不要 X」「下一步做什麼」「這交給你」)就擋下,反問它查過 wiki/查過派工表了沒。 | 🛑 擋 |
| `self-drive-judge.sh` | 跟上面同一件事,但改用小模型(haiku)判斷「換句話說」的請示句——防止 AI 只是把「要不要」改寫成「不確定是否符合期待」就閃過上一支閘。 | 🛑 擋 |
@@ -168,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
| 閘名 | 對你意味著什麼 | 動作 |
@@ -201,11 +255,18 @@ pre-write-guard.sh
pre-write-guard.template.sh
shadow-table-guard.sh
# hooks.json 註冊了、但檔案不存在 → 空(目前沒有這種「指向空氣」的閘)
# hooks.json 註冊了、但檔案不存在 → 應該是空的
$ comm -13 /tmp/fs_hooks.txt /tmp/registered_hooks.txt
(無輸出)
countdown-milestone-refresh.sh ← 假警報:它在 scripts/ 不在 hooks/
```
🔴 **上面那一筆是假警報,不是「指向空氣」的閘**`inkstone/ISEP#63` 之後,
SessionStart 上掛的東西不全是 `hooks/*.sh`——`scripts/countdown-milestone-refresh.sh`
是腳本不是閘。這組指令只比檔名、不看目錄,所以它會出現在這裡。
`inkstone/ISEP#82` 又多掛一條 `scripts/mainline refresh`,那一條沒有 `.sh`
副檔名,所以不會落進這個名單。)**照著跑會看到它,這裡先講清楚,
免得下一個人以為是自己弄壞的。**
🔴 **上面那兩行驗不出「表格漏列」**——它們比的是 `hooks.json`,不是這張表。
「有閘、但這頁沒有它那一行人話」要另外一組指令才看得見,就是下面這組
2026-08-27 就是靠它抓到本頁漏了 `isep-presence-beacon.sh``milestone-due-guard.sh` 兩列):
@@ -261,7 +322,7 @@ A1 缺人話、A2 表格有幽靈、C 全頁有幽靈。
**這次順便抓到的另一個落差**(🕐 **這一段是 2026-08-20 的快照,裡面每個數字都是那天的,不是現況**——
留著是因為它是「兩份文件的數字不會自己跟上」這個病的第一個案例;現況數字看本頁標頭):
`.claude-plugin/plugin.json` 的說明文字寫「42 支機械閘(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` 這批(檔頭日期都是今天)加了之後,兩份文件的數字沒有跟著更新。
+28 -3
View File
@@ -16,6 +16,16 @@
# ① UserPromptSubmit ── 注入:把算好的那一行送到眼前(模型不必自己算、也算不準)
# ② Stop ── 查核:這一回合的回覆裡到底有沒有那一行;沒有就擋一次
#
# ── 注入點的第二行:🎯 現在的主線是哪一個(inkstone/ISEP#82)────────────
# 同一個注入點,兩行,來源不同:⏱ 算時間,🎯 讀 `hooks/lib/mainline.py` 裡被標定的主線。
# 🔴 **Stop 那一半只查 ⏱,沒有跟著擴大成兩個標記**,兩個理由:
# ① ⏱ 那一行本身**已經帶著主線的名字**(`countdown.py` 的第三段),
# 所以主線是跟著一道**已經驗過的閘**到 leo 眼前的,不是靠新增一道會擋人的閘。
# ② 一個回合最多該被擋一次。多要求一個標記 = 多一種被擋的方式,
# 而本 repo 心法第 2 條寫死了:永遠在響的警報等於訓練人忽略警報。
# 主線這條線真正會擋人的地方在派工那一端(`hooks/mainline-focus-guard.sh`),
# 那裡擋的是「派了一張不在主線上的票」——**一個動作**,不是一段文字。
#
# 只有 ① = 又一個會被忽略的提醒(本 repo 心法第 2 條明令不要做這種東西)。
# 只有 ② = 罰它做一件它拿不到資料的事。兩段合起來才是一條走得通的路。
#
@@ -64,12 +74,27 @@ try:
except Exception:
raise SystemExit(0) # 算不出來就不出聲,不要讓它變成噪音
# 第二行:現在的主線是哪一個(inkstone/ISEP#82)。
# 同一個注入點的兩行,來源不同:⏱ 算時間,🎯 讀被標定的主線。
# 算不出來也一定回一句話(「現在沒有主線」),所以這裡不需要 fallback;
# 真的整支壞掉(import 失敗)才留白——一行注入不該讓整個 session 停下來。
try:
ml = cd._mainline().line()
except Exception:
ml = ""
ctx = (
stamp + "\n\n"
"🔴 這一行是 ISEP 的倒數(inkstone/ISEP#63)。**把它原樣放在你這則回覆的最前面**,"
stamp + "\n"
+ (ml + "\n" if ml else "")
+ "\n"
"🔴 第一行是 ISEP 的倒數(inkstone/ISEP#63)。**把它原樣放在你這則回覆的最前面**,"
"數字不要自己重算、也不要改寫措辭——它是機器算的,你算的會漂。\n"
"leo 整天上課、一天只看兩眼:沒有這行,他就不知道『這件事還來得及嗎』。\n"
"收工前這一回合若沒有戴上它,Stop 那一半會擋一次要你補。"
"收工前這一回合若沒有戴上它,Stop 那一半會擋一次要你補。\n\n"
"🎯 第二行是**現在的主線**inkstone/ISEP#82)——leo:「14 個里程碑同時亮著"
"卻不知道該看哪個」。它是你這回合的判準:**手上這件事在不在那條線上**。\n"
"不在,就當場說清楚是『補收』(它本來就該在主線上)還是『跳線』(真的插件事),"
"不要默默做完。主線的名字已經在第一行的倒數裡,戴上倒數=主線也一起到了 leo 眼前。"
)
print(json.dumps({
"hookSpecificOutput": {
+190
View File
@@ -0,0 +1,190 @@
#!/usr/bin/env bash
# 管什麼: 要把一段有份量的東西寫上票,而這個 session **沒派人查過那張票**、
# 那段話裡也**沒有任何走得過去的出處** → 擋一次。
# 為什麼: 未經調查的診斷寫在票上就長得像事實,工人會照著它去驗證而不是去查——
# 等於用權威把那個 repo 真正的專家的判斷力關掉。
# 誤觸時怎麼關: 把出處補上(檔案:行號/commit/comment 號/票號/網址/貼實測輸出),
# 或指令裡加 `evidence-ok`(留痕)。同一 session 同一張票只擋一次。
#
# diagnosis-evidence-guard.sh — 未經調查不寫診斷(PreToolUse: Bashinkstone/ISEP#87
#
# ━━ 來由(leoinkstone/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
+84 -16
View File
@@ -59,6 +59,25 @@
# touch /tmp/.dispatch-ok-<session_id>
# 閘是絆一下逼你重想,不是把人關起來。
#
# ━━ 攔截點不只一個(inkstone/ISEP#882026-08-27 補)━━━━━━━━━━━━━━
# 第一版只掛 `PreToolUse(Task|Agent)` ⇒ 只涵蓋「**新開**一個 subagent」。
# **回覆一個正在跑的 subagent 走的是別的工具,根本不經過那個攔截點。**
# 實況:第一次派工乾乾淨淨只有票號,中途回覆時又把一長串修改要求丟過去,
# **那些話票上一個字都沒有**——那條線被停掉就消失,正是本閘要解的病。
#
# ⇒ 不另造一支平行的閘(票上寫死):同一支閘、同一個判斷函式,
# 只是把所有通往 subagent 的路一次列全。那張表在
# `hooks/lib/dispatch_parse.py` 的 `tool_channel()`——新增一條路加在那裡。
#
# 兩條路只有一個地方不同:
# · `dispatch`TaskAgent)沒有【工單】 ⇒ 放行,那是 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)"
# 子 sessionsubagent)送出的**回覆**不管——它往上送的是交件不是派工
# (見上面「刻意不管的方向」)。**只放掉 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-27inkstone/ISEP#30 comment 4327
🚫 __HEADLINE__leo 2026-08-27inkstone/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 區段——
+66
View File
@@ -49,6 +49,10 @@
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/milestone-due-guard.sh"
},
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/milestone-account-guard.sh"
},
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/reply-identity-guard.sh"
@@ -56,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"
}
]
},
@@ -110,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"
}
]
},
@@ -149,6 +165,14 @@
"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"
},
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/search-is-not-proof-guard.sh"
@@ -192,12 +216,29 @@
"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"
},
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/search-is-not-proof-guard.sh"
}
]
},
{
"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": [
@@ -227,6 +268,18 @@
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/scripts/countdown-milestone-refresh.sh"
},
{
"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"
}
]
}
@@ -369,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"
@@ -378,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": [
+46
View File
@@ -0,0 +1,46 @@
#!/bin/sh
# investigate-first-stamp.sh — PostToolUseAgentTaskSendMessage):
# 記下「這個 session 真的**派人去查過那張票**了」。
#
# 配 diagnosis-evidence-guard.sh 使用(形狀抄自 subagent-first-stamp.shkbdb-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
+18 -2
View File
@@ -42,12 +42,28 @@ case "$ROOT" in
*) SRC="來源不明" ;;
esac
# ── 交給 lib/beacon_report.py 組訊息 ─────────────────────────────────
#
# 🔴 為什麼改成走 python 而不是繼續用 printf 拼 JSONinkstone/ISEP#90):
# 下面要多報三件事(版本落差/舊複本遮蔽正門/退役機制的殘骸),
# 而那三段的內容含引號與換行。**用 shell 內插拼 JSON,一個引號就會把整包 JSON 弄壞,
# 而壞掉的 JSON 讓信標整行消失** —— 那正是本檔要偵測的「零閘狀態」的長相。
# ⇒ 訊息本身可以變複雜,但**序列化這件事不准手拼**。
#
# 🔴 fail-openpython 掛掉、網路不通、什麼都不准讓這行消失。
# 信標消失=leo 會判定這個 session 沒有閘(本檔第 8 行寫的判準),
# 所以組不出加料版就退回原本那一行乾淨的訊息。
MSG="🟢 ISEP v${VER} 已載入(${GATES} 支閘|來源:${SRC}${ROOT}"
printf '%s\n' "{
FALLBACK="{
\"systemMessage\": \"${MSG}\",
\"hookSpecificOutput\": {
\"hookEventName\": \"SessionStart\",
\"additionalContext\": \"${MSG}。這行是 ISEP plugin 自己發的——看得到它就表示閘真的生效了。若某個 session 從頭到尾沒有這行,那個 session 是零閘狀態,先修 plugin 再做事,不要用『跑得動』當證據。\"
}
}"
OUT="$(python3 "$ROOT/hooks/lib/beacon_report.py" "$VER" "$GATES" "$SRC" 2>/dev/null || true)"
case "$OUT" in
\{*) printf '%s\n' "$OUT" ;;
*) printf '%s\n' "$FALLBACK" ;;
esac
+167
View File
@@ -0,0 +1,167 @@
# ── 以下三格是 inkstone/ISEP#90 加的「這一份是不是還有效」自檢 ──────────
# 都**只是報告,不擋任何事**(SessionStart 本來就不該擋),而且每一格拿不到答案就閉嘴。
import json, os, re, subprocess, sys, time, urllib.request
ROOT = os.environ.get("CLAUDE_PLUGIN_ROOT", "")
PROJ = os.environ.get("CLAUDE_PROJECT_DIR") or os.getcwd()
VER = sys.argv[1] if len(sys.argv) > 1 else "未知"
GATES = sys.argv[2] if len(sys.argv) > 2 else "?"
SRC = sys.argv[3] if len(sys.argv) > 3 else "來源不明"
MSG = "🟢 ISEP v%s 已載入(%s 支閘|來源:%s%s" % (VER, GATES, SRC, ROOT)
notes = []
# ══ ① 這一份跟 ISEP main 是不是同一版 ═══════════════════════════════════
#
# 🔴 為什麼要自己查(inkstone/ISEP#902026-08-27 實查):
# ISEP main 的 plugin.json → 0.9.0
# 雲端實際載入 → 0.3.9 ← 中間差 7 個 release
# 而信標**照樣是綠的**——它只證明「有一份 plugin 載入了」,不證明「載入的是哪一份」。
# 後果不是抽象的:0.3.9 裡還活著兩支已經在 v0.9.0 整支刪掉的 hook
# 於是 `.claude/pending-verification/` 在雲端**被清掉之後又長回來**。
# ⇒ 一個看不見的落差,會讓「已經刪掉的機制」在別人的工作區裡復活。
#
# 匿名讀(不帶任何憑證)⇒ D20 判準下屬於「讀」,不需要開閘、不計次。
# 快取 6 小時、逾時 6 秒、任何失敗一律閉嘴——信標不能因為網路而變吵或變慢。
def main_version():
cache = os.path.join(os.environ.get("ISEP_BEACON_CACHE_DIR", "/tmp"), ".isep-main-version")
try:
if time.time() - os.path.getmtime(cache) < 6 * 3600:
v = open(cache, encoding="utf-8").read().strip()
return v or None
except Exception:
pass
url = os.environ.get("ISEP_MAIN_MANIFEST_URL",
"https://git.uncle6.me/inkstone/ISEP/raw/branch/main/.claude-plugin/plugin.json")
try:
with urllib.request.urlopen(url, timeout=6) as r:
v = (json.loads(r.read().decode("utf-8")) or {}).get("version") or ""
except Exception:
v = ""
try:
open(cache, "w", encoding="utf-8").write(v)
except Exception:
pass
return v or None
def vtuple(v):
return tuple(int(x) for x in re.findall(r"\d+", v)[:3]) or (0,)
MAIN = main_version() if os.environ.get("ISEP_BEACON_SKIP_NET") != "1" else os.environ.get("ISEP_FAKE_MAIN_VERSION")
if MAIN and VER != "未知" and MAIN != VER:
if vtuple(MAIN) > vtuple(VER):
notes.append(
"🔴 **這一份落後 ISEP main**(載入 %s main %s)——你現在跑的不是最新那組閘,"
"而且**已經刪掉的機制可能還活著**(0.3.9 就是這樣讓 .claude/pending-verification/ 復活的)。"
"修:本機 `claude plugin update isep@inkstone`;雲端要去動一下 Environment 的 setup script "
"內容逼它重拍快照(快取約 7 天)。追蹤票 inkstone/ISEP#67。" % (VER, MAIN))
else:
notes.append("️ 這一份比 ISEP main 新(載入 %s main %s)——沒發版的改動只在這台機器上。" % (VER, MAIN))
# ══ ② 專案裡有沒有 ISEP 腳本的舊複本在遮蔽正門 ══════════════════════════
#
# 🔴 實例(inkstone/ISEP#90 ②):`InkStoneCo/scripts/ticket` 是 ISEP `scripts/ticket`
# 的**舊複本**,它的取 token 邏輯還停在「只認名叫 gitea 的 remote」,
# 而 bootstrap.sh 在雲端把 Gitea 設成 `origin`
# ⇒ 在雲端跑 `scripts/ticket` 一律死在「拿不到 gitea token」
# ⇒ 人只好繞過正門直接打 API——而那正是 ticket-api-bypass-guard.sh 在防的事。
# **一道閘把人逼去走它自己禁止的那條路,那道閘就是在製造違規。**
#
# 判準不是「檔名一樣」,是「檔名一樣**而內容不同**」——同步過的複本不吵。
def shadow_copies():
out = []
src = os.path.join(ROOT, "scripts")
if not os.path.isdir(src):
return out
roots = [PROJ, os.path.join(PROJ, "InkStoneCo")]
for name in sorted(os.listdir(src)):
a = os.path.join(src, name)
if not os.path.isfile(a):
continue
try:
ab = open(a, "rb").read()
except Exception:
continue
for base in roots:
b = os.path.join(base, "scripts", name)
if os.path.realpath(b) == os.path.realpath(a):
continue
if not os.path.isfile(b):
continue
try:
if open(b, "rb").read() != ab:
out.append(os.path.relpath(b, PROJ))
except Exception:
pass
return out
sh = shadow_copies()
if sh:
notes.append(
"🟡 **專案裡有 ISEP 腳本的舊複本**,而它們排在 plugin 前面被叫到:%s"
"兩份必然漂移,漂移的那份會安靜地騙人——`InkStoneCo/scripts/ticket` 就是這樣"
"在雲端一律死在「拿不到 gitea token」。要嘛刪掉複本改叫 "
"`\"$CLAUDE_PLUGIN_ROOT\"/scripts/<名字>`,要嘛把複本同步回 ISEP。" % "".join(sh))
# ══ ③ 工作區有沒有「已退役機制」留下的產物 ══════════════════════════════
#
# 判準是機械的、而且會自己長大:**plugin 自己的原始碼裡有沒有任何一個字提到這個目錄**。
# 提到了 ⇒ 它是現行機制的產物,正常。
# 一個字都沒提到 ⇒ 產生它的東西已經不在這一份 ISEP 裡了 ⇒ 它是殘骸。
# 刻意**不用關鍵字黑名單**leo 2026-08-17 已證明那條路 8 次誤攔、0 次正確攔截):
# 這裡問的是「plugin 現在還認不認得它」,不是「這個名字看起來像不像壞東西」。
NATIVE = {"hooks", "commands", "skills", "agents", "plugins", "wiki", "cloud-shell",
"projects", "statsig", "shell-snapshots", "todos", "ide", "local", "isep"}
def orphan_artifacts():
out = []
for base in [PROJ, os.path.join(PROJ, "InkStoneCo")]:
d = os.path.join(base, ".claude")
if not os.path.isdir(d):
continue
for name in sorted(os.listdir(d)):
p = os.path.join(d, name)
if not os.path.isdir(p) or name in NATIVE or name.startswith("."):
continue
# 🔴 只搜「會產生東西的那些檔」(hooks/scripts),不搜 docs
# docs 提到一個名字**不會讓那個目錄長出來**,但會讓這一格閉嘴。
# 🔴 也要把本檔排除掉:本檔的註解裡就寫著 `pending-verification` 當例子,
# 第一次跑就因此漏報了真正存在的那一個——**自己提到自己=這格靜音**。
try:
hit = False
for sub in ("hooks", "scripts"):
# 🔴 變數名不要跟外層的 `d`(.claude 那個目錄)撞——撞了會把
# 外層迴圈的基準目錄換掉,第二個名字之後全部被靜靜跳過。
# 第一版就是這樣寫的,實測結果:真的存在的 `verified-claims`
# 一聲不吭地消失了。**假綠不是漏寫檢查,是檢查跑在錯的對象上。**
sd = os.path.join(ROOT, sub)
if not os.path.isdir(sd):
continue
if subprocess.run(["grep", "-rqlF", "--exclude", os.path.basename(__file__),
"--", name, sd],
capture_output=True, timeout=20).returncode == 0:
hit = True
break
except Exception:
hit = True # 問不出來就當它有效,不亂報
if not hit:
out.append(os.path.relpath(p, PROJ))
return out
orph = orphan_artifacts()
if orph:
notes.append(
"🟡 **工作區有已退役機制的產物**%s。這一份 ISEP 裡沒有任何東西提到它們"
"(v0.9.0 已整支刪除產生它的 hook),所以它們是殘骸——"
"**它們還在長,就表示這台機器跑的是舊版**(見上面那格)。確認之後刪掉。" % "".join(orph))
CONTEXT = ("%s。這行是 ISEP plugin 自己發的——看得到它就表示閘真的生效了。"
"若某個 session 從頭到尾沒有這行,那個 session 是零閘狀態,"
"先修 plugin 再做事,不要用『跑得動』當證據。" % MSG)
if notes:
MSG = MSG + "\n" + "\n".join(notes)
CONTEXT = CONTEXT + "\n\n" + "\n".join(notes)
print(json.dumps({"systemMessage": MSG,
"hookSpecificOutput": {"hookEventName": "SessionStart",
"additionalContext": CONTEXT}},
ensure_ascii=False))
+46 -6
View File
@@ -27,6 +27,13 @@ leo 2026-08-28 07:40:「今天我整天上課……**下午四點左右**希
從快取檔讀,**這支從不打網路**UserPromptSubmit 走在每一則訊息的關鍵路徑上,
在那裡打網路 = 每一句話都先等一次 HTTP。快取由 SessionStart 那支負責更新
`scripts/countdown-milestone-refresh.sh`),拿不到就整段不顯示,不編數字。
🔴 **來源有兩個,優先序寫死(inkstone/ISEP#82**
① **被標定的主線**`hooks/lib/mainline.py``scripts/mainline set` 寫的)—— 優先
② 期限最近的那一個(`milestone-due` 快取,`countdown-milestone-refresh.sh` 寫的)
② 是**猜**出來的(「最近到期的大概就是在做的那個」),而 ISEP#82 的整件事就是
「那個 active milestone」在 14 個裡沒有指涉對象。⇒ 一旦有人真的標了主線,
就不准再用猜的那個蓋過它。兩個都沒有 ⇒ 整段不顯示。
"""
import json
import os
@@ -137,10 +144,40 @@ def deadline(now: datetime) -> datetime:
return d
def milestone() -> "tuple[str, datetime] | None":
"""主線 milestone 的期限。快取檔一行:`YYYY-MM-DD|<名稱>`。沒有就沒有。"""
def _mainline():
"""借 `hooks/lib/mainline.py` 讀「被標定的主線」。拿不到就當沒有(fail-open)。
刻意用檔案路徑載入而不是 `import mainline`:本檔自己也是被
`spec_from_file_location` 載進去的,`sys.path` 裡沒有 `hooks/lib`。
"""
try:
import importlib.util
p = os.path.join(os.path.dirname(os.path.abspath(__file__)), "mainline.py")
spec = importlib.util.spec_from_file_location("isep_mainline", p)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
except Exception:
return None
def milestone() -> "tuple[str, datetime | None] | None":
"""主線 milestone 的期限。優先讀被標定的主線,其次讀「期限最近」的快取。
快取檔一行:`YYYY-MM-DD|<名稱>`。兩個都沒有就沒有(回 None,整段不顯示)。
期限可能是 None(被標定的主線沒設期限)——那時只說名字,不編一個日期。
"""
raw = os.environ.get("ISEP_MILESTONE_DUE", "").strip()
name = os.environ.get("ISEP_MILESTONE_NAME", "").strip()
if not raw:
mod = _mainline()
if mod is not None:
try:
seg = mod.countdown_segment()
except Exception:
seg = None
if seg:
return seg
if not raw:
p = os.path.join(state_dir(), "milestone-due")
try:
@@ -184,11 +221,14 @@ def line(session_id: str, transcript_path: str) -> str:
ms = milestone()
if ms:
name, due = ms
gap = due - now
if gap.total_seconds() >= 0:
parts.append("主線 %s%s" % (name, human(gap)))
if due is None:
parts.append("主線 %s(沒設期限)" % name)
else:
parts.append("🔴 主線 %s 已逾期 %s" % (name, human(gap)))
gap = due - now
if gap.total_seconds() >= 0:
parts.append("主線 %s%s" % (name, human(gap)))
else:
parts.append("🔴 主線 %s 已逾期 %s" % (name, human(gap)))
return "".join(parts)
+169 -10
View File
@@ -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`TaskAgent)沒有【工單】 ⇒ **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))
+123
View File
@@ -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`)。
━━ 規則本身(leoinkstone/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"
+248
View File
@@ -0,0 +1,248 @@
#!/usr/bin/env python3
"""hooks/lib/mainline.py —— 「現在的主線是哪一個」這件事的**唯一存放處**。
這是 helper,不是閘(inkstone/ISEP#40 S7`lib/` 底下的東西不算一支手寫的閘)。
三個呼叫者共用它,所以「主線是誰」的答案只有一份:
· `scripts/mainline` —— 人問「現在的主線是哪一個」
· `hooks/lib/countdown.py` —— 每一則回覆眼前那一行的第二段
· `hooks/mainline-focus-guard.sh` —— 派了一張不屬於主線的票就攔一次
── 為什麼要有這個檔(inkstone/ISEP#82)──────────────────────────────
2026-08-27 實查:`GET /repos/inkstone/*/milestones?state=open` 回 **14 個**
其中「Mira 現代化」這個名字同時活在 5 個 repo,還有 5 個已經逾期。
SOP 說「有 active milestone 時只做該 milestone 的事」——但**「那個」在現場沒有指涉對象**。
leo 的原話是「**14 個里程碑同時亮著卻不知道該看哪個**」。
⇒ 要解的不是「milestone 太多」(跨 repo 同名是既有做法,v0.6.0 §4SOP S7-4),
要解的是「**這一刻我在做哪一條**」沒有答案。
⇒ 所以這個檔的形狀就是答案的形狀:**一個檔,放得下一條**。
沒有清單、沒有優先序、沒有「前三名」——被覆蓋掉的那條就不再是主線。
問「現在的主線是哪一個」永遠只會拿到 0 或 1 個答案,不會拿到 14 個。
── 主線是一個「名字」,錨在一個具體的 milestone 上 ──────────────────
Gitea 的 milestone 不能跨 repo,而同一條線常常同時開在 5 個 repo(票上實查)。
所以:
· **錨**anchor)= `owner/repo#<milestone id>`:期限、進度、目標宣告都讀它
· **成員判定** 票所屬 milestone 的**標題**與主線標題相同(不分 repo)
⇒ 「同名跨 repo」照舊能用,而「現在在做哪一條」仍然只有一個答案。
── 這支從不打網路 ────────────────────────────────────────────────
它被 `UserPromptSubmit` 用(每一則訊息的關鍵路徑)。在那裡打 HTTP = 每講一句話
都先等一次網路。網路那一半全部在 `scripts/mainline`setrefreshadopt),
本支只讀寫 state 檔。讀不到 ⇒ 回「沒有主線」,**不編一條出來**。
"""
import json
import os
from datetime import datetime, timedelta, timezone
TAIPEI = timezone(timedelta(hours=8))
MARKER = "🎯"
STATE_NAME = "mainline.json"
JUMP_LOG = "mainline-jumps.jsonl"
def state_dir() -> str:
"""與倒數共用同一個狀態目錄——兩行字是同一個注入點的兩半,狀態不該分家。"""
d = os.environ.get("ISEP_COUNTDOWN_STATE_DIR", "").strip()
if not d:
d = os.path.join(os.path.expanduser("~"), ".claude", "isep-countdown")
try:
os.makedirs(d, exist_ok=True)
except Exception:
pass
return d
def path() -> str:
return os.path.join(state_dir(), STATE_NAME)
def load():
"""讀主線。沒有/壞掉 ⇒ None(=現在沒有主線),**不是例外**。
「沒有主線」是一個正常狀態,不是故障:ISEP#82 驗收第 4 條寫死了
「沒有任何主線時不能整組壞掉」。所以這支從不 raise。
"""
try:
with open(path()) as f:
d = json.load(f)
except Exception:
return None
if not isinstance(d, dict) or not d.get("title"):
return None
return d
def save(d) -> bool:
try:
with open(path(), "w") as f:
json.dump(d, f, ensure_ascii=False, indent=2)
return True
except Exception:
return False
def clear() -> bool:
try:
os.remove(path())
return True
except FileNotFoundError:
return True
except Exception:
return False
def parse_ts(raw):
if not isinstance(raw, str) or not raw.strip():
return None
s = raw.strip().replace("Z", "+00:00")
try:
dt = datetime.fromisoformat(s)
except Exception:
try:
dt = datetime.fromisoformat(s + "T23:59:59+08:00")
except Exception:
return None
return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc)
def due_of(ms):
return parse_ts((ms or {}).get("due_on") or "")
def norm(s) -> str:
"""比對標題用。只做「看不見的差異」的正規化——大小寫與空白。
🔴 刻意不做同義詞、不做模糊比對:主線是**被指定的**,不是被猜出來的。
猜錯的方向是「把不屬於主線的票放行」,那正是這張票要擋的事。
"""
return "".join((s or "").split()).lower()
def ref_of(ms) -> str:
if not ms:
return ""
return "%s/%s#%s" % (ms.get("owner") or "?", ms.get("repo") or "?", ms.get("id") or "?")
def members(ms):
return set(ms.get("members") or []) if ms else set()
def belongs(ticket_ref, ms, ticket_milestone_title=None):
"""這張票屬不屬於主線?回 True/FalseNone。
None = **不知道**(快取裡沒有這張票,而呼叫端也沒查到它的 milestone)。
🔴 「不知道」不等於「不屬於」——這條分界就是誤攔與否的分水嶺。
呼叫的閘拿到 None 時該放行(本 repo 心法第 2 條:誤攔比漏擋更該修)。
"""
if not ms:
return True # 沒有主線 ⇒ 沒有「不屬於主線」這回事
ref = (ticket_ref or "").strip()
if ref and ref in members(ms):
return True
if ticket_milestone_title is not None:
return norm(ticket_milestone_title) == norm(ms.get("title"))
return None
def fixture_titles():
"""測試用:把「去 Gitea 問這張票掛在哪」換成一份現成的答案。
`ISEP_MAINLINE_FIXTURE` 指向一個 JSON`{"owner/repo#N": "milestone 標題"}`。
設了它,閘就不打網路,直接拿這份當事實。
🔴 這與 `ISEP_COUNTDOWN_NOW`(把時鐘定住)是同一個性質的東西:
**把外界定住,讓閘能離線、可重複地被測**。正式環境不會設它;
沒設 ⇒ 這支回 None,判決完全走真實的 Gitea。
"""
p = os.environ.get("ISEP_MAINLINE_FIXTURE", "").strip()
if not p:
return None
try:
with open(p) as f:
d = json.load(f)
return d if isinstance(d, dict) else None
except Exception:
return None
def record_jump(ticket_ref, ms):
"""把「派了一張不在主線上的票」記一筆。**只留痕,不判罪。**
跳線本身常常是對的(補收、真的插件事)。留痕是為了讓「這條線今天被岔開幾次」
事後數得出來——沒有紀錄的話,跳線與專注在機器上長得一模一樣。
"""
try:
with open(os.path.join(state_dir(), JUMP_LOG), "a") as f:
f.write(json.dumps({
"at": datetime.now(timezone.utc).isoformat(),
"ticket": ticket_ref,
"mainline": ref_of(ms),
"mainline_title": (ms or {}).get("title"),
}, ensure_ascii=False) + "\n")
except Exception:
pass
def _human(delta: timedelta) -> str:
days = int(abs(delta).total_seconds()) // 86400
if days >= 1:
return "%d" % days
hours = int(abs(delta).total_seconds()) // 3600
return "%d 小時" % hours
def due_phrase(ms, now=None) -> str:
"""期限那一段。沒設期限就說沒設——不准編一個日期出來。"""
dt = due_of(ms)
if not dt:
return "沒設期限"
now = now or datetime.now(timezone.utc)
day = dt.astimezone(TAIPEI).strftime("%Y-%m-%d")
gap = dt - now
if gap.total_seconds() >= 0:
return "%s(剩 %s" % (day, _human(gap))
return "%s(🔴 已逾期 %s" % (day, _human(gap))
def progress(ms):
o = int((ms or {}).get("open_issues") or 0)
c = int((ms or {}).get("closed_issues") or 0)
total = o + c
pct = int(round(c * 100.0 / total)) if total else 0
return c, total, pct
def line(now=None) -> str:
"""注入用的那一行(倒數那一行的第二行)。**沒有主線時也要回一句話。**
ISEP#82 驗收第 4 條:沒有主線不能整組壞掉,要講得出「現在沒有主線」。
⇒ 這支永遠回一個非空字串,永遠不 raise。
"""
ms = load()
if not ms:
return ("%s 現在沒有主線——沒有任何 milestone 被標成「現在在做的那一條」。"
"要標:`scripts/mainline set <owner/repo#milestone_id>`"
"(先看有哪些:`scripts/mainline list`" % MARKER)
c, total, pct = progress(ms)
head = "%s 主線:%s%s」|%s%d%d 張已關(%d%%" % (
MARKER, ref_of(ms), ms.get("title") or "?", due_phrase(ms, now), c, total, pct)
goal = (ms.get("description") or "").strip().splitlines()
if goal:
head += "\n 目標:" + goal[0].strip()[:120]
return head
def countdown_segment(now=None):
"""給 `countdown.py` 用的 (名稱, 期限) ——讓 ⏱ 那一行說得出主線是誰。
為什麼要塞進 ⏱ 那一行:⏱ 那一行**已經有一支 Stop 閘在查它有沒有被戴上**
inkstone/ISEP#63)。主線的名字掛在那一行上,就跟著那道已經驗過的閘一起
到 leo 眼前——不必為了同一件事再立第二道會擋人的閘。
"""
ms = load()
if not ms:
return None
return (ms.get("title") or "主線", due_of(ms))
+57
View File
@@ -0,0 +1,57 @@
# hooks/lib/mtime.sh — 「這個檔幾點被動的」,跨 macOS/Linux 都問得出來。
# 不是獨立掛的閘(沒進 hooks.json),給那幾支用 /tmp 戳記的閘 `source` 用。
#
# ══ 為什麼要有這支(inkstone/ISEP#90 ④,2026-08-28 實查)═══════════════
#
# 三支閘(prod-write-guard、main-and-prod-push-guard、stage-before-prod-guard
# 的戳記檢查都寫成這一行:
#
# MT=$(stat -f %m "$STAMP" 2>/dev/null || stat -c %Y "$STAMP" 2>/dev/null || echo 0)
#
# 在 macOSBSD stat)上它是對的:`-f %m` 就是 mtime。
# 在 **LinuxGNU coreutils)上 `-f` 是「顯示檔案系統資訊」**,而且它
# **一邊回非零、一邊往 stdout 吐一整段檔案系統的區塊**:
#
# $ stat -f %m /tmp/.probe
# stat: cannot read file system information for '%m': No such file or directory ← stderr
# File: "/tmp/.probe" ← stdout
# ID: 0 Namelen: 255 Type: ext2/ext3
# …
#
# ⇒ `2>/dev/null` 把錯誤訊息吃掉、`||` 接著跑 `stat -c %Y` 把正確的秒數**接在那堆垃圾後面**
# ⇒ `MT` 變成「一段多行文字+一個數字」
# ⇒ 閘下一行的 `case "$NOW$MT" in *[!0-9]*) return 1` 一定命中
# ⇒ **在 Linux 上,那三支閘的戳記永遠不會被接受。**
#
# 🔴 這件事的後果不是「少一個便利功能」:
# 那三支閘都是「擋下來,但**總管看過就可以放行**」的設計。
# 放行的那道門在雲端(Linux)打不開 ⇒ **它們在雲端等於純擋**,
# 總管照著閘自己印的指示做,做幾次都打不開,而閘不會告訴他門是壞的。
# inkstone/InkStoneCo#99 記的「連續四次蓋不出戳記」就是這個形狀。
#
# ── 再往下一層:`-f` 根本不吃格式參數,所以連離開碼都不可靠 ────────────
#
# 2026-08-28 兩個人量這一格,一個量到 `exit=0`、一個量到 `exit=1`。**兩個都是真的**
# 而分歧本身就是最後一塊拼圖:**GNU 的 `-f` 是 `--file-system`,布林旗標、不接格式字串**
# ⇒ `%m` 被當成**另一個檔名運算元** ⇒ 離開碼取決於「cwd 裡有沒有一個叫 `%m` 的檔」:
#
# A. 沒有(一般情況) → exit 1 ⇒ `||` **會**跑 ⇒ 正確的秒數接在垃圾後面
# B. 剛好有 → exit 0 ⇒ `||` **不會**跑 ⇒ 整包連一個數字都沒有
#
# 兩種情況下閘的結果一樣:`MT` 都不是純數字,戳記都作廢。(實測 GNU coreutils 9.4
#
# 🔴 **這才是該記住的教訓**:舊寫法的 `||` fallback 救不了,
# **不是因為它沒跑,而是因為「跑不跑」根本不由這支腳本決定**
# ——它由「cwd 裡有沒有某個檔名」決定。
# 一個**行為取決於 cwd 裡有沒有某個檔**的判斷式,不管跑不跑都是壞的。
#
# 🔴 所以修法不是「把順序反過來」而已:改成先 `-c %Y`(GNU)再 `-f %m`BSD),
# 而且**每一步都驗它是不是純數字**——因為這個 bug 的成因正是
# 「命令失敗了卻還是印了東西出來」,只看離開碼會再被騙一次。
file_mtime() {
_fm=$(stat -c %Y "$1" 2>/dev/null || true)
case "${_fm:-}" in ''|*[!0-9]*) _fm=$(stat -f %m "$1" 2>/dev/null || true) ;; esac
case "${_fm:-}" in ''|*[!0-9]*) _fm=$(python3 -c 'import os,sys; print(int(os.path.getmtime(sys.argv[1])))' "$1" 2>/dev/null || true) ;; esac
case "${_fm:-}" in ''|*[!0-9]*) _fm=0 ;; esac
printf '%s' "$_fm"
}
+111
View File
@@ -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` 的身份欄)。
━━ 規則本身(leoinkstone/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", ""))
+16 -2
View File
@@ -74,6 +74,18 @@ esac
# ⇒ 沒有可靠的身分辨識時,改成**正向確認**:推 main 前要有一枚新鮮的戳記。
# subagent 當然造得出那枚戳記——但它得刻意繞過一段明講「不要這樣做」的訊息。
# **閘擋的是無心,不是惡意。** 而現在這個版本連無心都擋不了(它誰都擋)。
# 🔴 戳記的 mtime 一律走 lib/mtime.sh:舊寫法(`stat -f %m || stat -c %Y`)在 Linux 上
# **永遠算不出數字**(GNU 的 `-f` 是「檔案系統資訊」,它一邊回非零一邊吐一整段文字,
# 於是後面接上的秒數被那段垃圾污染)⇒ 這支閘給總管的逃生口在雲端整個焊死,
# 而閘不會告訴他門是壞的(inkstone/ISEP#90 ④,全文見 hooks/lib/mtime.sh)。
# lib 不在時就地補一份同順序的定義——這道門不能因為少一個檔案就再關上一次。
. "$(dirname "$0")/lib/mtime.sh" 2>/dev/null || file_mtime() {
_fm=$(stat -c %Y "$1" 2>/dev/null || true)
case "${_fm:-}" in ''|*[!0-9]*) _fm=$(stat -f %m "$1" 2>/dev/null || true) ;; esac
case "${_fm:-}" in ''|*[!0-9]*) _fm=0 ;; esac
printf '%s' "$_fm"
}
STAMP="/tmp/.main-push-ok"
# 🔴 2026-08-11 這道閘被自己的戳記穿透了,修法寫在這裡:
@@ -87,7 +99,7 @@ STAMP="/tmp/.main-push-ok"
stamp_ok() {
[ -f "$STAMP" ] || return 1
NOW=$(date +%s 2>/dev/null || echo 0)
MT=$(stat -f %m "$STAMP" 2>/dev/null || stat -c %Y "$STAMP" 2>/dev/null || echo 0)
MT=$(file_mtime "$STAMP")
case "$NOW$MT" in *[!0-9]*) return 1 ;; esac
[ "$NOW" -gt 0 ] && [ "$MT" -gt 0 ] || return 1
[ $((NOW - MT)) -lt 900 ] || return 1
@@ -261,7 +273,9 @@ leo 原話:
逐筆看過那些 commit`git log --oneline gitea/main..<branch>`、`git diff --stat`),
確定它們該進 main,再:
git rev-parse --show-toplevel > /tmp/.main-push-ok && <你的 git push 指令>
bash "$CLAUDE_PLUGIN_ROOT/scripts/gate-ok" main-push <那個 repo 的路徑>
<你的 git push 指令>
(等價的手打法:git rev-parse --show-toplevel > /tmp/.main-push-ok
戳記 **綁這個 repo、只能用一次、15 分鐘失效**——它代表「**這一次、這個 repo,我看過了**」。
+176
View File
@@ -0,0 +1,176 @@
#!/usr/bin/env bash
# mainline-focus-guard.sh — 派了一張不在主線上的票,就當場問清楚(inkstone/ISEP#82
#
# ── 補的是哪一格 ────────────────────────────────────────────────
# leo 2026-08-27:「**14 個里程碑同時亮著卻不知道該看哪個**」「今天……分心」。
# SOP 說「有 active milestone 時只做該 milestone 的事」——但現場 14 個 open milestone
# 「那個」沒有指涉對象 ⇒ **那條規則在機器上等於不存在**(同款第 N 次:
# history-firstKBDB-firststage-first 全是「規則被讀到了,卻沒有機制驗證有沒有照做」)。
#
# ISEP#82 把「現在在做哪一條」變成一個查得到的事實(`scripts/mainline`),
# 本閘是那個事實的**用處**:派工的票號不在主線上,就當場說是補收還是跳線,
# 不要默默做完——分心不是「做了壞事」,是「岔開了而沒有人注意到」。
#
# ── 🔴 這不是關鍵字黑名單(leo 2026-08-17 已證偽那條路)────────────────
# 「自然語言的變體是無限的,blacklist 永遠追不完……封路哲學之所以有效,
# 是因為它封的是**動作**——動作有限且可枚舉,文字不是。」
# 當日實測:文字層的閘 8 次誤攔、0 次正確攔截。
#
# 本閘從頭到尾**不讀派工單的任何一個字義**。它只做兩個查表:
# ① 派工單的【工單】欄位是哪一張票(`hooks/lib/dispatch_parse.py` 解結構,不判語意)
# ② 那張票所屬的 milestone 標題,等不等於被標定的主線標題(字串相等,不是模糊比對)
# 措辭怎麼寫都不影響判決;改的只有「這張票掛在哪」這個 Gitea 上的事實。
#
# ── 誤攔的出口全部先關掉(誤攔比漏擋更該修)──────────────────────────
# · **沒有主線 ⇒ 一律放行**(ISEP#82 驗收第 4 條:沒有主線不能整組壞掉)
# · 派工單裡沒有票號 ⇒ 放行(那是 no-ticket-no-dispatch.sh 的地盤,
# 兩支閘同時開口,收工方會拿到兩份打架的教學)
# · 多張票只要**有一張**在主線上 ⇒ 放行(那次派工有在推主線)
# · 快取裡查不到,就**打一次網路去問那張票**;問不到 ⇒ 放行
# (🔴「讀不到」不等於「不屬於」——把這兩件事講成同一句是最貴的錯)
# · 子 session 不查:主線是**派工者**手上的判準(CLAUDE.md:「critical path 是
# 你用來盯 subagent 的判準」),不是收工方要背的東西
# · **同一張票只擋一次**:擋完就落一個戳記,重送即放行 ⇒ 不會鬼打牆
# · 內部出錯一律放行——這是節拍器不是安全閘,它壞掉不該讓派工停擺
set -uo pipefail
INPUT="$(cat)"
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# 子 session 不查(見上)
[ "${CLAUDE_CODE_CHILD_SESSION:-}" = "1" ] && exit 0
VERDICT=$(printf '%s' "$INPUT" | ISEP_HOOKS_DIR="$HERE" python3 -c '
import importlib.machinery, importlib.util, json, os, sys, urllib.request
HERE = os.environ["ISEP_HOOKS_DIR"]
def load(name, path):
ldr = importlib.machinery.SourceFileLoader(name, path)
spec = importlib.util.spec_from_file_location(name, path, loader=ldr)
mod = importlib.util.module_from_spec(spec)
ldr.exec_module(mod)
return mod
def bail(why):
print("SKIP:" + why); raise SystemExit
try:
d = json.load(sys.stdin)
except Exception:
bail("bad-json")
prompt = (d.get("tool_input") or {}).get("prompt") or ""
if not prompt.strip():
bail("no-prompt")
try:
dp = load("isep_dispatch_parse", os.path.join(HERE, "lib", "dispatch_parse.py"))
ML = load("isep_mainline", os.path.join(HERE, "lib", "mainline.py"))
except Exception:
bail("lib-unavailable")
try:
refs = dp.parse_dispatch(prompt).get("refs") or []
except Exception:
bail("parse-failed")
if not refs:
bail("no-ticket") # no-ticket-no-dispatch 的地盤
ms = ML.load()
if not ms:
bail("no-mainline") # 沒有主線 ⇒ 沒有「不屬於主線」這回事
wanted = ["%s/%s#%d" % (r["owner"], r["repo"], r["num"]) for r in refs]
# ① 離線先問快取:有一張在主線上就放行
unknown = []
for ref in wanted:
v = ML.belongs(ref, ms)
if v is True:
print("SKIP:on-mainline"); raise SystemExit
if v is None:
unknown.append(ref)
# ② 快取查不到的,去問那張票掛在哪。問不到 ⇒ 放行。
if unknown:
fx = ML.fixture_titles()
if fx is not None: # 測試把外界定住(見 mainline.fixture_titles
learned = [r for r in unknown
if ML.norm(fx.get(r, "")) == ML.norm(ms.get("title"))]
if learned:
print("SKIP:on-mainline-fixture"); raise SystemExit
if not any(r in fx for r in unknown):
bail("unreachable") # fixture 沒收錄 ⇒ 等同問不到
else:
try:
T = load("isep_ticket", os.path.join(HERE, "..", "scripts", "ticket"))
tok = T.token()
host = T.HOST
# 🔴 BaseException 不是筆誤:`scripts/ticket` 拿不到憑證時是 sys.exitSystemExit),
# 那不是 Exception 的子類。只接 Exception 的話,這支會整個死掉而不是放行。
except BaseException:
bail("no-token") # 讀不到 ≠ 不屬於
reached = False
learned = []
for ref in unknown:
try:
owner, rest = ref.split("/", 1)
repo, num = rest.split("#", 1)
req = urllib.request.Request(
"%s/api/v1/repos/%s/%s/issues/%s" % (host, owner, repo, num),
headers={"Authorization": "token %s" % tok})
it = json.load(urllib.request.urlopen(req, timeout=6))
reached = True
except Exception:
continue
title = ((it or {}).get("milestone") or {}).get("title") or ""
if ML.norm(title) == ML.norm(ms.get("title")):
learned.append(ref)
if learned:
ms["members"] = sorted(set(ms.get("members") or []) | set(learned))
ML.save(ms)
print("SKIP:on-mainline-fresh"); raise SystemExit
if not reached:
bail("unreachable") # 一張都問不到 ⇒ 放行
# ③ 確定都不在主線上 —— 同一組票只擋一次
import hashlib
key = hashlib.sha1(("|".join(sorted(wanted)) + "@" + ML.ref_of(ms)).encode()).hexdigest()[:16]
stamp = os.path.join(ML.state_dir(), "jump-ok-" + key)
if os.path.exists(stamp):
print("SKIP:already-asked"); raise SystemExit
try:
open(stamp, "w").close()
except Exception:
pass
ML.record_jump(",".join(wanted), ms)
print("BLOCK:%s\t%s\t%s" % (",".join(wanted), ML.ref_of(ms), ms.get("title") or "?"))
' 2>/dev/null || echo "SKIP:crash")
case "$VERDICT" in
BLOCK:*)
PAYLOAD="${VERDICT#BLOCK:}"
TICKETS=$(printf '%s' "$PAYLOAD" | cut -f1)
MREF=$(printf '%s' "$PAYLOAD" | cut -f2)
MTITLE=$(printf '%s' "$PAYLOAD" | cut -f3)
FIRST=$(printf '%s' "$TICKETS" | cut -d, -f1)
{
printf '🎯 這張票不在主線上(inkstone/ISEP#82\n\n'
printf ' 現在的主線:%s「%s」\n' "$MREF" "$MTITLE"
printf ' 這次要派的:%s\n\n' "$TICKETS"
printf '**先說是哪一種,再繼續**——兩種都可能是對的,錯的是默默做完:\n\n'
printf ' ① 補收 —— 它本來就該在主線上,只是沒掛上去\n'
printf ' `scripts/mainline adopt %s` 然後重送\n\n' "$FIRST"
printf ' ② 跳線 —— 真的是插件事(leo 臨時交辦、擋路的地雷…)\n'
printf ' 直接重送即可(**同一張票只擋一次**),並在回覆講一句為什麼現在要岔開主線\n\n'
printf '為什麼要問:leo 2026-08-27「今天……分心」。分心的前提是有一條主線,\n'
printf '而分心本身不是做了壞事,是**岔開了而沒有人注意到**。這一問就是那個「注意到」。\n\n'
printf '想確認主線是誰:`scripts/mainline`(唯一答案)。\n'
} >&2
exit 2
;;
*)
exit 0
;;
esac
+107
View File
@@ -0,0 +1,107 @@
#!/usr/bin/env bash
# 管什麼: 直接打 Gitea API 把一個里程碑改成 closed,卻沒有先記帳(估多久/花多久/
# 差多少/為什麼差),就擋下來。
# 為什麼: leo 2026-08-27 說那天「拖時間」,但拖了多久、比預計多拖多少、為什麼拖——
# 一個數字都沒有。偵測器早就有了(mainline-idle-guardfactory-idle-guard),
# **但沒有人把它算成帳**,所以事後只能說「這次比較慢」,下次還是一樣。
# 誤觸時怎麼關: 走正門 `scripts/milestone-account close <owner/repo#編號>`
# (它會自己算三個數字、自己去 Gitea 的時間軸取證據、然後幫你關)。
# 真的有理由要繞過去 ⇒ 指令裡加 `milestone-account-ok`(留痕,commit 說明理由)。
#
# milestone-account-guard.sh — 結案要記帳(PreToolUseBashinkstone/ISEP#85
#
# ── 這支跟 milestone-due-guard.sh 是同一條線的兩端 ────────────────────
# 那支管**開**里程碑要有真的期限(沒有期限就沒有「估多久」可言)。
# 這支管**關**里程碑要留下那三個數字。少了任何一端,帳都算不出來。
#
# ── 判準:三件同時成立才擋,缺一放行 ──────────────────────────────
# ① 指令位置真的有人在發網路請求(curlwgetpythonnodegh
# ——「講到它」跟「執行它」是兩件事,這是本 repo 已經證明過的判準
# release-tag-guard.sh 的 `git tag` 位置比對、strip_heredoc.py 的來由)。
# ② URL 指名了**某一個**里程碑:`/repos/<owner>/<repo>/milestones/<數字>`
# ——沒有數字的(建里程碑、列清單)不是本閘管的形狀。
# ③ 有寫入動詞(PATCHPOST.patch(.post(**而且**內容把 state 設成 closed。
#
# ⇒ 純 GET、建里程碑、改標題改期限、`cat` 這支腳本、把指令寫進文件(heredoc 內文
# 先被 strip_heredoc.py 剝掉)、走正門 `scripts/milestone-account close`
# ——全部放行,一條都不擋。
#
# ── 抽不出 owner/repo/編號就不擋 ────────────────────────────────────
# fail-open on 解析失敗(不是 fail-open on 檢查結果)。理由同 release-tag-guard.sh
# 誤攔比漏擋更該修——**誤攔會懲罰謹慎**,而被誤攔幾次之後人就學會繞過這支閘。
#
# ── 已知的漏擋(寫出來,不假裝沒有)────────────────────────────────
# `bash -c 'curl -X PATCH …'` 這種把真正的指令包進引號裡的寫法,本閘看不到。
# 這是刻意的取捨:要接住它就得去猜引號裡的內容,而那條路的盡頭是關鍵字黑名單
# leo 2026-08-17 已證偽:當日 8 次誤攔、0 次正確攔截)。
#
# 迴歸測試:hooks/tests/milestone-account-guard.test.sh(離線,不打 Gitea
set -uo pipefail
HOOKDIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
INPUT=$(cat 2>/dev/null || echo '{}')
CMD=$(printf '%s' "$INPUT" | python3 -c '
import json, sys
try: print(json.load(sys.stdin).get("tool_input", {}).get("command", "") or "")
except Exception: print("")
' 2>/dev/null || printf '')
[ -n "$CMD" ] || exit 0
# 明講的豁免(留痕)
case "$CMD" in *milestone-account-ok*) exit 0 ;; esac
# heredoc 的內文是資料不是指令——先剝掉,理由見 hooks/lib/strip_heredoc.py 檔頭
if [ -f "$HOOKDIR/lib/strip_heredoc.py" ]; then
CMD=$(printf '%s' "$CMD" | python3 "$HOOKDIR/lib/strip_heredoc.py" 2>/dev/null || printf '%s' "$CMD")
fi
# ① 指令位置真的有人在發請求
printf '%s' "$CMD" | grep -qE '(^|[;&|(`]|&&|\|\|)[[:space:]]*(sudo[[:space:]]+)?(curl|wget|http|python3?|node|gh)[[:space:]]' || exit 0
# ② 指名某一個里程碑
REF=$(printf '%s' "$CMD" | grep -oE '/repos/[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+/milestones/[0-9]+' | head -1 || printf '')
[ -n "$REF" ] || exit 0
# ③ 寫入動詞 把 state 設成 closed
printf '%s' "$CMD" | grep -qiE -- '-X[[:space:]]*[^A-Za-z]{0,3}(PATCH|POST)|--request[[:space:]]*[^A-Za-z]{0,3}(PATCH|POST)|\.(patch|post)\(|method[[:space:]]*=[[:space:]]*[^A-Za-z]{0,3}(PATCH|POST)' || exit 0
printf '%s' "$CMD" | grep -qiE 'state[^A-Za-z0-9]{1,8}closed' || exit 0
OWNER=$(printf '%s' "$REF" | cut -d/ -f3)
REPO=$(printf '%s' "$REF" | cut -d/ -f4)
NUM=$(printf '%s' "$REF" | cut -d/ -f6)
TARGET="$OWNER/$REPO#$NUM"
# 已經記過帳的就放行——正門 `milestone-account close` 是**先記帳、再關**
# 所以「帳本裡有這一筆」就代表三個數字已經留下來了。
LEDGER="${MILESTONE_ACCOUNT_LEDGER:-}"
if [ -z "$LEDGER" ] && [ -x "$HOOKDIR/../scripts/milestone-account" ]; then
LEDGER=$(python3 "$HOOKDIR/../scripts/milestone-account" ledger-path 2>/dev/null || printf '')
fi
if [ -n "$LEDGER" ] && [ -f "$LEDGER" ] && grep -qF "\"ref\": \"$TARGET\"" "$LEDGER" 2>/dev/null; then
exit 0
fi
cat >&2 <<MSG
⏱️ 結案要記帳:**$TARGET 還沒有留下「估多久/花多久/差多少」。**
【inkstone/ISEP#85】leo 2026-08-27 說那天「**拖時間**」。
但拖了多久、比預計多拖多少、為什麼拖——**沒有任何數字**。
「偵測器早就有了,但沒有人把它算成帳」⇒ 每次事後只能說「這次比較慢」,下次還是一樣。
━━ 走正門,它會把該做的一次做完 ━━━━━━━━━━━━━━━━━━━━━━━━
先看時間軸怎麼說: scripts/milestone-account audit $TARGET
結案(會自己算、自己關):
scripts/milestone-account close $TARGET [--reason <代號>]
三個數字**它自己算**(取自 Gitea 的 created_atdue_onclosed_at),不用你手填。
差超過 ±25%(超時或提早都算)才需要 --reason,而且代號只能從那七個裡面挑:
scripts/milestone-account codes
代號要有**時間軸撐得住**——證據取自 Gitea,不採信任何人的自述。
━━ 為什麼不是「你自己 curl 一下就好」 ━━━━━━━━━━━━━━━━━━━━
這一關掉,那個里程碑就再也算不出帳了(closed_at 之後沒有人會回頭補)。
⇒ 這是**不可逆**的一步,所以閘長在這裡,不長在誰的記性上。
真的有理由要手動關 ⇒ 指令裡加 `milestone-account-ok`(留痕,commit 說明理由)。
MSG
exit 2
+109
View File
@@ -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
+19 -3
View File
@@ -68,11 +68,23 @@ CMD=$(printf '%s' "$CMD" | python3 "$(dirname "$0")/lib/strip_""heredoc.py" 2>/d
# **15 分鐘內一條並行的 subagent 把 commit 推上了另一個 repo 的 main**。
# ⇒ **多條 subagent 並行時,「時間窗」本身就是漏洞。** 所以改成單次用完即丟。
# ⇒ 而且只在**真的要擋的那一刻**才檢查——放在檔頭會被任何一條無關指令把戳記燒掉。
# 🔴 戳記的 mtime 一律走 lib/mtime.sh:舊寫法(`stat -f %m || stat -c %Y`)在 Linux 上
# **永遠算不出數字**(GNU 的 `-f` 是「檔案系統資訊」,它一邊回非零一邊吐一整段文字,
# 於是後面接上的秒數被那段垃圾污染)⇒ 這支閘給總管的逃生口在雲端整個焊死,
# 而閘不會告訴他門是壞的(inkstone/ISEP#90 ④,全文見 hooks/lib/mtime.sh)。
# lib 不在時就地補一份同順序的定義——這道門不能因為少一個檔案就再關上一次。
. "$(dirname "$0")/lib/mtime.sh" 2>/dev/null || file_mtime() {
_fm=$(stat -c %Y "$1" 2>/dev/null || true)
case "${_fm:-}" in ''|*[!0-9]*) _fm=$(stat -f %m "$1" 2>/dev/null || true) ;; esac
case "${_fm:-}" in ''|*[!0-9]*) _fm=0 ;; esac
printf '%s' "$_fm"
}
STAMP="/tmp/.prod-write-ok"
stamp_ok() {
[ -f "$STAMP" ] || return 1
NOW=$(date +%s 2>/dev/null || echo 0)
MT=$(stat -f %m "$STAMP" 2>/dev/null || stat -c %Y "$STAMP" 2>/dev/null || echo 0)
MT=$(file_mtime "$STAMP")
case "$NOW$MT" in *[!0-9]*) return 1 ;; esac
[ "$NOW" -gt 0 ] && [ "$MT" -gt 0 ] || return 1
[ $((NOW - MT)) -lt 900 ] || return 1
@@ -112,7 +124,9 @@ leo 原話:
━━━ 你是總管 ━━━
看清楚它會覆蓋什麼(`arcrun_get_workflow` 先撈一份現況存起來,好回滾),確定要推,再:
touch /tmp/.prod-write-ok # ⚠️ 只放行「下一個」動作,用完即消失
bash "$CLAUDE_PLUGIN_ROOT/scripts/gate-ok" prod-write # ⚠️ 只放行「下一個」動作,用完即消失
(等價的手打法:touch /tmp/.prod-write-ok。gate-ok 是所有閘共用的同一個入口,
好處是它的形狀固定 ⇒ 一條規則就能事先放行,不會每次被權限層擋在門外)
戳記 15 分鐘有效。**若這一推會讓封測者/用戶當場拿到東西,那就不是你的層級——要 leo 解保險。**
MSG
@@ -310,7 +324,9 @@ leo 原話:
━━━ 你是總管 ━━━
確認過要推什麼、會蓋掉什麼之後:
touch /tmp/.prod-write-ok # ⚠️ 只放行「下一個」動作,用完即消失
bash "$CLAUDE_PLUGIN_ROOT/scripts/gate-ok" prod-write # ⚠️ 只放行「下一個」動作,用完即消失
(等價的手打法:touch /tmp/.prod-write-ok。gate-ok 是所有閘共用的同一個入口,
好處是它的形狀固定 ⇒ 一條規則就能事先放行,不會每次被權限層擋在門外)
⚠️ `acr recipe push` 另有一道**互動式暴露同意閘**(終端機裡要人親手輸入資源名)。
那道閘擋的是「把資源變成可被外部呼叫」,**本閘的戳記蓋不過它**——那是 leo 的手。
+186
View File
@@ -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 — PreToolUseTaskAgent):**工人要有名字**inkstone/ISEP#86
#
# ━━ 來由(leoinkstone/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 的 namedescription、`**負責 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
+27
View File
@@ -144,6 +144,33 @@ if [ -f "$MISTAKES_FILE" ] && grep -q 'MISTAKE' "$MISTAKES_FILE" 2>/dev/null; th
echo ""
fi
# ── push 6:還債線今天撈過了沒(inkstone/ISEP#83)──────────────────────────
# 🔴 **這裡不打網路、不撈票**——只看狀態檔的日期,講一句話。
# 要真的撈是人(或這個 session)自己跑那一行;SessionStart 只負責讓它被想起來。
# 「每天都在追新的,所以要用 Routine 去消化舊的」(leo 2026-08-27)——
# 沒有這一句,沒掛 milestone 的那 170 多張永遠不會被任何人拿起來。
DEBT_STATE="${ISEP_DEBT_STATE_DIR:-$HOME/.claude/isep-debt}/claimed.json"
DEBT_LINE=$(python3 - "$DEBT_STATE" <<'PY' 2>/dev/null || true
import json, sys, datetime
try:
d = json.load(open(sys.argv[1]))
except Exception:
d = {}
last = (d.get("last_run") or "")[:10]
today = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%d")
n = len(d.get("claims") or {})
if last == today:
print(f"✅ 今天撈過了(手上領著 {n} 張)")
else:
print("🔴 今天還沒撈過還債清單" + (f"(手上還領著 {n} 張沒關)" if n else ""))
PY
)
echo "────────────────────────────────────────────────"
echo "🧾 還債線(inkstone/ISEP#83):${DEBT_LINE:-🔴 今天還沒撈過還債清單}"
echo " 撈一次 python3 ${CLAUDE_PLUGIN_ROOT:-.}/scripts/debt-worklist list"
echo " 它排的是「沒掛 milestone 的舊票」——掛著的歸緊急線(milestone)管,兩條線不互搶。"
echo ""
echo "────────────────────────────────────────────────"
echo "以上是 index,不是內容——它只讓你知道「有這件事、去哪裡找」。"
echo "要完整脈絡(decisions / mistakes / SDD)→ 執行 /wiki-recall"
+15 -2
View File
@@ -159,6 +159,18 @@ fi
# ⇒ 改成兩者都認:內容有數字就用內容,沒有就用 mtime(`touch` 因此真的有效)。
#
# 另外認 leo 親手蓋的章(scripts/stage-ok.sh 產生),那是比我自評更強的訊號。
# 🔴 戳記的 mtime 一律走 lib/mtime.sh:舊寫法(`stat -f %m || stat -c %Y`)在 Linux 上
# **永遠算不出數字**(GNU 的 `-f` 是「檔案系統資訊」,它一邊回非零一邊吐一整段文字,
# 於是後面接上的秒數被那段垃圾污染)⇒ 這支閘給總管的逃生口在雲端整個焊死,
# 而閘不會告訴他門是壞的(inkstone/ISEP#90 ④,全文見 hooks/lib/mtime.sh)。
# lib 不在時就地補一份同順序的定義——這道門不能因為少一個檔案就再關上一次。
. "$(dirname "$0")/lib/mtime.sh" 2>/dev/null || file_mtime() {
_fm=$(stat -c %Y "$1" 2>/dev/null || true)
case "${_fm:-}" in ''|*[!0-9]*) _fm=$(stat -f %m "$1" 2>/dev/null || true) ;; esac
case "${_fm:-}" in ''|*[!0-9]*) _fm=0 ;; esac
printf '%s' "$_fm"
}
for STAMP in /tmp/.stage-ok-by-leo /tmp/.stage-verified; do
[ -f "$STAMP" ] || continue
now=$(date +%s)
@@ -172,7 +184,7 @@ for STAMP in /tmp/.stage-ok-by-leo /tmp/.stage-verified; do
# 抽數字會得到天文數字 ⇒ `now - t` 是**負數** ⇒ `< 21600` 成立 ⇒ **永遠放行**。
# ⇒ 只有「不晚於現在」的時間戳才算數;不合理就退回 mtime,別當成通過。
if [ "$t" -eq 0 ] || [ "$t" -gt "$now" ]; then
t=$(stat -f %m "$STAMP" 2>/dev/null || stat -c %Y "$STAMP" 2>/dev/null || echo 0)
t=$(file_mtime "$STAMP")
fi
[ "$t" -gt 0 ] && [ "$((now - t))" -lt 21600 ] && exit 0 # 6 小時內驗過 stage → 放行
done
@@ -196,7 +208,8 @@ cat >&2 <<'EOF'
⚠️ 身分:leo 2026-07-25 令「測試一律用 youlin,別拿 leo21c 當探針(會製造假信號)」
⇒ 部署前先 `acr whoami` 確認身分。
驗過了 ⇒ `touch /tmp/.stage-verified` 後重送,並在回覆裡**貼 stage 的實測輸出**
驗過了 ⇒ `bash "$CLAUDE_PLUGIN_ROOT/scripts/gate-ok" stage-verified` 後重送
(等價:`touch /tmp/.stage-verified`),並在回覆裡**貼 stage 的實測輸出**
(「我測過了」不算——貼指令與它吐出來的東西)。
EOF
exit 2
+8 -3
View File
@@ -145,12 +145,17 @@ case "$CTX" in
esac
# milestone:沒有快取就整段不出現(不准編一個日期出來)
case "$CTX" in
*"主線"*) no "⑨ 沒有 milestone 快取 ⇒ 那一段不出現" "實得:$CTX" ;;
# 🔴 只比對**第一行**(那條 ⏱ 倒數本身)。inkstone/ISEP#82 之後,同一個注入點還帶著
# 第二行「🎯 主線……」與一段說明,那段文字裡本來就會出現「主線」兩個字
# ——拿整段去比對會把說明當成數字,變成一條**測不到重點的假紅**。
# 這一格要驗的一直都是「⏱ 那一行有沒有多長出一段編出來的期限」。
STAMP_LINE_ONLY=$(printf '%s\n' "$CTX" | head -1)
case "$STAMP_LINE_ONLY" in
*"主線"*) no "⑨ 沒有 milestone 快取 ⇒ 那一段不出現" "實得:$STAMP_LINE_ONLY" ;;
*) ok "⑨ 沒有 milestone 快取 ⇒ 那一段不出現(不編數字)" ;;
esac
printf '2026-08-30|Sprint 8-28\n' > "$ISEP_COUNTDOWN_STATE_DIR/milestone-due"
CTX=$(inject s-ms "$TMP/a5.jsonl" "$T0")
CTX=$(printf '%s\n' "$(inject s-ms "$TMP/a5.jsonl" "$T0")" | head -1)
case "$CTX" in
*"主線 Sprint 8-28 剩"*) ok "⑩ 有 milestone 快取 ⇒ 期限那一段出現" ;;
*) no "⑩ 有 milestone 快取 ⇒ 期限那一段出現" "實得:$CTX" ;;
+171
View File
@@ -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 "⑦ 側門:隱式 POSTrequests.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
+97
View File
@@ -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 "㉔ 雲端開新 sessioncreate_session 的 prompt" \
"$(reply_payload mcp__Claude_Code_Remote__create_session prompt '去把那個 worker 修好,順便升版')"
t 2 "㉕ 送訊息進某個 sessionsend_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 開一個新 sessionBash 的 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 個失敗} ══"
+88
View File
@@ -0,0 +1,88 @@
#!/usr/bin/env bash
# scripts/gate-ok +「戳記真的打得開門」的迴歸測試(inkstone/ISEP#90 ④)
#
# ══ 這支守的是什麼 ═════════════════════════════════════════════════════
# 三支閘(prod-writemain-and-prod-pushstage-before-prod)都是
# 「擋下來,但**總管看過就可以放行**」的設計。既有的測試只驗了「擋得住」,
# **一條都沒有驗過「放得開」**——於是 2026-08-28 才發現:
# `stat -f %m` 在 GNU coreutils 上是「檔案系統資訊」,它一邊回非零一邊吐一整段文字,
# 把接在後面的秒數污染掉 ⇒ **在 Linux(=每一個雲端 session)上,那三支閘的戳記
# 永遠不會被接受** ⇒ 它們在雲端等於純擋,而閘不會告訴你門是壞的。
#
# 所以這支的第一優先是「門打得開」,第二優先是「門沒有因此變寬」。
#
# 🔴 這支會真的寫 /tmp 的戳記檔(那是閘寫死的路徑,沒有覆寫的開關)。
# 每一條測完就把它刪掉;跑之前若有真的在等的戳記,會被這支洗掉——
# 請不要在「已經蓋好戳記正要推東西」的當下跑它。
set -u
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" # hooks/
REPO="$(cd "$ROOT/.." && pwd)"
GATEOK="$REPO/scripts/gate-ok"
TMP=$(mktemp -d); trap 'rm -rf "$TMP"; rm -f /tmp/.prod-write-ok /tmp/.main-push-ok /tmp/.stage-verified /tmp/.solo-ok-TESTSID' EXIT
export GIT_CONFIG_GLOBAL="$TMP/gitconfig"; : > "$GIT_CONFIG_GLOBAL"
export GIT_AUTHOR_NAME=t GIT_AUTHOR_EMAIL=t@t GIT_COMMITTER_NAME=t GIT_COMMITTER_EMAIL=t@t
PASS=0; FAIL=0
ok(){ if [ "$2" = "$3" ]; then printf ' ✅ %s\n' "$1"; PASS=$((PASS+1));
else printf ' ❌ %s —— 期望 %s,實得 %s\n' "$1" "$3" "$2"; FAIL=$((FAIL+1)); fi; }
mkrepo(){ d="$TMP/$1"; git init -q -b main "$d"; ( cd "$d" && echo a>a && git add a && git commit -qm x ) >/dev/null 2>&1; printf '%s\n' "$d"; }
fire_prod(){ printf '{"tool_name":"Bash","tool_input":{"command":"acr push x.yaml"}}' \
| bash "$ROOT/prod-write-guard.sh" >/dev/null 2>&1; echo $?; }
fire_push(){ printf '{"tool_name":"Bash","tool_input":{"command":"git push origin main"}}' \
| ( cd "$1" && bash "$ROOT/main-and-prod-push-guard.sh" >/dev/null 2>&1; echo $? ); }
rm -f /tmp/.prod-write-ok /tmp/.main-push-ok /tmp/.stage-verified
echo "── 最重要:門打得開(Linux 上 stat -f %m 那個 bug 的迴歸)──────"
ok "① 沒戳記 → prod-write 擋" "$(fire_prod)" 2
bash "$GATEOK" prod-write >/dev/null
ok "② gate-ok prod-write 之後 → 放行" "$(fire_prod)" 0
ok "③ 單次用完即丟:同一枚不能放行第二次" "$(fire_prod)" 2
R1=$(mkrepo r1); R2=$(mkrepo r2)
ok "④ 沒戳記 → 推 main 擋" "$(fire_push "$R1")" 2
bash "$GATEOK" main-push "$R1" >/dev/null
ok "⑤ gate-ok main-push <repo> 之後 → 放行" "$(fire_push "$R1")" 0
echo "── 門沒有因此變寬 ────────────────────────────────────────────"
bash "$GATEOK" main-push "$R1" >/dev/null
ok "⑥ 替 r1 開的門,r2 走不過(綁 repo 沒鬆)" "$(fire_push "$R2")" 2
rm -f /tmp/.main-push-ok
# 過期的戳記不算數:把 mtime 調到 16 分鐘前
bash "$GATEOK" prod-write >/dev/null
touch -d '16 minutes ago' /tmp/.prod-write-ok 2>/dev/null || touch -A -001600 /tmp/.prod-write-ok 2>/dev/null
ok "⑦ 16 分鐘前的戳記 → 過期,照樣擋" "$(fire_prod)" 2
rm -f /tmp/.prod-write-ok
# 空內容的 main-push 戳記=萬用鑰匙,08-12 那次穿透的形狀,不准復活
: > /tmp/.main-push-ok
ok "⑧ 空內容的 main-push 戳記 → 不算數" "$(fire_push "$R1")" 2
rm -f /tmp/.main-push-ok
echo "── gate-ok 自己:不認得的就要拒絕,別蓋一枚打不開的戳記 ──────"
bash "$GATEOK" nonsense >/dev/null 2>&1; ok "⑨ 不認得的閘名 → 離開碼 2" "$?" 2
bash "$GATEOK" solo >/dev/null 2>&1; ok "⑩ solo 沒帶 session id → 離開碼 2" "$?" 2
bash "$GATEOK" main-push "$TMP" >/dev/null 2>&1; ok "⑪ 不是 git repo → 離開碼 2" "$?" 2
ok "⑫ 而且**沒有**留下一枚註定打不開的空戳記" "$([ -f /tmp/.main-push-ok ] && echo yes || echo no)" no
bash "$GATEOK" solo TESTSID >/dev/null 2>&1
ok "⑬ solo <sid> → 蓋出 /tmp/.solo-ok-<sid>" "$([ -f /tmp/.solo-ok-TESTSID ] && echo yes || echo no)" yes
bash "$GATEOK" stage-verified >/dev/null 2>&1
ok "⑭ stage-verified → 蓋出 /tmp/.stage-verified" "$([ -f /tmp/.stage-verified ] && echo yes || echo no)" yes
echo "── file_mtime 本身:兩個平台的寫法都要答得出純數字 ────────────"
. "$ROOT/lib/mtime.sh"
touch "$TMP/probe"
M=$(file_mtime "$TMP/probe")
case "$M" in ''|*[!0-9]*) R=bad ;; *) R=ok ;; esac
ok "⑮ file_mtime 回純數字(舊寫法在 Linux 上會回一整段文字)" "$R" ok
ok "⑯ 檔案不存在 → 回 0,不是空字串" "$(file_mtime "$TMP/nope")" 0
# 🔴 ⑰ 守的是這個 bug 的最後一層:舊寫法的行為**取決於 cwd 裡有沒有一個叫 `%m` 的檔**
# GNU 的 -f 是布林旗標,`%m` 被當成另一個檔名運算元)。有 ⇒ exit 0 ⇒ `||` 連跑都不跑。
# 一個行為取決於 cwd 有沒有某個檔的判斷式,不管跑不跑都是壞的。
M2=$( cd "$TMP" && touch '%m' && file_mtime "$TMP/probe" )
case "$M2" in ''|*[!0-9]*) R2=bad ;; *) R2=ok ;; esac
ok "⑰ cwd 裡有一個叫 %m 的檔 → 仍要回純數字" "$R2" ok
echo
printf '通過 %s 條,失敗 %s 條\n' "$PASS" "$FAIL"
[ "$FAIL" = 0 ] || exit 1
+117
View File
@@ -0,0 +1,117 @@
#!/usr/bin/env bash
# isep-presence-beacon.sh 的迴歸測試(inkstone/ISEP#90 ②③)
#
# 信標的價值全在鑑別力,所以這支要同時證明兩件相反的事:
# ① 該報的三件真的報得出來(版本落差/舊複本遮蔽正門/退役機制的殘骸)
# ② **不管加多少報告,那行綠色信標永遠在**——它消失=leo 會判定這個 session 零閘。
# 所以連 python 掛掉、網路不通、訊息裡有引號,都要還有一行合法 JSON。
#
# 🔴 全程離線(ISEP_BEACON_SKIP_NET=1,版本用 ISEP_FAKE_MAIN_VERSION 造),
# 造假的 plugin root 與 project dir 都在 TMP 底下,不碰真 repo。
set -u
HOOK="${1:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/isep-presence-beacon.sh}"
REAL_HOOKS="$(cd "$(dirname "$HOOK")" && pwd)"
TMP=$(mktemp -d); trap 'rm -rf "$TMP"' EXIT
export ISEP_BEACON_SKIP_NET=1 ISEP_BEACON_CACHE_DIR="$TMP"
PASS=0; FAIL=0; N=0
# mkplugin <版本> → 造一份假的 plugin root(只放信標真的會讀的東西)
mkplugin() {
d="$TMP/plugin-$1"; mkdir -p "$d/.claude-plugin" "$d/hooks/lib" "$d/scripts"
printf '{"name":"isep","version":"%s"}\n' "$1" > "$d/.claude-plugin/plugin.json"
printf '{"hooks":{"Stop":[{"hooks":[{"command":"hooks/a.sh"},{"command":"hooks/b.sh"}]}]}}\n' \
> "$d/hooks/hooks.json"
cp "$HOOK" "$d/hooks/$(basename "$HOOK")"
cp "$REAL_HOOKS/lib/beacon_report.py" "$d/hooks/lib/beacon_report.py"
printf '#!/bin/sh\necho real\n' > "$d/scripts/ticket"
printf '%s\n' "$d"
}
# run <plugin root> <project dir> → 印出 systemMessageJSON 壞掉就印 __BADJSON__
run() {
CLAUDE_PLUGIN_ROOT="$1" CLAUDE_PROJECT_DIR="$2" bash "$1/hooks/$(basename "$HOOK")" 2>/dev/null \
| python3 -c 'import json,sys
try: print(json.load(sys.stdin)["systemMessage"])
except Exception: print("__BADJSON__")'
}
check() { # check <說明> <輸出> <該出現|!不該出現>...
desc="$1"; out="$2"; shift 2; N=$((N+1)); ok=1; why=""
for w in "$@"; do
case "$w" in
"!"*) if printf '%s' "$out" | grep -qF -- "${w#!}"; then ok=0; why="不該出現卻出現了:${w#!}"; fi ;;
*) if ! printf '%s' "$out" | grep -qF -- "$w"; then ok=0; why="少了:$w"; fi ;;
esac
done
if [ "$ok" = 1 ]; then printf ' ✅ %s\n' "$desc"; PASS=$((PASS+1))
else printf ' ❌ %s —— %s\n' "$desc" "$why"; printf '%s\n' "$out" | sed 's/^/ /'; FAIL=$((FAIL+1)); fi
}
echo "── 基本盤:那行綠色信標永遠在 ────────────────────────────────"
P=$(mkplugin 0.9.0); J="$TMP/proj-clean"; mkdir -p "$J"
out=$(run "$P" "$J")
check "① 乾淨環境:只有一行信標,版本與閘數都在" "$out" \
"🟢 ISEP v0.9.0 已載入" "2 支閘" "!🔴" "!🟡" "!__BADJSON__"
echo "── ①版本落差 ────────────────────────────────────────────────"
out=$(ISEP_FAKE_MAIN_VERSION=0.9.0 run "$P" "$J")
check "② 載入版=main 版 → 不吵" "$out" "🟢 ISEP v0.9.0" "!落後"
P39=$(mkplugin 0.3.9)
out=$(ISEP_FAKE_MAIN_VERSION=0.9.0 run "$P39" "$J")
check "③ 載入 0.3.9、main 0.9.0 → 要說它落後(雲端那個現場)" "$out" \
"🟢 ISEP v0.3.9 已載入" "落後 ISEP main" "0.9.0" "inkstone/ISEP#67"
out=$(ISEP_FAKE_MAIN_VERSION=0.1.0 run "$P" "$J")
check "④ 載入版比 main 新 → 說「沒發版」,不說「落後」" "$out" "比 ISEP main 新" "!落後 ISEP main"
echo "── ②舊複本遮蔽正門 ──────────────────────────────────────────"
J2="$TMP/proj-shadow"; mkdir -p "$J2/scripts"
printf '#!/bin/sh\necho OLD\n' > "$J2/scripts/ticket"
out=$(run "$P" "$J2")
check "⑤ 專案裡有內容不同的同名腳本 → 要點名(ticket 那件)" "$out" \
"舊複本" "scripts/ticket"
J3="$TMP/proj-same"; mkdir -p "$J3/scripts"; cp "$P/scripts/ticket" "$J3/scripts/ticket"
out=$(run "$P" "$J3")
check "⑥ 複本內容一模一樣(同步過的)→ 不吵" "$out" "!舊複本"
J4="$TMP/proj-body"; mkdir -p "$J4/InkStoneCo/scripts"
printf '#!/bin/sh\necho OLD\n' > "$J4/InkStoneCo/scripts/ticket"
out=$(run "$P" "$J4")
check "⑦ 雲端排法:複本在 \$TOP/InkStoneCo/ 底下也要抓到" "$out" \
"舊複本" "InkStoneCo/scripts/ticket"
echo "── ③退役機制的殘骸 ──────────────────────────────────────────"
J5="$TMP/proj-orphan"; mkdir -p "$J5/.claude/pending-verification" "$J5/.claude/hooks"
out=$(run "$P" "$J5")
check "⑧ plugin 的 hooks/scripts 完全沒提到的目錄 → 報殘骸" "$out" \
"已退役機制的產物" ".claude/pending-verification"
check "⑨ Claude Code 自己的目錄(hooks)不算殘骸" "$out" "!.claude/hooks"
# 現行機制的產物不該被報:讓 plugin 的某支 hook 提到它
mkdir -p "$J5/.claude/pending-main-push"
printf '#!/bin/sh\n# 寫到 pending-main-push/\n' > "$P/hooks/x.sh"
out=$(run "$P" "$J5")
check "⑩ hooks 裡有東西提到它 → 那是現行機制的產物,不報" "$out" "!pending-main-push"
# 🔴 這條守的是第一版真的犯過的 bug:內層迴圈的變數名跟外層的 `d` 撞,
# 第二個名字之後全部被靜靜跳過 ⇒ 真的存在的殘骸一聲不吭地消失。
mkdir -p "$J5/.claude/verified-claims"
out=$(run "$P" "$J5")
check "⑪ 同一個 .claude 底下有多個殘骸 → 每一個都要報(變數撞名的迴歸)" "$out" \
".claude/pending-verification" ".claude/verified-claims"
echo "── 最重要:加了報告也不准讓信標消失 ──────────────────────────"
# python 掛掉(把 report 換成一定會爆的內容)→ 仍要有一行合法 JSON 的信標
PB=$(mkplugin 0.9.0); printf 'raise SystemExit(3)\n' > "$PB/hooks/lib/beacon_report.py"
out=$(run "$PB" "$J")
check "⑫ 組訊息的 python 整支掛掉 → 退回乾淨的那一行,不是沉默" "$out" \
"🟢 ISEP v0.9.0 已載入" "!__BADJSON__"
rm -f "$PB/hooks/lib/beacon_report.py"
out=$(run "$PB" "$J")
check "⑬ 連 report 檔都不見 → 一樣要有信標" "$out" "🟢 ISEP v0.9.0 已載入" "!__BADJSON__"
# 訊息裡有引號/換行時 JSON 仍要合法(舊版是 printf 手拼的,這正是它會壞的地方)
J6="$TMP/proj-quote"; mkdir -p "$J6/scripts"
printf '#!/bin/sh\necho "OLD"\n' > "$J6/scripts/ticket"
out=$(run "$P" "$J6")
check "⑭ 報告內容含引號 → JSON 仍然合法" "$out" "🟢 ISEP" "!__BADJSON__"
echo
printf '通過 %s 條,失敗 %s 條(共 %s 條)\n' "$PASS" "$FAIL" "$N"
[ "$FAIL" = 0 ] || exit 1
+202
View File
@@ -0,0 +1,202 @@
#!/usr/bin/env bash
# 主線那一條線的迴歸測試(inkstone/ISEP#82
#
# 票上四條驗收,這裡一條一群:
# A 「現在的主線是哪一個」→ 一個指令答得出來,而且答案唯一
# B 標了主線之後開新回合 → 目標宣告自己出現(不用人去翻)
# C 派一張不屬於主線的票 → 攔一次,並問是補收還是跳線
# D 沒有任何主線 → 不能整組壞掉,要講得出「現在沒有主線」
# E 不該擋的(誤攔比漏擋更該修)
#
# 用法:hooks/tests/mainline-focus-guard.test.sh
# 🔴 全程離線:狀態走 ISEP_COUNTDOWN_STATE_DIR、時鐘走 ISEP_COUNTDOWN_NOW、
# 「那張票掛在哪」走 ISEP_MAINLINE_FIXTURE,並把 TICKET_HOST 指到一個
# 連不上的位址——**任何一條真的走到網路,就會在那裡當場失敗,不會靜靜地變成假綠**。
set -u
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
GUARD="$ROOT/mainline-focus-guard.sh"
COUNTDOWN="$ROOT/countdown-guard.sh"
MAINLINE="$ROOT/../scripts/mainline"
TMP=$(mktemp -d)
trap 'rm -rf "$TMP"' EXIT
export ISEP_COUNTDOWN_STATE_DIR="$TMP/state"
export TICKET_HOST="http://127.0.0.1:9" # 連不上:走到網路就會馬上失敗
mkdir -p "$ISEP_COUNTDOWN_STATE_DIR"
unset ISEP_MAINLINE_FIXTURE
PASS=0; FAIL=0
ok(){ printf ' ✅ %s\n' "$1"; PASS=$((PASS+1)); }
no(){ printf ' ❌ %s —— %s\n' "$1" "$2"; FAIL=$((FAIL+1)); }
T0=$(python3 -c 'import datetime as d;print(int(d.datetime(2026,8,28,0,0,tzinfo=d.timezone.utc).timestamp()))')
# set_mainline <標題> <成員 ref...> —— 直接寫 state(離線;`mainline set` 那半要網路)
set_mainline(){
python3 - "$ISEP_COUNTDOWN_STATE_DIR/mainline.json" "$@" <<'PY'
import json, sys
path, title = sys.argv[1], sys.argv[2]
json.dump({"ref": "inkstone/ISEP#57", "owner": "inkstone", "repo": "ISEP", "id": 57,
"title": title, "description": "把 SOP 變成閘:規則要長在機器上,不是長在誰的記性上。",
"due_on": "2026-08-31T15:59:59+08:00",
"open_issues": 12, "closed_issues": 1,
"members": list(sys.argv[3:]), "set_at": "2026-08-28 08:00"},
open(path, "w"), ensure_ascii=False)
PY
}
clear_mainline(){ rm -f "$ISEP_COUNTDOWN_STATE_DIR/mainline.json"; }
# dispatch <派工單內文> → "exit|訊息"
dispatch(){
out=$(python3 -c '
import json,sys; print(json.dumps({"tool_input":{"prompt":sys.argv[1]},"session_id":"t"}))' "$1" \
| CLAUDE_CODE_CHILD_SESSION= bash "$GUARD" 2>&1); rc=$?
printf '%s|%s' "$rc" "$(printf '%s' "$out" | tr '\n' ' ')"
}
# inject <now> → UserPromptSubmit 注入的內容
inject(){
printf '{"hook_event_name":"UserPromptSubmit","session_id":"s","transcript_path":"%s"}' "$TMP/none.jsonl" \
| ISEP_COUNTDOWN_NOW="$1" bash "$COUNTDOWN" 2>/dev/null \
| python3 -c 'import json,sys
try: print(json.load(sys.stdin)["hookSpecificOutput"]["additionalContext"])
except Exception: print("")'
}
echo "── A 群:「現在的主線是哪一個」一個指令答得出來,答案唯一(驗收 1)──"
clear_mainline
OUT=$(python3 "$MAINLINE" 2>&1); RC=$?
[ "$RC" = 0 ] && ok "① 沒標過主線 ⇒ 指令仍然回答得出來(exit 0,不是壞掉)" \
|| no "① 沒標過主線 ⇒ exit 0" "實得 exit=$RC"
case "$OUT" in
*"現在沒有主線"*) ok "② 沒標過主線 ⇒ 答案是「現在沒有主線」(驗收 4)" ;;
*) no "② 沒標過 ⇒ 說「現在沒有主線」" "實得:$OUT" ;;
esac
set_mainline "SOP 變成閘" "inkstone/ISEP#82" "inkstone/ISEP#57"
OUT=$(python3 "$MAINLINE" 2>&1)
N=$(printf '%s\n' "$OUT" | grep -c "現在的主線")
[ "$N" = 1 ] && ok "③ 標了之後 ⇒ 只有一個答案(「現在的主線」出現 1 次,不是 14 個)" \
|| no "③ 答案唯一" "「現在的主線」出現 $N"
case "$OUT" in
*"SOP 變成閘"*) ok "④ 答案指名道姓(標題印出來了)" ;;
*) no "④ 答案指名道姓" "實得:$OUT" ;;
esac
set_mainline "換一條線" "inkstone/ISEP#82"
# 🔴 只看標題那一行:描述欄裡本來就會提到舊標題(它是同一份測資),
# 拿整份輸出去比對會**因為描述而誤判**——那不是「舊的還是主線」。
OUT=$(python3 "$MAINLINE" 2>&1 | head -1)
case "$OUT" in
*"SOP 變成閘"*) no "⑤ 換標一條 ⇒ 舊的不再是主線" "舊標題還在:$OUT" ;;
*"換一條線"*) ok "⑤ 換標一條 ⇒ 舊的當場不再是主線(一個檔放得下一條)" ;;
*) no "⑤ 換標一條" "實得:$OUT" ;;
esac
echo "── B 群:標了之後開新回合,目標宣告自己出現(驗收 2)──────────"
set_mainline "SOP 變成閘" "inkstone/ISEP#82"
CTX=$(inject "$T0")
case "$CTX" in
*"🎯 主線:"*) ok "⑥ 什麼都沒交代 ⇒ 注入裡有 🎯 主線那一行" ;;
*) no "⑥ 注入裡有 🎯 主線那一行" "實得:$CTX" ;;
esac
case "$CTX" in
*"SOP 變成閘"*) ok "⑦ 注入裡有主線的名字" ;;
*) no "⑦ 注入裡有主線的名字" "實得:$CTX" ;;
esac
case "$CTX" in
*"目標:把 SOP 變成閘"*) ok "⑧ 注入裡有**目標宣告**(milestone 的描述),不用人去翻" ;;
*) no "⑧ 注入裡有目標宣告" "實得:$CTX" ;;
esac
# ⏱ 那一行是**已經有 Stop 閘在查**的那一行(ISEP#63)。主線的名字掛在它上面,
# 就跟著那道驗過的閘一起到 leo 眼前——不必為了同一件事再立第二道會擋人的閘。
FIRST=$(printf '%s\n' "$CTX" | head -1)
case "$FIRST" in
*"⏱"*"主線 SOP 變成閘"*) ok "⑨ 被查核的那一行(⏱)也帶著主線名字 ⇒ 主線會到 leo 眼前" ;;
*) no "⑨ ⏱ 那一行帶主線名字" "實得:$FIRST" ;;
esac
# 被標定的主線要蓋過「期限最近的那個」快取——後者是猜的,前者是被指定的
printf '2026-08-29|某個期限更近的\n' > "$ISEP_COUNTDOWN_STATE_DIR/milestone-due"
FIRST=$(inject "$T0" | head -1)
case "$FIRST" in
*"主線 SOP 變成閘"*) ok "⑩ 標定的主線蓋過「期限最近」的猜測(不准用猜的蓋過指定的)" ;;
*) no "⑩ 標定的主線優先" "實得:$FIRST" ;;
esac
rm -f "$ISEP_COUNTDOWN_STATE_DIR/milestone-due"
clear_mainline
CTX=$(inject "$T0")
case "$CTX" in
*"現在沒有主線"*) ok "⑪ 沒有主線 ⇒ 注入照樣講得出「現在沒有主線」(驗收 4,不編一條)" ;;
*) no "⑪ 沒有主線 ⇒ 注入講「現在沒有主線」" "實得:$CTX" ;;
esac
echo "── C 群:派了不在主線上的票 ⇒ 攔一次並問清楚(驗收 3)──────────"
set_mainline "SOP 變成閘" "inkstone/ISEP#82" "inkstone/ISEP#57"
printf '{"inkstone/Arcrun#50":"AI 問得到內文"}\n' > "$TMP/fx.json"
export ISEP_MAINLINE_FIXTURE="$TMP/fx.json"
R=$(dispatch '【工單】inkstone/ISEP#82')
[ "${R%%|*}" = 0 ] && ok "⑫ 派主線上的票 ⇒ 放行" || no "⑫ 派主線上的票 ⇒ 放行" "實得 exit=${R%%|*}"
R=$(dispatch '【工單】inkstone/Arcrun#50')
[ "${R%%|*}" = 2 ] && ok "⑬ 派不在主線上的票 ⇒ 擋" || no "⑬ 派不在主線上的票 ⇒ 擋" "實得 exit=${R%%|*}"
case "${R#*|}" in
*"補收"*"跳線"*) ok "⑭ 擋下時問的是「補收還是跳線」,兩條路都給了指令" ;;
*) no "⑭ 訊息問補收/跳線" "實得:${R#*|}" ;;
esac
case "${R#*|}" in
*"SOP 變成閘"*) ok "⑮ 擋下時說得出現在的主線是誰(不是只說「你錯了」)" ;;
*) no "⑮ 訊息說出主線是誰" "實得:${R#*|}" ;;
esac
R=$(dispatch '【工單】inkstone/Arcrun#50')
[ "${R%%|*}" = 0 ] && ok "⑯ 同一張票重送 ⇒ 放行(至多擋一次,不鬼打牆)" \
|| no "⑯ 重送 ⇒ 放行" "實得 exit=${R%%|*}"
J=$(wc -l < "$ISEP_COUNTDOWN_STATE_DIR/mainline-jumps.jsonl" 2>/dev/null || echo 0)
[ "$J" -ge 1 ] && ok "⑰ 跳線留痕(mainline-jumps.jsonl 記了一筆)——不留痕的話跳線與專注長得一樣" \
|| no "⑰ 跳線留痕" "jumps 檔有 $J"
R=$(dispatch '【工單】inkstone/Arcrun#50
【工單】inkstone/ISEP#82')
[ "${R%%|*}" = 0 ] && ok "⑱ 多張票只要有一張在主線上 ⇒ 放行(那次派工有在推主線)" \
|| no "⑱ 多張票有一張在主線 ⇒ 放行" "實得 exit=${R%%|*}"
echo "── D/E 群:不該擋(誤攔比漏擋更該修)──────────────────────────"
clear_mainline
R=$(dispatch '【工單】inkstone/Arcrun#50')
[ "${R%%|*}" = 0 ] && ok "⑲ 沒有主線 ⇒ 一律放行(驗收 4:不能整組壞掉)" \
|| no "⑲ 沒有主線 ⇒ 放行" "實得 exit=${R%%|*}"
set_mainline "SOP 變成閘" "inkstone/ISEP#82"
R=$(dispatch '幫我看一下這個 repo 的狀況')
[ "${R%%|*}" = 0 ] && ok "⑳ 派工單裡沒有票號 ⇒ 放行(那是 no-ticket-no-dispatch 的地盤)" \
|| no "⑳ 沒有票號 ⇒ 放行" "實得 exit=${R%%|*}"
out=$(python3 -c '
import json;print(json.dumps({"tool_input":{"prompt":"【工單】inkstone/Arcrun#50"},"session_id":"t"}))' \
| CLAUDE_CODE_CHILD_SESSION=1 bash "$GUARD" 2>&1); rc=$?
[ "$rc" = 0 ] && ok "㉑ 子 session ⇒ 放行(主線是派工者手上的判準,不是收工方要背的)" \
|| no "㉑ 子 session ⇒ 放行" "實得 exit=$rc"
unset ISEP_MAINLINE_FIXTURE
R=$(dispatch '【工單】inkstone/mira#999')
[ "${R%%|*}" = 0 ] && ok "㉒ 問不到那張票掛在哪(沒 fixture、網路也不通)⇒ 放行:讀不到 ≠ 不屬於" \
|| no "㉒ 問不到 ⇒ 放行" "實得 exit=${R%%|*}"
printf 'not json {{{\n' > "$ISEP_COUNTDOWN_STATE_DIR/mainline.json"
R=$(dispatch '【工單】inkstone/Arcrun#50')
[ "${R%%|*}" = 0 ] && ok "㉓ state 檔壞掉 ⇒ 放行(節拍器壞掉不該讓派工停擺)" \
|| no "㉓ state 壞掉 ⇒ 放行" "實得 exit=${R%%|*}"
OUT=$(python3 "$MAINLINE" 2>&1); RC=$?
[ "$RC" = 0 ] && ok "㉔ state 檔壞掉 ⇒ 指令仍然回答得出來(當成「現在沒有主線」)" \
|| no "㉔ state 壞掉 ⇒ 指令不崩" "實得 exit=$RC$OUT"
echo
echo "結果:通過 $PASS 條,失敗 $FAIL"
[ "$FAIL" -eq 0 ] || exit 1
@@ -0,0 +1,85 @@
#!/usr/bin/env bash
# milestone-account-guard.sh 的迴歸測試(inkstone/ISEP#85
#
# A 群「不該擋」——純讀取/建里程碑/改別的欄位/只是談論它/heredoc 內文/
# 走正門 `milestone-account close`/已經記過帳/解析不出來
# B 群「該擋」 ——真的用 curl/python 把某一個里程碑 PATCH 成 closed,而帳本裡沒有它
# C 群「訊息承諾的出路真的走得通」——記過帳之後立刻不再擋;加了豁免字樣就放行
#
# 🔴 全程離線:帳本走 MILESTONE_ACCOUNT_LEDGER 指到 mktemp 的檔,不打 Gitea。
# **誤攔比漏擋更該修**——A 群任何一條紅,就是這支閘在懲罰謹慎。
set -u
HOOK="${1:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/milestone-account-guard.sh}"
TMP=$(mktemp -d); trap 'rm -rf "$TMP"' EXIT
LED="$TMP/ledger.jsonl"; : > "$LED"
PASS=0; FAIL=0; N=0
fire() {
want="$1"; desc="$2"; cmd="$3"
N=$((N+1))
payload=$(python3 -c 'import json,sys; print(json.dumps({"tool_input":{"command":sys.argv[1]}}))' "$cmd")
out=$(printf '%s' "$payload" | MILESTONE_ACCOUNT_LEDGER="$LED" bash "$HOOK" 2>&1 >/dev/null); rc=$?
if [ "$rc" -eq "$want" ]; then printf ' ✅ %s\n' "$desc"; PASS=$((PASS+1))
else
printf ' ❌ %s —— 期望 exit=%s,實得 exit=%s\n' "$desc" "$want" "$rc"
printf '%s\n' "$out" | sed -n '1,6p' | sed 's/^/ /'; FAIL=$((FAIL+1))
fi
}
CLOSE='curl -s -X PATCH -H "Authorization: token X" -d {"state":"closed"} https://git.uncle6.me/api/v1/repos/inkstone/ISEP/milestones/12'
echo "── A 群:不該擋(誤攔比漏擋更該修)─────────────────────────"
fire 0 "① 空指令" ""
fire 0 "② 純 GET 撈里程碑" \
'curl -s https://git.uncle6.me/api/v1/repos/inkstone/ISEP/milestones/12'
fire 0 "③ 列出所有里程碑" \
'curl -s "https://git.uncle6.me/api/v1/repos/inkstone/ISEP/milestones?state=open"'
fire 0 "④ 建一個新里程碑(那是 milestone-due-guard 管的,不是這支)" \
'curl -X POST -d {"title":"x","due_on":"2026-09-05T23:59:59Z"} https://git.uncle6.me/api/v1/repos/inkstone/ISEP/milestones'
fire 0 "⑤ PATCH 但只是改期限,沒有要關它" \
'curl -X PATCH -d {"due_on":"2026-09-05T23:59:59Z"} https://git.uncle6.me/api/v1/repos/inkstone/ISEP/milestones/12'
fire 0 "⑥ 只是談論它(echo 在指令位置,curl 在字串裡)" \
"echo '$CLOSE'"
fire 0 "⑦ 讀這支閘自己的原始碼" 'cat hooks/milestone-account-guard.sh'
fire 0 "⑧ commit 訊息裡出現「關掉 milestone」" \
'git commit -m "關掉 milestone 12state closed"'
fire 0 "⑨ 走正門:scripts/milestone-account close" \
'python3 scripts/milestone-account close inkstone/ISEP#12 --reason idle'
fire 0 "⑩ 正門的 --dry-run" \
'python3 scripts/milestone-account close inkstone/ISEP#12 --reason idle --dry-run'
fire 0 "⑪ 把那段指令寫進文件(heredoc 內文是資料不是指令)" \
"$(printf 'cat > docs/x.md <<%s\n%s\nEOD\n' "'EOD'" "$CLOSE")"
fire 0 "⑫ 網址沒有里程碑編號(抽不出目標 ⇒ 不擋)" \
'curl -X PATCH -d {"state":"closed"} https://git.uncle6.me/api/v1/repos/inkstone/ISEP/milestones'
fire 0 "⑬ 關的是一張票不是里程碑" \
'curl -X PATCH -d {"state":"closed"} https://git.uncle6.me/api/v1/repos/inkstone/ISEP/issues/12'
echo "── B 群:該擋 ──────────────────────────────────────────────"
fire 2 "⑭ curl PATCH state=closed,帳本裡沒有它" "$CLOSE"
fire 2 "⑮ 換成 python urllibmethod=\"PATCH\")一樣擋" \
'python3 -c "import urllib.request,json; urllib.request.urlopen(urllib.request.Request(\"https://git.uncle6.me/api/v1/repos/inkstone/ISEP/milestones/12\", data=json.dumps({\"state\":\"closed\"}).encode(), method=\"PATCH\"))"'
fire 2 "⑯ 前面串一個無害指令也擋(不是靠前綴判斷)" "ls && $CLOSE"
fire 2 "⑰ --request PATCH 的長寫法" \
'curl --request PATCH --data {"state":"closed"} https://git.uncle6.me/api/v1/repos/inkstone/ISEP/milestones/12'
fire 2 "⑱ 別的 repo 的里程碑一樣管" \
'curl -X PATCH -d {"state":"closed"} https://git.uncle6.me/api/v1/repos/inkstone/arcrun-rag/milestones/47'
echo "── C 群:訊息承諾的出路真的走得通 ──────────────────────────"
N=$((N+1))
out=$(python3 -c 'import json,sys; print(json.dumps({"tool_input":{"command":sys.argv[1]}}))' "$CLOSE" \
| MILESTONE_ACCOUNT_LEDGER="$LED" bash "$HOOK" 2>&1 >/dev/null)
if printf '%s' "$out" | grep -q "scripts/milestone-account audit inkstone/ISEP#12"; then
printf ' ✅ ⑲ 訊息直接給出可以照打的那一行(含正確的 owner/repo#編號)\n'; PASS=$((PASS+1))
else
printf ' ❌ ⑲ 訊息沒有點名目標\n'; FAIL=$((FAIL+1))
fi
echo '{"ref": "inkstone/ISEP#12", "reason": "idle"}' >> "$LED"
fire 0 "⑳ 記過帳之後立刻放行(正門是先記帳再關)" "$CLOSE"
fire 2 "㉑ 但別的里程碑還是照擋(不是一記帳就全開)" \
'curl -X PATCH -d {"state":"closed"} https://git.uncle6.me/api/v1/repos/inkstone/ISEP/milestones/13'
fire 0 "㉒ 明講的豁免字樣(留痕)" "$CLOSE # milestone-account-ok"
echo
echo "通過 $PASS 條,失敗 $FAIL 條(共 $N 條)"
[ "$FAIL" -eq 0 ]
+211
View File
@@ -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":"這是一個 PRpr-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" "逾期的 milestone5 個)" && 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 ]
+181
View File
@@ -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-handinkstone/ISEPfeat/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
+137
View File
@@ -0,0 +1,137 @@
#!/usr/bin/env bash
# unpushed-police.sh 的迴歸測試(inkstone/ISEP#90
#
# 這支存在的理由是一個**穩定重現**的誤報:雲端 session 開出來的工作分支
# 天生沒有 upstream,內容卻等於遠端 main ⇒ 每個雲端 session、每次收工都被攔一次。
# 2026-08-27 一個 session 攔五次,五次全是誤報。
#
# 兩個方向都要有證據,而且 **A 群(不該報)比 B 群更重要**:
# 永遠在響的警報=訓練人忽略這個警報,下一條真的失蹤的分支會混在雜訊裡。
#
# 🔴 全程離線:用本機 bare repo 當「遠端」,`git ls-remote` 打的是檔案路徑。
# 不碰任何真 repo、不打網路、不寫 /tmp 的正式快取(走 UNPUSHED_CACHE_DIR)。
set -u
HOOK="${1:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/unpushed-police.sh}"
TMP=$(mktemp -d)
trap 'rm -rf "$TMP"' EXIT
export GIT_CONFIG_GLOBAL="$TMP/gitconfig"; : > "$GIT_CONFIG_GLOBAL"
export GIT_AUTHOR_NAME=t GIT_AUTHOR_EMAIL=t@t GIT_COMMITTER_NAME=t GIT_COMMITTER_EMAIL=t@t
PASS=0; FAIL=0; N=0
# mk <名字> → 造一個「遠端 bare + 本機 clone」的場,印出本機 repo 路徑
mk() {
d="$TMP/$1"; mkdir -p "$d"
git init -q --bare "$d/remote.git"
git init -q -b main "$d/work"
( cd "$d/work"
echo a > a.txt; git add a.txt; git commit -qm first
git remote add origin "$d/remote.git"
git push -q origin main
git branch --unset-upstream 2>/dev/null || true ) >/dev/null 2>&1
printf '%s\n' "$d/work"
}
# fire <repo> <期望 exit> <說明> [期望訊息片段]
fire() {
repo="$1"; want="$2"; desc="$3"; want_msg="${4:-}"
N=$((N+1))
out=$(printf '{}' | CLAUDE_PROJECT_DIR="$repo" UNPUSHED_CACHE_DIR="$TMP/cache-$N" \
bash "$HOOK" 2>&1); rc=$?
mkdir -p "$TMP/cache-$N" 2>/dev/null
ok=1
[ "$rc" -eq "$want" ] || ok=0
if [ -n "$want_msg" ] && ! printf '%s' "$out" | grep -qF "$want_msg"; then ok=0; fi
if [ "$ok" = 1 ]; then
printf ' ✅ %s\n' "$desc"; PASS=$((PASS+1))
else
printf ' ❌ %s —— 期望 exit=%s%s,實得 exit=%s\n' "$desc" "$want" \
"${want_msg:+ 且訊息含「$want_msg}" "$rc"
printf '%s\n' "$out" | sed -n '1,14p' | sed 's/^/ /'
FAIL=$((FAIL+1))
fi
}
# 每個案例先把快取目錄準備好(fire 用的是自己那一格,互不污染)
for i in $(seq 1 40); do mkdir -p "$TMP/cache-$i"; done
echo "── A 群:不該報(誤攔比漏擋更該修)──────────────────────────"
# ① 這就是雲端那個現場:分支沒有 upstream,但它那顆 commit 就是遠端 main 那顆
r=$(mk cloud)
( cd "$r" && git checkout -q -b claude/session-xyz && git branch -D main -q 2>/dev/null
git remote set-head origin -d 2>/dev/null; git update-ref -d refs/remotes/origin/main ) >/dev/null 2>&1
fire "$r" 0 "① 沒 upstream,但遠端就有這顆 commit(雲端每個 session 的現場)"
# ② HEAD 是遠端 main 的**祖先**(遠端已經走在前面)——同樣不是「沒推」
r=$(mk ancestor)
( cd "$r"
echo b > b.txt && git add b.txt && git commit -qm second && git push -q origin main
git checkout -q -b work HEAD~1
git update-ref -d refs/remotes/origin/main ) >/dev/null 2>&1
fire "$r" 0 "② HEAD 是遠端 main 的祖先 → 遠端已經有它"
# ③ 本機的 remote-tracking ref **過期**08-28 實測落後遠端 4 天的那一格)
r=$(mk stale)
( cd "$r"
old=$(git rev-parse HEAD)
echo b > b.txt && git add b.txt && git commit -qm second && git push -q origin main
git update-ref refs/remotes/origin/main "$old" # 本機那份停在舊的
git checkout -q -b claude/session-abc ) >/dev/null 2>&1
fire "$r" 0 "③ 本機 origin/main 過期,但遠端真的有這顆 → 不報"
# ④ 完全問不到遠端(沒有任何 remote)→ **不當成沒推**
r="$TMP/noremote"; git init -q -b main "$r"
( cd "$r" && echo a > a.txt && git add a.txt && git commit -qm only ) >/dev/null 2>&1
fire "$r" 0 "④ 沒有任何 remote=問不到 → 不報(不拿離線當罪證)"
# ⑤ 基準過期時,已經在遠端 main 上的分支不算散落
r=$(mk straystale)
( cd "$r"
old=$(git rev-parse HEAD)
git checkout -q -b feat/done
echo b > b.txt && git add b.txt && git commit -qm done
git push -q origin feat/done:main # 遠端 main 已經含它
git update-ref refs/remotes/origin/main "$old" ) >/dev/null 2>&1
fire "$r" 0 "⑤ 分支已在遠端 main 上,只是本機基準舊 → 不算散落"
# ⑥ stop_hook_active → 一律放行
r=$(mk stopactive)
( cd "$r" && echo x >> a.txt ) >/dev/null 2>&1
N=$((N+1))
out=$(printf '{"stop_hook_active":true}' | CLAUDE_PROJECT_DIR="$r" \
UNPUSHED_CACHE_DIR="$TMP/cache-$N" bash "$HOOK" 2>&1); rc=$?
if [ "$rc" -eq 0 ]; then printf ' ✅ %s\n' "⑥ stop_hook_activetrue → 放行"; PASS=$((PASS+1))
else printf ' ❌ %sexit=%s\n' "⑥ stop_hook_activetrue → 放行" "$rc"; FAIL=$((FAIL+1)); fi
echo "── B 群:該報(不能為了不吵就變成放行)────────────────────"
# ⑦ 真的有遠端沒有的 commit = 真的沒推
r=$(mk unpushed)
( cd "$r"
git checkout -q -b feat/real
echo b > b.txt && git add b.txt && git commit -qm "沒推的那筆" ) >/dev/null 2>&1
fire "$r" 2 "⑦ 分支有遠端沒有的 commit → 要報" "從未推過"
# ⑧ 有改動沒 commit
r=$(mk dirty)
( cd "$r" && echo more >> a.txt ) >/dev/null 2>&1
fire "$r" 2 "⑧ 工作區有未 commit 的改動 → 要報" "未 commit"
# ⑨ 散落分支:本地分支有遠端 main 沒有的東西
r=$(mk stray)
( cd "$r"
git checkout -q -b feat/lost
echo c > c.txt && git add c.txt && git commit -qm "躺著的那筆"
git checkout -q main ) >/dev/null 2>&1
fire "$r" 2 "⑨ 有分支沒併回、遠端也沒有 → 要報散落" "feat/lost"
# ⑩ 判準沒有滑回「有沒有 upstream」:有 upstream 但真的領先,照樣要報
r=$(mk ahead)
( cd "$r"
git branch --set-upstream-to=origin/main main
echo b > b.txt && git add b.txt && git commit -qm "領先一筆" ) >/dev/null 2>&1
fire "$r" 2 "⑩ 有 upstream 且真的領先 → 照樣要報" "領先"
echo
printf '通過 %s 條,失敗 %s 條(共 %s 條)\n' "$PASS" "$FAIL" "$N"
[ "$FAIL" = 0 ] || exit 1
+186
View File
@@ -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 ]
+152 -7
View File
@@ -35,10 +35,119 @@ except Exception: print(False)
' 2>/dev/null)"
[ "$stop_active" = "True" ] && exit 0
# ══════════════════════════════════════════════════════════════════════
# 「遠端到底有沒有這顆 commit」——本檔唯一的判準(inkstone/ISEP#902026-08-28
# ══════════════════════════════════════════════════════════════════════
#
# 🔴 舊判準是「這條分支有沒有 upstream」,而**雲端 session 開出來的工作分支
# 天生就沒有 upstream**,內容卻等於遠端 main
#
# 薄殼 HEAD = 081c547d90dc2d80692485af083ca1f71f2094f2
# GitHub 遠端 main = 081c547d90dc2d80692485af083ca1f71f2094f2 ← 同一顆
#
# ⇒ 每個雲端 session、每次收工都被攔一次。2026-08-27 一個 session 攔五次,
# **五次全是誤報**。而永遠在響的警報=訓練人忽略這個警報
# (本檔第 ③ 段自己在 08-12 就寫過這句,這次是同一個病長在別的判準上)。
#
# 🔴 也不能改成「本機的 remote-tracking ref 有沒有含它」的變體:
# 那份 ref 會過期——08-28 實測本 session 的 `origin/main` 落後遠端整整 4 天。
# 本機只答得準「有」,答不準「沒有」⇒ 答「沒有」的時候要去問遠端。
#
# 三態,不是兩態:0=遠端有 1=遠端沒有 2=問不到(沒 remote/網路不通/沒權限)。
# **2 一律不報。** 誤攔比漏擋更該修(leo 2026-08-17:文字層那些閘 8 次誤攔、
# 0 次正確攔截)——把「離線」當成「你沒推」,就是拿雜訊懲罰謹慎。
# 🔴 戳記的 mtime 一律走 lib/mtime.sh:舊寫法(`stat -f %m || stat -c %Y`)在 Linux 上
# **永遠算不出數字**(GNU 的 `-f` 是「檔案系統資訊」,它一邊回非零一邊吐一整段文字,
# 於是後面接上的秒數被那段垃圾污染)⇒ 這支閘給總管的逃生口在雲端整個焊死,
# 而閘不會告訴他門是壞的(inkstone/ISEP#90 ④,全文見 hooks/lib/mtime.sh)。
# lib 不在時就地補一份同順序的定義——這道門不能因為少一個檔案就再關上一次。
. "$(dirname "$0")/lib/mtime.sh" 2>/dev/null || file_mtime() {
_fm=$(stat -c %Y "$1" 2>/dev/null || true)
case "${_fm:-}" in ''|*[!0-9]*) _fm=$(stat -f %m "$1" 2>/dev/null || true) ;; esac
case "${_fm:-}" in ''|*[!0-9]*) _fm=0 ;; esac
printf '%s' "$_fm"
}
GIT_ASK="env GIT_TERMINAL_PROMPT=0 GIT_ASKPASS=/bin/echo GIT_SSH_COMMAND=ssh\ -oBatchMode=yes"
_TO=""
command -v timeout >/dev/null 2>&1 && _TO="timeout 15"
command -v gtimeout >/dev/null 2>&1 && _TO="gtimeout 15"
# ls_remote_heads <repo> <remote> → 印出「<sha> <ref>」數行;問不到就印空、離開碼 1
# 快取 120 秒:Stop hook 每個回合都跑,同一個遠端不必每回合都打一次網路。
ls_remote_heads() {
_lr_repo="$1"; _lr_rm="$2"
_lr_key="$(printf '%s|%s' "$_lr_repo" "$_lr_rm" | cksum | tr -d ' /')"
# 快取位置可被 UNPUSHED_CACHE_DIR 覆寫——測試要能造「同一個 repo、遠端變了」的場,
# 不能被上一個案例的快取餵到舊答案(那會是假綠)。
_lr_cache="${UNPUSHED_CACHE_DIR:-/tmp}/.unpushed-lsremote-$_lr_key"
if [ -f "$_lr_cache" ]; then
_lr_now=$(date +%s 2>/dev/null || echo 0)
_lr_mt=$(file_mtime "$_lr_cache")
case "$_lr_now$_lr_mt" in *[!0-9]*) _lr_mt=0 ;; esac
if [ "$_lr_mt" -gt 0 ] && [ $((_lr_now - _lr_mt)) -lt 120 ]; then
[ -s "$_lr_cache" ] || return 1
cat "$_lr_cache"; return 0
fi
fi
if _lr_out="$($_TO $GIT_ASK git -C "$_lr_repo" ls-remote --heads "$_lr_rm" 2>/dev/null)" \
&& [ -n "$_lr_out" ]; then
printf '%s\n' "$_lr_out" > "$_lr_cache" 2>/dev/null || true
printf '%s\n' "$_lr_out"
return 0
fi
: > "$_lr_cache" 2>/dev/null || true # 記「這次問不到」,免得每回合都等一次 timeout
return 1
}
# remote_has_commit <repo> <sha> → 0 遠端有/1 遠端沒有/2 問不到
remote_has_commit() {
_rh_repo="$1"; _rh_sha="$2"
[ -n "$_rh_sha" ] || return 2
# ① 本機就答得出「有」:任何 remote-tracking ref 含這顆 ⇒ 一定推過,不必打網路
if [ -n "$(git -C "$_rh_repo" branch -r --contains "$_rh_sha" 2>/dev/null | head -1)" ]; then
return 0
fi
_rh_asked=0
for _rh_rm in $(git -C "$_rh_repo" remote 2>/dev/null); do
_rh_ls="$(ls_remote_heads "$_rh_repo" "$_rh_rm")" || continue
_rh_asked=1
# 遠端某條 head 就是這顆 ⇒ 推過(雲端那個誤報正是落在這一格)
printf '%s\n' "$_rh_ls" | awk '{print $1}' | grep -qx "$_rh_sha" && return 0
# 遠端那顆本機也有時,才問得出祖先關係(本機沒有就只能靠上面的等值比對)
printf '%s\n' "$_rh_ls" | while read -r _rh_rsha _; do
[ -n "$_rh_rsha" ] || continue
git -C "$_rh_repo" cat-file -e "${_rh_rsha}^{commit}" 2>/dev/null || continue
git -C "$_rh_repo" merge-base --is-ancestor "$_rh_sha" "$_rh_rsha" 2>/dev/null && exit 7
done
[ $? = 7 ] && return 0
done
[ "$_rh_asked" = 1 ] && return 1
return 2
}
# remote_sha_of <repo> <遠端名> <分支名> → 印出遠端那顆 sha(拿不到印空)
remote_sha_of() {
ls_remote_heads "$1" "$2" 2>/dev/null \
| awk -v b="refs/heads/$3" '$2 == b { print $1; exit }'
}
TOP="${CLAUDE_PROJECT_DIR:-$(cd "$(dirname "$0")/../.." && pwd)}"
# 掃描對象:頂層 + 各子 repo(存在才掃)。worktree 一併涵蓋(git -C 會自己解)。
REPOS="$TOP $TOP/matrix/arcrun $TOP/products/arcrun-rag $TOP/polaris/mira"
#
# 🔴 2026-08-28inkstone/ISEP#90)補雲端那一格:雲端的 `$CLAUDE_PROJECT_DIR`
# 是**薄殼根**,真身被 bootstrap.sh clone 到 `$TOP/InkStoneCo/`,子 repo 在
# `$TOP/InkStoneCo/matrix/arcrun` …。舊清單只認地端那種「頂層就是真身」的排法
# ⇒ **雲端上真身有東西沒推,本閘一輩子不會知道**(它掃的四個路徑一個都不存在)。
# ⇒ 這跟 ① 的誤攔是同一個病的兩面:在雲端,這支閘既亂叫、又看不到該看的地方。
REPOS=""
for _body in "$TOP" "$TOP/InkStoneCo"; do
[ -d "$_body/.git" ] || [ -f "$_body/.git" ] || continue
REPOS="$REPOS $_body $_body/matrix/arcrun $_body/products/arcrun-rag $_body/polaris/mira"
done
[ -n "$REPOS" ] || REPOS="$TOP"
problems=""
for r in $REPOS; do
@@ -50,17 +159,33 @@ for r in $REPOS; do
staged="$(git -C "$r" diff --cached --shortstat 2>/dev/null | head -1)"
# ② 已 commit 但沒推(**當前分支**
#
unknown=""
# 🔴 判準是「遠端有沒有這顆 commit」,不是「有沒有 upstream」(ISEP#90,見檔頭那段)。
# 有 upstream 時仍照原本的算法報「領先幾筆」——那個數字有用;
# 沒 upstream 時去問遠端,問不到就**不報**。
unpushed=""
br="$(git -C "$r" branch --show-current 2>/dev/null)"
if [ -n "$br" ]; then
head_sha="$(git -C "$r" rev-parse HEAD 2>/dev/null || true)"
up="$(git -C "$r" rev-parse --abbrev-ref "@{upstream}" 2>/dev/null || true)"
if [ -n "$up" ]; then
n="$(git -C "$r" rev-list --count "$up".."$br" 2>/dev/null || echo 0)"
[ "$n" != "0" ] && unpushed="領先 $up $n"
if [ "$n" != "0" ]; then
# upstream 本身可能過期(本機那份 ref 落後遠端 4 天是實測過的)
# ⇒ 先問遠端;遠端已經有這顆就不是「沒推」。
remote_has_commit "$r" "$head_sha"
[ "$?" = 0 ] || unpushed="領先 $up $n"
fi
else
# 沒 upstream=這條分支從來沒推過(今天 fix/cis-round3-install 就是這樣)
has_commits="$(git -C "$r" rev-list --count HEAD 2>/dev/null || echo 0)"
[ "$has_commits" != "0" ] && unpushed="分支 $br **從未推過**(無 upstream"
remote_has_commit "$r" "$head_sha"
case "$?" in
1) unpushed="分支 $br **從未推過**(遠端沒有 $(printf '%.7s' "$head_sha")" ;;
# 0=遠端有這顆(雲端 session 的工作分支就是這種)→ 不報
# 2=問不到遠端 → 不報,但這件事本身值得知道,收在下面當脈絡
2) [ -n "$(git -C "$r" rev-list --count HEAD 2>/dev/null)" ] \
&& unknown="分支 $br 問不到遠端(沒有 remote/網路不通)——**不當成沒推**" ;;
esac
fi
fi
@@ -79,9 +204,27 @@ for r in $REPOS; do
# **這正是同一晚重複八次的那個病:檢查跑了,但檢查的對象是錯的。**
# ⇒ 基準改成「整合分支」:gitea/main > origin/main > main > 當前分支。
base="$br"
base_label=""
for cand in gitea/main origin/main main; do
if git -C "$r" rev-parse --verify -q "$cand" >/dev/null 2>&1; then base="$cand"; break; fi
done
base_label="$base"
# 🔴 2026-08-28inkstone/ISEP#90):**基準的 ref 也會過期。**
# 08-28 實測:本機 `origin/main` 落後遠端整整 4 天 ⇒ 拿它當基準,
# 「已經在遠端 main 上」的分支會被整批報成散落——包括**雲端 session 自己
# 那條工作分支**(它的內容就等於遠端 main)。
# 08-15 那次訂正的是「基準挑錯了分支」,這次是「基準挑對了但那份是舊的」:
# 同一個病的第三面——檢查跑了,但檢查的對象是舊的。
# ⇒ 問一次遠端;拿得到、而且本機真的有那顆 commit(不然算不了祖先),才換過去。
case "$base_label" in
*/*) _base_rm="${base_label%%/*}"; _base_br="${base_label#*/}" ;;
*) _base_rm="origin"; _base_br="$base_label" ;;
esac
_base_fresh="$(remote_sha_of "$r" "$_base_rm" "$_base_br")"
if [ -n "$_base_fresh" ] && git -C "$r" cat-file -e "${_base_fresh}^{commit}" 2>/dev/null; then
base="$_base_fresh"
base_label="$_base_rm/$_base_br(遠端實際那顆 $(printf '%.7s' "$_base_fresh")"
fi
if [ -n "$base" ]; then
for b in $(git -C "$r" for-each-ref --format='%(refname:short)' refs/heads/ 2>/dev/null); do
[ "$b" = "$base" ] && continue
@@ -111,7 +254,7 @@ for r in $REPOS; do
age="$(git -C "$r" log -1 --format='%ad' --date=format:'%m-%d' "$b" 2>/dev/null)"
# 用真實換行累積,不用 \n 逃脫——否則後面的 printf '%b' 會把分支名再解讀一次
stray="$stray
- $b$cnt 筆未併入 $base(最後動 $age"
- $b$cnt 筆未併入 $base_label(最後動 $age"
done
fi
@@ -123,10 +266,12 @@ for r in $REPOS; do
[ -n "$unpushed" ] && line="$line
· $unpushed"
[ -n "$stray" ] && line="$line
· 🔀 **散落分支**(有 commit 沒併回 $br):$stray"
· 🔀 **散落分支**(有 commit 沒併回 $base_label):$stray"
# 刻意保留的分支**不會自己觸發警報**(那正是加它的目的),
# 但這個 repo 若本來就有別的問題要報,就順便把它列出來當脈絡——
# 免得「保留」變成另一種看不見。
[ -n "$line" ] && [ -n "$unknown" ] && line="$line
· ❔ $unknown"
[ -n "$line" ] && [ -n "$held" ] && line="$line
· ⏸️ 刻意保留(不觸發警報,理由在 .claude/branch-holds.md):$held"
[ -n "$line" ] && problems="$problems
+183
View File
@@ -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
+20
View File
@@ -101,6 +101,26 @@ labels:
exclusive: true
description: "雜務、整理、不改行為"
# ── 使用者旅程(正交,`j/` scope)──────────────────
#
# inkstone/ISEP#92:「這件事服務哪個使用者旅程,沒有記錄⋯⋯後面要把相關的票
# 聚成一包時,只能靠人重新讀一遍每張票在講什麼。」
#
# `ticket subtask/handoff --journey <名>` 會把 `j/<名>` **同時貼在母子兩端**
# (只貼一端就聚不起來)。標籤不存在時它會擋下來並指回本檔——
# **「要求標籤在場」,不是「猜哪些名字合法」**。
#
# 🔴 這裡刻意是空的。旅程怎麼切、叫什麼名字,是方向題(四題公式第 4 題),
# 不該由工具或 subagent 代決;隨手造名字會讓同一條旅程長出三個拼法。
# 要新增一條就照這個形狀加一筆,然後跑 scripts/gitea-labels-sync.sh
#
# - name: "j/收件匣"
# color: "fbca04"
# exclusive: false
# description: "🧭 旅程:<一句話講這條旅程的頭和尾>"
#
# (票上明講「對不上就不貼,不硬湊」——沒有對應旅程的票就不要加 --journey。)
# ── 結構(正交)──────────────────────────────────
- name: "hub"
color: "0052cc"
+559
View File
@@ -0,0 +1,559 @@
#!/usr/bin/env python3
"""debt-worklist — 還債線:每天固定撈一次「今天可以領什麼」,整包整包地領。
leo 2026-08-27inkstone/ISEP#83 的來由,他當場訂正總管的誤解):
「這是兩件不同的事,你根本沒搞清楚,距離是在確定有個緊急事件要儘速達成時,
現在每天都在追新的,所以**要用 Routine 去消化舊的**,可以有多個條件」
⇒ **兩條線,各用各的判準**,不是二選一:
緊急線 有事要趕 → 用「距離目標的遠近」排(=掛在 open milestone 上的那些)
還債線 每天消化舊的 → 用多條件排(事故/擋人/承諾/票齡/估工) ← 本工具
病根:全 org 229 張 open 票,每天在動的只有掛在 active milestone 上的那些。
沒掛的不是不重要,是**沒有任何機制會去拿它們**。`worklist-guard.sh` 擋的是
「這一輪已經領到的工作沒做完就收工」——它不會去把舊票撈出來。本工具補的是供給面。
三個動詞:
debt-worklist list 撈一次,排好序印出來(預設動作)
debt-worklist claim <票...> 領走(今天領的,明天就不會再出現在清單上)
debt-worklist forget <票...> 放回去(領錯了、或那條線改方向了)
票的寫法:`owner/repo#N`,例:`inkstone/InkStoneCo#44`
🔴 **不是輪詢。** 這支腳本自己不會醒——是人跑它、或 SessionStart 那一次順手跑。
不准掛 Gitea Actionswebhook fan-outCLAUDE.md 避免再被 flag 的硬規則,
`issue-handle` 明寫「有事才讀」,換成 Gitea 也不放寬)。
離線可測:設 `ISEP_DEBT_FIXTURE=<json 檔>` 就完全不打網路(測試用)。
狀態檔位置可用 `ISEP_DEBT_STATE_DIR` 改(測試用,預設 `~/.claude/isep-debt/`)。
"""
import json
import os
import re
import subprocess
import sys
import urllib.error
import urllib.parse
import urllib.request
from datetime import datetime, timezone
HOST = os.environ.get("TICKET_HOST") or "https://git.uncle6.me"
ORG = os.environ.get("ISEP_DEBT_ORG") or "inkstone"
# ── 「現在的主線是哪一個」只有一份答案(inkstone/ISEP#82 → hooks/lib/mainline.py)──
# 本工具排除的是**緊急線正在管的那些**。在 #82 之前,「哪個是 active」沒有指涉對象
# 14 個 open milestone 同時亮著),所以第一版只能粗暴地「掛 open milestone 就排除」。
# 現在有唯一答案了 ⇒ 接上去,**不要自己養第二套判斷**(兩份必然漂移)。
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "hooks", "lib"))
try:
import mainline as ML
except Exception: # 拿不到共用零件就退回舊行為(fail-open),不讓還債線整個癱掉
ML = None
# 等 leo 的票不進「可領清單」——不是它們不重要,是**沒有人能替他領**:
# Humanhuman/exec 要他親手批示或動手
# s/stage 已經部到 stage,等他驗(session-start-recall 就是這樣定義的)
# 這些的正確動作是「催」不是「領」,而催辦是 inkstone/ISEP#93 的事。本票是供給面。
WAITING_LEO_LABELS = {"Human", "human/exec", "s/stage"}
# 已經有人在做/已經有 PR 的狀態:這是「答應過人家的」,債比沒開工的還硬。
STARTED_LABELS = {"s/doing", "s/review"}
def die(msg, code=2):
print(msg, file=sys.stderr)
sys.exit(code)
# ── 連線(跟 scripts/ticket 同一套:先找帶憑證的 remote,再退環境變數)────────
def token():
root = os.environ.get("CLAUDE_PROJECT_DIR") or os.getcwd()
host = HOST.split("//")[-1].rstrip("/")
try:
out = subprocess.run(["git", "-C", root, "remote", "-v"],
capture_output=True, text=True, timeout=20).stdout
except Exception:
out = ""
for line in out.splitlines():
if host in line:
m = re.search(r"//[^:/]+:([^@]+)@", line)
if m:
return m.group(1)
for env in ("GITEA_TOKEN_CLAUDE_CODE", "GITEA_TOKEN"):
if os.environ.get(env):
return os.environ[env]
die(f"""🔴 拿不到 {host} 的 token
擇一:
• 讓某個 remote 帶憑證(多數 repo 的 gitea/origin 本來就有)
• 或設環境變數 GITEA_TOKEN_CLAUDE_CODE""")
def api(path):
url = f"{HOST}/api/v1{path}"
req = urllib.request.Request(url, headers={"Authorization": f"token {token()}"})
try:
return json.load(urllib.request.urlopen(req, timeout=60))
except urllib.error.HTTPError as e:
die(f"🔴 Gitea {e.code}{e.read().decode()[:300]}\n path={path}")
except Exception as e:
die(f"🔴 打不到 {HOST}{e}\n path={path}")
# ── 收集:全 org 每個 repo 的 open issue open PR ──────────────────────────
def collect():
"""回 (repos, items)。repos 是「我查了哪些」的證明,items 是原始票。
fixture 模式(測試用):完全不打網路,資料從 json 檔讀。
"""
fx = os.environ.get("ISEP_DEBT_FIXTURE")
if fx:
with open(fx) as f:
data = json.load(f)
return data["repos"], data["items"]
repos, items = [], []
for r in api(f"/orgs/{ORG}/repos?limit=100") or []:
full = r["full_name"]
if not r.get("has_issues", True):
repos.append({"name": full, "issues": 0, "prs": 0, "skipped": "issues 關閉"})
continue
got, page = [], 1
while True:
q = urllib.parse.urlencode({"state": "open", "limit": 50, "page": page})
batch = api(f"/repos/{full}/issues?{q}") or []
got.extend(batch)
if len(batch) < 50:
break
page += 1
if page > 20: # 保險絲:不可能有一千張,有的話是我寫錯了
break
for it in got:
it.setdefault("repository", {"full_name": full})
n_pr = sum(1 for it in got if it.get("pull_request"))
repos.append({"name": full, "issues": len(got) - n_pr, "prs": n_pr})
items.extend(got)
return repos, items
# ── 工具 ───────────────────────────────────────────────────────────────────
def ref_of(it):
return f"{it['repository']['full_name']}#{it['number']}"
def labels_of(it):
return {l["name"] for l in (it.get("labels") or [])}
def is_pr(it):
return bool(it.get("pull_request"))
def parse_ts(s):
if not s:
return None
try:
return datetime.fromisoformat(s.replace("Z", "+00:00"))
except Exception:
return None
def now():
"""時鐘可以被定住——測試要能重現,不能今天跑綠明天跑紅。"""
inj = os.environ.get("ISEP_DEBT_NOW")
if inj:
d = parse_ts(inj)
if d:
return d
return datetime.now(timezone.utc)
def days_since(s):
d = parse_ts(s)
if not d:
return 0.0
return max(0.0, (now() - d).total_seconds() / 86400)
REF_RE = re.compile(r"(?:([\w.-]+)/([\w.-]+))?#(\d+)")
def refs_in(it):
"""從標題+內文抓出它指到的別張票。裸 `#12` 算同 repo。
只認得出「文字上指了誰」——這是零成本的訊號(內文本來就在清單裡)。
Gitea 原生的 dependencies 要一張票打一次 API229 張=229 次,不值得。
"""
owner, repo = it["repository"]["full_name"].split("/")
out = set()
text = f"{it.get('title') or ''}\n{it.get('body') or ''}"
for o, r, n in REF_RE.findall(text):
out.add(f"{o or owner}/{r or repo}#{n}")
out.discard(ref_of(it))
return out
# ── 排序:多個條件,每一條都看得見自己貢獻了幾分 ─────────────────────────────
#
# leo 給的順序(票上原文):出事的 → 擋住別人的 → 答應過人家的 → 放最久的 → 最快做完的。
# 分數只是把這個順序寫成可重現的東西;**每一項都會印在票旁邊**,
# 排出來覺得不對可以直接指著說「這一項給太多」,而不是猜它為什麼在第一名。
def score(it, in_degree):
lab = labels_of(it)
parts = []
if "type/bug" in lab:
parts.append(("事故", 40))
if "p/high" in lab:
parts.append(("高優先", 20))
if "p/low" in lab:
parts.append(("低優先", -12))
blocked = in_degree.get(ref_of(it), 0)
if blocked:
parts.append((f"擋住{blocked}張", min(12 * blocked, 36)))
if is_pr(it):
# 躺著沒結論的 PR 擋的是 merge 那條路(inkstone/ISEP#81 是它的另一半)
parts.append(("PR躺著", 25))
started = lab & STARTED_LABELS
if started:
parts.append((f"已開工({'/'.join(sorted(started))})", 18))
if it.get("assignees") or it.get("assignee"):
parts.append(("有人接了", 12))
ms = it.get("milestone") or {}
if ms and (ms.get("state") or "open") == "open" and ML is not None:
due = ML.due_of(ms)
if due and due < now():
# 掛在**別條**(非主線)而且期限已經過去的 milestone ⇒ 這是承諾過的日期,
# 而那個日期已經過了。它之所以還在這份清單上,正是因為沒有人在管那條線。
parts.append((f"逾期{int((now() - due).total_seconds() // 86400)}天的承諾", 15))
age = days_since(it.get("updated_at"))
if age >= 1:
parts.append((f"擱置{int(age)}天", round(min(age * 0.6, 30), 1)))
body_len = len(it.get("body") or "")
if "type/chore" in lab or body_len < 800:
parts.append(("看起來小", 10))
total = round(sum(p[1] for p in parts), 1)
return total, parts
def pick(items, claimed):
"""把原始票分成三堆:可領的/緊急線的/要 leo 動手的。並回統計。"""
open_refs = {ref_of(it) for it in items}
in_degree = {}
for it in items:
for target in refs_in(it):
if target in open_refs:
in_degree[target] = in_degree.get(target, 0) + 1
ml = ML.load() if ML is not None else None
pool, stats = [], {"milestone": 0, "human": 0, "claimed": 0, "total": len(items),
"other_ms": 0, "mainline": ml}
for it in items:
ref = ref_of(it)
ms = it.get("milestone")
if ms and (ms.get("state") or "open") == "open":
if ml is None:
# 現在沒有主線 ⇒ 分不出哪一條是緊急線在管的。退回保守做法:
# 掛 open milestone 的一律不領(寧可少領,不要跟緊急線搶同一張)。
stats["milestone"] += 1
continue
if ML.belongs(ref, ml, ms.get("title")):
stats["milestone"] += 1 # 這張真的在主線上 ⇒ 緊急線在管,不重複領
continue
# 掛在**別條** milestone 上:那條線現在沒有人在推 ⇒ 它就是債
stats["other_ms"] += 1
if labels_of(it) & WAITING_LEO_LABELS:
stats["human"] += 1 # 沒有人能替 leo 領(催辦是 ISEP#93
continue
if ref in claimed:
stats["claimed"] += 1 # 昨天領走的,今天不再出現
continue
s, parts = score(it, in_degree)
pool.append({"ref": ref, "title": it.get("title") or "", "score": s,
"why": parts, "labels": sorted(labels_of(it)),
"pr": is_pr(it), "url": it.get("html_url"),
"age": round(days_since(it.get("updated_at")), 1),
"refs": sorted(refs_in(it) & open_refs)})
pool.sort(key=lambda d: (-d["score"], d["ref"]))
return pool, stats, in_degree
# ── 成包:互相指著的必然同包(票上原文「相關的票要成包一起領,不要拆散」)──────
#
# 🔴 **只認強訊號,寧可少成包**。第一版用「單向文字引用」成包,結果是
# **前 190 張裡有 121 張黏成一包**——因為大家都會順手引用 hub 票。
# 一包 121 張=沒有人領得動=跟沒分包一樣。所以只認這兩種:
# ① 互相指著(A 的內文提 B,且 B 的內文提 A)——單向不算
# ② Gitea 原生相依(`ticket subtask` 建的母子票就是這個)
# 代價:同一條使用者旅程但沒有互指、也沒建相依的兩張票不會成包。
# 這是誠實的取捨——**寧可漏成包,不要成一包沒人領得動的**。
BUNDLE_CAP = 8 # 一包最多印幾張;超過就說「還有幾張」
DEPS_TOP_K = 25 # 只對前 K 名查原生相依(一張一次 API,不對全部查)
def dep_edges(pool, enabled=True):
"""回 [(a, b)…]:前 K 名的原生相依邊,只留兩端都在 pool 裡的。"""
fx = os.environ.get("ISEP_DEBT_FIXTURE")
if fx:
with open(fx) as f:
deps = (json.load(f).get("deps") or {})
in_pool = {d["ref"] for d in pool}
return [(a, b) for a, bs in deps.items() for b in bs
if a in in_pool and b in in_pool]
if not enabled:
return []
in_pool = {d["ref"] for d in pool}
edges = []
for d in pool[:DEPS_TOP_K]:
owner, rest = d["ref"].split("/", 1)
repo, num = rest.split("#")
try:
got = api(f"/repos/{owner}/{repo}/issues/{num}/dependencies") or []
except SystemExit:
continue
for i in got:
other = f"{i['repository']['full_name']}#{i['number']}"
if other in in_pool:
edges.append((d["ref"], other))
return edges
def mutual_edges(pool):
idx = {d["ref"]: set(d["refs"]) for d in pool}
out = []
for ref, targets in idx.items():
for t in targets:
if t in idx and ref in idx[t] and ref < t:
out.append((ref, t))
return out
def bundle(pool, edges):
idx = {d["ref"]: d for d in pool}
parent = {r: r for r in idx}
def find(x):
while parent[x] != x:
parent[x] = parent[parent[x]]
x = parent[x]
return x
def union(a, b):
ra, rb = find(a), find(b)
if ra != rb:
parent[rb] = ra
for a, b in edges:
if a in idx and b in idx:
union(a, b)
groups = {}
for d in pool:
groups.setdefault(find(d["ref"]), []).append(d)
out = []
for members in groups.values():
members.sort(key=lambda d: (-d["score"], d["ref"]))
out.append({"lead": members[0], "members": members,
"score": members[0]["score"]})
out.sort(key=lambda b: (-b["score"], b["lead"]["ref"]))
return out
# ── 狀態:領走了什麼(明天的清單要看得出昨天領走的不見了)───────────────────
def state_dir():
d = os.environ.get("ISEP_DEBT_STATE_DIR") or os.path.expanduser("~/.claude/isep-debt")
os.makedirs(d, exist_ok=True)
return d
def state_path():
return os.path.join(state_dir(), "claimed.json")
def load_state():
try:
with open(state_path()) as f:
d = json.load(f)
except Exception:
d = {}
d.setdefault("claims", {})
d.setdefault("last_run", None)
return d
def save_state(d):
with open(state_path(), "w") as f:
json.dump(d, f, ensure_ascii=False, indent=1)
# ── 印 ─────────────────────────────────────────────────────────────────────
def print_proof(repos, stats):
scanned = [r for r in repos if not r.get("skipped")]
n_iss = sum(r["issues"] for r in scanned)
n_pr = sum(r["prs"] for r in scanned)
print(f"🔎 查過的 repo{len(repos)} 個({ORG} 全組織)"
f" open issue {n_iss}、open PR {n_pr}")
for r in sorted(repos, key=lambda x: -(x["issues"] + x["prs"])):
mark = f"(跳過:{r['skipped']}" if r.get("skipped") else ""
print(f" · {r['name']} issue {r['issues']}PR {r['prs']} {mark}")
ml = stats.get("mainline")
if ml:
print(f"\n🎯 現在的主線:{ML.ref_of(ml)}「{ml.get('title')}」"
f"(唯一答案來自 scripts/mainline,本工具不自己判)")
print(f" 掛在**別條** milestone 上的 {stats['other_ms']} 張仍算債"
"——那條線現在沒有人在推")
else:
print("\n🎯 現在沒有標主線(`scripts/mainline set …`"
"⇒ 分不出哪條是緊急線在管的,保守起見:掛 open milestone 的一律不領")
print(f"\n 扣掉:掛在 open milestone 上的 {stats['milestone']} 張(那些歸緊急線管)"
f"/等 leo 的 {stats['human']} 張"
f"Human・human/exec・s/stage——那些要催不是要領,見 inkstone/ISEP#93"
f"/已經領走的 {stats['claimed']} 張")
def print_carry(state, items):
"""昨天領走的,今天怎麼了——**這一段就是「看得出領走的不在清單上」**。"""
claims = state.get("claims") or {}
if not claims:
return
open_refs = {ref_of(it) for it in items}
done = [r for r in claims if r not in open_refs]
doing = [r for r in claims if r in open_refs]
print(f"\n📦 之前領走的 {len(claims)} 張:已關 {len(done)}、還開著 {len(doing)}")
for r in sorted(doing):
c = claims[r]
print(f" ◻ {r} {c.get('at', '?')[:10]} 領的){c.get('title', '')[:40]}")
for r in sorted(done):
print(f" ✅ {r} 已關")
if doing:
print(" 🔴 還開著的沒有回到可領清單裡——做完就關票,或 "
"`debt-worklist forget <票>` 把它放回去。")
def print_bundle(i, b, verbose):
lead = b["lead"]
kind = "PR" if lead["pr"] else "票"
print(f"\n {i}. [{lead['score']}] {lead['ref']}{kind}")
print(f" {lead['title'][:72]}")
print(" 為什麼排這裡:" + "、".join(f"{n}{v:+g}" for n, v in lead["why"]))
if lead["labels"]:
print(f" 標籤:{lead['labels']}")
others = b["members"][1:]
if others:
print(f" ↳ 同一包(互相指著/原生相依,要一起領)共 {len(b['members'])} 張:")
shown = others if verbose else others[:BUNDLE_CAP]
for m in shown:
print(f" · [{m['score']}] {m['ref']} {m['title'][:56]}")
if len(others) > len(shown):
print(f" · …另外 {len(others) - len(shown)} 張(`--all` 看全部)")
def cmd_list(argv):
top = int(argv[argv.index("--top") + 1]) if "--top" in argv else 6
verbose = "--all" in argv
as_json = "--json" in argv
repos, items = collect()
state = load_state()
pool, stats, _ = pick(items, set(state.get("claims") or {}))
edges = mutual_edges(pool) + dep_edges(pool, enabled="--no-deps" not in argv)
bundles = bundle(pool, edges)
if as_json:
print(json.dumps({
"at": now().isoformat(), "repos": repos, "stats": stats,
"claimed": sorted(state.get("claims") or {}),
"bundles": [{"score": b["score"],
"members": [{"ref": m["ref"], "score": m["score"],
"title": m["title"], "why": m["why"]}
for m in b["members"]]} for b in bundles],
}, ensure_ascii=False, indent=1))
else:
print(f"🧾 還債清單 {now().strftime('%Y-%m-%d %H:%M UTC')}\n")
print_proof(repos, stats)
print_carry(state, items)
print(f"\n── 今天可以領的:{len(pool)} 張,收斂成 {len(bundles)} 包 "
f"────────────────────")
if not pool:
# 🔴 空清單要說話。默默結束=人分不出「查過沒東西」跟「根本沒查」。
print("\n ✅ **查詢跑過了,結果是零。**"
f"\n {len(repos)} 個 repo 都掃過(見上面那份清單),"
"沒有一張是「沒掛 milestone、沒等 leo、還沒被領走」的。"
"\n 這代表舊債清乾淨了——不是查詢壞掉。")
for i, b in enumerate(bundles[:len(bundles) if verbose else top], 1):
print_bundle(i, b, verbose)
if not verbose and len(bundles) > top:
print(f"\n …另外 {len(bundles) - top} 包(`--top N` 或 `--all` 看更多)")
if pool:
first = " ".join(m["ref"] for m in bundles[0]["members"][:BUNDLE_CAP])
print(f"""
── 領走它 ──────────────────────────────────────────────────
debt-worklist claim {first}
領走 = 今天要做它。領了之後它就不會再出現在這份清單上,
直到你把票關掉、或 `debt-worklist forget` 把它放回去。
🔴 **整包一起領**:同一包是互相指著的,拆開做會做到一半發現卡在另一張。""")
state["last_run"] = now().isoformat()
save_state(state)
return 0
def _titles(items):
return {ref_of(it): (it.get("title") or "") for it in items}
def cmd_claim(argv):
refs = [a for a in argv if not a.startswith("-")]
if not refs:
die("用法:debt-worklist claim <owner/repo#N> [更多…]")
for r in refs:
if not re.match(r"^[\w.-]+/[\w.-]+#\d+$", r):
die(f"🔴 票的寫法是 owner/repo#N,你給的是:{r}")
_, items = collect()
titles = _titles(items)
unknown = [r for r in refs if r not in titles]
if unknown:
die("🔴 這幾張不在 open 清單裡(打錯了?已經關了?):" + "、".join(unknown))
state = load_state()
for r in refs:
state["claims"][r] = {"at": now().isoformat(), "title": titles[r]}
save_state(state)
print(f"📦 領走 {len(refs)} 張,明天的清單不會再出現它們:")
for r in refs:
print(f" · {r} {titles[r][:60]}")
print("\n🔴 領了就是今天要做它。做完關票;改方向就 `debt-worklist forget`。")
return 0
def cmd_forget(argv):
refs = [a for a in argv if not a.startswith("-")]
if not refs:
die("用法:debt-worklist forget <owner/repo#N> [更多…]")
state = load_state()
gone = [r for r in refs if state["claims"].pop(r, None) is not None]
save_state(state)
print(f"↩️ 放回清單 {len(gone)} 張:" + ("、".join(gone) or "(本來就沒領過)"))
return 0
CMDS = {"list": cmd_list, "claim": cmd_claim, "forget": cmd_forget}
if __name__ == "__main__":
if len(sys.argv) < 2:
sys.exit(cmd_list([]))
if sys.argv[1] in ("-h", "--help", "help"):
print(__doc__)
sys.exit(0)
if sys.argv[1] not in CMDS:
print(__doc__)
sys.exit(2)
sys.exit(CMDS[sys.argv[1]](sys.argv[2:]))
+113
View File
@@ -0,0 +1,113 @@
#!/usr/bin/env bash
# gate-ok — 各道閘「總管看過了,放行下一個動作」的**單一入口**inkstone/ISEP#90 ④)
#
# ══ 為什麼要有這支 ═════════════════════════════════════════════════════
#
# 這些閘每一支都留了一道給總管的門,而每一道門長得都不一樣:
# touch /tmp/.prod-write-ok
# git rev-parse --show-toplevel > /tmp/.main-push-ok
# touch /tmp/.stage-verified
# touch /tmp/.solo-ok-<session_id>
# …
# 兩個後果:
# ① **記不住**。門的形狀藏在被擋下的那則訊息裡,每支都不同,
# 而且有兩支還帶參數(綁 repo、綁 session)——照著抄錯一個字,門就打不開。
# ② **放行不了**。逃生口是「一條臨時湊出來的 Bash 指令」,
# 而臨時湊出來的指令**沒有穩定的形狀可以事先放行**:
# `.claude/settings.json` 的 allow 只能逐條寫死
# (現場真的就寫著 `Bash(touch /tmp/.prod-write-ok)` 這種完全比對的一行),
# 多一個 `&&`、換一個檔名、加一個 session id,就落在那條規則之外。
# 2026-08-28inkstone/InkStoneCo#99)總管在雲端連續四次沒能把
# prod-write 的戳記蓋出去 ⇒ **閘留了一道給總管的門,而那道門打不開**
# ⇒ 任何「擋下來、但總管可以放行」的閘,在那個環境裡等於「純擋」。
#
# ⇒ 這支把所有逃生口收斂成**一個名字、一種形狀**:
# bash "$CLAUDE_PLUGIN_ROOT/scripts/gate-ok" <閘名> [參數]
# 一條前綴規則就涵蓋全部,而且以後新增閘不必再動一次設定。
#
# 🔴 這支**不會弱化任何一道閘**:它蓋的戳記跟閘原本認的是同一個檔、同一種語意
# (單次用完即丟、綁 repo、綁 session、有效期都不變)。它換掉的只有「怎麼蓋」。
# 🔴 也**不是給 subagent 用的**:閘擋的是無心不是惡意(見 main-and-prod-push-guard
# 第 76 行)。subagent 照樣造得出戳記,但它得刻意繞過一段明講「不要這樣做」的訊息。
set -eu
usage() {
cat <<'USAGE'
用法:gate-ok <閘名> [參數]
prod-write 改到線上實例(prod-write-guard)——放行下一個動作,用完即消失
main-push [repo 路徑] 推 gitea mainmain-and-prod-push-guard)——綁 repo,預設是 cwd
stage-verified stage 真的驗過了(stage-before-prod-guard)——記得在回覆貼實測輸出
solo <session_id> 這件事真的該自己做(subagent-first-guard)——回覆裡要寫理由
dispatch <session_id> 派工單真的有例外(dispatch-format-guard
ask <session_id> 這題真的是人閘(ask-user-question-guard
kbdb-down KBDB 真的連不上(history-first-guard)——回覆裡要說明
--list 列出目前存在的戳記
🔴 每一枚戳記都是**留痕**:它會出現在指令歷史上,而且多數是單次用完即丟。
蓋之前先確定你真的看過那個動作會改到什麼;蓋完在回覆裡寫一句為什麼。
USAGE
}
stamp() { # stamp <檔> <說明> [內容]
if [ $# -ge 3 ]; then printf '%s\n' "$3" > "$1"; else : >> "$1"; touch "$1"; fi
printf '✅ 已蓋戳記:%s\n %s\n' "$1" "$2"
}
[ $# -ge 1 ] || { usage; exit 2; }
case "$1" in
-h|--help|help) usage; exit 0 ;;
--list)
printf '目前存在的戳記:\n'
ls -la /tmp/.prod-write-ok /tmp/.main-push-ok /tmp/.stage-verified \
/tmp/.kbdb-down /tmp/.solo-ok-* /tmp/.dispatch-ok-* /tmp/.ask-ok-* 2>/dev/null \
|| printf '(一枚都沒有)\n'
exit 0 ;;
esac
GATE="$1"; shift || true
case "$GATE" in
prod-write)
stamp /tmp/.prod-write-ok "只放行**下一個**會改到線上那台的動作,用完即消失(15 分鐘失效)" ;;
main-push)
# 綁 repo:戳記內容要是那個 repo 的 toplevel,閘會拿它跟「push 的目標 repo」比對。
# 🔴 解不出 repo 時**寧可失敗也不蓋空的**——閘明文寫著空內容不算數
# (08-12 那次穿透就是空檔案變成萬用鑰匙),蓋一個註定打不開的戳記
# 只會讓人以為門開了。
D="${1:-$(pwd)}"
TOP="$(git -C "$D" rev-parse --show-toplevel 2>/dev/null || true)"
if [ -z "$TOP" ]; then
printf '❌ %s 不在任何 git repo 裡,解不出要放行哪個 repo。\n' "$D" >&2
printf ' 用法:gate-ok main-push <那個 repo 的路徑>\n' >&2
exit 2
fi
stamp /tmp/.main-push-ok "只放行推 $TOP 的 main,單次、15 分鐘失效" "$TOP" ;;
stage-verified)
stamp /tmp/.stage-verified "stage 驗過了(6 小時)——**記得在回覆裡貼 stage 的實測輸出**" ;;
kbdb-down)
stamp /tmp/.kbdb-down "KBDB 連不上而放行——**回覆裡要說明**,這是留痕不是豁免" ;;
solo|dispatch|ask)
SID="${1:-${CLAUDE_SESSION_ID:-}}"
if [ -z "$SID" ]; then
printf '❌ %s 要綁 session id(閘用它認「是不是同一個 session」)。\n' "$GATE" >&2
printf ' 被擋下的那則訊息裡就有;或 gate-ok %s <session_id>\n' "$GATE" >&2
exit 2
fi
case "$GATE" in
solo) stamp "/tmp/.solo-ok-$SID" "這件事自己做——**回覆裡要寫理由**" ;;
dispatch) stamp "/tmp/.dispatch-ok-$SID" "這張派工單的例外,放行一次" ;;
ask) stamp "/tmp/.ask-ok-$SID" "這題確認是人閘,放行一次" ;;
esac ;;
*)
printf '❌ 不認得的閘名:%s\n\n' "$GATE" >&2
usage >&2
exit 2 ;;
esac
+393
View File
@@ -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 tokenGITEA_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/doings/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:]))
+429
View File
@@ -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-Agent2026-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 tokenGITEA_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:]))
+332
View File
@@ -0,0 +1,332 @@
#!/usr/bin/env python3
"""mainline — 「現在的主線是哪一個」這個問題的**唯一指令**inkstone/ISEP#82)。
leo 2026-08-27:「**14 個里程碑同時亮著卻不知道該看哪個**」。
當天實查 `GET /repos/inkstone/*/milestones?state=open` 回 14 個,其中「Mira 現代化」
這個名字同時活在 5 個 repo,另有 5 個已經逾期。
⇒ SOP 說的「**那個** active milestone」在現場根本沒有指涉對象——
要注入不知道注哪一個,要擋跳線也不知道拿哪一個當基準。
這支不解「milestone 太多」(跨 repo 同名是既有做法,不動它),
它解的是「**這一刻我在做哪一條**」沒有答案:
mainline 現在的主線是哪一個(= show,唯一答案,離線)
mainline list 有哪些 open milestone 可以選(打網路)
mainline set <owner/repo#id> 把某一個 milestone 標成主線(打網路,寫進 state)
mainline clear 現在沒有主線(例:一條線收掉了,下一條還沒開始)
mainline refresh [--members] 更新進度/期限(SessionStart 跑一次,不輪詢)
mainline adopt <owner/repo#N> 「補收」:把一張票掛進主線(打網路)
mainline has <owner/repo#N> 這張票在不在主線上(exit 0 =在,1 =不在)
🔴 **`<owner/repo#id>` 的 id 是 milestone 的 id,不是 issue 的號碼。**
兩者在 Gitea 是兩套獨立編號,`mainline list` 印出來的就是可以直接貼的那一個。
── 答案為什麼唯一 ─────────────────────────────────────────────────
主線住在一個檔裡(`~/.claude/isep-countdown/mainline.json`),那個檔**放得下一條**。
沒有清單、沒有優先序、沒有「前三名」——`set` 覆蓋掉的那條就不再是主線。
⇒ 問一次只會拿到 0 或 1 個答案,不會拿到 14 個。
── 主線是一個名字,錨在一個具體的 milestone 上 ────────────────────
Gitea 的 milestone 不能跨 repo,而同一條線常常同時開在 5 個 repo。所以
**錨**(期限/進度/目標宣告都讀它)是 `owner/repo#id`
**成員判定**看的是「票所屬 milestone 的標題與主線標題相同」(不分 repo)。
兩件事都成立:同名跨 repo 照舊能用,而「現在在做哪一條」仍然只有一個答案。
"""
import importlib.machinery
import importlib.util
import os
import sys
import urllib.parse
from datetime import datetime, timezone
HERE = os.path.dirname(os.path.abspath(__file__))
def _load(name, path):
"""把一個檔載進來當模組。`scripts/ticket` 沒有 `.py` 副檔名,所以要明著給 loader
`spec_from_file_location` 只靠副檔名認不出它,會回 None)。"""
loader = importlib.machinery.SourceFileLoader(name, path)
spec = importlib.util.spec_from_file_location(name, path, loader=loader)
mod = importlib.util.module_from_spec(spec)
loader.exec_module(mod)
return mod
# 憑證怎麼拿、API 怎麼打、票號怎麼解析——**全部沿用 `scripts/ticket` 那一份**。
# 🔴 刻意不自己再寫一份 token():金鑰的取得只准有一條路(頂層 CLAUDE.md 金鑰鐵律
# 2026-07-29 t145 的實害——同一把金鑰兩種寫法並存,必然漂移)。
ML = _load("isep_mainline", os.path.join(HERE, "..", "hooks", "lib", "mainline.py"))
try:
T = _load("isep_ticket", os.path.join(HERE, "ticket"))
except Exception:
# 🔴 載不到就只是「打不了網路的那幾個動作不能用」,不是整支壞掉。
# ISEP#82 驗收第 4 條要的是「沒有主線也不能整組壞掉」,同一個道理:
# `show` 是離線的,它不該因為別的零件壞了而回答不出來。
T = None
def die(msg, code=2):
print(msg, file=sys.stderr)
sys.exit(code)
def need_net():
if T is None:
die("🔴 載不到 `scripts/ticket`(憑證與 API 都沿用它),這個動作打不了網路。\n"
" `scripts/mainline show` 仍然可以用——它是離線的。")
def parse_ms_ref(s):
"""`owner/repo#<milestone id>`。與票號同形,所以錯手貼票號會拿到 404,訊息要講清楚。"""
import re
m = re.match(r"^([\w.-]+)/([\w.-]+)#(\d+)$", (s or "").strip())
if not m:
die("🔴 寫法是 owner/repo#<milestone id>,你給的是:%s\n"
"(先跑 `scripts/mainline list` 看有哪些,那裡印的就是可以直接貼的)" % s)
return m.group(1), m.group(2), int(m.group(3))
def org_repos(owner):
rows = T.api_soft("/orgs/%s/repos?limit=100" % owner) or []
return [r["name"] for r in rows if isinstance(r, dict) and r.get("name")]
def issues_in_milestone(owner, repo, title):
q = urllib.parse.urlencode({"state": "all", "type": "issues",
"milestones": title, "limit": 100})
rows = T.api_soft("/repos/%s/%s/issues?%s" % (owner, repo, q)) or []
out = []
for it in rows:
if not isinstance(it, dict) or it.get("pull_request"):
continue
ms = it.get("milestone") or {}
# 🔴 再比對一次標題:`milestones=` 這個參數在不同 Gitea 版本上對「名稱 vs id」
# 的解讀不完全一致,拿回來的東西要自己確認過才算數(不要相信查詢字串)。
if ML.norm(ms.get("title")) != ML.norm(title):
continue
out.append("%s/%s#%d" % (owner, repo, it["number"]))
return out
def collect_members(owner, title, anchor_repo):
"""跨 repo 收齊「掛在同名 milestone 底下」的票。"""
refs, scanned = [], []
repos = org_repos(owner)
if anchor_repo not in repos:
repos.append(anchor_repo)
for r in repos:
got = issues_in_milestone(owner, r, title)
if got:
scanned.append((r, len(got)))
refs.extend(got)
return sorted(set(refs)), scanned
def fetch_milestone(owner, repo, mid):
d = T.api_soft("/repos/%s/%s/milestones/%d" % (owner, repo, mid))
if not isinstance(d, dict) or not d.get("title"):
die("🔴 讀不到 %s/%s#%d 這個 milestone。\n"
" · id 是 milestone 的 id,不是 issue 的號碼(`scripts/mainline list` 會印對的)\n"
" · 或是這台機器現在拿不到 Gitea(那就先別標,不要標一個猜的)" % (owner, repo, mid))
return d
# ── show ─────────────────────────────────────────────────────────────────
def cmd_show(argv):
"""離線。**永遠回一個答案**,包括「現在沒有主線」(ISEP#82 驗收第 4 條)。"""
ms = ML.load()
if not ms:
print("🎯 現在沒有主線。")
print()
print(" 這不是故障——沒有人標過,或上一條收掉了。")
print(" 要標一條:`scripts/mainline list` 看有哪些 → `scripts/mainline set <owner/repo#id>`")
print()
print("📌 在標之前,每回合的 🎯 那一行也會照樣講「現在沒有主線」,不會編一條出來。")
return
c, total, pct = ML.progress(ms)
print("🎯 現在的主線:%s「%s」" % (ML.ref_of(ms), ms.get("title")))
desc = (ms.get("description") or "").strip()
if desc:
first = desc.splitlines()[0].strip()
# 只印第一行、最多 150 字:這是**一眼掃**的答案,不是 milestone 的全文。
# leo 一天看兩眼(白話鐵律),整段貼過來他就不會看。
print(" 目標:" + (first[:150] + "…" if len(first) > 150 else first))
print(" 期限:%s" % ML.due_phrase(ms))
print(" 進度:%d%d 張已關(%d%%" % (c, total, pct))
mem = sorted(ML.members(ms))
byrepo = {}
for r in mem:
byrepo[r.split("#")[0]] = byrepo.get(r.split("#")[0], 0) + 1
print(" 屬於主線的票:%d 張%s" % (
len(mem), ("" + "、".join("%s %d" % (k, v) for k, v in sorted(byrepo.items())) + "") if byrepo else ""))
if ms.get("set_at"):
print(" 標定於:%s" % ms["set_at"])
if ms.get("refreshed_at"):
print(" 最後更新:%s" % ms["refreshed_at"])
print()
print("📌 這是唯一答案——主線住在一個檔裡,那個檔放得下一條:%s" % ML.path())
# ── list ─────────────────────────────────────────────────────────────────
def cmd_list(argv):
need_net()
owner = argv[0] if argv else T.ORG
rows = []
for r in org_repos(owner):
for m in (T.api_soft("/repos/%s/%s/milestones?state=open" % (owner, r)) or []):
if not isinstance(m, dict):
continue
rows.append((owner, r, m))
if not rows:
die("🔴 一個 open milestone 都沒讀到——多半是拿不到 Gitea,不是真的沒有。\n"
"(讀不到 ≠ 不存在。先修連線,不要因此標一個猜的主線。)", 1)
now = datetime.now(timezone.utc)
cur = ML.load()
print("open milestone 共 %d 個(%s):\n" % (len(rows), owner))
for o, r, m in sorted(rows, key=lambda x: (ML.due_of(x[2]) or datetime.max.replace(tzinfo=timezone.utc))):
c, total, pct = ML.progress(m)
mark = " ← 現在的主線" if cur and ML.ref_of(cur) == "%s/%s#%s" % (o, r, m.get("id")) else ""
print(" %-28s %-24s %3d%% 期限 %s%s" % (
"%s/%s#%s" % (o, r, m.get("id")), (m.get("title") or "")[:24],
pct, ML.due_phrase(m, now), mark))
print("\n📌 貼左邊那一欄:`scripts/mainline set <owner/repo#id>`")
# ── set ──────────────────────────────────────────────────────────────────
def cmd_set(argv):
need_net()
if not argv:
die("用法:mainline set <owner/repo#milestone_id>")
owner, repo, mid = parse_ms_ref(argv[0])
m = fetch_milestone(owner, repo, mid)
title = m.get("title")
refs, scanned = collect_members(owner, title, repo)
now = datetime.now(timezone.utc).astimezone().strftime("%Y-%m-%d %H:%M")
ok = ML.save({
"ref": "%s/%s#%d" % (owner, repo, mid),
"owner": owner, "repo": repo, "id": mid,
"title": title,
"description": m.get("description") or "",
"due_on": m.get("due_on") or "",
"open_issues": m.get("open_issues") or 0,
"closed_issues": m.get("closed_issues") or 0,
"members": refs,
"set_at": now, "refreshed_at": now,
})
if not ok:
die("🔴 寫不進 %s——主線沒有被標上。" % ML.path())
print("✅ 主線已標定:%s/%s#%d「%s」" % (owner, repo, mid, title))
if scanned:
print(" 跨 repo 收到同名 milestone 的票 %d 張:%s"
% (len(refs), "、".join("%s %d" % (r, n) for r, n in scanned)))
print()
cmd_show([])
def cmd_clear(argv):
had = ML.load()
ML.clear()
print("✅ 已清掉主線%s。現在沒有主線——每回合的 🎯 那一行會照實說。"
% ("(原本是 %s「%s」)" % (ML.ref_of(had), had.get("title")) if had else ""))
# ── refresh ──────────────────────────────────────────────────────────────
def cmd_refresh(argv):
"""SessionStart 跑一次。**拿不到就原封不動**——寧可資料舊,不要把主線弄丟。"""
ms = ML.load()
if not ms:
return # 沒有主線就沒有東西要更新,安靜結束
d = T.api_soft("/repos/%s/%s/milestones/%s" % (ms["owner"], ms["repo"], ms["id"]))
if not isinstance(d, dict) or not d.get("title"):
return # 讀不到 ≠ 主線不存在
ms.update({
"title": d.get("title") or ms["title"],
"description": d.get("description") or ms.get("description") or "",
"due_on": d.get("due_on") or ms.get("due_on") or "",
"open_issues": d.get("open_issues") or 0,
"closed_issues": d.get("closed_issues") or 0,
"refreshed_at": datetime.now(timezone.utc).astimezone().strftime("%Y-%m-%d %H:%M"),
})
if "--members" in argv:
refs, _ = collect_members(ms["owner"], ms["title"], ms["repo"])
if refs:
ms["members"] = refs
ML.save(ms)
# ── adopt(補收)──────────────────────────────────────────────────────────
def cmd_adopt(argv):
need_net()
if not argv:
die("用法:mainline adopt <owner/repo#N>(把一張票掛進主線=「補收」)")
ms = ML.load()
if not ms:
die("🔴 現在沒有主線,沒有東西可以補收進去。先 `scripts/mainline set …`。")
owner, repo, num = T.parse_ref(argv[0])
cands = [m for m in (T.api_soft("/repos/%s/%s/milestones?state=all" % (owner, repo)) or [])
if isinstance(m, dict) and ML.norm(m.get("title")) == ML.norm(ms["title"])]
if not cands:
die("""🔴 `%s/%s` 底下沒有叫「%s」的 milestone,所以這張票掛不進去。
兩條路,**都要人決定,這支不會替你選**:
· 這件事真的屬於主線 ⇒ 在 `%s/%s` 開一個同名 milestone(記得設真的期限,
`milestone-due-guard.sh` 會擋沒期限的),再跑一次 adopt
· 這件事其實是另一條線 ⇒ 那就是**跳線**,不是補收。直接重送派工
(本閘同一張票只擋一次),並在回覆說清楚為什麼現在要岔開主線。""" % (
owner, repo, ms["title"], owner, repo))
mid = cands[0]["id"]
T.api("/repos/%s/%s/issues/%d" % (owner, repo, num), {"milestone": mid}, method="PATCH")
ref = "%s/%s#%d" % (owner, repo, num)
ms["members"] = sorted(set(ms.get("members") or []) | {ref})
ML.save(ms)
print("✅ 補收:%s 已掛進「%s」(%s/%s#%d" % (ref, ms["title"], owner, repo, mid))
# ── has ──────────────────────────────────────────────────────────────────
def cmd_has(argv):
if not argv:
die("用法:mainline has <owner/repo#N>")
ms = ML.load()
ref = argv[0].strip()
if not ms:
print("🎯 現在沒有主線 ⇒ 沒有「不屬於主線」這回事。")
return
v = ML.belongs(ref, ms)
if v is None:
owner, repo, num = T.parse_ref(ref)
it = T.api_soft("/repos/%s/%s/issues/%d" % (owner, repo, num))
title = ((it or {}).get("milestone") or {}).get("title") if isinstance(it, dict) else None
v = ML.belongs(ref, ms, ticket_milestone_title=title or "")
if not isinstance(it, dict):
print("⚪ 讀不到 %s,判不出來(讀不到 ≠ 不屬於)。" % ref)
sys.exit(0)
if v:
print("✅ %s 在主線「%s」上。" % (ref, ms["title"]))
sys.exit(0)
print("🚧 %s **不在**主線「%s」上。\n"
" 補收 ⇒ `scripts/mainline adopt %s`;真的是插件事 ⇒ 那是跳線,說清楚再做。"
% (ref, ms["title"], ref))
sys.exit(1)
CMDS = {"show": cmd_show, "list": cmd_list, "set": cmd_set, "clear": cmd_clear,
"refresh": cmd_refresh, "adopt": cmd_adopt, "has": cmd_has}
if __name__ == "__main__":
# `mainline | head` 是很自然的用法,別讓它吐一段 BrokenPipe 的 traceback
# ——那會讓人以為這支壞了。
try:
import signal
signal.signal(signal.SIGPIPE, signal.SIG_DFL)
except Exception:
pass
if len(sys.argv) < 2:
cmd_show([])
sys.exit(0)
if sys.argv[1] in ("-h", "--help", "help"):
print(__doc__)
sys.exit(0)
if sys.argv[1] not in CMDS:
print(__doc__)
sys.exit(2)
CMDS[sys.argv[1]](sys.argv[2:])
+634
View File
@@ -0,0 +1,634 @@
#!/usr/bin/env python3
"""milestone-account — 每次結案都留下「估多久/花多久/差多少/為什麼」(inkstone/ISEP#85)。
leo 的問題(票上原文):「leo 2026-08-27 說今天『**拖時間**』。但拖了多久、
比預計多拖多少、為什麼拖——**沒有任何數字**⋯⋯每次事後只能用『這次比較慢』
這種話帶過,下次還是一樣。」
milestone-account codes 七個代號是哪七個(封閉集合)
milestone-account list --org inkstone [--overdue] 現在每個里程碑的三個數字
milestone-account audit inkstone/ISEP#12 從 Gitea 時間軸算證據
milestone-account close inkstone/ISEP#12 --reason idle [--dry-run]
milestone-account report 累計:哪一種原因最常發生
milestone-account ledger-path 帳本檔在哪(給閘問的)
🔴 這支工具的三條,全部長在機器上(不是寫在規範裡靠人記得):
① **三個數字自己算,不用手填**:期限與實際都取自 Gitea 的 `created_at`
`due_on``closed_at`。手填的數字沒有人會去對,等於沒有。
② **差超過 25%(超時或提早都算)就一定要挑一個代號**,挑不出來不准結案。
提早太多不是好消息,是**當初估太鬆**——所以兩個方向同一條線。
③ **代號要有時間軸撐得住**:`audit` 先從 Gitea 的時間軸算出哪幾個訊號成立,
`close` 只接受**訊號成立的那幾個代號**;一個都沒成立時,唯一合法的代號
就是 `misestimate`(=票上那句「以上都沒有,但還是差很多」)。
⇒ **不採信我自己說的**,這是票上寫死的。
🔴 代號是**封閉集合**,這一點是刻意的——票上原文:「**能統計的前提是分類有限**」。
真的歸不進去才提案加一個,而那要走 leo 核准(開票,不是自己加)。
離線測試:`scripts/test-milestone-account.sh`(走 `MILESTONE_ACCOUNT_FIXTURE`,不打網路)。
"""
import json
import os
import re
import subprocess
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
HOST = os.environ.get("MILESTONE_ACCOUNT_HOST", "https://git.uncle6.me")
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
def _resolve_dir():
"""帳本住哪裡。
🔴 **不能住在 plugin 目錄裡**ISEP 是用 marketplace 裝的,
`claude plugin update` 會把那個目錄整個換掉 ⇒ 累積幾個月的帳一次歸零,
而這張票的全部價值就在「累積」。
順序:環境變數 → 這個 session 的專案根底下的 `system-dev/`(總管的跨專案知識庫,
帳本本來就屬於那一層)→ 這份原始碼所在 repo 的 `system-dev/` → `~/.claude/`。
"""
v = os.environ.get("MILESTONE_ACCOUNT_DIR")
if v:
return v
for base in (os.environ.get("CLAUDE_PROJECT_DIR") or "", ROOT):
if base and os.path.isdir(os.path.join(base, "system-dev")):
return os.path.join(base, "system-dev", "estimates")
return os.path.join(os.path.expanduser("~"), ".claude", "isep-estimates")
DIR = _resolve_dir()
LEDGER = os.path.join(DIR, "ledger.jsonl")
FIXTURE = os.environ.get("MILESTONE_ACCOUNT_FIXTURE", "")
DRY = False
THRESHOLD = 25.0 # 差超過幾 % 就一定要挑代號(票上寫死「四分之一」)
# ── 封閉集合:七個代號,一個都不准多、不准少 ──────────────────────────────
CODES = [
("idle", "磨洋工", "小事拖成大事", "時間軸上有長長的空白"),
("sidetrack", "分心跳線", "講到別的就跑去做", "期間出現不屬於這條線的動作"),
("undispatched", "忘了派工", "該派沒派,票停著", "票很久沒有人被指派"),
("cherry-pick", "偏科", "挑喜歡的做,難的擱著", "完成順序跟優先順序倒過來"),
("waiting", "等答案", "問了就原地等,沒去做別的", "等待期間其他票零進度"),
("scope-creep", "範圍變大", "一直塞新東西進來", "補收的次數、票數成長曲線"),
("misestimate", "純粹估錯", "過程沒異常,就是估錯", "以上都沒有,但還是差很多"),
]
CODE_IDS = [c[0] for c in CODES]
LABEL = {c[0]: c[1] for c in CODES}
def die(msg, code=2):
print(msg, file=sys.stderr)
sys.exit(code)
def token():
root = os.environ.get("CLAUDE_PROJECT_DIR") or HERE
host = HOST.split("//")[-1].rstrip("/")
try:
out = subprocess.run(["git", "-C", root, "remote", "-v"],
capture_output=True, text=True, timeout=20).stdout
except Exception:
out = ""
for line in out.splitlines():
if host in line:
m = re.search(r"//[^:/]+:([^@]+)@", line)
if m:
return m.group(1)
for env in ("GITEA_TOKEN_CLAUDE_CODE", "GITEA_TOKEN"):
if os.environ.get(env):
return os.environ[env]
return ""
_FX = None
def fx():
global _FX
if _FX is None:
try:
with open(FIXTURE) as f:
_FX = json.load(f)
except Exception:
die("🔴 讀不到 MILESTONE_ACCOUNT_FIXTURE%s" % FIXTURE)
return _FX
def api(path, payload=None, method=None):
verb = method or ("POST" if payload is not None else "GET")
if FIXTURE:
if verb == "GET":
return (fx().get("api") or {}).get(path)
print(" [fixture] %s %s %s" % (verb, path, json.dumps(payload or {})))
return {}
if DRY and verb != "GET":
print(" [dry-run] %s %s %s" % (verb, path, json.dumps(payload or {})))
return {}
tk = token()
if not tk:
die("🔴 拿不到 %s 的 tokenremote 沒帶憑證,也沒有 GITEA_TOKEN_CLAUDE_CODE" % HOST)
data = json.dumps(payload).encode() if payload is not None else None
req = urllib.request.Request(
HOST + "/api/v1" + path, data=data, method=verb,
headers={"Authorization": "token " + tk, "Content-Type": "application/json"})
try:
raw = urllib.request.urlopen(req, timeout=30).read()
return json.loads(raw) if raw.strip() else {}
except urllib.error.HTTPError as e:
if e.code == 404:
return None
die("🔴 Gitea %d on %s %s%s" % (e.code, verb, path, e.read().decode()[:200]))
except Exception as e:
die("🔴 連不上 %s%s" % (HOST, e))
# ── 時間 ─────────────────────────────────────────────────────────────────
_TS = re.compile(r"^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2}):(\d{2})"
r"(?:\.\d+)?(Z|[+-]\d{2}:?\d{2})?$")
def ts(s):
"""Gitea 的時間字串 → epoch 秒。認得 Z 與 +08:00 兩種尾巴;認不得回 None。"""
if not s:
return None
m = _TS.match(str(s).strip())
if not m:
return None
y, mo, d, h, mi, se, off = m.groups()
t = time.mktime((int(y), int(mo), int(d), int(h), int(mi), int(se), 0, 1, 0)) \
- time.timezone
if off and off not in ("Z", "z"):
sign = 1 if off[0] == "+" else -1
off = off[1:].replace(":", "")
t -= sign * (int(off[:2]) * 3600 + int(off[2:]) * 60)
return t
def now():
v = os.environ.get("MILESTONE_ACCOUNT_NOW")
return ts(v) if v else time.time()
def days(sec):
return sec / 86400.0
def parse_ref(s):
m = re.match(r"^([\w.-]+)/([\w.-]+)#(\d+)$", s.strip())
if not m:
die("🔴 寫法是 owner/repo#里程碑編號,你給的是:%s" % s)
return m.group(1), m.group(2), int(m.group(3))
# ── 三個數字 ─────────────────────────────────────────────────────────────
def numbers(ms):
"""回 (planned_days, actual_days, deviation_pct)。算不出來的格子回 None。"""
c, due = ts(ms.get("created_at")), ts(ms.get("due_on"))
end = ts(ms.get("closed_at")) or now()
if c is None:
return None, None, None
actual = days(end - c)
if due is None or due <= c:
return None, actual, None
planned = days(due - c)
return planned, actual, (actual - planned) / planned * 100.0
def fmt(v, unit=""):
return "—" if v is None else ("%.1f%s" % (v, unit))
def three_lines(ms, planned, actual, dev):
return (" 當初訂的期限 %s天 %s → %s\n"
" 實際花掉的  %s天 (到 %s\n"
" 差了     %s"
% (fmt(planned), (ms.get("created_at") or "?")[:10],
(ms.get("due_on") or "沒填期限")[:10],
fmt(actual), (ms.get("closed_at") or "現在")[:10],
"算不出來(期限沒填,或期限不晚於建立日)" if dev is None
else "%+.0f%%%s" % (dev, " 🔴 超過 ±25%,要挑代號"
if abs(dev) > THRESHOLD else "")))
# ── 從 Gitea 時間軸取證據 ────────────────────────────────────────────────
def fetch_issues(owner, repo):
rows, page = [], 1
while page <= 5:
r = api("/repos/%s/%s/issues?state=all&type=issues&limit=50&page=%d"
% (owner, repo, page))
if not isinstance(r, list) or not r:
break
rows.extend(r)
if len(r) < 50:
break
page += 1
return rows
def fetch_comments(owner, repo, since):
q = "?limit=100"
if since:
q += "&since=" + urllib.parse.quote(since)
r = api("/repos/%s/%s/issues/comments%s" % (owner, repo, q))
return r if isinstance(r, list) else []
def prio(issue):
"""優先度:p/high=3、p/med=2、p/low=1、沒標=0。"""
v = 0
for l in issue.get("labels") or []:
n = (l.get("name") or "").lower()
if n in ("p/high", "p/urgent", "p0", "p1"):
v = max(v, 3)
elif n in ("p/med", "p/medium", "p2"):
v = max(v, 2)
elif n in ("p/low", "p3"):
v = max(v, 1)
return v
def evidence(owner, repo, ms):
"""把 Gitea 時間軸算成七個訊號。回 (signals, detail)。
🔴 這裡**一句話都不讀**:全部只看時間戳、指派欄位、標籤、票的歸屬。
票上寫死「不採信我自己說的」,所以判準不能碰任何自然語言。
"""
start = ts(ms.get("created_at"))
end = ts(ms.get("closed_at")) or now()
span = max(end - start, 1.0)
mid = ms.get("id")
issues = fetch_issues(owner, repo)
if issues is None:
issues = []
mine = [i for i in issues if (i.get("milestone") or {}).get("id") == mid]
others = [i for i in issues if (i.get("milestone") or {}).get("id") != mid]
comments = fetch_comments(owner, repo, ms.get("created_at"))
mine_ids = set(i.get("number") for i in mine)
def cnum(c):
u = c.get("issue_url") or c.get("html_url") or ""
m = re.search(r"/(?:issues|pulls)/(\d+)", u)
return int(m.group(1)) if m else c.get("issue_number")
ev_mine, ev_other = [], []
for i in mine:
for k in ("created_at", "closed_at"):
t = ts(i.get(k))
if t and start <= t <= end:
ev_mine.append(t)
for c in comments:
t = ts(c.get("created_at"))
if not t or not (start <= t <= end):
continue
(ev_mine if cnum(c) in mine_ids else ev_other).append(t)
for i in others:
t = ts(i.get("created_at"))
if t and start <= t <= end:
ev_other.append(t)
marks = sorted([start] + ev_mine + [end])
gap, gap_at = 0.0, None
for a, b in zip(marks, marks[1:]):
if b - a > gap:
gap, gap_at = b - a, a
sig, det = {}, {}
# ① 磨洋工:時間軸上有長長的空白
sig["idle"] = days(gap) >= 2.0 and gap / span >= 0.35
det["idle"] = ("最長空白 %.1f 天(佔整段 %.0f%%),起點 %s"
% (days(gap), gap / span * 100,
time.strftime("%Y-%m-%d", time.gmtime(gap_at)) if gap_at else "?"))
# ② 分心跳線:期間出現不屬於這條線的動作
tot = len(ev_mine) + len(ev_other)
ratio = (len(ev_other) / tot) if tot else 0.0
sig["sidetrack"] = len(ev_other) >= 5 and ratio >= 0.30
det["sidetrack"] = ("這段期間 %d 個動作在這條線上、%d 個在別的票上(%.0f%%"
% (len(ev_mine), len(ev_other), ratio * 100))
# ③ 忘了派工:票很久沒有人被指派
naked = [i for i in mine if not (i.get("assignees") or i.get("assignee"))
and days(end - (ts(i.get("created_at")) or end)) >= days(span) * 0.5]
sig["undispatched"] = len(naked) > 0
det["undispatched"] = ("%d 張票整段期間沒有人被指派:%s"
% (len(naked), ", ".join("#%s" % i.get("number")
for i in naked[:6])) if naked
else "每一張票都有人被指派過")
# ④ 偏科:完成順序跟優先順序倒過來
inv = 0
closed = [i for i in mine if ts(i.get("closed_at"))]
openv = [i for i in mine if not ts(i.get("closed_at"))]
for a in closed:
for b in closed:
if prio(a) < prio(b) and ts(a["closed_at"]) < ts(b["closed_at"]):
inv += 1
for a in closed:
for b in openv:
if prio(a) < prio(b):
inv += 1
sig["cherry-pick"] = inv >= 2
det["cherry-pick"] = ("低優先先收掉、高優先還晾著的配對 %d 組" % inv)
# ⑤ 等答案:等待期間其他票零進度
waitset = [i for i in mine
if any((l.get("name") or "").lower() == "human"
for l in (i.get("labels") or []))]
wait_days = 0.0
for i in waitset:
s = ts(i.get("created_at")) or start
e = ts(i.get("closed_at")) or end
wait_days = max(wait_days, days(e - s))
moved = sum(1 for i in mine
if ts(i.get("closed_at")) and i not in waitset)
sig["waiting"] = bool(waitset) and wait_days >= days(span) * 0.30 and moved == 0
det["waiting"] = ("掛 Human 的票 %d 張、最長等 %.1f 天;期間其他票關掉 %d 張"
% (len(waitset), wait_days, moved))
# ⑥ 範圍變大:補收的次數、票數成長曲線
late = [i for i in mine
if (ts(i.get("created_at")) or start) > start + span * 0.25]
sig["scope-creep"] = len(late) >= 1 and len(mine) and len(late) / len(mine) >= 0.20
det["scope-creep"] = ("開跑之後才補進來的票 %d%d 張:%s"
% (len(late), len(mine),
", ".join("#%s" % i.get("number") for i in late[:6])))
# ⑦ 純粹估錯:以上都沒有
sig["misestimate"] = not any(sig[c] for c in CODE_IDS if c != "misestimate")
det["misestimate"] = ("上面六個訊號%s成立"
% ("一個都沒有" if sig["misestimate"] else "有"))
return sig, det
def get_milestone(owner, repo, num):
ms = api("/repos/%s/%s/milestones/%d" % (owner, repo, num))
if not isinstance(ms, dict) or not ms.get("id"):
die("🔴 抓不到里程碑 %s/%s#%d(編號對嗎?)" % (owner, repo, num))
return ms
# ── 指令 ─────────────────────────────────────────────────────────────────
def cmd_codes(argv):
print("七個代號(封閉集合,inkstone/ISEP#85)——「能統計的前提是分類有限」\n")
for cid, label, plain, how in CODES:
print(" %-13s %-8s %-22s 從哪看出來:%s" % (cid, label, plain, how))
print("\n真的歸不進去 ⇒ 開一張票請 leo 核准加一個,**不要自己加**。")
def _repos(argv):
rs = [r for r in (argv[i + 1] for i, a in enumerate(argv)
if a == "--repo" and i + 1 < len(argv))]
if rs:
return rs
org = None
for i, a in enumerate(argv):
if a == "--org" and i + 1 < len(argv):
org = argv[i + 1]
if not org:
die("用法:milestone-account list --org inkstone [--overdue] 或 --repo owner/repo")
rows = api("/orgs/%s/repos?limit=100" % org)
if not isinstance(rows, list):
die("🔴 撈不到 %s 底下的 repo" % org)
return [r["full_name"] for r in rows]
def cmd_list(argv):
overdue = "--overdue" in argv
n = 0
for full in _repos(argv):
owner, repo = full.split("/", 1)
mss = api("/repos/%s/%s/milestones?state=open&limit=50" % (owner, repo))
if not isinstance(mss, list):
continue
for ms in mss:
planned, actual, dev = numbers(ms)
late = dev is not None and dev > 0
if overdue and not late:
continue
n += 1
print("%s %-34s %-30s 估 %s天/已 %s天/%s"
% ("🔴" if (dev is not None and abs(dev) > THRESHOLD) else " ",
"%s#%s" % (full, ms.get("id")), (ms.get("title") or "")[:28],
fmt(planned), fmt(actual),
"—" if dev is None else "%+.0f%%" % dev))
print("\n共 %d 個%s里程碑。🔴 = 差超過 ±25%%,結案時一定要挑一個代號。"
% (n, "逾期的 " if overdue else "open "))
print("挑代號之前先跑:milestone-account audit <owner/repo#編號>")
def cmd_audit(argv):
if not argv:
die("用法:milestone-account audit <owner/repo#里程碑編號>")
owner, repo, num = parse_ref(argv[0])
ms = get_milestone(owner, repo, num)
planned, actual, dev = numbers(ms)
print("【%s/%s#%d】%s\n" % (owner, repo, num, ms.get("title") or ""))
print(three_lines(ms, planned, actual, dev))
sig, det = evidence(owner, repo, ms)
print("\n【時間軸怎麼說】(全部取自 Gitea 的時間戳/欄位,一句話都沒讀)")
for cid, label, _plain, _how in CODES:
print(" %s %-13s %-8s %s" % ("🔴" if sig[cid] else " ", cid, label, det[cid]))
ok = [c for c in CODE_IDS if sig[c]]
print("\n可以挑的代號:%s" % " / ".join(ok))
print("結案:milestone-account close %s/%s#%d --reason %s" % (owner, repo, num, ok[0]))
def _ledger():
rows = []
try:
with open(LEDGER) as f:
for line in f:
line = line.strip()
if line:
try:
rows.append(json.loads(line))
except Exception:
pass
except Exception:
pass
return rows
def cmd_close(argv):
if not argv:
die("用法:milestone-account close <owner/repo#編號> [--reason 代號] [--dry-run]")
owner, repo, num = parse_ref(argv[0])
ref = "%s/%s#%d" % (owner, repo, num)
reason = None
for i, a in enumerate(argv):
if a == "--reason" and i + 1 < len(argv):
reason = argv[i + 1]
ms = get_milestone(owner, repo, num)
planned, actual, dev = numbers(ms)
print("【%s】%s\n" % (ref, ms.get("title") or ""))
print(three_lines(ms, planned, actual, dev))
sig, det = evidence(owner, repo, ms)
legal = [c for c in CODE_IDS if sig[c]]
need = dev is None or abs(dev) > THRESHOLD
if need and not reason:
die("\n🚫 **差 %s,超過 ±25%%——挑不出代號不准結案。**\n\n"
"【inkstone/ISEP#85】「差超過四分之一(不管是超時還是提早太多,\n"
" **提早太多代表當初估太鬆**)就要挑一個原因,而且**只能從固定的幾個裡面挑**,\n"
" 不准用一段話帶過。」\n\n"
"時間軸撐得住的代號:%s\n"
"%s\n"
" 再送一次:milestone-account close %s --reason %s\n"
" 想看全部七個:milestone-account codes 看證據:milestone-account audit %s"
% ("算不出來(期限沒填)" if dev is None else "%+.0f%%" % dev,
" / ".join(legal),
"\n".join(" %-13s %s" % (c, det[c]) for c in legal), ref, legal[0], ref))
if reason:
if reason not in CODE_IDS:
die("\n🚫 `%s` 不是那七個代號之一。\n"
" 【inkstone/ISEP#85】代號是**封閉集合**——「能統計的前提是分類有限」。\n"
" 七個:%s\n"
" 真的歸不進去 ⇒ 開票請 leo 核准加一個,不要自己造。"
% (reason, " / ".join(CODE_IDS)))
if reason not in legal:
die("\n🚫 `%s`%s**時間軸撐不住**——不接受。\n\n"
" 【inkstone/ISEP#85】「證據一律從 Gitea 的時間軸取"
"(留言、指派、標籤、commit 都有時間戳),**不採信我自己說的**。」\n\n"
" 這個代號的訊號:%s\n"
" 撐得住的是:%s\n"
" (訊號真的判錯了 ⇒ 那是 audit 的 bug,去修偵測器,"
"不是在這裡繞過去——這條線的意義就是「可以統計、可以迭代」。)"
% (reason, LABEL[reason], det[reason], " / ".join(legal)))
rec = {"ref": ref, "title": ms.get("title") or "",
"created_at": ms.get("created_at"), "due_on": ms.get("due_on"),
"closed_at": ms.get("closed_at") or time.strftime(
"%Y-%m-%dT%H:%M:%SZ", time.gmtime(now())),
"planned_days": None if planned is None else round(planned, 2),
"actual_days": None if actual is None else round(actual, 2),
"deviation_pct": None if dev is None else round(dev, 1),
"over_threshold": bool(need),
"reason": reason, "reason_label": LABEL.get(reason or "", ""),
"signals": {c: bool(sig[c]) for c in CODE_IDS},
"evidence": {c: det[c] for c in CODE_IDS},
"recorded_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(now()))}
if DRY:
print("\n[dry-run] 會寫進 %s" % LEDGER)
print(json.dumps(rec, ensure_ascii=False, indent=2)[:900])
print(" [dry-run] PATCH /repos/%s/%s/milestones/%d {\"state\": \"closed\"}"
% (owner, repo, num))
print("[dry-run] 一筆都沒有真的寫出去。")
return
# 🔴 順序:**先記帳、再關**。反過來的話,關成功而記帳失敗,
# 那個里程碑就永遠不會出現在統計裡——而那正是這張票要解的病。
os.makedirs(DIR, exist_ok=True)
with open(LEDGER, "a") as f:
f.write(json.dumps(rec, ensure_ascii=False) + "\n")
print("\n✅ 已記帳:%s 估 %s天/花 %s天/%s%s"
% (ref, fmt(planned), fmt(actual),
"—" if dev is None else "%+.0f%%" % dev,
" 原因=%s%s" % (reason, LABEL[reason]) if reason else ""))
if (ms.get("state") or "") != "closed":
api("/repos/%s/%s/milestones/%d" % (owner, repo, num),
{"state": "closed"}, method="PATCH")
print("✅ 里程碑已關閉")
else:
print("️ 里程碑本來就是 closed,只補記帳")
write_report()
print("📊 累計統計已更新:%s" % os.path.join(DIR, "REPORT.md"))
def write_report():
rows = _ledger()
cnt = {}
for r in rows:
if r.get("reason"):
cnt[r["reason"]] = cnt.get(r["reason"], 0) + 1
devs = [r["deviation_pct"] for r in rows if r.get("deviation_pct") is not None]
lines = ["# 估多久/花多久/差多少——累計統計", "",
"> 這份檔案由 `scripts/milestone-account` 自動重寫,**不要手改**。",
"> 【inkstone/ISEP#85】「每次事後只能用『這次比較慢』這種話帶過,"
"下次還是一樣。」", "",
"- 累計結案 **%d** 個里程碑" % len(rows)]
if devs:
lines.append("\t- 平均偏差 **%+.0f%%**(正=超時,負=提早)"
% (sum(devs) / len(devs)))
lines.append("\t- 超過 ±25%% 的有 **%d** 個"
% sum(1 for d in devs if abs(d) > THRESHOLD))
lines.append("- 哪一種原因最常發生(這才是迭代的依據)")
if not cnt:
lines.append("\t- 目前還沒有任何被挑過的代號。")
for cid, n in sorted(cnt.items(), key=lambda kv: -kv[1]):
lines.append("\t- **%s%s** ×%d" % (LABEL.get(cid, cid), cid, n))
lines.append("- 逐筆")
for r in sorted(rows, key=lambda r: r.get("recorded_at") or "", reverse=True):
lines.append("\t- `%s` %s" % (r["ref"], r.get("title", "")))
lines.append("\t\t- 估 %s 天/花 %s 天/差 %s"
% (r.get("planned_days"), r.get("actual_days"),
"—" if r.get("deviation_pct") is None
else "%+.0f%%" % r["deviation_pct"]))
if r.get("reason"):
lines.append("\t\t- 原因:**%s%s**" % (r.get("reason_label"), r["reason"]))
lines.append("\t\t- 時間軸證據:%s"
% (r.get("evidence") or {}).get(r["reason"], "—"))
try:
os.makedirs(DIR, exist_ok=True)
with open(os.path.join(DIR, "REPORT.md"), "w") as f:
f.write("\n".join(lines) + "\n")
except Exception as e:
die("🔴 寫不進 %s%s" % (DIR, e))
def cmd_report(argv):
rows = _ledger()
if not rows:
print("(帳本還是空的:%s" % LEDGER)
print("關掉第一個里程碑就會有第一筆:milestone-account close <owner/repo#編號>")
return
cnt = {}
for r in rows:
if r.get("reason"):
cnt[r["reason"]] = cnt.get(r["reason"], 0) + 1
devs = [r["deviation_pct"] for r in rows if r.get("deviation_pct") is not None]
print("累計結案 %d 個里程碑" % len(rows))
if devs:
print("平均偏差 %+.0f%%(正=超時,負=提早);超過 ±25%% 的有 %d 個"
% (sum(devs) / len(devs), sum(1 for d in devs if abs(d) > THRESHOLD)))
print("\n哪一種原因最常發生:")
if not cnt:
print(" (還沒有任何被挑過的代號)")
for cid, n in sorted(cnt.items(), key=lambda kv: -kv[1]):
print(" %-13s %-8s ×%d %s" % (cid, LABEL.get(cid, cid), n, "█" * n))
write_report()
print("\n人看的版本:%s" % os.path.join(DIR, "REPORT.md"))
def main():
global DRY
argv = sys.argv[1:]
if "--dry-run" in argv:
DRY = True
argv = [a for a in argv if a != "--dry-run"]
if not argv:
print(__doc__)
return
cmd, rest = argv[0], argv[1:]
table = {"codes": cmd_codes, "list": cmd_list, "audit": cmd_audit,
"close": cmd_close, "report": cmd_report,
# 給 hooks/milestone-account-guard.sh 問「帳本在哪」用的。
# 讓「怎麼找到帳本」只有一份答案,閘不必自己再實作一次解析。
"ledger-path": lambda a: print(LEDGER)}
if cmd not in table:
print(__doc__)
die("🔴 沒有這個子指令:%s" % cmd)
table[cmd](rest)
if __name__ == "__main__":
main()
+410
View File
@@ -0,0 +1,410 @@
#!/usr/bin/env python3
"""release-manifest — 一次交貨 一份清單(inkstone/ISEP#84)。
leo 的問題(票上原文):「一次交貨常常同時動到好幾個 repo,但**沒有任何地方記著
『這一版 = 哪幾個 repo 的哪幾個版本』**⋯⋯要退版的時候,只退了其中一個 repo,
剩下的還停在新版 ⇒ **變成一組從來沒測過的組合**。」
release-manifest freeze R-2026.08.30 --part inkstone/Arcrun@v1.4.52 \
--part inkstone/arcrun-collector@v0.18.36
release-manifest current 我現在手上是哪一版、由什麼組成
release-manifest list 全部清單,新的在上面
release-manifest show R-2026.08.30
release-manifest verify R-2026.08.30 清單上的版本號現在還抓得到嗎
release-manifest diff R-2026.08.29 R-2026.08.30
release-manifest rollback R-2026.08.29 退版:**整份一起退**
🔴 三條硬規則,全部長在這支工具裡(不是寫在文件裡靠人記得):
① **freeze 之前每一個版本號都真的去 Gitea 抓一次**。抓不到就不給凍結——
「寫上去」跟「存在」是兩件事,而清單的用處完全建立在後者。
② **凍結了就不准再加東西**。同名清單已存在 ⇒ 拒絕覆寫,新的需求進下一份。
③ **退版沒有「只退一個」這個選項**。`rollback` 一定吐出整份清單的每一格;
指名單一 repo(`--only`)會被擋下,因為那正是票上那個病:
**剩下的還停在新版 ⇒ 一組從來沒測過的組合。**
📌 跟 `release-tag-guard.sh` 是包含關係不是重複:那支管「打 tag 那一刻,
單一 repo 內版本號三處一致」;這支管它上面那一層——**跨 repo 的組合**。
📌 單一 repo 的交貨不必特例:清單就退化成只有一格,規則一字不改。
離線測試:`scripts/test-release-manifest.sh`(走 `RELEASE_MANIFEST_FIXTURE`,不打網路)。
"""
import json
import os
import re
import subprocess
import sys
import time
import urllib.error
import urllib.request
HOST = os.environ.get("RELEASE_MANIFEST_HOST", "https://git.uncle6.me")
HERE = os.path.dirname(os.path.abspath(__file__))
def _resolve_dir():
"""清單住哪裡。
🔴 **不能住在 plugin 目錄裡**ISEP 是用 marketplace 裝的,
`claude plugin update` 會把那個目錄整個換掉 ⇒ 所有出貨清單一次消失,
而清單的用處全部發生在「以後要退版的那一天」。
順序:環境變數 → 這個 session 的專案根(有 `system-dev/` =總管的工作區,
跨 repo 的出貨清單本來就屬於那一層)→ 這份原始碼所在 repo → `~/.claude/`。
"""
v = os.environ.get("RELEASE_MANIFEST_DIR")
if v:
return v
root = os.path.dirname(HERE)
for base in (os.environ.get("CLAUDE_PROJECT_DIR") or "", root):
if base and os.path.isdir(os.path.join(base, "system-dev")):
return os.path.join(base, "releases")
return os.path.join(os.path.expanduser("~"), ".claude", "isep-releases")
DIR = _resolve_dir()
FIXTURE = os.environ.get("RELEASE_MANIFEST_FIXTURE", "")
DRY = False
NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$")
PART_RE = re.compile(r"^([\w.-]+)/([\w.-]+)@(\S+)$")
def die(msg, code=2):
print(msg, file=sys.stderr)
sys.exit(code)
def token():
root = os.environ.get("CLAUDE_PROJECT_DIR") or HERE
host = HOST.split("//")[-1].rstrip("/")
try:
out = subprocess.run(["git", "-C", root, "remote", "-v"],
capture_output=True, text=True, timeout=20).stdout
except Exception:
out = ""
for line in out.splitlines():
if host in line:
m = re.search(r"//[^:/]+:([^@]+)@", line)
if m:
return m.group(1)
for env in ("GITEA_TOKEN_CLAUDE_CODE", "GITEA_TOKEN"):
if os.environ.get(env):
return os.environ[env]
return ""
def _fixture():
try:
with open(FIXTURE) as f:
return json.load(f)
except Exception:
die("🔴 讀不到 RELEASE_MANIFEST_FIXTURE%s" % FIXTURE)
def api(path):
"""GET 一筆。回 dictlist,查不到回 None。fixture 模式完全不碰網路。"""
if FIXTURE:
return None
tk = token()
if not tk:
die("🔴 拿不到 %s 的 tokenremote 沒帶憑證,也沒有 GITEA_TOKEN_CLAUDE_CODE" % HOST)
try:
req = urllib.request.Request(HOST + "/api/v1" + path,
headers={"Authorization": "token " + tk})
return json.load(urllib.request.urlopen(req, timeout=25))
except urllib.error.HTTPError:
return None
except Exception as e:
die("🔴 連不上 %s%s" % (HOST, e))
def resolve(owner, repo, ver):
"""這個版本號**真的存在嗎**。存在回一段可以貼給人看的證據,不存在回 None。
先問 release,再問 tag:有些 repo 只打 tag 沒建 release,那也算存在
(票上要的是「抓得到那個 release」=抓得到那個版本,不是逼每個 repo 都建 release 頁)。
"""
if FIXTURE:
fx = _fixture().get("versions") or {}
return fx.get("%s/%s@%s" % (owner, repo, ver))
r = api("/repos/%s/%s/releases/tags/%s" % (owner, repo, ver))
if isinstance(r, dict) and r.get("tag_name"):
return {"kind": "release", "url": r.get("html_url") or "",
"commit": (r.get("target_commitish") or "")[:12]}
t = api("/repos/%s/%s/tags/%s" % (owner, repo, ver))
if isinstance(t, dict) and t.get("name"):
return {"kind": "tag", "url": (t.get("commit") or {}).get("url") or "",
"commit": ((t.get("commit") or {}).get("sha") or "")[:12]}
return None
# ── 清單的存取 ────────────────────────────────────────────────────────────
def path_of(name):
return os.path.join(DIR, name + ".json")
def load(name):
try:
with open(path_of(name)) as f:
return json.load(f)
except Exception:
return None
def all_manifests():
try:
names = [f[:-5] for f in os.listdir(DIR) if f.endswith(".json")]
except Exception:
return []
rows = [load(n) for n in names]
rows = [m for m in rows if m]
# 🔴 排序鍵不能只用時間戳:同一分鐘凍兩份清單就會並列,
# 而並列之後「現在手上的是哪一份」會變成 os.listdir 的順序(等於隨機)。
# seq 是 freeze 當下遞增的流水號 ⇒ 先後永遠有唯一解。
rows.sort(key=lambda m: (m.get("seq", 0), m.get("frozen_at") or ""), reverse=True)
return rows
def parse_part(s):
m = PART_RE.match(s.strip())
if not m:
die("🔴 寫法是 owner/repo@版本號,你給的是:%s" % s)
return m.group(1), m.group(2), m.group(3)
def opts(argv, name):
return [argv[i + 1] for i, a in enumerate(argv) if a == name and i + 1 < len(argv)]
def opt(argv, name, default=None):
v = opts(argv, name)
return v[0] if v else default
# ── INDEX.md:leo 打開就看得懂「我現在手上是哪一版」 ──────────────────────
def write_index():
rows = all_manifests()
lines = ["# 出貨清單(哪一版由哪幾個 repo 的哪幾版組成)", "",
"> 這份檔案由 `scripts/release-manifest` 自動重寫,**不要手改**"
"(手改的那一份會安靜地騙人)。", "",
"- 每一列 = 一次交貨。**最上面那一份就是現在手上的這一版。**"]
if not rows:
lines.append("\t- 目前還沒有任何凍結過的清單。")
for i, m in enumerate(rows):
head = "**%s**%s" % (m["name"], " ← 現在手上的這一版" if i == 0 else "")
lines.append("\t- " + head)
lines.append("\t\t- 凍結於 %sUTC" % m.get("frozen_at", "?"))
if m.get("note"):
lines.append("\t\t- 說明:%s" % m["note"])
lines.append("\t\t- 由這些組成(沒有更動的 repo 不會出現在這裡)")
for p in m.get("parts", []):
ev = p.get("verified") or {}
lines.append("\t\t\t- `%s` @ **%s** (凍結當下驗過:%s %s"
% (p["repo"], p["version"], ev.get("kind", "?"),
ev.get("commit", "")))
lines.append("\t\t- 要退回這一版:`scripts/release-manifest rollback %s`" % m["name"])
lines.append("")
lines.append("- 為什麼要有這份東西(`inkstone/ISEP#84`")
lines.append("\t- 退版只退了其中一個 repo,剩下的還停在新版"
" ⇒ **變成一組從來沒測過的組合**。")
lines.append("\t- 所以送測、放行、出貨、退版**全部以一份清單為單位**,"
"不以單一 repo 為單位。")
try:
os.makedirs(DIR, exist_ok=True)
with open(os.path.join(DIR, "INDEX.md"), "w") as f:
f.write("\n".join(lines) + "\n")
except Exception as e:
die("🔴 寫不進 %s%s" % (DIR, e))
# ── freeze ───────────────────────────────────────────────────────────────
def cmd_freeze(argv):
if not argv:
die("用法:release-manifest freeze <清單名> --part owner/repo@版本 [--part …] "
"[--note 說明] [--dry-run]")
name = argv[0]
if not NAME_RE.match(name):
die("🔴 清單名只准英數與 . _ -(例:R-2026.08.30),你給的是:%s" % name)
raw = opts(argv[1:], "--part")
if not raw:
die("🚫 一份清單至少要有一格 --part owner/repo@版本號。\n"
" (單一 repo 的交貨也要開清單——它只是退化成一格,規則不變。)")
if load(name) is not None:
die("🚫 %s 已經凍結過了,不准再加東西。\n"
" 【inkstone/ISEP#84】「清單一旦凍結就不再加東西,**新的需求進下一版**。」\n"
" 要出新的組合 ⇒ 換一個清單名重新 freeze(舊的留著,退版時要用)。" % name)
seen, parts = set(), []
for s in raw:
owner, repo, ver = parse_part(s)
full = "%s/%s" % (owner, repo)
if full in seen:
die("🔴 同一個 repo 在清單裡出現兩次:%s" % full)
seen.add(full)
ev = resolve(owner, repo, ver)
if not ev:
die("🚫 %s@%s **在 Gitea 上抓不到**——不給凍結。\n"
" 【inkstone/ISEP#84 驗收 1】「清單上的版本號**真的存在**"
"(不是寫上去而已,要抓得到那個 release)」。\n"
" 先把那個 repo 的版本打出來(tagrelease),再回來 freeze。"
% (full, ver))
parts.append({"repo": full, "version": ver, "verified": ev})
nxt = max([m.get("seq", 0) for m in all_manifests()] or [0]) + 1
man = {"name": name, "seq": nxt,
"frozen_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
"note": opt(argv[1:], "--note", "") or "",
"parts": parts}
if DRY:
print("[dry-run] 會寫進 %s" % path_of(name))
print(json.dumps(man, ensure_ascii=False, indent=2))
print("[dry-run] 一個檔都沒有真的寫出去。")
return
os.makedirs(DIR, exist_ok=True)
with open(path_of(name), "w") as f:
json.dump(man, f, ensure_ascii=False, indent=2)
write_index()
print("✅ 清單 %s 已凍結(%d 個 repo,每一格都驗過真的抓得到)" % (name, len(parts)))
for p in parts:
print(" %-34s %s" % (p["repo"], p["version"]))
print("\n📌 從現在起:送測/放行/出貨/退版都以 %s 為單位,不以單一 repo 為單位。" % name)
print(" 人看的版本:%s" % os.path.join(DIR, "INDEX.md"))
# ── verify / show / list / current / diff ────────────────────────────────
def cmd_verify(argv):
if not argv:
die("用法:release-manifest verify <清單名>")
man = load(argv[0]) or die("🔴 沒有這份清單:%s" % argv[0])
bad = 0
for p in man["parts"]:
owner, repo = p["repo"].split("/", 1)
ev = resolve(owner, repo, p["version"])
print("%s %-34s %s" % ("✅" if ev else "❌", p["repo"], p["version"]))
if not ev:
bad += 1
if bad:
die("\n🔴 %d 格抓不到了——這份清單現在**退不回去**,"
"要嘛版本被刪了,要嘛當初就沒真的存在。" % bad)
print("\n✅ %s 全部 %d 格都還抓得到,可以拿它退版。" % (man["name"], len(man["parts"])))
def cmd_show(argv):
if not argv:
die("用法:release-manifest show <清單名>")
man = load(argv[0]) or die("🔴 沒有這份清單:%s" % argv[0])
print(json.dumps(man, ensure_ascii=False, indent=2))
def cmd_list(argv):
rows = all_manifests()
if not rows:
print("(還沒有任何凍結過的清單。第一份用 release-manifest freeze 開。)")
return
for i, m in enumerate(rows):
print("%s %-18s %s %d 個 repo%s"
% ("👉" if i == 0 else " ", m["name"], m.get("frozen_at", "?"),
len(m.get("parts", [])),
" + ".join("%s@%s" % (p["repo"].split("/")[-1], p["version"])
for p in m.get("parts", []))))
print("\n👉 = 現在手上的這一版。人看的版本:%s" % os.path.join(DIR, "INDEX.md"))
def cmd_current(argv):
rows = all_manifests()
if not rows:
die("🔴 一份清單都沒有——「我現在手上是哪一版」目前沒有人答得出來。", 1)
m = rows[0]
print("現在手上的這一版:%s(凍結於 %s" % (m["name"], m.get("frozen_at", "?")))
for p in m["parts"]:
print(" %-34s %s" % (p["repo"], p["version"]))
def cmd_diff(argv):
if len(argv) < 2:
die("用法:release-manifest diff <舊清單> <新清單>")
a = load(argv[0]) or die("🔴 沒有這份清單:%s" % argv[0])
b = load(argv[1]) or die("🔴 沒有這份清單:%s" % argv[1])
va = {p["repo"]: p["version"] for p in a["parts"]}
vb = {p["repo"]: p["version"] for p in b["parts"]}
for repo in sorted(set(va) | set(vb)):
x, y = va.get(repo), vb.get(repo)
if x == y:
print(" = %-34s %s" % (repo, x))
elif x is None:
print(" + %-34s %s(新進來的)" % (repo, y))
elif y is None:
print(" - %-34s %s(新清單裡沒有了)" % (repo, x))
else:
print(" ~ %-34s %s → %s" % (repo, x, y))
# ── rollback:整份一起退,沒有「只退一個」這個選項 ────────────────────────
def cmd_rollback(argv):
if not argv:
die("用法:release-manifest rollback <清單名> [--dry-run]")
name = argv[0]
man = load(name) or die("🔴 沒有這份清單:%s" % name)
only = opts(argv[1:], "--only")
if only:
die("🚫 **退版沒有「只退一個」這個選項。**(你指名了:%s)\n\n"
" 【inkstone/ISEP#84】「要退版的時候,只退了其中一個 repo,\n"
" 剩下的還停在新版 ⇒ **變成一組從來沒測過的組合**。」\n\n"
" %s 這一版由 %d 個 repo 組成:\n%s\n"
" ⇒ 拿掉 --only,整份一起退:release-manifest rollback %s\n"
" ⇒ 真的只有一個 repo 要動 ⇒ 那就 freeze 一份**新的**清單,"
"把不動的那幾格照抄過去。"
% (", ".join(only), name, len(man["parts"]),
"\n".join(" %s @ %s" % (p["repo"], p["version"])
for p in man["parts"]), name))
cur = all_manifests()
curv = {p["repo"]: p["version"] for p in cur[0]["parts"]} if cur else {}
curname = cur[0]["name"] if cur else "(沒有)"
print("退版目標:%s(現在手上的是 %s\n" % (name, curname))
moves, stays = [], []
for p in man["parts"]:
(stays if curv.get(p["repo"]) == p["version"] else moves).append(p)
print("【要動的】")
if not moves:
print(" (沒有——現在手上的組合已經等於這份清單)")
for p in moves:
owner_repo = p["repo"]
print(" %-34s %s → %s" % (owner_repo, curv.get(owner_repo, "?"), p["version"]))
print(" git -C <%s 的工作目錄> fetch --tags && "
"git -C <同上> checkout %s" % (owner_repo.split("/")[-1], p["version"]))
if stays:
print("\n【已經在目標版本、不必動】(列出來是為了讓你確認**沒有一格被漏掉**)")
for p in stays:
print(" %-34s %s" % (p["repo"], p["version"]))
print("\n🔴 這 %d 格是一組,要嘛全部到位,要嘛就不算退版完成。"
% len(man["parts"]))
print(" 退完跑一次複驗:release-manifest verify %s" % name)
def main():
global DRY
argv = sys.argv[1:]
if "--dry-run" in argv:
DRY = True
argv = [a for a in argv if a != "--dry-run"]
if not argv:
print(__doc__)
return
cmd, rest = argv[0], argv[1:]
table = {"freeze": cmd_freeze, "verify": cmd_verify, "show": cmd_show,
"list": cmd_list, "current": cmd_current, "diff": cmd_diff,
"rollback": cmd_rollback}
if cmd not in table:
print(__doc__)
die("🔴 沒有這個子指令:%s" % cmd)
table[cmd](rest)
if __name__ == "__main__":
main()
Executable
+94
View File
@@ -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())
+222
View File
@@ -0,0 +1,222 @@
#!/bin/bash
# 還債清單(scripts/debt-worklist)的測試——inkstone/ISEP#83 的四條驗收條件,一條一組。
#
# 全程離線:資料走 ISEP_DEBT_FIXTURE、時鐘走 ISEP_DEBT_NOW、狀態走 ISEP_DEBT_STATE_DIR。
# **不打 Gitea、不留任何測試票、不碰 $HOME**。
#
# 為什麼要能離線測:這支工具的判斷(誰排前面、誰算緊急線、領走的還在不在)
# 全都是純函式,只有「撈」那一步要網路。把撈的那一步換掉,其餘每一條都測得動——
# 否則就只剩「今天跑起來看起來對」,那不是測試(本 repo 心法:跑過 ≠ 驗過)。
cd "$(dirname "$0")/.." || exit 1
W=scripts/debt-worklist
PASS=0; FAIL=0
T=$(mktemp -d); trap 'rm -rf "$T"' EXIT
NOW=2026-08-28T00:00:00Z
ok(){ # $1=條件 $2=說明
if eval "$1"; then PASS=$((PASS+1)); printf ' ✅ %s\n' "$2"
else FAIL=$((FAIL+1)); printf ' ❌ %s\n 判準:%s\n' "$2" "$1"; fi
}
run(){ # 跑 list(或任何子命令),輸出留在 $OUT,離開碼留在 $RC
# 🔴 ISEP_COUNTDOWN_STATE_DIR 也要指到暫存目錄:主線(hooks/lib/mainline.py)住在那裡,
# 不隔離的話這支測試的結果會取決於「這台機器現在標了哪條主線」——那就不是測試了。
OUT=$(ISEP_DEBT_FIXTURE="$FX" ISEP_DEBT_STATE_DIR="$T/state" ISEP_DEBT_NOW="$NOW" \
ISEP_COUNTDOWN_STATE_DIR="$ML_DIR" python3 "$W" "$@" 2>&1); RC=$?
}
ML_DIR="$T/mainline"; mkdir -p "$ML_DIR"
set_mainline(){ # $1=主線 milestone 標題(空=沒有主線)
if [ -z "$1" ]; then rm -f "$ML_DIR/mainline.json"; return; fi
python3 - "$ML_DIR/mainline.json" "$1" <<'PY'
import json, sys
path, title = sys.argv[1], sys.argv[2]
json.dump({"ref": "inkstone/A#1", "owner": "inkstone", "repo": "A", "id": 1,
"title": title, "members": [], "due_on": ""}, open(path, "w"))
PY
}
# ── 造資料:一份長得像 Gitea 回應的 fixture ─────────────────────────────────
mkfx(){ FX="$T/$1.json"; python3 - "$FX" "$1" <<'PY'
import json, sys
path, which = sys.argv[1], sys.argv[2]
def it(repo, n, title, labels=(), ms=None, ms_state="open", assignees=(),
updated="2026-08-01T00:00:00Z", body="", pr=False):
d = {"number": n, "title": title, "body": body, "updated_at": updated,
"created_at": "2026-07-01T00:00:00Z", "state": "open",
"labels": [{"name": l} for l in labels],
"assignees": [{"login": a} for a in assignees] or None,
"repository": {"full_name": repo},
"html_url": f"https://git.uncle6.me/{repo}/issues/{n}"}
if ms:
d["milestone"] = {"title": ms, "state": ms_state}
if ms == "上個 sprint":
d["milestone"]["due_on"] = "2026-08-10T23:59:59Z" # 已經過了 18 天
if pr:
d["pull_request"] = {"merged": False}
return d
repos = [{"name": "inkstone/A", "issues": 0, "prs": 0},
{"name": "inkstone/B", "issues": 0, "prs": 0}]
deps, items = {}, []
if which == "full":
items = [
# 事故+擋住別人的 → 該排第一
it("inkstone/A", 1, "壞掉了", ["type/bug"], updated="2026-08-20T00:00:00Z",
body="x" * 2000),
it("inkstone/A", 2, "我等 A#1", updated="2026-08-27T00:00:00Z",
body="被 inkstone/A#1 擋住" + "y" * 2000),
it("inkstone/B", 9, "我也等 A#1", updated="2026-08-27T00:00:00Z",
body="要先解 inkstone/A#1" + "y" * 2000),
# 掛在 open milestone 上=緊急線在管,不該出現
it("inkstone/A", 3, "緊急線的票", ["p/high", "type/bug"], ms="這週出貨"),
# 掛在**別條**(非主線)milestone 上,而且期限已經過了
it("inkstone/A", 8, "別條線的票", ms="上個 sprint", body="q" * 2000),
# milestone 已關但票還開著=孤兒,該出現
it("inkstone/A", 4, "孤兒票", ms="上週", ms_state="closed", body="z" * 2000),
# 等 leo 的三種,都不該出現在可領清單
it("inkstone/A", 5, "等 leo 批", ["Human", "p/high", "type/bug"]),
it("inkstone/A", 6, "等 leo 動手", ["human/exec"]),
it("inkstone/A", 7, "等 leo 驗", ["s/stage", "p/high"]),
# 放很久沒人動 vs 昨天才動過:同條件下前者要排前面
it("inkstone/B", 10, "放了很久", updated="2026-06-01T00:00:00Z", body="w" * 2000),
it("inkstone/B", 11, "昨天才動過", updated="2026-08-27T00:00:00Z", body="w" * 2000),
# 躺著的 PR 也是債
it("inkstone/B", 12, "躺著的 PR", pr=True, updated="2026-08-10T00:00:00Z",
body="v" * 2000),
# p/low:往後排
it("inkstone/B", 13, "晚點沒關係", ["p/low"], body="u" * 2000),
# 互相指著=必然同包
it("inkstone/B", 20, "雙胞胎甲", body="跟 inkstone/B#21 是同一件事" + "t" * 2000,
updated="2026-08-05T00:00:00Z"),
it("inkstone/B", 21, "雙胞胎乙", body="見 inkstone/B#20" + "t" * 2000,
updated="2026-08-05T00:00:00Z"),
# 母票/子票:只有原生相依看得出來(內文沒有互指)
it("inkstone/B", 30, "母票", body="s" * 2000, updated="2026-08-05T00:00:00Z"),
it("inkstone/B", 31, "子票", body="s" * 2000, updated="2026-08-05T00:00:00Z"),
]
deps = {"inkstone/B#30": ["inkstone/B#31"]}
elif which == "empty":
items = [it("inkstone/A", 3, "緊急線的票", ["p/high"], ms="這週出貨"),
it("inkstone/A", 5, "等 leo 批", ["Human"])]
elif which == "onedone":
# 「昨天領走的今天關掉了」——它從 open 清單消失
items = [it("inkstone/A", 2, "我等 A#1", body="y" * 2000)]
json.dump({"repos": repos, "items": items, "deps": deps}, open(path, "w"))
PY
}
mkfx full; FULL="$T/full.json"
mkfx empty
mkfx onedone
echo "── 驗收 1:跑一次要吐出排好序的清單+「我查了哪些 repo」的證明 ──"
FX="$FULL"; run list --top 20
ok '[ "$RC" = 0 ]' '離開碼 0'
ok 'grep -q "查過的 repo2 個" <<<"$OUT"' '證明段落列出查過的 repo 數'
ok 'grep -q "inkstone/A" <<<"$OUT" && grep -q "inkstone/B" <<<"$OUT"' '每個 repo 都列名(不是只給總數)'
ok 'grep -q "1\. \[.*\] inkstone/A#1" <<<"$OUT"' '事故+擋住兩張 ⇒ 排第一名'
ok 'grep -q "事故+40" <<<"$OUT"' '每張票旁邊看得到「為什麼排這裡」的分項'
ok 'grep -q "擋住2張" <<<"$OUT"' '「擋住別人的」算得出來(兩張指著 A#1)'
echo "── 驗收 1b:排序的五個條件真的有作用 ──"
pos(){ grep -n "$1" <<<"$OUT" | head -1 | cut -d: -f1; }
ok '[ "$(pos "inkstone/B#10")" -lt "$(pos "inkstone/B#11")" ]' '放最久的排在昨天才動過的前面'
ok '[ "$(pos "inkstone/B#12")" -lt "$(pos "inkstone/B#13")" ]' '躺著的 PR 排在 p/low 前面'
ok 'grep -q "PR躺著+25" <<<"$OUT"' 'PR 被認出來(不是只算 issue'
ok '! grep -q "inkstone/A#3" <<<"$OUT"' '掛 open milestone 的不進清單(那歸緊急線)'
ok 'grep -q "inkstone/A#4" <<<"$OUT"' 'milestone 已關但票還開著 ⇒ 是債,要進清單'
ok '! grep -q "inkstone/A#5" <<<"$OUT" && ! grep -q "inkstone/A#6" <<<"$OUT"' \
'Humanhuman-exec 不進可領清單(沒有人能替 leo 領)'
ok '! grep -q "inkstone/A#7" <<<"$OUT"' 's/stage=等 leo 驗,要催不是要領'
ok 'grep -q "等 leo 的 3 張" <<<"$OUT"' '被扣掉的有講數字,不是靜靜消失'
echo "── 驗收 1c:成包——相關的要一起領,但不准黏成一坨 ──"
ok 'grep -A6 "inkstone/B#20(票)" <<<"$OUT" | grep -q "inkstone/B#21"' '互相指著的成同一包'
ok 'grep -A6 "inkstone/B#30(票)" <<<"$OUT" | grep -q "inkstone/B#31"' '原生相依(母票→子票)成同一包'
ok '! (grep -A6 "inkstone/A#1(票)" <<<"$OUT" | grep -q "同一包")' \
'單向引用不成包(A#2、B#9 都指著 A#1,但 A#1 沒指回來 ⇒ 不能黏成一坨)'
echo "── 接上「現在的主線是哪一個」(inkstone/ISEP#82 的唯一答案)──"
set_mainline ""; FX="$FULL"; run list --top 20
ok 'grep -q "現在沒有標主線" <<<"$OUT"' '沒標主線 ⇒ 照實說,不編一條出來'
ok '! grep -q "inkstone/A#3" <<<"$OUT" && ! grep -q "inkstone/A#8" <<<"$OUT"' \
'沒標主線 ⇒ 保守:掛 open milestone 的一律不領(不跟緊急線搶同一張)'
set_mainline "這週出貨"; run list --top 20
ok 'grep -q "現在的主線" <<<"$OUT" && grep -q "這週出貨" <<<"$OUT"' \
'標了主線 ⇒ 清單上講得出用的是哪一條(判斷來自 scripts/mainline,不是自己算的)'
ok '! grep -q "inkstone/A#3" <<<"$OUT"' '在主線上的票仍然不領(那是緊急線在管的)'
ok 'grep -q "inkstone/A#8" <<<"$OUT"' '掛在別條 milestone 的是債(那條線現在沒人推)'
ok 'grep -q "逾期17天的承諾+15" <<<"$OUT"' '別條 milestone 已逾期 ⇒ 算「答應過人家的」加分'
ok 'grep -q "別條\*\* milestone 上的 1 張仍算債" <<<"$OUT"' '被改判成債的有幾張,講出來'
set_mainline "" # 收回去,後面幾組回到「沒有主線」的世界
echo "── 驗收 2:清單是空的要說話,不能默默結束 ──"
FX="$T/empty.json"; run list
ok '[ "$RC" = 0 ]' '離開碼 0(沒東西可領不是錯誤)'
ok 'grep -q "查詢跑過了,結果是零" <<<"$OUT"' '明講「查過了,結果是零」'
ok 'grep -q "查過的 repo2 個" <<<"$OUT"' '空清單一樣附查詢證明(分得出「沒東西」與「沒查」)'
echo "── 驗收 3:今天領走的,明天不在清單上 ──"
rm -rf "$T/state"; FX="$FULL"
run claim inkstone/B#20 inkstone/B#21
ok '[ "$RC" = 0 ]' '領走兩張成功'
run list --top 20
PICK=$(sed -n '/今天可以領的/,$p' <<<"$OUT")
ok '! grep -q "inkstone/B#21" <<<"$PICK"' '領走的不再出現在可領清單(只看清單那一段)'
ok 'grep -q "之前領走的 2 張" <<<"$OUT"' '第二天看得到「之前領走的」那一段'
ok 'grep -q "還開著 2" <<<"$OUT"' '還開著的照實說(沒關就是還沒做完)'
ok 'grep -q "已經領走的 2 張" <<<"$OUT"' '扣除數字對得上'
echo "── 驗收 3b:領走的票關掉之後,狀態要跟著變 ──"
FX="$T/onedone.json"; run list
ok 'grep -q "已關 2" <<<"$OUT"' '票已經不在 open 清單 ⇒ 認定它做完了'
echo "── 驗收 3c:放回去 ──"
FX="$FULL"; run forget inkstone/B#20 inkstone/B#21
ok '[ "$RC" = 0 ]' 'forget 成功'
run list --top 20
ok 'grep -q "inkstone/B#20" <<<"$OUT"' '放回去之後又回到可領清單'
echo "── 誤用:擋得住,而且說得出怎麼改 ──"
run claim B#20
ok '[ "$RC" = 2 ] && grep -q "owner/repo#N" <<<"$OUT"' '票號寫錯 ⇒ 擋下並示範正確寫法'
run claim inkstone/B#999
ok '[ "$RC" = 2 ]' '領一張不存在/已關的票 ⇒ 擋下'
run claim
ok '[ "$RC" = 2 ] && grep -q "用法" <<<"$OUT"' '沒給票號 ⇒ 印用法'
echo "── 機器可讀(要接進別的東西時不用 parse 中文)──"
run list --json
ok 'python3 -c "import json,sys;d=json.load(sys.stdin);sys.exit(0 if d[\"bundles\"] and d[\"repos\"] else 1)" <<<"$OUT"' \
'--json 吐得出合法 JSON,且有 bundlesrepos'
echo "── 紅線:這支腳本自己不會醒(不准做成輪詢/fan-out)──"
ok '! grep -nE "time\\.sleep|crontab|sched|urlopen.*hooks" '"$W" \
'原始碼裡沒有等待/排程/裝 webhook 的動作(只有註解在談紅線,那不算)'
echo "── SessionStart 那一句:不用有人記得下指令,也不准偷偷打網路 ──"
# 把 session-start-recall.sh 裡「還債線」那一段切出來單獨跑(其餘段落會打雲端,這裡不要)。
FRAG="$T/frag.sh"
sed -n '/push 6:還債線/,/^echo ""$/p' hooks/session-start-recall.sh > "$FRAG"
ok '[ -s "$FRAG" ]' 'session-start-recall.sh 裡真的有那一段(被刪掉就會紅)'
rm -rf "$T/nudge"; mkdir -p "$T/nudge"
OUT=$(set -euo pipefail; ISEP_DEBT_STATE_DIR="$T/nudge" bash "$FRAG" 2>&1); RC=$?
ok '[ "$RC" = 0 ]' '沒有狀態檔也跑得過(第一次開 session)'
ok 'grep -q "今天還沒撈過" <<<"$OUT"' '今天沒撈過 ⇒ 開 session 就會說'
ok 'grep -q "debt-worklist list" <<<"$OUT"' '順手把要跑的那一行給出來'
printf '{"claims":{"a/b#1":{}},"last_run":"%sT00:00:00Z"}' "$(date -u +%Y-%m-%d)" > "$T/nudge/claimed.json"
OUT=$(ISEP_DEBT_STATE_DIR="$T/nudge" bash "$FRAG" 2>&1)
ok 'grep -q "今天撈過了" <<<"$OUT"' '今天撈過了 ⇒ 不再重複催'
ok 'grep -q "手上領著 1 張" <<<"$OUT"' '手上還領著幾張講出來'
ok '! grep -qE "curl|urlopen|http" <<<"$(cat "$FRAG")"' \
'SessionStart 那一段完全不打網路(撈是人決定的動作,不是開機自動去撈)'
echo
echo "通過 $PASS 條,失敗 $FAIL"
[ "$FAIL" = 0 ]
+155
View File
@@ -0,0 +1,155 @@
#!/usr/bin/env bash
# scripts/milestone-account 的迴歸測試(inkstone/ISEP#85
#
# 驗收 1:關掉一個 milestone → 三個數字要自己算出來,不用手填
# 驗收 2:差超過 25% → 要逼我挑一個代號,挑不出來不准結案
# 驗收 3:累積幾次之後 → 看得出哪一種原因最常發生
# 驗收 4:拿已經逾期的 milestone 進去算 → 要算得出數字
#
# 🔴 全程離線(MILESTONE_ACCOUNT_FIXTURE =一份假的 Gitea 回應表),
# 時鐘用 MILESTONE_ACCOUNT_NOW 定住,帳本寫在 mktemp 目錄。
# **不打 Gitea、不關任何真的 milestone、不碰 repo 裡的 system-dev/estimates/。**
set -u
TOOL="${1:-$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/milestone-account}"
TMP=$(mktemp -d); trap 'rm -rf "$TMP"' EXIT
PASS=0; FAIL=0; N=0
NOW="2026-08-28T00:00:00Z"
FX="$TMP/fx.json"
python3 - "$FX" <<'PY'
import json, sys, urllib.parse
api = {}
def iso(d, h=0):
return "2026-08-%02dT%02d:00:00Z" % (d, h)
def issue(n, created, closed=None, assignee=True, labels=(), ms=None):
return {"number": n, "created_at": created, "closed_at": closed,
"updated_at": closed or created,
"assignees": [{"login": "cc"}] if assignee else [],
"labels": [{"name": l} for l in labels],
"milestone": ({"id": ms} if ms else None)}
def comment(n, at):
return {"issue_url": "https://x/api/v1/repos/o/r/issues/%d" % n, "created_at": at}
def repo(full, mid, title, created, due, closed, issues, comments, open_ms=True):
o, r = full.split("/")
ms = {"id": mid, "title": title, "created_at": created, "due_on": due,
"closed_at": closed, "state": "open" if closed is None else "closed"}
api["/repos/%s/%s/milestones/%d" % (o, r, mid)] = ms
api["/repos/%s/%s/issues?state=all&type=issues&limit=50&page=1" % (o, r)] = issues
api["/repos/%s/%s/issues/comments?limit=100&since=%s"
% (o, r, urllib.parse.quote(created))] = comments
api["/repos/%s/%s/milestones?state=open&limit=50" % (o, r)] = [ms] if open_ms else []
# ① A#12 磨洋工:08-02 收掉一張之後,時間軸整整空了 24 天
repo("inkstone/A", 12, "sprint-磨洋工", iso(1), "2026-08-08T23:59:59Z", None,
[issue(1, iso(1, 1), iso(2), ms=12), issue(2, iso(1, 2), ms=12),
issue(3, iso(1, 3), ms=12), issue(9, iso(10), ms=None)],
[comment(1, iso(26))])
# ② B#13 準時收掉:偏差只有 -6%,不必挑代號
repo("inkstone/B", 13, "sprint-準時", iso(20), "2026-08-27T23:59:59Z",
"2026-08-27T12:00:00Z",
[issue(1, iso(20, 1), iso(25), ms=13), issue(2, iso(20, 2), iso(26), ms=13)],
[comment(1, iso(22)), comment(2, iso(24))], open_ms=False)
# ③ C#14 過程沒異常、就是估錯:每兩天都有動作,其他六個訊號全不成立
repo("inkstone/C", 14, "sprint-估錯", iso(1), "2026-08-10T23:59:59Z",
"2026-08-25T00:00:00Z",
[issue(1, iso(1, 1), iso(24), ms=14), issue(2, iso(1, 2), iso(23), ms=14)],
[comment(1, iso(d)) for d in range(3, 25, 2)], open_ms=False)
# ④ D#15 範圍變大:開跑之後又補進來三張票
repo("inkstone/D", 15, "sprint-範圍變大", iso(1), "2026-08-10T23:59:59Z",
"2026-08-25T00:00:00Z",
[issue(1, iso(1, 1), iso(24), ms=15), issue(2, iso(1, 2), iso(23), ms=15),
issue(3, iso(15), ms=15), issue(4, iso(15, 1), ms=15),
issue(5, iso(15, 2), ms=15)],
[comment(1, iso(d)) for d in range(3, 25, 2)], open_ms=False)
api["/orgs/inkstone/repos?limit=100"] = [
{"full_name": "inkstone/%s" % x} for x in "ABCD"]
json.dump({"api": api}, open(sys.argv[1], "w"), ensure_ascii=False)
PY
run() { MILESTONE_ACCOUNT_FIXTURE="$FX" MILESTONE_ACCOUNT_DIR="$TMP/led" \
MILESTONE_ACCOUNT_NOW="$NOW" python3 "$TOOL" "$@" 2>&1; }
t() {
want="$1"; desc="$2"; must="$3"; shift 4
N=$((N+1)); out=$(run "$@"); rc=$?
ok=1; [ "$rc" -eq "$want" ] || ok=0
if [ -n "$must" ] && ! printf '%s' "$out" | grep -qF "$must"; then ok=0; fi
if [ "$ok" -eq 1 ]; then printf ' ✅ %s\n' "$desc"; PASS=$((PASS+1))
else
printf ' ❌ %s —— 期望 exit=%s%s,實得 exit=%s\n' "$desc" "$want" \
"${must:+ 且輸出含「$must}" "$rc"
printf '%s\n' "$out" | sed -n '1,12p' | sed 's/^/ /'; FAIL=$((FAIL+1))
fi
}
echo "── 驗收 1:三個數字自己算出來 ─────────────────────────────"
t 0 "① audit 印出「當初訂的期限」" "當初訂的期限" -- audit inkstone/A#12
t 0 "② audit 印出「實際花掉的」" "實際花掉的" -- audit inkstone/A#12
t 0 "③ 偏差算得出來(A#12 逾期 +238%" "+238%" -- audit inkstone/A#12
t 0 "④ 期限是 08-08、實際算到今天 08-28 ⇒ 27 天" "27.0天" -- audit inkstone/A#12
echo "── 驗收 2:差超過 25% 逼你挑代號 ──────────────────────────"
t 2 "⑤ A#12 差 +237% 卻沒給 --reason ⇒ 不准結案" "挑不出代號不准結案" -- \
close inkstone/A#12
t 2 "⑥ 承上:訊息要直接列出撐得住的代號" "時間軸撐得住的代號:idle" -- close inkstone/A#12
t 0 "⑦ B#13 只差 -6% ⇒ 不必挑代號,直接結得掉" "已記帳" -- close inkstone/B#13
t 2 "⑧ 造一個不存在的代號 ⇒ 擋(封閉集合)" "不是那七個代號之一" -- \
close inkstone/A#12 --reason 拖延症
t 2 "⑨ 挑一個時間軸撐不住的代號 ⇒ 擋(不採信我自己說的)" "時間軸撐不住" -- \
close inkstone/C#14 --reason idle
t 0 "⑩ C#14 六個訊號都不成立 ⇒ 唯一合法的是 misestimate" "misestimate" -- \
audit inkstone/C#14
t 0 "⑪ 承上:--reason misestimate 收得掉" "原因=misestimate" -- \
close inkstone/C#14 --reason misestimate
t 0 "⑫ A#12 挑 idle(時間軸真的有 24 天空白)⇒ 收得掉" "原因=idle" -- \
close inkstone/A#12 --reason idle
t 0 "⑬ D#15 開跑後補三張票 ⇒ scope-creep 撐得住" "原因=scope-creep" -- \
close inkstone/D#15 --reason scope-creep
echo "── 驗收 3:看得出哪一種原因最常發生 ───────────────────────"
t 0 "⑭ report 數得出每個代號各幾次" "磨洋工" -- report
t 0 "⑮ report 算得出累計結案幾個" "累計結案 4 個里程碑" -- report
N=$((N+1))
if grep -q "哪一種原因最常發生" "$TMP/led/REPORT.md" 2>/dev/null \
&& grep -q "時間軸證據" "$TMP/led/REPORT.md"; then
printf ' ✅ ⑯ REPORT.md 寫出來了,而且每一筆都帶時間軸證據\n'; PASS=$((PASS+1))
else
printf ' ❌ ⑯ REPORT.md 內容不對\n'; FAIL=$((FAIL+1))
fi
N=$((N+1))
if [ "$(wc -l < "$TMP/led/ledger.jsonl")" = "4" ]; then
printf ' ✅ ⑰ 帳本剛好四筆(沒有被擋下的那幾次也記進去)\n'; PASS=$((PASS+1))
else
printf ' ❌ ⑰ 帳本筆數不對:%s\n' "$(wc -l < "$TMP/led/ledger.jsonl" 2>/dev/null)"
FAIL=$((FAIL+1))
fi
echo "── 驗收 4:拿已經逾期的里程碑進去算 ───────────────────────"
t 0 "⑱ list --overdue 撈得到逾期的那一個,而且帶數字" "inkstone/A#12" -- \
list --org inkstone --overdue
t 0 "⑲ 承上:逾期的要被標成「差超過 ±25%」那一類" "🔴" -- \
list --org inkstone --overdue
echo "── 其他 ───────────────────────────────────────────────────"
t 0 "⑳ codes 印得出七個代號,一個不多一個不少" "misestimate" -- codes
N=$((N+1))
if [ "$(run codes | grep -cE '^ [a-z-]+ ')" = "7" ]; then
printf ' ✅ ㉑ 真的是七個(封閉集合)\n'; PASS=$((PASS+1))
else
printf ' ❌ ㉑ 代號數量不是 7%s\n' "$(run codes | grep -cE '^ [a-z-]+ ')"; FAIL=$((FAIL+1))
fi
t 0 "㉒ --dry-run 印出要打的那通 API 但不送出" "一筆都沒有真的寫出去" -- \
close inkstone/A#12 --reason idle --dry-run
t 2 "㉓ 里程碑編號寫錯 ⇒ 講清楚寫法" "寫法是 owner/repo#里程碑編號" -- audit ISEP-12
echo
echo "通過 $PASS 條,失敗 $FAIL 條(共 $N 條)"
[ "$FAIL" -eq 0 ]
+98
View File
@@ -0,0 +1,98 @@
#!/usr/bin/env bash
# scripts/release-manifest 的迴歸測試(inkstone/ISEP#84
#
# 兩個方向都要有證據:
# A 群「該擋」——版本號在 Gitea 抓不到/清單已凍結還想加東西/退版只退一個
# B 群「該過」——版本號真的存在就凍結得起來、退版吐出整份、diff 看得出差在哪
# C 群「leo 打開看得懂」——INDEX.md 真的被重寫,而且第一份標著「現在手上的這一版」
#
# 🔴 全程離線(RELEASE_MANIFEST_FIXTURE),不打 Gitea、不建任何真的 release。
# 清單寫在 mktemp 的目錄(RELEASE_MANIFEST_DIR),不碰 repo 裡的 releases/。
set -u
TOOL="${1:-$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/release-manifest}"
TMP=$(mktemp -d); trap 'rm -rf "$TMP"' EXIT
PASS=0; FAIL=0; N=0
FX="$TMP/fx.json"
cat > "$FX" <<'JSON'
{"versions": {
"inkstone/Arcrun@v1.4.52": {"kind":"release","url":"u1","commit":"aaaaaaaaaaaa"},
"inkstone/Arcrun@v1.4.51": {"kind":"release","url":"u2","commit":"bbbbbbbbbbbb"},
"inkstone/arcrun-collector@v0.18.36": {"kind":"tag","url":"u3","commit":"cccccccccccc"},
"inkstone/arcrun-collector@v0.18.35": {"kind":"tag","url":"u4","commit":"dddddddddddd"}
}}
JSON
run() { RELEASE_MANIFEST_FIXTURE="$FX" RELEASE_MANIFEST_DIR="$TMP/rel" python3 "$TOOL" "$@" 2>&1; }
# t <期望 exit> <說明> <必須出現的字串(可空)> -- <參數…>
t() {
want="$1"; desc="$2"; must="$3"; shift 4
N=$((N+1))
out=$(run "$@"); rc=$?
ok=1
[ "$rc" -eq "$want" ] || ok=0
if [ -n "$must" ] && ! printf '%s' "$out" | grep -qF "$must"; then ok=0; fi
if [ "$ok" -eq 1 ]; then printf ' ✅ %s\n' "$desc"; PASS=$((PASS+1))
else
printf ' ❌ %s —— 期望 exit=%s%s,實得 exit=%s\n' "$desc" "$want" \
"${must:+ 且輸出含「$must}" "$rc"
printf '%s\n' "$out" | sed -n '1,8p' | sed 's/^/ /'; FAIL=$((FAIL+1))
fi
}
echo "── B 群:該過 ──────────────────────────────────────────────"
t 0 "① 兩個 repo 的版本都真的存在 ⇒ 凍結得起來" "已凍結(2 個 repo" -- \
freeze R-2026.08.29 --part inkstone/Arcrun@v1.4.51 --part inkstone/arcrun-collector@v0.18.35
t 0 "② 單一 repo 的交貨不必特例(清單退化成一格)" "已凍結(1 個 repo" -- \
freeze R-single --part inkstone/Arcrun@v1.4.51
t 0 "③ 再凍一份新的(這才是「新的需求進下一版」)" "已凍結(2 個 repo" -- \
freeze R-2026.08.30 --part inkstone/Arcrun@v1.4.52 --part inkstone/arcrun-collector@v0.18.36
t 0 "④ current 答得出「我現在手上是哪一版」" "R-2026.08.30" -- current
t 0 "⑤ verify:每一格都還抓得到" "全部 2 格都還抓得到" -- verify R-2026.08.30
t 0 "⑥ diff 看得出兩份差在哪一格" "v1.4.51 → v1.4.52" -- diff R-2026.08.29 R-2026.08.30
t 0 "⑦ --dry-run 不寫檔" "一個檔都沒有真的寫出去" -- \
freeze R-dryrun --part inkstone/Arcrun@v1.4.52 --dry-run
t 2 "⑧ 承上:--dry-run 那份真的沒被寫出去" "沒有這份清單" -- show R-dryrun
echo "── A 群:該擋 ──────────────────────────────────────────────"
t 2 "⑨ 版本號在 Gitea 抓不到 ⇒ 不給凍結(驗收 1)" "在 Gitea 上抓不到" -- \
freeze R-bad --part inkstone/Arcrun@v9.9.9
t 2 "⑩ 承上:整份都不寫(不會留下半份清單)" "沒有這份清單" -- show R-bad
t 2 "⑪ 一格都不給 ⇒ 擋" "至少要有一格" -- freeze R-empty
t 2 "⑫ 同名清單已凍結 ⇒ 不准再加東西" "新的需求進下一版" -- \
freeze R-2026.08.30 --part inkstone/Arcrun@v1.4.51
t 2 "⑬ 同一個 repo 出現兩次 ⇒ 擋" "出現兩次" -- \
freeze R-dup --part inkstone/Arcrun@v1.4.51 --part inkstone/Arcrun@v1.4.52
t 2 "⑭ 清單名亂寫 ⇒ 擋" "清單名只准" -- freeze "R 2026/08" --part inkstone/Arcrun@v1.4.51
t 2 "⑮ part 寫法不對 ⇒ 擋" "寫法是 owner/repo@版本號" -- \
freeze R-x --part Arcrun-v1.4.52
echo "── 驗收 2/3:退版 ─────────────────────────────────────────"
t 0 "⑯ 退版吐出整份清單的每一格(驗收 2)" "這 2 格是一組" -- rollback R-2026.08.29
t 0 "⑰ 承上:兩個 repo 都被列進「要動的」" "arcrun-collector" -- rollback R-2026.08.29
t 2 "⑱ 試著只退其中一個 ⇒ 被擋下來(驗收 3)" "沒有「只退一個」這個選項" -- \
rollback R-2026.08.29 --only inkstone/Arcrun
t 0 "⑲ 已經在目標版本的那幾格也要列出來(沒有一格被漏掉)" "不必動" -- \
rollback R-2026.08.30
echo "── 驗收 4:leo 打開那份清單看得懂 ──────────────────────────"
N=$((N+1))
IDX="$TMP/rel/INDEX.md"
if [ -f "$IDX" ] && grep -q "現在手上的這一版" "$IDX" \
&& grep -q "R-2026.08.30" "$IDX" && grep -q "從來沒測過的組合" "$IDX"; then
printf ' ✅ ⑳ INDEX.md 被重寫,標出「現在手上的這一版」與為什麼要有它\n'; PASS=$((PASS+1))
else
printf ' ❌ ⑳ INDEX.md 內容不對\n'; sed -n '1,20p' "$IDX" 2>/dev/null | sed 's/^/ /'
FAIL=$((FAIL+1))
fi
N=$((N+1))
if head -20 "$IDX" 2>/dev/null | grep -A2 "R-2026.08.30" | grep -q "現在手上的這一版"; then
printf ' ✅ ㉑ 最新那一份(不是最舊那份)才被標成「現在手上的」\n'; PASS=$((PASS+1))
else
printf ' ❌ ㉑ 標錯了哪一份是現在手上的\n'; FAIL=$((FAIL+1))
fi
echo
echo "通過 $PASS 條,失敗 $FAIL 條(共 $N 條)"
[ "$FAIL" -eq 0 ]
+274
View File
@@ -0,0 +1,274 @@
#!/bin/bash
# 「下游做完時頂層跟著關」的測試(inkstone/ISEP#92
#
# 這支測三件事:
# ① 判準本身(純函式 is_loosewriteback_planjourney_label
# —— 這三支不打網路,所以「撈得對不對」「回寫該做什麼」測得動,
# 不必真的開票、關票來驗。
# ② `subtask``handoff` 的參數閘:該擋的擋、齊全的放得過
# ③ `loose` 這個動詞真的接得上(走到網路那一層才炸)
#
# 怎麼在「不真的動任何票」的前提下分辨擋/放行(沿用 test-ticket-where-seen-guard.sh):
# TICKET_HOST 指到一個連不上的位址(127.0.0.1:9
# 離開碼 2 = 閘擋下(die)
# 離開碼 1 = 閘全部放行,走到網路那一層才炸(Connection refused
# 全程離線、不打真實 Gitea、不產生任何票、不關任何票。
cd "$(dirname "$0")/.." || exit 1
T=scripts/ticket
TMP=$(mktemp -d)
trap 'rm -rf "$TMP"' EXIT
PASS=0; FAIL=0
cat > "$TMP/body.md" <<'MD'
## 目標
測試用內文。
## 驗收條件
離開碼 2 = 被擋;離開碼 1 = 放行(走到網路才炸)。
## deliverable 類型
code
MD
BAD_BODY="$TMP/bad.md"; echo "沒有模板欄位" > "$BAD_BODY"
TITLE="身為看頂層票的人,我要下游做完時頂層跟著關,我才不會看到一堆早就做完的票"
# ── 純函式探針:把 scripts/ticket 當模組載進來問 ────────────────────────
probe() { # probe <python 片段> → 印出結果
python3 - "$1" <<'PY'
import sys, importlib.util as u
from importlib.machinery import SourceFileLoader as L
spec = u.spec_from_loader("t", L("t", "scripts/ticket"))
m = u.module_from_spec(spec); spec.loader.exec_module(m)
print(eval(sys.argv[1], {"m": m}))
PY
}
p() { # p <說明> <want> <python 片段>
got=$(probe "$3" 2>&1)
if [ "$got" = "$2" ]; then PASS=$((PASS+1)); printf ' ✅ '; else FAIL=$((FAIL+1)); printf ' ❌ '; fi
printf 'want=%s got=%s %s\n' "$2" "$got" "$1"
}
O='{"state":"open"}'; C='{"state":"closed"}'
echo "── ① is_loose:「下游都關了、自己還開著」是機械事實 ──"
p "★ open + 下游全關 → 撈得到(票上驗收第 3 條)" "True" "m.is_loose('open',[$C,$C])"
p "open + 還有一張下游開著 → 不撈(它還在跑)" "False" "m.is_loose('open',[$C,$O])"
p "open + 從來沒有下游 → 不撈(它根本不是頂層票)" "False" "m.is_loose('open',[])"
p "closed + 下游全關 → 不撈(自己都關了)" "False" "m.is_loose('closed',[$C,$C])"
p "open 下游全開 → 不撈" "False" "m.is_loose('open',[$O,$O])"
echo
echo "── ② writeback_plan:下游關掉之後,頂層票該被怎麼處理 ──"
p "★ 最後一張下游關掉 → handback(指派回總管+s/review,不默默留著)" \
"('handback', [])" "m.writeback_plan('open',[$C,$C])"
p "還有別的下游沒關 → 只記一筆,別假裝它可以收了" \
"('note', [{'state': 'open'}])" "m.writeback_plan('open',[$C,$O])"
p "頂層票自己已經關了 → skip" \
"('skip', [])" "m.writeback_plan('closed',[$C,$C])"
p "頂層票已關但還有 open 下游 → 一樣 skip(不去吵已關的票)" \
"('skip', [{'state': 'open'}])" "m.writeback_plan('closed',[$O])"
p "防禦:頂層票沒有任何下游(實務上到不了這裡)→ 不會炸" \
"('handback', [])" "m.writeback_plan('open',[])"
echo
echo "── ③ journey_label:只加前綴,不猜名字合不合法 ──"
p "收件匣 → j/收件匣" "j/收件匣" "m.journey_label('收件匣')"
p "已經帶前綴 → 不重複加" "j/收件匣" "m.journey_label('j/收件匣')"
p "前後空白 → 修掉" "j/收件匣" "m.journey_label(' 收件匣 ')"
echo
echo "── ④ subtaskhandoff 的參數閘(該擋)──"
run() { # run <want_rc> <說明> <verb> [參數...]
want=$1; what=$2; verb=$3; shift 3
out=$(TICKET_HOST=http://127.0.0.1:9 GITEA_TOKEN=x \
python3 "$T" "$verb" inkstone/ISEP#92 "$@" 2>&1)
got=$?
LAST_OUT="$out"
if [ "$got" = "$want" ]; then PASS=$((PASS+1)); printf ' ✅ '; else FAIL=$((FAIL+1)); printf ' ❌ '; fi
printf 'want=%s got=%s %s\n' "$want" "$got" "$what"
}
has() { if printf '%s' "$LAST_OUT" | grep -q "$1"; then PASS=$((PASS+1)); printf ' ✅ '
else FAIL=$((FAIL+1)); printf ' ❌ '; fi; printf '訊息裡有「%s」 %s\n' "$1" "$2"; }
run 2 "缺 --title" subtask -F "$TMP/body.md"
run 2 "缺 -F" subtask --title "$TITLE"
run 2 "標題不是 User Story" subtask --title "整理一下票" -F "$TMP/body.md"
run 2 "內文缺模板欄位" subtask --title "$TITLE" -F "$BAD_BODY"
run 2 "指派了人卻沒寫 --next" subtask --title "$TITLE" -F "$TMP/body.md" --assign someone
run 2 "--journey 給了空字串(對不上就不要加這個參數)" \
subtask --title "$TITLE" -F "$TMP/body.md" --journey ""
has "不硬湊" "空 journey 的訊息講的是「不硬湊」,不是叫人隨便填一個"
echo
echo "── ⑤ 齊全就放得過(誤攔比漏擋更該修)──"
run 1 "subtask 齊全 → 走到網路才炸" subtask --title "$TITLE" -F "$TMP/body.md"
run 1 "★ handoff 是同一個動作的別名,一樣放得過" handoff --title "$TITLE" -F "$TMP/body.md" --to arcrun-rag
run 1 "帶 --journey 一樣放得過" subtask --title "$TITLE" -F "$TMP/body.md" --journey 收件匣
run 1 "--assign 有配 --next → 放行" subtask --title "$TITLE" -F "$TMP/body.md" --assign someone --next "先看一眼"
echo
echo "── ⑥ loose 這個動詞接得上 ──"
out=$(TICKET_HOST=http://127.0.0.1:9 GITEA_TOKEN=x python3 "$T" loose 2>&1); got=$?
if [ "$got" = 1 ]; then PASS=$((PASS+1)); printf ' ✅ '; else FAIL=$((FAIL+1)); printf ' ❌ '; fi
printf 'want=1 got=%s loose 走到網路那一層才炸(動詞有註冊、參數不擋)\n' "$got"
out=$(python3 "$T" 2>&1)
if printf '%s' "$out" | grep -q "ticket loose"; then PASS=$((PASS+1)); printf ' ✅ '
else FAIL=$((FAIL+1)); printf ' ❌ '; fi
printf '不帶參數的說明裡列得出 loose(撈不到的東西等於不存在)\n'
echo
echo "── ⑦ 完工回寫的接線(票上驗收第 2 條)──"
echo " 把 api() 換成錄音機跑一次 _writeback,驗它到底打了哪些端點、送了什麼。"
echo " 不打網路、不動任何真票;/blocks 的回應形狀取自 2026-08-28 實查 inkstone/ISEP#75。"
w() { # w <說明> <want> <python 片段(用 rec 這個錄音清單)>
got=$(python3 - "$3" <<'PY' 2>&1
import sys, json, importlib.util as u
from importlib.machinery import SourceFileLoader as L
spec = u.spec_from_loader("t", L("t", "scripts/ticket"))
m = u.module_from_spec(spec); spec.loader.exec_module(m)
SCEN = json.loads(__import__("os").environ["SCEN"])
rec = []
def fake_api(path, payload=None, method=None):
rec.append((method or ("POST" if payload is not None else "GET"), path, payload))
if path.endswith("/blocks"):
return SCEN["blocks"]
if path.endswith("/dependencies"):
return SCEN["deps"]
if "/labels" in path and (method or "GET") == "GET":
return [{"name": n, "id": i} for i, n in enumerate(SCEN.get("labels", []), 1)]
return {}
m.api = fake_api
# _writeback 會把進度印出來——那是給人看的,不是這支測試的判準。
# 判準是**它打了哪些端點、送了什麼**(rec),所以把它的輸出吞掉。
import contextlib, io
buf = io.StringIO()
with contextlib.redirect_stdout(buf), contextlib.redirect_stderr(buf):
m._writeback("inkstone", "arcrun-rag", 143, "https://example/pr/1")
print(eval(sys.argv[1], {"rec": rec, "m": m}))
PY
)
if [ "$got" = "$2" ]; then PASS=$((PASS+1)); printf ' ✅ '; else FAIL=$((FAIL+1)); printf ' ❌ '; fi
printf 'want=%s got=%s %s\n' "$2" "$got" "$1"
}
PARENT='{"number":136,"state":"open","title":"頂層票","labels":[{"name":"s/doing"},{"name":"p/high"}],"repository":{"full_name":"inkstone/arcrun-rag"}}'
LBL='["s/todo","s/doing","s/review","p/high"]'
# 情境 A:這是最後一張下游 → 母票要被指派回總管+改 s/review
export SCEN="{\"blocks\":[$PARENT],\"deps\":[{\"state\":\"closed\",\"number\":143,\"title\":\"x\",\"repository\":{\"full_name\":\"inkstone/arcrun-rag\"}}],\"labels\":$LBL}"
w "★ 有去問子票的 /blocks(誰在等我)" "True" \
"any(p.endswith('/issues/143/blocks') for _,p,_ in rec)"
w "★ 有在母票的時間軸貼回寫留言" "True" \
"any(mm=='POST' and p=='/repos/inkstone/arcrun-rag/issues/136/comments' for mm,p,_ in rec)"
w "★ 留言裡點名了剛關掉的那張下游票" "True" \
"any('arcrun-rag#143' in (pl or {}).get('body','') for _,p,pl in rec if p.endswith('/136/comments'))"
w "★ 留言裡有交付物連結(不是空口說做完了)" "True" \
"any('https://example/pr/1' in (pl or {}).get('body','') for _,p,pl in rec if p.endswith('/136/comments'))"
w "★ 留言第一行有身份欄(機器貼的留言一樣要標)" "True" \
"all((pl or {}).get('body','').startswith('【身份】') for _,p,pl in rec if p.endswith('/136/comments'))"
w "★ 母票被指派回總管(欄位,不是一句話)" "['claude-code']" \
"[(pl or {}).get('assignees') for mm,p,pl in rec if mm=='PATCH' and p=='/repos/inkstone/arcrun-rag/issues/136'][0]"
w "★ 母票的 s/* 換成 s/review,而非 s/* 的標籤留著" "True" \
"[sorted(pl['labels'])==sorted([3,4]) for mm,p,pl in rec if mm=='PUT' and p.endswith('/136/labels')][0]"
w "沒有去關母票(關票要有人驗——默默關掉跟默默留著是同一個病)" "False" \
"any((pl or {}).get('state')=='closed' for _,_,pl in rec)"
# 情境 B:母票還有別的下游沒關 → 只記一筆,不准指派、不准改 tag
export SCEN="{\"blocks\":[$PARENT],\"deps\":[{\"state\":\"closed\",\"number\":143,\"title\":\"x\",\"repository\":{\"full_name\":\"inkstone/arcrun-rag\"}},{\"state\":\"open\",\"number\":150,\"title\":\"還沒做的\",\"repository\":{\"full_name\":\"inkstone/Arcrun\"}}],\"labels\":$LBL}"
w "還有下游沒關 → 只貼留言,不指派" "False" "any(mm=='PATCH' for mm,_,_ in rec)"
w "還有下游沒關 → 不改 tag" "False" "any(mm=='PUT' for mm,_,_ in rec)"
w "留言裡把還沒關的那張列出來(跨 repo 也要列全稱)" "True" \
"any('inkstone/Arcrun#150' in (pl or {}).get('body','') for _,p,pl in rec if p.endswith('/136/comments'))"
# 情境 C:母票自己已經關了 → 完全不動它
PARENT_CLOSED='{"number":136,"state":"closed","title":"頂層票","labels":[],"repository":{"full_name":"inkstone/arcrun-rag"}}'
export SCEN="{\"blocks\":[$PARENT_CLOSED],\"deps\":[{\"state\":\"closed\",\"number\":143,\"title\":\"x\",\"repository\":{\"full_name\":\"inkstone/arcrun-rag\"}}],\"labels\":$LBL}"
w "母票已關 → 一則留言都不貼(不去吵已關的票)" "False" \
"any(p.endswith('/136/comments') for _,p,_ in rec)"
# 情境 D:這張票沒有任何頂層票在等它 → 不要吵
export SCEN="{\"blocks\":[],\"deps\":[],\"labels\":$LBL}"
w "沒有頂層票在等它 → 只問一次 /blocks 就收工" "1" "len(rec)"
unset SCEN
echo
echo "── ⑧ 雙向連結的接線(票上驗收第 1 條)──"
echo " 同樣用錄音機跑一次 subtask:驗「兩張票互相看得到對方」不是靠人記得補。"
b() { # b <說明> <want> <python 片段> [額外 argv...]
what=$1; want=$2; frag=$3; shift 3
got=$(ARGS_JSON=$(printf '%s\n' "$@" | python3 -c "import sys,json;print(json.dumps([l.rstrip('\n') for l in sys.stdin if l.strip()!='']))") \
python3 - "$frag" <<'PY' 2>&1
import sys, os, json, contextlib, io, importlib.util as u
from importlib.machinery import SourceFileLoader as L
spec = u.spec_from_loader("t", L("t", "scripts/ticket"))
m = u.module_from_spec(spec); spec.loader.exec_module(m)
rec = []
def fake_api(path, payload=None, method=None):
rec.append((method or ("POST" if payload is not None else "GET"), path, payload))
if path.endswith("/labels?limit=100"):
return [{"name": n, "id": i} for i, n in
enumerate(["s/todo", "s/review", "p/high", "j/收件匣"], 1)]
if path.endswith("/dependencies"):
return [{"state": "open", "number": 900, "title": "子票",
"repository": {"full_name": "inkstone/arcrun-rag"}}]
if path == "/repos/inkstone/ISEP/issues":
return {"number": 900, "html_url": "https://git.uncle6.me/inkstone/ISEP/issues/900"}
if path == "/repos/inkstone/arcrun-rag/issues":
return {"number": 900, "html_url": "https://git.uncle6.me/inkstone/arcrun-rag/issues/900"}
if path == "/repos/inkstone/ISEP/issues/92":
return {"number": 92, "labels": [{"name": "p/high"}]}
return {}
m.api = fake_api
argv = ["inkstone/ISEP#92"] + json.loads(os.environ["ARGS_JSON"])
buf = io.StringIO()
blocked = False
with contextlib.redirect_stdout(buf), contextlib.redirect_stderr(buf):
try:
m.cmd_subtask(argv)
except SystemExit:
blocked = True # 被閘擋下也是一種結果,不該讓整支測試靜靜掛掉
print(eval(sys.argv[1], {"rec": rec, "m": m, "blocked": blocked, "buf": buf.getvalue()}))
PY
)
if [ "$got" = "$want" ]; then PASS=$((PASS+1)); printf ' ✅ '; else FAIL=$((FAIL+1)); printf ' ❌ '; fi
printf 'want=%s got=%s %s\n' "$want" "$got" "$what"
}
A_BASE=(--title "$TITLE" -F "$TMP/body.md" --to arcrun-rag)
b "★ 子票內文寫著它的頂層票是誰(下游看得到上游)" "True" \
"any('Parent: inkstone/ISEP#92' in (pl or {}).get('body','') for mm,p,pl in rec if p=='/repos/inkstone/arcrun-rag/issues')" \
"${A_BASE[@]}"
b "★ 有掛上 Gitea 原生相依(不是只寫在內文裡)" "True" \
"any(mm=='POST' and p=='/repos/inkstone/ISEP/issues/92/dependencies' for mm,p,pl in rec)" \
"${A_BASE[@]}"
b "★ 頂層票的時間軸上有一則指向下游的留言(上游看得到下游)" "True" \
"any('inkstone/arcrun-rag#900' in (pl or {}).get('body','') for mm,p,pl in rec if p=='/repos/inkstone/ISEP/issues/92/comments')" \
"${A_BASE[@]}"
b "★ 那則留言明講「不會因為轉出去就自動關」(第 2 條的預告)" "True" \
"any('不會因為轉出去就自動關' in (pl or {}).get('body','') for mm,p,pl in rec if p=='/repos/inkstone/ISEP/issues/92/comments')" \
"${A_BASE[@]}"
b "沒給 --journey → 留言裡誠實寫「沒有貼」(不硬湊,也不假裝有)" "True" \
"any('沒有貼' in (pl or {}).get('body','') for mm,p,pl in rec if p=='/repos/inkstone/ISEP/issues/92/comments')" \
"${A_BASE[@]}"
b "沒給 --journey → 不去動任何一端的標籤軸(只設子票的 s/*)" "1" \
"len([1 for mm,p,pl in rec if mm=='PUT' and p.endswith('/labels')])" \
"${A_BASE[@]}"
b "★ 給了 --journey → 母子兩端都被貼(只貼一端就聚不起來)" "2" \
"len([1 for mm,p,pl in rec if mm=='PUT' and p.endswith('/labels') and 4 in pl['labels']])" \
"${A_BASE[@]}" --journey 收件匣
b "★ 貼在頂層票上時保留它原本的標籤(不是整組蓋掉)" "True" \
"any(mm=='PUT' and p=='/repos/inkstone/ISEP/issues/92/labels' and sorted(pl['labels'])==[3,4] for mm,p,pl in rec)" \
"${A_BASE[@]}" --journey 收件匣
b "labels.yaml 沒有的旅程名 → 擋下來(要求標籤在場,不是猜名字合不合法)" "True" \
"blocked" "${A_BASE[@]}" --journey 沒這條旅程
b "擋下來時直接指回 labels.yaml(不叫人自己猜怎麼修)" "True" \
"'labels.yaml' in buf" "${A_BASE[@]}" --journey 沒這條旅程
echo
echo "通過 $PASS 條,失敗 $FAIL"
[ "$FAIL" = 0 ] || exit 1
+295 -6
View File
@@ -21,8 +21,10 @@ leo 2026-08-16 三句話,本工具就是它們的機械化:
三個 Gitea 原生欄位(leo 2026-08-27「這些全部都要」,缺一個就會掉棒):
ticket subtask <母票> --title <US> -F <檔> 討論串裡的一件事 → 看得見的子票+相依
ticket handoff <頂層票> --to <repo> … 同一個動作,換個名字:頂層 → 下游 repo
ticket handback <票> --to <誰> --next <一句> 收工=指派+改 tag+寫下一步,一個動作
ticket mine [--user <誰>] 撈一次:棒子在誰手上、每根下一步是什麼
ticket loose 撈一次:下游都關了、自己還開著的頂層票
票的寫法:`owner/repo#N`,例:`inkstone/InkStoneCo#44`
"""
@@ -452,10 +454,87 @@ def cmd_close(argv):
if deliv:
api(f"/repos/{owner}/{repo}/issues/{num}/comments",
{"body": f"{os.environ.get('ISEP_IDENTITY') or f'【身份】總管/{owner}/{repo}-'}\n\n✅ 結案。交付物:{deliv}"})
{"body": f"{_identity(owner, repo)}\n\n✅ 結案。交付物:{deliv}"})
api(f"/repos/{owner}/{repo}/issues/{num}", {"state": "closed"}, method="PATCH")
print(f"✅ {owner}/{repo}#{num} 已關(交付物:{deliv or ('PR' if has_pr else '票上回報')}")
# ── 完工回寫(inkstone/ISEP#92 最痛的那一格)─────────────────────────
# 關掉一張下游票之後,**同一個動作**回頭處理它的頂層票。
# 不做這件事的後果票上寫得很清楚:「頂層票會永遠掛著,而 leo 是看頂層的」。
_writeback(owner, repo, num, deliv or ("PR" if has_pr else "票上回報"))
def _writeback(owner, repo, num, deliv):
"""子票關掉 → 回頭處理每一張在等它的頂層票。
🔴 失敗要吵。回寫悄悄失敗 = 這格等於沒做,而沒有人會發現
(那正是本票在治的病)。所以任何一張處理失敗就非零離開。
"""
child = f"{owner}/{repo}#{num}"
try:
parents = _blocks(owner, repo, num)
except SystemExit:
print(f"\n⚠️ 撈不到 {child} 的頂層票(/blocks 讀失敗)——"
f"回寫這一格**沒有做**,請手動確認或跑 `ticket loose`", file=sys.stderr)
sys.exit(1)
if not parents:
print("\n📌 這張票沒有任何頂層票在等它(沒有相依邊)⇒ 不需要回寫。")
return
print(f"\n🔗 有 {len(parents)} 張頂層票在等這張票,逐一回寫:")
failed = []
for pt in parents:
pfull = pt["repository"]["full_name"]
po, pr = pfull.split("/")
pn = pt["number"]
try:
pdeps = _deps(po, pr, pn)
action, open_left = writeback_plan(pt.get("state"), pdeps)
if action == "skip":
print(f" · {pfull}#{pn} 已經是 closed ⇒ 不動")
continue
lines = [_identity(po, pr), "",
f"⬇️ **下游完工回寫**`{child}` 已關(交付物:{deliv}"]
if open_left:
lines += ["", f"這張票還在等 {len(open_left)} 張下游(都關了才輪到它):"]
lines += [f"- ◻ {i['repository']['full_name']}#{i['number']} {i['title'][:60]}"
for i in open_left]
lines += ["", "⇒ 本票維持原狀,不要當成可以收了。"]
else:
lines += ["", "**這是最後一張下游——本票的下游已經全部關閉。**", "",
f"{BATON_MARK} → `{HANDBACK_TO}`", "",
"**下一步**:驗一次頂層要的東西真的到齊了,到齊就 "
f"`ticket close {pfull}#{pn} --deliverable <URL>`"
"沒到齊就開下一張下游票,不要把它留在原地。", "",
"(本則由 `ticket close` 的完工回寫自動貼上,"
"機制見 `inkstone/ISEP#92`"]
api(f"/repos/{po}/{pr}/issues/{pn}/comments", {"body": "\n".join(lines)})
if action == "handback":
# 指派 + 改 tag:**欄位**才撈得到,留言撈不到。
# 08-26 那次掉棒就是話有說、欄位沒動,棒子躺在地上 14 小時。
api(f"/repos/{po}/{pr}/issues/{pn}",
{"assignees": [HANDBACK_TO]}, method="PATCH")
cur = [l["name"] for l in (pt.get("labels") or [])]
keep = [n for n in cur if not n.startswith("s/")] + ["s/review"]
_set_labels(po, pr, pn, keep)
print(f" · {pfull}#{pn} 下游全關 ⇒ 已指派 {HANDBACK_TO}s/review(等人驗)")
else:
print(f" · {pfull}#{pn} 還有 {len(open_left)} 張下游沒關 ⇒ 只記一筆")
except SystemExit:
failed.append(f"{pfull}#{pn}")
except Exception as e:
failed.append(f"{pfull}#{pn}{e}")
if failed:
print(f"\n⚠️ 這幾張頂層票**沒有回寫成功**{'、'.join(failed)}\n"
f" 回寫失敗要吵——悄悄失敗等於這格沒做。手動補,或稍後跑 `ticket loose` 對帳。",
file=sys.stderr)
sys.exit(1)
# ── decide ───────────────────────────────────────────────────────────────
@@ -526,6 +605,10 @@ def cmd_decide(argv):
BATON_MARK = "🏃 **棒子交回**"
# 棒子的預設終點:總管。**這是 Gitea 的 assignee 欄位,不是一句話**——
# 撈「指派給你的」一頁就看得到,撈留言看不到。
HANDBACK_TO = os.environ.get("ISEP_HANDBACK_TO") or "claude-code"
def _labels_of(owner, repo):
return {l["name"]: l["id"] for l in api(f"/repos/{owner}/{repo}/labels?limit=100")}
@@ -545,6 +628,97 @@ def _deps(owner, repo, num):
return api(f"/repos/{owner}/{repo}/issues/{num}/dependencies") or []
def _blocks(owner, repo, num):
"""反方向:**誰把這張票當相依**——也就是這張票的頂層票(可能不只一張、可能跨 repo)。
Gitea 原生兩個端點是同一條邊的兩端,方向不可混淆:
/issues/<N>/dependencies 這張票在等誰 → 它的下游
/issues/<N>/blocks 誰在等這張票 → 它的頂層
`subtask` POST 的是**母票的 dependencies**,所以子票這端看到的是 `blocks`。
"""
return api(f"/repos/{owner}/{repo}/issues/{num}/blocks") or []
def _identity(owner, repo):
"""票上的每一則留言第一行要表明身份(規約見 docs/governance/dispatch-and-reply-format.md §3)。
機器自動貼的留言同樣要標——不標的話,回寫留言看起來像某個人寫的,
而下一個讀票的人會去找那個人。
"""
return os.environ.get("ISEP_IDENTITY") or f"【身份】總管/{owner}/{repo}-"
# ══════════════════════════════════════════════════════════════════════════
# inkstone/ISEP#92 — 「下游做完時頂層跟著關」的三格
#
# 票上的原話:「開在頂層的票,下游做完了卻沒人回來關⋯⋯頂層票會永遠掛著,
# 而 leo 是看頂層的。」
#
# 缺的三格,各由下面一段承接:
# ① 雙向連結的強制 → cmd_subtask 一個動作同時掛相依 + 在母票留下指回來的留言
# ② journey 標籤 → journey_label()(對不上就不貼,不硬湊)
# ③ 完工回寫 → writeback_plan() cmd_close 關完子票立刻回頭處理母票
# ④ 撈得出來 → is_loose() cmd_loose(③ 走側門漏掉的,這張網補回來)
#
# 🔴 這四格的判準**全部是機械事實**(相依邊在不在、state 是什麼),
# 沒有一格在猜文字。leo 2026-08-17 已證偽關鍵字黑名單那條路
# (8 次誤攔、0 次正確攔截)。
# ══════════════════════════════════════════════════════════════════════════
JOURNEY_PREFIX = "j/"
def journey_label(name):
"""`--journey 收件匣` → `j/收件匣`。只加前綴,不做語意判斷。
標籤存不存在由 `_set_labels` 去問 Gitea(不存在就擋,並指回 labels.yaml)——
**「要求某個東西在場」,不是「猜哪些名字合法」**。
⇒ 新的一條旅程要先加進 labels.yaml 再 sync,這是刻意的:
journey 是**跨票聚類的軸**,隨手造名字會讓同一條旅程長出三個拼法。
"""
n = (name or "").strip()
if not n:
die("🚫 --journey 後面要接旅程名(例:--journey 收件匣)。\n"
" 對不上任何旅程就**不要加這個參數**——票上明講不硬湊。")
return n if n.startswith(JOURNEY_PREFIX) else JOURNEY_PREFIX + n
def is_loose(state, deps):
"""這張票是不是「下游都關了、自己還開著」的頂層票。
純函式(不打網路)⇒ 判準本身測得動,不必開真的票來驗。
三個條件缺一不可:
· 自己還 open 關掉的票不用管
· 曾經有下游 沒有相依邊 ⇒ 它根本不是頂層票,不要拿它來吵
· 沒有任何 open 下游 有一張還開著就還在跑,不是掉在地上
"""
if state != "open":
return False
if not deps:
return False
return not any(d.get("state") == "open" for d in deps)
def writeback_plan(parent_state, parent_deps):
"""下游關掉之後,它的頂層票該被怎麼處理。**只回計畫,不做副作用**⇒ 離線測得動。
回 (動作, 還開著的下游):
"skip" 頂層票自己已經關了 ⇒ 沒事
"note" 還有別的下游沒關 ⇒ 只在頂層票記一筆,別假裝它可以收了
"handback" 下游全關了 ⇒ 這張票現在**只等人驗**:
指派回總管 + s/review,讓它出現在「指派給你的」
🔴 為什麼不自動關母票:關票要有交付物、要有人看過(`ticket close` 的既有閘)。
票上要的是「不能默默留著」,不是「自動消失」——
默默關掉跟默默留著是同一個病的兩面。
"""
open_left = [d for d in (parent_deps or []) if d.get("state") == "open"]
if parent_state != "open":
return "skip", open_left
return ("note" if open_left else "handback"), open_left
# ── subtask ──────────────────────────────────────────────────────────────
def cmd_subtask(argv):
"""把「討論串裡的一件事」長成看得見的子票,並掛成母票的 Gitea 原生相依。
@@ -565,7 +739,9 @@ def cmd_subtask(argv):
if len(argv) < 1:
die("用法:ticket subtask <母票 owner/repo#N> --title \"<User Story>\" -F <內文檔>\n"
" [--repo <收件 repo,預設跟母票同一個>] [--label <s/xxx,預設 s/todo>]\n"
" [--assign <誰做> --next \"<他第一件事要做什麼>\"] ← 指派了就一定要寫下一步")
" [--journey <旅程名>] ← 這件事服務哪條使用者旅程(對不上就不要加)\n"
" [--assign <誰做> --next \"<他第一件事要做什麼>\"] ← 指派了就一定要寫下一步\n"
"\n`ticket handoff` 是同一個動作的別名:頂層票 → 下游 repo)")
powner, prepo, pnum = parse_ref(argv[0])
def opt(name, default=None):
@@ -585,10 +761,13 @@ def cmd_subtask(argv):
" leo 2026-08-27:『每個事情沒有歷史記錄才是大問題』,\n"
" 而沒寫驗收條件的票,關掉之後就沒有歷史,只剩一個關字)")
repo = opt("--repo", prepo)
# `--to <repo>` 是 `ticket handoff` 那個名字底下比較自然的講法,同一個東西。
# handback 的 `--to` 是「交給誰」,subtask 沒有那個參數,不會撞。)
repo = opt("--repo", opt("--to", prepo))
label = opt("--label", "s/todo")
assign = opt("--assign")
nxt = opt("--next")
jlabel = journey_label(opt("--journey")) if "--journey" in argv else None
if assign and not nxt:
die("🚫 指派了人卻沒寫 --next。\n"
" **有人被指到這張票,他撈到它的第一件事就是問「所以我要做什麼」**——\n"
@@ -597,7 +776,8 @@ def cmd_subtask(argv):
body = (f"Parent: {powner}/{prepo}#{pnum}\n"
f"> 這張票是從母票的討論串裡長出來的一件事。\n"
f"> **它沒關,母票關不掉**(Gitea 原生相依,實測 412 硬擋)。\n\n") + body
f"> **它沒關,母票關不掉**(Gitea 原生相依,實測 412 硬擋)。\n"
f"> **它關掉時,母票會收到一則回寫**(`ticket close` 自動做,見 `inkstone/ISEP#92`)。\n\n") + body
d = api(f"/repos/{ORG}/{repo}/issues", {"title": title, "body": body})
cnum = d["number"]
@@ -619,6 +799,38 @@ def cmd_subtask(argv):
deps = _deps(powner, prepo, pnum)
openn = [i for i in deps if i["state"] == "open"]
print(f" 相依:已掛上 {powner}/{prepo}#{pnum}(它存在、且擋著母票)")
# 🔴 雙向連結:相依邊只長在 Gitea 的側欄,**母票的時間軸上什麼都沒有**。
# 票上的原話:「頂層票寫了『轉去某個 repo』,但那邊的票沒有指回來,
# 從下游那張票看不出它從哪來,脈絡就斷了。」
# ⇒ 兩端各留一個看得見的指標:子票內文的 `Parent:`(上面),母票時間軸的這一則。
# 跨 repo 時尤其重要——側欄的相依很容易被當成同 repo 的東西。
plines = [_identity(powner, prepo), "",
f"➡️ **已轉往下游**`{ORG}/{repo}#{cnum}` {title}", "",
f"{d['html_url']}", "",
f"- 收件 repo`{ORG}/{repo}` tag`{label}`"
+ (f" 旅程:`{jlabel}`" if jlabel else "")
+ (f" 指派:`{assign}`" if assign else " (還沒指派,留在票池等人領)")]
if jlabel is None:
plines += ["- 旅程:**沒有貼**(開票時判定對不上任何一條,不硬湊)"]
plines += ["",
"**這張頂層票不會因為轉出去就自動關**——下游那張關掉時,"
"`ticket close` 會回頭在這裡貼一則回寫;"
"全部下游關完,這張票會被指派回總管等驗。(機制:`inkstone/ISEP#92`"]
api(f"/repos/{powner}/{prepo}/issues/{pnum}/comments", {"body": "\n".join(plines)})
print(f" 回指:已在母票 {powner}/{prepo}#{pnum} 的時間軸貼上指向子票的留言")
# journey 標籤:兩端都貼,聚類才聚得起來(只貼一端=撈不到另一端)
if jlabel:
_set_labels(ORG, repo, cnum, [label, jlabel])
pcur = [l["name"] for l in (api(f"/repos/{powner}/{prepo}/issues/{pnum}")
.get("labels") or [])]
if jlabel not in pcur:
_set_labels(powner, prepo, pnum, pcur + [jlabel])
print(f" 旅程:`{jlabel}`(母子兩端都貼——只貼一端就聚不起來)")
else:
print(" 旅程:沒有貼(沒給 --journey)。對得上就補一次:"
f"`ticket subtask … --journey <旅程名>`;對不上就不要硬湊。")
print(f"\n📌 母票 {powner}/{prepo}#{pnum} 目前有 {len(openn)} 張未關的相依:")
for i in deps:
mark = "◻" if i["state"] == "open" else "☑"
@@ -747,9 +959,86 @@ def _last_next(owner, repo, num):
return None
# ── loose ────────────────────────────────────────────────────────────────
def cmd_loose(argv):
"""撈一次:**下游都關了、自己還開著**的頂層票。
票上的驗收條件第 3、4 條(`inkstone/ISEP#92`):
「撈一次『已經沒有 open 下游票、但自己還開著的頂層票』→ 要撈得出來(現在撈不出來)」
「拿現有的頂層票跑一次 → 要能指出哪幾張是這種情況」
這是 `ticket close` 自動回寫的**補網**:回寫只在走正門(`ticket close`)時發生,
而關票的側門(直接 PATCH `state=closed`)是刻意放行的
(見 `hooks/ticket-api-bypass-guard.sh`:「帶了 ID 的子路徑」一律放行)。
⇒ 側門漏掉的,由這張網撈回來。**不是輪詢**——掛在開場/收工對帳跑一次。
"""
def opt(name, default=None):
return argv[argv.index(name) + 1] if name in argv else default
org = opt("--org", ORG)
only = opt("--repo")
print(f"🔎 掃 `{org}` 的 open 票,找「下游都關了、自己還開著」的頂層票"
+ (f"(只看 {only}" if only else "") + " …")
items, page = [], 1
while page <= 12:
q = urllib.parse.urlencode({"state": "open", "type": "issues",
"limit": 50, "page": page, "owner": org})
got = api(f"/repos/issues/search?{q}") or []
if not got:
break
items += got
page += 1
if only:
items = [i for i in items if i["repository"]["full_name"].split("/")[-1] == only]
print(f" open 票 {len(items)} 張,逐張問它的下游 …")
def probe(it):
full = it["repository"]["full_name"]
o, r = full.split("/")
try:
deps = _deps(o, r, it["number"])
except SystemExit:
return None # 讀不到 ≠ 沒有下游;寧可漏報也不要編一筆出來
except Exception:
return None
return (full, it, deps)
from concurrent.futures import ThreadPoolExecutor
with ThreadPoolExecutor(max_workers=12) as ex:
probed = [x for x in ex.map(probe, items) if x]
parents = [x for x in probed if x[2]]
loose = [x for x in parents if is_loose("open", x[2])]
print(f"\n 有下游相依的頂層票:{len(parents)} 張")
print(f" 其中「下游都關了、自己還開著」:**{len(loose)} 張**\n")
if not loose:
print("✅ 一張都沒有——每張頂層票都還有下游在跑,或已經跟著關了。")
for full, it, deps in loose:
n = it["number"]
st = [l["name"] for l in it.get("labels") or []]
who = [a["login"] for a in (it.get("assignees") or [])] or ["(沒有人)"]
print(f" ● {full}#{n} {st} 指派:{'、'.join(who)}")
print(f" {it['title'][:64]}")
for d in deps:
print(f" ☑ {d['repository']['full_name']}#{d['number']}"
f" {d['title'][:52]}")
print(f" → 驗過就關:ticket close {full}#{n} --deliverable <URL>")
print(f" 還缺東西:ticket subtask {full}#{n} --title \"<US>\" -F <檔>")
print()
print("📌 這張表撈的是**機械事實**(相依邊的 state),不是猜文字。")
print(" 走 `ticket close` 關下游時會自動回寫、這張表就不會長;")
print(" 會長出來的都是走側門關掉的那些。")
return loose
CMDS = {"where": cmd_where, "say": cmd_say, "new": cmd_new, "close": cmd_close,
"decide": cmd_decide, "subtask": cmd_subtask, "handback": cmd_handback,
"mine": cmd_mine}
"decide": cmd_decide, "subtask": cmd_subtask, "handoff": cmd_subtask,
"handback": cmd_handback, "mine": cmd_mine, "loose": cmd_loose}
if __name__ == "__main__":
if len(sys.argv) < 2 or sys.argv[1] not in CMDS:
+598
View File
@@ -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:]))