Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9d20ef925e | |||
| b46bc5e44c | |||
| 0b6af15f1a | |||
| e474b4cb6f | |||
| 8e7e265589 | |||
| e0557334bc | |||
| d4547bfec3 | |||
| 2f43ecc346 | |||
| 1b95388a0f |
@@ -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"
|
||||
|
||||
@@ -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.8",
|
||||
"description": "InkStone Environment Plugin —— leo 的 Claude Code 環境唯一真相源:46 支機械閘(56 條註冊,白話盤點見 docs/hooks-inventory.md)、7 支 slash command、2 支 skill、27 支腳本,外加治理規範與標籤真相源。本機與雲端裝同一份,沒有子集。",
|
||||
"version": "0.4.0",
|
||||
"keywords": [
|
||||
"inkstone",
|
||||
"guardrails",
|
||||
|
||||
@@ -50,6 +50,26 @@ 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 標籤 模組化
|
||||
@@ -60,6 +80,30 @@ 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 實測:第一版判準連兩次都在同一題漏擋,收緊 ③④ 定義後三次全綠)
|
||||
|
||||
### A6 — 標籤對齊且冪等
|
||||
```
|
||||
bash scripts/gitea-labels-sync.sh
|
||||
@@ -196,6 +240,8 @@ B2(信標那行)/B3(setup 輸出)/B4(閘的訊息)三個畫面
|
||||
| A4 開票側門閘 | 總管 | ✅ 13/13 |
|
||||
| A5 搜尋跨 repo | 總管 | ✅ |
|
||||
| A6 標籤對齊+冪等 | 總管 | ✅ 14 repo,第二次 0/0 |
|
||||
| **A9 人閘警察管路** | 總管 | ✅ 14/14(2026-08-26) |
|
||||
| **A10 人閘警察準度** | 總管 | ✅ 9/9,連跑三次(2026-08-26),A 群誤攔 0 |
|
||||
| A7 plugin 裝得起來 | 總管 | ✅ |
|
||||
| **A8 新 session 閘會觸發** | 總管 | 見本版 release note |
|
||||
| **B1–B5 雲端** | **leo** | 還沒跑(機器碰不到 Cloud environment) |
|
||||
|
||||
@@ -702,3 +702,150 @@ 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)。
|
||||
|
||||
+22
-2
@@ -1,4 +1,4 @@
|
||||
# 43 支閘,白話盤點表
|
||||
# 48 支閘,白話盤點表
|
||||
|
||||
> 回應 `inkstone/InkStoneCo#40`:「如果加入了,我應該可以白話文看到 hooks 的內容?」
|
||||
> 這份表就是那個「白話文」——不用點開任何 `.sh` 檔,一行看懂一支閘在管什麼。
|
||||
@@ -7,10 +7,16 @@
|
||||
|
||||
## 一句話結論
|
||||
|
||||
`hooks/` 底下有 **43 個 `.sh` 檔**,`hooks.json` 實際掛上 **53 條註冊**(同一支閘常被多種情境同時掛上);
|
||||
`hooks/` 底下有 **48 個 `.sh` 檔**,`hooks.json` 實際掛上 **58 條註冊**(同一支閘常被多種情境同時掛上);
|
||||
其中 **3 支檔案存在但沒被掛上**(2 支是待人填的空範本、1 支是刻意留著沒開的止血帶,見下面「未生效」表)。
|
||||
下面按「你會在什麼時候撞到它」分組,一支一行。
|
||||
|
||||
> 🔴 **這兩個數字上一版是錯的(2026-08-26 實際數過才發現)**:本頁原本寫「43 個檔、53 條註冊」,
|
||||
> 而當時真實是 **45 個檔、55 條註冊**——中間有兩支閘進來時沒有回頭改這裡。
|
||||
> 現在的寫法是實際數出來的:
|
||||
> `ls hooks/*.sh | wc -l` = 48;`hooks.json` 展開後的 command 條目 = 58。
|
||||
> **一份會偷偷過期的盤點表,跟沒有盤點表差不多**——見本頁最後「怎麼跟實況對帳」那段。
|
||||
|
||||
---
|
||||
|
||||
## 怎麼讀這張表
|
||||
@@ -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 票(會漏掉「開票前先搜過」這道檢查)就擋下。 | 🛑 擋 |
|
||||
| `comment-carries-task-guard.sh` | AI 要在票裡留一則「等某某上線才驗得了」這種**還沒做完的事**,卻沒有把它開成一張子票時,擋一次。留言沒人會回頭讀,票的 open/closed 撈一次就看得到。 | 🛑 擋(同一輪只擋一次) |
|
||||
|
||||
## B. AI 想寫檔案/改程式碼的當下(PreToolUse / Write·Edit·MultiEdit)
|
||||
|
||||
@@ -71,6 +78,18 @@
|
||||
| `irreversible-dispatch-guard.sh` | 派工單裡出現「刪分支」「drop table」「rm -rf」這類不可逆動作,卻沒寫「先停下來等回覆才執行」就擋下。 | 🛑 擋 |
|
||||
| `no-ticket-no-dispatch.sh` | 派工單裡沒有寫工單號(`【工單】owner/repo#N`),或那張票已經關閉/根本不存在,就擋下——沒有票號的工作沒有人追得到進度。 | 🛑 擋 |
|
||||
|
||||
## 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)
|
||||
|
||||
| 閘名 | 對你意味著什麼 | 動作 |
|
||||
@@ -108,6 +127,7 @@
|
||||
| `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)
|
||||
|
||||
|
||||
Executable
+261
@@ -0,0 +1,261 @@
|
||||
#!/usr/bin/env bash
|
||||
# ask-user-question-guard.sh — PreToolUse(AskUserQuestion):**在「問出口的那一刻」攔住**
|
||||
#
|
||||
# 病根(leo 2026-08-26):「**今天已經好幾次問我,為什麼 hooks 沒有攔下來?**」
|
||||
# 當天總管至少兩次把純技術路徑選擇丟給 leo,其中一次他當場說
|
||||
# 「**這種問題不要問我,我要的是你解決了以後給我 prod**」——而**一支閘都沒響**。
|
||||
#
|
||||
# 實查(2026-08-26,ISEP 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-open(exit 0):判官掛掉/沒網路/`claude` 不在 PATH,
|
||||
# 都只會回到「今天的狀態」(沒有閘),不會變成「問不出去」。
|
||||
# ⇒ 這支閘壞掉的最壞後果,等於它不存在,不會癱瘓任何 session。
|
||||
#
|
||||
# 官方文件核實(2026-08-26 查 code.claude.com,不是憑記憶):
|
||||
# • `AskUserQuestion` **會**觸發 PreToolUse,matcher 寫工具名有效
|
||||
# — 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/hooks(Exit 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'
|
||||
🚦 人閘警察(PreToolUse/AskUserQuestion):**這一問四題全否,是你自己該裁的。**
|
||||
|
||||
判官的話:__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
|
||||
Executable
+136
@@ -0,0 +1,136 @@
|
||||
#!/usr/bin/env bash
|
||||
# 管什麼: 一條派工線收工時,檢查它的工單票有沒有把棒子交回來——指派(誰該動)+ tag(卡在哪)+ 下一步(做什麼)三格。
|
||||
# 為什麼: 2026-08-26 那次交回寫在 comment 裡,票沒指派、tag 沒動 ⇒ 沒有任何欄位在說「這張票在等你」,棒子躺了 14 小時。撈一次看得到的只有欄位,不是留言。
|
||||
# 誤觸時怎麼關: 這條線不對應任何票 → 派工單本來就不該少了【工單】那行(no-ticket-no-dispatch.sh 管那個);真要放行在指令裡留 `baton-ok: <理由>`(本閘只提醒不阻擋,不會擋住任何動作)。
|
||||
#
|
||||
# baton-handback-guard.sh — 收工=把棒子交回去(PostToolUse: Agent|Task)
|
||||
#
|
||||
# ── leo 2026-08-27 的原話(本閘的規格)────────────────────────────────
|
||||
# 「它把事情做完後如果要檢查後續,就要把任務指定給你並改 tag,
|
||||
# 你收到指定用 tag 查就知道要去做,所以**每個任務結束必須要指派回總管**,
|
||||
# 要說明下一步怎麼做,**直到最後交出正確 deliverable 並且驗證**」
|
||||
#
|
||||
# ── 三格各自回答一個問題(leo:「這些全部都要」)─────────────────────
|
||||
# 指派 assignee 現在誰該動? 缺了它 → 大家都以為是別人的事
|
||||
# tag s/* 它卡在哪一段? 缺了它 → 撈得到票,但要重讀整串
|
||||
# 下一步(留言) 收下的人要做什麼? 缺了它 → 撿起棒子的人得自己重建脈絡
|
||||
#
|
||||
# ── 為什麼掛在 PostToolUse(Agent) 而不是 SubagentStop ──────────────
|
||||
# 工單票號寫在**派工單**裡(`【工單】owner/repo#N`),而派工單就是 Agent 的 tool_input。
|
||||
# SubagentStop 拿到的 transcript 是主對話,票號要繞一圈才找得回來
|
||||
# (見 subagent-claim-worksheet.sh 檔頭那段實撞紀錄)。
|
||||
# 同一個位置 issue-status-autoflip.sh 已經在用,形狀是驗過的。
|
||||
#
|
||||
# ── 只提醒,不阻擋 ────────────────────────────────────────────────
|
||||
# 工作已經做完了,擋在這裡沒有任何東西可以被挽救;能做的是**讓總管在當下就看到缺哪一格**。
|
||||
# 同 unpushed-police.sh 的定位:exit 2 讓訊息進到模型眼前,不是 blocker。
|
||||
set -uo pipefail
|
||||
|
||||
INPUT=$(cat)
|
||||
|
||||
SPEC=$(printf '%s' "$INPUT" | python3 -c '
|
||||
import sys, json, re
|
||||
try:
|
||||
d = json.load(sys.stdin)
|
||||
except Exception:
|
||||
raise SystemExit
|
||||
p = (d.get("tool_input") or {}).get("prompt") or ""
|
||||
for line in p.splitlines():
|
||||
if "【工單】" not in line:
|
||||
continue
|
||||
m = re.search(r"([A-Za-z0-9_.-]+)/([A-Za-z0-9_.-]+)#(\d+)", line)
|
||||
if m:
|
||||
print(f"{m.group(1)} {m.group(2)} {m.group(3)}")
|
||||
break
|
||||
' 2>/dev/null || true)
|
||||
|
||||
[ -n "${SPEC:-}" ] || exit 0
|
||||
# shellcheck disable=SC2086
|
||||
set -- $SPEC
|
||||
OWNER="$1"; REPO="$2"; NUM="$3"
|
||||
|
||||
# ── 測試縫(唯一目的:讓判斷邏輯測得起來,不必每跑一次測試就在票池留一張票)──
|
||||
# BATON_GUARD_FIXTURE=<目錄> ⇒ 讀 issue.json / comments.json,不打網路。
|
||||
# 正式執行時這個變數不存在,一行都不會走到。
|
||||
if [ -n "${BATON_GUARD_FIXTURE:-}" ]; then
|
||||
J=$(cat "$BATON_GUARD_FIXTURE/issue.json")
|
||||
C=$(cat "$BATON_GUARD_FIXTURE/comments.json")
|
||||
else
|
||||
# token:掃已知的 checkout 找第一個帶憑證、指向本站的 remote(同 scripts/ticket 的做法)
|
||||
ROOT="${CLAUDE_PROJECT_DIR:-$(pwd)}"
|
||||
URL=""
|
||||
for d in "$ROOT" "$ROOT/products/$REPO" "$ROOT/matrix/$REPO" "$ROOT/polaris/$REPO"; do
|
||||
[ -e "$d/.git" ] || continue
|
||||
U=$(git -C "$d" remote -v 2>/dev/null | grep -m1 'git\.uncle6\.me' | grep '@' | awk '{print $2}') || true
|
||||
[ -n "${U:-}" ] && { URL="$U"; break; }
|
||||
done
|
||||
[ -n "$URL" ] || exit 0
|
||||
TOKEN=$(printf '%s' "$URL" | sed -E 's|.*//[^:]+:([^@]+)@.*|\1|')
|
||||
[ "$TOKEN" != "$URL" ] || exit 0
|
||||
|
||||
J=$(curl -s --max-time 12 -H "Authorization: token $TOKEN" \
|
||||
"https://git.uncle6.me/api/v1/repos/$OWNER/$REPO/issues/$NUM" 2>/dev/null) || exit 0
|
||||
C=$(curl -s --max-time 12 -H "Authorization: token $TOKEN" \
|
||||
"https://git.uncle6.me/api/v1/repos/$OWNER/$REPO/issues/$NUM/comments?limit=100" 2>/dev/null) || C='[]'
|
||||
fi
|
||||
|
||||
REPORT=$(python3 - "$J" "$C" <<'PY' 2>/dev/null
|
||||
import json, sys
|
||||
try:
|
||||
it = json.loads(sys.argv[1]); cs = json.loads(sys.argv[2])
|
||||
except Exception:
|
||||
raise SystemExit
|
||||
if it.get("state") == "closed":
|
||||
raise SystemExit # 已經關了 = 棒子到終點了,沒有下一手
|
||||
miss = []
|
||||
if not (it.get("assignees") or []):
|
||||
miss.append("指派:沒有人被指到這張票 ⇒ 撈 assignee 撈不到它,等於棒子躺在地上")
|
||||
labs = [l["name"] for l in (it.get("labels") or [])]
|
||||
st = [n for n in labs if n.startswith("s/")]
|
||||
if not st:
|
||||
miss.append("tag:沒有任何 s/* ⇒ 看不出它卡在哪一段")
|
||||
elif st == ["s/doing"]:
|
||||
miss.append("tag:還停在 s/doing,但這條線已經收工了 ⇒ 它到底做完沒有?"
|
||||
"(等總管複驗=s/review/已上 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
|
||||
Executable
+132
@@ -0,0 +1,132 @@
|
||||
#!/usr/bin/env bash
|
||||
# 管什麼: 往 Gitea 票裡貼「留言裡藏著一件還沒做完的事」(等 X/驗不了/還沒…)卻沒有把它長成子票時,擋一次。
|
||||
# 為什麼: 2026-08-26 arcrun-rag#136 comment 4267 就是這樣掉的——「等雲端那半出貨才驗得了」只活在討論串,收端後來真的上線了,卻沒有任何東西讓人回頭把它撿起來,躺了 14 小時。
|
||||
# 誤觸時怎麼關: 這則留言真的不含待辦 → 指令裡加 `no-subtask: <一句理由>` 放行(留痕);同一 session 只擋一次。
|
||||
#
|
||||
# comment-carries-task-guard.sh — 討論串裡的任務要長成看得見的子票(PreToolUse: Bash)
|
||||
#
|
||||
# ── leo 2026-08-27 的原話(本閘的規格)────────────────────────────────
|
||||
# 「可以在討論串延伸子票,它完成了子票就完工,不然這筆任務就是沒完工,
|
||||
# 如果你沒辦法察覺,那不要把任務加在討論串,而是每個任務長出子票做為這張票的相依,
|
||||
# **票沒完工可以察覺嗎?**」
|
||||
#
|
||||
# ── 為什麼子票比「讓等待會響」更根本 ─────────────────────────────────
|
||||
# 任務寫在討論串 → 要有人回頭讀 comment 才知道它存在 → 沒人回頭讀 → 掉
|
||||
# 任務長成子票 → open/closed 是**狀態** → 撈一次就看得到 → 掉不了
|
||||
# ⇒ 不是「讓它會響」,是**讓它本來就在視線裡**。
|
||||
#
|
||||
# 而且相依是硬的:實測(2026-08-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
|
||||
@@ -41,6 +41,10 @@
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/ticket-api-bypass-guard.sh"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/comment-carries-task-guard.sh"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/milestone-due-guard.sh"
|
||||
@@ -169,6 +173,15 @@
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/no-ticket-no-dispatch.sh"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"matcher": "AskUserQuestion",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/ask-user-question-guard.sh"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"SessionStart": [
|
||||
@@ -322,6 +335,10 @@
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/issue-status-autoflip.sh"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/baton-handback-guard.sh"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -29,7 +29,20 @@ case "$GATES" in
|
||||
GATES="$(ls "$ROOT"/hooks/*.sh 2>/dev/null | grep -cv '\.template\.sh$' | tr -d ' ')" ;;
|
||||
esac
|
||||
|
||||
MSG="🟢 ISEP v${VER} 已載入(${GATES} 支閘在 ${ROOT})"
|
||||
# 🔴 講清楚「這一份是誰」(leo 2026-08-23 的雲端驗收卡在這裡整整一輪):
|
||||
# 同一台機器上 ISEP 可能有兩份——marketplace 裝的 plugin 快取,
|
||||
# 與 repo 裡 vendor 進去的複製本。**兩份都會印這一行**,而版本號一樣時分不出誰在說話。
|
||||
# 「閘到底從哪一份走」正是決定「另一份能不能拆」的唯一判準
|
||||
# ⇒ 讓路徑自己講:快取在 plugins/cache/ 底下,vendor 的在 repo 的 .claude/ 底下。
|
||||
# (查過歷史:本檔只有 daa1674 建立、b1f399f 改閘數兩輪,沒有人試過標示來源,也沒有被否決的方案。
|
||||
# KBDB 語意搜尋這段開發史:0 命中,該餵。)
|
||||
case "$ROOT" in
|
||||
*/plugins/cache/*) SRC="plugin(marketplace 裝的)" ;;
|
||||
*/.claude/isep*) SRC="vendor(repo 裡的複製本)" ;;
|
||||
*) SRC="來源不明" ;;
|
||||
esac
|
||||
|
||||
MSG="🟢 ISEP v${VER} 已載入(${GATES} 支閘|來源:${SRC}|${ROOT})"
|
||||
|
||||
printf '%s\n' "{
|
||||
\"systemMessage\": \"${MSG}\",
|
||||
|
||||
+92
@@ -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 ]
|
||||
Executable
+165
@@ -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 ]
|
||||
Executable
+55
@@ -0,0 +1,55 @@
|
||||
#!/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 ]
|
||||
Executable
+69
@@ -0,0 +1,69 @@
|
||||
#!/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 ]
|
||||
+244
-1
@@ -18,6 +18,12 @@ 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
|
||||
@@ -351,7 +357,244 @@ def cmd_decide(argv):
|
||||
print(" 兩件事是同一個動作——不會只改標籤而忘了記,也不會記了而看板還在說『等 leo』。")
|
||||
|
||||
|
||||
CMDS = {"where": cmd_where, "say": cmd_say, "new": cmd_new, "close": cmd_close, "decide": cmd_decide}
|
||||
# ══════════════════════════════════════════════════════════════════════════
|
||||
# 三個 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}
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) < 2 or sys.argv[1] not in CMDS:
|
||||
|
||||
@@ -35,3 +35,45 @@ 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 就是這一條)。
|
||||
|
||||
Reference in New Issue
Block a user