Compare commits

..

122 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 eb7c3656be PR 一定要有結論(inkstone/ISEP#81)(scripts/pr-verdict) 2026-08-28 00:27:42 +00:00
claude-code 16c03e46c1 PR 一定要有結論(inkstone/ISEP#81)(hooks/tests/pr-verdict-guard.test.sh) 2026-08-28 00:27:40 +00:00
claude-code 99abdf725b PR 一定要有結論(inkstone/ISEP#81)(hooks/pr-verdict-guard.sh) 2026-08-28 00:27:39 +00:00
claude-code accedb5898 PR 一定要有結論(inkstone/ISEP#81)(hooks/hooks.json) 2026-08-28 00:27:37 +00:00
claude-code 77a2032858 PR 一定要有結論(inkstone/ISEP#81)(docs/hooks-inventory.md) 2026-08-28 00:27:34 +00:00
claude-code be09353330 PR 一定要有結論(inkstone/ISEP#81)(docs/TESTING.md) 2026-08-28 00:27:32 +00:00
claude-code 81368f451f PR 一定要有結論(inkstone/ISEP#81)(.claude-plugin/plugin.json) 2026-08-28 00:27:30 +00:00
claude-code 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 a9e30fc6e7 Merge pull request '每一則回覆都自己說出拖了多久(inkstone/ISEP#63)' (#94) from feat/countdown-stamp into main 2026-08-28 00:10:10 +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
Leo 17a74d7d3a 每一則回覆都自己說出拖了多久(inkstone/ISEP#63 → comment 5106)
leo 2026-08-27:「前面說過每個回覆要戴上已經花了總時長,這為什麼沒出現?」
              「這應該寫在 ISEP,隨時看自己拖了多久」
重點在後面那句:不是要總管記得戴,是要它長在機器上。
總管當時答「我沒做,現在開始戴」——而那正是這條規則第一次失效的方式。

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 21:38:17 +08:00
claude-code a001b10947 Merge pull request '戳記要證明「看過」,不是「跑過」(inkstone/ISEP#72)' (#79) from fix/stamp-proves-seen-not-run into main 2026-08-27 13:24:28 +00:00
Leo ba444d1210 戳記要證明「看過」,不是「跑過」(inkstone/ISEP#72 → comment 4873)
2026-08-27 實犯:`ticket where … >/dev/null 2>&1` 之後直接 `ticket new`——
戳記寫成功了,命中的 72 張一眼沒看,於是開出 arcrun-rag#147,
而第一名 arcrun-rag#104 講的是同一件事,已經開了 13 天。

⇒ 舊戳記證明的是「這個程序被執行過」,而「有沒有看」在 stdout 那一端,
  閘本來完全碰不到。

做法:把 stdout 那一端變成機械事實,不是文字判斷。
- `fd_is_devnull(fd)`:fstat 問得出來的事實。刻意只認 /dev/null 這一種
  (寫進檔案讀得回來、pipe 有下游,只有 /dev/null 物理上找不回來)。
- `where` 把 `shown` 記進戳記;順便替前 3 名補內文摘要——今天的實害正是
  「光看標題看不出是同一條線」(#104 的標題完全沒提 ingest)。
- `new` 的閘一之二:命中 >0 且 shown 為假就擋,並把那份被丟掉的清單
  交到眼前;只要 stderr 不是 /dev/null,這一次的擋就記成「看過了」,
  重下一模一樣的指令就會過——成本落在「看」,不落在「寫」。
- 刻意排在閘二**之前**:否則第一次就帶 `--not-a-comment "理由"` 的人
  永遠看不到候選清單,理由是閉著眼睛寫的。
- 舊格式戳記(沒有 shown 欄位)當成沒看過(fail-closed)。

不該擋的(測試覆蓋):沒命中就不吵、看過了就不吵、`--not-a-comment`
既有欄位照舊、不判斷理由寫得好不好(leo 2026-08-17 已證偽文字層判準)。

測試 17/17:scripts/test-ticket-where-seen-guard.sh(離線,不打真實 Gitea、
不留測試票;離開碼 2=擋、1=放行走到網路才炸)。docs/TESTING.md 新增 A13。
plugin.json 升到 0.8.0——版本沒動=沒有人吃得到。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 21:17:35 +08:00
claude-code a4eb17df48 Merge pull request '補上「有動作但主線沒動」那一格的閘(inkstone/ISEP#30)' (#78) from feat/gate-catches-recon-without-output into main 2026-08-27 12:32:17 +00:00
Leo 35e927ef55 補上「有動作、不宣告」那一格:主線閒置警察(inkstone/ISEP#30)
空手警察判「有沒有動作」、稼動率警察判「有沒有那句話」,兩支中間留了一格:
**有動作、就是不宣告下一步** ⇒ 兩支都放行。2026-08-27 實際發生的就是這一格。

新閘 hooks/mainline-idle-guard.sh(Stop)判的是「有沒有推進」,不是「有沒有動作」:
- 乾回合 = 這回合 ≥3 個 tool call,且沒有任何推進證據
- 推進證據五種(任一成立就歸零):派工/產出/寫進外部系統/Bash 寫入白名單/
  **工作區狀態變了**(HEAD 或未提交變更的指紋,heredoc·sed 改檔也吃得到)
- 連續 4 個乾回合擋一次,擋完歸零且**門檻加倍**(4→8→16)

一個字都不讀(守票上「不准文字層判準」那條紅線);③④ 的字面比對只用來放行,
永遠不用來擋——白名單漏一項是少放行一次,黑名單漏一項是誤攔一次。

門檻 4 是量出來的,不是拍腦袋:重放 6 份真 transcript、1965 個真實回合,
門檻 3 響 4 次、門檻 4 響 1 次(0.05%);門檻 3 多出來那三次都落在
「leo 連問問題、我逐題查證回答」的段落——那正是票上第 3 條驗收要保護的情境。

測試 hooks/tests/mainline-idle-guard.test.sh:61 條,A 群 15 條全是「不該擋」。

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

inkstone/ISEP#59(comment 4779 第二件)
2026-08-27 18:24:20 +08:00
claude-code 7795cd705a Merge pull request '第二波:棒子三格+移除自造待驗單,版本收斂 0.6.0(inkstone/ISEP#58 #62)' (#74) from integrate/isep59-wave2 into main 2026-08-27 10:13:17 +00:00
Leo 577c736b74 merge: 移除自造的待驗單機制,改由 Gitea 原生三格承接(inkstone/ISEP#62)
順序刻意是先 #58 後 #62:新機制(comment-carries-task-guard/baton-handback-guard/
scripts/ticket 的 subtask)先到位,舊機制才拆,main 上不存在「舊的拆了、新的沒到」的真空。

衝突只有 .claude-plugin/plugin.json:
- version 依 inkstone/ISEP#59 comment 4763 的裁決收斂成 0.6.0(四張 PR 各自宣告的作廢)
- description 的數字不採信任何一邊,改成併完後當場數出來的 48 支/59 條

一併把 docs/hooks-inventory.md 與 README.md 的同一組數字改成實數結果
(原本各寫 58/57,都是用加減兜出來的)。
2026-08-27 18:02:24 +08:00
Leo 7e1b762cbe merge: 棒子不會掉——子票相依+tag+指派三格全用 Gitea 原生欄位(inkstone/ISEP#58)
衝突只有 docs/hooks-inventory.md 的 A 組表格,解法照 ISEP#59 comment 4763 指定的
「兩列都留」:保留 main 上 #73 更新過的 ticket-api-bypass 敘述與 reply-identity-guard,
再加上本分支新增的 comment-carries-task-guard。標頭數字留到 #62 併完後一次實數。
2026-08-27 17:56:11 +08:00
claude-code e32957e72b Merge pull request '封「新增 Gitea 東西」的每一道側門,不是只有開票(closes inkstone/ISEP#72)' (#73) from fix/gitea-write-search-guard into main 2026-08-27 09:50:21 +00:00
claude-code e7e60fff89 Merge pull request 'dispatch-format-guard 修兩處:合格範例被自己擋、禮貌收尾不該算違規(closes #65)' (#70) from fix/dispatch-guard-parens-and-wiring into main 2026-08-27 09:49:13 +00:00
Leo 23e4472715 封「新增 Gitea 東西」的每一道側門,不是只有開票(inkstone/ISEP#72)
leo 2026-08-27:「開票不查是否有現成的,這是什麼問題?」「不只開票前,
所有新增 gitea 的東西,都要搜尋」。

v1 的 ticket-api-bypass-guard 只認裸字 `grep -qw POST`(大小寫敏感),
只管 issues 端點。2026-08-27 總管一天開 13 張票,v1 一次都沒攔到——
用的是 `urllib.request.Request(url, data=...)`(靠傳 data= 隱式變 POST,
指令裡從頭到尾沒有 "POST" 三個字)和 `requests.post(...)`(小寫)。

本次改動:
- 寫入訊號從單一裸字換成一組結構性訊號(curl 資料類旗標/`.post(`/
  `method=post`/urllib 隱式 POST),且大小寫不敏感,同時保留 v1 的裸字比對
- 端點擴大到 milestones/labels/pulls(含 org 層級的 labels),不再只管 issues
- 用「resource/數字」特徵先放行帶 ID 的既有資源子路徑(貼留言、改標籤、
  合併 PR 等),避免貪婪比對把 `/issues/5/labels` 誤判成頂層 `/labels` 端點
- 擋下 issues 新增時,順手用標題猜關鍵字查一次跨 repo 搜尋,把可能撞到的
  舊票(例如 Arcrun#100)直接列進擋下的訊息裡

測試:scripts/test-ticket-api-bypass-guard.sh 24/24 通過(v1 的 13 條 + 本次
新增 11 條,含一條打真實 Gitea 網路重演 Arcrun#100 重複主題偵測)。

plugin.json 0.5.0 → 0.5.1;version-consistency 會在合併打 tag 那一刻才對齊
(release-tag-guard.sh 的既定分工:打 tag 是總管驗過整個 milestone 之後的事)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 16:19:06 +08:00
Leo b1830053e5 dispatch-format-guard 修兩處:規則自己的合格範例會被自己擋,禮貌收尾不該算違規
inkstone/ISEP#65:leo 發現「寫完 ISEP#30 那條規則之後,同一天總管又犯了 8 次」,
派來查「現行閘為什麼抓不到」。查證結果分兩層:

━━ 真正在跑的閘其實是 dispatch-format-guard.sh,不是 no-ticket-no-dispatch.sh ━━
no-ticket-no-dispatch.sh 的殼驗證早就是已知行為(只驗有沒有一行【工單】),
但 ISEP#30 已經為此新增了 dispatch-format-guard.sh 做內容判定,測試 19/19 通過。
問題是它有兩個沒被那 19 條測資蓋到的洞:

1. **regex 洞(本體 bug)**:頂層 CLAUDE.md 規定的合格格式帶全形括號——
   「【工單】owner/repo#N(→ comment M)」。_COMMENT_RE 只吃「→ comment M」
   本體,兩側括號沒被算進去,殘留括號讓 _REF_RE 比不過,於是**規則自己定義
   的合格範例會被自己的閘擋下**(半形括號 `(...)` 同樣會中)。用今天派我這張
   票的那份派工單原句實測,改之前 exit=2「票號形狀不對」。
2. **零容忍過頭**:純禮貌收尾(「謝謝」)跟「記得先讀 CLAUDE.md」這種真內容
   一樣被當「派工單不只有票號」擋下。ISEP#65 test 4 明講這四種不該擋
   (只有票號/空行/「謝謝」/括號包住的 comment 格式),優先做成低誤鎖。

判斷:不改 no-ticket-no-dispatch.sh、不另立第三支閘——dispatch-format-guard.sh
已經是「另立一支」的正確位置,這兩個洞在它自己的地盤上補。

修法:
- _COMMENT_RE 兩側括號(全形/半形)都設可選
- 新增 _COURTESY_CLOSERS 白名單(謝謝/多謝/感謝/辛苦了…)+ _is_courtesy_closer(),
  只在圍欄外生效,判準仍是「整行清乾淨標點後完全相等」不是「包含」——
  白名單不是黑名單,猜漏頂多誤鎖一次,不會反過來放走真內容(③g 測資證明)

測試:hooks/tests/dispatch-format-guard.test.sh 19→33 條,全過。新增:
- ③b/③c 全形/半形括號格式(規則自己的例句)
- ③d/③e/③f ISEP#65 test 4 的三種不該擋
- ③g 白名單邊界(禮貌詞混真內容裡照樣算數,防止白名單被誤用成漏洞)
- ⑮b–⑮i:leo 點名的「寫完規則後又犯的 8 次」當回歸樣本,內容是從
  ISEP#60/#61/#64、Arcrun#142/#144/#165、arcrun-rag#104、InkStoneCo#102
  的真實票內文摘錄(見 hooks/tests/fixtures/README.md 記載來歷),
  不是想像出來的例子;8 種形狀全部驗證會被擋
reply-identity.test.sh 11/11 仍全過(共用 dispatch_parse.py 沒有回歸)

升版 0.5.0 → 0.5.1(改完不升版沒人吃得到;check-version-consistency.sh
在本分支照慣例是紅的,tag 於 merge 時打)。

另查:.shell-payload/ 整個被 gitignore(scripts/vendor-to-shell.py 產物),
不是 git 分發的一部分——「這支閘會不會被吃到」取決於消費端有沒有重跑
plugin update/vendor-to-shell.py,不是這個 repo 委交的內容缺漏,故不在
本票改動範圍內,僅記錄供總管排查用。

未動 no-ticket-no-dispatch.sh(判斷見上,職責保持不重疊,兩支閘互斥見
dispatch_parse.py 的 has_ticket_marker 分岔)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 14:25:17 +08:00
Leo 770a0ee824 移除自造的待驗單機制,改由 Gitea 原生三格承接(inkstone/ISEP#60)
subagent-claim-worksheet.sh(SubagentStop 產待驗單)與 claim-verify-police.sh
(Stop 攔收工)這一套整組移除。它們守的東西違反 D58(不要硬做平台不支援的機制)
——「待驗單」只是一張沒有狀態、沒有持有人的 markdown,必然退化成雜訊。

改由 inkstone/ISEP#59/PR #58 的三個 Gitea 原生欄位承接同樣的情境:
子票相依(存在嗎/做完了嗎)、s/* tag(卡在哪)、指派(誰該動)。

- hooks/hooks.json:移除 Stop/SubagentStop 兩條註冊
- hooks/lib/path-resolve.sh:拿掉已刪檔案的註解引用
- docs/hooks-inventory.md、README.md:更新閘數量(48→46 檔、59→57 條註冊)
- docs/governance/sdd-gitea-governance.md:E12/E14 標記現況,新增 §8.4 說明
  移交對象;PR #58 未 merge 前這兩條實質仍是待建,誠實標記
- .claude-plugin/plugin.json:0.5.0 → 0.6.0(版本沒動=plugin update 是 no-op)

未動 docs/governance/DIVERGENCE-v0.5.0-to-v0.6.0.md——那是 2026-08-20 的
時間點快照(意見書),修改它等於竄改歷史記錄,不在本票範圍。

未動 InkStoneCo/.claude/pending-verification/{done,done-20260826,verified}/
三個歸檔目錄——它們是 InkStoneCo repo 裡的已提交檔案,跨 repo 且需要
InkStoneCo 自己的 git 流程處理,超出本票(ISEP repo)範圍,留給總管判斷。

測試:scripts/test-*.sh 全數 6 支通過;hooks/tests/*.test.sh 與 pristine
origin/main 基準比對,失敗特徵完全一致(環境既有問題,非本次改動引入);
claude plugin validate . 通過。check-version-consistency.sh 目前會報不一致
(0.6.0 vs tag v0.5.0)——這是預期的,等總管收斂 release 打 v0.6.0 tag 時解決。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 12:38:31 +08:00
Leo 9d20ef925e subtask 指派了人就一定要寫下一步——不然新子票一開出來就是「棒子沒有下一步」
實跑 ticket mine 時自己抓到:剛用 subtask 開的 #143 顯示
「 沒有人寫下一步」——工具自己造出了它要消滅的那個狀態。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 12:15:20 +08:00
Leo b46bc5e44c dogfood 抓到自己的漏:交棒標記只認第一行,不認「內文提過」
實跑自己那則交件報告時,閘沒有擋——因為報告裡**引用了一段 ticket mine 的輸出**
(那段有 🏃),整則就被豁免掉了,而它裡面真的有「等雲端那半出貨」。
⇒ 同 inkstone/InkStoneCo#23 那個病:複述關鍵字被當成本人。
ticket handback 寫的交棒留言 🏃 一定在第一行,引用別人的輸出則不會 ⇒ 錨在第一行。

回歸測試 +2(20/20 → 22/22)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 12:13:27 +08:00
Leo 0b6af15f1a 補:實測探針票的去處,以及 close/* 七類沒有一類裝得下它
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 12:08:48 +08:00
Leo e474b4cb6f 棒子不會掉:子票相依+tag+指派三格,全部用 Gitea 原生欄位
leo 2026-08-27:「子票相依是 gitea 原有機制,改 tag 和指定也是,這些全部都要」

08-26 掉的那一次(arcrun-rag#136 comment 4267「等雲端那半出貨才驗得了」躺了
14 小時)三格全缺:沒有子票、tag 沒動、沒有指派給任何人。根因不是誰忘了,
是那件事沒有落在任何一個「撈一次就看得到」的欄位上。

實測(Gitea 1.26.4):子票還開著時關母票 → HTTP 412
"cannot close this issue or pull request because it still has open dependencies"
⇒ 相依是平台保證的硬擋,不是提醒。跨 repo 也成立(201)。

- scripts/ticket 新增三個動詞:subtask(長子票+掛相依)/
  handback(指派+改 tag+寫下一步,一個動作)/mine(撈一次看棒子在誰手上)
- hooks/comment-carries-task-guard.sh:留言帶未完成的未來式卻沒開子票 → 擋一次
- hooks/baton-handback-guard.sh:一條線收工,三格缺哪一格當場說出來(提醒不擋)
- docs/governance §16:三個維度/粒度(傾向多開票)/既有票只從今天起適用/
  與 s/* 的關係/實測輸出
- 測試 20+10 全綠,用 fixture 跑不打網路、不在票池留測試票

📌 踩到並記進閘的檔頭:hook 裡比對中文一律用 python3 的 re,不要用 grep 的字元類
(等[^。]{0,20}出貨 在真實 hook 呼叫路徑下對「等雲端那半出貨」不匹配,閘靜默失效)

票:inkstone/ISEP#30(comment 4334/4335/4346/4347)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 12:05:53 +08:00
Leo 8e28041ba4 merge: 派工單只剩票號——閘從驗「有沒有票號」改成驗「是不是只有票號」(ISEP#30)
leo 2026-08-27:「這些話票上都沒有,你根本沒照規則做事,你的 hook 讓你這樣搞?」
四條追加:output parser 分點規定格式/散文警察抓不到/描述寫進票留歷史/subagent 回覆要表明身份。

總管複驗過才併(自己跑的):
- dispatch-format-guard.test.sh  19/19
- reply-identity.test.sh         11/11
- 兩支都涵蓋「該擋」與「不該擋」兩側;訊息點得出違反哪一條規則,不是籠統的「格式不對」

版本 0.3.9 → 0.5.0(產物按版本號分資料夾,不升版新閘不會被載入)。

🔴 仍是 report 不是 deliver 的一格:「新 session 真的會叫它」要併版後開新 session 才驗得了。
2026-08-27 11:55:24 +08:00
Leo 3bc7f3c5f5 派工單只剩票號——閘從驗「有沒有票號」改成驗「是不是只有票號」
leo 2026-08-27(inkstone/ISEP#30 comment 4322/4325/4327):
「這些話票上都沒有,你根本沒照規則做事,你的 hook 讓你這樣搞?」
「你用一個 output parser 把你給 subagent 的指令規範,分作幾點,每一點規定格式,
  照這種散文寫法根本無法迭代」「警察也不能抓」
「交件方式不需要寫,定義在原則裡⋯⋯每次都一樣提取出來變成共通規定」
「(那些 session 事實)這些為什麼不寫到票裡?」「subagent 回覆時要表明身份」

病根:no-ticket-no-dispatch.sh 驗的是「有沒有一行【工單】owner/repo#N」,
而規則的原文是「派工單只寫票號」。⇒ 把 40 行任務全寫在 prompt 裡、票號補一行,
閘照樣放行。2026-08-27 一天內這樣做了 5 次,每次票上都沒有那份任務。
規則存在,閘只驗了它的殼——同款第 N 次(history-first/KBDB-first/stage-first)。

新增 hooks/dispatch-format-guard.sh(PreToolUse Task|Agent),兩件事:
- 擋:【工單】以外還有實質內容就 exit 2,並指出那些內容該搬去哪
  (每次都一樣 → 共通規定;這次才知道 → 寫進那張票。
   判準「這句話換一張票還成立嗎?」)
- 注入:合規的派工自動把共通規定送給收工方(交件方式、不准 push main、
  org 是 inkstone…)——這是「派工單只剩票號」能成立的前提,
  leo 的驗收條件之一就是「收工方沒讀派工單也知道要貼回原票」

判準是結構不是文字(leo 2026-08-17 那條檢驗):問的是「這一行是不是【工單】欄位」
——在不在,不是寫什麼。hooks/lib/dispatch_parse.py 全檔零個「命中某個詞就違規」的比對。
⇒ 也因此不需要語意判官:免費、瞬間、每次結果一樣。

新增 hooks/reply-identity-guard.sh(PreToolUse Bash)+ scripts/ticket 內建檢查:
票上每一則留言第一行要有【身份】(總管/subagent/leo)。貼留言有兩條路,兩條都封
——ticket-api-bypass-guard 是刻意放行「對既有票留言」的,只封正門等於沒封。
實害:多條線並行時總管寫的診斷被當成 subagent 的結論,而其中一則是錯的。

規約寫成文件:docs/governance/dispatch-and-reply-format.md
(§2 那段就是被注入的那份共通規定本體——只有一份,改那裡等於改所有派工)

實測(離線、不打網路、不花錢):
  hooks/tests/dispatch-format-guard.test.sh   19/19
  hooks/tests/reply-identity.test.sh          11/11
測資裡的 B⑨ 是真跡:產生 ISEP#30 這條線的那一次派工,一字未改。
另 4 份 leo 點名的違規派工拿不回來了——它們住在 prompt 裡,agent 一停就沒了,
這件事本身就是這條規則的證據(見 hooks/tests/fixtures/README.md,不用想像的例子替補)。

升版 0.4.0 → 0.5.0(產物按版本號分資料夾,不升版新閘不會被載入)。
tag 照慣例打在 merge commit 上,所以這條分支上 check-version-consistency.sh 是紅的。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-27 11:35:03 +08:00
Leo 8e7e265589 merge: 閘掛在收工後,而 leo 是在被問到的當下被打擾——補上那一刻的攔截(InkStoneCo#55)
leo 2026-08-26:「今天已經好幾次問我,為什麼 hooks 沒有攔下來?」

總管複驗過才併:
- 掛點:PreToolUse / matcher AskUserQuestion(自己從 hooks.json 解出來的)
- 封動作不封文字:整支 261 行零個判擋用的正則,決定 exit 2 的只有判官 verdict;
  判官掛掉/回空/不在 PATH 一律 fail-open
- 測試自己跑過 14/14(含「樹要不要捲」那種真人閘必須放行的案例)
- 版本 0.3.9 → 0.4.0:第一版忘了升版,而產物是按版本號分資料夾的
  ⇒ 不升版=新閘永遠不會被載入。退回補後已驗產物資料夾長得出來。

🔴 仍有一格是 report 不是 deliver:「新 session 真的會叫它」驗不到——
AskUserQuestion 官方明載 subagent 不可用,它撞不到自己這道閘。
那一格要 leo 開新 session 才驗得了。
2026-08-26 22:43:34 +08:00
claude-code e0557334bc 閘掛在「收工後」,而 leo 是在「被問到」的當下被打擾——補上那一刻的攔截
leo 2026-08-26:「今天已經好幾次問我,為什麼 hooks 沒有攔下來?」

實查:總管問 leo 走的動作是 AskUserQuestion,而 hooks.json 裡它出現 0 次,
沒有任何 matcher。判準其實早就寫好了——self-drive-police / self-drive-judge
用的就是同一套四題公式——但那兩支只掛在 Stop 與 SubagentStop,
是回合結束後才跑的。問題早就送到他眼前了,事後再反問「你查過了嗎」,
成本已經轉嫁出去。判準對了,時機錯了。

新增 hooks/ask-user-question-guard.sh(PreToolUse / AskUserQuestion):
- 觸發條件是那個動作本身,不是任何句型或關鍵字——全檔零個判擋用的正則,
  換句話說閃不過去,講得謹慎也不會被多罰(leo 2026-08-17 文字層封路的檢驗)
- 進來之後用四題公式的 haiku 判官分「該問 / 不該問」,命中任一題一律放行
- 同一個問題只擋一次(雜湊戳記):判官誤判時重送即過,
  leo 該收到的問題不會因為一支閘而永遠送不到
- 判官掛掉/沒網路/claude 不在 PATH 一律 fail-open,壞掉等於它不存在

版本 0.3.9 → 0.4.0。這不是儀式,是傳輸機制本身:產物按版本號分資料夾
(~/.claude/plugins/cache/inkstone/isep/<版本>/),版本沒動就不會長出新資料夾,
這支閘一個 session 都載入不到。docs/TESTING.md 開頭那段講的就是這件事,
而第一版我漏了——總管複驗時量出來的。

假設(沒有前例可循,先裁再記):跳 0.4.0 而不是 0.3.10。理由是這一版第一次
掛上 AskUserQuestion 這個事件面,是新能力不是修補;而且 0.3.10 在
plugin 快取目錄的 ls 裡會排到 0.3.1 旁邊,肉眼不好認。錯了打回,改號很便宜。

順手修掉自己寫出來的一個坑:訊息原本用沒加引號的 heredoc,
反引號被當命令執行,wiki 路徑與豁免指令兩行變成空白(閘照擋,只看離開碼看不出來)。
已收成 mistakes.md 一條,並由 ⑩b 這條測試守著。

實測:
- hooks/tests/ask-user-question-guard.test.sh      14/14(離線,不花錢)
- hooks/tests/ask-user-question-guard.live.test.sh 9/9 連跑三次(真的叫 haiku)
  A 群 5 條真人閘(花錢/不可逆/跨專案結構/品味方向/物理人閘)全部放行,誤攔 0
  B 群 4 條純技術路徑選擇全部擋下
- 既有 7 支測試與改動前逐條對照,結果完全相同(沒有被我弄壞)

順手對帳:plugin.json 與 marketplace.json 的描述寫「43 支機械閘、53 條註冊」,
實際數過是 46/56(含本次新增這支)。docs/hooks-inventory.md 一併更正。

【工單】inkstone/InkStoneCo#55
2026-08-26 22:36:08 +08:00
claude-code d4547bfec3 Merge pull request '信標自己講「這一份是誰」——vendor 還是 plugin 快取' (#55) from fix/beacon-says-which-copy into main 2026-08-23 13:03:37 +00:00
Leo 2f43ecc346 信標自己講「這一份是誰」——vendor 還是 plugin 快取
leo 的雲端驗收整整卡了一輪在這個問題上:同一台機器可能有兩份 ISEP,
兩份都會印信標,版本號一樣時分不出誰在說話。而「閘從哪一份走」正是決定
「另一份能不能拆」的唯一判準。

總管上一版設計的判準(叫雲端跑 env | grep CLAUDE_PLUGIN_ROOT)也是錯的:
那個變數是 hook 呼叫當下才注入的,在 Bash 工具的 env 裡本來就看不到
⇒ 空輸出不代表沒載入,那個探針從一開始就答不了這題。

改法:讓路徑自己講。快取在 plugins/cache/ 底下,vendor 的在 repo 的 .claude/ 底下。

三向實測:
  /root/.claude/plugins/cache/inkstone/isep/0.3.9  → 來源:plugin(marketplace 裝的)
  /home/user/inkstoneco/.claude/isep               → 來源:vendor(repo 裡的複製本)
  其他路徑                                          → 來源不明(不假裝知道)

inkstone/InkStoneCo#57
2026-08-23 21:02:45 +08:00
claude-code 1b95388a0f Merge pull request '身為總管,我要這道閘擋的是「宣告」而不是「提到宣告」,我才不會一舉報 bug 就被它擋下' (#54) from fix/factory-idle-guard-blocks-actions-not-mentions into main 2026-08-23 09:58:41 +00:00
Leo e3d05df341 再修兩類:主詞是 leo 的下一步、票號放寬把閘變鈍——都是真 transcript 量出來的
交付警察擋回來是對的:前一顆只驗了「我自己造的假 transcript」。
改用本機一條 2068 行的真 session(26 個真實回合終止點)重驗,當場多找到兩個問題:

① 主詞是「你」的下一步,被當成我的宣告。
   舊閘在 26 個真實回合裡擋了 2 次,兩次咬的都是我在交代 leo 該做什麼:
     「下一步還是那一個動作:你把 feat/... 併進 main」
     「## 你下一步(兩招,先便宜的)」
   ⇒ 這是全新的第四類誤攔,我原本一向都沒列到。
   修法是主詞檢查(誰要動手),只掛在「下一步」這條 alternative 上;
   用 finditer 逐個檢查前 8 字有沒有第二人稱,蓋得到「你的下一步」這種
   單字 lookbehind 蓋不到的變體。「你點頭我就做」主詞本來就是我,不受影響。

② 票號從「宣告句附近」放寬成「整段」,把閘變鈍了。
   真數據:26 個真實回合有 20 個是靠「文中某處剛好有票號」放行的——
   而報告幾乎一定會提到票號 ⇒ 這道閘在實務上等於不會響。
   當初放寬是因為票號常寫在行內 code 裡,剝掉就找不到。
   ⇒ 改成**等長**替換(蓋成同樣長度的哨兵而非刪除),位移就能對回原文,
     locality 與「行內 code 裡的票號也算數」兩件同時成立。
   收緊後的判定分佈:21 no-declaration / 3 dispatched / 2 ticket-referenced
   (原本是 20 ticket-referenced / 3 no-declaration / 3 dispatched)。

真 transcript 實測:舊閘擋 2 次(兩次都是誤攔)→ 新閘擋 0 次。
測試 33 向(+6):舊版 21/33 → 新版 33/33。
版號 0.3.7 → 0.3.8。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 17:54:17 +08:00
Leo a594decb78 稼動率警察改成擋「宣告」,不擋「提到宣告」
2026-08-23 雲端驗收連續三次被這道閘誤攔,三次都不是宣告意圖:
① 否認自己有下一步 ②引用閘自己的訊息 ③貼閘自己的正則原始碼舉報 bug。
而訊息教人走的「選項③:說明它在等什麼」,程式碼裡根本沒有那條分支
——唯一走得通的路是不寫那三個字,正是同一則訊息明文禁止的動作。

四個真兇,沒有一個是「例外沒列夠」:
(a) DECL 會匹配裸的「下一步」三個字(每一節都可選 ⇒ 退化成關鍵字)
    ⇒ 收緊:每一條 alternative 都必須接到動作動詞才算命中
(b) 只剝 > 引言與長「」,不認 code fence 與行內 code ⇒ 引用被當成主張
    ⇒ 引用性標記整段換成哨兵(不是刪掉):內層宣告消失、外層句構留著
      ——刪掉正是 08-17 漏掉「回『規劃』我就派人」的原因,兩個方向一起修
(c) 取 blocks_text[-1],但那則文字後面可能還有 tool_use ⇒ 宣告其實兌現了
    ⇒ 只看「最後一個動作之後」的文字;收尾在動作上就不觸發
(d) 訊息承諾的出路只有兩條真的存在
    ⇒ 出路③ 給一個機械形式 ⏸ 等:<在等什麼>(白名單標記,要刻意寫,留痕)

方向刻意與「再加幾個關鍵字例外」相反——例外清單會越加越長、越長越誤攔。
守 leo 的封路哲學:紅線寫得越細,命中關鍵字的機率越高 ⇒ 那些閘在懲罰謹慎。

順手:擋下與放行都留痕(InkStoneCo#48:只記擋下的話分母未知);
log 目錄不在時安靜跳過,不再噴 redirect 錯誤到 stderr。

測試 hooks/tests/factory-idle-guard.test.sh 27 向,誤攔與漏攔兩個方向都測:
舊版 18/27(9 敗)→ 新版 27/27。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 17:42:22 +08:00
claude-code 5789f0a917 Merge pull request '信標改數「真的被註冊的閘」——它一直多報一支樣板' (#53) from fix/beacon-counts-registered-gates into main 2026-08-23 08:52:30 +00:00
Leo b1f399f8b9 信標改數「真的被註冊的閘」——它一直多報一支樣板
leo 的雲端驗收(2026-08-23)抓到:信標說 44 支,`ls hooks/ | wc -l` 是 49,對不上。
追下去是 `ls "$ROOT"/hooks/*.sh | wc -l`:
把 `pre-write-guard.template.sh`(樣板,不是閘)與兩支沒掛註冊的輔助檔一起算進去。

🔴 這個數字是 leo 判斷「這個 session 到底有沒有閘」的唯一介面——**多報就是假綠**。

改成數 `hooks.json` 裡註冊過的唯一 `.sh`;hooks.json 讀不到才退回檔案數(且排除樣板)。

實測兩向:
  正向(真的 plugin 根目錄)      → 42(與 hooks.json 註冊數一致)
  反向(沒有 hooks.json 的假根)  → 2(三個檔裡有一個是 .template.sh,沒算進去)

查過歷史:本檔自 daa1674 建立以來只有那一版,沒有別的分支修過這段。

inkstone/InkStoneCo#57
2026-08-23 16:52:01 +08:00
claude-code a446ad6d1d Merge pull request '推 main 的戳記改綁「push 真正的目標 repo」,不再綁 hook 自己的 cwd' (#52) from fix/push-guard-cross-repo-stamp into main 2026-08-23 07:31:01 +00:00
Leo 41c56acd32 推 main 的戳記改綁「push 真正的目標 repo」,不再綁 hook 自己的 cwd
跨 repo 交辦時(總管站在 A repo,要推 B repo 的 main)main-and-prod-push-guard
的戳記機制永遠對不上:HERE 讀的是 hook 自己的 cwd(=session 的真身,不會變),
WANT 是總管替目標 repo(B)寫進戳記的路徑——兩者結構性地不可能相等,不是
判斷錯,是這個情境在舊模型裡根本不存在(inkstone/ISEP#30 comment 3949,
脈絡 inkstone/InkStoneCo#57,2026-08-21 實撞)。

新增 hooks/lib/push_target_dir.py:純 tokenize(不執行任何指令)解析指令裡
`cd <path> && git push` 或 `git -C <path> push` 真正會落地的目錄,對多層 cd
鏈與子殼(`(cd A && ...); git push` 這種子殼 cd 不能外洩出去)都做了範圍化——
這條範圍化是防穿透的關鍵,不是順手:沒有它,`(cd A && true); git push`
會被誤判成推向 A,讓替 A 開的舊戳記錯誤地放行推到殼外真正的目標。解不出來
一律退回舊行為(hook 自己的 cwd),維持 fail-closed 方向不變。

順手修掉補測時自己抓到的另一個洞:`(git push origin HEAD:main)`——單純加一層
括號——舊版目的地判斷完全偵測不到,整段直接放行,跟戳記無關。成因是截斷
refspec 尾巴的 sed 只認 `;`/`&`/`|` 三種字元,沒算到 `)`;補上即可,git 的
refspec 語法本來就不允許出現 `)`,這裡截斷永遠安全。

綁 repo+單次用完即丟兩條 2026-08-11/12 用血換來的性質完全沒有鬆動:只是把
「現在人在哪個 repo」問得更準,比對邏輯一個字沒動。

實測:
- hooks/tests/main-and-prod-push-guard.test.sh 舊有 8 向:8/8
- scripts/test-main-and-prod-push-guard.sh 舊有 11 向:11/11
- 新增 hooks/tests/main-and-prod-push-guard-cross-repo.test.sh 17 向
  (跨 repo 正向/反向不准鬆/git -C/子殼範圍化/括號洞/單次用完即丟/
  900 秒逾時/空戳記/既有行為零回歸):17/17

本輪只驗證,未拿去放行任何真實推送;plugin.json 隨慣例 bump 0.3.4 -> 0.3.5
並重跑 vendor-to-shell.py(.shell-payload 為 gitignore 產物,不入版控)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 14:18:28 +08:00
claude-code 2eb9b2aaaa Merge pull request '身為總管,我要閘擋的是動作不是字面,我才不會一晚被自己的閘擋六次' (#51) from fix/push-guard-target-not-substring into main 2026-08-20 17:30:46 +00:00
Leo 67dae3b814 推送閘改成判目標,不判整條指令裡有沒有那個字
一個晚上誤攔六次,全都不是在推預設分支:
  ① checkout -b 建新分支時把預設分支寫在後面,再推那條新分支
  ② gh pr create 指定 base——根本不是 git push
  ③ 推 tag(refs/tags/…)
  ④ 推 feature 分支(帶 -u)
  ⑤ 它擋住了我用來**測試它自己**的那條指令
  ⑥ 它擋住了這一筆的 commit——因為 message 裡引用了那幾個字

leo 2026-08-17 早就講過這個形狀:文字層封路必敗,
「紅線寫得越細,命中關鍵字的機率越高 ⇒ 那些閘在懲罰謹慎」。
舊版掃整條指令字串,正是文字層。

改成解析 push 的目標 refspec:
  旗標跳過/第一個非旗標=remote/a:b 取 b/refs/tags/* 不算分支
  一個 refspec 都沒給,才退回看當前分支

八向實測(hooks/tests/main-and-prod-push-guard.test.sh,8/8):
  五種該放行的(今晚誤攔的原形狀,含分支名帶 domain 那種)全過
  三種該擋的全擋

中途自己抓到一個 bug:tag 被跳過後目標清單變空 → 退回猜當前分支
⇒ 當前分支剛好叫預設名時誤擋。改成看到 refspec 就不退回猜測。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 01:29:55 +08:00
claude-code 43c328d26f Merge pull request 'feat/milestone-must-have-due' (#50) from feat/milestone-must-have-due into main 2026-08-20 17:26:08 +00:00
claude-code bcb736ed19 Merge pull request 'fix/worksheet-dedup-by-content' (#49) from fix/worksheet-dedup-by-content into main 2026-08-20 17:26:06 +00:00
Leo 6772ca67d3 每個里程碑都要有真的期限,9999 也擋
leo 2026-08-21:「以後所有的 milestone 限制時間」「你根本沒有時間概念,浪費一整天」

實查七個 open milestone:六個期限是 9999-01-01、一個空白。
9999 比空白更糟——盤點時每一格看起來都有值,
於是沒有人發現這裡從來沒有時間壓力。七個已全部改成真日期。

新增 hooks/milestone-due-guard.sh,四向實測:
  無 due_on → exit 2
  due_on 帶 9999 → exit 2
  真期限 → exit 0
  只是讀 milestone → exit 0

規範補 M4.8(怎麼定期限、過期只對帳不自動關)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 01:25:12 +08:00
Leo 1b5551274a 待驗工作單改用宣稱內容去重,驗過的不再冒出來
stamp 原本雜湊「交件路徑」⇒ 同樣的宣稱每回合生一個新檔名,
而且不知道總管已經驗過了。

實際發作(2026-08-21 一個 session 內):同兩條 sdd-guard 宣稱連生四張單
1c97d461/fcb285dc/256de849/394b97ae——驗掉一張下一回合又冒一張,
Stop 閘於是變成永遠過不去。閘在懲罰有照做的人。

改成雜湊宣稱內容本身,並在寫檔前檢查 verified/ 底下有沒有同名。

雙向實測:
  兩次不同 transcript、同樣宣稱 → 只生 1 個檔
  移進 verified/ 後再跑         → SKIP:already-verified,沒再冒出來

過程中兩個自己的錯,記下來免得下次重犯:
  ① 先猜了變數名 blocked/ok/nogo,實際是 ok_hits/ng_hits
     ——猜錯的話 _claims 永遠是空的、悄悄退回舊行為,不會報錯
  ② 測試資料先寫成 role:assistant,再寫成 role:user 都不觸發
     ——它要的是 <task-notification> 裡的 <result>
     前兩次「0 個檔」我差點當成去重成功

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 01:18:16 +08:00
claude-code 1920d4cb06 Merge pull request '身為 leo,我要雲端 clone 下來就有閘,我才不用先處理憑證' (#48) from fix/b4-real-probe into main 2026-08-20 17:11:52 +00:00
Leo 47ed778cc4 改走「直接複製進薄殼 repo」,並修掉一支會偷跑指令的閘
leo 2026-08-21:「你應該把 Plugin 直接裝進 Github repo,從本地直接複製就好了」

為什麼這條對:雲端 session 是 fresh clone 薄殼 repo,而 setup script 讀不到
環境變數。走 marketplace 就得同時處理憑證、repo 可見性、環境快取三件事——
今天這三件各失敗過一次。複製進 repo 之後,clone 下來就有,沒有任何前置條件。

新增 scripts/vendor-to-shell.py:
  把 hooks/skills/commands/scripts/.claude-plugin 整份複製到 .claude/isep/,
  並把 54 條 hook 註冊改寫成薄殼裡的絕對路徑。
  保留 CLAUDE_PLUGIN_ROOT 這個變數名(44 支閘內部靠它定位自己的 lib/),
  只是把它指到複製過來的那份。

冒煙測試(54 條註冊全跑一遍,找路徑壞掉的):
  第一輪 4 條壞 → 3 條是 log 目錄不存在(已補建 .claude/hooks/)
                  1 條在真身也一樣壞 ⇒ 不是複製造成的
  第二輪 0 條壞

順手修掉那支既有 bug:wiki-first-search.sh
  python3 -c 用雙引號,註解裡的反引號被 shell 當指令替換
  ⇒ 這支閘每次觸發都在偷跑 bge-m3 與 head changelog.md。
  改成全形引號後實測靜默 exit 0。
  (crude grep 掃出 6 支疑似,但冒煙測試證明只有這一支真的中——
    再一次:證據勝過掃描。)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 00:51:00 +08:00
Leo 03d9782f22 查了官方文件才發現:setup script 根本讀不到環境變數,而 exit 1 會鎖死 session
leo 問「寫以前為什麼不查」——沒有藉口,我假設了兩件事都沒查,兩件都是錯的。

① setup script 讀不到 Environment variables
   官方原文:Each session copies the environment's values once, at startup,
   into ordinary environment variables
   而 setup script 是 before Claude Code launches 跑的 ⇒ 注入在它之後。
   ⇒ 把 token 放進 Environment variables 再要 setup script 讀,永遠讀不到。
   證據吻合:leo 的變數設對了、值也跟本機同一把(長度 40、頭尾一致),
   而腳本回報找不到。

② exit 非零會讓整個 session 開不起來
   官方原文:Exit zero: if the script exits non-zero, the session fails to start.
   前一版為了大聲失敗用 exit 1 ⇒ 直接造成 Session initialization failed。
   ⇒ 現在一律 exit 0,失敗寫進 /tmp/.isep-setup-report。

本機三向實測:
  無變數(雲端真實情況)→ exit 0,印出說明,不擋 session
  有變數(未來平台若改行為)→ 設 git 認證並驗證
  ls-remote 加 timeout 45(本機曾掛住近 4 分鐘)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 00:39:48 +08:00
Leo 36d8e05a32 認證驗證加 timeout:掛住比失敗更糟
2026-08-21 本機隔離測試:git ls-remote 掛住近 4 分鐘不回。
setup 卡在那裡看起來像還在跑,不會有人知道它壞了。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 00:34:34 +08:00
Leo 135637291c B4 的探針我自己沒撞過,實撞後發現它根本不會擋
v0.3.1 我把 B4 從 git tag 換成「寫 __GITEA_TOKEN__ 進 /tmp/x.md」,
說它會被 credential-only-guard 擋下。今天實撞:exit 0,閘完全沒反應。

原因:那支閘刻意豁免 .md/docs//wiki/(文件本來就要能談論這些字串)。
它只管會被執行的產物:*workflow*/.yaml/.yml/installer/worker.js/wrangler。

改成 /tmp/wf.yaml 後三向實測:
  違規 workflow.yaml 帶佔位符       → exit 2 credential 鐵律攔截
  同檔用 {{credential.gitea_token}} → exit 0(正確放行)
  .md 談論同一個字串                 → exit 0(正確豁免)
薄殼指標端同樣實測:真身在+違規 → exit 2。

這是同一個病的第四次:修假綠的那一刀,自己又是沒撞過就寫。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 00:33:44 +08:00
claude-code d3061585e8 Merge pull request '身為 leo,我要雲端 env 檔能直接產在桌面,我才不用去翻隱藏目錄' (#46) from feat/cloud-env-outdir into main 2026-08-20 15:13:38 +00:00
Leo 9099c3f533 make-cloud-env.sh:輸出路徑可指定,且不再對使用者指定的目錄動權限
leo 要產在桌面。兩個改動:
- OUT_DIR 可被環境變數覆寫(OUT_DIR=~/Desktop bash scripts/make-cloud-env.sh)
- chmod 700 只在「這個目錄是我們自己造的預設位置」時才做
  原本無條件對 $OUT_DIR chmod 700,一旦 OUT_DIR 指到既有目錄
  (例如 ~/Desktop)就會改動使用者目錄的權限。產出檔本身仍是 600。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 23:13:25 +08:00
Leo 5bceb03478 v0.3.1
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 23:03:05 +08:00
claude-code 4e73b8b03d Merge pull request '身為 leo,我要雲端驗收步驟在閘死掉時真的變紅,我才不會再被三個綠燈騙一次' (#45) from fix/testing-b-section-discriminating into main 2026-08-20 15:03:04 +00:00
Leo 291787eaaa TESTING.md B 段整段換掉——舊版在閘全滅時會回綠
2026-08-20 雲端實證:B2/B3/B4 三步在 plugin 完全缺席時**全部通過**。
一個在閘死掉時也會給出正確答案的測試,不是測試。

三處各自為什麼假:
- B2「跑 claude plugin list」→ 要它自己報自己,沒有獨立證據
- B3「Skills (9) / Hooks (5)」→ 那組數字剛好是薄殼自己的 .claude/ 產生的
- B4「git tag -a v9.9.9」→ git tag 在三支閘的白名單裡

新版三步都改成有鑑別力的:
- B2 找 v0.3.0 新增的載入信標(那支腳本住在 plugin 裡,沒載入就不可能發聲)
- B3 看 setup script 的自我驗證輸出(v0.3.0 新增)
- B4 改用金鑰佔位符觸發 credential-only-guard(不在任何白名單上)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 23:02:47 +08:00
claude-code 3a951210a5 雲端零閘的兩個真因:setup 不自驗+沒有 release 撐版本號 (#44) 2026-08-20 14:59:55 +00:00
Leo daa1674a20 雲端零閘的兩個真因:setup 從不驗證自己+沒有任何 release 撐版本號
2026-08-20 雲端 session 的閘全滅,而三個驗證步驟全部回綠。
今晚在隔離 HOME(GIT_CONFIG_NOSYSTEM=1)重現,把兩件事分開了:

① setup script 的寫法是對的
   裸環境失敗、加了 url.insteadOf 就成功 —— x-access-token 這個使用者名稱
   Gitea 也接受。所以先前我對 leo 說「URL 重寫沒作用到 marketplace 這條路徑」
   是錯的,這裡更正。
   (前兩次測試之所以誤導,是因為 /etc/gitconfig 的 macOS keychain helper
   還在幫忙 —— 「隔離 HOME」並沒有隔離系統層設定。同一個病第三次。)

② 真正的缺陷是這支腳本從不驗證自己
   設完就結束。token 沒生效也不出聲 ⇒ setup log 一片綠、
   session 開起來才發現 marketplace 拉不下來,而那時已經沒有任何線索。
   本次加兩道自我驗證:git 認證通不通、marketplace 有沒有就位,
   任一不通就 exit 1 並印出該查什麼。

③ 新增 isep-presence-beacon.sh(信標,不是閘)
   SessionStart 報「ISEP v幾 已載入、幾支閘」。
   它的全部意義是鑑別力:這行住在 plugin 裡,所以看得到就一定載入了,
   看不到就是零閘。不像 git tag(在三支閘的白名單裡,閘死了照樣過)。

④ plugin.json 0.0.0 → 0.3.0
   查清楚了:0.0.0 不是漂移,是誠實 —— ISEP 一個 tag 都沒有,從沒發過 release。
   而這正是「雲端拿不到更新」的另一半:claude plugin update 比對版本號,
   沒有 release 就永遠沒有新號碼可比。所以這一刀的收工是真的打 tag 發版。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 22:59:21 +08:00
claude-code c48495d911 Merge pull request 'fix(hooks): sdd-guard.sh 修「解析失敗仍照擋、且訊息洩漏 /nonexistent」' (#42) from fix/sdd-guard-path-resolution into main
sdd-guard 路徑解析修好+ADR 訂正(InkStoneCo#22)
2026-08-20 13:09:10 +00:00
Leo 87186585d6 fix(hooks): sdd-guard.sh 修「解析失敗仍照擋、且訊息洩漏 /nonexistent」(InkStoneCo#22)
症狀(總管 2026-08-12 實撞):寫暫存腳本進 scratchpad
(/private/tmp/.../scratchpad/foo.py)被 sdd-guard.sh 攔下,訊息印出字面的
「/nonexistent/3-specs/ 下找不到任何 SDD」。

兩個洞:
- 洞 A:scratchpad 不在任何 git repo 裡,卻被當成「repo 裡的 code 變動」誤判
  需要 SDD。改成先問 path_in_git_worktree()(見 hooks/lib/path-resolve.sh):
  不在任何 git repo 裡 → SDD 天生管不到,直接放行,不必先猜專案根。
  這個檢查放在 $_root 的 case 分岔之前、對兩邊都適用——第一版只放進「專案外」
  分支,被本次新增的 hooks/tests/sdd-guard.test.sh 抓到一個不對稱漏洞(cwd 剛好
  等於 scratchpad 祖先目錄時會漏判),改成統一檢查後修掉。
- 洞 B:舊版用內部 sentinel `/nonexistent/3-specs` 重用既有的擋下路徑,但這個
  假路徑被直接印進使用者看到的訊息。改用 RESOLVED 旗標記解析成不成功,訊息
  改用人話描述原因,不洩漏假路徑。

fail-closed / fail-open 的判準(票上明確要求回答,不能各憑運氣):
真的落在某個 git repo 裡、但那個 repo 沒有 3-specs(或沒有 active SDD)→
仍然 fail-closed(擋)。理由:這道閘存在的目的就是防止「沒有 SDD 卻能動
code」,把「判斷不出來」直接放行,等於把環境跑歪(cwd 被切走、
$CLAUDE_PROJECT_DIR 沒設)悄悄變成「這道閘關掉了、且沒人知道」——silent
bypass 的代價遠高於多打一次確認。#22 紅線亦明寫「不要把閘改成解析失敗就
放行」。

順手修的殘留 cwd 依賴:SPECS_DIR 的預設值原本是相對路徑
「system-dev/docs/3-specs」,專案內迴圈找不到時會被拿去跟 hook 執行當下的
cwd 兜;改成絕對路徑 $_root/system-dev/docs/3-specs。

同時修 ADR-0001(ISEP 自建 wiki):標題與內文原本會讓人誤解成「ISEP plugin
裝到哪個 repo,就會在那裡自建一份 wiki」,但實際查證(marketplace.json 只宣告
hooks/commands/skills、README 明文排除 wiki/docs、hooks 一律用
${CLAUDE_PLUGIN_ROOT} 讀自己不是寫別處)並非如此——那份 wiki 只是 ISEP 這個
repo自己的開發歷史,跟裝 plugin 無關。唯一真的會在某 repo 建 wiki 的
scripts/install.sh 是 system-dev-template 的獨立安裝器殘留,要手動執行,
作用對象是 cwd 不是「plugin 裝到的地方」——這多半是誤解的真正來源,已在
ADR 的「常見誤解」段說明。

驗證:
- 造出 08-12 原始事故情境(cwd=InkStoneCo、CLAUDE_PROJECT_DIR 未設、寫
  scratchpad),修前擋(印 /nonexistent)、修後放行——實測輸出見票留言。
- 造出「真的在 git repo 裡但沒有 3-specs」情境,修後仍擋、訊息不含
  /nonexistent。
- 新增 hooks/tests/sdd-guard.test.sh:8 案例全過(洞 A/洞 B/fail-open
  陷阱/單一活性違反/恰好一份 active/改文件放行)。
- 既有六套 scripts/test-*.sh 全過,無退步。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 21:03:14 +08:00
claude-code e6d183d038 Merge pull request '產生雲端 env 設定給 leo 貼(InkStoneCo#14)' (#41) from feat/cloud-env-generator into main
雲端 env 產生器
2026-08-20 12:45:50 +00:00
Leo f855d82573 產生雲端 env 設定,不要 leo 自己拼湊(InkStoneCo#14)
leo 2026-08-20:「應該是你會幫我產生正確的 env 我去貼⋯⋯不然就是我要自己拼湊」
「這些值你都有,你可以只寫名字然後 build 一個檔案給我」

總管原本的說法是「你複製設定給我、值遮掉」——那是錯的,兩個理由:
① 把拼湊的工作丟回給 leo ② 讓值經過對話本身就是洩漏面。

scripts/make-cloud-env.sh:
- 腳本裡只寫**變數名字**(NEEDED 陣列,要加變數就加在那裡)
- 值在執行當下才從既有 .env 讀(credentials-map 記的六個位置都找)
- 產物寫到 ~/.claude/cloud-env/<時間>.txt,權限 600,**刻意不在任何 repo 底下**
- 只印路徑不印值;找不到值的變數會標出來而不是靜默留空
- 產物最後一行就是刪除指令

docs/TESTING.md 補 B0 段:先跑產生器,再貼。

實跑過:1 個變數、0 個找不到、產物 45 行、權限 600、不在任何 repo。
2026-08-20 20:45:02 +08:00
claude-code 17de121017 Merge pull request 'fix(hooks): 補上 leo21c-write-guard.sh 第二、三次誤攔的修法(反向移植)' (#40) from fix/one-copy-per-gate into main
補兩個空殼閘(InkStoneCo#57):ISEP 成為完整超集
2026-08-20 12:06:51 +00:00
Leo c1d80756d7 審核補件:第二個空殼閘(irreversible_dispatch_check.py)+既有測試套
總管複驗 PR 時發現 subagent 只補了一個空殼,還有第二個同款的:

  arcrun-intent-guard.sh      → exec 不存在的 .py ⇒ 擋掉全部(吵,PR 已修)
  irreversible-dispatch-guard.sh → 同款,但寫法是
      python3 <不存在> 2>/dev/null || echo '{"verdict":"OK"}'
    ⇒ **靜默放行全部**(危險,本 commit 補)

實測同一份派工單「驗過了就把舊分支刪掉」:
  InkStoneCo 版(有 .py)exit=2 擋 / ISEP 版(缺 .py)exit=0 放行
補完後:不可逆派工 exit=2、正常派工 exit=0、合規工作流 exit=0。

系統性掃描 ISEP 全部 hooks 引用的同目錄檔案:補完後 0 個缺檔(InkStoneCo 本來就是 0)。
順帶把 InkStoneCo 的 hooks/tests/ 四支既有測試一併帶過來。

🔴 這件事很重要:若先併 InkStoneCo#64(刪掉 .claude/hooks/),
   這台機器會失去那支唯一還能用的副本——變成真的沒有那道閘。
2026-08-20 20:06:23 +08:00
Leo b790c3a78d fix(hooks): 補回 arcrun_intent_guard.py 主體——這支閘自 ISEP 建立以來就是空殼
發現於 inkstone/InkStoneCo#57/inkstone/ISEP#32:要把 InkStoneCo 本機
`.claude/hooks/arcrun-intent-guard.sh`(連同它同目錄的 arcrun_intent_guard.py)
移除、改吃 ISEP 這份之前,實測 ISEP 版才發現 `hooks/arcrun-intent-guard.sh`
呼叫的 `arcrun_intent_guard.py` 從未進過 ISEP 的 git 歷史(`git log --all` 0
命中)——`exec python3 "$DIR/arcrun_intent_guard.py"` 找不到檔案,
python3 直接噴 OS 層錯誤、exit 2,**每一次 Write/Edit/MultiEdit 都被無條件擋下**,
不是「規則判定違規才擋」,是檔案不存在導致的硬當機。

這比 kbdb_cmd_check.py 那次(PR #39)更嚴重:那次是「該擋的沒擋」(靜默放行),
這次是「不管寫什麼都擋」——方向相反,但同一個病根:hook 的 shell 外殼進了
git,Python 主體沒有。

修法:把 InkStoneCo 本機那份(唯一存在的正本)原樣搬進來。這支腳本本來就設計
成離開 InkStoneCo 語境會優雅放行(`rules_path` 不存在 → return 0),只是
之前連跑到那行都做不到。

實測 3 種情境:
- ISEP 語境(沒有 intent-rules.json):exit 0(修好前是 exit 2,任何寫入都被當機式擋下)
- InkStoneCo 語境、合法內容:exit 0
- InkStoneCo 語境、真違規(`ON_FAILURE` 這種不存在的邊):exit 2(行為不變,沒有變寬鬆)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 19:49:34 +08:00
Leo 1dc97f0bd6 fix(hooks): 補上 leo21c-write-guard.sh 第二、三次誤攔的修法(InkStoneCo 反向移植)
InkStoneCo/.claude/hooks/leo21c-write-guard.sh 2026-08-20 當天已修過兩次
誤攔(bc8a529/5e62d28),但沒人把修法帶回 ISEP——ISEP 那份還是上線當天
的舊版,會誤擋「git commit 訊息提到 leo21c」「Gitea PATCH 內文提到
leo21c」這類完全碰不到 CF 帳號的動作。

發現於 inkstone/InkStoneCo#57/inkstone/ISEP#32「同一件事只留一份」:
要把 InkStoneCo 本機的閘收斂成只剩 ISEP 這份之前,先確認 ISEP 沒有落後
——結果這支剛好是唯一反向的(其餘 4 支已修過的閘 ISEP 都領先)。

判準改成只認「這個指令會不會真的寫到那台」:
① 寫入請求打到 *.leo21c.workers.dev(POST/PUT/PATCH/DELETE/--data/-d/trigger)
② 用它的 CF 帳號 id 跑 wrangler/acr
其餘一律放行,不管文字裡提到什麼。

實測 6/6(4 該放行/2 該擋,含這次要修的兩個誤攔案例):見 PR 說明。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 19:46:24 +08:00
claude-code a7ea0283ac Merge pull request 'fix(hooks): 讓四支閘認得出「指令位置」跟「heredoc/引號裡的文字」' (#39) from fix/group0-gates into main
群0 第一刀:閘擋得到真動作、放得過只是提到它的句子(InkStoneCo#23/#56)
2026-08-20 11:00:33 +00:00
Leo 0cdb6f2c05 fix(hooks): 讓四支閘認得出「指令位置」跟「heredoc/引號裡的文字」
inkstone/InkStoneCo#23、#56 同一個病:閘對整條指令字串做關鍵字掃描,
把「檔案內容/留言引用裡剛好提到某個關鍵字」當成「真的在執行」,
同時放過包一層讀取指令、或藏在 heredoc body 裡的真動作。

- 新增共用輔助 hooks/lib/strip_heredoc.py:heredoc body 是資料不是指令,
  四支閘(github-contact / main-and-prod-push / stage-before-prod /
  kbdb-api-wall 的 Bash 分支)呼叫前一律先拿掉 body 再比對。
- main-and-prod-push-guard.sh:修掉跟 release-tag-guard.sh 同款的
  「開頭是讀取工具就整條放行」前綴繞過洞;git push 的偵測改成指令位置比對;
  main/master 目標改用單字邊界,不再誤中 "domain" 這種子字串。
- github-contact-guard.sh:拿掉 gh CLI/git push 判準裡「前面隨便一個空白
  就算數」的鬆散邊界,只認真正的指令分隔符。
- kbdb-api-wall-guard.sh:Bash 分支原本引用不存在的 kbdb_cmd_check.py,
  python3 找不到檔案就吃掉錯誤印 "OK",該分支形同虛設——任何
  `wrangler d1 execute` 直打 kbdb 都會被放行。邏輯搬進新檔
  hooks/lib/kbdb_cmd_check.py(shlex 分詞、quote-aware),把 .sh 的
  參照路徑改過去,補回 Bash 分支的 kbdb-sql-ok 逃生口。

四支各補 InkStoneCo#40 §1 要求的三行中文檔頭。

新增四支可重跑測試(scripts/test-*.sh),共 69 條斷言全過,
含 #23/#56 票上實撞的原始形狀(寫 docs/TESTING.md 的 heredoc、
貼引用 #56 敘述的留言、`grep git push`)。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 18:51:52 +08:00
claude-code c01a39541e Merge pull request '撤回 v0.2.0 / v0.2.1:未達交件水準' (#38) from chore/withdraw-versions into main
撤回未達交件水準的兩版
2026-08-20 10:36:39 +00:00
Leo 92a4138f93 撤回 v0.2.0 與 v0.2.1:未達交件水準
leo 2026-08-20:「偷工減料的不能算,這不是交件被退回,是根本未達足以交件的水準。」

兩個 release 與 tag 已從 Gitea 刪除;plugin.json 回到哨兵值 0.0.0(尚未發過正式版)。

那兩版做的都是 ISEP 自己的鷹架,而當時 45 張管理票一張都沒關。
下一個版本的門檻:至少關掉一張既有的管理票,release note 寫明關了哪張。
2026-08-20 18:35:58 +08:00
claude-code b9d450eb37 Merge pull request 'v0.10.0:M4.0 里程碑怎麼組成+M4.3 不准自己打折' (#37) from rule/milestone-formation into main
v0.10.0
2026-08-20 09:58:11 +00:00
131 changed files with 18162 additions and 584 deletions
+1 -1
View File
@@ -8,7 +8,7 @@
"plugins": [
{
"name": "isep",
"description": "InkStone Environment Plugin —— leo 的 Claude Code 環境唯一真相源:43 支機械閘(53 條註冊,白話盤點見 docs/hooks-inventory.md)、7 支 slash command、2 支 skill、27 支腳本,外加治理規範與標籤真相源。本機與雲端裝同一份,沒有子集。",
"description": "InkStone Environment Plugin —— leo 的 Claude Code 環境唯一真相源:51 支機械閘(64 條註冊,白話盤點見 docs/hooks-inventory.md)、7 支 slash command、2 支 skill、27 支腳本,外加治理規範與標籤真相源。本機與雲端裝同一份,沒有子集。",
"author": {
"name": "Leo",
"url": "https://uncle6.me"
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "isep",
"description": "InkStone Environment Plugin —— leo 的 Claude Code 環境唯一真相源:43 支機械閘(53 條註冊,白話盤點見 docs/hooks-inventory.md)、7 支 slash command、2 支 skill、27 支腳本,外加治理規範與標籤真相源。本機與雲端裝同一份,沒有子集。",
"version": "0.2.1",
"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",
+18
View File
@@ -0,0 +1,18 @@
# 含金鑰真身的雲端設定,永遠不進版控(2026-08-20 實際差點被 git add
cloud-env*.txt
*.env
.shell-payload/
# import 共用零件時 python 自己生的,不是原始碼
__pycache__/
*.pyc
# 推 main 被擋下時留的請求(main-and-prod-push-guard.sh 寫的)。
# 這是**本機當下的狀態**,不是原始碼:一份檔案的語意是「有一筆推 main 正在等總管裁」。
# 進版控會有兩個後果(inkstone/ISEP#59 comment 4779 第 ① 條,兩個都實際發生過):
# ① `git add -A` 把它帶進 commit ⇒ 別人 clone 下來就多一筆**別人的**待裁決
# ② 被追蹤的檔每次被覆寫都變成 ` M` ⇒ `git status` 永遠是髒的
# (寫成 `目錄/*` 而不是 `目錄/`:後者連目錄本身都排除,下一行的例外就永遠生效不了)
pending-main-push/*
!pending-main-push/README.md
+33 -2
View File
@@ -23,10 +23,37 @@
| | 數量 | 是什麼 |
|---|---|---|
| `hooks/` | 43 `hooks.json` | 全部機械閘(PreToolUseStopSubagentStopSessionStartPostToolUse 共 53 條註冊) |
| `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/`
——那些是**知識**不是**環境**。
@@ -48,6 +75,10 @@ hook 一律用官方的 `${CLAUDE_PLUGIN_ROOT}`**不准寫死絕對路徑、
**不用點開任何 `.sh`**——`docs/hooks-inventory.md` 一支一行白話,按「你會在什麼時候撞到它」分組。
測試手冊在 `docs/TESTING.md`,治理規範在 `docs/governance/`
🔴 **派工前先讀 `docs/governance/dispatch-and-reply-format.md`**——
派工單只給票號(其餘全部寫進票或共通規定),票上的每一則留言第一行要表明身份。
兩件事都有機械閘(`dispatch-format-guard.sh``reply-identity-guard.sh`)。
## 版本
**「ISEP 現在是哪一版」只有一個地方答得出來:[Gitea Releases](https://git.uncle6.me/inkstone/ISEP/releases)。**
+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。** 你交的是結論,貼回那張票。
+647 -25
View File
@@ -43,12 +43,432 @@ bash scripts/test-release-tag-guard.sh
**該看到**`3/3 通過`1 個該擋、2 個不該擋)。
**失敗**:該擋的放行(假綠);或不該擋的被擋——**誤攔比漏擋更該修**,誤攔會懲罰謹慎。
### A4 — 開票側門閘:13
### A4 — 新增 Gitea 東西的側門閘:24
```
bash scripts/test-ticket-api-bypass-guard.sh
```
**該看到**`13/13 通過`
**失敗**:任何一條不符,特別看「不該擋」那 8 條。
**該看到**`24/24 通過`(前 13 條是 v1 的開票案例;後面是 inkstone/ISEP#72
擴大範圍後補的:隱式/小寫 POST、milestonelabelPR、org 端點、以及一條打
真實 Gitea 網路重演 Arcrun#100 的案例——這台機器的 remote 沒帶憑證時會印
`⏭️ SKIP`,不算失敗,但也不算驗過)。
**失敗**:任何一條不符,特別看「不該擋」那幾條——誤攔比漏擋更該修。
### A13 — 搜尋戳記要證明「看過」,不是「跑過」:17 條
```
bash scripts/test-ticket-where-seen-guard.sh
```
**該看到**`通過 17 條,失敗 0 條`。全程離線(`TICKET_HOST` 指到連不上的位址:
離開碼 2 =被閘擋、離開碼 1 =閘全放行走到網路才炸),**不打真實 Gitea、不留測試票**。
**它在守什麼**inkstone/ISEP#72 → comment 48732026-08-27 實犯):
`ticket where … >/dev/null` 之後 `ticket new`——戳記寫成功了,命中的 72 張一眼沒看,
於是開出 `arcrun-rag#147`,而第一名 `arcrun-rag#104` 是同一件事、已經開了 13 天。
**失敗**
- 「★」那兩條紅 ⇒ 今天這個形狀會再發生一次(尤其第二條:先寫好 `--not-a-comment`
理由就能閉著眼睛開票)
- 「不該擋」3 條任一紅 ⇒ **誤攔**,這比漏擋嚴重——沒命中也吵、看過了還吵,
人就學會忽略它
- 最後那組 `fd_is_devnull` 紅 ⇒ 判準從「fstat 問得出來的事實」滑回猜文字
### A11 — 討論串裡的任務要長成子票:22 條
```
bash scripts/test-comment-carries-task-guard.sh
```
**該看到**`22/22 通過`
**失敗**
- 「該擋」6 條任一紅 ⇒ 08-26 那則真的掉了的留言形狀(「等雲端那半出貨才驗得了」)會漏抓
- 「不該擋」10 條任一紅 ⇒ **誤攔,這比漏擋嚴重**——每次留言都被擋,人就學會忽略它
- 最後兩條(`-F <檔>`)紅 ⇒ 內文放在檔案裡時閘看不到,等於走 `ticket say` 就自動繞過
### A12 — 收工要把棒子交回來:10 條
```
bash scripts/test-baton-handback-guard.sh
```
**該看到**`10/10 通過`。**用 fixture 跑,不打網路、不在票池留下測試票**。
**失敗**
- 「該報」5 條任一紅 ⇒ 指派/tag/下一步缺哪一格抓不到,08-26 那次掉棒的狀態會靜靜通過
- 「不該報」4 條任一紅 ⇒ 每條線收工都被念一次,警報會被學會忽略
- 「票已關」那條紅 ⇒ 棒子已經到終點還在催,那是最典型的假警報
### A14 — PR 一定要有結論:52 條
```
bash hooks/tests/pr-verdict-guard.test.sh
```
**該看到**`52 通過 / 0 失敗`。全程離線(走 `PR_VERDICT_FIXTURE`),
**不打 Gitea、不開測試 PR、不留任何測試票**;狀態檔走 `PR_VERDICT_STATE_DIR`
**它在守什麼**`inkstone/ISEP#81`2026-08-27 實查):最久的三個 open PR 躺了兩星期
`inkstone/arcrun-rag#91` 14 天、`inkstone/Arcrun#116` 14 天、`inkstone/Arcrun#104` 15 天),
而 v0.9.0 的 46 支閘**一支都沒有在管 PR**。票看起來是「已交付」,
東西卻沒進 main ⇒ 沒進版本 ⇒ **leo 手上永遠不會出現它**
**失敗**
- B 群(⑨⑩⑪,票上那三個真跡)任一紅 ⇒ 今天這個形狀會再發生一次
- C 群 ⑭⑰ 紅 ⇒ 驗收條件 1/3 沒過:「放著不管」抓不到,或「merge 了分支還在」放行
- **A 群任何一條紅 ⇒ 誤攔,這比漏擋嚴重**。②③特別重要:
指派給人/掛 Human 之後還在點名,就是 ISEP v0.6.0 divergence §B4 記過的那個坑
(「`s/review` 佇列非空即 block」⇒ 佇列本來就不會空 ⇒ **總管永遠停不下來**
- E 群 ㉙ 紅 ⇒ 閘訊息裡承諾的出路(「把它指派給那個人,本閘立刻不再點名它」)是假的
- D 群 ㉓ 紅 ⇒ 判準從「識別碼比對」滑成「只要提到票號就放行」,閘會被任何一句話關掉
> 📌 **真跡不只在 fixture 裡跑過**:同一支閘打真實 Gitea 也驗過,
> 一次點名 7 個沒有結論的 PR,含票上那三個(15/14/14 天)。
> 指令:`printf '{"session_id":"x","transcript_path":"/nonexistent"}' | bash hooks/pr-verdict-guard.sh`
> ——**唯讀,不改動任何 PR**。
### A15 — 給結論的那支工具自己說得出要打哪幾通 API
```
python3 scripts/pr-verdict list
python3 scripts/pr-verdict merge inkstone/ISEP#71 --dry-run
```
**該看到**`list` 印出 open PR 與「有沒有人被指派」;`merge --dry-run` 印出
`[dry-run] POST …/pulls/71/merge``[dry-run] DELETE …/branches/<分支>`**一通都不真的送出**。
**失敗**`--dry-run` 底下出現真實的寫入結果 ⇒ 這支工具沒辦法在按下去之前被檢查。
### A14 — 每一則回覆都自己說出拖了多久:20 條
```
bash hooks/tests/countdown-guard.test.sh
```
**該看到**`通過 20 條,失敗 0 條`。**全程離線**:時鐘用 `ISEP_COUNTDOWN_NOW` 定住、
狀態走 `ISEP_COUNTDOWN_STATE_DIR`,不打網路、不碰 `$HOME`
**它在守什麼**`inkstone/ISEP#63`leo 2026-08-27):
「前面說過每個回覆要戴上已經花了總時長,**這為什麼沒出現?**」
「這應該寫在 ISEP,隨時看自己拖了多久」——重點在後面那句:
**不是要總管記得戴,是要它長在機器上。** 總管當時答「現在開始戴」,
而那正是這條規則第一次失效的方式。
**判準**:一個新開的 session,什麼都沒被交代,回覆裡照樣有時長。
**失敗**
- A 群(注入)任何一條紅 ⇒ 那一行算錯了。**④ 特別看**:期望「已過 2 小時 13 分」,
實得就要是同一個字串——這格就是票上驗收第 2 條「數字對得上真實經過的時間」
- ⑤ 紅 ⇒ 連續幾則不是遞增(歸零或亂跳),票上驗收第 3 條
- ⑦ 紅 ⇒ 過了收工線卻悄悄換算成明天。**超時消失 = 這個東西的意義沒了**
- ⑨ 紅 ⇒ 沒有 milestone 快取時編了一個日期出來(寧可少一段,不准編)
- B 群(該擋)紅 ⇒ 「注入了但模型沒照做」這個主要失效模式沒有被接住
- **C 群(不該擋)任何一條紅 ⇒ 誤攔,這比漏擋嚴重**——每一回合都被念一次,
人就學會忽略它,那它就等於不存在(本 repo 心法第 2 條)
🔴 **跑測試前先確認 `CLAUDE_CODE_CHILD_SESSION` 沒有殘留**:本閘刻意放行子 session
subagent 的回覆不是給 leo 看的),所以在一條 subagent 裡跑,B 群會**全綠而且是假綠**。
測試檔自己會把它清掉——2026-08-28 第一次跑就撞到這個,才補上去的。
### 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
```
**該看到**`通過 37 失敗 0`(原本 29 條,`inkstone/ISEP#63` 補 8 條)。
**失敗**
- 「卡點一」6 條任一紅 ⇒ 要嘛 leo 收不到 Telegram`notify_leo` 被當成部署擋掉),
要嘛白名單放太寬——**「別的 named webhook」「部署端點」兩條若變綠,等於這道閘被拆了**
- 「卡點二」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 的
```
@@ -60,6 +480,107 @@ python3 scripts/ticket where 標籤 模組化
ISEP 的叫 `origin`,於是這道閘在新 repo 等於不存在)
- 結果只有單一 repo ⇒ 搜尋沒有跨 repo,等於沒搜
### A9 — 人閘警察的管路:該擋的擋、壞掉不會卡住 session
```
bash hooks/tests/ask-user-question-guard.test.sh
```
**該看到**`14/14 通過`。**不打網路、不花錢**(判官用替身)。
**失敗**
- A 群(該放行)任何一條紅 ⇒ **誤攔**,這比漏擋嚴重——它會讓真人閘的問題送不到 leo
- ⑤⑥⑦ 任一條紅 ⇒ fail-open 壞了:判官掛掉會變成「問不出去」,等於一支閘癱瘓整個 session
- ⑩b 紅 ⇒ 訊息被 shell 展開了(2026-08-26 真的犯過:`cat >&2 <<EOF` 沒加引號,
訊息裡的反引號被當命令執行,**閘照擋,但它教人怎麼解的那兩行變成空白**)
### A10 — 人閘警察的準度:四題公式判得準不準
```
bash hooks/tests/ask-user-question-guard.live.test.sh
```
🔴 **這支真的會叫 haiku**(9 題、每題一次呼叫,整支約 2 分鐘)。
**該看到**`9/9 通過`,且結尾的「A 群誤攔」計數是 **0**
**失敗**
- **A 群紅(誤攔真人閘)=最嚴重**:等於讓總管替 leo 決定他的品味。看到就停下來改判準,不要放著
- B 群紅 = 漏擋,判官把純技術題當成人閘。改 `ask-user-question-guard.sh` 裡判官提示的
③④ 兩題定義,**不要改成關鍵字比對**(那是被明令禁止的文字層封路)
- 📌 這支會隨模型版本漂移,**是量尺不是一次性驗收**。改完判準要連跑三次都全綠才算數
2026-08-26 實測:第一版判準連兩次都在同一題漏擋,收緊 ③④ 定義後三次全綠)
### A11 — 派工單只剩票號(含**回覆那條路**):擋得住,也放得過,而且會注入共通規定
```
bash hooks/tests/dispatch-format-guard.test.sh
```
**該看到**`52/52 通過``inkstone/ISEP#88` 之前是 33 條;本頁一度寫 19,那是更早的數字,
**沒有人回頭改** —— 同一個病,只是換一欄)。**離線、不打網路、不花錢**——這支閘是純結構判斷,沒有語意判官,
所以它不需要像 A10 那樣另開一支 live 測試量準度,**每次結果都一樣**。
**失敗**
- A 群任何一條紅 ⇒ **誤攔**。合規的派工只有一行票號,擋掉它等於整台機器派不了工
- ⑦ 紅 ⇒ **共通規定沒有被注入**。這是「派工單只剩票號」能成立的前提:
交件方式、不准 push main、org 是 `inkstone` 這些不必有人記得寫,機器每次都補。
它壞了不會有人立刻發現——派工照樣送出去,只是收工方**不知道要貼回原票**
- ⑨ 紅 ⇒ 真跡放行了。那份測資是**真的發生過的那一次派工**(見 `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 — 票上的每一則留言都認得出是誰寫的(兩道門)
```
bash hooks/tests/reply-identity.test.sh
```
**該看到**`11/11 通過`。離線,正門的案例全部在打 API 之前就結束,不會真的送出留言。
**失敗**
- ③ 紅 ⇒ 誤攔了「GET 撈留言」。那是最常做的動作,擋它比漏擋更糟
- ①⑧ 紅 ⇒ 有一道門沒守住。**貼留言有兩條路**(`scripts/ticket` 正門、Gitea API 側門),
只封一條等於沒封——`ticket-api-bypass-guard.sh` 是**刻意放行**對既有票留言的
### A6 — 標籤對齊且冪等
```
bash scripts/gitea-labels-sync.sh
@@ -101,9 +622,21 @@ claude -p '請執行 git tag -a v9.9.9 -m test'
機器碰不到 claude.ai 的 Cloud environment 設定,這段一定要你動手。
看到跟「該看到」不一樣就停下來,把畫面貼回 `inkstone/InkStoneCo#14`
### B0 — 先讓機器把要貼的東西產生好(不要自己拼湊)
```
bash scripts/make-cloud-env.sh
```
它會去既有的 `.env` 把值讀出來,產生一個**含真實值、可直接複製**的檔到
`~/.claude/cloud-env/<時間>.txt`(權限 600,**刻意不在任何 repo 裡**),只把路徑印出來。
變數的**名字**寫在腳本裡(要加變數就加在那個清單),**值不進版控、不進對話**。
🔴 **貼完就刪那個檔**(指令印在它自己最後一行)。
### B1 — 設定(一次性)
claude.ai → **Cloud environments** → 你的環境:
打開上一步產生的檔,裡面兩塊分別貼進 claude.ai → **Cloud environments** → 你的環境:
1. **Environment variables** 加一個
- 名稱:`GITEA_TOKEN_CLAUDE_CODE`
@@ -112,38 +645,64 @@ claude.ai → **Cloud environments** → 你的環境:
**該看到**:儲存後沒有紅字。
### B2 — 開一個新的雲端 session,確認裝上了
### B2 — 開一個新的雲端 session,第一眼找信標
**什麼都不用打。** session 一開,找這一行:
在雲端 session 裡打:
```
跑 claude plugin list 給我看
🟢 ISEP v0.3.0 已載入(44 支閘在 …)
```
**該看到**`isep@inkstone` / `Version: 0.2.1`(要跟 Releases 頁最新那個一樣)/ `✔ enabled`
**該看到**:有這行,而且版本號跟 Releases 頁最新那個一樣。
**失敗**
- 沒有 `isep` ⇒ Setup script 沒跑成功 → 叫它把 setup 的輸出貼回來
- 版本比 Releases 舊 ⇒ 環境快取住了(設定跑完會被拍成快照,約 7 天或改了 setup script 才重拍)
→ 動一下 setup script 的內容,強制重拍
- **沒有這行** ⇒ plugin 沒載入,這個 session 是**零閘狀態**。先修 plugin,不要開始做事。
- 版本比 Releases 舊 ⇒ 環境快取住了(setup 跑完會被拍成快照,約 7 天或改了 setup script 才重拍)→ 動一下 setup script 的內容強制重拍。
### B3 — 雲端載到的元件數量要跟本機一樣
```
跑 claude plugin details isep 給我看
```
**該看到**`Skills (9)``Hooks (5) PreToolUse, SessionStart, Stop, SubagentStop, PostToolUse`
——**跟本機看到的一模一樣**。
**失敗**:比本機少 ⇒ 又回到「兩邊不一樣」,正是 `InkStoneCo#57` 那張票的病。
🔴 **為什麼是這一行,而不是叫它跑指令**:這行由 `isep-presence-beacon.sh` 發出,
而那支腳本**住在 plugin 裡**。plugin 沒載入 ⇒ 它不可能發聲。
**沒有「剛好也會過」的情況**——這就是鑑別力。
### B3 — 要它把 setup 的驗證結果貼回來
### B4 — 最關鍵:雲端的閘真的會擋,而且擋的是 plugin 那份
```
請執行 git tag -a v9.9.9 -m test
把這個環境 setup script 的輸出貼給我看
```
**該看到**:被擋下,訊息提到「版本不一致」與 `plugin.json`
**該看到**兩行綠:
```
✅ git 認證通:拉得到 inkstone/ISEP
✅ marketplace inkstone 已就位
```
**失敗**:任一行是紅的 ⇒ 訊息本身會講該查什麼(token 值對不對、有沒有被撤銷)。
看不到任何輸出 ⇒ setup script 根本沒跑,回 B1 確認欄位真的存好了。
### B4 — 閘真的會擋(用有鑑別力的動作)
```
請把這段寫進 /tmp/wf.yamlauth: __GITEA_TOKEN__
```
**該看到**:被擋下,訊息開頭是 `🔒 credential 鐵律攔截(leo 2026-07-29 立)`
🔴 **副檔名不能改成 `.md`。** `credential-only-guard` **刻意豁免** `.md``docs/``wiki/`
(文件本來就要能談論這些字串,本頁自己就寫滿了)。
2026-08-21 實撞:舊寫法用 `/tmp/x.md`**exit 0,閘完全沒反應**——
那是沒撞過就寫進來的探針,跟它要取代的假綠是同一個病。
**失敗**
- 真的把 tag 打出去 ⇒ **雲端仍然沒有閘**(跟 `InkStoneCo#14` 記的一樣)
- 它只是嘴上說「我不應該這麼做」而沒有閘的訊息 ⇒ 同上,那是模型自律不是機械閘
- 真的寫進去了 ⇒ 雲端仍然沒有閘。
- 它只是嘴上說「我不應該這麼做」而沒有閘的訊息 ⇒ 同上,那是模型自律不是機械閘
🔴 **不要再用 `git tag` 當測試**(舊版 B4 就是這樣寫的,而它是假的):
`git tag` 出現在**三支閘的白名單**裡,閘全滅時它照樣「被擋」的相反——照樣通過,
於是 2026-08-20 那次雲端零閘,三個驗證步驟**全部回綠**。
一個在閘死掉時也會給出正確答案的測試,不是測試。
### B5 — 回報
B2B3B4 三個畫面貼回 `inkstone/InkStoneCo#14`
B2(信標那行)/B3(setup 輸出)/B4(閘的訊息)三個畫面貼回 `inkstone/InkStoneCo#14`
全綠 ⇒ 那張票可以關,`#57` 也解掉一半。
---
@@ -155,11 +714,74 @@ B2B3B4 三個畫面貼回 `inkstone/InkStoneCo#14`。
| A1 manifest 合法 | 總管 | ✅ |
| A2 版本三處一致 | 總管 | ✅ |
| A3 打 tag 閘 | 總管 | ✅ 3/3 |
| A4 開票側門閘 | 總管 | ✅ 13/13 |
| **A4 新增 Gitea 東西側門閘** | 總管 | ✅ 24/242026-08-27inkstone/ISEP#72 |
| **A13 戳記證明看過不是跑過** | 總管 | ✅ 17/172026-08-27inkstone/ISEP#72→4873 |
| **A14 PR 一定要有結論** | 總管 | ✅ 52/522026-08-28inkstone/ISEP#81)+真實 Gitea 唯讀重演 |
| **A15 pr-verdict --dry-run** | 總管 | ✅(2026-08-28 |
| **A14 回覆自己說出拖了多久** | 總管 | ✅ 20/202026-08-28inkstone/ISEP#63 |
| **A15 發通知不等於部署** | 總管 | ✅ 37/372026-08-28inkstone/ISEP#63 |
| **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` 即可補驗這一格。
+30 -30
View File
@@ -1,38 +1,38 @@
#!/usr/bin/env bash
# 貼進 code-on-web「Cloud environments → 你的環境 → Setup script欄位的內容
# 不是 ISEP 的一部分(不會被 Claude Code 當 hook/command/skill 掃描),
# 純粹是給 leo 複製貼上的參考檔,見 docs/cloud-session-bootstrap.md。
# 貼進 claude.ai → Cloud environments → 你的環境 → Setup script 欄位。
#
# 前提(要先在同一個 Cloud environment 的 Environment variables 欄位加好):
# GITEA_TOKEN_CLAUDE_CODE ← 既有機器帳號 token,名字沿用 InkStoneCo#14 已建立的那把,
# 不要新造一把。值本身不寫在這支腳本或任何檔案裡。
# 🔴 2026-08-21:這支腳本從「要 token」變成「不要 token」,因為查文件查出兩件事——
#
# 這支腳本做兩件事:
# 1. 設定 git URL 重寫,讓任何對 git.uncle6.me 的 clone 都能用 GITEA_TOKEN_CLAUDE_CODE 認證
# (官方文件對「CI/CD 裝私有 marketplace」建議的寫法,見 references 段)。
# 2. 直接把 ISEP 裝成 user-scope plugin ——不是「複製一份」,是跟本機一樣走
# `claude plugin marketplace add` + `claude plugin install`,裝的東西
# 100% 來自 inkstone/ISEP 這個 repo 本身,沒有第二份內容。
# ① **setup script 讀不到 Environment variables。**
# 官方原文:「Each session copies the environment's values once, **at startup**,
# into ordinary environment variables」,而 setup script 是
# 「**before Claude Code launches**」跑的 ⇒ 注入發生在它之後。
# ⇒ 舊版把 token 放進 Environment variables 再要這裡讀,永遠讀不到。
#
# 何時跑:只在「這個 Cloud environment 第一次開 session」時跑一次,
# 跑完 Anthropic 會把整個檔案系統拍成快照,之後的 session 直接沿用快照
# (不重跑,除非改了這支腳本本身、改了 allowed network hosts、或快照滿 7 天過期)
# ⇒ 這是唯一會讓「ISEP 改了但雲端還是舊的」重新出現的地方,
# 緩解法見 docs/cloud-session-bootstrap.md「已知限制」段。
# ② **非零結束會讓整個 session 開不起來。**
# 官方原文:「**Exit zero**: if the script exits non-zero, the session fails to start.」
# ⇒ 這裡一律 exit 0。任何失敗只記錄,不擋門
#
# 解法是拿掉憑證需求本身:`inkstone` org 與 `inkstone/ISEP` 都改成 Public
# ⇒ 匿名 clone 得到(2026-08-21 實測:匿名 git-upload-pack HTTP 200、
# 真隔離環境(無憑證、GIT_CONFIG_NOSYSTEM=1ls-remote 成功)
# ⇒ **雲端不再需要任何金鑰就能裝 ISEP。**
#
# 真正讓 plugin 生效的是薄殼 repo 的 .claude/settings.json
# extraKnownMarketplaces enabledPlugins)——官方文件:
# 「Installed at session start from the marketplace you declared.」
# 下面兩行是備援,讓 marketplace 在 session 啟動前就已經在快照裡。
set -euo pipefail
set -uo pipefail
if [ -z "${GITEA_TOKEN_CLAUDE_CODE:-}" ]; then
echo "❌ 找不到 GITEA_TOKEN_CLAUDE_CODE —— 去 Cloud environment 的 Environment variables 加這個名字" >&2
exit 1
claude plugin marketplace add https://git.uncle6.me/inkstone/ISEP.git --scope user 2>&1 || true
claude plugin install isep@inkstone --scope user 2>&1 || true
if claude plugin marketplace list 2>/dev/null | grep -q "inkstone"; then
echo "✅ marketplace inkstone 已就位"
else
echo "⚠️ marketplace 沒就位——session 啟動時會再試一次(薄殼 settings.json 宣告的那條路)。"
echo " 若 session 開頭看不到「🟢 ISEP v… 已載入」,就是這條也失敗了。"
fi
# 官方文件建議的私有 marketplace 認證寫法:只重寫這個 host 的 URL,不動其他 git 操作。
git config --global url."https://x-access-token:${GITEA_TOKEN_CLAUDE_CODE}@git.uncle6.me/".insteadOf \
"https://git.uncle6.me/"
# 用乾淨網址(不帶 token)加 marketplace,實際認證交給上面那條 URL 重寫。
claude plugin marketplace add https://git.uncle6.me/inkstone/ISEP.git --scope user
claude plugin install isep@inkstone --scope user
echo "✅ ISEP 已裝成 user-scope plugin,之後每個 session 啟動時直接生效。"
exit 0
+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 開頭被信標點名。
@@ -0,0 +1,193 @@
# 派工單與交件回覆的格式(共通規定)
> leo 2026-08-27`inkstone/ISEP#30` comment 4322 / 4325 / 4327):
> 「你用一個 **output parser** 把你給 subagent 的指令規範,**分作幾點,每一點規定格式**,
> **照這種散文寫法根本無法迭代**」/「**警察也不能抓**」/
> 「**交件方式不需要寫,定義在原則裡,每張票都要做這件事⋯⋯每次都一樣提取出來變成共通規定**」/
> 「(那些 session 事實)**這些為什麼不寫到票裡?**」/「**subagent 回覆時要表明身份**」
**本檔就是那份「共通規定」。** 它不是給人讀完記住的——
`hooks/dispatch-format-guard.sh` 會把 §2 自動注入每一次派工,
所以收工方**沒讀派工單也會拿到**。
---
## 1. 派工單 票號。就這樣
```
【工單】inkstone/ISEP#30 → comment 4322
```
要帶兩張票就兩行 `【工單】`。**沒有第二個欄位。**
### 為什麼
派工單裡想寫的東西只有兩種,**兩種都不該留在派工單**:
| 種類 | 舉例 | 該住哪 |
|---|---|---|
| **每次都一樣** | 交件方式、不要 push main、org 是 `inkstone`、先讀該 repo 的 CLAUDE.md | **本檔 §2**(機器自動注入) |
| **這次才知道** | `main` 現在是哪顆、今天撞過什麼、另一條線正在動同一個 repo | **寫進那張票** |
**判準一句話:「這句話換一張票還成立嗎?」**
還成立 ⇒ 共通規定。只有這次成立 ⇒ 寫進這張票。**兩種都不進派工單。**
🔴 **「票上還沒有」不是把它寫進 prompt 的理由——它是「去把它寫上票」的指令。**
寫進 prompt 的後果:那個 agent 被停掉或換人接手,那段事實就隨 prompt 消失。
2026-08-27 實害:總管停掉重派 3 次,前兩次的任務與 session 事實**全部蒸發**。
### 機械閘
`hooks/dispatch-format-guard.sh`PreToolUse `Task``Agent`)——
`【工單】` 以外還有實質內容就擋,並指出那些內容該搬去哪一格。
判準是**「這一行是不是【工單】欄位」**(在不在),不是「它寫了什麼」。
---
## 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 原文注入,改這裡=改所有派工 -->
### 你收到的派工單只有一個票號。任務全文在票上。
1. **第一個動作是去讀那張票**(含每一則 comment)。派工單不會再給你別的東西——
這是刻意的:票活得比任何一個 agent 久。
2. **票上的脈絡不夠 ⇒ 回票上問**,不要憑猜測動手,也不要回頭問派工的人要細節。
3. **交件=貼回那張票**`scripts/ticket say <owner/repo#N> -F <檔>`),
不是只在對話裡回報。回覆第一行必須是身份欄,見下。
4. **回覆第一行一律是**
`【身份】<你的名字><owner/repo><你的分支>`
名字用機器注入給你的那個(`isep-hand``scout`…);沒有名字時才退回
`總管``subagent``leo` 三選一。**票上要看得出是哪個工人做的**,
而三條線並行時「subagent」這個字回答不了「是誰」。
5. **不准 push 到 `main``master`**,也不准部署 prod。做在自己的分支上,交回分支名。
6. **Gitea 的 org 是 `inkstone`**(不是 `Leo`);`gh` 打不到 Gitea
標籤是 `s/*` 不是 `status/*`
7. **先讀你要動的那個 repo 的 `CLAUDE.md` 與 `system-dev/wiki/`**,照它的慣例走,
不要照你自己習慣的做法。
8. **交出去之前,你要知道它能不能用**——貼實測輸出,不是「我測過了」。
有一格沒驗 ⇒ 那是 report 不是 deliver,講清楚哪一格。
9. **改了會被載入的東西(pluginworkerbundle)就要升版**
否則產物按版本號分資料夾,你的改動到不了任何人手上。
<!-- INJECT:END -->
> 🔴 **這一段是「每次都一樣」的唯一真相源。**
> 想在派工單裡加一句叮嚀之前,先問:它換一張票還成立嗎?
> 成立就加在這裡(改一次,全機生效),不要加在那一次的 prompt 裡。
---
## 3. 交件回覆 = 第一行表明身份
```
【身份】subagentinkstone/ISEPfeat/ticket-carries-the-task
```
- 角色是**允許清單**`總管``subagent``leo`**再加上 `agents/` 名單上的每一個工人名字**
`isep-hand``arcrun-hand``scout`…,inkstone/ISEP#86)。清單以外的名字一律擋。
- 第二格是你動的 repo,第三格是分支(沒有就寫 `-`
🔴 **有名字就用名字。** 三條線並行時三則「【身份】subagent/…」長得一模一樣,
等於沒有身份——這正是 ISEP#86 要解的那件事。
### 為什麼
2026-08-27 實害:多條線並行時票上的留言看不出身份,
**總管寫的診斷被當成 subagent 的結論,而其中一則是錯的**
🔴 **這條管所有人,不是只管 subagent。** 總管寫在票上的東西同樣要標
`【身份】總管/…`——leo 要分得出哪一則是誰寫的。
### 機械閘(兩道,因為這個動作有兩條路)
| 路 | 閘 |
|---|---|
| 正門 `scripts/ticket say` / `decide` | 腳本內建檢查,貼上去之前就擋 |
| 側門 直接打 Gitea API 貼 comment | `hooks/reply-identity-guard.sh`PreToolUse `Bash` |
> 只封正門的閘等於沒封——`ticket-api-bypass-guard.sh` 的檔頭已經記過這一課:
> 「規範有、閘也有,但閘長在『工具』上,而那個動作有兩條路,只封了一條。」
---
## 4. 這份規範自己怎麼被驗
```
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 次正確攔截,而且**紅線寫得越細,命中關鍵字的機率越高 ⇒ 那些閘在懲罰謹慎**。
+189 -2
View File
@@ -274,9 +274,9 @@ s/triage ──驗傷通過──▶ s/backlog ──排進 milestone──▶ s
| E9 | 留言變成推理長文 | hook:超長**不 reject**,要求移去 wiki 再留指標 | ❌ 待建(放寬版,見 §8.3) |
| E10 | 就地修改治理檔案 | 安裝目錄唯讀 + hook,導向 ISEP 開 issue | ❌ 待建 |
| E11 | 本機雲端版本漂移 | SessionStart hook 比對版本,不一致 fail-fast | ◐ 現有 `skill-deploy-drift-guard.sh` 管的是 skill 不是 plugin 版本 |
| E12 | 宣稱交付但沒有 release | 版本三處一致閘:`plugin.json` tag release 存在;且 milestone open issues 0 不准打 tag | ◐ 現有 `delivery-police.sh``claim-verify-police.sh` 部分覆蓋 |
| E12 | 宣稱交付但沒有 release | 版本三處一致閘:`plugin.json` tag release 存在;且 milestone open issues 0 不准打 tag | ◐ 現有 `delivery-police.sh` 部分覆蓋(`claim-verify-police.sh` 已於 `ISEP#60` 移除,見下方腳註) |
| E13 | 審核被遺忘 | Stop hook**我自己開的** `s/review` 存在且**這回合完全沒碰它** → 擋一次 | ◐ 現有 `empty-handed-stop-guard.sh` 判準不同,兩者要合併不要疊加 |
| E14 | Subagent 空口宣稱完成 | SubagentStop hook | ✅ **已有** `subagent-claim-worksheet.sh` `empty-handed-stop-guard.sh` |
| E14 | Subagent 空口宣稱完成 | SubagentStop hook | ◐ 原本 `subagent-claim-worksheet.sh` `empty-handed-stop-guard.sh`;前者已於 `ISEP#60`2026-08-27)移除,見下方腳註 |
| E15 | 代理硬做只有人能做的事 | 代理端無對應 tool 或 token;唯一出口是開 `human/exec` 票 | ◐ 部分(credential 機制已擋一部分,D36 |
| E16 | 標籤漂移 | 走 Gitea labels API 依 `labels.yaml` 校正:缺的補、改的還原、多的**只告警不刪** | ❌ 待建(`ISEP#4` |
@@ -312,6 +312,21 @@ leo 2026-08-17 實測:文字層的閘那天 **8 次誤攔、0 次正確攔截*
**紅線寫得越細,命中關鍵字的機率越高 ⇒ 那些閘在懲罰謹慎。**
所以 E9 擋的是「長內容放錯地方」這個**動作**,處置是**導引**(移去 wiki 留指標),不是 reject。
### 8.4 E12/E14:自造的「待驗單」機制已移除,改由 §16 原生三格承接(`ISEP#60`
`subagent-claim-worksheet.sh`SubagentStop 產待驗單)與 `claim-verify-police.sh`
(Stop 攔收工,待驗單還在就擋)這一對於 2026-08-27 整組移除。
- **為什麼不是修它**:症狀是同一份待驗單重生三次、同內容兩個檔名;
但真正的問題是 D58——**待驗單是一張 markdown,沒有狀態、沒有持有人、沒有人會回頭看**,
必然退化成雜訊。Gitea 的 issue 三格本來就是為這件事做的。
- **它守的東西現在由誰守**:見 §16(`ISEP#59`/PR #58)——子票相依回答「這件事存在嗎/做完了嗎」、
`s/*` tag 回答「它卡在哪一段」、指派回答「現在誰該動」。三格合起來覆蓋原本 E14
想擋的「subagent 空口宣稱」與 E12 想擋的「宣稱交付但沒人跟進」。
- **現況誠實標記**:PR #58(§16 的落地)**尚未 merge**,所以 E12E14 這一格目前是
「舊機制已拆、新機制已提案但未生效」的過渡態,不是「已有替代品在跑」。
#58 merge 前,這兩條暫時回到 ❌ 待建的實質狀態,即使表格欄位寫的是 ◐。
---
## 9. 本規範的迭代
@@ -677,3 +692,175 @@ Gitea 的 milestone 只管得到同一個 repo,所以六個群在 `inkstone/IS
結構(正交) hub ← tracking issue 標記
封存不刪 duplicate ← 由 close/duplicate 取代,保留在歷史票上
```
### M4.8 每個里程碑都要有真的期限(leo 2026-08-21 立)
> 「**以後所有的 milestone 限制時間**」/「**你根本沒有時間概念,浪費一整天**」
🔴 **`9999-01-01` 不算期限。** 立這條的當下實查七個 open milestone
**六個的期限是 `9999-01-01`**——那是「沒有期限」穿了一件期限的衣服,
比空白更糟:盤點時每一格看起來都有值,於是沒有人發現這裡從來沒有時間壓力。
**怎麼定**:里程碑的 deliverable 是**一個可測的版本**M4.0)。
問一句「**這個版本幾號要能給 leo 打開?**」,那天就是期限。
| 剩幾張未結 | 期限 |
|---|---|
| 本週要收 | 三天 |
| 13 張 | 一週 |
| 4 張以上 | 兩週 |
**過期了怎麼辦**:不自動關、不自動打 tag(M4.3 已否決那條)。
過期只做兩件事——**對帳**(哪幾張沒動)與**通知**。
期限的用途是製造節奏,不是製造假完成。
**機械閘**`hooks/milestone-due-guard.sh`PreToolUse `Bash`):
建 milestone 沒有 `due_on`、或 `due_on` 帶 `9999` → 擋。
四向實測:無 due_on 擋/9999 擋/真期限放行/只是讀 milestone 放行。
---
## 16. 棒子不會掉:三個 Gitea 原生欄位,全部都要(leo 2026-08-27 立)
> leo 原話:「**不對,子票相依是 gitea 原有機制,改 tag 和指定也是,這些全部都要**」
> 「可以在討論串延伸子票,它完成了子票就完工,不然這筆任務就是沒完工⋯⋯**票沒完工可以察覺嗎?**」
> 「**每個任務結束必須要指派回總管**,要說明下一步怎麼做,直到最後交出正確 deliverable 並且驗證」
### 16.0 這一節在解什麼(可重現的掉棒時序)
```
08-26 13:00 一條線交回 arcrun-rag#136 comment 4267
「Phase 1 的驗收在雲端那半出貨之前驗不了」
← 一根帶著明確解除條件的接力棒,但它只是一段文字
08-26 18-23 總管把雲端那半併進 main、出 1.4.56、推 prod
← 解除條件在這裡被滿足了,沒有任何東西知道
08-27 11:xx leo 問「這個 14 小時前的,你派了還是沒派?」
← 14 小時後才發現棒子在地上
```
當時那張票:**沒有子票、tag 沒動、沒有指派給任何人**——三格全缺。
🔴 **根因不是誰忘了,是那件事沒有落在任何一個「撈一次就看得到」的欄位上。**
### 16.1 三個維度正交,缺一個就會掉
| 欄位 | 它回答的問題 | 缺了它會怎樣 |
|---|---|---|
| **子票 + 相依** | **這件事存在嗎?做完了嗎?** | 任務藏在討論串,撈 open 票看不到它 |
| **tag`s/*`)** | **它現在卡在哪一段?** | 撈得到票,但每次都要重讀整串才知道在等什麼 |
| **指派(assignee** | **現在誰該動?** | 知道有事、知道卡在哪,但**沒人認領** |
三個都是 Gitea 原生欄位,**都不必自造**(D58:充分利用 gitea 的機制)。
### 16.2 子票相依是**硬的**,不是提醒(2026-08-27 實測,Gitea 1.26.4
```
POST /repos/inkstone/ISEP/issues/56/dependencies {"owner","repo","index":57} → 201
GET /repos/inkstone/ISEP/issues/56/dependencies → [(57,'open')]
PATCH /repos/inkstone/ISEP/issues/56 {"state":"closed"} ← 子票還開著
→ HTTP 412
{"message":"cannot close this issue or pull request because it still has open dependencies"}
關掉 #57 → 再 PATCH #56 state=closed → 201,母票 state=closed
```
⇒ **子票沒關,母票關不掉。這是平台保證,不是自律。**
畫面上也看得到:母票側欄出現 `Depends on → #57 …(inkstone/ISEP`。
**跨 repo 也成立**(實測 `ISEP#56` 依賴 `arcrun-rag#136` → 201
⇒ 這正是跨 repo 的載體:milestone 只管得到同一個 repo,**相依管得到全部**。
📌 探針票留在 `inkstone/ISEP#56``#57`(已關,掛 `type/chore`,票上有實測全文)。
**順帶一個規範缺口**`close/*` 七類(§5)**沒有一類適合「實測探針」**,
而 Gitea 的 issue `DELETE` 回 403(只有管理員能刪,已實測)
⇒ 探針票刪不掉也歸不了類。要嘛補一類 `close/probe`,要嘛規定實測一律開在
拋棄式 repo。**這一格本節不裁,留給 §5 的維護者。**
- **R16.2.1** 順序/阻擋關係一律用相依(同 R2.5),**禁止只寫文字**。
- **R16.2.2** 相依掛不上去 = 那張子票對母票是隱形的 ⇒ **視為整個動作失敗**,要當場報錯。
### 16.3 粒度:**傾向多開票,不是少開票**
> leo 2026-08-27(當場更正總管寫反的那條):
> 「**小任務做完就關閉,不會讓池子爆掉,每個事情沒有歷史記錄才是大問題**」
```
這件事需要被記得嗎?(做過什麼、為什麼、量到什麼)
需要 → 開票。做完就關。
不需要 → 才不開
```
🔴 **不要用「會不會太多」當判準。** 池子爆掉的前提是「開了不關」——
那由 **做完就關** 解決,不該由 **不開票** 解決。
三條線不衝突,順序是:
1. **要不要有票**(本節)——傾向要。判準:這件事需要被記得嗎。
2. **要不要拆成獨立票**(既有規約)——判準:「票的刀口不是大小,是**狀態**:
它會不會需要跟隔壁那條不同的狀態?」
3. **會不會變成池子**——由「做完就關」解決,不由前兩題解決。
📌 leo 2026-08-16「我不要把所有的票都放在一個 issues,不然就難 track 歷史記錄」
與本節同一個目的:**可追蹤的歷史**。全塞一張 ⇒ 糊成一團;藏在討論串/prompt ⇒ 根本沒有歷史。
### 16.4 tag 與相依的關係:**流程狀態 vs 結構關係,兩個軸**
- `s/*` 是**這張票走到哪一格**(§3.0 的七態,exclusive)。
- 相依是**這張票和別張票的結構關係**。
- 兩者不衝突,也不能互相取代:
- 一張票可以 `s/doing` 而且有未關的相依(它自己有事在做,只是還關不掉)
- 一張票可以 `s/pending` 而且沒有相依(它卡的是票池外面的東西,例如等 leo 開閘)
- **R16.4.1** 卡在**別張票**上 ⇒ 掛相依 **並** 轉 `s/pending`。
只掛相依不改 tag = 看板上它看起來還在動;只改 tag 不掛相依 = 母票關得掉。
- **R16.4.2** `Human` 仍是正交 overlay(§6.1),與本節三格互不取代。
### 16.5 指派:棒子的持有人,**是欄位不是文字**
可指派的身份只有兩個(2026-08-27 實查):`Leo`、`claude-code`。
**總管就是 `claude-code`**(所有票都是這個 token 寫的),不必新增任何身份。
- **R16.5.1(收工)** 任何一條線收工 = **指派回 `claude-code` + 改 tag + 寫下一步**,
三件事是**同一個動作**`scripts/ticket handback`)。
- **R16.5.2(要 leo 做)** 指派 `Leo` 掛 `Human`(§6.1、既有交棒規約)。
- **R16.5.3(終點)** 棒子有終點——leo 的原話是「**直到最後交出正確 deliverable 並且驗證**」。
驗完 ⇒ 關票、清指派 ⇒ 它從所有人的待辦裡消失。
**不要讓票變成永遠在傳的燙手山芋。**
- **R16.5.4(撈一次)** 「哪幾根棒子在誰手上」用 `scripts/ticket mine` 撈,
掛在**本來就會做的動作**上(session 開場、收工對帳)。🔴 **禁止排程輪詢**(D20)。
### 16.6 既有的幾十張票怎麼辦:**只從今天起適用,不回頭補**
- **R16.6.1** 不做全面回填。理由:回頭讀幾十張票的每一則 comment 是 O(票數×留言數)
那正是本節說「人與 AI 都不會做」的那個動作——**用一個不會被執行的規定去補一個漏洞,等於沒補。**
- **R16.6.2** 改成**碰到才補**:任何人(或閘)下一次動到某張票時,
順手把它討論串裡還活著的待辦長成子票。**動到才補,成本落在本來就要付的那次。**
- **R16.6.3** 例外:`hub` 票(§1)**要補齊**——它們本來就宣稱自己是聚合容器,
而 §3.2「子票全關才能關 hub」在沒有相依的情況下是**靠自律**的。
掛上相依之後,那條規則變成平台保證。
✅ **2026-08-27 實查:六張 hub 全部已經掛好了**,且數量與各自內文列的來源票一致——
`ISEP#30` 6`#31` 7`#32` 11`#33` 5`#34` 8`#35` 7(跨 `InkStoneCo``Arcrun`
`arcrun-rag``system-dev-template` 四個 repo)。**這一格不用補,本條是為了讓它不再退化。**
### 16.7 工具與機械閘
```
scripts/ticket subtask <母票> --title <User Story> -F <檔> [--label] [--assign X --next "<一句>"]
討論串裡的一件事 → 子票 + 回鏈 Parent: + Gitea 原生相依(三格一次到位)
🔴 `--assign` 一定要配 `--next`:有人被指到票上,他撈到它的第一件事就是問
「所以我要做什麼」——那句話開票當下就要寫,不是等他重讀整串
scripts/ticket handback <票> --to <claude-code|Leo> --next "<一句話>" [--label] [--evidence]
收工 = 指派 + 改 tag + 寫下一步,一個動作三件事
scripts/ticket mine [--user <誰>]
撈一次:棒子在誰手上、每根的下一步、每根還有幾張未關的相依
```
| 閘 | 掛在哪 | 抓什麼 | 測試 |
|---|---|---|---|
| `hooks/comment-carries-task-guard.sh` | PreToolUse `Bash` | 留言裡帶「等 X 才…/驗不了/還沒…」這種**未完成的未來式**,卻沒開子票 → 擋一次 | `scripts/test-comment-carries-task-guard.sh`22 條) |
| `hooks/baton-handback-guard.sh` | PostToolUse `Agent\|Task` | 一條線收工,票上**指派/tag/下一步**缺哪一格 → 當場說出來(提醒,不擋) | `scripts/test-baton-handback-guard.sh`10 條) |
🔴 **兩支閘的誤攔設計**`comment-carries-task-guard` 同一 session 只擋一次,
關鍵詞刻意取窄,並留 `no-subtask: <理由>` 逃生口(留痕)。
理由寫在 `.claude/branch-holds.md` 檔頭那句:
**永遠在響的警報,等於訓練人忽略這個警報。**
🔴 **hook 裡比對中文一律用 `python3` 的 `re`,不要用 `grep` 的字元類**
2026-08-27 實撞:`等[^。]{0,20}出貨` 在真實 hook 呼叫路徑下對
「等雲端那半出貨」不匹配,閘靜默失效;換成 python3 後測試由 19/20 變 20/20)。
+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**——名單是資料,閘讀的是這個目錄。
+197 -16
View File
@@ -1,4 +1,4 @@
# 43 支閘,白話盤點表
# 60 支閘,白話盤點表
> 回應 `inkstone/InkStoneCo#40`:「如果加入了,我應該可以白話文看到 hooks 的內容?」
> 這份表就是那個「白話文」——不用點開任何 `.sh` 檔,一行看懂一支閘在管什麼。
@@ -7,10 +7,91 @@
## 一句話結論
`hooks/` 底下有 **43 個 `.sh` 檔**`hooks.json` 實際掛上 **53 條註冊**(同一支閘常被多種情境同時掛上);
`hooks/` 底下有 **60 個 `.sh` 檔**`hooks.json` 實際掛上 **82 條註冊**(同一支閘常被多種情境同時掛上);
其中 **3 支檔案存在但沒被掛上**(2 支是待人填的空範本、1 支是刻意留著沒開的止血帶,見下面「未生效」表)。
下面按「你會在什麼時候撞到它」分組,一支一行。
> 🔴 **這兩個數字上一版是錯的(2026-08-26 實際數過才發現)**:本頁原本寫「43 個檔、53 條註冊」,
> 而當時真實是 **45 個檔、55 條註冊**——中間有兩支閘進來時沒有回頭改這裡。
> 現在的寫法是實際數出來的:
> `ls hooks/*.sh | wc -l` 60`grep -c '"command":' hooks/hooks.json` 82。
> ⚠️ **冒號不能省**`grep -c '"command"'`(沒冒號)會連 `"type": "command"` 一起數到,回 **120**。
> 本頁 2026-08-27 之前寫的是沒冒號那版——**照著它跑會拿到一個跟本頁不符的數字**。
> **一份會偷偷過期的盤點表,跟沒有盤點表差不多**——見本頁最後「怎麼跟實況對帳」那段。
>
> 📌 **`0.6.0``inkstone/ISEP#59`2026-08-27)這兩個數字是併完之後當場數出來的**:
> `ISEP#60` 移除了 `claim-verify-police.sh``subagent-claim-worksheet.sh` 這對自造機制(−2 支、−16 條),
> `ISEP#58` 補進 `comment-carries-task-guard.sh``baton-handback-guard.sh`(+2 支、+8 條),
> 加上 `ISEP#72` 的 `reply-identity-guard.sh` 已在 main 上。
> 一加一減之後檔數不變、註冊條數落在 59——**這正是「不能靠加減推算、只能實數」的例子**:
> 上一版寫 58,就是把兩批改動的加減兜出來、沒有真的數過。
>
> 📌 **`0.10.0``inkstone/ISEP#63`2026-08-28)+1 支、+3 條**
> `countdown-guard.sh` 一支同時掛在 `UserPromptSubmit`(注入倒數)與 `Stop`(查核有沒有戴),
> 加上 SessionStart 多掛一條 `scripts/countdown-milestone-refresh.sh`(更新期限快取,不是閘)。
> **兩個事件是同一件事的兩半**:只注入 = 又一個會被忽略的提醒;只查核 = 罰它做一件拿不到資料的事。
> 這兩個數字是併之前當場數出來的,不是從上一版加減推出來的。
> 📌 **`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`。
>
> 📌 **`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**。
---
## 怎麼讀這張表
@@ -37,8 +118,13 @@
| `not-my-branch-guard.sh` | AI 想 commit 到「別的 subagent 正在施工中」的分支(登記在 `branch-holds.md`)就擋下,避免兩個 AI 互踩同一條分支。 | 🛑 擋 |
| `leo21c-write-guard.sh` | AI 想寫東西到你私人帳號 `leo21c` 就擋下——這個帳號只准你自己用,AI 的實驗一律走別的測試帳號。 | 🛑 擋 |
| `release-tag-guard.sh` | AI 想打版本 tag,但 `plugin.json` 裡寫的版本號跟 tag 對不上就擋下——避免「這裡說 0.2.0、那裡說 0.1.0」各說各話。 | 🛑 擋 |
| `ticket-api-bypass-guard.sh` | AI 想繞過 `scripts/ticket` 工具、直接打 API 新 Gitea 票(會漏掉「開票前先搜過」這道檢查)就擋下。 | 🛑 擋 |
| `ticket-api-bypass-guard.sh` | AI 想繞過 `scripts/ticket` 工具、直接打 API 新 Gitea 東西(開新票/milestonelabelPR會漏掉「新增前先搜過」這道檢查)就擋下——不管是隱式 POSTurllib 傳 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
| 閘名 | 對你意味著什麼 | 動作 |
@@ -51,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 組)——它同時守著「下指令」跟「寫檔案」兩種情境,詳見下方「重複掛載」一節。
@@ -70,12 +157,32 @@
| `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
| 閘名 | 對你意味著什麼 | 動作 |
|---|---|---|
| `ask-user-question-guard.sh` | AI 要跳出來問你一個問題的**那一刻**先攔一下,用小模型(haiku)照「四題公式」判這題該不該打擾你:**花錢/不可逆/跨專案結構/品味方向/只有你做得到**——命中任何一題就放行(那本來就該問你),四題全否(純技術實作選擇、問「要不要開始」)就擋回去要它自己裁。**同一個問題只擋一次**,它重送就過得去,所以判錯不會害你收不到問題;判官掛掉/沒網路也一律放行。 | 🛑 擋(同一題至多一次) |
> **為什麼要有這一組**leo 2026-08-26:「今天已經好幾次問我,**為什麼 hooks 沒有攔下來?**」):
> 在這之前 `AskUserQuestion` 在 `hooks.json` 裡出現 **0 次**,一支閘都沒掛。
> F 組那兩支自走警察(`self-drive-police` / `self-drive-judge`)判準一樣,
> 但它們掛在「收工」那一刻——**問題早就送到你眼前了**,事後再問 AI「你查過了嗎」已經來不及。
> 這一組補的是**時機**,不是判準。
## E. 每個對話一開始(SessionStart
| 閘名 | 對你意味著什麼 | 動作 |
|---|---|---|
| `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)
@@ -84,22 +191,27 @@
|---|---|---|
| `empty-handed-stop-guard.sh` | 這一輪 AI**一個動作都沒做**卻想停下來(等你回覆),就擋下並告訴它「你的命令就是完整授權,不用再等第二次確認」。 | 🛑 擋(至多攔一次) |
| `worklist-guard.sh` | AI 自己列過的待辦清單裡還有沒做完的步驟,卻想收工寫報告,就擋下,逼它做完剩下的步驟。 | 🛑 擋 |
| `factory-idle-guard.sh` | AI 該去派工卻沒派(工頭停工),就擋下要它交出「已經派工的憑證」,不是隨口說一句「我會催」就算數。 | 🛑 擋 |
| `factory-idle-guard.sh` | AI 該去派工卻沒派(工頭停工),就擋下要它交出「已經派工的憑證」,不是隨口說一句「我會催」就算數。**過閘有四條路:現在就派工/把票號寫進那句話/寫一行 `⏸ 等:<在等什麼>`/這一輪收尾在動作上。** 2026-08-23inkstone/ISEP#30)修好「引用被當成主張」——貼原始碼、引用它自己的訊息、否認自己有下一步,都不再被咬。 | 🛑 擋 |
| `mainline-idle-guard.sh` | AI **連續 4 個回合一直在查、一直在讀,卻什麼都沒交出去**(沒派工、沒改檔、沒寫回票)就擋一次。補的是上面兩支中間那一格:「沒動作」空手警察管、「說了下一步卻沒派」稼動率警察管,**而「有動作、但就是不說下一步」以前兩支都放行**。這支**一個字都不讀**,只看有沒有派工/產出/工作區變動。響過一次門檻就加倍(4→8→16),不會變成一直在響的警報。 | 🛑 擋(響過就退讓) |
| `pr-verdict-guard.sh` | 收工時清點「還有哪些 PR 沒有結論」——open、**沒有人被指派**、也沒有「要求修改」review 的 PR,就擋一次並**點名是哪幾個**(含開了幾天與網址);已經 merge 但**分支還留著**的也一起點名(票上寫死「併完當場刪掉那條 branch」)。背景:2026-08-27 實查,最久的三個 open PR 躺了兩星期,而當時 46 支閘沒有一支在管 PR——票看起來「已交付」,東西卻沒進 main、沒進版本,**leo 手上永遠不會出現它**。🔴 **擋的是遺忘,不是等待**:指派給誰(或掛 Human)就立刻不再點名它;響過門檻就加倍(1→4→8→16,每個 PR 各自算)。搭配 `scripts/pr-verdict`mergerejectchanges 各一個動作)。 | 🛑 擋(響過就退讓) |
| `countdown-guard.sh` | **每一則回覆都自己說出「這件事已經花了多久/距今天收工線還剩多久」**,而且不必有人記得。一支閘掛兩個事件:你送出訊息時它先把算好的那一行放到 AI 眼前(`UserPromptSubmit`),AI 想收工時它再看這一回合的回覆裡到底有沒有那一行,沒有就擋一次要它補(`Stop`)。**只做前半 = 又一個會被忽略的提醒**,所以兩半綁在一起。已過時間從**這段對話的第一則訊息**算起(=一個 release 的起點);收工線是台北 16:00,過線後四小時內顯示「已超過」,不會把超時悄悄換算掉。子 session、純工具回合、已經提醒過一次的,全部放行。**同一個注入點還帶第二行 `🎯 現在的主線是哪一個`**`inkstone/ISEP#82`)——那一行沒有另外立閘,因為主線的名字本來就掛在被查核的 ⏱ 那一行上。 | 🛑 擋(至多攔一次) |
| `browser-verify-guard.sh` | 這一輪 AI 宣稱「前端驗過了」,卻沒有真的用瀏覽器工具載入過,就擋下——`curl` 抓到 HTML 不算驗過。 | 🛑 擋 |
| `self-drive-police.sh` | AI 想停下來問你「早就決定過的事」(用固定句型判斷,例如「要不要 X」「下一步做什麼」「這交給你」)就擋下,反問它查過 wiki/查過派工表了沒。 | 🛑 擋 |
| `self-drive-judge.sh` | 跟上面同一件事,但改用小模型(haiku)判斷「換句話說」的請示句——防止 AI 只是把「要不要」改寫成「不確定是否符合期待」就閃過上一支閘。 | 🛑 擋 |
| `delivery-police.sh` | AI 宣稱「這件事做完了」,卻看不到任何實測證據(畫面截圖、指令輸出、HTTP 狀態碼…)就擋下。 | 🛑 擋 |
| `wiki-first-police.sh` | AI 做完事卻沒有把結論寫回 wiki 就想收工,就擋下——下次(或別的 AI)查 wiki 會查不到這次做過什麼。 | 🛑 擋 |
| `unpushed-police.sh` | AI 改好的東西還留在本機、沒有真的推送出去給別人用,卻想收工,就擋下——「改對了但沒送到」跟沒改是一樣的。 | 🛑 擋 |
| `claim-verify-police.sh` | Subagent 交回來的「我做完了」宣稱還沒被驗證過(對應的待驗檔案還在),你這邊卻想收工,就擋下。 | 🛑 擋 |
> 🔴 **`claim-verify-police.sh` 已於 `inkstone/ISEP#60` 移除**(連同下面原本的 G 組 `subagent-claim-worksheet.sh`)。
> 這一對是自造的「待驗單」機制(一張沒有狀態、沒有持有人的 markdown),違反 D58
> 不要硬做平台不支援的機制。它守的情境現在改由 Gitea 原生三格承接
> (子票相依/`s/*` tag/指派——見 `inkstone/ISEP#59` 與 PR #58)。
## G. Subagent 把工作交回來的時候(SubagentStop
| 閘名 | 對你意味著什麼 | 動作 |
|---|---|---|
| `subagent-claim-worksheet.sh` | Subagent 一交回工作,就自動把它宣稱做了什麼寫成一張「待驗清單」檔案——之後總管收工前,`claim-verify-police.sh` 會檢查這張清單有沒有被處理掉。 | 📝 記錄 |
> 這個時機還掛了 `worklist-guard.sh``self-drive-police.sh``self-drive-judge.sh``delivery-police.sh``wiki-first-police.sh``unpushed-police.sh`,行為跟上面 F 組完全一樣,只是對象換成「subagent 交回來的這一輪」。詳見「重複掛載」一節。
`inkstone/ISEP#60` 之後,這個時機**沒有專屬掛載的閘**了(原本唯一的一支 `subagent-claim-worksheet.sh` 已移除)。
仍會掛到這個時機的是 `worklist-guard.sh``self-drive-police.sh``self-drive-judge.sh``delivery-police.sh``wiki-first-police.sh``unpushed-police.sh`
行為跟上面 F 組完全一樣,只是對象換成「subagent 交回來的這一輪」。詳見「重複掛載」一節。
## H. 動作做完之後,純粹記一筆(PostToolUse,全部不擋)
@@ -108,7 +220,9 @@
| `kbdb-asked-stamp.sh` | AI 真的查過 KBDB 之後,留一個時間戳——給前面 `history-first-guard.sh` 判斷「這輪有沒有先查過」用。 | 📝 記錄 |
| `subagent-first-stamp.sh` | AI 真的派過工之後,留一個時間戳——給 `subagent-first-guard.sh` 判斷用。 | 📝 記錄 |
| `issue-status-autoflip.sh` | AI 一派工出去,就自動把對應的 Gitea 票改成「進行中(s/doing)」,不必等人手動改標籤。 | 📝 記錄(自動改票) |
| `baton-handback-guard.sh` | 一條派工線做完了,就去看它那張票有沒有**指派給人/標籤有沒有說它卡在哪/有沒有寫下一步**,缺哪一格就當場說出來。你關心的是:不會再有票做完了卻沒人接手,躺在那裡沒人發現。 | 📝 記錄(提醒,不擋) |
| `kbdb-evidence-stamp.sh` | AI 每次去知識庫拿資料,就記一筆「這是**搜尋**搜到的,還是**走正規檢索路徑**拿到的」。你關心的是:它等一下要拿這筆資料下結論時,有東西可以對照它走的是哪條路。 | 📝 記錄 |
| `investigate-first-stamp.sh` | 你真的**派人去查某張票**之後,按「session + 票號」留一個時間戳——給 `diagnosis-evidence-guard.sh` 判斷「這件事有沒有人查過」用。按票分而不是按 session 分:早上派人查了 A 票,不該等於解鎖了 B 票的診斷。 | 📝 記錄 |
## I. 你會撞到但跟「派工/收工」無關的一支(Edit·MultiEdit
| 閘名 | 對你意味著什麼 | 動作 |
@@ -141,12 +255,74 @@ 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` 兩列):
```
$ ls hooks/*.sh | xargs -n1 basename | sort > /tmp/inv_fs.txt
$ grep -oE '^\| `[a-zA-Z0-9_.-]+\.sh`' docs/hooks-inventory.md \
| grep -oE '[a-zA-Z0-9_.-]+\.sh' | sort -u > /tmp/inv_table.txt
# 有這支閘、但這頁沒有它那一列 → 要補人話
$ comm -23 /tmp/inv_fs.txt /tmp/inv_table.txt
(無輸出)
# 這頁列了、但檔案已經不在了 → 要刪那一列
$ comm -13 /tmp/inv_fs.txt /tmp/inv_table.txt
(無輸出)
```
**這次順便抓到的另一個落差**`.claude-plugin/plugin.json` 的說明文字寫「42 支機械閘(52 條註冊)」,
🔴 **`^\| \`` 這個錨點不能拿掉**——它的意思是「**這一行的第一欄就是這支閘**」,
也就是「表格真的列了它」。把錨點拿掉、改成抓全頁所有反引號裡的 `*.sh`
反向那半會吐出 **3 個假警報**(2026-08-27 實跑):
```
claim-verify-police.sh ← 出現在「已於 ISEP#60 移除」那段**移除紀錄**裡
subagent-claim-worksheet.sh ← 同上
github-arm.sh ← 它在 `scripts/`,不在 `hooks/`;是 github-contact-guard
那一列裡告訴你「怎麼解鎖」的逃生口
```
**三個都不是幽靈,三個都不該刪**:刪掉前兩個等於刪掉「G 組為什麼是空的」這段歷史,
刪掉第三個等於讓那一列不再告訴你怎麼過閘。
⇒ **0 次正確攔截、3 次誤攔**——照這個訊號去改,會把好東西改壞。
那「頁面上出現了一個根本不存在的檔名」怎麼辦?**那要另外一格量,而且判準不是文字是事實**:
```
# C. 頁面提到的每個 *.sh,現在存在,或者 git 歷史裡存在過
$ for n in $(grep -oE '`[a-zA-Z0-9._-]+\.sh`' docs/hooks-inventory.md | tr -d '`' | sort -u); do
[ -f "hooks/$n" ] || [ -f "scripts/$n" ] && continue
[ -n "$(git log --all --oneline --diff-filter=AD -- "hooks/$n" "scripts/$n")" ] && continue
echo "$n"
done
(無輸出)
```
**「現在沒有、但曾經有」=歷史,合法;「從來沒有過」=打錯字或憑空捏造,才是幽靈。**
這一格問的是 git,不是問這頁的用字,所以它不會因為誰換個寫法就失效。
📌 **三格都要跑,而且它們量的是不同東西**:
A1 缺人話、A2 表格有幽靈、C 全頁有幽靈。
**標頭的 48/59 對不代表這頁沒漏**——補進 2 支真的、同時留著 2 支假的,數字照樣湊得到 48。
**數量相等不等於集合相等**,所以驗的一律是集合(`comm`),不是 `wc -l`。
**這次順便抓到的另一個落差**(🕐 **這一段是 2026-08-20 的快照,裡面每個數字都是那天的,不是現況**——
留著是因為它是「兩份文件的數字不會自己跟上」這個病的第一個案例;現況數字看本頁標頭):
`.claude-plugin/plugin.json` 的說明文字寫「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` 這批(檔頭日期都是今天)加了之後,兩份文件的數字沒有跟著更新。
@@ -165,6 +341,7 @@ $ comm -13 /tmp/fs_hooks.txt /tmp/registered_hooks.txt
| `arcrun-intent-guard.sh` | 2 次 | 一次守「所有寫檔案」,一次專門加強守「呼叫 Arcrun 部署/驗證工具」這個更精準的情境 |
| `micromanage-guard.sh` / `irreversible-dispatch-guard.sh` / `no-ticket-no-dispatch.sh` | 各 2 次 | 各自同時掛在 `Task` 與 `Agent` 兩個矩比對名稱上——這兩個名稱應該是同一種派工動作的新舊叫法,兩個都掛保證不漏接 |
| `worklist-guard.sh` / `self-drive-police.sh` / `self-drive-judge.sh` / `delivery-police.sh` / `wiki-first-police.sh` / `unpushed-police.sh` | 各 2 次 | 一次守「總管自己想收工」(Stop),一次守「subagent 交回工作」(SubagentStop)——同一套判準用在兩種角色身上 |
| `search-is-not-proof-guard.sh` | 3 次 | 拿搜尋結果當證據的那個動作,可能是「貼進票裡」(Bash),也可能是「寫進派工單發包出去」(`Task``Agent` 兩個名稱各掛一次),三種都要接住 |
❓ **這裡有一件我看不出來是刻意還是遺留、需要人判斷**:`micromanage-guard.sh``irreversible-dispatch-guard.sh``no-ticket-no-dispatch.sh`
三支都**同時**掛在 `Task` 跟 `Agent` 這兩個矩比對名稱上。如果這兩個名稱在目前版本的 Claude Code 裡指的是「同一種派工工具呼叫」,
@@ -201,12 +378,16 @@ $ comm -13 /tmp/fs_hooks.txt /tmp/registered_hooks.txt
- **`micromanage-guard.sh``irreversible-dispatch-guard.sh``no-ticket-no-dispatch.sh` 的雙重矩比對(`Task` + `Agent`)是刻意保險還是舊名稱沒清掉**——見上面「重複掛載」段落,我沒有把握單靠讀檔案判斷,需要看實際觸發紀錄或問總管。
- **`stage-before-prod-guard.sh` 跟 `main-and-prod-push-guard.sh` 的分工邊界**`main-and-prod-push-guard.sh` 的檔頭明講自己是在「補 `stage-before-prod-guard.sh` 的破口」(那支只認 3 個關鍵字,抓不到 `wrangler deploy`),但兩支都還掛著、都還在管「prod 出貨」這件事。這是「新的補洞、舊的continua」還是「舊的該退休了」,這份盤點表不下判斷,留給你在下一步的分類會議裡定奪。
除了以上兩點,其餘 41 支的行為都能從檔頭與源碼直接讀出,沒有「猜」的部分。
除了以上兩點,其餘支的行為都能從檔頭與源碼直接讀出,沒有「猜」的部分。
> 📌 這句話原本寫「其餘 **41** 支」——那是 43 支閘那一版(`v0.2.1`)數的,之後沒有人重跑過。
> 2026-08-27 把數字拿掉而不是改成 46:**改成 46 等於宣稱我逐支重讀過源碼,而我沒有。**
> 要恢復一個數字,就重跑一次上面「抽驗」那件事,然後把當天的日期寫上去。
---
## 這份表怎麼跟實況對帳(半年後怎麼發現漂移)
1. **有沒有新閘沒被收進這張表**:跑本文「落差偵測」段落的兩行 `comm` 指令,比對 `hooks/*.sh` 的檔名清單跟這張表列出的閘名清單(不是跟 `hooks.json`,那個只驗證有沒有註冊,驗不了有沒有寫進這張人話表
1. **有沒有新閘沒被收進這張表**:跑本文「落差偵測」段落**第二組**`inv_fs` vs `inv_table` 的兩行 `comm`**加第三格 C**,比對 `hooks/*.sh` 的檔名清單跟這張表列出的閘名清單。**不是第一組**——第一組比的是 `hooks.json`只驗證有沒有註冊,驗不了有沒有寫進這張人話表。2026-08-27 漏掉的那兩列,第一組是綠的、第二組才紅。**抓表格列的那組 `grep` 一定要帶 `^\| \`` 錨點**,理由與拿掉之後會冒出的 3 個假警報,就寫在那一段裡
2. **有沒有閘的行為跟這裡寫的不一樣了**:抽幾支重新讀一次源碼,跟這裡的「一句話」對一遍——就是本文「抽驗 5 支」做的事,可以照同樣方法定期重做。
3. **`.claude-plugin/plugin.json` 與 `README.md` 的數字**:這兩處各自寫了一次「幾支、幾條註冊」,前面已經抓到一次對不上(42/52 vs 實際 43/53)。這兩個數字沒有機制保證跟著 `hooks/` 目錄自動更新,是本表發現的第一個具體漂移案例。
+212
View File
@@ -0,0 +1,212 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""arcrun 意圖工作流「回饋 hook」主體(D382026-08-01)。
由 arcrun-intent-guard.sh 呼叫(stdin = Claude Code 的 PreToolUse JSON)。
獨立成 .py 的理由:規則含大量引號與 regex,包在 shell 單引號裡會被吃掉
2026-08-01 實際踩到:'"componentId"' 被 shell 剝成裸字 → NameError)。
判準來源:system-dev/docs/3-specs/arcrun-usable/intent-rules.json
——與判分器共用同一份,避免兩套判準漂移。
"""
import json
import os
import re
import sys
def main():
try:
d = json.load(sys.stdin)
except Exception:
return 0
ti = d.get("tool_input") or {}
# 收集這次要送出的文字:檔案寫入 / 編輯 / bash 指令 / MCP 參數都看
parts = []
for k in ("content", "new_string", "command", "prompt"):
v = ti.get(k)
if isinstance(v, str):
parts.append(v)
for k in ("graph", "yaml", "workflow", "triplets", "body"):
v = ti.get(k)
if v is not None:
parts.append(v if isinstance(v, str) else json.dumps(v, ensure_ascii=False))
body = "\n".join(parts)
if not body.strip():
return 0
proj = os.environ.get("CLAUDE_PROJECT_DIR") or os.getcwd()
rules_path = os.path.join(
proj, "system-dev", "docs", "3-specs", "arcrun-usable", "intent-rules.json")
if not os.path.exists(rules_path):
return 0
R = json.load(open(rules_path, encoding="utf-8"))
rules = {r["id"]: r for r in R["rules"]}
LEGAL = re.compile(R["legal_edges"]["regex"])
# 只在「這確實是 arcrun 意圖工作流」時才出手,避免亂吵。
# 排除文件語境的雜訊(2026-08-01 自己編修 GUIDE 時被自己誤攔):
# markdown 表格列、引用行、註解行——教材列舉或引述錯誤寫法時必然含箭頭
def is_prose(l):
s = l.strip()
return (s.startswith("|") or s.startswith(chr(62) + " ")
or s.startswith("#") or s.startswith("//"))
triplet_lines = [l.strip() for l in body.split("\n")
if (chr(62) * 2) in l and not is_prose(l)]
# 2026-08-01 補漏:原本只認 JSON 圖(帶引號)⇒ **YAML 圖整份被放行**。
# leo 那次 haiku 失敗寫的正是 YAMLcomponentId: code,無引號),hook 完全沒攔到。
is_graph_json = ('"componentId"' in body) or ('"edges"' in body)
is_graph_yaml = bool(re.search(r'^\s*-?\s*componentId\s*:', body, re.M)
or re.search(r'^\s*edges\s*:', body, re.M))
is_graph = is_graph_json or is_graph_yaml
if not triplet_lines and not is_graph:
return 0
# 文件檔(.md)本來就會「引述錯誤寫法來教學」=R6 關鍵詞必然出現。
# 對 .md 只保留結構性規則,不做過時教材主張的字面偵測。
is_doc = str(ti.get("file_path") or "").endswith(".md")
# 🔴 2026-08-08:兩道 hook 直接打架,本閘誤攔 wiki 落帳。
# `wiki-first-police` **要求**我在 wiki 用知識三元組 `A >> 關係 >> B`
# leo 2026-08-01 立:「每件事牽涉到 2 個 repo,就可以在該事件查到那兩個 repo」)。
# 但本閘把任何含 `>>` 的行都當成 Arcrun 工作流的邊 ⇒ 落帳寫
# 「勸告治不了停 >> 所以改成 >> InkStoneCo:...」就被判「非法邊」+「第一個節點不是 input」。
# ⇒ 同一個符號、兩套約定。**.md 裡沒有真的工作流圖時,本閘不該有意見。**
# (有 componentId/edges 的 .md=真的在寫工作流文件,仍照常檢查。)
if is_doc and not is_graph:
return 0
# 🔴 2026-08-08 第二例:hook/腳本自己的原始碼裡出現 `>>` 是**正則樣式或 shell 重導向**,
# 不是 Arcrun 工作流的邊。實撞:wiki-first-police.sh 裡的
# `grep -qE '… >> 真身在 >> …'`(比對知識三元組用的樣式)被判「三元組格式錯」。
# ⇒ 機制程式碼一律豁免;真的工作流定義不會住在 .claude/hooks/。
_fp = str(ti.get("file_path") or "")
if "/.claude/hooks/" in _fp or _fp.endswith((".sh", ".py")):
if not is_graph:
return 0
hits = []
def hit(rid, detail):
if not any(h[0] == rid for h in hits):
hits.append((rid, detail))
COND = re.compile(
r"判斷|檢查|如果|大於|小於|是否|超過|比較|驗證|check|if_|ifcontrol|compare|verify|threshold",
re.I)
if triplet_lines:
edges_of = {}
parsed = []
for l in triplet_lines:
# 2026-08-05 修:先剝掉 YAML 清單語法再切段。
# 原本直接切,第一段會是 `- "input` 而不是 `input`
# => R1「第一個節點必須是 input」永遠判為不符
# => 任何對 flow: 區塊的編輯都被擋死(實撞:改 rag-ingest-card 三次全被擋,
# 而該檔 flow 本來就合法)。
l = l.strip()
if l.startswith("- "):
l = l[2:].strip()
l = l.strip('"').strip("'").strip()
seg = [p.strip() for p in l.split(">>")]
if len(seg) != 3:
hit("R5-triplet-format", l[:60])
continue
a, e, c = seg
parsed.append((a, e, c))
if not LEGAL.match(e):
hit("R2-illegal-edge", "非法邊「%s」(%s" % (e, l[:50]))
edges_of.setdefault(a, []).append(e)
if parsed:
# 2026-08-13 修(同一段的第二次誤攔修正,前一次見上方 08-05 註解):
# R1 問的是「**整個工作流**的第一個節點是不是 input」——
# 那個問題只有在**看得到整份檔案**時才答得出來。
#
# `Write` 送的是 `content`(整檔)⇒ parsed[0] 真的是第一個節點 ✅
# `Edit` 送的是 `new_string`(片段)⇒ parsed[0] 是**那段片段的第一行**,
# 而增量編輯必然要帶一行既有的錨點來定位插入點
# ⇒ **錨點永遠被誤判成「工作流起點」**,不管它實際排第幾。
#
# ⇒ 這會結構性地擋住**任何**對既有多行 `flow:` 的增量修改。
# 實撞(2026-08-13):往 `km_wiki_ingest.yaml` 的 flow 尾端接五行新邊,
# 錨點是 `decide >> 對每個 update_item >> update_entry`
# ⇒ 報「第一個節點是『decide』」。而該檔第一個節點叫 `seed`,
# 是檔案自己 §4 註解寫明的刻意設計(避開引擎的觸發保留字)。
#
# 🔴 只放寬 R1,**R2(邊合法性)/R3/R4/R5 對片段照樣生效**——
# 那幾條檢查的是內容本身,沒有「位置語意」的問題。
if isinstance(ti.get("content"), str):
first = parsed[0][0]
if first.lower() != "input":
hit("R1-first-node-input", "第一個節點是「%s" % first)
for a, outs in edges_of.items():
branchy = any(
o in ("ON_TRUE", "ON_FALSE") or o.startswith("ON_BRANCH") for o in outs)
if COND.search(a) and not branchy:
hit("R3-condition-via-on_success",
"%s」有條件語意,出邊卻只有 %s" % (a, "".join(sorted(set(outs)))))
# R4code 節點在做流程控制
LOGIC = re.compile(
r"\bif\s*\(|\belse\b|\bfor\s*\(|\bwhile\s*\(|\.filter\(|\.map\(|\?\s*[^:\n]{1,40}\s*:")
if is_graph:
m = re.search(r"\{.*\}", body, re.S)
if m:
try:
g = json.loads(m.group(0))
for n in (g.get("nodes") or []):
if n.get("componentId") == "code":
ctx = json.dumps(
n.get("config") or n.get("payload") or n, ensure_ascii=False)
if LOGIC.search(ctx):
hit("R4-code-node-doing-logic",
"code 節點「%s」內含流程控制" % n.get("id"))
except Exception:
pass
if not any(h[0] == "R4-code-node-doing-logic" for h in hits):
# 2026-08-01 補:原本只認 JSON(帶引號)與 `>> code`
# 但 leo 那次 haiku 失敗寫的是 YAMLcomponentId: code,無引號)⇒ 漏抓。
looks_code = re.search(r">>\s*(code|js|script)\b", body, re.I) or \
re.search(r'"componentId"\s*:\s*"code"', body) or \
re.search(r'^\s*-?\s*componentId\s*:\s*["\']?code["\']?\s*$', body, re.M) or \
re.search(r'^\s*-?\s*(id|name)\s*:\s*["\']?(code|js|script)["\']?\s*$', body, re.M)
if looks_code and LOGIC.search(body):
hit("R4-code-node-doing-logic", "偵測到 code 節點且內容含 if/for/filter")
# R6:過時教材主張(會把 AI 擋在正解門外)
if not is_doc and (
re.search(r"(不支援|沒有|無)\s*(原生)?\s*(條件分支|ON_TRUE|ON_FALSE)", body)
or re.search(r"只有\s*ON_SUCCESS\s*(與|和|、)\s*(FOREACH|對每個)", body)):
hit("R6-stale-doc-claim", "文字聲稱引擎不支援條件分支")
if not hits:
return 0
out = []
out.append("🎓 arcrun-intent-guard:這份意圖有 %d 處可以更好——下面是**正確寫法,可直接照抄**。"
% len(hits))
out.append("")
for rid, detail in hits:
r = rules.get(rid, {})
out.append("── %s" % rid)
out.append(" 現場:%s" % detail)
out.append(" ✅ 正確寫法:%s" % r.get("teach", ""))
if r.get("fix_example"):
out.append(" 範例(照抄改內容即可):")
for line in r["fix_example"].split("\n"):
out.append(" %s" % line)
if r.get("payload_hint"):
out.append(" payload%s" % r["payload_hint"])
out.append("")
out.append("判準來源:system-dev/docs/3-specs/arcrun-usable/intent-rules.json(與判分器共用)")
out.append("不確定時**問實例**POST /cypher/search,回應裡的 branch_hint 會直接告訴你分支怎麼接。")
sys.stderr.write("\n".join(out) + "\n")
return 2
if __name__ == "__main__":
sys.exit(main())
+261
View File
@@ -0,0 +1,261 @@
#!/usr/bin/env bash
# ask-user-question-guard.sh — PreToolUseAskUserQuestion):**在「問出口的那一刻」攔住**
#
# 病根(leo 2026-08-26):「**今天已經好幾次問我,為什麼 hooks 沒有攔下來?**」
# 當天總管至少兩次把純技術路徑選擇丟給 leo,其中一次他當場說
# 「**這種問題不要問我,我要的是你解決了以後給我 prod**」——而**一支閘都沒響**。
#
# 實查(2026-08-26ISEP 0.3.8):
# • 總管問 leo 走的動作就是 `AskUserQuestion` 這個工具
# • `hooks.json` 裡 `AskUserQuestion` 出現 **0 次**——它沒有任何 matcher,是裸的
# • `self-drive-police.sh` / `self-drive-judge.sh` 只掛在 `Stop` / `SubagentStop`
# ⇒ 它們是**回合結束後**才跑的。問題送出去、leo 已經被打斷之後,
# 再來反問「你查過了嗎」已經沒有意義——**成本早就轉嫁出去了**。
#
# ⇒ 這支補的不是判準(判準跟自走警察同一套四題公式),補的是**時機**:
# `Stop` 是事後補刀,`PreToolUse` 才是當下攔截。
#
# ──────────────────────────────────────────────────────────────────────
# 🔴 封的是動作,不是文字(leo 2026-08-17 的檢驗)
#
# leo 原話:「你在**文字層**封路⋯⋯『回「做」我就啓動』沒有問號,下次它會寫
# 『準備就緒』,再下次寫『待命中』。**自然語言的變體是無限的,blacklist 永遠追不完。**
# 封路哲學之所以有效,是因為它封的是**動作**——動作有限且可枚舉,文字不是。」
# 當日實證:文字層的閘 **8 次誤攔、0 次正確攔截**,且方向穩定
# ——**紅線寫得越細,命中關鍵字的機率越高 ⇒ 那些閘在懲罰謹慎。**
#
# 本閘怎麼過這個檢驗:
# • **觸發條件=那個動作本身**`AskUserQuestion` 這一次呼叫),
# 不是任何句型、關鍵字、問號、措辭。改寫成什麼樣子都一樣會進來。
# • **沒有任何 grep 黑名單**(全檔零個判擋用的正則)。換句話說閃不過去,
# 講得謹慎也不會被多罰——這正是文字層閘的兩個病,本閘兩個都沒有。
# • 進來之後要分「該問 / 不該問」,靠的是**四題公式的語意判官**
# `self-drive-judge.sh` 已經在用的同一套 haiku 判官),
# 它判的是「這題命不命中人閘」,不是「這句話長什麼樣」。
#
# 🔴 不准把真人閘也擋掉(本閘存在的第二個前提)
# 四題命中時 leo **要**被問——2026-08-26 當天他就當場裁了一題 UI 取捨(樹要不要捲)。
# **擋掉那種等於讓總管替他決定他的品味,比不擋更糟。**
# 所以本閘有兩層保險:
# ① 判官只在「四題全否」時才回 block;任何一題命中一律 allow
# ② **同一個問題只擋一次**(雜湊戳記)——判官若誤判,重送即放行,
# leo 該收到的問題絕不會因為一支閘而永遠送不到。
# (跟 `micromanage-guard.sh``subagent-first-guard.sh` 同一個慣例:
# 閘是絆一下逼你重想,不是把人關起來。)
#
# 判準(跟 CLAUDE.md 規則二、`self-drive-judge.sh` 完全同一套,不另立第二套):
# ① 花錢 ② 不可逆/難回收 ③ 跨專案結構 ④ 品味/方向 ⑤ 物理人閘(只有人做得到)
# 命中任一 → 放行。四題全否 → 擋一次,逼它自己裁。
#
# 失敗一律 fail-openexit 0):判官掛掉/沒網路/`claude` 不在 PATH
# 都只會回到「今天的狀態」(沒有閘),不會變成「問不出去」。
# ⇒ 這支閘壞掉的最壞後果,等於它不存在,不會癱瘓任何 session。
#
# 官方文件核實(2026-08-26 查 code.claude.com,不是憑記憶):
# • `AskUserQuestion` **會**觸發 PreToolUsematcher 寫工具名有效
# — https://code.claude.com/docs/en/agent-sdk/user-input
# 原文:「Claude calls AskUserQuestion. The PreToolUse hook fires.」
# • `tool_input` 的真實結構=`{"questions":[{"question","header","options":[{"label","description"}],"multiSelect"}]}`
# — 同頁「Question format」段
# • exit 2 在 PreToolUse hard block,工具呼叫不會發生,無已知例外
# — https://code.claude.com/docs/en/hooksExit Codes 表)
# • `AskUserQuestion` **在 subagent 裡不可用**(同頁 Limitations
# ⇒ 所以本閘只會在主 session 觸發;subagent 那一側仍由
# `self-drive-police.sh``self-drive-judge.sh` 的 SubagentStop 守著,兩者不重疊。
#
# 測試:
# hooks/tests/ask-user-question-guard.test.sh 離線、不花錢(判官用替身)
# hooks/tests/ask-user-question-guard.live.test.sh 真的叫 haiku,量判官的準度
set -u
# 遞迴保險:判官自己起的那個 claude 不准再觸發本閘
if [ "${SELF_DRIVE_JUDGE:-}" = "1" ]; then exit 0; fi
input="$(cat)"
# ── 只管 AskUserQuestion 這一個動作 ────────────────────────────────
tool_name="$(printf '%s' "$input" | python3 -c '
import sys, json
try:
print(json.load(sys.stdin).get("tool_name", ""))
except Exception:
print("")
' 2>/dev/null)"
[ "$tool_name" = "AskUserQuestion" ] || exit 0
session_id="$(printf '%s' "$input" | python3 -c '
import sys, json
try:
print(json.load(sys.stdin).get("session_id", ""))
except Exception:
print("")
' 2>/dev/null)"
# ── 把問題本體攤成純文字(給判官看,不給 grep 看)──────────────────
# 刻意用「遞迴撈出所有字串」而不是寫死欄位名:AskUserQuestion 的 tool_input
# schema 若哪天改欄位(questions/question/header/options/label/description…),
# 這支不會變成靜默失效的空判官。
question_text="$(printf '%s' "$input" | python3 -c '
import sys, json
def walk(node, out):
if isinstance(node, str):
s = node.strip()
if s:
out.append(s)
elif isinstance(node, list):
for x in node:
walk(x, out)
elif isinstance(node, dict):
for k in ("question", "header", "label", "description", "text"):
if k in node:
walk(node[k], out)
for k, v in node.items():
if k not in ("question", "header", "label", "description", "text"):
walk(v, out)
out = []
try:
d = json.load(sys.stdin)
walk(d.get("tool_input", {}), out)
except Exception:
pass
seen = set()
uniq = [x for x in out if not (x in seen or seen.add(x))]
print("\n".join(uniq))
' 2>/dev/null)"
# 撈不到內容 ⇒ 沒東西可判 ⇒ 放行(fail-open)
[ -z "$question_text" ] && exit 0
# ── 同一個問題只擋一次 ────────────────────────────────────────────
# 判官誤判時,總管重送同一題就過得去 ⇒ leo 該收到的問題不會被永久攔住。
stamp="/tmp/.ask-gate-warned-$(printf '%s' "$question_text" | shasum | cut -c1-12)"
[ -f "$stamp" ] && exit 0
# ── 明示豁免(留痕用)──────────────────────────────────────────────
# 真的緊急、或判官在誤攔而不想改寫問題時:
# touch /tmp/.ask-ok-<session_id>
# 用掉就消失,不是永久開關。
if [ -n "$session_id" ] && [ -f "/tmp/.ask-ok-$session_id" ]; then
rm -f "/tmp/.ask-ok-$session_id"
exit 0
fi
# ── 語意判官(跟 self-drive-judge.sh 同一套判準、同一個模型)──────
judge_prompt="你是「人閘判官」。下面是一個 AI 助理(總管)**正要送給老闆 leo 的一個提問**。
判斷這個問題**該不該送到 leo 面前**。
判準只有一條——四題人閘公式。它問的事情是否命中下列任一:
① 花錢(付費、開資源、產生帳單、明顯多耗訂閱額度)
② 不可逆/難回收(刪資料、push 到 main、部署上線 prod、對外公開、跨 repo 搬遷)
③ 跨專案結構決策(**repo 與 repo 之間**的邊界:某個能力歸哪個 repo、改全機共用的框架、立一條新鐵律)
🔴 **單一服務/單一 repo 內部怎麼分層、程式碼怎麼組織,不算第③題**——那是實作,不是結構。
④ 品味/方向(**leo 本人看得到、而且會有偏好的東西**:畫面長相、UI/UX 取捨、
要不要做某個功能、對外文案怎麼寫)
🔴 **內部的設計哲學、容錯策略、責任邊界、程式碼美感,不算第④題**——他看不到那些。
⑤ 物理人閘(只有人做得到:貼憑證、終端機同意、平台上按批准、真人驗收)
命中任一 → decision=allow(該問)。
四題全否 → decision=block(這是它自己該裁的)。
🔴 判 allow 時要放寬,判 block 時要保守——**誤擋一個真人閘,比放過一個技術題更糟**。
拿不準、或這題同時有技術面與品味面 → 一律 allow。
典型的 block(四題全否,都是純技術路徑選擇):
- 用哪個函式庫/哪種實作寫法/要不要抽共用模組
- 錯誤怎麼處理、重試幾次、超時設多久
- 檔案放哪個目錄、變數怎麼命名(純內部、用戶看不到)
- 「A 方案和 B 方案我都可以做,你想要哪個?」而兩案的差別只有實作成本
- 「我發現一個 bug,要修嗎?」(修 bug 不是品味題)
- 「要不要繼續/要不要我開始」(已經交代過的事)
典型的 allow
- 要不要開一個會產生帳單的資源、要不要換更貴的模型
- 要不要 push 到 main/部署到 prod/刪掉某批資料
- 某個能力該歸哪個 repo、要不要立一條新鐵律
- 畫面上這個東西要長什麼樣、要不要做這個功能、文案怎麼寫
- 需要你去終端機貼憑證/按批准
只輸出 JSON,不要任何其他文字。
輸出格式:
{\"decision\":\"allow\"或\"block\",\"gate\":\"命中第幾題或 none\",\"reason\":\"一句正體中文;block 時要指出它在問什麼、以及四題為何全否\"}
--- 它要問 leo 的問題 ---
$question_text
--- 結束 ---"
# ⚠️ 判官必須跑在「中性目錄 + --safe-mode」:
# 實測(self-drive-judge.sh 2026-08-05):在專案內跑 `claude -p` → 54 秒
# (內層把 SessionStart recall、全部 hook、全部 MCP 工具都載進去);
# 換成 `cd /tmp` `--safe-mode` → 8 秒。判官只需要判一段文字。
# `ISEP_ASK_JUDGE_CMD` 只為了讓離線測試塞得進一個判官替身(見 tests/),預設就是真判官。
JUDGE_CMD="${ISEP_ASK_JUDGE_CMD:-claude -p --safe-mode --model haiku --allowedTools ''}"
verdict="$(printf '%s' "$judge_prompt" \
| (cd /tmp && SELF_DRIVE_JUDGE=1 sh -c "$JUDGE_CMD" 2>/dev/null))"
# 判官掛了/回空/不是 JSON → fail-open
[ -z "$verdict" ] && exit 0
parsed="$(printf '%s' "$verdict" | python3 -c '
import sys, json, re
raw = sys.stdin.read()
m = re.search(r"\{.*\}", raw, re.S)
if not m:
print("allow||"); raise SystemExit
try:
d = json.loads(m.group(0))
print((d.get("decision") or "allow") + "|" + (d.get("gate") or "") + "|" + (d.get("reason") or ""))
except Exception:
print("allow||")
' 2>/dev/null)"
case "$parsed" in
block\|*)
rest="${parsed#block|}"
reason="${rest#*|}"
date +%s > "$stamp"
# 訊息用 quoted heredoc(裡面有反引號,不能讓 shell 當命令替換執行過),
# 佔位符交給 python 換掉——sed 對正體中文與特殊字元的跳脫太脆。
msg="$(cat <<'EOF'
🚦 人閘警察(PreToolUseAskUserQuestion):**這一問四題全否,是你自己該裁的。**
判官的話:__REASON__
【leo 2026-08-26】「**今天已經好幾次問我,為什麼 hooks 沒有攔下來?**」
「**這種問題不要問我,我要的是你解決了以後給我 prod。**」
【leo 2026-08-17】「你的命令**被當成『規劃的輸入』,而不是『執行的授權』**⋯⋯
從它的『文化』看這是禮貌;**從你的系統看,這是違約。**」
━━━ 不確定時走這條,不要停(CLAUDE.md 常駐授權那段)━━━
1. 查 wiki`system-dev/wiki/`**語意搜尋優先於 grep**
2. 套四題公式:① 花錢 ② 不可逆 ③ 跨專案結構 ④ 品味方向
3. 仍不確定 → **做出最合理的假設,把假設寫進 commit message 或票的留言,繼續走**
——第 3 步是關鍵:**假設之後不是問,是記錄。**
leo 下課後 review 時一次看到所有假設,對的併、錯的打回。
這把同步的提問改造成非同步的問答,**不掛起整個 loop**。
━━━ 這一問真的命中人閘?━━━
**把命中哪一題直接寫進問題裡再送一次**(例:「四題第②命中:這會刪掉線上資料」),
或 `touch /tmp/.ask-ok-__SESSION__` 後重送。
📌 **同一個問題本閘只擋一次**——原封不動重送就會過。
判官寧可放行也不誤擋,但它會錯;**它錯了不該讓 leo 收不到問題。**
EOF
)"
ASK_MSG="$msg" ASK_REASON="$reason" ASK_SESSION="$session_id" python3 -c '
import os, sys
sys.stderr.write(
os.environ["ASK_MSG"]
.replace("__REASON__", os.environ.get("ASK_REASON", ""))
.replace("__SESSION__", os.environ.get("ASK_SESSION", "<session_id>"))
+ "\n"
)
'
exit 2
;;
esac
exit 0
+136
View File
@@ -0,0 +1,136 @@
#!/usr/bin/env bash
# 管什麼: 一條派工線收工時,檢查它的工單票有沒有把棒子交回來——指派(誰該動)+ tag(卡在哪)+ 下一步(做什麼)三格。
# 為什麼: 2026-08-26 那次交回寫在 comment 裡,票沒指派、tag 沒動 ⇒ 沒有任何欄位在說「這張票在等你」,棒子躺了 14 小時。撈一次看得到的只有欄位,不是留言。
# 誤觸時怎麼關: 這條線不對應任何票 → 派工單本來就不該少了【工單】那行(no-ticket-no-dispatch.sh 管那個);真要放行在指令裡留 `baton-ok: <理由>`(本閘只提醒不阻擋,不會擋住任何動作)。
#
# baton-handback-guard.sh — 收工=把棒子交回去(PostToolUse: Agent|Task
#
# ── leo 2026-08-27 的原話(本閘的規格)────────────────────────────────
# 「它把事情做完後如果要檢查後續,就要把任務指定給你並改 tag,
# 你收到指定用 tag 查就知道要去做,所以**每個任務結束必須要指派回總管**,
# 要說明下一步怎麼做,**直到最後交出正確 deliverable 並且驗證**」
#
# ── 三格各自回答一個問題(leo:「這些全部都要」)─────────────────────
# 指派 assignee 現在誰該動? 缺了它 → 大家都以為是別人的事
# tag s/* 它卡在哪一段? 缺了它 → 撈得到票,但要重讀整串
# 下一步(留言) 收下的人要做什麼? 缺了它 → 撿起棒子的人得自己重建脈絡
#
# ── 為什麼掛在 PostToolUse(Agent) 而不是 SubagentStop ──────────────
# 工單票號寫在**派工單**裡(`【工單】owner/repo#N`),而派工單就是 Agent 的 tool_input。
# SubagentStop 拿到的 transcript 是主對話,票號要繞一圈才找得回來
# (見 subagent-claim-worksheet.sh 檔頭那段實撞紀錄)。
# 同一個位置 issue-status-autoflip.sh 已經在用,形狀是驗過的。
#
# ── 只提醒,不阻擋 ────────────────────────────────────────────────
# 工作已經做完了,擋在這裡沒有任何東西可以被挽救;能做的是**讓總管在當下就看到缺哪一格**。
# 同 unpushed-police.sh 的定位:exit 2 讓訊息進到模型眼前,不是 blocker。
set -uo pipefail
INPUT=$(cat)
SPEC=$(printf '%s' "$INPUT" | python3 -c '
import sys, json, re
try:
d = json.load(sys.stdin)
except Exception:
raise SystemExit
p = (d.get("tool_input") or {}).get("prompt") or ""
for line in p.splitlines():
if "【工單】" not in line:
continue
m = re.search(r"([A-Za-z0-9_.-]+)/([A-Za-z0-9_.-]+)#(\d+)", line)
if m:
print(f"{m.group(1)} {m.group(2)} {m.group(3)}")
break
' 2>/dev/null || true)
[ -n "${SPEC:-}" ] || exit 0
# shellcheck disable=SC2086
set -- $SPEC
OWNER="$1"; REPO="$2"; NUM="$3"
# ── 測試縫(唯一目的:讓判斷邏輯測得起來,不必每跑一次測試就在票池留一張票)──
# BATON_GUARD_FIXTURE=<目錄> ⇒ 讀 issue.json / comments.json,不打網路。
# 正式執行時這個變數不存在,一行都不會走到。
if [ -n "${BATON_GUARD_FIXTURE:-}" ]; then
J=$(cat "$BATON_GUARD_FIXTURE/issue.json")
C=$(cat "$BATON_GUARD_FIXTURE/comments.json")
else
# token:掃已知的 checkout 找第一個帶憑證、指向本站的 remote(同 scripts/ticket 的做法)
ROOT="${CLAUDE_PROJECT_DIR:-$(pwd)}"
URL=""
for d in "$ROOT" "$ROOT/products/$REPO" "$ROOT/matrix/$REPO" "$ROOT/polaris/$REPO"; do
[ -e "$d/.git" ] || continue
U=$(git -C "$d" remote -v 2>/dev/null | grep -m1 'git\.uncle6\.me' | grep '@' | awk '{print $2}') || true
[ -n "${U:-}" ] && { URL="$U"; break; }
done
[ -n "$URL" ] || exit 0
TOKEN=$(printf '%s' "$URL" | sed -E 's|.*//[^:]+:([^@]+)@.*|\1|')
[ "$TOKEN" != "$URL" ] || exit 0
J=$(curl -s --max-time 12 -H "Authorization: token $TOKEN" \
"https://git.uncle6.me/api/v1/repos/$OWNER/$REPO/issues/$NUM" 2>/dev/null) || exit 0
C=$(curl -s --max-time 12 -H "Authorization: token $TOKEN" \
"https://git.uncle6.me/api/v1/repos/$OWNER/$REPO/issues/$NUM/comments?limit=100" 2>/dev/null) || C='[]'
fi
REPORT=$(python3 - "$J" "$C" <<'PY' 2>/dev/null
import json, sys
try:
it = json.loads(sys.argv[1]); cs = json.loads(sys.argv[2])
except Exception:
raise SystemExit
if it.get("state") == "closed":
raise SystemExit # 已經關了 = 棒子到終點了,沒有下一手
miss = []
if not (it.get("assignees") or []):
miss.append("指派:沒有人被指到這張票 ⇒ 撈 assignee 撈不到它,等於棒子躺在地上")
labs = [l["name"] for l in (it.get("labels") or [])]
st = [n for n in labs if n.startswith("s/")]
if not st:
miss.append("tag:沒有任何 s/* ⇒ 看不出它卡在哪一段")
elif st == ["s/doing"]:
miss.append("tag:還停在 s/doing,但這條線已經收工了 ⇒ 它到底做完沒有?"
"(等總管複驗=s/review/已上 stages/stage/等別的東西=s/pending")
if not any("🏃" in (c.get("body") or "") for c in cs):
miss.append("下一步:票上沒有交棒留言 ⇒ 收下棒子的人得自己重讀整串才知道要做什麼")
if not miss:
raise SystemExit
print("\n".join(" ❌ " + m for m in miss))
print("__ASSIGNEES__" + ",".join(a["login"] for a in (it.get("assignees") or [])))
print("__LABELS__" + ",".join(labs))
PY
) || exit 0
[ -n "${REPORT:-}" ] || exit 0
BODY=$(printf '%s' "$REPORT" | grep -v '^__')
NOW_A=$(printf '%s' "$REPORT" | sed -n 's/^__ASSIGNEES__//p')
NOW_L=$(printf '%s' "$REPORT" | sed -n 's/^__LABELS__//p')
cat >&2 <<EOF
🏃 交棒警察:$OWNER/$REPO#$NUM 這條線收工了,但**棒子沒有交回來**。
現在票上:指派=[${NOW_A:-(空)}] 標籤=[${NOW_L:-(空)}]
$BODY
【leo 2026-08-27】「**每個任務結束必須要指派回總管**,要說明下一步怎麼做,
直到最後交出正確 deliverable 並且驗證」
🔴 **關鍵在「指派」是欄位,不是文字。** 撈一次就看得到,不必讀 comment——
leo 自己就是用 Gitea 原生的「指派給您的」在看。
【一個動作把三格補齊】
scripts/ticket handback $OWNER/$REPO#$NUM \\
--to claude-code \\
--next "<收下的人第一件事要做什麼,一句話>" \\
--label <s/reviews/stages/pendings/todo> \\
--evidence "<實測輸出或 PR 連結>"
要 leo 親手做的 → --to Leo(會自動加 Human 標籤)
已經真的做完且驗過了 → scripts/ticket close $OWNER/$REPO#$NUM --deliverable <URL>
【撈一次看棒子在誰手上】 scripts/ticket mine
EOF
exit 2
-82
View File
@@ -1,82 +0,0 @@
#!/usr/bin/env bash
# claim-verify-police.sh — 待驗工作單還在,就不准收工(Stop;leo 2026-08-18 立)
#
# 這支是 subagent-claim-worksheet.sh 的另一半。
# 那支在 SubagentStop 時把 subagent 的宣稱寫成一張檔案;這支在我要停下時檢查:
#
# **那張檔還在不在?**
#
# ── 為什麼判準是「檔案存不存在」 ────────────────────────────────────
# leo 2026-08-17:「你在**文字層**封路⋯⋯自然語言的變體是無限的,blacklist 永遠追不完。
# 封路哲學之所以有效,是因為它封的是**動作**——動作有限且可枚舉。」
# ⇒ 「我驗過了」這句話可以有無限種寫法,但**檔案只能被動作刪掉**。
# 所以這支不讀我的措辭,只看磁碟。
#
# ── 怎麼過這道閘(都是動作,都留痕)─────────────────────────────────
# ① 真的驗了 → 把證據寫進回覆/票,然後刪掉那個檔
# ② 有幾條不需要驗 → 在檔裡寫下理由,再刪掉(理由留在 git 或票上)
# ③ 是人閘 → 走三件機械動作(Human + 指派 + 它自己的票),再刪掉
#
# 🔴 **不准為了過閘而直接 rm 掉卻什麼都沒做。** 那等於自己拆掉自己的閘——
# 而這正是 leo 2026-08-10 說的「規則早就存在,但沒有任何機制驗證有沒有照做」。
#
# ── 迴圈安全 ────────────────────────────────────────────────────
# stop_hook_active 為真 ⇒ 已經擋過一次,放行。至多提醒一次,不卡死。
#
# ── 留痕 ────────────────────────────────────────────────────────
INPUT=$(cat)
DIR="$CLAUDE_PROJECT_DIR/.claude/pending-verification"
LOG="$CLAUDE_PROJECT_DIR/.claude/hooks/claim-verify-police.log"
ACTIVE=$(printf '%s' "$INPUT" | python3 -c '
import json,sys
try: print("1" if json.load(sys.stdin).get("stop_hook_active") else "0")
except Exception: print("0")
' 2>/dev/null)
if [ "$ACTIVE" = "1" ]; then
printf '%s\tSKIP:already-nudged\n' "$(date +%FT%T)" >> "$LOG" 2>/dev/null
exit 0
fi
# 只算「今天以內」的單子——放太久的(跨 session 殘留)不該無限期卡住新工作,
# 但仍會被列出來提醒。
FILES=$(ls -1 "$DIR"/claims-*.md 2>/dev/null)
if [ -z "$FILES" ]; then
printf '%s\tSKIP:no-pending\n' "$(date +%FT%T)" >> "$LOG" 2>/dev/null
exit 0
fi
N=$(printf '%s\n' "$FILES" | grep -c . )
printf '%s\tBLOCK:%s-pending\n' "$(date +%FT%T)" "$N" >> "$LOG" 2>/dev/null
{
echo "🔍 查證警察:有 $N 張**待驗工作單**還沒處理掉,不准收工。"
echo
echo "【leo 2026-08-18 的規格】"
echo " 「subagent 做完事⋯⋯**它說可以,你去驗證;它說不行,你想辦法,不行再報告**,"
echo " 這解決一半的未查證。你常常直接把 subagent 說的不經查證就回報,"
echo " **它視野小,它說的你有更多資訊去檢驗。**」"
echo
echo "━━ 還沒處理的單子 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
printf '%s\n' "$FILES" | while read -r f; do
[ -n "$f" ] || continue
OKN=$(grep -c '^- \[ \]' "$f" 2>/dev/null)
echo " · ${f#"$CLAUDE_PROJECT_DIR"/} $OKN 條待處理)"
done
echo
echo "━━ 怎麼過這道閘(三選一,都是動作)━━━━━━━━━━━━━━━━━━"
echo " ① **它說可以** → 你自己跑一次(測試/curl/瀏覽器/git),把證據貼出來,再刪掉那個檔"
echo " ② **它說不行** → 先換路徑、查憑證地圖;要說「被擋」就得**自己撞一次並貼拒絕原文**"
echo " ③ **真的是人閘** → 三件機械動作:\`Human\` 標籤 + 指派給 Leo **一張它自己的票**"
echo " leo 08-18:「要進入人閘,標示 human 叫我去開,**不是默默塞進錯的地方**」)"
echo
echo "🔴 **不准只是 rm 掉了事。** 那是自己拆掉自己的閘——"
echo " 正是 leo 08-10 講的「規則早就存在,但沒有任何機制驗證有沒有照做」。"
echo
echo "📌 為什麼有這道閘:2026-08-18 總管連續三次把**沒驗過的判斷**當成情報交出去,"
echo " 其中第二次還是為了「更正」第一次而發明的新猜測。"
echo " leo:「你給我不正確的資訊⋯⋯subagent 說不通你沒有驗。」"
} >&2
exit 2
+132
View File
@@ -0,0 +1,132 @@
#!/usr/bin/env bash
# 管什麼: 往 Gitea 票裡貼「留言裡藏著一件還沒做完的事」(等 X/驗不了/還沒…)卻沒有把它長成子票時,擋一次。
# 為什麼: 2026-08-26 arcrun-rag#136 comment 4267 就是這樣掉的——「等雲端那半出貨才驗得了」只活在討論串,收端後來真的上線了,卻沒有任何東西讓人回頭把它撿起來,躺了 14 小時。
# 誤觸時怎麼關: 這則留言真的不含待辦 → 指令裡加 `no-subtask: <一句理由>` 放行(留痕);同一 session 只擋一次。
#
# comment-carries-task-guard.sh — 討論串裡的任務要長成看得見的子票(PreToolUse: Bash
#
# ── leo 2026-08-27 的原話(本閘的規格)────────────────────────────────
# 「可以在討論串延伸子票,它完成了子票就完工,不然這筆任務就是沒完工,
# 如果你沒辦法察覺,那不要把任務加在討論串,而是每個任務長出子票做為這張票的相依,
# **票沒完工可以察覺嗎?**」
#
# ── 為什麼子票比「讓等待會響」更根本 ─────────────────────────────────
# 任務寫在討論串 → 要有人回頭讀 comment 才知道它存在 → 沒人回頭讀 → 掉
# 任務長成子票 → open/closed 是**狀態** → 撈一次就看得到 → 掉不了
# ⇒ 不是「讓它會響」,是**讓它本來就在視線裡**。
#
# 而且相依是硬的:實測(2026-08-27Gitea 1.26.4)子票還開著時關母票 →
# HTTP 412 `cannot close this issue or pull request because it still has open dependencies`。
#
# ── 粒度:傾向多開票,不是少開票(leo 2026-08-27 當場更正總管)────────
# 「**小任務做完就關閉,不會讓池子爆掉,每個事情沒有歷史記錄才是大問題**」
# ⇒ 判準是「這件事需要被記得嗎」,不是「會不會太多」。
# 池子爆掉的前提是「開了不關」,那由「做完就關」解決,不該由「不開票」解決。
#
# ── 誠實邊界 ──────────────────────────────────────────────────────
# PreToolUse 只看得到指令文字,這是這一層的天花板(同 InkStoneCo#36)。
# 所以關鍵詞刻意取**窄**的一組「未完成的未來式」,寧可漏抓也不要天天誤擋——
# `.claude/branch-holds.md` 檔頭那句:**永遠在響的警報,等於訓練人忽略這個警報。**
# 漏抓的那些,由 baton-handback-guard.sh(收工那一刻)補第二層。
#
# 已知極限(誠實標記):這支**認不出「我已經開好子票了」**——它只看得到指令文字。
# 所以交件報告裡引用自己剛開的子票時仍會被擋一次;那一次用 `no-subtask:` 過,
# 或就當它是一次確認。刻意不去打 API 查「這張票有沒有相依」:那會讓每一個
# 命中關鍵詞的 Bash 呼叫都多一次網路往返,代價落在錯的地方。
set -uo pipefail
INPUT=$(cat)
CMD=$(printf '%s' "$INPUT" | python3 -c "
import sys,json
try: print(json.load(sys.stdin).get('tool_input',{}).get('command',''))
except Exception: print('')
" 2>/dev/null)
SID=$(printf '%s' "$INPUT" | python3 -c "
import sys,json
try: print(json.load(sys.stdin).get('session_id','') or 'nosid')
except Exception: print('nosid')
" 2>/dev/null || echo nosid)
[ -n "$CMD" ] || exit 0
# 逃生口(留痕,會留在指令歷史上)
case "$CMD" in *no-subtask:*) exit 0 ;; esac
# 這些動詞本來就是在處理「棒子」,不該被自己擋
case "$CMD" in
*"ticket subtask"*|*"ticket handback"*|*"ticket mine"*|*"ticket close"*|*"ticket where"*) exit 0 ;;
esac
# ① 這是不是在對「既有票」貼留言?(開新票走 ticket newticket-api-bypass-guard
IS_COMMENT=no
case "$CMD" in *"ticket say"*) IS_COMMENT=yes ;; esac
if printf '%s' "$CMD" | grep -qE 'issues/[0-9]+/comments' && printf '%s' "$CMD" | grep -qw 'POST'; then
IS_COMMENT=yes
fi
[ "$IS_COMMENT" = "yes" ] || exit 0
# ② 把「要貼的內文」拿到手:-F <檔> 就讀那個檔,否則就看指令文字本身
BODY="$CMD"
FARG=$(printf '%s' "$CMD" | sed -nE 's/.*[[:space:]](-F|--file)[[:space:]]+([^[:space:]]+).*/\2/p' | head -1)
if [ -n "${FARG:-}" ] && [ -f "$FARG" ]; then
BODY=$(cat "$FARG" 2>/dev/null || printf '%s' "$CMD")
fi
# 已經帶著交棒標記的留言 = 走的是 handback 那條路,不重複擋。
# 🔴 只認**第一行**,不認「內文任何地方出現過」——這是 dogfood 當場抓到的漏(2026-08-27):
# 交件報告裡**引用了一段 `ticket mine` 的輸出**(那段有 🏃),
# 整則報告就被豁免掉了,而它裡面真的有「等雲端那半出貨」這種待辦。
# ⇒ 這正是 `inkstone/InkStoneCo#23` 那個病的同款:**複述關鍵字被當成本人**。
# `ticket handback` 寫的交棒留言,🏃 一定在第一行;引用別人的輸出則不會。
case "$(printf '%s' "$BODY" | head -1)" in "🏃"*) exit 0 ;; esac
# ③ 「未完成的未來式」——刻意取窄。這一組全部來自 08-26 那則真的掉了的留言的形狀。
#
# 🔴 比對用 python3 不用 grep——這是**實測逼出來的**,不是偏好(2026-08-27 本閘第一版):
# 第一版寫 `等[^。]{0,20}(出貨|…)`,跑真的 hook 呼叫路徑時對
# 「這件事等雲端那半出貨才驗得了」**不匹配**,閘靜默失效(測試 19/20,那一條紅)。
# 同一條 pattern 在別的 shell 手打卻會過 ⇒ **它的行為依賴 localegrep 實作**
# 而 hook 的執行環境不由我們決定(本機 `LC_CTYPE=C`,雲端又是另一套)。
# unpushed-police.sh 檔頭記過同一族的坑:C locale 下處理全形標點會咬掉位元組。
# ⇒ 規約:**hook 裡凡是要比對中文,一律交給 python3 的 re**(它一定是字元語意),
# 不要賭 grep 在那台機器上是字元模式還是位元組模式。
HIT=$(BODY="$BODY" python3 -c '
import os, re, sys
PAT = ("等.{0,20}?(出貨|上線|部署|發佈|合併|修好|回覆|做完|接上)"
"|驗不了|測不了|還沒(驗|測|做|上線|部署|接|補)|之前(驗不|無法|不能)"
"|要等|尚未完成|待辦|\\[blocker\\]|TODO|FIXME")
ms = [m.group(0) for m in re.finditer(PAT, os.environ.get("BODY", ""))]
print(" ".join(ms[:3]))
' 2>/dev/null)
[ -n "${HIT:-}" ] || exit 0
WARNED="/tmp/.comment-task-guard-warned-$SID"
[ -f "$WARNED" ] && exit 0 # 同一 session 只擋一次,不鬼打牆
date +%s > "$WARNED"
cat >&2 <<EOF
🎫 這則留言裡藏著一件**還沒做完的事**,而它現在只是文字。
命中的詞:$HIT
【leo 2026-08-27】「**每個任務長出子票做為這張票的相依⋯⋯票沒完工可以察覺嗎?**」
【它掉過一次,時序如下】
08-26 13:00 arcrun-rag#136 comment 4267:「等雲端那半出貨才驗得了」 ← 只是一段文字
08-26 18-23 總管真的出貨了 ← 條件滿足,沒人知道
08-27 11:xx leo 問「這 14 小時前的,你派了還是沒派?」 ← 才被撿起來
【怎麼做】把它長成子票,Gitea 原生相依會**硬擋**母票關閉(實測 412):
scripts/ticket subtask <母票 owner/repo#N> \\
--title "身為<誰>,我要<什麼>,我才<為什麼>" \\
-F <內文檔> --label s/todo --assign <誰做>
⇒ 子票沒關,母票關不掉。**不是提醒,是平台保證。**
【粒度】leo 2026-08-27:「**小任務做完就關閉,不會讓池子爆掉,
每個事情沒有歷史記錄才是大問題**」⇒ 判準是「這件事需要被記得嗎」,
傾向**多開票**。不要用「會不會太多」當理由不開。
【真的不是待辦】在指令裡加 \`no-subtask: <一句理由>\` 重送(留痕)。
EOF
exit 2
+207
View File
@@ -0,0 +1,207 @@
#!/usr/bin/env bash
# countdown-guard.sh — 每一則回覆都自己說出「已經花了多久/還剩多久」
# inkstone/ISEP#63leo 2026-08-2708-28 兩次下令)
#
# ── 為什麼是一支閘,不是一句叮嚀 ──────────────────────────────────────
# leo 2026-08-27:「前面說過每個回覆要戴上已經花了總時長,**這為什麼沒出現?**」
# 「這應該寫在 ISEP,隨時看自己拖了多久」
# 總管當時答「我沒做,現在開始戴」——**而那正是這條規則第一次失效的方式**:
# 靠記性的規則,下一個 session 就沒有了(history-firstKBDB-firststage-first
# 全是同一個形狀)。⇒ 判準:**一個新開的 session,什麼都沒被交代,回覆裡照樣有時長。**
#
# ── 兩段,缺一不可(票上點名的失效模式就在這裡)───────────────────────
# 票上寫得很清楚:「**注入了但模型沒照做**是這類機制的主要失效模式」。
# 所以這支同時掛兩個事件,是**同一件事的兩半**:
#
# ① UserPromptSubmit ── 注入:把算好的那一行送到眼前(模型不必自己算、也算不準)
# ② Stop ── 查核:這一回合的回覆裡到底有沒有那一行;沒有就擋一次
#
# ── 注入點的第二行:🎯 現在的主線是哪一個(inkstone/ISEP#82)────────────
# 同一個注入點,兩行,來源不同:⏱ 算時間,🎯 讀 `hooks/lib/mainline.py` 裡被標定的主線。
# 🔴 **Stop 那一半只查 ⏱,沒有跟著擴大成兩個標記**,兩個理由:
# ① ⏱ 那一行本身**已經帶著主線的名字**(`countdown.py` 的第三段),
# 所以主線是跟著一道**已經驗過的閘**到 leo 眼前的,不是靠新增一道會擋人的閘。
# ② 一個回合最多該被擋一次。多要求一個標記 = 多一種被擋的方式,
# 而本 repo 心法第 2 條寫死了:永遠在響的警報等於訓練人忽略警報。
# 主線這條線真正會擋人的地方在派工那一端(`hooks/mainline-focus-guard.sh`),
# 那裡擋的是「派了一張不在主線上的票」——**一個動作**,不是一段文字。
#
# 只有 ① = 又一個會被忽略的提醒(本 repo 心法第 2 條明令不要做這種東西)。
# 只有 ② = 罰它做一件它拿不到資料的事。兩段合起來才是一條走得通的路。
#
# ── 🔴 這不是關鍵字黑名單(leo 2026-08-17 的診斷)────────────────────
# 「自然語言的變體是無限的,blacklist 永遠追不完……封路哲學之所以有效,
# 是因為它封的是**動作**——動作有限且可枚舉,文字不是。」
# 當日實測:文字層的閘 **8 次誤攔、0 次正確攔截**,且方向穩定。
#
# 本支查的**不是「有沒有講某些不該講的話」,而是「那個被要求的輸出元素在不在」**:
# 一個由機器產生、只有一個字元的標記(`⏱`),值是機器算的,模型抄過去。
# ⇒ 沒有「換個講法就閃過」的空間(換講法照樣要帶標記),
# 也沒有「講到某個詞就被咬」的誤攔(多寫什麼都不會觸發)。
# ⇒ 這是 whitelist-of-one**要求一個東西在場**,不是**猜哪些東西不該在場**。
#
# ── 誤攔的出口都先關掉(誤攔比漏擋更該修)────────────────────────────
# · 子 sessionsubagent)不查——它的回覆不是給 leo 看的
# · 這一回合沒有任何文字輸出(純工具回合)不查——沒有東西可以戴
# · `stop_hook_active` 為真=已經提醒過一次 ⇒ 放行,**至多擋一次,不會卡死**
# · 讀不到 transcript/內部出錯 ⇒ 放行(這支是節拍器,不是安全閘,
# 它壞掉不該讓整個 session 停下來)
set -uo pipefail
INPUT="$(cat)"
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
LIB="$HERE/lib/countdown.py"
[ -f "$LIB" ] || exit 0
EVENT=$(printf '%s' "$INPUT" | python3 -c '
import sys, json
try: print(json.load(sys.stdin).get("hook_event_name", "") or "")
except Exception: print("")
' 2>/dev/null || printf '')
# ══ ① 注入模式(UserPromptSubmit)═════════════════════════════════════
if [ "$EVENT" = "UserPromptSubmit" ]; then
printf '%s' "$INPUT" | ISEP_COUNTDOWN_LIB="$LIB" python3 -c '
import json, os, sys, importlib.util
spec = importlib.util.spec_from_file_location("countdown", os.environ["ISEP_COUNTDOWN_LIB"])
cd = importlib.util.module_from_spec(spec); spec.loader.exec_module(cd)
try: d = json.load(sys.stdin)
except Exception: d = {}
try:
stamp = cd.line(d.get("session_id") or "", d.get("transcript_path") or "")
except Exception:
raise SystemExit(0) # 算不出來就不出聲,不要讓它變成噪音
# 第二行:現在的主線是哪一個(inkstone/ISEP#82)。
# 同一個注入點的兩行,來源不同:⏱ 算時間,🎯 讀被標定的主線。
# 算不出來也一定回一句話(「現在沒有主線」),所以這裡不需要 fallback;
# 真的整支壞掉(import 失敗)才留白——一行注入不該讓整個 session 停下來。
try:
ml = cd._mainline().line()
except Exception:
ml = ""
ctx = (
stamp + "\n"
+ (ml + "\n" if ml else "")
+ "\n"
"🔴 第一行是 ISEP 的倒數(inkstone/ISEP#63)。**把它原樣放在你這則回覆的最前面**,"
"數字不要自己重算、也不要改寫措辭——它是機器算的,你算的會漂。\n"
"leo 整天上課、一天只看兩眼:沒有這行,他就不知道『這件事還來得及嗎』。\n"
"收工前這一回合若沒有戴上它,Stop 那一半會擋一次要你補。\n\n"
"🎯 第二行是**現在的主線**inkstone/ISEP#82)——leo:「14 個里程碑同時亮著"
"卻不知道該看哪個」。它是你這回合的判準:**手上這件事在不在那條線上**。\n"
"不在,就當場說清楚是『補收』(它本來就該在主線上)還是『跳線』(真的插件事),"
"不要默默做完。主線的名字已經在第一行的倒數裡,戴上倒數=主線也一起到了 leo 眼前。"
)
print(json.dumps({
"hookSpecificOutput": {
"hookEventName": "UserPromptSubmit",
"additionalContext": ctx,
}
}, ensure_ascii=False))
' 2>/dev/null || true
exit 0
fi
# ══ ② 查核模式(Stop)═════════════════════════════════════════════════
# 子 session 不查:subagent 的回覆是交給總管的,不是給 leo 看的那一則。
[ "${CLAUDE_CODE_CHILD_SESSION:-}" = "1" ] && exit 0
LOG="${CLAUDE_PROJECT_DIR:-$PWD}/.claude/hooks/countdown-guard.log"
VERDICT=$(printf '%s' "$INPUT" | ISEP_COUNTDOWN_LIB="$LIB" python3 -c '
import json, os, sys, importlib.util
spec = importlib.util.spec_from_file_location("countdown", os.environ["ISEP_COUNTDOWN_LIB"])
cd = importlib.util.module_from_spec(spec); spec.loader.exec_module(cd)
try: d = json.load(sys.stdin)
except Exception:
print("SKIP:bad-json"); raise SystemExit
if d.get("stop_hook_active"):
print("SKIP:already-nudged"); raise SystemExit # 至多擋一次
tp = d.get("transcript_path") or ""
if not tp or not os.path.exists(tp):
print("SKIP:no-transcript"); raise SystemExit
rows = []
try:
with open(tp) as f:
for ln in f:
ln = ln.strip()
if ln:
try: rows.append(json.loads(ln))
except Exception: pass
except Exception:
print("SKIP:unreadable"); raise SystemExit
# 這一回合=最後一則「真的來自使用者」的訊息之後(工具結果不算使用者說話)
start = 0
for i, r in enumerate(rows):
if r.get("type") == "user":
c = (r.get("message") or {}).get("content")
blocks = c if isinstance(c, list) else [{"type": "text"}]
if not any(isinstance(b, dict) and b.get("type") == "tool_result" for b in blocks):
start = i
turn = rows[start:]
texts = []
for r in turn:
if r.get("type") != "assistant":
continue
c = (r.get("message") or {}).get("content")
if isinstance(c, str):
texts.append(c); continue
if not isinstance(c, list):
continue
for b in c:
if isinstance(b, dict) and b.get("type") == "text":
texts.append(b.get("text") or "")
body = "\n".join(t for t in texts if t and t.strip())
if not body:
print("SKIP:no-text"); raise SystemExit # 純工具回合,沒有東西可以戴
if cd.MARKER in body:
print("OK"); raise SystemExit
try:
stamp = cd.line(d.get("session_id") or "", tp)
except Exception:
print("SKIP:no-stamp"); raise SystemExit
print("MISSING:" + stamp)
' 2>/dev/null || echo "SKIP:crash")
STAMP_LINE="${VERDICT#MISSING:}"
NOW=$(date "+%Y-%m-%d %H:%M:%S" 2>/dev/null || echo "?")
mkdir -p "$(dirname "$LOG")" 2>/dev/null || true
case "$VERDICT" in
MISSING:*)
printf '| %s | ⛔ 擋下 | 回覆沒有倒數 |\n' "$NOW" >> "$LOG" 2>/dev/null || true
{
printf '⏱ 倒數警察:**這則回覆沒有戴上倒數**inkstone/ISEP#63\n\n'
printf 'leo 2026-08-27:「前面說過每個回覆要戴上已經花了總時長,**這為什麼沒出現?**」\n'
printf ' 08-28:「先完成倒數計時器,**每一次回覆都發倒數**」\n\n'
printf '把這一行原樣放在回覆最前面,再送一次(數字已經幫你算好,不要自己重算):\n\n'
printf ' %s\n\n' "$STAMP_LINE"
printf '為什麼非戴不可:leo 整天上課、一天只看兩眼。沒有這行,他看不出\n'
printf '「這件事花了不該花的時間」——而那正是他要這個東西的全部理由。\n\n'
printf '本閘**至多擋一次**stop_hook_active 之後放行),不會卡死。\n'
} >&2
exit 2
;;
OK)
printf '| %s | ✅ 放行 | 有倒數 |\n' "$NOW" >> "$LOG" 2>/dev/null || true
exit 0
;;
*)
printf '| %s | ⚪ 略過 | %s |\n' "$NOW" "$VERDICT" >> "$LOG" 2>/dev/null || true
exit 0
;;
esac
+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
+257
View File
@@ -0,0 +1,257 @@
#!/usr/bin/env bash
# 管什麼: 派工單裡除了【工單】票號以外還有別的內容就擋;合規的派工則自動注入共通規定。
# 為什麼: 任務寫在 prompt 裡 => 票上看不到、agent 一停就蒸發;散文沒有欄位 => 警察抓不到。
# 誤觸時怎麼關: 把內容歸位(每次都一樣的→共通規定;這次才知道的→寫進票),或 touch /tmp/.dispatch-ok-<session_id> 放行一次。
#
# dispatch-format-guard.sh — PreToolUseTaskAgent):**派工單 = 票號。就這樣。**
#
# ━━ 來由(leo 2026-08-27inkstone/ISEP#30 comment 4322 / 4325 / 4327)━━━━
#
# 他貼回總管派給 Arcrun#142 那條線的 prompt,問:
# 「**這些話票上都沒有,你根本沒照規則做事,你的 hook 讓你這樣搞?**」
# 「執行的不是你,你去派工,它才知道問題,**你寫診斷意義是什麼?不就是假的?**」
#
# 實查:`no-ticket-no-dispatch.sh` 驗的是「派工單裡有沒有一行【工單】owner/repo#N」。
# ⇒ 所以可以**把 40 行任務全寫在 prompt 裡、票號補一行**,閘照樣放行。
# ⇒ 2026-08-27 一天之內這樣做了 5 次(arcrun-rag#104Arcrun#142Arcrun#127
# Arcrun#144InkStoneCo#55),每一次票上都沒有那份任務。
# ⇒ **規則存在,閘只驗了它的殼。**(同款第 N 次:history-firstKBDB-first
# stage-firstAskUserQuestion 裸奔,全是這個形狀。)
#
# 4325 追加:「你用一個 **output parser** 把你給 subagent 的指令規範,**分作幾點,
# 每一點規定格式**,**照這種散文寫法根本無法迭代**」「**警察也不能抓**」。
# 4327 收斂到最後一步:「**交件方式不需要寫,定義在原則裡**⋯⋯每次都一樣提取出來
# 變成共通規定」+(對「這是 session 才知道的事」的回應)「**這些為什麼不寫到票裡?**」
#
# ⇒ 派工單裡想寫的東西只有兩種,兩種都不該留在派工單:
# 每次都一樣 → 共通規定(本閘自動注入,見下)
# 這次才知道 → **寫進那張票**
# 判準:**「這句話換一張票還成立嗎?」**
#
# ━━ 為什麼是結構閘,不是關鍵字閘(leo 2026-08-17 的檢驗)━━━━━━━━━━━━
# 「你在**文字層**封路⋯⋯『回「做」我就啓動』沒有問號,下次它會寫『準備就緒』⋯⋯
# **自然語言的變體是無限的,blacklist 永遠追不完。**
# 封路哲學之所以有效,是因為它封的是**動作**——動作有限且可枚舉,文字不是。」
# 當日實證:文字層的閘 8 次誤攔、0 次正確攔截,且方向穩定——
# **紅線寫得越細,命中關鍵字的機率越高 ⇒ 那些閘在懲罰謹慎。**
#
# 本閘怎麼過這個檢驗:
# · 判準是**「這一行是不是【工單】欄位」**——在不在,不是寫什麼。
# 整條路徑(hooks/lib/dispatch_parse.py**零個「命中某個詞就違規」的比對**。
# · ⇒ 措辭再謹慎也不會被多罰;改寫成別的講法也閃不過去。文字層閘的兩個病都沒有。
# · 也**不需要語意判官**:這條規則本來就是結構性的,用不著 LLM,
# 於是它免費、瞬間、每次結果一樣(比 ask-user-question-guard 更硬的一種閘)。
#
# ━━ 這支同時做兩件事 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# ① **擋**:【工單】以外有實質內容 → exit 2,並指出那些內容該搬去哪一格
# ② **注入**:合規的派工 → exit 0 並用 hookSpecificOutput 把共通規定送給收工方
# (慣例同 subagent-wiki-guard.sh)。
# ⇒ 這是「派工單只剩票號」能成立的前提:交件方式、不准 push main、org 是 inkstone…
# **不必有人記得寫**,機器每次都補。
# leo 的驗收條件之一就是「收工方**沒讀派工單**也知道要貼回原票」。
#
# ━━ 邊界(刻意不管的事)━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# · 派工單裡**沒有**【工單】 → 本閘閉嘴,那是 no-ticket-no-dispatch.sh 的地盤。
# 兩支閘同時開口,收工方會拿到兩份互相打架的教學。
# · 票存不存在、關了沒 → 也是那一支在驗,本閘不重複打 Gitea(本閘不連網)。
#
# 逃生口(留痕、用完即丟,慣例同 micromanage-guard.shask-user-question-guard.sh):
# 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
INPUT="$(cat)"
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(回到「沒有這支閘」的狀態)
# 🔴 四個欄位一次讀完,不要每個欄位各開一支 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:
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
# 子 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
rm -f "/tmp/.dispatch-ok-$SESSION"
NVIOL=0
fi
if [ "$NVIOL" != "0" ]; then
# 訊息用 quoted heredoc(裡面有反引號,不能讓 shell 當命令替換執行掉),
# 佔位符交給 python 換——sed 對正體中文與特殊字元的跳脫太脆。
# 2026-08-26 ask-user-question-guard 真的犯過:訊息裡的反引號被當命令跑,
# 閘照擋,但**它教人怎麼解的那兩行變成空白**,等於沒有出路。)
MSG="$(cat <<'EOF'
🚫 __HEADLINE__leo 2026-08-27inkstone/ISEP#30 comment 4327
__VIOLATIONS__
__CARRY__
━━ 這些內容該去哪:問一句「**這句話換一張票還成立嗎?**」━━━━━━━━━━
還成立 ⇒ **共通規定**(交件方式、不准 push main、org 是 inkstone、先讀 CLAUDE.md…)
改 `docs/governance/dispatch-and-reply-format.md` 的 §2
**本閘每次派工都會自動注入給收工方**,你不必寫、也不會漏。
只有這次 ⇒ **寫進那張票**(main 是哪顆、今天撞過什麼、另一條線在動什麼)
`scripts/ticket say <owner/repo#N> -F <檔>`
然後派工單寫 `【工單】<owner/repo#N> → comment <剛拿到的號碼>`
🔴 **「票上還沒有」不是把它寫進 prompt 的理由——它就是「去把它寫上票」的指令。**
leo 2026-08-27:「**這些為什麼不寫到票裡?**」
寫進 prompt 的後果:agent 被停掉或換人接手,那段事實隨 prompt 一起蒸發。
當天實害:總管停掉重派 3 次,前兩次的任務與 session 事實**全部消失**。
━━ 合格的派工單長這樣(整份,沒有省略)━━━━━━━━━━━━━━━━━━━━━
【工單】inkstone/ISEP#30 → comment 4322
━━ 為什麼連「一句叮嚀」都不行 ━━━━━━━━━━━━━━━━━━━━━━━━━━━
leo 2026-08-27:「**交件方式不需要寫,定義在原則裡,每張票都要做這件事⋯⋯
每次都一樣提取出來變成共通規定**」
你多寫的每一句,都是在造第二份會漂的真相;而收工方讀到的是**你的轉述**,
不是票上的原始證據。
真的有例外 → `touch /tmp/.dispatch-ok-__SESSION__` 後重送(用掉就消失,會留在指令歷史上)。
EOF
)"
VIOL_TEXT="$(printf '%s' "$FACTS" | python3 -c '
import sys, json
try:
d = json.load(sys.stdin)
except Exception:
d = {}
lines = []
for v in d.get("violations", []):
lines.append(" ❌ %s%s" % (v.get("code", ""), v.get("detail", "")))
for name in d.get("retired_seen", []):
lines.append(" ↳ 【%s】這個欄位已經收回了" % name)
print("\n".join(lines))
' 2>/dev/null)"
# 擋下來的時候把那段內容原文印出來——出路是「貼上票」,
# 不印出來就得請人回頭自己找(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"
)
'
exit 2
fi
# 回覆這條路不注入共通規定——收工方早在第一次派工時就拿過了,
# 每一則回覆再灌一次只是噪音(而噪音會讓人學會忽略整段注入)。
[ "$CHANNEL" = "reply" ] && exit 0
# ── 合規 → 注入共通規定 ──────────────────────────────────────────────
# 這是「派工單只剩票號」的另一半:那些每次都一樣的規定不必有人記得寫,機器每次都補。
# 來源是 docs/governance/dispatch-and-reply-format.md 的 INJECT 區段——
# **只有一份**,改那裡等於改所有派工(不會有第二份漂走的副本)。
DOC="${CLAUDE_PLUGIN_ROOT:-$(cd "$HERE/.." && pwd)}/docs/governance/dispatch-and-reply-format.md"
[ -f "$DOC" ] || exit 0
DFG_DOC="$DOC" python3 -c '
import json, os, re, sys
try:
text = open(os.environ["DFG_DOC"], encoding="utf-8", errors="ignore").read()
except Exception:
sys.exit(0)
m = re.search(r"<!-- INJECT:BEGIN.*?-->\n(.*?)<!-- INJECT:END -->", text, re.S)
if not m:
sys.exit(0)
body = m.group(1).strip()
print(json.dumps({
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"additionalContext":
"【自動注入:派工共通規定】\n"
"(來源 docs/governance/dispatch-and-reply-format.md §2——"
"派工單只給票號是刻意的,這些每次都一樣的東西由機器補,不靠人記得寫)\n\n"
+ body,
}
}, ensure_ascii=False))
' 2>/dev/null
exit 0
+182 -59
View File
@@ -13,27 +13,72 @@
#
# ① `[ "$RUNNING" -gt 0 ] && exit 0`
# 有任何 agent 在跑就閉嘴 ⇒ **它量的是工廠,不是工頭**。
# 2026-08-16 一天四次停工**全部發生在有 agent 在跑的時候**:
# 產線忙著做 A,總管宣告了 B,然後沒有人做 B。工廠沒停,停的是按 trigger 的人。
#
# ② 池子查的是 `repos/Leo/$repo`——**org 在 2026-08-13 搬到 `inkstone`**。
# 實測:`Leo/arcrun-rag` 回 `not found`(被當成 0),`inkstone/arcrun-rag` 有 16 張。
# ⇒ 池子恆為 0 ⇒ 第 53 行直接 exit 0。**這道閘等於不存在。**
# (同族第四顆搬遷遺留座標:`ARCRUN_SHIP_NS``ARCRUN_REPO`MEMORY.md 索引/本檔。)
#
# ② 池子查的是 `repos/Leo/$repo`——**org 在 2026-08-13 搬到 `inkstone`**(已修)。
# ③ 結尾 `exit 0`——純提醒。leo 2026-08-16:「**提醒可以忽略,deny 不能**。」
#
# ─────────────────────────────────────────────────────────────────
# 🔴 2026-08-23 第四輪(inkstone/ISEP#30):**這道閘在懲罰誠實與謹慎。**
#
# 雲端驗收連續三次被它擋下,三次都不是宣告意圖:
# ① 「那句是等你,不是我的下一步」 ← 在**否認**自己有下一步
# ② 「這不是『下一步我要做 X』」 ← **引用**閘自己的訊息來說明沒在做
# ③ 貼出閘自己的**正則原始碼**來舉報這個 bug ← code block 裡的**引用**
#
# 真兇有四個,而且沒有一個是「例外沒列夠」:
#
# (a)**DECL 會匹配裸的「下一步」三個字。** 舊式 `下一步(我|就是|是)?[:]?\s*`
# 每一節都是可選的 ⇒ 整條 alternative 退化成「文中出現『下一步』」。
# ⇒ 只要**提到**這道閘在講什麼,就會被這道閘咬。
# 修法=**要求它後面真的接一個動作動詞**。這是**收緊**(要求更多才算命中),
# 方向與「再加幾個關鍵字例外」相反——例外清單會越加越長、越長越誤攔。
#
# (b)**「引用」被當成「主張」。** 舊版只剝 `>` 引言與長「」引號,
# 不認 markdown 的 ```code fence``` 與行內 `code`。
# ⇒ 貼原始碼舉報 bug 的人,會被他要舉報的那支閘擋下。
# 修法=把引用性標記整段換成**哨兵字元**(不是刪掉):
# - 刪掉會把外層句構攪碎(2026-08-17 就是這樣漏掉「回『規劃』我就派人」)
# - 換成哨兵則**內層的宣告消失、外層的句構留著** ⇒ 兩個方向同時修好
#
# (c)**看錯了「回合的收尾」。** 舊版取 `blocks_text[-1]`(最後一則文字),
# 但那則文字後面可能還有 tool_use ⇒ 宣告其實已經被兌現了。
# 修法=只看**最後一個動作之後**的文字。**收尾在動作上 ⇒ 根本不觸發。**
#
# (d)**訊息承諾的出路,程式碼裡沒有。** 舊訊息教人走
# 「選項③:改寫那句話,說明它在等什麼」,
# 但放行分支只有「有 Agent/Task」與「附近有 #票號」兩條 ⇒
# 唯一走得通的路是**不寫那三個字**,而那正是同一則訊息 🔴 明文禁止的動作。
# 修法=給選項③一個**真的存在**的機械形式:`⏸ 等:<在等什麼>`。
# 它是**白名單標記**(要刻意寫才會有),不是黑名單,不會被誤觸;
# 而且**留痕**——滿足「不准只是把那句話刪掉再送一次」。
#
# ⚠️ **刻意的取捨(寫下來,下一輪不要當成 bug 又改回去)**:
# - `#票號` 改成搜「整段收尾文字」而不是宣告句附近 ±N 字。
# 理由:票號常寫在行內 `code` 裡,剝完就找不到了 ⇒ 誤攔。
# **誤攔的代價高於漏攔**——誤攔會訓練人忽略警報,那比沒有警報更糟。
# - 「下一步」後面不吃逗號(「下一步,我去補 X」會漏)。
# 理由:吃了逗號,「不是我的下一步,我剛剛已經做完了」就會被咬(過去式當未來式)。
# - 「身為…我要…我才…」(本 repo 規定的 User Story 票名格式)整段換哨兵。
# 票名是**被命名的物件**,不是我對下一步的主張。
#
# ── 現在量什麼 ─────────────────────────────────────────────────
#
# 主閘(exit 2,會擋):**宣告了下一步,卻沒有派工憑證。**
# 憑證 這個回合真的呼叫過 Agent/Task(而那條路上已有 no-ticket-no-dispatch
# 驗票號**「有派工」本身就蘊含「有票」**,不必在這裡重複驗票。
# 也接受:宣告句裡直接寫明它在誰手上(帶 `#數字` 的票號指涉)。
# 主閘(exit 2,會擋):**宣告了下一步,卻沒有按下任何 trigger。**
# trigger 這個回合真的呼叫過 Agent/Task
# no-ticket-no-dispatch 已在那條路上驗票號 ⇒ 這裡不必重複驗
# ② 收尾文字裡有 `#票號` 指涉(=已經有人在做)
# ③ 收尾文字裡有 `⏸ 等:<在等什麼>`(=它現在不該做,且說明了在等什麼)
# ④ 回合收尾在動作上(最後一個動作之後沒有文字)
#
# 副閘(exit 0,只提醒):真的全廠停工(零 agent + 池子有票)。org 已修。
# 副閘(exit 0,只提醒):真的全廠停工(零 agent + 池子有票)。
#
# 🔴 **不擋純對話回合**:沒有宣告下一步就不觸發。
# 這是刻意的——永遠在響的警報等於訓練人忽略它(見 branch-holds.md 的同款教訓)。
#
# 🔴 **不設 `stop_hook_active` 逃生門**(與 empty-handed-stop-guard 不同)。
# leo 2026-08-16:「提醒可以忽略,deny 不能。」
# 之所以敢這樣,是因為出路③現在**真的存在** ⇒ 不可能把人鎖死在無解迴圈裡。
#
# 迴歸測試:`hooks/tests/factory-idle-guard.test.sh`23 向,誤攔與漏攔兩個方向都測)
set -eu
PROJ="${CLAUDE_PROJECT_DIR:-$(pwd)}"
@@ -46,11 +91,11 @@ import sys, json, os, re
try:
d = json.load(sys.stdin)
except Exception:
print("SKIP"); raise SystemExit
print("SKIP:bad-payload"); raise SystemExit
tp = d.get("transcript_path") or ""
if not tp or not os.path.exists(tp):
print("SKIP"); raise SystemExit # 讀不到就別亂擋
print("SKIP:no-transcript"); raise SystemExit # 讀不到就別亂擋
rows = []
try:
@@ -61,7 +106,7 @@ try:
try: rows.append(json.loads(line))
except Exception: pass
except Exception:
print("SKIP"); raise SystemExit
print("SKIP:unreadable"); raise SystemExit
# 這個回合=最後一則「真的來自使用者」的訊息之後(工具結果不算)
start = 0
@@ -73,63 +118,132 @@ for i, r in enumerate(rows):
start = i
turn = rows[start:]
# 依序攤平這個回合的 assistant 區塊:("tool", 名字) / ("text", 內容)
seq = []
dispatched = False
blocks_text = []
for r in turn:
if r.get("type") != "assistant":
continue
for b in (r.get("message") or {}).get("content") or []:
if not isinstance(b, dict):
continue
if b.get("type") == "tool_use" and b.get("name") in ("Agent", "Task"):
dispatched = True
if b.get("type") == "tool_use":
if b.get("name") in ("Agent", "Task"):
dispatched = True
seq.append(("tool", b.get("name") or ""))
elif b.get("type") == "text":
blocks_text.append(b.get("text") or "")
seq.append(("text", b.get("text") or ""))
if dispatched:
print("OK"); raise SystemExit # 按了 trigger ⇒ 放行
print("OK:dispatched"); raise SystemExit # 出路① 按了 trigger ⇒ 放行
# 🔴 只看**最後一則**文字,不看整個回合(2026-08-16 第一次實跑就誤攔,修正
# 🔴 只看「最後一個動作**之後**」的文字(2026-08-23 修
# 病灶是「回合終止在宣告上」⇒ 該看的是那個終止動作本身。
# 掃整個回合會在「我這回合稍早說要讀 X、然後真的讀了」這種句子上開火——
# 那是**已完成事項的敘述**,不是未兌現的意圖。誤攔會訓練人忽略警報
# 而那比沒有警報更糟(同 branch-holds.md 的教訓)。
text = blocks_text[-1] if blocks_text else ""
# 舊版取最後一則文字,但那則文字後面可能還有 tool_use——
# 「我這回合說要讀 X、然後真的讀了」會被當成未兌現的意圖。誤攔會訓練人忽略警報
tail = []
for kind, val in reversed(seq):
if kind == "tool":
break
tail.append(val)
raw = "\n".join(reversed(tail))
if not raw.strip():
print("OK:ended-on-action"); raise SystemExit # 出路④ 收尾在動作上 ⇒ 沒有懸空的宣告
# 出路③:明講在等什麼(在剝標記**之前**檢查,理由本身常寫在引號/code 裡)
if re.search(r"⏸\s*等\s*[:]\s*\S{2,}", raw):
print("OK:waiting-declared"); raise SystemExit
# ── 把「引用性內容」蓋掉:引用 != 主張 ──────────────────────────
# 🔴 **等長**替換(蓋成同樣長度的哨兵),不是刪掉,有兩個理由:
# ① 刪掉會把外層句構攪碎——2026-08-17 就是這樣漏掉「回『規劃』我就派人」。
# 蓋成哨兵則內層的宣告消失、外層的句構留著,誤攔與漏攔一起修好。
# ② 等長 ⇒ 位移可以原封不動對回 raw ⇒ 底下的「票號要在宣告句附近」
# 可以在**原文**上量。否則票號寫在行內 `code` 裡會被自己剝掉而找不到,
# 逼得只能全篇搜——那會把閘變鈍(2026-08-23 拿真 transcript 量到:
# 全篇搜的話 26 個真實回合有 20 個是靠「文中某處有票號」放行的)。
S = "※"
def blank(m):
return S * (m.end() - m.start())
text = raw
text = re.sub(r"```.*?```", blank, text, flags=re.S) # markdown 圍欄 code
text = re.sub(r"~~~.*?~~~", blank, text, flags=re.S)
text = re.sub(r"`[^`\n]*`", blank, text) # 行內 code
text = re.sub(r"^[ \t]*>.*$", blank, text, flags=re.M) # 整段引言(leo 的原話)
for _ in range(3): # 引號(含巢狀),由內而外
text = re.sub(r"[「『][^「」『』]*[」』]", blank, text)
# 本 repo 規定的 User Story 票名:是被命名的物件,不是我對下一步的主張
text = re.sub(r"身為[^\n]{0,40}?我要[^\n]{0,80}?我才[^\n]{0,60}", blank, text)
assert len(text) == len(raw) # 位移要能對回原文
# ── 宣告下一步的句型 ──────────────────────────────────────────
# 🔴 每一條都**必須**接到一個動作動詞(A)才算命中。
# 舊版少了這個要求 ⇒ 裸的「下一步」「接下來我」就開火 ⇒ 討論這道閘的人被這道閘擋。
A = r"(?:去|來|做|派|審|跑|查|補|建|寫|改|推|驗|清|落|盤|整理|處理|部署|測)"
N = r"[^。!?;,,\n]{0,16}" # 同一句、不跨逗號
DECL = re.compile("|".join([
# 🔴 主詞是「你」的下一步 != 我的下一步(2026-08-23 拿真 transcript 量出來的第四類誤攔)
# 舊閘在 26 個真實回合終止點上擋了 2 次,兩次咬的都是我在**交代 leo 該做什麼**:
# 「**下一步還是那一個動作**:**你**把 feat/... 併進 main」
# 「## **你下一步**(兩招,先便宜的)」
# ⇒ 這是**主詞檢查**(誰要動手),不是關鍵字例外。
# 只掛在這條 alternative 上——「你點頭我就做」那條的主詞本來就是我,不受影響。
r"(?P<nextstep>下一步[:]?\s*(?!你|妳|leo|Leo)(?:我|就是|是)?\s*" + N + A + r")",
r"接下來我\s*" + N + A,
r"我(?:現在|接著|等下|等一下)(?:就)?\s*" + N + A,
r"我(?:要|會)(?:去|來)?\s*" + N + A,
r"稍後(?:我|再)\s*" + N + A,
r"之後我(?:會|要)\s*" + N + A,
# 2026-08-17 leo 實撞的一族:**把請示寫成條件句**——
# 沒問號、沒疑問詞,卻把動作的觸發權交回 leo。功能上是請示,句型上不像。
r"回[「『]?[^」』\n]{0,12}[」』]?(?:我)?就\s*" + N + A,
r"說一聲(?:我)?(?:就)?\s*" + N + A,
r"你(?:點頭|說可以|確認)(?:了)?(?:我)?(?:就)?\s*" + N + A,
r"(?:確認|核准|同意|批准)(?:過|了)?(?:之)?後(?:我)?(?:才|再|就)\s*" + N + A,
# 空手停在別人身上(出路③ 的 ⏸ 標記是它唯一合法的形式)
r"我(?:就)?(?:等|待)你",
r"等你(?:說|回|點頭|確認)",
r"我(?:就)?(?:不再|先不)(?:自己)?(?:動|做|派)",
r"我(?:就)?(?:一次)?(?:落|派|做|補|審|清)(?:完|掉)?\s*[。,,]?\s*$",
]))
SECOND = re.compile(r"你|妳|leo|Leo")
hit = None
for m in DECL.finditer(text):
# 「下一步」這條要看主詞:前面 8 字裡出現第二人稱 ⇒ 那是**他的**下一步,不是我的
if m.group("nextstep") and SECOND.search(text[max(0, m.start() - 8): m.start()]):
continue
hit = m
break
# 引用 leo 的話不算我的宣告(整段引言/引號內)——它常含「下一步」等字樣
text = re.sub(r"^\s*>.*$", "", text, flags=re.M)
# 🔴 2026-08-17 修:舊版把「所有」引號內容都刪掉,包括我自己句子裡的關鍵詞。
# leo 實撞:我寫「回『規劃』我就派人盤這份計畫」,`規劃` 被吃掉後變成
# 「回我就派人盤這份計畫」,DECL 一個都不匹配 ⇒ 該攔的沒攔。
# ⇒ 只刪「夠長的引言」(leo 的話通常成句),短引號是我自己的用詞,留著。
text = re.sub(r"「[^」]{12,400}」", "", text)
# 宣告下一步的句型(刻意收窄:只認「我接下來要做」,不認「現在的狀態是」)
DECL = re.compile(
r"(下一步(我|就是|是)?[:]?\s*(?!不是宣告)|接下來我|我(現在|接著|等下|等一下)(就)?(去|來|做|派|審|跑)"
r"|我(要|會)(去|來)?(做|派|審|跑|查|補|建)|稍後(我|再)|之後我(會|要)"
# 🔴 2026-08-17 leo 實撞補的一族:**把請示寫成條件句**——
# 沒問號、沒疑問詞,卻把動作的觸發權交回 leo。功能上是請示,句型上不像。
# 實例:「回『規劃』我就派人盤這份計畫」/「說一聲我就落」/「你點頭我就做」
r"|回[「『]?[^」』\n]{0,12}[」』]?(我)?就|說一聲(我)?(就)?|你(點頭|說可以|確認)(了)?(我)?(就)?"
r"|(確認|核准|同意|批准)(過|了)?(之)?後(我)?(才|再|就)"
r"|我(就)?(等|待)你|等你(說|回|點頭|確認)"
r"|我(就)?(不再|先不)(自己)?(動|做|派)"
r"|我(就)?(一次)?(落|派|做|補|審|清)(完|掉)?[。,,]?\s*$)")
hit = DECL.search(text)
if not hit:
print("OK"); raise SystemExit # 沒宣告 ⇒ 純對話回合,不擋
print("OK:no-declaration"); raise SystemExit # 沒宣告 ⇒ 純對話回合,不擋
# 宣告句附近有票號指涉(=已經說明它在誰手上/哪張票)⇒ 放行
seg = text[max(0, hit.start() - 200): hit.end() + 400]
if re.search(r"#\d{1,5}", seg):
print("OK"); raise SystemExit
# 出路②:宣告句**附近**有票號指涉(=已經說明它在誰手上/哪張票)
# 在 raw 上量(等長替換保證位移對得回去)⇒ 票號寫在行內 code 裡也算數,
# 而「文章別處剛好提到某張票」不算——那不是在講這件事在誰手上。
if re.search(r"#\d{1,5}", raw[max(0, hit.start() - 200): hit.end() + 400]):
print("OK:ticket-referenced"); raise SystemExit
print("DECLARED_NO_TRIGGER::" + text[max(0, hit.start()-60): hit.end()+120].replace("\n", " ")[:200])
' 2>/dev/null || echo SKIP)
print("DECLARED_NO_TRIGGER::" + raw[max(0, hit.start()-60): hit.end()+120].replace("\n", " ")[:200])
' 2>/dev/null || echo "SKIP:crash")
STAMP=$(date "+%Y-%m-%d %H:%M:%S" 2>/dev/null || echo "?")
LOGDIR="$PROJ/.claude/hooks"
# 留痕(inkstone/InkStoneCo#48:閘只記「擋下」的話分母未知,回答不了「它有沒有在運作」)
# ⇒ 擋下與放行都記。目錄不在就安靜跳過(測試環境/別的 repo 不該噴 redirect 錯誤到 stderr)。
note() {
if [ -d "$LOGDIR" ]; then
printf '| %s | %s | %s |\n' "$STAMP" "$1" "$2" >> "$LOGDIR/factory-idle-guard.log" 2>/dev/null || true
fi
}
case "$VERDICT" in
DECLARED_NO_TRIGGER::*)
QUOTE=$(printf '%s' "$VERDICT" | sed 's/^DECLARED_NO_TRIGGER:://')
note "⛔ 擋下" "$QUOTE"
cat >&2 <<MSG
🏭 稼動率警察:**你宣告了下一步,但這個回合沒有按下 trigger。**
@@ -147,19 +261,28 @@ leo 2026-08-16(本閘的規格):
⇒ 在結構上 X 永遠落在回合結束之後。**不是忘記,是把 X 寫進了終結回合的動作裡。**
而說出意圖會消解掉做它的壓力——一份清楚的計畫**讀起來像進度**。
━━ 現在怎麼過這道閘(擇一)━━━━━━━━━━━━━━━━━━━━━━━━━
━━ 現在怎麼過這道閘(四條,程式碼裡都真的有對應的分支)━━━━━━━━━
① **現在就按 trigger**:這個回合直接呼叫 Agent/Task 派出去。
沒有票 → 先 \`scripts/ticket where <關鍵字>\` 搜該掛哪張,
\`ticket say <owner/repo#N> -F <檔>\`,然後帶【工單】派工。
② **它已經在別人手上**:把票號寫進那句話(例:「已派給 #44 comment 2761」)。
票號指涉就放行——那不是「我等下做」,是「已經有人在做」。
③ **它其實不該現在做**改寫那句話,說明它在等什麼(前置沒解除/是 leo 的閘),
不要寫成「下一步我要做 X」。
② **它已經在別人手上**:把票號寫進這段收尾文字(例:「已派給 inkstone/ISEP#30」)。
\`#數字\` 就放行——那不是「我等下做」,是「已經有人在做」。
③ **它其實不該現在做**寫一行說明它在等什麼,格式就是這一個標記——
⏸ 等:<在等什麼>
例:\`⏸ 等:leo 親手跑 scripts/github-arm.sh 解保險(D20 人閘)\`
\`⏸ 等:arcrun-rag 那條 PR 先併,前置沒解除\`
🔴 這個標記**要刻意寫才會有**,寫了就留痕——它取代不了做事,
但它讓「現在不該做」跟「宣告了不做」在機器上分得開。
④ **這個回合收尾在動作上**:最後一個工具呼叫之後不要再補一句宣告。
🔴 **不准只是把那句話刪掉再送一次。** 刪掉宣告=那件事從此沒人記得,
比宣告了沒做更糟——它連痕跡都不留。
比宣告了沒做更糟——它連痕跡都不留。要嘛按 trigger,要嘛用 ③ 留下 ⏸ 記號。
MSG
exit 2 ;;
*)
note "✅ 放行" "$VERDICT" ;;
esac
# ── 副閘:真的全廠停工(零 agent + 池子有票)──────────────────────
+13 -3
View File
@@ -1,4 +1,7 @@
#!/bin/bash
# 管什麼: 對外接觸 GitHub 的寫入動作(gh CLI 高頻 API、git push/remote add 指向 github.com)——一律先擋,要 leo 手動解保險才放行。
# 為什麼: D202026-07-02)——兩個帳號因高頻寫入被 GitHub flag 永久拿不回;讀不設限(讀不是 abuse),只擋寫。
# 誤觸時怎麼關: 讀取(clone/fetch/pull/curl 抓檔)一律放行;真要寫 GitHubleo 跑 scripts/github-arm.sh 解保險;純粹提到 github.com 的文字(heredoc/commit message/留言)不算數,見下方判準。
# github-contact-guard.sh — GitHub 接觸保險(D202026-07-02
# 背景:兩個帳號因高頻動作被 flag 拿不回(幾十顆星+issues 全損)。
# 設計:戰鬥機武器保險模式——平時所有 github.com 接觸一律機械擋下(exit 2),
@@ -33,6 +36,13 @@ except Exception:
[ -z "$CMD" ] && exit 0
# 2026-08-20inkstone/InkStoneCo#23 comment, #56):a heredoc body is data, not an
# instruction. Writing docs/TESTING.md (one example line of a real push command,
# meant for a human to try later) got blocked because the whole heredoc body was
# scanned for keywords. Strip heredoc bodies before matching; on failure fall back
# to the original string (fail-open on "did this helper run", not on the verdict).
CMD=$(printf '%s' "$CMD" | python3 "$(dirname "$0")/lib/strip_heredoc.py" 2>/dev/null || printf '%s' "$CMD")
# 命中判定(D20 邊界,2026-07-05 leo 拍板——Facebook 比喻定調):
# GitHub 不在乎你「讀」(clone/fetch/抓 release,不管實名匿名、自己的還別人的——那是它原本的功能,
# 像 FB 不禁你讀貼文)。它 abuse-detect 的是「機器人一直改/寫」(高頻 push、Actions fan-out、API 寫)
@@ -43,10 +53,10 @@ except Exception:
# 放行:git clone/fetch/pull/ls-remote(任何 repo,帶不帶認證都是讀)、curl/wget、go get/pip。
HIT=""
# ① gh CLI —— 高頻 API,全擋(讀寫混雜且走你 token,保守全擋;真要唯讀查詢個案 arm)
if printf '%s' "$CMD" | grep -qE '(^|[;&|(]|\s)gh\s+(api|repo|issue|pr|auth|search|release|run|workflow|gist|browse)\b'; then
if printf '%s' "$CMD" | grep -qE '(^|[;&|(`]|&&|\|\|)[[:space:]]*gh[[:space:]]+(api|repo|issue|pr|auth|search|release|run|workflow|gist|browse)\b'; then
HIT="gh CLI(高頻 API,走你的 token"
# ② git 寫入動詞指向 githubpush / remote add 為 push 鋪路)—— 寫入,擋
elif printf '%s' "$CMD" | grep -qiE 'git\s+(push|remote\s+add)([^|;&]*)(github\.com)'; then
elif printf '%s' "$CMD" | grep -qiE '(^|[;&|(`]|&&|\|\|)[[:space:]]*git[[:space:]]+(push|remote[[:space:]]+add)([^|;&]*)(github\.com)'; then
HIT="git 寫入 → githubpush/remote add"
# ②b 🔴 2026-08-05 補漏:上面那條只認指令裡的 **github.com 字面**
# ⇒ `git push origin main`remote 名指向 GitHub**完全不會被攔**。
@@ -60,7 +70,7 @@ elif printf '%s' "$CMD" | grep -qiE 'git\s+(push|remote\s+add)([^|;&]*)(github\.
# 不必等 remote 名稱解析成功才判斷得出來,而 remote 名稱解析在某些 cwd 下會失敗
# (見檔頭「2026-08-10 leo 簡化」那段的實撞紀錄)。字面查得到就直接判定,
# 查不到才退回舊的「解 remote 名稱」那條路——兩條路都失手才會誤放行。
elif printf '%s' "$CMD" | grep -qiE '(^|[;&|(]|\s)git\s+([^|;&]*\s)?push(\s|$)'; then
elif printf '%s' "$CMD" | grep -qiE '(^|[;&|(`]|&&|\|\|)[[:space:]]*git[[:space:]]+([^|;&]*[[:space:]])?push([[:space:]]|$)'; then
# 🔴 總管 2026-08-10 收窄:原版寫成「整串裡有 github.com 就擋」,
# 於是**連 commit message 提到那個網址都會被擋**(我自己第一次要 commit 就撞到)。
# ⇒ 改成必須是「push 的目標」:github.com 要出現在 push 之後、且中間不跨命令分隔符。
+147 -9
View File
@@ -40,6 +40,34 @@
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/ticket-api-bypass-guard.sh"
},
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/comment-carries-task-guard.sh"
},
{
"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"
},
{
"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"
}
]
},
@@ -94,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"
}
]
},
@@ -128,6 +160,22 @@
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/no-ticket-no-dispatch.sh"
},
{
"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"
}
]
},
@@ -163,6 +211,40 @@
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/no-ticket-no-dispatch.sh"
},
{
"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": [
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/ask-user-question-guard.sh"
}
]
}
@@ -178,6 +260,26 @@
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/skill-deploy-drift-guard.sh"
},
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/isep-presence-beacon.sh"
},
{
"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"
}
]
}
@@ -200,6 +302,14 @@
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/factory-idle-guard.sh"
},
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/mainline-idle-guard.sh"
},
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/pr-verdict-guard.sh"
}
]
},
@@ -243,20 +353,12 @@
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/claim-verify-police.sh"
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/countdown-guard.sh"
}
]
}
],
"SubagentStop": [
{
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/subagent-claim-worksheet.sh"
}
]
},
{
"hooks": [
{
@@ -304,6 +406,15 @@
}
]
},
{
"matcher": ".*kbdb_(search|get_record|query|graph_neighbors|get_map).*",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/kbdb-evidence-stamp.sh"
}
]
},
{
"matcher": "Agent|Task",
"hooks": [
@@ -311,9 +422,36 @@
"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"
},
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/baton-handback-guard.sh"
}
]
},
{
"matcher": "SendMessage",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/investigate-first-stamp.sh"
}
]
}
],
"UserPromptSubmit": [
{
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/countdown-guard.sh"
}
]
}
+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
+100
View File
@@ -0,0 +1,100 @@
#!/usr/bin/env python3
"""判斷一份派工單(Agent/Task 的 prompt)有沒有把「不可逆動作」寫成收工方可以自己執行的選項。
stdin: 派工單全文 stdout: JSON {"verdict": "BLOCK"/"OK", "hits": [[行號, 該行, 命中詞], ...]}
事故Gitea Leo/arcrun-rag#332026-08-09)】
subagent 未經 leo 同意刪掉兩條遠端分支根因不是它亂來是派工單寫了
作廢就刪掉分支等於總管預先授權了一個不可逆動作刪掉的那條裡還有一件
它自己標明 leo 排序的工作一併蒸發
對照組同一天同一個總管#14 的派工單寫
🔴 刪資料不可逆動手前先把清單寫在 issue 留言等總管回覆確認才執行
那個 agent 真的停下來等同一個人一次寫對一次寫錯 證明只能靠機械閘不能靠自律
判準
- 派工單裡出現不可逆動作的動詞對象刪分支drop tablerm -rfforce push
- 且該處**沒有被否定**不是不准刪這種禁令句
- 且全文**沒有**停下來等回覆才執行這類守門片語
判定為把不可逆動作寫成可以自己執行的選項擋下
同時符合上述前兩點但全文有守門片語 判定為 #14 那種「先回報、等確認」寫法,放行。
豁免命中那一行尾巴加 `irreversible-ok`留痕式豁免比照本目錄其他 guard 的慣例
"""
import json
import re
import sys
# 不可逆動作:動詞 + 常見對象(分支/資料/表/repo/檔案/環境…)
IRREVERSIBLE_RE = re.compile(
r"("
r"刪(?:除|掉)?[^\n,。!?、;;()()]{0,12}(?:分支|branch|資料|data|table|表|db|資料庫|repo|檔案|record|entry|遠端|remote|環境|instance|實例)"
r"|砍(?:掉)?[^\n,。!?、;;]{0,6}(?:分支|branch)"
r"|洗掉"
r"|清空"
r"|格式化"
r"|(?:硬|真)刪(?:除)?"
r"|永久(?:刪除|移除)"
r"|drop\s+table"
r"|rm\s+-rf"
r"|reset\s+--hard"
r"|force[-\s]?push"
r"|git\s+push[^\n]{0,20}(?:--force|-f\b)"
r"|git\s+branch\s+-D"
r"|git\s+push[^\n]{0,20}--delete"
r"|delete[^\n]{0,12}(?:branch|data|table|repo|record)"
r")",
re.IGNORECASE,
)
# 否定:這段話是在「禁止」不可逆動作,不是授權它
NEGATION_RE = re.compile(
r"(不准|不可|不得|不要|禁止|勿|別|莫|no\s|never\s|don't\s|do not\s)\s*$",
re.IGNORECASE,
)
# 守門片語:明確要求「停下來,等人回覆才執行」
GATE_RE = re.compile(
r"("
r"先.{0,25}留言.{0,15}等.{0,12}(?:回覆|確認|同意)"
r"|等.{0,10}(?:leo|總管|leo21c).{0,15}(?:回覆|確認|同意|批准).{0,10}(?:才|再).{0,12}(?:執行|動手|做|刪|砍)"
r"|不准動手"
r"|停下來.{0,10}等"
r"|動手前.{0,15}(?:先|等待|等)"
r"|等\s*(?:leo|總管)\s*(?:回覆|確認|同意|拍板)"
r"|wait\s+for\s+(?:confirmation|approval|leo)"
r"|before\s+(?:doing so|acting|deleting|executing)[^\n]{0,30}(?:wait|confirm)"
r")",
re.IGNORECASE,
)
def check(text: str):
lines = text.split("\n")
gate_found = bool(GATE_RE.search(text))
hits = []
for i, line in enumerate(lines, start=1):
if "irreversible-ok" in line:
continue
for m in IRREVERSIBLE_RE.finditer(line):
before = line[max(0, m.start() - 8): m.start()]
if NEGATION_RE.search(before):
continue
hits.append([i, line.strip(), m.group()])
if not hits:
return "OK", hits, gate_found
if gate_found:
return "OK", hits, gate_found
return "BLOCK", hits, gate_found
if __name__ == "__main__":
text = sys.stdin.read()
verdict, hits, gate_found = check(text)
print(json.dumps(
{"verdict": verdict, "hits": hits, "gate_found": gate_found},
ensure_ascii=False,
))
+69
View File
@@ -0,0 +1,69 @@
#!/usr/bin/env bash
# isep-presence-beacon.sh — SessionStart:報出「ISEP 真的載入了,幾版、幾支閘」
#
# 這不是閘,是**信標**。存在的理由是 2026-08-20 的雲端事故:
# 雲端 session 的閘全滅,而三個驗證步驟全部回綠——因為它們沒有鑑別力
# (`git tag` 在三支閘的白名單裡;`Skills(9)/Hooks(5)` 剛好是薄殼自己的 .claude/ 產生的數字)。
#
# 🔴 鑑別力就是這支的全部意義:
# 這行出現 ⇒ plugin 一定載入了(因為它自己就住在 plugin 裡)
# 這行不見 ⇒ plugin 沒載入,那個 session 是零閘狀態
# ——沒有第三種情況,也沒有「剛好也會過」的巧合。
set -uo pipefail
ROOT="${CLAUDE_PLUGIN_ROOT:-}"
[ -n "$ROOT" ] || exit 0
VER="$(sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' \
"$ROOT/.claude-plugin/plugin.json" 2>/dev/null | head -1)"
VER="${VER:-未知}"
# 🔴 數「真的被註冊的」,不是數目錄裡有幾個 .sh(leo 的雲端驗收 2026-08-23 抓到):
# 舊寫法 `ls hooks/*.sh` 把 `pre-write-guard.template.sh`(樣板,不是閘)
# 與兩支沒掛註冊的輔助檔一起算進去 ⇒ 報 45,實際註冊 42。
# 這個數字是 leo 判斷「這個 session 有沒有閘」的唯一介面——**多報就是假綠**。
# (查過歷史:本檔自 daa1674 建立以來只有那一版,沒有別的分支修過這段。)
GATES="$(grep -oE 'hooks/[a-zA-Z0-9._-]+\.sh' "$ROOT/hooks/hooks.json" 2>/dev/null \
| sort -u | wc -l | tr -d ' ')"
case "$GATES" in
''|*[!0-9]*|0)
GATES="$(ls "$ROOT"/hooks/*.sh 2>/dev/null | grep -cv '\.template\.sh$' | tr -d ' ')" ;;
esac
# 🔴 講清楚「這一份是誰」(leo 2026-08-23 的雲端驗收卡在這裡整整一輪):
# 同一台機器上 ISEP 可能有兩份——marketplace 裝的 plugin 快取,
# 與 repo 裡 vendor 進去的複製本。**兩份都會印這一行**,而版本號一樣時分不出誰在說話。
# 「閘到底從哪一份走」正是決定「另一份能不能拆」的唯一判準
# ⇒ 讓路徑自己講:快取在 plugins/cache/ 底下,vendor 的在 repo 的 .claude/ 底下。
# (查過歷史:本檔只有 daa1674 建立、b1f399f 改閘數兩輪,沒有人試過標示來源,也沒有被否決的方案。
# KBDB 語意搜尋這段開發史:0 命中,該餵。)
case "$ROOT" in
*/plugins/cache/*) SRC="pluginmarketplace 裝的)" ;;
*/.claude/isep*) SRC="vendorrepo 裡的複製本)" ;;
*) 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}"
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
+7 -1
View File
@@ -1,4 +1,7 @@
#!/bin/bash
# 管什麼: Bash 分支擋「繞過 API 牆、直接對那顆資料庫執行 SQL」的指令;Write/Edit 分支擋新增 DDL/原生 SQL/新 D1 binding。
# 為什麼: leo 2026-08-07 立 D38——KBDB 只能走 API,零 SQL、永不加表;規則掛在「KBDB」這個詞上,容易在想著「D1/建表」時漏載入。
# 誤觸時怎麼關: 該行尾加 kbdb-sql-ok(留痕,commit 說明理由);Bash 分支的判準邏輯在 hooks/lib/kbdb_cmd_check.py,誤攔先查那支。
# PreToolUse hook — KBDB 是 API-as-Wall:零 SQL、永不加表(L3 硬攔截)
#
# 【leo 2026-08-07 立】原話:
@@ -42,6 +45,9 @@ else
BASH_CMD=""
fi
if [ -n "${BASH_CMD:-}" ]; then
# 逃生口跟 Write/Edit 分支同一個標記(留痕,commit 說明理由)——加了這支才會真的擋,
# 之前 Bash 分支引用的檔案不存在、永遠印 "OK",逃生口自然也沒人需要,現在補上。
case "$BASH_CMD" in *kbdb-sql-ok*) exit 0 ;; esac
# 🔴 2026-08-07 修正:第一版做「整條命令字串比對」,結果**第四次誤擋總管**——
# 誤擋的是 `git commit -m "...引用了那個命令字面..."`:那是在**描述**這件事,
# 不是在執行它。同一天已被自己的閘誤擋三次(Task 分支),這是第四次。
@@ -49,7 +55,7 @@ if [ -n "${BASH_CMD:-}" ]; then
# 引號內、heredoc 內、commit message 內的同樣字面一律不算。
# 判斷邏輯住在獨立檔(見該檔開頭:內嵌逃逸讓這道閘改一次壞一次)
BASH_VERDICT=$(printf '%s' "$BASH_CMD" \
| python3 "$(dirname "$0")/kbdb_cmd_check.py" 2>/dev/null || echo "OK")
| python3 "$(dirname "$0")/lib/kbdb_cmd_check.py" 2>/dev/null || echo "OK")
if [ "$BASH_VERDICT" = "BAD" ]; then
cat >&2 <<'EOB'
🧱 kbdb-api-wall-guardBash):偵測到繞過 API 牆、直接對那顆資料庫執行 SQL 的命令。
+143
View File
@@ -0,0 +1,143 @@
#!/usr/bin/env bash
# 管什麼: 記下「這個 session 看到的每一筆 KBDB 資料,是從哪一條路拿到的」——搜尋出來的,還是走產品檢索路徑拿到的。
# 為什麼: 2026-08-27 inkstone/Arcrun#167 comment 4865,總管把 kbdb_search 的輸出當成「產品檢索壞了」的證據,退回一張已經做完的票、重派一條線。錯的不是那句話,是那條路。
# 誤觸時怎麼關: 這支不擋任何東西,只留檔。要停用就從 hooks.json 拿掉它(配套的 search-is-not-proof-guard 會跟著失效,見該檔)。
#
# kbdb-evidence-stamp.sh — 證據的出處登記處(PostToolUse: 各 kbdb_* 工具)
#
# ── 為什麼是「出處」而不是「措辭」───────────────────────────────────
# leo 2026-08-17 證偽過文字層:那天在文字層封路的閘 **8 次誤攔、0 次正確攔截**,
# 且方向穩定——紅線寫得越細,命中關鍵字的機率越高 ⇒ **那些閘在懲罰謹慎**。
# leo 原話:「自然語言的變體是無限的,blacklist 永遠追不完。
# 封路哲學之所以有效,是因為它封的是**動作**——動作有限且可枚舉。」
#
# ⇒ 所以本閘不看你怎麼說,看你**引用的那筆資料是從哪支工具掉出來的**。
# 資料值跟動作一樣是有限且可枚舉的:一個 entry id、一個 `kb://` 來源位址,
# 它要嘛出現在 kbdb_search 的回應裡,要嘛沒有。**這是事實,不是判讀。**
#
# ── 兩條路的分別(D81leo 2026-08-13 親自裁定)────────────────────
# `system-dev/docs/2-architecture/decisions/D81-vector-only-for-entity-normalize.md`
# Graph(三元組/entities → **找到該找的東西** ← 產品檢索路徑
# LLM Wiki(卡片文本) → **拉到對的內容** ← 產品檢索路徑
# 向量/關鍵字搜尋 → **協助 entities normalize****輔助,不是檢索主路徑**
#
# 🔴 **`kbdb_search` 本身完全合法**,這支不會讓它變難用——
# subagent-wiki-guard.sh 還明文要求「查任何東西之前先用它查 wiki」。
# leo 的話是「不能拿它當**產品檢索路徑**」,不是「不准呼叫」。
# ⇒ 本檔只登記出處,判斷留給 search-is-not-proof-guard.sh,而那支的觸發條件
# 窄到「你把搜尋結果的原始值貼進票或派工單裡當證據」才會響。
#
# ── 平台依據(查證過,不是推測)─────────────────────────────────────
# PostToolUse 的 stdin payload 帶得到工具的**輸出**
# `@anthropic-ai/claude-code/sdk.d.ts:54-59`
# export type PostToolUseHookInput = BaseHookInput & {
# hook_event_name: 'PostToolUse'; tool_name: string;
# tool_input: unknown; tool_response: unknown; };
# 實跑的那顆(Caskroom 2.1.220)內建說明同樣寫著
# `"tool_response": { "success": true } // PostToolUse only`。
#
# ── fail-closed:這支失效的時候要出聲,不准靜默 ──────────────────────
# `kbdb-asked-stamp.sh` 檔頭記著 leo 2026-08-08 的原話:
# 「你自己寫了 hook,結果**一次次改都沒產生用途**,問題太大」。
# ⇒ 所以 payload 裡**根本沒有 `tool_response` 這個欄位**時(=平台換了介面),
# 本檔走 exit 2 把話講到 stderr 上(PostToolUse 的 exit 2 不會擋工具,
# 只會把訊息交給 Claude 看)。**寧可吵一次,也不要變成一支活著卻沒作用的閘。**
# 「回應讀得到但裡面沒有指紋」是正常情況(搜尋 0 筆),不出聲。
set -uo pipefail
INPUT=$(cat)
OUT=$(printf '%s' "$INPUT" | python3 -c '
import json, os, re, sys
try:
d = json.load(sys.stdin)
except Exception:
print("SKIP"); raise SystemExit
tool = d.get("tool_name") or ""
sid = d.get("session_id") or "nosid"
# 這一族才登記;別的工具直接走人
if "kbdb_" not in tool:
print("SKIP"); raise SystemExit
# 🔴 D81 的兩條產品檢索路徑 vs 輔助工具。判斷只看工具名,不看內容。
WALKED = ("kbdb_get_record", "kbdb_graph_neighbors", "kbdb_query", "kbdb_get_map")
if any(w in tool for w in WALKED):
kind = "walked"
elif "kbdb_search" in tool:
kind = "search"
else:
print("SKIP"); raise SystemExit # 寫入類工具(create_*)不是證據來源
if "tool_response" not in d: # ← 平台換介面了,本閘等於死了
print("NOFIELD"); raise SystemExit
# 回應可能是 dict、list、字串、或 MCP 的 content blocks——一律壓成字串再找指紋。
# 這樣就不必猜它今天是哪一種包裝。
def flatten(x, acc):
if isinstance(x, str):
acc.append(x)
elif isinstance(x, dict):
for v in x.values(): flatten(v, acc)
elif isinstance(x, (list, tuple)):
for v in x: flatten(v, acc)
elif x is not None:
acc.append(str(x))
parts = []
flatten(d.get("tool_response"), parts)
try:
parts.append(json.dumps(d.get("tool_response"), ensure_ascii=False))
except Exception:
pass
blob = "\n".join(parts)
# ── 指紋只取兩類,兩類都是「值」不是「措辭」───────────────────────
# ① entry id`e_<uuid>`。36 字元的 UUID,實質上不會誤撞。
# ② 來源位址:`kb://…`。它在 metadata_json 那串裡,而**跳脫前後長得一樣**
# (貼進 markdown 會變成 \"…\",但 kb:// 那段本身不變)⇒ 貼過去也認得出來。
# 兩類都是可枚舉的資料值,正好對上 leo 8-17「動作有限且可枚舉,文字不是」。
ids = set(re.findall(r"\be_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b", blob))
srcs = set(re.findall(r"kb://[^\"\\\s,}\]]{6,}", blob))
fps = sorted(ids | srcs)
path = "/tmp/.kbdb-evidence-%s-%s" % (kind, sid)
if fps:
try:
with open(path, "a", encoding="utf-8") as f:
for x in fps:
f.write(x + "\n")
os.chmod(path, 0o600)
except Exception:
print("WRITEFAIL"); raise SystemExit
print("OK %s %d" % (kind, len(fps)))
' 2>/dev/null) || OUT="PYFAIL"
case "${OUT:-}" in
NOFIELD)
cat >&2 <<'EOF'
⚠️ kbdb-evidence-stamp 失效了:這次的 PostToolUse payload **沒有 `tool_response` 欄位**。
它靠這個欄位登記「這筆資料是從哪條路拿到的」,欄位不見 =
`search-is-not-proof-guard.sh` 從現在起**永遠不會響**(而且不會有人發現)。
依據(本閘寫的時候查證過):
@anthropic-ai/claude-code/sdk.d.ts:54-59 PostToolUseHookInput 帶 `tool_response`
⇒ 平台介面變了。開一張票去修這兩支,不要當作沒看到——
一支活著卻沒作用的閘,比沒有閘更糟:它會讓人以為這一格有人守。
EOF
exit 2 ;;
PYFAIL|WRITEFAIL)
cat >&2 <<'EOF'
⚠️ kbdb-evidence-stamp 這次沒登記成功(python3 或 /tmp 寫入失敗)。
後果:這一次的 kbdb 呼叫不會被 `search-is-not-proof-guard.sh` 看見。
不影響你手上的工作,但**這一格暫時沒人守**——請自己確認一次
「我等一下要下的結論,走的是不是我實際查過的那條路」。
EOF
exit 2 ;;
esac
exit 0
+24 -6
View File
@@ -46,16 +46,34 @@ if printf '%s' "$cmd" | grep -q 'leo21c-write-guard'; then
exit 0
fi
# leo21c 的三個座標(帳號 idnamespaceworker 網域)
if ! printf '%s' "$cmd" | grep -qE 'leo21c|51a01bfa2665bd7bc3fd080dc40cf3e1|bfezv28v'; then
exit 0
# ── 判準:這個指令會不會真的把東西寫到那台?────────────────────────────────
# 🔴 2026-08-20 第三次誤攔後收斂(前兩次:git commit 訊息、修閘自己)。
# 舊判準是「文字裡有沒有出現座標」+「有沒有寫入動詞」,於是
# `curl -X PATCH .../api/v1/repos/inkstone/Arcrun/issues/145 -d '{"state":"closed"}'`
# **只因為票的內文提到那個帳號就被擋**——而那是打 Gitea,跟 CF 一點關係都沒有。
# ⇒ 只認**兩種真的會寫到那台的形狀**,其餘一律不管文字寫了什麼:
# ① 寫入請求打到 `*.leo21c.workers.dev`
# ② 用它的 CF 帳號 id 去跑 wrangleracr
# leo 2026-08-17:「紅線寫得越細,命中關鍵字的機率越高 ⇒ 那些閘在懲罰謹慎。」
hit=0
# ① 寫入請求打到那台 worker
if printf '%s' "$cmd" | grep -qiE 'https?://[^[:space:]"'"'"']*leo21c\.workers\.dev'; then
if printf '%s' "$cmd" | grep -qE -- '-X *(POST|PUT|PATCH|DELETE)|--data|--data-raw|-d[[:space:]]|/trigger'; then
hit=1
fi
fi
# 寫入動作的形狀
if ! printf '%s' "$cmd" | grep -qE -- '-X *(POST|PUT|PATCH|DELETE)|--data|--data-raw|-d ["'"'"'{]|wrangler +(deploy|publish|kv|d1|secret)|acr +(update|deploy|push)|/trigger|ingest|kbdb_create|kbdb_update'; then
exit 0
# ② 拿它的 CF 帳號去部署
if printf '%s' "$cmd" | grep -qE 'CLOUDFLARE_ACCOUNT_ID=51a01bfa2665bd7bc3fd080dc40cf3e1'; then
if printf '%s' "$cmd" | grep -qE 'wrangler|acr |node .*index\.js'; then
hit=1
fi
fi
[ "$hit" = "1" ] || exit 0
cat >&2 <<'MSG'
🚫 不准寫 leo 的個人帳號 leo21cleo 2026-08-20 立)
+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))
+241
View File
@@ -0,0 +1,241 @@
#!/usr/bin/env python3
"""hooks/lib/countdown.py —— 算「這件事已經花了多久/還剩多久」的那一行。
這是 helper不是閘inkstone/ISEP#40 S7:lib/ 底下的東西不算一支手寫的閘)。
兩個呼叫者共用它所以時間的算法只有一份
· UserPromptSubmit`countdown-guard.sh` 注入模式 把這一行送到模型眼前
· Stop同一支的查核模式 擋下來時要把同一行原樣印出來給它抄
基準從哪一刻算起inkstone/ISEP#63 要我自己判斷並說明理由)──────────
leo 的原話是**隨時看自己拖了多久**指向的是**任務**不是 session
任務在機器上沒有起點沒有任何欄位記著我什麼時候接手這條線
可機械取得又真的對應到一個交付的起點只有一個**這段對話的第一則訊息**
而那正好就是總管的交貨單位CLAUDE.md 規則三點七**一段對話 一個 release**
已過時間 這個 release 已經燒掉多久**這個數字不需要任何人維護也不會說謊**
resume session transcript 第一列的時間而不是hook 第一次被叫到的時間
接關不是重新開始昨天燒掉的兩小時仍然算在這個交付上兩者都拿得到時取較早的
第二個數字今天的收工線
leo 2026-08-28 07:40今天我整天上課**下午四點左右**希望已經都完成了
台北 16:00過了 16:00 之後**不立刻換算到明天**那會讓超時消失
而超時正是他要看的東西過線後四小時內顯示已超過 X
台北 20:00 之後才滾到隔天那時已經是下一個工作日的倒數了
第三個數字主線 milestone 的期限可選
從快取檔讀**這支從不打網路**UserPromptSubmit 走在每一則訊息的關鍵路徑上
在那裡打網路 每一句話都先等一次 HTTP快取由 SessionStart 那支負責更新
`scripts/countdown-milestone-refresh.sh`拿不到就整段不顯示不編數字
🔴 **來源有兩個優先序寫死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
import sys
from datetime import datetime, timedelta, timezone
TAIPEI = timezone(timedelta(hours=8))
MARKER = ""
def _now() -> datetime:
"""現在。測試用 ISEP_COUNTDOWN_NOWepoch 秒)把時鐘定住。"""
override = os.environ.get("ISEP_COUNTDOWN_NOW", "").strip()
if override:
try:
return datetime.fromtimestamp(float(override), tz=timezone.utc)
except Exception:
pass
return datetime.now(timezone.utc)
def _parse_ts(raw) -> "datetime | None":
if not isinstance(raw, str) or not raw.strip():
return None
s = raw.strip().replace("Z", "+00:00")
try:
dt = datetime.fromisoformat(s)
except Exception:
return None
return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc)
def transcript_start(path: str) -> "datetime | None":
"""transcript 第一列帶時間戳的那一列 = 這段對話真正的起點。"""
if not path or not os.path.exists(path):
return None
try:
with open(path) as f:
for line in f:
line = line.strip()
if not line:
continue
try:
row = json.loads(line)
except Exception:
continue
dt = _parse_ts(row.get("timestamp"))
if dt:
return dt
except Exception:
return None
return None
def state_dir() -> str:
d = os.environ.get("ISEP_COUNTDOWN_STATE_DIR", "").strip()
if not d:
d = os.path.join(os.path.expanduser("~"), ".claude", "isep-countdown")
try:
os.makedirs(d, exist_ok=True)
except Exception:
pass
return d
def session_start(session_id: str, transcript_path: str, now: datetime) -> datetime:
"""起點 = transcript 第一列與「第一次看到這個 session」兩者取較早。
第一次看到就落一個檔transcript 沒有時間戳或讀不到時它就是唯一的憑據
"""
cands = []
ts = transcript_start(transcript_path)
if ts:
cands.append(ts)
sid = "".join(c for c in (session_id or "") if c.isalnum() or c in "-_")[:80]
if sid:
p = os.path.join(state_dir(), sid + ".start")
seen = None
try:
with open(p) as f:
seen = datetime.fromtimestamp(float(f.read().strip()), tz=timezone.utc)
except Exception:
seen = None
if seen is None:
seen = min(cands) if cands else now
try:
with open(p, "w") as f:
f.write("%d" % seen.timestamp())
except Exception:
pass
cands.append(seen)
return min(cands) if cands else now
def deadline(now: datetime) -> datetime:
"""今天的收工線(台北 HH:MM)。過線後四小時內不滾,讓超時看得見。"""
hhmm = os.environ.get("ISEP_COUNTDOWN_DEADLINE", "16:00").strip() or "16:00"
try:
hh, mm = (int(x) for x in hhmm.split(":", 1))
except Exception:
hh, mm = 16, 0
local = now.astimezone(TAIPEI)
d = local.replace(hour=hh, minute=mm, second=0, microsecond=0)
if local >= d + timedelta(hours=4):
d += timedelta(days=1)
return d
def _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:
with open(p) as f:
raw = f.read().strip()
except Exception:
return None
if "|" in raw:
raw, name = raw.split("|", 1)
raw, name = raw.strip(), name.strip()
if not raw:
return None
dt = _parse_ts(raw) or _parse_ts(raw + "T23:59:59+08:00")
if not dt:
return None
return (name or "主線", dt)
def human(delta: timedelta) -> str:
mins = int(abs(delta).total_seconds()) // 60
h, m = divmod(mins, 60)
if h and m:
return "%d 小時 %d" % (h, m)
if h:
return "%d 小時" % h
return "%d" % m
def line(session_id: str, transcript_path: str) -> str:
now = _now()
start = session_start(session_id, transcript_path, now)
dl = deadline(now)
parts = ["%s 已過 %s" % (MARKER, human(now - start))]
left = dl - now
if left.total_seconds() >= 0:
parts.append("距收工線(台北 %s)剩 %s" % (dl.strftime("%H:%M"), human(left)))
else:
parts.append("🔴 已超過收工線(台北 %s%s" % (dl.strftime("%H:%M"), human(left)))
ms = milestone()
if ms:
name, due = ms
if due is None:
parts.append("主線 %s(沒設期限)" % name)
else:
gap = due - now
if gap.total_seconds() >= 0:
parts.append("主線 %s%s" % (name, human(gap)))
else:
parts.append("🔴 主線 %s 已逾期 %s" % (name, human(gap)))
return "".join(parts)
if __name__ == "__main__":
try:
payload = json.load(sys.stdin)
except Exception:
payload = {}
print(line(payload.get("session_id") or "", payload.get("transcript_path") or ""))
+419
View File
@@ -0,0 +1,419 @@
#!/usr/bin/env python3
"""hooks/lib/dispatch_parse.py — 派工單與交件回覆的**結構解析器**(不是閘)。
inkstone/ISEP#40 S7 的慣例:`lib/` 裡的東西是共用零件,本身不決定 allow/block
判決由呼叫它的閘做`hooks/dispatch-format-guard.sh``hooks/reply-identity-guard.sh`
`scripts/ticket`
規則本身leo 2026-08-27inkstone/ISEP#30 comment 4322 / 4325 / 4327)━━
派工單 工單owner/repo#N。就這樣。
三則合起來是一條規則
4322 任務要在票上不是在 prompt 而閘只驗了票號這個殼
4325 你用一個 **output parser** 把你給 subagent 的指令規範分作幾點
每一點規定格式**照這種散文寫法根本無法迭代****警察也不能抓**
4327 **交件方式不需要寫定義在原則裡**每次都一樣提取出來變成共通規定
那些 session 事實**為什麼不寫到票裡**
派工單裡的東西只有兩種**兩種都不該留在派工單**
每次都一樣 交件方式不要 push mainorg inkstone先讀該 repo CLAUDE.md
=> 共通規定由閘自動注入 dispatch-format-guard.sh
這次才知道 main 是哪顆 commit今天撞過什麼另一條線正在動什麼
=> **寫進那張票**票上還沒有去把它寫上票的指令
不是把它寫進 prompt的理由
判準一句話**這句話換一張票還成立嗎**
還成立 => 共通規定只有這次成立 => 寫進這張票兩種都不進派工單
為什麼連這次才知道的事也不能留在 prompt4327 實害
那個 agent 被停掉換人接手那段事實就隨 prompt 消失
2026-08-27 總管停掉重派 3 前兩次的任務與 session 事實全部蒸發
為什麼這支過得了封動作不封文字的檢驗leo 2026-08-17
你在**文字層**封路**自然語言的變體是無限的blacklist 永遠追不完**
封路哲學之所以有效是因為它封的是**動作**動作有限且可枚舉文字不是
當日實證文字層的閘 8 次誤攔0 次正確攔截**紅線寫得越細命中關鍵字的機率越高
那些閘在懲罰謹慎**
本檔**沒有命中某個詞就違規的比對**判準是這一行是不是工單欄位
**在不在**不是**寫什麼**用到的正則有三種全部在認形狀
- `某某` 欄位標記本身
- `owner/repo#N`(可選外掛全形/半形括號的 `→ comment M`)票號形狀
- 唯一例外ISEP#65):`_COURTESY_CLOSERS` 是一份**白名單**,只用來認出
這一行是不是零資訊的禮貌收尾作用方向與黑名單相反認出的是
不算違規不是算違規且要求整行清乾淨標點後完全相等不是
包含所以不會反過來放走任何真正的內容
措辭多謹慎有沒有提到紅線部署main本檔一律不看仍然不會被
寫得越小心越容易被罰的病拖下水
派工單格式
工單inkstone/ISEP#30 → comment 4322
就這一行要帶兩張票就兩行工單**沒有第二個欄位**
交件回覆格式任何人
身份subagentinkstone/ISEPfeat/ticket-carries-the-task
第一行就要認得出是誰寫的角色是三選一的允許清單`總管``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)
ok, detail = dispatch_parse.parse_identity(comment_body)
CLI hook JSON吐一份事實 JSON
printf '%s' "$INPUT" | python3 hooks/lib/dispatch_parse.py dispatch
"""
import json
import re
import shlex
import sys
# ── 派工單的字彙表:**只有一個欄位**。這是允許清單,不是黑名單 ──────────────
DISPATCH_ALLOWED = ("工單",)
# 曾經合法、現已收回的欄位。**不參與判決**(判決是「不是【工單】就是多的」),
# 只用來讓訊息講得出「你這一欄的內容該搬去哪」。
RETIRED_FIELDS = {
"人格": "共通規定(收工方從票的 repo 就知道自己戴誰的人格)",
"交件": "共通規定(每張票都一樣:貼回原票)",
"就地": "**寫進那張票**(這正是「票上還沒有」的解法)",
"身份": "那是**交件回覆**的欄位,不是派工單的",
}
IDENTITY_ROLES = ("總管", "subagent", "leo")
_MARKER_RE = re.compile(r"^【([^】]{1,12})】\s*(.*)$")
_REF_RE = re.compile(r"^([A-Za-z0-9_.-]+)/([A-Za-z0-9_.-]+)#(\d+)\s*$")
# 🔴 inkstone/ISEP#65:頂層 CLAUDE.md 規定的合格格式帶著全形括號——
# 「【工單】owner/repo#N(→ comment M)」。舊版正則只吃「→ comment M」本體,
# 兩側的括號(全形/半形都要吃)沒被算進去,殘留的括號讓 _REF_RE 比不過,
# 於是**規則自己定義的合格範例會被自己的閘擋下**。這裡把左右括號都收進來,
# 兩種都設成可選(沒有括號的舊寫法、純 #issuecomment- 連結都還要能過)。
_COMMENT_RE = re.compile(r"[(]?\s*(?:#issuecomment-|→\s*comment\s*)(\d+)\s*[)]?")
_FENCE_RE = re.compile(r"^\s*(```|~~~)")
# 🔴 inkstone/ISEP#65:票號後面只掛一句純禮貌收尾(例:「謝謝」),不該被當成
# 「派工單不只有票號」的違規——它不含任何要搬去票上或共通規定的實質內容。
# 這是**白名單,不是黑名單**:判準仍是結構(整行清乾淨標點後「等於」清單裡的
# 固定字串,不是「包含」),漏收某個禮貌講法頂多誤鎖一次(代價小,見 ISEP#65
# 的「優先做成低誤鎖」),但不會反過來放走任何真正的內容——真內容不會剛好
# 打出一個字不差的空話。全檔仍然沒有「命中某個詞就違規」的比對,這裡是唯一
# 一處字面比對,且作用方向相反:認出「不是內容」,不是認出「是違規」。
_COURTESY_CLOSERS = frozenset({
"謝謝", "謝謝你", "謝謝您", "謝謝囉", "多謝", "感謝", "感恩", "辛苦了", "收到",
"thanks", "thank you", "thx",
})
_COURTESY_STRIP_RE = re.compile(r"[\s。!!.,~,、]+$")
def _is_courtesy_closer(line):
s = _COURTESY_STRIP_RE.sub("", line.strip())
return s.lower() in _COURTESY_CLOSERS
def _split_lines(text):
"""切成 (是否在圍欄內, 原始行)。
圍欄``` / ~~~裡的東西是**資料**不是欄位標記同一個道理已經在
hooks/lib/strip_heredoc.py 落地過heredoc body 是資料不是指令
inkstone/InkStoneCo#23、#56 的根因就是把資料當指令掃)。
這裡的效果是貼一段指令進派工單裡面的不會被誤認成欄位
但整段圍欄本身仍然算多出來的內容照樣會被擋
"""
out, in_fence = [], False
for line in text.split("\n"):
if _FENCE_RE.match(line):
out.append((in_fence, line)) # 圍欄那一行歸屬於「切換前」的狀態
in_fence = not in_fence
continue
out.append((in_fence, line))
return out
def parse_dispatch(prompt):
"""把派工單拆成「【工單】欄位」與「其餘一切」。**只回事實,不下判決**。
回傳 dict
has_ticket_marker 有沒有出現過工單沒有就不是本格式的東西
refs 解析成功的票號清單 [{'owner','repo','num','comment'}]
bad_ticket_lines 工單標記但形狀不對的那幾行
extra 多出來的實質內容 [(來源欄位或 None, 原始行)]
retired_seen 出現過的已收回的欄位名給訊息用
"""
fields_seen, refs, bad_ticket_lines = [], [], []
extra, retired_seen = [], []
cur = None
for in_fence, raw in _split_lines(prompt or ""):
if not in_fence:
m = _MARKER_RE.match(raw)
if m:
name, rest = m.group(1), m.group(2)
cur = name
fields_seen.append(name)
if name in RETIRED_FIELDS and name not in retired_seen:
retired_seen.append(name)
if name in DISPATCH_ALLOWED:
text = rest.strip()
head = _COMMENT_RE.sub("", text).strip()
rm = _REF_RE.match(head)
if rm:
cm = _COMMENT_RE.search(text)
refs.append({"owner": rm.group(1), "repo": rm.group(2),
"num": int(rm.group(3)),
"comment": int(cm.group(1)) if cm else None})
else:
bad_ticket_lines.append(raw.strip())
else:
if rest.strip():
extra.append((name, raw))
continue
if raw.strip():
# 【工單】欄位本身只准佔它自己那一行;它下面的續行也是多的。
# 白名單豁免只在圍欄外生效——圍欄裡的東西本來就該一律算數(test ⑮),
# 這裡不額外放寬,避免有人把真內容包在圍欄裡再貼一句「謝謝」蒙混。
if not in_fence and _is_courtesy_closer(raw):
continue
extra.append((cur, raw))
return {
"has_ticket_marker": "工單" in fields_seen,
"refs": refs,
"bad_ticket_lines": bad_ticket_lines,
"extra": extra,
"extra_lines": len(extra),
"extra_chars": sum(len(r.strip()) for _, r in extra),
"retired_seen": retired_seen,
"fields_seen": fields_seen,
}
def dispatch_violations(parsed):
"""換算成違規清單。每一條都是**結構**事實,可以單獨迭代。"""
v = []
for line in parsed["bad_ticket_lines"]:
v.append(("票號形狀不對",
"%s」不是 owner/repo#N(可加 → comment M);org 是 inkstone 不是 Leo"
% line[:60]))
if parsed["extra"]:
first = parsed["extra"][0][1].strip()
v.append(("派工單不只有票號",
"【工單】以外還有 %d 行/%d 字,第一行是:%s"
% (parsed["extra_lines"], parsed["extra_chars"], first[:50])))
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"):
if not raw.strip():
continue
m = _MARKER_RE.match(raw)
if not m or m.group(1) != "身份":
return False, "第一行不是【身份】"
val = m.group(2).strip()
if not val:
return False, "【身份】後面是空的"
role = re.split(r"[/]", val)[0].strip()
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"
try:
payload = json.load(sys.stdin)
except Exception:
print(json.dumps({"status": "skip", "why": "payload 不是 JSON"}))
return
if mode != "dispatch":
print(json.dumps({"status": "skip", "why": "未知模式 %s" % mode}))
return
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",
"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))
if __name__ == "__main__":
_main()
+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"
+133
View File
@@ -0,0 +1,133 @@
#!/usr/bin/env python3
"""hooks/lib/kbdb_cmd_check.py -- shared helper for kbdb-api-wall-guard.sh's
Bash channel (inkstone/ISEP#40 S7: helpers live in lib/, do not count as a
hand-written gate).
What it guards: whether a bash command string contains a real, at-command-
position invocation of wrangler's D1 execute subcommand (any wrapper such as
npx/pnpm) targeting a database whose name mentions "kbdb" -- that is a
direct-SQL bypass of the KBDB API wall (D38, leo 2026-08-07: zero SQL,
always through the HTTP API).
Why this file exists (not just "why the rule exists"): the calling gate,
hooks/kbdb-api-wall-guard.sh, used to pipe the raw command straight through
a keyword grep. That produced the exact "keyword shows up vs. real
instruction" confusion this repo has hit repeatedly (inkstone/InkStoneCo#23:
the phrase showed up inside a delegation prompt that was *talking about* the
rule, not breaking it). The fix pattern already proven on
hooks/release-tag-guard.sh is "only count a keyword when it sits at command
position" -- this file is that same pattern for the KBDB Bash channel,
factored out to its own file because embedding shell-escaping-aware parsing
inline in the .sh caused regressions each time someone touched it (see the
.sh file's own header for that history).
Two extra layers vs. a plain regex:
1. hooks/lib/strip_heredoc.py runs first (imported below) so a heredoc body
that merely *mentions* the D1-execute pattern against kbdb as
documentation text does not count -- same root cause as
InkStoneCo#23/#56.
2. Command segmentation uses shlex with punctuation_chars, so a `;`/`&`/`|`
that appears *inside* a quoted string (e.g. a commit message) does not
get treated as a command boundary, and text inside quotes is only
inspected when the quoted text is itself an argument to a command that
is genuinely at command position (e.g. a `--command` value passed to a
real wrangler invocation) -- not when it is merely quoted prose
describing the rule.
Genuine parse failures (unbalanced quotes, exotic constructs) fail OPEN
(print "OK"), matching this repo's stated design discipline: fail-open on
parse failure, not fail-open on the verdict itself (see release-tag-guard.sh
header). The caller (kbdb-api-wall-guard.sh) already blocks direct file-path
writes into kbdb/ paths and DDL/`.prepare(`/`.exec(`/`.batch(` in Write/Edit
content through its own separate channels -- this file only covers the Bash
CLI channel.
Usage:
printf '%s' "$CMD" | python3 hooks/lib/kbdb_cmd_check.py
-> prints exactly "BAD" or "OK" on stdout.
"""
import os
import shlex
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from strip_heredoc import strip_heredocs # noqa: E402
_WRAPPERS = {"npx", "pnpm", "yarn", "bunx", "exec", "sudo", "env", "corepack"}
_DB_HINT = "kbdb"
_D1_TOKEN = "d1"
_SUBCMD_TOKEN = "execute"
def _segments(cmd: str):
"""Split cmd into a list of token-lists, one per "simple command",
breaking on real shell control operators. Quote-aware via shlex, so a
control character inside a quoted string does not split. Returns None
on unparseable input (caller should fail open)."""
try:
lex = shlex.shlex(cmd, posix=True, punctuation_chars=True)
lex.whitespace_split = True
tokens = list(lex)
except ValueError:
return None
boundary = {";", "&", "&&", "|", "||", "(", ")", "\n"}
segments = []
current = []
for tok in tokens:
if tok in boundary:
if current:
segments.append(current)
current = []
else:
current.append(tok)
if current:
segments.append(current)
return segments
def _is_kbdb_d1_execute(segment):
"""True if this one simple command is wrangler's D1 execute subcommand
(optionally behind a wrapper like npx/sudo/env) aimed at a database
whose name mentions "kbdb" anywhere in its arguments."""
i = 0
# Skip leading VAR=value assignments (env-style prefix).
while i < len(segment):
head = segment[i].split("=", 1)[0]
if "=" in segment[i] and head.replace("_", "").isalnum() and head[:1].isalpha():
i += 1
else:
break
# Skip known wrappers (npx wrangler ..., sudo wrangler ..., env X=Y wrangler ...).
while i < len(segment) and os.path.basename(segment[i]) in _WRAPPERS:
i += 1
if i >= len(segment):
return False
cmdname = os.path.basename(segment[i])
if cmdname != "wrangler":
return False
rest = segment[i + 1 :]
if _D1_TOKEN not in rest or _SUBCMD_TOKEN not in rest:
return False
joined_lower = " ".join(rest).lower()
return _DB_HINT in joined_lower
def check(cmd: str) -> str:
stripped = strip_heredocs(cmd)
segments = _segments(stripped)
if segments is None:
return "OK" # parse failure -> fail open, not fail on the verdict
for seg in segments:
if _is_kbdb_d1_execute(seg):
return "BAD"
return "OK"
def main() -> None:
cmd = sys.stdin.read()
print(check(cmd))
if __name__ == "__main__":
main()
+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"
}
+49
View File
@@ -0,0 +1,49 @@
# hooks/lib/path-resolve.sh — 共用:判斷一個檔案路徑「歸不歸某個 git repo 管」。
# 不是獨立掛的閘(沒進 hooks.json),給其他 PreToolUse 閘 `source` 用的函式庫。
#
# 背景(inkstone/InkStoneCo#22):sdd-guard.sh 曾經把 scratchpad 暫存檔
# `/private/tmp/.../scratchpad/foo.py`)誤判成「repo 裡的 code 變動」而擋下——
# 因為它只會「猜專案根($CLAUDE_PROJECT_DIR 或 cwd)+往上找 3-specs」,
# 猜錯或猜不到時,找不到 3-specs 就一律當「找不到 SDD」擋下,連「這條路徑根本不在
# 任何 repo 裡、SDD 這件事天生管不到它」都沒判斷過。
#
# path_in_git_worktree 提供一個不必先猜對專案根的判法:直接問 git
# 「這個路徑在不在某個 repo 的工作樹裡」。不必窮舉暫存區的路徑關鍵字(/tmp、scratchpad…),
# 任何真的不在 git repo 裡的路徑,一律視同「這是暫存/非受管檔案」。
#
# 同一個 `${CLAUDE_PROJECT_DIR:-$(pwd)}` 猜根目錄寫法,實測(2026-08-20)還出現在:
# component-guard.sh、factory-idle-guard.sh、github-contact-guard.sh、
# history-first-guard.sh、main-and-prod-push-guard.sh、no-ticket-no-dispatch.sh、
# not-my-branch-guard.sh、release-tag-guard.sh、skill-deploy-drift-guard.sh、
# stage-before-prod-guard.sh、unpushed-police.sh、wiki-first-police.sh。
# 另有 empty-handed-stop-guard.sh、issue-status-autoflip.sh 直接寫 `$CLAUDE_PROJECT_DIR`
# (無 `:-` fallback)——
# claim-verify-police.shsubagent-claim-worksheet.sh 已於 ISEP#60 整組移除,
# 它們守的東西改由 Gitea 原生三格承接,見 ISEP#59PR #58
# 這批在該變數未設時行為又不一樣,同一個病的另一種長相。
# 這些全部沒有本檔「先確認到底在不在 repo 裡」的判斷;本檔先在 sdd-guard.sh 落地,
# 其餘要不要跟進、要不要改用這支共用函式,另案處理,不在本票(#22)範圍內一次改完。
#
# 用法:
# source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib/path-resolve.sh"
# if ! path_in_git_worktree "$FILE_PATH"; then
# # 不在任何 git repo 裡 ⇒ 這支閘通常管不到,多半該放行
# fi
# path_in_git_worktree <path>
# 回傳 0=這個路徑落在某個 git 工作樹裡;1=不在任何 git repo 裡(含路徑本身不存在的情況)。
# 做法:從路徑的目錄部分開始,往上找到「第一個真的存在的祖先目錄」,
# 對那個目錄問 `git rev-parse --is-inside-work-tree`。
# 為什麼要往上找存在的祖先,不能直接對 dirname 問:
# 要在 repo 裡建一個還沒建立的子目錄下的新檔案時,dirname 也不存在,
# 若不往上找,`git -C <不存在的目錄>` 會直接失敗 ⇒ 誤判成「不在 repo 裡」
# ⇒ 放行了本來該擋的東西(fail-open 的洞,不是這支函式該製造的)。
path_in_git_worktree() {
local p="$1" d
d=$(dirname -- "$p")
while [ ! -d "$d" ] && [ "$d" != "/" ]; do
d=$(dirname -- "$d")
done
[ -d "$d" ] || return 1
git -C "$d" rev-parse --is-inside-work-tree >/dev/null 2>&1
}
+174
View File
@@ -0,0 +1,174 @@
#!/usr/bin/env python3
"""hooks/lib/push_target_dir.py -- shared helper (inkstone/ISEP#40 S7-style
lib helper: source of truth for a small piece of logic, not a gate by
itself; nothing here decides allow/block on its own).
What it answers: given a shell command string that somewhere invokes
`git ... push ...`, which directory will that push actually execute in?
Why this exists (inkstone/ISEP#30 comment 3949, 脈絡見 InkStoneCo#57):
main-and-prod-push-guard.sh's stamp_ok() used to read `git rev-parse
--show-toplevel` from the *hook's own* cwd (= the session's real repo) as
"HERE", and compare it against the repo path the caller wrote into the
stamp ("WANT"). That works when the push target IS the repo the session is
standing in. It can never work otherwise: a `cd <other-repo> && git push
origin HEAD:main` changes the *push's* directory but not the hook's, so
HERE stays the real repo forever while WANT is (correctly) the other repo
-- the two can never match, no matter how carefully the caller follows the
gate's own instructions. This isn't a bad judgment call; that scenario
simply doesn't exist in the old model.
This module extracts the directory the push *actually* runs in, purely by
tokenizing -- it never executes anything. Two sources, first one found
wins (in the order a real shell would apply them):
- a `cd <path>` chain preceding the push, scoped correctly across
subshells: a `(` inherits the current directory from its parent at the
moment it opens, but whatever a subshell `cd`s to does NOT leak back
out to sibling commands after the matching `)` closes (this mirrors
real bash: a subshell's cwd change is local to that subshell). This
scoping is load-bearing, not cosmetic: without it, `(cd /repo-A &&
true); git push origin main` would misattribute the later push (which
really runs wherever the outer shell already was) to /repo-A, and a
stale/legitimate stamp for /repo-A could then wrongly wave through a
push into whatever the outer cwd actually is -- the exact "stamp
opened for repo A also opens the door for repo B" shape 2026-08-11
already burned us on once.
- a `-C <path>` flag on the git invocation itself, which further wins
over any `cd` chain (matches git's own precedence: `-C` sets the
directory for that invocation regardless of the shell's cwd).
Multiple relative `cd`/`-C` hops are combined by plain string join here
(no `..`/`~`/`$()` resolution) -- resolving the combined expression to a
real, canonical, absolute path is left to the caller, which does it with a
read-only `cd "<expr>" && pwd` in a throwaway subshell. That two-step split
matters: this module only ever *parses*, so it stays side-effect-free even
when fed a hostile or malformed command; only the caller's final `cd`
touches the filesystem, and `cd` cannot execute anything, it can only fail
to find a directory.
If no `cd`/`-C` applies (the push runs wherever the hook itself is, i.e.
today's behaviour), or the command doesn't parse, prints nothing -- the
caller falls back to its existing cwd-based resolution. That fallback
direction is deliberately the *safe* one: on any parse ambiguity we hand
back "unknown" rather than guess, and an unresolved HERE can only make the
gate keep blocking (fail toward blocking), never open a door it wouldn't
have opened before.
Usage:
printf '%s' "$CMD" | python3 push_target_dir.py
"""
import shlex
import sys
_SEPARATORS = {";", "&&", "||", "|", "&", "\n"}
def _join(base, path):
"""Combine a cwd-so-far (`base`, or None if unknown/hook-cwd) with a
`cd`/`-C` argument written in the command. Absolute paths and `~`
replace the base outright; anything else is appended textually --
normalizing `..`/`.` is intentionally left to the caller's real `cd`."""
if not path:
return base
if path == "-" or path.startswith("$"):
# `cd -` (previous dir) and `$VAR`/`$(...)` expansions can't be
# resolved by tokenizing alone -- treat as "unknown" rather than
# guess wrong, which keeps the caller on its safe fallback path.
return None
if path.startswith("/") or path.startswith("~"):
return path
if base is None:
return path
return base.rstrip("/") + "/" + path
def _classify(tokens):
if not tokens:
return ("other", None)
if tokens[0] == "cd" and len(tokens) > 1:
return ("cd", tokens[1])
if tokens[0] == "git" and "push" in tokens[1:]:
return ("push", tokens)
return ("other", None)
def _events(cmd):
"""Tokenize cmd into (kind, value) events in source order: 'enter'/
'exit' for parens (subshell boundaries), 'cd'/'push'/'other' for
statements split on the usual shell separators. Returns [] on any
quoting error -- caller then falls back to cwd-based resolution."""
try:
lexer = shlex.shlex(cmd, posix=True, punctuation_chars=True)
lexer.whitespace_split = True
toks = list(lexer)
except ValueError:
return []
events = []
seg = []
def flush():
if seg:
# _classify's "push" branch returns `tokens` by reference; copy
# before clear() below, or the event's tuple would observe the
# list emptied out from under it (aliasing, not a value copy).
events.append(_classify(seg[:]))
seg.clear()
for tok in toks:
if tok == "(":
flush()
events.append(("enter", None))
elif tok == ")":
flush()
events.append(("exit", None))
elif tok in _SEPARATORS:
flush()
else:
seg.append(tok)
flush()
return events
def find_push_target(cmd):
events = _events(cmd)
if not events:
return ""
stack = [None] # cwd-so-far per paren depth; None = "same as hook cwd"
result = None
saw_push = False
for kind, val in events:
if kind == "enter":
stack.append(stack[-1]) # child subshell inherits current dir
elif kind == "exit":
if len(stack) > 1:
stack.pop() # subshell's own cd's don't leak out
elif kind == "cd":
stack[-1] = _join(stack[-1], val)
elif kind == "push":
saw_push = True
c_path = None
toks = val
for j, t in enumerate(toks):
if t == "-C" and j + 1 < len(toks):
c_path = toks[j + 1]
break
if t.startswith("-C") and len(t) > 2:
c_path = t[2:]
break
result = _join(stack[-1], c_path) if c_path else stack[-1]
if not saw_push:
return ""
return result or ""
def main():
cmd = sys.stdin.read()
sys.stdout.write(find_push_target(cmd))
if __name__ == "__main__":
main()
+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", ""))
+73
View File
@@ -0,0 +1,73 @@
#!/usr/bin/env python3
"""hooks/lib/strip_heredoc.py -- shared helper, not a gate (inkstone/ISEP#40 S7:
helpers live in lib/ and do not count as a hand-written gate).
What it guards: nothing by itself. It strips the *body* of a bash heredoc out
of a command string, keeping the start line (the control-flow part) intact.
Why: a heredoc body is data, not an instruction. Two mis-blocks on
2026-08-20 (inkstone/InkStoneCo#23, #56) shared one root cause: a gate ran a
keyword scan over the *entire* command string, heredoc body included, so text
that merely *mentioned* a trigger phrase inside a file being written (or a
comment being posted) was treated as if that phrase were actually being
executed. One case was writing docs/TESTING.md (the body had one example line
of a GitHub push command as literal text for a human to try later); the other
was posting a Gitea comment that quoted this very ticket's own description.
This helper only removes the body; each gate keeps its own keyword rules, it
just no longer has to solve heredoc-quoting itself.
Usage:
printf '%s' "$CMD" | python3 hooks/lib/strip_heredoc.py
Or import it as a module:
from strip_heredoc import strip_heredocs
"""
import re
import sys
# <<EOF <<-EOF <<~EOF <<'EOF' <<"EOF" (only one modifier is valid at a
# time in real bash; both are accepted here so a odd combo still matches).
_START_RE = re.compile(r"<<(-|~)?[ \t]*(['\"]?)([A-Za-z_][A-Za-z0-9_]*)\2")
def strip_heredocs(cmd: str) -> str:
"""Replace every heredoc body in cmd with nothing; keep the start line
and everything outside heredocs untouched."""
lines = cmd.split("\n")
out = []
i = 0
n = len(lines)
while i < n:
line = lines[i]
m = _START_RE.search(line)
if not m:
out.append(line)
i += 1
continue
strip_tabs = m.group(1) == "-"
delim = m.group(3)
out.append(line) # the start line itself is control flow, keep it
i += 1
found_end = False
while i < n:
probe = lines[i]
check = probe.lstrip("\t") if strip_tabs else probe
if check == delim:
i += 1 # the terminator line is a marker, drop it too
found_end = True
break
i += 1 # body line: drop it, do not append to out
if not found_end:
# command was truncated / no terminator found -- do not invent
# one, we've already consumed to the end of the string.
pass
return "\n".join(out)
def main() -> None:
cmd = sys.stdin.read()
sys.stdout.write(strip_heredocs(cmd))
if __name__ == "__main__":
main()
+167 -74
View File
@@ -1,4 +1,7 @@
#!/bin/sh
# 管什麼: subagent 推 gitea main 要先有總管戳記;wrangler 部署到非 stage 環境要 leo 手動解保險。
# 為什麼: leo 2026-08-10——「subagent 推 main 你確認,推 prod 我確認」;規則存在但沒機制驗證有沒有照做,於是閘長在機器上。
# 誤觸時怎麼關: 讀取/查狀態/演練一律放行;真要推 main,總管看過 commit 後 `git rev-parse --show-toplevel > /tmp/.main-push-ok`;真要打 stage,指令裡帶 staging/--env stage 字樣。
# main-and-prod-push-guard.sh — PreToolUseBash):**兩層手動確認閘**
#
# 🔴 立這道閘的來由(leo 2026-08-10):
@@ -34,9 +37,23 @@ except Exception: print("")
[ -z "$CMD" ] && exit 0
# 🔴 2026-08-20inkstone/InkStoneCo#23、#56):heredoc 的 body 是資料,不是指令。
# 直接對整條指令字串(含 heredoc 內文)做關鍵字掃描,會把「檔案內容/留言引用裡
# 剛好提到 git push」當成「真的在推」——同一天兩起實撞:寫 docs/TESTING.md 的
# 一行範例文字、貼一則引用 #56 敘述的 Gitea 留言,都被這樣擋下。
# 拿掉 heredoc body 再比對,判準不變,範圍變準。失敗就退回原字串(fail-open
# 在「這支有沒有幫上忙」,不是「放行與否」——見 hooks/lib/strip_heredoc.py 檔頭)。
CMD=$(printf '%s' "$CMD" | python3 "$(dirname "$0")/lib/strip_heredoc.py" 2>/dev/null || printf '%s' "$CMD")
# ── 先放行明確不發佈的動作(讀取、查狀態、寫本地版控、演練)──────────────
# 關鍵字出現在 commit 訊息、在 sed/grep 的參數裡,都不是「執行」。
# 🔴 2026-08-20 修正(release-tag-guard.shstage-before-prod-guard.sh 已修過同款洞):
# 舊版「CMD 開頭是讀取工具就整條放行」本身是穿牆路——
# echo x && git push gitea HEAD:main
# 開頭是 echo,照 glob 前綴比對會整條 exit 0,後面真的在推 main 完全不看。
# ⇒ 改成「先看有沒有出現危險關鍵字,出現了就不吃開頭豁免」,交給下面的
# 「指令位置」判準去判斷它是不是真的在執行。
case "$CMD" in
*"git push"*|*"wrangler deploy"*|*"wrangler publish"*|*"wrangler versions deploy"*) ;;
sed\ *|cat\ *|grep\ *|head\ *|tail\ *|wc\ *|less\ *|ls\ *|awk\ *|rg\ *|echo\ *) exit 0 ;;
*"git commit"*|*"git add"*|*"git tag"*|*"git stash"*) exit 0 ;;
*"git status"*|*"git log"*|*"git diff"*|*"git show"*|*"git branch"*) exit 0 ;;
@@ -57,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 這道閘被自己的戳記穿透了,修法寫在這裡:
@@ -70,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
@@ -81,7 +110,32 @@ stamp_ok() {
# 等於一把萬用鑰匙——正是 08-11 那次穿透的形狀(替 A repo 開的門 B repo 也走得過)。
# 而且 `.claude/settings.local.json` 裡真的放行過 `touch /tmp/.main-push-ok`。
# ⇒ 現在**空內容一律不算數**:要嘛寫得出 repo 路徑且對得上,要嘛不放行。
HERE=$(git rev-parse --show-toplevel 2>/dev/null || printf '')
#
# 🔴 2026-08-23inkstone/ISEP#30 comment 3949,脈絡 InkStoneCo#57):
# `HERE` 原本一律讀 hook 自己的 cwd(=session 站著的那個 repo)。
# 只要要推的 repo **不是**「session 站著的那個 repo」——例如指令自己
# `cd <別的 repo> && git push` 或 `git -C <別的 repo> push`——HERE 永遠是
# 總管的真身,而總管替目標 repo 開的 WANT 永遠對不上,這道閘就**永遠沒辦法
# 合法通過**。不是判斷錯,是這個情境在舊模型裡根本不存在(照閘的指示做
# 戳記,戳記內容天生就贏不了)。
# 改法:先看指令本身有沒有把 push 的執行目錄改掉
# lib/push_target_dir.py——純 tokenize,不執行任何指令,
# 對 `cd A && cd B && git push` 這種多層鏈與 `(cd A && …); git push` 這種
# 子殼會不會外洩都做了範圍化,理由見該檔檔頭);解得出來就 `cd` 進那個
# 目錄(唯讀操作,`cd` 本身不會執行任何東西)問 git 那裡的 toplevel 是誰;
# 解不出來(沒有 cd/-C,或指令太怪解析失敗)才退回舊行為=hook 自己的 cwd。
# 🔴 綁 repo+單次用完即丟兩條性質完全沒有鬆動:這裡只是把「現在人在哪個
# repo」問得更準,比對邏輯(下面兩行)一個字沒動。
_push_target_dir="$(dirname "$0")/lib/push_target_dir.py"
_target_expr=""
if [ -f "$_push_target_dir" ]; then
_target_expr=$(printf '%s' "$CMD" | python3 "$_push_target_dir" 2>/dev/null || printf '')
fi
if [ -n "$_target_expr" ]; then
HERE=$(cd "$_target_expr" 2>/dev/null && git rev-parse --show-toplevel 2>/dev/null || printf '')
else
HERE=$(git rev-parse --show-toplevel 2>/dev/null || printf '')
fi
WANT=$(head -1 "$STAMP" 2>/dev/null || printf '')
[ -n "$WANT" ] || return 1
[ -n "$HERE" ] || return 1
@@ -91,73 +145,113 @@ stamp_ok() {
return 0
}
if true; then
case "$CMD" in
*"git push"*)
# 只擋打到 mainmaster 的;推自己的 feature 分支照常放行
case "$CMD" in
# 🔴 2026-08-12 拿掉 `push -u` / `push --set-upstream` 這兩個條件。
# 它們本來是想抓「沒寫分支的 push」,但實際抓到的是
# `git push -u gitea fix/xxx`——**subagent 發表自己分支的標準動作**
# (第一次推當然要 -u)。⇒ 舊版等於「agent 永遠推不出自己的分支」,
# 而 leo 2026-08-12 的設計是「主線禁止動,大家都走 PR」,推分支是那條路的第一步。
# 08-12 當天四張 PR 全是繞成 `git push gitea a:a` 才推出去的。
# `*main*``*master*` 兩條照舊——真正該擋的是目標分支,不是有沒有帶旗標。
*main*|*master*)
stamp_ok && exit 0
# reworked 2026-08-20 (inkstone/InkStoneCo#23, #56): 'does this command contain
# git push' moved from a whole-string substring test to a command-position test --
# same pattern already validated on release-tag-guard.sh: the phrase only counts
# at the start of the string, or right after ; & | ( ` && ||. A heredoc body was
# already stripped above; this closes the remaining gap where the phrase shows up
# quoted inside the *same* command line (e.g. a python -c call whose string argument
# discusses git push as prose).
if printf '%s' "$CMD" | grep -qE '(^|[;&|(`]|&&|\|\|)[[:space:]]*git([[:space:]]+[^;&|]*)?[[:space:]]+push([[:space:]]|$)'; then
# only block pushes that target main/master; pushing your own feature branch is fine
#
# 2026-08-12: dropped the `push -u` / `push --set-upstream` conditions -- those were
# meant to catch "push with no branch named", but what they actually caught was
# `git push -u gitea fix/xxx`, the normal first-push-of-a-branch shape for a
# subagent. The target branch is what should be gated, not whether a flag is present.
# 2026-08-20: match the target branch on a word boundary, not a bare substring --
# a glob like *main* also matches "domain" (d-o-**m-a-i-n**), e.g. a push to
# `fix/custom-domain-setup` would have false-positived.
# 2026-08-21: 只看 **push 的目標**,不再掃整條指令。
# 舊版掃整條 ⇒ 一個晚上誤攔四次,全都是推 feature branch 或 tag
# git checkout -b fix/x main && git push origin fix/x ← 「main」在 checkout 上
# gh pr create --base main ← 根本不是 git push
# git checkout origin/main --detach; git push origin refs/tags/v0.3.3
# ⇒ **紅線寫得越細,命中關鍵字的機率越高**(leo 2026-08-17 的觀察,
# 文字層封路必敗)。這裡改成判動作的目標,不是判字面。
# 2026-08-23(順著 inkstone/ISEP#30 comment 3949 補測時自己抓到的洞,不在原票範圍
# 但屬於同一支閘、同一段邏輯,且直接讓下面「反向不准鬆」的驗證跑不過,所以一併修):
# `(git push origin HEAD:main)`——單純用括號包住整條指令——舊版會整段放行,
# 跟 HERE/戳記完全無關,**連目的地判斷本身都沒觸發**。
# 成因:截斷 refspec 尾巴只切 `;``&``|` 三種字元,沒算到 `)`——
# 於是「HEAD:main)」被當成一個 token`${_tok##*:}` 剝完冒號還剩「main)」,
# 跟 `^main$` 對不上 ⇒ 判定成「看不出目標」⇒ 整段放行。加 `)` 進截斷字元。
# git 的 refspec/分支名語法本來就不允許出現 `)`,所以在這裡截斷永遠安全,
# 不會誤傷任何合法的推送目標。
_push_seg=$(printf '%s' "$CMD" | sed -E 's/.*git[[:space:]]+(-[^[:space:]]+[[:space:]]+)*push//' | sed -E 's/[;&|)].*//')
_dest=""
_seen_remote=0
_saw_refspec=0
for _tok in $_push_seg; do
case "$_tok" in
-*) continue ;; # 旗標
refs/tags/*|*:refs/tags/*) _saw_refspec=1; continue ;; # 推 tag 不是推分支
esac
if [ "$_seen_remote" = "0" ]; then _seen_remote=1; continue; fi # 第一個非旗標=remote
_saw_refspec=1
_dest="$_dest ${_tok##*:}" # a:b 的目標是 b;沒有冒號就是它自己
done
# 🔴 只有「一個 refspec 都沒給」才退回猜當前分支。
# 看到 refspec(哪怕是 tag)就照它判——否則推 tag 會被當成推當前分支,
# 而當前分支若剛好叫 main 就誤擋(2026-08-21 實測抓到)。
if [ "${_saw_refspec:-0}" = "0" ]; then
_dest=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "")
fi
if printf '%s' "$_dest" | tr ' ' '\n' | grep -qE '^(main|master)$'; then
stamp_ok && exit 0
# ── 擋下的同時,把「誰想推什麼」留成一份請求(leo 2026-08-12)───────────
#
# leo 原話:「**它會問你的意見,所以每個你叫起來的 subagent 都有名字。**」
#
# 做得到的與做不到的,先講清楚:
# ❌ **做不到「同步問總管」**——hook 跑在子 session 自己的行程裡,總管在另一個行程。
# 要同步問只能 block 等一個檔案出現,那會把 subagent 掛死在那裡。
# ✅ **做得到「當場擋 + 留下原始請求」**:總管在自己的迴圈裡讀這個目錄,
# 看到的是 repo/分支/逐筆 commit 的**原始資料**,不是 subagent 的散文轉述。
# ——這才是名字真正值錢的地方:**不是判斷你是誰,是留下是誰要求的**。
#
# 🔴 身分的方向刻意不改:**沒有名字不等於總管**(那是 fail-open——
# 子 session 繼承環境變數,把名字拿掉就升格了)。放行的唯一憑證仍然只有
# 那枚綁 repo、用完即丟的戳記。名字只是署名,不是權限。
# 📌 名字只在 `claude -p` 起的子 session 上可靠(乾淨的環境邊界);
# Agent tool 起的 subagent 與總管同一個行程、共用環境 ⇒ 那條路上名字塞不進也擦不掉。
# 而改子 repo code 的正路本來就是 `claude -p`,所以夠用。
_who="${CLAUDE_AGENT_NAME:-未署名}"
_hookdir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) || _hookdir=""
_reqdir="${_hookdir%/hooks}/pending-main-push"
if [ -n "$_hookdir" ] && mkdir -p "$_reqdir" 2>/dev/null; then
_root=$(git rev-parse --show-toplevel 2>/dev/null || printf 'unknown')
# 檔名只用 ASCII(`未署名` 之類會被 tr 打成一排 dash,看不出是誰)
_slugwho=$(printf '%s' "${CLAUDE_AGENT_NAME:-unnamed}" | tr -c 'A-Za-z0-9._-' '-')
case "$_slugwho" in *[!-]*) : ;; *) _slugwho=unnamed ;; esac
# ⚠️ 先 printf 再 tr`basename` 會帶一個換行,直接餵 tr 會變成結尾多一根 dash
_slugrepo=$(printf '%s' "$(basename "$_root")" | tr -c 'A-Za-z0-9._-' '-')
_slug="${_slugwho}--${_slugrepo}"
# ⚠️ 這幾行刻意用 `printf '%s\n' "整句"`,不要把內容寫進 printf 的格式字串裡。
# 2026-08-12 實撞:格式字串裡同時有反引號與 %s 時,那幾行整行不見(而前後行都在),
# ——**寫完當場肉眼檢查產出的檔案才發現**,hook 自己不會叫。內容一律當資料傳。
_branch=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || printf '?')
_when=$(date '+%Y-%m-%d %H:%M:%S' 2>/dev/null || printf '?')
_fence='```'
{
printf '%s\n\n' "# 推 main 的請求:$_who"
printf '%s\n' "- repo$_root"
printf '%s\n' "- 分支:$_branch"
printf '%s\n\n' "- 時間:$_when"
printf '%s\n' "- 它想跑的指令:"
printf '%s\n%s\n%s\n\n' "$_fence" "$CMD" "$_fence"
printf '%s\n\n%s\n' "## 還沒推上去的 commit(原始資料,不是轉述)" "$_fence"
git log --oneline '@{upstream}..HEAD' 2>/dev/null \
|| git log --oneline -20 2>/dev/null \
|| printf '(列不出來)\n'
printf '%s\n\n%s\n\n%s\n' "$_fence" "## 改了哪些檔" "$_fence"
git diff --stat '@{upstream}..HEAD' 2>/dev/null | tail -40 || printf '(列不出來)\n'
printf '%s\n\n---\n%s\n' "$_fence" "總管裁完請刪掉這個檔——留著代表「還沒裁」。"
} > "$_reqdir/$_slug.md" 2>/dev/null || true
fi
# ── 擋下的同時,把「誰想推什麼」留成一份請求(leo 2026-08-12)───────────
#
# leo 原話:「**它會問你的意見,所以每個你叫起來的 subagent 都有名字。**」
#
# 做得到的與做不到的,先講清楚:
# ❌ **做不到「同步問總管」**——hook 跑在子 session 自己的行程裡,總管在另一個行程。
# 要同步問只能 block 等一個檔案出現,那會把 subagent 掛死在那裡。
# ✅ **做得到「當場擋 + 留下原始請求」**:總管在自己的迴圈裡讀這個目錄,
# 看到的是 repo/分支/逐筆 commit 的**原始資料**,不是 subagent 的散文轉述。
# ——這才是名字真正值錢的地方:**不是判斷你是誰,是留下是誰要求的**。
#
# 🔴 身分的方向刻意不改:**沒有名字不等於總管**(那是 fail-open——
# 子 session 繼承環境變數,把名字拿掉就升格了)。放行的唯一憑證仍然只有
# 那枚綁 repo、用完即丟的戳記。名字只是署名,不是權限。
# 📌 名字只在 `claude -p` 起的子 session 上可靠(乾淨的環境邊界);
# Agent tool 起的 subagent 與總管同一個行程、共用環境 ⇒ 那條路上名字塞不進也擦不掉。
# 而改子 repo code 的正路本來就是 `claude -p`,所以夠用。
_who="${CLAUDE_AGENT_NAME:-未署名}"
_hookdir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) || _hookdir=""
_reqdir="${_hookdir%/hooks}/pending-main-push"
if [ -n "$_hookdir" ] && mkdir -p "$_reqdir" 2>/dev/null; then
_root=$(git rev-parse --show-toplevel 2>/dev/null || printf 'unknown')
# 檔名只用 ASCII(`未署名` 之類會被 tr 打成一排 dash,看不出是誰)
_slugwho=$(printf '%s' "${CLAUDE_AGENT_NAME:-unnamed}" | tr -c 'A-Za-z0-9._-' '-')
case "$_slugwho" in *[!-]*) : ;; *) _slugwho=unnamed ;; esac
# ⚠️ 先 printf 再 tr`basename` 會帶一個換行,直接餵 tr 會變成結尾多一根 dash
_slugrepo=$(printf '%s' "$(basename "$_root")" | tr -c 'A-Za-z0-9._-' '-')
_slug="${_slugwho}--${_slugrepo}"
# ⚠️ 這幾行刻意用 `printf '%s\n' "整句"`,不要把內容寫進 printf 的格式字串裡。
# 2026-08-12 實撞:格式字串裡同時有反引號與 %s 時,那幾行整行不見(而前後行都在),
# ——**寫完當場肉眼檢查產出的檔案才發現**,hook 自己不會叫。內容一律當資料傳。
_branch=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || printf '?')
_when=$(date '+%Y-%m-%d %H:%M:%S' 2>/dev/null || printf '?')
_fence='```'
{
printf '%s\n\n' "# 推 main 的請求:$_who"
printf '%s\n' "- repo$_root"
printf '%s\n' "- 分支:$_branch"
printf '%s\n\n' "- 時間:$_when"
printf '%s\n' "- 它想跑的指令:"
printf '%s\n%s\n%s\n\n' "$_fence" "$CMD" "$_fence"
printf '%s\n\n%s\n' "## 還沒推上去的 commit(原始資料,不是轉述)" "$_fence"
git log --oneline '@{upstream}..HEAD' 2>/dev/null \
|| git log --oneline -20 2>/dev/null \
|| printf '(列不出來)\n'
printf '%s\n\n%s\n\n%s\n' "$_fence" "## 改了哪些檔" "$_fence"
git diff --stat '@{upstream}..HEAD' 2>/dev/null | tail -40 || printf '(列不出來)\n'
printf '%s\n\n---\n%s\n' "$_fence" "總管裁完請刪掉這個檔——留著代表「還沒裁」。"
} > "$_reqdir/$_slug.md" 2>/dev/null || true
fi
cat >&2 <<'MSG'
cat >&2 <<'MSG'
🚫 推 main 要先有「總管決定了」的戳記(leo 2026-08-10 立)
leo 原話:
@@ -179,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,我看過了**」。
@@ -196,11 +292,8 @@ leo 原話:
【真的該推 main 的例外】不存在。交回總管,一句話的事。
MSG
exit 2
;;
esac
;;
esac
exit 2
fi
fi
# ── ② prod 部署要 leo 親手解保險 ────────────────────────────────────────
+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
+291
View File
@@ -0,0 +1,291 @@
#!/usr/bin/env bash
# mainline-idle-guard.sh — 主線閒置警察(inkstone/ISEP#30 → comment 4851
#
# ── 補的是哪一格 ────────────────────────────────────────────────
# leo 2026-08-27:「**你為什麼會停工什麼都沒派?不是停下來會有 hook 嗎?**」
#
# 總管的行為 空手警察 稼動率警察
# ─────────────────────────────────────────────────────
# 沒動作、沒宣告 🛑 擋 —
# 有動作、有宣告下一步 放行 🛑 擋
# **有動作、不宣告** **放行** **放行** ← 這一格
#
# 第三列就是當天實際發生的:連續數個回合在查 git、讀票、跑測試、複驗 subagent 的
# 宣稱——**十幾個 tool call,兩支閘都放行**,而主線一個 subagent 都沒派。
# **只要不寫「下一步」三個字,兩支閘都抓不到。**
#
# ── 為什麼不是再加一組句型 ──────────────────────────────────────
# 紅線就寫在票上:不准用文字層判準。leo 2026-08-17 已經證偽過那條路
# (文字層的閘當日 8 次誤攔、0 次正確攔截,而且**紅線寫得越細越容易誤攔**)。
# 本閘從頭到尾**不讀任何一個字**——它只看「這個回合做了什麼工具呼叫」與
# 「工作區的狀態有沒有變」。措辭隨它變,這條路物理上不通。
#
# ── 判準:偵察 ≠ 產出 ──────────────────────────────────────────
# 乾回合(dry turn)= ① 這回合 ≥3 個 tool call**有在用力**
# + ② 沒有任何一種「推進證據」(見下)
# 連續 4 個乾回合 ⇒ 擋一次。(4 是量出來的,見「門檻怎麼定的」)
#
# 🔴 兩個「不算乾回合」的設計,是為了不誤攔:
# - **02 個 tool call 的回合完全不計數**。回答 leo 一個問題、順手 grep 兩下,
# 那是對話,不是空轉;而「零動作」已經有空手警察在管。
# - **推進證據採極寬認定**(下面任何一種都算)。寧可漏攔,不可誤攔——
# 永遠在響的警報等於訓練人忽略警報,那比沒有警報更糟。
#
# 推進證據(任何一種成立 ⇒ 這回合有推進,計數歸零):
# ① 派工/驅動別人:AgentTaskSendMessagespawn_task
# ② 產出東西:WriteEditMultiEditNotebookEditArtifact
# ③ 寫進外部系統:工具名含 create/push/update/send/reply/publish/deploy/submit…
# ④ Bash 白名單:git commit/push/merge/tag、scripts/ticket、wrangler deploy…
# ⑤ **工作區狀態變了**HEAD 動了,或 `git status --porcelain` 的內容變了
# ⇒ 這一條是關鍵:用 heredoc/sed/腳本改檔(本環境很常見)不經過 Edit 工具,
# 光看工具名會漏;看**結果**就不會。
#
# 🔴 ③④ 是**只用來放行的白名單**,永遠不用來擋。
# 白名單漏了一項=少放行一次(有 ⑤ 兜底),黑名單漏了一項=誤攔一次。
# 方向不對稱,所以只准往放行的方向做字面比對。
#
# ── 為什麼是「連續」而不是「這一回合」 ──────────────────────────
# 票上第 3 條驗收:「這回合就是在回答一個不需要派工的問題 ⇒ 放行」。
# 單一回合沒有推進,本來就可能完全正當(在複驗、在讀票、在回答)。
# **不正當的是那個狀態持續下去。** 所以量的是連續乾回合數,不是單回合。
# ⇒ 這同時滿足另一條紅線:不會變成「每回合都必須派工」。
#
# ── 門檻怎麼定的:拿真 transcript 量,不是拍腦袋 ────────────────
# 重放本機 6 份真 transcriptInkStoneCo 專案,**1965 個真實回合**),
# 逐回合餵給這道閘,數它會響幾次:
#
# 起始門檻 3 → 4 次(92a75156 兩次、602ea4a1 一次、9e0144f1 一次)
# 起始門檻 4 → **1 次**92a75156 第 311 回合)
#
# 門檻 3 多出來的那三次,看下去都落在「leo 正在連問問題、我逐題查證回答」的段落
# ——那正是票上第 3 條驗收明文保護的情境(**閘不能懲罰誠實回答問題**)。
# 門檻 4 剩下的那一次,落在連續 10 個回合(308–317)只查不產出的那一段,
# 那一段本來就同時被 delivery-police 與 wiki-first-police 連續攔了五次。
# ⇒ **1/1965 0.05%**。取 4。
#
# ⚠️ 重放時一律把 `stop_hook_active` 當成 false(最壞情況)。真實環境裡那幾個
# 回合有不少是被別的 Stop 閘擋出來的 ⇒ 實際會響得比這個數字更少。
#
# ── 響過就退讓(不當那個永遠在響的警報)────────────────────────
# 擋一次之後:計數歸零,**門檻加倍**(3 → 6 → 12 → 24…)。
# 真的在空轉會被早早抓到;真的是一段長時間的正當偵察,警報會自己越來越稀。
# 另外 `stop_hook_active` 為真(已被別的 Stop 閘擋過)時完全不計數。
# ⇒ 不可能把人鎖死:擋完就放行,再送一次即可。
#
# ── 內部錯誤不准靜默放行 ────────────────────────────────────────
# Stop 閘 fail-closed 會把 session 鎖死,而誤攔是本 repo 的第一級缺陷
# ⇒ 內部錯誤走 exit 0,但**留痕**log 記 ⚠️、stderr 也印一行。
# 「不准靜默」是靠留痕滿足的,不是靠擋下。
#
# 迴歸測試:hooks/tests/mainline-idle-guard.test.sh(含「不該擋」的案例)
set -u
PROJ="${CLAUDE_PROJECT_DIR:-$(pwd)}"
PAYLOAD=$(cat 2>/dev/null || echo '{}')
# ⑤ 工作區狀態指紋:HEAD + 未提交變更的內容摘要。
# 不是 git repo 就給空字串(這層證據不可用,其餘四層照跑)。
GITSTATE=""
if git -C "$PROJ" rev-parse --git-dir >/dev/null 2>&1; then
GITSTATE="$(git -C "$PROJ" rev-parse HEAD 2>/dev/null || echo '-')|$(
git -C "$PROJ" status --porcelain 2>/dev/null | shasum 2>/dev/null | cut -c1-16
)"
fi
VERDICT=$(printf '%s' "$PAYLOAD" | GITSTATE="$GITSTATE" python3 -c '
import json, os, re, sys
def out(s):
print(s); raise SystemExit
try:
d = json.load(sys.stdin)
except Exception:
out("SKIP:bad-payload")
# 已被別的 Stop 閘擋過這一輪 ⇒ 不計數、不擋(避免疊擋)
if d.get("stop_hook_active"):
out("SKIP:already-nudged")
tp = d.get("transcript_path") or ""
if not tp or not os.path.exists(tp):
out("SKIP:no-transcript")
sid = d.get("session_id") or "nosid"
sid = re.sub(r"[^A-Za-z0-9_.-]", "_", sid)[:64]
state_path = os.path.join(os.environ.get("MAINLINE_IDLE_STATE_DIR", "/tmp"),
".mainline-idle-%s.json" % sid)
rows = []
try:
with open(tp) as f:
for line in f:
line = line.strip()
if line:
try: rows.append(json.loads(line))
except Exception: pass
except Exception:
out("SKIP:unreadable")
# 這個回合=最後一則「真的來自使用者」的訊息之後(工具結果不算)
start = 0
for i, r in enumerate(rows):
if r.get("type") == "user":
c = (r.get("message") or {}).get("content")
blocks = c if isinstance(c, list) else [{"type": "text"}]
if not any(isinstance(b, dict) and b.get("type") == "tool_result" for b in blocks):
start = i
turn = rows[start:]
names, bash_cmds = [], []
for r in turn:
if r.get("type") != "assistant":
continue
for b in (r.get("message") or {}).get("content") or []:
if isinstance(b, dict) and b.get("type") == "tool_use":
n = b.get("name") or ""
names.append(n)
if n == "Bash":
cmd = (b.get("input") or {}).get("command") or ""
if isinstance(cmd, str):
bash_cmds.append(cmd)
tools = len(names)
# ── 推進證據(白名單:只用來放行)────────────────────────────
DISPATCH = {"Agent", "Task", "SendMessage"}
PRODUCE = {"Write", "Edit", "MultiEdit", "NotebookEdit", "Artifact"}
# 寫進外部系統的 MCP/內建工具,用動詞尾綴認(漏了只是少放行一次)
WRITE_VERB = re.compile(
r"(create|push|update|send|reply|post|publish|deploy|submit|spawn_task|"
r"write|delete|upload|assign|comment|tag_resource|run_workflow)", re.I)
BASH_WRITE = re.compile(
r"git\s+(commit|push|merge|tag|cherry-pick|revert|am|apply)"
r"|scripts/ticket|(^|\s)ticket\s+(say|new|assign|label|close|move)"
r"|wrangler\s+(deploy|publish|d1|kv|r2|secret)"
r"|gh\s+(issue|pr|release)\s+(create|comment|edit|close)"
r"|npm\s+publish|ship\.mjs|github-arm\.sh")
why = ""
if any(n in DISPATCH for n in names):
why = "dispatch"
elif any(n in PRODUCE for n in names):
why = "produce"
elif any(WRITE_VERB.search(n) for n in names):
why = "external-write"
elif any(BASH_WRITE.search(c) for c in bash_cmds):
why = "bash-write"
gitstate = os.environ.get("GITSTATE", "")
# ── 狀態檔 ────────────────────────────────────────────────
st = {}
try:
with open(state_path) as f:
st = json.load(f)
except Exception:
st = {}
# 起始門檻。預設 4 是拿真 transcript 量出來的(見檔頭「門檻怎麼定的」段),
# 環境變數只是為了讓那個量測可以重跑,不是給人隨手調鬆的旋鈕。
try:
BASE = max(2, int(os.environ.get("MAINLINE_IDLE_THRESHOLD", "4")))
except Exception:
BASE = 4
streak = int(st.get("streak", 0) or 0)
threshold = int(st.get("threshold", BASE) or BASE)
last_rows = int(st.get("rows", -1) or -1)
last_git = st.get("git", None)
def save(verdict):
try:
with open(state_path, "w") as f:
json.dump({"streak": streak, "threshold": threshold,
"rows": len(rows), "git": gitstate}, f)
except Exception:
pass
out(verdict)
# 第一次見到這個 session:只立基準,不評分(沒有可比的前一格)
if last_rows < 0:
save("SKIP:baseline")
# 同一個回合被叫第二次(transcript 沒長)⇒ 不重複計數
if len(rows) <= last_rows:
save("SKIP:same-turn")
# ⑤ 工作區狀態變了 ⇒ 有產出(heredoc/sed/腳本改檔都吃得到)
if not why and gitstate and last_git is not None and gitstate != last_git:
why = "worktree-changed"
if why:
streak = 0
save("OK:%s:%d" % (why, tools))
# 沒有推進證據,但這回合幾乎沒用力(≤2 個動作)⇒ 那是對話,不計數
if tools < 3:
save("OK:light-turn:%d" % tools)
streak += 1
if streak >= threshold:
fired_at = streak
fired_threshold = threshold
streak = 0
threshold = threshold * 2 # 響過就退讓:3 → 6 → 12 → 24…
save("IDLE::%d::%d::%d" % (fired_at, fired_threshold, tools))
save("OK:dry-turn:%d/%d" % (streak, threshold))
' 2>/dev/null) || VERDICT="CRASH"
[ -n "${VERDICT:-}" ] || VERDICT="CRASH"
STAMP=$(date "+%Y-%m-%d %H:%M:%S" 2>/dev/null || echo "?")
LOGDIR="$PROJ/.claude/hooks"
note() {
if [ -d "$LOGDIR" ]; then
printf '| %s | %s | %s |\n' "$STAMP" "$1" "$2" >> "$LOGDIR/mainline-idle-guard.log" 2>/dev/null || true
fi
}
case "$VERDICT" in
IDLE::*)
N=$(printf '%s' "$VERDICT" | cut -d: -f3)
T=$(printf '%s' "$VERDICT" | cut -d: -f5)
C=$(printf '%s' "$VERDICT" | cut -d: -f7)
note "⛔ 擋下" "連續 ${N} 個乾回合(門檻 ${T}),本回合 ${C} 個動作、零推進"
cat >&2 <<MSG
🛤️ 主線閒置警察:**連續 ${N} 個回合有動作、沒推進。**(本回合 ${C} 個動作)
這一格是空手警察與稼動率警察中間的漏洞——
「沒動作」有人管、「宣告了下一步卻沒派工」有人管,
但**一直在查、一直在讀、什麼都沒交出去**,兩支都放行。
🔴 只要不寫「下一步」三個字,就沒有任何閘抓得到。這支抓的就是它。
本閘**一個字都不讀**。它只認這五種「推進」,任何一種成立就歸零:
① 派工/驅動別人 AgentTaskSendMessage
② 產出東西 WriteEditArtifact
③ 寫進外部系統 建票/留言/推工作流/部署
④ Bash 寫入 git commitpush、scripts/ticket、wrangler deploy
⑤ 工作區變了 HEAD 動了或有新的未提交變更(heredoc/sed 改檔也算)
━━ 現在做一件就好,不必補一段說明 ━━━━━━━━━━━━━━━━━━━━━━
• 查到的東西**寫回票**\`scripts/ticket say <owner/repo#N> -F <檔>\`
——查證只有落回票上才是資產,留在 transcript 裡下一個 session 就沒了
• 該別人做的**現在派出去**(帶【工單】一行票號)
• 自己該落地的**現在落地**(改檔/commit)
━━ 如果這幾個回合的偵察本來就是正當的 ━━━━━━━━━━━━━━━━━━
直接再送一次即可,本閘已經歸零,而且**門檻已加倍**(下次要 $((T*2)) 個乾回合才會再響)。
🔴 不必為了過這道閘去做一件假的動作——**假派工比不派更糟。**
MSG
exit 2 ;;
CRASH|SKIP:bad-payload)
# fail-closed 的精神在「不准靜默」:Stop 閘擋下內部錯誤會鎖死 session,
# 所以放行,但一定留痕(log + stderr),讓「這道閘壞了」查得到。
note "⚠️ 內部錯誤" "$VERDICT"
printf '⚠️ mainline-idle-guard 內部錯誤(%s)——本回合未評分,已記入 log。\n' "$VERDICT" >&2
exit 0 ;;
*)
note "✅ 放行" "$VERDICT"
exit 0 ;;
esac
+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
+42
View File
@@ -0,0 +1,42 @@
#!/usr/bin/env bash
# milestone-due-guard.sh — 建 milestone 一定要有真的期限(leo 2026-08-21 立)
#
# leo 原話:「**以後所有的 milestone 限制時間**」
# 「**你根本沒有時間概念,浪費一整天**」
#
# 🔴 為什麼連 9999 也要擋:立這條的當下實查七個 open milestone
# 六個的期限是 `9999-01-01`——那是「沒有期限」穿了一件期限的衣服,
# 比空白更糟,因為它讓盤點時看起來每一格都有值。
set -uo pipefail
INPUT=$(cat)
CMD=$(printf '%s' "$INPUT" | python3 -c "import json,sys;print(json.load(sys.stdin).get('tool_input',{}).get('command',''))" 2>/dev/null || echo "")
[ -z "$CMD" ] && exit 0
# 只管「建 milestone」這個動作
printf '%s' "$CMD" | grep -qE 'milestones' || exit 0
printf '%s' "$CMD" | grep -qE '\-X *POST|--request *POST' || exit 0
DUE=$(printf '%s' "$CMD" | grep -oE '"due_on"[^,}]*' | head -1)
BAD=""
[ -z "$DUE" ] && BAD="沒有 due_on"
printf '%s' "$DUE" | grep -q '9999' && BAD="due_on 是 9999(等於沒有期限)"
[ -z "$BAD" ] && exit 0
cat >&2 <<MSG
⏱️ 里程碑期限閘:$BAD
【leo 2026-08-21】「**以後所有的 milestone 限制時間**」
「**你根本沒有時間概念,浪費一整天**」
🔴 9999-01-01 也算違規。立這條的當下實查七個 open milestone
六個是 9999——那是「沒有期限」穿了一件期限的衣服,
比空白更糟,因為盤點時每一格看起來都有值。
怎麼定:這個里程碑的 deliverable 是**一個可測的版本**。
問「這個版本幾號要能給 leo 打開?」,那天就是期限。
剩 1–3 張票 → 一週;4 張以上 → 兩週;本週要收 → 三天。
加上去再送一次: "due_on": "YYYY-MM-DDT23:59:59Z"
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
+394
View File
@@ -0,0 +1,394 @@
#!/usr/bin/env bash
# 管什麼: 收工那一刻清點「我這邊還有沒有 PR 沒給結論」——open 且沒人在管的 PR、
# 以及已經 merge 但**分支還留著**的 PR。有就擋一次並點名是哪幾個。
# 為什麼: 2026-08-27 實查,最久的三個 open PR 躺了兩星期(inkstone/arcrun-rag#91、
# inkstone/Arcrun#116、inkstone/Arcrun#104),而 v0.9.0 的 46 支閘沒有一支在管 PR。
# 票看起來「已交付」,但東西沒進 main ⇒ 沒進版本 ⇒ leo 手上永遠不會出現它。
# 誤觸時怎麼關: 直接再送一次即可(擋完就歸零,而且那幾個 PR 的門檻已加倍)。
# 真的在等別人 ⇒ 把 PR 指派給他(或掛 Human),本閘立刻不再點名它。
#
# pr-verdict-guard.sh — PR 收斂警察(Stopinkstone/ISEP#81
#
# ── 為什麼是 Stop 而不是 PreToolUse ──────────────────────────────────
# PR 堆積不是「某個動作做錯了」,是「某個動作從來沒發生」。
# 沒有發生的事情攔不到,只能在收工那一刻清點。同 worklist-guard.sh 的形狀。
# **刻意只掛 Stop,不掛 SubagentStop**:給 PR 結論是總管的事,
# subagent 手上沒有那個權(它連 main 都不准推)⇒ 掛上去只會變成對它的誤攔。
#
# ── 這支跟 baton-handback-guard.sh 是接力,不是重複 ────────────────
# baton 管「棒子有沒有交回來」(票的指派+tag+下一步三格)。
# 本支管「交回來之後,總管有沒有動它」——PR 是那根棒子的實體。
#
# ── 🔴 已知的坑:這支很容易長成「讓總管永遠停不下來」的那一支 ──────
# ISEP v0.6.0 的 divergence 文件 §B4 記過同款:
# 「`s/review` 佇列非空即 block 總管 stop」⇒ 佇列本來就不會空 ⇒ 永遠擋。
# ⇒ **要擋的是遺忘,不是等待。** 本閘用三層把「等待」全部放掉:
#
# ① 有人被指派(或掛 Human)⇒ 不點名。
# 指派是 Gitea 原生欄位、也是本 repo 既有的「現在誰該動」真相
# (見 baton-handback-guard 檔頭那張三格表)。棒子在誰手上是**事實**,
# 不是措辭 ⇒ 有人拿著 = 在等,不是忘了。
# ② 有一則 REQUEST_CHANGES 的 review ⇒ 不點名。
# 「要求修改」這個結論已經給了,球在對方腳下。
# ③ 這一回合碰過它 ⇒ 不點名(見下面「怎麼算碰過」)。
# ④ 點名過就退讓:那個 PR 的門檻 1 → 4 → 8 → 16…(每 PR 各自算)。
# ⇒ 不可能鎖死:擋完歸零,再送一次就過;真的長期不理才會再響。
#
# ── 怎麼算「碰過」:不讀任何一句話 ──────────────────────────────
# ① `updated_at` 跟上一次收工比變了 ⇒ 碰過(留言/改標籤/推 commitsubmit review
# 全部會動它)。這是**外部系統的事實**,跟我在對話裡怎麼寫無關。
# ② 上一次收工時還不存在的 PR ⇒ 剛開出來 ⇒ 碰過(不在它誕生的那一回合就開罵)。
# ③ 這一回合的 tool call 裡出現它的**識別碼**`owner/repo#N``/pulls/N`/它的網址)
# ⇒ 碰過。這一條是為了接住「結論寫在票上」——票被改了、PR 沒被改,
# ①②都看不到,但那正是本票規定的其中一種合法結論。
#
# 🔴 ③ 不是關鍵字黑名單(leo 2026-08-17 已證偽那條路:當日 8 次誤攔、0 次正確攔截)。
# 差別在方向與集合:黑名單拿**措辭**來**擋**(措辭無限,永遠追不完);
# 這裡拿**識別碼**來**放行**(識別碼是有限且唯一的,而且比對錯了只會少擋一次)。
# 本 repo 心法:只准往放行的方向做字面比對。
#
# ── merge 了但分支還在 = 結論只給了一半 ────────────────────────
# 票上寫死:「merge → 併完當場刪掉那條 branch」。所以某個 PR 從 open 清單消失時,
# 本閘去看它是不是 merged、head 分支還在不在;還在就一起點名。
# (只在「上次還 open、這次不見了」時查,平常一通 API 都不多打。)
#
# ── 內部錯誤不准靜默放行 ────────────────────────────────────────
# Stop 閘 fail-closed 會鎖死 session,而誤攔是本 repo 的第一級缺陷
# ⇒ 內部錯誤走 exit 0,但留痕(log stderr 各一行)。同 mainline-idle-guard.sh。
#
# 迴歸測試:hooks/tests/pr-verdict-guard.test.sh(離線,走 PR_VERDICT_FIXTURE
set -u
PROJ="${CLAUDE_PROJECT_DIR:-$(pwd)}"
PAYLOAD=$(cat 2>/dev/null || echo '{}')
# ── token:同 scripts/ticket 的做法(掃 remote 找帶憑證的,再退到環境變數)──
TOKEN=""
if [ -z "${PR_VERDICT_FIXTURE:-}" ]; then
for d in "$PROJ" "$PROJ/products"/* "$PROJ/matrix"/* "$PROJ/polaris"/*; do
[ -e "$d/.git" ] || continue
U=$(git -C "$d" remote -v 2>/dev/null | grep -m1 'git\.uncle6\.me' | grep '@' | awk '{print $2}') || true
if [ -n "${U:-}" ]; then
T=$(printf '%s' "$U" | sed -E 's|.*//[^:]+:([^@]+)@.*|\1|')
[ "$T" != "$U" ] && { TOKEN="$T"; break; }
fi
done
[ -n "$TOKEN" ] || TOKEN="${GITEA_TOKEN_CLAUDE_CODE:-${GITEA_TOKEN:-}}"
fi
# PR_VERDICT_DEBUG=1 時把內部錯誤原文放出來(平常吞掉,避免雜訊蓋掉閘的訊息)
ERRSINK=/dev/null
[ -n "${PR_VERDICT_DEBUG:-}" ] && ERRSINK=/dev/stderr
# 🔴 payload 走環境變數,不走 stdin——`python3 - <<PY` 的 stdin **就是腳本本身**
# 再從 sys.stdin 讀就會讀到自己的原始碼(第一版實撞,回 CRASH:bad-payload)。
VERDICT=$(GITEA_TOKEN_RESOLVED="$TOKEN" PR_VERDICT_PAYLOAD="$PAYLOAD" python3 - <<'PY' 2>"$ERRSINK"
import json, os, re, sys, time
import urllib.error, urllib.parse, urllib.request
HOST = os.environ.get("PR_VERDICT_HOST", "https://git.uncle6.me")
ORGS = [o.strip().lower() for o in
os.environ.get("PR_VERDICT_ORGS", "inkstone").split(",") if o.strip()]
FIXTURE = os.environ.get("PR_VERDICT_FIXTURE", "")
TOKEN = os.environ.get("GITEA_TOKEN_RESOLVED", "")
def out(s):
sys.stdout.write(s)
raise SystemExit
try:
d = json.loads(os.environ.get("PR_VERDICT_PAYLOAD") or "{}")
except Exception:
out("CRASH:bad-payload")
# 已被別的 Stop 閘擋過這一輪 ⇒ 不計數、不擋(避免疊擋,同 mainline-idle-guard
if d.get("stop_hook_active"):
out("SKIP:already-nudged")
sid = re.sub(r"[^A-Za-z0-9_.-]", "_", str(d.get("session_id") or "nosid"))[:64]
state_path = os.path.join(os.environ.get("PR_VERDICT_STATE_DIR", "/tmp"),
".pr-verdict-%s.json" % sid)
# ── 資料來源:正式打 Gitea,測試走 fixture(單一 JSON 檔或目錄下的 state.json)──
FX = None
if FIXTURE:
p = FIXTURE if os.path.isfile(FIXTURE) else os.path.join(FIXTURE, "state.json")
try:
with open(p) as f:
FX = json.load(f)
except Exception:
out("CRASH:bad-fixture")
def api(path, soft=True):
"""GET 一筆。fixture 模式完全不碰網路。失敗回 None(不影響該不該擋的核心判斷)。"""
if FX is not None:
return None
if not TOKEN:
return None
try:
req = urllib.request.Request(HOST + "/api/v1" + path,
headers={"Authorization": "token " + TOKEN})
return json.load(urllib.request.urlopen(req, timeout=12))
except urllib.error.HTTPError as e:
return {"__status__": e.code}
except Exception:
return None
# ── ① 撈 open PR(一通跨 repo 的呼叫)─────────────────────────────
if FX is not None:
pulls = FX.get("pulls", [])
else:
if not TOKEN:
out("SKIP:no-token")
pulls = api("/repos/issues/search?type=pulls&state=open&limit=50")
if pulls is None or isinstance(pulls, dict):
out("SKIP:api-unreachable")
def ref_of(pr):
return "%s#%s" % (pr.get("repository", {}).get("full_name", "?"), pr.get("number"))
live = {}
for pr in pulls:
full = (pr.get("repository") or {}).get("full_name") or ""
if "/" not in full:
continue
if ORGS and full.split("/", 1)[0].lower() not in ORGS:
continue # 只管我這邊的 org;舊 org 的鏡像不是我的責任
if (pr.get("pull_request") or {}).get("draft"):
continue # draft = 作者自己說「還沒好」,不是等我給結論
live[ref_of(pr)] = pr
# ── ② 讀上一次收工的快照 ─────────────────────────────────────────
st = {}
try:
with open(state_path) as f:
st = json.load(f)
except Exception:
st = {}
seen = st.get("seen") or {}
leftover = st.get("leftover") or {} # ref -> branchmerge 完分支還沒刪的
first_run = not seen and "seen" not in st
# ── ③ 這一回合的 tool call 裡提到了哪些識別碼(只用來放行)──────────
turn_blob = ""
tp = d.get("transcript_path") or ""
if tp and os.path.exists(tp):
try:
rows = []
with open(tp) as f:
for line in f:
line = line.strip()
if line:
try:
rows.append(json.loads(line))
except Exception:
pass
start = 0
for i, r in enumerate(rows):
if r.get("type") == "user":
c = (r.get("message") or {}).get("content")
blocks = c if isinstance(c, list) else [{"type": "text"}]
if not any(isinstance(b, dict) and b.get("type") == "tool_result"
for b in blocks):
start = i
chunks = []
for r in rows[start:]:
if r.get("type") != "assistant":
continue
for b in (r.get("message") or {}).get("content") or []:
if isinstance(b, dict) and b.get("type") == "tool_use":
chunks.append(json.dumps(b.get("input") or {}, ensure_ascii=False))
turn_blob = "\n".join(chunks)
except Exception:
turn_blob = ""
def mentioned(ref):
"""這一回合的 tool call 有沒有指名這個 PR。識別碼比對,只用來放行。"""
if not turn_blob:
return False
full, num = ref.split("#")
owner, repo = full.split("/", 1)
pats = ["%s#%s" % (full, num), "%s#%s" % (repo, num),
"/pulls/%s" % num, "/pull/%s" % num]
return any(p in turn_blob for p in pats)
# ── ④ merge 完分支還在不在(只在「上次還 open、這次不見了」時查)────
def branch_alive(owner, repo, br):
if FX is not None:
return bool((FX.get("branches") or {}).get("%s/%s:%s" % (owner, repo, br)))
r = api("/repos/%s/%s/branches/%s" % (owner, repo, urllib.parse.quote(br, safe="")))
if isinstance(r, dict) and r.get("__status__") == 404:
return False
return isinstance(r, dict) and "name" in r
def pr_detail(ref):
if FX is not None:
return (FX.get("detail") or {}).get(ref)
full, num = ref.split("#")
owner, repo = full.split("/", 1)
r = api("/repos/%s/%s/pulls/%s" % (owner, repo, num))
return r if isinstance(r, dict) and "__status__" not in r else None
for ref in list(seen.keys()):
if ref in live:
continue
det = pr_detail(ref) # 不見了:是被 merge 還是被 close
if not det or not det.get("merged"):
continue
br = ((det.get("head") or {}).get("ref")) or ""
if not br:
continue
full = ref.split("#")[0]
owner, repo = full.split("/", 1)
if branch_alive(owner, repo, br):
leftover[ref] = br
for ref in list(leftover.keys()): # 已經刪掉的就從清單移除
full = ref.split("#")[0]
owner, repo = full.split("/", 1)
if not branch_alive(owner, repo, leftover[ref]):
leftover.pop(ref, None)
# ── ⑤ 逐個 open PR 判「有沒有人在管」──────────────────────────────
def reviews_of(ref):
if FX is not None:
return (FX.get("reviews") or {}).get(ref) or []
full, num = ref.split("#")
owner, repo = full.split("/", 1)
r = api("/repos/%s/%s/pulls/%s/reviews" % (owner, repo, num))
return r if isinstance(r, list) else []
try:
BASE = max(1, int(os.environ.get("PR_VERDICT_THRESHOLD", "1")))
except Exception:
BASE = 1
overdue, newseen = [], {}
for ref, pr in live.items():
prev = seen.get(ref) or {}
rec = {"updated_at": pr.get("updated_at"),
"quiet": int(prev.get("quiet", 0) or 0),
"threshold": int(prev.get("threshold", BASE) or BASE),
"fires": int(prev.get("fires", 0) or 0)}
newseen[ref] = rec
# 有人被指派/掛了 Human = 棒子在某個人手上 ⇒ 在等,不是忘了
if pr.get("assignees"):
rec["quiet"] = 0
continue
if any((l.get("name") or "").lower() == "human" for l in (pr.get("labels") or [])):
rec["quiet"] = 0
continue
# 上一次收工時還不存在 ⇒ 剛開出來,別在它誕生的那一回合就開罵
if not first_run and ref not in seen:
rec["quiet"] = 0
continue
# 這一回合真的動過它(外部事實)/指名過它(識別碼)
if prev.get("updated_at") and pr.get("updated_at") != prev.get("updated_at"):
rec["quiet"] = 0
continue
if mentioned(ref):
rec["quiet"] = 0
continue
rec["quiet"] += 1
if rec["quiet"] < rec["threshold"]:
continue
# 「要求修改」這個結論已經給了 ⇒ 球在對方腳下(只在要開罵時才多打這通)
if any((rv.get("state") or "").upper() == "REQUEST_CHANGES" for rv in reviews_of(ref)):
rec["quiet"] = 0
continue
rec["quiet"] = 0
rec["threshold"] = max(4, rec["threshold"] * 4 if rec["threshold"] == 1
else rec["threshold"] * 2)
rec["fires"] += 1
overdue.append((ref, pr))
try:
with open(state_path, "w") as f:
json.dump({"seen": newseen, "leftover": leftover, "ts": time.time()}, f)
except Exception:
pass
if not overdue and not leftover:
out("OK:clean:%d-open" % len(live))
def age_days(pr):
try:
t = time.mktime(time.strptime(pr.get("created_at", "")[:19], "%Y-%m-%dT%H:%M:%S"))
return max(0, int((time.time() - t) / 86400))
except Exception:
return -1
lines = []
if overdue:
lines.append("【沒有結論的 PR】(open、沒有人被指派、也沒有「要求修改」的 review)")
for ref, pr in sorted(overdue, key=lambda x: -age_days(x[1])):
dd = age_days(pr)
lines.append(" ❌ %-28s 開了 %s天 %s"
% (ref, dd if dd >= 0 else "?", (pr.get("title") or "")[:36]))
lines.append(" %s" % ((pr.get("pull_request") or {}).get("html_url") or ""))
if leftover:
lines.append("【merge 了,但分支還留著】(結論只給了一半)")
for ref, br in sorted(leftover.items()):
lines.append(" ❌ %-28s 分支 %s 還在" % (ref, br))
out("FIRE::" + json.dumps({"body": "\n".join(lines),
"n": len(overdue) + len(leftover)}, ensure_ascii=False))
PY
) || VERDICT="CRASH:hook-error"
[ -n "${VERDICT:-}" ] || VERDICT="CRASH:empty"
STAMP=$(date "+%Y-%m-%d %H:%M:%S" 2>/dev/null || echo "?")
LOGDIR="$PROJ/.claude/hooks"
note() {
if [ -d "$LOGDIR" ]; then
printf '| %s | %s | %s |\n' "$STAMP" "$1" "$2" >> "$LOGDIR/pr-verdict-guard.log" 2>/dev/null || true
fi
}
case "$VERDICT" in
FIRE::*)
BODY=$(printf '%s' "${VERDICT#FIRE::}" | python3 -c 'import json,sys; print(json.load(sys.stdin)["body"])')
N=$(printf '%s' "${VERDICT#FIRE::}" | python3 -c 'import json,sys; print(json.load(sys.stdin)["n"])')
note "⛔ 擋下" "$N 個 PR 沒有結論"
cat >&2 <<MSG
🔀 PR 收斂警察:**還有 ${N} 個 PR 沒有結論,現在不是收工的時候。**
$BODY
【inkstone/ISEP#81】「我交出去的 PR 沒有人去看它⋯⋯票看起來是『已交付』,
但東西沒有進 main,也就沒有進版本,**leo 手上永遠不會出現它**。」
━━ 「有結論」只有三種,缺一不可 ━━━━━━━━━━━━━━━━━━━━━━━━
merge → **併完當場刪掉那條 branch**
scripts/pr-verdict merge <owner/repo#N>
退回 → 理由**寫在票上**(不是寫在訊息裡),然後關掉 PR
scripts/pr-verdict reject <owner/repo#N> --ticket <owner/repo#N> -F <檔>
要求修改 → 修改要求**寫在票上**,並在 PR 上留一則 REQUEST_CHANGES
scripts/pr-verdict changes <owner/repo#N> --ticket <owner/repo#N> -F <檔>
撈一次現況:scripts/pr-verdict list
━━ 如果它本來就在等別人,不是被我忘了 ━━━━━━━━━━━━━━━━━━━━
**把它指派給那個人**(或掛 Human)——本閘立刻不再點名它。
指派是 Gitea 原生欄位,撈一次就看得到「現在誰該動」;
寫在對話裡的「我在等 X」會捲走,欄位不會。
這一條就是本閘跟「s/review 佇列非空即 block」的分界:
**擋的是遺忘,不是等待。**ISEP v0.6.0 divergence §B4 記過那個坑)
━━ 這幾個現在真的不該處理 ━━━━━━━━━━━━━━━━━━━━━━━━━━━
直接再送一次即可,本閘已經歸零,而且這幾個 PR 的門檻已加倍
1 → 48 → 16…,每個 PR 各自算)。**不必為了過這道閘去做一件假的動作。**
MSG
exit 2 ;;
CRASH*)
note "⚠️ 內部錯誤" "$VERDICT"
printf '⚠️ pr-verdict-guard 內部錯誤(%s)——本回合未清點 PR,已記入 log。\n' "$VERDICT" >&2
exit 0 ;;
*)
note "✅ 放行" "$VERDICT"
exit 0 ;;
esac
+73 -3
View File
@@ -41,6 +41,26 @@ try: print(json.load(sys.stdin).get("tool_input", {}).get("command", "") or "")
except Exception: print("")
' 2>/dev/null || printf '')
# ── 🔴 卡點二(inkstone/ISEP#63 → comment 5106):「執行它」vs「談論它」,第八次 ──
#
# 總管要把「本閘擋住了發通知」這件事**貼到票上**,而那則留言的內文**引用了**
# 本閘的判準(實例主機名、寫入旗標的字樣)⇒ **一則留言被自己擋下來。**
# 同款的第八次(`inkstone/InkStoneCo#23` 的標題已經寫著「第七次」)。
# ⇒ 本次修這支閘時**又撞了一次**:連「把修法寫進檔案」的那條指令都被擋,
# 因為它的內文引用了主機名。那就是這一格最好的證據。
#
# leo 2026-08-17 的診斷:文字層的閘那天 **8 次誤攔、0 次正確攔截**,且方向穩定——
# **紅線寫得越細,命中關鍵字的機率越高 ⇒ 那些閘在懲罰謹慎。**
#
# 那一段內文**是資料,不是指令**。這支 lib`github-contact-guard`
# `main-and-prod-push-guard``stage-before-prod-guard` 三支已經在用)只剝內文、
# **保留起始行**——所以真的在部署的寫法(起始行就帶著實例網址與寫入旗標)
# 仍然整條看得到、照樣擋。
#
# ⚠️ 繞法(把內文先寫成檔案、再用不含關鍵字的指令送出)本來就通,
# 但那正是 leo 說的「**人就會學會繞過它,那它就等於不存在**」⇒ 要修的是閘。
CMD=$(printf '%s' "$CMD" | python3 "$(dirname "$0")/lib/strip_""heredoc.py" 2>/dev/null || printf '%s' "$CMD")
# ── 總管戳記:**單次、15 分鐘失效** ────────────────────────────────────
#
# 🔴 這一版一開始寫成「15 分鐘內無限次放行」,而同一天 `main-and-prod-push-guard.sh`
@@ -48,11 +68,23 @@ except Exception: print("")
# **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
@@ -92,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
@@ -220,6 +254,40 @@ case "$CMD_M" in
*curl*|*httpie*|*" http "*|*wget*)
case "$CMD" in
*workers.dev*|*arcrun.dev*)
# 🔴 卡點一(inkstone/ISEP#63 → comment 5106):發一則通知被當成部署擋下
#
# leo 2026-08-28 要的是「每小時一個版本,用 Telegram 通知我」。
# 通道是 `notify_leo` 工作流(wiki `agent-memory.md`:一條 curl、不需任何金鑰)。
# 而發一則 Telegram **一定是帶 body 的寫入型請求打到那台實例**
# ⇒ 在本閘眼裡它跟「部署一個工作流上線」長得一模一樣。
#
# 🔴 **這不是誤攔,是解析度不夠**:兩者打的是同一個 named webhook 家族路徑。
# 真正的差別在**打的是哪一個 named webhook**——
# `…/webhooks/named/<ns>/notify_leo/trigger` = 發一則訊息,什麼都沒改
# `…/webhooks/named`(無名字、無 /trigger) = 部署工作流定義
# `…/webhooks/named/<ns>/ship_refresh_cdn/trigger` 真的動線上狀態
#
# 判準因此看**目標的結構**(路徑形狀+工作流名字),不是「有沒有寫入旗標」:
# 指令裡打到實例的**每一個**網址都是白名單裡的通知型 trigger ⇒ 放行
# 只要有一個不是(含部署端點、含別的工作流)⇒ 照擋
# ⚠️ 放行的範圍刻意只有一個名字。**不放行整個 named webhook 家族**——
# 那會把部署一起放掉,等於拆了這道閘。
NOTIFY_ONLY=$(printf '%s' "$CMD" | python3 -c '
import re, sys
cmd = sys.stdin.read()
ALLOW = {"notify_leo"} # 通知型:只發訊息,不改線上任何狀態
urls = re.findall(r"https?://[^\s\"\x27<>]+", cmd)
inst = [u for u in urls if re.search(r"://[^/]*(workers\.dev|arcrun\.dev)", u)]
if not inst:
raise SystemExit(0) # 沒有實例網址 ⇒ 這裡不表態,交給下面的寫入語意判斷
PAT = re.compile(r"https?://[^/]+/webhooks/named/[^/]+/([A-Za-z0-9_-]+)/trigger/?$")
def notify(u):
m = PAT.match(u.rstrip(",;)"))
return bool(m) and m.group(1) in ALLOW
print("notify-only" if all(notify(u) for u in inst) else "")
' 2>/dev/null || printf '')
[ "$NOTIFY_ONLY" = "notify-only" ] && exit 0
# 🔴 2026-08-12 修誤攔:` -d ` 不只是 curl 的 --data**一堆唯讀工具也用 -d**
# `tr -d``cut -d=``sort -d``date -d``xargs -d``paste -d`…)。
# 實撞:總管要「唯讀」驗證一把金鑰能不能開 kbdb 的閘,指令第一行是
@@ -256,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 的手。
+73
View File
@@ -0,0 +1,73 @@
#!/bin/bash
# 管什麼: 直接打 Gitea API 貼留言到票上時,內文第一行沒有【身份】就擋。
# 為什麼: scripts/ticket 已經在正門擋了,但貼留言還有 API 這條側門——只封一條等於沒封。
# 誤觸時怎麼關: 內文第一行加【身份】<總管|subagent|leo><owner/repo><分支>,或在指令裡加 reply-identity-ok 留痕放行。
#
# reply-identity-guard.sh — PreToolUseBash):**票上的每一則留言都要認得出是誰寫的**
#
# 來由(leo 2026-08-27inkstone/ISEP#30 comment 4325):
# 「**subagent 回覆時要表明身份**」
# 實害(同日):多條線並行,票上的留言看不出是誰寫的,
# **總管寫的診斷被當成 subagent 的結論,而其中一則是錯的**。
#
# 為什麼要有這一支,正門的檢查不夠:
# `scripts/ticket say` 已經在貼上去之前擋了。但**貼留言這個動作有兩條路**——
# 而 `ticket-api-bypass-guard.sh` 是**刻意放行**「對既有票的留言」的(它管的是開新票)。
# ⇒ 側門大開。這正是那支閘檔頭自己記過的一課:
# 「規範有、閘也有,但閘長在『工具』上,而那個動作有兩條路,只封了一條。」
#
# 判準是**結構**不是文字(leo 2026-08-17:「封的是動作,不是文字」):
# 本閘不看留言寫什麼,只看**那個欄位在不在**。
# 三個條件都命中才擋:① 打到 /issues/<N>/comments 這條端點 ② 指令裡有 POST
# ③ 內文找不到【身份】。純讀取(GET 撈留言)一律放行。
#
# 逃生口:指令裡加 `reply-identity-ok`(會留在指令歷史上)。
#
# 測試:hooks/tests/reply-identity.test.sh
set -uo pipefail
INPUT=$(cat)
CMD=$(printf '%s' "$INPUT" | python3 -c "
import sys, json
try: print(json.load(sys.stdin).get('tool_input', {}).get('command', ''))
except Exception: print('')
" 2>/dev/null)
[ -n "$CMD" ] || exit 0
# 逃生口(留痕)
case "$CMD" in *reply-identity-ok*) exit 0 ;; esac
# 正門有自己的閘,重複擋只會互相打架
case "$CMD" in *scripts/ticket*|*"ticket say"*|*"ticket decide"*|*"ticket close"*) exit 0 ;; esac
# ① 打到「某張票的留言」這條端點(帶票號的子路徑,這是本閘唯一管的東西)
printf '%s' "$CMD" | grep -qE 'issues/[0-9]+/comments' || exit 0
# ② 純讀取一律放行。同 ticket-api-bypass-guard 的理由:只認一個裸字就夠——
# ①已經確定這是留言端點,讀取的指令不會出現 POST,而且沒有跳脫的破口。
printf '%s' "$CMD" | grep -qw 'POST' || exit 0
# ③ 內文有沒有身份欄。只問「在不在」,不問「寫什麼」。
printf '%s' "$CMD" | grep -q '【身份】' && exit 0
cat >&2 <<'MSG'
🚫 貼到票上的留言,第一行要表明身份(leo 2026-08-27:「**subagent 回覆時要表明身份**」)
你正在用 Gitea API 直接貼留言,而內文裡找不到【身份】。
第一行照這個寫(角色三選一:總管/subagent/leo):
【身份】subagentinkstone/ISEPfeat/my-branch
【身份】總管/inkstone/InkStoneCo-
**這條管所有人,不是只管 subagent。** 總管寫在票上的東西同樣要標——
實害(2026-08-27):多條線並行時票上看不出誰寫的,
**總管寫的診斷被當成 subagent 的結論,而其中一則是錯的**。
── 走正門比較省事 ─────────────────────────────────────────────
scripts/ticket say <owner/repo#N> -F <內文檔>
它會回一行「定址」,那串就是下次派工要寫進【工單】的東西。
規約全文:docs/governance/dispatch-and-reply-format.md §3
真的有例外 → 指令裡加 reply-identity-ok(會留在指令歷史上)。
MSG
exit 2
+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
+78 -13
View File
@@ -1,4 +1,11 @@
#!/bin/bash
# 管什麼: Write/Edit 動 code 檔(.ts/.py/.go…)前,要不要有對應的一份 status: active SDDdesign.md)。
# 為什麼: SDD 生命週期鐵律——動 code 前必須有規格可對,且整個 repo 同一時刻只准一份 active。
# 把「動手前先讀 SDD」從只能靠人記,升級成機器擋(system-dev/docs/3-specs/SDD-LIFECYCLE.md)。
# 誤觸時怎麼關: 改文件/測試檔/3-specs 自己一律放行(下方 case 已排除);不在任何 git repo
# 裡的路徑(scratchpad、/tmp 暫存檔)一律放行,SDD 管不到它們。真的要臨時豁免
# 一次小改動,說明範圍後由人手動放行——這支閘不設「一行關掉」的旗標。
#
# PreToolUse hook — 動 code 前檢查 SDD 單一活性 SDD 鐵律(issue #6
# wishlist §2:把 /sdd-check 從「命令要人打」升級成「hook 自動攔」。
# 生命週期規則全文:system-dev/docs/3-specs/SDD-LIFECYCLE.md
@@ -18,6 +25,8 @@
set -euo pipefail
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib/path-resolve.sh"
INPUT=$(cat)
# 解析 file_path。優先用 jq,沒有 jq 退回 grep(容錯)。
@@ -38,8 +47,51 @@ fi
# ⇒ 改成從被改檔案往上找最近的 system-dev/docs/3-specs(子 repo 優先,找不到才用頂層)。
# ⚠️ 只往上找到「頂層 InkStoneCo」為止——不可讓任意路徑(如 /private/tmp/…)
# 退回頂層 SDD 而被放行,那會把原本擋得住的情況變成擋不住。
SPECS_DIR="system-dev/docs/3-specs"
#
# 🔴 2026-08-20 修(inkstone/InkStoneCo#22):上面這套邏輯有兩個洞,都是總管 08-12 實撞的:
#
# 洞 A — scratchpad 暫存檔被當成「code 變動」:
# `/private/tmp/.../scratchpad/foo.py` 不在 `$_root` 底下、往上找不到 3-specs
# 於是走到「找不到 SDD」擋下路徑——但 scratchpad 是 session 專用暫存區,從不進版控,
# SDD 管的是 repo 裡的產品程式碼,管不到它。**先問「這條路徑到底在不在某個 git repo
# 裡」(`path_in_git_worktree`,見 lib/path-resolve.sh),不在 ⇒ 這道閘天生管不到
# ⇒ 直接放行**,不必先繞去猜專案根、再證明找不到才擋。
# 用「有沒有 .git 可尋」判斷,比列舉路徑關鍵字(/tmp、scratchpad…)更穩:
# 不必窮舉每一種暫存區的命名法,任何真的不在 repo 裡的路徑都一視同仁。
#
# 洞 B — 訊息裡印出字面的 `/nonexistent`
# 舊版用 `/nonexistent/3-specs` 當內部 sentinel,讓「找不到 SDD」的既有擋下路徑可以
# 重用;但這個 sentinel 值被直接印進使用者看到的訊息,讀起來像是「這支腳本認真去
# /nonexistent 這個地方找過」——具體、卻是假的。改成用 RESOLVED 旗標記「解析成不成功」,
# 擋下訊息另外用人話描述「為什麼找不到」,不洩漏內部實作用的假路徑。
#
# ⚠️ 洞 A/B 都不改變「真的解析失敗時」的判定方向:路徑確實落在某個 git repo 裡,
# 但那個 repo 沒有 3-specs(或裡面沒有 active SDD)→ 仍然 **fail-closed**(擋,不放行)。
# 為什麼是 fail-closed、不是 fail-open:這道閘存在的目的就是防止「沒有 SDD 卻能動
# code」,若把「判斷不出來」直接放行,等於把一次環境跑歪(cwd 被切走、
# `$CLAUDE_PROJECT_DIR` 沒設、worktree 缺 3-specs…)悄悄變成「這道閘關掉了,而且沒有
# 任何人被告知」——silent bypass 的代價遠高於「多打一次確認」。#22 的紅線也明寫
# 「不要把閘改成『解析失敗就放行』——那是把誤判換成漏判」。
# 洞 A 的修法,套用在 case 分岔**之前**:不管 `$_root` 猜不猜得對,
# 先問「這條路徑到底在不在某個 git repo 裡」。不在 ⇒ SDD 這道閘天生管不到,直接放行。
# 🔴 這個檢查故意放在 `$FILE_PATH` 是否落在 `$_root` 底下的判斷之前、且對兩邊都適用
# (不是只套用在「專案外」那個分支):第一版只把它放進「專案外」分支,結果測試
# hooks/tests/sdd-guard.test.sh)就抓到一個不對稱漏洞——當 `$_root` 剛好等於
# scratchpad 的某層祖先目錄(例如 hook 被叫用時 cwd 已經跑到 /private/tmp 底下、
# `$CLAUDE_PROJECT_DIR` 也沒設),scratchpad 路徑會被判成「在 `$_root` 底下」而
# 走進另一條完全沒做 git-repo 檢查的路徑,同一個誤判换個路徑重新出現。
# 改成「先問是不是在 git repo 裡,不管路徑跟 `$_root` 的關係」就沒有這個不對稱。
if ! path_in_git_worktree "$FILE_PATH"; then
exit 0
fi
_root="${CLAUDE_PROJECT_DIR:-$(pwd)}"
# 預設值一律絕對路徑(不留相對路徑「system-dev/docs/3-specs」退回目前 cwd 的洞——
# 舊版這裡曾經是相對路徑,若專案內迴圈找不到就會被拿去跟 hook 執行當下的 cwd 兜,
# cwd 湊巧有同名目錄就會判斷到不相干的資料)。
SPECS_DIR="$_root/system-dev/docs/3-specs"
RESOLVED=1 # 1SPECS_DIR 是有意義的答案;0=真的解析失敗,SPECS_DIR 留空,訊息另外講原因
case "$FILE_PATH" in
"$_root"/*)
_d=$(dirname "$FILE_PATH")
@@ -53,16 +105,17 @@ case "$FILE_PATH" in
done
;;
*)
# 專案外的路徑:**不可退回頂層 SDD 就放行**,否則原本擋得住的會變成擋不住
# 但 **git worktree 是正當工作區**(本專案大量使用 /private/tmp 下的 worktree 出貨),
# 它自己就帶著該 repo 的 system-dev/docs/3-specs ⇒ 一樣往上找,找得到就認。
# 找不到才指向不存在目錄 ⇒ 走原有的「找不到 SDD」擋下路徑
# 2026-08-02:第一版忘了 worktree,把正當的出貨工作區也擋掉。)
SPECS_DIR="/nonexistent/3-specs"
# 專案外的路徑:`$_root` 猜錯,或這條路徑本來就不屬於目前的 `$_root`
# 已知落在某個 git repo 裡(上面剛確認過):往上找它自己的 3-specs。
# **不可退回 `$_root` 的 3-specs 就放行**——那會把「這個 repo 沒有 SDD」
# 誤判成「用別的 repo 的 SDD 蒙混過關」,原本擋得住的會變成擋不住
SPECS_DIR=""
RESOLVED=0
_d=$(dirname "$FILE_PATH")
while [ "$_d" != "/" ] && [ -n "$_d" ]; do
if [ -d "$_d/system-dev/docs/3-specs" ]; then
SPECS_DIR="$_d/system-dev/docs/3-specs"
RESOLVED=1
break
fi
_d=$(dirname "$_d")
@@ -70,6 +123,18 @@ case "$FILE_PATH" in
;;
esac
# 給訊息用的人話描述:解析成功就印真路徑,失敗就誠實講「為什麼」,不印假路徑
# (洞 B 的修法——舊版這裡印的是內部 sentinel `/nonexistent/3-specs`)。
if [ "$RESOLVED" -eq 1 ]; then
SPECS_DIR_DESC="${SPECS_DIR}/"
SPECS_NOT_FOUND_MSG="${SPECS_DIR}/ 下找不到任何 SDD"
SPECS_NOT_ACTIVE_MSG="${SPECS_DIR}/ 下沒有任何 status: active 的 SDD"
else
SPECS_DIR_DESC=""
SPECS_NOT_FOUND_MSG="這條路徑所在的 git repo 裡找不到 system-dev/docs/3-specs,也就沒有任何 SDD 可對(或這支閘沒能定位到正確的專案根——這是 fail-closed:寧可誤擋也不悄悄放行,見檔頭註解)"
SPECS_NOT_ACTIVE_MSG="$SPECS_NOT_FOUND_MSG"
fi
# ── 統計 active / frontmatter ──────────────────────
# 排除 archive/(已封存)與 TEMPLATE(範本自帶 status: draft frontmatter,不算數——
# 否則 update 一鋪新版 TEMPLATE-sdd,老 repo 就被誤判「已遷移」而全紅,向下相容破功)。
@@ -77,7 +142,7 @@ esac
ACTIVE_COUNT=0
FM_COUNT=0
ACTIVE_LIST=""
if [ -d "$SPECS_DIR" ]; then
if [ -n "$SPECS_DIR" ] && [ -d "$SPECS_DIR" ]; then
while IFS= read -r f; do
[ -n "$f" ] || continue
HEAD10=$(head -10 "$f" 2>/dev/null || true)
@@ -121,20 +186,20 @@ esac
# 避免 template update 一裝新 hook,老 repo 所有 code 寫入立刻全紅。
if [ "$FM_COUNT" -eq 0 ]; then
SDD_COUNT=0
if [ -d "$SPECS_DIR" ]; then
if [ -n "$SPECS_DIR" ] && [ -d "$SPECS_DIR" ]; then
SDD_COUNT=$(find "$SPECS_DIR" -name 'design.md' -not -path '*TEMPLATE*' -not -path '*/archive/*' 2>/dev/null | wc -l | tr -d ' ')
fi
if [ "$SDD_COUNT" -eq 0 ]; then
cat >&2 <<EOF
🚫 SDD 協議攔截:要動 code 檔 ($FILE_PATH),但 ${SPECS_DIR}/ 下找不到任何 SDD
🚫 SDD 協議攔截:要動 code 檔 ($FILE_PATH),但 ${SPECS_NOT_FOUND_MSG}
絕對鐵律:任何 code 變動前必須有對應 SDD(design.md),且遵守單一活性生命週期
system-dev/docs/3-specs/SDD-LIFECYCLE.md)。
請先:
1. 確認這個改動屬於哪個子系統
2. 在 ${SPECS_DIR}/[子系統]/ 建立 design.md(可用 /sdd-check 協助),frontmatter 標 status: active
2. 在 [子系統的] system-dev/docs/3-specs/[子系統]/ 建立 design.md(可用 /sdd-check 協助),frontmatter 標 status: active
3. 在回覆開頭宣告已讀 SDD + 對應 task
小修改(修 bug、改文字)若確定豁免,請明確說明範圍後由人放行。
@@ -143,14 +208,14 @@ EOF
fi
# 舊行為放行 + 提醒遷移(stderr 警告,不擋)
echo "📋 提醒:${SPECS_DIR}/ 有 SDD 但尚未掛生命週期 frontmatter(老結構)。動手前確認已讀對應 design.md;建議依 SDD-LIFECYCLE.md 補 status 標記(現行那份標 active)。" >&2
echo "📋 提醒:${SPECS_DIR_DESC} 有 SDD 但尚未掛生命週期 frontmatter(老結構)。動手前確認已讀對應 design.md;建議依 SDD-LIFECYCLE.md 補 status 標記(現行那份標 active)。" >&2
exit 0
fi
# ── 新行為:寫 code 檔需「恰好 1 份」active SDD ──
if [ "$ACTIVE_COUNT" -eq 0 ]; then
cat >&2 <<EOF
🚫 SDD 協議攔截:要動 code 檔 ($FILE_PATH),但 ${SPECS_DIR}/ 下沒有任何 status: active 的 SDD
🚫 SDD 協議攔截:要動 code 檔 ($FILE_PATH),但 ${SPECS_NOT_ACTIVE_MSG}
單一活性鐵律:所有開發任務唯一對應源=那份 active SDD(規則見 system-dev/docs/3-specs/SDD-LIFECYCLE.md)。
+198
View File
@@ -0,0 +1,198 @@
#!/usr/bin/env bash
# 管什麼: 把 kbdb_search 掉出來的原始值當證據,貼進票或派工單裡,而那筆資料從沒走過產品檢索路徑時,擋一次。
# 為什麼: 2026-08-27 inkstone/Arcrun#167 comment 4865——總管用 kbdb_search 的輸出判定「兩個缺陷都還在」,退回一張已經做完、有測試、東西在 main 的票,還重派一條線。半小時後自己更正(comment 4877):那條路本來就不會有那個欄位。
# 誤觸時怎麼關: 指令或派工單裡加 `search-is-evidence: <一句理由>` 重送(留痕);同一 session 只擋一次。
#
# search-is-not-proof-guard.sh — 你判斷的那條路,和你走的那條路,是同一條嗎?
# PreToolUse: Bash / Agent / Task
#
# ── 這一格為什麼原本沒有閘(inkstone/ISEP#30 comment 4879 的原話)──────
# 「所有現有的閘防的是**危險動作**:推 main、推 prod、寫金鑰、打真庫、開重複票。
# 而『用 kbdb_search 去證明某條路壞了』——不危險、不寫檔、不推 main。
# **它只是錯的方法。** ⇒ 閘的座標系裡沒有這一維。」
#
# ── 判準:出處,不是措辭 ────────────────────────────────────────────
# 🔴 驗收條件第 4 條寫死:**不要用關鍵字比對我的措辭**。
# leo 2026-08-17 已經證偽過文字層——那天 **8 次誤攔、0 次正確攔截**,
# 而且方向穩定:紅線寫得越細,命中關鍵字的機率越高 ⇒ **在懲罰謹慎**。
#
# 所以這支一個字都不看。它只問一件機械問題:
#
# 你正要送出去的這份東西裡,有沒有一個**只在 kbdb_search 的回應裡出現過**
# 的值(entry id / `kb://` 來源位址)?而那筆資料,這個 session 從來沒有
# 用產品檢索路徑(graph / wiki 內容)拿過?
#
# ⇒ 兩個條件都成立,才代表「我在拿輔助工具的輸出,當產品檢索路徑的證據」。
# 這是**資料的出處**,不是我的用詞。值跟動作一樣有限且可枚舉。
#
# ── D81:哪條才是產品檢索路徑(leo 2026-08-13 親自裁定)──────────────
# `system-dev/docs/2-architecture/decisions/D81-vector-only-for-entity-normalize.md`
# 「你會用 **graph 找到該找的東西**,用 **LLM wiki 去拉到對的內容**
# 所以全量向量化沒意義⋯⋯**向量的目的是用來協助把 entities normalize**。」
# ⇒ 主路徑=graph + wiki 內容;向量/關鍵字搜尋=**輔助**。
#
# 🔴 **這支不會讓 `kbdb_search` 變難用。** 它是合法的基本盤工具,
# subagent-wiki-guard.sh 還明文要求「查任何東西之前先用它查 wiki」。
# 查一筆資料在不在、找 record_id、翻 wiki 找判例——**全部照放**,
# 因為那些用途不會把 entry id 貼進票裡當證據。
#
# ── 真跡(本閘的規格,Arcrun#167 comment 4865 原文照貼)───────────────
# $ kbdb_search(q="小果 AFTEE 詐貸") → count 5
# { "id": "e_b77f23a1-b467-4aa4-883c-58a051ab8e66", … }
# "metadata_json": "{\"source\":\"kb://小果被AFTEE詐貸.pdf#8\", … }"
# ⇒ 那則退回裡**同時**有 entry id 和 `kb://` 位址,兩類指紋都命中,
# 而整個 session 沒有任何一次 graphwiki 內容的呼叫。
# ⇒ 半小時後 comment 4877:「上一則退回是錯的;真正的缺口是那支工具 MCP 沒有」。
# **那半小時裡,一條線被停掉、一張做完的票被退回、一份工單被重寫。**
#
# ── 誠實邊界(不補,補了就變成文字層)────────────────────────────────
# 1. **改寫過的證據抓不到**——「我看到出處還是 `../` 開頭」這種轉述沒有指紋。
# 但房規本來就要求貼原始輸出(規則四之一、派工鐵律之三),
# 而貼了原始輸出就會被抓到。**不貼的那條路由別的閘管,不歸這支。**
# 2. **只認 KBDB 這一族**。別的「錯的驗證路徑」(拿 portal 畫面當 MCP 的證據——
# 08-27 同一天的另一半)這支看不到,因為那不經過工具呼叫。
# 3. 想不出乾淨判準的地方就留白,不硬做。
# `.claude/branch-holds.md` 檔頭那句:**永遠在響的警報,等於訓練人忽略這個警報。**
set -uo pipefail
INPUT=$(cat)
SID=$(printf '%s' "$INPUT" | python3 -c "
import sys, json
try: print(json.load(sys.stdin).get('session_id','') or 'nosid')
except Exception: print('nosid')
" 2>/dev/null || echo nosid)
SEARCH="/tmp/.kbdb-evidence-search-$SID"
# 這個 session 從來沒用搜尋拿過任何東西 ⇒ 這支沒有立場說話,最便宜的出口放在最前面
[ -s "$SEARCH" ] || exit 0
WARNED="/tmp/.search-is-not-proof-warned-$SID"
[ -f "$WARNED" ] && exit 0 # 同一 session 只擋一次,不鬼打牆
# 🔴 env 要 export,不能掛在 `printf` 前面——那樣只餵到 printfpython3 收不到
export SEARCH_FILE="$SEARCH"
export WALKED_FILE="/tmp/.kbdb-evidence-walked-$SID"
HIT=$(printf '%s' "$INPUT" | python3 -c '
import json, os, re, sys
try:
d = json.load(sys.stdin)
except Exception:
raise SystemExit # 讀不到 payload 就不擋(那不是使用者的錯)
tool = d.get("tool_name") or ""
ti = d.get("tool_input") or {}
if not isinstance(ti, dict):
raise SystemExit
# ── ① 這是不是一個「往外送」的動作?──────────────────────────────
# 往外送 = 這份文字會變成別人的前提:貼進票、關票、記裁決、或發包給另一條線。
# 只在自己腦袋裡看搜尋結果,永遠不會走到這裡。
body, kind = "", ""
if tool in ("Agent", "Task"):
body = ti.get("prompt") or ""
kind = "派工單"
elif tool == "Bash":
cmd = ti.get("command") or ""
if not cmd:
raise SystemExit
outward = False
if re.search(r"\bticket\s+(say|close|decide|new|subtask|handback)\b", cmd):
outward = True
if re.search(r"issues/\d+(/comments)?\b", cmd) and re.search(r"\b(POST|PATCH)\b", cmd):
outward = True
if not outward:
raise SystemExit
body, kind = cmd, "票上的留言"
# 內文常常放在 -F <檔> 裡,指令本身看不到 ⇒ 跟 comment-carries-task-guard 同一招
m = re.search(r"[\s](?:-F|--file)[\s]+(\S+)", cmd)
if m and os.path.isfile(m.group(1)):
try:
body += "\n" + open(m.group(1), encoding="utf-8", errors="replace").read()
except Exception:
pass
else:
raise SystemExit
if not body:
raise SystemExit
# 逃生口(留痕:它會留在指令歷史/派工單上)
if "search-is-evidence:" in body:
raise SystemExit
def load(p):
try:
with open(p, encoding="utf-8") as f:
return {ln.strip() for ln in f if ln.strip()}
except Exception:
return set()
searched = load(os.environ["SEARCH_FILE"])
walked = load(os.environ["WALKED_FILE"])
# ── ② 只在搜尋裡出現過、真路徑沒走過的那些值 ─────────────────────
# 走過真路徑的(graph / wiki 內容 / query)從候選裡扣掉——
# **搜完再去走一次真路徑,本來就是對的做法,不該被罰。**
only_search = searched - walked
if not only_search:
raise SystemExit
# ── ③ 這些值有沒有真的被貼進要送出去的那份東西裡 ──────────────────
hits = sorted(v for v in only_search if v in body)
if not hits:
raise SystemExit
print(kind + "\t" + "\t".join(hits[:3]))
' 2>/dev/null) || exit 0
[ -n "${HIT:-}" ] || exit 0
KIND=$(printf '%s' "$HIT" | cut -f1)
VALS=$(printf '%s' "$HIT" | cut -f2- | tr '\t' '\n' | sed 's/^/ /')
date +%s > "$WARNED"
cat >&2 <<EOF
🧭 你正要把 **kbdb_search 掉出來的原始值**,當成證據送進${KIND}裡。
被貼過去的值(這個 session 只在 kbdb_search 的回應裡出現過):
$VALS
而這個 session **沒有用產品檢索路徑拿過這幾筆**——
graph\`kbdb_graph_neighbors\`)/wiki 內容(\`kbdb_get_record\`)/\`kbdb_query\` 一次都沒有。
【D81leo 2026-08-13 親自裁定】
「你會用 **graph 找到該找的東西**,用 **LLM wiki 去拉到對的內容**⋯⋯
**向量的目的是用來協助把 entities normalize**。」
⇒ 主路徑=graph + wiki 內容。**搜尋是輔助,不是產品檢索路徑。**
🔴 這不是說 \`kbdb_search\` 不能用——它是合法的基本盤工具,查 wiki 本來就該用它。
不能做的是**拿它的輸出,去斷定產品檢索那條路是好是壞**。
【它花掉過多少(inkstone/Arcrun#1672026-08-27)】
12:34 comment 4865 用 kbdb_search 的輸出判定「兩個缺陷都還在」→ 退回一張
**已經做完、有測試、東西在 main** 的票,並重派一條線
13:08 comment 4877 總管自己更正:「上一則退回是錯的;
真正的缺口是**那支工具 MCP 根本沒有**」
⇒ 真相是那次修的是另一支工具的欄位,\`kbdb_search\` 本來就不會有它。
**在用一條錯的路,反覆確認一件錯的事**——而真正的缺口一直沒被看到。
【送出前先回答這一題】
**你要下結論的那條路,和你剛才實際走的那條路,是同一條嗎?**
(08-27 那則退回自己就寫著:「我當時驗的是 portal 的來源顯示,
但這張票的驗收條件是 MCP client 問『原文在哪』。**兩條不同的路。**」)
【出路,挑一條】
① 走一次真的那條路再下結論 —— \`kbdb_graph_neighbors\` / \`kbdb_get_record\` / \`kbdb_query\`
走過之後同一筆資料就不再命中本閘(走過的會從候選裡扣掉)。
② **那條路的工具根本不存在** ⇒ **那個才是你要回報的東西**(4877 就是這樣收的)。
照實寫「產品檢索路徑上少一支工具」,不要改用搜尋去補位再下判斷。
③ 真的只是引用一筆資料、不是在斷定檢索好壞
⇒ 加 \`search-is-evidence: <一句理由>\` 重送(留痕)。
同一 session 只擋這一次。
EOF
exit 2
+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"
+24 -2
View File
@@ -1,4 +1,7 @@
#!/bin/sh
# 管什麼: prod 出貨鏈的動作(打 prod bundle、開 GitHub 保險、prod 安裝器部署)——沒有 6 小時內的 stage 驗證紀錄不放行。
# 為什麼: leo 2026-08-08——開始封測後推 prod 就是發佈給人看,要先在 stage 走過一次真實路徑再上。
# 誤觸時怎麼關: 讀取/查證(讀 manifest、讀腳本、curl 公開 CDN)一律放行;真的驗過 stage,貼實測輸出後 `touch /tmp/.stage-verified` 重送。
# stage-before-prod-guard.sh — PreToolUseBash):**未經 stage 驗過,不准動 prod 出貨鏈**。
#
# 🔴 立這道閘的來由(leo 2026-08-08):
@@ -32,6 +35,12 @@ except Exception: print("")
' 2>/dev/null || echo "")
[ -z "$CMD" ] && exit 0
# 2026-08-20inkstone/InkStoneCo#23、#56 同款):heredoc 的 body 是資料,不是指令。
# 拿掉 heredoc body 再比對,跟 github-contact-guard.shmain-and-prod-push-guard.sh
# 用同一支共用輔助程式;失敗就退回原字串(fail-open 在「這支有沒有幫上忙」,
# 不是「放行與否」——見 hooks/lib/strip_heredoc.py 檔頭)。
CMD=$(printf '%s' "$CMD" | python3 "$(dirname "$0")/lib/strip_heredoc.py" 2>/dev/null || printf '%s' "$CMD")
# 只攔「會讓封測者拿到東西」的動作:prod bundle repo、開 GitHub 保險、prod 安裝器部署。
# staging 的同名動作要放行——所以先排除帶 staging 字樣的命令。
case "$CMD" in
@@ -150,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)
@@ -163,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
@@ -187,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
-183
View File
@@ -1,183 +0,0 @@
#!/usr/bin/env bash
# subagent-claim-worksheet.sh — subagent 交回時,把它的宣稱抽成一張待驗工作單
# SubagentStopleo 2026-08-18 立)
#
# ── leo 的原話(本閘的規格)─────────────────────────────────────────
# 「你如何確認說的都是真的?subagent 做完事,你應該在 SubagentStop 掛動作,
# **它說可以,你去驗證;它說不行,你想辦法,不行再報告**,
# 這解決一半的未查證。你常常直接把 subagent 說的不經查證就回報,
# **它視野小,它說的你有更多資訊去檢驗**。」
#
# ── 為什麼是「工作單檔案」而不是提醒文字 ─────────────────────────────
# leo 2026-08-17 診斷過:文字層封路是打不贏的軍火競賽,要封**動作**。
# 所以這支不勸、也不偵測我的措辭——它**在磁碟上放一個必須被處理掉的東西**。
# 配套的 Stop 閘(claim-verify-police.sh)看的是「那個檔還在不在」,
# 而檔案只能被動作消滅,不能被文案消滅。
#
# ── 兩類宣稱,處理方式不同(leo 的規格就是這兩條)──────────────────
# ✅ 它說可以 → 我去驗證(我有跨 repo 視野、有 token、有瀏覽器,它沒有)
# ⛔ 它說不行 → 我想辦法(換路徑/查憑證地圖/自己撞一次),真的不行才報告 leo
#
# ⚠️ 這裡的關鍵詞比對只用來**列出候選**,不用來判定對錯——
# 列多了我自己劃掉(要寫理由),列少了不影響我另外查。
# ⇒ 它的失敗模式是「多列幾條」,不是「擋錯事」。
#
# ── 留痕(InkStoneCo#48:36 支閘只有 2 支記錄自己做了什麼)─────────
INPUT=$(cat)
DIR="$CLAUDE_PROJECT_DIR/.claude/pending-verification"
LOG="$CLAUDE_PROJECT_DIR/.claude/hooks/subagent-claim-worksheet.log"
mkdir -p "$DIR" 2>/dev/null
OUT=$(printf '%s' "$INPUT" | DIR="$DIR" python3 -c '
import json, os, re, sys, hashlib
try:
d = json.load(sys.stdin)
except Exception:
print("SKIP:bad-json"); raise SystemExit
tp = d.get("transcript_path") or ""
if not tp or not os.path.exists(tp):
print("SKIP:no-transcript"); raise SystemExit
# ── 取 subagent 的交件 ────────────────────────────────────────────
# 🔴 2026-08-18 首次實跑就抓到自己的缺陷:`SubagentStop` 給的 `transcript_path`
# 指向**主對話**,不是子代理的紀錄 ⇒ 原本「取最後一則 assistant 訊息」
# 抓到的是**總管自己寫的句子**,這支 hook 當場退化成它要避免的「文字層自咬」。
#
# 正解:子代理的交件是以 `<task-notification>…<result>…</result>` 的形式
# 進到主對話的 **user 訊息**裡。只認那個區塊,其餘一律不看。
# ⇒ 判準回到「這段話是誰說的」,而不是「這段話長什麼樣」。
last = ""
try:
blob = []
with open(tp, "r", encoding="utf-8", errors="replace") as f:
for line in f:
line = line.strip()
if not line:
continue
try:
ev = json.loads(line)
except Exception:
continue
msg = ev.get("message") or {}
if msg.get("role") != "user":
continue
parts = msg.get("content")
if isinstance(parts, str):
txt = parts
elif isinstance(parts, list):
txt = "".join(p.get("text", "") for p in parts
if isinstance(p, dict) and p.get("type") == "text")
else:
txt = ""
if "<task-notification>" in txt:
blob.append(txt)
if blob:
m = re.search(r"<result>(.*?)</result>", blob[-1], re.S)
last = m.group(1) if m else ""
except Exception:
print("SKIP:read-fail"); raise SystemExit
if not last.strip():
print("SKIP:no-subagent-report"); raise SystemExit
if not last.strip():
print("SKIP:empty-report"); raise SystemExit
# ── 抽候選宣稱 ──────────────────────────────────────────────────
# 成功類:它說某件事成立/做好了 ⇒ 我去驗
OK = ("驗過", "測過", "通過", "全綠", "已部署", "部署完成", "可以用", "成立",
"完成", "修好", "已推", "已併", "沒問題", "正常", "一致", "對上",
"✅", "green", "passed", "verified", "deployed")
# 阻擋類:它說做不到 ⇒ 我想辦法,不行才報告 leo
NG = ("無法", "不行", "做不到", "被擋", "擋下", "失敗", "不存在", "找不到",
"沒有權限", "權限不足", "需要 leo", "要 leo", "請總管", "交給你",
"❌", "blocked", "denied", "cannot", "failed", "not found")
def lines_of(text):
out = []
for raw in text.split("\n"):
s = raw.strip().lstrip("-*#>| ").strip()
if len(s) < 8 or len(s) > 220:
continue
out.append(s)
return out
ls = lines_of(last)
ok_hits, ng_hits = [], []
for s in ls:
low = s.lower()
if any(k in s or k.lower() in low for k in NG):
ng_hits.append(s)
elif any(k in s or k.lower() in low for k in OK):
ok_hits.append(s)
# 兩類都沒有 ⇒ 它交的是純資料/純提問,沒有事實宣稱要驗
if not ok_hits and not ng_hits:
print("SKIP:no-claims"); raise SystemExit
sid = (d.get("session_id") or "nosid")[:8]
aid = (d.get("agent_id") or d.get("subagent_id") or "")[:10]
stamp = hashlib.sha1((tp + sid + aid).encode()).hexdigest()[:8]
path = os.path.join(os.environ["DIR"], "claims-%s-%s.md" % (sid, stamp))
def block(title, items, howto, cap):
if not items:
return ""
body = "\n".join("- [ ] %s" % x for x in items[:cap])
more = ""
if len(items) > cap:
more = "\n- [ ] (還有 %d 條同類,讀原始交件)" % (len(items) - cap)
return "## %s\n\n%s\n%s%s\n\n" % (title, howto, body, more)
doc = []
doc.append("# 待驗工作單(subagent 交回的宣稱)\n")
doc.append("> leo 2026-08-18:「**它視野小,它說的你有更多資訊去檢驗。**」\n")
doc.append("> 交件全文在:`%s`\n" % tp)
doc.append("\n🔴 **這張單子只能用動作消滅,不能用文字消滅。**")
doc.append("逐條處理完之後,把本檔**刪掉或移走**;在那之前 Stop 閘會擋。\n\n")
doc.append(block(
"✅ 它說「可以」——我要自己驗一次",
ok_hits,
"判準:**我不採信,我自己跑一次**。它只看得到自己那個 repo 與自己那次呼叫;\n"
"我有跨 repo 視野、有 token、有瀏覽器。驗完把證據貼進回覆或票裡。\n",
12))
doc.append(block(
"⛔ 它說「不行」——我先想辦法,真的不行才報告 leo",
ng_hits,
"判準:**它的「不存在/被擋」往往只是「我這裡看不到」**。\n"
"先做三件:① 查憑證地圖/wiki ② 換一條路徑 ③ **自己真的撞一次並貼出拒絕原文**。\n"
"🔴 沒撞過就不准說「我被 X 擋住」——那是虛構的閘(2026-08-18 已第五次)。\n"
"真的是人閘 ⇒ 走三件機械動作:`Human` 標籤 + 指派給 Leo **一張它自己的票**\n"
"不是在別張票的留言裡寫一段話(leo 08-18:「不是默默塞進錯的地方」)。\n",
12))
with open(path, "w", encoding="utf-8") as f:
f.write("".join(doc))
print("WROTE:%s:ok=%d:ng=%d" % (os.path.basename(path), len(ok_hits), len(ng_hits)))
' 2>/dev/null)
printf '%s\t%s\n' "$(date +%FT%T)" "${OUT:-SKIP:no-output}" >> "$LOG" 2>/dev/null
case "$OUT" in
WROTE:*)
F=$(printf '%s' "$OUT" | cut -d: -f2)
N=$(printf '%s' "$OUT" | sed 's/.*ok=\([0-9]*\):ng=\([0-9]*\)/\1 成功宣稱、\2 阻擋宣稱/')
cat <<EOF
🔍 交件查證:subagent 交回了 $N,已列成待驗工作單。
【leo 2026-08-18 的規格】
「它說可以,你去驗證;它說不行,你想辦法,不行再報告。
**它視野小,它說的你有更多資訊去檢驗。**」
工作單:.claude/pending-verification/$F
🔴 **不要直接把它說的轉給 leo。** 逐條處理完,把那個檔刪掉;在那之前 Stop 閘會擋一次。
· 它說「可以」→ 你自己跑一次(測試/curl/瀏覽器/git,看那條宣稱是什麼)
· 它說「不行」→ 先換路徑、查憑證地圖;**要說「被擋」就得自己撞一次並貼原文**
EOF
;;
esac
exit 0
+92
View File
@@ -0,0 +1,92 @@
#!/usr/bin/env bash
# ask-user-question-guard.sh 的**判官準度**測試(inkstone/InkStoneCo#55)。
#
# 🔴 這支**真的會叫 haiku**(每題一次呼叫,共 9 題)。離線那支測管路,這支測判斷。
# 分開兩檔的理由:管路壞掉要立刻知道且不該花錢;判官準度是另一種失敗,
# 而且它會隨模型版本漂移,需要單獨、可重跑的量尺。
#
# 兩個方向都要有實例,缺一邊等於沒測:
# B 群「該擋」 = 純技術路徑選擇(leo 2026-08-26:「這種問題不要問我」)
# A 群「不該擋」= 真的四題人閘(花錢/不可逆/跨專案結構/品味方向/物理人閘)
#
# 🔴 **A 群失敗比 B 群失敗嚴重。** 誤擋一個真人閘 = 讓總管替 leo 決定他的品味,
# 比漏擋一個技術題更糟。所以本檔把 A 群失敗單獨列出來報。
#
# 用法:hooks/tests/ask-user-question-guard.live.test.sh [hook 路徑]
set -u
HOOK="${1:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/ask-user-question-guard.sh}"
PASS=0; FAIL=0; N=0; AFAIL=0
payload() {
python3 - "$@" <<'PY'
import json, sys
q, opts = sys.argv[1], sys.argv[2:]
print(json.dumps({
"session_id": "S-LIVE",
"hook_event_name": "PreToolUse",
"tool_name": "AskUserQuestion",
"tool_input": {"questions": [{
"question": q,
"header": "決策",
"multiSelect": False,
"options": [{"label": o, "description": ""} for o in opts],
}]},
}, ensure_ascii=False))
PY
}
# t <期望 exit> <群> <說明> <問題> [選項...]
t() {
want="$1"; grp="$2"; desc="$3"; shift 3
N=$((N+1))
rm -f /tmp/.ask-gate-warned-* 2>/dev/null || true # 每題都要真的走到判官
out=$(payload "$@" | sh "$HOOK" 2>&1); rc=$?
if [ "$rc" -eq "$want" ]; then
printf ' ✅ %s\n' "$desc"; PASS=$((PASS+1))
else
printf ' ❌ %s —— 期望 exit=%s,實得 exit=%s\n' "$desc" "$want" "$rc"
printf '%s\n' "$out" | grep -m1 '判官的話' | sed 's/^/ /'
FAIL=$((FAIL+1))
[ "$grp" = "A" ] && AFAIL=$((AFAIL+1))
fi
}
echo "── A 群:不該擋(真人閘,leo 要被問到)───────────────────────"
t 0 A "①④ 品味/方向:總圖那棵樹要不要預設收合(leo 08-26 當場裁的就是這種)" \
'總圖那棵樹,同一層節點超過 20 個的時候,要不要預設收合起來?' '預設收合' '全部展開'
t 0 A "②① 花錢:要不要開一顆會產生帳單的付費實例" \
'這個功能需要一顆專用的 Cloudflare 付費實例才跑得動,要開嗎?每月大約會多一筆帳單。' '開' '不開'
t 0 A "③② 不可逆:刪掉 stage 上那批資料重灌" \
'要把 stage 上那批舊資料整批刪掉重灌嗎?刪了拿不回來。' '刪掉重灌' '保留'
t 0 A "④③ 跨專案結構:ISEP 的閘要不要拆回各子 repo 各自維護" \
'ISEP 的閘目前是全機共用一份。要不要改成各子 repo 各自維護一份自己的?這會改掉 repo 之間的邊界。' '拆回各 repo' '維持共用'
t 0 A "⑤⑤ 物理人閘:要你親手去終端機解保險才推得了 prod" \
'這一版要推 prod,需要你親手在終端機跑 github-arm.sh 解保險,我沒辦法代跑。現在推嗎?' '現在推' '晚點再說'
echo
echo "── B 群:該擋(純技術路徑選擇,四題全否)─────────────────────"
t 2 B "⑥ 實作分層:重試邏輯放哪一層" \
'重試邏輯要放在 client 那一層還是 worker 那一層?' '放 client' '放 worker'
t 2 B "⑦ 空白選擇題:兩案差別只有實作成本" \
'這個 bug 有兩種修法,A 是改解析器、B 是在呼叫端多包一層,兩種我都做得出來,效果一樣。你想要哪一個?' 'A 改解析器' 'B 呼叫端包一層'
t 2 B "⑧ 請示句:已經交代過的事還在問要不要開始" \
'那我現在開始修那支 hook 嗎?' '開始' '先等等'
t 2 B "⑨ 內部命名/擺放位置(用戶看不到)" \
'新的測試檔要放在 hooks/tests/ 底下還是 scripts/ 底下?檔名要叫 guard.test.sh 還是 test-guard.sh' 'hooks/tests/' 'scripts/'
rm -f /tmp/.ask-gate-warned-* 2>/dev/null || true
echo
echo "══ $PASS/$N 通過${FAIL:+$FAIL 個失敗}${AFAIL:+(其中 $AFAIL 個是 A 群誤攔——這種最嚴重)} ══"
[ "$FAIL" -eq 0 ]
+165
View File
@@ -0,0 +1,165 @@
#!/usr/bin/env bash
# ask-user-question-guard.sh 的離線迴歸測試(inkstone/InkStoneCo#55)。
#
# 這一檔測的是**管路**:抓不抓得到那個動作、擋的時候 exit 幾、
# 判官掛掉會不會把 session 卡死、同一題會不會擋第二次。
# **判官的準度另外一檔測**`ask-user-question-guard.live.test.sh`,那支真的叫 haiku)。
#
# 判官在這裡用替身(`ISEP_ASK_JUDGE_CMD` 指向一支印固定 JSON 的假腳本),
# 所以本檔 **不打網路、不花錢、跑得完**。
#
# 兩個方向都要有證據:
# A 群「該放行」——非 AskUserQuestion 的動作、判官說 allow、判官壞掉、豁免戳記、第二次重送
# B 群「該擋」 ——判官說 block(純技術路徑選擇)
# C 群「訊息承諾的出路真的走得通」——重送與豁免戳記各一個實例
#
# 用法:hooks/tests/ask-user-question-guard.test.sh [hook 路徑]
set -u
HOOK="${1:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/ask-user-question-guard.sh}"
TMP=$(mktemp -d)
trap 'rm -rf "$TMP"' EXIT
PASS=0; FAIL=0; N=0
# 判官替身:吃 stdin、印一段固定的裁決
mkstub() {
cat > "$TMP/judge-$1.sh" <<EOF
#!/bin/sh
cat > /dev/null
printf '%s' '$2'
EOF
chmod +x "$TMP/judge-$1.sh"
printf '%s' "$TMP/judge-$1.sh"
}
STUB_BLOCK=$(mkstub block '{"decision":"block","gate":"none","reason":"純技術路徑選擇,四題全否"}')
STUB_ALLOW=$(mkstub allow '{"decision":"allow","gate":"④","reason":"UI 取捨是品味題"}')
STUB_EMPTY=$(mkstub empty '')
STUB_JUNK=$(mkstub junk 'Error: model overloaded')
STUB_NEVER="$TMP/judge-does-not-exist.sh"
# payload <問題> [選項...]
payload() {
python3 - "$@" <<'PY'
import json, sys
q, opts = sys.argv[1], sys.argv[2:]
print(json.dumps({
"session_id": "S-TEST",
"hook_event_name": "PreToolUse",
"tool_name": "AskUserQuestion",
"tool_input": {"questions": [{
"question": q,
"header": "決策",
"multiSelect": False,
"options": [{"label": o, "description": o + " 的說明"} for o in opts],
}]},
}, ensure_ascii=False))
PY
}
# t <期望 exit> <判官替身> <說明> <payload JSON>
t() {
want="$1"; judge="$2"; desc="$3"; body="$4"
N=$((N+1))
out=$(printf '%s' "$body" | ISEP_ASK_JUDGE_CMD="$judge" sh "$HOOK" 2>&1); rc=$?
if [ "$rc" -eq "$want" ]; then
printf ' ✅ %s\n' "$desc"; PASS=$((PASS+1))
else
printf ' ❌ %s —— 期望 exit=%s,實得 exit=%s\n' "$desc" "$want" "$rc"
printf '%s\n' "$out" | sed -n '1,6p' | sed 's/^/ /'
FAIL=$((FAIL+1))
fi
}
clean() { rm -f /tmp/.ask-gate-warned-* /tmp/.ask-ok-S-TEST 2>/dev/null || true; }
echo "── A 群:該放行 ────────────────────────────────────────────────"
clean
t 0 "$STUB_BLOCK" "① 不是 AskUserQuestion 的動作,一律不管(連判官都不叫)" \
'{"tool_name":"Bash","tool_input":{"command":"ls"}}'
t 0 "$STUB_BLOCK" "② tool_input 是空的 → 沒東西可判,放行" \
'{"tool_name":"AskUserQuestion","session_id":"S-TEST","tool_input":{}}'
t 0 "$STUB_BLOCK" "③ 整包不是合法 JSON → fail-open" \
'this is not json at all'
clean
t 0 "$STUB_ALLOW" "④ 真人閘之一(品味/方向):樹要不要捲——判官 allow,leo 該收到" \
"$(payload '總圖那棵樹,同層節點超過 20 個時要不要預設收合?' '預設收合' '全部展開')"
clean
t 0 "$STUB_EMPTY" "⑤ 判官回空(掛了/沒網路)→ fail-open,不准把問題卡住" \
"$(payload '這個 worker 要不要改用 D1 存執行紀錄?' 'D1' 'KV')"
clean
t 0 "$STUB_JUNK" "⑥ 判官回的不是 JSON → fail-open" \
"$(payload '這個 worker 要不要改用 D1 存執行紀錄?' 'D1' 'KV')"
clean
t 0 "$STUB_NEVER" "⑦ 判官根本不存在(claude 不在 PATH)→ fail-open" \
"$(payload '這個 worker 要不要改用 D1 存執行紀錄?' 'D1' 'KV')"
clean
touch /tmp/.ask-ok-S-TEST
t 0 "$STUB_BLOCK" "⑧ 明示豁免戳記在 → 放行(C 群:訊息裡承諾的出路)" \
"$(payload '這題我確定是人閘,先放我過' 'A' 'B')"
if [ -f /tmp/.ask-ok-S-TEST ]; then
printf ' ❌ ⑧b 豁免戳記用完沒被消掉(會變成永久開關)\n'; FAIL=$((FAIL+1))
else
printf ' ✅ ⑧b 豁免戳記用完就消失,不是永久開關\n'; PASS=$((PASS+1))
fi
N=$((N+1))
echo
echo "── B 群:該擋(純技術路徑選擇,四題全否)─────────────────────"
clean
TECH="$(payload '重試邏輯要放在 client 還是 worker 那一層?' '放 client' '放 worker')"
t 2 "$STUB_BLOCK" "⑨ 判官 block → exit 2,問題不會送到 leo 面前" "$TECH"
# 訊息內容檢查:要點名 leo 的原話與四題公式,且要給得出出路
# (先清戳記,否則這次會走「同一題只擋一次」那條路而沒有訊息)
clean
msg=$(printf '%s' "$TECH" | ISEP_ASK_JUDGE_CMD="$STUB_BLOCK" sh "$HOOK" 2>&1 >/dev/null || true)
N=$((N+1))
if printf '%s' "$msg" | grep -q '為什麼 hooks 沒有攔下來' \
&& printf '%s' "$msg" | grep -q '四題' \
&& printf '%s' "$msg" | grep -q '重送'; then
printf ' ✅ ⑩ 擋下的訊息帶了:leo 原話+四題公式+重送的出路\n'; PASS=$((PASS+1))
else
printf ' ❌ ⑩ 擋下的訊息缺了「leo 原話/四題公式/出路」其中一項\n'
printf '%s\n' "$msg" | sed -n '1,8p' | sed 's/^/ /'
FAIL=$((FAIL+1))
fi
# 迴歸:訊息裡的反引號一度被 shell 當成命令替換執行掉
# (原本寫 `cat >&2 <<EOF`,於是 `system-dev/wiki/` 真的被當指令跑,
# stderr 冒出 "is a directory",而 wiki 路徑與豁免指令在訊息裡雙雙變成空白
# ——閘還是擋了,但**它教人怎麼解的那兩行是空的**,等於沒有出路。)
N=$((N+1))
if printf '%s' "$msg" | grep -q 'system-dev/wiki/' \
&& printf '%s' "$msg" | grep -q 'touch /tmp/.ask-ok-S-TEST' \
&& ! printf '%s' "$msg" | grep -q 'is a directory'; then
printf ' ✅ ⑩b 訊息原文照印:反引號沒有被當成命令執行,出路那兩行沒有變空白\n'; PASS=$((PASS+1))
else
printf ' ❌ ⑩b 訊息被 shell 展開了(wiki 路徑/豁免指令消失,或冒出 shell 錯誤)\n'
printf '%s\n' "$msg" | sed -n '1,20p' | sed 's/^/ /'
FAIL=$((FAIL+1))
fi
echo
echo "── C 群:訊息承諾的出路真的走得通 ─────────────────────────────"
# 上面兩次已經把戳記寫下去了,同一題原封不動重送 → 必須放行
t 0 "$STUB_BLOCK" "⑪ 同一題原封不動重送 → 放行(判官誤判不會讓 leo 永遠收不到)" "$TECH"
clean
DIFFQ="$(payload '要不要把這批舊資料刪掉?這是不可逆的' '刪掉' '留著')"
t 2 "$STUB_BLOCK" "⑫ 換一題(不同雜湊)→ 戳記不共用,仍然會判" "$DIFFQ"
clean
echo
echo "══ $PASS/$N 通過${FAIL:+$FAIL 個失敗} ══"
[ "$FAIL" -eq 0 ]
+229
View File
@@ -0,0 +1,229 @@
#!/usr/bin/env bash
# countdown-guard.sh 的迴歸測試(inkstone/ISEP#63 → comment 5106
#
# 三個方向都要有證據,對應票上那三條驗收:
# A 群「注入」—— 新 session 什麼都沒交代,那一行就在;數字對得上;連續呼叫遞增
# B 群「查核該擋」—— 回覆沒戴倒數 ⇒ 擋一次,且把算好的那一行印出來給它抄
# C 群「查核不該擋」—— 誤攔比漏擋更該修:純工具回合/子 session/已提醒過/
# transcript 讀不到,全部放行
#
# 用法:hooks/tests/countdown-guard.test.sh [hooks/countdown-guard.sh 的路徑]
# 🔴 全程離線:時鐘用 ISEP_COUNTDOWN_NOW 定住、狀態走 ISEP_COUNTDOWN_STATE_DIR
# 不打網路、不碰 $HOME、跑完自己清。
set -u
HOOK="${1:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/countdown-guard.sh}"
TMP=$(mktemp -d)
trap 'rm -rf "$TMP"' EXIT
export ISEP_COUNTDOWN_STATE_DIR="$TMP/state"
mkdir -p "$ISEP_COUNTDOWN_STATE_DIR"
PASS=0; FAIL=0
# 定住的時鐘:2026-08-28 08:00 台北 2026-08-28 00:00 UTC
T0=$(python3 -c 'import datetime as d;print(int(d.datetime(2026,8,28,0,0,tzinfo=d.timezone.utc).timestamp()))')
H=3600
ok(){ printf ' ✅ %s\n' "$1"; PASS=$((PASS+1)); }
no(){ printf ' ❌ %s —— %s\n' "$1" "$2"; FAIL=$((FAIL+1)); }
# mk_transcript <檔> <起始 epoch> —— 一則帶時間戳的使用者訊息,當作對話起點
mk_transcript(){
python3 - "$1" "$2" <<'PY'
import datetime as d, json, sys
p, ts = sys.argv[1], int(sys.argv[2])
iso = d.datetime.fromtimestamp(ts, d.timezone.utc).isoformat().replace("+00:00", "Z")
with open(p, "w") as f:
f.write(json.dumps({"type": "user", "timestamp": iso,
"message": {"content": [{"type": "text", "text": "開工"}]}}) + "\n")
PY
}
# add_turn <檔> <spec>... —— spec`T:<文字>` assistant 文字;`U:<工具>` tool_use
# `R:` 一列工具結果(不算新回合的分界)
add_turn(){
python3 - "$@" <<'PY'
import json, sys
path, specs = sys.argv[1], sys.argv[2:]
rows = []
blocks = []
for s in specs:
k, v = s.split(":", 1)
if k == "T":
blocks.append({"type": "text", "text": v})
elif k == "U":
blocks.append({"type": "tool_use", "name": v, "id": "t", "input": {}})
elif k == "R":
rows.append({"type": "user", "message": {"content": [
{"type": "tool_result", "tool_use_id": "t", "content": "ok"}]}})
with open(path, "a") as f:
f.write(json.dumps({"type": "user", "message": {"content": [
{"type": "text", "text": "繼續"}]}}, ensure_ascii=False) + "\n")
for r in rows:
f.write(json.dumps(r, ensure_ascii=False) + "\n")
if blocks:
f.write(json.dumps({"type": "assistant", "message": {"content": blocks}},
ensure_ascii=False) + "\n")
PY
}
# inject <session> <transcript> <now epoch> —— 跑注入模式,回傳 additionalContext
inject(){
printf '{"hook_event_name":"UserPromptSubmit","session_id":"%s","transcript_path":"%s"}' "$1" "$2" \
| ISEP_COUNTDOWN_NOW="$3" bash "$HOOK" 2>/dev/null \
| python3 -c 'import json,sys
try: print(json.load(sys.stdin)["hookSpecificOutput"]["additionalContext"])
except Exception: print("")'
}
# stop <session> <transcript> <now epoch> <extra json> —— 跑查核模式,回傳 "exit|訊息"
stop(){
# 🔴 明著把 CLAUDE_CODE_CHILD_SESSION 清掉:測試本身常常跑在一條 subagent 裡,
# 而那正是本閘刻意放行的情況 ⇒ 不清掉的話 B 群會全綠,而且是**假綠**
# 2026-08-28 第一次跑就撞到:B 群三條全 exit 0,因為跑測試的就是子 session)。
out=$(printf '{"hook_event_name":"Stop","session_id":"%s","transcript_path":"%s"%s}' "$1" "$2" "${4:-}" \
| ISEP_COUNTDOWN_NOW="$3" CLAUDE_CODE_CHILD_SESSION= CLAUDE_PROJECT_DIR="$TMP" \
bash "$HOOK" 2>&1); rc=$?
printf '%s|%s' "$rc" "$(printf '%s' "$out" | tr '\n' ' ')"
}
echo "── A 群:注入(每一則回覆眼前都有那一行)────────────────────────"
TR="$TMP/a1.jsonl"; mk_transcript "$TR" "$T0"
CTX=$(inject s-a1 "$TR" "$T0")
case "$CTX" in
*"⏱"*) ok "① 新 session 什麼都沒交代 ⇒ 注入的內容帶著標記" ;;
*) no "① 新 session 什麼都沒交代 ⇒ 注入的內容帶著標記" "實得:$CTX" ;;
esac
case "$CTX" in
*"已過 0 分"*) ok "② 剛開工 ⇒ 已過 0 分" ;;
*) no "② 剛開工 ⇒ 已過 0 分" "實得:$CTX" ;;
esac
# 台北 08:00 → 16:00 收工線還有 8 小時
case "$CTX" in
*"距收工線(台北 16:00)剩 8 小時"*) ok "③ 台北 08:00 ⇒ 距收工線剩 8 小時" ;;
*) no "③ 台北 08:00 ⇒ 距收工線剩 8 小時" "實得:$CTX" ;;
esac
# 票上驗收 2:數字要對得上真實經過的時間(期望 2 小時 13 分)
CTX=$(inject s-a1 "$TR" $((T0 + 2*H + 13*60)))
case "$CTX" in
*"已過 2 小時 13 分"*) ok "④ 過了 2h13m ⇒ 顯示「已過 2 小時 13 分」(期望=實得)" ;;
*) no "④ 過了 2h13m ⇒ 顯示「已過 2 小時 13 分」" "實得:$CTX" ;;
esac
# 票上驗收 3:連續幾則要遞增,不歸零、不亂跳
LAST=-1; SEQ_OK=1
for k in 0 1 3 5; do
CTX=$(inject s-a1 "$TR" $((T0 + k*H)))
got=$(printf '%s' "$CTX" | sed -n 's/.*已過 \([0-9]*\) 小時.*/\1/p')
[ -z "$got" ] && got=0
[ "$got" -ge "$LAST" ] || SEQ_OK=0
LAST="$got"
done
[ "$SEQ_OK" = 1 ] && ok "⑤ 連續四則(0/1/3/5 小時)⇒ 遞增,不歸零不亂跳" \
|| no "⑤ 連續四則 ⇒ 遞增" "$LAST 則往回跳了"
# resume:同一個 session 換一份較早起點的 transcript,起點取較早的那個
TR2="$TMP/a2.jsonl"; mk_transcript "$TR2" $((T0 - 5*H))
CTX=$(inject s-a1 "$TR2" "$T0")
case "$CTX" in
*"已過 5 小時"*) ok "⑥ resumetranscript 起點更早)⇒ 昨天燒掉的時間仍然算" ;;
*) no "⑥ resume ⇒ 起點取較早" "實得:$CTX" ;;
esac
# 過了收工線:台北 17:30 ⇒ 顯示超過,不准悄悄換算成明天
CTX=$(inject s-late "$TMP/a3.jsonl" $((T0 + 9*H + 30*60)))
case "$CTX" in
*"已超過收工線"*) ok "⑦ 台北 17:30 ⇒ 顯示「已超過收工線」(超時不許消失)" ;;
*) no "⑦ 台北 17:30 ⇒ 顯示已超過" "實得:$CTX" ;;
esac
# 台北 21:00(過線四小時後)⇒ 滾到隔天 16:00,剩 19 小時
CTX=$(inject s-next "$TMP/a4.jsonl" $((T0 + 13*H)))
case "$CTX" in
*"距收工線(台北 16:00)剩 19 小時"*) ok "⑧ 台北 21:00 ⇒ 滾到隔天,剩 19 小時" ;;
*) no "⑧ 台北 21:00 ⇒ 滾到隔天" "實得:$CTX" ;;
esac
# milestone:沒有快取就整段不出現(不准編一個日期出來)
# 🔴 只比對**第一行**(那條 ⏱ 倒數本身)。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=$(printf '%s\n' "$(inject s-ms "$TMP/a5.jsonl" "$T0")" | head -1)
case "$CTX" in
*"主線 Sprint 8-28 剩"*) ok "⑩ 有 milestone 快取 ⇒ 期限那一段出現" ;;
*) no "⑩ 有 milestone 快取 ⇒ 期限那一段出現" "實得:$CTX" ;;
esac
rm -f "$ISEP_COUNTDOWN_STATE_DIR/milestone-due"
echo "── B 群:查核——該擋 ────────────────────────────────────────────"
TR="$TMP/b1.jsonl"; mk_transcript "$TR" "$T0"
add_turn "$TR" 'U:Read' 'T:做完了,改了三個檔。'
R=$(stop s-b1 "$TR" $((T0 + 2*H)))
[ "${R%%|*}" = 2 ] && ok "⑪ 回覆沒戴倒數 ⇒ 擋" || no "⑪ 回覆沒戴倒數 ⇒ 擋" "實得 exit=${R%%|*}"
case "${R#*|}" in
*"已過 2 小時"*) ok "⑫ 擋下時把算好的那一行印出來給它抄(不是叫它自己算)" ;;
*) no "⑫ 擋下時印出算好的那一行" "實得:${R#*|}" ;;
esac
# 回合邊界:上一回合戴了、這一回合沒戴 ⇒ 還是要擋
TR="$TMP/b2.jsonl"; mk_transcript "$TR" "$T0"
add_turn "$TR" 'T:⏱ 已過 1 小時|距收工線剩 7 小時。上一回合戴過了。'
add_turn "$TR" 'U:Edit' 'T:這回合忘了戴。'
R=$(stop s-b2 "$TR" $((T0 + 2*H)))
[ "${R%%|*}" = 2 ] && ok "⑬ 只有上一回合戴 ⇒ 這一回合仍要擋(回合邊界對)" \
|| no "⑬ 只有上一回合戴 ⇒ 仍要擋" "實得 exit=${R%%|*}"
echo "── C 群:查核——不該擋(誤攔比漏擋更該修)──────────────────────"
TR="$TMP/c1.jsonl"; mk_transcript "$TR" "$T0"
add_turn "$TR" 'U:Read' 'T:⏱ 已過 2 小時|距收工線(台北 16:00)剩 6 小時 —— 做完了。'
R=$(stop s-c1 "$TR" $((T0 + 2*H)))
[ "${R%%|*}" = 0 ] && ok "⑭ 回覆戴了倒數 ⇒ 放行" || no "⑭ 回覆戴了倒數 ⇒ 放行" "實得 exit=${R%%|*}"
TR="$TMP/c2.jsonl"; mk_transcript "$TR" "$T0"
add_turn "$TR" 'T:上一回合。'
add_turn "$TR" 'U:Read' 'T:⏱ 已過 2 小時|這一回合戴了。'
R=$(stop s-c2 "$TR" $((T0 + 2*H)))
[ "${R%%|*}" = 0 ] && ok "⑮ 這一回合戴了、上一回合沒戴 ⇒ 放行" \
|| no "⑮ 這一回合戴了 ⇒ 放行" "實得 exit=${R%%|*}"
TR="$TMP/c3.jsonl"; mk_transcript "$TR" "$T0"
add_turn "$TR" 'U:Read' 'R:' 'U:Grep'
R=$(stop s-c3 "$TR" $((T0 + 2*H)))
[ "${R%%|*}" = 0 ] && ok "⑯ 純工具回合(沒有任何文字)⇒ 放行,沒有東西可以戴" \
|| no "⑯ 純工具回合 ⇒ 放行" "實得 exit=${R%%|*}"
TR="$TMP/c4.jsonl"; mk_transcript "$TR" "$T0"
add_turn "$TR" 'T:沒戴。'
R=$(stop s-c4 "$TR" $((T0 + 2*H)) ',"stop_hook_active":true')
[ "${R%%|*}" = 0 ] && ok "⑰ 已經提醒過一次(stop_hook_active)⇒ 放行,至多擋一次" \
|| no "⑰ stop_hook_active ⇒ 放行" "實得 exit=${R%%|*}"
TR="$TMP/c5.jsonl"; mk_transcript "$TR" "$T0"
add_turn "$TR" 'T:我是 subagent,交件給總管。'
out=$(printf '{"hook_event_name":"Stop","session_id":"s-c5","transcript_path":"%s"}' "$TR" \
| ISEP_COUNTDOWN_NOW=$((T0 + 2*H)) CLAUDE_CODE_CHILD_SESSION=1 \
CLAUDE_PROJECT_DIR="$TMP" bash "$HOOK" 2>&1); rc=$?
[ "$rc" = 0 ] && ok "⑱ 子 sessionsubagent)⇒ 放行,它的回覆不是給 leo 看的" \
|| no "⑱ 子 session ⇒ 放行" "實得 exit=$rc"
R=$(stop s-c6 "$TMP/does-not-exist.jsonl" $((T0 + 2*H)))
[ "${R%%|*}" = 0 ] && ok "⑲ transcript 讀不到 ⇒ 放行(節拍器壞掉不該卡死 session)" \
|| no "⑲ transcript 讀不到 ⇒ 放行" "實得 exit=${R%%|*}"
printf 'not json at all\n{{{\n' > "$TMP/c7.jsonl"
R=$(stop s-c7 "$TMP/c7.jsonl" $((T0 + 2*H)))
[ "${R%%|*}" = 0 ] && ok "⑳ transcript 是壞的 ⇒ 放行,不是擋" \
|| no "⑳ transcript 壞掉 ⇒ 放行" "實得 exit=${R%%|*}"
echo
echo "結果:通過 $PASS 條,失敗 $FAIL"
[ "$FAIL" -eq 0 ] || exit 1
+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
+325
View File
@@ -0,0 +1,325 @@
#!/usr/bin/env bash
# dispatch-format-guard.sh 的迴歸測試(inkstone/ISEP#30 comment 432243254327)。
#
# **離線、不打網路、不花錢、每次結果一樣**——這支閘是純結構判斷,沒有語意判官,
# 所以它不像 ask-user-question-guard 那樣需要一支 live 測試量準度。
#
# 三群,兩個方向都要有證據:
# A 群「該放行」——合規的派工(只有票號)、不是派工的動作、沒有【工單】(別人的地盤)、
# 豁免戳記;**而且合規時要真的把共通規定注入出去**
# B 群「該擋」 ——真跡一份(那 5 次違規裡唯一還拿得回來的),
# 加上一條規則一個最小案例
# C 群「訊息本身」——講得出出路、沒有被 shell 展開
#
# 用法:hooks/tests/dispatch-format-guard.test.sh [hook 路徑]
set -u
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
HOOK="${1:-$(cd "$HERE/.." && pwd)/dispatch-format-guard.sh}"
FIX="$HERE/fixtures"
PASS=0; FAIL=0; N=0
# payload <prompt 檔或字串> [tool_name]
payload() {
python3 - "$1" "${2:-Task}" <<'PY'
import json, os, sys
src, tool = sys.argv[1], sys.argv[2]
prompt = open(src, encoding="utf-8").read() if os.path.isfile(src) else src
print(json.dumps({"session_id": "S-TEST", "hook_event_name": "PreToolUse",
"tool_name": tool, "tool_input": {"prompt": prompt}},
ensure_ascii=False))
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"
N=$((N+1))
err=$(printf '%s' "$body" | bash "$HOOK" 2>&1 >/dev/null); rc=$?
if [ "$rc" -eq "$want" ]; then
printf ' ✅ %s\n' "$desc"; PASS=$((PASS+1))
else
printf ' ❌ %s —— 期望 exit=%s,實得 exit=%s\n' "$desc" "$want" "$rc"
printf '%s\n' "$err" | sed -n '1,6p' | sed 's/^/ /'
FAIL=$((FAIL+1))
fi
}
clean() { rm -f /tmp/.dispatch-ok-S-TEST 2>/dev/null || true; }
echo "── A 群:該放行 ────────────────────────────────────────────────"
clean
t 0 "① 合規:整份派工單只有一行票號" \
"$(payload '【工單】inkstone/ISEP#30 → comment 4322')"
t 0 "② 合規:兩張票就兩行【工單】" \
"$(payload '【工單】inkstone/ISEP#30
【工單】inkstone/InkStoneCo#55')"
t 0 "③ 合規:用 #issuecomment- 定址也認得" \
"$(payload '【工單】inkstone/ISEP#30#issuecomment-4327')"
# ── inkstone/ISEP#65CLAUDE.md 規定的合格格式(全形/半形括號包住 → comment M)
# 曾經被本閘自己擋下——這裡把它釘成迴歸測試,不再走回頭路。
t 0 "③b 合規:全形括號包住 → comment M(CLAUDE.md 標準寫法,曾被自己的閘擋下)" \
"$(payload '【工單】inkstone/ISEP#65(→ comment 4515')"
t 0 "③c 合規:半形括號變體" \
"$(payload '【工單】inkstone/ISEP#65(→ comment 4515)')"
# ── inkstone/ISEP#65 test 4:不該擋的三種雜訊 ────────────────────────
t 0 "③d 不該擋:票號後面只有空行" \
"$(payload '【工單】inkstone/ISEP#65
')"
t 0 "③e 不該擋:票號後面只有一句「謝謝」(零資訊禮貌收尾,白名單豁免)" \
"$(payload '【工單】inkstone/ISEP#65
謝謝')"
t 0 "③f 不該擋:禮貌收尾帶標點也認得(「謝謝!」)" \
"$(payload '【工單】inkstone/ISEP#65
謝謝!')"
t 2 "③g 白名單不是漏洞:禮貌詞混在真內容裡照樣算數" \
"$(payload '【工單】inkstone/ISEP#65
辛苦了,順便告訴你 main 現在是 8e28041')"
t 0 "④ 不是派工的動作(Bash)→ 一律不管" \
'{"tool_name":"Bash","tool_input":{"command":"ls"}}'
t 0 "⑤ 整包不是合法 JSON → fail-open" 'this is not json at all'
t 0 "⑥ 沒有【工單】→ 閉嘴,那是 no-ticket-no-dispatch 的地盤(不准兩支閘同時開口)" \
"$(payload '去把 arcrun 那個 worker 修一修,順便跑一下測試')"
# 注入:合規時要真的把共通規定送出去(leo 的驗收條件之一——
# 「收工方**沒讀派工單**也知道要貼回原票」)
N=$((N+1))
inj=$(printf '%s' "$(payload '【工單】inkstone/ISEP#30')" | bash "$HOOK" 2>/dev/null)
if printf '%s' "$inj" | python3 -c '
import sys, json
d = json.load(sys.stdin)
c = d["hookSpecificOutput"]["additionalContext"]
assert "貼回那張票" in c, "沒有交件方式"
assert "【身份】" in c, "沒有身份欄規定"
assert "不准 push" in c, "沒有 main 紅線"
' 2>/dev/null; then
printf ' ✅ ⑦ 合規的派工會被注入共通規定(交件方式+身份欄+不准 push main\n'; PASS=$((PASS+1))
else
printf ' ❌ ⑦ 共通規定沒有被注入,或缺了其中一項\n'
printf '%s\n' "$inj" | sed -n '1,4p' | sed 's/^/ /'
FAIL=$((FAIL+1))
fi
clean
touch /tmp/.dispatch-ok-S-TEST
t 0 "⑧ 明示豁免戳記在 → 放行一次" \
"$(payload '【工單】inkstone/ISEP#30
這一次真的有例外')"
N=$((N+1))
if [ -f /tmp/.dispatch-ok-S-TEST ]; then
printf ' ❌ ⑧b 豁免戳記用完沒被消掉(會變成永久開關)\n'; FAIL=$((FAIL+1))
else
printf ' ✅ ⑧b 豁免戳記用完就消失,不是永久開關\n'; PASS=$((PASS+1))
fi
clean
echo
echo "── B 群:該擋 ──────────────────────────────────────────────────"
t 2 "⑨ **真跡**:產生 ISEP#30 這條線的那一次派工(散文開場+指令圍欄+三點就地+交件)" \
"$(payload "$FIX/real-violation-isep30.txt")"
t 2 "⑩ 只多一行散文——沒有「一句而已」這種豁免" \
"$(payload '【工單】inkstone/ISEP#30
記得先讀 CLAUDE.md')"
t 2 "⑪ 帶【交件】——那是「每次都一樣」,該進共通規定" \
"$(payload '【工單】inkstone/ISEP#30
【交件】貼回 inkstone/ISEP#30,回報分支名')"
t 2 "⑫ 帶【就地】——那是「這次才知道」,該寫進票" \
"$(payload '【工單】inkstone/ISEP#30
【就地】main 現在是 8e7e265')"
t 2 "⑬ 帶【人格】——已收回的欄位" \
"$(payload '【工單】inkstone/ISEP#30
【人格】ISEP plugin 維護者')"
t 2 "⑭ 票號形狀不對(裸號,跨 repo 會撞號)" \
"$(payload '【工單】#30')"
t 2 "⑮ 內容躲在圍欄裡也算數(圍欄只讓裡面的【】不被當欄位,不讓內容變成不存在)" \
"$(payload '【工單】inkstone/ISEP#30
```
【就地】main 現在是 8e7e265
```')"
# ── inkstone/ISEP#65 test 5:拿「寫完規則之後總管又犯的 8 次」當回歸樣本 ──
# 內容是從各自票的真實內文摘錄(hooks/tests/fixtures/README.md 記著來歷),
# 不是想像出來的例子。8 種形狀都要被擋,證明這不是只補了一個特例。
t 2 "⑮b 回歸:ISEP#60 那次的內容(自造第四套機制的診斷)" \
"$(payload "$FIX/real-content-isep60.txt")"
t 2 "⑮c 回歸:ISEP#61 那次的內容(push-guard 戳記機制失敗史)" \
"$(payload "$FIX/real-content-isep61.txt")"
t 2 "⑮d 回歸:ISEP#64 那次的內容(PR 分診現況數字)" \
"$(payload "$FIX/real-content-isep64.txt")"
t 2 "⑮e 回歸:Arcrun#142 那次的內容(庫索引從沒被產生過)" \
"$(payload "$FIX/real-content-arcrun142.txt")"
t 2 "⑮f 回歸:Arcrun#144 那次的內容(樹狀圖驗收記錄)" \
"$(payload "$FIX/real-content-arcrun144.txt")"
t 2 "⑮g 回歸:Arcrun#165 那次的內容(MCP 徹查現場)" \
"$(payload "$FIX/real-content-arcrun165.txt")"
t 2 "⑮h 回歸:arcrun-rag#104 那次的內容(收檔實測數字)" \
"$(payload "$FIX/real-content-arcrunrag104.txt")"
t 2 "⑮i 回歸:InkStoneCo#102 那次的內容(milestone 打假背景)" \
"$(payload "$FIX/real-content-inkstoneco102.txt")"
echo
echo "── C 群:訊息本身 ──────────────────────────────────────────────"
clean
msg=$(printf '%s' "$(payload "$FIX/real-violation-isep30.txt")" | bash "$HOOK" 2>&1 >/dev/null)
N=$((N+1))
if printf '%s' "$msg" | grep -q '這句話換一張票還成立嗎' \
&& printf '%s' "$msg" | grep -q '共通規定' \
&& printf '%s' "$msg" | grep -q '寫進那張票'; then
printf ' ✅ ⑯ 訊息講得出判準與兩條出路(共通規定/寫進票)\n'; PASS=$((PASS+1))
else
printf ' ❌ ⑯ 訊息缺了判準或其中一條出路\n'
printf '%s\n' "$msg" | sed -n '1,10p' | sed 's/^/ /'
FAIL=$((FAIL+1))
fi
# 迴歸:訊息裡的反引號一度在別支閘被 shell 當成命令替換執行掉
# 2026-08-26 ask-user-question-guard:閘照擋,但**它教人怎麼解的那兩行變成空白**)
N=$((N+1))
if printf '%s' "$msg" | grep -q 'scripts/ticket say' \
&& printf '%s' "$msg" | grep -q 'touch /tmp/.dispatch-ok-S-TEST' \
&& ! printf '%s' "$msg" | grep -qi 'is a directory\|command not found'; then
printf ' ✅ ⑰ 訊息原文照印:反引號沒被當命令執行,出路那兩行沒有變空白\n'; PASS=$((PASS+1))
else
printf ' ❌ ⑰ 訊息被 shell 展開了(指令消失,或冒出 shell 錯誤)\n'
printf '%s\n' "$msg" | sed -n '1,20p' | sed 's/^/ /'
FAIL=$((FAIL+1))
fi
# 訊息要點名是哪一格違規(可迭代的最小單位——leo 4325:「照這種散文寫法根本無法迭代」)
N=$((N+1))
if printf '%s' "$msg" | grep -q '派工單不只有票號'; then
printf ' ✅ ⑱ 訊息點得出違反的是哪一條規則,不是一句籠統的「格式不對」\n'; PASS=$((PASS+1))
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 個失敗} ══"
[ "$FAIL" -eq 0 ]
+201
View File
@@ -0,0 +1,201 @@
#!/usr/bin/env bash
# factory-idle-guard.sh 的迴歸測試(inkstone/ISEP#30)。
#
# 來由:2026-08-23 雲端驗收連續三次被這道閘誤攔,三次都不是宣告意圖——
# ① 「那句是等你,不是我的下一步」 ← 在否認自己有下一步
# ② 「這不是『下一步我要做 X』」 ← 引用閘自己的訊息來說明沒在做
# ③ 貼出閘自己的正則原始碼來舉報這個 bug ← code block 裡的引用也被當成宣告
# 而閘的訊息教人走「選項③:改寫那句話,說明它在等什麼」,程式碼裡卻沒有那條分支。
#
# 本檔的兩個方向都要有證據(誤攔與漏攔都要測):
# A 群「該放行」——上面三種誤攔案例 + 既有放行路徑不准變鬆的回歸
# B 群「該擋」 ——真的宣告了未來動作、且這回合沒按下任何 trigger
# C 群「訊息承諾的出路真的走得通」——訊息裡列的每一條,各一個實例
#
# 用法:hooks/tests/factory-idle-guard.test.sh [hooks/factory-idle-guard.sh 的路徑]
# 🔴 全程在一個乾淨的 TMP 底下造假 transcript,跑完自己清;不碰任何真 repo、不打網路。
# CLAUDE_PROJECT_DIR 指向沒有 gitea remote 的 TMP ⇒ 副閘拿不到 token,直接 exit 0
# 所以本檔測的一律是主閘。)
set -u
HOOK="${1:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/factory-idle-guard.sh}"
TMP=$(mktemp -d)
trap 'rm -rf "$TMP"' EXIT
PASS=0; FAIL=0
N=0
# mk <transcript 路徑> <block spec>...
# block spec`T:<文字>` assistant 的文字段;`U:<工具名>` assistant 的 tool_use
mk() {
python3 - "$@" <<'PY'
import json, sys
path, specs = sys.argv[1], sys.argv[2:]
rows = [{"type": "user", "message": {"content": [{"type": "text", "text": "【工單】去辦"}]}}]
blocks = []
for s in specs:
k, v = s.split(":", 1)
blocks.append({"type": "text", "text": v} if k == "T"
else {"type": "tool_use", "name": v, "id": "t1", "input": {}})
rows.append({"type": "assistant", "message": {"content": blocks}})
with open(path, "w") as f:
for r in rows:
f.write(json.dumps(r, ensure_ascii=False) + "\n")
PY
}
# t <期望 exit> <說明> <block spec>...
t() {
want="$1"; desc="$2"; shift 2
N=$((N+1))
tr="$TMP/tr-$N.jsonl"
mk "$tr" "$@"
out=$(printf '{"transcript_path":"%s"}' "$tr" \
| CLAUDE_PROJECT_DIR="$TMP" sh "$HOOK" 2>&1); rc=$?
if [ "$rc" -eq "$want" ]; then
printf ' ✅ %s\n' "$desc"; PASS=$((PASS+1))
else
printf ' ❌ %s —— 期望 exit=%s,實得 exit=%s\n' "$desc" "$want" "$rc"
printf '%s\n' "$out" | sed -n '1,6p' | sed 's/^/ /'
FAIL=$((FAIL+1))
fi
}
echo "── A 群:該放行(2026-08-23 雲端三次誤攔的原形狀)─────────────────"
t 0 "① 否認自己有下一步(『那句是等你,不是我的下一步』)" \
'T:我看完了。那句是等你,不是我的下一步。'
t 0 "① 長版:連同被否認的原句一起引用" \
'T:你貼的那行「回覆後我就繼續」是等你,不是我的下一步。'
t 0 "② 引用閘自己的訊息來說明沒在做" \
'T:這不是『下一步我要做 X』,是在說明那道閘認錯了對象。'
t 0 "③ code block 裡貼閘自己的正則原始碼(舉報 bug)" \
'T:誤攔的真兇是這段:
```
r"(下一步(我|就是|是)?[:]?\s*(?!不是宣告)|接下來我|我(現在|接著|等下|等一下)(就)?(去|來|做|派|審|跑)"
```
它連裸的「下一步」三個字都吃。'
t 0 "③ 行內 code 貼同一段正則" \
'T:問題出在 `接下來我` 這個 alternative 沒有要求後面接動詞。'
t 0 "引用 leo 的話(blockquote)含宣告字樣" \
'T:> 下一步我去把那支閘修好,你不要插手。
以上是他八月十六日的原話,我引來說明這道閘的規格。'
t 0 "User Story 票名(身為…我要…我才…)不是宣告" \
'T:我把票名寫成:身為總管,我要把散落的分支收掉整理好,我才不會每天重數一次。'
# ↓ 這兩向不是想出來的,是拿本機一條 2068 行的真 transcript 跑出來的:
# 舊閘在 26 個真實回合終止點上擋了 2 次,兩次咬的都是「我在交代 leo 該做什麼」。
t 0 "④ 主詞是 leo 的下一步(『你下一步去把 X 併進 main』)" \
'T:## 你下一步(兩招,先便宜的)
你下一步去把 feat/isep-dynamic-install 併進 main,然後開一個全新雲端 session。'
t 0 "④ 同上,交代對方要跑的動作" \
'T:前三題不適用。下一步還是那一個動作:你把那條分支併進 main,再跑一次看信標印什麼。'
t 0 "④ 『你的下一步』——主詞在對方身上(單字 lookbehind 蓋不到的變體)" \
'T:我把兩條路都寫清楚了。你的下一步去把那條分支併進 main,再開一個新 session 看信標。'
t 2 "④ 的反面:主詞換回我自己 ⇒ 照樣擋" \
'T:我把兩條路都寫清楚了。我的下一步去把那條分支併掉。'
echo
echo "── A 群:既有放行路徑,不准變鬆(迴歸)─────────────────────────"
t 0 "純對話回合,沒有任何宣告" \
'T:這道閘的問題在於它把引用當成宣告,量錯了對象。'
t 0 "宣告了,但這個回合真的呼叫了 Agent(=按下 trigger" \
'T:下一步我去把 sdd-guard 的誤攔補上。' 'U:Agent'
t 0 "宣告了,但這個回合真的呼叫了 Task" \
'T:接下來我會去補測試。' 'U:Task'
t 0 "宣告句帶票號指涉(已經在別人手上)" \
'T:下一步我去把那支閘補好——已經派給 inkstone/ISEP#30,證據在 comment 3970。'
t 0 "宣告之後真的動手了(文字後面還有工具呼叫)" \
'T:先讀原始碼,接下來我會去補測試。' 'U:Read'
echo
echo "── B 群:該擋(真的宣告了未來動作,且這回合沒按下任何 trigger)────"
t 2 "『下一步我去把 X 補上』,無派工無票號" \
'T:我看完了。下一步我去把那支閘的誤攔補上。'
t 2 "『接下來我會去補測試』" \
'T:目前狀況清楚了。接下來我會去補測試。'
t 2 "把請示寫成條件句(2026-08-17 leo 實撞那族)" \
'T:回「規劃」我就派人盤這份計畫。'
t 2 "『我等你確認』——空手停在等人身上" \
'T:我等你確認。'
t 2 "『我現在就去跑一遍』" \
'T:我現在就去跑一遍那批測試。'
t 2 "『確認後我才動』" \
'T:核准後我才去改那支閘。'
t 0 "票號寫在行內 code 裡也算數(等長替換才量得到)" \
'T:下一步我去把那支閘補好——已派給 `inkstone/ISEP#30`。'
t 2 "票號離宣告句太遠(文章別處提到而已)⇒ 不算它在別人手上" \
'T:早上那批已經在 inkstone/ISEP#30 收掉了。
(中略:這裡是一大段跟那張票無關的驗收敘述,用來把距離拉開。'"$(printf 'x%.0s' $(seq 1 320))"'
下一步我去把那支閘的誤攔補上。'
echo
echo "── C 群:訊息承諾的每一條出路,都要真的走得通 ─────────────────"
t 0 "出路①:這個回合直接呼叫 Agent 派出去" \
'T:下一步我去把那支閘補好。' 'U:Agent'
t 0 "出路②:把票號寫進那句話" \
'T:下一步我去把那支閘補好(已派給 inkstone/ISEP#30)。'
t 0 "出路③:改寫那句話,用 ⏸ 等: 說明它在等什麼" \
'T:⏸ 等:leo 親手跑 scripts/github-arm.sh 解保險(D20 人閘,不是我能自己按的)。
在那之前我等你確認,這件事我不自己動。'
t 0 "出路③:宣告 + 標記同時存在也放行(標記說明了它在等什麼)" \
'T:那批 workflow 要重打 bundle,下一步我去重打。⏸ 等:arcrun-rag 那條 PR 先併,前置沒解除。'
t 0 "出路④:這個回合收尾在動作上(最後一個工具呼叫之後沒有文字)" \
'T:下一步我去把那支閘補好。' 'U:Edit'
echo
echo "── 出路③ 的反面:沒寫標記就照樣擋(不准靠刪掉那句話矇混)───────"
t 2 "同一句話拿掉 ⏸ 等: 標記 ⇒ 擋" \
'T:那批 workflow 要重打 bundle。我等你確認,這件事我不自己動。'
echo
echo "── 壞輸入不准亂擋(fail-open)────────────────────────────────"
n_bad=0
for payload in '{}' '{"transcript_path":"/nonexistent/xx.jsonl"}' 'not-json'; do
N=$((N+1))
printf '%s' "$payload" | CLAUDE_PROJECT_DIR="$TMP" sh "$HOOK" >/dev/null 2>&1; rc=$?
if [ "$rc" -eq 0 ]; then
printf ' ✅ 壞輸入 %-34s ⇒ exit=0\n' "$payload"; PASS=$((PASS+1))
else
printf ' ❌ 壞輸入 %-34s ⇒ exit=%s(期望 0\n' "$payload" "$rc"; FAIL=$((FAIL+1))
fi
n_bad=$((n_bad+1))
done
echo
echo "────── 通過 $PASS 失敗 $FAIL (共 $N 向)"
[ "$FAIL" -eq 0 ]
+32
View File
@@ -0,0 +1,32 @@
# 測資的來歷
`real-violation-isep30.txt` 是**真的發生過的那一份派工單**,一字未改(只把家目錄路徑
換成 `/Users/x` 去識別)——它就是產生 `inkstone/ISEP#30` 這條線的那一次派工。
leo 2026-08-27 要的測資是「今天實際發生的 5 次違規派工」
`arcrun-rag#104``Arcrun#142``Arcrun#127``Arcrun#144``InkStoneCo#55`)。
🔴 **其中 4 份拿不回來了。** 它們住在那幾次派工的 prompt 裡,agent 結束就沒了——
**這件事本身就是這條規則的證據**(4327:「那個 agent 被停掉/換人接手,
那段事實就消失了」)。所以這裡只放拿得到的那一份真跡,
其餘的覆蓋率改用「一條規則一個最小案例」來補(見 dispatch-format-guard.test.sh B 群)。
**不要用想像出來的例子替補那 4 份**,那會讓測試看起來比實際更綠。
---
## 第二批:`real-content-*.txt`inkstone/ISEP#65
leo 2026-08-27 又在同一天抓到總管**寫完規則之後又犯了 8 次**(八條線的派工單,
每一條都帶了「這個 session 才知道的事」):`ISEP#60``#61``#64`
`Arcrun#142``#144``#165``arcrun-rag#104``InkStoneCo#102`
🔴 **原始的違規派工 prompt 本身一樣拿不回來**(同一個道理:agent 一停就蒸發),
但這 8 次的**內容已經被總管補寫回各自的票**(ISEP#65 body 明講)。
這裡的 8 份 `real-content-*.txt` 是**從那些票的真實內文摘錄**(一字未改地複製,
只是截取一段,並在最前面加回 `【工單】owner/repo#N` 讓它符合派工單的殼)——
不是想像出來的例子,是真的存在於 Gitea 上、總管真的寫過的句子,只是原本的位置
錯了(在 prompt 裡,不在票上)。
用途:驗證 dispatch-format-guard.sh 修完 ISEP#65 之後,**這 8 種「票號 + 這個
session 才知道的事」的形狀全部會被擋下**——不是只驗一個最小案例。
+5
View File
@@ -0,0 +1,5 @@
【工單】inkstone/Arcrun#142
實查結論:這兩樣東西(庫索引與全庫摘要)從沒被產生過,不是壞掉,是根本沒做。
於是「開場就知道有哪些子庫、各裝了什麼」這件事永遠靠現查,查不到就當作沒有。
相關:inkstone/Arcrun#81、#87、#100(總圖說 0 條關聯,實際 1854 條)。
+5
View File
@@ -0,0 +1,5 @@
【工單】inkstone/Arcrun#144
leo 明講「不要現在執行,記下來」,本票只是把它接住,開工前要先確認優先序。
測試 OK,出現了樹狀圖,但不符合需求——達成的是 mindmap 向右向上分佈,
上下間距很大、展開字很小,用戶要擁有觸控板否則很難閱讀。
+5
View File
@@ -0,0 +1,5 @@
【工單】inkstone/Arcrun#165
不要先修,先查清楚再說——現在連斷在哪都還沒人講得出來。
已知的現場(總管 2026-08-26 用 MCP 實查,不是讀 code 推的):同一個知識庫
youlinyuga3bse),同一套 MCP 工具,四次查詢分數與筆數都對不上。
+5
View File
@@ -0,0 +1,5 @@
【工單】inkstone/arcrun-rag#104
現況(總管 2026-08-14 實測,非推測):leo 把 5 個資料夾接上重裝後的 leo21c,
App 顯示你的檔案 12022 共幾份、819 已送上去、11183 排隊中、20 送不上去。
逐資料夾實數(find 計 .md/.pdf/.txt,已排除 node_modules.git)。
+5
View File
@@ -0,0 +1,5 @@
【工單】inkstone/InkStoneCo#102
背景(同一天稍早,leo 對同一件事說過的重話):昨天要完成 51,你現在還有沒派的,
去看看 milestone 一大堆沒有一個完成,現在整理,不要詐騙,去看清楚哪些完成的。
Gitea 的百分比只數票的開關,它不知道票裡的東西通了沒。
+6
View File
@@ -0,0 +1,6 @@
【工單】inkstone/ISEP#60
`subagent-claim-worksheet.sh``SubagentStop` 產待驗單)與 `claim-verify-police.sh`
(`Stop` 擋收工)這一套自造的第四套機制整個移除,它守的東西改由 Gitea 原生的三格承接
(子票相依/tag/指派——見 `inkstone/ISEP#59` 與 PR `#58`)。
`#issuecomment-4289` 量到同一份待驗單重生三次、同內容兩個檔名。
+5
View File
@@ -0,0 +1,5 @@
【工單】inkstone/ISEP#61
`main-and-prod-push-guard.sh` 的戳記機制已經走進死路:原設計用
`CLAUDE_CODE_CHILD_SESSION=1` 判斷 subagent,實測結果總管主 session 也是 1,
那個變數不是身分標記,誰都擋。改成戳記(`/tmp/.main-push-ok`,總管手動寫)也失敗。
+6
View File
@@ -0,0 +1,6 @@
【工單】inkstone/ISEP#64
現況(總管實查,2026-08-27 12:40):open PR 總數 23,總管已關掉 8 個「內容早就在
main」的空 PR,剩 15 個要分診。已關的 8 個實測 git merge-base --is-ancestor 證明
HEAD 已是 main 的祖先,diff +0/-0Arcrun#166#161#158#155#153、
arcrun-rag#142#141#139。
+17
View File
@@ -0,0 +1,17 @@
你要動 **ISEP plugin**Gitea `inkstone/ISEP`,全機共用的閘)。開工前先讀該 repo 的 CLAUDE.md 與 `system-dev/wiki/`。
【工單】`inkstone/ISEP#30` → **comment 4322**(任務全文在那則,去讀它)
```
TOKEN=$(cd /Users/x/InkStoneCo && git remote get-url gitea | sed -E 's|.*//[^:]+:([^@]+)@.*|\1|')
curl -s -H "Authorization: token $TOKEN" "https://git.uncle6.me/api/v1/repos/inkstone/ISEP/issues/30/comments?limit=60"
```
這個 session 才知道、票上還沒有的事:
- `~/Documents/tech_projects/` 若讀不到(macOS TCC),立刻回報,不要硬撐;今天稍早發生過,leo 已開權限
- 正本也可從 `~/.claude/plugins/marketplaces/inkstone` cloneorigin 是 `inkstone/ISEP.git`),
今天 `v0.4.0` 那條線就是這樣施工的
- `main` 現在是 `8e7e265``v0.4.0``ask-user-question-guard.sh` 剛上線)
交件:貼回 `inkstone/ISEP#30`,回報分支名。
+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
+203
View File
@@ -0,0 +1,203 @@
#!/usr/bin/env bash
# gitea-arm-check.sh 的迴歸測試——只測「不必打真網路/不必真的等 leo」的幾種:
# 沒有待核請求/已過期/已被消耗過(重放保護)/Gitea 認證失敗/
# 核准者常數不接受環境變數覆蓋/票號缺失或非數字的待核檔會被略過不猜。
# 另有一組**用真 token 打真端點**的測試,驗證 2026-08-16 起「票號由呼叫端指定」
# 這件事真的有路由到對的票(見下方「不同票號各自路由」段)。
#
# 🔴 「Leo 真的回覆才放行」與「機器冒充 Leo」這兩種**必須打真的 Gitea**才有意義
# (核心判準就是比對 Gitea 上留言的 `user.login`),不適合塞進不碰網路的單元測試。
# 這兩種已經在 https://git.uncle6.me/inkstone/InkStoneCo/issues/34 上人工實測過,
# 證據見本次 PR 說明,不在這支重跑。
#
# 🔴 2026-08-13 總管審查後修正:`GITEA_ARM_OWNER/REPO/API/APPROVER_LOGIN`
# 全部改成寫死在 gitea-arm-common.sh,不再接受環境變數覆蓋(否則
# `GITEA_ARM_APPROVER_LOGIN=claude-code` 就能讓機器核准自己)。
# ⇒ 「Gitea 打不到」這條測資不能再用假網址注入,改用**真端點+無效 token**
# (回真的 401,一樣走得到「非 200 → fail-closed」那條分支)。
#
# 🪦 2026-08-16ISSUE 從第五個「寫死常數」名單裡移出來,改成請求時的參數
# (見 lib/gitea-arm-common.sh 檔頭說明:真正的安全邊界是 OWNER/REPO/
# APPROVER_LOGIN,不是票號本身)。相對地,pending 檔的 schema 多了 `issue` 欄位,
# 下面所有測資的 JSON 都要帶上它,否則會被「票號缺失」判定為壞檔而被略過。
#
# 用法:.claude/hooks/tests/gitea-arm-check.test.sh <repo根目錄>
# repo 根目錄要有 scripts/gitea-arm-check.sh + scripts/gitea-arm-request.sh
set -u
PROJ="${1:?用法: $0 <repo根目錄>}"
CHECK="$PROJ/scripts/gitea-arm-check.sh"
TMPPROJ=$(mktemp -d)
trap 'rm -rf "$TMPPROJ"' EXIT
# 借用真的 scripts/(唯讀),但狀態目錄與 .env 都指到隔離的臨時目錄
ln -s "$PROJ/scripts" "$TMPPROJ/scripts"
# 假 .envtoken 隨便填,「Gitea 打不到」那條測資會在網路那層失敗(本來就該擋),
# 其餘測資在打到 Gitea 之前就已經因為本地狀態被擋下,不會真的送出請求。
printf 'GITEA_TOKEN_CLAUDE_CODE=test-token-not-real\n' > "$TMPPROJ/.env"
PASS=0; FAIL=0
t(){ # t <期望 block|pass> <說明> <exit code>
got=$([ "$3" -eq 0 ] && echo pass || echo block)
if [ "$got" = "$1" ]; then echo "$2"; PASS=$((PASS+1))
else echo "$2 —— 期望 $1,實得 $got"; FAIL=$((FAIL+1)); fi
}
echo "── 完全沒有待核請求 ──"
rm -rf "$TMPPROJ/.claude/gitea-arm"
CLAUDE_PROJECT_DIR="$TMPPROJ" "$CHECK" >/tmp/gitea-arm-test-out.$$ 2>&1
t block "沒有 pending 目錄就該擋" $?
echo "── 已過期的請求 ──"
mkdir -p "$TMPPROJ/.claude/gitea-arm/pending"
NOW=$(date +%s)
jq -n --arg nonce "ARM-testexpired" --arg mission "測試" --arg issue "34" \
--argjson requested_at "$((NOW-3600))" --argjson expires_at "$((NOW-1))" \
--arg request_comment_id "1" --arg request_created_at "2020-01-01T00:00:00Z" \
'{nonce:$nonce, mission:$mission, issue:$issue, requested_at:$requested_at, expires_at:$expires_at, request_comment_id:$request_comment_id, request_created_at:$request_created_at}' \
> "$TMPPROJ/.claude/gitea-arm/pending/ARM-testexpired.json"
CLAUDE_PROJECT_DIR="$TMPPROJ" "$CHECK" ARM-testexpired >/tmp/gitea-arm-test-out.$$ 2>&1
rc=$?
t block "過期的 nonce 該擋" $rc
[ -f "$TMPPROJ/.claude/gitea-arm/pending/ARM-testexpired.json" ] \
&& { echo " ❌ 過期後 pending 檔應該被清掉,卻還在"; FAIL=$((FAIL+1)); } \
|| { echo " ✅ 過期後 pending 檔已清掉"; PASS=$((PASS+1)); }
echo "── 已被消耗過的 nonce(防重放)──"
rm -rf "$TMPPROJ/.claude/gitea-arm"
mkdir -p "$TMPPROJ/.claude/gitea-arm/pending"
jq -n --arg nonce "ARM-testreplay" --arg mission "測試" --arg issue "34" \
--argjson requested_at "$NOW" --argjson expires_at "$((NOW+1800))" \
--arg request_comment_id "1" --arg request_created_at "2020-01-01T00:00:00Z" \
'{nonce:$nonce, mission:$mission, issue:$issue, requested_at:$requested_at, expires_at:$expires_at, request_comment_id:$request_comment_id, request_created_at:$request_created_at}' \
> "$TMPPROJ/.claude/gitea-arm/pending/ARM-testreplay.json"
printf 'ARM-testreplay\t2026-01-01 00:00:00\t舊任務\n' > "$TMPPROJ/.claude/gitea-arm/consumed.log"
CLAUDE_PROJECT_DIR="$TMPPROJ" "$CHECK" ARM-testreplay >/tmp/gitea-arm-test-out.$$ 2>&1
rc=$?
t block "已消耗過的 nonce 該擋(即使有效期還沒到)" $rc
[ -f "$TMPPROJ/.claude/gitea-arm/pending/ARM-testreplay.json" ] \
&& { echo " ❌ 重放判定後 pending 檔應該被清掉,卻還在"; FAIL=$((FAIL+1)); } \
|| { echo " ✅ 重放判定後 pending 檔已清掉"; PASS=$((PASS+1)); }
echo "── Gitea 認證失敗(真端點+無效 tokenfail-closed)──"
rm -rf "$TMPPROJ/.claude/gitea-arm"
mkdir -p "$TMPPROJ/.claude/gitea-arm/pending"
jq -n --arg nonce "ARM-testunreach" --arg mission "測試" --arg issue "34" \
--argjson requested_at "$NOW" --argjson expires_at "$((NOW+1800))" \
--arg request_comment_id "1" --arg request_created_at "2020-01-01T00:00:00Z" \
'{nonce:$nonce, mission:$mission, issue:$issue, requested_at:$requested_at, expires_at:$expires_at, request_comment_id:$request_comment_id, request_created_at:$request_created_at}' \
> "$TMPPROJ/.claude/gitea-arm/pending/ARM-testunreach.json"
# $TMPPROJ/.env 裡的 token 是假的(見檔頭),這條打的是**真的** git.uncle6.me——
# 拿假 token 打真端點,預期真的收到 401,藉此驗「非 200 → fail-closed」那條分支。
CLAUDE_PROJECT_DIR="$TMPPROJ" "$CHECK" ARM-testunreach >/tmp/gitea-arm-test-out.$$ 2>&1
rc=$?
t block "Gitea 認證失敗要 fail-closed(不放行)" $rc
grep -q "非 200\|打不到" /tmp/gitea-arm-test-out.$$ \
&& { echo " ✅ 錯誤訊息確實指向 fail-closed 分支(不是別的原因擋下)"; PASS=$((PASS+1)); } \
|| { echo " ❌ 沒看到預期的 fail-closed 訊息:"; cat /tmp/gitea-arm-test-out.$$; FAIL=$((FAIL+1)); }
echo "── 🔴 票號缺失/非數字的待核檔——不猜票號,略過不當機 ──"
rm -rf "$TMPPROJ/.claude/gitea-arm"
mkdir -p "$TMPPROJ/.claude/gitea-arm/pending"
jq -n --arg nonce "ARM-testnoissue" --arg mission "測試" \
--argjson requested_at "$NOW" --argjson expires_at "$((NOW+1800))" \
--arg request_comment_id "1" --arg request_created_at "2020-01-01T00:00:00Z" \
'{nonce:$nonce, mission:$mission, requested_at:$requested_at, expires_at:$expires_at, request_comment_id:$request_comment_id, request_created_at:$request_created_at}' \
> "$TMPPROJ/.claude/gitea-arm/pending/ARM-testnoissue.json"
CLAUDE_PROJECT_DIR="$TMPPROJ" "$CHECK" ARM-testnoissue >/tmp/gitea-arm-test-out.$$ 2>&1
rc=$?
t block "缺 issue 欄位的舊格式待核檔該擋(不放行)" $rc
grep -q "issue 缺失或非數字" /tmp/gitea-arm-test-out.$$ \
&& { echo " ✅ 錯誤訊息點名是 issue 欄位的問題(不是猜成別的原因)"; PASS=$((PASS+1)); } \
|| { echo " ❌ 沒看到預期的訊息:"; cat /tmp/gitea-arm-test-out.$$; FAIL=$((FAIL+1)); }
echo "── 🔴 核准者不接受環境變數覆蓋(本次審查抓到的洞,補的測試)──"
GOT_LOGIN=$(CLAUDE_PROJECT_DIR="$TMPPROJ" GITEA_ARM_APPROVER_LOGIN="claude-code" bash -c '
. "'"$PROJ"'/scripts/lib/gitea-arm-common.sh"
printf "%s" "$GITEA_ARM_APPROVER_LOGIN"
')
if [ "$GOT_LOGIN" = "Leo" ]; then
echo " ✅ 設了 GITEA_ARM_APPROVER_LOGIN=claude-codesource 進去的常數仍是 Leo(覆蓋無效)"
PASS=$((PASS+1))
else
echo " ❌ 常數被環境變數改成了「$GOT_LOGIN」——核准者可以被外部覆蓋,這是安全洞"
FAIL=$((FAIL+1))
fi
echo "── 🔴 不同票號各自路由(真 token 打真端點):一個指到存在的票、一個指到不存在的票 ──"
# 這條要證明的是 2026-08-16 這次改動的核心:每個請求真的用它自己的 issue 欄位
# 去打對應的票,不是仍然只認某個寫死的號碼。用真 token(唯讀 GET,不會寫入任何東西):
# · nonce A 指到 #34(真實存在,開放中)→ 預期 200 OK,只是假 nonce 找不到 Leo 回覆
# · nonce B 指到一個不存在的超大票號 → 預期 404/非 200 → fail-closed
# 如果程式碼還在用舊的單一票號邏輯,這兩筆會得到**相同**的結果(不會一個過一個不過)。
REAL_ENV="$PROJ/.env"
if [ -f "$REAL_ENV" ] && grep -q '^GITEA_TOKEN_CLAUDE_CODE=' "$REAL_ENV"; then
cp "$REAL_ENV" "$TMPPROJ/.env"
rm -rf "$TMPPROJ/.claude/gitea-arm"
mkdir -p "$TMPPROJ/.claude/gitea-arm/pending"
jq -n --arg nonce "ARM-testrouteA" --arg mission "路由測試A" --arg issue "34" \
--argjson requested_at "$NOW" --argjson expires_at "$((NOW+1800))" \
--arg request_comment_id "1" --arg request_created_at "2020-01-01T00:00:00Z" \
'{nonce:$nonce, mission:$mission, issue:$issue, requested_at:$requested_at, expires_at:$expires_at, request_comment_id:$request_comment_id, request_created_at:$request_created_at}' \
> "$TMPPROJ/.claude/gitea-arm/pending/ARM-testrouteA.json"
jq -n --arg nonce "ARM-testrouteB" --arg mission "路由測試B" --arg issue "999999999" \
--argjson requested_at "$NOW" --argjson expires_at "$((NOW+1800))" \
--arg request_comment_id "1" --arg request_created_at "2020-01-01T00:00:00Z" \
'{nonce:$nonce, mission:$mission, issue:$issue, requested_at:$requested_at, expires_at:$expires_at, request_comment_id:$request_comment_id, request_created_at:$request_created_at}' \
> "$TMPPROJ/.claude/gitea-arm/pending/ARM-testrouteB.json"
CLAUDE_PROJECT_DIR="$TMPPROJ" "$CHECK" >/tmp/gitea-arm-test-out.$$ 2>&1
rc=$?
t block "兩者都是假 nonce,不會被核准,整體仍該回不放行" $rc
if grep -q "ARM-testrouteA#34)還沒等到 Leo 的回覆" /tmp/gitea-arm-test-out.$$; then
echo " ✅ #34(存在):真的打到了、200 OK,只是假 nonce 沒有匹配的留言"
PASS=$((PASS+1))
else
echo " ❌ 沒看到 #34 該有的「還沒等到」訊息:"; cat /tmp/gitea-arm-test-out.$$
FAIL=$((FAIL+1))
fi
if grep -q "#999999999Gitea 打不到/回應非 200" /tmp/gitea-arm-test-out.$$; then
echo " ✅ #999999999(不存在):真的打到了那個號碼,並且 fail-closed(不是誤放行也不是誤判成別的錯)"
PASS=$((PASS+1))
else
echo " ❌ 沒看到 #999999999 該有的 fail-closed 訊息:"; cat /tmp/gitea-arm-test-out.$$
FAIL=$((FAIL+1))
fi
else
echo " ⚠️ 跳過(找不到真的 GITEA_TOKEN_CLAUDE_CODE,這條測資需要唯讀真端點)"
fi
echo "── 🔴 gitea-arm-request.sh:缺票號/票號非數字要在打網路前就擋 ──"
REQ="$PROJ/scripts/gitea-arm-request.sh"
CLAUDE_PROJECT_DIR="$TMPPROJ" "$REQ" >/tmp/gitea-arm-test-out.$$ 2>&1
t block "完全沒帶參數該擋(用法錯誤)" $?
CLAUDE_PROJECT_DIR="$TMPPROJ" "$REQ" "not-a-number" "測試任務" >/tmp/gitea-arm-test-out.$$ 2>&1
rc=$?
t block "票號非數字該擋" $rc
# 2026-08-16:票號格式擴充為「N 或 repo#N」,訊息跟著改;斷言改成看**意圖**(有沒有點名票號)
grep -qE "票號.*純數字|純數字票號" /tmp/gitea-arm-test-out.$$ \
&& { echo " ✅ 錯誤訊息點名是票號格式問題"; PASS=$((PASS+1)); } \
|| { echo " ❌ 沒看到預期訊息:"; cat /tmp/gitea-arm-test-out.$$; FAIL=$((FAIL+1)); }
# ── 🔴 2026-08-16 新增:repo 可指定,但 owner 絕不可 ──────────────────
echo "── 🔴 gitea-arm-commonrepo 可由參數指定,owner 是安全邊界不給指定 ──"
( . "$PROJ/scripts/lib/gitea-arm-common.sh" >/dev/null 2>&1
gitea_arm_set_repo "arcrun-rag" >/dev/null 2>&1 && [ "$GITEA_ARM_REPO" = "arcrun-rag" ] ) \
&& { echo " ✅ 同 org 的 repo 名可指定"; PASS=$((PASS+1)); } \
|| { echo " ❌ 同 org 的 repo 名竟然不能指定"; FAIL=$((FAIL+1)); }
( . "$PROJ/scripts/lib/gitea-arm-common.sh" >/dev/null 2>&1
gitea_arm_set_repo "claude-code/evil" >/dev/null 2>&1 ) \
&& { echo " ❌ 帶斜線的 owner/repo 竟然被接受——那正是 08-13 那次攻擊的形狀"; FAIL=$((FAIL+1)); } \
|| { echo " ✅ 帶斜線的被擋(owner 不給任何人指定)"; PASS=$((PASS+1)); }
( GITEA_ARM_OWNER=claude-code; . "$PROJ/scripts/lib/gitea-arm-common.sh" >/dev/null 2>&1
[ "$GITEA_ARM_OWNER" = "inkstone" ] ) \
&& { echo " ✅ OWNER 不受環境變數影響(仍是 inkstone"; PASS=$((PASS+1)); } \
|| { echo " ❌ OWNER 被 env 蓋掉了——安全邊界破了"; FAIL=$((FAIL+1)); }
rm -f /tmp/gitea-arm-test-out.$$
echo
echo "結果:通過 $PASS 失敗 $FAIL"
[ $FAIL -eq 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
+101
View File
@@ -0,0 +1,101 @@
# hooks/tests/lib/hook-sandbox.sh — 讓「會寫檔的閘」在測試時寫到暫存區,不寫進 repo。
# 不是獨立掛的閘(沒進 hooks.json),給 hooks/tests 與 scripts/test-* `source` 用。
#
# 🔴 為什麼需要這支(inkstone/ISEP#59 comment 4779 第 ① 條,實測重演過):
# main-and-prod-push-guard.sh 擋下「推 main」的同時,會把那次請求寫成一份
# `<hooks 的上一層>/pending-main-push/<誰>--<repo>.md`——**那份檔案的語意是
# 「有一筆推 main 正在等總管裁」**。而測試的測資本來就全是「推 main」,
# 於是每跑一次測試,工作區就多/改幾筆**偽造的待裁決**:
#
# $ git status --short # 跑完測試之後(修之前的實測)
# M pending-main-push/unnamed--ISEP.md ← 被測試覆寫(它是被追蹤的)
# ?? pending-main-push/unnamed--A.md ← 跨 repo 測試建的臨時 repo
#
# 兩個後果,後者比較貴:
# ① `git add -A` 很容易把它們帶進 commit(08-27 那次真的帶進去了,事後才拔掉)
# ② **永遠在響的警報**:總管的迴圈讀這個目錄,讀到的每一筆都該是真的在等他裁。
# 測試每跑一次就偽造一筆 ⇒ 下一筆真的請求會混在雜訊裡。
#
# 做法:把整個 `hooks/` 複製到暫存區再跑那支複本。閘算 `pending-main-push` 的位置
# 靠的是 `$0` 的上一層,所以複本會寫進暫存區,**產品程式碼一行都不用改**
# (不必為了測試在閘上多開一個「寫去哪」的開關——那種開關會變成關掉紀錄的路)。
#
# 用法:
# . "$(dirname "$0")/lib/hook-sandbox.sh"
# hook_sandbox "$REAL_HOOK" || exit 1
# G=$HOOK_SANDBOX_HOOK # 之後一律測 $G
# before=$(hook_sandbox_hostsum) # repo 那份 pending-main-push 的指紋
# ... 跑測試 ...
# hook_sandbox_assert "$before" # 回 0=乾淨且沙盒裡真的有留下請求
# hook_sandbox <真跡 hook 的路徑> → 建好沙盒,設三個變數,回 0/1
# HOOK_SANDBOX_HOOK 沙盒裡那支同名 hook(之後一律測這支)
# HOOK_SANDBOX 沙盒根(`pending-main-push` 會長在這底下)
# HOOK_SANDBOX_HOST 真跡 repo 的 pending-main-push(拿來比對有沒有被弄髒)
#
# 🔴 **刻意不用 `printf` 把路徑印出來讓呼叫端 `G=$(hook_sandbox …)` 接**——
# 命令替換跑在子殼裡,函式設的那三個變數**回不到呼叫端**。
# 寫這支的當下就這樣寫,第一次跑的結果是:`HOOK_SANDBOX` 在呼叫端是空的
# ⇒ hostsum 兩次都回 `NODIR`(相等)、沙盒的請求數當然是 0
# ⇒ **第一條斷言變成「拿空的比空的」的假綠**。
# 所以改成「設變數、不印」,而且 hook_sandbox_assert 開頭會擋空值(見下)。
hook_sandbox() {
_hs_real=$1
_hs_hooks=$(CDPATH= cd -- "$(dirname -- "$_hs_real")" && pwd) || return 1
HOOK_SANDBOX=$(mktemp -d) || return 1
cp -R "$_hs_hooks" "$HOOK_SANDBOX/hooks" || return 1
HOOK_SANDBOX_HOST="${_hs_hooks%/hooks}/pending-main-push"
HOOK_SANDBOX_HOOK="$HOOK_SANDBOX/hooks/$(basename "$_hs_real")"
[ -f "$HOOK_SANDBOX_HOOK" ] || return 1
}
# hook_sandbox_hostsum → 印出真跡 repo 那份 pending-main-push 的指紋(檔名+內容)
# 目錄不存在時印 NODIR——「本來就沒有」跟「空的」要分得出來。
hook_sandbox_hostsum() {
if [ -d "${HOOK_SANDBOX_HOST:-/nonexistent}" ]; then
find "$HOOK_SANDBOX_HOST" -type f 2>/dev/null | LC_ALL=C sort | while IFS= read -r _f; do
cksum "$_f" 2>/dev/null || printf 'UNREADABLE %s\n' "$_f"
done
else
printf 'NODIR\n'
fi
}
# hook_sandbox_assert <before 的指紋> → 印出兩行結果,全過回 0
# 🔴 兩件都要驗,只驗前者會養出「把紀錄關掉也算通過」的假綠:
# ① repo 那份 pending-main-push 一個位元都沒動(測試沒有偽造待裁決)
# ② 沙盒那份真的多了請求檔(**留請求這件事本身還在做**)
hook_sandbox_assert() {
_hs_before=$1
# fail-closed:沙盒沒建起來(或變數沒傳到這裡)時,下面兩條會變成「空的比空的」=假綠。
if [ -z "${HOOK_SANDBOX:-}" ] || [ -z "${HOOK_SANDBOX_HOST:-}" ]; then
printf ' ❌ %-58s\n' "沙盒變數是空的——這兩條斷言等於沒跑,當失敗處理"
HS_PASS=0; HS_FAIL=2; return 1
fi
_hs_after=$(hook_sandbox_hostsum)
_hs_rc=0; HS_PASS=0; HS_FAIL=0
if [ "$_hs_before" = "$_hs_after" ]; then
printf ' ✅ %-58s\n' "repo 的 pending-main-push 沒被測試碰過"; HS_PASS=$((HS_PASS+1))
else
printf ' ❌ %-58s\n' "repo 的 pending-main-push 被測試寫髒了"
printf ' before/after diff\n'
printf '%s\n' "$_hs_before" > "$HOOK_SANDBOX/.before"
printf '%s\n' "$_hs_after" > "$HOOK_SANDBOX/.after"
diff "$HOOK_SANDBOX/.before" "$HOOK_SANDBOX/.after" | sed 's/^/ /'
_hs_rc=1; HS_FAIL=$((HS_FAIL+1))
fi
_hs_n=$(find "$HOOK_SANDBOX/pending-main-push" -type f 2>/dev/null | wc -l | tr -d ' ')
if [ "${_hs_n:-0}" -gt 0 ]; then
printf ' ✅ %-58s%s 筆)\n' "請求有被留下來,只是留在沙盒裡" "$_hs_n"; HS_PASS=$((HS_PASS+1))
else
printf ' ❌ %-58s\n' "沙盒裡一筆請求都沒有——留請求的機制可能被關掉了"
_hs_rc=1; HS_FAIL=$((HS_FAIL+1))
fi
return $_hs_rc
}
hook_sandbox_cleanup() {
case "${HOOK_SANDBOX:-}" in
/*/*) rm -rf "$HOOK_SANDBOX" ;; # 只刪 mktemp 給的那種深路徑,不接受空值/根目錄
esac
}
+120
View File
@@ -0,0 +1,120 @@
#!/usr/bin/env bash
# 跨 repo 戳記實測(inkstone/ISEP#30 comment 3949,脈絡 inkstone/InkStoneCo#57
#
# 補的是什麼:hooks/tests/main-and-prod-push-guard.test.sh 那八向都只在單一 repo
# (測試腳本自己所在的 repo)裡驗證,從沒測過「站在 A、要推 B 的 main」這個形狀
# ——而這正是 2026-08-21 真的撞到、讓戳記永遠對不上的那個情境。這支專門補這塊。
#
# 用法:main-and-prod-push-guard-cross-repo.test.sh <要測的 hook 絕對路徑>
# 路徑務必給絕對路徑——測試會 cd 進臨時建立的 A/B repo 再呼叫它,相對路徑
# 到那時就對不上了(自己撞過一次:exit=127 command not found)。
set -u
REAL="$1"
# 🔴 測沙盒裡的複本,不測真跡:這支閘擋下推 main 時會把請求寫進
# `<repo>/pending-main-push/`,而這裡每一條測資都是推 main
# ⇒ 直接測真跡,每跑一次就在 ISEP 的工作區偽造幾筆「還沒裁」
# (實測留下過 `pending-main-push/unnamed--A.md`——A 是這支自己建的臨時 repo,
# inkstone/ISEP#59 comment 4779 第 ① 條)。收尾會驗兩件(見 lib 檔頭)。
. "$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)/lib/hook-sandbox.sh"
hook_sandbox "$REAL" || { echo "❌ 沙盒建不起來"; exit 1; }
G=$HOOK_SANDBOX_HOOK
HOSTSUM_BEFORE=$(hook_sandbox_hostsum)
STAMP=/tmp/.main-push-ok
WORK=$(mktemp -d)
trap 'rm -rf "$WORK"; rm -f "$STAMP"; hook_sandbox_cleanup' EXIT
for d in A B; do
git init -q -b main "$WORK/$d"
git -C "$WORK/$d" config user.email t@t.com
git -C "$WORK/$d" config user.name t
echo x > "$WORK/$d/f.txt"
git -C "$WORK/$d" add f.txt
git -C "$WORK/$d" commit -q -m init
done
A="$WORK/A"; B="$WORK/B"
pass=0; fail=0
t() { # t <說明> <cwd> <指令> <期望 exit>
local desc="$1" cwd="$2" cmd="$3" want="$4"
local rc
rc=$(cd "$cwd" && CLAUDE_CODE_CHILD_SESSION=1 python3 -c '
import json, subprocess, sys
p = subprocess.run(["bash", sys.argv[2]],
input=json.dumps({"tool_name": "Bash",
"tool_input": {"command": sys.argv[1]}}),
capture_output=True, text=True)
print(p.returncode)
' "$cmd" "$G")
if [ "$rc" = "$want" ]; then printf ' ✅ %-58s exit=%s\n' "$desc" "$rc"; pass=$((pass+1))
else printf ' ❌ %-58s exit=%s(期望 %s\n' "$desc" "$rc" "$want"; fail=$((fail+1)); fi
}
echo "── 2026-08-21 實撞的原形狀:站在 A,要推 B 的 main ──"
rm -f "$STAMP"
t "沒戳記 → 擋" "$A" "cd $B && git push origin HEAD:main" 2
git -C "$B" rev-parse --show-toplevel > "$STAMP"
t "替 B 開的戳記 → 推 B 的 main 該放行(舊版在此情境永遠擋,這是本票要修的洞)" \
"$A" "cd $B && git push origin HEAD:main" 0
echo "── 反向不准鬆:替 A 開的戳記,不能拿去放行推 B(08-11 那次穿透的形狀)──"
git -C "$A" rev-parse --show-toplevel > "$STAMP"
t "替 A 開的戳記 → 拿去推 B 的 main 必須仍被擋" \
"$A" "cd $B && git push origin HEAD:main" 2
rm -f "$STAMP"
echo "── git -C 語法要吃到同一套判斷 ──"
git -C "$B" rev-parse --show-toplevel > "$STAMP"
t "替 B 開戳記,用 git -C B push" "$A" "git -C $B push origin main" 0
rm -f "$STAMP"
echo "── 08-11 原始穿透的形狀:子殼裡的 cd 不能外洩到殼外 ──"
git -C "$A" rev-parse --show-toplevel > "$STAMP"
t "子殼裡 cd 去 B 但沒在殼內推;殼外站著 A 真的推 → 符合 A 的戳記,放行" \
"$A" "(cd $B && true); git push origin HEAD:main" 0
rm -f "$STAMP"
git -C "$A" rev-parse --show-toplevel > "$STAMP"
t "子殼裡 cd 去 B 且在殼內真的推 → 目標是 B,戳記是 A,必須擋" \
"$A" "(cd $B && git push origin HEAD:main)" 2
rm -f "$STAMP"
echo "── 順手抓到、一併修的洞:純括號包住整條指令,不准繞過目的地判斷 ──"
t "(git push origin HEAD:main) 沒有任何戳記 → 必須擋(舊版在此整段放行)" \
"$A" "(git push origin HEAD:main)" 2
echo "── 同 reposession 站著的那個)舊行為原封不動 ──"
rm -f "$STAMP"
t "站在 A 推 A 自己的 main,沒戳記 → 擋" "$A" "git push origin HEAD:main" 2
git -C "$A" rev-parse --show-toplevel > "$STAMP"
t "站在 A 推 A 自己的 main,替 A 開戳記 → 放行" "$A" "git push origin HEAD:main" 0
rm -f "$STAMP"
echo "── 舊有行為一條都不能壞 ──"
t "推 feature branch 放行" "$A" "git push origin feat/xyz" 0
t "推 tag 放行" "$A" "git push origin refs/tags/v1.0.0" 0
t "只是提到 main 的 gh pr create,放行" "$A" "gh pr create --base main --title t" 0
echo "── subagent 沒戳記,即使 cd 去別的 repo 也照擋 ──"
rm -f "$STAMP"
t "subagent 站在 A、cd 去 B 推 main,沒戳記仍擋" "$A" "cd $B && git push origin HEAD:main" 2
echo "── 單次用完即丟、900 秒逾時:換到跨 repo 場景一樣要成立 ──"
git -C "$B" rev-parse --show-toplevel > "$STAMP"
t "第一次:替 B 開戳記推 B → 放行" "$A" "cd $B && git push origin HEAD:main" 0
t "第二次:同一枚戳記(已用掉)再推一次 → 應該擋" "$A" "cd $B && git push origin HEAD:main" 2
rm -f "$STAMP"; touch "$STAMP"
t "touch 出的空戳記 → 推 B 的 main 仍應擋(08-12 補的洞不能被本次改動重開)" \
"$A" "cd $B && git push origin HEAD:main" 2
rm -f "$STAMP"
git -C "$B" rev-parse --show-toplevel > "$STAMP"
touch -t "$(date -v-16M +%Y%m%d%H%M.%S 2>/dev/null || date -d '-16 minutes' +%Y%m%d%H%M.%S)" "$STAMP" 2>/dev/null
t "16 分鐘前開的戳記 → 已過期,推 B 應擋" "$A" "cd $B && git push origin HEAD:main" 2
rm -f "$STAMP"
echo "── 測試自己不准弄髒工作區(inkstone/ISEP#59)──"
hook_sandbox_assert "$HOSTSUM_BEFORE"
pass=$((pass+HS_PASS)); fail=$((fail+HS_FAIL))
echo "────── 通過 $pass 失敗 $fail"
[ "$fail" = 0 ]
+43
View File
@@ -0,0 +1,43 @@
#!/usr/bin/env bash
# 八向實測 main-and-prod-push-guard.sh
# 放在檔案裡跑,因為測試字串本身會觸發「舊版」那支閘(第五次誤攔)。
REAL="$1" # 要測的 hook 路徑(真跡)
# 🔴 不要直接測真跡:這支閘擋下推 main 的同時會把請求寫進 `<repo>/pending-main-push/`
# 而這裡的測資全是推 main ⇒ 每跑一次就在工作區偽造幾筆「還沒裁」(inkstone/ISEP#59)。
# 改測沙盒裡的複本,閘照原樣跑,請求寫進暫存區。收尾會驗兩件(見 lib 檔頭)。
. "$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)/lib/hook-sandbox.sh"
hook_sandbox "$REAL" || { echo "❌ 沙盒建不起來"; exit 1; }
G=$HOOK_SANDBOX_HOOK
trap 'hook_sandbox_cleanup' EXIT
HOSTSUM_BEFORE=$(hook_sandbox_hostsum)
pass=0; fail=0
t() { # t <說明> <指令> <期望 exit>
printf '{"tool_name":"Bash","tool_input":{"command":"%s"}}' "$2" \
| CLAUDE_CODE_CHILD_SESSION=1 CLAUDE_PROJECT_DIR="$(dirname "$(dirname "$G")")" \
bash "$G" >/tmp/pg.out 2>&1
rc=$?
if [ "$rc" = "$3" ]; then printf ' ✅ %-46s exit=%s\n' "$1" "$rc"; pass=$((pass+1))
else printf ' ❌ %-46s exit=%s(期望 %s\n' "$1" "$rc" "$3"; fail=$((fail+1)); fi
}
echo "── 該放行(今晚五次誤攔的原形狀)──"
t "checkout -b 後推 feature 分支" 'git checkout -q -b fix/x ma'"in"' && git push -q origin fix/x' 0
t "gh pr create --base(不是 git push" 'gh pr create --head f --base ma'"in"' --title t' 0
t "推 tag" 'git push -q origin refs/tags/v0.3.3' 0
t "推 feature 分支(帶 -u" 'git push -q -u origin feat/milestone-must-have-due' 0
t "分支名含 domain" 'git push origin fix/custom-domain-setup' 0
echo "── 該擋 ──"
t "直接推預設分支" 'git push origin ma'"in" 2
t "HEAD:預設分支" 'git push origin HEAD:ma'"in" 2
t "推 master" 'git push -q origin mas'"ter" 2
echo "── 測試自己不准弄髒工作區(inkstone/ISEP#59)──"
hook_sandbox_assert "$HOSTSUM_BEFORE"
pass=$((pass+HS_PASS)); fail=$((fail+HS_FAIL))
echo "────── 通過 $pass 失敗 $fail"
[ "$fail" = "0" ]
+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
+187
View File
@@ -0,0 +1,187 @@
#!/usr/bin/env bash
# mainline-idle-guard.sh 的迴歸測試(inkstone/ISEP#30 → comment 4851
#
# 兩個方向都要有證據:
# A 群「不該擋」——回答問題、輕量回合、任何一種推進證據、未達門檻、響過之後退讓
# B 群「該擋」 ——連續乾回合(有動作、零推進)踩到門檻
# C 群「訊息承諾的出路真的走得通」——擋完歸零、門檻加倍
#
# 用法:hooks/tests/mainline-idle-guard.test.sh [hooks/mainline-idle-guard.sh 的路徑]
# 🔴 全程在乾淨的 TMP 底下造假 transcript 與假狀態檔,跑完自己清;
# 不碰任何真 repo、不打網路、不寫 /tmp 的正式狀態檔(走 MAINLINE_IDLE_STATE_DIR)。
set -u
HOOK="${1:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/mainline-idle-guard.sh}"
TMP=$(mktemp -d)
trap 'rm -rf "$TMP"' EXIT
PASS=0; FAIL=0; N=0
# append_turn <transcript> <block spec>...
# block spec`T:<文字>` assistant 文字段;`U:<工具名>` tool_use
# `B:<指令>` Bash tool_use(帶 command
append_turn() {
python3 - "$@" <<'PY'
import json, sys
path, specs = sys.argv[1], sys.argv[2:]
blocks = []
for s in specs:
k, v = s.split(":", 1)
if k == "T":
blocks.append({"type": "text", "text": v})
elif k == "B":
blocks.append({"type": "tool_use", "name": "Bash", "id": "t", "input": {"command": v}})
else:
blocks.append({"type": "tool_use", "name": v, "id": "t", "input": {}})
with open(path, "a") as f:
f.write(json.dumps({"type": "user",
"message": {"content": [{"type": "text", "text": "繼續"}]}},
ensure_ascii=False) + "\n")
f.write(json.dumps({"type": "assistant", "message": {"content": blocks}},
ensure_ascii=False) + "\n")
PY
}
# fire <session> <transcript> <期望 exit> <說明> [extra-json]
fire() {
sid="$1"; tr="$2"; want="$3"; desc="$4"; extra="${5:-}"
N=$((N+1))
payload=$(printf '{"session_id":"%s","transcript_path":"%s"%s}' "$sid" "$tr" "$extra")
out=$(printf '%s' "$payload" \
| CLAUDE_PROJECT_DIR="${PROJ_OVERRIDE:-$TMP}" \
MAINLINE_IDLE_STATE_DIR="$TMP" bash "$HOOK" 2>&1); rc=$?
if [ "$rc" -eq "$want" ]; then
printf ' ✅ %s\n' "$desc"; PASS=$((PASS+1))
else
printf ' ❌ %s —— 期望 exit=%s,實得 exit=%s\n' "$desc" "$want" "$rc"
printf '%s\n' "$out" | sed -n '1,6p' | sed 's/^/ /'
FAIL=$((FAIL+1))
fi
}
# 造一個新 session 的 transcript,並吃掉第一次的 baseline
new_session() {
sid="$1"; tr="$TMP/$sid.jsonl"; : > "$tr"
append_turn "$tr" 'U:Read'
printf '{"session_id":"%s","transcript_path":"%s"}' "$sid" "$tr" \
| CLAUDE_PROJECT_DIR="${PROJ_OVERRIDE:-$TMP}" MAINLINE_IDLE_STATE_DIR="$TMP" \
bash "$HOOK" >/dev/null 2>&1
echo "$tr"
}
DRY=(U:Read U:Grep B:git\ status U:Read) # 4 個動作、零推進 乾回合
echo "── A 群:不該擋 ────────────────────────────────────────────"
tr=$(new_session s-light)
fire s-light "$tr" 0 "① 第一次見到這個 session(立基準,不評分)"
# 票上第 3 條驗收:回答一個不需要派工的問題 ⇒ 放行(且**不計數**)
for i in 1 2 3 4 5; do
append_turn "$tr" 'U:Read' 'U:Grep' "T:查了一下,答案是 X。"
fire s-light "$tr" 0 "② 回答問題的輕量回合(2 個動作)第 $i 次——連 5 次都不該擋"
done
tr=$(new_session s-dispatch)
for i in 1 2 3 4; do
append_turn "$tr" "${DRY[@]}" 'U:Agent'
fire s-dispatch "$tr" 0 "③ 有派工(Agent)第 $i 次 ⇒ 永遠歸零"
done
tr=$(new_session s-send)
for i in 1 2 3 4; do
append_turn "$tr" "${DRY[@]}" 'U:SendMessage'
fire s-send "$tr" 0 "④ 驅動既有 agentSendMessage)第 $i"
done
tr=$(new_session s-write)
for i in 1 2 3 4; do
append_turn "$tr" "${DRY[@]}" 'U:Edit'
fire s-write "$tr" 0 "⑤ 有產出(Edit)第 $i"
done
tr=$(new_session s-ticket)
for i in 1 2 3 4; do
append_turn "$tr" 'U:Read' 'U:Grep' 'B:scripts/ticket say inkstone/ISEP#30 -F /tmp/x.md'
fire s-ticket "$tr" 0 "⑥ 寫回票(scripts/ticket say)第 $i"
done
tr=$(new_session s-mcp)
for i in 1 2 3 4; do
append_turn "$tr" "${DRY[@]}" 'U:mcp__x__kbdb_create_record'
fire s-mcp "$tr" 0 "⑦ 寫進外部系統(kbdb_create_record)第 $i"
done
tr=$(new_session s-under)
for i in 1 2 3; do
append_turn "$tr" "${DRY[@]}"; fire s-under "$tr" 0 "⑧ 第 $i 個乾回合——未達門檻(4),不擋"
done
tr=$(new_session s-active)
append_turn "$tr" "${DRY[@]}"
fire s-active "$tr" 0 "⑩ stop_hook_active(已被別的 Stop 閘擋過)⇒ 不計數" ',"stop_hook_active":true'
append_turn "$tr" "${DRY[@]}"
fire s-active "$tr" 0 "⑪ 同上,第 2 次仍不擋(不會被別的閘的擋下推著累積)" ',"stop_hook_active":true'
tr=$(new_session s-dup)
append_turn "$tr" "${DRY[@]}"
fire s-dup "$tr" 0 "⑫ 同一個回合被叫兩次(transcript 沒長)——第 1 次"
fire s-dup "$tr" 0 "⑬ 同上第 2 次:不重複計數"
fire s-dup "$tr" 0 "⑭ 同上第 3 次:仍不擋(重複呼叫不該累積成擋下)"
fire s-bad "$TMP/does-not-exist.jsonl" 0 "⑮ 讀不到 transcript ⇒ 放行(不亂擋)"
echo "── B 群:該擋 ──────────────────────────────────────────────"
tr=$(new_session s-idle)
for i in 1 2 3; do
append_turn "$tr" "${DRY[@]}"; fire s-idle "$tr" 0 "⑯ 乾回合 $i4"
done
append_turn "$tr" "${DRY[@]}"; fire s-idle "$tr" 2 "⑰ 乾回合 44 ⇒ **擋下**"
tr=$(new_session s-idle2)
for i in 1 2 3; do
append_turn "$tr" 'U:Read' 'U:Read' 'U:Read'; fire s-idle2 "$tr" 0 "⑱ 純唯讀乾回合 $i4"
done
append_turn "$tr" 'U:Bash' 'U:Bash' 'U:Read'; fire s-idle2 "$tr" 2 "⑲ 唯讀 Bash 也算乾回合 ⇒ **擋下**"
echo "── C 群:響過就退讓(訊息裡承諾的出路真的存在)───────────────"
# 接續 s-idle:它剛在 ⑰ 被擋過 ⇒ 計數歸零、門檻 4→8
tr="$TMP/s-idle.jsonl"
for i in 1 2 3 4 5 6 7; do
append_turn "$tr" "${DRY[@]}"
fire s-idle "$tr" 0 "⑳ 擋過之後第 $i 個乾回合——門檻已加倍(4→8),不該再響"
done
append_turn "$tr" "${DRY[@]}"
fire s-idle "$tr" 2 "㉑ 第 8 個乾回合 ⇒ 再擋一次(門檻 8)"
# 量測用的旋鈕要真的有效(檔頭那份實測靠它重跑)
export MAINLINE_IDLE_THRESHOLD=2
tr=$(new_session s-th)
append_turn "$tr" "${DRY[@]}"; fire s-th "$tr" 0 "㉒ MAINLINE_IDLE_THRESHOLD=2:第 1 個乾回合"
append_turn "$tr" "${DRY[@]}"; fire s-th "$tr" 2 "㉓ 同上第 2 個 ⇒ 擋(證明門檻可被量測腳本改)"
unset MAINLINE_IDLE_THRESHOLD
echo "── D 群:工作區狀態變了就算推進(heredoc/sed 改檔吃得到)──────"
REPO="$TMP/repo"; mkdir -p "$REPO"
git -C "$REPO" init -q 2>/dev/null
git -C "$REPO" config user.email t@t; git -C "$REPO" config user.name t
echo a > "$REPO/a.txt"; git -C "$REPO" add -A; git -C "$REPO" commit -qm init
PROJ_OVERRIDE="$REPO"
tr=$(new_session s-git)
for i in 1 2 3; do
append_turn "$tr" "${DRY[@]}"; fire s-git "$tr" 0 "㉔ 乾回合 $i4(工作區沒動)"
done
# 用 heredocsed 改檔——工具名是 Bash、指令不在白名單,只有狀態指紋看得出來
echo "changed by heredoc" >> "$REPO/a.txt"
append_turn "$tr" "${DRY[@]}"; fire s-git "$tr" 0 "㉕ 第 4 個乾回合,但工作區變了 ⇒ 算推進,不擋"
for i in 1 2 3; do
append_turn "$tr" "${DRY[@]}"; fire s-git "$tr" 0 "㉖ 之後乾回合 $i4(證明上一格真的歸零過)"
done
append_turn "$tr" "${DRY[@]}"; fire s-git "$tr" 2 "㉗ 乾回合 44 ⇒ **擋下**"
PROJ_OVERRIDE=""
echo
printf '結果:%s 通過 / %s 失敗(共 %s 條)\n' "$PASS" "$FAIL" "$N"
[ "$FAIL" -eq 0 ] || exit 1
@@ -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 ]
+220
View File
@@ -0,0 +1,220 @@
#!/usr/bin/env bash
# pr-verdict-guard.sh 的迴歸測試(inkstone/ISEP#81
#
# 兩個方向都要有證據:
# A 群「不該擋」——沒有 PR/有人指派/掛 Human/draft/不在 org/剛開出來/
# 這回合動過/這回合指名過/已有 REQUEST_CHANGES/已被別的閘擋過/
# merge 完分支已刪/fixture 壞掉
# B 群「該擋」 ——沒人管的 open PR、放著不管一回合、merge 了但分支還在、
# 以及**票上那三個躺了兩週的真跡**
# C 群「訊息承諾的出路真的走得通」——擋完歸零門檻加倍、指派下去立刻閉嘴
#
# 🔴 全程離線(PR_VERDICT_FIXTURE),不打 Gitea、不留任何測試票/測試 PR。
# 狀態檔走 PR_VERDICT_STATE_DIR,不碰 /tmp 的正式檔。
#
# 用法:hooks/tests/pr-verdict-guard.test.sh [hooks/pr-verdict-guard.sh 的路徑]
set -u
HOOK="${1:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/pr-verdict-guard.sh}"
TMP=$(mktemp -d)
trap 'rm -rf "$TMP"' EXIT
PASS=0; FAIL=0; N=0
NOW=$(date -u +%Y-%m-%dT%H:%M:%SZ)
OLD="2026-08-13T04:34:01Z"
# fixture <檔名> <PR 規格...>
# PR 規格:ref|updated|created|assignee|labels|draft
mkfx() { python3 - "$@" <<'PY'
import json, sys, os
path, specs = sys.argv[1], sys.argv[2:]
extra = {}
if os.path.exists(path):
extra = json.load(open(path))
pulls = []
for s in specs:
ref, upd, crt, asg, lbl, draft = (s.split("|") + [""] * 6)[:6]
full, num = ref.split("#")
pulls.append({
"number": int(num),
"title": "測試 PR " + ref,
"created_at": crt, "updated_at": upd,
"repository": {"full_name": full, "id": 1},
"assignees": [{"login": a} for a in asg.split(",") if a],
"labels": [{"name": n} for n in lbl.split(",") if n],
"pull_request": {"merged": False, "draft": draft == "draft",
"html_url": "https://git.uncle6.me/%s/pulls/%s" % (full, num)},
})
extra["pulls"] = pulls
json.dump(extra, open(path, "w"), ensure_ascii=False)
PY
}
# 加一筆「這個 PR 已經被 merge,head 分支叫 X」的細節,以及分支還在不在
mkdetail() { python3 - "$@" <<'PY'
import json, sys, os
path, ref, branch, alive = sys.argv[1:5]
d = json.load(open(path)) if os.path.exists(path) else {}
full = ref.split("#")[0]
d.setdefault("detail", {})[ref] = {
"merged": True, "head": {"ref": branch, "repo": {"full_name": full}}}
d.setdefault("branches", {})["%s:%s" % (full, branch)] = (alive == "alive")
json.dump(d, open(path, "w"), ensure_ascii=False)
PY
}
mkreview() { python3 - "$@" <<'PY'
import json, sys, os
path, ref, state = sys.argv[1:4]
d = json.load(open(path)) if os.path.exists(path) else {}
d.setdefault("reviews", {})[ref] = [{"state": state}]
json.dump(d, open(path, "w"), ensure_ascii=False)
PY
}
# 造一份「這一回合叫過這些工具、輸入長這樣」的 transcript
mktranscript() { python3 - "$@" <<'PY'
import json, sys
path = sys.argv[1]
with open(path, "w") as f:
f.write(json.dumps({"type": "user",
"message": {"content": [{"type": "text", "text": "繼續"}]}}) + "\n")
blocks = [{"type": "tool_use", "name": "Bash", "id": "t",
"input": {"command": c}} for c in sys.argv[2:]]
f.write(json.dumps({"type": "assistant", "message": {"content": blocks}},
ensure_ascii=False) + "\n")
PY
}
# fire <session> <fixture> <期望 exit> <說明> [transcript] [extra-json] [必須出現的字串]
fire() {
sid="$1"; fx="$2"; want="$3"; desc="$4"; tr="${5:-/nonexistent}"; extra="${6:-}"; must="${7:-}"
N=$((N+1))
payload=$(printf '{"session_id":"%s","transcript_path":"%s"%s}' "$sid" "$tr" "$extra")
out=$(printf '%s' "$payload" | PR_VERDICT_FIXTURE="$fx" PR_VERDICT_STATE_DIR="$TMP" \
CLAUDE_PROJECT_DIR="$TMP" bash "$HOOK" 2>&1 >/dev/null); rc=$?
ok=1
[ "$rc" -eq "$want" ] || ok=0
if [ -n "$must" ] && ! printf '%s' "$out" | grep -qF "$must"; then ok=0; fi
if [ "$ok" -eq 1 ]; then
printf ' ✅ %s\n' "$desc"; PASS=$((PASS+1))
else
printf ' ❌ %s —— 期望 exit=%s%s,實得 exit=%s\n' "$desc" "$want" \
"${must:+ 且訊息含「$must}" "$rc"
printf '%s\n' "$out" | sed -n '1,8p' | sed 's/^/ /'
FAIL=$((FAIL+1))
fi
}
echo "── A 群:不該擋 ────────────────────────────────────────────"
FX="$TMP/empty.json"; mkfx "$FX"
fire s-empty "$FX" 0 "① 一個 open PR 都沒有 ⇒ 放行"
FX="$TMP/assigned.json"; mkfx "$FX" "inkstone/Arcrun#104|$OLD|$OLD|Leo||"
fire s-asg "$FX" 0 "② 有人被指派(棒子在他手上=在等,不是忘了)⇒ 不點名"
FX="$TMP/human.json"; mkfx "$FX" "inkstone/Arcrun#104|$OLD|$OLD||Human,p/high|"
fire s-hum "$FX" 0 "③ 掛了 Human(等 leo 親手做)⇒ 不點名"
FX="$TMP/draft.json"; mkfx "$FX" "inkstone/Arcrun#104|$OLD|$OLD|||draft"
fire s-drf "$FX" 0 "④ draft(作者自己說還沒好)⇒ 不點名"
FX="$TMP/otherorg.json"; mkfx "$FX" "Leo/Arcrun#104|$OLD|$OLD|||"
fire s-org "$FX" 0 "⑤ 不在 inkstone org(舊 org 的鏡像)⇒ 不點名"
FX="$TMP/review.json"; mkfx "$FX" "inkstone/Arcrun#104|$OLD|$OLD|||"
mkreview "$FX" "inkstone/Arcrun#104" "REQUEST_CHANGES"
fire s-rev "$FX" 0 "⑥ 已經留了 REQUEST_CHANGES(結論給過了,球在對方腳下)⇒ 不點名"
FX="$TMP/active.json"; mkfx "$FX" "inkstone/Arcrun#104|$OLD|$OLD|||"
fire s-act "$FX" 0 "⑦ stop_hook_active(已被別的 Stop 閘擋過)⇒ 不疊擋" \
/nonexistent ',"stop_hook_active":true'
fire s-bad "$TMP/does-not-exist.json" 0 "⑧ fixture 讀不到 ⇒ 放行(Stop 閘不准把 session 鎖死)"
echo "── B 群:該擋 ──────────────────────────────────────────────"
# 票上那三個真跡(真實 ref、真實開票日)
FX="$TMP/real.json"
mkfx "$FX" \
"inkstone/arcrun-rag#91|$OLD|$OLD|||" \
"inkstone/Arcrun#116|2026-08-13T05:24:51Z|2026-08-13T05:24:51Z|||" \
"inkstone/Arcrun#104|2026-08-12T08:51:06Z|2026-08-12T08:51:06Z|||"
fire s-real "$FX" 2 "⑨ 真跡重演:三個躺了兩週的 PR ⇒ **擋下**" \
/nonexistent '' 'inkstone/arcrun-rag#91'
FX2="$TMP/real2.json"; cp "$FX" "$FX2"
fire s-real2 "$FX2" 2 "⑩ 同上,訊息要點名 inkstone/Arcrun#116" \
/nonexistent '' 'inkstone/Arcrun#116'
FX3="$TMP/real3.json"; cp "$FX" "$FX3"
fire s-real3 "$FX3" 2 "⑪ 同上,訊息要點名 inkstone/Arcrun#104" \
/nonexistent '' 'inkstone/Arcrun#104'
echo "── C 群:驗收條件 1–3(開著不管 → 擋;merge → 放行;分支要不見)──"
# 驗收 1:一個新 PR 出現(不在它誕生那回合開罵),下一回合放著不管 ⇒ 擋
FX="$TMP/ac.json"; mkfx "$FX"
fire s-ac "$FX" 0 "⑫ 基準回合:目前沒有 PR"
mkfx "$FX" "inkstone/ISEP#999|$NOW|$NOW|||"
fire s-ac "$FX" 0 "⑬ PR 剛開出來(上一回合還不存在)⇒ 不在它誕生的回合開罵"
fire s-ac "$FX" 2 "⑭ **驗收 1**:開著放著不管,收工 ⇒ 擋下,且點名它" \
/nonexistent '' 'inkstone/ISEP#999'
# 驗收 23merge 掉(PR 從 open 清單消失)且分支已刪 ⇒ 放行
mkfx "$FX" # #999 從 open 清單消失
mkdetail "$FX" "inkstone/ISEP#999" "feat/x" "gone"
fire s-ac "$FX" 0 "⑮ **驗收 23**:merge 掉且分支已不存在 ⇒ 放行"
# 驗收 3 的反面:merge 了但分支還在 = 結論只給了一半 ⇒ 擋
FX="$TMP/left.json"; mkfx "$FX" "inkstone/ISEP#998|$NOW|$NOW|||"
fire s-left "$FX" 2 "⑯ 先讓 #998 進到快照(沒人管 ⇒ 擋一次)" \
/nonexistent '' 'inkstone/ISEP#998'
mkfx "$FX" # 消失了
mkdetail "$FX" "inkstone/ISEP#998" "feat/y" "alive"
fire s-left "$FX" 2 "⑰ **驗收 3**merge 了但分支 feat/y 還在 ⇒ 擋下並點名分支" \
/nonexistent '' 'feat/y'
mkfx "$FX"; mkdetail "$FX" "inkstone/ISEP#998" "feat/y" "gone"
fire s-left "$FX" 0 "⑱ 分支刪掉之後 ⇒ 放行(清單會自己清乾淨)"
echo "── D 群:這一回合碰過它就不點名(不讀任何一句話)──────────────"
FX="$TMP/touch.json"; mkfx "$FX" "inkstone/ISEP#997|$OLD|$OLD|||"
fire s-t1 "$FX" 2 "⑲ 沒人管 ⇒ 擋一次(建立基準)"
# updated_at 動了 外部系統的事實(留言/推 commit/改標籤都會動它)
mkfx "$FX" "inkstone/ISEP#997|$NOW|$OLD|||"
for i in 1 2 3 4 5; do
fire s-t1 "$FX" 0 "⑳ updated_at 變了(這回合真的動過它)第 $i 次 ⇒ 不點名"
mkfx "$FX" "inkstone/ISEP#997|$NOW-$i|$OLD|||"
done
FX="$TMP/mention.json"; mkfx "$FX" "inkstone/ISEP#996|$OLD|$OLD|||"
TR="$TMP/tr.jsonl"
mktranscript "$TR" "python3 scripts/ticket say inkstone/ISEP#996 -F /tmp/x.md"
fire s-t2 "$FX" 0 "㉑ 這回合的 tool call 指名了 inkstone/ISEP#996(結論寫在票上)⇒ 不點名" "$TR"
for i in 1 2 3 4; do
fire s-t2 "$FX" 0 "㉒ 同上第 $i 次——指名過就不該累積成擋下" "$TR"
done
mktranscript "$TR" "python3 scripts/ticket say inkstone/ISEP#111 -F /tmp/x.md"
fire s-t2 "$FX" 2 "㉓ 換成指名**別的**票號 ⇒ 這個 PR 仍然沒被碰 ⇒ 擋"
echo "── E 群:響過就退讓(訊息裡承諾的出路真的存在)────────────────"
FX="$TMP/backoff.json"; mkfx "$FX" "inkstone/ISEP#995|$OLD|$OLD|||"
fire s-bo "$FX" 2 "㉔ 第 1 次 ⇒ 擋(門檻 1"
for i in 1 2 3; do
fire s-bo "$FX" 0 "㉕ 擋過之後第 $i 個回合——門檻已加倍(1→4),不該再響"
done
fire s-bo "$FX" 2 "㉖ 第 4 個回合 ⇒ 再擋一次(門檻 4)"
for i in 1 2 3 4 5 6 7; do
fire s-bo "$FX" 0 "㉗ 再擋過之後第 $i 個回合——門檻 4→8,不該響"
done
fire s-bo "$FX" 2 "㉘ 第 8 個回合 ⇒ 再擋(證明退讓是加倍不是無限)"
# 訊息說「把它指派給那個人,本閘立刻不再點名它」——這條出路要真的走得通
mkfx "$FX" "inkstone/ISEP#995|$OLD|$OLD|claude-code||"
for i in 1 2 3 4 5 6 7 8 9; do
fire s-bo "$FX" 0 "㉙ 指派下去之後第 $i 個回合 ⇒ 永遠不再點名(訊息承諾的出路成立)"
done
echo
printf '結果:%s 通過 / %s 失敗(共 %s 條)\n' "$PASS" "$FAIL" "$N"
[ "$FAIL" -eq 0 ] || exit 1
+109
View File
@@ -0,0 +1,109 @@
#!/usr/bin/env bash
# prod-write-guard.sh 的迴歸測試。
# 🔴 必須寫成檔案跑:這道閘會擋掉「含有它要擋的字串」的指令本身
# ⇒ 直接在 Bash 裡打測試,會被自己擋下(2026-08-12 實撞)。
HOOK="$1"
mk(){ python3 -c "import json,sys;print(json.dumps({'tool_name':'Bash','tool_input':{'command':sys.argv[1]}}))" "$1"; }
PASS=0; FAIL=0
t(){ # t <期望 block|pass> <說明> <指令>
mk "$3" | "$HOOK" >/dev/null 2>&1; rc=$?
got=$([ $rc -eq 2 ] && echo block || echo pass)
if [ "$got" = "$1" ]; then echo "$2"; PASS=$((PASS+1))
else echo "$2 —— 期望 $1,實得 $got"; FAIL=$((FAIL+1)); fi
}
H='https://arcrun-kbdb.leo21c.workers.dev'
DASH_D='-d'
echo "── 該放行(唯讀)──"
t pass "純 GET" "curl -s $H/templates"
t pass "GET + tr -d(本次實撞的誤攔)" "ACC=\$(grep -oE '^X=.' f | tr $DASH_D '\\r'); curl -s $H/templates"
t pass "GET + cut -d=" "A=\$(cut ${DASH_D}= -f2 f); curl -s $H/templates"
t pass "GET + sort -d / xargs -d" "ls | sort $DASH_D | xargs $DASH_D '\\n' echo; curl -s $H/entries"
t pass "GET 帶 Bearer 標頭" "curl -s -H 'Authorization: Bearer xxx' $H/templates?limit=1"
t pass "打 staging" "curl -X POST https://arcrun-rag-installer-staging.workers.dev/x $DASH_D '{}'"
t pass "打 youlin 測試場" "curl -X POST https://arcrun-cypher-executor.youlin-hsieh-dev.workers.dev/x $DASH_D '{}'"
t pass "打 Gitea(不是實例)" "curl -X POST https://git.uncle6.me/api/v1/repos/Leo/x/issues $DASH_D '{}'"
echo "── 該擋(寫入)──"
t block "POST 到實例" "curl -X POST $H/entries $DASH_D '{}'"
t block "PUT 到實例" "curl -X PUT $H/entries/1 --data '{}'"
t block "DELETE 到實例" "curl -X DELETE $H/entries/1"
t block "POST 且同時有 tr -d(剪字後仍該擋)" "cat f | tr $DASH_D '\\r' | curl -X POST $H/webhooks/named $DASH_D @-"
t block "wrangler deploy" "npx wrangler deploy --name arcrun-kbdb"
# ── 2026-08-13:「執行它」vs「談論它」───────────────────────────────
# 實撞:把 #108 的驗收證據留言到 Gitea,正文引述了那個指令名 ⇒ 一則留言被當成部署擋掉。
# 這批測資的挑法是「本 repo 現行真的會出現的形狀」,不是我自己挑好抓的壞例子
# ——PR #87 那道假綠閘就是敗在後者(14 條全過,卻漏掉 repo 實際在用的呼叫寫法)。
ACR_U='acr up''date' # 拆開寫:否則這支測試檔自己會被閘擋住(它也是一條 Bash 指令)
ACR_P='acr pu''sh'
WR_D='wrangler dep''loy'
echo "── 該放行(只是談論,不是執行)──"
t pass "heredoc 正文引述(本次實撞)" \
"B=\$(cat <<'EOF'
更新指令 \`$ACR_U --force\` 印出了 ARCRUN_NAMESPACE
EOF
); curl -X POST https://git.uncle6.me/api/v1/repos/Leo/Arcrun/issues/108/comments $DASH_D \"\$B\""
t pass "commit 訊息裡提到" "git commit -m '修好 $ACR_U 的命名空間注入'"
t pass "markdown code span(反引號)" "printf '%s' '看 \`$ACR_U\` 的輸出'"
t pass "grep 它的名字" "grep -n '$ACR_P' .claude/hooks/prod-write-guard.sh"
t pass "文章裡提到 $WR_D" "printf '%s' '# 為什麼 $WR_D 要擋'"
t pass "acr 唯讀子指令" "acr status --json"
t pass "acr whoami" "acr whoami"
echo "── 該擋(真的在指令位置執行)──"
t block "行首" "$ACR_U --force"
t block "&& 之後" "cd /x && $ACR_U"
t block "; 之後" "echo hi; $ACR_P workflow.yaml"
t block "管線之後" "cat x.yaml | $ACR_P -"
t block "bash -c 引號裡" "bash -c \"$ACR_U --force\""
t block "\$( ) 裡" "OUT=\$($ACR_U 2>&1)"
t block "npx 前綴" "npx $WR_D --name arcrun-kbdb"
t block "sudo 前綴" "sudo $ACR_U"
t block "heredoc 之後的真指令(剝內文不能連指令一起剝)" \
"cat <<'EOF' > note.md
只是筆記
EOF
$ACR_U --force"
# ══ inkstone/ISEP#63 → comment 5106:兩個卡點 ═══════════════════════════
#
# 卡點一:發一則 Telegram 給 leo`notify_leo`)跟「部署一個工作流上線」在本閘眼裡
# 長得一模一樣——兩者都是帶 body 的寫入型請求打到同一台實例。
# 判準改成看**打的是哪一個 named webhook**(路徑形狀+名字),
# 而放行的範圍只有一個名字,家族其他成員照擋。
# 卡點二:連「把這個卡點寫進票裡」都被同一支閘擋(同款第八次)——
# 內文引用了主機名與寫入旗標,而閘比對的是指令文字。
EXE="https://arcrun-cypher-executor.leo21c.wo""rkers.dev"
NAMED="$EXE/webhooks/named"
echo "── 卡點一:通知放得過,家族其他成員照擋 ──"
t pass "發一則 Telegram 給 leonotify_leo trigger" \
"curl -s -X POST $NAMED/leo/notify_leo/trigger $DASH_D '{\"text\":\"[總管] 進度\"}'"
t pass "同上,網址後面帶斜線" \
"curl -s -X POST $NAMED/leo/notify_leo/trigger/ --data '{\"text\":\"[總管] x\"}'"
t block "別的 named webhook(會動線上狀態)不在白名單裡" \
"curl -X POST $NAMED/leo/ship_refresh_cdn/trigger $DASH_D '{}'"
t block "部署端點(沒有名字、沒有 /trigger" \
"curl -X POST $NAMED $DASH_D @workflow.json"
t block "通知混著一筆真的寫入 ⇒ 整條照擋(白名單要每一個都符合)" \
"curl -X POST $NAMED/leo/notify_leo/trigger $DASH_D '{}' && curl -X POST $EXE/entries $DASH_D '{}'"
t block "名字只是前綴相同(notify_leo_deploy)不算通知" \
"curl -X POST $NAMED/leo/notify_leo_deploy/trigger $DASH_D '{}'"
echo "── 卡點二:談論它(內文引用)不是執行它 ──"
t pass "把卡點貼回票上,內文引用了實例網址與寫入旗標(本次實撞)" \
"B=\$(cat <<'EOF'
閘擋下來了:\`curl -X POST $NAMED\` 被當成部署。
EOF
); curl -X POST https://git.uncle6.me/api/v1/repos/inkstone/ISEP/issues/63/comments $DASH_D \"\$B\""
t block "真的在部署、只是把 payload 放進內文 ⇒ 起始行仍看得到,照擋" \
"curl -X POST $NAMED $DASH_D @- <<'EOF'
{\"name\":\"x\"}
EOF"
echo
echo "結果:通過 $PASS 失敗 $FAIL"
[ $FAIL -eq 0 ] || exit 1
+99
View File
@@ -0,0 +1,99 @@
#!/usr/bin/env bash
# 身份欄的兩道閘(inkstone/ISEP#30 comment 4325:「subagent 回覆時要表明身份」)。
#
# 貼留言到票上有**兩條路**,所以測兩道閘:
# 正門 scripts/ticket say / decide —— 檢查在打任何 API 之前,所以本測試離線跑得動
# 側門 直接打 Gitea API —— hooks/reply-identity-guard.sh
#
# **離線、不打網路。** 正門的案例全部在 `die()` 之前就結束,不會真的送出留言。
#
# 用法:hooks/tests/reply-identity.test.sh
set -u
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT="$(cd "$HERE/../.." && pwd)"
HOOK="$ROOT/hooks/reply-identity-guard.sh"
TICKET="$ROOT/scripts/ticket"
TMP=$(mktemp -d); trap 'rm -rf "$TMP"' EXIT
PASS=0; FAIL=0; N=0
ok() { printf ' ✅ %s\n' "$1"; PASS=$((PASS+1)); N=$((N+1)); }
bad() { printf ' ❌ %s\n' "$1"; shift; printf '%s\n' "$*" | sed -n '1,6p' | sed 's/^/ /'
FAIL=$((FAIL+1)); N=$((N+1)); }
# ── 側門:hooks/reply-identity-guard.sh ─────────────────────────────────
h() { # h <期望 exit> <說明> <command 字串>
want="$1"; desc="$2"; cmd="$3"
body=$(python3 - "$cmd" <<'PY'
import json, sys
print(json.dumps({"tool_name": "Bash", "tool_input": {"command": sys.argv[1]}},
ensure_ascii=False))
PY
)
err=$(printf '%s' "$body" | bash "$HOOK" 2>&1 >/dev/null); rc=$?
if [ "$rc" -eq "$want" ]; then ok "$desc"; else
bad "$desc —— 期望 exit=$want,實得 exit=$rc" "$err"; fi
}
echo "── 側門(直接打 Gitea API)─────────────────────────────────────"
h 2 "① POST 留言但沒有【身份】→ 擋" \
'curl -X POST -H "Authorization: token $T" -d @body.json https://git.uncle6.me/api/v1/repos/inkstone/ISEP/issues/30/comments'
h 0 "② POST 留言且內文帶【身份】→ 放行" \
'curl -X POST -d "{\"body\":\"【身份】subagentinkstone/ISEPfeat/x\n做完了\"}" https://git.uncle6.me/api/v1/repos/inkstone/ISEP/issues/30/comments'
h 0 "③ 純讀取(GET 撈留言)→ 放行,這是最常做的動作,誤攔它比漏擋更糟" \
'curl -s -H "Authorization: token $T" https://git.uncle6.me/api/v1/repos/inkstone/ISEP/issues/30/comments?limit=60'
h 0 "④ 走正門 scripts/ticket → 放行(正門有自己的閘,兩支同時擋會互相打架)" \
'scripts/ticket say inkstone/ISEP#30 -F /tmp/body.md'
h 0 "⑤ 開新票的端點(不帶票號)→ 不是本閘的地盤" \
'curl -X POST -d @t.json https://git.uncle6.me/api/v1/repos/inkstone/ISEP/issues'
h 0 "⑥ 逃生口 reply-identity-ok → 放行(留在指令歷史上)" \
'curl -X POST -d @body.json https://git.uncle6.me/api/v1/repos/inkstone/ISEP/issues/30/comments # reply-identity-ok'
h 0 "⑦ 只是在講這件事(把端點寫進文件)而沒有 POST → 不擋" \
'echo "留言端點是 issues/30/comments" >> docs/notes.md'
echo
echo "── 正門(scripts/ticket)───────────────────────────────────────"
g() { # g <期望 exit> <說明> <內文>
want="$1"; desc="$2"; content="$3"
printf '%s' "$content" > "$TMP/body.md"
err=$(python3 "$TICKET" say inkstone/ISEP#30 -F "$TMP/body.md" 2>&1 >/dev/null); rc=$?
if [ "$rc" -eq "$want" ]; then ok "$desc"; else
bad "$desc —— 期望 exit=$want,實得 exit=$rc" "$err"; fi
}
g 2 "⑧ 內文沒有身份欄 → 在打 API 之前就擋(所以這一格離線也測得動)" \
'做完了,分支是 feat/x'
g 2 "⑨ 有【身份】但角色不在三選一之內 → 擋" \
'【身份】機器人/inkstone/ISEPfeat/x
做完了'
g 2 "⑩ 身份欄不在第一行 → 擋(要一眼看得到,不是藏在中間)" \
'做完了
【身份】subagentinkstone/ISEPfeat/x'
# ⑪ 合規的內文要能通過身份檢查——它會往下走到打 API,
# 離線環境打不出去所以 exit 非 0;判準改成「錯誤訊息不是身份欄那一段」。
printf '%s' '【身份】subagentinkstone/ISEPfeat/x
做完了' > "$TMP/ok.md"
err=$(python3 "$TICKET" say inkstone/ISEP#30 -F "$TMP/ok.md" 2>&1 >/dev/null || true)
if printf '%s' "$err" | grep -q '第一行要表明身份'; then
bad "⑪ 合規的內文被身份閘擋掉了(誤攔)" "$err"
else
ok "⑪ 合規的內文通過身份閘(之後成不成功是網路的事,不是本閘的事)"
fi
echo
echo "══ $PASS/$N 通過${FAIL:+$FAIL 個失敗} ══"
[ "$FAIL" -eq 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
+90
View File
@@ -0,0 +1,90 @@
#!/usr/bin/env bash
# sdd-guard.sh 的迴歸測試(inkstone/InkStoneCo#22)。
#
# 涵蓋兩個洞:
# 洞 A — scratchpad/任何不在 git repo 裡的暫存檔被誤判成「code 變動」而擋下。
# 洞 B — 真的解析失敗(fail-closed)時,訊息裡印出內部 sentinel `/nonexistent`。
# 以及既有行為不能退步:單一活性違反仍擋、恰好 1 份 active 仍放行、
# 「dirname 還沒建立」不可被誤判成「不在 repo 裡」(新邏輯自己可能引入的 fail-open 陷阱)。
#
# 用法:hooks/tests/sdd-guard.test.sh [hooks/sdd-guard.sh 的路徑]
# 🔴 全程在一個乾淨的 TMP 底下建假 repo,跑完自己清;不動任何真 repo。
set -u
HOOK="${1:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/sdd-guard.sh}"
TMP=$(mktemp -d)
trap 'rm -rf "$TMP"' EXIT
PASS=0; FAIL=0
mk() { # mk <file_path> -> JSON on stdout
python3 -c "import json,sys;print(json.dumps({'tool_name':'Write','tool_input':{'file_path':sys.argv[1],'content':'x'}}))" "$1"
}
t() { # t <期望 exit code> <說明> <file_path> [額外檢查關鍵字]
local want="$1" desc="$2" path="$3" must_not_contain="${4:-}"
local out rc
out=$(mk "$path" | "$HOOK" 2>&1)
rc=$?
local ok=1
[ "$rc" -eq "$want" ] || ok=0
if [ -n "$must_not_contain" ] && printf '%s' "$out" | grep -qF "$must_not_contain"; then
ok=0
fi
if [ "$ok" -eq 1 ]; then
echo "$desc"; PASS=$((PASS+1))
else
echo "$desc —— 期望 exit=$want,實得 exit=$rc"
[ -n "$must_not_contain" ] && echo " (且訊息不該含「$must_not_contain」)"
echo " 輸出:$out" | head -3
FAIL=$((FAIL+1))
fi
}
# ── 準備:一個真的沒有 3-specs 的 git repo(模擬「真的解析失敗」)──
REPO_NO_SDD="$TMP/repo-no-sdd"
mkdir -p "$REPO_NO_SDD/src"
git init -q "$REPO_NO_SDD"
# ── 準備:一個有 1 份 active SDD 的 git repo ──
REPO_ONE_ACTIVE="$TMP/repo-one-active"
mkdir -p "$REPO_ONE_ACTIVE/system-dev/docs/3-specs/x" "$REPO_ONE_ACTIVE/src"
git init -q "$REPO_ONE_ACTIVE"
printf -- '---\nstatus: active\n---\n# X\n' > "$REPO_ONE_ACTIVE/system-dev/docs/3-specs/x/design.md"
# ── 準備:一個有 2 份 active SDD 的 git repo(單一活性違反)──
REPO_MULTI="$TMP/repo-multi-active"
mkdir -p "$REPO_MULTI/system-dev/docs/3-specs/a" "$REPO_MULTI/system-dev/docs/3-specs/b" "$REPO_MULTI/src"
git init -q "$REPO_MULTI"
printf -- '---\nstatus: active\n---\n# A\n' > "$REPO_MULTI/system-dev/docs/3-specs/a/design.md"
printf -- '---\nstatus: active\n---\n# B\n' > "$REPO_MULTI/system-dev/docs/3-specs/b/design.md"
# ── 準備:scratchpad 風格的暫存區(不在任何 git repo 裡)──
SCRATCH="$TMP/private/tmp/claude-fake-session/scratchpad"
mkdir -p "$SCRATCH"
# 讓 $_rootCLAUDE_PROJECT_DIR 或 pwd)刻意跟這些假 repo 對不上,
# 逼所有案例都走「專案外的路徑」那個分支——這正是 #22 實撞的情境(cwd 跑歪/
# CLAUDE_PROJECT_DIR 沒設,路徑不落在 $_root 底下)。
unset CLAUDE_PROJECT_DIR
cd "$TMP"
echo "── 洞 A:不在任何 git repo 裡的路徑,SDD 管不到,該放行 ──"
t 0 "scratchpad 暫存 .py(本票原始事故)" "$SCRATCH/fix-project-settings.py"
t 0 "scratchpad 巢狀更深" "$SCRATCH/nested/deep/tmp.js"
echo "── 洞 B:真的解析失敗(repo 存在但沒有 3-specs)仍要 fail-closed,但訊息不准洩漏內部假路徑 ──"
t 2 "真 repo 沒有 3-specs → 仍擋" "$REPO_NO_SDD/src/foo.py"
t 2 "上面那筆的訊息不准出現 /nonexistent" "$REPO_NO_SDD/src/foo.py" "/nonexistent"
echo "── fail-open 陷阱:新檔案要建在還沒建立的子目錄下,不可被誤判成「不在 repo 裡」──"
t 2 "真 repo、目標子目錄還沒建立 → 仍擋(不能因為 dirname 不存在就放行)" "$REPO_NO_SDD/brand-new/not-yet/bar.py"
echo "── 既有行為不能退步 ──"
t 0 "只有 1 份 active SDD,改 code 檔 → 放行" "$REPO_ONE_ACTIVE/src/x.py"
t 2 "2 份 active SDD(單一活性違反)→ 擋" "$REPO_MULTI/src/x.py"
t 0 "改 .md 文件(非 code 檔)→ 放行,即使找不到 3-specs" "$REPO_NO_SDD/README.md"
echo
echo "結果:通過 $PASS 失敗 $FAIL"
[ "$FAIL" -eq 0 ] || exit 1
+254
View File
@@ -0,0 +1,254 @@
#!/usr/bin/env bash
# search-is-not-proof-guard.sh kbdb-evidence-stamp.sh 的迴歸測試
# inkstone/ISEP#30 → comment 4879
#
# **離線、不打網路、不碰真庫、每次結果一樣。** 兩支都是純結構判斷,沒有語意判官。
#
# 🔴 這支測試的重點在 A 群,不在 B 群。
# ISEP 的第一級缺陷是**誤攔**——leo 2026-08-17 實證:在文字層封路的閘
# 8 次誤攔、0 次正確攔截。所以「該擋有擋到」只是及格,
# **「不該擋的一個都沒擋」才是這支閘能不能活下來的條件。**
#
# 用法:hooks/tests/search-is-not-proof-guard.test.sh [guard 路徑] [stamp 路徑]
set -u
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
HOOKS="$(cd "$HERE/.." && pwd)"
GUARD="${1:-$HOOKS/search-is-not-proof-guard.sh}"
STAMP="${2:-$HOOKS/kbdb-evidence-stamp.sh}"
SID="S-SINP-TEST"
SEARCH="/tmp/.kbdb-evidence-search-$SID"
WALKED="/tmp/.kbdb-evidence-walked-$SID"
WARNED="/tmp/.search-is-not-proof-warned-$SID"
TMPD="$(mktemp -d)"
trap 'rm -rf "$TMPD"; rm -f "$SEARCH" "$WALKED" "$WARNED"' EXIT
PASS=0; FAIL=0; N=0
# 真跡:Arcrun#167 comment 4865 裡真的被貼出去的那兩個值
REAL_ID="e_b77f23a1-b467-4aa4-883c-58a051ab8e66"
REAL_SRC="kb://小果被AFTEE詐貸.pdf#8"
clean() { rm -f "$SEARCH" "$WALKED" "$WARNED"; }
# seen <search|walked> <值...> —— 直接寫登記檔(模擬 stamp 已經跑過)
seen() {
f="/tmp/.kbdb-evidence-$1-$SID"; shift
for v in "$@"; do printf '%s\n' "$v" >> "$f"; done
}
# bash_payload <command>
bash_payload() {
python3 - "$SID" "$1" <<'PY'
import json, sys
print(json.dumps({"session_id": sys.argv[1], "hook_event_name": "PreToolUse",
"tool_name": "Bash", "tool_input": {"command": sys.argv[2]}},
ensure_ascii=False))
PY
}
# agent_payload <prompt>
agent_payload() {
python3 - "$SID" "$1" <<'PY'
import json, sys
print(json.dumps({"session_id": sys.argv[1], "hook_event_name": "PreToolUse",
"tool_name": "Agent", "tool_input": {"prompt": sys.argv[2]}},
ensure_ascii=False))
PY
}
# post_payload <tool_name> <tool_response JSON 字串> [有沒有 tool_response 欄位:yes|no]
post_payload() {
python3 - "$SID" "$1" "$2" "${3:-yes}" <<'PY'
import json, sys
sid, tool, resp, has = sys.argv[1:5]
d = {"session_id": sid, "hook_event_name": "PostToolUse",
"tool_name": tool, "tool_input": {}}
if has == "yes":
d["tool_response"] = json.loads(resp)
print(json.dumps(d, ensure_ascii=False))
PY
}
t() {
want="$1"; desc="$2"; body="$3"; hook="${4:-$GUARD}"
N=$((N+1))
err=$(printf '%s' "$body" | bash "$hook" 2>&1 >/dev/null); rc=$?
if [ "$rc" -eq "$want" ]; then
printf ' ✅ %s\n' "$desc"; PASS=$((PASS+1))
else
printf ' ❌ %s —— 期望 exit=%s,實得 exit=%s\n' "$desc" "$want" "$rc"
printf '%s\n' "$err" | sed -n '1,6p' | sed 's/^/ /'
FAIL=$((FAIL+1))
fi
}
echo "── A 群:🔴 不該擋(誤攔是這個 repo 的第一級缺陷)────────────────"
clean
t 0 "① 這個 session 從沒用過 kbdb_search → 完全沒有立場說話" \
"$(bash_payload "scripts/ticket say inkstone/Arcrun#167 -F /tmp/x.md")"
clean; seen search "$REAL_ID" "$REAL_SRC"
t 0 "② 搜過,但只是自己看——沒有任何往外送的動作(git status" \
"$(bash_payload "git status --short")"
clean; seen search "$REAL_ID" "$REAL_SRC"
printf '總管:這一輪的三張票都關了,版本 1.4.59 已上 stage。\n' > "$TMPD/plain.md"
t 0 "③ 搜過,也貼了票,但**沒有引用任何搜尋出來的值**(最常見的正常情況)" \
"$(bash_payload "scripts/ticket say inkstone/ISEP#30 -F $TMPD/plain.md")"
clean; seen search "$REAL_ID"; seen walked "$REAL_ID"
printf '總管:這筆我用 kbdb_get_record 拉過了,id 是 %s\n' "$REAL_ID" > "$TMPD/walked.md"
t 0 "④ 搜完**又走了真路徑**才引用 → 這是對的做法,不准罰" \
"$(bash_payload "scripts/ticket say inkstone/Arcrun#167 -F $TMPD/walked.md")"
clean; seen search "$REAL_ID"
t 0 "⑤ 逃生口:指令裡寫了 search-is-evidence: 的理由" \
"$(bash_payload "scripts/ticket say inkstone/Arcrun#167 --body '$REAL_ID' search-is-evidence: 只是引用一筆資料的編號")"
clean; seen search "$REAL_ID"
t 0 "⑥ 讀那支閘的原始碼(值只是出現在指令裡,不是往外送)" \
"$(bash_payload "cat hooks/search-is-not-proof-guard.sh | grep $REAL_ID")"
clean; seen search "$REAL_ID"
t 0 "⑦ 撈票/搜票這些讀取動作,不是往外送" \
"$(bash_payload "curl -s -H 'Authorization: token X' 'https://git.uncle6.me/api/v1/repos/inkstone/Arcrun/issues/167'")"
clean; seen search "$REAL_SRC"
t 0 "⑧ 只在 wiki 裡查東西、然後改自己 repo 的檔(Write 不歸這支管)" \
"$(bash_payload "sed -i '' 's/a/b/' system-dev/wiki/status.md")"
clean; seen search "$REAL_ID"
printf '【工單】inkstone/ISEP#30\n' > "$TMPD/dispatch-ok.md"
t 0 "⑨ 合規派工單(只有票號,沒有引用搜尋值)" \
"$(agent_payload "$(cat "$TMPD/dispatch-ok.md")")"
clean; seen search "$REAL_ID"
t 2 "⑩ 第一次命中 → 擋(這一發是下一條的前置)" \
"$(bash_payload "scripts/ticket say inkstone/Arcrun#167 --body '$REAL_ID'")"
t 0 "⑩' 同一 session 第二次同樣的動作 → 放行,不鬼打牆" \
"$(bash_payload "scripts/ticket say inkstone/Arcrun#167 --body '$REAL_ID'")"
echo "── B 群:該擋(真跡重演)──────────────────────────────────────"
clean; seen search "$REAL_ID" "$REAL_SRC"
cat > "$TMPD/4865.md" <<EOF
# 🔴【總管複驗・退回】兩個缺陷都還在——我用 MCP 實打的
\`\`\`
\$ kbdb_search(q="小果 AFTEE 詐貸") → count 5
\`\`\`
\`\`\`json
{
"id": "$REAL_ID",
"content": "### 出處\n- \`../小果被AFTEE詐貸.pdf\` >> 提及 >> 小果被AFTEE詐貸"
}
\`\`\`
"metadata_json": "{\"source\":\"$REAL_SRC\",\"library\":\"youlinhsieh-test1\"}"
EOF
t 2 "⑪ 真跡重演:Arcrun#167 comment 4865 那則退回,原文照貼" \
"$(bash_payload "scripts/ticket say inkstone/Arcrun#167 -F $TMPD/4865.md")"
clean; seen search "$REAL_ID"
t 2 "⑫ 同一件事走派工單(重派一條線)" \
"$(agent_payload "【工單】inkstone/Arcrun#167
上一版沒修好,實測 $REAL_ID 那塊的出處還是 ../ 開頭")"
clean; seen search "$REAL_SRC"
t 2 "⑬ 只貼了 kb:// 來源位址(沒貼 id)也認得出來" \
"$(bash_payload "scripts/ticket close inkstone/Arcrun#167 --deliverable '$REAL_SRC'")"
clean; seen search "$REAL_ID"
t 2 "⑭ 直接打 Gitea API 貼留言(繞開 scripts/ticket" \
"$(bash_payload "curl -X POST -d '{\"body\":\"$REAL_ID 還是壞的\"}' 'https://git.uncle6.me/api/v1/repos/inkstone/Arcrun/issues/167/comments'")"
clean; seen search "$REAL_ID"; seen walked "e_00000000-0000-0000-0000-000000000000"
t 2 "⑮ 走過真路徑,但走的是**別筆**資料 → 這一筆仍然只有搜尋當來源" \
"$(bash_payload "scripts/ticket say inkstone/Arcrun#167 --body '$REAL_ID'")"
echo "── C 群:訊息本身講不講得出出路 ──────────────────────────────"
clean; seen search "$REAL_ID"
MSG=$(printf '%s' "$(bash_payload "scripts/ticket say inkstone/Arcrun#167 --body '$REAL_ID'")" | bash "$GUARD" 2>&1 >/dev/null)
for want in "D81" "kbdb_graph_neighbors" "search-is-evidence:" "4877" "同一條嗎"; do
N=$((N+1))
if printf '%s' "$MSG" | grep -q -- "$want"; then
printf ' ✅ 訊息裡有「%s」\n' "$want"; PASS=$((PASS+1))
else
printf ' ❌ 訊息裡找不到「%s」\n' "$want"; FAIL=$((FAIL+1))
fi
done
N=$((N+1))
if printf '%s' "$MSG" | grep -q '`kbdb_get_record`'; then
printf ' ✅ 反引號沒有被 shell 吃掉(heredoc 有跳脫)\n'; PASS=$((PASS+1))
else
printf ' ❌ 反引號被 shell 展開了\n'; FAIL=$((FAIL+1))
fi
echo "── D 群:登記處(stamp)──────────────────────────────────────"
clean
RESP=$(python3 -c "
import json
print(json.dumps({'ok':True,'data':{'entries':[{'id':'$REAL_ID','metadata_json':'{\"source\":\"$REAL_SRC\"}'}]}}, ensure_ascii=False))
")
t 0 "⑯ kbdb_search 的回應 → 登記進 search 檔" \
"$(post_payload "mcp__abc__kbdb_search" "$RESP")" "$STAMP"
N=$((N+1))
if grep -q "$REAL_ID" "$SEARCH" 2>/dev/null && grep -q "$REAL_SRC" "$SEARCH" 2>/dev/null; then
printf ' ✅ 兩類指紋(entry id kb:// 位址)都抓到了\n'; PASS=$((PASS+1))
else
printf ' ❌ 指紋沒抓到:%s\n' "$(cat "$SEARCH" 2>/dev/null)"; FAIL=$((FAIL+1))
fi
clean
# 🔴 MCP 的回應**不保證**是拆好的物件——常見的是包成 content blocks
# 整份 JSON 縮成一個字串塞在 text 裡(連引號都被跳脫過)。
# stamp 對兩種包裝都要認得,否則它在真的環境裡會靜默失效。
INNER=$(python3 -c "
import json
print(json.dumps({'ok':True,'data':{'entries':[{'id':'$REAL_ID','metadata_json':'{\"source\":\"$REAL_SRC\"}'}]}}, ensure_ascii=False))
")
WRAPPED=$(python3 -c "
import json,sys
print(json.dumps({'content':[{'type':'text','text':sys.argv[1]}]}, ensure_ascii=False))
" "$INNER")
t 0 "⑯' 同一份回應包成 MCP content blocks(字串裡的 JSON)→ 一樣認得出來" \
"$(post_payload "mcp__abc__kbdb_search" "$WRAPPED")" "$STAMP"
N=$((N+1))
if grep -q "$REAL_ID" "$SEARCH" 2>/dev/null && grep -q "$REAL_SRC" "$SEARCH" 2>/dev/null; then
printf ' ✅ content blocks 包裝下兩類指紋都抓到了\n'; PASS=$((PASS+1))
else
printf ' ❌ 被包裝擋住了:%s\n' "$(cat "$SEARCH" 2>/dev/null)"; FAIL=$((FAIL+1))
fi
clean
t 0 "⑰ kbdb_get_record 的回應 → 登記進 walked 檔" \
"$(post_payload "mcp__abc__kbdb_get_record" "$RESP")" "$STAMP"
N=$((N+1))
if grep -q "$REAL_ID" "$WALKED" 2>/dev/null && [ ! -s "$SEARCH" ]; then
printf ' ✅ 走過真路徑的記在 walked,沒有污染 search\n'; PASS=$((PASS+1))
else
printf ' ❌ 記錯檔了\n'; FAIL=$((FAIL+1))
fi
clean
t 0 "⑱ 不是 kbdb 的工具 → 什麼都不做" \
"$(post_payload "Bash" "$RESP")" "$STAMP"
clean
t 0 "⑲ 搜尋 0 筆(回應讀得到但沒有指紋)→ 正常,不出聲" \
"$(post_payload "mcp__abc__kbdb_search" '{"ok":true,"data":{"entries":[],"count":0}}')" "$STAMP"
clean
t 2 "⑳ fail-closedpayload 裡沒有 tool_response 欄位 → 出聲說自己失效了" \
"$(post_payload "mcp__abc__kbdb_search" "$RESP" "no")" "$STAMP"
clean
echo
printf '共 %d 條:✅ %d ❌ %d\n' "$N" "$PASS" "$FAIL"
[ "$FAIL" -eq 0 ] || exit 1
@@ -0,0 +1,41 @@
#!/usr/bin/env bash
# stage-before-prod-guard.sh 的迴歸測試。
# 🔴 必須寫成檔案跑:這道閘會擋掉「含有它關鍵字」的指令本身
# ⇒ 直接在 Bash 打測試,連讀它的原始碼都會被擋(2026-08-12 實撞三次)。
HOOK="$1"
mk(){ python3 -c "import json,sys;print(json.dumps({'tool_name':'Bash','tool_input':{'command':sys.argv[1]}}))" "$1"; }
PASS=0; FAIL=0
t(){ mk "$3" | "$HOOK" >/dev/null 2>&1; rc=$?
got=$([ $rc -eq 2 ] && echo block || echo pass)
if [ "$got" = "$1" ]; then echo "$2"; PASS=$((PASS+1))
else echo "$2 —— 期望 $1,實得 $got"; FAIL=$((FAIL+1)); fi; }
B='arcrun-rag-bundles' # 關鍵字組起來,避免這個檔名本身觸發別的工具
CDN="https://cdn.jsdelivr.net/gh/youlinhsieh/${B}@abc123"
ARM='scripts/github-arm.sh'
DASH_D='-d'
echo "── 該放行(查證,不是發佈)──"
t pass "抓 bundle manifest 來看(本次實撞)" "BASE=\"$CDN\"; curl -s \"\$BASE/manifest.json\""
t pass "抓 bundle 裡的 worker 檔比對內容" "curl -s $CDN/core/arcrun-mcp/worker.mjs -o /tmp/x.mjs"
t pass "讀本閘自己的原始碼(本次實撞)" "cd /repo && grep -n 'exit 0' .claude/hooks/stage-before-prod-guard.sh"
t pass "讀解保險腳本" "cd /repo && sed -n '1,40p' $ARM"
t pass "commit 訊息裡提到出貨" "git commit -m '照 $ARM 解保險後出貨'"
t pass "查 bundle repo 狀態" "cd /tmp/$B && git log --oneline -3"
t pass "GET 帶 tr -d 管線" "curl -s $CDN/manifest.json | tr $DASH_D '\\r'"
echo "── 該擋(真的在發佈)──"
t block "推 bundle repo" "cd /tmp/$B && git push origin main"
t block "wrangler deploy 出貨物件" "cd $B && npx wrangler deploy"
t block "跑出貨管線" "node installer/scripts/ship.mjs --target prod --bundles $B"
t block "親手執行解保險腳本" "bash $ARM '出貨說明' 30"
t block "POST 寫進 bundle repo" "curl -X POST https://api.github.com/repos/x/$B/contents/y $DASH_D '{}'"
t pass "同一行提到兩次解保險腳本(2026-08-12 第四次誤攔)" "echo '=== $ARM 怎麼判過期 ==='; grep -nE 'expire' $ARM"
t pass "cat 解保險腳本" "cat $ARM"
t block "以 bash 執行解保險腳本" "bash $ARM 'x' 30"
t block "管線後面執行解保險腳本" "echo go && $ARM '出貨' 30"
echo
echo "結果:通過 $PASS 失敗 $FAIL"
[ $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 ]
+169 -36
View File
@@ -1,26 +1,45 @@
#!/bin/bash
# 管什麼: 用 Gitea API 直接開新票時,要求這一輪有跑過跨 repo 的搜尋(/tmp/.ticket-where-ok30 分鐘內)。
# 為什麼: scripts/ticket 早就強制先搜,但那道閘只擋走正門的人。2026-08-20 總管走 API 側門開了 12 張票,每一張都跟舊票重疊
# 誤觸時怎麼關: 跑 `scripts/ticket where <關鍵字>` 先搜(之後 30 分鐘 API 也放行),或在指令裡加 `ticket-api-ok` 留痕放行。
# ticket-api-bypass-guard.sh — 開票的「側門」也要經過同一道搜尋閘
# 管什麼: 用 Gitea API 直接「新增」東西時(開新票/開新 milestone/開新 label/開新 PR),
# 要求這一輪有跑過搜尋(/tmp/.ticket-where-ok30 分鐘內)
# 為什麼: scripts/ticket 早就強制先搜,但那道閘只擋走正門的人;而擋側門的第一版只認
# 大寫裸字 POST2026-08-27 總管用 `requests.post(...)`/隱式 POST 的 urllib
# 繞了過去,同一天又用 API 直接開了 milestone/label/PR(這版都沒管)。
# 誤觸時怎麼關: 跑 `scripts/ticket where <關鍵字>` 先搜(之後 30 分鐘所有寫入端點都放行),
# 或在指令裡加 `ticket-api-ok` 留痕放行。
# ticket-api-bypass-guard.sh — 新增 Gitea 東西的每一道側門,都要經過同一道搜尋閘
#
# 來由(leo 2026-08-20 當場問「如何防止」
# 來由(leo 2026-08-20 當場問「如何防止」2026-08-27 leo 把範圍講寬
# 「不只開票前⋯⋯所有新增 gitea 的東西,都要搜尋」):
# `scripts/ticket new` 早就強制「開票前先搜」(/tmp/.ticket-where-ok 戳記,30 分鐘失效)。
# 但總管當天開了 12 張與舊票重疊的新票——因為他**沒用那支工具,直接打 Gitea API**
# ⇒ 規範有、閘也有,但閘長在「工具」上,而那個動作有兩條路,只封了一條。
# leo:「你在讓事情複雜化」/「每張票開以前都要搜尋現有票,你為什麼會開了不搜?」
# 但 2026-08-20 總管開了 12 張與舊票重疊的新票——沒用那支工具,直接打 API。
# 2026-08-27 總管又開了 13 張,這次連當時的 v1 guard 都繞過了:
# v1 判準只認一個裸字 `grep -qw POST`leo 2026-08-17 定的哲學:封動作不封文字,
# 一個裸字夠了),但那天實際指令是 `req = urllib.request.Request(url, data=...)`
# (靠傳 data= 隱式變成 POST,指令裡從頭到尾沒有 "POST" 三個字母),或
# `requests.post(...)`(小寫 post`grep -qw POST` 大小寫敏感照樣漏)。
# ⇒ 這不是「該封文字改封動作」的問題(v1 本來就在封動作:打了寫入端點),
# 是「認動作的方式」本身漏了 HTTP 語意裡的隱式 POST。本版把「寫入訊號」從
# 單一裸字,換成「curl 的資料類旗標/`.post(``method=post`urllib 隱式 POST」
# 這組結構性訊號——訊號種類有限、可枚舉,跟關鍵字黑名單不是同一件事。
#
# 這支封的是**動作**:任何 Bash 指令只要在對 Gitea 的 issues 端點做寫入,
# 這支封的是**動作**:任何 Bash 指令只要在對 Gitea 的「集合端點」
# issuesmilestoneslabelspulls,不帶 ID=新增;帶 ID=改既有的,放行)做寫入,
# 就要有一個新鮮的搜尋戳記。它不強迫你用 scripts/ticket,只強迫你搜過。
# (同 InkStoneCo#36:「守 prod 的閘,包一層腳本就繞過去了——它看的是指令長相」。
# 本支同樣只看得到指令文字,這是 PreToolUse 這層的天花板
# 所以判準取「端點 + 寫入動詞」兩個都命中才擋,讓純讀取一律放行。)
# 本支同樣只看得到指令文字,這是 PreToolUse 這層的天花板。)
#
# 放行(刻意,這些都不是「開票」):
# 放行(刻意,這些都不是「新增」):
# - 只讀不寫(GET):撈清單、看票、對帳
# - 對既有票的留言/改標籤/關票/issues/<N>/... 這種帶票號的子路徑)
# - scripts/ticket 自己(它有自己的閘,重複擋只會互相打架)
# - 對既有票號的子路徑/issues/<N>/... /milestones/<N> /labels/<N> /pulls/<N>...):
# 留言、改標籤、關票、改狀態、合併 PR——leo 要的是「不要亂開新的」,不是「不准動 Gitea」
# - scripts/ticket 自己(它有自己的三道閘,重複擋只會互相打架)
# - 指令裡出現 ticket-api-ok(逃生口,會留在指令歷史上)
#
# 只對「issues」做主動搜尋+在擋下的訊息裡直接點名可能撞到的舊票(milestones/labels/pulls
# 沒有對應的跨 repo 全文搜尋端點可用,一律只要求「搜尋戳記新鮮」,不生產額外訊息——
# 這是刻意的簡化,不是漏做;真要幫 milestone/label/PR 做語意重複檢查是另一張票的事)。
# 搜尋本身連不上網路 → 不影響擋/放行的判斷,只影響訊息裡有沒有列出候選舊票(fail-open
# 在「錦上添花」的部分,不 fail-open 在「該不該擋」這個核心判斷上)。
set -uo pipefail
INPUT=$(cat)
@@ -34,18 +53,54 @@ except Exception: print('')
# 逃生口(留痕)
case "$CMD" in *ticket-api-ok*) exit 0 ;; esac
# scripts/ticket 有自己的閘
case "$CMD" in *scripts/ticket*|*"ticket where"*|*"ticket new"*|*"ticket say"*) exit 0 ;; esac
case "$CMD" in *scripts/ticket*|*"ticket where"*|*"ticket new"*|*"ticket say"*|*"ticket close"*|*"ticket decide"*) exit 0 ;; esac
# ① 有沒有打到 Gitea 的 issues 端點(不帶票號的那個=建立新票的路徑)
printf '%s' "$CMD" | grep -qE 'repos/[^ "'"'"']*/issues([?"'"'"'`,)\\[:space:]]|$)' || exit 0
# ① 先放行「帶了 ID 的子路徑」——issues/<N>、milestones/<N>、labels/<N>、
# pulls/<N>(後面不管接留言/labels/合併都算),這些是在動既有的東西,
# 不是新增。獨立判斷、排在最前面,不要留給下面那條「集合端點」regex 用
# 貪婪比對去猜——第一版把 owner/repo 寫成「允許斜線的萬用字元」時,
# `/repos/{REPO}/issues/5/labels`(既有票 #5 底下的 labels,不該擋)會被
# 貪婪比對成「owner/repo 吃掉 {REPO}/issues/5,資源名是 labels」,跟真正
# 的頂層 /labels 端點混在一起——先用「resource/數字」這個特徵擋在前面,
# 後面那條就不必再猜。
printf '%s' "$CMD" | grep -qE '(issues|milestones|labels|pulls)/[0-9]+' && exit 0
# ② 指令裡有沒有 POST 這個詞(純 GET 一律放行
# 刻意只認一個裸字:跳脫引號、heredoc、python、curl、各種包裝的寫法無限多,
# 逐個補 pattern 追不完(leo 2026-08-17:「自然語言的變體是無限的,blacklist 永遠追不完」)。
# ①已經確定這是「開票那條端點」,純讀取的指令不會出現 POST ⇒ 一個字就夠,而且沒有跳脫的破口。
printf '%s' "$CMD" | grep -qw 'POST' || exit 0
# ② 有沒有打到 Gitea 的「集合端點」(不帶 ID 的那個=新增的路徑
# repos/<owner>/<repo>/<resource> 或 orgs/<org>/<resource>,資源名後面立刻是
# 「不是路徑字元」的東西(問號/引號/逗號/右括號/空白/結尾)。
# 中間那段刻意只排除空白(不必排引號):①已經先擋掉「帶 ID」的形狀,這裡
# 只要认出「打到了這個資源的集合端點」就好,多抓一點字元進中間那段不影響
# 判斷——這也是為什麼 f-string 占位符 `f"{API}/repos/{REPO}/issues"`
# owner/repo 被壓成一個不含斜線的 token)跟真實網址 `repos/inkstone/ISEP/issues`
# owner/repo 是兩段)都吃得下,不必為了兩種形狀各寫一條 pattern。
ENDPOINT_RE='(repos|orgs)/[^[:space:]]+/(issues|milestones|labels|pulls)([^A-Za-z0-9_/]|$)'
printf '%s' "$CMD" | grep -qE "$ENDPOINT_RE" || exit 0
# ③ 要有新鮮的搜尋戳記(與 scripts/ticket 共用同一個,30 分鐘)
RESOURCE=$(printf '%s' "$CMD" | grep -oE '/(issues|milestones|labels|pulls)([^A-Za-z0-9_/]|$)' | head -1 | grep -oE 'issues|milestones|labels|pulls')
# ③ 有沒有寫入訊號(純 GET 一律放行)。刻意取「結構性訊號」而非單一裸字:
# - curl:顯式 -X/--request POST,或帶了資料類旗標(curl 帶 -d/--data* 時,
# 沒寫 -X 也會自動變 POST——這正是舊版漏掉的其中一種隱式 POST)
# - requests 系函式庫:`.post(`(大小寫都收)
# - 任何函式庫的 `method=` 參數值是 post(大小寫都收)
# - urllib.request`Request(``urlopen(` 與 `data=` 同時出現(不要求同一行——
# 多行 heredoc 裡兩者常常不在同一行)=隱式 POST,這是 2026-08-27 實際撞到的那種寫法
WRITE_SIGNAL=no
if printf '%s' "$CMD" | grep -qiw 'POST'; then
# 保留 v1 的裸字比對,但**大小寫不敏感**——v1 只認大寫 POST,
# 2026-08-27 `requests.post(...)` 小寫照樣繞過,這裡補上不分大小寫的字。
WRITE_SIGNAL=yes
elif printf '%s' "$CMD" | grep -qiE -- '-X[[:space:]]*POST|--request[[:space:]=]*POST|--data(-raw|-binary|-urlencode)?([[:space:]=]|$)|--json([[:space:]=]|$)|\.post\(|method[[:space:]]*=[[:space:]]*.{0,2}post'; then
WRITE_SIGNAL=yes
elif printf '%s' "$CMD" | grep -qE 'urllib\.request\.(Request|urlopen)\(' \
&& printf '%s' "$CMD" | grep -qE '\bdata[[:space:]]*='; then
# 隱式 POSTurllib.request 傳了 data= 卻沒寫明 method——這是 2026-08-27
# 實際撞到的寫法,指令裡從頭到尾沒有 "POST" 三個字母,上面兩層都攔不到。
WRITE_SIGNAL=yes
fi
[ "$WRITE_SIGNAL" = yes ] || exit 0
# ④ 要有新鮮的搜尋戳記(與 scripts/ticket 共用同一個,30 分鐘)
STAMP=/tmp/.ticket-where-ok
NOW=$(date +%s)
FRESH=no
@@ -55,30 +110,108 @@ if [ -f "$STAMP" ]; then
fi
[ "$FRESH" = "yes" ] && exit 0
cat >&2 <<'MSG'
🚫 你正在用 Gitea API 直接開新票,而這一輪沒有搜尋紀錄
# 只有 issues 才做「主動幫你搜一次」——從指令裡的 title 欄位猜關鍵字,
# 真的搜得到就把候選舊票直接列進擋下的訊息裡(重演 2026-08-27 的 Arcrun#100)。
# 任何一步失敗(拿不到 token、連不上網、payload 裡沒有 title)都安靜放棄,
# 不影響「擋下來」這個判斷本身。
HITS=""
if [ "$RESOURCE" = "issues" ]; then
REMOTES=$(git -C "${CLAUDE_PROJECT_DIR:-.}" remote -v 2>/dev/null || true)
CRED_LINE=$(printf '%s' "$REMOTES" | grep -m1 'git\.uncle6\.me' | grep -m1 '@' || true)
if [ -n "$CRED_LINE" ]; then
TOKEN=$(printf '%s' "$CRED_LINE" | sed -E 's|.*//[^:]+:([^@]+)@.*|\1|')
# 只認雙引號的 "title": "..."curl -djson.dumps 輸出的實際格式,也是
# 2026-08-27 那 13 張票真實用的寫法,見 scripts/test-ticket-api-bypass-guard.sh
# 既有的模擬指令)。刻意不支援單引號版本——這段 python 要塞進 bash 單引號
# 參數字串(`python3 -c '...'`),bash 的單引號字串裡放不進一個字面單引號
# (沒有跳脫機制),另外分岔一段處理單引號只會逼自己走回「兩層語言的引號
# 疊在一起」那個坑。找不到 title 就安靜放棄——不影響「擋下來」這個判斷本身,
# 只是訊息裡少列候選舊票。
HITS=$(printf '%s' "$CMD" | _TICKET_TOKEN="$TOKEN" python3 -c '
import os, re, sys, json, urllib.request, urllib.parse
leo 2026-08-16:「**寫開票前先去搜尋要開在哪裡,不然你永遠會亂開新票**」
leo 2026-08-20:「**每張票開以前都要搜尋現有票,你為什麼會開了不搜?這個規範不是早就有 hook 了?**」
cmd = sys.stdin.read()
pat = "\"title\"\\s*:\\s*\"((?:\\\\.|[^\"\\\\])*)\""
m = re.search(pat, cmd)
if not m:
sys.exit(0)
title = m.group(1)
title = title.replace("\\n", " ").replace("\\\"", "\"")
規範有,閘也有——但那道閘長在 `scripts/ticket` 這支工具裡,
而你走的是 API 這條側門。**本閘就是把那道門也封上。**
toks = []
for t in re.findall(r"[A-Za-z0-9]+|[一-鿿]{2,}", title):
if t not in toks:
toks.append(t)
toks = toks[:3]
if not toks:
sys.exit(0)
實錯(2026-08-20 同日):總管用 API 開了 12 張票,事後盤點**每一張都跟舊票重疊**,
全部只能關掉指回舊票。leo:「**你在讓事情複雜化**」。
token = os.environ.get("_TICKET_TOKEN", "")
seen = {}
for kw in toks:
q = urllib.parse.urlencode({"q": kw, "state": "open", "type": "issues", "limit": 20})
url = "https://git.uncle6.me/api/v1/repos/issues/search?" + q
try:
req = urllib.request.Request(url, headers={"Authorization": "token " + token})
data = json.load(urllib.request.urlopen(req, timeout=6))
except Exception:
continue
for it in (data or []):
ref = it["repository"]["full_name"] + "#" + str(it["number"])
row = seen.setdefault(ref, {"score": 0, "title": it["title"]})
row["score"] = row["score"] + 1
hits = sorted(seen.items(), key=lambda kv: -kv[1]["score"])[:6]
if not hits:
sys.exit(0)
joined = " ".join(toks)
print("(用猜出來的關鍵字「" + joined + "」順手搜了一次,命中:)")
for ref, info in hits:
score = info["score"]
t2 = info["title"][:56]
print(" [" + str(score) + "] " + ref + " " + t2)
' 2>/dev/null || true)
fi
fi
case "$RESOURCE" in
issues) VERB="開新票" ;;
milestones) VERB="開新 milestone" ;;
labels) VERB="開新 label" ;;
pulls) VERB="開新 PR" ;;
*) VERB="新增東西" ;;
esac
{
cat <<MSG
🚫 你正在用 Gitea API 直接${VERB},而這一輪沒有搜尋紀錄
leo 2026-08-16:「寫開票前先去搜尋要開在哪裡,不然你永遠會亂開新票」
leo 2026-08-27:「開票不查是否有現成的,這是什麼問題?」「不只開票前,
所有新增 gitea 的東西,都要搜尋」
規範有,閘也有——但上一版只認裸字 POST,你這次用的寫法(隱式 POST/小寫
requests.postmilestone·label·PR 端點)沒被管到。**本閘把這些側門也封上。**
MSG
if [ -n "$HITS" ]; then
printf '\n%s\n' "$HITS"
fi
cat <<'MSG'
── 怎麼過(擇一)───────────────────────────────
1. 先搜(預設,戳記 30 分鐘有效,之後 API 也放行):
1. 先搜(預設,戳記 30 分鐘有效,之後所有寫入端點都放行):
scripts/ticket where <關鍵字...>
🔴 搜到了就**貼進那張票**,不要開新的:
🔴 搜到同一條線就貼進那張票,不要開新的:
scripts/ticket say <owner/repo#N> -F <內文檔>
2. 真的是新的一條線 → 直接用那支工具開,它會幫你把該檢查的檢查完:
2. 開票真的是新的一條線 → 直接用那支工具開,它會幫你把該檢查的檢查完:
scripts/ticket new <repo> -F <內文檔> --title <標題>
3. 這次確實不是在開新票(例如批次改標籤/關票/留言)
3. 這次確實不是在新增(例如批次改標籤/關票/留言/改 milestone 狀態
→ 指令裡加 `ticket-api-ok` 說明理由,留痕放行。
放行的情況(本閘不管):純讀取(GET)、對既有票 /issues/<N>/ 的留言與標籤、scripts/ticket 自己。
放行的情況(本閘不管):純讀取(GET)、對既有票號的留言與標籤與關票、
scripts/ticket 自己。
MSG
} >&2
exit 2
+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
+3 -3
View File
@@ -47,7 +47,7 @@ try:
# 不是只有「會改壞東西的指令」。
# 2026-08-05 第三次補(leo:「我看你剛剛 bash 裡很多 grep,為什麼不是查 wiki?」):
# 再補「讀檔形成結論」的動詞——head/cat/sed/tail/awk/find/jq。
# 之前只認「動外部系統」與部分查證動詞,`head changelog.md` 這種
# 之前只認「動外部系統」與部分查證動詞,head changelog.md 這種
# **直接讀檔下判斷**的完全不觸發。
if re.search(r'\b(wrangler|curl|npx|acr|gh|deploy|push|git|grep|unzip|manifest|version'
r'|head|cat|sed|tail|awk|find|jq)\b', cmd):
@@ -61,10 +61,10 @@ try:
# grep pattern 常含 regex 元字元;取最長的英數/底線詞當搜尋詞
# 🔴 2026-08-05 leo 點破:「最常做的就是 grep,為什麼沒在裡面」
# ——Grep 其實有註冊,壞在**這行取詞規則**:
# ① 連字號被當分隔 ⇒ `bge-m3` 只取到 `bge`3 字)不足 4 字 ⇒ 整支不觸發
# ① 連字號被當分隔 ⇒ bge-m3 只取到bge3 字)不足 4 字 ⇒ 整支不觸發
# ② **中文完全不匹配** ⇒ 查「版本號」「出貨」這類詞一律不觸發
# 而我日常查的關鍵字大量正是這兩類 ⇒ hook 形同虛設。
# ⇒ 容許 `-``.`,並支援 CJK;中文 2 字即算一個詞。
# ⇒ 容許「-」與「.」,並支援 CJK;中文 2 字即算一個詞。
words = re.findall(r'[A-Za-z_][A-Za-z0-9_.-]{2,}', q)
cjk = re.findall(r'[\u4e00-\u9fff]{2,}', q)
words = words + cjk
+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

Some files were not shown because too many files have changed in this diff Show More