Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b8060ca263 | |||
| 8e28041ba4 | |||
| 3bc7f3c5f5 |
@@ -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 環境唯一真相源:49 支機械閘(60 條註冊,白話盤點見 docs/hooks-inventory.md)、7 支 slash command、2 支 skill、27 支腳本,外加治理規範與標籤真相源。本機與雲端裝同一份,沒有子集。",
|
||||
"version": "0.5.1",
|
||||
"keywords": [
|
||||
"inkstone",
|
||||
"guardrails",
|
||||
|
||||
@@ -3,3 +3,7 @@
|
||||
cloud-env*.txt
|
||||
*.env
|
||||
.shell-payload/
|
||||
|
||||
# import 共用零件時 python 自己生的,不是原始碼
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
@@ -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)。**
|
||||
|
||||
+26
-20
@@ -50,26 +50,6 @@ bash scripts/test-ticket-api-bypass-guard.sh
|
||||
**該看到**:`13/13 通過`。
|
||||
**失敗**:任何一條不符,特別看「不該擋」那 8 條。
|
||||
|
||||
### A11 — 討論串裡的任務要長成子票:22 條
|
||||
```
|
||||
bash scripts/test-comment-carries-task-guard.sh
|
||||
```
|
||||
**該看到**:`22/22 通過`。
|
||||
**失敗**:
|
||||
- 「該擋」6 條任一紅 ⇒ 08-26 那則真的掉了的留言形狀(「等雲端那半出貨才驗得了」)會漏抓
|
||||
- 「不該擋」10 條任一紅 ⇒ **誤攔,這比漏擋嚴重**——每次留言都被擋,人就學會忽略它
|
||||
- 最後兩條(`-F <檔>`)紅 ⇒ 內文放在檔案裡時閘看不到,等於走 `ticket say` 就自動繞過
|
||||
|
||||
### A12 — 收工要把棒子交回來:10 條
|
||||
```
|
||||
bash scripts/test-baton-handback-guard.sh
|
||||
```
|
||||
**該看到**:`10/10 通過`。**用 fixture 跑,不打網路、不在票池留下測試票**。
|
||||
**失敗**:
|
||||
- 「該報」5 條任一紅 ⇒ 指派/tag/下一步缺哪一格抓不到,08-26 那次掉棒的狀態會靜靜通過
|
||||
- 「不該報」4 條任一紅 ⇒ 每條線收工都被念一次,警報會被學會忽略
|
||||
- 「票已關」那條紅 ⇒ 棒子已經到終點還在催,那是最典型的假警報
|
||||
|
||||
### A5 — 開票前的搜尋是跨 repo 的
|
||||
```
|
||||
python3 scripts/ticket where 標籤 模組化
|
||||
@@ -104,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
|
||||
@@ -242,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) |
|
||||
|
||||
@@ -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. **改了會被載入的東西(plugin/worker/bundle)就要升版**,
|
||||
否則產物按版本號分資料夾,你的改動到不了任何人手上。
|
||||
<!-- INJECT:END -->
|
||||
|
||||
> 🔴 **這一段是「每次都一樣」的唯一真相源。**
|
||||
> 想在派工單裡加一句叮嚀之前,先問:它換一張票還成立嗎?
|
||||
> 成立就加在這裡(改一次,全機生效),不要加在那一次的 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 身份欄:正門與側門兩道
|
||||
```
|
||||
|
||||
測資裡放的是**真的發生過的那幾份違規派工單**,不是想像出來的例子。
|
||||
@@ -702,150 +702,3 @@ Gitea 的 milestone 只管得到同一個 repo,所以六個群在 `inkstone/IS
|
||||
**機械閘**=`hooks/milestone-due-guard.sh`(PreToolUse `Bash`):
|
||||
建 milestone 沒有 `due_on`、或 `due_on` 帶 `9999` → 擋。
|
||||
四向實測:無 due_on 擋/9999 擋/真期限放行/只是讀 milestone 放行。
|
||||
|
||||
---
|
||||
|
||||
## 16. 棒子不會掉:三個 Gitea 原生欄位,全部都要(leo 2026-08-27 立)
|
||||
|
||||
> leo 原話:「**不對,子票相依是 gitea 原有機制,改 tag 和指定也是,這些全部都要**」
|
||||
> 「可以在討論串延伸子票,它完成了子票就完工,不然這筆任務就是沒完工⋯⋯**票沒完工可以察覺嗎?**」
|
||||
> 「**每個任務結束必須要指派回總管**,要說明下一步怎麼做,直到最後交出正確 deliverable 並且驗證」
|
||||
|
||||
### 16.0 這一節在解什麼(可重現的掉棒時序)
|
||||
|
||||
```
|
||||
08-26 13:00 一條線交回 arcrun-rag#136 comment 4267:
|
||||
「Phase 1 的驗收在雲端那半出貨之前驗不了」
|
||||
← 一根帶著明確解除條件的接力棒,但它只是一段文字
|
||||
08-26 18-23 總管把雲端那半併進 main、出 1.4.56、推 prod
|
||||
← 解除條件在這裡被滿足了,沒有任何東西知道
|
||||
08-27 11:xx leo 問「這個 14 小時前的,你派了還是沒派?」
|
||||
← 14 小時後才發現棒子在地上
|
||||
```
|
||||
|
||||
當時那張票:**沒有子票、tag 沒動、沒有指派給任何人**——三格全缺。
|
||||
🔴 **根因不是誰忘了,是那件事沒有落在任何一個「撈一次就看得到」的欄位上。**
|
||||
|
||||
### 16.1 三個維度正交,缺一個就會掉
|
||||
|
||||
| 欄位 | 它回答的問題 | 缺了它會怎樣 |
|
||||
|---|---|---|
|
||||
| **子票 + 相依** | **這件事存在嗎?做完了嗎?** | 任務藏在討論串,撈 open 票看不到它 |
|
||||
| **tag(`s/*`)** | **它現在卡在哪一段?** | 撈得到票,但每次都要重讀整串才知道在等什麼 |
|
||||
| **指派(assignee)** | **現在誰該動?** | 知道有事、知道卡在哪,但**沒人認領** |
|
||||
|
||||
三個都是 Gitea 原生欄位,**都不必自造**(D58:充分利用 gitea 的機制)。
|
||||
|
||||
### 16.2 子票相依是**硬的**,不是提醒(2026-08-27 實測,Gitea 1.26.4)
|
||||
|
||||
```
|
||||
POST /repos/inkstone/ISEP/issues/56/dependencies {"owner","repo","index":57} → 201
|
||||
GET /repos/inkstone/ISEP/issues/56/dependencies → [(57,'open')]
|
||||
PATCH /repos/inkstone/ISEP/issues/56 {"state":"closed"} ← 子票還開著
|
||||
→ HTTP 412
|
||||
{"message":"cannot close this issue or pull request because it still has open dependencies"}
|
||||
關掉 #57 → 再 PATCH #56 state=closed → 201,母票 state=closed
|
||||
```
|
||||
|
||||
⇒ **子票沒關,母票關不掉。這是平台保證,不是自律。**
|
||||
畫面上也看得到:母票側欄出現 `Depends on → #57 …(inkstone/ISEP)`。
|
||||
|
||||
**跨 repo 也成立**(實測 `ISEP#56` 依賴 `arcrun-rag#136` → 201)
|
||||
⇒ 這正是跨 repo 的載體:milestone 只管得到同一個 repo,**相依管得到全部**。
|
||||
|
||||
📌 探針票留在 `inkstone/ISEP#56`/`#57`(已關,掛 `type/chore`,票上有實測全文)。
|
||||
**順帶一個規範缺口**:`close/*` 七類(§5)**沒有一類適合「實測探針」**,
|
||||
而 Gitea 的 issue `DELETE` 回 403(只有管理員能刪,已實測)
|
||||
⇒ 探針票刪不掉也歸不了類。要嘛補一類 `close/probe`,要嘛規定實測一律開在
|
||||
拋棄式 repo。**這一格本節不裁,留給 §5 的維護者。**
|
||||
|
||||
- **R16.2.1** 順序/阻擋關係一律用相依(同 R2.5),**禁止只寫文字**。
|
||||
- **R16.2.2** 相依掛不上去 = 那張子票對母票是隱形的 ⇒ **視為整個動作失敗**,要當場報錯。
|
||||
|
||||
### 16.3 粒度:**傾向多開票,不是少開票**
|
||||
|
||||
> leo 2026-08-27(當場更正總管寫反的那條):
|
||||
> 「**小任務做完就關閉,不會讓池子爆掉,每個事情沒有歷史記錄才是大問題**」
|
||||
|
||||
```
|
||||
這件事需要被記得嗎?(做過什麼、為什麼、量到什麼)
|
||||
需要 → 開票。做完就關。
|
||||
不需要 → 才不開
|
||||
```
|
||||
|
||||
🔴 **不要用「會不會太多」當判準。** 池子爆掉的前提是「開了不關」——
|
||||
那由 **做完就關** 解決,不該由 **不開票** 解決。
|
||||
|
||||
三條線不衝突,順序是:
|
||||
1. **要不要有票**(本節)——傾向要。判準:這件事需要被記得嗎。
|
||||
2. **要不要拆成獨立票**(既有規約)——判準:「票的刀口不是大小,是**狀態**:
|
||||
它會不會需要跟隔壁那條不同的狀態?」
|
||||
3. **會不會變成池子**——由「做完就關」解決,不由前兩題解決。
|
||||
|
||||
📌 leo 2026-08-16「我不要把所有的票都放在一個 issues,不然就難 track 歷史記錄」
|
||||
與本節同一個目的:**可追蹤的歷史**。全塞一張 ⇒ 糊成一團;藏在討論串/prompt ⇒ 根本沒有歷史。
|
||||
|
||||
### 16.4 tag 與相依的關係:**流程狀態 vs 結構關係,兩個軸**
|
||||
|
||||
- `s/*` 是**這張票走到哪一格**(§3.0 的七態,exclusive)。
|
||||
- 相依是**這張票和別張票的結構關係**。
|
||||
- 兩者不衝突,也不能互相取代:
|
||||
- 一張票可以 `s/doing` 而且有未關的相依(它自己有事在做,只是還關不掉)
|
||||
- 一張票可以 `s/pending` 而且沒有相依(它卡的是票池外面的東西,例如等 leo 開閘)
|
||||
- **R16.4.1** 卡在**別張票**上 ⇒ 掛相依 **並** 轉 `s/pending`。
|
||||
只掛相依不改 tag = 看板上它看起來還在動;只改 tag 不掛相依 = 母票關得掉。
|
||||
- **R16.4.2** `Human` 仍是正交 overlay(§6.1),與本節三格互不取代。
|
||||
|
||||
### 16.5 指派:棒子的持有人,**是欄位不是文字**
|
||||
|
||||
可指派的身份只有兩個(2026-08-27 實查):`Leo`、`claude-code`。
|
||||
**總管就是 `claude-code`**(所有票都是這個 token 寫的),不必新增任何身份。
|
||||
|
||||
- **R16.5.1(收工)** 任何一條線收工 = **指派回 `claude-code` + 改 tag + 寫下一步**,
|
||||
三件事是**同一個動作**(`scripts/ticket handback`)。
|
||||
- **R16.5.2(要 leo 做)** 指派 `Leo` + 掛 `Human`(§6.1、既有交棒規約)。
|
||||
- **R16.5.3(終點)** 棒子有終點——leo 的原話是「**直到最後交出正確 deliverable 並且驗證**」。
|
||||
驗完 ⇒ 關票、清指派 ⇒ 它從所有人的待辦裡消失。
|
||||
**不要讓票變成永遠在傳的燙手山芋。**
|
||||
- **R16.5.4(撈一次)** 「哪幾根棒子在誰手上」用 `scripts/ticket mine` 撈,
|
||||
掛在**本來就會做的動作**上(session 開場、收工對帳)。🔴 **禁止排程輪詢**(D20)。
|
||||
|
||||
### 16.6 既有的幾十張票怎麼辦:**只從今天起適用,不回頭補**
|
||||
|
||||
- **R16.6.1** 不做全面回填。理由:回頭讀幾十張票的每一則 comment 是 O(票數×留言數),
|
||||
那正是本節說「人與 AI 都不會做」的那個動作——**用一個不會被執行的規定去補一個漏洞,等於沒補。**
|
||||
- **R16.6.2** 改成**碰到才補**:任何人(或閘)下一次動到某張票時,
|
||||
順手把它討論串裡還活著的待辦長成子票。**動到才補,成本落在本來就要付的那次。**
|
||||
- **R16.6.3** 例外:`hub` 票(§1)**要補齊**——它們本來就宣稱自己是聚合容器,
|
||||
而 §3.2「子票全關才能關 hub」在沒有相依的情況下是**靠自律**的。
|
||||
掛上相依之後,那條規則變成平台保證。
|
||||
✅ **2026-08-27 實查:六張 hub 全部已經掛好了**,且數量與各自內文列的來源票一致——
|
||||
`ISEP#30` 6/`#31` 7/`#32` 11/`#33` 5/`#34` 8/`#35` 7(跨 `InkStoneCo`/`Arcrun`/
|
||||
`arcrun-rag`/`system-dev-template` 四個 repo)。**這一格不用補,本條是為了讓它不再退化。**
|
||||
|
||||
### 16.7 工具與機械閘
|
||||
|
||||
```
|
||||
scripts/ticket subtask <母票> --title <User Story> -F <檔> [--label] [--assign X --next "<一句>"]
|
||||
討論串裡的一件事 → 子票 + 回鏈 Parent: + Gitea 原生相依(三格一次到位)
|
||||
🔴 `--assign` 一定要配 `--next`:有人被指到票上,他撈到它的第一件事就是問
|
||||
「所以我要做什麼」——那句話開票當下就要寫,不是等他重讀整串
|
||||
scripts/ticket handback <票> --to <claude-code|Leo> --next "<一句話>" [--label] [--evidence]
|
||||
收工 = 指派 + 改 tag + 寫下一步,一個動作三件事
|
||||
scripts/ticket mine [--user <誰>]
|
||||
撈一次:棒子在誰手上、每根的下一步、每根還有幾張未關的相依
|
||||
```
|
||||
|
||||
| 閘 | 掛在哪 | 抓什麼 | 測試 |
|
||||
|---|---|---|---|
|
||||
| `hooks/comment-carries-task-guard.sh` | PreToolUse `Bash` | 留言裡帶「等 X 才…/驗不了/還沒…」這種**未完成的未來式**,卻沒開子票 → 擋一次 | `scripts/test-comment-carries-task-guard.sh`(22 條) |
|
||||
| `hooks/baton-handback-guard.sh` | PostToolUse `Agent\|Task` | 一條線收工,票上**指派/tag/下一步**缺哪一格 → 當場說出來(提醒,不擋) | `scripts/test-baton-handback-guard.sh`(10 條) |
|
||||
|
||||
🔴 **兩支閘的誤攔設計**:`comment-carries-task-guard` 同一 session 只擋一次,
|
||||
關鍵詞刻意取窄,並留 `no-subtask: <理由>` 逃生口(留痕)。
|
||||
理由寫在 `.claude/branch-holds.md` 檔頭那句:
|
||||
**永遠在響的警報,等於訓練人忽略這個警報。**
|
||||
|
||||
🔴 **hook 裡比對中文一律用 `python3` 的 `re`,不要用 `grep` 的字元類**
|
||||
(2026-08-27 實撞:`等[^。]{0,20}出貨` 在真實 hook 呼叫路徑下對
|
||||
「等雲端那半出貨」不匹配,閘靜默失效;換成 python3 後測試由 19/20 變 20/20)。
|
||||
|
||||
+11
-6
@@ -1,4 +1,4 @@
|
||||
# 48 支閘,白話盤點表
|
||||
# 47 支閘,白話盤點表
|
||||
|
||||
> 回應 `inkstone/InkStoneCo#40`:「如果加入了,我應該可以白話文看到 hooks 的內容?」
|
||||
> 這份表就是那個「白話文」——不用點開任何 `.sh` 檔,一行看懂一支閘在管什麼。
|
||||
@@ -7,14 +7,18 @@
|
||||
|
||||
## 一句話結論
|
||||
|
||||
`hooks/` 底下有 **48 個 `.sh` 檔**,`hooks.json` 實際掛上 **58 條註冊**(同一支閘常被多種情境同時掛上);
|
||||
`hooks/` 底下有 **47 個 `.sh` 檔**,`hooks.json` 實際掛上 **57 條註冊**(同一支閘常被多種情境同時掛上);
|
||||
其中 **3 支檔案存在但沒被掛上**(2 支是待人填的空範本、1 支是刻意留著沒開的止血帶,見下面「未生效」表)。
|
||||
下面按「你會在什麼時候撞到它」分組,一支一行。
|
||||
|
||||
> 🔴 **這兩個數字上一版是錯的(2026-08-26 實際數過才發現)**:本頁原本寫「43 個檔、53 條註冊」,
|
||||
> 而當時真實是 **45 個檔、55 條註冊**——中間有兩支閘進來時沒有回頭改這裡。
|
||||
> 現在的寫法是實際數出來的:
|
||||
> `ls hooks/*.sh | wc -l` = 48;`hooks.json` 展開後的 command 條目 = 58。
|
||||
> +1/+1 是本次(ISEP#69)新增的 `leo21c-mcp-guard.sh`。
|
||||
>
|
||||
> ⚠️ **這份表本身可能又已經漂移**:`ls hooks/*.sh | wc -l` 在本分支實測是 49
|
||||
> (比這裡寫的 47 多 2),初步比對疑似是 `isep-presence-beacon.sh`/
|
||||
> `milestone-due-guard.sh` 兩支已經掛進 `hooks.json` 卻沒被收進這張表——
|
||||
> 但那兩支不是本票(ISEP#69)動的閘,沒有一併查證+改動,留給總管或後續票核實。
|
||||
> **一份會偷偷過期的盤點表,跟沒有盤點表差不多**——見本頁最後「怎麼跟實況對帳」那段。
|
||||
|
||||
---
|
||||
@@ -42,9 +46,10 @@
|
||||
| `prod-write-guard.sh` | AI 想做任何「會真的改到線上正式帳號(leo21c)」的操作(不只是 push,也包含直接呼叫會寫入的工具)就擋下,交回總管處理。 | 🛑 擋 |
|
||||
| `not-my-branch-guard.sh` | AI 想 commit 到「別的 subagent 正在施工中」的分支(登記在 `branch-holds.md`)就擋下,避免兩個 AI 互踩同一條分支。 | 🛑 擋 |
|
||||
| `leo21c-write-guard.sh` | AI 想寫東西到你私人帳號 `leo21c` 就擋下——這個帳號只准你自己用,AI 的實驗一律走別的測試帳號。 | 🛑 擋 |
|
||||
| `leo21c-mcp-guard.sh` | AI 想用 Arcrun/KBDB 的 MCP 工具查或寫**你的真庫(leo21c)**就擋下(不管讀寫)——測試場是另一條 MCP 連線,沒指定就該用那條。`arcrun_whoami` 本身不擋(用來確認身份);真的要碰真庫要明說(`touch /tmp/.leo21c-mcp-ok`),會留痕。連線的 server id 若不在已知清單裡也擋(清單可能過期,寧可先擋不要靜默放行)。 | 🛑 擋 |
|
||||
| `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 票(會漏掉「開票前先搜過」這道檢查)就擋下。 | 🛑 擋 |
|
||||
| `comment-carries-task-guard.sh` | AI 要在票裡留一則「等某某上線才驗得了」這種**還沒做完的事**,卻沒有把它開成一張子票時,擋一次。留言沒人會回頭讀,票的 open/closed 撈一次就看得到。 | 🛑 擋(同一輪只擋一次) |
|
||||
| `reply-identity-guard.sh` | 直接打 Gitea API 貼留言到票上時,內文第一行沒有 `【身份】`(`總管`/`subagent`/`leo`)就擋下——票上多條線並行時,看不出哪一則是誰寫的。純讀取、走 `scripts/ticket` 正門、指令裡加 `reply-identity-ok` 都放行。 | 🛑 擋 |
|
||||
|
||||
## B. AI 想寫檔案/改程式碼的當下(PreToolUse / Write·Edit·MultiEdit)
|
||||
|
||||
@@ -77,6 +82,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)
|
||||
|
||||
@@ -127,7 +133,6 @@
|
||||
| `kbdb-asked-stamp.sh` | AI 真的查過 KBDB 之後,留一個時間戳——給前面 `history-first-guard.sh` 判斷「這輪有沒有先查過」用。 | 📝 記錄 |
|
||||
| `subagent-first-stamp.sh` | AI 真的派過工之後,留一個時間戳——給 `subagent-first-guard.sh` 判斷用。 | 📝 記錄 |
|
||||
| `issue-status-autoflip.sh` | AI 一派工出去,就自動把對應的 Gitea 票改成「進行中(s/doing)」,不必等人手動改標籤。 | 📝 記錄(自動改票) |
|
||||
| `baton-handback-guard.sh` | 一條派工線做完了,就去看它那張票有沒有**指派給人/標籤有沒有說它卡在哪/有沒有寫下一步**,缺哪一格就當場說出來。你關心的是:不會再有票做完了卻沒人接手,躺在那裡沒人發現。 | 📝 記錄(提醒,不擋) |
|
||||
|
||||
## I. 你會撞到但跟「派工/收工」無關的一支(Edit·MultiEdit)
|
||||
|
||||
|
||||
@@ -1,136 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# 管什麼: 一條派工線收工時,檢查它的工單票有沒有把棒子交回來——指派(誰該動)+ tag(卡在哪)+ 下一步(做什麼)三格。
|
||||
# 為什麼: 2026-08-26 那次交回寫在 comment 裡,票沒指派、tag 沒動 ⇒ 沒有任何欄位在說「這張票在等你」,棒子躺了 14 小時。撈一次看得到的只有欄位,不是留言。
|
||||
# 誤觸時怎麼關: 這條線不對應任何票 → 派工單本來就不該少了【工單】那行(no-ticket-no-dispatch.sh 管那個);真要放行在指令裡留 `baton-ok: <理由>`(本閘只提醒不阻擋,不會擋住任何動作)。
|
||||
#
|
||||
# baton-handback-guard.sh — 收工=把棒子交回去(PostToolUse: Agent|Task)
|
||||
#
|
||||
# ── leo 2026-08-27 的原話(本閘的規格)────────────────────────────────
|
||||
# 「它把事情做完後如果要檢查後續,就要把任務指定給你並改 tag,
|
||||
# 你收到指定用 tag 查就知道要去做,所以**每個任務結束必須要指派回總管**,
|
||||
# 要說明下一步怎麼做,**直到最後交出正確 deliverable 並且驗證**」
|
||||
#
|
||||
# ── 三格各自回答一個問題(leo:「這些全部都要」)─────────────────────
|
||||
# 指派 assignee 現在誰該動? 缺了它 → 大家都以為是別人的事
|
||||
# tag s/* 它卡在哪一段? 缺了它 → 撈得到票,但要重讀整串
|
||||
# 下一步(留言) 收下的人要做什麼? 缺了它 → 撿起棒子的人得自己重建脈絡
|
||||
#
|
||||
# ── 為什麼掛在 PostToolUse(Agent) 而不是 SubagentStop ──────────────
|
||||
# 工單票號寫在**派工單**裡(`【工單】owner/repo#N`),而派工單就是 Agent 的 tool_input。
|
||||
# SubagentStop 拿到的 transcript 是主對話,票號要繞一圈才找得回來
|
||||
# (見 subagent-claim-worksheet.sh 檔頭那段實撞紀錄)。
|
||||
# 同一個位置 issue-status-autoflip.sh 已經在用,形狀是驗過的。
|
||||
#
|
||||
# ── 只提醒,不阻擋 ────────────────────────────────────────────────
|
||||
# 工作已經做完了,擋在這裡沒有任何東西可以被挽救;能做的是**讓總管在當下就看到缺哪一格**。
|
||||
# 同 unpushed-police.sh 的定位:exit 2 讓訊息進到模型眼前,不是 blocker。
|
||||
set -uo pipefail
|
||||
|
||||
INPUT=$(cat)
|
||||
|
||||
SPEC=$(printf '%s' "$INPUT" | python3 -c '
|
||||
import sys, json, re
|
||||
try:
|
||||
d = json.load(sys.stdin)
|
||||
except Exception:
|
||||
raise SystemExit
|
||||
p = (d.get("tool_input") or {}).get("prompt") or ""
|
||||
for line in p.splitlines():
|
||||
if "【工單】" not in line:
|
||||
continue
|
||||
m = re.search(r"([A-Za-z0-9_.-]+)/([A-Za-z0-9_.-]+)#(\d+)", line)
|
||||
if m:
|
||||
print(f"{m.group(1)} {m.group(2)} {m.group(3)}")
|
||||
break
|
||||
' 2>/dev/null || true)
|
||||
|
||||
[ -n "${SPEC:-}" ] || exit 0
|
||||
# shellcheck disable=SC2086
|
||||
set -- $SPEC
|
||||
OWNER="$1"; REPO="$2"; NUM="$3"
|
||||
|
||||
# ── 測試縫(唯一目的:讓判斷邏輯測得起來,不必每跑一次測試就在票池留一張票)──
|
||||
# BATON_GUARD_FIXTURE=<目錄> ⇒ 讀 issue.json / comments.json,不打網路。
|
||||
# 正式執行時這個變數不存在,一行都不會走到。
|
||||
if [ -n "${BATON_GUARD_FIXTURE:-}" ]; then
|
||||
J=$(cat "$BATON_GUARD_FIXTURE/issue.json")
|
||||
C=$(cat "$BATON_GUARD_FIXTURE/comments.json")
|
||||
else
|
||||
# token:掃已知的 checkout 找第一個帶憑證、指向本站的 remote(同 scripts/ticket 的做法)
|
||||
ROOT="${CLAUDE_PROJECT_DIR:-$(pwd)}"
|
||||
URL=""
|
||||
for d in "$ROOT" "$ROOT/products/$REPO" "$ROOT/matrix/$REPO" "$ROOT/polaris/$REPO"; do
|
||||
[ -e "$d/.git" ] || continue
|
||||
U=$(git -C "$d" remote -v 2>/dev/null | grep -m1 'git\.uncle6\.me' | grep '@' | awk '{print $2}') || true
|
||||
[ -n "${U:-}" ] && { URL="$U"; break; }
|
||||
done
|
||||
[ -n "$URL" ] || exit 0
|
||||
TOKEN=$(printf '%s' "$URL" | sed -E 's|.*//[^:]+:([^@]+)@.*|\1|')
|
||||
[ "$TOKEN" != "$URL" ] || exit 0
|
||||
|
||||
J=$(curl -s --max-time 12 -H "Authorization: token $TOKEN" \
|
||||
"https://git.uncle6.me/api/v1/repos/$OWNER/$REPO/issues/$NUM" 2>/dev/null) || exit 0
|
||||
C=$(curl -s --max-time 12 -H "Authorization: token $TOKEN" \
|
||||
"https://git.uncle6.me/api/v1/repos/$OWNER/$REPO/issues/$NUM/comments?limit=100" 2>/dev/null) || C='[]'
|
||||
fi
|
||||
|
||||
REPORT=$(python3 - "$J" "$C" <<'PY' 2>/dev/null
|
||||
import json, sys
|
||||
try:
|
||||
it = json.loads(sys.argv[1]); cs = json.loads(sys.argv[2])
|
||||
except Exception:
|
||||
raise SystemExit
|
||||
if it.get("state") == "closed":
|
||||
raise SystemExit # 已經關了 = 棒子到終點了,沒有下一手
|
||||
miss = []
|
||||
if not (it.get("assignees") or []):
|
||||
miss.append("指派:沒有人被指到這張票 ⇒ 撈 assignee 撈不到它,等於棒子躺在地上")
|
||||
labs = [l["name"] for l in (it.get("labels") or [])]
|
||||
st = [n for n in labs if n.startswith("s/")]
|
||||
if not st:
|
||||
miss.append("tag:沒有任何 s/* ⇒ 看不出它卡在哪一段")
|
||||
elif st == ["s/doing"]:
|
||||
miss.append("tag:還停在 s/doing,但這條線已經收工了 ⇒ 它到底做完沒有?"
|
||||
"(等總管複驗=s/review/已上 stage=s/stage/等別的東西=s/pending)")
|
||||
if not any("🏃" in (c.get("body") or "") for c in cs):
|
||||
miss.append("下一步:票上沒有交棒留言 ⇒ 收下棒子的人得自己重讀整串才知道要做什麼")
|
||||
if not miss:
|
||||
raise SystemExit
|
||||
print("\n".join(" ❌ " + m for m in miss))
|
||||
print("__ASSIGNEES__" + ",".join(a["login"] for a in (it.get("assignees") or [])))
|
||||
print("__LABELS__" + ",".join(labs))
|
||||
PY
|
||||
) || exit 0
|
||||
|
||||
[ -n "${REPORT:-}" ] || exit 0
|
||||
|
||||
BODY=$(printf '%s' "$REPORT" | grep -v '^__')
|
||||
NOW_A=$(printf '%s' "$REPORT" | sed -n 's/^__ASSIGNEES__//p')
|
||||
NOW_L=$(printf '%s' "$REPORT" | sed -n 's/^__LABELS__//p')
|
||||
|
||||
cat >&2 <<EOF
|
||||
🏃 交棒警察:$OWNER/$REPO#$NUM 這條線收工了,但**棒子沒有交回來**。
|
||||
|
||||
現在票上:指派=[${NOW_A:-(空)}] 標籤=[${NOW_L:-(空)}]
|
||||
|
||||
$BODY
|
||||
|
||||
【leo 2026-08-27】「**每個任務結束必須要指派回總管**,要說明下一步怎麼做,
|
||||
直到最後交出正確 deliverable 並且驗證」
|
||||
|
||||
🔴 **關鍵在「指派」是欄位,不是文字。** 撈一次就看得到,不必讀 comment——
|
||||
leo 自己就是用 Gitea 原生的「指派給您的」在看。
|
||||
|
||||
【一個動作把三格補齊】
|
||||
scripts/ticket handback $OWNER/$REPO#$NUM \\
|
||||
--to claude-code \\
|
||||
--next "<收下的人第一件事要做什麼,一句話>" \\
|
||||
--label <s/review|s/stage|s/pending|s/todo> \\
|
||||
--evidence "<實測輸出或 PR 連結>"
|
||||
|
||||
要 leo 親手做的 → --to Leo(會自動加 Human 標籤)
|
||||
已經真的做完且驗過了 → scripts/ticket close $OWNER/$REPO#$NUM --deliverable <URL>
|
||||
|
||||
【撈一次看棒子在誰手上】 scripts/ticket mine
|
||||
EOF
|
||||
exit 2
|
||||
@@ -1,132 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# 管什麼: 往 Gitea 票裡貼「留言裡藏著一件還沒做完的事」(等 X/驗不了/還沒…)卻沒有把它長成子票時,擋一次。
|
||||
# 為什麼: 2026-08-26 arcrun-rag#136 comment 4267 就是這樣掉的——「等雲端那半出貨才驗得了」只活在討論串,收端後來真的上線了,卻沒有任何東西讓人回頭把它撿起來,躺了 14 小時。
|
||||
# 誤觸時怎麼關: 這則留言真的不含待辦 → 指令裡加 `no-subtask: <一句理由>` 放行(留痕);同一 session 只擋一次。
|
||||
#
|
||||
# comment-carries-task-guard.sh — 討論串裡的任務要長成看得見的子票(PreToolUse: Bash)
|
||||
#
|
||||
# ── leo 2026-08-27 的原話(本閘的規格)────────────────────────────────
|
||||
# 「可以在討論串延伸子票,它完成了子票就完工,不然這筆任務就是沒完工,
|
||||
# 如果你沒辦法察覺,那不要把任務加在討論串,而是每個任務長出子票做為這張票的相依,
|
||||
# **票沒完工可以察覺嗎?**」
|
||||
#
|
||||
# ── 為什麼子票比「讓等待會響」更根本 ─────────────────────────────────
|
||||
# 任務寫在討論串 → 要有人回頭讀 comment 才知道它存在 → 沒人回頭讀 → 掉
|
||||
# 任務長成子票 → open/closed 是**狀態** → 撈一次就看得到 → 掉不了
|
||||
# ⇒ 不是「讓它會響」,是**讓它本來就在視線裡**。
|
||||
#
|
||||
# 而且相依是硬的:實測(2026-08-27,Gitea 1.26.4)子票還開著時關母票 →
|
||||
# HTTP 412 `cannot close this issue or pull request because it still has open dependencies`。
|
||||
#
|
||||
# ── 粒度:傾向多開票,不是少開票(leo 2026-08-27 當場更正總管)────────
|
||||
# 「**小任務做完就關閉,不會讓池子爆掉,每個事情沒有歷史記錄才是大問題**」
|
||||
# ⇒ 判準是「這件事需要被記得嗎」,不是「會不會太多」。
|
||||
# 池子爆掉的前提是「開了不關」,那由「做完就關」解決,不該由「不開票」解決。
|
||||
#
|
||||
# ── 誠實邊界 ──────────────────────────────────────────────────────
|
||||
# PreToolUse 只看得到指令文字,這是這一層的天花板(同 InkStoneCo#36)。
|
||||
# 所以關鍵詞刻意取**窄**的一組「未完成的未來式」,寧可漏抓也不要天天誤擋——
|
||||
# `.claude/branch-holds.md` 檔頭那句:**永遠在響的警報,等於訓練人忽略這個警報。**
|
||||
# 漏抓的那些,由 baton-handback-guard.sh(收工那一刻)補第二層。
|
||||
#
|
||||
# 已知極限(誠實標記):這支**認不出「我已經開好子票了」**——它只看得到指令文字。
|
||||
# 所以交件報告裡引用自己剛開的子票時仍會被擋一次;那一次用 `no-subtask:` 過,
|
||||
# 或就當它是一次確認。刻意不去打 API 查「這張票有沒有相依」:那會讓每一個
|
||||
# 命中關鍵詞的 Bash 呼叫都多一次網路往返,代價落在錯的地方。
|
||||
set -uo pipefail
|
||||
|
||||
INPUT=$(cat)
|
||||
CMD=$(printf '%s' "$INPUT" | python3 -c "
|
||||
import sys,json
|
||||
try: print(json.load(sys.stdin).get('tool_input',{}).get('command',''))
|
||||
except Exception: print('')
|
||||
" 2>/dev/null)
|
||||
SID=$(printf '%s' "$INPUT" | python3 -c "
|
||||
import sys,json
|
||||
try: print(json.load(sys.stdin).get('session_id','') or 'nosid')
|
||||
except Exception: print('nosid')
|
||||
" 2>/dev/null || echo nosid)
|
||||
[ -n "$CMD" ] || exit 0
|
||||
|
||||
# 逃生口(留痕,會留在指令歷史上)
|
||||
case "$CMD" in *no-subtask:*) exit 0 ;; esac
|
||||
|
||||
# 這些動詞本來就是在處理「棒子」,不該被自己擋
|
||||
case "$CMD" in
|
||||
*"ticket subtask"*|*"ticket handback"*|*"ticket mine"*|*"ticket close"*|*"ticket where"*) exit 0 ;;
|
||||
esac
|
||||
|
||||
# ① 這是不是在對「既有票」貼留言?(開新票走 ticket new/ticket-api-bypass-guard)
|
||||
IS_COMMENT=no
|
||||
case "$CMD" in *"ticket say"*) IS_COMMENT=yes ;; esac
|
||||
if printf '%s' "$CMD" | grep -qE 'issues/[0-9]+/comments' && printf '%s' "$CMD" | grep -qw 'POST'; then
|
||||
IS_COMMENT=yes
|
||||
fi
|
||||
[ "$IS_COMMENT" = "yes" ] || exit 0
|
||||
|
||||
# ② 把「要貼的內文」拿到手:-F <檔> 就讀那個檔,否則就看指令文字本身
|
||||
BODY="$CMD"
|
||||
FARG=$(printf '%s' "$CMD" | sed -nE 's/.*[[:space:]](-F|--file)[[:space:]]+([^[:space:]]+).*/\2/p' | head -1)
|
||||
if [ -n "${FARG:-}" ] && [ -f "$FARG" ]; then
|
||||
BODY=$(cat "$FARG" 2>/dev/null || printf '%s' "$CMD")
|
||||
fi
|
||||
|
||||
# 已經帶著交棒標記的留言 = 走的是 handback 那條路,不重複擋。
|
||||
# 🔴 只認**第一行**,不認「內文任何地方出現過」——這是 dogfood 當場抓到的漏(2026-08-27):
|
||||
# 交件報告裡**引用了一段 `ticket mine` 的輸出**(那段有 🏃),
|
||||
# 整則報告就被豁免掉了,而它裡面真的有「等雲端那半出貨」這種待辦。
|
||||
# ⇒ 這正是 `inkstone/InkStoneCo#23` 那個病的同款:**複述關鍵字被當成本人**。
|
||||
# `ticket handback` 寫的交棒留言,🏃 一定在第一行;引用別人的輸出則不會。
|
||||
case "$(printf '%s' "$BODY" | head -1)" in "🏃"*) exit 0 ;; esac
|
||||
|
||||
# ③ 「未完成的未來式」——刻意取窄。這一組全部來自 08-26 那則真的掉了的留言的形狀。
|
||||
#
|
||||
# 🔴 比對用 python3 不用 grep——這是**實測逼出來的**,不是偏好(2026-08-27 本閘第一版):
|
||||
# 第一版寫 `等[^。]{0,20}(出貨|…)`,跑真的 hook 呼叫路徑時對
|
||||
# 「這件事等雲端那半出貨才驗得了」**不匹配**,閘靜默失效(測試 19/20,那一條紅)。
|
||||
# 同一條 pattern 在別的 shell 手打卻會過 ⇒ **它的行為依賴 locale/grep 實作**,
|
||||
# 而 hook 的執行環境不由我們決定(本機 `LC_CTYPE=C`,雲端又是另一套)。
|
||||
# unpushed-police.sh 檔頭記過同一族的坑:C locale 下處理全形標點會咬掉位元組。
|
||||
# ⇒ 規約:**hook 裡凡是要比對中文,一律交給 python3 的 re**(它一定是字元語意),
|
||||
# 不要賭 grep 在那台機器上是字元模式還是位元組模式。
|
||||
HIT=$(BODY="$BODY" python3 -c '
|
||||
import os, re, sys
|
||||
PAT = ("等.{0,20}?(出貨|上線|部署|發佈|合併|修好|回覆|做完|接上)"
|
||||
"|驗不了|測不了|還沒(驗|測|做|上線|部署|接|補)|之前(驗不|無法|不能)"
|
||||
"|要等|尚未完成|待辦|\\[blocker\\]|TODO|FIXME")
|
||||
ms = [m.group(0) for m in re.finditer(PAT, os.environ.get("BODY", ""))]
|
||||
print(" ".join(ms[:3]))
|
||||
' 2>/dev/null)
|
||||
[ -n "${HIT:-}" ] || exit 0
|
||||
|
||||
WARNED="/tmp/.comment-task-guard-warned-$SID"
|
||||
[ -f "$WARNED" ] && exit 0 # 同一 session 只擋一次,不鬼打牆
|
||||
date +%s > "$WARNED"
|
||||
|
||||
cat >&2 <<EOF
|
||||
🎫 這則留言裡藏著一件**還沒做完的事**,而它現在只是文字。
|
||||
|
||||
命中的詞:$HIT
|
||||
|
||||
【leo 2026-08-27】「**每個任務長出子票做為這張票的相依⋯⋯票沒完工可以察覺嗎?**」
|
||||
|
||||
【它掉過一次,時序如下】
|
||||
08-26 13:00 arcrun-rag#136 comment 4267:「等雲端那半出貨才驗得了」 ← 只是一段文字
|
||||
08-26 18-23 總管真的出貨了 ← 條件滿足,沒人知道
|
||||
08-27 11:xx leo 問「這 14 小時前的,你派了還是沒派?」 ← 才被撿起來
|
||||
|
||||
【怎麼做】把它長成子票,Gitea 原生相依會**硬擋**母票關閉(實測 412):
|
||||
|
||||
scripts/ticket subtask <母票 owner/repo#N> \\
|
||||
--title "身為<誰>,我要<什麼>,我才<為什麼>" \\
|
||||
-F <內文檔> --label s/todo --assign <誰做>
|
||||
|
||||
⇒ 子票沒關,母票關不掉。**不是提醒,是平台保證。**
|
||||
|
||||
【粒度】leo 2026-08-27:「**小任務做完就關閉,不會讓池子爆掉,
|
||||
每個事情沒有歷史記錄才是大問題**」⇒ 判準是「這件事需要被記得嗎」,
|
||||
傾向**多開票**。不要用「會不會太多」當理由不開。
|
||||
|
||||
【真的不是待辦】在指令裡加 \`no-subtask: <一句理由>\` 重送(留痕)。
|
||||
EOF
|
||||
exit 2
|
||||
Executable
+189
@@ -0,0 +1,189 @@
|
||||
#!/usr/bin/env bash
|
||||
# 管什麼: 派工單裡除了【工單】票號以外還有別的內容就擋;合規的派工則自動注入共通規定。
|
||||
# 為什麼: 任務寫在 prompt 裡 => 票上看不到、agent 一停就蒸發;散文沒有欄位 => 警察抓不到。
|
||||
# 誤觸時怎麼關: 把內容歸位(每次都一樣的→共通規定;這次才知道的→寫進票),或 touch /tmp/.dispatch-ok-<session_id> 放行一次。
|
||||
#
|
||||
# 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-<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-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 <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
|
||||
+20
-7
@@ -43,11 +43,11 @@
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/comment-carries-task-guard.sh"
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/milestone-due-guard.sh"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/milestone-due-guard.sh"
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/reply-identity-guard.sh"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -60,6 +60,15 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": ".*mcp__[0-9a-fA-F-]+__(kbdb_|arcrun_).*",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/leo21c-mcp-guard.sh"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "Write|Edit|MultiEdit",
|
||||
"hooks": [
|
||||
@@ -136,6 +145,10 @@
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/no-ticket-no-dispatch.sh"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/dispatch-format-guard.sh"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -171,6 +184,10 @@
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/no-ticket-no-dispatch.sh"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/dispatch-format-guard.sh"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -335,13 +352,9 @@
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/issue-status-autoflip.sh"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/baton-handback-guard.sh"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Executable
+202
@@ -0,0 +1,202 @@
|
||||
#!/bin/sh
|
||||
# leo21c-mcp-guard.sh — PreToolUse:擋下打向 leo21c 真庫的 Arcrun/KBDB MCP 呼叫
|
||||
# (leo 2026-08-27 立,ISEP#69)
|
||||
#
|
||||
# leo 原話:「我從頭的指令就叫你去打 youlin,stage 環境是它你不知道嗎?
|
||||
# 為什麼打算用我的真庫測試?」
|
||||
#
|
||||
# 🔴 立這道閘的來由(2026-08-27 總管實查):
|
||||
# 這台機器同時掛著兩條 Arcrun MCP 連線,工具名是兩串亂碼 UUID:
|
||||
# mcp__00402d88-5e2f-4125-b971-8bdac50d7456__* → logged_in_as: leo21c 🔴 leo 的真庫
|
||||
# mcp__d0eaf65b-583e-464e-aacc-00527ac05c4b__* → logged_in_as: youlin ✅ 測試場
|
||||
# 總管一整天用的是第一條,所有測試資料卻寫在 youlin。範圍講清楚:
|
||||
# 打在 leo21c 上的**全是唯讀查詢**(kbdb_search/kbdb_get_map/whoami),
|
||||
# 沒有寫入、沒有 ingest。但唯讀也不該發生,而且它造成了三次假結論:
|
||||
# ①「wiki 沒送上去」——其實上去了,在 youlin
|
||||
# ②「檔案排在 1691 筆佇列後面」——查錯庫
|
||||
# ③「ingest 回 200 但一個半小時還沒寫進去」——換 youlin 那條查,四塊內容全在
|
||||
# 最難看的一格:總管每一份派工單都親手寫著「測試一律用 youlin,絕對不要碰 leo21c」
|
||||
# ——規則寫給別人,自己一次都沒套用。
|
||||
#
|
||||
# 判準(封動作,不封措辭 —— 同 empty-handed-stop-guard/leo21c-write-guard 的哲學):
|
||||
# 任何打向 leo21c 座標的 Arcrun/KBDB MCP 呼叫 ⇒ 擋,除非明說要打它(見下方戳記)。
|
||||
# 讀寫都擋,但訊息不同:唯讀提示「你可能查錯地方」,寫入提示「這會汙染真資料」。
|
||||
# `arcrun_whoami` 本身永遠放行——那是唯一能確認身份的工具,擋它會造成死結。
|
||||
#
|
||||
# 🔴 已知限制(紅線要求「查不到就明講」,這裡就是查不到的那部分):
|
||||
# MCP server 的 UUID 是這台機器這次連線分配的,**不是穩定不變的識別碼**
|
||||
# ——查過 ~/.claude.json/本機常見設定檔,這兩條 Arcrun 連線都不是本地
|
||||
# mcpServers 設定出來的(找不到來源設定檔),判斷是雲端 connector 這層
|
||||
# 動態配的,UUID 會在重裝/換 session 時换掉。沒有找到官方文件證實這點,
|
||||
# 這裡寫的是「查過但查不到穩定識別法」,不是猜測的答案。
|
||||
# ⇒ 下面兩個 UUID 是寫死的(2026-08-27 由 leo 實測 arcrun_whoami 得到),
|
||||
# **這份對照表會過期**。因此設計上不是「表格以外的一律放行」,
|
||||
# 而是「不在表格裡的 UUID 一樣擋,且訊息明講『清單可能過期』」——
|
||||
# 大聲失敗,不要靜默放行到一個身份不明的目標(fail-closed)。
|
||||
set -eu
|
||||
|
||||
LEO21C_UUID="00402d88-5e2f-4125-b971-8bdac50d7456"
|
||||
YOULIN_UUID="d0eaf65b-583e-464e-aacc-00527ac05c4b"
|
||||
STAMP="/tmp/.leo21c-mcp-ok"
|
||||
PROJ="${CLAUDE_PROJECT_DIR:-$(pwd)}"
|
||||
LOGDIR="$PROJ/.claude/hooks"
|
||||
|
||||
trace() {
|
||||
# 留痕:擋下與放行都記,"明說放行" 也要留下痕跡讓 leo 事後查得到
|
||||
# (inkstone/InkStoneCo#48 同一個理由:閘只記「擋下」,分母未知答不出「它有沒有在動」)
|
||||
T=$(date "+%Y-%m-%d %H:%M:%S" 2>/dev/null || echo "?")
|
||||
if [ -d "$LOGDIR" ]; then
|
||||
printf '| %s | %s | %s | %s |\n' "$T" "$1" "${UUID:-?}" "${TOOL:-?}" \
|
||||
>> "$LOGDIR/leo21c-mcp-guard.log" 2>/dev/null || true
|
||||
fi
|
||||
}
|
||||
|
||||
stamp_ok() {
|
||||
# 單次、15 分鐘失效 —— 同 prod-write-guard.sh 的戳記設計,用完即丟
|
||||
# (多條 subagent 並行時「時間窗」本身就是漏洞,單次消耗把窗口壓到最小)
|
||||
[ -f "$STAMP" ] || return 1
|
||||
NOW=$(date +%s 2>/dev/null || echo 0)
|
||||
MT=$(stat -f %m "$STAMP" 2>/dev/null || stat -c %Y "$STAMP" 2>/dev/null || echo 0)
|
||||
case "$NOW$MT" in *[!0-9]*) return 1 ;; esac
|
||||
[ "$NOW" -gt 0 ] && [ "$MT" -gt 0 ] || return 1
|
||||
[ $((NOW - MT)) -lt 900 ] || return 1
|
||||
rm -f "$STAMP" 2>/dev/null || true
|
||||
return 0
|
||||
}
|
||||
|
||||
INPUT="$(cat)"
|
||||
|
||||
TOOL=$(printf '%s' "$INPUT" | python3 -c '
|
||||
import json,sys
|
||||
try: print(json.load(sys.stdin).get("tool_name","") or "")
|
||||
except Exception: print("__PYFAIL__")
|
||||
' 2>/dev/null || printf '__PYFAIL__')
|
||||
|
||||
# ── fail-closed:解析壞了不准變成靜默放行 ────────────────────────────
|
||||
# 這裡拿不到 tool_name,就退回對原始 payload 做字串比對;
|
||||
# 命中真庫座標就照「未知/不確定」等級擋,而不是直接 exit 0。
|
||||
if [ "$TOOL" = "__PYFAIL__" ] || [ -z "$TOOL" ]; then
|
||||
if printf '%s' "$INPUT" | grep -q "$LEO21C_UUID"; then
|
||||
UUID="$LEO21C_UUID"; TOOL="(解析失敗,字串比對命中真庫座標)"
|
||||
stamp_ok && { trace "✅ 明說放行(解析失敗)"; exit 0; }
|
||||
trace "⛔ 擋下(解析失敗仍命中真庫)"
|
||||
cat >&2 <<MSG
|
||||
🚫 leo21c-mcp-guard 解析這次呼叫的 tool_name 失敗,
|
||||
但原始內容裡看得到 leo21c 的座標 —— fail-closed,先擋。
|
||||
|
||||
要放行這一次:touch /tmp/.leo21c-mcp-ok
|
||||
MSG
|
||||
exit 2
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 只管「mcp__<uuid>__(kbdb_|arcrun_)*」這個形狀,其餘一律放行
|
||||
case "$TOOL" in
|
||||
mcp__*__kbdb_*|mcp__*__arcrun_*) ;;
|
||||
*) exit 0 ;;
|
||||
esac
|
||||
|
||||
UUID=$(printf '%s' "$TOOL" | sed -E 's/^mcp__([0-9a-fA-F-]+)__.*/\1/')
|
||||
SUFFIX=$(printf '%s' "$TOOL" | sed -E 's/^mcp__[0-9a-fA-F-]+__//')
|
||||
|
||||
# 🔴 紅線:arcrun_whoami 本身永遠不擋——它是唯一能確認身份的工具,
|
||||
# 擋它會造成死結(想查是誰、卻連查都查不了)。
|
||||
case "$SUFFIX" in
|
||||
arcrun_whoami) exit 0 ;;
|
||||
esac
|
||||
|
||||
# youlin 測試場:預設放行,不留痕(每次都記會把 log 灌爆,而這是預期的正常路徑)
|
||||
[ "$UUID" = "$YOULIN_UUID" ] && exit 0
|
||||
|
||||
# 明說要打(leo 有時真的需要查自己的庫):單次戳記放行,留痕
|
||||
if stamp_ok; then
|
||||
trace "✅ 明說放行"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 判斷讀寫,訊息不同(兩者都擋,但傷害的說法不一樣)
|
||||
case "$SUFFIX" in
|
||||
*create_record*|*create_template*|*push_workflow*|*delete_workflow*|\
|
||||
*recipe_push*|*recipe_delete*|*create_tag*|*delete_tag*|\
|
||||
*tag_resource*|*untag_resource*|*run_workflow*|*report_feedback*|*recipe_submit*)
|
||||
KIND="write" ;;
|
||||
*)
|
||||
KIND="read" ;;
|
||||
esac
|
||||
|
||||
if [ "$UUID" = "$LEO21C_UUID" ]; then
|
||||
trace "⛔ 擋下(真庫/$KIND)"
|
||||
if [ "$KIND" = "write" ]; then
|
||||
cat >&2 <<MSG
|
||||
🚫 這個寫入打向 leo21c —— leo 本人的真庫,不是測試場(leo 2026-08-27 立,ISEP#69)
|
||||
|
||||
leo 原話:「我從頭的指令就叫你去打 youlin,stage 環境是它你不知道嗎?
|
||||
為什麼打算用我的真庫測試?」
|
||||
|
||||
你剛剛呼叫的是:
|
||||
mcp__${LEO21C_UUID}__${SUFFIX}
|
||||
→ logged_in_as: leo21c 🔴 真庫
|
||||
|
||||
這不是「查錯地方」——這是**把測試資料寫進 leo 自己在用的知識庫**,會汙染他的真實資料。
|
||||
|
||||
你要的測試場是另一條線(同樣的動作,換一個 server 前綴):
|
||||
mcp__${YOULIN_UUID}__${SUFFIX}
|
||||
→ logged_in_as: youlin.hsieh.dev ✅ 測試場,隨便寫
|
||||
|
||||
改法:換那條線再打一次。
|
||||
|
||||
⚠️ 若這次真的是 leo 要你動他的真庫(而不是測試):
|
||||
touch /tmp/.leo21c-mcp-ok # 只放行「下一個」呼叫,15 分鐘內有效、用完即消失
|
||||
會被記錄到 .claude/hooks/leo21c-mcp-guard.log。
|
||||
MSG
|
||||
else
|
||||
cat >&2 <<MSG
|
||||
🚫 這個查詢打向 leo21c —— leo 本人的真庫,不是測試場(leo 2026-08-27 立,ISEP#69)
|
||||
|
||||
leo 原話:「我從頭的指令就叫你去打 youlin,stage 環境是它你不知道嗎?
|
||||
為什麼打算用我的真庫測試?」
|
||||
|
||||
你剛剛呼叫的是:
|
||||
mcp__${LEO21C_UUID}__${SUFFIX}
|
||||
→ logged_in_as: leo21c 🔴 真庫
|
||||
|
||||
即使只是唯讀查詢,打錯庫依然有害——**你可能正在查錯地方**:
|
||||
真庫的資料跟測試場的資料是兩份,用真庫的查詢結果去判斷測試進度,
|
||||
會做出「wiki 沒送上去」「排在 1691 筆後面」這類看起來像結論、其實是查錯庫的假結論
|
||||
(2026-08-27 總管本人今天就連中三次)。
|
||||
|
||||
你要的測試場是另一條線(同樣的查詢,換一個 server 前綴):
|
||||
mcp__${YOULIN_UUID}__${SUFFIX}
|
||||
→ logged_in_as: youlin.hsieh.dev ✅ 測試場
|
||||
|
||||
改法:換那條線再查一次。
|
||||
|
||||
⚠️ 若這次真的是要查 leo 自己的庫(他本人有時真的需要):
|
||||
touch /tmp/.leo21c-mcp-ok # 只放行「下一個」呼叫,15 分鐘內有效、用完即消失
|
||||
會被記錄到 .claude/hooks/leo21c-mcp-guard.log。
|
||||
MSG
|
||||
fi
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# ── 走到這裡:UUID 兩個都不是 —— 對照表可能過期,不要靜默放行 ─────────
|
||||
# 這正是檔頭說的「大聲失敗」:某條連線被重裝、UUID 換了,我們認不出它是誰,
|
||||
# 與其猜測放行、不如逼一次 arcrun_whoami 或人工更新這份表。
|
||||
trace "⛔ 擋下(未知UUID,清單可能過期)"
|
||||
cat >&2 <<MSG
|
||||
🚫 這個 MCP server 的 id 不在 leo21c-mcp-guard.sh 的已知清單裡
|
||||
|
||||
已知清單(2026-08-27 由 arcrun_whoami 實測寫死,見本檔檔頭):
|
||||
${LEO21C_UUID} → leo21c(真庫,預設擋)
|
||||
${YOULIN_UUID} → youlin(測試場,預設放)
|
||||
|
||||
這次打的是:${UUID}
|
||||
兩個都不是 —— 代表某條 MCP 連線被重裝過、UUID 換了,這份對照表可能已經過期。
|
||||
|
||||
寧可先擋、不要靜默放行到一個身份不明的目標。怎麼辦:
|
||||
1. 呼叫 arcrun_whoami(這支工具本身不會被擋)確認這個 server 真正的身份
|
||||
2. 如果它是新的 youlin id → 回報 isep-guard 更新 hooks/leo21c-mcp-guard.sh 的對照表
|
||||
3. 現在就是要放行這一次 → touch /tmp/.leo21c-mcp-ok(15 分鐘內有效、用完即消失)
|
||||
MSG
|
||||
exit 2
|
||||
@@ -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()
|
||||
Executable
+73
@@ -0,0 +1,73 @@
|
||||
#!/bin/bash
|
||||
# 管什麼: 直接打 Gitea API 貼留言到票上時,內文第一行沒有【身份】就擋。
|
||||
# 為什麼: scripts/ticket 已經在正門擋了,但貼留言還有 API 這條側門——只封一條等於沒封。
|
||||
# 誤觸時怎麼關: 內文第一行加【身份】<總管|subagent|leo>/<owner/repo>/<分支>,或在指令裡加 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/<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):
|
||||
|
||||
【身份】subagent/inkstone/ISEP/feat/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
|
||||
Executable
+175
@@ -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 <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 ]
|
||||
Vendored
+14
@@ -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
@@ -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`,回報分支名。
|
||||
Executable
+71
@@ -0,0 +1,71 @@
|
||||
#!/usr/bin/env bash
|
||||
# leo21c-mcp-guard.sh 的迴歸測試(ISEP#69)。
|
||||
HOOK="$1"
|
||||
rm -f /tmp/.leo21c-mcp-ok 2>/dev/null # 起手清乾淨,避免上一輪殘留戳記污染這輪
|
||||
mk(){ python3 -c "import json,sys;print(json.dumps({'tool_name':sys.argv[1],'tool_input':{}}))" "$1"; }
|
||||
PASS=0; FAIL=0
|
||||
t(){ # t <期望 block|pass> <說明> <tool_name> —— 除了明說放行那幾格,其餘每格開跑前先確保沒有殘留戳記
|
||||
mk "$3" | "$HOOK" >/dev/null 2>&1; rc=$?
|
||||
got=$([ $rc -eq 2 ] && echo block || echo pass)
|
||||
if [ "$got" = "$1" ]; then echo " ✅ $2"; PASS=$((PASS+1))
|
||||
else echo " ❌ $2 —— 期望 $1,實得 $got"; FAIL=$((FAIL+1)); fi
|
||||
}
|
||||
|
||||
LEO="00402d88-5e2f-4125-b971-8bdac50d7456"
|
||||
YOULIN="d0eaf65b-583e-464e-aacc-00527ac05c4b"
|
||||
UNKNOWN="11111111-2222-3333-4444-555555555555"
|
||||
|
||||
echo "── 該擋(打向 leo21c 真庫)──"
|
||||
t block "唯讀查詢打 leo21c" "mcp__${LEO}__kbdb_search"
|
||||
t block "get_map 打 leo21c" "mcp__${LEO}__kbdb_get_map"
|
||||
t block "寫入打 leo21c" "mcp__${LEO}__kbdb_create_record"
|
||||
t block "推工作流打 leo21c" "mcp__${LEO}__arcrun_push_workflow"
|
||||
|
||||
echo "── 該放行(打向 youlin 測試場)──"
|
||||
t pass "唯讀查詢打 youlin" "mcp__${YOULIN}__kbdb_search"
|
||||
t pass "寫入打 youlin" "mcp__${YOULIN}__kbdb_create_record"
|
||||
t pass "查 template 打 youlin" "mcp__${YOULIN}__kbdb_list_templates"
|
||||
t pass "arcrun 讀清單打 youlin" "mcp__${YOULIN}__arcrun_list_workflows"
|
||||
|
||||
echo "── 不該擋:arcrun_whoami 本身(不管打哪條線)──"
|
||||
t pass "whoami 打 leo21c" "mcp__${LEO}__arcrun_whoami"
|
||||
t pass "whoami 打 youlin" "mcp__${YOULIN}__arcrun_whoami"
|
||||
t pass "whoami 打未知 UUID" "mcp__${UNKNOWN}__arcrun_whoami"
|
||||
|
||||
echo "── 不該擋:非 kbdb/arcrun 的 MCP 工具、非 MCP 工具 ──"
|
||||
t pass "無關的 MCP 工具" "mcp__${LEO}__some_other_tool"
|
||||
t pass "一般工具名" "Bash"
|
||||
t pass "另一個一般工具" "kbdb_search"
|
||||
|
||||
echo "── 該擋(清單以外的未知 UUID —— 對照表可能過期,大聲失敗)──"
|
||||
t block "未知 UUID 的 kbdb_search" "mcp__${UNKNOWN}__kbdb_search"
|
||||
t block "未知 UUID 的寫入" "mcp__${UNKNOWN}__kbdb_create_record"
|
||||
|
||||
echo "── 明說要打真庫:戳記放行(單次)──"
|
||||
rm -f /tmp/.leo21c-mcp-ok
|
||||
touch /tmp/.leo21c-mcp-ok
|
||||
t pass "戳記在,打 leo21c 放行一次" "mcp__${LEO}__kbdb_search"
|
||||
# 上面 t() 已經在下一次呼叫前 rm -f 戳記,這裡驗證戳記確實是單次
|
||||
touch /tmp/.leo21c-mcp-ok
|
||||
mk "mcp__${LEO}__kbdb_search" | "$HOOK" >/dev/null 2>&1
|
||||
mk "mcp__${LEO}__kbdb_search" | "$HOOK" >/dev/null 2>&1; rc2=$?
|
||||
if [ $rc2 -eq 2 ]; then echo " ✅ 戳記用完即消失(第二次仍被擋)"; PASS=$((PASS+1))
|
||||
else echo " ❌ 戳記用完即消失 —— 期望第二次被擋,實得 pass"; FAIL=$((FAIL+1)); fi
|
||||
rm -f /tmp/.leo21c-mcp-ok 2>/dev/null
|
||||
|
||||
echo "── 明說放行要留痕 ──"
|
||||
LOGDIR="${CLAUDE_PROJECT_DIR:-$(pwd)}/.claude/hooks"
|
||||
mkdir -p "$LOGDIR"
|
||||
rm -f "$LOGDIR/leo21c-mcp-guard.log"
|
||||
touch /tmp/.leo21c-mcp-ok
|
||||
mk "mcp__${LEO}__kbdb_search" | "$HOOK" >/dev/null 2>&1
|
||||
if [ -f "$LOGDIR/leo21c-mcp-guard.log" ] && grep -q "明說放行" "$LOGDIR/leo21c-mcp-guard.log"; then
|
||||
echo " ✅ 明說放行有寫進 log"; PASS=$((PASS+1))
|
||||
else
|
||||
echo " ❌ 明說放行沒有留痕"; FAIL=$((FAIL+1))
|
||||
fi
|
||||
rm -f "$LOGDIR/leo21c-mcp-guard.log" /tmp/.leo21c-mcp-ok 2>/dev/null
|
||||
|
||||
echo
|
||||
echo "結果:通過 $PASS / 失敗 $FAIL"
|
||||
[ $FAIL -eq 0 ] || exit 1
|
||||
Executable
+99
@@ -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\":\"【身份】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 ]
|
||||
@@ -1,55 +0,0 @@
|
||||
#!/bin/bash
|
||||
# 交棒警察的測試——三格(指派/tag/下一步)各自缺漏時要被抓到
|
||||
# 判準:5 種該報、5 種不該報
|
||||
# 用 fixture 跑,不打網路、不在票池留下任何一張測試票。
|
||||
cd "$(dirname "$0")/.." || exit 1
|
||||
H=hooks/baton-handback-guard.sh
|
||||
PASS=0; FAIL=0
|
||||
FX=$(mktemp -d); trap 'rm -rf "$FX"' EXIT
|
||||
|
||||
mkfx(){ # $1=state $2=assignees(csv) $3=labels(csv) $4=有沒有交棒留言(y/n)
|
||||
python3 - "$FX" "$1" "$2" "$3" "$4" <<'PY'
|
||||
import json, sys, os
|
||||
d, state, asg, labs, baton = sys.argv[1:6]
|
||||
issue = {"state": state,
|
||||
"assignees": [{"login": a} for a in asg.split(",") if a],
|
||||
"labels": [{"name": l} for l in labs.split(",") if l]}
|
||||
comments = [{"body": "一般進度留言"}]
|
||||
if baton == "y":
|
||||
comments.append({"body": "🏃 **棒子交回** → `claude-code`\n\n**下一步**:去驗那一格"})
|
||||
json.dump(issue, open(os.path.join(d, "issue.json"), "w"))
|
||||
json.dump(comments, open(os.path.join(d, "comments.json"), "w"))
|
||||
PY
|
||||
}
|
||||
|
||||
run(){ # $1=want $2=說明 (fixture 已備好)
|
||||
printf '%s' '{"tool_name":"Agent","tool_input":{"prompt":"【工單】inkstone/arcrun-rag#136\n做點事"}}' \
|
||||
| BATON_GUARD_FIXTURE="$FX" bash "$H" >/dev/null 2>&1
|
||||
got=$?
|
||||
if [ "$got" = "$1" ]; then PASS=$((PASS+1)); printf ' ✅ '; else FAIL=$((FAIL+1)); printf ' ❌ '; fi
|
||||
printf 'want=%s got=%s %s\n' "$1" "$got" "$2"
|
||||
}
|
||||
|
||||
echo "── 該報:三格有缺 ──"
|
||||
mkfx open "" "s/doing" n ; run 2 "三格全缺(=08-26 那次掉棒的狀態)"
|
||||
mkfx open "" "s/review" y ; run 2 "缺指派:撈 assignee 撈不到它"
|
||||
mkfx open "claude-code" "" y ; run 2 "缺 tag:看不出它卡在哪一段"
|
||||
mkfx open "claude-code" "s/review" n ; run 2 "缺下一步:收下的人得重讀整串"
|
||||
mkfx open "claude-code" "s/doing" y ; run 2 "tag 還停在 s/doing,但這條線收工了"
|
||||
|
||||
echo "── 不該報 ──"
|
||||
mkfx open "claude-code" "s/review" y ; run 0 "三格都齊(交回總管等 review)"
|
||||
mkfx open "Leo" "s/stage,Human" y ; run 0 "三格都齊(交給 leo 驗收)"
|
||||
mkfx open "claude-code" "s/pending,p/high" y ; run 0 "卡在依賴上,但三格都齊"
|
||||
mkfx closed "" "" n ; run 0 "票已關=棒子到終點,沒有下一手"
|
||||
|
||||
echo "── 派工單裡沒有【工單】那行 ⇒ 不歸這支管 ──"
|
||||
printf '%s' '{"tool_name":"Agent","tool_input":{"prompt":"隨便做點事"}}' \
|
||||
| BATON_GUARD_FIXTURE="$FX" bash "$H" >/dev/null 2>&1
|
||||
got=$?
|
||||
if [ "$got" = 0 ]; then PASS=$((PASS+1)); printf ' ✅ '; else FAIL=$((FAIL+1)); printf ' ❌ '; fi
|
||||
printf 'want=0 got=%s 沒有票號的派工單\n' "$got"
|
||||
|
||||
echo
|
||||
echo "$PASS/$((PASS+FAIL)) 通過"
|
||||
[ "$FAIL" = 0 ]
|
||||
@@ -1,69 +0,0 @@
|
||||
#!/bin/bash
|
||||
# 討論串裡的任務要長成子票——這道閘的測試
|
||||
# 判準:6 種該擋(留言裡真的藏著待辦)、10 種不該擋
|
||||
# 🔴 「不該擋」比「該擋」重要:branch-holds.md 檔頭那句——
|
||||
# 永遠在響的警報,等於訓練人忽略這個警報。
|
||||
cd "$(dirname "$0")/.." || exit 1
|
||||
H=hooks/comment-carries-task-guard.sh
|
||||
PASS=0; FAIL=0
|
||||
SID=test-cctg-$$
|
||||
run(){ # $1=want $2=cmd
|
||||
rm -f "/tmp/.comment-task-guard-warned-$SID"
|
||||
printf '%s' "{\"session_id\":\"$SID\",\"tool_name\":\"Bash\",\"tool_input\":{\"command\":$(python3 -c 'import json,sys;print(json.dumps(sys.argv[1]))' "$2")}}" \
|
||||
| bash "$H" >/dev/null 2>&1
|
||||
got=$?
|
||||
if [ "$got" = "$1" ]; then PASS=$((PASS+1)); printf ' ✅ '; else FAIL=$((FAIL+1)); printf ' ❌ '; fi
|
||||
printf 'want=%s got=%s %.62s\n' "$1" "$got" "$2"
|
||||
}
|
||||
|
||||
echo "── 該擋:留言裡藏著一件還沒做完的事 ──"
|
||||
# 這一條就是 2026-08-26 真的掉了的那則的形狀
|
||||
run 2 'req("POST", f"{API}/repos/inkstone/arcrun-rag/issues/136/comments", {"body":"Phase 1 的驗收在雲端那半出貨之前驗不了"})'
|
||||
run 2 'curl -X POST "$API/repos/inkstone/Arcrun/issues/12/comments" -d "{\"body\":\"要等 leo 開閘才能部署\"}"'
|
||||
run 2 'req("POST", f"{API}/repos/x/y/issues/3/comments", {"body":"[blocker] 收端還沒上線"})'
|
||||
run 2 'req("POST", f"{API}/repos/x/y/issues/3/comments", {"body":"這條路徑還沒測,先擱著"})'
|
||||
run 2 'req("POST", f"{API}/repos/x/y/issues/3/comments", {"body":"TODO: 補一條端到端測試"})'
|
||||
run 2 'req("POST", f"{API}/repos/x/y/issues/3/comments", {"body":"等雲端出貨之後要回頭重驗一次"})'
|
||||
|
||||
echo "── 不該擋 ──"
|
||||
run 0 'curl -s "$API/repos/inkstone/ISEP/issues/30/comments"' # 純讀
|
||||
run 0 'req("POST", f"{API}/repos/x/y/issues/3/comments", {"body":"已驗過,8/8 全綠"})' # 沒有待辦形狀
|
||||
run 0 'scripts/ticket subtask inkstone/arcrun-rag#136 --title "身為x,我要y,我才z" -F /tmp/c.md'
|
||||
run 0 'scripts/ticket handback inkstone/arcrun-rag#136 --to claude-code --next "還沒驗的那格去驗"'
|
||||
run 0 'scripts/ticket mine'
|
||||
run 0 'scripts/ticket close inkstone/x#1 --deliverable https://example.invalid/pr/1'
|
||||
run 0 'grep -n "驗不了" hooks/comment-carries-task-guard.sh' # 只是讀這支閘
|
||||
run 0 'echo "這則留言等雲端出貨才驗得了 —— no-subtask: 它已經有子票 #143 了"' # 逃生口
|
||||
run 0 'curl -X POST "$API/repos/x/y/issues" -d "{\"title\":\"等出貨\"}"' # 開新票不歸這支管
|
||||
run 0 'req("POST", f"{API}/repos/x/y/issues/3/comments", {"body":"🏃 **棒子交回** → claude-code\n\n**下一步**:等 #143"})'
|
||||
|
||||
echo "── 交棒標記只認第一行(2026-08-27 dogfood 抓到的漏)──"
|
||||
# 真的交棒留言:🏃 在第一行 ⇒ 放行
|
||||
run 0 'req("POST", f"{API}/repos/x/y/issues/3/comments", {"body":"🏃 **棒子交回** → claude-code\n\n**下一步**:等 #143"})'
|
||||
# 交件報告**引用**了一段有 🏃 的輸出,而報告本身帶著待辦 ⇒ 不准被豁免
|
||||
TMPR=$(mktemp)
|
||||
printf '交件報告\n\n```\n🏃 在總管手上的棒子:2 根\n```\n\n還有一格等雲端那半出貨才驗得了\n' > "$TMPR"
|
||||
run 2 "scripts/ticket say inkstone/ISEP#30 -F $TMPR"
|
||||
rm -f "$TMPR"
|
||||
|
||||
echo "── 同一 session 只擋一次(不鬼打牆)──"
|
||||
rm -f "/tmp/.comment-task-guard-warned-$SID"
|
||||
C='req("POST", f"{API}/repos/x/y/issues/3/comments", {"body":"驗不了"})'
|
||||
for want in 2 0; do
|
||||
printf '%s' "{\"session_id\":\"$SID\",\"tool_name\":\"Bash\",\"tool_input\":{\"command\":$(python3 -c 'import json,sys;print(json.dumps(sys.argv[1]))' "$C")}}" \
|
||||
| bash "$H" >/dev/null 2>&1
|
||||
got=$?
|
||||
if [ "$got" = "$want" ]; then PASS=$((PASS+1)); printf ' ✅ '; else FAIL=$((FAIL+1)); printf ' ❌ '; fi
|
||||
printf 'want=%s got=%s 第 %s 次同樣的指令\n' "$want" "$got" "$([ "$want" = 2 ] && echo 1 || echo 2)"
|
||||
done
|
||||
|
||||
echo "── -F <檔>:內文在檔案裡也要看得到 ──"
|
||||
TMPF=$(mktemp); printf '這件事等雲端那半出貨才驗得了\n' > "$TMPF"
|
||||
run 2 "scripts/ticket say inkstone/arcrun-rag#136 -F $TMPF"
|
||||
printf '已驗完,全部綠燈\n' > "$TMPF"
|
||||
run 0 "scripts/ticket say inkstone/arcrun-rag#136 -F $TMPF"
|
||||
rm -f "$TMPF" "/tmp/.comment-task-guard-warned-$SID"
|
||||
|
||||
echo
|
||||
echo "$PASS/$((PASS+FAIL)) 通過"
|
||||
[ "$FAIL" = 0 ]
|
||||
+40
-245
@@ -18,12 +18,6 @@ leo 2026-08-16 三句話,本工具就是它們的機械化:
|
||||
ticket close <票> --deliverable <URL> 關票(要有交付物連結)
|
||||
ticket decide <票> -F <答案檔> 記 leo 的裁決+改狀態(同一個動作)
|
||||
|
||||
三個 Gitea 原生欄位(leo 2026-08-27「這些全部都要」,缺一個就會掉棒):
|
||||
|
||||
ticket subtask <母票> --title <US> -F <檔> 討論串裡的一件事 → 看得見的子票+相依
|
||||
ticket handback <票> --to <誰> --next <一句> 收工=指派+改 tag+寫下一步,一個動作
|
||||
ticket mine [--user <誰>] 撈一次:棒子在誰手上、每根下一步是什麼
|
||||
|
||||
票的寫法:`owner/repo#N`,例:`inkstone/InkStoneCo#44`
|
||||
"""
|
||||
import json
|
||||
@@ -36,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"
|
||||
@@ -154,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']}")
|
||||
@@ -299,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 '票上回報')})")
|
||||
|
||||
@@ -329,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"
|
||||
@@ -357,244 +389,7 @@ def cmd_decide(argv):
|
||||
print(" 兩件事是同一個動作——不會只改標籤而忘了記,也不會記了而看板還在說『等 leo』。")
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════════════
|
||||
# 三個 Gitea 原生欄位(leo 2026-08-27:「子票相依是 gitea 原有機制,改 tag 和指定
|
||||
# 也是,**這些全部都要**」)。三者正交,各回答一個問題:
|
||||
#
|
||||
# 子票相依 這件事存在嗎?做完了嗎? ← 缺了它:任務藏在討論串,撈 open 票看不到
|
||||
# tag s/* 它現在卡在哪一段? ← 缺了它:撈得到票但要重讀整串才知道在等什麼
|
||||
# 指派 現在誰該動? ← 缺了它:大家都以為是別人的事
|
||||
#
|
||||
# 08-26 那次掉棒(arcrun-rag#136 comment 4267「等雲端那半出貨才驗得了」躺了 14 小時)
|
||||
# 三格全缺:沒有子票、tag 沒動、沒指派給任何人。
|
||||
# ══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
BATON_MARK = "🏃 **棒子交回**"
|
||||
|
||||
|
||||
def _labels_of(owner, repo):
|
||||
return {l["name"]: l["id"] for l in api(f"/repos/{owner}/{repo}/labels?limit=100")}
|
||||
|
||||
|
||||
def _set_labels(owner, repo, num, keep):
|
||||
ids = _labels_of(owner, repo)
|
||||
unknown = [n for n in keep if n not in ids]
|
||||
if unknown:
|
||||
die(f"🚫 這個 repo 沒有這些標籤:{unknown}\n"
|
||||
f" (標籤的唯一真相源是 ISEP 的 labels.yaml,改標籤要改那裡再 sync)")
|
||||
api(f"/repos/{owner}/{repo}/issues/{num}/labels",
|
||||
{"labels": [ids[n] for n in keep]}, method="PUT")
|
||||
|
||||
|
||||
def _deps(owner, repo, num):
|
||||
return api(f"/repos/{owner}/{repo}/issues/{num}/dependencies") or []
|
||||
|
||||
|
||||
# ── subtask ──────────────────────────────────────────────────────────────
|
||||
def cmd_subtask(argv):
|
||||
"""把「討論串裡的一件事」長成看得見的子票,並掛成母票的 Gitea 原生相依。
|
||||
|
||||
為什麼是子票而不是留言(leo 2026-08-27):
|
||||
「可以在討論串延伸子票,它完成了子票就完工,不然這筆任務就是沒完工⋯⋯
|
||||
票沒完工可以察覺嗎?」
|
||||
留言要有人回頭讀才看得見;票的 open/closed 是**狀態**,撈一次就在眼前。
|
||||
|
||||
而且相依是**硬的**,不是提醒——實測(2026-08-27,Gitea 1.26.4):
|
||||
子票還開著時 PATCH 母票 state=closed → **HTTP 412
|
||||
"cannot close this issue or pull request because it still has open dependencies"**
|
||||
⇒ 子票沒關,母票關不掉。這是平台保證,不是自律。
|
||||
|
||||
刻意不要求 `ticket where` 戳記:leaf 屬於一條**已知**的線(它的母票就是那條線),
|
||||
不是「新開一條線」——那道閘擋的是後者。
|
||||
"""
|
||||
if len(argv) < 1:
|
||||
die("用法:ticket subtask <母票 owner/repo#N> --title \"<User Story>\" -F <內文檔>\n"
|
||||
" [--repo <收件 repo,預設跟母票同一個>] [--label <s/xxx,預設 s/todo>]\n"
|
||||
" [--assign <誰做> --next \"<他第一件事要做什麼>\"] ← 指派了就一定要寫下一步")
|
||||
powner, prepo, pnum = parse_ref(argv[0])
|
||||
|
||||
def opt(name, default=None):
|
||||
return argv[argv.index(name) + 1] if name in argv else default
|
||||
|
||||
title = opt("--title")
|
||||
fpath = opt("-F") or opt("--file")
|
||||
if not title or not fpath:
|
||||
die("🚫 缺 --title 或 -F <內文檔>")
|
||||
check_title(title)
|
||||
body = open(fpath).read()
|
||||
|
||||
missing = [s for s in REQUIRED_SECTIONS if s not in body]
|
||||
if missing:
|
||||
die(f"🚫 子票一樣是票,模板欄位不能少:{'、'.join(missing)}\n"
|
||||
" (子票通常很小,但「小」不等於「可以說不清楚」——\n"
|
||||
" leo 2026-08-27:『每個事情沒有歷史記錄才是大問題』,\n"
|
||||
" 而沒寫驗收條件的票,關掉之後就沒有歷史,只剩一個關字)")
|
||||
|
||||
repo = opt("--repo", prepo)
|
||||
label = opt("--label", "s/todo")
|
||||
assign = opt("--assign")
|
||||
nxt = opt("--next")
|
||||
if assign and not nxt:
|
||||
die("🚫 指派了人卻沒寫 --next。\n"
|
||||
" **有人被指到這張票,他撈到它的第一件事就是問「所以我要做什麼」**——\n"
|
||||
" 那句話現在就要寫下來,不是等他重讀整串。\n"
|
||||
" (沒有要指派給誰就不要加 --assign,那張票會留在票池等人領。)")
|
||||
|
||||
body = (f"Parent: {powner}/{prepo}#{pnum}\n"
|
||||
f"> 這張票是從母票的討論串裡長出來的一件事。\n"
|
||||
f"> **它沒關,母票關不掉**(Gitea 原生相依,實測 412 硬擋)。\n\n") + body
|
||||
|
||||
d = api(f"/repos/{ORG}/{repo}/issues", {"title": title, "body": body})
|
||||
cnum = d["number"]
|
||||
print(f"✅ 子票 {ORG}/{repo}#{cnum} 已開:{d['html_url']}")
|
||||
|
||||
_set_labels(ORG, repo, cnum, [label])
|
||||
print(f" tag:{label}(它卡在哪一段)")
|
||||
if assign:
|
||||
api(f"/repos/{ORG}/{repo}/issues/{cnum}", {"assignees": [assign]}, method="PATCH")
|
||||
api(f"/repos/{ORG}/{repo}/issues/{cnum}/comments",
|
||||
{"body": f"{BATON_MARK} → `{assign}`\n\n**下一步**:{nxt}\n\n"
|
||||
f"(這張票是 `{powner}/{prepo}#{pnum}` 的相依——它沒關,母票關不掉)"})
|
||||
print(f" 指派:{assign}(誰該動)")
|
||||
print(f" 下一步:{nxt}")
|
||||
|
||||
# 🔴 相依掛不上去 = 這張子票對母票是隱形的 ⇒ 整個動作算失敗,要讓人看到
|
||||
api(f"/repos/{powner}/{prepo}/issues/{pnum}/dependencies",
|
||||
{"owner": ORG, "repo": repo, "index": cnum})
|
||||
deps = _deps(powner, prepo, pnum)
|
||||
openn = [i for i in deps if i["state"] == "open"]
|
||||
print(f" 相依:已掛上 {powner}/{prepo}#{pnum}(它存在、且擋著母票)")
|
||||
print(f"\n📌 母票 {powner}/{prepo}#{pnum} 目前有 {len(openn)} 張未關的相依:")
|
||||
for i in deps:
|
||||
mark = "◻" if i["state"] == "open" else "☑"
|
||||
print(f" {mark} {i['repository']['full_name']}#{i['number']} {i['title'][:50]}")
|
||||
print(" 在它們全關之前,母票 PATCH state=closed 會被 Gitea 擋下(412)。")
|
||||
|
||||
|
||||
# ── handback ─────────────────────────────────────────────────────────────
|
||||
def cmd_handback(argv):
|
||||
"""收工=把棒子交回去:**指派 + tag + 下一步**,一個動作三件事。
|
||||
|
||||
leo 2026-08-27:「它把事情做完後如果要檢查後續,就要把任務指定給你並改 tag,
|
||||
你收到指定用 tag 查就知道要去做,所以**每個任務結束必須要指派回總管**,
|
||||
要說明下一步怎麼做,直到最後交出正確 deliverable 並且驗證」
|
||||
|
||||
🔴 關鍵在「指派」是**欄位**不是文字——撈一次就看得到,不必讀 comment。
|
||||
08-26 那次掉棒,交回的話寫在 comment 裡,票沒指派、tag 沒動 ⇒ 棒子躺在地上 14 小時。
|
||||
"""
|
||||
if len(argv) < 1:
|
||||
die("用法:ticket handback <owner/repo#N> --to <claude-code|Leo> --next \"<下一步一句話>\"\n"
|
||||
" [--label <s/xxx>] [--evidence <URL 或一行實測>]\n"
|
||||
"(--to claude-code = 交回總管;--to Leo = 要 leo 親手做,會自動加 Human)")
|
||||
owner, repo, num = parse_ref(argv[0])
|
||||
|
||||
def opt(name, default=None):
|
||||
return argv[argv.index(name) + 1] if name in argv else default
|
||||
|
||||
to = opt("--to")
|
||||
nxt = opt("--next")
|
||||
if not to or not nxt:
|
||||
die("🚫 缺 --to 或 --next。\n"
|
||||
" **「下一步」不准省略**——收下棒子的人要能不重讀整串就知道該做什麼。\n"
|
||||
" (這正是 08-26 掉棒那次缺的:話有說,但沒有欄位在說「這張票在等你」)")
|
||||
label = opt("--label")
|
||||
ev = opt("--evidence")
|
||||
|
||||
issue = api(f"/repos/{owner}/{repo}/issues/{num}")
|
||||
cur = [l["name"] for l in issue.get("labels") or []]
|
||||
|
||||
deps = _deps(owner, repo, num)
|
||||
openn = [i for i in deps if i["state"] == "open"]
|
||||
|
||||
lines = [f"{BATON_MARK} → `{to}`", "",
|
||||
f"**下一步**:{nxt}"]
|
||||
if ev:
|
||||
lines += ["", f"**證據**:{ev}"]
|
||||
if openn:
|
||||
lines += ["", "**未關的相依**(它們全關之前這張票關不掉):"]
|
||||
lines += [f"- {i['repository']['full_name']}#{i['number']} {i['title'][:60]}" for i in openn]
|
||||
api(f"/repos/{owner}/{repo}/issues/{num}/comments", {"body": "\n".join(lines)})
|
||||
|
||||
api(f"/repos/{owner}/{repo}/issues/{num}", {"assignees": [to]}, method="PATCH")
|
||||
|
||||
keep = list(cur)
|
||||
if label:
|
||||
keep = [n for n in keep if not n.startswith("s/")] + [label]
|
||||
if to == "Leo" and "Human" not in keep:
|
||||
keep.append("Human")
|
||||
if to != "Leo" and "Human" in keep:
|
||||
keep = [n for n in keep if n != "Human"]
|
||||
_set_labels(owner, repo, num, keep)
|
||||
|
||||
print(f"✅ {owner}/{repo}#{num} 棒子交回 `{to}`")
|
||||
print(f" 指派:{to} tag:{[n for n in keep if n.startswith('s/')] or '(未動)'}"
|
||||
f"{' +Human' if 'Human' in keep else ''}")
|
||||
print(f" 下一步:{nxt}")
|
||||
if openn:
|
||||
print(f" ⚠️ 還有 {len(openn)} 張未關的相依 ⇒ 這張票現在關不掉(412),這是對的")
|
||||
print("\n📌 三件事是同一個動作——不會只留言而忘了指派,也不會指派了而看板還說 s/doing。")
|
||||
|
||||
|
||||
# ── mine ─────────────────────────────────────────────────────────────────
|
||||
def cmd_mine(argv):
|
||||
"""撈一次:**哪幾根棒子在誰手上、每根的下一步是什麼**。
|
||||
|
||||
不是輪詢——掛在本來就會做的動作上(session 開場、收工對帳)跑一次。
|
||||
"""
|
||||
user = argv[argv.index("--user") + 1] if "--user" in argv else None
|
||||
|
||||
if user in (None, "", "me"):
|
||||
who = "總管(本 token 的身份)"
|
||||
items = api("/repos/issues/search?state=open&type=issues&assigned=true&limit=100") or []
|
||||
else:
|
||||
who = user
|
||||
items = []
|
||||
for r in api(f"/orgs/{ORG}/repos?limit=100") or []:
|
||||
q = urllib.parse.urlencode({"state": "open", "assigned_by": user, "limit": 50})
|
||||
for it in api(f"/repos/{ORG}/{r['name']}/issues?{q}") or []:
|
||||
if it.get("pull_request"):
|
||||
continue
|
||||
it.setdefault("repository", {"full_name": f"{ORG}/{r['name']}"})
|
||||
items.append(it)
|
||||
|
||||
print(f"🏃 在「{who}」手上的棒子:{len(items)} 根\n")
|
||||
if not items:
|
||||
print(" (沒有——每一根都已經交出去或關掉了)")
|
||||
for it in items:
|
||||
ref = it["repository"]["full_name"] + "#" + str(it["number"])
|
||||
st = [l["name"] for l in it.get("labels") or []]
|
||||
o, rp, n = ref.split("/")[0], ref.split("/")[1].split("#")[0], it["number"]
|
||||
deps = _deps(o, rp, n)
|
||||
openn = [i for i in deps if i["state"] == "open"]
|
||||
print(f" ● {ref} {st}")
|
||||
print(f" {it['title'][:64]}")
|
||||
nxt = _last_next(o, rp, n)
|
||||
print(f" 下一步:{nxt or '❌ 沒有人寫下一步(交棒時漏了 --next)'}")
|
||||
if openn:
|
||||
print(f" ◻ 未關相依 {len(openn)}:" +
|
||||
"、".join(f"{i['repository']['full_name']}#{i['number']}" for i in openn))
|
||||
print("\n📌 終點是 leo 說的「直到最後交出正確 deliverable 並且驗證」——"
|
||||
"驗完就關票、清指派,這張表才會縮短。")
|
||||
|
||||
|
||||
def _last_next(owner, repo, num):
|
||||
"""從最後一則交棒留言抓「下一步」。抓不到就誠實回 None,不要編。"""
|
||||
try:
|
||||
cs = api(f"/repos/{owner}/{repo}/issues/{num}/comments?limit=100") or []
|
||||
except SystemExit:
|
||||
return None
|
||||
for c in reversed(cs):
|
||||
b = c.get("body") or ""
|
||||
if BATON_MARK.strip("*") .replace("**", "") in b or BATON_MARK in b:
|
||||
m = re.search(r"\*\*下一步\*\*[::]\s*(.+)", b)
|
||||
if m:
|
||||
return m.group(1).strip()[:100]
|
||||
return None
|
||||
|
||||
|
||||
CMDS = {"where": cmd_where, "say": cmd_say, "new": cmd_new, "close": cmd_close,
|
||||
"decide": cmd_decide, "subtask": cmd_subtask, "handback": cmd_handback,
|
||||
"mine": cmd_mine}
|
||||
CMDS = {"where": cmd_where, "say": cmd_say, "new": cmd_new, "close": cmd_close, "decide": cmd_decide}
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) < 2 or sys.argv[1] not in CMDS:
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user