diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 85debb8..e4eea3a 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "isep", - "description": "InkStone Environment Plugin —— leo 的 Claude Code 環境唯一真相源:46 支機械閘(56 條註冊,白話盤點見 docs/hooks-inventory.md)、7 支 slash command、2 支 skill、27 支腳本,外加治理規範與標籤真相源。本機與雲端裝同一份,沒有子集。", - "version": "0.4.0", + "description": "InkStone Environment Plugin —— leo 的 Claude Code 環境唯一真相源:48 支機械閘(59 條註冊,白話盤點見 docs/hooks-inventory.md)、7 支 slash command、2 支 skill、27 支腳本,外加治理規範與標籤真相源。本機與雲端裝同一份,沒有子集。", + "version": "0.5.0", "keywords": [ "inkstone", "guardrails", diff --git a/.gitignore b/.gitignore index 150c6f5..db123e3 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,7 @@ cloud-env*.txt *.env .shell-payload/ + +# import 共用零件時 python 自己生的,不是原始碼 +__pycache__/ +*.pyc diff --git a/README.md b/README.md index 2cec0be..ee275f4 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ | | 數量 | 是什麼 | |---|---|---| -| `hooks/` | 43 支 + `hooks.json` | 全部機械閘(PreToolUse/Stop/SubagentStop/SessionStart/PostToolUse 共 53 條註冊) | +| `hooks/` | 48 支 + `hooks.json` | 全部機械閘(PreToolUse/Stop/SubagentStop/SessionStart/PostToolUse 共 59 條註冊) | | `commands/` | 7 支 | `/wiki-recall` `/ship-check` `/cp-write` … | | `skills/` | 2 支 | | | `scripts/` | 23 支 | `ticket`/`github-arm.sh`/`gitea-bootstrap.sh` … | @@ -48,6 +48,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)。** diff --git a/docs/TESTING.md b/docs/TESTING.md index 97ab691..01a6c43 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -84,6 +84,30 @@ bash hooks/tests/ask-user-question-guard.live.test.sh - 📌 這支會隨模型版本漂移,**是量尺不是一次性驗收**。改完判準要連跑三次都全綠才算數 (2026-08-26 實測:第一版判準連兩次都在同一題漏擋,收緊 ③④ 定義後三次全綠) +### A11 — 派工單只剩票號:擋得住,也放得過,而且會注入共通規定 +``` +bash hooks/tests/dispatch-format-guard.test.sh +``` +**該看到**:`19/19 通過`。**離線、不打網路、不花錢**——這支閘是純結構判斷,沒有語意判官, +所以它不需要像 A10 那樣另開一支 live 測試量準度,**每次結果都一樣**。 +**失敗**: +- A 群任何一條紅 ⇒ **誤攔**。合規的派工只有一行票號,擋掉它等於整台機器派不了工 +- ⑦ 紅 ⇒ **共通規定沒有被注入**。這是「派工單只剩票號」能成立的前提: + 交件方式、不准 push main、org 是 `inkstone` 這些不必有人記得寫,機器每次都補。 + 它壞了不會有人立刻發現——派工照樣送出去,只是收工方**不知道要貼回原票** +- ⑨ 紅 ⇒ 真跡放行了。那份測資是**真的發生過的那一次派工**(見 `hooks/tests/fixtures/README.md`) +- ⑰ 紅 ⇒ 訊息被 shell 展開了(同 A9 ⑩b 那個病:閘照擋,但它教人怎麼解的那兩行變成空白) + +### 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 @@ -222,6 +246,8 @@ B2(信標那行)/B3(setup 輸出)/B4(閘的訊息)三個畫面 | A6 標籤對齊+冪等 | 總管 | ✅ 14 repo,第二次 0/0 | | **A9 人閘警察管路** | 總管 | ✅ 14/14(2026-08-26) | | **A10 人閘警察準度** | 總管 | ✅ 9/9,連跑三次(2026-08-26),A 群誤攔 0 | +| **A11 派工單只剩票號** | 總管 | ✅ 19/19(2026-08-27) | +| **A12 留言身份欄(兩道門)** | 總管 | ✅ 11/11(2026-08-27) | | A7 plugin 裝得起來 | 總管 | ✅ | | **A8 新 session 閘會觸發** | 總管 | 見本版 release note | | **B1–B5 雲端** | **leo** | 還沒跑(機器碰不到 Cloud environment) | diff --git a/docs/governance/dispatch-and-reply-format.md b/docs/governance/dispatch-and-reply-format.md new file mode 100644 index 0000000..e132a9d --- /dev/null +++ b/docs/governance/dispatch-and-reply-format.md @@ -0,0 +1,113 @@ +# 派工單與交件回覆的格式(共通規定) + +> 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`)—— +`【工單】` 以外還有實質內容就擋,並指出那些內容該搬去哪一格。 +判準是**「這一行是不是【工單】欄位」**(在不在),不是「它寫了什麼」。 + +--- + +## 2. 共通規定(每一次派工由機器自動注入給收工方) + + +### 你收到的派工單只有一個票號。任務全文在票上。 + +1. **第一個動作是去讀那張票**(含每一則 comment)。派工單不會再給你別的東西—— + 這是刻意的:票活得比任何一個 agent 久。 +2. **票上的脈絡不夠 ⇒ 回票上問**,不要憑猜測動手,也不要回頭問派工的人要細節。 +3. **交件=貼回那張票**(`scripts/ticket say -F <檔>`), + 不是只在對話裡回報。回覆第一行必須是身份欄,見下。 +4. **回覆第一行一律是**: + `【身份】subagent//<你的分支>` + 角色三選一:`總管`/`subagent`/`leo`。 +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. **改了會被載入的東西(plugin/worker/bundle)就要升版**, + 否則產物按版本號分資料夾,你的改動到不了任何人手上。 + + +> 🔴 **這一段是「每次都一樣」的唯一真相源。** +> 想在派工單裡加一句叮嚀之前,先問:它換一張票還成立嗎? +> 成立就加在這裡(改一次,全機生效),不要加在那一次的 prompt 裡。 + +--- + +## 3. 交件回覆 = 第一行表明身份 + +``` +【身份】subagent/inkstone/ISEP/feat/ticket-carries-the-task +``` + +- 角色是**三選一的允許清單**:`總管`/`subagent`/`leo` +- 第二格是你動的 repo,第三格是分支(沒有就寫 `-`) + +### 為什麼 + +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 身份欄:正門與側門兩道 +``` + +測資裡放的是**真的發生過的那幾份違規派工單**,不是想像出來的例子。 diff --git a/docs/hooks-inventory.md b/docs/hooks-inventory.md index 71561c7..23695ea 100644 --- a/docs/hooks-inventory.md +++ b/docs/hooks-inventory.md @@ -44,6 +44,7 @@ | `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 票(會漏掉「開票前先搜過」這道檢查)就擋下。 | 🛑 擋 | +| `reply-identity-guard.sh` | 直接打 Gitea API 貼留言到票上時,內文第一行沒有 `【身份】`(`總管`/`subagent`/`leo`)就擋下——票上多條線並行時,看不出哪一則是誰寫的。純讀取、走 `scripts/ticket` 正門、指令裡加 `reply-identity-ok` 都放行。 | 🛑 擋 | ## B. AI 想寫檔案/改程式碼的當下(PreToolUse / Write·Edit·MultiEdit) @@ -76,6 +77,7 @@ | `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`…),所以你不寫也不會漏。判準是「這一行是不是【工單】欄位」——**在不在**,不是寫了什麼,所以講得再謹慎也不會被多罰。 | 🛑 擋(+注入) | ## D2. AI 想開口問你問題的當下(PreToolUse / AskUserQuestion) diff --git a/hooks/dispatch-format-guard.sh b/hooks/dispatch-format-guard.sh new file mode 100755 index 0000000..3eaa097 --- /dev/null +++ b/hooks/dispatch-format-guard.sh @@ -0,0 +1,189 @@ +#!/usr/bin/env bash +# 管什麼: 派工單裡除了【工單】票號以外還有別的內容就擋;合規的派工則自動注入共通規定。 +# 為什麼: 任務寫在 prompt 裡 => 票上看不到、agent 一停就蒸發;散文沒有欄位 => 警察抓不到。 +# 誤觸時怎麼關: 把內容歸位(每次都一樣的→共通規定;這次才知道的→寫進票),或 touch /tmp/.dispatch-ok- 放行一次。 +# +# dispatch-format-guard.sh — PreToolUse(Task|Agent):**派工單 = 票號。就這樣。** +# +# ━━ 來由(leo 2026-08-27,inkstone/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#104/Arcrun#142/Arcrun#127/ +# Arcrun#144/InkStoneCo#55),每一次票上都沒有那份任務。 +# ⇒ **規則存在,閘只驗了它的殼。**(同款第 N 次:history-first/KBDB-first/ +# stage-first/AskUserQuestion 裸奔,全是這個形狀。) +# +# 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.sh/ask-user-question-guard.sh): +# touch /tmp/.dispatch-ok- +# 閘是絆一下逼你重想,不是把人關起來。 +# +# 測試: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(回到「沒有這支閘」的狀態) + +STATUS="$(printf '%s' "$FACTS" | python3 -c ' +import sys, json +try: print(json.load(sys.stdin).get("status", "skip")) +except Exception: print("skip") +' 2>/dev/null)" +[ "$STATUS" = "ok" ] || exit 0 + +SESSION="$(printf '%s' "$FACTS" | python3 -c ' +import sys, json +try: print(json.load(sys.stdin).get("session_id", "")) +except Exception: print("") +' 2>/dev/null)" + +NVIOL="$(printf '%s' "$FACTS" | python3 -c ' +import sys, json +try: print(len(json.load(sys.stdin).get("violations", []))) +except Exception: print(0) +' 2>/dev/null)" + +# ── 明示豁免(留痕用,用掉就消失,不是永久開關)──────────────────────── +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' +🚫 派工單 = 票號。就這樣。(leo 2026-08-27,inkstone/ISEP#30 comment 4327) + +__VIOLATIONS__ + +━━ 這些內容該去哪:問一句「**這句話換一張票還成立嗎?**」━━━━━━━━━━ + + 還成立 ⇒ **共通規定**(交件方式、不准 push main、org 是 inkstone、先讀 CLAUDE.md…) + 改 `docs/governance/dispatch-and-reply-format.md` 的 §2, + **本閘每次派工都會自動注入給收工方**,你不必寫、也不會漏。 + + 只有這次 ⇒ **寫進那張票**(main 是哪顆、今天撞過什麼、另一條線在動什麼) + `scripts/ticket say -F <檔>` + 然後派工單寫 `【工單】 → 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)" + + DFG_MSG="$MSG" DFG_VIOL="$VIOL_TEXT" DFG_SESSION="$SESSION" python3 -c ' +import os, sys +sys.stderr.write( + os.environ["DFG_MSG"] + .replace("__VIOLATIONS__", os.environ.get("DFG_VIOL", "")) + .replace("__SESSION__", os.environ.get("DFG_SESSION", "") or "") + + "\n" +) +' + exit 2 +fi + +# ── 合規 → 注入共通規定 ────────────────────────────────────────────── +# 這是「派工單只剩票號」的另一半:那些每次都一樣的規定不必有人記得寫,機器每次都補。 +# 來源是 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"\n(.*?)", 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 diff --git a/hooks/hooks.json b/hooks/hooks.json index a4b3f78..feded4a 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -44,6 +44,10 @@ { "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/milestone-due-guard.sh" + }, + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/hooks/reply-identity-guard.sh" } ] }, @@ -132,6 +136,10 @@ { "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/no-ticket-no-dispatch.sh" + }, + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/hooks/dispatch-format-guard.sh" } ] }, @@ -167,6 +175,10 @@ { "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/no-ticket-no-dispatch.sh" + }, + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/hooks/dispatch-format-guard.sh" } ] }, @@ -336,4 +348,4 @@ } ] } -} \ No newline at end of file +} diff --git a/hooks/lib/dispatch_parse.py b/hooks/lib/dispatch_parse.py new file mode 100644 index 0000000..f8c6507 --- /dev/null +++ b/hooks/lib/dispatch_parse.py @@ -0,0 +1,229 @@ +#!/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-27,inkstone/ISEP#30 comment 4322 / 4325 / 4327)━━ + + 派工單 = 【工單】owner/repo#N。就這樣。 + + 三則合起來是一條規則: + 4322 任務要在票上,不是在 prompt 裡 —— 而閘只驗了票號這個殼 + 4325 「你用一個 **output parser** 把你給 subagent 的指令規範,分作幾點, + 每一點規定格式,**照這種散文寫法根本無法迭代**」「**警察也不能抓**」 + 4327 「**交件方式不需要寫,定義在原則裡**⋯⋯每次都一樣提取出來變成共通規定」 + 「(那些 session 事實)**為什麼不寫到票裡?**」 + + ⇒ 派工單裡的東西只有兩種,**兩種都不該留在派工單**: + + 每次都一樣 交件方式、不要 push main、org 是 inkstone、先讀該 repo 的 CLAUDE.md + => 共通規定(由閘自動注入,見 dispatch-format-guard.sh) + 這次才知道 main 是哪顆 commit、今天撞過什麼、另一條線正在動什麼 + => **寫進那張票**(「票上還沒有」是「去把它寫上票」的指令, + 不是「把它寫進 prompt」的理由) + + 判準一句話:**「這句話換一張票還成立嗎?」** + 還成立 => 共通規定;只有這次成立 => 寫進這張票。兩種都不進派工單。 + + 為什麼連「這次才知道的事」也不能留在 prompt(4327 實害): + 那個 agent 被停掉/換人接手,那段事實就隨 prompt 消失。 + 2026-08-27 總管停掉重派 3 次,前兩次的任務與 session 事實全部蒸發。 + +━━ 為什麼這支過得了「封動作不封文字」的檢驗(leo 2026-08-17)━━━━━━━━ + 「你在**文字層**封路⋯⋯**自然語言的變體是無限的,blacklist 永遠追不完。** + 封路哲學之所以有效,是因為它封的是**動作**——動作有限且可枚舉,文字不是。」 + 當日實證:文字層的閘 8 次誤攔、0 次正確攔截,**紅線寫得越細,命中關鍵字的機率越高 + ⇒ 那些閘在懲罰謹慎**。 + + 本檔**全檔零個「命中某個詞就違規」的比對**。判準是「這一行是不是【工單】欄位」—— + **在不在**,不是**寫什麼**。用到的正則只有兩種,都在認形狀: + - `【某某】` 欄位標記本身 + - `owner/repo#N` 票號形狀 + 措辭多謹慎、有沒有提到「紅線」「部署」「main」,本檔一律不看。 + ⇒ 也因此它不會像文字層的閘那樣「寫得越小心越容易被罰」。 + +━━ 派工單格式 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + 【工單】inkstone/ISEP#30 → comment 4322 + + 就這一行。要帶兩張票就兩行【工單】。**沒有第二個欄位。** + +━━ 交件回覆格式(任何人 → 票)━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + 【身份】subagent/inkstone/ISEP/feat/ticket-carries-the-task + + 第一行就要認得出是誰寫的。角色是三選一的允許清單:`總管`/`subagent`/`leo`。 + 2026-08-27 實害:總管寫的診斷被當成 subagent 的結論,而其中一則是錯的。 + +用法: + 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 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*$") +_COMMENT_RE = re.compile(r"(?:#issuecomment-|→\s*comment\s*)(\d+)") +_FENCE_RE = re.compile(r"^\s*(```|~~~)") + + +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(): + # 【工單】欄位本身只准佔它自己那一行;它下面的續行也是多的。 + 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 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() + if role not in IDENTITY_ROLES: + return False, "角色「%s」不在 %s 之內" % (role or "(空)", "/".join(IDENTITY_ROLES)) + return True, val + return False, "內文是空的" + + +# ── 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 + + 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"})) + return + + print(json.dumps({ + "status": "ok", + "violations": [{"code": c, "detail": d} for c, d in dispatch_violations(parsed)], + "refs": parsed["refs"], + "retired_seen": parsed["retired_seen"], + "extra_lines": parsed["extra_lines"], + "session_id": payload.get("session_id") or "", + }, ensure_ascii=False)) + + +if __name__ == "__main__": + _main() diff --git a/hooks/reply-identity-guard.sh b/hooks/reply-identity-guard.sh new file mode 100755 index 0000000..c915567 --- /dev/null +++ b/hooks/reply-identity-guard.sh @@ -0,0 +1,73 @@ +#!/bin/bash +# 管什麼: 直接打 Gitea API 貼留言到票上時,內文第一行沒有【身份】就擋。 +# 為什麼: scripts/ticket 已經在正門擋了,但貼留言還有 API 這條側門——只封一條等於沒封。 +# 誤觸時怎麼關: 內文第一行加【身份】<總管|subagent|leo>//<分支>,或在指令裡加 reply-identity-ok 留痕放行。 +# +# reply-identity-guard.sh — PreToolUse(Bash):**票上的每一則留言都要認得出是誰寫的** +# +# 來由(leo 2026-08-27,inkstone/ISEP#30 comment 4325): +# 「**subagent 回覆時要表明身份**」 +# 實害(同日):多條線並行,票上的留言看不出是誰寫的, +# **總管寫的診斷被當成 subagent 的結論,而其中一則是錯的**。 +# +# 為什麼要有這一支,正門的檢查不夠: +# `scripts/ticket say` 已經在貼上去之前擋了。但**貼留言這個動作有兩條路**—— +# 而 `ticket-api-bypass-guard.sh` 是**刻意放行**「對既有票的留言」的(它管的是開新票)。 +# ⇒ 側門大開。這正是那支閘檔頭自己記過的一課: +# 「規範有、閘也有,但閘長在『工具』上,而那個動作有兩條路,只封了一條。」 +# +# 判準是**結構**不是文字(leo 2026-08-17:「封的是動作,不是文字」): +# 本閘不看留言寫什麼,只看**那個欄位在不在**。 +# 三個條件都命中才擋:① 打到 /issues//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): + + 【身份】subagent/inkstone/ISEP/feat/my-branch + 【身份】總管/inkstone/InkStoneCo/- + +**這條管所有人,不是只管 subagent。** 總管寫在票上的東西同樣要標—— +實害(2026-08-27):多條線並行時票上看不出誰寫的, +**總管寫的診斷被當成 subagent 的結論,而其中一則是錯的**。 + +── 走正門比較省事 ───────────────────────────────────────────── + scripts/ticket say -F <內文檔> + 它會回一行「定址」,那串就是下次派工要寫進【工單】的東西。 + +規約全文:docs/governance/dispatch-and-reply-format.md §3 +真的有例外 → 指令裡加 reply-identity-ok(會留在指令歷史上)。 +MSG +exit 2 diff --git a/hooks/tests/dispatch-format-guard.test.sh b/hooks/tests/dispatch-format-guard.test.sh new file mode 100755 index 0000000..515bb53 --- /dev/null +++ b/hooks/tests/dispatch-format-guard.test.sh @@ -0,0 +1,175 @@ +#!/usr/bin/env bash +# dispatch-format-guard.sh 的迴歸測試(inkstone/ISEP#30 comment 4322/4325/4327)。 +# +# **離線、不打網路、不花錢、每次結果一樣**——這支閘是純結構判斷,沒有語意判官, +# 所以它不像 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 [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 +} + +# t <期望 exit> <說明> +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')" + +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 +```')" + +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 + +clean +echo +echo "══ $PASS/$N 通過${FAIL:+,$FAIL 個失敗} ══" +[ "$FAIL" -eq 0 ] diff --git a/hooks/tests/fixtures/README.md b/hooks/tests/fixtures/README.md new file mode 100644 index 0000000..5b4bc0f --- /dev/null +++ b/hooks/tests/fixtures/README.md @@ -0,0 +1,14 @@ +# 測資的來歷 + +`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 份**,那會讓測試看起來比實際更綠。 diff --git a/hooks/tests/fixtures/real-violation-isep30.txt b/hooks/tests/fixtures/real-violation-isep30.txt new file mode 100644 index 0000000..be6707b --- /dev/null +++ b/hooks/tests/fixtures/real-violation-isep30.txt @@ -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` clone(origin 是 `inkstone/ISEP.git`), + 今天 `v0.4.0` 那條線就是這樣施工的 +- `main` 現在是 `8e7e265`(`v0.4.0`,`ask-user-question-guard.sh` 剛上線) + +交件:貼回 `inkstone/ISEP#30`,回報分支名。 diff --git a/hooks/tests/reply-identity.test.sh b/hooks/tests/reply-identity.test.sh new file mode 100755 index 0000000..cbd8fab --- /dev/null +++ b/hooks/tests/reply-identity.test.sh @@ -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> <說明> + 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\":\"【身份】subagent/inkstone/ISEP/feat/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/ISEP/feat/x + +做完了' + +g 2 "⑩ 身份欄不在第一行 → 擋(要一眼看得到,不是藏在中間)" \ + '做完了 +【身份】subagent/inkstone/ISEP/feat/x' + +# ⑪ 合規的內文要能通過身份檢查——它會往下走到打 API, +# 離線環境打不出去所以 exit 非 0;判準改成「錯誤訊息不是身份欄那一段」。 +printf '%s' '【身份】subagent/inkstone/ISEP/feat/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 ] diff --git a/scripts/ticket b/scripts/ticket index cd3320b..41ad627 100755 --- a/scripts/ticket +++ b/scripts/ticket @@ -30,6 +30,41 @@ import urllib.error import urllib.parse import urllib.request +# ── 身份欄:與 hooks/lib/dispatch_parse.py 共用同一份定義 ────────────────── +# leo 2026-08-27:「**subagent 回覆時要表明身份**」 +# 實害(同日):多條線並行,票上的留言看不出是誰寫的, +# **總管寫的診斷被當成 subagent 的結論,而其中一則是錯的**。 +# 規約與側門閘見 docs/governance/dispatch-and-reply-format.md §3。 +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "hooks", "lib")) +try: + from dispatch_parse import parse_identity, IDENTITY_ROLES +except Exception: # 拿不到共用零件就不擋(fail-open)——開票的路不該被一個 import 卡死 + parse_identity, IDENTITY_ROLES = None, ("總管", "subagent", "leo") + + +def check_identity(body, what): + """貼進票的內文第一行要表明身份。**在打任何 API 之前就擋**,所以離線測得動。""" + if parse_identity is None: + return + ok, detail = parse_identity(body) + if ok: + return + die(f"""🚫 {what}的第一行要表明身份(leo 2026-08-27:「**subagent 回覆時要表明身份**」) + + 現在的問題:{detail} + +第一行照這個寫(角色三選一:{'/'.join(IDENTITY_ROLES)}): + + 【身份】subagent/inkstone/ISEP/feat/my-branch + 【身份】總管/inkstone/InkStoneCo/- + +**這條管所有人,不是只管 subagent。** 總管寫在票上的東西同樣要標。 +實害(2026-08-27):多條線並行時票上看不出誰寫的, +總管寫的診斷被當成 subagent 的結論,而其中一則是錯的。 + +規約全文:docs/governance/dispatch-and-reply-format.md §3""") + + HOST = "https://git.uncle6.me" ORG = "inkstone" STAMP_DIR = "/tmp" @@ -148,6 +183,7 @@ def cmd_say(argv): die("用法:ticket say -F <內文檔>") owner, repo, num = parse_ref(argv[0]) body = open(argv[2]).read() + check_identity(body, "貼進票的留言") c = api(f"/repos/{owner}/{repo}/issues/{num}/comments", {"body": body}) print(f"✅ 已貼進 {owner}/{repo}#{num}") print(f" 定址:{owner}/{repo}#{num}#issuecomment-{c['id']}") @@ -293,7 +329,7 @@ def cmd_close(argv): if deliv: api(f"/repos/{owner}/{repo}/issues/{num}/comments", - {"body": f"✅ 結案。交付物:{deliv}"}) + {"body": f"{os.environ.get('ISEP_IDENTITY') or f'【身份】總管/{owner}/{repo}/-'}\n\n✅ 結案。交付物:{deliv}"}) api(f"/repos/{owner}/{repo}/issues/{num}", {"state": "closed"}, method="PATCH") print(f"✅ {owner}/{repo}#{num} 已關(交付物:{deliv or ('PR' if has_pr else '票上回報')})") @@ -323,6 +359,8 @@ def cmd_decide(argv): body = open(argv[2]).read() nxt = argv[argv.index("--next") + 1] if "--next" in argv else None + check_identity(body, "裁決紀錄") + if "leo" not in body.lower() and "原話" not in body: die("🚫 答案檔裡看不到 leo 的原話。\n" " **裁決要記原話,不是記你的轉述**——轉述會漂,原話不會。\n" diff --git a/system-dev/wiki/mistakes.md b/system-dev/wiki/mistakes.md index fa976d8..d05f3e1 100644 --- a/system-dev/wiki/mistakes.md +++ b/system-dev/wiki/mistakes.md @@ -77,3 +77,44 @@ Gitea 上**一個 release tag 都沒有**。 **不要用 sed**,正體中文加上訊息裡的 `/`、`&`、`\` 讓跳脫非常脆。 迴歸測試要**檢查訊息內容**,不能只檢查離開碼 (`hooks/tests/ask-user-question-guard.test.sh` 的 ⑩b 就是這一條)。 + +## ⚠️ MISTAKE: 閘只驗了規則的**殼**,沒驗規則本身 + +`no-ticket-no-dispatch.sh` 掛在派工的當下,檢查「派工單裡有沒有一行 `【工單】owner/repo#N`」。 +規則的原文卻是「**不准把票上已經有的東西再抄一遍進派工單**⋯⋯派工單只寫票號」。 + +⇒ 於是可以**把 40 行任務全寫在 prompt 裡、票號補一行**,閘照樣放行。 +⇒ 2026-08-27 一天之內這樣做了 5 次,每一次票上都沒有那份任務。 + leo:「**這些話票上都沒有,你根本沒照規則做事,你的 hook 讓你這樣搞?**」 + +**根因不是那支閘寫壞了,是它驗的東西比規則小。** +「有沒有票號」是規則最容易機械化的那一格,所以它被實作了; +「任務有沒有真的落在票上」比較難,所以沒有——而漏掉的那格才是規則的本體。 + +⇒ **判準:寫完一支閘,回頭把規則原文逐句對一次,問「這一句被驗到了嗎」。** + 只驗得到最容易的那一格 ⇒ 那支閘會製造「有在管」的錯覺,比沒有閘更危險。 +⇒ 同款:history-first/KBDB-first/stage-first/`AskUserQuestion` 裸奔,全是這個形狀。 + +修法(v0.5.0):`dispatch-format-guard.sh`——**派工單 = 票號,多一個字都擋**。 +規則變得比原本更嚴,反而更好驗:判準從「內容夠不夠」變成「這一行是不是【工單】欄位」, +純結構、不用語意判官、每次結果一樣。 +規約:`docs/governance/dispatch-and-reply-format.md`。 + +日期: 2026-08-27(`inkstone/ISEP#30` comment 4322/4325/4327) + +## ⚠️ MISTAKE: 「這是 session 才知道的事」被當成寫進 prompt 的正當理由 + +派工鐵律允許派工單帶「這個 session 才知道、票上還沒有的事」。 +總管照字面理解,把 TCC 權限、`main` 是哪顆 commit、正本能從哪裡 clone 三件事寫進 prompt。 + +leo 當場:「**這些為什麼不寫到票裡?**」 + +⇒ **「票上還沒有」不是把它寫進 prompt 的理由——它就是「去把它寫上票」的指令。** +⇒ 實害(同日):總管停掉重派 3 次,**前兩次的任務與 session 事實全部隨 prompt 蒸發**。 + 票活得比任何一個 agent 久,prompt 不是。 + +判準:**「這句話換一張票還成立嗎?」** +還成立 ⇒ 共通規定(`docs/governance/dispatch-and-reply-format.md` §2,機器自動注入)。 +只有這次成立 ⇒ 寫進那張票。**兩種都不進派工單。** + +日期: 2026-08-27(`inkstone/ISEP#30` comment 4327)