From c7af690c2b9835da1e2a56853ef061fec930ac31 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 31 Aug 2026 00:56:37 +0000 Subject: [PATCH] =?UTF-8?q?sdd-guard=20=E9=80=80=E5=BD=B9=EF=BC=9A?= =?UTF-8?q?=E8=AE=93=E3=80=8C=E5=8F=96=E6=B6=88=20Active=20SDD=E3=80=8D?= =?UTF-8?q?=E9=80=99=E5=80=8B=E8=A3=81=E6=B1=BA=E7=9C=9F=E7=9A=84=E5=9F=B7?= =?UTF-8?q?=E8=A1=8C=E5=BE=97=E4=B8=8B=E5=8E=BB=EF=BC=88inkstone/ISEP#91?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit leo 2026-08-16 在 inkstone/InkStoneCo#40 → comment 2942 裁定「取消 Active SDD」, 11 天沒被執行。原因不是有人偷懶,是**執行它的第一步會鎖死自己**: sdd-guard.sh 是 fail-closed 的 · 動 code 檔時 status: active 不是恰好 1 份 → 擋(0 份也擋) · 路徑所在的 repo 沒有 3-specs → 也擋 ⇒ 照裁決把最後那份 active 拿掉 → 變 0 份 → 任何人動任何 .ts/.py/.go 全被擋。 而 ISEP 這個 repo 自己就沒有 3-specs——本票開工第一件事實測到: $ echo '{"tool_name":"Edit","tool_input":{"file_path":".../hooks/lib/dispatch_parse.py"}}' \ | bash hooks/sdd-guard.sh 🚫 SDD 協議攔截:… 找不到任何 SDD exit 2 **它一直在誤攔 ISEP 自己,只是沒人回報。** ── 這一版做了什麼 ──────────────────────────────────── · hooks/sdd-guard.sh 刪除,hooks.json 取消註冊(85 → 84 條,只少這一條) · commands/sdd-check.md 改寫:SDD 只記「起初的樣子」,任務本體在 Gitea 票; 找不到 SDD 不再是停下來的理由,找不到票才是 · agents/inkstoneco-hand.md 拿掉「任何時刻只允許一份 status: active」那條紅線 · hooks/lib/path-resolve.sh 只加註解:它的唯一 caller 走了,但別順手刪 (#22 學到的東西住在裡面,十幾支閘還在用它要修的那個寫法) ── 迴歸測試:測的不是「檔案刪了沒」,是「那個擋還會不會發生」── hooks/tests/sdd-guard-retired.test.sh(通過 8/失敗 0,離線): 把「裁決執行完之後的世界」(沒有 3-specs 的 repo、兩份 status: active 的 repo) 丟給 hooks.json 上**整組** Write|Edit|MultiEdit 的閘——清單當場從 hooks.json 讀、 不寫死——不准有任何一支用 SDD/3-specs 當理由擋下來。 ⇒ 日後有人換個檔名把同一個形狀種回來,這支照樣紅。 🔴 判準刻意不是「一支閘都不准擋」:同組還住著跟 SDD 無關、且看 session 狀態 決定擋不擋的閘(history-first/subagent-first)。把它們算失敗,這支測試會在別人 改別的東西時無故變紅,紅久了就沒人看——誤攔比漏擋更該修,對測試一樣成立。 **紅的證明**:把 sdd-guard 暫時復原(檔案+註冊)重跑 → 通過 1/失敗 7, 四條行為格全部指名 sdd-guard.sh。已還原。 ── 自己跑過的 ────────────────────────────────────── · hooks/tests/sdd-guard-retired.test.sh 通過 8/失敗 0 · 逐支點名 hooks.json(README「裝什麼」那道指令) 84 條,對 main 做集合差: 少了 sdd-guard.sh × 1,多出 0 支,其餘一支不差 · 盤點數字全部在這棵樹上實數,不是加減推: ls hooks/*.sh|wc -l = 61(原 62) grep -c '"command":' hooks/hooks.json = 84(原 85) agents 7/commands 7/skills 2/scripts 48(皆未變動) · 盤點表對帳三格(hooks-inventory 自己寫死的那三道):三格皆無輸出 · scripts/check-version-consistency.sh ✅ 0.16.2 一致 · claude plugin validate . ✅ Validation passed · hooks/tests/ 全部離線測試 24 支重跑:本次改動 0 退步 (dispatch-format-guard 40/52、prod-write-guard 18/19-fail、 stage-before-prod-guard 9/7-fail、另 3 支需帶參數/建不起沙盒—— **六支在 gitea/main 上逐支重跑結果一模一樣,是既有狀態不是本次造成**) ── 沒做、也不該由這張票做的 ───────────────────────── · inkstone/InkStoneCo 那半(拿掉 active SDD + 刪 CLAUDE.md「單一活性鐵律」段) ——那是 inkstoneco-hand 的 repo;而且順序上本來就要等這一版出去、 兩邊 /plugin update 之後才動得,先拿掉就鎖死 · checkbox 分診(掛 inkstone/InkStoneCo#49,票上明寫不要另開票) · 版本號:待總管定版 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016ZBu4Sa1cGntKFRBYNZ6xs --- .claude-plugin/plugin.json | 2 +- README.md | 2 +- agents/inkstoneco-hand.md | 4 +- commands/sdd-check.md | 98 ++++++----- docs/TESTING.md | 32 ++++ docs/hooks-inventory.md | 31 +++- hooks/hooks.json | 4 - hooks/lib/path-resolve.sh | 6 + hooks/sdd-guard.sh | 234 -------------------------- hooks/tests/sdd-guard-retired.test.sh | 154 +++++++++++++++++ hooks/tests/sdd-guard.test.sh | 90 ---------- system-dev/wiki/mistakes.md | 36 ++++ 12 files changed, 317 insertions(+), 376 deletions(-) delete mode 100755 hooks/sdd-guard.sh create mode 100755 hooks/tests/sdd-guard-retired.test.sh delete mode 100755 hooks/tests/sdd-guard.test.sh diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 509bbed..1dd63e0 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "isep", - "description": "InkStone Environment Plugin —— leo 的 Claude Code 環境唯一真相源:62 支機械閘(85 條註冊,白話盤點見 docs/hooks-inventory.md)、7 支 slash command、2 支 skill、7 位有名字的工人(agents/,見 docs/governance/worker-roster.md)、48 支腳本,外加治理規範與標籤真相源。本機與雲端裝同一份,沒有子集。", + "description": "InkStone Environment Plugin —— leo 的 Claude Code 環境唯一真相源:61 支機械閘(84 條註冊,白話盤點見 docs/hooks-inventory.md)、7 支 slash command、2 支 skill、7 位有名字的工人(agents/,見 docs/governance/worker-roster.md)、48 支腳本,外加治理規範與標籤真相源。本機與雲端裝同一份,沒有子集。", "version": "0.16.2", "keywords": [ "inkstone", diff --git a/README.md b/README.md index ea77511..5a0cb3d 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ | | 數量 | 是什麼 | |---|---|---| -| `hooks/` | 62 支 + `hooks.json` | 全部機械閘(PreToolUse/Stop/SubagentStop/SessionStart/PostToolUse/UserPromptSubmit 共 85 條註冊)。**一支一行的白話盤點在 `docs/hooks-inventory.md`,那裡才是這兩個數字的家** | +| `hooks/` | 61 支 + `hooks.json` | 全部機械閘(PreToolUse/Stop/SubagentStop/SessionStart/PostToolUse/UserPromptSubmit 共 84 條註冊)。**一支一行的白話盤點在 `docs/hooks-inventory.md`,那裡才是這兩個數字的家** | | `agents/` | 7 位 | **工人名單**(`inkstone/ISEP#86`)——派工時指名派給誰,規約見 `docs/governance/worker-roster.md` | | `commands/` | 7 支 | `/wiki-recall` `/ship-check` `/cp-write` … | | `skills/` | 2 支 | | diff --git a/agents/inkstoneco-hand.md b/agents/inkstoneco-hand.md index e5ae910..a50a52a 100644 --- a/agents/inkstoneco-hand.md +++ b/agents/inkstoneco-hand.md @@ -16,6 +16,8 @@ description: 頂層知識庫的工人——wiki、SDD、Critical Path、決策 ## 紅線 - **頂層不寫業務程式碼。** 這一層只放跨專案的整理與判準。 -- **任何時刻只允許一份 `status: active` 的 SDD**;不准自行建 SDD。 +- **SDD 不是任務清單**(leo 2026-08-16 取消 Active SDD,`inkstone/InkStoneCo#40`): + 它只記「起初的樣子」,**不帶 `status:`、不帶任務 checkbox**。任務本體在 Gitea 票。 + ⇒ 不准自行建 SDD,也不准把進度寫回 SDD。 - 規格層的變更 ⇒ 開一張 Gitea 票(`Human` + 指派 `Leo` + `s/triage`),不要自己改規格。 - 寫給 leo 看的 md 一律**巢狀 bullet**(Logseq outliner),禁表格平攤。 diff --git a/commands/sdd-check.md b/commands/sdd-check.md index fcf6e87..56e16c8 100644 --- a/commands/sdd-check.md +++ b/commands/sdd-check.md @@ -1,65 +1,81 @@ --- name: sdd-check description: | - 開始任何開發任務前確認有沒有對應的 SDD——要寫 code、開新功能、 - 或不確定「這件事屬於哪份規格」時自動載入。 - 依 D35 SDD 生命週期鐵律:任何時刻只允許一份 status: active 的 SDD, - 所有開發任務對應它的 tasks,找不到就停下來問(不得自行建 SDD); + 動手前把這件事的來歷讀齊——要寫 code、開新功能、 + 或不確定「這件事當初為什麼長這樣」時自動載入。 + 🔴 Active SDD 已於 2026-08-16 取消(inkstone/InkStoneCo#40): + **任務本體在 Gitea 票,不在 SDD**;SDD 只記「起初的樣子」, + 沒有 status、沒有唯一活性、也不再養任務 checkbox。 + ⇒ 找不到 SDD 不是停下來的理由,找不到票才是;也不要為了動手而現編一份 SDD。 規格層變更改開 Gitea 票(Human+指派 Leo)後停止等 confirm——pending-changes.md 已於 2026-08-19 廢除,禁止寫入。 --- -# /sdd-check — 確認當前任務有沒有對應 SDD +# /sdd-check — 動手前把這件事的來歷讀齊 -動手前執行。確保 CC 有全局觀,不會在沒有設計文件的情況下猛衝。 +## 先講清楚它現在是什麼 + +leo 2026-08-16(`inkstone/InkStoneCo#40` → comment 2942): + +> 「**取消 Active SDD**⋯⋯所有文件中都不帶有任務 checkbox,因為移動到 Gitea 來管理, +> SDD 會脫節,它所做的是記錄起初的樣子⋯⋯實際情況靠 issues, PR, release 等機制來管理。」 + +所以三件事分家(`docs/governance/sdd-gitea-governance.md` §0 公理 1): + +``` +意圖真相 → SDD(docs/3-specs/) 未來式:當初打算怎麼做 +狀態真相 → Gitea 票/PR/release 現在式:現在做到哪、誰在做 +知識真相 → wiki 過去式:撞過什麼、學到什麼 +``` + +🔴 **這支命令已經不是一道閘了。** 舊版靠 `sdd-guard.sh` 在動 code 前強制要有 +「恰好一份 status: active 的 SDD」,那支閘於 `inkstone/ISEP#91` 退役—— +它是 fail-closed 的,裁決一執行(active SDD 歸零)就會鎖死所有 code 寫入。 --- ## 執行流程 -### 第一步:理解任務 +### 第一步:這件事的**票**是哪一張 -確認使用者要做什麼: -- 涉及哪個子系統? -- 是新功能還是修改現有功能? -- 影響範圍? +**這一步不能跳,也是唯一會讓你停下來的一步。** -### 第二步:尋找對應 SDD +- 有票號 → 讀它(含整串留言:裁決、假設、前手交回的下一步都在那裡) +- 沒票號 → **先問清楚是哪一張,或先把票開出來**,不要憑一句話開工 + (`commands/issue-handle.md`:Issue 是唯一任務介面) -在 `docs/3-specs/` 下尋找對應的子系統目錄,確認有沒有: -- `design.md`(設計文件) -- `tasks.md`(任務清單) +### 第二步:這件事的**來歷**在哪份 SDD -### 第三步:根據結果回應 +在 `system-dev/docs/3-specs/` 下找對應子系統的 `design.md`。 + +- 找到 → 讀它,弄清楚「當初為什麼這樣設計、有哪些邊界不能動」 +- **找不到 → 照樣可以動手**。票才是任務本體; + 沒有 SDD 只代表這塊當初沒有寫下設計,不代表這件事沒被批准 + +🔴 **不要為了動手而現編一份 SDD**,也不要去改任何 `status:` frontmatter—— +那是已經取消的制度的殘留物。 + +🔴 **不要把任務 checkbox 寫進 SDD**。要追蹤進度就開票/改票的狀態標籤; +寫進文件裡的 checkbox 沒有人會回來關掉它(`inkstone/InkStoneCo#49` +清的就是這一批:378 條沒人維護的 checkbox)。 + +### 第三步:把讀到的東西講出來再動手 + +回覆開頭一行交代即可: -**情況 A:找到對應 SDD** ``` -✅ 找到 SDD:docs/3-specs/[子系統]/ -📋 design.md:[確認] -📋 tasks.md:[確認,列出相關 task] -🎯 對應 task:[編號和描述] -繼續嗎? +📌 票: —— <一句話它要解什麼> +📋 來歷: +🚧 邊界:<這次不准動的東西> ``` -**情況 B:找不到 SDD,任務明確** -``` -⚠️ 找不到對應 SDD -任務:[描述] -建議在 docs/3-specs/[建議子系統名]/ 建立 SDD +--- -要我幫你起草 design.md 嗎?(需要你確認後才動手) -``` +## 什麼時候該停下來問 -**情況 C:找不到 SDD,任務模糊** -``` -⚠️ 找不到對應 SDD,而且任務範圍不夠清楚 -請先回答: -1. 這個功能屬於哪個子系統? -2. 完成的標準是什麼? -3. 有沒有不能動的邊界? -``` +只有這幾種,**都跟 SDD 在不在無關**: -### 注意 +- 找不到對應的票,也問不出是哪一張 +- 票上的要求跟現行規範互相衝突(貼出兩邊出處,給出你的推測,再問) +- 命中四題公式:花錢/不可逆/跨專案結構/品味方向 -- 找不到 SDD **不等於可以直接動手** -- 小修改(修 bug、改文字)可以豁免,但要明確說「這是小修改,範圍是 X」 -- 新功能、架構變動、跨模組的修改 → 一定要有 SDD +其餘情況做出最合理的假設、把假設寫進 commit message 或票的留言,繼續走。 diff --git a/docs/TESTING.md b/docs/TESTING.md index abba36d..b7e9dd8 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -515,6 +515,38 @@ D 群同時釘住反面:認不出來(`cd $VAR`、`cd -`、cd 到不是 repo - ① 紅 ⇒ 連總管在自己的目錄裡切分支都被擋。那不是嚴格,那是把共用目錄的主人趕出去 - ㉞㉟ 紅 ⇒ `prune` 做錯了。㉞是沒清掉說謊的登記;**㉟是把還在的 worktree 也弄掉了,那是災難** +### A25 — `sdd-guard` 退役了,而且退乾淨了:8 條 +``` +bash hooks/tests/sdd-guard-retired.test.sh +``` +**該看到**:`通過 8 / 失敗 0`。離線,自己開兩顆假 git repo,跑完自己清。 + +**它在守什麼**(`inkstone/ISEP#91`):leo 2026-08-16 在 `inkstone/InkStoneCo#40` 裁定 +**取消 Active SDD**——任務狀態搬到 Gitea 管,SDD 只記「起初的樣子」。 +但 `sdd-guard.sh` 是 **fail-closed** 的:`status: active` 不是恰好 1 份就擋(**0 份也擋**), +路徑所在的 repo 沒有 `3-specs` 也擋。⇒ **照裁決把 active SDD 拿掉,會當場鎖死所有 code 寫入**, +這就是那個裁決躺了 11 天沒人敢執行的真正原因。 + +🔴 **這支測的不是「檔案刪掉了沒有」,是「那個擋還會不會發生」**: +它把「裁決執行完之後的世界」(沒有 `3-specs` 的 repo、有兩份 `status: active` 的 repo) +丟給 `hooks.json` 上**整組** `Write|Edit|MultiEdit` 的閘——清單是當場從 `hooks.json` 讀的, +不寫死——**不准有任何一支用 SDD/3-specs 當理由擋下來**。 +⇒ 日後有人換個檔名把同一個形狀種回來,這支照樣紅。 + +**失敗**: +- ②那四條任一紅 ⇒ 裁決又被種回去了。**最要看的是最後一條**(ISEP 自己的 `hooks/lib/*.py`): + ISEP 這個 repo 本身就沒有 `3-specs`,退役前那一格是紅的——它是這件事的活體證據 +- ③紅 ⇒ 條文回來了。有人在 `commands/`/`agents/`/`skills/`/`hooks/` 裡 + 又寫了一次「只准一份 active SDD」,而**條文會被載入,載入就會被照做** +- ①的第三條(`hooks.json` 仍是合法 JSON)紅 ⇒ 合併把檔案弄壞了; + 但**它只證明語法沒壞、不證明閘還在**——閘在不在要另外跑 README 那道逐支點名 + +📌 **這支只管 ISEP 這一半。** 裁決的另外兩件在 `inkstone/InkStoneCo`: +拿掉那份 active SDD + 刪 `CLAUDE.md` 的「單一活性鐵律」段,以及 +`system-dev/docs/` 底下的任務 checkbox 分診(掛 `inkstone/InkStoneCo#49`)。 +🔴 **順序不可顛倒**:要等這一版 ISEP 出去、兩邊 `/plugin update` 之後, +那半才動得——先拿掉 active SDD 就會鎖死。 + ### A5 — 開票前的搜尋是跨 repo 的 ``` python3 scripts/ticket where 標籤 模組化 diff --git a/docs/hooks-inventory.md b/docs/hooks-inventory.md index 9a7b27a..53b6354 100644 --- a/docs/hooks-inventory.md +++ b/docs/hooks-inventory.md @@ -1,4 +1,4 @@ -# 62 支閘,白話盤點表 +# 61 支閘,白話盤點表 > 回應 `inkstone/InkStoneCo#40`:「如果加入了,我應該可以白話文看到 hooks 的內容?」 > 這份表就是那個「白話文」——不用點開任何 `.sh` 檔,一行看懂一支閘在管什麼。 @@ -7,14 +7,14 @@ ## 一句話結論 -`hooks/` 底下有 **62 個 `.sh` 檔**,`hooks.json` 實際掛上 **85 條註冊**(同一支閘常被多種情境同時掛上); +`hooks/` 底下有 **61 個 `.sh` 檔**,`hooks.json` 實際掛上 **84 條註冊**(同一支閘常被多種情境同時掛上); 其中 **3 支檔案存在但沒被掛上**(2 支是待人填的空範本、1 支是刻意留著沒開的止血帶,見下面「未生效」表)。 下面按「你會在什麼時候撞到它」分組,一支一行。 > 🔴 **這兩個數字上一版是錯的(2026-08-26 實際數過才發現)**:本頁原本寫「43 個檔、53 條註冊」, > 而當時真實是 **45 個檔、55 條註冊**——中間有兩支閘進來時沒有回頭改這裡。 > 現在的寫法是實際數出來的: -> `ls hooks/*.sh | wc -l` = 62;`grep -c '"command":' hooks/hooks.json` = 85。 +> `ls hooks/*.sh | wc -l` = 61;`grep -c '"command":' hooks/hooks.json` = 84。 > ⚠️ **冒號不能省**:`grep -c '"command"'`(沒冒號)會連 `"type": "command"` 一起數到,回 **120**。 > 本頁 2026-08-27 之前寫的是沒冒號那版——**照著它跑會拿到一個跟本頁不符的數字**。 > **一份會偷偷過期的盤點表,跟沒有盤點表差不多**——見本頁最後「怎麼跟實況對帳」那段。 @@ -78,6 +78,12 @@ > (`ls -p scripts | grep -v / | wc -l`,只數檔案)在合併後的樹上實數是 **48**。 > **同一個病,第 N 次,只是換一欄。** +> 📌 **`inkstone/ISEP#91`(2026-08-31)退一支、−1 條**:`sdd-guard.sh`(B 組)退役, +> 執行 leo 2026-08-16 在 `inkstone/InkStoneCo#40` 那個「取消 Active SDD」的裁決。 +> 62→**61** 支、85→**84** 條,兩個數字都是**在自己這棵樹上當場數出來的** +> (`ls hooks/*.sh | wc -l`/`grep -c '"command":' hooks/hooks.json`),不是拿上一版減一。 +> 退役的理由與順序寫在 B 組表格底下那段。 + > 📌 **`0.10.0`(`inkstone/ISEP#81`,2026-08-28)進來一支**:`pr-verdict-guard.sh`(F 組,Stop)。 > 51→**52** 支、64→**65** 條,兩個數字都是加完之後當場數出來的(指令同上)。 > 順手改掉一個過期的數字:描述欄長期寫「27 支腳本」,實數是 **34** @@ -133,7 +139,6 @@ | `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 卻要自己動手改程式碼,就先擋一次,逼它想一想「這件事能不能交給別人做」。 | 🛑 擋 | @@ -141,6 +146,24 @@ | `wiki-size-guard.sh` | AI 想**一口氣砍掉 wiki 檔一大半內容**(淨縮水超過 800 字且超過原本 45%)就擋一次——那不是一次編輯,那是一次壓縮,而**壓縮會弄丟東西,弄丟的當下沒有人會發現**。出路是走 `scripts/wiki-compress`:它逼你附票號、把壓掉了什麼寫進 `.compress-log.md`,並用內文雜湊**逐條對帳**證明沒弄丟。只是改字、加字、小修一律不碰;真要手改就在內容裡放 `wiki-compress-ok` 留痕。 | 🛑 擋(至多攔一次) | | `pending-changes-retired.sh` | AI 想寫東西進已經廢除的 `pending-changes.md` 檔案就擋下——這個檔案已停用,規格變更一律改開 Gitea 票。 | 🛑 擋 | +> 🔴 **`sdd-guard.sh` 已於 `inkstone/ISEP#91` 退役**(原本掛在這一組)。 +> 它擋的是「動 code 檔時,`status: active` 的 SDD 不是恰好一份」。 +> leo 2026-08-16(`inkstone/InkStoneCo#40` → comment 2942)已裁定**取消 Active SDD**: +> 任務狀態搬到 Gitea 管,SDD 只記「起初的樣子」,不帶任務 checkbox。 +> ⇒ 那支閘要的東西,制度上已經不會再有人生產。 +> +> **它為什麼非退不可,而不是放著沒關係**:它是 fail-closed 的—— +> `active` 不是恰好 1 份就擋,**0 份也擋**;路徑所在的 repo 沒有 `3-specs` 也擋。 +> 所以「照裁決把 active SDD 拿掉」這個動作本身,會把所有人動任何 +> `.ts`/`.py`/`.go` 的路一起鎖死。這就是那個裁決躺了 11 天沒人敢執行的真正原因。 +> 而 **ISEP 這個 repo 自己就沒有 `3-specs`**:退役之前,改自己的 `hooks/lib/*.py` +> 當場被這支閘擋下——那不是假設,是這張票開工第一件事實測到的。 +> ⇒ 退役的順序是「**先讓閘退役,再拿掉 active SDD**」,顛倒過來就鎖死。 +> +> 迴歸測試在 `hooks/tests/sdd-guard-retired.test.sh`:它測的不是「檔案刪了沒有」, +> 而是把「裁決執行完之後的世界」丟給整組寫檔閘,**不准有任何閘用 SDD 當理由擋下來** +> ——有人日後換個檔名把同一個形狀種回來,那支測試照樣會紅。 + > `kbdb-api-wall-guard.sh` 在這裡也重複掛了一次(見 A 組)——它同時守著「下指令」跟「寫檔案」兩種情境,詳見下方「重複掛載」一節。 ## C. AI 要去翻程式碼/查資料之前(PreToolUse / Grep·Glob·Read·Bash) diff --git a/hooks/hooks.json b/hooks/hooks.json index d39137c..391d1c3 100644 --- a/hooks/hooks.json +++ b/hooks/hooks.json @@ -99,10 +99,6 @@ "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/component-guard.sh" }, - { - "type": "command", - "command": "${CLAUDE_PLUGIN_ROOT}/hooks/sdd-guard.sh" - }, { "type": "command", "command": "${CLAUDE_PLUGIN_ROOT}/hooks/credential-only-guard.sh" diff --git a/hooks/lib/path-resolve.sh b/hooks/lib/path-resolve.sh index a19663d..6f990c9 100644 --- a/hooks/lib/path-resolve.sh +++ b/hooks/lib/path-resolve.sh @@ -24,6 +24,12 @@ # 這些全部沒有本檔「先確認到底在不在 repo 裡」的判斷;本檔先在 sdd-guard.sh 落地, # 其餘要不要跟進、要不要改用這支共用函式,另案處理,不在本票(#22)範圍內一次改完。 # +# ⚠️ **`sdd-guard.sh` 已於 `inkstone/ISEP#91` 退役**(執行 leo「取消 Active SDD」的裁決), +# 所以本檔目前**沒有任何 caller**——但它不是死 code:上面那份清單裡的十幾支閘全都還在用 +# 「猜專案根」那個寫法,本檔就是要給它們用的解法。**不要因為沒人 source 就順手刪掉**, +# 刪掉等於把 #22 學到的東西一起丟了。 +# (本段只加註解,不動下面任何一行邏輯——`8718658` 那輪修的判定行為原封不動。) +# # 用法: # source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib/path-resolve.sh" # if ! path_in_git_worktree "$FILE_PATH"; then diff --git a/hooks/sdd-guard.sh b/hooks/sdd-guard.sh deleted file mode 100755 index f97827c..0000000 --- a/hooks/sdd-guard.sh +++ /dev/null @@ -1,234 +0,0 @@ -#!/bin/bash -# 管什麼: Write/Edit 動 code 檔(.ts/.py/.go…)前,要不要有對應的一份 status: active SDD(design.md)。 -# 為什麼: SDD 生命週期鐵律——動 code 前必須有規格可對,且整個 repo 同一時刻只准一份 active。 -# 把「動手前先讀 SDD」從只能靠人記,升級成機器擋(system-dev/docs/3-specs/SDD-LIFECYCLE.md)。 -# 誤觸時怎麼關: 改文件/測試檔/3-specs 自己一律放行(下方 case 已排除);不在任何 git repo -# 裡的路徑(scratchpad、/tmp 暫存檔)一律放行,SDD 管不到它們。真的要臨時豁免 -# 一次小改動,說明範圍後由人手動放行——這支閘不設「一行關掉」的旗標。 -# -# PreToolUse hook — 動 code 前檢查 SDD + 單一活性 SDD 鐵律(issue #6) -# wishlist §2:把 /sdd-check 從「命令要人打」升級成「hook 自動攔」。 -# 生命週期規則全文:system-dev/docs/3-specs/SDD-LIFECYCLE.md -# -# 掛在 settings.json 的 PreToolUse(matcher: Write|Edit)。 -# stdin 收到 JSON:{ tool_name, tool_input: { file_path, ... } } -# 行為: -# 1. status: active 的 SDD > 1 份 → 單一活性鐵律已被違反,**不論寫什麼檔**一律擋(exit 2), -# 先收斂到一份再說。 -# 2. 動 code 檔(.ts/.go/...)→ 需要「恰好 1 份」active SDD;0 份 → 擋。 -# 3. 向下相容:3-specs 下完全沒有任何 design.md 帶 frontmatter(老 repo 尚未遷移生命週期制度) -# → 退回舊行為:有 design.md 就放行+提醒,沒有才擋。避免 template update 後老 repo 立刻全紅。 -# -# 誠實限制(抄 arcrun):只擋語法層明顯違規(直接寫 code 檔)。 -# 藏在 helper 裡、用 bash 繞道的改動擋不到。 -# 價值是「想跳過會被抓到 + 留痕可審」,不是技術防偽。絕不聲稱「不可能繞過」。 - -set -euo pipefail - -source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib/path-resolve.sh" - -INPUT=$(cat) - -# 解析 file_path。優先用 jq,沒有 jq 退回 grep(容錯)。 -if command -v jq >/dev/null 2>&1; then - FILE_PATH=$(printf '%s' "$INPUT" | jq -r '.tool_input.file_path // empty') -else - FILE_PATH=$(printf '%s' "$INPUT" | grep -o '"file_path"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed 's/.*"file_path"[[:space:]]*:[[:space:]]*"//;s/"$//') -fi - -# 拿不到路徑 → 不擋(容錯,寧可放過也不誤殺) -[ -z "$FILE_PATH" ] && exit 0 - -# 🔴 2026-08-02 修:原本寫死相對路徑 `system-dev/docs/3-specs`, -# 但 hook 的工作目錄是**頂層 InkStoneCo**,改子 repo 的 code 時就去頂層找 SDD -# ⇒ 看不到子 repo 自己那份 ⇒ **一律誤報「找不到任何 SDD」**。 -# 實撞:改 products/arcrun-rag/collector/... 被擋,但該 repo 明明有 -# system-dev/docs/3-specs/daemon-beta/design.md(status: active)。 -# ⇒ 改成從被改檔案往上找最近的 system-dev/docs/3-specs(子 repo 優先,找不到才用頂層)。 -# ⚠️ 只往上找到「頂層 InkStoneCo」為止——不可讓任意路徑(如 /private/tmp/…) -# 退回頂層 SDD 而被放行,那會把原本擋得住的情況變成擋不住。 -# -# 🔴 2026-08-20 修(inkstone/InkStoneCo#22):上面這套邏輯有兩個洞,都是總管 08-12 實撞的: -# -# 洞 A — scratchpad 暫存檔被當成「code 變動」: -# `/private/tmp/.../scratchpad/foo.py` 不在 `$_root` 底下、往上找不到 3-specs, -# 於是走到「找不到 SDD」擋下路徑——但 scratchpad 是 session 專用暫存區,從不進版控, -# SDD 管的是 repo 裡的產品程式碼,管不到它。**先問「這條路徑到底在不在某個 git repo -# 裡」(`path_in_git_worktree`,見 lib/path-resolve.sh),不在 ⇒ 這道閘天生管不到 -# ⇒ 直接放行**,不必先繞去猜專案根、再證明找不到才擋。 -# 用「有沒有 .git 可尋」判斷,比列舉路徑關鍵字(/tmp、scratchpad…)更穩: -# 不必窮舉每一種暫存區的命名法,任何真的不在 repo 裡的路徑都一視同仁。 -# -# 洞 B — 訊息裡印出字面的 `/nonexistent`: -# 舊版用 `/nonexistent/3-specs` 當內部 sentinel,讓「找不到 SDD」的既有擋下路徑可以 -# 重用;但這個 sentinel 值被直接印進使用者看到的訊息,讀起來像是「這支腳本認真去 -# /nonexistent 這個地方找過」——具體、卻是假的。改成用 RESOLVED 旗標記「解析成不成功」, -# 擋下訊息另外用人話描述「為什麼找不到」,不洩漏內部實作用的假路徑。 -# -# ⚠️ 洞 A/B 都不改變「真的解析失敗時」的判定方向:路徑確實落在某個 git repo 裡, -# 但那個 repo 沒有 3-specs(或裡面沒有 active SDD)→ 仍然 **fail-closed**(擋,不放行)。 -# 為什麼是 fail-closed、不是 fail-open:這道閘存在的目的就是防止「沒有 SDD 卻能動 -# code」,若把「判斷不出來」直接放行,等於把一次環境跑歪(cwd 被切走、 -# `$CLAUDE_PROJECT_DIR` 沒設、worktree 缺 3-specs…)悄悄變成「這道閘關掉了,而且沒有 -# 任何人被告知」——silent bypass 的代價遠高於「多打一次確認」。#22 的紅線也明寫 -# 「不要把閘改成『解析失敗就放行』——那是把誤判換成漏判」。 -# 洞 A 的修法,套用在 case 分岔**之前**:不管 `$_root` 猜不猜得對, -# 先問「這條路徑到底在不在某個 git repo 裡」。不在 ⇒ SDD 這道閘天生管不到,直接放行。 -# 🔴 這個檢查故意放在 `$FILE_PATH` 是否落在 `$_root` 底下的判斷之前、且對兩邊都適用 -# (不是只套用在「專案外」那個分支):第一版只把它放進「專案外」分支,結果測試 -# (hooks/tests/sdd-guard.test.sh)就抓到一個不對稱漏洞——當 `$_root` 剛好等於 -# scratchpad 的某層祖先目錄(例如 hook 被叫用時 cwd 已經跑到 /private/tmp 底下、 -# `$CLAUDE_PROJECT_DIR` 也沒設),scratchpad 路徑會被判成「在 `$_root` 底下」而 -# 走進另一條完全沒做 git-repo 檢查的路徑,同一個誤判换個路徑重新出現。 -# 改成「先問是不是在 git repo 裡,不管路徑跟 `$_root` 的關係」就沒有這個不對稱。 -if ! path_in_git_worktree "$FILE_PATH"; then - exit 0 -fi - -_root="${CLAUDE_PROJECT_DIR:-$(pwd)}" -# 預設值一律絕對路徑(不留相對路徑「system-dev/docs/3-specs」退回目前 cwd 的洞—— -# 舊版這裡曾經是相對路徑,若專案內迴圈找不到就會被拿去跟 hook 執行當下的 cwd 兜, -# cwd 湊巧有同名目錄就會判斷到不相干的資料)。 -SPECS_DIR="$_root/system-dev/docs/3-specs" -RESOLVED=1 # 1=SPECS_DIR 是有意義的答案;0=真的解析失敗,SPECS_DIR 留空,訊息另外講原因 - -case "$FILE_PATH" in - "$_root"/*) - _d=$(dirname "$FILE_PATH") - while [ "$_d" != "/" ] && [ -n "$_d" ]; do - if [ -d "$_d/system-dev/docs/3-specs" ]; then - SPECS_DIR="$_d/system-dev/docs/3-specs" - break - fi - [ "$_d" = "$_root" ] && break - _d=$(dirname "$_d") - done - ;; - *) - # 專案外的路徑:`$_root` 猜錯,或這條路徑本來就不屬於目前的 `$_root`。 - # 已知落在某個 git repo 裡(上面剛確認過):往上找它自己的 3-specs。 - # **不可退回 `$_root` 的 3-specs 就放行**——那會把「這個 repo 沒有 SDD」 - # 誤判成「用別的 repo 的 SDD 蒙混過關」,原本擋得住的會變成擋不住。 - SPECS_DIR="" - RESOLVED=0 - _d=$(dirname "$FILE_PATH") - while [ "$_d" != "/" ] && [ -n "$_d" ]; do - if [ -d "$_d/system-dev/docs/3-specs" ]; then - SPECS_DIR="$_d/system-dev/docs/3-specs" - RESOLVED=1 - break - fi - _d=$(dirname "$_d") - done - ;; -esac - -# 給訊息用的人話描述:解析成功就印真路徑,失敗就誠實講「為什麼」,不印假路徑 -# (洞 B 的修法——舊版這裡印的是內部 sentinel `/nonexistent/3-specs`)。 -if [ "$RESOLVED" -eq 1 ]; then - SPECS_DIR_DESC="${SPECS_DIR}/" - SPECS_NOT_FOUND_MSG="${SPECS_DIR}/ 下找不到任何 SDD" - SPECS_NOT_ACTIVE_MSG="${SPECS_DIR}/ 下沒有任何 status: active 的 SDD" -else - SPECS_DIR_DESC="" - SPECS_NOT_FOUND_MSG="這條路徑所在的 git repo 裡找不到 system-dev/docs/3-specs,也就沒有任何 SDD 可對(或這支閘沒能定位到正確的專案根——這是 fail-closed:寧可誤擋也不悄悄放行,見檔頭註解)" - SPECS_NOT_ACTIVE_MSG="$SPECS_NOT_FOUND_MSG" -fi - -# ── 統計 active / frontmatter ────────────────────── -# 排除 archive/(已封存)與 TEMPLATE(範本自帶 status: draft frontmatter,不算數—— -# 否則 update 一鋪新版 TEMPLATE-sdd,老 repo 就被誤判「已遷移」而全紅,向下相容破功)。 -# frontmatter 判定=design.md 前 10 行有 ^status: 行(機器可查,見 SDD-LIFECYCLE.md)。 -ACTIVE_COUNT=0 -FM_COUNT=0 -ACTIVE_LIST="" -if [ -n "$SPECS_DIR" ] && [ -d "$SPECS_DIR" ]; then - while IFS= read -r f; do - [ -n "$f" ] || continue - HEAD10=$(head -10 "$f" 2>/dev/null || true) - if printf '%s\n' "$HEAD10" | grep -q '^status:[[:space:]]*'; then - FM_COUNT=$((FM_COUNT + 1)) - if printf '%s\n' "$HEAD10" | grep -q '^status:[[:space:]]*active'; then - ACTIVE_COUNT=$((ACTIVE_COUNT + 1)) - ACTIVE_LIST="${ACTIVE_LIST} • ${f} -" - fi - fi - done < <(find "$SPECS_DIR" -name 'design.md' -not -path '*TEMPLATE*' -not -path '*/archive/*' 2>/dev/null) -fi - -# ── 鐵律 1:單一活性被違反(active > 1)→ 不論寫什麼檔一律擋 ── -if [ "$ACTIVE_COUNT" -gt 1 ]; then - cat >&2 </dev/null | wc -l | tr -d ' ') - fi - - if [ "$SDD_COUNT" -eq 0 ]; then - cat >&2 <&2 - exit 0 -fi - -# ── 新行為:寫 code 檔需「恰好 1 份」active SDD ── -if [ "$ACTIVE_COUNT" -eq 0 ]; then - cat >&2 <&2 -exit 0 diff --git a/hooks/tests/sdd-guard-retired.test.sh b/hooks/tests/sdd-guard-retired.test.sh new file mode 100755 index 0000000..11ee96a --- /dev/null +++ b/hooks/tests/sdd-guard-retired.test.sh @@ -0,0 +1,154 @@ +#!/usr/bin/env bash +# sdd-guard 退役的迴歸測試(inkstone/ISEP#91) +# +# ── 這支在守什麼 ──────────────────────────────────────────────────── +# leo 2026-08-16 在 inkstone/InkStoneCo#40 裁定「**取消 Active SDD**」: +# 任務狀態搬到 Gitea 管,SDD 只記「起初的樣子」。 +# 但 `sdd-guard.sh` 是 **fail-closed** 的—— +# 動 code 檔時,active SDD 不是「恰好 1 份」→ 擋(0 份也擋) +# 路徑所在的 repo 沒有 3-specs → 也擋 +# ⇒ 照裁決把 active SDD 拿掉,會把「動任何 .ts/.py/.go」整個鎖死。 +# 這就是那個裁決 11 天沒人敢執行的真正原因。 +# +# 所以退役的順序是「**先讓閘退役,再拿掉 active SDD**」,而這支測的是第一步做完了沒有。 +# +# 🔴 它測的不是「檔案刪掉了沒有」,而是**那個擋還會不會發生**: +# 把整組 `Write|Edit|MultiEdit` 的 PreToolUse 閘,拿去撞「裁決執行完之後的世界」 +# (一個沒有 3-specs 的 repo、以及一個有兩份 active SDD 的 repo),一支都不准擋。 +# ⇒ 有人日後用別的檔名把同一個形狀種回來,這支照樣會紅。 +# +# 用法:hooks/tests/sdd-guard-retired.test.sh +# 🔴 全程在 TMP 底下建假 repo,跑完自己清;不動任何真 repo、不打網路。 + +set -u +HOOKS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +REPO_ROOT="$(cd "$HOOKS_DIR/.." && pwd)" +TMP=$(mktemp -d) +trap 'rm -rf "$TMP"' EXIT + +PASS=0; FAIL=0 +ok() { echo " ✅ $1"; PASS=$((PASS+1)); } +bad() { echo " ❌ $1"; FAIL=$((FAIL+1)); } + +# ── ① 閘本體與它的註冊都不在了 ──────────────────────────────────── +echo "── ① 退役:檔案與註冊 ──" + +if [ -e "$HOOKS_DIR/sdd-guard.sh" ]; then + bad "hooks/sdd-guard.sh 還在(應已刪除)" +else + ok "hooks/sdd-guard.sh 已刪除" +fi + +if grep -q 'sdd-guard' "$HOOKS_DIR/hooks.json"; then + bad "hooks.json 還註冊著 sdd-guard(照裁決做會鎖死所有 code 寫入)" +else + ok "hooks.json 已無 sdd-guard 註冊" +fi + +# hooks.json 要仍然是合法 JSON,而且別的閘一支都沒被順手弄掉 +# (README:合錯的時候「語法合法、閘卻不見了」,不會有任何東西喊一聲) +REGISTERED=$(python3 - "$HOOKS_DIR/hooks.json" <<'PY' +import json, sys +d = json.load(open(sys.argv[1])) +print(sum(len(g.get("hooks", [])) for ev in d["hooks"].values() for g in ev)) +PY +) || REGISTERED="" +if [ -n "$REGISTERED" ]; then + ok "hooks.json 仍是合法 JSON(註冊 $REGISTERED 條)" +else + bad "hooks.json 解析失敗" +fi + +# ── ② 真正的驗收:裁決執行完之後,動 code 檔不能被擋 ────────────── +echo "── ② 行為:把「拿掉 active SDD 之後的世界」丟給整組寫檔閘,一支都不准擋 ──" + +# 這兩個假 repo 就是舊 sdd-guard 一定會擋下的兩種狀態: +# A. 完全沒有 3-specs(=裁決執行完的樣子,也是 ISEP 自己現在的樣子) +# B. 兩份 status: active(=舊的「單一活性鐵律」違反) +REPO_NO_SDD="$TMP/repo-no-sdd" +mkdir -p "$REPO_NO_SDD/src" +git init -q "$REPO_NO_SDD" + +REPO_MULTI="$TMP/repo-multi-active" +mkdir -p "$REPO_MULTI/system-dev/docs/3-specs/a" "$REPO_MULTI/system-dev/docs/3-specs/b" "$REPO_MULTI/src" +git init -q "$REPO_MULTI" +printf -- '---\nstatus: active\n---\n# A\n' > "$REPO_MULTI/system-dev/docs/3-specs/a/design.md" +printf -- '---\nstatus: active\n---\n# B\n' > "$REPO_MULTI/system-dev/docs/3-specs/b/design.md" + +# 撈出所有掛在 Write|Edit|MultiEdit 上的 PreToolUse 閘(照 hooks.json 的實況,不寫死清單) +mapfile -t WRITE_HOOKS < <(python3 - "$HOOKS_DIR/hooks.json" <<'PY' +import json, sys +d = json.load(open(sys.argv[1])) +for g in d["hooks"].get("PreToolUse", []): + m = g.get("matcher", "") or "" + if "Write" in m or "Edit" in m: + for h in g.get("hooks", []): + print(h["command"].split("/")[-1]) +PY +) + +if [ "${#WRITE_HOOKS[@]}" -eq 0 ]; then + bad "撈不到任何 Write|Edit 的 PreToolUse 閘——測試本身失效了,先修這裡" +fi + +# 判準:擋下來的理由**是不是 SDD**。 +# +# 🔴 這裡刻意不寫成「一支閘都不准擋」——同一組 matcher 上還住著好幾支跟 SDD 無關、 +# 而且看 session 狀態決定擋不擋的閘(`subagent-first-guard`/`history-first-guard`…)。 +# 把它們算成失敗,這支測試就會在別人改別的東西時無故變紅, +# 紅久了就沒人看——`docs/TESTING.md` 開頭那句「誤攔比漏擋更該修」對測試一樣成立。 +# 所以失敗的定義是:**有閘擋下來,而且它擋的理由指向 SDD/3-specs**。 +# 其餘的擋只印出來給人看,不判失敗。 +probe() { # probe <說明> + local desc="$1" path="$2" sdd_blocked="" other_blocked="" + local payload out rc + payload=$(python3 -c "import json,sys;print(json.dumps({'tool_name':'Write','tool_input':{'file_path':sys.argv[1],'content':'export const x = 1;\n'}}))" "$path") + for name in "${WRITE_HOOKS[@]}"; do + [ -x "$HOOKS_DIR/$name" ] || continue + out=$(printf '%s' "$payload" | "$HOOKS_DIR/$name" 2>&1) + rc=$? + [ "$rc" -eq 2 ] || continue + if printf '%s' "$out" | grep -qE 'SDD|3-specs'; then + sdd_blocked="$sdd_blocked $name" + else + other_blocked="$other_blocked $name" + fi + done + if [ -n "$sdd_blocked" ]; then + bad "$desc —— 仍被 SDD 理由擋下:$sdd_blocked" + else + ok "$desc" + [ -n "$other_blocked" ] && echo " (另有與 SDD 無關的閘擋下,不算失敗:$other_blocked)" + fi +} + +# 刻意讓 $_root(CLAUDE_PROJECT_DIR/pwd)跟這些假 repo 對不上—— +# 舊 sdd-guard 在這個情境下走的正是 fail-closed 那條分支。 +unset CLAUDE_PROJECT_DIR +cd "$TMP" || exit 1 + +probe "repo 沒有 3-specs(裁決執行完的樣子)→ 寫 .ts 不被擋" "$REPO_NO_SDD/src/x.ts" +probe "repo 沒有 3-specs → 寫 .py 不被擋" "$REPO_NO_SDD/src/x.py" +probe "兩份 status: active(舊「單一活性」違反)→ 寫 .ts 不被擋" "$REPO_MULTI/src/x.ts" + +# ISEP 自己就是「沒有 3-specs 的 repo」——退役前,改自己的 .py 當場被擋。 +probe "ISEP 自己的 hooks/lib/*.py → 不被擋(退役前這一格是紅的)" "$REPO_ROOT/hooks/lib/dispatch_parse.py" + +# ── ③ 會被載入的那幾份,不能再有人讀到「只准一份 active SDD」 ────── +echo "── ③ 條文:載入面不再宣告單一活性鐵律 ──" +# 只查**會被載入**的那幾份(commands / agents / skills / hooks 腳本)。 +# docs/ 與 wiki/ 不查——它們得說得出「退役了什麼」,不然這件事沒有歷史。 +STALE="" +while IFS= read -r f; do + grep -qE '只(准|允許)一份|單一活性' "$f" && STALE="$STALE $f" +done < <(find "$REPO_ROOT/commands" "$REPO_ROOT/agents" "$REPO_ROOT/skills" -name '*.md' 2>/dev/null + find "$REPO_ROOT/hooks" -maxdepth 1 -name '*.sh' 2>/dev/null) +if [ -n "$STALE" ]; then + bad "這些載入面還在宣告「只准一份 active SDD」:$STALE" +else + ok "commands/agents/skills/hooks 都不再宣告單一活性鐵律" +fi + +echo +echo "結果:通過 $PASS / 失敗 $FAIL" +[ "$FAIL" -eq 0 ] || exit 1 diff --git a/hooks/tests/sdd-guard.test.sh b/hooks/tests/sdd-guard.test.sh deleted file mode 100755 index 9c5d328..0000000 --- a/hooks/tests/sdd-guard.test.sh +++ /dev/null @@ -1,90 +0,0 @@ -#!/usr/bin/env bash -# sdd-guard.sh 的迴歸測試(inkstone/InkStoneCo#22)。 -# -# 涵蓋兩個洞: -# 洞 A — scratchpad/任何不在 git repo 裡的暫存檔被誤判成「code 變動」而擋下。 -# 洞 B — 真的解析失敗(fail-closed)時,訊息裡印出內部 sentinel `/nonexistent`。 -# 以及既有行為不能退步:單一活性違反仍擋、恰好 1 份 active 仍放行、 -# 「dirname 還沒建立」不可被誤判成「不在 repo 裡」(新邏輯自己可能引入的 fail-open 陷阱)。 -# -# 用法:hooks/tests/sdd-guard.test.sh [hooks/sdd-guard.sh 的路徑] -# 🔴 全程在一個乾淨的 TMP 底下建假 repo,跑完自己清;不動任何真 repo。 - -set -u -HOOK="${1:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/sdd-guard.sh}" -TMP=$(mktemp -d) -trap 'rm -rf "$TMP"' EXIT - -PASS=0; FAIL=0 - -mk() { # mk -> JSON on stdout - python3 -c "import json,sys;print(json.dumps({'tool_name':'Write','tool_input':{'file_path':sys.argv[1],'content':'x'}}))" "$1" -} - -t() { # t <期望 exit code> <說明> [額外檢查關鍵字] - local want="$1" desc="$2" path="$3" must_not_contain="${4:-}" - local out rc - out=$(mk "$path" | "$HOOK" 2>&1) - rc=$? - local ok=1 - [ "$rc" -eq "$want" ] || ok=0 - if [ -n "$must_not_contain" ] && printf '%s' "$out" | grep -qF "$must_not_contain"; then - ok=0 - fi - if [ "$ok" -eq 1 ]; then - echo " ✅ $desc"; PASS=$((PASS+1)) - else - echo " ❌ $desc —— 期望 exit=$want,實得 exit=$rc" - [ -n "$must_not_contain" ] && echo " (且訊息不該含「$must_not_contain」)" - echo " 輸出:$out" | head -3 - FAIL=$((FAIL+1)) - fi -} - -# ── 準備:一個真的沒有 3-specs 的 git repo(模擬「真的解析失敗」)── -REPO_NO_SDD="$TMP/repo-no-sdd" -mkdir -p "$REPO_NO_SDD/src" -git init -q "$REPO_NO_SDD" - -# ── 準備:一個有 1 份 active SDD 的 git repo ── -REPO_ONE_ACTIVE="$TMP/repo-one-active" -mkdir -p "$REPO_ONE_ACTIVE/system-dev/docs/3-specs/x" "$REPO_ONE_ACTIVE/src" -git init -q "$REPO_ONE_ACTIVE" -printf -- '---\nstatus: active\n---\n# X\n' > "$REPO_ONE_ACTIVE/system-dev/docs/3-specs/x/design.md" - -# ── 準備:一個有 2 份 active SDD 的 git repo(單一活性違反)── -REPO_MULTI="$TMP/repo-multi-active" -mkdir -p "$REPO_MULTI/system-dev/docs/3-specs/a" "$REPO_MULTI/system-dev/docs/3-specs/b" "$REPO_MULTI/src" -git init -q "$REPO_MULTI" -printf -- '---\nstatus: active\n---\n# A\n' > "$REPO_MULTI/system-dev/docs/3-specs/a/design.md" -printf -- '---\nstatus: active\n---\n# B\n' > "$REPO_MULTI/system-dev/docs/3-specs/b/design.md" - -# ── 準備:scratchpad 風格的暫存區(不在任何 git repo 裡)── -SCRATCH="$TMP/private/tmp/claude-fake-session/scratchpad" -mkdir -p "$SCRATCH" - -# 讓 $_root(CLAUDE_PROJECT_DIR 或 pwd)刻意跟這些假 repo 對不上, -# 逼所有案例都走「專案外的路徑」那個分支——這正是 #22 實撞的情境(cwd 跑歪/ -# CLAUDE_PROJECT_DIR 沒設,路徑不落在 $_root 底下)。 -unset CLAUDE_PROJECT_DIR -cd "$TMP" - -echo "── 洞 A:不在任何 git repo 裡的路徑,SDD 管不到,該放行 ──" -t 0 "scratchpad 暫存 .py(本票原始事故)" "$SCRATCH/fix-project-settings.py" -t 0 "scratchpad 巢狀更深" "$SCRATCH/nested/deep/tmp.js" - -echo "── 洞 B:真的解析失敗(repo 存在但沒有 3-specs)仍要 fail-closed,但訊息不准洩漏內部假路徑 ──" -t 2 "真 repo 沒有 3-specs → 仍擋" "$REPO_NO_SDD/src/foo.py" -t 2 "上面那筆的訊息不准出現 /nonexistent" "$REPO_NO_SDD/src/foo.py" "/nonexistent" - -echo "── fail-open 陷阱:新檔案要建在還沒建立的子目錄下,不可被誤判成「不在 repo 裡」──" -t 2 "真 repo、目標子目錄還沒建立 → 仍擋(不能因為 dirname 不存在就放行)" "$REPO_NO_SDD/brand-new/not-yet/bar.py" - -echo "── 既有行為不能退步 ──" -t 0 "只有 1 份 active SDD,改 code 檔 → 放行" "$REPO_ONE_ACTIVE/src/x.py" -t 2 "2 份 active SDD(單一活性違反)→ 擋" "$REPO_MULTI/src/x.py" -t 0 "改 .md 文件(非 code 檔)→ 放行,即使找不到 3-specs" "$REPO_NO_SDD/README.md" - -echo -echo "結果:通過 $PASS / 失敗 $FAIL" -[ "$FAIL" -eq 0 ] || exit 1 diff --git a/system-dev/wiki/mistakes.md b/system-dev/wiki/mistakes.md index d05f3e1..61cc562 100644 --- a/system-dev/wiki/mistakes.md +++ b/system-dev/wiki/mistakes.md @@ -118,3 +118,39 @@ leo 當場:「**這些為什麼不寫到票裡?**」 只有這次成立 ⇒ 寫進那張票。**兩種都不進派工單。** 日期: 2026-08-27(`inkstone/ISEP#30` comment 4327) + +## ⚠️ MISTAKE: fail-closed 的閘,會把「執行裁決」這個動作本身變成不可執行 + +票: `inkstone/ISEP#91`(裁決原文在 `inkstone/InkStoneCo#40` → comment 2942) +日期: 2026-08-31 + +症狀: leo 2026-08-16 裁定「**取消 Active SDD**」——任務狀態搬到 Gitea 管, + SDD 只記「起初的樣子」,文件不帶任務 checkbox。 + **11 天過去,這個裁決一個字都沒被執行。** + +實查: `sdd-guard.sh` 是 fail-closed 的:動 code 檔時 `status: active` 的 SDD + **不是恰好 1 份就擋(0 份也擋)**,路徑所在的 repo 沒有 `3-specs` 也擋。 + ⇒ 照裁決把最後那份 active 拿掉 → 變 0 份 → **任何人動任何 + `.ts`/`.py`/`.go` 全部被擋**。當時沒出事,純粹是因為剛好還剩 1 份。 + 而 **ISEP 這個 repo 自己根本沒有 `3-specs`**——本票開工第一件事就實測到: + 改自己的 `hooks/lib/*.py` 當場被這支閘擋下(exit 2)。**它一直在誤攔,只是沒人回報。** + +原因: **不是有人偷懶,是沒有人把裁決和那支閘連起來看。** + 裁決被讀到了、也沒人反對,但執行它的第一步會當場鎖死自己, + 於是每個人都在那一步前面停下來,而**「我停下來了」不會留下任何痕跡**。 + +正確做法: + - 收到「取消某個制度」的裁決,第一個動作是**去數還有幾支閘在執行那個制度**, + 並且**先問那些閘是 fail-open 還是 fail-closed**。 + fail-closed 的那幾支決定了執行順序:**先讓閘退役,再拿掉它要的東西**,顛倒就鎖死。 + - 退役要驗的不是「檔案刪了沒有」,是「**那個擋還會不會發生**」。 + `hooks/tests/sdd-guard-retired.test.sh` 把「裁決執行完之後的世界」丟給 + `hooks.json` 上整組寫檔閘(清單當場從 `hooks.json` 讀,不寫死), + **不准有任何一支用 SDD 當理由擋下來** ⇒ 換個檔名種回來照樣紅。 + +📌 一個沒人敢執行的裁決,看起來跟一個沒人記得的裁決一模一樣。 + 差別只有在**去問「執行它的第一步會發生什麼」**的時候才看得出來。 + +📌 KBDB 缺這一段: 2026-08-31 用 `kbdb_search(mode='semantic')` 查 + 「SDD 生命週期/單一活性/取消 Active SDD」**0 命中**(最接近的是 2026-08-09 + 一張講 SDD × Gitea 整合摩擦的卡,那是裁決之前)。⇒ 這條開發史還沒進 KBDB。