diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 1ce8f43..533ff3e 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "isep", - "description": "InkStone Environment Plugin —— leo 的 Claude Code 環境唯一真相源:55 支機械閘(72 條註冊,白話盤點見 docs/hooks-inventory.md)、7 支 slash command、2 支 skill、48 支腳本,外加治理規範與標籤真相源。本機與雲端裝同一份,沒有子集。", - "version": "0.14.0", + "description": "InkStone Environment Plugin —— leo 的 Claude Code 環境唯一真相源:58 支機械閘(79 條註冊,白話盤點見 docs/hooks-inventory.md)、7 支 slash command、2 支 skill、7 位有名字的工人(agents/,見 docs/governance/worker-roster.md)、49 支腳本,外加治理規範與標籤真相源。本機與雲端裝同一份,沒有子集。", + "version": "0.15.0", "keywords": [ "inkstone", "guardrails", diff --git a/README.md b/README.md index 56188ec..33e447e 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,17 @@ | | 數量 | 是什麼 | |---|---|---| +| `hooks/` | 56 支 + `hooks.json` | 全部機械閘(PreToolUse/Stop/SubagentStop/SessionStart/PostToolUse 共 75 條註冊) | +| `agents/` | 7 位 | **工人名單**(`inkstone/ISEP#86`)——派工時指名派給誰,規約見 `docs/governance/worker-roster.md` | +| `commands/` | 7 支 | `/wiki-recall` `/ship-check` `/cp-write` … | +| `skills/` | 2 支 | | +| `scripts/` | 36 支 | `ticket`/`roster`/`github-arm.sh`/`gitea-bootstrap.sh` … | + +> 🔴 這四個數字**每次都要在自己的樹上實數**,不准沿用上一版、也不准用加減推 +> (`system-dev/wiki/mistakes.md`:那是 leo 的驗收介面,多報就是假綠): +> `ls hooks/*.sh | wc -l`/`grep -c '"command":' hooks/hooks.json`(**冒號不能省**)/ +> `ls agents/*.md | wc -l`/`ls -p scripts | grep -v / | wc -l`。 +> 本版(`ISEP#86`/`#87`/`#88`)就是這樣數出來的:上一版寫 48/59/23,實際是 53/68/35。 | `hooks/` | 54 支 + `hooks.json` | 全部機械閘(PreToolUse/Stop/SubagentStop/SessionStart/PostToolUse 共 69 條註冊,2026-08-28 在樹上實數;`ISEP#60` 移除 `claim-verify-police.sh`/`subagent-claim-worksheet.sh` 這對自造機制,改由 Gitea 原生三格承接見 `ISEP#59`) | | `hooks/` | 54 支 + `hooks.json` | 全部機械閘(PreToolUse/Stop/SubagentStop/SessionStart/PostToolUse 共 71 條註冊。**一支一行的白話盤點在 `docs/hooks-inventory.md`,那裡才是這兩個數字的家**) | | `commands/` | 7 支 | `/wiki-recall` `/ship-check` `/cp-write` … | diff --git a/agents/arcrun-hand.md b/agents/arcrun-hand.md new file mode 100644 index 0000000..40bab40 --- /dev/null +++ b/agents/arcrun-hand.md @@ -0,0 +1,22 @@ +--- +name: arcrun-hand +description: Arcrun 引擎的工人——workflow、零件、graph-executor、Cloudflare worker。動 inkstone/Arcrun 派它。 +--- + +你是 **arcrun-hand**,`inkstone/Arcrun` 這個 repo 的工人。 + +**負責 repo**:inkstone/Arcrun + +## 開工前先讀 + +- 這個 repo 的 `CLAUDE.md` 與 `system-dev/wiki/` +- `InkStoneCo/system-dev/docs/4-guides/arcrun-primer.md`(不認識 Arcrun 就一定要讀) +- 要動 KBDB ⇒ 先讀 KBDB 的三張表鐵律,**永不加表、零 SQL、只走 API** + +## 紅線 + +- **不准把 Arcrun 的東西做成 standalone Worker**——零件走 WASM/recipe, + `code` 節點只用於局部整形,不是拿來重寫整條流程(那叫腹語術)。 +- **金鑰只寫 `{{credential.<名字>}}`**,真身由執行前回填;不准自建第二套傳遞法。 +- 動任何基礎設施詞(D1/資料表/migration/schema)之前,先問「**這個東西是誰的**」。 +- 不准部署 prod;stage 驗過再交。 diff --git a/agents/arcrun-rag-hand.md b/agents/arcrun-rag-hand.md new file mode 100644 index 0000000..777ac83 --- /dev/null +++ b/agents/arcrun-rag-hand.md @@ -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 親手解保險)。 diff --git a/agents/collector-hand.md b/agents/collector-hand.md new file mode 100644 index 0000000..6217fde --- /dev/null +++ b/agents/collector-hand.md @@ -0,0 +1,20 @@ +--- +name: collector-hand +description: 桌面 collector/daemon 的工人——打包、自動更新、MS Store 出貨鏈。動 inkstone/arcrun-collector 派它。 +--- + +你是 **collector-hand**,`inkstone/arcrun-collector` 這個 repo 的工人。 + +**負責 repo**:inkstone/arcrun-collector + +## 開工前先讀 + +- 這個 repo 的 `CLAUDE.md` +- `skills/ship-check/SKILL.md`——**daemon 這條版本線 leo 是在桌面「檢查更新」看的**, + 跟雲端 portal 那條是兩條線 + +## 紅線 + +- **版本號是 leo 唯一的驗收介面**:改了就要升版,並把 DMG/zip 真的打開檢查。 +- 出貨(推 prod/送 Store)要 leo 親手解保險。 +- 產物與源碼同處(D95),不要另立產物 repo。 diff --git a/agents/inkstoneco-hand.md b/agents/inkstoneco-hand.md new file mode 100644 index 0000000..e5ae910 --- /dev/null +++ b/agents/inkstoneco-hand.md @@ -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),禁表格平攤。 diff --git a/agents/isep-hand.md b/agents/isep-hand.md new file mode 100644 index 0000000..032c880 --- /dev/null +++ b/agents/isep-hand.md @@ -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`)的數字**在你的樹上自己數**,不准用加減推。 diff --git a/agents/mira-hand.md b/agents/mira-hand.md new file mode 100644 index 0000000..3417044 --- /dev/null +++ b/agents/mira-hand.md @@ -0,0 +1,19 @@ +--- +name: mira-hand +description: Mira 的工人——daemon、收件匣、通知管線。動 inkstone/mira 派它。 +--- + +你是 **mira-hand**,`inkstone/mira` 這個 repo 的工人。 + +**負責 repo**:inkstone/mira + +## 開工前先讀 + +- 這個 repo 的 `CLAUDE.md` 與 `.claude/wiki/` +- 跨 repo 的資料流 ⇒ `InkStoneCo/system-dev/docs/3-specs/journeys/`(驗收只認頭尾) + +## 紅線 + +- **Mira ≠ Arcrun**,分層不准混。 +- 同步到線上走 **ssh(scp/rsync)**,**不准用 GitHub Actions**(那是當初被 flag 的形狀)。 +- 不准部署 prod。 diff --git a/agents/scout.md b/agents/scout.md new file mode 100644 index 0000000..d763216 --- /dev/null +++ b/agents/scout.md @@ -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。** 你交的是結論,貼回那張票。 diff --git a/docs/TESTING.md b/docs/TESTING.md index 8feb7ed..ec91ddf 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -442,11 +442,12 @@ bash hooks/tests/ask-user-question-guard.live.test.sh - 📌 這支會隨模型版本漂移,**是量尺不是一次性驗收**。改完判準要連跑三次都全綠才算數 (2026-08-26 實測:第一版判準連兩次都在同一題漏擋,收緊 ③④ 定義後三次全綠) -### A11 — 派工單只剩票號:擋得住,也放得過,而且會注入共通規定 +### A11 — 派工單只剩票號(含**回覆那條路**):擋得住,也放得過,而且會注入共通規定 ``` bash hooks/tests/dispatch-format-guard.test.sh ``` -**該看到**:`19/19 通過`。**離線、不打網路、不花錢**——這支閘是純結構判斷,沒有語意判官, +**該看到**:`52/52 通過`(`inkstone/ISEP#88` 之前是 33 條;本頁一度寫 19,那是更早的數字, +**沒有人回頭改** —— 同一個病,只是換一欄)。**離線、不打網路、不花錢**——這支閘是純結構判斷,沒有語意判官, 所以它不需要像 A10 那樣另開一支 live 測試量準度,**每次結果都一樣**。 **失敗**: - A 群任何一條紅 ⇒ **誤攔**。合規的派工只有一行票號,擋掉它等於整台機器派不了工 @@ -455,6 +456,58 @@ bash hooks/tests/dispatch-format-guard.test.sh 它壞了不會有人立刻發現——派工照樣送出去,只是收工方**不知道要貼回原票** - ⑨ 紅 ⇒ 真跡放行了。那份測資是**真的發生過的那一次派工**(見 `hooks/tests/fixtures/README.md`) - ⑰ 紅 ⇒ 訊息被 shell 展開了(同 A9 ⑩b 那個病:閘照擋,但它教人怎麼解的那兩行變成空白) +- **D 群⑲⑳㉑㉒ 紅 ⇒ `inkstone/ISEP#88` 的驗收條件沒過**:回覆一個正在跑的 subagent + 時夾帶指令沒被擋(⑲)、或擋了卻沒把那段內容原文印出來(㉑,出路是「貼上票」, + 找不到原文就得回頭自己翻) +- **E 群㉔–㉙ 任一紅 ⇒ 有一條通往 subagent 的路又漏了**。那六條是雲端 session/trigger/ + `claude -p`——`ticket-api-bypass-guard.sh` 檔頭記過這一族的通病:「認動作的方式漏了一條路」 +- **F 群㉚–㉟ 任一紅 ⇒ 誤攔**。㉚特別重要:subagent 往上回報(`to: "main"`)是**交件**, + 擋它等於擋掉交件本身;㉟反過來,子 session 自己往下派工時規矩要照樣管它,不然是逃逸艙口 + +### A16 — 工人有名字:指名派工、沒有這個人、注入它的檔案:24 條 +``` +bash hooks/tests/roster-guard.test.sh +``` +**該看到**:`24 通過 / 0 失敗`。**離線、不打網路、不花錢**——純結構判斷(名字在不在名單裡), +沒有語意判官,每次結果一樣。 + +**它在守什麼**(`inkstone/ISEP#86`):「身為派工的人,我要工人有名字, +我才知道票上這件事到底是誰做的。」開票當日實查:`/root/.claude/agents/` 不存在, +派工派給的是**沒有名字的臨時工**——票上只看得到「有個 agent 做了」。 + +**失敗**: +- ①②③ 紅 ⇒ 驗收 1 沒過:`scripts/roster` 列不出名單、或列了卻看不出各自管什麼 +- ④⑤ 任一紅 ⇒ **誤攔**,名單上的人被擋掉 = 整台機器派不了工。這比漏擋嚴重 +- ⑥⑦⑧ 紅 ⇒ 驗收 4 沒過:派一個不存在的名字時講不出「沒有這個人」 +- ⑨ 紅 ⇒ 擋了卻沒印名單,人不知道有誰可以派(閘的訊息要講得出出路) +- ⑬ 紅 ⇒ 名單讀不到時**沒有 fail-open**。一份讀不到的名單不該讓整台機器停擺 +- ⑯⑰ 紅 ⇒ 注入壞了。那是「你是誰/先讀什麼/你的紅線」唯一的送達方式—— + 它壞了不會有人立刻發現,派工照樣送出去,只是工人不知道自己是誰 +- ⑱ 紅 ⇒ 驗收 3 沒過:票上的【身份】欄吃不下工人名字(或把原本三個角色弄壞了) + +### A17 — 未經調查不寫診斷:26 條 +``` +bash hooks/tests/diagnosis-evidence-guard.test.sh +``` +**該看到**:`26 通過 / 0 失敗`。**全程離線**:不打 Gitea、不開任何測試票, +戳記走 `ISEP_STAMP_DIR`(不碰 `/tmp` 的正式戳記)。 + +**它在守什麼**(`inkstone/ISEP#87`):「先派人查 → 拿到查的結果 → 才可以寫診斷。」 +未經調查的診斷寫在票上會長得像事實,**工人會照著它去驗證,而不是去查**。 + +**失敗**: +- ①–⑦ 任一紅 ⇒ 驗收 1 沒過。⑥⑦特別看:那是**側門**(直接打 Gitea API), + 只封正門等於沒封 +- ② 紅 ⇒ 驗收 3 沒過:訊息說不出「你還沒派人查這件事」,變成籠統的「格式不對」 +- ⑧⑨ 紅 ⇒ 驗收 2 沒過(派過人查卻還在擋),或戳記從「按票分」滑成「按 session 分」 + ——後者等於派過一次就永久解鎖 +- **⑩–⑫ 任一紅 ⇒ 驗收 4 沒過,這是誤攔**:轉述別人查到的東西(有出處)被擋, + 那會逼人把真的有出處的話也吞回去 +- ⑬ 紅 ⇒ 判準從結構滑回措辭。那一條**故意用一段連「根因/因為/應該是」都沒有的內容**, + 它一旦放行,代表閘開始靠關鍵字判斷了(leo 2026-08-17 已證偽那條路) +- ⑭–⑲ 任一紅 ⇒ 誤攔(短回覆、唯讀、搜尋、關票被擋) +- ㉑ 紅 ⇒ 鬼打牆:同一張票擋不只一次,人會學會忽略它 +- ㉓㉔ 紅 ⇒ payload 壞掉時擋住了。這支是 PreToolUse,fail-closed 會讓人做不了事 ### A12 — 票上的每一則留言都認得出是誰寫的(兩道門) ``` @@ -612,6 +665,10 @@ B2(信標那行)/B3(setup 輸出)/B4(閘的訊息)三個畫面 | A6 標籤對齊+冪等 | 總管 | ✅ 14 repo,第二次 0/0 | | **A9 人閘警察管路** | 總管 | ✅ 14/14(2026-08-26) | | **A10 人閘警察準度** | 總管 | ✅ 9/9,連跑三次(2026-08-26),A 群誤攔 0 | +| **A11 派工單只剩票號+回覆那條路(ISEP#88)** | 總管 | ✅ 52/52(2026-08-28) | +| **A12 留言身份欄(兩道門)** | 總管 | ✅ 11/11(2026-08-28 複跑) | +| **A16 工人有名字(ISEP#86)** | 總管 | ✅ 24/24(2026-08-28) | +| **A17 未經調查不寫診斷(ISEP#87)** | 總管 | ✅ 26/26(2026-08-28) | | **A11 派工單只剩票號** | 總管 | ✅ 19/19(2026-08-27) | | **A12 留言身份欄(兩道門)** | 總管 | ✅ 11/11(2026-08-27) | | **A16 放行的門真的打得開** | 總管 | ✅ 17/17(2026-08-28,inkstone/ISEP#90) | diff --git a/docs/governance/dispatch-and-reply-format.md b/docs/governance/dispatch-and-reply-format.md index e132a9d..d3d8e60 100644 --- a/docs/governance/dispatch-and-reply-format.md +++ b/docs/governance/dispatch-and-reply-format.md @@ -44,6 +44,58 @@ --- +## 1.5 派給誰:從名單裡挑一個**有名字的工人**(inkstone/ISEP#86) + +派工單只有票號,那「這是誰的活」寫在哪?——**不寫在派工單裡,寫在 `subagent_type`。** + +``` +Task(subagent_type="isep-hand", prompt="【工單】inkstone/ISEP#86") + ▲ 名單上的名字 ▲ 派工單仍然只有這一行 +``` + +| | | +|---|---| +| 名單在哪 | `agents/`,一個檔案一位工人(規約:`docs/governance/worker-roster.md`) | +| 怎麼看 | `python3 scripts/roster list`/`show <名字>`/`which ` | +| 機械閘 | `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 | + +🔴 **新增一條路要加在那張表裡,不要再開一支閘。** +「認動作的方式漏了一條路」是這一族的通病——`ticket-api-bypass-guard.sh` 的檔頭 +記過同一課(第一版只認大寫裸字 `POST`,`requests.post()` 與隱式 POST 全部漏掉)。 + +**刻意不管的方向**:subagent 往上回報(`SendMessage to: "main"`)=**交件不是派工**。 +擋它等於擋掉交件本身。 + +--- + ## 2. 共通規定(每一次派工由機器自動注入給收工方) @@ -55,8 +107,10 @@ 3. **交件=貼回那張票**(`scripts/ticket say -F <檔>`), 不是只在對話裡回報。回覆第一行必須是身份欄,見下。 4. **回覆第一行一律是**: - `【身份】subagent//<你的分支>` - 角色三選一:`總管`/`subagent`/`leo`。 + `【身份】<你的名字>//<你的分支>` + 名字用機器注入給你的那個(`isep-hand`、`scout`…);沒有名字時才退回 + `總管`/`subagent`/`leo` 三選一。**票上要看得出是哪個工人做的**, + 而三條線並行時「subagent」這個字回答不了「是誰」。 5. **不准 push 到 `main`/`master`**,也不准部署 prod。做在自己的分支上,交回分支名。 6. **Gitea 的 org 是 `inkstone`**(不是 `Leo`);`gh` 打不到 Gitea; 標籤是 `s/*` 不是 `status/*`。 @@ -80,9 +134,13 @@ 【身份】subagent/inkstone/ISEP/feat/ticket-carries-the-task ``` -- 角色是**三選一的允許清單**:`總管`/`subagent`/`leo` +- 角色是**允許清單**:`總管`/`subagent`/`leo`,**再加上 `agents/` 名單上的每一個工人名字** + (`isep-hand`/`arcrun-hand`/`scout`…,inkstone/ISEP#86)。清單以外的名字一律擋。 - 第二格是你動的 repo,第三格是分支(沒有就寫 `-`) +🔴 **有名字就用名字。** 三條線並行時三則「【身份】subagent/…」長得一模一樣, +等於沒有身份——這正是 ISEP#86 要解的那件事。 + ### 為什麼 2026-08-27 實害:多條線並行時票上的留言看不出身份, @@ -106,8 +164,30 @@ ## 4. 這份規範自己怎麼被驗 ``` -bash hooks/tests/dispatch-format-guard.test.sh 派工單閘:該擋的與不該擋的 -bash hooks/tests/reply-identity.test.sh 身份欄:正門與側門兩道 +bash hooks/tests/dispatch-format-guard.test.sh 派工單閘+**回覆那條路**:該擋的與不該擋的 +bash hooks/tests/reply-identity.test.sh 身份欄:正門與側門兩道 +bash hooks/tests/roster-guard.test.sh 工人名單:指名、沒有這個人、注入 +bash hooks/tests/diagnosis-evidence-guard.test.sh 未經調查不寫診斷 ``` 測資裡放的是**真的發生過的那幾份違規派工單**,不是想像出來的例子。 + +--- + +## 5. 寫上票之前:**未經調查不寫診斷**(inkstone/ISEP#87) + +順序只有一個方向:**先派人查 → 拿到查的結果 → 才可以寫診斷。** + +> 「診斷寫完了,工人就不知道自己要做什麼——它會照著我的結論去驗證,而不是去查。 +> 而我不是那個 repo 的專家,它才是。」 + +機械閘 `hooks/diagnosis-evidence-guard.sh` 看三個**結構**訊號(一個關鍵字都沒有): + +1. 這個 session 有沒有**派人查過那張票**(`investigate-first-stamp.sh` 的戳記) +2. 這段話裡有沒有**走得過去的出處**(檔案:行號/commit/comment 號/票號/網址/實測輸出) +3. 這段話**有沒有份量**(一句「收到」不可能是診斷) + +①有 ⇒ 放行。①無+②有 ⇒ 放行(**轉述有出處不能被誤擋**)。三者皆缺 ⇒ 擋一次。 + +🔴 **「根因」「因為」「應該是」這類詞一律不准當判準**——leo 2026-08-17 已證偽文字層封路: +當日 8 次誤攔、0 次正確攔截,而且**紅線寫得越細,命中關鍵字的機率越高 ⇒ 那些閘在懲罰謹慎**。 diff --git a/docs/governance/worker-roster.md b/docs/governance/worker-roster.md new file mode 100644 index 0000000..da8ad1f --- /dev/null +++ b/docs/governance/worker-roster.md @@ -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**——名單是資料,閘讀的是這個目錄。 diff --git a/docs/hooks-inventory.md b/docs/hooks-inventory.md index 19d9949..085def6 100644 --- a/docs/hooks-inventory.md +++ b/docs/hooks-inventory.md @@ -1,3 +1,4 @@ +# 56 支閘,白話盤點表 # 54 支閘,白話盤點表 > 回應 `inkstone/InkStoneCo#40`:「如果加入了,我應該可以白話文看到 hooks 的內容?」 @@ -7,16 +8,16 @@ ## 一句話結論 -`hooks/` 底下有 **55 個 `.sh` 檔**,`hooks.json` 實際掛上 **72 條註冊**(同一支閘常被多種情境同時掛上); -`hooks/` 底下有 **55 個 `.sh` 檔**,`hooks.json` 實際掛上 **72 條註冊**(同一支閘常被多種情境同時掛上); +`hooks/` 底下有 **58 個 `.sh` 檔**,`hooks.json` 實際掛上 **79 條註冊**(同一支閘常被多種情境同時掛上); +`hooks/` 底下有 **58 個 `.sh` 檔**,`hooks.json` 實際掛上 **79 條註冊**(同一支閘常被多種情境同時掛上); +`hooks/` 底下有 **58 個 `.sh` 檔**,`hooks.json` 實際掛上 **79 條註冊**(同一支閘常被多種情境同時掛上); 其中 **3 支檔案存在但沒被掛上**(2 支是待人填的空範本、1 支是刻意留著沒開的止血帶,見下面「未生效」表)。 下面按「你會在什麼時候撞到它」分組,一支一行。 > 🔴 **這兩個數字上一版是錯的(2026-08-26 實際數過才發現)**:本頁原本寫「43 個檔、53 條註冊」, > 而當時真實是 **45 個檔、55 條註冊**——中間有兩支閘進來時沒有回頭改這裡。 > 現在的寫法是實際數出來的: -> `ls hooks/*.sh | wc -l` = 53;`grep -c '"command":' hooks/hooks.json` = 68。 -> `ls hooks/*.sh | wc -l` = 53;`grep -c '"command":' hooks/hooks.json` = 68。 +> `ls hooks/*.sh | wc -l` = 56;`grep -c '"command":' hooks/hooks.json` = 75。 > ⚠️ **冒號不能省**:`grep -c '"command"'`(沒冒號)會連 `"type": "command"` 一起數到,回 **120**。 > 本頁 2026-08-27 之前寫的是沒冒號那版——**照著它跑會拿到一個跟本頁不符的數字**。 > **一份會偷偷過期的盤點表,跟沒有盤點表差不多**——見本頁最後「怎麼跟實況對帳」那段。 @@ -48,6 +49,18 @@ > 📌 **`0.7.0`(`inkstone/ISEP#30`,2026-08-27)進來一支**:`mainline-idle-guard.sh`(F 組,Stop)。 > 48→**49** 支、59→**60** 條,兩個數字都是加完之後當場數出來的(指令同上)。 > +> 📌 **`inkstone/ISEP#86`/`#87`/`#88`(2026-08-28)進來三支、+7 條**: +> `roster-guard.sh`(D 組,`Task`+`Agent` 各一條)、 +> `diagnosis-evidence-guard.sh`(A 組,Bash)、 +> `investigate-first-stamp.sh`(H 組,`Agent|Task` 與 `SendMessage` 各一條), +> 外加 `dispatch-format-guard.sh` 多掛兩處——Bash(接 `claude -p` 這條路)與 +> **新的「回覆 subagent」matcher**(`SendMessage` 與雲端 session/trigger 那幾支)。 +> 53→**56** 支、68→**75** 條,兩個數字都是**在這棵樹上當場數出來的** +> (`ls hooks/*.sh | wc -l`/`grep -c '"command":' hooks/hooks.json`), +> 不是拿上一版加減推的。順手改掉兩個過期的數字:`README.md` 的「48 支/59 條註冊/23 支腳本」 +> ——實數是 53/68/35(那是**併之前** main 上的實況),本版之後是 56/75/36。 +> **同一個病,只是換一個檔案。** + > 📌 **`0.10.0`(`inkstone/ISEP#81`,2026-08-28)進來一支**:`pr-verdict-guard.sh`(F 組,Stop)。 > 51→**52** 支、64→**65** 條,兩個數字都是加完之後當場數出來的(指令同上)。 > 順手改掉一個過期的數字:描述欄長期寫「27 支腳本」,實數是 **34** @@ -94,7 +107,7 @@ | `reply-identity-guard.sh` | 直接打 Gitea API 貼留言到票上時,內文第一行沒有 `【身份】`(`總管`/`subagent`/`leo`)就擋下——票上多條線並行時,看不出哪一則是誰寫的。純讀取、走 `scripts/ticket` 正門、指令裡加 `reply-identity-ok` 都放行。 | 🛑 擋 | | `comment-carries-task-guard.sh` | AI 要在票裡留一則「等某某上線才驗得了」這種**還沒做完的事**,卻沒有把它開成一張子票時,擋一次。留言沒人會回頭讀,票的 open/closed 撈一次就看得到。 | 🛑 擋(同一輪只擋一次) | | `search-is-not-proof-guard.sh` | AI 用知識庫的**搜尋**找到一筆東西,然後把那筆東西的原始編號直接貼進票裡或派工單裡,當成「某個功能壞了」的證據——而它從沒用正規的檢索路徑去查過那筆——擋一次。你關心的是:不會再有人拿錯的方法量出一個錯的結論,然後退回一張其實已經做完的票。 | 🛑 擋(同一輪只擋一次) | - +| `diagnosis-evidence-guard.sh` | 你想把一段有份量的東西寫上票,而這個 session **沒派人去查過那張票**、那段話裡也**沒有任何走得過去的出處**(檔案:行號/commit/comment 號/票號/網址/貼出來的實測輸出)就擋一次——未經調查的診斷寫在票上會長得像事實,工人會照著它去驗證而不是去查。派過人查、或把出處帶上,都放行;一句短回覆不管。 | 🛑 擋 | ## B. AI 想寫檔案/改程式碼的當下(PreToolUse / Write·Edit·MultiEdit) | 閘名 | 對你意味著什麼 | 動作 | @@ -126,6 +139,8 @@ | `micromanage-guard.sh` | 派工單寫得太細(指名檔案函式、編號步驟、要求每做一項回報一次…)就擋下——subagent 該被當成有能力的同事,不是照抄劇本的工具。 | 🛑 擋 | | `irreversible-dispatch-guard.sh` | 派工單裡出現「刪分支」「drop table」「rm -rf」這類不可逆動作,卻沒寫「先停下來等回覆才執行」就擋下。 | 🛑 擋 | | `no-ticket-no-dispatch.sh` | 派工單裡沒有寫工單號(`【工單】owner/repo#N`),或那張票已經關閉/根本不存在,就擋下——沒有票號的工作沒有人追得到進度。 | 🛑 擋 | +| `dispatch-format-guard.sh` | **派工單 = 票號,就這樣。** 票號以外還寫了別的東西(背景、驗收條件、紅線、交件方式、「這個 session 才知道的事」)就擋下,並告訴你那些內容該搬去哪:每次都一樣的 → 共通規定;這次才知道的 → **寫進那張票**。反過來,合規的派工它會**自動把共通規定注入**給收工方(交件方式、不准 push main、org 是 `inkstone`…),所以你不寫也不會漏。判準是「這一行是不是【工單】欄位」——**在不在**,不是寫了什麼,所以講得再謹慎也不會被多罰。純禮貌收尾(「謝謝」)不算違規;`(→ comment M)` 不論全形或半形括號都認得(ISEP#65 修過一次:CLAUDE.md 自己規定的合格寫法曾經被這支閘自己擋下)。**`ISEP#88` 起同一支閘也掛在「回覆 subagent」那條路上**(`SendMessage`、雲端 session/trigger、以及 Bash 的 `claude -p`)——回覆也是派工,同一套規矩;擋下來時會把那段內容**原文印出來**方便直接貼上票。subagent 往上回報(`to: "main"`)不管,那是交件不是派工。 | 🛑 擋(+注入) | +| `roster-guard.sh` | 派工要**指名派給誰**:`Task` 的 `subagent_type` 得是 `agents/` 名單上的名字(`isep-hand`/`arcrun-hand`/`scout`…),沒指名或指了不存在的名字就擋下,並把整份名單印出來。指對了它會**自動把那位工人的檔案原文注入**給它——你是誰、負責哪個 repo、開工前先讀什麼、你的紅線是什麼(這些每個工人都不一樣,共通規定補不了)。名單是資料:加一個工人=在 `agents/` 加一個 `.md`,不必改任何 hook。 | 🛑 擋(+注入) | | `mainline-focus-guard.sh` | 派出去的票**不在「現在的主線」上**,就擋一次,並問你是「補收」(它本來就該掛在主線上)還是「跳線」(真的插件事)。背景:2026-08-27 實查有 **14 個 milestone 同時開著**,其中「Mira 現代化」同名活在 5 個 repo ⇒ SOP 說的「只做該 milestone 的事」在現場**沒有指涉對象**,等於不存在。現在主線是一個查得到的事實(`scripts/mainline`,答案唯一),這支就是那個事實的用處。🔴 **沒有標主線 ⇒ 一律放行**;多張票只要有一張在主線上就放行;查不到那張票掛在哪也放行(**讀不到 ≠ 不屬於**);**同一張票只擋一次**,重送就過。跳線會留一筆紀錄——不留痕的話,跳線與專注在機器上長得一模一樣。 | 🛑 擋(同一張票至多一次) | | `dispatch-format-guard.sh` | **派工單 = 票號,就這樣。** 票號以外還寫了別的東西(背景、驗收條件、紅線、交件方式、「這個 session 才知道的事」)就擋下,並告訴你那些內容該搬去哪:每次都一樣的 → 共通規定;這次才知道的 → **寫進那張票**。反過來,合規的派工它會**自動把共通規定注入**給收工方(交件方式、不准 push main、org 是 `inkstone`…),所以你不寫也不會漏。判準是「這一行是不是【工單】欄位」——**在不在**,不是寫了什麼,所以講得再謹慎也不會被多罰。純禮貌收尾(「謝謝」)不算違規;`(→ comment M)` 不論全形或半形括號都認得(ISEP#65 修過一次:CLAUDE.md 自己規定的合格寫法曾經被這支閘自己擋下)。 | 🛑 擋(+注入) | @@ -187,7 +202,7 @@ | `issue-status-autoflip.sh` | AI 一派工出去,就自動把對應的 Gitea 票改成「進行中(s/doing)」,不必等人手動改標籤。 | 📝 記錄(自動改票) | | `baton-handback-guard.sh` | 一條派工線做完了,就去看它那張票有沒有**指派給人/標籤有沒有說它卡在哪/有沒有寫下一步**,缺哪一格就當場說出來。你關心的是:不會再有票做完了卻沒人接手,躺在那裡沒人發現。 | 📝 記錄(提醒,不擋) | | `kbdb-evidence-stamp.sh` | AI 每次去知識庫拿資料,就記一筆「這是**搜尋**搜到的,還是**走正規檢索路徑**拿到的」。你關心的是:它等一下要拿這筆資料下結論時,有東西可以對照它走的是哪條路。 | 📝 記錄 | - +| `investigate-first-stamp.sh` | 你真的**派人去查某張票**之後,按「session + 票號」留一個時間戳——給 `diagnosis-evidence-guard.sh` 判斷「這件事有沒有人查過」用。按票分而不是按 session 分:早上派人查了 A 票,不該等於解鎖了 B 票的診斷。 | 📝 記錄 | ## I. 你會撞到但跟「派工/收工」無關的一支(Edit·MultiEdit) | 閘名 | 對你意味著什麼 | 動作 | @@ -287,7 +302,7 @@ A1 缺人話、A2 表格有幽靈、C 全頁有幽靈。 **這次順便抓到的另一個落差**(🕐 **這一段是 2026-08-20 的快照,裡面每個數字都是那天的,不是現況**—— 留著是因為它是「兩份文件的數字不會自己跟上」這個病的第一個案例;現況數字看本頁標頭): -`.claude-plugin/plugin.json` 的說明文字寫「55 支機械閘(52 條註冊)」, +`.claude-plugin/plugin.json` 的說明文字寫「58 支機械閘(52 條註冊)」, `README.md` 也寫「42 支 + `hooks.json`」「共 52 條註冊」——但實測是 **43 支檔案、53 條註冊**。 差 1 支、差 1 條,猜測是今天(2026-08-20)新增的 `leo21c-write-guard.sh`/`release-tag-guard.sh`/ `ticket-api-bypass-guard.sh` 這批(檔頭日期都是今天)加了之後,兩份文件的數字沒有跟著更新。 diff --git a/hooks/diagnosis-evidence-guard.sh b/hooks/diagnosis-evidence-guard.sh new file mode 100755 index 0000000..2f95d79 --- /dev/null +++ b/hooks/diagnosis-evidence-guard.sh @@ -0,0 +1,190 @@ +#!/usr/bin/env bash +# 管什麼: 要把一段有份量的東西寫上票,而這個 session **沒派人查過那張票**、 +# 那段話裡也**沒有任何走得過去的出處** → 擋一次。 +# 為什麼: 未經調查的診斷寫在票上就長得像事實,工人會照著它去驗證而不是去查—— +# 等於用權威把那個 repo 真正的專家的判斷力關掉。 +# 誤觸時怎麼關: 把出處補上(檔案:行號/commit/comment 號/票號/網址/貼實測輸出), +# 或指令裡加 `evidence-ok`(留痕)。同一 session 同一張票只擋一次。 +# +# diagnosis-evidence-guard.sh — 未經調查不寫診斷(PreToolUse: Bash;inkstone/ISEP#87) +# +# ━━ 來由(leo,inkstone/ISEP#87)━━━━━━━━━━━━━━━━━━━━━━━━━━━ +# 「身為派人去查的人,我要自己在拿到調查結果前寫不出診斷, +# 我才不會用猜的結論把工人的判斷力關掉。」 +# 「**票裡寫未經調查的診斷**」是 2026-08-27 當天列出的錯之一。 +# +# ⇒ 順序倒過來:**先派人查 → 拿到查的結果 → 才可以寫診斷。** +# 我的診斷只能建立在別人查回來的東西上面,不能建立在我的印象上面。 +# +# ━━ 這支跟既有那兩支的分界(三支不重疊)━━━━━━━━━━━━━━━━━━━━━ +# `subagent-first-guard.sh` 鐵律 2 的**前半**:要改 code 卻一次工都沒派過 +# `micromanage-guard.sh` 替對方決定**做法**(給指令照跑、指名檔案函式…) +# **本支** 替對方決定**答案**(把還沒查證的結論寫進票) +# 前兩支都不會在「寫東西上票」這個動作上開口,所以不會兩支同時開罵。 +# +# ━━ 🔴 判準:三個結構訊號,一個關鍵字都沒有 ━━━━━━━━━━━━━━━━━━━ +# 票上寫死:「『根因』『因為』『應該是』這類詞**一律不准當判準**」 +# (leo 2026-08-17 已證偽文字層封路:當日 8 次誤攔、0 次正確攔截, +# 而且**紅線寫得越細,命中關鍵字的機率越高 ⇒ 那些閘在懲罰謹慎**)。 +# +# 三個訊號,全部是結構: +# ① **這個 session 有沒有派人查過那張票** +# —— `investigate-first-stamp.sh` 的戳記(在不在,不是寫什麼) +# ② **這段話裡有沒有走得過去的出處** +# —— 檔案:行號/commit/comment 號/票號/網址/檔案路徑/貼出來的實測輸出。 +# 方向是**放行**(認出來就放行),與黑名單相反: +# 比對錯了只會**少擋一次**,不會多罰一次(同 pr-verdict-guard ③)。 +# ③ **這段話有沒有份量**(扣掉身份欄與空白後的字數,門檻 60 字, +# `ISEP_DIAGNOSIS_FLOOR` 可調) +# —— 一句「收到,我來看」(6 字)不可能是診斷。長度是結構,不是措辭。 +# 門檻怎麼定的:拿一段真的診斷(「根因是 KV 額度爆掉⋯⋯」)實量 **73 字**, +# 拿最短的合理回覆實量 **6 字**——60 落在兩者之間,靠短的那邊。 +# **不是猜的,是量過的**(第一版寫 120,那段真的診斷就漏抓了)。 +# +# 三個訊號的組合:①有 ⇒ 放行(查過了)。①無 + ②有 ⇒ 放行(轉述有出處, +# 票上驗收條件 4 指明不能誤擋)。①無 + ②無 + ③夠長 ⇒ 擋一次。 +# +# ━━ 誠實邊界(做不到的事就寫出來)━━━━━━━━━━━━━━━━━━━━━━━━ +# · PreToolUse 只看得到指令文字與 `-F` 指到的檔(同 comment-carries-task-guard)。 +# · 本閘**認不出「這段話是不是診斷」**——沒有任何機器認得出來, +# 而想認出來的那條路(句型比對)已經被證偽。它認的是 +# 「**一段有份量、卻沒有任何出處、也沒人去查過**」的東西。 +# 漏抓的情況:診斷寫得很短、或診斷裡剛好夾著一個無關的網址。 +# 這是刻意的取捨——**誤攔比漏擋嚴重**,一支天天誤擋的閘會被學會忽略。 +# +# 逃生口:指令裡加 `evidence-ok`(會留在指令歷史上)。同 session 同票只擋一次。 +# +# 測試:hooks/tests/diagnosis-evidence-guard.test.sh(離線、不打網路、不留測試票) +set -uo pipefail + +INPUT=$(cat) +HERE="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)" + +VERDICT=$(printf '%s' "$INPUT" | DEG_LIB="$HERE/lib" python3 -c ' +import json, os, re, sys +sys.path.insert(0, os.environ.get("DEG_LIB", "")) +try: + import evidence +except Exception: + print("SKIP:no-lib"); raise SystemExit + +try: + d = json.load(sys.stdin) +except Exception: + print("SKIP:bad-payload"); raise SystemExit + +cmd = (d.get("tool_input") or {}).get("command") or "" +if not cmd: + print("SKIP:no-cmd"); raise SystemExit +if "evidence-ok" in cmd: # 逃生口(留痕) + print("SKIP:escape"); raise SystemExit + +sid = re.sub(r"[^A-Za-z0-9_.-]", "_", str(d.get("session_id") or "nosid"))[:64] + +# ── ① 這是不是「把東西寫上票」的動作 ──────────────────────────────── +# 兩條路都要認(`ticket-api-bypass-guard.sh` 檔頭記過那一課: +# 「認動作的方式漏了一條路」是這一族的通病)。 +target = None # (owner, repo, num) 或 ("", repo_or_?, "new") +m = re.search(r"scripts/ticket\s+(\w+)", cmd) or re.search(r"\bticket\s+(say|new|decide|subtask)\b", cmd) +verb = m.group(1) if m else "" +if verb in ("say", "decide", "subtask"): + r = re.search(r"([A-Za-z0-9_.-]+)/([A-Za-z0-9_.-]+)#(\d+)", cmd) + target = (r.group(1), r.group(2), r.group(3)) if r else ("", "", "?") +elif verb == "new": + r = re.search(r"ticket\s+new\s+([\w.-]+)", cmd) + target = ("inkstone", r.group(1) if r else "?", "new") +else: + is_post = re.search(r"\bPOST\b", cmd) or re.search(r"requests\.post|urlopen|\.post\(", cmd) + c = re.search(r"/repos/([\w.-]+)/([\w.-]+)/issues/(\d+)/comments", cmd) + n = re.search(r"/repos/([\w.-]+)/([\w.-]+)/issues\b", cmd) + if is_post and c: + target = (c.group(1), c.group(2), c.group(3)) + elif is_post and n: + target = (n.group(1), n.group(2), "new") + +if not target: + print("SKIP:not-a-ticket-write"); raise SystemExit + +owner, repo, num = target + +# ── ② 這個 session 派過人去查那張票了嗎(戳記在不在)────────────────── +stamp_dir = os.environ.get("ISEP_STAMP_DIR", "/tmp") +if num not in ("new", "?"): + if os.path.exists(os.path.join(stamp_dir, ".investigated-%s-%s_%s_%s" + % (sid, owner, repo, num))): + print("SKIP:investigated"); raise SystemExit + +body, src = evidence.body_of(cmd) + +# ── ③ 有出處嗎(放行方向的比對)────────────────────────────────── +hits = evidence.sources(body) +if hits: + print("SKIP:has-source:%s" % hits[0][0]); raise SystemExit + +# ── ④ 有份量嗎(長度是結構,不是措辭)──────────────────────────── +n_chars = evidence.substance(body) +try: + floor = int(os.environ.get("ISEP_DIAGNOSIS_FLOOR", "60")) +except Exception: + floor = 60 +if n_chars < floor: + print("SKIP:too-short:%d" % n_chars); raise SystemExit + +print("FIRE::" + json.dumps({"owner": owner, "repo": repo, "num": num, + "chars": n_chars, "src": src, "sid": sid}, + ensure_ascii=False)) +' 2>/dev/null) || VERDICT="SKIP:hook-error" +[ -n "${VERDICT:-}" ] || VERDICT="SKIP:empty" + +case "$VERDICT" in + FIRE::*) ;; + *) exit 0 ;; +esac + +REF=$(printf '%s' "${VERDICT#FIRE::}" | python3 -c ' +import json, sys +d = json.load(sys.stdin) +print("%s/%s#%s" % (d["owner"], d["repo"], d["num"]) if d["num"] not in ("new", "?") + else ("新的一張票(%s)" % d["repo"])) +' 2>/dev/null) +SID=$(printf '%s' "${VERDICT#FIRE::}" | python3 -c 'import json,sys; print(json.load(sys.stdin)["sid"])' 2>/dev/null) +KEY=$(printf '%s' "$REF" | tr -c 'A-Za-z0-9' '_') +WARNED="${ISEP_STAMP_DIR:-/tmp}/.diagnosis-guard-warned-$SID$KEY" +[ -f "$WARNED" ] && exit 0 # 同一 session 同一張票只擋一次,不鬼打牆 +date +%s > "$WARNED" 2>/dev/null || true + +DEG_REF="$REF" DEG_SID="$SID" python3 - <<'PY' >&2 +import os +print("""🔎 這段要寫上 %s 的東西,**沒有人查過,也沒有任何出處**。 + +【inkstone/ISEP#87】「身為派人去查的人,我要自己在**拿到調查結果前寫不出診斷**, + 我才不會用猜的結論把工人的判斷力關掉。」 + + · 診斷寫完了,**工人就不知道自己要做什麼**——它會照著你的結論去驗證,而不是去查 + · **你不是那個 repo 的專家,它才是**。你的結論通常是猜的, + 但因為寫在票上,看起來就像事實 + +━━ 為什麼是現在擋你(三個結構訊號,一個關鍵字都沒有)━━━━━━━━━━━━ + ① 這個 session **沒有派人去查這張票**(`investigate-first-stamp` 沒有戳記) + ② 這段話裡**找不到任何走得過去的出處** + ③ 而它**有份量**(不是一句「收到」) + +━━ 兩條出路,選一條 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + A. **先派人去查**(順序倒過來,這是本票要的) + 派工單就一行:【工單】%s + 它查完貼回票上,你再寫診斷——那時①就成立了。 + + B. **這段本來就是轉述別人查到的,那就把出處帶上**(帶了就不會被擋) + 檔案:行號  hooks/lib/evidence.py:42 + commit    8e28041 + comment 號  inkstone/ISEP#87 → comment 4327 + 網址/票號  https://git.uncle6.me/... + 實測輸出   用 ``` 圍欄把指令與輸出原樣貼上 + + 🔴 出處不是格式要求,是**讀的人可以自己走過去確認**。 + 「我記得是這樣」與「它就在那一行」的差別,只有這個分得出來。 + +真的有例外 → 指令裡加 `evidence-ok` 重送(會留在指令歷史上)。 +同一張票本 session 只擋這一次。""" % (os.environ.get("DEG_REF", ""), os.environ.get("DEG_REF", ""))) +PY +exit 2 diff --git a/hooks/dispatch-format-guard.sh b/hooks/dispatch-format-guard.sh index 3eaa097..a1bcdb1 100755 --- a/hooks/dispatch-format-guard.sh +++ b/hooks/dispatch-format-guard.sh @@ -59,6 +59,25 @@ # touch /tmp/.dispatch-ok- # 閘是絆一下逼你重想,不是把人關起來。 # +# ━━ 攔截點不只一個(inkstone/ISEP#88,2026-08-27 補)━━━━━━━━━━━━━━ +# 第一版只掛 `PreToolUse(Task|Agent)` ⇒ 只涵蓋「**新開**一個 subagent」。 +# **回覆一個正在跑的 subagent 走的是別的工具,根本不經過那個攔截點。** +# 實況:第一次派工乾乾淨淨只有票號,中途回覆時又把一長串修改要求丟過去, +# **那些話票上一個字都沒有**——那條線被停掉就消失,正是本閘要解的病。 +# +# ⇒ 不另造一支平行的閘(票上寫死):同一支閘、同一個判斷函式, +# 只是把所有通往 subagent 的路一次列全。那張表在 +# `hooks/lib/dispatch_parse.py` 的 `tool_channel()`——新增一條路加在那裡。 +# +# 兩條路只有一個地方不同: +# · `dispatch`(Task/Agent)沒有【工單】 ⇒ 放行,那是 no-ticket-no-dispatch 的地盤 +# · `reply`(SendMessage/雲端 session/`claude -p`…)沒有【工單】 ⇒ **擋**, +# 因為那支閘沒掛在這些路上,不擋就是整條裸奔 +# 刻意不管的方向:subagent 往上回報(`SendMessage to: "main"`)=交件不是派工。 +# +# 擋下來的時候會**把那段內容原文印出來**,方便直接貼上票—— +# 因為這條規則的出路就是「把它寫進票」,而不印出來就得請人回頭自己找。 +# # 測試:hooks/tests/dispatch-format-guard.test.sh(離線、不打網路、不花錢) set -u @@ -68,24 +87,45 @@ HERE="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)" FACTS="$(printf '%s' "$INPUT" | python3 "$HERE/lib/dispatch_parse.py" dispatch 2>/dev/null)" [ -n "$FACTS" ] || exit 0 # parser 掛了 → fail-open(回到「沒有這支閘」的狀態) -STATUS="$(printf '%s' "$FACTS" | python3 -c ' +# 🔴 四個欄位一次讀完,不要每個欄位各開一支 python。 +# 這不是效率問題,是**正確性**問題:每一次 `python3 -c` 都是一個新行程, +# 而每一次都用 `2>/dev/null` 吞掉錯誤 ⇒ 機器忙的時候只要有一次生不出行程, +# 那個欄位就靜靜變成空字串,閘的行為跟著變——**而且不會有任何痕跡**。 +# 實測(2026-08-28,本 repo 的測試連跑 10 次):`SESSION` 偶爾拿不到, +# 於是「明示豁免戳記」那條路 **10 次裡失敗 1 次**,症狀是「豁免了卻還是被擋」。 +# 一次讀完 ⇒ 要嘛四個都拿到、要嘛全空(全空=fail-open,回到沒有這支閘的狀態)。 +FIELDS="$(printf '%s' "$FACTS" | python3 -c ' import sys, json -try: print(json.load(sys.stdin).get("status", "skip")) -except Exception: print("skip") +try: + d = json.load(sys.stdin) +except Exception: + d = {} +print(d.get("status", "skip")) +print(d.get("session_id", "") or "") +print(len(d.get("violations", []) or [])) +print(d.get("channel", "dispatch") or "dispatch") ' 2>/dev/null)" + +STATUS=""; SESSION=""; NVIOL=""; CHANNEL="" +{ + IFS= read -r STATUS || true + IFS= read -r SESSION || true + IFS= read -r NVIOL || true + IFS= read -r CHANNEL || true +} </dev/null)" - -NVIOL="$(printf '%s' "$FACTS" | python3 -c ' -import sys, json -try: print(len(json.load(sys.stdin).get("violations", []))) -except Exception: print(0) -' 2>/dev/null)" +# 子 session(subagent)送出的**回覆**不管——它往上送的是交件不是派工 +# (見上面「刻意不管的方向」)。**只放掉 reply 這條路**: +# subagent 自己再往下派工(Task)時,派工單的規矩照樣管它。 +if [ "$CHANNEL" = "reply" ] && [ "${CLAUDE_CODE_CHILD_SESSION:-}" = "1" ]; then + exit 0 +fi # ── 明示豁免(留痕用,用掉就消失,不是永久開關)──────────────────────── if [ "$NVIOL" != "0" ] && [ -n "$SESSION" ] && [ -f "/tmp/.dispatch-ok-$SESSION" ]; then @@ -99,9 +139,10 @@ if [ "$NVIOL" != "0" ]; then # (2026-08-26 ask-user-question-guard 真的犯過:訊息裡的反引號被當命令跑, # 閘照擋,但**它教人怎麼解的那兩行變成空白**,等於沒有出路。) MSG="$(cat <<'EOF' -🚫 派工單 = 票號。就這樣。(leo 2026-08-27,inkstone/ISEP#30 comment 4327) +🚫 __HEADLINE__(leo 2026-08-27,inkstone/ISEP#30 comment 4327) __VIOLATIONS__ +__CARRY__ ━━ 這些內容該去哪:問一句「**這句話換一張票還成立嗎?**」━━━━━━━━━━ @@ -145,11 +186,34 @@ for name in d.get("retired_seen", []): print("\n".join(lines)) ' 2>/dev/null)" - DFG_MSG="$MSG" DFG_VIOL="$VIOL_TEXT" DFG_SESSION="$SESSION" python3 -c ' + # 擋下來的時候把那段內容原文印出來——出路是「貼上票」, + # 不印出來就得請人回頭自己找(inkstone/ISEP#88 驗收條件 1)。 + CARRY_TEXT="$(printf '%s' "$FACTS" | python3 -c ' +import sys, json +try: + t = (json.load(sys.stdin).get("extra_text") or "").strip() +except Exception: + t = "" +if t: + print("━━ 這段內容原文在這裡,直接貼上票 ━━━━━━━━━━━━━━━━━━━━━━━━\n") + print(t) + print() +' 2>/dev/null)" + + if [ "$CHANNEL" = "reply" ]; then + HEADLINE="回覆也是派工:內容寫進票,訊息只給票號" + else + HEADLINE="派工單 = 票號。就這樣。" + fi + + DFG_MSG="$MSG" DFG_VIOL="$VIOL_TEXT" DFG_SESSION="$SESSION" \ + DFG_HEAD="$HEADLINE" DFG_CARRY="$CARRY_TEXT" python3 -c ' import os, sys sys.stderr.write( os.environ["DFG_MSG"] + .replace("__HEADLINE__", os.environ.get("DFG_HEAD", "")) .replace("__VIOLATIONS__", os.environ.get("DFG_VIOL", "")) + .replace("__CARRY__", os.environ.get("DFG_CARRY", "")) .replace("__SESSION__", os.environ.get("DFG_SESSION", "") or "") + "\n" ) @@ -157,6 +221,10 @@ sys.stderr.write( exit 2 fi +# 回覆這條路不注入共通規定——收工方早在第一次派工時就拿過了, +# 每一則回覆再灌一次只是噪音(而噪音會讓人學會忽略整段注入)。 +[ "$CHANNEL" = "reply" ] && exit 0 + # ── 合規 → 注入共通規定 ────────────────────────────────────────────── # 這是「派工單只剩票號」的另一半:那些每次都一樣的規定不必有人記得寫,機器每次都補。 # 來源是 docs/governance/dispatch-and-reply-format.md 的 INJECT 區段—— diff --git a/hooks/hooks.json b/hooks/hooks.json index 2967f61..67b77ca 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -60,6 +60,14 @@ { "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/search-is-not-proof-guard.sh" + }, + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/hooks/diagnosis-evidence-guard.sh" + }, + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/hooks/dispatch-format-guard.sh" } ] }, @@ -153,6 +161,10 @@ "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/dispatch-format-guard.sh" }, + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/hooks/roster-guard.sh" + }, { "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/mainline-focus-guard.sh" @@ -200,6 +212,10 @@ "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/dispatch-format-guard.sh" }, + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/hooks/roster-guard.sh" + }, { "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/mainline-focus-guard.sh" @@ -210,6 +226,15 @@ } ] }, + { + "matcher": "SendMessage|.*Claude_Code_Remote__(send_message|create_session|create_trigger|update_trigger|fire_trigger|send_later).*", + "hooks": [ + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/hooks/dispatch-format-guard.sh" + } + ] + }, { "matcher": "AskUserQuestion", "hooks": [ @@ -385,6 +410,10 @@ "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/subagent-first-stamp.sh" }, + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/hooks/investigate-first-stamp.sh" + }, { "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/issue-status-autoflip.sh" @@ -394,6 +423,15 @@ "command": "${CLAUDE_PLUGIN_ROOT}/hooks/baton-handback-guard.sh" } ] + }, + { + "matcher": "SendMessage", + "hooks": [ + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/hooks/investigate-first-stamp.sh" + } + ] } ], "UserPromptSubmit": [ diff --git a/hooks/investigate-first-stamp.sh b/hooks/investigate-first-stamp.sh new file mode 100755 index 0000000..40bd694 --- /dev/null +++ b/hooks/investigate-first-stamp.sh @@ -0,0 +1,46 @@ +#!/bin/sh +# investigate-first-stamp.sh — PostToolUse(Agent|Task|SendMessage): +# 記下「這個 session 真的**派人去查過那張票**了」。 +# +# 配 diagnosis-evidence-guard.sh 使用(形狀抄自 subagent-first-stamp.sh/kbdb-asked-stamp.sh +# ——inkstone/ISEP#87 票上就是指定照那個形狀做)。只留時戳,不擋任何東西。 +# +# 🔴 為什麼按「session + 票號」分開存,不是只按 session: +# subagent-first-stamp 回答的是「這一輪有沒有派過工」(一個 session 一個答案)。 +# 本支要回答的是「**這件事**有沒有派人查過」——那是**每張票各自**的問題。 +# 只按 session 存的話,早上派過一次去查 A 票,下午就等於解鎖了 B 票的診斷, +# 這道閘會在第二次派工之後完全失效。 +set -eu + +INPUT=$(cat 2>/dev/null || echo '{}') +HERE=$(cd "$(dirname "$0")" && pwd) + +printf '%s' "$INPUT" | STAMP_LIB="$HERE/lib" python3 -c ' +import json, os, re, sys, time +sys.path.insert(0, os.environ.get("STAMP_LIB", "")) +try: + import dispatch_parse +except Exception: + raise SystemExit +try: + d = json.load(sys.stdin) +except Exception: + raise SystemExit + +sid = re.sub(r"[^A-Za-z0-9_.-]", "_", str(d.get("session_id") or "nosid"))[:64] +channel, text = dispatch_parse.tool_channel(d) +if not channel: + raise SystemExit +for ref in dispatch_parse.parse_dispatch(text)["refs"]: + # ISEP_STAMP_DIR 只給測試用(不碰 /tmp 的正式戳記)—— + # 與 diagnosis-evidence-guard.sh 讀的是同一個變數,兩支要一起改。 + stamp_dir = os.environ.get("ISEP_STAMP_DIR", "/tmp") + path = os.path.join(stamp_dir, ".investigated-%s-%s_%s_%s" + % (sid, ref["owner"], ref["repo"], ref["num"])) + try: + with open(path, "w") as f: + f.write(str(int(time.time()))) + except Exception: + pass +' 2>/dev/null || true +exit 0 diff --git a/hooks/lib/dispatch_parse.py b/hooks/lib/dispatch_parse.py index 1da6dc6..c5d4144 100644 --- a/hooks/lib/dispatch_parse.py +++ b/hooks/lib/dispatch_parse.py @@ -59,6 +59,37 @@ 第一行就要認得出是誰寫的。角色是三選一的允許清單:`總管`/`subagent`/`leo`。 2026-08-27 實害:總管寫的診斷被當成 subagent 的結論,而其中一則是錯的。 +━━ 通往 subagent 的路不只一條(inkstone/ISEP#88)━━━━━━━━━━━━━━━━ + + 第一版的閘只掛在 `PreToolUse(Task|Agent)`——那只涵蓋「**新開**一個 subagent」。 + **回覆一個正在跑的 subagent 走的是別的工具,根本不經過那個攔截點。** + 實況(2026-08-27 本 session):第一次派工乾乾淨淨只有票號, + 中途回覆時又把一長串修改要求直接丟過去,**那些話票上一個字都沒有**。 + 那條線被停掉或換人接手,它們就消失——正是壓縮派工單原本要解決的問題。 + + ⇒ 本檔不再只認一種 payload 形狀。`tool_channel()` 把所有通往 subagent 的路 + 收成一張表(見該函式),一次列全: + + Task / Agent 新開一個 subagent → prompt + SendMessage 回覆正在跑的那個 → message + *Claude_Code_Remote__create_session 開一個新的雲端 session → prompt + *Claude_Code_Remote__send_message 送訊息進某個 session → text / message + *Claude_Code_Remote__create_trigger 排程派工(未來會醒) → prompt + *Claude_Code_Remote__update_trigger 改排程派工的內容 → prompt + *Claude_Code_Remote__fire_trigger 當場點燃排程派工 → text + *Claude_Code_Remote__send_later 排一則訊息給自己 → message + Bash `claude -p ` 用 CLI 開一個新 session → 指令裡那段 prompt + + 🔴 這張表就是 `ticket-api-bypass-guard.sh` 檔頭記過的那一課的同款: + 「認動作的方式漏了一條路」是這一族的通病(那支第一版只認大寫裸字 `POST`, + 於是 `requests.post()` 與 urllib 的隱式 POST 全部漏掉)。 + ⇒ 新增一條路要加在 `tool_channel()`,**不要另造一支平行的閘**。 + + 兩條路的分界只有一個(其餘判準完全共用,這是刻意的): + · `dispatch`(Task/Agent)沒有【工單】 ⇒ **skip**,那是 no-ticket-no-dispatch 的地盤 + · `reply`(其餘全部)沒有【工單】 ⇒ **違規**,因為那支閘沒有掛在這些路上, + 不擋就等於這條路整條裸奔 + 用法: import dispatch_parse r = dispatch_parse.parse_dispatch(prompt_text) @@ -69,6 +100,7 @@ """ import json import re +import shlex import sys # ── 派工單的字彙表:**只有一個欄位**。這是允許清單,不是黑名單 ────────────── @@ -206,6 +238,22 @@ def dispatch_violations(parsed): return v +def identity_roles(): + """合法的身份角色 = 三個固定角色 + **名單上的工人名字**(inkstone/ISEP#86)。 + + leo:「票上的紀錄要看得出是哪個工人做的。」`subagent` 這個字回答不了「是誰」—— + 多條線並行時,三個 subagent 的留言長得一模一樣。 + 名單讀不到就退回三個固定角色(fail-open:名單壞掉不該讓人貼不了留言)。 + """ + try: + import os + sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + import roster + return tuple(IDENTITY_ROLES) + tuple(roster.names()) + except Exception: + return tuple(IDENTITY_ROLES) + + def parse_identity(body): """交件回覆的【身份】欄。回 (ok, detail)。""" for raw in (body or "").split("\n"): @@ -218,12 +266,105 @@ def parse_identity(body): if not val: return False, "【身份】後面是空的" role = re.split(r"[//]", val)[0].strip() - if role not in IDENTITY_ROLES: - return False, "角色「%s」不在 %s 之內" % (role or "(空)", "/".join(IDENTITY_ROLES)) + allowed = identity_roles() + if role not in allowed: + return False, "角色「%s」不在 %s 之內" % (role or "(空)", "/".join(allowed)) return True, val return False, "內文是空的" +# ── 通往 subagent 的路:一張表,一次列全(inkstone/ISEP#88)──────────────── +# 新增一條路加在這裡。**不要另造一支平行的閘**——票上寫死了: +# 「要嘛把攔截點加掛上去,要嘛讓兩條路共用同一個判斷函式。」 +# +# 值是「這個工具的哪幾個欄位裝著要送給 subagent 的話」,依序取第一個有內容的。 +_DISPATCH_TOOLS = { + "Task": ("prompt",), + "Agent": ("prompt",), +} +_REPLY_TOOLS = { + "SendMessage": ("message",), + "create_session": ("prompt",), + "send_message": ("text", "message"), + "create_trigger": ("prompt",), + "update_trigger": ("prompt",), + "fire_trigger": ("text",), + "send_later": ("message",), +} +# `claude -p ` = 用 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 取出來。取不到回 ""。""" + if not command or not _CLAUDE_CLI_RE.search(command): + return "" + try: + toks = shlex.split(command) + except Exception: + return "" + for i, t in enumerate(toks): + if t.rsplit("/", 1)[-1] != "claude": + continue + rest = toks[i + 1:] + if not any(f in rest for f in _CLI_PRINT_FLAGS): + return "" # 沒有 -p ⇒ 不是一次性的 prompt 執行 + skip = False + for j, tok in enumerate(rest): + if skip: + skip = False + continue + if tok in _CLI_VALUE_FLAGS: + skip = True + continue + if tok.startswith("-"): + continue + return tok # 第一個位置參數 = prompt + return "" + return "" + + +def tool_channel(payload): + """回 (channel, text)。channel ∈ {"dispatch", "reply", ""}。 + + "" = 這個工具呼叫不是在驅動任何 subagent,本閘管不到。 + """ + name = payload.get("tool_name") or "" + ti = payload.get("tool_input") or {} + if not isinstance(ti, dict): + return "", "" + short = name.rsplit("__", 1)[-1] + + if name == "Bash" or short == "Bash": + return ("reply", _claude_cli_prompt(ti.get("command") or "")) + + # 🔴 方向很重要:本規則管的是「**派工的人**送出去的話」。 + # subagent 往上回報(`to: "main"`)是**交件**,不是派工—— + # 擋它等於擋掉交件本身。交件的規矩由 baton-handback-guard/ + # reply-identity-guard 管,不在這裡。 + if (name == "SendMessage" or short == "SendMessage") and \ + str(ti.get("to") or "").strip().lower() == "main": + return "", "" + + for table, channel in ((_DISPATCH_TOOLS, "dispatch"), (_REPLY_TOOLS, "reply")): + keys = table.get(name) or table.get(short) + if not keys: + continue + for k in keys: + v = ti.get(k) + if isinstance(v, str) and v.strip(): + return channel, v + return channel, "" + return "", "" + + # ── CLI ────────────────────────────────────────────────────────────────── def _main(): mode = sys.argv[1] if len(sys.argv) > 1 else "dispatch" @@ -237,21 +378,39 @@ def _main(): print(json.dumps({"status": "skip", "why": "未知模式 %s" % mode})) return - prompt = (payload.get("tool_input") or {}).get("prompt") or "" - parsed = parse_dispatch(prompt) - - # 連【工單】都沒有 => 那是 no-ticket-no-dispatch.sh 的地盤,本閘閉嘴。 - # 兩支閘同時對同一件事開口,收工方會拿到兩份互相打架的教學。 - if not parsed["has_ticket_marker"]: - print(json.dumps({"status": "skip", "why": "沒有【工單】,交給 no-ticket-no-dispatch"})) + channel, text = tool_channel(payload) + if not channel: + print(json.dumps({"status": "skip", "why": "不是通往 subagent 的路"})) return + if channel == "reply" and not (text or "").strip(): + print(json.dumps({"status": "skip", "why": "這條路這次沒有帶任何話"})) + return + + parsed = parse_dispatch(text) + + if not parsed["has_ticket_marker"]: + if channel == "dispatch": + # 連【工單】都沒有 => 那是 no-ticket-no-dispatch.sh 的地盤,本閘閉嘴。 + # 兩支閘同時對同一件事開口,收工方會拿到兩份互相打架的教學。 + print(json.dumps({"status": "skip", + "why": "沒有【工單】,交給 no-ticket-no-dispatch"})) + return + # reply 這條路上**沒有**那支閘(它只掛 Task|Agent)⇒ 不擋就整條裸奔。 + violations = [{"code": "回覆沒有票號", + "detail": "回覆也是派工:內容寫進票,訊息只給【工單】owner/repo#N"}] + extra_text = (text or "").strip() + else: + violations = [{"code": c, "detail": d} for c, d in dispatch_violations(parsed)] + extra_text = "\n".join(r.rstrip() for _, r in parsed["extra"]).strip() print(json.dumps({ "status": "ok", - "violations": [{"code": c, "detail": d} for c, d in dispatch_violations(parsed)], + "channel": channel, + "violations": violations, "refs": parsed["refs"], "retired_seen": parsed["retired_seen"], "extra_lines": parsed["extra_lines"], + "extra_text": extra_text, "session_id": payload.get("session_id") or "", }, ensure_ascii=False)) diff --git a/hooks/lib/evidence.py b/hooks/lib/evidence.py new file mode 100644 index 0000000..f67ee3c --- /dev/null +++ b/hooks/lib/evidence.py @@ -0,0 +1,123 @@ +#!/usr/bin/env python3 +"""hooks/lib/evidence.py — 「這段要寫上票的內容,帶不帶得出出處?」(inkstone/ISEP#87) + +依 inkstone/ISEP#40 S7 的慣例:`lib/` 裡的東西是共用零件,本身不決定 allow/block, +判決由呼叫它的閘做(`hooks/diagnosis-evidence-guard.sh`)。 + +━━ 規則本身(leo,inkstone/ISEP#87)━━━━━━━━━━━━━━━━━━━━━━━━━ + + 「身為派人去查的人,我要自己在拿到調查結果前寫不出診斷, + 我才不會用猜的結論把工人的判斷力關掉。」 + + 票上原文的兩個壞法: + · 診斷寫完了,**工人就不知道自己要做什麼**——它會照著我的結論去驗證,而不是去查 + · **我不是那個 repo 的專家,它才是**。我的結論通常是猜的, + 但因為寫在票上,看起來就像事實 + + ⇒ 順序倒過來:**先派人查 → 拿到查的結果 → 才可以寫診斷。** + +━━ 🔴 為什麼判準不能是「找出診斷的句型」━━━━━━━━━━━━━━━━━━━━ + 票上寫死:「『根因』『因為』『應該是』這類詞**一律不准當判準**」。 + leo 2026-08-17 已證偽文字層封路:當日 **8 次誤攔、0 次正確攔截**,而且方向穩定—— + **紅線寫得越細,命中關鍵字的機率越高 ⇒ 那些閘在懲罰謹慎。** + + 所以本檔**沒有任何「命中某個詞就違規」的比對**。它只做一件事: + **認出「出處」在不在**——而且是**往放行的方向**做字面比對 + (認出來 ⇒ 放行;認不出來 ⇒ 交給閘去看別的結構訊號)。 + 這是 `pr-verdict-guard.sh` ③ 用過的形狀:識別碼有限且唯一, + 比對錯了只會**少擋一次**,不會多罰一次。 + +━━ 什麼算「出處」(七種,全部是可以走過去看的東西)━━━━━━━━━━━━━━ + 檔案:行號 / commit sha / comment 號 / 票號 / 網址 / + 檔案路徑(帶目錄與副檔名)/ 圍欄裡的實測輸出 + + 共同點:**讀的人可以自己走過去確認**。這正是「轉述別人查到的東西」與 + 「我猜的」之間唯一機械分得出來的差別(票上驗收條件 4:有出處的轉述不能被誤擋)。 + +用法: + import evidence + hits = evidence.sources(text) # [(種類, 命中的字串), ...] + body = evidence.body_of(command) # 從一段 shell 指令裡把「要貼上去的內文」挖出來 +""" +import os +import re + +# ── 七種出處。**方向是放行**:認出來就代表這段話走得回它的來源 ────────────── +_SOURCE_PATTERNS = [ + ("檔案:行號", re.compile(r"[\w./-]+\.\w{1,6}:\d+")), + ("commit", re.compile(r"\b[0-9a-f]{7,40}\b")), + ("comment 號", re.compile(r"(?:#issuecomment-|comment\s*)\d{2,}", re.I)), + ("票號", re.compile(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+#\d+")), + ("網址", re.compile(r"https?://\S{4,}")), + ("檔案路徑", re.compile(r"(?` 就讀那個檔, +# 否則就拿指令文字本身當內文(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" diff --git a/hooks/lib/roster.py b/hooks/lib/roster.py new file mode 100644 index 0000000..2df1ac9 --- /dev/null +++ b/hooks/lib/roster.py @@ -0,0 +1,111 @@ +#!/usr/bin/env python3 +"""hooks/lib/roster.py — 工人名單的**唯一讀取點**(inkstone/ISEP#86)。 + +依 inkstone/ISEP#40 S7 的慣例:`lib/` 裡的東西是共用零件,本身不決定 allow/block, +判決由呼叫它的閘做(`hooks/roster-guard.sh`、`scripts/roster`、 +`hooks/lib/dispatch_parse.py` 的身份欄)。 + +━━ 規則本身(leo,inkstone/ISEP#86)━━━━━━━━━━━━━━━━━━━━━━━━━ + + 「身為派工的人,我要工人有名字,我才知道票上這件事到底是誰做的。」 + + 實查(開票當日):`/root/.claude/agents/` 不存在、`InkStoneCo/.claude/agents/` + 只有一支內建的說明用 agent ⇒ **派工派給的是一個沒有名字的臨時工**。 + 後果三件:票上看不出誰做的/每次派工都要重新交代它是誰(而派工單只准寫票號)/ + 同一個 repo 這次派的跟上次派的沒有任何連續性。 + +━━ 為什麼名單住在 `agents/`,不是自己造一份 YAML ━━━━━━━━━━━━━━━━ + `agents/` 是 Claude Code plugin **原生**的 subagent 目錄,frontmatter 的 `name` + 就是 `Task` 工具的 `subagent_type`。⇒ 「指名派給誰」用的是既有欄位, + **派工單格式一個字都不用改**(仍然只有一行 `【工單】`)。 + 這也是本 repo 的慣例:不要重造輪子(見 CLAUDE.md 2.6「為什麼是 issue 不是 Notion」)。 + +━━ 檔案格式(四段,缺一不可)━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + --- + name: isep-hand ← 派工時寫進 subagent_type 的那個字 + description: 一句話它是誰 ← `roster list` 印的就是這一行 + --- + **負責 repo**:inkstone/ISEP ← 挑人的判準,也是【身份】欄第二格 + ## 開工前先讀 ← 每個工人各自不同,共通規定補不了 + ## 紅線 ← 只寫這個 repo 特有的 + +用法: + import roster + ws = roster.load() # [{'name','description','repo','path','body'}] + w = roster.find("isep-hand") # 找不到回 None + roster.names() # ['arcrun-hand', ...] +""" +import os +import re + +_HERE = os.path.dirname(os.path.abspath(__file__)) +# hooks/lib/ → hooks/ → plugin root +_DEFAULT_ROOT = os.path.abspath(os.path.join(_HERE, "..", "..")) + +_NAME_RE = re.compile(r"^name:\s*(.+?)\s*$", re.M) +_DESC_RE = re.compile(r"^description:\s*(.+?)\s*$", re.M) +# 「**負責 repo**:inkstone/ISEP」——粗體記號與全形冒號都當可選, +# 免得改一次排版就讓名單讀不到 repo(那會靜默失效,比擋錯更難發現)。 +_REPO_RE = re.compile(r"\*{0,2}負責\s*repo\*{0,2}\s*[::]\s*([^\s((]+)") + + +def roster_dir(): + """名單目錄。`ISEP_ROSTER_DIR` 只給測試用(不碰真的名單)。""" + d = os.environ.get("ISEP_ROSTER_DIR") + if d: + return d + root = os.environ.get("CLAUDE_PLUGIN_ROOT") or _DEFAULT_ROOT + return os.path.join(root, "agents") + + +def load(directory=None): + """讀出整份名單。讀不到目錄就回空清單(**不丟例外**—— + 呼叫它的閘要能在名單不在場時 fail-open,不能把 session 鎖死)。""" + d = directory or roster_dir() + out = [] + try: + files = sorted(f for f in os.listdir(d) if f.endswith(".md")) + except Exception: + return out + for fn in files: + path = os.path.join(d, fn) + try: + with open(path, encoding="utf-8", errors="ignore") as f: + text = f.read() + except Exception: + continue + head, _, body = text.partition("---\n")[2].partition("\n---") + if not head: # 沒有 frontmatter ⇒ 不是工人檔 + continue + m = _NAME_RE.search(head) + if not m: + continue + dm = _DESC_RE.search(head) + rm = _REPO_RE.search(body) + out.append({ + "name": m.group(1).strip(), + "description": (dm.group(1).strip() if dm else ""), + "repo": (rm.group(1).strip() if rm else ""), + "path": path, + "body": body.strip(), + }) + return out + + +def names(directory=None): + return [w["name"] for w in load(directory)] + + +def find(name, directory=None): + n = (name or "").strip() + if not n: + return None + for w in load(directory): + if w["name"] == n: + return w + return None + + +def one_liner(w): + repo = w.get("repo") or "(跨 repo)" + return "%-18s %-26s %s" % (w["name"], repo, w.get("description", "")) diff --git a/hooks/roster-guard.sh b/hooks/roster-guard.sh new file mode 100755 index 0000000..fb17502 --- /dev/null +++ b/hooks/roster-guard.sh @@ -0,0 +1,186 @@ +#!/usr/bin/env bash +# 管什麼: 派工(帶【工單】的 Task/Agent)沒有指名工人、或指了名單上沒有的名字 → 擋; +# 指對了就把那位工人的檔案原文注入給它(它是誰、先讀什麼、紅線)。 +# 為什麼: 派工派給「一個 agent」=票上看不出這件事是誰做的,也沒有任何連續性。 +# 誤觸時怎麼關: `python3 scripts/roster list` 挑一個名字放進 subagent_type; +# 真的沒有合適的人 → touch /tmp/.roster-ok- 放行一次(用完即丟)。 +# +# roster-guard.sh — PreToolUse(Task|Agent):**工人要有名字**(inkstone/ISEP#86) +# +# ━━ 來由(leo,inkstone/ISEP#86)━━━━━━━━━━━━━━━━━━━━━━━━━━━ +# 「身為派工的人,我要工人有名字,我才知道票上這件事到底是誰做的。」 +# +# 實查(開票當日):`/root/.claude/agents/` 不存在、`InkStoneCo/.claude/agents/` +# 只有一支內建的說明用 agent ⇒ 派工派給的是**沒有名字的臨時工**。後果三件: +# · 票上只看得到「有個 agent 做了」,看不出是誰 +# · 每次派工都要重新交代它是誰、該讀什麼——**而派工單只准寫票號** +# (`dispatch-format-guard.sh`,ISEP v0.5.0) +# · 同一個 repo 這次派的跟上次派的沒有任何連續性 +# +# ━━ 這支跟 dispatch-format-guard.sh 的分界(兩支不會同時開口)━━━━━━━━ +# dispatch-format-guard 管**派工單的內文**(只准有【工單】那一行)。 +# 本支管**派給誰**——那是 `Task` 的 `subagent_type` 欄,**不在內文裡**。 +# ⇒ 兩者正交:加上這道閘,派工單格式一個字都不用改。 +# +# 邊界同 dispatch-format-guard:派工單裡**沒有**【工單】時本閘閉嘴, +# 那是 `no-ticket-no-dispatch.sh` 的地盤(沒票的東西還輪不到談派給誰)。 +# +# ━━ 為什麼過得了「封動作不封文字」的檢驗(leo 2026-08-17)━━━━━━━━━ +# 判準是「這個名字**在不在名單裡**」——一次字面比對,方向是**放行** +# (在名單裡 ⇒ 放行)。沒有任何「命中某個詞就違規」的比對。 +# 名字是唯一識別碼、集合有限且可枚舉,正是 v0.11.0 那支閘用的形狀。 +# +# ━━ 這支同時做兩件事 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +# ① **擋**:沒指名/指了不存在的名字 → exit 2,並把整份名單印出來 +# ② **注入**:指對了 → exit 0,把那位工人的檔案原文送給它 +# (慣例同 dispatch-format-guard.sh 的共通規定注入) +# ⇒ 「你是誰、開工前先讀什麼、你的紅線」**不必寫進派工單**,機器每次都補。 +# 這正是 ISEP#86 說的那件共通規定補不了的事——它每個工人都不一樣。 +# +# ━━ fail-open 的界線 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +# 名單目錄讀不到/解析器掛了 → **放行**(回到「沒有這支閘」的狀態)。 +# 一份讀不到的名單不該讓整台機器派不了工。 +# +# 逃生口(留痕、用完即丟):touch /tmp/.roster-ok- +# +# 測試: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 + +━━ 怎麼派 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + 把名字放進 Task 的 `subagent_type`。**派工單本身不用改**—— + 它仍然只有一行【工單】(`dispatch-format-guard.sh` 沒有變)。 + 指對名字之後,本閘會把那位工人的檔案原文注入給它: + 它是誰、開工前先讀什麼、它的紅線是什麼。 + ⇒ **這些不必寫進派工單**,也不可能寫進共通規定(每個工人都不一樣)。 + +━━ 名單上真的沒有合適的人 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + 加一個工人 = 在 `agents/` 加一個 `.md`(照既有檔案的四段: + frontmatter 的 name/description、`**負責 repo**`、`## 開工前先讀`、`## 紅線`)。 + **不必改任何 hook**——名單是資料,閘讀的是那個目錄。 + 規約:docs/governance/worker-roster.md + +真的有例外 → `touch /tmp/.roster-ok-__SESSION__` 後重送(用掉就消失,會留在指令歷史上)。 +EOF +)" + if [ -z "$PICKED" ]; then + WHO="這次派工**沒有指名工人**(\`subagent_type\` 是空的)" + else + WHO="名單上**沒有「$PICKED」這個人**" + fi + RG_MSG="$MSG" RG_WHO="$WHO" RG_ROSTER="$ROSTER_TEXT" RG_SESSION="$SESSION" python3 -c ' +import os, sys +sys.stderr.write( + os.environ["RG_MSG"] + .replace("__WHO__", os.environ.get("RG_WHO", "")) + .replace("__ROSTER__", os.environ.get("RG_ROSTER", "")) + .replace("__SESSION__", os.environ.get("RG_SESSION", "") or "") + + "\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 diff --git a/hooks/tests/diagnosis-evidence-guard.test.sh b/hooks/tests/diagnosis-evidence-guard.test.sh new file mode 100755 index 0000000..915acf0 --- /dev/null +++ b/hooks/tests/diagnosis-evidence-guard.test.sh @@ -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> <說明> <指令> [必須出現的字串] +t() { + want="$1"; desc="$2"; sid="$3"; cmd="$4"; must="${5:-}" + N=$((N+1)) + err=$(bash_payload "$sid" "$cmd" | bash "$HOOK" 2>&1 >/dev/null); rc=$? + ok=1 + [ "$rc" -eq "$want" ] || ok=0 + if [ -n "$must" ] && ! printf '%s' "$err" | grep -qF "$must"; then ok=0; fi + if [ "$ok" -eq 1 ]; then printf ' ✅ %s\n' "$desc"; PASS=$((PASS+1)) + else + printf ' ❌ %s —— 期望 exit=%s%s,實得 exit=%s\n' "$desc" "$want" \ + "${must:+ 且訊息含「$must」}" "$rc" + printf '%s\n' "$err" | sed -n '1,8p' | sed 's/^/ /' + FAIL=$((FAIL+1)) + fi +} + +# 派一次工去查某張票(=讓戳記出現) +dispatch() { + python3 - "$1" "$2" <<'PY' | bash "$STAMP" +import json, sys +print(json.dumps({"session_id": sys.argv[1], "tool_name": "Task", + "tool_input": {"prompt": "【工單】%s" % sys.argv[2]}}, + ensure_ascii=False)) +PY +} + +# ── 測資:三份內文 ──────────────────────────────────────────────── +# ① 沒查過的診斷(0 個出處) +cat > "$TMP/diagnosis.md" <<'EOF' +【身份】總管/inkstone/ISEP/- +根因是 KV 額度爆掉,所以執行紀錄寫不進去;我判斷應該改用另一顆資料庫來存, +這樣就不會再撞到額度上限了。這件事拖了很久一直沒解,先照這個方向做下去。 +EOF +# ② 同一段話,但帶著出處(轉述別人查到的) +cat > "$TMP/relayed.md" <<'EOF' +【身份】總管/inkstone/ISEP/- +scout 查回來的:寫入那段在 hooks/lib/evidence.py:42,額度是在那裡爆的, +出處 inkstone/Arcrun#142 → comment 4327。我只是把它的結論搬過來,沒有自己加判斷。 +EOF +# ③ 一段**一個「根因/因為/應該是」都沒有**的長內容,同樣沒有出處 +cat > "$TMP/no-keyword.md" <<'EOF' +【身份】總管/inkstone/ISEP/- +那個匯入流程跑不動是設定檔的問題,換掉那個欄位就會通了,我看過一次有印象。 +先照這個方向改,改完應該就不會再出現同樣的狀況了,不用另外再查。 +EOF +# ④ 短回覆 +SHORT="收到,我來看" + +echo "── 驗收 1+3:沒人查過、也沒出處 ⇒ 擋,且說得出「還沒派人查」──" + +t 2 "① 貼一段沒人查過的診斷到票上 ⇒ 擋" \ + S-A "python3 scripts/ticket say inkstone/ISEP#87 -F $TMP/diagnosis.md" +t 2 "② 訊息講得出「這個 session 沒有派人去查這張票」(不是籠統的「格式不對」)" \ + S-B "python3 scripts/ticket say inkstone/ISEP#87 -F $TMP/diagnosis.md" \ + '沒有派人去查這張票' +t 2 "③ 訊息要點名是哪一張票(人才知道該派誰去查什麼)" \ + S-C "python3 scripts/ticket say inkstone/ISEP#87 -F $TMP/diagnosis.md" 'inkstone/ISEP#87' +t 2 "④ 訊息要給兩條出路:先派人查/把出處帶上" \ + S-D "python3 scripts/ticket say inkstone/ISEP#87 -F $TMP/diagnosis.md" '先派人去查' +t 2 "⑤ 開新票也一樣(新票天生沒人查過,`ticket new`)" \ + S-E "python3 scripts/ticket new ISEP -F $TMP/diagnosis.md --title 身為X我要Y我才Z" +t 2 "⑥ 側門:直接打 Gitea API POST 留言 ⇒ 同樣擋(認動作的方式不准漏一條路)" \ + S-F "curl -X POST -H 'Authorization: token T' -d '{\"body\":\"根因是 KV 額度爆掉,所以執行紀錄寫不進去;我判斷應該改用另一顆資料庫來存,這樣就不會再撞到額度上限了,這件事拖了很久一直沒解,先照這個方向做下去不要再查了\"}' https://git.uncle6.me/api/v1/repos/inkstone/ISEP/issues/87/comments" +t 2 "⑦ 側門:隱式 POST(requests.post)也要認得" \ + S-G "python3 -c \"requests.post(url + '/repos/inkstone/ISEP/issues/87/comments', json={'body': '根因是 KV 額度爆掉,所以執行紀錄寫不進去;我判斷應該改用另一顆資料庫來存,這樣就不會再撞到額度上限了,這件事拖了很久一直沒解,先照這個方向做下去不要再查了'})\"" + +echo "── 驗收 2:派人查過 ⇒ 放行 ─────────────────────────────────" + +dispatch S-H inkstone/ISEP#87 +t 0 "⑧ 同一 session 派過人去查 #87 ⇒ 同一段診斷放行" \ + S-H "python3 scripts/ticket say inkstone/ISEP#87 -F $TMP/diagnosis.md" + +dispatch S-I inkstone/ISEP#86 +t 2 "⑨ 派的是**別張票**(#86)⇒ #87 仍然沒人查過 ⇒ 照擋(戳記按票分,不是按 session)" \ + S-I "python3 scripts/ticket say inkstone/ISEP#87 -F $TMP/diagnosis.md" + +echo "── 驗收 4:轉述有出處 ⇒ 不能被誤擋 ─────────────────────────" + +t 0 "⑩ 帶檔案:行號+票號+comment 號的轉述 ⇒ 放行" \ + S-J "python3 scripts/ticket say inkstone/ISEP#87 -F $TMP/relayed.md" + +for pair in "網址:https://git.uncle6.me/inkstone/ISEP/pulls/97" \ + "commit:8e28041" \ + "檔案路徑:hooks/lib/dispatch_parse.py"; do + kind="${pair%%:*}"; token="${pair#*:}" + printf '【身份】總管/inkstone/ISEP/-\n那段行為我核對過了,寫在 %s,跟票上寫的不一樣,所以下一步要照那邊的實況走,先不要動別的地方。\n' \ + "$token" > "$TMP/one-source.md" + t 0 "⑪ 出處只有一個「$kind」也算數 ⇒ 放行" \ + "S-K-$kind" "python3 scripts/ticket say inkstone/ISEP#87 -F $TMP/one-source.md" +done + +printf '【身份】總管/inkstone/ISEP/-\n實測輸出如下,這是它自己跑出來的,不是我推的:\n```\n$ bash hooks/tests/x.sh\n通過 20 條,失敗 0 條\n```\n' > "$TMP/fenced.md" +t 0 "⑫ 貼實測輸出(圍欄)也算出處 ⇒ 放行" \ + S-L "python3 scripts/ticket say inkstone/ISEP#87 -F $TMP/fenced.md" + +echo "── 判準不是措辭(本閘沒有關鍵字比對)────────────────────────" + +t 2 "⑬ 一個「根因/因為/應該是」都沒有的內容,照樣擋(判準是結構不是詞)" \ + S-M "python3 scripts/ticket say inkstone/ISEP#87 -F $TMP/no-keyword.md" + +echo "── 不該擋(誤攔比漏擋嚴重)──────────────────────────────────" + +t 0 "⑭ 一句短回覆(「$SHORT」)⇒ 不可能是診斷,放行" \ + S-N "python3 scripts/ticket say inkstone/ISEP#87 --body '$SHORT'" +t 0 "⑮ GET 撈票(唯讀)⇒ 放行" \ + S-O "curl -s https://git.uncle6.me/api/v1/repos/inkstone/ISEP/issues/87" +t 0 "⑯ ticket where(搜尋)⇒ 不是寫票,放行" \ + S-P "python3 scripts/ticket where 診斷 調查 順序" +t 0 "⑰ ticket close(關票)⇒ 不是寫內文,放行" \ + S-Q "python3 scripts/ticket close inkstone/ISEP#87 --deliverable https://git.uncle6.me/x" +t 0 "⑱ 一般指令(ls)⇒ 一律不管" S-R "ls -la" +t 0 "⑲ 逃生口 evidence-ok 在指令裡 ⇒ 放行(留痕)" \ + S-S "python3 scripts/ticket say inkstone/ISEP#87 -F $TMP/diagnosis.md # evidence-ok" + +echo "── 只擋一次,不鬼打牆 ──────────────────────────────────────" + +t 2 "⑳ 第一次 ⇒ 擋" S-T "python3 scripts/ticket say inkstone/ISEP#87 -F $TMP/diagnosis.md" +t 0 "㉑ 同一 session 同一張票第二次 ⇒ 放行(閘是絆一下,不是把人關起來)" \ + S-T "python3 scripts/ticket say inkstone/ISEP#87 -F $TMP/diagnosis.md" +t 2 "㉒ 同一 session **換一張票** ⇒ 仍然擋(不是一次就永久解鎖)" \ + S-T "python3 scripts/ticket say inkstone/Arcrun#142 -F $TMP/diagnosis.md" + +echo "── 內部壞掉不准把人鎖死 ────────────────────────────────────" + +t 0 "㉓ 整包不是合法 JSON ⇒ fail-open" S-U '' +N=$((N+1)) +err=$(printf 'not json at all' | bash "$HOOK" 2>&1 >/dev/null); rc=$? +if [ "$rc" -eq 0 ]; then printf ' ✅ ㉔ payload 不是 JSON ⇒ fail-open\n'; PASS=$((PASS+1)) +else printf ' ❌ ㉔ payload 壞掉時擋住了(exit=%s)\n' "$rc"; FAIL=$((FAIL+1)); fi + +echo +printf '結果:%s 通過 / %s 失敗(共 %s 條)\n' "$PASS" "$FAIL" "$N" +[ "$FAIL" -eq 0 ] || exit 1 diff --git a/hooks/tests/dispatch-format-guard.test.sh b/hooks/tests/dispatch-format-guard.test.sh index 9f50996..6549282 100755 --- a/hooks/tests/dispatch-format-guard.test.sh +++ b/hooks/tests/dispatch-format-guard.test.sh @@ -32,6 +32,19 @@ print(json.dumps({"session_id": "S-TEST", "hook_event_name": "PreToolUse", PY } +# reply_payload <裝話的欄位名> <內容> [額外欄位 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> <說明> t() { want="$1"; desc="$2"; body="$3" @@ -222,6 +235,90 @@ else printf ' ❌ ⑱ 訊息沒有點名違反哪一條\n'; FAIL=$((FAIL+1)) fi +echo +echo "── D 群:回覆也是派工(inkstone/ISEP#88)────────────────────────" +clean + +# 驗收 1:回覆一個正在跑的 subagent,訊息裡夾一段修改要求 ⇒ 擋 +t 2 "⑲ **驗收 1**:SendMessage 夾了一段修改要求 ⇒ 擋" \ + "$(reply_payload SendMessage message '順便把 hooks.json 也改一下,改完重跑一次測試貼給我' '{"to":"isep-hand"}')" + +# 驗收 2:內容貼上票之後,回覆只給票號 ⇒ 放行 +t 0 "⑳ **驗收 2**:內容貼上票之後,回覆只有票號 ⇒ 放行" \ + "$(reply_payload SendMessage message '【工單】inkstone/ISEP#88 → comment 5001' '{"to":"isep-hand"}')" + +# 驗收 1 的後半:擋的時候要把那段內容原文印出來(不然人得回頭自己找) +N=$((N+1)) +rmsg=$(printf '%s' "$(reply_payload SendMessage message '去 main 分支重驗那三支 hook,把輸出貼回來' '{"to":"scout"}')" \ + | bash "$HOOK" 2>&1 >/dev/null) +if printf '%s' "$rmsg" | grep -q '直接貼上票' \ + && printf '%s' "$rmsg" | grep -q '去 main 分支重驗那三支 hook'; then + printf ' ✅ ㉑ 擋下來時把那段內容**原文**印出來,可以直接貼上票\n'; PASS=$((PASS+1)) +else + printf ' ❌ ㉑ 沒有把原文印出來(出路要求「寫進票」,找不到原文就得回頭自己翻)\n' + printf '%s\n' "$rmsg" | sed -n '1,10p' | sed 's/^/ /' + FAIL=$((FAIL+1)) +fi + +N=$((N+1)) +if printf '%s' "$rmsg" | grep -q '回覆也是派工'; then + printf ' ✅ ㉒ 訊息說得出這是「回覆」那條路,不是照抄派工單那段\n'; PASS=$((PASS+1)) +else + printf ' ❌ ㉒ 回覆被擋時給的是派工單的說法,人會找不到自己違反了什麼\n'; FAIL=$((FAIL+1)) +fi + +# 真跡重演(驗收 4):那兩則回覆的原文只活在總管的對話裡,拿不回來; +# 這裡改用**同一天真的寫進派工單的那幾段內容**(fixtures/README.md 記著來歷), +# 從**回覆這條路**再送一次——形狀一樣:一大段這次才知道的事實,票上一個字都沒有。 +for fx in real-content-isep60 real-content-arcrun142 real-content-arcrunrag104; do + t 2 "㉓ 真跡($fx)從回覆這條路送出去 ⇒ 一樣擋" \ + "$(reply_payload SendMessage message "$(cat "$FIX/$fx.txt")" '{"to":"isep-hand"}')" +done + +echo "── E 群:通往 subagent 的路要列全(一條都不能漏)──────────────" + +t 2 "㉔ 雲端開新 session(create_session 的 prompt)" \ + "$(reply_payload mcp__Claude_Code_Remote__create_session prompt '去把那個 worker 修好,順便升版')" +t 2 "㉕ 送訊息進某個 session(send_message 的 text)" \ + "$(reply_payload mcp__Claude_Code_Remote__send_message text '改一下剛剛那段,然後重跑測試')" +t 2 "㉖ 排程派工(create_trigger 的 prompt)" \ + "$(reply_payload mcp__Claude_Code_Remote__create_trigger prompt '每天早上去檢查 PR 有沒有結論,有的話處理掉')" +t 2 "㉗ 當場點燃排程派工(fire_trigger 的 text)" \ + "$(reply_payload mcp__Claude_Code_Remote__fire_trigger text '這次順便把 bundle 也重打一次')" +t 2 "㉘ 排一則訊息給自己(send_later 的 message)" \ + "$(reply_payload mcp__Claude_Code_Remote__send_later message '記得回頭把那三支 hook 的測試補齊')" +t 2 "㉙ 用 CLI 開一個新 session(Bash 的 claude -p)" \ + '{"session_id":"S-TEST","tool_name":"Bash","tool_input":{"command":"claude -p '"'"'去把 arcrun 那個 worker 修一修'"'"'"}}' + +echo "── F 群:這幾條**不該**被這支閘碰(誤攔比漏擋嚴重)─────────────" + +t 0 "㉚ subagent 往上回報(SendMessage to: main)=交件不是派工 ⇒ 不管" \ + "$(reply_payload SendMessage message '做完了,分支是 feat/x,測試 26/26' '{"to":"main"}')" +t 0 "㉛ 一般的 Bash 指令裡剛好有【工單】字樣(判準是指令名不是內容)⇒ 不管" \ + '{"session_id":"S-TEST","tool_name":"Bash","tool_input":{"command":"echo 【工單】inkstone/ISEP#88 順便改一下那個檔 > /tmp/x.md"}}' +t 0 "㉜ claude 但沒有 -p(開互動 session,不是一次性派工)⇒ 不管" \ + '{"session_id":"S-TEST","tool_name":"Bash","tool_input":{"command":"claude --version"}}' +t 0 "㉝ 回覆是空的(這條路這次沒帶任何話)⇒ 不管" \ + "$(reply_payload SendMessage message '' '{"to":"isep-hand"}')" + +N=$((N+1)) +child=$(printf '%s' "$(reply_payload SendMessage message '順便把那個檔也改一下' '{"to":"peer"}')" \ + | CLAUDE_CODE_CHILD_SESSION=1 bash "$HOOK" 2>&1 >/dev/null); crc=$? +if [ "$crc" -eq 0 ]; then + printf ' ✅ ㉞ 子 session 送出的回覆不管(它送的是交件,不是派工)\n'; PASS=$((PASS+1)) +else + printf ' ❌ ㉞ 子 session 的回覆被擋了(exit=%s)——那會擋掉交件本身\n' "$crc"; FAIL=$((FAIL+1)) +fi + +N=$((N+1)) +kid=$(printf '%s' "$(payload '【工單】inkstone/ISEP#88 +順便把 hooks.json 也改一下')" | CLAUDE_CODE_CHILD_SESSION=1 bash "$HOOK" 2>&1 >/dev/null); krc=$? +if [ "$krc" -eq 2 ]; then + printf ' ✅ ㉟ 但子 session **自己往下派工**(Task)時,派工單的規矩照樣管它\n'; PASS=$((PASS+1)) +else + printf ' ❌ ㉟ 子 session 派工被整個放掉了(exit=%s)——那是一個逃逸艙口\n' "$krc"; FAIL=$((FAIL+1)) +fi + clean echo echo "══ $PASS/$N 通過${FAIL:+,$FAIL 個失敗} ══" diff --git a/hooks/tests/roster-guard.test.sh b/hooks/tests/roster-guard.test.sh new file mode 100755 index 0000000..0bd2f2f --- /dev/null +++ b/hooks/tests/roster-guard.test.sh @@ -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 [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> <說明> [必須出現的字串] [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 該派給誰\n'; PASS=$((PASS+1)) +else + printf ' ❌ ③ `roster which inkstone/ISEP` 沒有回 isep-hand\n'; FAIL=$((FAIL+1)) +fi + +echo "── 驗收 2:派工要能指名派給誰(不該擋)────────────────────────" + +for who in isep-hand arcrun-hand arcrun-rag-hand inkstoneco-hand mira-hand collector-hand scout; do + t 0 "④ 指名 $who ⇒ 放行" "$(payload '【工單】inkstone/ISEP#86' "$who")" +done + +t 0 "⑤ Agent 這條路也認得(不是只認 Task)" \ + "$(payload '【工單】inkstone/ISEP#86' 'scout' 'Agent')" + +echo "── 驗收 4:名單上沒有的名字 → 講得出「沒有這個人」──────────────" + +t 2 "⑥ 派 general-purpose(就是「沒有名字的臨時工」)⇒ 擋,並說沒有這個人" \ + "$(payload '【工單】inkstone/ISEP#86' 'general-purpose')" '沒有「general-purpose」這個人' + +t 2 "⑦ 完全沒指名(subagent_type 是空的)⇒ 擋" \ + "$(payload '【工單】inkstone/ISEP#86' '')" '沒有指名工人' + +t 2 "⑧ 打錯字(isep-hands)⇒ 擋。名字是唯一識別碼,不做模糊比對" \ + "$(payload '【工單】inkstone/ISEP#86' 'isep-hands')" '沒有「isep-hands」這個人' + +t 2 "⑨ 擋下來的時候要把整份名單印出來(不然人不知道有誰可以派)" \ + "$(payload '【工單】inkstone/ISEP#86' 'nobody')" 'arcrun-rag-hand' + +echo "── 不該擋(誤攔比漏擋嚴重)──────────────────────────────────" + +t 0 "⑩ 沒有【工單】⇒ 閉嘴(那是 no-ticket-no-dispatch 的地盤,不准兩支閘同時開口)" \ + "$(payload '去查一下那個 worker 為什麼沒更新' 'general-purpose')" + +t 0 "⑪ 不是派工的動作(Bash)⇒ 一律不管" \ + '{"session_id":"S-ROSTER","tool_name":"Bash","tool_input":{"command":"ls"}}' + +t 0 "⑫ 整包不是合法 JSON ⇒ fail-open" 'this is not json at all' + +t 0 "⑬ 名單目錄是空的 ⇒ fail-open(讀不到的名單不該讓整台機器派不了工)" \ + "$(payload '【工單】inkstone/ISEP#86' 'general-purpose')" '' "$TMP" + +clean +touch /tmp/.roster-ok-S-ROSTER +t 0 "⑭ 明示豁免戳記在 ⇒ 放行一次" \ + "$(payload '【工單】inkstone/ISEP#86' 'general-purpose')" +N=$((N+1)) +if [ -f /tmp/.roster-ok-S-ROSTER ]; then + printf ' ❌ ⑮ 豁免戳記用完沒被消掉(會變成永久開關)\n'; FAIL=$((FAIL+1)) +else + printf ' ✅ ⑮ 豁免戳記用完就消失,不是永久開關\n'; PASS=$((PASS+1)) +fi +clean + +echo "── 注入:指對名字就把那位工人的檔案原文送過去 ──────────────────" + +N=$((N+1)) +inj=$(printf '%s' "$(payload '【工單】inkstone/ISEP#86' 'isep-hand')" | bash "$HOOK" 2>/dev/null) +if printf '%s' "$inj" | python3 -c ' +import sys, json +c = json.load(sys.stdin)["hookSpecificOutput"]["additionalContext"] +assert "isep-hand" in c, "沒有名字" +assert "inkstone/ISEP" in c, "沒有負責的 repo" +assert "開工前先讀" in c, "沒有「開工前先讀」" +assert "紅線" in c, "沒有紅線" +' 2>/dev/null; then + printf ' ✅ ⑯ 指對名字 ⇒ 注入「你是誰/負責哪個 repo/開工前先讀/紅線」四件\n'; PASS=$((PASS+1)) +else + printf ' ❌ ⑯ 沒有注入,或缺了其中一件\n'; printf '%s\n' "$inj" | sed -n '1,4p' | sed 's/^/ /' + FAIL=$((FAIL+1)) +fi + +N=$((N+1)) +inj2=$(printf '%s' "$(payload '【工單】inkstone/Arcrun#142' 'arcrun-hand')" | bash "$HOOK" 2>/dev/null) +if printf '%s' "$inj2" | grep -q 'arcrun-hand' && ! printf '%s' "$inj2" | grep -q 'isep-hand'; then + printf ' ✅ ⑰ 注入的是**那一位**的檔案,不是整份名單\n'; PASS=$((PASS+1)) +else + printf ' ❌ ⑰ 注入的內容不是指名的那一位\n'; FAIL=$((FAIL+1)) +fi + +echo "── 驗收 3:票上的紀錄看得出是哪個工人做的(身份欄)──────────────" + +N=$((N+1)) +if python3 - "$ROOT" <<'PY' 2>/dev/null +import sys, os +sys.path.insert(0, os.path.join(sys.argv[1], "hooks", "lib")) +import dispatch_parse as dp +ok, _ = dp.parse_identity("【身份】isep-hand/inkstone/ISEP/feat/x") +assert ok, "工人名字不被身份欄接受" +ok2, _ = dp.parse_identity("【身份】總管/inkstone/ISEP/-") +assert ok2, "原本的三個角色被弄壞了" +bad, _ = dp.parse_identity("【身份】某某某/inkstone/ISEP/-") +assert not bad, "亂寫的名字被放行了" +PY +then + printf ' ✅ ⑱ 【身份】欄吃得下工人名字,原本三個角色照舊,亂寫的仍然擋\n'; PASS=$((PASS+1)) +else + printf ' ❌ ⑱ 身份欄沒有吃下工人名字(或把原本的三個角色弄壞了)\n'; FAIL=$((FAIL+1)) +fi + +echo +printf '結果:%s 通過 / %s 失敗(共 %s 條)\n' "$PASS" "$FAIL" "$N" +[ "$FAIL" -eq 0 ] || exit 1 diff --git a/scripts/roster b/scripts/roster new file mode 100755 index 0000000..eaf14fe --- /dev/null +++ b/scripts/roster @@ -0,0 +1,94 @@ +#!/usr/bin/env python3 +"""roster — 工人名單(inkstone/ISEP#86) + + python3 scripts/roster list 名單:有哪幾個工人、各自管什麼 + python3 scripts/roster show <名字> 一個工人的全文(它開工時會讀到的東西) + python3 scripts/roster names 只印名字,一行一個(給腳本用) + python3 scripts/roster which 這個 repo 該派給誰 + +leo(ISEP#86):「身為派工的人,我要工人有名字,我才知道票上這件事到底是誰做的。」 + +名單是**資料**(`agents/*.md`),不是程式:加一個工人=加一個檔,不改任何 hook。 +派工時把名字放進 `Task` 的 `subagent_type`;名字不在名單上, +`hooks/roster-guard.sh` 會擋下來並說「沒有這個人」。 +""" +import os +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "hooks", "lib")) +try: + import roster as R +except Exception as e: # pragma: no cover + print("🔴 讀不到 hooks/lib/roster.py:%s" % e, file=sys.stderr) + sys.exit(1) + + +def cmd_list(): + ws = R.load() + if not ws: + print("🔴 名單是空的(找不到 %s 底下任何工人檔)" % R.roster_dir(), file=sys.stderr) + return 1 + print("工人名單(%d 位) 來源:%s\n" % (len(ws), R.roster_dir())) + print(" %-18s %-26s %s" % ("名字", "負責 repo", "它是誰")) + print(" " + "─" * 100) + for w in ws: + print(" " + R.one_liner(w)) + print("\n派工:把「名字」放進 Task 的 subagent_type;派工單本身仍然只有一行【工單】。") + print("看某一位的全文:python3 scripts/roster show <名字>") + return 0 + + +def cmd_show(name): + w = R.find(name) + if not w: + print("🚫 名單上沒有「%s」這個人。現有的是:\n %s" + % (name, "\n ".join(R.names()) or "(空)"), file=sys.stderr) + return 2 + print("# %s\n" % w["name"]) + print("負責 repo:%s" % (w["repo"] or "(跨 repo)")) + print("一句話 :%s" % w["description"]) + print("檔案  :%s\n" % w["path"]) + print(w["body"]) + return 0 + + +def cmd_which(repo): + hits = [w for w in R.load() if w["repo"].lower() == (repo or "").strip().lower()] + if not hits: + print("🚫 名單上沒有負責「%s」的人。現有的是:" % repo, file=sys.stderr) + for w in R.load(): + print(" " + R.one_liner(w), file=sys.stderr) + return 2 + for w in hits: + print(w["name"]) + return 0 + + +def main(): + argv = sys.argv[1:] + if not argv or argv[0] in ("-h", "--help", "help"): + print(__doc__) + return 0 + verb, rest = argv[0], argv[1:] + if verb == "list": + return cmd_list() + if verb == "names": + for n in R.names(): + print(n) + return 0 + if verb == "show": + if not rest: + print("用法:scripts/roster show <名字>", file=sys.stderr) + return 1 + return cmd_show(rest[0]) + if verb == "which": + if not rest: + print("用法:scripts/roster which ", file=sys.stderr) + return 1 + return cmd_which(rest[0]) + print("不認得的動詞「%s」。\n%s" % (verb, __doc__), file=sys.stderr) + return 1 + + +if __name__ == "__main__": + sys.exit(main())