diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 78f5e86..dfd01e9 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -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" diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 30000fa..48a632c 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -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", diff --git a/README.md b/README.md index b3d1be6..2cec0be 100644 --- a/README.md +++ b/README.md @@ -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)。** diff --git a/docs/TESTING.md b/docs/TESTING.md new file mode 100644 index 0000000..74694e0 --- /dev/null +++ b/docs/TESTING.md @@ -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 的里程碑不准關。** diff --git a/docs/hooks-inventory.md b/docs/hooks-inventory.md new file mode 100644 index 0000000..7515009 --- /dev/null +++ b/docs/hooks-inventory.md @@ -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/` 目錄自動更新,是本表發現的第一個具體漂移案例。 diff --git a/hooks/release-tag-guard.sh b/hooks/release-tag-guard.sh index 2b8b9a7..fb237b7 100755 --- a/hooks/release-tag-guard.sh +++ b/hooks/release-tag-guard.sh @@ -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 diff --git a/hooks/ticket-api-bypass-guard.sh b/hooks/ticket-api-bypass-guard.sh index f9d00a8..10ce272 100755 --- a/hooks/ticket-api-bypass-guard.sh +++ b/hooks/ticket-api-bypass-guard.sh @@ -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 當場問「如何防止」): diff --git a/scripts/test-release-tag-guard.sh b/scripts/test-release-tag-guard.sh new file mode 100755 index 0000000..7493b5c --- /dev/null +++ b/scripts/test-release-tag-guard.sh @@ -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 ] diff --git a/scripts/test-ticket-api-bypass-guard.sh b/scripts/test-ticket-api-bypass-guard.sh new file mode 100755 index 0000000..ca99599 --- /dev/null +++ b/scripts/test-ticket-api-bypass-guard.sh @@ -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 ] diff --git a/scripts/ticket b/scripts/ticket index c086cc5..cd3320b 100755 --- a/scripts/ticket +++ b/scripts/ticket @@ -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):