Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fa2f76177e | |||
| a671948fa5 | |||
| 7795cd705a | |||
| 577c736b74 | |||
| 7e1b762cbe | |||
| e32957e72b | |||
| e7e60fff89 | |||
| 23e4472715 | |||
| b1830053e5 | |||
| 770a0ee824 | |||
| 8e28041ba4 | |||
| 3bc7f3c5f5 |
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "isep",
|
"name": "isep",
|
||||||
"description": "InkStone Environment Plugin —— leo 的 Claude Code 環境唯一真相源:46 支機械閘(56 條註冊,白話盤點見 docs/hooks-inventory.md)、7 支 slash command、2 支 skill、27 支腳本,外加治理規範與標籤真相源。本機與雲端裝同一份,沒有子集。",
|
"description": "InkStone Environment Plugin —— leo 的 Claude Code 環境唯一真相源:48 支機械閘(59 條註冊,白話盤點見 docs/hooks-inventory.md)、7 支 slash command、2 支 skill、27 支腳本,外加治理規範與標籤真相源。本機與雲端裝同一份,沒有子集。",
|
||||||
"version": "0.4.0",
|
"version": "0.6.1",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"inkstone",
|
"inkstone",
|
||||||
"guardrails",
|
"guardrails",
|
||||||
|
|||||||
@@ -3,3 +3,7 @@
|
|||||||
cloud-env*.txt
|
cloud-env*.txt
|
||||||
*.env
|
*.env
|
||||||
.shell-payload/
|
.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 條註冊;`ISEP#60` 移除 `claim-verify-police.sh`/`subagent-claim-worksheet.sh` 這對自造機制,改由 Gitea 原生三格承接見 `ISEP#59`) |
|
||||||
| `commands/` | 7 支 | `/wiki-recall` `/ship-check` `/cp-write` … |
|
| `commands/` | 7 支 | `/wiki-recall` `/ship-check` `/cp-write` … |
|
||||||
| `skills/` | 2 支 | |
|
| `skills/` | 2 支 | |
|
||||||
| `scripts/` | 23 支 | `ticket`/`github-arm.sh`/`gitea-bootstrap.sh` … |
|
| `scripts/` | 23 支 | `ticket`/`github-arm.sh`/`gitea-bootstrap.sh` … |
|
||||||
@@ -48,6 +48,10 @@ hook 一律用官方的 `${CLAUDE_PLUGIN_ROOT}`,**不准寫死絕對路徑、
|
|||||||
**不用點開任何 `.sh`**——`docs/hooks-inventory.md` 一支一行白話,按「你會在什麼時候撞到它」分組。
|
**不用點開任何 `.sh`**——`docs/hooks-inventory.md` 一支一行白話,按「你會在什麼時候撞到它」分組。
|
||||||
測試手冊在 `docs/TESTING.md`,治理規範在 `docs/governance/`。
|
測試手冊在 `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)。**
|
**「ISEP 現在是哪一版」只有一個地方答得出來:[Gitea Releases](https://git.uncle6.me/inkstone/ISEP/releases)。**
|
||||||
|
|||||||
+33
-4
@@ -43,12 +43,15 @@ bash scripts/test-release-tag-guard.sh
|
|||||||
**該看到**:`3/3 通過`(1 個該擋、2 個不該擋)。
|
**該看到**:`3/3 通過`(1 個該擋、2 個不該擋)。
|
||||||
**失敗**:該擋的放行(假綠);或不該擋的被擋——**誤攔比漏擋更該修**,誤攔會懲罰謹慎。
|
**失敗**:該擋的放行(假綠);或不該擋的被擋——**誤攔比漏擋更該修**,誤攔會懲罰謹慎。
|
||||||
|
|
||||||
### A4 — 開票側門閘:13 條
|
### A4 — 新增 Gitea 東西的側門閘:24 條
|
||||||
```
|
```
|
||||||
bash scripts/test-ticket-api-bypass-guard.sh
|
bash scripts/test-ticket-api-bypass-guard.sh
|
||||||
```
|
```
|
||||||
**該看到**:`13/13 通過`。
|
**該看到**:`24/24 通過`(前 13 條是 v1 的開票案例;後面是 inkstone/ISEP#72
|
||||||
**失敗**:任何一條不符,特別看「不該擋」那 8 條。
|
擴大範圍後補的:隱式/小寫 POST、milestone/label/PR、org 端點、以及一條打
|
||||||
|
真實 Gitea 網路重演 Arcrun#100 的案例——這台機器的 remote 沒帶憑證時會印
|
||||||
|
`⏭️ SKIP`,不算失敗,但也不算驗過)。
|
||||||
|
**失敗**:任何一條不符,特別看「不該擋」那幾條——誤攔比漏擋更該修。
|
||||||
|
|
||||||
### A11 — 討論串裡的任務要長成子票:22 條
|
### A11 — 討論串裡的任務要長成子票:22 條
|
||||||
```
|
```
|
||||||
@@ -104,6 +107,30 @@ bash hooks/tests/ask-user-question-guard.live.test.sh
|
|||||||
- 📌 這支會隨模型版本漂移,**是量尺不是一次性驗收**。改完判準要連跑三次都全綠才算數
|
- 📌 這支會隨模型版本漂移,**是量尺不是一次性驗收**。改完判準要連跑三次都全綠才算數
|
||||||
(2026-08-26 實測:第一版判準連兩次都在同一題漏擋,收緊 ③④ 定義後三次全綠)
|
(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 — 標籤對齊且冪等
|
### A6 — 標籤對齊且冪等
|
||||||
```
|
```
|
||||||
bash scripts/gitea-labels-sync.sh
|
bash scripts/gitea-labels-sync.sh
|
||||||
@@ -237,11 +264,13 @@ B2(信標那行)/B3(setup 輸出)/B4(閘的訊息)三個畫面
|
|||||||
| A1 manifest 合法 | 總管 | ✅ |
|
| A1 manifest 合法 | 總管 | ✅ |
|
||||||
| A2 版本三處一致 | 總管 | ✅ |
|
| A2 版本三處一致 | 總管 | ✅ |
|
||||||
| A3 打 tag 閘 | 總管 | ✅ 3/3 |
|
| A3 打 tag 閘 | 總管 | ✅ 3/3 |
|
||||||
| A4 開票側門閘 | 總管 | ✅ 13/13 |
|
| **A4 新增 Gitea 東西側門閘** | 總管 | ✅ 24/24(2026-08-27,inkstone/ISEP#72) |
|
||||||
| A5 搜尋跨 repo | 總管 | ✅ |
|
| A5 搜尋跨 repo | 總管 | ✅ |
|
||||||
| A6 標籤對齊+冪等 | 總管 | ✅ 14 repo,第二次 0/0 |
|
| A6 標籤對齊+冪等 | 總管 | ✅ 14 repo,第二次 0/0 |
|
||||||
| **A9 人閘警察管路** | 總管 | ✅ 14/14(2026-08-26) |
|
| **A9 人閘警察管路** | 總管 | ✅ 14/14(2026-08-26) |
|
||||||
| **A10 人閘警察準度** | 總管 | ✅ 9/9,連跑三次(2026-08-26),A 群誤攔 0 |
|
| **A10 人閘警察準度** | 總管 | ✅ 9/9,連跑三次(2026-08-26),A 群誤攔 0 |
|
||||||
|
| **A11 派工單只剩票號** | 總管 | ✅ 19/19(2026-08-27) |
|
||||||
|
| **A12 留言身份欄(兩道門)** | 總管 | ✅ 11/11(2026-08-27) |
|
||||||
| A7 plugin 裝得起來 | 總管 | ✅ |
|
| A7 plugin 裝得起來 | 總管 | ✅ |
|
||||||
| **A8 新 session 閘會觸發** | 總管 | 見本版 release note |
|
| **A8 新 session 閘會觸發** | 總管 | 見本版 release note |
|
||||||
| **B1–B5 雲端** | **leo** | 還沒跑(機器碰不到 Cloud environment) |
|
| **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 身份欄:正門與側門兩道
|
||||||
|
```
|
||||||
|
|
||||||
|
測資裡放的是**真的發生過的那幾份違規派工單**,不是想像出來的例子。
|
||||||
@@ -274,9 +274,9 @@ s/triage ──驗傷通過──▶ s/backlog ──排進 milestone──▶ s
|
|||||||
| E9 | 留言變成推理長文 | hook:超長**不 reject**,要求移去 wiki 再留指標 | ❌ 待建(放寬版,見 §8.3) |
|
| E9 | 留言變成推理長文 | hook:超長**不 reject**,要求移去 wiki 再留指標 | ❌ 待建(放寬版,見 §8.3) |
|
||||||
| E10 | 就地修改治理檔案 | 安裝目錄唯讀 + hook,導向 ISEP 開 issue | ❌ 待建 |
|
| E10 | 就地修改治理檔案 | 安裝目錄唯讀 + hook,導向 ISEP 開 issue | ❌ 待建 |
|
||||||
| E11 | 本機雲端版本漂移 | SessionStart hook 比對版本,不一致 fail-fast | ◐ 現有 `skill-deploy-drift-guard.sh` 管的是 skill 不是 plugin 版本 |
|
| E11 | 本機雲端版本漂移 | SessionStart hook 比對版本,不一致 fail-fast | ◐ 現有 `skill-deploy-drift-guard.sh` 管的是 skill 不是 plugin 版本 |
|
||||||
| E12 | 宣稱交付但沒有 release | 版本三處一致閘:`plugin.json` = tag = release 存在;且 milestone open issues > 0 不准打 tag | ◐ 現有 `delivery-police.sh`/`claim-verify-police.sh` 部分覆蓋 |
|
| E12 | 宣稱交付但沒有 release | 版本三處一致閘:`plugin.json` = tag = release 存在;且 milestone open issues > 0 不准打 tag | ◐ 現有 `delivery-police.sh` 部分覆蓋(`claim-verify-police.sh` 已於 `ISEP#60` 移除,見下方腳註) |
|
||||||
| E13 | 審核被遺忘 | Stop hook:**我自己開的** `s/review` 存在且**這回合完全沒碰它** → 擋一次 | ◐ 現有 `empty-handed-stop-guard.sh` 判準不同,兩者要合併不要疊加 |
|
| E13 | 審核被遺忘 | Stop hook:**我自己開的** `s/review` 存在且**這回合完全沒碰它** → 擋一次 | ◐ 現有 `empty-handed-stop-guard.sh` 判準不同,兩者要合併不要疊加 |
|
||||||
| E14 | Subagent 空口宣稱完成 | SubagentStop hook | ✅ **已有** `subagent-claim-worksheet.sh` + `empty-handed-stop-guard.sh` |
|
| E14 | Subagent 空口宣稱完成 | SubagentStop hook | ◐ 原本 `subagent-claim-worksheet.sh` + `empty-handed-stop-guard.sh`;前者已於 `ISEP#60`(2026-08-27)移除,見下方腳註 |
|
||||||
| E15 | 代理硬做只有人能做的事 | 代理端無對應 tool 或 token;唯一出口是開 `human/exec` 票 | ◐ 部分(credential 機制已擋一部分,D36) |
|
| E15 | 代理硬做只有人能做的事 | 代理端無對應 tool 或 token;唯一出口是開 `human/exec` 票 | ◐ 部分(credential 機制已擋一部分,D36) |
|
||||||
| E16 | 標籤漂移 | 走 Gitea labels API 依 `labels.yaml` 校正:缺的補、改的還原、多的**只告警不刪** | ❌ 待建(`ISEP#4`) |
|
| E16 | 標籤漂移 | 走 Gitea labels API 依 `labels.yaml` 校正:缺的補、改的還原、多的**只告警不刪** | ❌ 待建(`ISEP#4`) |
|
||||||
|
|
||||||
@@ -312,6 +312,21 @@ leo 2026-08-17 實測:文字層的閘那天 **8 次誤攔、0 次正確攔截*
|
|||||||
**紅線寫得越細,命中關鍵字的機率越高 ⇒ 那些閘在懲罰謹慎。**
|
**紅線寫得越細,命中關鍵字的機率越高 ⇒ 那些閘在懲罰謹慎。**
|
||||||
所以 E9 擋的是「長內容放錯地方」這個**動作**,處置是**導引**(移去 wiki 留指標),不是 reject。
|
所以 E9 擋的是「長內容放錯地方」這個**動作**,處置是**導引**(移去 wiki 留指標),不是 reject。
|
||||||
|
|
||||||
|
### 8.4 E12/E14:自造的「待驗單」機制已移除,改由 §16 原生三格承接(`ISEP#60`)
|
||||||
|
|
||||||
|
`subagent-claim-worksheet.sh`(SubagentStop 產待驗單)與 `claim-verify-police.sh`
|
||||||
|
(Stop 攔收工,待驗單還在就擋)這一對於 2026-08-27 整組移除。
|
||||||
|
|
||||||
|
- **為什麼不是修它**:症狀是同一份待驗單重生三次、同內容兩個檔名;
|
||||||
|
但真正的問題是 D58——**待驗單是一張 markdown,沒有狀態、沒有持有人、沒有人會回頭看**,
|
||||||
|
必然退化成雜訊。Gitea 的 issue 三格本來就是為這件事做的。
|
||||||
|
- **它守的東西現在由誰守**:見 §16(`ISEP#59`/PR #58)——子票相依回答「這件事存在嗎/做完了嗎」、
|
||||||
|
`s/*` tag 回答「它卡在哪一段」、指派回答「現在誰該動」。三格合起來覆蓋原本 E14
|
||||||
|
想擋的「subagent 空口宣稱」與 E12 想擋的「宣稱交付但沒人跟進」。
|
||||||
|
- **現況誠實標記**:PR #58(§16 的落地)**尚未 merge**,所以 E12/E14 這一格目前是
|
||||||
|
「舊機制已拆、新機制已提案但未生效」的過渡態,不是「已有替代品在跑」。
|
||||||
|
#58 merge 前,這兩條暫時回到 ❌ 待建的實質狀態,即使表格欄位寫的是 ◐。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 9. 本規範的迭代
|
## 9. 本規範的迭代
|
||||||
|
|||||||
+90
-12
@@ -7,15 +7,28 @@
|
|||||||
|
|
||||||
## 一句話結論
|
## 一句話結論
|
||||||
|
|
||||||
`hooks/` 底下有 **48 個 `.sh` 檔**,`hooks.json` 實際掛上 **58 條註冊**(同一支閘常被多種情境同時掛上);
|
`hooks/` 底下有 **48 個 `.sh` 檔**,`hooks.json` 實際掛上 **59 條註冊**(同一支閘常被多種情境同時掛上);
|
||||||
其中 **3 支檔案存在但沒被掛上**(2 支是待人填的空範本、1 支是刻意留著沒開的止血帶,見下面「未生效」表)。
|
其中 **3 支檔案存在但沒被掛上**(2 支是待人填的空範本、1 支是刻意留著沒開的止血帶,見下面「未生效」表)。
|
||||||
下面按「你會在什麼時候撞到它」分組,一支一行。
|
下面按「你會在什麼時候撞到它」分組,一支一行。
|
||||||
|
|
||||||
> 🔴 **這兩個數字上一版是錯的(2026-08-26 實際數過才發現)**:本頁原本寫「43 個檔、53 條註冊」,
|
> 🔴 **這兩個數字上一版是錯的(2026-08-26 實際數過才發現)**:本頁原本寫「43 個檔、53 條註冊」,
|
||||||
> 而當時真實是 **45 個檔、55 條註冊**——中間有兩支閘進來時沒有回頭改這裡。
|
> 而當時真實是 **45 個檔、55 條註冊**——中間有兩支閘進來時沒有回頭改這裡。
|
||||||
> 現在的寫法是實際數出來的:
|
> 現在的寫法是實際數出來的:
|
||||||
> `ls hooks/*.sh | wc -l` = 48;`hooks.json` 展開後的 command 條目 = 58。
|
> `ls hooks/*.sh | wc -l` = 48;`grep -c '"command":' hooks/hooks.json` = 59。
|
||||||
|
> ⚠️ **冒號不能省**:`grep -c '"command"'`(沒冒號)會連 `"type": "command"` 一起數到,回 **118**。
|
||||||
|
> 本頁 2026-08-27 之前寫的是沒冒號那版——**照著它跑會拿到一個跟本頁不符的數字**。
|
||||||
> **一份會偷偷過期的盤點表,跟沒有盤點表差不多**——見本頁最後「怎麼跟實況對帳」那段。
|
> **一份會偷偷過期的盤點表,跟沒有盤點表差不多**——見本頁最後「怎麼跟實況對帳」那段。
|
||||||
|
>
|
||||||
|
> 📌 **`0.6.0`(`inkstone/ISEP#59`,2026-08-27)這兩個數字是併完之後當場數出來的**:
|
||||||
|
> `ISEP#60` 移除了 `claim-verify-police.sh`/`subagent-claim-worksheet.sh` 這對自造機制(−2 支、−16 條),
|
||||||
|
> `ISEP#58` 補進 `comment-carries-task-guard.sh`/`baton-handback-guard.sh`(+2 支、+8 條),
|
||||||
|
> 加上 `ISEP#72` 的 `reply-identity-guard.sh` 已在 main 上。
|
||||||
|
> 一加一減之後檔數不變、註冊條數落在 59——**這正是「不能靠加減推算、只能實數」的例子**:
|
||||||
|
> 上一版寫 58,就是把兩批改動的加減兜出來、沒有真的數過。
|
||||||
|
>
|
||||||
|
> 🔴 **`0.6.1`(2026-08-27)補回表格漏掉的兩列**:`isep-presence-beacon.sh`(E 組)、
|
||||||
|
> `milestone-due-guard.sh`(A 組)。**標頭寫 48 支的同時,下面的表格只列得出 46 支**
|
||||||
|
> ——數字是對的,人話少了兩行。發現的方法與防它再犯的指令,見下面「落差偵測」第二組 `comm`。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -43,7 +56,9 @@
|
|||||||
| `not-my-branch-guard.sh` | AI 想 commit 到「別的 subagent 正在施工中」的分支(登記在 `branch-holds.md`)就擋下,避免兩個 AI 互踩同一條分支。 | 🛑 擋 |
|
| `not-my-branch-guard.sh` | AI 想 commit 到「別的 subagent 正在施工中」的分支(登記在 `branch-holds.md`)就擋下,避免兩個 AI 互踩同一條分支。 | 🛑 擋 |
|
||||||
| `leo21c-write-guard.sh` | AI 想寫東西到你私人帳號 `leo21c` 就擋下——這個帳號只准你自己用,AI 的實驗一律走別的測試帳號。 | 🛑 擋 |
|
| `leo21c-write-guard.sh` | AI 想寫東西到你私人帳號 `leo21c` 就擋下——這個帳號只准你自己用,AI 的實驗一律走別的測試帳號。 | 🛑 擋 |
|
||||||
| `release-tag-guard.sh` | AI 想打版本 tag,但 `plugin.json` 裡寫的版本號跟 tag 對不上就擋下——避免「這裡說 0.2.0、那裡說 0.1.0」各說各話。 | 🛑 擋 |
|
| `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 票(會漏掉「開票前先搜過」這道檢查)就擋下。 | 🛑 擋 |
|
| `ticket-api-bypass-guard.sh` | AI 想繞過 `scripts/ticket` 工具、直接打 API 新增 Gitea 東西(開新票/milestone/label/PR,會漏掉「新增前先搜過」這道檢查)就擋下——不管是隱式 POST(urllib 傳 data= 沒寫 method)還是小寫 `requests.post(...)`。 | 🛑 擋 |
|
||||||
|
| `milestone-due-guard.sh` | AI 想開一個新的里程碑(milestone)卻沒填期限就擋下,**填 `9999-01-01` 一樣擋**——那是「沒有期限」穿了一件期限的衣服,盤點時每一格看起來都有值,其實一格都沒有。只管「建里程碑」這個動作,查詢/改別的欄位都放行。 | 🛑 擋 |
|
||||||
|
| `reply-identity-guard.sh` | 直接打 Gitea API 貼留言到票上時,內文第一行沒有 `【身份】`(`總管`/`subagent`/`leo`)就擋下——票上多條線並行時,看不出哪一則是誰寫的。純讀取、走 `scripts/ticket` 正門、指令裡加 `reply-identity-ok` 都放行。 | 🛑 擋 |
|
||||||
| `comment-carries-task-guard.sh` | AI 要在票裡留一則「等某某上線才驗得了」這種**還沒做完的事**,卻沒有把它開成一張子票時,擋一次。留言沒人會回頭讀,票的 open/closed 撈一次就看得到。 | 🛑 擋(同一輪只擋一次) |
|
| `comment-carries-task-guard.sh` | AI 要在票裡留一則「等某某上線才驗得了」這種**還沒做完的事**,卻沒有把它開成一張子票時,擋一次。留言沒人會回頭讀,票的 open/closed 撈一次就看得到。 | 🛑 擋(同一輪只擋一次) |
|
||||||
|
|
||||||
## B. AI 想寫檔案/改程式碼的當下(PreToolUse / Write·Edit·MultiEdit)
|
## B. AI 想寫檔案/改程式碼的當下(PreToolUse / Write·Edit·MultiEdit)
|
||||||
@@ -77,6 +92,7 @@
|
|||||||
| `micromanage-guard.sh` | 派工單寫得太細(指名檔案函式、編號步驟、要求每做一項回報一次…)就擋下——subagent 該被當成有能力的同事,不是照抄劇本的工具。 | 🛑 擋 |
|
| `micromanage-guard.sh` | 派工單寫得太細(指名檔案函式、編號步驟、要求每做一項回報一次…)就擋下——subagent 該被當成有能力的同事,不是照抄劇本的工具。 | 🛑 擋 |
|
||||||
| `irreversible-dispatch-guard.sh` | 派工單裡出現「刪分支」「drop table」「rm -rf」這類不可逆動作,卻沒寫「先停下來等回覆才執行」就擋下。 | 🛑 擋 |
|
| `irreversible-dispatch-guard.sh` | 派工單裡出現「刪分支」「drop table」「rm -rf」這類不可逆動作,卻沒寫「先停下來等回覆才執行」就擋下。 | 🛑 擋 |
|
||||||
| `no-ticket-no-dispatch.sh` | 派工單裡沒有寫工單號(`【工單】owner/repo#N`),或那張票已經關閉/根本不存在,就擋下——沒有票號的工作沒有人追得到進度。 | 🛑 擋 |
|
| `no-ticket-no-dispatch.sh` | 派工單裡沒有寫工單號(`【工單】owner/repo#N`),或那張票已經關閉/根本不存在,就擋下——沒有票號的工作沒有人追得到進度。 | 🛑 擋 |
|
||||||
|
| `dispatch-format-guard.sh` | **派工單 = 票號,就這樣。** 票號以外還寫了別的東西(背景、驗收條件、紅線、交件方式、「這個 session 才知道的事」)就擋下,並告訴你那些內容該搬去哪:每次都一樣的 → 共通規定;這次才知道的 → **寫進那張票**。反過來,合規的派工它會**自動把共通規定注入**給收工方(交件方式、不准 push main、org 是 `inkstone`…),所以你不寫也不會漏。判準是「這一行是不是【工單】欄位」——**在不在**,不是寫了什麼,所以講得再謹慎也不會被多罰。純禮貌收尾(「謝謝」)不算違規;`(→ comment M)` 不論全形或半形括號都認得(ISEP#65 修過一次:CLAUDE.md 自己規定的合格寫法曾經被這支閘自己擋下)。 | 🛑 擋(+注入) |
|
||||||
|
|
||||||
## D2. AI 想開口問你問題的當下(PreToolUse / AskUserQuestion)
|
## D2. AI 想開口問你問題的當下(PreToolUse / AskUserQuestion)
|
||||||
|
|
||||||
@@ -94,6 +110,7 @@
|
|||||||
|
|
||||||
| 閘名 | 對你意味著什麼 | 動作 |
|
| 閘名 | 對你意味著什麼 | 動作 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
| `isep-presence-beacon.sh` | 對話一開始印一行 `🟢 ISEP vX.Y.Z 已載入(N 支閘|來源:…)`。**這行不是裝飾,是唯一能證明「這個 session 真的有閘」的東西**——它自己就住在 plugin 裡,看得到它就表示 plugin 載入了;某個 session 從頭到尾沒有這行,那個 session 是零閘狀態,先修 plugin 再做事。同一台機器可能同時有兩份 ISEP(marketplace 裝的、repo 裡 vendor 的),所以那行會講出這次是哪一份在說話。 | 📝 記錄(context 注入) |
|
||||||
| `session-start-recall.sh` | 對話一開始就自動把「全局現況」(Gitea 各 repo 的票、KBDB 的藏書地圖)推到 AI 眼前,不必等它自己想到要查。 | 📝 記錄(context 注入) |
|
| `session-start-recall.sh` | 對話一開始就自動把「全局現況」(Gitea 各 repo 的票、KBDB 的藏書地圖)推到 AI 眼前,不必等它自己想到要查。 | 📝 記錄(context 注入) |
|
||||||
| `skill-deploy-drift-guard.sh` | 如果「全機真正在用的 skill」跟「repo 裡版控的正本」內容對不上,就在開場講出來——避免用著一份沒人知道已經跟正本分家的舊拷貝。 | 📝 記錄 |
|
| `skill-deploy-drift-guard.sh` | 如果「全機真正在用的 skill」跟「repo 裡版控的正本」內容對不上,就在開場講出來——避免用著一份沒人知道已經跟正本分家的舊拷貝。 | 📝 記錄 |
|
||||||
|
|
||||||
@@ -110,15 +127,17 @@
|
|||||||
| `delivery-police.sh` | AI 宣稱「這件事做完了」,卻看不到任何實測證據(畫面截圖、指令輸出、HTTP 狀態碼…)就擋下。 | 🛑 擋 |
|
| `delivery-police.sh` | AI 宣稱「這件事做完了」,卻看不到任何實測證據(畫面截圖、指令輸出、HTTP 狀態碼…)就擋下。 | 🛑 擋 |
|
||||||
| `wiki-first-police.sh` | AI 做完事卻沒有把結論寫回 wiki 就想收工,就擋下——下次(或別的 AI)查 wiki 會查不到這次做過什麼。 | 🛑 擋 |
|
| `wiki-first-police.sh` | AI 做完事卻沒有把結論寫回 wiki 就想收工,就擋下——下次(或別的 AI)查 wiki 會查不到這次做過什麼。 | 🛑 擋 |
|
||||||
| `unpushed-police.sh` | AI 改好的東西還留在本機、沒有真的推送出去給別人用,卻想收工,就擋下——「改對了但沒送到」跟沒改是一樣的。 | 🛑 擋 |
|
| `unpushed-police.sh` | AI 改好的東西還留在本機、沒有真的推送出去給別人用,卻想收工,就擋下——「改對了但沒送到」跟沒改是一樣的。 | 🛑 擋 |
|
||||||
| `claim-verify-police.sh` | Subagent 交回來的「我做完了」宣稱還沒被驗證過(對應的待驗檔案還在),你這邊卻想收工,就擋下。 | 🛑 擋 |
|
|
||||||
|
> 🔴 **`claim-verify-police.sh` 已於 `inkstone/ISEP#60` 移除**(連同下面原本的 G 組 `subagent-claim-worksheet.sh`)。
|
||||||
|
> 這一對是自造的「待驗單」機制(一張沒有狀態、沒有持有人的 markdown),違反 D58:
|
||||||
|
> 不要硬做平台不支援的機制。它守的情境現在改由 Gitea 原生三格承接
|
||||||
|
> (子票相依/`s/*` tag/指派——見 `inkstone/ISEP#59` 與 PR #58)。
|
||||||
|
|
||||||
## G. Subagent 把工作交回來的時候(SubagentStop)
|
## G. Subagent 把工作交回來的時候(SubagentStop)
|
||||||
|
|
||||||
| 閘名 | 對你意味著什麼 | 動作 |
|
`inkstone/ISEP#60` 之後,這個時機**沒有專屬掛載的閘**了(原本唯一的一支 `subagent-claim-worksheet.sh` 已移除)。
|
||||||
|---|---|---|
|
仍會掛到這個時機的是 `worklist-guard.sh`/`self-drive-police.sh`/`self-drive-judge.sh`/`delivery-police.sh`/`wiki-first-police.sh`/`unpushed-police.sh`,
|
||||||
| `subagent-claim-worksheet.sh` | Subagent 一交回工作,就自動把它宣稱做了什麼寫成一張「待驗清單」檔案——之後總管收工前,`claim-verify-police.sh` 會檢查這張清單有沒有被處理掉。 | 📝 記錄 |
|
行為跟上面 F 組完全一樣,只是對象換成「subagent 交回來的這一輪」。詳見「重複掛載」一節。
|
||||||
|
|
||||||
> 這個時機還掛了 `worklist-guard.sh`/`self-drive-police.sh`/`self-drive-judge.sh`/`delivery-police.sh`/`wiki-first-police.sh`/`unpushed-police.sh`,行為跟上面 F 組完全一樣,只是對象換成「subagent 交回來的這一輪」。詳見「重複掛載」一節。
|
|
||||||
|
|
||||||
## H. 動作做完之後,純粹記一筆(PostToolUse,全部不擋)
|
## H. 動作做完之後,純粹記一筆(PostToolUse,全部不擋)
|
||||||
|
|
||||||
@@ -166,7 +185,62 @@ $ comm -13 /tmp/fs_hooks.txt /tmp/registered_hooks.txt
|
|||||||
(無輸出)
|
(無輸出)
|
||||||
```
|
```
|
||||||
|
|
||||||
**這次順便抓到的另一個落差**:`.claude-plugin/plugin.json` 的說明文字寫「42 支機械閘(52 條註冊)」,
|
🔴 **上面那兩行驗不出「表格漏列」**——它們比的是 `hooks.json`,不是這張表。
|
||||||
|
「有閘、但這頁沒有它那一行人話」要另外一組指令才看得見,就是下面這組
|
||||||
|
(2026-08-27 就是靠它抓到本頁漏了 `isep-presence-beacon.sh`/`milestone-due-guard.sh` 兩列):
|
||||||
|
|
||||||
|
```
|
||||||
|
$ ls hooks/*.sh | xargs -n1 basename | sort > /tmp/inv_fs.txt
|
||||||
|
$ grep -oE '^\| `[a-zA-Z0-9_.-]+\.sh`' docs/hooks-inventory.md \
|
||||||
|
| grep -oE '[a-zA-Z0-9_.-]+\.sh' | sort -u > /tmp/inv_table.txt
|
||||||
|
|
||||||
|
# 有這支閘、但這頁沒有它那一列 → 要補人話
|
||||||
|
$ comm -23 /tmp/inv_fs.txt /tmp/inv_table.txt
|
||||||
|
(無輸出)
|
||||||
|
|
||||||
|
# 這頁列了、但檔案已經不在了 → 要刪那一列
|
||||||
|
$ comm -13 /tmp/inv_fs.txt /tmp/inv_table.txt
|
||||||
|
(無輸出)
|
||||||
|
```
|
||||||
|
|
||||||
|
🔴 **`^\| \`` 這個錨點不能拿掉**——它的意思是「**這一行的第一欄就是這支閘**」,
|
||||||
|
也就是「表格真的列了它」。把錨點拿掉、改成抓全頁所有反引號裡的 `*.sh`,
|
||||||
|
反向那半會吐出 **3 個假警報**(2026-08-27 實跑):
|
||||||
|
|
||||||
|
```
|
||||||
|
claim-verify-police.sh ← 出現在「已於 ISEP#60 移除」那段**移除紀錄**裡
|
||||||
|
subagent-claim-worksheet.sh ← 同上
|
||||||
|
github-arm.sh ← 它在 `scripts/`,不在 `hooks/`;是 github-contact-guard
|
||||||
|
那一列裡告訴你「怎麼解鎖」的逃生口
|
||||||
|
```
|
||||||
|
|
||||||
|
**三個都不是幽靈,三個都不該刪**:刪掉前兩個等於刪掉「G 組為什麼是空的」這段歷史,
|
||||||
|
刪掉第三個等於讓那一列不再告訴你怎麼過閘。
|
||||||
|
⇒ **0 次正確攔截、3 次誤攔**——照這個訊號去改,會把好東西改壞。
|
||||||
|
|
||||||
|
那「頁面上出現了一個根本不存在的檔名」怎麼辦?**那要另外一格量,而且判準不是文字是事實**:
|
||||||
|
|
||||||
|
```
|
||||||
|
# C. 頁面提到的每個 *.sh,現在存在,或者 git 歷史裡存在過
|
||||||
|
$ for n in $(grep -oE '`[a-zA-Z0-9._-]+\.sh`' docs/hooks-inventory.md | tr -d '`' | sort -u); do
|
||||||
|
[ -f "hooks/$n" ] || [ -f "scripts/$n" ] && continue
|
||||||
|
[ -n "$(git log --all --oneline --diff-filter=AD -- "hooks/$n" "scripts/$n")" ] && continue
|
||||||
|
echo "$n"
|
||||||
|
done
|
||||||
|
(無輸出)
|
||||||
|
```
|
||||||
|
|
||||||
|
**「現在沒有、但曾經有」=歷史,合法;「從來沒有過」=打錯字或憑空捏造,才是幽靈。**
|
||||||
|
這一格問的是 git,不是問這頁的用字,所以它不會因為誰換個寫法就失效。
|
||||||
|
|
||||||
|
📌 **三格都要跑,而且它們量的是不同東西**:
|
||||||
|
A1 缺人話、A2 表格有幽靈、C 全頁有幽靈。
|
||||||
|
**標頭的 48/59 對不代表這頁沒漏**——補進 2 支真的、同時留著 2 支假的,數字照樣湊得到 48。
|
||||||
|
**數量相等不等於集合相等**,所以驗的一律是集合(`comm`),不是 `wc -l`。
|
||||||
|
|
||||||
|
**這次順便抓到的另一個落差**(🕐 **這一段是 2026-08-20 的快照,裡面每個數字都是那天的,不是現況**——
|
||||||
|
留著是因為它是「兩份文件的數字不會自己跟上」這個病的第一個案例;現況數字看本頁標頭):
|
||||||
|
`.claude-plugin/plugin.json` 的說明文字寫「42 支機械閘(52 條註冊)」,
|
||||||
`README.md` 也寫「42 支 + `hooks.json`」「共 52 條註冊」——但實測是 **43 支檔案、53 條註冊**。
|
`README.md` 也寫「42 支 + `hooks.json`」「共 52 條註冊」——但實測是 **43 支檔案、53 條註冊**。
|
||||||
差 1 支、差 1 條,猜測是今天(2026-08-20)新增的 `leo21c-write-guard.sh`/`release-tag-guard.sh`/
|
差 1 支、差 1 條,猜測是今天(2026-08-20)新增的 `leo21c-write-guard.sh`/`release-tag-guard.sh`/
|
||||||
`ticket-api-bypass-guard.sh` 這批(檔頭日期都是今天)加了之後,兩份文件的數字沒有跟著更新。
|
`ticket-api-bypass-guard.sh` 這批(檔頭日期都是今天)加了之後,兩份文件的數字沒有跟著更新。
|
||||||
@@ -221,12 +295,16 @@ $ comm -13 /tmp/fs_hooks.txt /tmp/registered_hooks.txt
|
|||||||
- **`micromanage-guard.sh`/`irreversible-dispatch-guard.sh`/`no-ticket-no-dispatch.sh` 的雙重矩比對(`Task` + `Agent`)是刻意保險還是舊名稱沒清掉**——見上面「重複掛載」段落,我沒有把握單靠讀檔案判斷,需要看實際觸發紀錄或問總管。
|
- **`micromanage-guard.sh`/`irreversible-dispatch-guard.sh`/`no-ticket-no-dispatch.sh` 的雙重矩比對(`Task` + `Agent`)是刻意保險還是舊名稱沒清掉**——見上面「重複掛載」段落,我沒有把握單靠讀檔案判斷,需要看實際觸發紀錄或問總管。
|
||||||
- **`stage-before-prod-guard.sh` 跟 `main-and-prod-push-guard.sh` 的分工邊界**:`main-and-prod-push-guard.sh` 的檔頭明講自己是在「補 `stage-before-prod-guard.sh` 的破口」(那支只認 3 個關鍵字,抓不到 `wrangler deploy`),但兩支都還掛著、都還在管「prod 出貨」這件事。這是「新的補洞、舊的continua」還是「舊的該退休了」,這份盤點表不下判斷,留給你在下一步的分類會議裡定奪。
|
- **`stage-before-prod-guard.sh` 跟 `main-and-prod-push-guard.sh` 的分工邊界**:`main-and-prod-push-guard.sh` 的檔頭明講自己是在「補 `stage-before-prod-guard.sh` 的破口」(那支只認 3 個關鍵字,抓不到 `wrangler deploy`),但兩支都還掛著、都還在管「prod 出貨」這件事。這是「新的補洞、舊的continua」還是「舊的該退休了」,這份盤點表不下判斷,留給你在下一步的分類會議裡定奪。
|
||||||
|
|
||||||
除了以上兩點,其餘 41 支的行為都能從檔頭與源碼直接讀出,沒有「猜」的部分。
|
除了以上兩點,其餘各支的行為都能從檔頭與源碼直接讀出,沒有「猜」的部分。
|
||||||
|
|
||||||
|
> 📌 這句話原本寫「其餘 **41** 支」——那是 43 支閘那一版(`v0.2.1`)數的,之後沒有人重跑過。
|
||||||
|
> 2026-08-27 把數字拿掉而不是改成 46:**改成 46 等於宣稱我逐支重讀過源碼,而我沒有。**
|
||||||
|
> 要恢復一個數字,就重跑一次上面「抽驗」那件事,然後把當天的日期寫上去。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 這份表怎麼跟實況對帳(半年後怎麼發現漂移)
|
## 這份表怎麼跟實況對帳(半年後怎麼發現漂移)
|
||||||
|
|
||||||
1. **有沒有新閘沒被收進這張表**:跑本文「落差偵測」段落的兩行 `comm` 指令,比對 `hooks/*.sh` 的檔名清單跟這張表列出的閘名清單(不是跟 `hooks.json`,那個只驗證有沒有註冊,驗不了有沒有寫進這張人話表)。
|
1. **有沒有新閘沒被收進這張表**:跑本文「落差偵測」段落**第二組**(`inv_fs` vs `inv_table` 的兩行 `comm`)**加第三格 C**,比對 `hooks/*.sh` 的檔名清單跟這張表列出的閘名清單。**不是第一組**——第一組比的是 `hooks.json`,只驗證有沒有註冊,驗不了有沒有寫進這張人話表。2026-08-27 漏掉的那兩列,第一組是綠的、第二組才紅。**抓表格列的那組 `grep` 一定要帶 `^\| \`` 錨點**,理由與拿掉之後會冒出的 3 個假警報,就寫在那一段裡。
|
||||||
2. **有沒有閘的行為跟這裡寫的不一樣了**:抽幾支重新讀一次源碼,跟這裡的「一句話」對一遍——就是本文「抽驗 5 支」做的事,可以照同樣方法定期重做。
|
2. **有沒有閘的行為跟這裡寫的不一樣了**:抽幾支重新讀一次源碼,跟這裡的「一句話」對一遍——就是本文「抽驗 5 支」做的事,可以照同樣方法定期重做。
|
||||||
3. **`.claude-plugin/plugin.json` 與 `README.md` 的數字**:這兩處各自寫了一次「幾支、幾條註冊」,前面已經抓到一次對不上(42/52 vs 實際 43/53)。這兩個數字沒有機制保證跟著 `hooks/` 目錄自動更新,是本表發現的第一個具體漂移案例。
|
3. **`.claude-plugin/plugin.json` 與 `README.md` 的數字**:這兩處各自寫了一次「幾支、幾條註冊」,前面已經抓到一次對不上(42/52 vs 實際 43/53)。這兩個數字沒有機制保證跟著 `hooks/` 目錄自動更新,是本表發現的第一個具體漂移案例。
|
||||||
|
|||||||
@@ -1,82 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# claim-verify-police.sh — 待驗工作單還在,就不准收工(Stop;leo 2026-08-18 立)
|
|
||||||
#
|
|
||||||
# 這支是 subagent-claim-worksheet.sh 的另一半。
|
|
||||||
# 那支在 SubagentStop 時把 subagent 的宣稱寫成一張檔案;這支在我要停下時檢查:
|
|
||||||
#
|
|
||||||
# **那張檔還在不在?**
|
|
||||||
#
|
|
||||||
# ── 為什麼判準是「檔案存不存在」 ────────────────────────────────────
|
|
||||||
# leo 2026-08-17:「你在**文字層**封路⋯⋯自然語言的變體是無限的,blacklist 永遠追不完。
|
|
||||||
# 封路哲學之所以有效,是因為它封的是**動作**——動作有限且可枚舉。」
|
|
||||||
# ⇒ 「我驗過了」這句話可以有無限種寫法,但**檔案只能被動作刪掉**。
|
|
||||||
# 所以這支不讀我的措辭,只看磁碟。
|
|
||||||
#
|
|
||||||
# ── 怎麼過這道閘(都是動作,都留痕)─────────────────────────────────
|
|
||||||
# ① 真的驗了 → 把證據寫進回覆/票,然後刪掉那個檔
|
|
||||||
# ② 有幾條不需要驗 → 在檔裡寫下理由,再刪掉(理由留在 git 或票上)
|
|
||||||
# ③ 是人閘 → 走三件機械動作(Human + 指派 + 它自己的票),再刪掉
|
|
||||||
#
|
|
||||||
# 🔴 **不准為了過閘而直接 rm 掉卻什麼都沒做。** 那等於自己拆掉自己的閘——
|
|
||||||
# 而這正是 leo 2026-08-10 說的「規則早就存在,但沒有任何機制驗證有沒有照做」。
|
|
||||||
#
|
|
||||||
# ── 迴圈安全 ────────────────────────────────────────────────────
|
|
||||||
# stop_hook_active 為真 ⇒ 已經擋過一次,放行。至多提醒一次,不卡死。
|
|
||||||
#
|
|
||||||
# ── 留痕 ────────────────────────────────────────────────────────
|
|
||||||
INPUT=$(cat)
|
|
||||||
DIR="$CLAUDE_PROJECT_DIR/.claude/pending-verification"
|
|
||||||
LOG="$CLAUDE_PROJECT_DIR/.claude/hooks/claim-verify-police.log"
|
|
||||||
|
|
||||||
ACTIVE=$(printf '%s' "$INPUT" | python3 -c '
|
|
||||||
import json,sys
|
|
||||||
try: print("1" if json.load(sys.stdin).get("stop_hook_active") else "0")
|
|
||||||
except Exception: print("0")
|
|
||||||
' 2>/dev/null)
|
|
||||||
|
|
||||||
if [ "$ACTIVE" = "1" ]; then
|
|
||||||
printf '%s\tSKIP:already-nudged\n' "$(date +%FT%T)" >> "$LOG" 2>/dev/null
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 只算「今天以內」的單子——放太久的(跨 session 殘留)不該無限期卡住新工作,
|
|
||||||
# 但仍會被列出來提醒。
|
|
||||||
FILES=$(ls -1 "$DIR"/claims-*.md 2>/dev/null)
|
|
||||||
if [ -z "$FILES" ]; then
|
|
||||||
printf '%s\tSKIP:no-pending\n' "$(date +%FT%T)" >> "$LOG" 2>/dev/null
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
N=$(printf '%s\n' "$FILES" | grep -c . )
|
|
||||||
printf '%s\tBLOCK:%s-pending\n' "$(date +%FT%T)" "$N" >> "$LOG" 2>/dev/null
|
|
||||||
|
|
||||||
{
|
|
||||||
echo "🔍 查證警察:有 $N 張**待驗工作單**還沒處理掉,不准收工。"
|
|
||||||
echo
|
|
||||||
echo "【leo 2026-08-18 的規格】"
|
|
||||||
echo " 「subagent 做完事⋯⋯**它說可以,你去驗證;它說不行,你想辦法,不行再報告**,"
|
|
||||||
echo " 這解決一半的未查證。你常常直接把 subagent 說的不經查證就回報,"
|
|
||||||
echo " **它視野小,它說的你有更多資訊去檢驗。**」"
|
|
||||||
echo
|
|
||||||
echo "━━ 還沒處理的單子 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
|
||||||
printf '%s\n' "$FILES" | while read -r f; do
|
|
||||||
[ -n "$f" ] || continue
|
|
||||||
OKN=$(grep -c '^- \[ \]' "$f" 2>/dev/null)
|
|
||||||
echo " · ${f#"$CLAUDE_PROJECT_DIR"/} ($OKN 條待處理)"
|
|
||||||
done
|
|
||||||
echo
|
|
||||||
echo "━━ 怎麼過這道閘(三選一,都是動作)━━━━━━━━━━━━━━━━━━"
|
|
||||||
echo " ① **它說可以** → 你自己跑一次(測試/curl/瀏覽器/git),把證據貼出來,再刪掉那個檔"
|
|
||||||
echo " ② **它說不行** → 先換路徑、查憑證地圖;要說「被擋」就得**自己撞一次並貼拒絕原文**"
|
|
||||||
echo " ③ **真的是人閘** → 三件機械動作:\`Human\` 標籤 + 指派給 Leo + **一張它自己的票**"
|
|
||||||
echo " (leo 08-18:「要進入人閘,標示 human 叫我去開,**不是默默塞進錯的地方**」)"
|
|
||||||
echo
|
|
||||||
echo "🔴 **不准只是 rm 掉了事。** 那是自己拆掉自己的閘——"
|
|
||||||
echo " 正是 leo 08-10 講的「規則早就存在,但沒有任何機制驗證有沒有照做」。"
|
|
||||||
echo
|
|
||||||
echo "📌 為什麼有這道閘:2026-08-18 總管連續三次把**沒驗過的判斷**當成情報交出去,"
|
|
||||||
echo " 其中第二次還是為了「更正」第一次而發明的新猜測。"
|
|
||||||
echo " leo:「你給我不正確的資訊⋯⋯subagent 說不通你沒有驗。」"
|
|
||||||
} >&2
|
|
||||||
|
|
||||||
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
|
||||||
+12
-16
@@ -48,6 +48,10 @@
|
|||||||
{
|
{
|
||||||
"type": "command",
|
"type": "command",
|
||||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/milestone-due-guard.sh"
|
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/milestone-due-guard.sh"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/reply-identity-guard.sh"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -136,6 +140,10 @@
|
|||||||
{
|
{
|
||||||
"type": "command",
|
"type": "command",
|
||||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/no-ticket-no-dispatch.sh"
|
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/no-ticket-no-dispatch.sh"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/dispatch-format-guard.sh"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -171,6 +179,10 @@
|
|||||||
{
|
{
|
||||||
"type": "command",
|
"type": "command",
|
||||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/no-ticket-no-dispatch.sh"
|
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/no-ticket-no-dispatch.sh"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "command",
|
||||||
|
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/dispatch-format-guard.sh"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -259,25 +271,9 @@
|
|||||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/unpushed-police.sh"
|
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/unpushed-police.sh"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
|
||||||
{
|
|
||||||
"hooks": [
|
|
||||||
{
|
|
||||||
"type": "command",
|
|
||||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/claim-verify-police.sh"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"SubagentStop": [
|
"SubagentStop": [
|
||||||
{
|
|
||||||
"hooks": [
|
|
||||||
{
|
|
||||||
"type": "command",
|
|
||||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/subagent-claim-worksheet.sh"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"hooks": [
|
"hooks": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,260 @@
|
|||||||
|
#!/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`(可選外掛全形/半形括號的 `→ comment M`)票號形狀
|
||||||
|
- 唯一例外(ISEP#65):`_COURTESY_CLOSERS` 是一份**白名單**,只用來認出
|
||||||
|
「這一行是不是零資訊的禮貌收尾」,作用方向與黑名單相反——認出的是
|
||||||
|
「不算違規」,不是「算違規」;且要求整行清乾淨標點後完全相等,不是
|
||||||
|
「包含」,所以不會反過來放走任何真正的內容。
|
||||||
|
措辭多謹慎、有沒有提到「紅線」「部署」「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*$")
|
||||||
|
# 🔴 inkstone/ISEP#65:頂層 CLAUDE.md 規定的合格格式帶著全形括號——
|
||||||
|
# 「【工單】owner/repo#N(→ comment M)」。舊版正則只吃「→ comment M」本體,
|
||||||
|
# 兩側的括號(全形/半形都要吃)沒被算進去,殘留的括號讓 _REF_RE 比不過,
|
||||||
|
# 於是**規則自己定義的合格範例會被自己的閘擋下**。這裡把左右括號都收進來,
|
||||||
|
# 兩種都設成可選(沒有括號的舊寫法、純 #issuecomment- 連結都還要能過)。
|
||||||
|
_COMMENT_RE = re.compile(r"[((]?\s*(?:#issuecomment-|→\s*comment\s*)(\d+)\s*[))]?")
|
||||||
|
_FENCE_RE = re.compile(r"^\s*(```|~~~)")
|
||||||
|
|
||||||
|
# 🔴 inkstone/ISEP#65:票號後面只掛一句純禮貌收尾(例:「謝謝」),不該被當成
|
||||||
|
# 「派工單不只有票號」的違規——它不含任何要搬去票上或共通規定的實質內容。
|
||||||
|
# 這是**白名單,不是黑名單**:判準仍是結構(整行清乾淨標點後「等於」清單裡的
|
||||||
|
# 固定字串,不是「包含」),漏收某個禮貌講法頂多誤鎖一次(代價小,見 ISEP#65
|
||||||
|
# 的「優先做成低誤鎖」),但不會反過來放走任何真正的內容——真內容不會剛好
|
||||||
|
# 打出一個字不差的空話。全檔仍然沒有「命中某個詞就違規」的比對,這裡是唯一
|
||||||
|
# 一處字面比對,且作用方向相反:認出「不是內容」,不是認出「是違規」。
|
||||||
|
_COURTESY_CLOSERS = frozenset({
|
||||||
|
"謝謝", "謝謝你", "謝謝您", "謝謝囉", "多謝", "感謝", "感恩", "辛苦了", "收到",
|
||||||
|
"thanks", "thank you", "thx",
|
||||||
|
})
|
||||||
|
_COURTESY_STRIP_RE = re.compile(r"[\s。!!.,~~,、]+$")
|
||||||
|
|
||||||
|
|
||||||
|
def _is_courtesy_closer(line):
|
||||||
|
s = _COURTESY_STRIP_RE.sub("", line.strip())
|
||||||
|
return s.lower() in _COURTESY_CLOSERS
|
||||||
|
|
||||||
|
|
||||||
|
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():
|
||||||
|
# 【工單】欄位本身只准佔它自己那一行;它下面的續行也是多的。
|
||||||
|
# 白名單豁免只在圍欄外生效——圍欄裡的東西本來就該一律算數(test ⑮),
|
||||||
|
# 這裡不額外放寬,避免有人把真內容包在圍欄裡再貼一句「謝謝」蒙混。
|
||||||
|
if not in_fence and _is_courtesy_closer(raw):
|
||||||
|
continue
|
||||||
|
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()
|
||||||
@@ -16,8 +16,10 @@
|
|||||||
# history-first-guard.sh、main-and-prod-push-guard.sh、no-ticket-no-dispatch.sh、
|
# history-first-guard.sh、main-and-prod-push-guard.sh、no-ticket-no-dispatch.sh、
|
||||||
# not-my-branch-guard.sh、release-tag-guard.sh、skill-deploy-drift-guard.sh、
|
# not-my-branch-guard.sh、release-tag-guard.sh、skill-deploy-drift-guard.sh、
|
||||||
# stage-before-prod-guard.sh、unpushed-police.sh、wiki-first-police.sh。
|
# stage-before-prod-guard.sh、unpushed-police.sh、wiki-first-police.sh。
|
||||||
# 另有 claim-verify-police.sh、subagent-claim-worksheet.sh、empty-handed-stop-guard.sh、
|
# 另有 empty-handed-stop-guard.sh、issue-status-autoflip.sh 直接寫 `$CLAUDE_PROJECT_DIR`
|
||||||
# issue-status-autoflip.sh 直接寫 `$CLAUDE_PROJECT_DIR`(無 `:-` fallback)——
|
# (無 `:-` fallback)——
|
||||||
|
# (claim-verify-police.sh/subagent-claim-worksheet.sh 已於 ISEP#60 整組移除,
|
||||||
|
# 它們守的東西改由 Gitea 原生三格承接,見 ISEP#59/PR #58)
|
||||||
# 這批在該變數未設時行為又不一樣,同一個病的另一種長相。
|
# 這批在該變數未設時行為又不一樣,同一個病的另一種長相。
|
||||||
# 這些全部沒有本檔「先確認到底在不在 repo 裡」的判斷;本檔先在 sdd-guard.sh 落地,
|
# 這些全部沒有本檔「先確認到底在不在 repo 裡」的判斷;本檔先在 sdd-guard.sh 落地,
|
||||||
# 其餘要不要跟進、要不要改用這支共用函式,另案處理,不在本票(#22)範圍內一次改完。
|
# 其餘要不要跟進、要不要改用這支共用函式,另案處理,不在本票(#22)範圍內一次改完。
|
||||||
|
|||||||
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
|
||||||
@@ -1,196 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# subagent-claim-worksheet.sh — subagent 交回時,把它的宣稱抽成一張待驗工作單
|
|
||||||
# (SubagentStop;leo 2026-08-18 立)
|
|
||||||
#
|
|
||||||
# ── leo 的原話(本閘的規格)─────────────────────────────────────────
|
|
||||||
# 「你如何確認說的都是真的?subagent 做完事,你應該在 SubagentStop 掛動作,
|
|
||||||
# **它說可以,你去驗證;它說不行,你想辦法,不行再報告**,
|
|
||||||
# 這解決一半的未查證。你常常直接把 subagent 說的不經查證就回報,
|
|
||||||
# **它視野小,它說的你有更多資訊去檢驗**。」
|
|
||||||
#
|
|
||||||
# ── 為什麼是「工作單檔案」而不是提醒文字 ─────────────────────────────
|
|
||||||
# leo 2026-08-17 診斷過:文字層封路是打不贏的軍火競賽,要封**動作**。
|
|
||||||
# 所以這支不勸、也不偵測我的措辭——它**在磁碟上放一個必須被處理掉的東西**。
|
|
||||||
# 配套的 Stop 閘(claim-verify-police.sh)看的是「那個檔還在不在」,
|
|
||||||
# 而檔案只能被動作消滅,不能被文案消滅。
|
|
||||||
#
|
|
||||||
# ── 兩類宣稱,處理方式不同(leo 的規格就是這兩條)──────────────────
|
|
||||||
# ✅ 它說可以 → 我去驗證(我有跨 repo 視野、有 token、有瀏覽器,它沒有)
|
|
||||||
# ⛔ 它說不行 → 我想辦法(換路徑/查憑證地圖/自己撞一次),真的不行才報告 leo
|
|
||||||
#
|
|
||||||
# ⚠️ 這裡的關鍵詞比對只用來**列出候選**,不用來判定對錯——
|
|
||||||
# 列多了我自己劃掉(要寫理由),列少了不影響我另外查。
|
|
||||||
# ⇒ 它的失敗模式是「多列幾條」,不是「擋錯事」。
|
|
||||||
#
|
|
||||||
# ── 留痕(InkStoneCo#48:36 支閘只有 2 支記錄自己做了什麼)─────────
|
|
||||||
INPUT=$(cat)
|
|
||||||
DIR="$CLAUDE_PROJECT_DIR/.claude/pending-verification"
|
|
||||||
LOG="$CLAUDE_PROJECT_DIR/.claude/hooks/subagent-claim-worksheet.log"
|
|
||||||
mkdir -p "$DIR" 2>/dev/null
|
|
||||||
|
|
||||||
OUT=$(printf '%s' "$INPUT" | DIR="$DIR" python3 -c '
|
|
||||||
import json, os, re, sys, hashlib
|
|
||||||
|
|
||||||
try:
|
|
||||||
d = json.load(sys.stdin)
|
|
||||||
except Exception:
|
|
||||||
print("SKIP:bad-json"); raise SystemExit
|
|
||||||
|
|
||||||
tp = d.get("transcript_path") or ""
|
|
||||||
if not tp or not os.path.exists(tp):
|
|
||||||
print("SKIP:no-transcript"); raise SystemExit
|
|
||||||
|
|
||||||
# ── 取 subagent 的交件 ────────────────────────────────────────────
|
|
||||||
# 🔴 2026-08-18 首次實跑就抓到自己的缺陷:`SubagentStop` 給的 `transcript_path`
|
|
||||||
# 指向**主對話**,不是子代理的紀錄 ⇒ 原本「取最後一則 assistant 訊息」
|
|
||||||
# 抓到的是**總管自己寫的句子**,這支 hook 當場退化成它要避免的「文字層自咬」。
|
|
||||||
#
|
|
||||||
# 正解:子代理的交件是以 `<task-notification>…<result>…</result>` 的形式
|
|
||||||
# 進到主對話的 **user 訊息**裡。只認那個區塊,其餘一律不看。
|
|
||||||
# ⇒ 判準回到「這段話是誰說的」,而不是「這段話長什麼樣」。
|
|
||||||
last = ""
|
|
||||||
try:
|
|
||||||
blob = []
|
|
||||||
with open(tp, "r", encoding="utf-8", errors="replace") as f:
|
|
||||||
for line in f:
|
|
||||||
line = line.strip()
|
|
||||||
if not line:
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
ev = json.loads(line)
|
|
||||||
except Exception:
|
|
||||||
continue
|
|
||||||
msg = ev.get("message") or {}
|
|
||||||
if msg.get("role") != "user":
|
|
||||||
continue
|
|
||||||
parts = msg.get("content")
|
|
||||||
if isinstance(parts, str):
|
|
||||||
txt = parts
|
|
||||||
elif isinstance(parts, list):
|
|
||||||
txt = "".join(p.get("text", "") for p in parts
|
|
||||||
if isinstance(p, dict) and p.get("type") == "text")
|
|
||||||
else:
|
|
||||||
txt = ""
|
|
||||||
if "<task-notification>" in txt:
|
|
||||||
blob.append(txt)
|
|
||||||
if blob:
|
|
||||||
m = re.search(r"<result>(.*?)</result>", blob[-1], re.S)
|
|
||||||
last = m.group(1) if m else ""
|
|
||||||
except Exception:
|
|
||||||
print("SKIP:read-fail"); raise SystemExit
|
|
||||||
|
|
||||||
if not last.strip():
|
|
||||||
print("SKIP:no-subagent-report"); raise SystemExit
|
|
||||||
|
|
||||||
if not last.strip():
|
|
||||||
print("SKIP:empty-report"); raise SystemExit
|
|
||||||
|
|
||||||
# ── 抽候選宣稱 ──────────────────────────────────────────────────
|
|
||||||
# 成功類:它說某件事成立/做好了 ⇒ 我去驗
|
|
||||||
OK = ("驗過", "測過", "通過", "全綠", "已部署", "部署完成", "可以用", "成立",
|
|
||||||
"完成", "修好", "已推", "已併", "沒問題", "正常", "一致", "對上",
|
|
||||||
"✅", "green", "passed", "verified", "deployed")
|
|
||||||
# 阻擋類:它說做不到 ⇒ 我想辦法,不行才報告 leo
|
|
||||||
NG = ("無法", "不行", "做不到", "被擋", "擋下", "失敗", "不存在", "找不到",
|
|
||||||
"沒有權限", "權限不足", "需要 leo", "要 leo", "請總管", "交給你",
|
|
||||||
"❌", "blocked", "denied", "cannot", "failed", "not found")
|
|
||||||
|
|
||||||
def lines_of(text):
|
|
||||||
out = []
|
|
||||||
for raw in text.split("\n"):
|
|
||||||
s = raw.strip().lstrip("-*#>| ").strip()
|
|
||||||
if len(s) < 8 or len(s) > 220:
|
|
||||||
continue
|
|
||||||
out.append(s)
|
|
||||||
return out
|
|
||||||
|
|
||||||
ls = lines_of(last)
|
|
||||||
ok_hits, ng_hits = [], []
|
|
||||||
for s in ls:
|
|
||||||
low = s.lower()
|
|
||||||
if any(k in s or k.lower() in low for k in NG):
|
|
||||||
ng_hits.append(s)
|
|
||||||
elif any(k in s or k.lower() in low for k in OK):
|
|
||||||
ok_hits.append(s)
|
|
||||||
|
|
||||||
# 兩類都沒有 ⇒ 它交的是純資料/純提問,沒有事實宣稱要驗
|
|
||||||
if not ok_hits and not ng_hits:
|
|
||||||
print("SKIP:no-claims"); raise SystemExit
|
|
||||||
|
|
||||||
sid = (d.get("session_id") or "nosid")[:8]
|
|
||||||
aid = (d.get("agent_id") or d.get("subagent_id") or "")[:10]
|
|
||||||
# 🔴 2026-08-21:stamp 原本雜湊「交件路徑」⇒ 同樣的宣稱每回合生一個新檔名,
|
|
||||||
# 而且不知道總管已經驗過了。實際發作:同兩條 sdd-guard 宣稱連生四張單
|
|
||||||
# (1c97d461/fcb285dc/256de849/394b97ae),驗掉一張下一回合又冒一張。
|
|
||||||
# ⇒ 改成雜湊**宣稱內容本身**:同樣的宣稱=同一個檔名 ⇒ 驗過就不再冒出來。
|
|
||||||
_claims = sorted(set(str(x) for x in (ok_hits + ng_hits)))
|
|
||||||
# 🔴 空清單就退回舊行為 —— 但那等於這支閘沒抓到任何宣稱,本來就會 SKIP,
|
|
||||||
# 所以這個 fallback 實際上不會被用到;留著只是不讓 stamp 變成空字串的雜湊。
|
|
||||||
_basis = "\n".join(_claims) if _claims else (tp + sid + aid)
|
|
||||||
stamp = hashlib.sha1(_basis.encode()).hexdigest()[:8]
|
|
||||||
_name = "claims-%s-%s.md" % (sid, stamp)
|
|
||||||
path = os.path.join(os.environ["DIR"], _name)
|
|
||||||
|
|
||||||
# 已經驗過並移進 verified/ 的,不要再生一次。
|
|
||||||
if os.path.exists(os.path.join(os.environ["DIR"], "verified", _name)):
|
|
||||||
print("SKIP:already-verified"); raise SystemExit
|
|
||||||
|
|
||||||
def block(title, items, howto, cap):
|
|
||||||
if not items:
|
|
||||||
return ""
|
|
||||||
body = "\n".join("- [ ] %s" % x for x in items[:cap])
|
|
||||||
more = ""
|
|
||||||
if len(items) > cap:
|
|
||||||
more = "\n- [ ] (還有 %d 條同類,讀原始交件)" % (len(items) - cap)
|
|
||||||
return "## %s\n\n%s\n%s%s\n\n" % (title, howto, body, more)
|
|
||||||
|
|
||||||
doc = []
|
|
||||||
doc.append("# 待驗工作單(subagent 交回的宣稱)\n")
|
|
||||||
doc.append("> leo 2026-08-18:「**它視野小,它說的你有更多資訊去檢驗。**」\n")
|
|
||||||
doc.append("> 交件全文在:`%s`\n" % tp)
|
|
||||||
doc.append("\n🔴 **這張單子只能用動作消滅,不能用文字消滅。**")
|
|
||||||
doc.append("逐條處理完之後,把本檔**刪掉或移走**;在那之前 Stop 閘會擋。\n\n")
|
|
||||||
doc.append(block(
|
|
||||||
"✅ 它說「可以」——我要自己驗一次",
|
|
||||||
ok_hits,
|
|
||||||
"判準:**我不採信,我自己跑一次**。它只看得到自己那個 repo 與自己那次呼叫;\n"
|
|
||||||
"我有跨 repo 視野、有 token、有瀏覽器。驗完把證據貼進回覆或票裡。\n",
|
|
||||||
12))
|
|
||||||
doc.append(block(
|
|
||||||
"⛔ 它說「不行」——我先想辦法,真的不行才報告 leo",
|
|
||||||
ng_hits,
|
|
||||||
"判準:**它的「不存在/被擋」往往只是「我這裡看不到」**。\n"
|
|
||||||
"先做三件:① 查憑證地圖/wiki ② 換一條路徑 ③ **自己真的撞一次並貼出拒絕原文**。\n"
|
|
||||||
"🔴 沒撞過就不准說「我被 X 擋住」——那是虛構的閘(2026-08-18 已第五次)。\n"
|
|
||||||
"真的是人閘 ⇒ 走三件機械動作:`Human` 標籤 + 指派給 Leo + **一張它自己的票**,\n"
|
|
||||||
"不是在別張票的留言裡寫一段話(leo 08-18:「不是默默塞進錯的地方」)。\n",
|
|
||||||
12))
|
|
||||||
|
|
||||||
with open(path, "w", encoding="utf-8") as f:
|
|
||||||
f.write("".join(doc))
|
|
||||||
|
|
||||||
print("WROTE:%s:ok=%d:ng=%d" % (os.path.basename(path), len(ok_hits), len(ng_hits)))
|
|
||||||
' 2>/dev/null)
|
|
||||||
|
|
||||||
printf '%s\t%s\n' "$(date +%FT%T)" "${OUT:-SKIP:no-output}" >> "$LOG" 2>/dev/null
|
|
||||||
|
|
||||||
case "$OUT" in
|
|
||||||
WROTE:*)
|
|
||||||
F=$(printf '%s' "$OUT" | cut -d: -f2)
|
|
||||||
N=$(printf '%s' "$OUT" | sed 's/.*ok=\([0-9]*\):ng=\([0-9]*\)/\1 成功宣稱、\2 阻擋宣稱/')
|
|
||||||
cat <<EOF
|
|
||||||
🔍 交件查證:subagent 交回了 $N,已列成待驗工作單。
|
|
||||||
|
|
||||||
【leo 2026-08-18 的規格】
|
|
||||||
「它說可以,你去驗證;它說不行,你想辦法,不行再報告。
|
|
||||||
**它視野小,它說的你有更多資訊去檢驗。**」
|
|
||||||
|
|
||||||
工作單:.claude/pending-verification/$F
|
|
||||||
|
|
||||||
🔴 **不要直接把它說的轉給 leo。** 逐條處理完,把那個檔刪掉;在那之前 Stop 閘會擋一次。
|
|
||||||
· 它說「可以」→ 你自己跑一次(測試/curl/瀏覽器/git,看那條宣稱是什麼)
|
|
||||||
· 它說「不行」→ 先換路徑、查憑證地圖;**要說「被擋」就得自己撞一次並貼原文**
|
|
||||||
EOF
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
exit 0
|
|
||||||
Executable
+228
@@ -0,0 +1,228 @@
|
|||||||
|
#!/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')"
|
||||||
|
|
||||||
|
# ── inkstone/ISEP#65:CLAUDE.md 規定的合格格式(全形/半形括號包住 → comment M)
|
||||||
|
# 曾經被本閘自己擋下——這裡把它釘成迴歸測試,不再走回頭路。
|
||||||
|
t 0 "③b 合規:全形括號包住 → comment M(CLAUDE.md 標準寫法,曾被自己的閘擋下)" \
|
||||||
|
"$(payload '【工單】inkstone/ISEP#65(→ comment 4515)')"
|
||||||
|
|
||||||
|
t 0 "③c 合規:半形括號變體" \
|
||||||
|
"$(payload '【工單】inkstone/ISEP#65(→ comment 4515)')"
|
||||||
|
|
||||||
|
# ── inkstone/ISEP#65 test 4:不該擋的三種雜訊 ────────────────────────
|
||||||
|
t 0 "③d 不該擋:票號後面只有空行" \
|
||||||
|
"$(payload '【工單】inkstone/ISEP#65
|
||||||
|
|
||||||
|
')"
|
||||||
|
|
||||||
|
t 0 "③e 不該擋:票號後面只有一句「謝謝」(零資訊禮貌收尾,白名單豁免)" \
|
||||||
|
"$(payload '【工單】inkstone/ISEP#65
|
||||||
|
謝謝')"
|
||||||
|
|
||||||
|
t 0 "③f 不該擋:禮貌收尾帶標點也認得(「謝謝!」)" \
|
||||||
|
"$(payload '【工單】inkstone/ISEP#65
|
||||||
|
謝謝!')"
|
||||||
|
|
||||||
|
t 2 "③g 白名單不是漏洞:禮貌詞混在真內容裡照樣算數" \
|
||||||
|
"$(payload '【工單】inkstone/ISEP#65
|
||||||
|
辛苦了,順便告訴你 main 現在是 8e28041')"
|
||||||
|
|
||||||
|
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
|
||||||
|
```')"
|
||||||
|
|
||||||
|
# ── inkstone/ISEP#65 test 5:拿「寫完規則之後總管又犯的 8 次」當回歸樣本 ──
|
||||||
|
# 內容是從各自票的真實內文摘錄(hooks/tests/fixtures/README.md 記著來歷),
|
||||||
|
# 不是想像出來的例子。8 種形狀都要被擋,證明這不是只補了一個特例。
|
||||||
|
t 2 "⑮b 回歸:ISEP#60 那次的內容(自造第四套機制的診斷)" \
|
||||||
|
"$(payload "$FIX/real-content-isep60.txt")"
|
||||||
|
|
||||||
|
t 2 "⑮c 回歸:ISEP#61 那次的內容(push-guard 戳記機制失敗史)" \
|
||||||
|
"$(payload "$FIX/real-content-isep61.txt")"
|
||||||
|
|
||||||
|
t 2 "⑮d 回歸:ISEP#64 那次的內容(PR 分診現況數字)" \
|
||||||
|
"$(payload "$FIX/real-content-isep64.txt")"
|
||||||
|
|
||||||
|
t 2 "⑮e 回歸:Arcrun#142 那次的內容(庫索引從沒被產生過)" \
|
||||||
|
"$(payload "$FIX/real-content-arcrun142.txt")"
|
||||||
|
|
||||||
|
t 2 "⑮f 回歸:Arcrun#144 那次的內容(樹狀圖驗收記錄)" \
|
||||||
|
"$(payload "$FIX/real-content-arcrun144.txt")"
|
||||||
|
|
||||||
|
t 2 "⑮g 回歸:Arcrun#165 那次的內容(MCP 徹查現場)" \
|
||||||
|
"$(payload "$FIX/real-content-arcrun165.txt")"
|
||||||
|
|
||||||
|
t 2 "⑮h 回歸:arcrun-rag#104 那次的內容(收檔實測數字)" \
|
||||||
|
"$(payload "$FIX/real-content-arcrunrag104.txt")"
|
||||||
|
|
||||||
|
t 2 "⑮i 回歸:InkStoneCo#102 那次的內容(milestone 打假背景)" \
|
||||||
|
"$(payload "$FIX/real-content-inkstoneco102.txt")"
|
||||||
|
|
||||||
|
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
+32
@@ -0,0 +1,32 @@
|
|||||||
|
# 測資的來歷
|
||||||
|
|
||||||
|
`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 份**,那會讓測試看起來比實際更綠。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 第二批:`real-content-*.txt`(inkstone/ISEP#65)
|
||||||
|
|
||||||
|
leo 2026-08-27 又在同一天抓到總管**寫完規則之後又犯了 8 次**(八條線的派工單,
|
||||||
|
每一條都帶了「這個 session 才知道的事」):`ISEP#60`/`#61`/`#64`、
|
||||||
|
`Arcrun#142`/`#144`/`#165`、`arcrun-rag#104`、`InkStoneCo#102`。
|
||||||
|
|
||||||
|
🔴 **原始的違規派工 prompt 本身一樣拿不回來**(同一個道理:agent 一停就蒸發),
|
||||||
|
但這 8 次的**內容已經被總管補寫回各自的票**(ISEP#65 body 明講)。
|
||||||
|
這裡的 8 份 `real-content-*.txt` 是**從那些票的真實內文摘錄**(一字未改地複製,
|
||||||
|
只是截取一段,並在最前面加回 `【工單】owner/repo#N` 讓它符合派工單的殼)——
|
||||||
|
不是想像出來的例子,是真的存在於 Gitea 上、總管真的寫過的句子,只是原本的位置
|
||||||
|
錯了(在 prompt 裡,不在票上)。
|
||||||
|
|
||||||
|
用途:驗證 dispatch-format-guard.sh 修完 ISEP#65 之後,**這 8 種「票號 + 這個
|
||||||
|
session 才知道的事」的形狀全部會被擋下**——不是只驗一個最小案例。
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
【工單】inkstone/Arcrun#142
|
||||||
|
|
||||||
|
實查結論:這兩樣東西(庫索引與全庫摘要)從沒被產生過,不是壞掉,是根本沒做。
|
||||||
|
於是「開場就知道有哪些子庫、各裝了什麼」這件事永遠靠現查,查不到就當作沒有。
|
||||||
|
相關:inkstone/Arcrun#81、#87、#100(總圖說 0 條關聯,實際 1854 條)。
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
【工單】inkstone/Arcrun#144
|
||||||
|
|
||||||
|
leo 明講「不要現在執行,記下來」,本票只是把它接住,開工前要先確認優先序。
|
||||||
|
測試 OK,出現了樹狀圖,但不符合需求——達成的是 mindmap 向右向上分佈,
|
||||||
|
上下間距很大、展開字很小,用戶要擁有觸控板否則很難閱讀。
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
【工單】inkstone/Arcrun#165
|
||||||
|
|
||||||
|
不要先修,先查清楚再說——現在連斷在哪都還沒人講得出來。
|
||||||
|
已知的現場(總管 2026-08-26 用 MCP 實查,不是讀 code 推的):同一個知識庫
|
||||||
|
(youlin/yuga3bse),同一套 MCP 工具,四次查詢分數與筆數都對不上。
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
【工單】inkstone/arcrun-rag#104
|
||||||
|
|
||||||
|
現況(總管 2026-08-14 實測,非推測):leo 把 5 個資料夾接上重裝後的 leo21c,
|
||||||
|
App 顯示你的檔案 12022 共幾份、819 已送上去、11183 排隊中、20 送不上去。
|
||||||
|
逐資料夾實數(find 計 .md/.pdf/.txt,已排除 node_modules/.git)。
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
【工單】inkstone/InkStoneCo#102
|
||||||
|
|
||||||
|
背景(同一天稍早,leo 對同一件事說過的重話):昨天要完成 51,你現在還有沒派的,
|
||||||
|
去看看 milestone 一大堆沒有一個完成,現在整理,不要詐騙,去看清楚哪些完成的。
|
||||||
|
Gitea 的百分比只數票的開關,它不知道票裡的東西通了沒。
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
【工單】inkstone/ISEP#60
|
||||||
|
|
||||||
|
`subagent-claim-worksheet.sh`(`SubagentStop` 產待驗單)與 `claim-verify-police.sh`
|
||||||
|
(`Stop` 擋收工)這一套自造的第四套機制整個移除,它守的東西改由 Gitea 原生的三格承接
|
||||||
|
(子票相依/tag/指派——見 `inkstone/ISEP#59` 與 PR `#58`)。
|
||||||
|
`#issuecomment-4289` 量到同一份待驗單重生三次、同內容兩個檔名。
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
【工單】inkstone/ISEP#61
|
||||||
|
|
||||||
|
`main-and-prod-push-guard.sh` 的戳記機制已經走進死路:原設計用
|
||||||
|
`CLAUDE_CODE_CHILD_SESSION=1` 判斷 subagent,實測結果總管主 session 也是 1,
|
||||||
|
那個變數不是身分標記,誰都擋。改成戳記(`/tmp/.main-push-ok`,總管手動寫)也失敗。
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
【工單】inkstone/ISEP#64
|
||||||
|
|
||||||
|
現況(總管實查,2026-08-27 12:40):open PR 總數 23,總管已關掉 8 個「內容早就在
|
||||||
|
main」的空 PR,剩 15 個要分診。已關的 8 個實測 git merge-base --is-ancestor 證明
|
||||||
|
HEAD 已是 main 的祖先,diff +0/-0:Arcrun#166/#161/#158/#155/#153、
|
||||||
|
arcrun-rag#142/#141/#139。
|
||||||
+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
+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,26 +1,45 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# 管什麼: 用 Gitea API 直接開新票時,要求這一輪有跑過跨 repo 的搜尋(/tmp/.ticket-where-ok,30 分鐘內)。
|
# 管什麼: 用 Gitea API 直接「新增」東西時(開新票/開新 milestone/開新 label/開新 PR),
|
||||||
# 為什麼: scripts/ticket 早就強制先搜,但那道閘只擋走正門的人。2026-08-20 總管走 API 側門開了 12 張票,每一張都跟舊票重疊。
|
# 要求這一輪有跑過搜尋(/tmp/.ticket-where-ok,30 分鐘內)。
|
||||||
# 誤觸時怎麼關: 跑 `scripts/ticket where <關鍵字>` 先搜(之後 30 分鐘 API 也放行),或在指令裡加 `ticket-api-ok` 留痕放行。
|
# 為什麼: scripts/ticket 早就強制先搜,但那道閘只擋走正門的人;而擋側門的第一版只認
|
||||||
# ticket-api-bypass-guard.sh — 開票的「側門」也要經過同一道搜尋閘
|
# 大寫裸字 POST,2026-08-27 總管用 `requests.post(...)`/隱式 POST 的 urllib
|
||||||
|
# 繞了過去,同一天又用 API 直接開了 milestone/label/PR(這版都沒管)。
|
||||||
|
# 誤觸時怎麼關: 跑 `scripts/ticket where <關鍵字>` 先搜(之後 30 分鐘所有寫入端點都放行),
|
||||||
|
# 或在指令裡加 `ticket-api-ok` 留痕放行。
|
||||||
|
# ticket-api-bypass-guard.sh — 新增 Gitea 東西的每一道側門,都要經過同一道搜尋閘
|
||||||
#
|
#
|
||||||
# 來由(leo 2026-08-20 當場問「如何防止」):
|
# 來由(leo 2026-08-20 當場問「如何防止」;2026-08-27 leo 把範圍講寬:
|
||||||
|
# 「不只開票前⋯⋯所有新增 gitea 的東西,都要搜尋」):
|
||||||
# `scripts/ticket new` 早就強制「開票前先搜」(/tmp/.ticket-where-ok 戳記,30 分鐘失效)。
|
# `scripts/ticket new` 早就強制「開票前先搜」(/tmp/.ticket-where-ok 戳記,30 分鐘失效)。
|
||||||
# 但總管當天開了 12 張與舊票重疊的新票——因為他**沒用那支工具,直接打 Gitea API**。
|
# 但 2026-08-20 總管開了 12 張與舊票重疊的新票——沒用那支工具,直接打 API。
|
||||||
# ⇒ 規範有、閘也有,但閘長在「工具」上,而那個動作有兩條路,只封了一條。
|
# 2026-08-27 總管又開了 13 張,這次連當時的 v1 guard 都繞過了:
|
||||||
# leo:「你在讓事情複雜化」/「每張票開以前都要搜尋現有票,你為什麼會開了不搜?」
|
# v1 判準只認一個裸字 `grep -qw POST`(leo 2026-08-17 定的哲學:封動作不封文字,
|
||||||
|
# 一個裸字夠了),但那天實際指令是 `req = urllib.request.Request(url, data=...)`
|
||||||
|
# (靠傳 data= 隱式變成 POST,指令裡從頭到尾沒有 "POST" 三個字母),或
|
||||||
|
# `requests.post(...)`(小寫 post,`grep -qw POST` 大小寫敏感照樣漏)。
|
||||||
|
# ⇒ 這不是「該封文字改封動作」的問題(v1 本來就在封動作:打了寫入端點),
|
||||||
|
# 是「認動作的方式」本身漏了 HTTP 語意裡的隱式 POST。本版把「寫入訊號」從
|
||||||
|
# 單一裸字,換成「curl 的資料類旗標/`.post(`/`method=post`/urllib 隱式 POST」
|
||||||
|
# 這組結構性訊號——訊號種類有限、可枚舉,跟關鍵字黑名單不是同一件事。
|
||||||
#
|
#
|
||||||
# 這支封的是**動作**:任何 Bash 指令只要在對 Gitea 的 issues 端點做寫入,
|
# 這支封的是**動作**:任何 Bash 指令只要在對 Gitea 的「集合端點」
|
||||||
|
# (issues/milestones/labels/pulls,不帶 ID=新增;帶 ID=改既有的,放行)做寫入,
|
||||||
# 就要有一個新鮮的搜尋戳記。它不強迫你用 scripts/ticket,只強迫你搜過。
|
# 就要有一個新鮮的搜尋戳記。它不強迫你用 scripts/ticket,只強迫你搜過。
|
||||||
# (同 InkStoneCo#36:「守 prod 的閘,包一層腳本就繞過去了——它看的是指令長相」。
|
# (同 InkStoneCo#36:「守 prod 的閘,包一層腳本就繞過去了——它看的是指令長相」。
|
||||||
# 本支同樣只看得到指令文字,這是 PreToolUse 這層的天花板;
|
# 本支同樣只看得到指令文字,這是 PreToolUse 這層的天花板。)
|
||||||
# 所以判準取「端點 + 寫入動詞」兩個都命中才擋,讓純讀取一律放行。)
|
|
||||||
#
|
#
|
||||||
# 放行(刻意,這些都不是「開票」):
|
# 放行(刻意,這些都不是「新增」):
|
||||||
# - 只讀不寫(GET):撈清單、看票、對帳
|
# - 只讀不寫(GET):撈清單、看票、對帳
|
||||||
# - 對既有票的留言/改標籤/關票(/issues/<N>/... 這種帶票號的子路徑)
|
# - 對既有票號的子路徑(/issues/<N>/... //milestones/<N> //labels/<N> //pulls/<N>...):
|
||||||
# - scripts/ticket 自己(它有自己的閘,重複擋只會互相打架)
|
# 留言、改標籤、關票、改狀態、合併 PR——leo 要的是「不要亂開新的」,不是「不准動 Gitea」
|
||||||
|
# - scripts/ticket 自己(它有自己的三道閘,重複擋只會互相打架)
|
||||||
# - 指令裡出現 ticket-api-ok(逃生口,會留在指令歷史上)
|
# - 指令裡出現 ticket-api-ok(逃生口,會留在指令歷史上)
|
||||||
|
#
|
||||||
|
# 只對「issues」做主動搜尋+在擋下的訊息裡直接點名可能撞到的舊票(milestones/labels/pulls
|
||||||
|
# 沒有對應的跨 repo 全文搜尋端點可用,一律只要求「搜尋戳記新鮮」,不生產額外訊息——
|
||||||
|
# 這是刻意的簡化,不是漏做;真要幫 milestone/label/PR 做語意重複檢查是另一張票的事)。
|
||||||
|
# 搜尋本身連不上網路 → 不影響擋/放行的判斷,只影響訊息裡有沒有列出候選舊票(fail-open
|
||||||
|
# 在「錦上添花」的部分,不 fail-open 在「該不該擋」這個核心判斷上)。
|
||||||
set -uo pipefail
|
set -uo pipefail
|
||||||
|
|
||||||
INPUT=$(cat)
|
INPUT=$(cat)
|
||||||
@@ -34,18 +53,54 @@ except Exception: print('')
|
|||||||
# 逃生口(留痕)
|
# 逃生口(留痕)
|
||||||
case "$CMD" in *ticket-api-ok*) exit 0 ;; esac
|
case "$CMD" in *ticket-api-ok*) exit 0 ;; esac
|
||||||
# scripts/ticket 有自己的閘
|
# scripts/ticket 有自己的閘
|
||||||
case "$CMD" in *scripts/ticket*|*"ticket where"*|*"ticket new"*|*"ticket say"*) exit 0 ;; esac
|
case "$CMD" in *scripts/ticket*|*"ticket where"*|*"ticket new"*|*"ticket say"*|*"ticket close"*|*"ticket decide"*) exit 0 ;; esac
|
||||||
|
|
||||||
# ① 有沒有打到 Gitea 的 issues 端點(不帶票號的那個=建立新票的路徑)
|
# ① 先放行「帶了 ID 的子路徑」——issues/<N>、milestones/<N>、labels/<N>、
|
||||||
printf '%s' "$CMD" | grep -qE 'repos/[^ "'"'"']*/issues([?"'"'"'`,)\\[:space:]]|$)' || exit 0
|
# pulls/<N>(後面不管接留言/labels/合併都算),這些是在動既有的東西,
|
||||||
|
# 不是新增。獨立判斷、排在最前面,不要留給下面那條「集合端點」regex 用
|
||||||
|
# 貪婪比對去猜——第一版把 owner/repo 寫成「允許斜線的萬用字元」時,
|
||||||
|
# `/repos/{REPO}/issues/5/labels`(既有票 #5 底下的 labels,不該擋)會被
|
||||||
|
# 貪婪比對成「owner/repo 吃掉 {REPO}/issues/5,資源名是 labels」,跟真正
|
||||||
|
# 的頂層 /labels 端點混在一起——先用「resource/數字」這個特徵擋在前面,
|
||||||
|
# 後面那條就不必再猜。
|
||||||
|
printf '%s' "$CMD" | grep -qE '(issues|milestones|labels|pulls)/[0-9]+' && exit 0
|
||||||
|
|
||||||
# ② 指令裡有沒有 POST 這個詞(純 GET 一律放行)
|
# ② 有沒有打到 Gitea 的「集合端點」(不帶 ID 的那個=新增的路徑)
|
||||||
# 刻意只認一個裸字:跳脫引號、heredoc、python、curl、各種包裝的寫法無限多,
|
# repos/<owner>/<repo>/<resource> 或 orgs/<org>/<resource>,資源名後面立刻是
|
||||||
# 逐個補 pattern 追不完(leo 2026-08-17:「自然語言的變體是無限的,blacklist 永遠追不完」)。
|
# 「不是路徑字元」的東西(問號/引號/逗號/右括號/空白/結尾)。
|
||||||
# ①已經確定這是「開票那條端點」,純讀取的指令不會出現 POST ⇒ 一個字就夠,而且沒有跳脫的破口。
|
# 中間那段刻意只排除空白(不必排引號):①已經先擋掉「帶 ID」的形狀,這裡
|
||||||
printf '%s' "$CMD" | grep -qw 'POST' || exit 0
|
# 只要认出「打到了這個資源的集合端點」就好,多抓一點字元進中間那段不影響
|
||||||
|
# 判斷——這也是為什麼 f-string 占位符 `f"{API}/repos/{REPO}/issues"`
|
||||||
|
# (owner/repo 被壓成一個不含斜線的 token)跟真實網址 `repos/inkstone/ISEP/issues`
|
||||||
|
# (owner/repo 是兩段)都吃得下,不必為了兩種形狀各寫一條 pattern。
|
||||||
|
ENDPOINT_RE='(repos|orgs)/[^[:space:]]+/(issues|milestones|labels|pulls)([^A-Za-z0-9_/]|$)'
|
||||||
|
printf '%s' "$CMD" | grep -qE "$ENDPOINT_RE" || exit 0
|
||||||
|
|
||||||
# ③ 要有新鮮的搜尋戳記(與 scripts/ticket 共用同一個,30 分鐘)
|
RESOURCE=$(printf '%s' "$CMD" | grep -oE '/(issues|milestones|labels|pulls)([^A-Za-z0-9_/]|$)' | head -1 | grep -oE 'issues|milestones|labels|pulls')
|
||||||
|
|
||||||
|
# ③ 有沒有寫入訊號(純 GET 一律放行)。刻意取「結構性訊號」而非單一裸字:
|
||||||
|
# - curl:顯式 -X/--request POST,或帶了資料類旗標(curl 帶 -d/--data* 時,
|
||||||
|
# 沒寫 -X 也會自動變 POST——這正是舊版漏掉的其中一種隱式 POST)
|
||||||
|
# - requests 系函式庫:`.post(`(大小寫都收)
|
||||||
|
# - 任何函式庫的 `method=` 參數值是 post(大小寫都收)
|
||||||
|
# - urllib.request:`Request(`/`urlopen(` 與 `data=` 同時出現(不要求同一行——
|
||||||
|
# 多行 heredoc 裡兩者常常不在同一行)=隱式 POST,這是 2026-08-27 實際撞到的那種寫法
|
||||||
|
WRITE_SIGNAL=no
|
||||||
|
if printf '%s' "$CMD" | grep -qiw 'POST'; then
|
||||||
|
# 保留 v1 的裸字比對,但**大小寫不敏感**——v1 只認大寫 POST,
|
||||||
|
# 2026-08-27 `requests.post(...)` 小寫照樣繞過,這裡補上不分大小寫的字。
|
||||||
|
WRITE_SIGNAL=yes
|
||||||
|
elif printf '%s' "$CMD" | grep -qiE -- '-X[[:space:]]*POST|--request[[:space:]=]*POST|--data(-raw|-binary|-urlencode)?([[:space:]=]|$)|--json([[:space:]=]|$)|\.post\(|method[[:space:]]*=[[:space:]]*.{0,2}post'; then
|
||||||
|
WRITE_SIGNAL=yes
|
||||||
|
elif printf '%s' "$CMD" | grep -qE 'urllib\.request\.(Request|urlopen)\(' \
|
||||||
|
&& printf '%s' "$CMD" | grep -qE '\bdata[[:space:]]*='; then
|
||||||
|
# 隱式 POST:urllib.request 傳了 data= 卻沒寫明 method——這是 2026-08-27
|
||||||
|
# 實際撞到的寫法,指令裡從頭到尾沒有 "POST" 三個字母,上面兩層都攔不到。
|
||||||
|
WRITE_SIGNAL=yes
|
||||||
|
fi
|
||||||
|
[ "$WRITE_SIGNAL" = yes ] || exit 0
|
||||||
|
|
||||||
|
# ④ 要有新鮮的搜尋戳記(與 scripts/ticket 共用同一個,30 分鐘)
|
||||||
STAMP=/tmp/.ticket-where-ok
|
STAMP=/tmp/.ticket-where-ok
|
||||||
NOW=$(date +%s)
|
NOW=$(date +%s)
|
||||||
FRESH=no
|
FRESH=no
|
||||||
@@ -55,30 +110,108 @@ if [ -f "$STAMP" ]; then
|
|||||||
fi
|
fi
|
||||||
[ "$FRESH" = "yes" ] && exit 0
|
[ "$FRESH" = "yes" ] && exit 0
|
||||||
|
|
||||||
cat >&2 <<'MSG'
|
# 只有 issues 才做「主動幫你搜一次」——從指令裡的 title 欄位猜關鍵字,
|
||||||
🚫 你正在用 Gitea API 直接開新票,而這一輪沒有搜尋紀錄
|
# 真的搜得到就把候選舊票直接列進擋下的訊息裡(重演 2026-08-27 的 Arcrun#100)。
|
||||||
|
# 任何一步失敗(拿不到 token、連不上網、payload 裡沒有 title)都安靜放棄,
|
||||||
|
# 不影響「擋下來」這個判斷本身。
|
||||||
|
HITS=""
|
||||||
|
if [ "$RESOURCE" = "issues" ]; then
|
||||||
|
REMOTES=$(git -C "${CLAUDE_PROJECT_DIR:-.}" remote -v 2>/dev/null || true)
|
||||||
|
CRED_LINE=$(printf '%s' "$REMOTES" | grep -m1 'git\.uncle6\.me' | grep -m1 '@' || true)
|
||||||
|
if [ -n "$CRED_LINE" ]; then
|
||||||
|
TOKEN=$(printf '%s' "$CRED_LINE" | sed -E 's|.*//[^:]+:([^@]+)@.*|\1|')
|
||||||
|
# 只認雙引號的 "title": "..."(curl -d/json.dumps 輸出的實際格式,也是
|
||||||
|
# 2026-08-27 那 13 張票真實用的寫法,見 scripts/test-ticket-api-bypass-guard.sh
|
||||||
|
# 既有的模擬指令)。刻意不支援單引號版本——這段 python 要塞進 bash 單引號
|
||||||
|
# 參數字串(`python3 -c '...'`),bash 的單引號字串裡放不進一個字面單引號
|
||||||
|
# (沒有跳脫機制),另外分岔一段處理單引號只會逼自己走回「兩層語言的引號
|
||||||
|
# 疊在一起」那個坑。找不到 title 就安靜放棄——不影響「擋下來」這個判斷本身,
|
||||||
|
# 只是訊息裡少列候選舊票。
|
||||||
|
HITS=$(printf '%s' "$CMD" | _TICKET_TOKEN="$TOKEN" python3 -c '
|
||||||
|
import os, re, sys, json, urllib.request, urllib.parse
|
||||||
|
|
||||||
leo 2026-08-16:「**寫開票前先去搜尋要開在哪裡,不然你永遠會亂開新票**」
|
cmd = sys.stdin.read()
|
||||||
leo 2026-08-20:「**每張票開以前都要搜尋現有票,你為什麼會開了不搜?這個規範不是早就有 hook 了?**」
|
pat = "\"title\"\\s*:\\s*\"((?:\\\\.|[^\"\\\\])*)\""
|
||||||
|
m = re.search(pat, cmd)
|
||||||
|
if not m:
|
||||||
|
sys.exit(0)
|
||||||
|
title = m.group(1)
|
||||||
|
title = title.replace("\\n", " ").replace("\\\"", "\"")
|
||||||
|
|
||||||
規範有,閘也有——但那道閘長在 `scripts/ticket` 這支工具裡,
|
toks = []
|
||||||
而你走的是 API 這條側門。**本閘就是把那道門也封上。**
|
for t in re.findall(r"[A-Za-z0-9]+|[一-鿿]{2,}", title):
|
||||||
|
if t not in toks:
|
||||||
|
toks.append(t)
|
||||||
|
toks = toks[:3]
|
||||||
|
if not toks:
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
實錯(2026-08-20 同日):總管用 API 開了 12 張票,事後盤點**每一張都跟舊票重疊**,
|
token = os.environ.get("_TICKET_TOKEN", "")
|
||||||
全部只能關掉指回舊票。leo:「**你在讓事情複雜化**」。
|
seen = {}
|
||||||
|
for kw in toks:
|
||||||
|
q = urllib.parse.urlencode({"q": kw, "state": "open", "type": "issues", "limit": 20})
|
||||||
|
url = "https://git.uncle6.me/api/v1/repos/issues/search?" + q
|
||||||
|
try:
|
||||||
|
req = urllib.request.Request(url, headers={"Authorization": "token " + token})
|
||||||
|
data = json.load(urllib.request.urlopen(req, timeout=6))
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
for it in (data or []):
|
||||||
|
ref = it["repository"]["full_name"] + "#" + str(it["number"])
|
||||||
|
row = seen.setdefault(ref, {"score": 0, "title": it["title"]})
|
||||||
|
row["score"] = row["score"] + 1
|
||||||
|
|
||||||
|
hits = sorted(seen.items(), key=lambda kv: -kv[1]["score"])[:6]
|
||||||
|
if not hits:
|
||||||
|
sys.exit(0)
|
||||||
|
joined = " ".join(toks)
|
||||||
|
print("(用猜出來的關鍵字「" + joined + "」順手搜了一次,命中:)")
|
||||||
|
for ref, info in hits:
|
||||||
|
score = info["score"]
|
||||||
|
t2 = info["title"][:56]
|
||||||
|
print(" [" + str(score) + "] " + ref + " " + t2)
|
||||||
|
' 2>/dev/null || true)
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
case "$RESOURCE" in
|
||||||
|
issues) VERB="開新票" ;;
|
||||||
|
milestones) VERB="開新 milestone" ;;
|
||||||
|
labels) VERB="開新 label" ;;
|
||||||
|
pulls) VERB="開新 PR" ;;
|
||||||
|
*) VERB="新增東西" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
{
|
||||||
|
cat <<MSG
|
||||||
|
🚫 你正在用 Gitea API 直接${VERB},而這一輪沒有搜尋紀錄
|
||||||
|
|
||||||
|
leo 2026-08-16:「寫開票前先去搜尋要開在哪裡,不然你永遠會亂開新票」
|
||||||
|
leo 2026-08-27:「開票不查是否有現成的,這是什麼問題?」「不只開票前,
|
||||||
|
所有新增 gitea 的東西,都要搜尋」
|
||||||
|
|
||||||
|
規範有,閘也有——但上一版只認裸字 POST,你這次用的寫法(隱式 POST/小寫
|
||||||
|
requests.post/milestone·label·PR 端點)沒被管到。**本閘把這些側門也封上。**
|
||||||
|
MSG
|
||||||
|
if [ -n "$HITS" ]; then
|
||||||
|
printf '\n%s\n' "$HITS"
|
||||||
|
fi
|
||||||
|
cat <<'MSG'
|
||||||
|
|
||||||
── 怎麼過(擇一)───────────────────────────────
|
── 怎麼過(擇一)───────────────────────────────
|
||||||
1. 先搜(預設,戳記 30 分鐘有效,之後 API 也放行):
|
1. 先搜(預設,戳記 30 分鐘有效,之後所有寫入端點都放行):
|
||||||
scripts/ticket where <關鍵字...>
|
scripts/ticket where <關鍵字...>
|
||||||
🔴 搜到了就**貼進那張票**,不要開新的:
|
🔴 搜到同一條線就貼進那張票,不要開新的:
|
||||||
scripts/ticket say <owner/repo#N> -F <內文檔>
|
scripts/ticket say <owner/repo#N> -F <內文檔>
|
||||||
|
|
||||||
2. 真的是新的一條線 → 直接用那支工具開,它會幫你把該檢查的檢查完:
|
2. 開票真的是新的一條線 → 直接用那支工具開,它會幫你把該檢查的檢查完:
|
||||||
scripts/ticket new <repo> -F <內文檔> --title <標題>
|
scripts/ticket new <repo> -F <內文檔> --title <標題>
|
||||||
|
|
||||||
3. 這次確實不是在開新票(例如批次改標籤/關票/留言)
|
3. 這次確實不是在新增(例如批次改標籤/關票/留言/改 milestone 狀態)
|
||||||
→ 指令裡加 `ticket-api-ok` 說明理由,留痕放行。
|
→ 指令裡加 `ticket-api-ok` 說明理由,留痕放行。
|
||||||
|
|
||||||
放行的情況(本閘不管):純讀取(GET)、對既有票 /issues/<N>/ 的留言與標籤、scripts/ticket 自己。
|
放行的情況(本閘不管):純讀取(GET)、對既有票號的留言與標籤與關票、
|
||||||
|
scripts/ticket 自己。
|
||||||
MSG
|
MSG
|
||||||
|
} >&2
|
||||||
exit 2
|
exit 2
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# 開票側門閘的測試(docs/TESTING.md A4)
|
# 開票側門閘的測試(docs/TESTING.md A4)
|
||||||
# 判準:4 種該擋、8 種不該擋、1 種有戳記時放行 = 13 條
|
# 判準:4 種該擋、8 種不該擋、1 種有戳記時放行 = 13 條(v1,仍須全過,不能為了
|
||||||
|
# 新案例把舊的改壞)+ inkstone/ISEP#72 擴大範圍後新增的案例(隱式/小寫 POST、
|
||||||
|
# milestone/label/PR、org 端點、Arcrun#100 重演)。
|
||||||
cd "$(dirname "$0")/.." || exit 1
|
cd "$(dirname "$0")/.." || exit 1
|
||||||
H=hooks/ticket-api-bypass-guard.sh
|
H=hooks/ticket-api-bypass-guard.sh
|
||||||
PASS=0; FAIL=0
|
PASS=0; FAIL=0
|
||||||
@@ -33,8 +35,53 @@ run 0 'curl -s "$API/repos/inkstone/Arcrun/issues?state=open&limit=100"'
|
|||||||
echo "── 有新鮮戳記時放行 ──"
|
echo "── 有新鮮戳記時放行 ──"
|
||||||
python3 -c "import json,time;json.dump({'at':time.time(),'n':0,'top':[]},open('/tmp/.ticket-where-ok','w'))"
|
python3 -c "import json,time;json.dump({'at':time.time(),'n':0,'top':[]},open('/tmp/.ticket-where-ok','w'))"
|
||||||
run 0 'curl -X POST https://git.uncle6.me/api/v1/repos/inkstone/ISEP/issues'
|
run 0 'curl -X POST https://git.uncle6.me/api/v1/repos/inkstone/ISEP/issues'
|
||||||
|
|
||||||
rm -f /tmp/.ticket-where-ok
|
rm -f /tmp/.ticket-where-ok
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "── ISEP#72:2026-08-27 實際撞到的繞法(v1 grep -qw POST 漏掉的),該擋 ──"
|
||||||
|
run 2 'url = "https://git.uncle6.me/api/v1/repos/inkstone/ISEP/issues"; req = urllib.request.Request(url, data=json.dumps(payload).encode(), headers=h); urllib.request.urlopen(req)'
|
||||||
|
run 2 'python3 -c "requests.post(f\"{API}/repos/{REPO}/issues\", json={\"title\":\"x\"})"'
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "── ISEP#72:範圍擴大到 milestone/label/PR,該擋 ──"
|
||||||
|
run 2 'curl -X POST https://git.uncle6.me/api/v1/repos/inkstone/ISEP/milestones -d @m.json'
|
||||||
|
run 2 'curl -X POST https://git.uncle6.me/api/v1/repos/inkstone/ISEP/labels -d @l.json'
|
||||||
|
run 2 'curl -X POST https://git.uncle6.me/api/v1/repos/inkstone/ISEP/pulls -d @p.json'
|
||||||
|
run 2 'curl -X POST https://git.uncle6.me/api/v1/orgs/inkstone/labels -d @l.json'
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "── ISEP#72:帶 ID 的 milestone/label/PR 子路徑,不該擋(改狀態/合併,不是新增)──"
|
||||||
|
run 0 'curl -X PATCH https://git.uncle6.me/api/v1/repos/inkstone/ISEP/milestones/3 -d @m.json'
|
||||||
|
run 0 'curl -X PATCH https://git.uncle6.me/api/v1/repos/inkstone/ISEP/labels/9 -d @l.json'
|
||||||
|
run 0 'curl -X POST https://git.uncle6.me/api/v1/repos/inkstone/ISEP/pulls/12/merge'
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "── ISEP#72:有戳記時,milestone/label/PR 也放行(不是只有 issues)──"
|
||||||
|
python3 -c "import json,time;json.dump({'at':time.time(),'n':0,'top':[]},open('/tmp/.ticket-where-ok','w'))"
|
||||||
|
run 0 'curl -X POST https://git.uncle6.me/api/v1/repos/inkstone/ISEP/milestones -d @m.json'
|
||||||
|
rm -f /tmp/.ticket-where-ok
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "── ISEP#72:Arcrun#100 重演——標題撞同主題舊票,擋下來還要點名 ──"
|
||||||
|
echo " (這條打真實 Gitea 網路;離線環境會印 SKIP,不算失敗。標題故意用"
|
||||||
|
echo " 裸引號的 python dict 寫法,跟 2026-08-27 那 13 張票實際的寫法一致——"
|
||||||
|
echo " curl -d 那種「JSON 被跳脫成字串塞進另一層引號」的寫法,本閘的猜題功能"
|
||||||
|
echo " 刻意不支援,見 hook 檔頭「只認雙引號」那段註解,訊息裡不會列候選舊票,"
|
||||||
|
echo " 但擋下來這件事不受影響,另有獨立案例覆蓋。)"
|
||||||
|
if git remote -v 2>/dev/null | grep 'git\.uncle6\.me' | grep -q '@'; then
|
||||||
|
OUT=$(printf '%s' "{\"tool_name\":\"Bash\",\"tool_input\":{\"command\":$(python3 -c 'import json,sys;print(json.dumps(sys.argv[1]))' 'req("POST", f"{API}/repos/{REPO}/issues", {"title":"總圖說我一條關聯都沒有,三元組 graph 全部消失"})')}}" \
|
||||||
|
| bash "$H" 2>&1 >/dev/null)
|
||||||
|
RC=$?
|
||||||
|
if [ "$RC" = 2 ] && printf '%s' "$OUT" | grep -q 'Arcrun#100'; then
|
||||||
|
PASS=$((PASS+1)); echo " ✅ 擋下來(rc=2)且點名 Arcrun#100"
|
||||||
|
else
|
||||||
|
FAIL=$((FAIL+1)); echo " ❌ rc=$RC,訊息裡有沒有 Arcrun#100:"; printf '%s\n' "$OUT" | grep -q 'Arcrun#100' && echo "有" || echo "沒有"
|
||||||
|
printf '%s\n' "$OUT"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo " ⏭️ SKIP(這台機器的 git remote 沒帶 git.uncle6.me 的憑證,連不上就不算數)"
|
||||||
|
fi
|
||||||
|
|
||||||
[ -n "$SAVED" ] && printf '%s' "$SAVED" > /tmp/.ticket-where-ok
|
[ -n "$SAVED" ] && printf '%s' "$SAVED" > /tmp/.ticket-where-ok
|
||||||
echo
|
echo
|
||||||
echo "$PASS/$((PASS+FAIL)) 通過"
|
echo "$PASS/$((PASS+FAIL)) 通過"
|
||||||
|
|||||||
+39
-1
@@ -36,6 +36,41 @@ import urllib.error
|
|||||||
import urllib.parse
|
import urllib.parse
|
||||||
import urllib.request
|
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"
|
HOST = "https://git.uncle6.me"
|
||||||
ORG = "inkstone"
|
ORG = "inkstone"
|
||||||
STAMP_DIR = "/tmp"
|
STAMP_DIR = "/tmp"
|
||||||
@@ -154,6 +189,7 @@ def cmd_say(argv):
|
|||||||
die("用法:ticket say <owner/repo#N> -F <內文檔>")
|
die("用法:ticket say <owner/repo#N> -F <內文檔>")
|
||||||
owner, repo, num = parse_ref(argv[0])
|
owner, repo, num = parse_ref(argv[0])
|
||||||
body = open(argv[2]).read()
|
body = open(argv[2]).read()
|
||||||
|
check_identity(body, "貼進票的留言")
|
||||||
c = api(f"/repos/{owner}/{repo}/issues/{num}/comments", {"body": body})
|
c = api(f"/repos/{owner}/{repo}/issues/{num}/comments", {"body": body})
|
||||||
print(f"✅ 已貼進 {owner}/{repo}#{num}")
|
print(f"✅ 已貼進 {owner}/{repo}#{num}")
|
||||||
print(f" 定址:{owner}/{repo}#{num}#issuecomment-{c['id']}")
|
print(f" 定址:{owner}/{repo}#{num}#issuecomment-{c['id']}")
|
||||||
@@ -299,7 +335,7 @@ def cmd_close(argv):
|
|||||||
|
|
||||||
if deliv:
|
if deliv:
|
||||||
api(f"/repos/{owner}/{repo}/issues/{num}/comments",
|
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")
|
api(f"/repos/{owner}/{repo}/issues/{num}", {"state": "closed"}, method="PATCH")
|
||||||
print(f"✅ {owner}/{repo}#{num} 已關(交付物:{deliv or ('PR' if has_pr else '票上回報')})")
|
print(f"✅ {owner}/{repo}#{num} 已關(交付物:{deliv or ('PR' if has_pr else '票上回報')})")
|
||||||
|
|
||||||
@@ -329,6 +365,8 @@ def cmd_decide(argv):
|
|||||||
body = open(argv[2]).read()
|
body = open(argv[2]).read()
|
||||||
nxt = argv[argv.index("--next") + 1] if "--next" in argv else None
|
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:
|
if "leo" not in body.lower() and "原話" not in body:
|
||||||
die("🚫 答案檔裡看不到 leo 的原話。\n"
|
die("🚫 答案檔裡看不到 leo 的原話。\n"
|
||||||
" **裁決要記原話,不是記你的轉述**——轉述會漂,原話不會。\n"
|
" **裁決要記原話,不是記你的轉述**——轉述會漂,原話不會。\n"
|
||||||
|
|||||||
@@ -77,3 +77,44 @@ Gitea 上**一個 release tag 都沒有**。
|
|||||||
**不要用 sed**,正體中文加上訊息裡的 `/`、`&`、`\` 讓跳脫非常脆。
|
**不要用 sed**,正體中文加上訊息裡的 `/`、`&`、`\` 讓跳脫非常脆。
|
||||||
迴歸測試要**檢查訊息內容**,不能只檢查離開碼
|
迴歸測試要**檢查訊息內容**,不能只檢查離開碼
|
||||||
(`hooks/tests/ask-user-question-guard.test.sh` 的 ⑩b 就是這一條)。
|
(`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