Compare commits

..

5 Commits

Author SHA1 Message Date
Leo e177f6372b subagent-first-guard 補三個洞:閘不管 .sh、派過工變免死金牌、警報響一次就啞掉
leo 2026-08-27(inkstone/ISEP#66):「你禁止寫 code,為什麼你改?為什麼你可以執行?」
總管親手改了 no-ticket-no-dispatch.sh,這道閘一聲不響。

三個洞,一次補:
1. `.sh` 從沒進過受管副檔名清單——這個 repo 自己的閘全是 .sh,等於這道閘
   從第一行 case 就直接放行,路徑白名單根本沒機會判斷。這才是真正原因,
   不是路徑豁免。修法:閘本身(`.claude/hooks/*.sh`、`hooks/*.sh`)獨立收進
   受管清單;同時拿掉 `*/.claude/hooks/*` 整目錄豁免——能改閘的人等於能關掉
   所有其他閘,這句話本身就是拿掉它的理由。
2. 「這個 session 派過一次工」曾被當永久放行條件,但那混淆了「這件任務有沒有
   交出去」跟「下一次任意的手改合不合理」——整個拿掉,改成純資訊(留在被擋下
   時的訊息裡,告訴你上次派工是幾分鐘前)。
3. 「同一 session 只擋一次」讓警報響過一次就對餘生啞掉。改成寬限期(預設 15
   分鐘,可用 SUBAGENT_GUARD_NAG_TTL 覆寫做測試):寬限期內不重複鬼打牆,
   過了就再響——不選邊犧牲「有效」或「不煩人」。

放行只剩三種:CLAUDE_CODE_CHILD_SESSION=1(subagent 本人,它就是被派來寫的)/
solo-ok 非空(寫了理由,不是 touch 空檔)/檔案本身不歸這道閘管(測試檔/
system-dev 的 wiki/非受管副檔名)。

新增 scripts/test-subagent-first-guard.sh,20/20 通過:涵蓋三個洞各自的重演、
CHILD_SESSION 放行、測試檔/wiki 放行、solo-ok 空檔不算數、寬限期到期再響。

plugin.json 0.5.0 → 0.5.1;docs/hooks-inventory.md 同步更新兩支閘的說明。

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

【工單】inkstone/InkStoneCo#55
2026-08-26 22:36:08 +08:00
23 changed files with 1834 additions and 27 deletions
+1 -1
View File
@@ -8,7 +8,7 @@
"plugins": [
{
"name": "isep",
"description": "InkStone Environment Plugin —— leo 的 Claude Code 環境唯一真相源:43 支機械閘(53 條註冊,白話盤點見 docs/hooks-inventory.md)、7 支 slash command、2 支 skill、27 支腳本,外加治理規範與標籤真相源。本機與雲端裝同一份,沒有子集。",
"description": "InkStone Environment Plugin —— leo 的 Claude Code 環境唯一真相源:46 支機械閘(56 條註冊,白話盤點見 docs/hooks-inventory.md)、7 支 slash command、2 支 skill、27 支腳本,外加治理規範與標籤真相源。本機與雲端裝同一份,沒有子集。",
"author": {
"name": "Leo",
"url": "https://uncle6.me"
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "isep",
"description": "InkStone Environment Plugin —— leo 的 Claude Code 環境唯一真相源:43 支機械閘(53 條註冊,白話盤點見 docs/hooks-inventory.md)、7 支 slash command、2 支 skill、27 支腳本,外加治理規範與標籤真相源。本機與雲端裝同一份,沒有子集。",
"version": "0.3.9",
"description": "InkStone Environment Plugin —— leo 的 Claude Code 環境唯一真相源:48 支機械閘(59 條註冊,白話盤點見 docs/hooks-inventory.md)、7 支 slash command、2 支 skill、27 支腳本,外加治理規範與標籤真相源。本機與雲端裝同一份,沒有子集。",
"version": "0.5.1",
"keywords": [
"inkstone",
"guardrails",
+4
View File
@@ -3,3 +3,7 @@
cloud-env*.txt
*.env
.shell-payload/
# import 共用零件時 python 自己生的,不是原始碼
__pycache__/
*.pyc
+5 -1
View File
@@ -23,7 +23,7 @@
| | 數量 | 是什麼 |
|---|---|---|
| `hooks/` | 43 `hooks.json` | 全部機械閘(PreToolUseStopSubagentStopSessionStartPostToolUse 共 53 條註冊) |
| `hooks/` | 48 `hooks.json` | 全部機械閘(PreToolUseStopSubagentStopSessionStartPostToolUse 共 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)。**
+52
View File
@@ -60,6 +60,54 @@ python3 scripts/ticket where 標籤 模組化
ISEP 的叫 `origin`,於是這道閘在新 repo 等於不存在)
- 結果只有單一 repo ⇒ 搜尋沒有跨 repo,等於沒搜
### A9 — 人閘警察的管路:該擋的擋、壞掉不會卡住 session
```
bash hooks/tests/ask-user-question-guard.test.sh
```
**該看到**`14/14 通過`。**不打網路、不花錢**(判官用替身)。
**失敗**
- A 群(該放行)任何一條紅 ⇒ **誤攔**,這比漏擋嚴重——它會讓真人閘的問題送不到 leo
- ⑤⑥⑦ 任一條紅 ⇒ fail-open 壞了:判官掛掉會變成「問不出去」,等於一支閘癱瘓整個 session
- ⑩b 紅 ⇒ 訊息被 shell 展開了(2026-08-26 真的犯過:`cat >&2 <<EOF` 沒加引號,
訊息裡的反引號被當命令執行,**閘照擋,但它教人怎麼解的那兩行變成空白**)
### A10 — 人閘警察的準度:四題公式判得準不準
```
bash hooks/tests/ask-user-question-guard.live.test.sh
```
🔴 **這支真的會叫 haiku**(9 題、每題一次呼叫,整支約 2 分鐘)。
**該看到**`9/9 通過`,且結尾的「A 群誤攔」計數是 **0**
**失敗**
- **A 群紅(誤攔真人閘)=最嚴重**:等於讓總管替 leo 決定他的品味。看到就停下來改判準,不要放著
- B 群紅 = 漏擋,判官把純技術題當成人閘。改 `ask-user-question-guard.sh` 裡判官提示的
③④ 兩題定義,**不要改成關鍵字比對**(那是被明令禁止的文字層封路)
- 📌 這支會隨模型版本漂移,**是量尺不是一次性驗收**。改完判準要連跑三次都全綠才算數
2026-08-26 實測:第一版判準連兩次都在同一題漏擋,收緊 ③④ 定義後三次全綠)
### A11 — 派工單只剩票號:擋得住,也放得過,而且會注入共通規定
```
bash hooks/tests/dispatch-format-guard.test.sh
```
**該看到**`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
@@ -196,6 +244,10 @@ B2(信標那行)/B3(setup 輸出)/B4(閘的訊息)三個畫面
| A4 開票側門閘 | 總管 | ✅ 13/13 |
| A5 搜尋跨 repo | 總管 | ✅ |
| A6 標籤對齊+冪等 | 總管 | ✅ 14 repo,第二次 0/0 |
| **A9 人閘警察管路** | 總管 | ✅ 14/142026-08-26 |
| **A10 人閘警察準度** | 總管 | ✅ 9/9,連跑三次(2026-08-26),A 群誤攔 0 |
| **A11 派工單只剩票號** | 總管 | ✅ 19/192026-08-27 |
| **A12 留言身份欄(兩道門)** | 總管 | ✅ 11/112026-08-27 |
| A7 plugin 裝得起來 | 總管 | ✅ |
| **A8 新 session 閘會觸發** | 總管 | 見本版 release note |
| **B1B5 雲端** | **leo** | 還沒跑(機器碰不到 Cloud environment |
@@ -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. 共通規定(每一次派工由機器自動注入給收工方)
<!-- INJECT:BEGIN 這段之間的內容會被 dispatch-format-guard.sh 原文注入,改這裡=改所有派工 -->
### 你收到的派工單只有一個票號。任務全文在票上。
1. **第一個動作是去讀那張票**(含每一則 comment)。派工單不會再給你別的東西——
這是刻意的:票活得比任何一個 agent 久。
2. **票上的脈絡不夠 ⇒ 回票上問**,不要憑猜測動手,也不要回頭問派工的人要細節。
3. **交件=貼回那張票**`scripts/ticket say <owner/repo#N> -F <檔>`),
不是只在對話裡回報。回覆第一行必須是身份欄,見下。
4. **回覆第一行一律是**
`【身份】subagent<owner/repo><你的分支>`
角色三選一:`總管``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. **改了會被載入的東西(pluginworkerbundle)就要升版**
否則產物按版本號分資料夾,你的改動到不了任何人手上。
<!-- INJECT:END -->
> 🔴 **這一段是「每次都一樣」的唯一真相源。**
> 想在派工單裡加一句叮嚀之前,先問:它換一張票還成立嗎?
> 成立就加在這裡(改一次,全機生效),不要加在那一次的 prompt 裡。
---
## 3. 交件回覆 = 第一行表明身份
```
【身份】subagentinkstone/ISEPfeat/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 身份欄:正門與側門兩道
```
測資裡放的是**真的發生過的那幾份違規派工單**,不是想像出來的例子。
+24 -4
View File
@@ -1,4 +1,4 @@
# 43 支閘,白話盤點表
# 46 支閘,白話盤點表
> 回應 `inkstone/InkStoneCo#40`:「如果加入了,我應該可以白話文看到 hooks 的內容?」
> 這份表就是那個「白話文」——不用點開任何 `.sh` 檔,一行看懂一支閘在管什麼。
@@ -7,10 +7,16 @@
## 一句話結論
`hooks/` 底下有 **43 個 `.sh` 檔**`hooks.json` 實際掛上 **53 條註冊**(同一支閘常被多種情境同時掛上);
`hooks/` 底下有 **46 個 `.sh` 檔**`hooks.json` 實際掛上 **56 條註冊**(同一支閘常被多種情境同時掛上);
其中 **3 支檔案存在但沒被掛上**(2 支是待人填的空範本、1 支是刻意留著沒開的止血帶,見下面「未生效」表)。
下面按「你會在什麼時候撞到它」分組,一支一行。
> 🔴 **這兩個數字上一版是錯的(2026-08-26 實際數過才發現)**:本頁原本寫「43 個檔、53 條註冊」,
> 而當時真實是 **45 個檔、55 條註冊**——中間有兩支閘進來時沒有回頭改這裡。
> 現在的寫法是實際數出來的:
> `ls hooks/*.sh | wc -l` 46`hooks.json` 展開後的 command 條目 56。
> **一份會偷偷過期的盤點表,跟沒有盤點表差不多**——見本頁最後「怎麼跟實況對帳」那段。
---
## 怎麼讀這張表
@@ -38,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
@@ -49,7 +56,7 @@
| `sdd-guard.sh` | AI 想直接動程式碼檔案,但找不到「唯一一份 active 規格文件(SDD)」對應這件事,或同時有一份以上 active 規格就擋下。 | 🛑 擋 |
| `credential-only-guard.sh` | AI 想把金鑰真身或自製佔位符(例如 `__XXX_TOKEN__`)寫進設定檔就擋下——金鑰只准放在統一的 credential 中心。 | 🛑 擋 |
| `arcrun-intent-guard.sh` | AI 寫的 Arcrun workflow 語法不對就擋下,而且**直接把正確寫法回貼給它**(不是只罵它錯,是教它怎麼改)。 | 🛑 擋(教學型) |
| `subagent-first-guard.sh` | 這個對話**從頭到尾都沒有派過任何 subagent**,AI 卻要自己動手改程式碼,就先擋一次,逼它想一想「這件事能不能交給別人做」。 | 🛑 擋 |
| `subagent-first-guard.sh` | AI(不是被派來做這件事的 subagent 本人)要親手改程式碼(含閘自己的 `.sh`),就擋下逼它想「這件事能不能交給別人做」——擋過一次後有寬限期不鬼打牆,但寬限期一過會再響,不是擋過一次就永久放行。 | 🛑 擋 |
| `mistake-needs-ticket-guard.sh` | AI 想往 `mistakes.md`(教訓紀錄)新增一條「機制可以防止」的教訓,卻沒附對應票號就擋下——沒有票號的教訓沒有人會回頭處理。 | 🛑 擋 |
| `pending-changes-retired.sh` | AI 想寫東西進已經廢除的 `pending-changes.md` 檔案就擋下——這個檔案已停用,規格變更一律改開 Gitea 票。 | 🛑 擋 |
@@ -70,6 +77,19 @@
| `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
| 閘名 | 對你意味著什麼 | 動作 |
|---|---|---|
| `ask-user-question-guard.sh` | AI 要跳出來問你一個問題的**那一刻**先攔一下,用小模型(haiku)照「四題公式」判這題該不該打擾你:**花錢/不可逆/跨專案結構/品味方向/只有你做得到**——命中任何一題就放行(那本來就該問你),四題全否(純技術實作選擇、問「要不要開始」)就擋回去要它自己裁。**同一個問題只擋一次**,它重送就過得去,所以判錯不會害你收不到問題;判官掛掉/沒網路也一律放行。 | 🛑 擋(同一題至多一次) |
> **為什麼要有這一組**leo 2026-08-26:「今天已經好幾次問我,**為什麼 hooks 沒有攔下來?**」):
> 在這之前 `AskUserQuestion` 在 `hooks.json` 裡出現 **0 次**,一支閘都沒掛。
> F 組那兩支自走警察(`self-drive-police` / `self-drive-judge`)判準一樣,
> 但它們掛在「收工」那一刻——**問題早就送到你眼前了**,事後再問 AI「你查過了嗎」已經來不及。
> 這一組補的是**時機**,不是判準。
## E. 每個對話一開始(SessionStart
@@ -106,7 +126,7 @@
| 閘名 | 對你意味著什麼 | 動作 |
|---|---|---|
| `kbdb-asked-stamp.sh` | AI 真的查過 KBDB 之後,留一個時間戳——給前面 `history-first-guard.sh` 判斷「這輪有沒有先查過」用。 | 📝 記錄 |
| `subagent-first-stamp.sh` | AI 真的派過工之後,留一個時間戳—— `subagent-first-guard.sh` 判斷用。 | 📝 記錄 |
| `subagent-first-stamp.sh` | AI 真的派過工之後,留一個時間戳——純資訊,給被 `subagent-first-guard.sh` 擋下時的訊息當脈絡(「上次派工是幾分鐘前」),不再是放行判準。 | 📝 記錄 |
| `issue-status-autoflip.sh` | AI 一派工出去,就自動把對應的 Gitea 票改成「進行中(s/doing)」,不必等人手動改標籤。 | 📝 記錄(自動改票) |
## I. 你會撞到但跟「派工/收工」無關的一支(Edit·MultiEdit
+261
View File
@@ -0,0 +1,261 @@
#!/usr/bin/env bash
# ask-user-question-guard.sh — PreToolUseAskUserQuestion):**在「問出口的那一刻」攔住**
#
# 病根(leo 2026-08-26):「**今天已經好幾次問我,為什麼 hooks 沒有攔下來?**」
# 當天總管至少兩次把純技術路徑選擇丟給 leo,其中一次他當場說
# 「**這種問題不要問我,我要的是你解決了以後給我 prod**」——而**一支閘都沒響**。
#
# 實查(2026-08-26ISEP 0.3.8):
# • 總管問 leo 走的動作就是 `AskUserQuestion` 這個工具
# • `hooks.json` 裡 `AskUserQuestion` 出現 **0 次**——它沒有任何 matcher,是裸的
# • `self-drive-police.sh` / `self-drive-judge.sh` 只掛在 `Stop` / `SubagentStop`
# ⇒ 它們是**回合結束後**才跑的。問題送出去、leo 已經被打斷之後,
# 再來反問「你查過了嗎」已經沒有意義——**成本早就轉嫁出去了**。
#
# ⇒ 這支補的不是判準(判準跟自走警察同一套四題公式),補的是**時機**:
# `Stop` 是事後補刀,`PreToolUse` 才是當下攔截。
#
# ──────────────────────────────────────────────────────────────────────
# 🔴 封的是動作,不是文字(leo 2026-08-17 的檢驗)
#
# leo 原話:「你在**文字層**封路⋯⋯『回「做」我就啓動』沒有問號,下次它會寫
# 『準備就緒』,再下次寫『待命中』。**自然語言的變體是無限的,blacklist 永遠追不完。**
# 封路哲學之所以有效,是因為它封的是**動作**——動作有限且可枚舉,文字不是。」
# 當日實證:文字層的閘 **8 次誤攔、0 次正確攔截**,且方向穩定
# ——**紅線寫得越細,命中關鍵字的機率越高 ⇒ 那些閘在懲罰謹慎。**
#
# 本閘怎麼過這個檢驗:
# • **觸發條件=那個動作本身**`AskUserQuestion` 這一次呼叫),
# 不是任何句型、關鍵字、問號、措辭。改寫成什麼樣子都一樣會進來。
# • **沒有任何 grep 黑名單**(全檔零個判擋用的正則)。換句話說閃不過去,
# 講得謹慎也不會被多罰——這正是文字層閘的兩個病,本閘兩個都沒有。
# • 進來之後要分「該問 / 不該問」,靠的是**四題公式的語意判官**
# `self-drive-judge.sh` 已經在用的同一套 haiku 判官),
# 它判的是「這題命不命中人閘」,不是「這句話長什麼樣」。
#
# 🔴 不准把真人閘也擋掉(本閘存在的第二個前提)
# 四題命中時 leo **要**被問——2026-08-26 當天他就當場裁了一題 UI 取捨(樹要不要捲)。
# **擋掉那種等於讓總管替他決定他的品味,比不擋更糟。**
# 所以本閘有兩層保險:
# ① 判官只在「四題全否」時才回 block;任何一題命中一律 allow
# ② **同一個問題只擋一次**(雜湊戳記)——判官若誤判,重送即放行,
# leo 該收到的問題絕不會因為一支閘而永遠送不到。
# (跟 `micromanage-guard.sh``subagent-first-guard.sh` 同一個慣例:
# 閘是絆一下逼你重想,不是把人關起來。)
#
# 判準(跟 CLAUDE.md 規則二、`self-drive-judge.sh` 完全同一套,不另立第二套):
# ① 花錢 ② 不可逆/難回收 ③ 跨專案結構 ④ 品味/方向 ⑤ 物理人閘(只有人做得到)
# 命中任一 → 放行。四題全否 → 擋一次,逼它自己裁。
#
# 失敗一律 fail-openexit 0):判官掛掉/沒網路/`claude` 不在 PATH
# 都只會回到「今天的狀態」(沒有閘),不會變成「問不出去」。
# ⇒ 這支閘壞掉的最壞後果,等於它不存在,不會癱瘓任何 session。
#
# 官方文件核實(2026-08-26 查 code.claude.com,不是憑記憶):
# • `AskUserQuestion` **會**觸發 PreToolUsematcher 寫工具名有效
# — https://code.claude.com/docs/en/agent-sdk/user-input
# 原文:「Claude calls AskUserQuestion. The PreToolUse hook fires.」
# • `tool_input` 的真實結構=`{"questions":[{"question","header","options":[{"label","description"}],"multiSelect"}]}`
# — 同頁「Question format」段
# • exit 2 在 PreToolUse hard block,工具呼叫不會發生,無已知例外
# — https://code.claude.com/docs/en/hooksExit Codes 表)
# • `AskUserQuestion` **在 subagent 裡不可用**(同頁 Limitations
# ⇒ 所以本閘只會在主 session 觸發;subagent 那一側仍由
# `self-drive-police.sh``self-drive-judge.sh` 的 SubagentStop 守著,兩者不重疊。
#
# 測試:
# hooks/tests/ask-user-question-guard.test.sh 離線、不花錢(判官用替身)
# hooks/tests/ask-user-question-guard.live.test.sh 真的叫 haiku,量判官的準度
set -u
# 遞迴保險:判官自己起的那個 claude 不准再觸發本閘
if [ "${SELF_DRIVE_JUDGE:-}" = "1" ]; then exit 0; fi
input="$(cat)"
# ── 只管 AskUserQuestion 這一個動作 ────────────────────────────────
tool_name="$(printf '%s' "$input" | python3 -c '
import sys, json
try:
print(json.load(sys.stdin).get("tool_name", ""))
except Exception:
print("")
' 2>/dev/null)"
[ "$tool_name" = "AskUserQuestion" ] || exit 0
session_id="$(printf '%s' "$input" | python3 -c '
import sys, json
try:
print(json.load(sys.stdin).get("session_id", ""))
except Exception:
print("")
' 2>/dev/null)"
# ── 把問題本體攤成純文字(給判官看,不給 grep 看)──────────────────
# 刻意用「遞迴撈出所有字串」而不是寫死欄位名:AskUserQuestion 的 tool_input
# schema 若哪天改欄位(questions/question/header/options/label/description…),
# 這支不會變成靜默失效的空判官。
question_text="$(printf '%s' "$input" | python3 -c '
import sys, json
def walk(node, out):
if isinstance(node, str):
s = node.strip()
if s:
out.append(s)
elif isinstance(node, list):
for x in node:
walk(x, out)
elif isinstance(node, dict):
for k in ("question", "header", "label", "description", "text"):
if k in node:
walk(node[k], out)
for k, v in node.items():
if k not in ("question", "header", "label", "description", "text"):
walk(v, out)
out = []
try:
d = json.load(sys.stdin)
walk(d.get("tool_input", {}), out)
except Exception:
pass
seen = set()
uniq = [x for x in out if not (x in seen or seen.add(x))]
print("\n".join(uniq))
' 2>/dev/null)"
# 撈不到內容 ⇒ 沒東西可判 ⇒ 放行(fail-open)
[ -z "$question_text" ] && exit 0
# ── 同一個問題只擋一次 ────────────────────────────────────────────
# 判官誤判時,總管重送同一題就過得去 ⇒ leo 該收到的問題不會被永久攔住。
stamp="/tmp/.ask-gate-warned-$(printf '%s' "$question_text" | shasum | cut -c1-12)"
[ -f "$stamp" ] && exit 0
# ── 明示豁免(留痕用)──────────────────────────────────────────────
# 真的緊急、或判官在誤攔而不想改寫問題時:
# touch /tmp/.ask-ok-<session_id>
# 用掉就消失,不是永久開關。
if [ -n "$session_id" ] && [ -f "/tmp/.ask-ok-$session_id" ]; then
rm -f "/tmp/.ask-ok-$session_id"
exit 0
fi
# ── 語意判官(跟 self-drive-judge.sh 同一套判準、同一個模型)──────
judge_prompt="你是「人閘判官」。下面是一個 AI 助理(總管)**正要送給老闆 leo 的一個提問**。
判斷這個問題**該不該送到 leo 面前**。
判準只有一條——四題人閘公式。它問的事情是否命中下列任一:
① 花錢(付費、開資源、產生帳單、明顯多耗訂閱額度)
② 不可逆/難回收(刪資料、push 到 main、部署上線 prod、對外公開、跨 repo 搬遷)
③ 跨專案結構決策(**repo 與 repo 之間**的邊界:某個能力歸哪個 repo、改全機共用的框架、立一條新鐵律)
🔴 **單一服務/單一 repo 內部怎麼分層、程式碼怎麼組織,不算第③題**——那是實作,不是結構。
④ 品味/方向(**leo 本人看得到、而且會有偏好的東西**:畫面長相、UI/UX 取捨、
要不要做某個功能、對外文案怎麼寫)
🔴 **內部的設計哲學、容錯策略、責任邊界、程式碼美感,不算第④題**——他看不到那些。
⑤ 物理人閘(只有人做得到:貼憑證、終端機同意、平台上按批准、真人驗收)
命中任一 → decision=allow(該問)。
四題全否 → decision=block(這是它自己該裁的)。
🔴 判 allow 時要放寬,判 block 時要保守——**誤擋一個真人閘,比放過一個技術題更糟**。
拿不準、或這題同時有技術面與品味面 → 一律 allow。
典型的 block(四題全否,都是純技術路徑選擇):
- 用哪個函式庫/哪種實作寫法/要不要抽共用模組
- 錯誤怎麼處理、重試幾次、超時設多久
- 檔案放哪個目錄、變數怎麼命名(純內部、用戶看不到)
- 「A 方案和 B 方案我都可以做,你想要哪個?」而兩案的差別只有實作成本
- 「我發現一個 bug,要修嗎?」(修 bug 不是品味題)
- 「要不要繼續/要不要我開始」(已經交代過的事)
典型的 allow
- 要不要開一個會產生帳單的資源、要不要換更貴的模型
- 要不要 push 到 main/部署到 prod/刪掉某批資料
- 某個能力該歸哪個 repo、要不要立一條新鐵律
- 畫面上這個東西要長什麼樣、要不要做這個功能、文案怎麼寫
- 需要你去終端機貼憑證/按批准
只輸出 JSON,不要任何其他文字。
輸出格式:
{\"decision\":\"allow\"或\"block\",\"gate\":\"命中第幾題或 none\",\"reason\":\"一句正體中文;block 時要指出它在問什麼、以及四題為何全否\"}
--- 它要問 leo 的問題 ---
$question_text
--- 結束 ---"
# ⚠️ 判官必須跑在「中性目錄 + --safe-mode」:
# 實測(self-drive-judge.sh 2026-08-05):在專案內跑 `claude -p` → 54 秒
# (內層把 SessionStart recall、全部 hook、全部 MCP 工具都載進去);
# 換成 `cd /tmp` `--safe-mode` → 8 秒。判官只需要判一段文字。
# `ISEP_ASK_JUDGE_CMD` 只為了讓離線測試塞得進一個判官替身(見 tests/),預設就是真判官。
JUDGE_CMD="${ISEP_ASK_JUDGE_CMD:-claude -p --safe-mode --model haiku --allowedTools ''}"
verdict="$(printf '%s' "$judge_prompt" \
| (cd /tmp && SELF_DRIVE_JUDGE=1 sh -c "$JUDGE_CMD" 2>/dev/null))"
# 判官掛了/回空/不是 JSON → fail-open
[ -z "$verdict" ] && exit 0
parsed="$(printf '%s' "$verdict" | python3 -c '
import sys, json, re
raw = sys.stdin.read()
m = re.search(r"\{.*\}", raw, re.S)
if not m:
print("allow||"); raise SystemExit
try:
d = json.loads(m.group(0))
print((d.get("decision") or "allow") + "|" + (d.get("gate") or "") + "|" + (d.get("reason") or ""))
except Exception:
print("allow||")
' 2>/dev/null)"
case "$parsed" in
block\|*)
rest="${parsed#block|}"
reason="${rest#*|}"
date +%s > "$stamp"
# 訊息用 quoted heredoc(裡面有反引號,不能讓 shell 當命令替換執行過),
# 佔位符交給 python 換掉——sed 對正體中文與特殊字元的跳脫太脆。
msg="$(cat <<'EOF'
🚦 人閘警察(PreToolUseAskUserQuestion):**這一問四題全否,是你自己該裁的。**
判官的話:__REASON__
【leo 2026-08-26】「**今天已經好幾次問我,為什麼 hooks 沒有攔下來?**」
「**這種問題不要問我,我要的是你解決了以後給我 prod。**」
【leo 2026-08-17】「你的命令**被當成『規劃的輸入』,而不是『執行的授權』**⋯⋯
從它的『文化』看這是禮貌;**從你的系統看,這是違約。**」
━━━ 不確定時走這條,不要停(CLAUDE.md 常駐授權那段)━━━
1. 查 wiki`system-dev/wiki/`**語意搜尋優先於 grep**
2. 套四題公式:① 花錢 ② 不可逆 ③ 跨專案結構 ④ 品味方向
3. 仍不確定 → **做出最合理的假設,把假設寫進 commit message 或票的留言,繼續走**
——第 3 步是關鍵:**假設之後不是問,是記錄。**
leo 下課後 review 時一次看到所有假設,對的併、錯的打回。
這把同步的提問改造成非同步的問答,**不掛起整個 loop**。
━━━ 這一問真的命中人閘?━━━
**把命中哪一題直接寫進問題裡再送一次**(例:「四題第②命中:這會刪掉線上資料」),
或 `touch /tmp/.ask-ok-__SESSION__` 後重送。
📌 **同一個問題本閘只擋一次**——原封不動重送就會過。
判官寧可放行也不誤擋,但它會錯;**它錯了不該讓 leo 收不到問題。**
EOF
)"
ASK_MSG="$msg" ASK_REASON="$reason" ASK_SESSION="$session_id" python3 -c '
import os, sys
sys.stderr.write(
os.environ["ASK_MSG"]
.replace("__REASON__", os.environ.get("ASK_REASON", ""))
.replace("__SESSION__", os.environ.get("ASK_SESSION", "<session_id>"))
+ "\n"
)
'
exit 2
;;
esac
exit 0
+189
View File
@@ -0,0 +1,189 @@
#!/usr/bin/env bash
# 管什麼: 派工單裡除了【工單】票號以外還有別的內容就擋;合規的派工則自動注入共通規定。
# 為什麼: 任務寫在 prompt 裡 => 票上看不到、agent 一停就蒸發;散文沒有欄位 => 警察抓不到。
# 誤觸時怎麼關: 把內容歸位(每次都一樣的→共通規定;這次才知道的→寫進票),或 touch /tmp/.dispatch-ok-<session_id> 放行一次。
#
# dispatch-format-guard.sh — PreToolUseTaskAgent):**派工單 = 票號。就這樣。**
#
# ━━ 來由(leo 2026-08-27inkstone/ISEP#30 comment 4322 / 4325 / 4327)━━━━
#
# 他貼回總管派給 Arcrun#142 那條線的 prompt,問:
# 「**這些話票上都沒有,你根本沒照規則做事,你的 hook 讓你這樣搞?**」
# 「執行的不是你,你去派工,它才知道問題,**你寫診斷意義是什麼?不就是假的?**」
#
# 實查:`no-ticket-no-dispatch.sh` 驗的是「派工單裡有沒有一行【工單】owner/repo#N」。
# ⇒ 所以可以**把 40 行任務全寫在 prompt 裡、票號補一行**,閘照樣放行。
# ⇒ 2026-08-27 一天之內這樣做了 5 次(arcrun-rag#104Arcrun#142Arcrun#127
# Arcrun#144InkStoneCo#55),每一次票上都沒有那份任務。
# ⇒ **規則存在,閘只驗了它的殼。**(同款第 N 次:history-firstKBDB-first
# stage-firstAskUserQuestion 裸奔,全是這個形狀。)
#
# 4325 追加:「你用一個 **output parser** 把你給 subagent 的指令規範,**分作幾點,
# 每一點規定格式**,**照這種散文寫法根本無法迭代**」「**警察也不能抓**」。
# 4327 收斂到最後一步:「**交件方式不需要寫,定義在原則裡**⋯⋯每次都一樣提取出來
# 變成共通規定」+(對「這是 session 才知道的事」的回應)「**這些為什麼不寫到票裡?**」
#
# ⇒ 派工單裡想寫的東西只有兩種,兩種都不該留在派工單:
# 每次都一樣 → 共通規定(本閘自動注入,見下)
# 這次才知道 → **寫進那張票**
# 判準:**「這句話換一張票還成立嗎?」**
#
# ━━ 為什麼是結構閘,不是關鍵字閘(leo 2026-08-17 的檢驗)━━━━━━━━━━━━
# 「你在**文字層**封路⋯⋯『回「做」我就啓動』沒有問號,下次它會寫『準備就緒』⋯⋯
# **自然語言的變體是無限的,blacklist 永遠追不完。**
# 封路哲學之所以有效,是因為它封的是**動作**——動作有限且可枚舉,文字不是。」
# 當日實證:文字層的閘 8 次誤攔、0 次正確攔截,且方向穩定——
# **紅線寫得越細,命中關鍵字的機率越高 ⇒ 那些閘在懲罰謹慎。**
#
# 本閘怎麼過這個檢驗:
# · 判準是**「這一行是不是【工單】欄位」**——在不在,不是寫什麼。
# 整條路徑(hooks/lib/dispatch_parse.py**零個「命中某個詞就違規」的比對**。
# · ⇒ 措辭再謹慎也不會被多罰;改寫成別的講法也閃不過去。文字層閘的兩個病都沒有。
# · 也**不需要語意判官**:這條規則本來就是結構性的,用不著 LLM,
# 於是它免費、瞬間、每次結果一樣(比 ask-user-question-guard 更硬的一種閘)。
#
# ━━ 這支同時做兩件事 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# ① **擋**:【工單】以外有實質內容 → exit 2,並指出那些內容該搬去哪一格
# ② **注入**:合規的派工 → exit 0 並用 hookSpecificOutput 把共通規定送給收工方
# (慣例同 subagent-wiki-guard.sh)。
# ⇒ 這是「派工單只剩票號」能成立的前提:交件方式、不准 push main、org 是 inkstone…
# **不必有人記得寫**,機器每次都補。
# leo 的驗收條件之一就是「收工方**沒讀派工單**也知道要貼回原票」。
#
# ━━ 邊界(刻意不管的事)━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# · 派工單裡**沒有**【工單】 → 本閘閉嘴,那是 no-ticket-no-dispatch.sh 的地盤。
# 兩支閘同時開口,收工方會拿到兩份互相打架的教學。
# · 票存不存在、關了沒 → 也是那一支在驗,本閘不重複打 Gitea(本閘不連網)。
#
# 逃生口(留痕、用完即丟,慣例同 micromanage-guard.shask-user-question-guard.sh):
# touch /tmp/.dispatch-ok-<session_id>
# 閘是絆一下逼你重想,不是把人關起來。
#
# 測試: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-27inkstone/ISEP#30 comment 4327
__VIOLATIONS__
━━ 這些內容該去哪:問一句「**這句話換一張票還成立嗎?**」━━━━━━━━━━
還成立 ⇒ **共通規定**(交件方式、不准 push main、org 是 inkstone、先讀 CLAUDE.md…)
改 `docs/governance/dispatch-and-reply-format.md` 的 §2
**本閘每次派工都會自動注入給收工方**,你不必寫、也不會漏。
只有這次 ⇒ **寫進那張票**(main 是哪顆、今天撞過什麼、另一條線在動什麼)
`scripts/ticket say <owner/repo#N> -F <檔>`
然後派工單寫 `【工單】<owner/repo#N> → comment <剛拿到的號碼>`
🔴 **「票上還沒有」不是把它寫進 prompt 的理由——它就是「去把它寫上票」的指令。**
leo 2026-08-27:「**這些為什麼不寫到票裡?**」
寫進 prompt 的後果:agent 被停掉或換人接手,那段事實隨 prompt 一起蒸發。
當天實害:總管停掉重派 3 次,前兩次的任務與 session 事實**全部消失**。
━━ 合格的派工單長這樣(整份,沒有省略)━━━━━━━━━━━━━━━━━━━━━
【工單】inkstone/ISEP#30 → comment 4322
━━ 為什麼連「一句叮嚀」都不行 ━━━━━━━━━━━━━━━━━━━━━━━━━━━
leo 2026-08-27:「**交件方式不需要寫,定義在原則裡,每張票都要做這件事⋯⋯
每次都一樣提取出來變成共通規定**」
你多寫的每一句,都是在造第二份會漂的真相;而收工方讀到的是**你的轉述**,
不是票上的原始證據。
真的有例外 → `touch /tmp/.dispatch-ok-__SESSION__` 後重送(用掉就消失,會留在指令歷史上)。
EOF
)"
VIOL_TEXT="$(printf '%s' "$FACTS" | python3 -c '
import sys, json
try:
d = json.load(sys.stdin)
except Exception:
d = {}
lines = []
for v in d.get("violations", []):
lines.append(" ❌ %s%s" % (v.get("code", ""), v.get("detail", "")))
for name in d.get("retired_seen", []):
lines.append(" ↳ 【%s】這個欄位已經收回了" % name)
print("\n".join(lines))
' 2>/dev/null)"
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 "<session_id>")
+ "\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"<!-- INJECT:BEGIN.*?-->\n(.*?)<!-- INJECT:END -->", text, re.S)
if not m:
sys.exit(0)
body = m.group(1).strip()
print(json.dumps({
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"additionalContext":
"【自動注入:派工共通規定】\n"
"(來源 docs/governance/dispatch-and-reply-format.md §2——"
"派工單只給票號是刻意的,這些每次都一樣的東西由機器補,不靠人記得寫)\n\n"
+ body,
}
}, ensure_ascii=False))
' 2>/dev/null
exit 0
+22 -1
View File
@@ -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,19 @@
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/no-ticket-no-dispatch.sh"
},
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/dispatch-format-guard.sh"
}
]
},
{
"matcher": "AskUserQuestion",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/ask-user-question-guard.sh"
}
]
}
@@ -327,4 +348,4 @@
}
]
}
}
}
+229
View File
@@ -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-27inkstone/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
就這一行。要帶兩張票就兩行【工單】。**沒有第二個欄位。**
━━ 交件回覆格式(任何人 → 票)━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
【身份】subagentinkstone/ISEPfeat/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()
+73
View File
@@ -0,0 +1,73 @@
#!/bin/bash
# 管什麼: 直接打 Gitea API 貼留言到票上時,內文第一行沒有【身份】就擋。
# 為什麼: scripts/ticket 已經在正門擋了,但貼留言還有 API 這條側門——只封一條等於沒封。
# 誤觸時怎麼關: 內文第一行加【身份】<總管|subagent|leo><owner/repo><分支>,或在指令裡加 reply-identity-ok 留痕放行。
#
# reply-identity-guard.sh — PreToolUseBash):**票上的每一則留言都要認得出是誰寫的**
#
# 來由(leo 2026-08-27inkstone/ISEP#30 comment 4325):
# 「**subagent 回覆時要表明身份**」
# 實害(同日):多條線並行,票上的留言看不出是誰寫的,
# **總管寫的診斷被當成 subagent 的結論,而其中一則是錯的**。
#
# 為什麼要有這一支,正門的檢查不夠:
# `scripts/ticket say` 已經在貼上去之前擋了。但**貼留言這個動作有兩條路**——
# 而 `ticket-api-bypass-guard.sh` 是**刻意放行**「對既有票的留言」的(它管的是開新票)。
# ⇒ 側門大開。這正是那支閘檔頭自己記過的一課:
# 「規範有、閘也有,但閘長在『工具』上,而那個動作有兩條路,只封了一條。」
#
# 判準是**結構**不是文字(leo 2026-08-17:「封的是動作,不是文字」):
# 本閘不看留言寫什麼,只看**那個欄位在不在**。
# 三個條件都命中才擋:① 打到 /issues/<N>/comments 這條端點 ② 指令裡有 POST
# ③ 內文找不到【身份】。純讀取(GET 撈留言)一律放行。
#
# 逃生口:指令裡加 `reply-identity-ok`(會留在指令歷史上)。
#
# 測試:hooks/tests/reply-identity.test.sh
set -uo pipefail
INPUT=$(cat)
CMD=$(printf '%s' "$INPUT" | python3 -c "
import sys, json
try: print(json.load(sys.stdin).get('tool_input', {}).get('command', ''))
except Exception: print('')
" 2>/dev/null)
[ -n "$CMD" ] || exit 0
# 逃生口(留痕)
case "$CMD" in *reply-identity-ok*) exit 0 ;; esac
# 正門有自己的閘,重複擋只會互相打架
case "$CMD" in *scripts/ticket*|*"ticket say"*|*"ticket decide"*|*"ticket close"*) exit 0 ;; esac
# ① 打到「某張票的留言」這條端點(帶票號的子路徑,這是本閘唯一管的東西)
printf '%s' "$CMD" | grep -qE 'issues/[0-9]+/comments' || exit 0
# ② 純讀取一律放行。同 ticket-api-bypass-guard 的理由:只認一個裸字就夠——
# ①已經確定這是留言端點,讀取的指令不會出現 POST,而且沒有跳脫的破口。
printf '%s' "$CMD" | grep -qw 'POST' || exit 0
# ③ 內文有沒有身份欄。只問「在不在」,不問「寫什麼」。
printf '%s' "$CMD" | grep -q '【身份】' && exit 0
cat >&2 <<'MSG'
🚫 貼到票上的留言,第一行要表明身份(leo 2026-08-27:「**subagent 回覆時要表明身份**」)
你正在用 Gitea API 直接貼留言,而內文裡找不到【身份】。
第一行照這個寫(角色三選一:總管/subagent/leo):
【身份】subagentinkstone/ISEPfeat/my-branch
【身份】總管/inkstone/InkStoneCo-
**這條管所有人,不是只管 subagent。** 總管寫在票上的東西同樣要標——
實害(2026-08-27):多條線並行時票上看不出誰寫的,
**總管寫的診斷被當成 subagent 的結論,而其中一則是錯的**。
── 走正門比較省事 ─────────────────────────────────────────────
scripts/ticket say <owner/repo#N> -F <內文檔>
它會回一行「定址」,那串就是下次派工要寫進【工單】的東西。
規約全文:docs/governance/dispatch-and-reply-format.md §3
真的有例外 → 指令裡加 reply-identity-ok(會留在指令歷史上)。
MSG
exit 2
+73 -17
View File
@@ -1,6 +1,6 @@
#!/bin/sh
# subagent-first-guard.sh — PreToolUseWrite|Edit|MultiEdit):
# 要親手改 code,卻**這個 session 一次工都沒派過** → 擋一次,逼你先回答「這件事該不該派出去」。
# 要親手改 code,卻不是被派來做這件事的人 → 擋,逼你先回答「這件事該不該派出去」。
#
# 🔴 立這道閘的來由(leo 2026-08-08):
# 「你記得**要叫 subagent 開工,你負責維護 loop**,而不是你開工後過一陣子停下來對吧?
@@ -15,8 +15,34 @@
# 這道閘負責讓「授權」真的變成「行為」——同 KBDB 那道的教訓:
# **規則被讀到 ≠ 會被執行,要有機制驗證照做**。
#
# 逃生口:真的該自己做(單行修、改 hook 自己、緊急止血、subagent 回報後的收尾)
# → `touch /tmp/.solo-ok-<session_id>` 後重送,並**在回覆裡說明理由**(留痕)。
# 🔴 2026-08-27 改版(inkstone/ISEP#66)——leo 當場撞到:「你禁止寫 code,為什麼你改?
# 為什麼你可以執行?」查出來這道閘同時有三個洞,改完的是這三個:
#
# 洞一:`.sh` 從來不在受管副檔名清單裡。這個 repo 自己的閘全部是 .sh
# ⇒ 總管改任何一支閘,這道閘從第一行 case 就直接放行,路徑白名單根本沒機會判斷。
# 這才是「改 no-ticket-no-dispatch.sh 閘一聲不響」的真正原因,不是路徑豁免。
# → 修法:閘本身(`.claude/hooks/*.sh`、`hooks/*.sh`)獨立收進受管清單,
# 不看副檔名是不是「傳統程式語言」。同時拿掉 `*/.claude/hooks/*` 整目錄豁免——
# 「能改閘的人,等於能關掉所有其他閘」,這句話本身就是拿掉它的理由;
# 真要緊急止血,走下面②的 solo-ok,不需要一扇整目錄常開的門。
# 洞二:「這個 session 派過一次工」被當永久放行條件。但「派過一次」跟
# 「有派工的習慣」是兩件事——派過工只回答了「那一件任務有沒有交出去」,
# 回答不了「這一次任意的手改合不合理」。→ 修法:這個訊號整個拿掉,
# 不再是放行條件(只留來在被擋下時的提示文字裡,讓你看到「你剛剛才派過工,
# 這次怎麼不派」,純資訊、不放行)。
# 洞三:「同一 session 只擋一次」=警報响過一次就對這個 session 的餘生啞掉,
# 等於自己承認「這道閘只在第一次有效」。但本 repo 心法第二條反過來也成立:
# 永遠在響 = 訓練人忽略它。→ 修法:改成「寬限期內不重複鬼打牆,
# 寬限期一過就再響」——兩頭都要顧,不是選一邊犧牲。
#
# 放行只剩三種:
# ① CLAUDE_CODE_CHILD_SESSION=1 —— 這是被派來做事的 subagent 本人,它就是被派來寫的
# ② /tmp/.solo-ok-<session_id> 存在且**非空**(寫了理由,不是 touch 空檔)
# ③ 檔案本身不歸這道閘管(測試檔/system-dev 的 wiki/非受管副檔名)
#
# 逃生口:真的該自己做(單行修、緊急止血、subagent 回報後的收尾)
# → 把理由寫進檔案本身,不是單純 touch:
# echo "單行修 xxx,理由 yyy" > /tmp/.solo-ok-<session_id>
set -eu
INPUT="$(cat)"
@@ -33,34 +59,62 @@ except Exception: print("nosid")
[ -z "$FILE_PATH" ] && exit 0
# 只管 code 檔——文件、SDD、wiki、測試、hook 自己都放行
# (這些本來就常是總管自己該寫的:判準、規格、落帳)
# ① 這是被派來做事的 subagent 本人(不是總管自己)→ 它本來就該寫 code,不歸這道閘管
[ "${CLAUDE_CODE_CHILD_SESSION:-}" = "1" ] && exit 0
# 只管「這是不是該被派工規則管的程式碼」——
# 一般受管語言,加上「閘本身」(即使是 .sh,這正是洞一要補的地方)
case "$FILE_PATH" in
*/.claude/hooks/*.sh|*/hooks/*.sh) ;;
*.ts|*.tsx|*.js|*.jsx|*.mjs|*.go|*.py|*.rs|*.java|*.rb) ;;
*) exit 0 ;;
esac
# 就算是受管檔案,這兩類仍放行:測試檔/頂層 wiki(system-dev/
case "$FILE_PATH" in
*_test.*|*.test.*|*.spec.*|*/tests/*|*/test/*) exit 0 ;;
*/.claude/hooks/*|*system-dev/*) exit 0 ;;
*system-dev/*) exit 0 ;;
esac
[ -f "/tmp/.subagent-spawned-$SID" ] && exit 0 # 這個 session 派過工了 → 放行
[ -f "/tmp/.solo-ok-$SID" ] && exit 0 # 明示要自己做 → 放行(留痕)
# ② 明示「我自己做」且寫了理由(不是空檔)→ 放行
SOLO_OK="/tmp/.solo-ok-$SID"
[ -s "$SOLO_OK" ] && exit 0
NOW=$(date +%s)
NAG_TTL=${SUBAGENT_GUARD_NAG_TTL:-900} # 15 分鐘寬限期:同一輪不重複鬼打牆,過了才再響
WARNED="/tmp/.subagent-guard-warned-$SID"
[ -f "$WARNED" ] && exit 0 # 同一 session 只擋一次,不鬼打牆
if [ -f "$WARNED" ]; then
WARN_TS=$(cat "$WARNED" 2>/dev/null || echo 0)
case "$WARN_TS" in ''|*[!0-9]*) WARN_TS=0 ;; esac
if [ $((NOW - WARN_TS)) -lt "$NAG_TTL" ]; then
exit 0 # 剛擋過,還在寬限期內 → 不重複鬼打牆
fi
fi
date +%s > "$WARNED"
cat >&2 <<'EOF'
🧑‍🏭 派工警察:你要親手改 code,但這個 session **一次工都沒派過**。
# 派過工不是放行條件(洞二),但拿來給脈絡:讓你看到「上次派工是多久前」
DISPATCH_NOTE=""
SPAWNED="/tmp/.subagent-spawned-$SID"
if [ -f "$SPAWNED" ]; then
D_TS=$(cat "$SPAWNED" 2>/dev/null || echo 0)
case "$D_TS" in ''|*[!0-9]*) D_TS=0 ;; esac
if [ "$D_TS" -gt 0 ] 2>/dev/null; then
MIN_AGO=$(( (NOW - D_TS) / 60 ))
DISPATCH_NOTE="
【這個 session 派過工】最近一次是 ${MIN_AGO} 分鐘前——
但「派過一次」不等於「這次也不用派」,兩件事分開判斷。"
fi
fi
cat >&2 <<EOF
🧑‍🏭 派工警察:你要親手改 code。${DISPATCH_NOTE}
【leo 2026-08-08】「你記得**要叫 subagent 開工,你負責維護 loop**
而不是你開工後過一陣子停下來對吧?」
【已知的環境陷阱】Claude Desktop 這個 surface 的 system prompt 有
`Do not call the AgentTool unless the user requested it`,會壓過 CLAUDE.md 規則三點五。
**leo 已在 CLAUDE.md 給出常駐授權**(規則三點五的「常駐授權」段)=那個條件永久滿足,
不必再等他開口。
【leo 2026-08-27】「你禁止寫 code,為什麼你改?為什麼你可以執行?」
——這句話問的就是現在這一次。
先回答一句(答不出來就是該派):
這件事**為什麼不能交給 subagent**
@@ -69,6 +123,8 @@ cat >&2 <<'EOF'
✅ 該自己做:判準/規格/落帳(那些檔本來就放行)、subagent 回報後的收尾裁決、
單行修、緊急止血
決定自己做 ⇒ `touch /tmp/.solo-ok-$SID` 後重送,並**在回覆裡說明理由**。
決定自己做 ⇒ 把理由寫進檔案本身(不是 touch 空檔):
echo "理由:xxx" > /tmp/.solo-ok-$SID
再重送這次操作。
EOF
exit 2
+6
View File
@@ -4,6 +4,12 @@
# 配 subagent-first-guard.sh 使用(同 kbdb-asked-stamp.sh 的形狀)。
# 只留時戳,不擋任何東西。
#
# 🔴 2026-08-27inkstone/ISEP#66):這個時戳**不再是放行條件**。
# 「派過一次工」曾經被 subagent-first-guard.sh 當成「這個 session 永久放行」——
# 但那混淆了「這件任務有沒有交出去」跟「下一次任意的手改合不合理」,是兩件事。
# 現在這支只負責留時戳,給被擋下時的訊息當脈絡用(「上次派工是幾分鐘前」),
# 純資訊、不影響放行判斷。
#
# 🔴 為什麼要按 session 分開存(而不是像 KBDB 那道用全域+1 小時 TTL):
# 「這一輪工作有沒有先考慮派工」是**每個 session 各自要回答**的問題。
# 用全域檔的話,昨天派過一次就等於永久解鎖,這道閘會在第二天起完全失效。
+92
View File
@@ -0,0 +1,92 @@
#!/usr/bin/env bash
# ask-user-question-guard.sh 的**判官準度**測試(inkstone/InkStoneCo#55)。
#
# 🔴 這支**真的會叫 haiku**(每題一次呼叫,共 9 題)。離線那支測管路,這支測判斷。
# 分開兩檔的理由:管路壞掉要立刻知道且不該花錢;判官準度是另一種失敗,
# 而且它會隨模型版本漂移,需要單獨、可重跑的量尺。
#
# 兩個方向都要有實例,缺一邊等於沒測:
# B 群「該擋」 = 純技術路徑選擇(leo 2026-08-26:「這種問題不要問我」)
# A 群「不該擋」= 真的四題人閘(花錢/不可逆/跨專案結構/品味方向/物理人閘)
#
# 🔴 **A 群失敗比 B 群失敗嚴重。** 誤擋一個真人閘 = 讓總管替 leo 決定他的品味,
# 比漏擋一個技術題更糟。所以本檔把 A 群失敗單獨列出來報。
#
# 用法:hooks/tests/ask-user-question-guard.live.test.sh [hook 路徑]
set -u
HOOK="${1:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/ask-user-question-guard.sh}"
PASS=0; FAIL=0; N=0; AFAIL=0
payload() {
python3 - "$@" <<'PY'
import json, sys
q, opts = sys.argv[1], sys.argv[2:]
print(json.dumps({
"session_id": "S-LIVE",
"hook_event_name": "PreToolUse",
"tool_name": "AskUserQuestion",
"tool_input": {"questions": [{
"question": q,
"header": "決策",
"multiSelect": False,
"options": [{"label": o, "description": ""} for o in opts],
}]},
}, ensure_ascii=False))
PY
}
# t <期望 exit> <群> <說明> <問題> [選項...]
t() {
want="$1"; grp="$2"; desc="$3"; shift 3
N=$((N+1))
rm -f /tmp/.ask-gate-warned-* 2>/dev/null || true # 每題都要真的走到判官
out=$(payload "$@" | sh "$HOOK" 2>&1); rc=$?
if [ "$rc" -eq "$want" ]; then
printf ' ✅ %s\n' "$desc"; PASS=$((PASS+1))
else
printf ' ❌ %s —— 期望 exit=%s,實得 exit=%s\n' "$desc" "$want" "$rc"
printf '%s\n' "$out" | grep -m1 '判官的話' | sed 's/^/ /'
FAIL=$((FAIL+1))
[ "$grp" = "A" ] && AFAIL=$((AFAIL+1))
fi
}
echo "── A 群:不該擋(真人閘,leo 要被問到)───────────────────────"
t 0 A "①④ 品味/方向:總圖那棵樹要不要預設收合(leo 08-26 當場裁的就是這種)" \
'總圖那棵樹,同一層節點超過 20 個的時候,要不要預設收合起來?' '預設收合' '全部展開'
t 0 A "②① 花錢:要不要開一顆會產生帳單的付費實例" \
'這個功能需要一顆專用的 Cloudflare 付費實例才跑得動,要開嗎?每月大約會多一筆帳單。' '開' '不開'
t 0 A "③② 不可逆:刪掉 stage 上那批資料重灌" \
'要把 stage 上那批舊資料整批刪掉重灌嗎?刪了拿不回來。' '刪掉重灌' '保留'
t 0 A "④③ 跨專案結構:ISEP 的閘要不要拆回各子 repo 各自維護" \
'ISEP 的閘目前是全機共用一份。要不要改成各子 repo 各自維護一份自己的?這會改掉 repo 之間的邊界。' '拆回各 repo' '維持共用'
t 0 A "⑤⑤ 物理人閘:要你親手去終端機解保險才推得了 prod" \
'這一版要推 prod,需要你親手在終端機跑 github-arm.sh 解保險,我沒辦法代跑。現在推嗎?' '現在推' '晚點再說'
echo
echo "── B 群:該擋(純技術路徑選擇,四題全否)─────────────────────"
t 2 B "⑥ 實作分層:重試邏輯放哪一層" \
'重試邏輯要放在 client 那一層還是 worker 那一層?' '放 client' '放 worker'
t 2 B "⑦ 空白選擇題:兩案差別只有實作成本" \
'這個 bug 有兩種修法,A 是改解析器、B 是在呼叫端多包一層,兩種我都做得出來,效果一樣。你想要哪一個?' 'A 改解析器' 'B 呼叫端包一層'
t 2 B "⑧ 請示句:已經交代過的事還在問要不要開始" \
'那我現在開始修那支 hook 嗎?' '開始' '先等等'
t 2 B "⑨ 內部命名/擺放位置(用戶看不到)" \
'新的測試檔要放在 hooks/tests/ 底下還是 scripts/ 底下?檔名要叫 guard.test.sh 還是 test-guard.sh' 'hooks/tests/' 'scripts/'
rm -f /tmp/.ask-gate-warned-* 2>/dev/null || true
echo
echo "══ $PASS/$N 通過${FAIL:+$FAIL 個失敗}${AFAIL:+(其中 $AFAIL 個是 A 群誤攔——這種最嚴重)} ══"
[ "$FAIL" -eq 0 ]
+165
View File
@@ -0,0 +1,165 @@
#!/usr/bin/env bash
# ask-user-question-guard.sh 的離線迴歸測試(inkstone/InkStoneCo#55)。
#
# 這一檔測的是**管路**:抓不抓得到那個動作、擋的時候 exit 幾、
# 判官掛掉會不會把 session 卡死、同一題會不會擋第二次。
# **判官的準度另外一檔測**`ask-user-question-guard.live.test.sh`,那支真的叫 haiku)。
#
# 判官在這裡用替身(`ISEP_ASK_JUDGE_CMD` 指向一支印固定 JSON 的假腳本),
# 所以本檔 **不打網路、不花錢、跑得完**。
#
# 兩個方向都要有證據:
# A 群「該放行」——非 AskUserQuestion 的動作、判官說 allow、判官壞掉、豁免戳記、第二次重送
# B 群「該擋」 ——判官說 block(純技術路徑選擇)
# C 群「訊息承諾的出路真的走得通」——重送與豁免戳記各一個實例
#
# 用法:hooks/tests/ask-user-question-guard.test.sh [hook 路徑]
set -u
HOOK="${1:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/ask-user-question-guard.sh}"
TMP=$(mktemp -d)
trap 'rm -rf "$TMP"' EXIT
PASS=0; FAIL=0; N=0
# 判官替身:吃 stdin、印一段固定的裁決
mkstub() {
cat > "$TMP/judge-$1.sh" <<EOF
#!/bin/sh
cat > /dev/null
printf '%s' '$2'
EOF
chmod +x "$TMP/judge-$1.sh"
printf '%s' "$TMP/judge-$1.sh"
}
STUB_BLOCK=$(mkstub block '{"decision":"block","gate":"none","reason":"純技術路徑選擇,四題全否"}')
STUB_ALLOW=$(mkstub allow '{"decision":"allow","gate":"④","reason":"UI 取捨是品味題"}')
STUB_EMPTY=$(mkstub empty '')
STUB_JUNK=$(mkstub junk 'Error: model overloaded')
STUB_NEVER="$TMP/judge-does-not-exist.sh"
# payload <問題> [選項...]
payload() {
python3 - "$@" <<'PY'
import json, sys
q, opts = sys.argv[1], sys.argv[2:]
print(json.dumps({
"session_id": "S-TEST",
"hook_event_name": "PreToolUse",
"tool_name": "AskUserQuestion",
"tool_input": {"questions": [{
"question": q,
"header": "決策",
"multiSelect": False,
"options": [{"label": o, "description": o + " 的說明"} for o in opts],
}]},
}, ensure_ascii=False))
PY
}
# t <期望 exit> <判官替身> <說明> <payload JSON>
t() {
want="$1"; judge="$2"; desc="$3"; body="$4"
N=$((N+1))
out=$(printf '%s' "$body" | ISEP_ASK_JUDGE_CMD="$judge" sh "$HOOK" 2>&1); rc=$?
if [ "$rc" -eq "$want" ]; then
printf ' ✅ %s\n' "$desc"; PASS=$((PASS+1))
else
printf ' ❌ %s —— 期望 exit=%s,實得 exit=%s\n' "$desc" "$want" "$rc"
printf '%s\n' "$out" | sed -n '1,6p' | sed 's/^/ /'
FAIL=$((FAIL+1))
fi
}
clean() { rm -f /tmp/.ask-gate-warned-* /tmp/.ask-ok-S-TEST 2>/dev/null || true; }
echo "── A 群:該放行 ────────────────────────────────────────────────"
clean
t 0 "$STUB_BLOCK" "① 不是 AskUserQuestion 的動作,一律不管(連判官都不叫)" \
'{"tool_name":"Bash","tool_input":{"command":"ls"}}'
t 0 "$STUB_BLOCK" "② tool_input 是空的 → 沒東西可判,放行" \
'{"tool_name":"AskUserQuestion","session_id":"S-TEST","tool_input":{}}'
t 0 "$STUB_BLOCK" "③ 整包不是合法 JSON → fail-open" \
'this is not json at all'
clean
t 0 "$STUB_ALLOW" "④ 真人閘之一(品味/方向):樹要不要捲——判官 allow,leo 該收到" \
"$(payload '總圖那棵樹,同層節點超過 20 個時要不要預設收合?' '預設收合' '全部展開')"
clean
t 0 "$STUB_EMPTY" "⑤ 判官回空(掛了/沒網路)→ fail-open,不准把問題卡住" \
"$(payload '這個 worker 要不要改用 D1 存執行紀錄?' 'D1' 'KV')"
clean
t 0 "$STUB_JUNK" "⑥ 判官回的不是 JSON → fail-open" \
"$(payload '這個 worker 要不要改用 D1 存執行紀錄?' 'D1' 'KV')"
clean
t 0 "$STUB_NEVER" "⑦ 判官根本不存在(claude 不在 PATH)→ fail-open" \
"$(payload '這個 worker 要不要改用 D1 存執行紀錄?' 'D1' 'KV')"
clean
touch /tmp/.ask-ok-S-TEST
t 0 "$STUB_BLOCK" "⑧ 明示豁免戳記在 → 放行(C 群:訊息裡承諾的出路)" \
"$(payload '這題我確定是人閘,先放我過' 'A' 'B')"
if [ -f /tmp/.ask-ok-S-TEST ]; then
printf ' ❌ ⑧b 豁免戳記用完沒被消掉(會變成永久開關)\n'; FAIL=$((FAIL+1))
else
printf ' ✅ ⑧b 豁免戳記用完就消失,不是永久開關\n'; PASS=$((PASS+1))
fi
N=$((N+1))
echo
echo "── B 群:該擋(純技術路徑選擇,四題全否)─────────────────────"
clean
TECH="$(payload '重試邏輯要放在 client 還是 worker 那一層?' '放 client' '放 worker')"
t 2 "$STUB_BLOCK" "⑨ 判官 block → exit 2,問題不會送到 leo 面前" "$TECH"
# 訊息內容檢查:要點名 leo 的原話與四題公式,且要給得出出路
# (先清戳記,否則這次會走「同一題只擋一次」那條路而沒有訊息)
clean
msg=$(printf '%s' "$TECH" | ISEP_ASK_JUDGE_CMD="$STUB_BLOCK" sh "$HOOK" 2>&1 >/dev/null || true)
N=$((N+1))
if printf '%s' "$msg" | grep -q '為什麼 hooks 沒有攔下來' \
&& printf '%s' "$msg" | grep -q '四題' \
&& printf '%s' "$msg" | grep -q '重送'; then
printf ' ✅ ⑩ 擋下的訊息帶了:leo 原話+四題公式+重送的出路\n'; PASS=$((PASS+1))
else
printf ' ❌ ⑩ 擋下的訊息缺了「leo 原話/四題公式/出路」其中一項\n'
printf '%s\n' "$msg" | sed -n '1,8p' | sed 's/^/ /'
FAIL=$((FAIL+1))
fi
# 迴歸:訊息裡的反引號一度被 shell 當成命令替換執行掉
# (原本寫 `cat >&2 <<EOF`,於是 `system-dev/wiki/` 真的被當指令跑,
# stderr 冒出 "is a directory",而 wiki 路徑與豁免指令在訊息裡雙雙變成空白
# ——閘還是擋了,但**它教人怎麼解的那兩行是空的**,等於沒有出路。)
N=$((N+1))
if printf '%s' "$msg" | grep -q 'system-dev/wiki/' \
&& printf '%s' "$msg" | grep -q 'touch /tmp/.ask-ok-S-TEST' \
&& ! printf '%s' "$msg" | grep -q 'is a directory'; then
printf ' ✅ ⑩b 訊息原文照印:反引號沒有被當成命令執行,出路那兩行沒有變空白\n'; PASS=$((PASS+1))
else
printf ' ❌ ⑩b 訊息被 shell 展開了(wiki 路徑/豁免指令消失,或冒出 shell 錯誤)\n'
printf '%s\n' "$msg" | sed -n '1,20p' | sed 's/^/ /'
FAIL=$((FAIL+1))
fi
echo
echo "── C 群:訊息承諾的出路真的走得通 ─────────────────────────────"
# 上面兩次已經把戳記寫下去了,同一題原封不動重送 → 必須放行
t 0 "$STUB_BLOCK" "⑪ 同一題原封不動重送 → 放行(判官誤判不會讓 leo 永遠收不到)" "$TECH"
clean
DIFFQ="$(payload '要不要把這批舊資料刪掉?這是不可逆的' '刪掉' '留著')"
t 2 "$STUB_BLOCK" "⑫ 換一題(不同雜湊)→ 戳記不共用,仍然會判" "$DIFFQ"
clean
echo
echo "══ $PASS/$N 通過${FAIL:+$FAIL 個失敗} ══"
[ "$FAIL" -eq 0 ]
+175
View File
@@ -0,0 +1,175 @@
#!/usr/bin/env bash
# dispatch-format-guard.sh 的迴歸測試(inkstone/ISEP#30 comment 432243254327)。
#
# **離線、不打網路、不花錢、每次結果一樣**——這支閘是純結構判斷,沒有語意判官,
# 所以它不像 ask-user-question-guard 那樣需要一支 live 測試量準度。
#
# 三群,兩個方向都要有證據:
# A 群「該放行」——合規的派工(只有票號)、不是派工的動作、沒有【工單】(別人的地盤)、
# 豁免戳記;**而且合規時要真的把共通規定注入出去**
# B 群「該擋」 ——真跡一份(那 5 次違規裡唯一還拿得回來的),
# 加上一條規則一個最小案例
# C 群「訊息本身」——講得出出路、沒有被 shell 展開
#
# 用法:hooks/tests/dispatch-format-guard.test.sh [hook 路徑]
set -u
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
HOOK="${1:-$(cd "$HERE/.." && pwd)/dispatch-format-guard.sh}"
FIX="$HERE/fixtures"
PASS=0; FAIL=0; N=0
# payload <prompt 檔或字串> [tool_name]
payload() {
python3 - "$1" "${2:-Task}" <<'PY'
import json, os, sys
src, tool = sys.argv[1], sys.argv[2]
prompt = open(src, encoding="utf-8").read() if os.path.isfile(src) else src
print(json.dumps({"session_id": "S-TEST", "hook_event_name": "PreToolUse",
"tool_name": tool, "tool_input": {"prompt": prompt}},
ensure_ascii=False))
PY
}
# t <期望 exit> <說明> <payload JSON>
t() {
want="$1"; desc="$2"; body="$3"
N=$((N+1))
err=$(printf '%s' "$body" | bash "$HOOK" 2>&1 >/dev/null); rc=$?
if [ "$rc" -eq "$want" ]; then
printf ' ✅ %s\n' "$desc"; PASS=$((PASS+1))
else
printf ' ❌ %s —— 期望 exit=%s,實得 exit=%s\n' "$desc" "$want" "$rc"
printf '%s\n' "$err" | sed -n '1,6p' | sed 's/^/ /'
FAIL=$((FAIL+1))
fi
}
clean() { rm -f /tmp/.dispatch-ok-S-TEST 2>/dev/null || true; }
echo "── A 群:該放行 ────────────────────────────────────────────────"
clean
t 0 "① 合規:整份派工單只有一行票號" \
"$(payload '【工單】inkstone/ISEP#30 → comment 4322')"
t 0 "② 合規:兩張票就兩行【工單】" \
"$(payload '【工單】inkstone/ISEP#30
【工單】inkstone/InkStoneCo#55')"
t 0 "③ 合規:用 #issuecomment- 定址也認得" \
"$(payload '【工單】inkstone/ISEP#30#issuecomment-4327')"
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 ]
+14
View File
@@ -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 份**,那會讓測試看起來比實際更綠。
+17
View File
@@ -0,0 +1,17 @@
你要動 **ISEP plugin**Gitea `inkstone/ISEP`,全機共用的閘)。開工前先讀該 repo 的 CLAUDE.md 與 `system-dev/wiki/`。
【工單】`inkstone/ISEP#30` → **comment 4322**(任務全文在那則,去讀它)
```
TOKEN=$(cd /Users/x/InkStoneCo && git remote get-url gitea | sed -E 's|.*//[^:]+:([^@]+)@.*|\1|')
curl -s -H "Authorization: token $TOKEN" "https://git.uncle6.me/api/v1/repos/inkstone/ISEP/issues/30/comments?limit=60"
```
這個 session 才知道、票上還沒有的事:
- `~/Documents/tech_projects/` 若讀不到(macOS TCC),立刻回報,不要硬撐;今天稍早發生過,leo 已開權限
- 正本也可從 `~/.claude/plugins/marketplaces/inkstone` cloneorigin 是 `inkstone/ISEP.git`),
今天 `v0.4.0` 那條線就是這樣施工的
- `main` 現在是 `8e7e265``v0.4.0``ask-user-question-guard.sh` 剛上線)
交件:貼回 `inkstone/ISEP#30`,回報分支名。
+99
View File
@@ -0,0 +1,99 @@
#!/usr/bin/env bash
# 身份欄的兩道閘(inkstone/ISEP#30 comment 4325:「subagent 回覆時要表明身份」)。
#
# 貼留言到票上有**兩條路**,所以測兩道閘:
# 正門 scripts/ticket say / decide —— 檢查在打任何 API 之前,所以本測試離線跑得動
# 側門 直接打 Gitea API —— hooks/reply-identity-guard.sh
#
# **離線、不打網路。** 正門的案例全部在 `die()` 之前就結束,不會真的送出留言。
#
# 用法:hooks/tests/reply-identity.test.sh
set -u
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT="$(cd "$HERE/../.." && pwd)"
HOOK="$ROOT/hooks/reply-identity-guard.sh"
TICKET="$ROOT/scripts/ticket"
TMP=$(mktemp -d); trap 'rm -rf "$TMP"' EXIT
PASS=0; FAIL=0; N=0
ok() { printf ' ✅ %s\n' "$1"; PASS=$((PASS+1)); N=$((N+1)); }
bad() { printf ' ❌ %s\n' "$1"; shift; printf '%s\n' "$*" | sed -n '1,6p' | sed 's/^/ /'
FAIL=$((FAIL+1)); N=$((N+1)); }
# ── 側門:hooks/reply-identity-guard.sh ─────────────────────────────────
h() { # h <期望 exit> <說明> <command 字串>
want="$1"; desc="$2"; cmd="$3"
body=$(python3 - "$cmd" <<'PY'
import json, sys
print(json.dumps({"tool_name": "Bash", "tool_input": {"command": sys.argv[1]}},
ensure_ascii=False))
PY
)
err=$(printf '%s' "$body" | bash "$HOOK" 2>&1 >/dev/null); rc=$?
if [ "$rc" -eq "$want" ]; then ok "$desc"; else
bad "$desc —— 期望 exit=$want,實得 exit=$rc" "$err"; fi
}
echo "── 側門(直接打 Gitea API)─────────────────────────────────────"
h 2 "① POST 留言但沒有【身份】→ 擋" \
'curl -X POST -H "Authorization: token $T" -d @body.json https://git.uncle6.me/api/v1/repos/inkstone/ISEP/issues/30/comments'
h 0 "② POST 留言且內文帶【身份】→ 放行" \
'curl -X POST -d "{\"body\":\"【身份】subagentinkstone/ISEPfeat/x\n做完了\"}" https://git.uncle6.me/api/v1/repos/inkstone/ISEP/issues/30/comments'
h 0 "③ 純讀取(GET 撈留言)→ 放行,這是最常做的動作,誤攔它比漏擋更糟" \
'curl -s -H "Authorization: token $T" https://git.uncle6.me/api/v1/repos/inkstone/ISEP/issues/30/comments?limit=60'
h 0 "④ 走正門 scripts/ticket → 放行(正門有自己的閘,兩支同時擋會互相打架)" \
'scripts/ticket say inkstone/ISEP#30 -F /tmp/body.md'
h 0 "⑤ 開新票的端點(不帶票號)→ 不是本閘的地盤" \
'curl -X POST -d @t.json https://git.uncle6.me/api/v1/repos/inkstone/ISEP/issues'
h 0 "⑥ 逃生口 reply-identity-ok → 放行(留在指令歷史上)" \
'curl -X POST -d @body.json https://git.uncle6.me/api/v1/repos/inkstone/ISEP/issues/30/comments # reply-identity-ok'
h 0 "⑦ 只是在講這件事(把端點寫進文件)而沒有 POST → 不擋" \
'echo "留言端點是 issues/30/comments" >> docs/notes.md'
echo
echo "── 正門(scripts/ticket)───────────────────────────────────────"
g() { # g <期望 exit> <說明> <內文>
want="$1"; desc="$2"; content="$3"
printf '%s' "$content" > "$TMP/body.md"
err=$(python3 "$TICKET" say inkstone/ISEP#30 -F "$TMP/body.md" 2>&1 >/dev/null); rc=$?
if [ "$rc" -eq "$want" ]; then ok "$desc"; else
bad "$desc —— 期望 exit=$want,實得 exit=$rc" "$err"; fi
}
g 2 "⑧ 內文沒有身份欄 → 在打 API 之前就擋(所以這一格離線也測得動)" \
'做完了,分支是 feat/x'
g 2 "⑨ 有【身份】但角色不在三選一之內 → 擋" \
'【身份】機器人/inkstone/ISEPfeat/x
做完了'
g 2 "⑩ 身份欄不在第一行 → 擋(要一眼看得到,不是藏在中間)" \
'做完了
【身份】subagentinkstone/ISEPfeat/x'
# ⑪ 合規的內文要能通過身份檢查——它會往下走到打 API,
# 離線環境打不出去所以 exit 非 0;判準改成「錯誤訊息不是身份欄那一段」。
printf '%s' '【身份】subagentinkstone/ISEPfeat/x
做完了' > "$TMP/ok.md"
err=$(python3 "$TICKET" say inkstone/ISEP#30 -F "$TMP/ok.md" 2>&1 >/dev/null || true)
if printf '%s' "$err" | grep -q '第一行要表明身份'; then
bad "⑪ 合規的內文被身份閘擋掉了(誤攔)" "$err"
else
ok "⑪ 合規的內文通過身份閘(之後成不成功是網路的事,不是本閘的事)"
fi
echo
echo "══ $PASS/$N 通過${FAIL:+$FAIL 個失敗} ══"
[ "$FAIL" -eq 0 ]
+96
View File
@@ -0,0 +1,96 @@
#!/bin/bash
# subagent-first-guard 的測試(inkstone/ISEP#66
#
# 補的三個洞:
# 洞一:.sh 從沒進過受管副檔名清單 ⇒ 這個 repo 自己的閘(全是 .sh)從沒被這道閘管過。
# 洞二:「這個 session 派過一次工」被當永久放行——拿掉,不再是放行條件。
# 洞三:「同一 session 只擋一次」讓警報響過一次就對餘生啞掉——改成寬限期,過了再響。
#
# 判準:派過工不再免死金牌/閘本身(.sh)進受管清單/寬限期內不鬼打牆、過了要再響/
# CHILD_SESSIONsubagent 本人放行/測試檔・system-dev wiki 放行/
# solo-ok 要非空(寫理由)才放行,單純 touch 不算。
cd "$(dirname "$0")/.." || exit 1
H=hooks/subagent-first-guard.sh
PASS=0; FAIL=0
run(){ # $1=want_exit $2=file_path $3=sid $4=child(0/1)
local want="$1" fp="$2" sid="$3" child="${4:-0}"
local payload
payload=$(python3 -c 'import json,sys;print(json.dumps({"tool_name":"Edit","tool_input":{"file_path":sys.argv[1]},"session_id":sys.argv[2]}))' "$fp" "$sid")
if [ "$child" = "1" ]; then
got=$(CLAUDE_CODE_CHILD_SESSION=1 SUBAGENT_GUARD_NAG_TTL="${TTL:-900}" bash -c 'printf "%s" "$1" | bash "$2" >/dev/null 2>&1; echo $?' _ "$payload" "$H")
else
got=$(env -u CLAUDE_CODE_CHILD_SESSION SUBAGENT_GUARD_NAG_TTL="${TTL:-900}" bash -c 'printf "%s" "$1" | bash "$2" >/dev/null 2>&1; echo $?' _ "$payload" "$H")
fi
if [ "$got" = "$want" ]; then PASS=$((PASS+1)); printf ' ✅ '; else FAIL=$((FAIL+1)); printf ' ❌ '; fi
printf 'want=%s got=%s sid=%s %.70s\n' "$want" "$got" "$sid" "$fp"
}
clean(){ rm -f "/tmp/.subagent-spawned-$1" "/tmp/.solo-ok-$1" "/tmp/.subagent-guard-warned-$1"; }
R="/Users/youlinhsieh/Documents/tech_projects/InkStoneCo"
ISEP="/Users/youlinhsieh/Documents/tech_projects/ISEP"
echo "── 洞一:閘本身是 .sh,也要被這道閘管(重演 2026-08-27 那次)──"
S=t66-hole1-a; clean "$S"
run 2 "$R/.claude/hooks/no-ticket-no-dispatch.sh" "$S" 0
S=t66-hole1-b; clean "$S"
run 2 "$ISEP/hooks/subagent-first-guard.sh" "$S" 0
echo "── 洞二:派過工不是免死金牌——就算剛派、就算派了很多次,直接手改一樣要擋 ──"
S=t66-hole2-a; clean "$S"
date +%s > "/tmp/.subagent-spawned-$S" # 剛派過工(此刻)
run 2 "$R/.claude/hooks/no-ticket-no-dispatch.sh" "$S" 0
S=t66-hole2-b; clean "$S"
echo $(( $(date +%s) - 3600 )) > "/tmp/.subagent-spawned-$S" # 一小時前派過(今天真實情境的重演)
run 2 "$R/.claude/hooks/no-ticket-no-dispatch.sh" "$S" 0
echo "── 洞三:警報不能響一次就對餘生啞掉——寬限期內不鬼打牆,過了要再響 ──"
S=t66-hole3; clean "$S"; TTL=2
run 2 "$R/matrix/arcrun/foo.py" "$S" 0 # 第一次:沒見過 → 擋,順便留下 warned 時戳
run 0 "$R/matrix/arcrun/foo.py" "$S" 0 # 馬上重試:寬限期內 → 不鬼打牆,放行
sleep 3
run 2 "$R/matrix/arcrun/foo.py" "$S" 0 # 寬限期過了 → 再響
TTL=900
echo "── 不該擋:subagent 本人(CHILD_SESSION=1)自己寫它被派來寫的 code ──"
S=t66-child; clean "$S"
run 0 "$R/.claude/hooks/no-ticket-no-dispatch.sh" "$S" 1
run 0 "$R/matrix/arcrun/foo.py" "$S" 1
echo "── 不該擋:測試檔 ──"
S=t66-test; clean "$S"
run 0 "$R/matrix/arcrun/foo_test.py" "$S" 0
run 0 "$R/matrix/arcrun/foo.test.ts" "$S" 0
run 0 "$ISEP/tests/foo.spec.js" "$S" 0
echo "── 不該擋:system-dev/ 的 wiki(即使副檔名受管)──"
S=t66-wiki; clean "$S"
run 0 "$R/system-dev/wiki/status.md" "$S" 0
run 0 "$R/system-dev/docs/some_script.py" "$S" 0
echo "── 不該擋:非受管副檔名(一般 .sh 不在 hooks 目錄/.md.json)──"
S=t66-nonguard; clean "$S"
run 0 "$R/scripts/deploy.sh" "$S" 0
run 0 "$R/README.md" "$S" 0
run 0 "$R/package.json" "$S" 0
echo "── solo-ok:要非空(寫理由)才放行,單純 touch 不算 ──"
S=t66-soloempty; clean "$S"
touch "/tmp/.solo-ok-$S"
run 2 "$R/matrix/arcrun/foo.py" "$S" 0
clean "$S"
echo "理由:單行修,緊急止血" > "/tmp/.solo-ok-$S"
run 0 "$R/matrix/arcrun/foo.py" "$S" 0
clean "$S"
echo "── 真違規不能因為這次改動漏擋:全新 session 直接改一般 code ──"
S=t66-fresh; clean "$S"
run 2 "$R/matrix/arcrun/graph-executor.ts" "$S" 0
# 清理
for s in t66-hole1-a t66-hole1-b t66-hole2-a t66-hole2-b t66-hole3 t66-child t66-test t66-wiki t66-nonguard t66-soloempty t66-fresh; do clean "$s"; done
echo
echo "$PASS/$((PASS+FAIL)) 通過"
[ "$FAIL" -eq 0 ]
+39 -1
View File
@@ -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)}):
【身份】subagentinkstone/ISEPfeat/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 <owner/repo#N> -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"
+83
View File
@@ -35,3 +35,86 @@ Gitea 上**一個 release tag 都沒有**。
⇒ 「東西做出來了」與「有人在用它」是兩件事,而只有後者算交付。
⇒ 判準:**去執行環境查它有沒有被載入**(`claude plugin list` / `claude plugin details`),
不要從 repo 裡有什麼檔案去推論。
## ⚠️ MISTAKE: 判準寫在閘裡了,但那個閘掛在**做完之後**才跑的時機上
票: `inkstone/InkStoneCo#55`
日期: 2026-08-26
症狀: leo 一天內好幾次被丟純技術路徑選擇,當場問「**今天已經好幾次問我,
為什麼 hooks 沒有攔下來?**」,其中一次他直接說「這種問題不要問我,
我要的是你解決了以後給我 prod」。
實查: 總管問 leo 走的動作是 `AskUserQuestion` 這個工具,而
`hooks.json``AskUserQuestion` 出現 **0 次**——沒有任何 matcher,它是裸的。
判準其實早就寫好了(`self-drive-police.sh` / `self-drive-judge.sh` 用的就是四題公式),
但那兩支只掛在 `Stop``SubagentStop`
原因: **判準對了,時機錯了。** `Stop` 是回合結束後才跑——問題早就送到 leo 眼前、
他早就被打斷了,這時再反問 AI「你查過了嗎」,成本已經轉嫁出去了。
正確做法: 攔截點要長在**那個動作發生的那一刻**(`PreToolUse` / `AskUserQuestion`)。
新增 `hooks/ask-user-question-guard.sh`
🔴 **推廣**:以後看到「規則寫了卻沒被攔下來」,先問的不是「判準對不對」,
而是「**這支閘掛在哪個事件上、那個事件發生時傷害造成了沒有**」。
## ⚠️ MISTAKE: hook 訊息用沒加引號的 heredoc,反引號會被當成命令執行
票: `inkstone/InkStoneCo#55`
日期: 2026-08-26
症狀: `ask-user-question-guard.sh` 擋下之後,stderr 冒出
`line 218: system-dev/wiki/: is a directory`,而訊息裡
「去查 `system-dev/wiki/`」和「`touch /tmp/.ask-ok-<session_id>`」兩行
**變成空白**。閘照擋 exit 2,所以測試若只看離開碼**完全看不出來**。
原因: 寫成 `cat >&2 <<EOF`(heredoc 標記沒加引號)⇒ shell 會對內容做展開,
而本 repo 的 hook 訊息**慣例上大量使用反引號**標路徑與指令
⇒ 每一組反引號都被當成命令替換真的去執行。
正確做法: hook 的訊息一律用 `cat <<'EOF'`(標記加單引號)。
需要塞變數就留 `__PLACEHOLDER__`,事後用 python 換掉——
**不要用 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-firstKBDB-firststage-first`AskUserQuestion` 裸奔,全是這個形狀。
修法(v0.5.0):`dispatch-format-guard.sh`——**派工單 = 票號,多一個字都擋**。
規則變得比原本更嚴,反而更好驗:判準從「內容夠不夠」變成「這一行是不是【工單】欄位」,
純結構、不用語意判官、每次結果一樣。
規約:`docs/governance/dispatch-and-reply-format.md`
日期: 2026-08-27`inkstone/ISEP#30` comment 432243254327
## ⚠️ 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