v0.2.1:43 支閘白話盤點+測試手冊+補兩個實撞的洞 #27
@@ -8,7 +8,7 @@
|
||||
"plugins": [
|
||||
{
|
||||
"name": "isep",
|
||||
"description": "InkStone Environment Plugin —— leo 的 Claude Code 環境唯一真相源:42 支機械閘(52 條註冊)、7 支 slash command、2 支 skill、25 支腳本,外加治理規範與標籤真相源。本機與雲端裝同一份,沒有子集。",
|
||||
"description": "InkStone Environment Plugin —— leo 的 Claude Code 環境唯一真相源:43 支機械閘(53 條註冊,白話盤點見 docs/hooks-inventory.md)、7 支 slash command、2 支 skill、27 支腳本,外加治理規範與標籤真相源。本機與雲端裝同一份,沒有子集。",
|
||||
"author": {
|
||||
"name": "Leo",
|
||||
"url": "https://uncle6.me"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "isep",
|
||||
"description": "InkStone Environment Plugin —— leo 的 Claude Code 環境唯一真相源:42 支機械閘(52 條註冊)、7 支 slash command、2 支 skill、25 支腳本,外加治理規範與標籤真相源。本機與雲端裝同一份,沒有子集。",
|
||||
"version": "0.2.0",
|
||||
"description": "InkStone Environment Plugin —— leo 的 Claude Code 環境唯一真相源:43 支機械閘(53 條註冊,白話盤點見 docs/hooks-inventory.md)、7 支 slash command、2 支 skill、27 支腳本,外加治理規範與標籤真相源。本機與雲端裝同一份,沒有子集。",
|
||||
"version": "0.2.1",
|
||||
"keywords": [
|
||||
"inkstone",
|
||||
"guardrails",
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
|
||||
| | 數量 | 是什麼 |
|
||||
|---|---|---|
|
||||
| `hooks/` | 42 支 + `hooks.json` | 全部機械閘(PreToolUse/Stop/SubagentStop/SessionStart/PostToolUse 共 52 條註冊) |
|
||||
| `hooks/` | 43 支 + `hooks.json` | 全部機械閘(PreToolUse/Stop/SubagentStop/SessionStart/PostToolUse 共 53 條註冊) |
|
||||
| `commands/` | 7 支 | `/wiki-recall` `/ship-check` `/cp-write` … |
|
||||
| `skills/` | 2 支 | |
|
||||
| `scripts/` | 23 支 | `ticket`/`github-arm.sh`/`gitea-bootstrap.sh` … |
|
||||
@@ -43,6 +43,11 @@ hook 一律用官方的 `${CLAUDE_PLUGIN_ROOT}`,**不准寫死絕對路徑、
|
||||
🔴 **只改這裡,然後兩邊 `/plugin update`。**
|
||||
不要再改 `InkStoneCo/.claude/hooks/`——那個目錄退場中。
|
||||
|
||||
## 這些閘各自在管什麼
|
||||
|
||||
**不用點開任何 `.sh`**——`docs/hooks-inventory.md` 一支一行白話,按「你會在什麼時候撞到它」分組。
|
||||
測試手冊在 `docs/TESTING.md`,治理規範在 `docs/governance/`。
|
||||
|
||||
## 版本
|
||||
|
||||
**「ISEP 現在是哪一版」只有一個地方答得出來:[Gitea Releases](https://git.uncle6.me/inkstone/ISEP/releases)。**
|
||||
|
||||
+165
@@ -0,0 +1,165 @@
|
||||
# ISEP 測試手冊
|
||||
|
||||
> leo 2026-08-20:「**你交出版本測試了嗎?你要測試無誤才叫我測試,
|
||||
> 如果雲端不能測試也要提供 test cases 讓我開啓雲端測試**」。
|
||||
>
|
||||
> 規約:每一格都要有「**怎麼跑/該看到什麼/什麼算失敗**」三件。
|
||||
> **沒跑過的格子一律標空白,不准標綠。**
|
||||
|
||||
---
|
||||
|
||||
## 先讀:改了 ISEP 卻沒發版,改動到不了任何人手上
|
||||
|
||||
2026-08-20 實撞:新增一支 hook 併進 `main`,然後跑 `claude plugin update isep@inkstone`
|
||||
→ 回「**已是最新版 (0.2.0)**」,新 hook **沒有進到安裝的那一份**。
|
||||
|
||||
原因:`plugin update` 比的是 **`plugin.json` 的版本號,不是內容**。
|
||||
⇒ **版本沒動 = 更新是 no-op = 本機與雲端又各自停在不同內容上**(就是 `InkStoneCo#57` 的病)。
|
||||
|
||||
**所以:任何要生效的改動,都必須跟著一個新版本號。這不是儀式,是傳輸機制本身。**
|
||||
|
||||
---
|
||||
|
||||
## A. 總管自己要跑完的(交給 leo 之前)
|
||||
|
||||
### A1 — plugin manifest 合法
|
||||
```
|
||||
claude plugin validate .
|
||||
```
|
||||
**該看到**:`✔ Validation passed`,不帶 warning。
|
||||
**失敗**:任何 error;或有 warning 卻沒處理。
|
||||
|
||||
### A2 — 版本三處一致
|
||||
```
|
||||
bash scripts/check-version-consistency.sh
|
||||
```
|
||||
**該看到**:`✅ 版本一致:plugin.json=X.Y.Z,最新 tag=vX.Y.Z,README 沒有自行宣告版本。`
|
||||
**失敗**:exit 1;或 README 又出現寫死的版本號。
|
||||
|
||||
### A3 — 打 tag 的閘:擋得住,也放得過
|
||||
```
|
||||
bash scripts/test-release-tag-guard.sh
|
||||
```
|
||||
**該看到**:`3/3 通過`(1 個該擋、2 個不該擋)。
|
||||
**失敗**:該擋的放行(假綠);或不該擋的被擋——**誤攔比漏擋更該修**,誤攔會懲罰謹慎。
|
||||
|
||||
### A4 — 開票側門閘:13 條
|
||||
```
|
||||
bash scripts/test-ticket-api-bypass-guard.sh
|
||||
```
|
||||
**該看到**:`13/13 通過`。
|
||||
**失敗**:任何一條不符,特別看「不該擋」那 8 條。
|
||||
|
||||
### A5 — 開票前的搜尋是跨 repo 的
|
||||
```
|
||||
python3 scripts/ticket where 標籤 模組化
|
||||
```
|
||||
**該看到**:命中數 > 0,而且結果**橫跨多個 repo**(`InkStoneCo` / `Arcrun` / `arcrun-rag` …)。
|
||||
**失敗**:
|
||||
- `🔴 拿不到 token` ⇒ 這個 repo 的 remote 沒帶憑證(2026-08-20 修過一次:原本寫死只認名叫 `gitea` 的 remote,
|
||||
ISEP 的叫 `origin`,於是這道閘在新 repo 等於不存在)
|
||||
- 結果只有單一 repo ⇒ 搜尋沒有跨 repo,等於沒搜
|
||||
|
||||
### A6 — 標籤對齊且冪等
|
||||
```
|
||||
bash scripts/gitea-labels-sync.sh
|
||||
bash scripts/gitea-labels-sync.sh
|
||||
```
|
||||
**該看到**:第二次全部 `0 created / 0 updated`。
|
||||
**失敗**:第二次還在改(不冪等);或任何既有標籤被刪除。
|
||||
|
||||
### A7 — plugin 裝得起來、內容對得上
|
||||
```
|
||||
claude plugin marketplace add https://git.uncle6.me/inkstone/ISEP.git
|
||||
claude plugin install isep@inkstone
|
||||
claude plugin list
|
||||
claude plugin details isep
|
||||
```
|
||||
**該看到**:`isep@inkstone` `enabled`,版本=最新 release;`details` 列出 9 skills、5 個 hook 事件。
|
||||
**失敗**:版本落後(先發版,見開頭那段);或 `marketplace list` 的 `Source` 顯示**本機目錄**而非 Git URL
|
||||
——本機目錄有未提交改動就會跟 main 分岔,那是一條漂移路徑。
|
||||
|
||||
### A8 — 閘在**新 session** 真的會觸發
|
||||
|
||||
前七格證明「腳本會擋」與「檔案就位」,**不是「harness 真的會去叫它」**。
|
||||
plugin 的 hook 是 session 啟動時載入,所以這格一定要開**新**的 session。
|
||||
```
|
||||
claude -p '請執行 git tag -a v9.9.9 -m test'
|
||||
```
|
||||
**該看到**:回報被擋,訊息是 `release-tag-guard` 那段(提到 plugin.json 與版本對不上)。
|
||||
**失敗**:
|
||||
- tag 真的被打出去 ⇒ **閘沒被載入**,這是最危險的假綠
|
||||
- 訊息來自 `InkStoneCo/.claude/hooks/…` 而不是 plugin ⇒ 你驗到的是舊那份
|
||||
|
||||
> 為什麼挑 `release-tag-guard` 當考題:它**只存在於 ISEP**,舊的 `.claude/` 那份沒有。
|
||||
> 用它才分得出「載到的是 plugin」還是「載到的是舊的」。
|
||||
|
||||
---
|
||||
|
||||
## B. 只有 leo 能跑的(雲端)
|
||||
|
||||
機器碰不到 claude.ai 的 Cloud environment 設定,這段一定要你動手。
|
||||
看到跟「該看到」不一樣就停下來,把畫面貼回 `inkstone/InkStoneCo#14`。
|
||||
|
||||
### B1 — 設定(一次性)
|
||||
|
||||
claude.ai → **Cloud environments** → 你的環境:
|
||||
|
||||
1. **Environment variables** 加一個
|
||||
- 名稱:`GITEA_TOKEN_CLAUDE_CODE`
|
||||
- 值:**既有的** claude-code 機器帳號 Gitea token(不要新造一把)
|
||||
2. **Setup script** 欄位:貼進 `docs/cloud-setup-script.sh` 的全文,一字不改。
|
||||
|
||||
**該看到**:儲存後沒有紅字。
|
||||
|
||||
### B2 — 開一個新的雲端 session,確認裝上了
|
||||
|
||||
在雲端 session 裡打:
|
||||
```
|
||||
跑 claude plugin list 給我看
|
||||
```
|
||||
**該看到**:`isep@inkstone` / `Version: 0.2.1`(要跟 Releases 頁最新那個一樣)/ `✔ enabled`。
|
||||
**失敗**:
|
||||
- 沒有 `isep` ⇒ Setup script 沒跑成功 → 叫它把 setup 的輸出貼回來
|
||||
- 版本比 Releases 舊 ⇒ 環境快取住了(設定跑完會被拍成快照,約 7 天或改了 setup script 才重拍)
|
||||
→ 動一下 setup script 的內容,強制重拍
|
||||
|
||||
### B3 — 雲端載到的元件數量要跟本機一樣
|
||||
```
|
||||
跑 claude plugin details isep 給我看
|
||||
```
|
||||
**該看到**:`Skills (9)`、`Hooks (5) PreToolUse, SessionStart, Stop, SubagentStop, PostToolUse`
|
||||
——**跟本機看到的一模一樣**。
|
||||
**失敗**:比本機少 ⇒ 又回到「兩邊不一樣」,正是 `InkStoneCo#57` 那張票的病。
|
||||
|
||||
### B4 — 最關鍵:雲端的閘真的會擋,而且擋的是 plugin 那份
|
||||
```
|
||||
請執行 git tag -a v9.9.9 -m test
|
||||
```
|
||||
**該看到**:被擋下,訊息提到「版本不一致」與 `plugin.json`。
|
||||
**失敗**:
|
||||
- 它真的把 tag 打出去 ⇒ **雲端仍然沒有閘**(跟 `InkStoneCo#14` 記的一樣)
|
||||
- 它只是嘴上說「我不應該這麼做」而沒有閘的訊息 ⇒ 同上,那是模型自律不是機械閘
|
||||
|
||||
### B5 — 回報
|
||||
|
||||
B2/B3/B4 三個畫面貼回 `inkstone/InkStoneCo#14`。
|
||||
全綠 ⇒ 那張票可以關,`#57` 也解掉一半。
|
||||
|
||||
---
|
||||
|
||||
## 目前狀態
|
||||
|
||||
| | 誰跑 | 狀態 |
|
||||
|---|---|---|
|
||||
| A1 manifest 合法 | 總管 | ✅ |
|
||||
| A2 版本三處一致 | 總管 | ✅ |
|
||||
| A3 打 tag 閘 | 總管 | ✅ 3/3 |
|
||||
| A4 開票側門閘 | 總管 | ✅ 13/13 |
|
||||
| A5 搜尋跨 repo | 總管 | ✅ |
|
||||
| A6 標籤對齊+冪等 | 總管 | ✅ 14 repo,第二次 0/0 |
|
||||
| A7 plugin 裝得起來 | 總管 | ✅ |
|
||||
| **A8 新 session 閘會觸發** | 總管 | 見本版 release note |
|
||||
| **B1–B5 雲端** | **leo** | 還沒跑(機器碰不到 Cloud environment) |
|
||||
|
||||
**A8 與 B 全綠之前,這個 sprint 的里程碑不准關。**
|
||||
@@ -0,0 +1,212 @@
|
||||
# 43 支閘,白話盤點表
|
||||
|
||||
> 回應 `inkstone/InkStoneCo#40`:「如果加入了,我應該可以白話文看到 hooks 的內容?」
|
||||
> 這份表就是那個「白話文」——不用點開任何 `.sh` 檔,一行看懂一支閘在管什麼。
|
||||
>
|
||||
> **最高原則(票上原文)**:每一條規則你都要能在 30 秒內看懂它在管什麼。
|
||||
|
||||
## 一句話結論
|
||||
|
||||
`hooks/` 底下有 **43 個 `.sh` 檔**,`hooks.json` 實際掛上 **53 條註冊**(同一支閘常被多種情境同時掛上);
|
||||
其中 **3 支檔案存在但沒被掛上**(2 支是待人填的空範本、1 支是刻意留著沒開的止血帶,見下面「未生效」表)。
|
||||
下面按「你會在什麼時候撞到它」分組,一支一行。
|
||||
|
||||
---
|
||||
|
||||
## 怎麼讀這張表
|
||||
|
||||
| 符號 | 意思 |
|
||||
|---|---|
|
||||
| 🛑 擋 | 條件不滿足就**真的擋下**這個動作(exit 2),你或 AI 會看到一段紅字說明 |
|
||||
| 📝 記錄 | **不擋任何東西**,只是在背景寫一筆紀錄或送一段提示文字給 AI |
|
||||
| 💀 未生效 | 檔案存在,但**沒有掛進 `hooks.json`**——目前是死的,不會被執行 |
|
||||
|
||||
「對你意味著什麼」欄一律用「如果你看到 X,代表 Y」的角度寫,不寫程式邏輯。
|
||||
|
||||
---
|
||||
|
||||
## A. 你(或 AI)在終端機打指令的當下(PreToolUse / Bash)
|
||||
|
||||
| 閘名 | 對你意味著什麼 | 動作 |
|
||||
|---|---|---|
|
||||
| `github-contact-guard.sh` | AI 想碰 GitHub(`git push`、`gh api` 等)就先擋下——兩個帳號曾經因為機器人式高頻寫入被永久 flag 過。**只有讀取(clone/fetch)自由,寫入一律要你在終端機跑 `github-arm.sh` 解鎖**。 | 🛑 擋 |
|
||||
| `kbdb-api-wall-guard.sh` | AI 想繞過 KBDB 的 API、直接對資料庫下 SQL 或建新表就擋下——KBDB 的鐵律是「只能用 API、永遠只有三張表」。 | 🛑 擋 |
|
||||
| `stage-before-prod-guard.sh` | AI 想動「出貨鏈」相關指令(含 `arcrun-rag-bundles`/`github-arm`/`publish-github` 字樣)卻沒先在 stage 環境驗過就擋下。 | 🛑 擋 |
|
||||
| `main-and-prod-push-guard.sh` | 兩層確認:① subagent 想推 Gitea `main` 分支 → 一定要交回總管,總管確認過才放行;② 任何 `wrangler deploy` 打正式環境 → 一定要你親手解鎖。 | 🛑 擋 |
|
||||
| `prod-write-guard.sh` | AI 想做任何「會真的改到線上正式帳號(leo21c)」的操作(不只是 push,也包含直接呼叫會寫入的工具)就擋下,交回總管處理。 | 🛑 擋 |
|
||||
| `not-my-branch-guard.sh` | AI 想 commit 到「別的 subagent 正在施工中」的分支(登記在 `branch-holds.md`)就擋下,避免兩個 AI 互踩同一條分支。 | 🛑 擋 |
|
||||
| `leo21c-write-guard.sh` | AI 想寫東西到你私人帳號 `leo21c` 就擋下——這個帳號只准你自己用,AI 的實驗一律走別的測試帳號。 | 🛑 擋 |
|
||||
| `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 票(會漏掉「開票前先搜過」這道檢查)就擋下。 | 🛑 擋 |
|
||||
|
||||
## B. AI 想寫檔案/改程式碼的當下(PreToolUse / Write·Edit·MultiEdit)
|
||||
|
||||
| 閘名 | 對你意味著什麼 | 動作 |
|
||||
|---|---|---|
|
||||
| `guard-cross-project.sh` | 總管(頂層)想直接改某個子 repo 的程式碼(非 `.md`)就擋下——頂層只做安排交辦,實作要進那個子 repo 自己做。 | 🛑 擋 |
|
||||
| `wiki-secret-scan.sh` | 要寫進 `system-dev/wiki/` 的內容裡出現密碼/金鑰/身分證/信用卡等特徵就擋下,防止機敏資料意外留在會被反覆讀取的記憶空間裡。 | 🛑 擋 |
|
||||
| `component-guard.sh` | AI 想自己新建一個零件(component)或亂接 service binding 就擋下——逼它先想「現成零件夠不夠用」,真要建要你解鎖。 | 🛑 擋 |
|
||||
| `sdd-guard.sh` | AI 想直接動程式碼檔案,但找不到「唯一一份 active 規格文件(SDD)」對應這件事,或同時有一份以上 active 規格就擋下。 | 🛑 擋 |
|
||||
| `credential-only-guard.sh` | AI 想把金鑰真身或自製佔位符(例如 `__XXX_TOKEN__`)寫進設定檔就擋下——金鑰只准放在統一的 credential 中心。 | 🛑 擋 |
|
||||
| `arcrun-intent-guard.sh` | AI 寫的 Arcrun workflow 語法不對就擋下,而且**直接把正確寫法回貼給它**(不是只罵它錯,是教它怎麼改)。 | 🛑 擋(教學型) |
|
||||
| `subagent-first-guard.sh` | 這個對話**從頭到尾都沒有派過任何 subagent**,AI 卻要自己動手改程式碼,就先擋一次,逼它想一想「這件事能不能交給別人做」。 | 🛑 擋 |
|
||||
| `mistake-needs-ticket-guard.sh` | AI 想往 `mistakes.md`(教訓紀錄)新增一條「機制可以防止」的教訓,卻沒附對應票號就擋下——沒有票號的教訓沒有人會回頭處理。 | 🛑 擋 |
|
||||
| `pending-changes-retired.sh` | AI 想寫東西進已經廢除的 `pending-changes.md` 檔案就擋下——這個檔案已停用,規格變更一律改開 Gitea 票。 | 🛑 擋 |
|
||||
|
||||
> `kbdb-api-wall-guard.sh` 在這裡也重複掛了一次(見 A 組)——它同時守著「下指令」跟「寫檔案」兩種情境,詳見下方「重複掛載」一節。
|
||||
|
||||
## C. AI 要去翻程式碼/查資料之前(PreToolUse / Grep·Glob·Read·Bash)
|
||||
|
||||
| 閘名 | 對你意味著什麼 | 動作 |
|
||||
|---|---|---|
|
||||
| `wiki-first-search.sh` | AI 這回合**還沒查過 wiki**就要去翻程式碼或查外部資料,就先擋下、逼它用你的關鍵字先搜一次 wiki(查過一次、不論有沒有找到,這回合後面就放行)。 | 🛑 擋 |
|
||||
|
||||
## D. AI 要派工給別的 AI(subagent)之前(PreToolUse / Agent·Task)
|
||||
|
||||
| 閘名 | 對你意味著什麼 | 動作 |
|
||||
|---|---|---|
|
||||
| `subagent-wiki-guard.sh` | AI 要派一個查證/實作類任務出去,就自動在派工單裡塞一句「先查 wiki」的提示——不擋,只是順手夾帶叮嚀。 | 📝 記錄 |
|
||||
| `kbdb-api-wall-guard.sh` | 同 A/B 組,只是這裡管的是「派工單裡有沒有寫出違反 KBDB 規約的指示」。 | 🛑 擋 |
|
||||
| `micromanage-guard.sh` | 派工單寫得太細(指名檔案函式、編號步驟、要求每做一項回報一次…)就擋下——subagent 該被當成有能力的同事,不是照抄劇本的工具。 | 🛑 擋 |
|
||||
| `irreversible-dispatch-guard.sh` | 派工單裡出現「刪分支」「drop table」「rm -rf」這類不可逆動作,卻沒寫「先停下來等回覆才執行」就擋下。 | 🛑 擋 |
|
||||
| `no-ticket-no-dispatch.sh` | 派工單裡沒有寫工單號(`【工單】owner/repo#N`),或那張票已經關閉/根本不存在,就擋下——沒有票號的工作沒有人追得到進度。 | 🛑 擋 |
|
||||
|
||||
## E. 每個對話一開始(SessionStart)
|
||||
|
||||
| 閘名 | 對你意味著什麼 | 動作 |
|
||||
|---|---|---|
|
||||
| `session-start-recall.sh` | 對話一開始就自動把「全局現況」(Gitea 各 repo 的票、KBDB 的藏書地圖)推到 AI 眼前,不必等它自己想到要查。 | 📝 記錄(context 注入) |
|
||||
| `skill-deploy-drift-guard.sh` | 如果「全機真正在用的 skill」跟「repo 裡版控的正本」內容對不上,就在開場講出來——避免用著一份沒人知道已經跟正本分家的舊拷貝。 | 📝 記錄 |
|
||||
|
||||
## F. AI 想結束這一輪、要收工的時候(Stop)
|
||||
|
||||
| 閘名 | 對你意味著什麼 | 動作 |
|
||||
|---|---|---|
|
||||
| `empty-handed-stop-guard.sh` | 這一輪 AI**一個動作都沒做**卻想停下來(等你回覆),就擋下並告訴它「你的命令就是完整授權,不用再等第二次確認」。 | 🛑 擋(至多攔一次) |
|
||||
| `worklist-guard.sh` | AI 自己列過的待辦清單裡還有沒做完的步驟,卻想收工寫報告,就擋下,逼它做完剩下的步驟。 | 🛑 擋 |
|
||||
| `factory-idle-guard.sh` | AI 該去派工卻沒派(工頭停工),就擋下要它交出「已經派工的憑證」,不是隨口說一句「我會催」就算數。 | 🛑 擋 |
|
||||
| `browser-verify-guard.sh` | 這一輪 AI 宣稱「前端驗過了」,卻沒有真的用瀏覽器工具載入過,就擋下——`curl` 抓到 HTML 不算驗過。 | 🛑 擋 |
|
||||
| `self-drive-police.sh` | AI 想停下來問你「早就決定過的事」(用固定句型判斷,例如「要不要 X」「下一步做什麼」「這交給你」)就擋下,反問它查過 wiki/查過派工表了沒。 | 🛑 擋 |
|
||||
| `self-drive-judge.sh` | 跟上面同一件事,但改用小模型(haiku)判斷「換句話說」的請示句——防止 AI 只是把「要不要」改寫成「不確定是否符合期待」就閃過上一支閘。 | 🛑 擋 |
|
||||
| `delivery-police.sh` | AI 宣稱「這件事做完了」,卻看不到任何實測證據(畫面截圖、指令輸出、HTTP 狀態碼…)就擋下。 | 🛑 擋 |
|
||||
| `wiki-first-police.sh` | AI 做完事卻沒有把結論寫回 wiki 就想收工,就擋下——下次(或別的 AI)查 wiki 會查不到這次做過什麼。 | 🛑 擋 |
|
||||
| `unpushed-police.sh` | AI 改好的東西還留在本機、沒有真的推送出去給別人用,卻想收工,就擋下——「改對了但沒送到」跟沒改是一樣的。 | 🛑 擋 |
|
||||
| `claim-verify-police.sh` | Subagent 交回來的「我做完了」宣稱還沒被驗證過(對應的待驗檔案還在),你這邊卻想收工,就擋下。 | 🛑 擋 |
|
||||
|
||||
## G. Subagent 把工作交回來的時候(SubagentStop)
|
||||
|
||||
| 閘名 | 對你意味著什麼 | 動作 |
|
||||
|---|---|---|
|
||||
| `subagent-claim-worksheet.sh` | Subagent 一交回工作,就自動把它宣稱做了什麼寫成一張「待驗清單」檔案——之後總管收工前,`claim-verify-police.sh` 會檢查這張清單有沒有被處理掉。 | 📝 記錄 |
|
||||
|
||||
> 這個時機還掛了 `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,全部不擋)
|
||||
|
||||
| 閘名 | 對你意味著什麼 | 動作 |
|
||||
|---|---|---|
|
||||
| `kbdb-asked-stamp.sh` | AI 真的查過 KBDB 之後,留一個時間戳——給前面 `history-first-guard.sh` 判斷「這輪有沒有先查過」用。 | 📝 記錄 |
|
||||
| `subagent-first-stamp.sh` | AI 真的派過工之後,留一個時間戳——給 `subagent-first-guard.sh` 判斷用。 | 📝 記錄 |
|
||||
| `issue-status-autoflip.sh` | AI 一派工出去,就自動把對應的 Gitea 票改成「進行中(s/doing)」,不必等人手動改標籤。 | 📝 記錄(自動改票) |
|
||||
|
||||
## I. 你會撞到但跟「派工/收工」無關的一支(Edit·MultiEdit)
|
||||
|
||||
| 閘名 | 對你意味著什麼 | 動作 |
|
||||
|---|---|---|
|
||||
| `history-first-guard.sh` | AI 要改一個舊檔案之前,先把這個檔案過去被改過幾次、被誰在什麼情況下改過的紀錄攤在它眼前,逼它回答「這是不是已經修過的老問題」再動手。 | 🛑 擋 |
|
||||
|
||||
---
|
||||
|
||||
## 目前沒生效的 3 支(存在但沒掛進 `hooks.json`)
|
||||
|
||||
| 檔名 | 為什麼沒掛 |
|
||||
|---|---|
|
||||
| `pre-write-guard.template.sh` | 官方留的空殼範本,預設不攔任何東西。要用要自己手填禁令清單、自己去掛。檔頭寫明「別誤以為裝了它就有保護」。 |
|
||||
| `pre-write-guard.sh` | 同一個範本的另一份(看起來是填過一半的版本),同樣沒掛進 `hooks.json`。 |
|
||||
| `shadow-table-guard.sh` | 檔頭自己寫明「本檔目前是死的」——leo 2026-08-15 說過「提案封鎖方式,不是要你就去做」,所以先寫好、測過,但要不要真的掛上由你裁。 |
|
||||
|
||||
---
|
||||
|
||||
## 落差偵測(票上要求的「這件事本身值得被看到」)
|
||||
|
||||
**方法**:比對 `hooks/*.sh` 檔案清單 vs `hooks.json` 裡出現的檔名。
|
||||
|
||||
```
|
||||
$ ls hooks/*.sh | xargs -n1 basename | sort > /tmp/fs_hooks.txt
|
||||
$ grep -oE '[a-zA-Z0-9_-]+\.sh' hooks/hooks.json | sort -u > /tmp/registered_hooks.txt
|
||||
|
||||
# 有檔案、但 hooks.json 沒註冊到 → 上面「沒生效的 3 支」
|
||||
$ comm -23 /tmp/fs_hooks.txt /tmp/registered_hooks.txt
|
||||
pre-write-guard.sh
|
||||
pre-write-guard.template.sh
|
||||
shadow-table-guard.sh
|
||||
|
||||
# hooks.json 註冊了、但檔案不存在 → 空(目前沒有這種「指向空氣」的閘)
|
||||
$ comm -13 /tmp/fs_hooks.txt /tmp/registered_hooks.txt
|
||||
(無輸出)
|
||||
```
|
||||
|
||||
**這次順便抓到的另一個落差**:`.claude-plugin/plugin.json` 的說明文字寫「42 支機械閘(52 條註冊)」,
|
||||
`README.md` 也寫「42 支 + `hooks.json`」「共 52 條註冊」——但實測是 **43 支檔案、53 條註冊**。
|
||||
差 1 支、差 1 條,猜測是今天(2026-08-20)新增的 `leo21c-write-guard.sh`/`release-tag-guard.sh`/
|
||||
`ticket-api-bypass-guard.sh` 這批(檔頭日期都是今天)加了之後,兩份文件的數字沒有跟著更新。
|
||||
**這兩個檔案本次刻意沒動**(在你劃的紅線內:不准碰 `.claude-plugin/`),只在這裡把落差標出來給你看。
|
||||
|
||||
---
|
||||
|
||||
## 重複掛載(同一支閘在不只一個時機生效)
|
||||
|
||||
不是衝突,是同一支閘刻意守好幾個情境;列出來是因為你可能會納悶「怎麼同一句紅字出現在不同地方」:
|
||||
|
||||
| 閘名 | 掛了幾次 | 為什麼 |
|
||||
|---|---|---|
|
||||
| `kbdb-api-wall-guard.sh` | 3 次 | 下指令(Bash)、寫檔案(Write/Edit)、派工(Task)三種情境都可能違反 KBDB 規約,各掛一次 |
|
||||
| `prod-write-guard.sh` | 2 次 | 一次守「下指令」,一次專門守「會直接寫進線上的 MCP 工具」(不是走終端機指令的那種) |
|
||||
| `arcrun-intent-guard.sh` | 2 次 | 一次守「所有寫檔案」,一次專門加強守「呼叫 Arcrun 部署/驗證工具」這個更精準的情境 |
|
||||
| `micromanage-guard.sh` / `irreversible-dispatch-guard.sh` / `no-ticket-no-dispatch.sh` | 各 2 次 | 各自同時掛在 `Task` 與 `Agent` 兩個矩比對名稱上——這兩個名稱應該是同一種派工動作的新舊叫法,兩個都掛保證不漏接 |
|
||||
| `worklist-guard.sh` / `self-drive-police.sh` / `self-drive-judge.sh` / `delivery-police.sh` / `wiki-first-police.sh` / `unpushed-police.sh` | 各 2 次 | 一次守「總管自己想收工」(Stop),一次守「subagent 交回工作」(SubagentStop)——同一套判準用在兩種角色身上 |
|
||||
|
||||
❓ **這裡有一件我看不出來是刻意還是遺留、需要人判斷**:`micromanage-guard.sh`/`irreversible-dispatch-guard.sh`/`no-ticket-no-dispatch.sh`
|
||||
三支都**同時**掛在 `Task` 跟 `Agent` 這兩個矩比對名稱上。如果這兩個名稱在目前版本的 Claude Code 裡指的是「同一種派工工具呼叫」,
|
||||
那這是保險(兩個名字都接住,不怕哪天官方改名),沒問題;但如果其實只有一個名稱會真的觸發,另一個是舊名稱留下來沒清掉,
|
||||
那就是「規則說是兩層防護,實際只有一層在動」。這個要靠實際觸發紀錄核對,我沒有把握單靠讀檔案判斷,標成 ❓ 而不是硬下結論。
|
||||
|
||||
---
|
||||
|
||||
## 抽驗 5 支:一句話 vs 實際邏輯逐條對照
|
||||
|
||||
隨機抽了跨越不同層級/時機的 5 支,逐行核對過源碼(不是只讀檔頭):
|
||||
|
||||
1. **`github-contact-guard.sh`**——表格寫「AI 想碰 GitHub 就先擋下,只有讀取自由」。
|
||||
源碼核對:只擋 `gh api/repo/issue/pr/...` 子指令,以及 `git push`/`git remote add` 指向 github.com(含用 remote 名稱反解出網址的情況);
|
||||
`git clone/fetch/pull/ls-remote`、`curl`、`go get` 一律放行。帶憑證的「實名讀」也放行但會留一筆紀錄。**與表格描述一致。**
|
||||
2. **`main-and-prod-push-guard.sh`**——表格寫「推 main 要總管戳記、prod 部署要你解鎖」。
|
||||
源碼核對:`git push` 目標含 `main`/`master` 才擋,且要 `/tmp/.main-push-ok` 戳記綁對 repo 路徑、15 分鐘內、用過即丟才放行;
|
||||
`wrangler deploy/publish/versions deploy` 且指令裡看不出打的是 stage/youlin/geek6688 就擋,要 `.github-armed` 或 Gitea 票上核准碼才放行。**與表格描述一致**,且比表格寫得更細(例如 stage 與 geek6688 兩個白名單)。
|
||||
3. **`empty-handed-stop-guard.sh`**——表格寫「這輪零動作卻想停就擋,至多攔一次」。
|
||||
源碼核對:讀 transcript 數這回合 `tool_use` 出現次數,0 次 → exit 2;`stop_hook_active` 已為真(代表已經擋過一次)→ 直接放行,不會卡死。**與表格描述一致。**
|
||||
4. **`no-ticket-no-dispatch.sh`**——表格寫「派工單沒工單號,或票已關閉/不存在就擋」。
|
||||
源碼核對:從 `tool_input.prompt` 抓 `【工單】owner/repo#N`,抓不到 → exit 2;抓到但打 Gitea API 查到 `state=closed` 或查不到(`missing`)→ exit 2;
|
||||
查不到網路(token 拿不到、API 連不上)→ 放行(fail-open,避免網路抖動卡死工作)。**與表格描述一致**,且多了一個表格沒特別寫的細節:網路問題不擋。
|
||||
5. **`wiki-secret-scan.sh`**——表格寫「寫進 wiki 的內容有密碼/金鑰/身分證/信用卡特徵就擋」。
|
||||
源碼核對:只在 `file_path` 命中 `system-dev/wiki/*` 時啟動;用 6 類 regex(密碼賦值、PEM 私鑰、雲端金鑰前綴、JWT、連線字串內嵌帳密、身分證、信用卡)逐條檢查要寫入的內容;
|
||||
行尾標記 `wiki-secret-ok` 可豁免。**與表格描述一致。**
|
||||
|
||||
五支全部核對通過,沒有發現表格描述跟實際邏輯對不上的情況。
|
||||
|
||||
---
|
||||
|
||||
## 我看不懂、需要人看的地方
|
||||
|
||||
- **`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」還是「舊的該退休了」,這份盤點表不下判斷,留給你在下一步的分類會議裡定奪。
|
||||
|
||||
除了以上兩點,其餘 41 支的行為都能從檔頭與源碼直接讀出,沒有「猜」的部分。
|
||||
|
||||
---
|
||||
|
||||
## 這份表怎麼跟實況對帳(半年後怎麼發現漂移)
|
||||
|
||||
1. **有沒有新閘沒被收進這張表**:跑本文「落差偵測」段落的兩行 `comm` 指令,比對 `hooks/*.sh` 的檔名清單跟這張表列出的閘名清單(不是跟 `hooks.json`,那個只驗證有沒有註冊,驗不了有沒有寫進這張人話表)。
|
||||
2. **有沒有閘的行為跟這裡寫的不一樣了**:抽幾支重新讀一次源碼,跟這裡的「一句話」對一遍——就是本文「抽驗 5 支」做的事,可以照同樣方法定期重做。
|
||||
3. **`.claude-plugin/plugin.json` 與 `README.md` 的數字**:這兩處各自寫了一次「幾支、幾條註冊」,前面已經抓到一次對不上(42/52 vs 實際 43/53)。這兩個數字沒有機制保證跟著 `hooks/` 目錄自動更新,是本表發現的第一個具體漂移案例。
|
||||
@@ -34,9 +34,20 @@ except Exception: print("")
|
||||
|
||||
[ -z "$CMD" ] && exit 0
|
||||
|
||||
# ── 先排除不是「打新 tag」的動作 ──────────────────────────────────────
|
||||
# ── 判準:`git tag` 要出現在「指令位置」才算真的要打 tag ──────────────
|
||||
# 🔴 這裡原本用前綴比對排除 sed/cat/grep/echo/ls…(`echo\ *)` 這種),
|
||||
# 2026-08-20 被一個新 session 抓到洞、總管複驗屬實:
|
||||
# echo 開始 && git tag -a v9.9.9 -m test → 整條放行
|
||||
# ls && git tag -a v9.9.9 -m x → 整條放行
|
||||
# 因為前綴是 `echo `/`ls ` 就整條 exit 0,後面串什麼都不看。
|
||||
# ⇒ 就是 inkstone/InkStoneCo#36「守 prod 的閘,包一層腳本就繞過去」的同一個病,
|
||||
# 而且發生在同一天新寫的閘上。
|
||||
# 修法用 #23 已驗證過的判準:**關鍵字要在指令位置才算執行**
|
||||
# (行首、或跟在 ; & | ( && || 之後),只是被別的指令當成文字提到就不算。
|
||||
printf '%s' "$CMD" | grep -qE '(^|[;&|(`]|&&|\|\|)[[:space:]]*git[[:space:]]+tag[[:space:]]' || exit 0
|
||||
|
||||
# 讀取/刪除類的 tag 動作不是「打新 tag」,放行
|
||||
case "$CMD" in
|
||||
sed\ *|cat\ *|grep\ *|head\ *|tail\ *|wc\ *|less\ *|ls\ *|awk\ *|rg\ *|echo\ *) exit 0 ;;
|
||||
*"git tag -d"*|*"git tag --delete"*|*"git tag -l"*|*"git tag --list"*|*"git tag -n"*) exit 0 ;;
|
||||
*" --dry-run"*|*"--dry-run "*) exit 0 ;;
|
||||
esac
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
#!/bin/bash
|
||||
# 管什麼: 用 Gitea API 直接開新票時,要求這一輪有跑過跨 repo 的搜尋(/tmp/.ticket-where-ok,30 分鐘內)。
|
||||
# 為什麼: scripts/ticket 早就強制先搜,但那道閘只擋走正門的人。2026-08-20 總管走 API 側門開了 12 張票,每一張都跟舊票重疊。
|
||||
# 誤觸時怎麼關: 跑 `scripts/ticket where <關鍵字>` 先搜(之後 30 分鐘 API 也放行),或在指令裡加 `ticket-api-ok` 留痕放行。
|
||||
# ticket-api-bypass-guard.sh — 開票的「側門」也要經過同一道搜尋閘
|
||||
#
|
||||
# 來由(leo 2026-08-20 當場問「如何防止」):
|
||||
|
||||
Executable
+27
@@ -0,0 +1,27 @@
|
||||
#!/bin/bash
|
||||
# 打 tag 閘的測試(docs/TESTING.md A3)
|
||||
# 判準:版本對不上的 tag 要擋;只是讀 tag、或文字裡提到,都不准擋
|
||||
cd "$(dirname "$0")/.." || exit 1
|
||||
H=hooks/release-tag-guard.sh
|
||||
PASS=0; FAIL=0
|
||||
run(){
|
||||
printf '%s' "{\"tool_name\":\"Bash\",\"tool_input\":{\"command\":$(python3 -c 'import json,sys;print(json.dumps(sys.argv[1]))' "$2")}}" \
|
||||
| bash "$H" >/dev/null 2>&1
|
||||
got=$?
|
||||
if [ "$got" = "$1" ]; then PASS=$((PASS+1)); printf ' ✅ '; else FAIL=$((FAIL+1)); printf ' ❌ '; fi
|
||||
printf 'want=%s got=%s %.56s\n' "$1" "$got" "$2"
|
||||
}
|
||||
echo "── 該擋:版本號與 plugin.json 對不上 ──"
|
||||
run 2 'git tag -a v9.9.9 -m test'
|
||||
run 2 'echo 開始 && git tag -a v9.9.9 -m test'
|
||||
run 2 'ls && git tag -a v9.9.9 -m x'
|
||||
run 2 'cd /tmp; git tag -a v9.9.9 -m x'
|
||||
echo "── 不該擋:只是讀、只是提到 ──"
|
||||
|
||||
run 0 'git tag -l'
|
||||
run 0 'echo 等一下要 git tag -a v9.9.9'
|
||||
run 0 'git tag -a v9.9.9 -m x --dry-run'
|
||||
run 0 'grep -n "git tag" hooks/release-tag-guard.sh'
|
||||
echo
|
||||
echo "$PASS/$((PASS+FAIL)) 通過"
|
||||
[ "$FAIL" -eq 0 ]
|
||||
Executable
+41
@@ -0,0 +1,41 @@
|
||||
#!/bin/bash
|
||||
# 開票側門閘的測試(docs/TESTING.md A4)
|
||||
# 判準:4 種該擋、8 種不該擋、1 種有戳記時放行 = 13 條
|
||||
cd "$(dirname "$0")/.." || exit 1
|
||||
H=hooks/ticket-api-bypass-guard.sh
|
||||
PASS=0; FAIL=0
|
||||
run(){ # $1=want $2=cmd
|
||||
printf '%s' "{\"tool_name\":\"Bash\",\"tool_input\":{\"command\":$(python3 -c 'import json,sys;print(json.dumps(sys.argv[1]))' "$2")}}" \
|
||||
| bash "$H" >/dev/null 2>&1
|
||||
got=$?
|
||||
if [ "$got" = "$1" ]; then PASS=$((PASS+1)); printf ' ✅ '; else FAIL=$((FAIL+1)); printf ' ❌ '; fi
|
||||
printf 'want=%s got=%s %.56s\n' "$1" "$got" "$2"
|
||||
}
|
||||
SAVED=""; [ -f /tmp/.ticket-where-ok ] && SAVED=$(cat /tmp/.ticket-where-ok)
|
||||
rm -f /tmp/.ticket-where-ok
|
||||
|
||||
echo "── 該擋(沒有搜尋戳記,且真的在開新票)──"
|
||||
run 2 'curl -X POST https://git.uncle6.me/api/v1/repos/inkstone/ISEP/issues -d @b.json'
|
||||
run 2 'python3 -c "req(\"POST\", f\"{API}/repos/{REPO}/issues\", {\"title\":\"x\"})"'
|
||||
run 2 'curl --request POST "$API/repos/inkstone/InkStoneCo/issues"'
|
||||
run 2 'req("POST",f"{API}/repos/{REPO}/issues",{"title":"x","labels":[1]})'
|
||||
|
||||
echo "── 不該擋(誤攔比漏擋更該修)──"
|
||||
run 0 'curl -s "https://git.uncle6.me/api/v1/repos/inkstone/ISEP/issues?state=open"'
|
||||
run 0 'req("POST", f"{API}/repos/{REPO}/issues/14/comments", {"body":"x"})'
|
||||
run 0 'req("POST", f"{API}/repos/{REPO}/issues/5/labels", {"labels":[1]})'
|
||||
run 0 'req("PATCH", f"{API}/repos/{REPO}/issues/5", {"state":"closed"})'
|
||||
run 0 'scripts/ticket new ISEP -F /tmp/b.md --title "x"'
|
||||
run 0 'echo "等一下要開票到 /repos/x/issues"'
|
||||
run 0 'grep -n issues hooks/ticket-api-bypass-guard.sh'
|
||||
run 0 'curl -s "$API/repos/inkstone/Arcrun/issues?state=open&limit=100"'
|
||||
|
||||
echo "── 有新鮮戳記時放行 ──"
|
||||
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'
|
||||
|
||||
rm -f /tmp/.ticket-where-ok
|
||||
[ -n "$SAVED" ] && printf '%s' "$SAVED" > /tmp/.ticket-where-ok
|
||||
echo
|
||||
echo "$PASS/$((PASS+FAIL)) 通過"
|
||||
[ "$FAIL" -eq 0 ]
|
||||
+27
-7
@@ -47,15 +47,35 @@ def die(msg, code=2):
|
||||
|
||||
def token():
|
||||
root = os.environ.get("CLAUDE_PROJECT_DIR") or os.getcwd()
|
||||
# 掃這個 repo 的**所有** remote,找第一個指向本站、且帶憑證的。
|
||||
# 原本寫死只認名叫 "gitea" 的 remote —— 2026-08-20 實撞:
|
||||
# ISEP 這個新 repo 的 remote 叫 origin,於是這支腳本在那裡整個跑不起來,
|
||||
# 「開票前先搜」那道閘在新 repo 等於不存在。閘不該綁在某個 remote 的名字上。
|
||||
host = HOST.split("//")[-1].rstrip("/")
|
||||
try:
|
||||
url = subprocess.run(["git", "-C", root, "remote", "get-url", "gitea"],
|
||||
capture_output=True, text=True, timeout=20).stdout.strip()
|
||||
out = subprocess.run(["git", "-C", root, "remote", "-v"],
|
||||
capture_output=True, text=True, timeout=20).stdout
|
||||
except Exception:
|
||||
url = ""
|
||||
m = re.search(r"//[^:]+:([^@]+)@", url)
|
||||
if not m:
|
||||
die("🔴 拿不到 gitea token(該 repo 的 gitea remote 沒有帶憑證)")
|
||||
return m.group(1)
|
||||
out = ""
|
||||
for line in out.splitlines():
|
||||
if host not in line:
|
||||
continue
|
||||
m = re.search(r"//[^:/]+:([^@]+)@", line)
|
||||
if m:
|
||||
return m.group(1)
|
||||
# 退而求其次:環境變數(雲端/CI 沒有帶憑證的 remote 時走這條)
|
||||
for env in ("GITEA_TOKEN_CLAUDE_CODE", "GITEA_TOKEN"):
|
||||
v = os.environ.get(env)
|
||||
if v:
|
||||
return v
|
||||
die(f"""🔴 拿不到 {host} 的 token
|
||||
|
||||
這個 repo 的 remote 裡沒有一個帶憑證且指向 {host}:
|
||||
{out.strip() or "(沒有任何 remote)"}
|
||||
|
||||
擇一:
|
||||
• 讓某個 remote 帶憑證(多數 repo 的 gitea/origin 本來就有)
|
||||
• 或設環境變數 GITEA_TOKEN_CLAUDE_CODE""")
|
||||
|
||||
|
||||
def api(path, payload=None, method=None):
|
||||
|
||||
Reference in New Issue
Block a user