diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 5e547ca..44a13b2 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "jsc-hooks", - "version": "0.3.3", + "version": "0.3.4", "description": "跨 CLI hooks:STE100 語言強制、工時計時、技能用量記錄、SDLC 模型鎖、版本前置檢查、註解範圍守門、繁中編碼守門、部署後強制重啟、寫入與提交閘門", "skills": "./skills", "author": { diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index a8aab1a..20b68ee 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,7 @@ { + "hooks": "./hooks/codex-hooks.json", "name": "jsc-hooks", - "version": "0.3.3", + "version": "0.3.4", "description": "跨 CLI hooks:STE100 語言強制、工時計時、技能用量記錄、SDLC 模型鎖、版本前置檢查、註解範圍守門、繁中編碼守門、部署後強制重啟、寫入與提交閘門", "skills": "./skills", "jsc": { diff --git a/README.md b/README.md index ac41b6c..80536d9 100644 --- a/README.md +++ b/README.md @@ -24,17 +24,62 @@ Marketplace 統一為 `jsc`(https://gitea.jsc.idv.tw/plugins/meta.git),安 | --- | --- | --- | | `hooks/ste100-guard.sh` | UserPromptSubmit | 注入 STE100 繁體中文輸出規則(hook > prompt 強制層) | | `hooks/session-timer.sh` | SessionStart / Stop / SessionEnd | 記錄工作階段起訖。子指令:`start` 記起始時間(已有紀錄就不動,給 claude 這種每階段有自己 session id 的 CLI)、`restart` 一律覆寫起始時間(給接不到 session id 的 kiro,不覆寫會把上一階段算進來)、`mark` 更新最後活動時間、`report` 供 `jsc-log:worklog` 取花費時間。`start` 與 `restart` 判定為新工作階段時,另外呼叫 `restart-gate.sh clear` 放下部署後的重啟閘門——新工作階段代表 CLI 行程是新起的,新版一定已經載入。清除的範圍只有跑到這支腳本的那一支 CLI 自己那一份狀態檔,別支沒重啟就繼續被擋 | -| `hooks/version-guard.sh` | PreToolUse(Skill) | 技能使用前的版本前置檢查,擋兩種情況,兩種都以 exit 2 擋下該次呼叫並提示更新指令(更新指令依當前 CLI 給):一是本機**實際載入**版本落後遠端發佈版本,二是技能所屬 plugin 的 manifest 在 `jsc.requires` 宣告的相依 plugin 版本落後——相依那一項讀 `installPath` 底下那份 `plugin.json`,逐項比對相依 plugin 的本機實際載入版本,訊息講明哪一個 plugin、需要哪一版、目前哪一版、怎麼補。相依檢查排在遠端比對之前,全部讀本機檔案,離線也判得動;判定邏輯自己實作,不呼叫 `jsc-cli/tools/check-requires.sh`,免得 hook 散落到別的 domain,也免得跟已宣告相依 `jsc-hooks` 的 `jsc-cli` 做出循環相依。部署那端照樣更新、只回報,阻擋落在這支 hook。兩種都只擋確定落後:超前放行(開發技能組時本機本來就會超前),讀不到本機版本、推導不出站台、查不到遠端版本、解不出安裝路徑、讀不到 manifest、manifest 沒有 `jsc.requires`、讀不到相依 plugin 的本機載入版本也一律放行。遠端版本快取在 `$JSC_HOME/version-cache/{CLI 代號}/{domain}`,一支 CLI 一份;舊路徑 `$JSC_HOME/version-cache/{domain}` 會在第一次讀取時複製到新路徑。逃生門 `JSC_VERSION_GUARD=off`。豁免 `jsc-cli:deploy`、`jsc-hooks:hooks-install`、`jsc-hooks:repair`、`jsc-cli:models`、`jsc-meta:*`、`jsc-ask:ask`、`jsc-gitea:wiki`——共 7 項,兩種擋人情況共用同一份,相依落後不另立短清單;清單的唯一來源是 `hooks/version-guard.sh` 的檔頭,那裡一項一個理由 | -| `hooks/restart-gate.sh` | PreToolUse(Skill) | 部署後強制重啟閘門:`$JSC_HOME/restart-required.d/{CLI 代號}` 一支 CLI 一份,當前 CLI 那份存在時以 exit 2 擋下 jsc 技能呼叫,並印出要重新啟動哪一支 CLI;別支 CLI 那幾份不影響這一支。狀態檔由 `jsc-cli:deploy` 在 install 或 update 收尾時經 `restart-gate.sh require {install|update} [{domain}...]` 寫入當前 CLI 那一份,在下一個工作階段開始時由 `session-timer.sh` 呼叫 `restart-gate.sh clear` 只清除那一份。判定看檔案在不在:狀態檔讀不到、CLI 代號取不到、技能名取不到都放行(理由與 `version-guard.sh` 一致,只擋確定違規)。舊格式的單一檔案 `$JSC_HOME/restart-required` 存在時一律擋,`clear` 會一併刪掉它(過渡相容,詳見下面「部署後重啟狀態檔」)。豁免 `jsc-cli:deploy`、`jsc-hooks:hooks-install`、`jsc-hooks:repair`、`jsc-gitea:wiki`、`jsc-log:worklog`、`jsc-log:learn`、`jsc-meta:*`、`jsc-ask:ask`、`jsc-git:pr`、`jsc-git:commit`——部署後還要寫得完技能組異動報告與工作日誌,hook 壞掉也要修得回來,整批擋下去這些規則會互相打死。清單認技能名不認呼叫鏈,後三支是為了讓前七支走得完才補進來的:`deploy` 要問模式、報告寫完要開 PR。另有唯讀子指令 `report`,一支 CLI 一行印出每一份狀態檔的內容,看得出還有哪幾支沒重啟。逃生門 `JSC_RESTART_GATE=off` | +| `hooks/skill-name.sh` | 不直接接線,由 `version-guard.sh` 與 `restart-gate.sh` 呼叫 | 從各 CLI 的 hook 負載解析出這一次要用哪一支 jsc 技能,一支 CLI 一個子命令,印一行「{domain}{技能名}」,解析不出來就印空字串。取值來源:claude 讀 stdin JSON 的 `skill` 欄位、codex 讀 `tool_input.command` 裡那條 `SKILL.md` 路徑(Codex 沒有 Skill 工具,技能是模型自己用 Bash 讀 `SKILL.md` 載入的)、copilot 讀 `toolArgs`(字串化的 JSON,要先剝一層跳脫)、antigravity 讀 `toolCall.args.AbsolutePath` 另收提示字串(斜線指令不產生工具呼叫)、kiro 讀 `prompt` 開頭那個斜線指令;五支都先看環境變數 `JSC_SKILL`、`SKILL`。永遠 exit 0:閘門那一端一律 fail-open,而且 copilot 的 command hook 是 fail-closed 的,回非零等於拒絕。規則只有這一份,兩支閘門都不重寫第二套 | +| `hooks/deny.sh` | 不直接接線,由 `version-guard.sh` 與 `restart-gate.sh` 呼叫 | 產出各 CLI 認得的阻擋輸出,訊息從參數或標準輸入進。claude、codex、copilot 訊息寫 stderr 並回 exit 2;antigravity 印 stdout 的 `{"decision":"deny","reason":"..."}` 並固定回 0——那支 CLI 的結束碼語意兩邊文件都沒寫,靠結束碼會變成「判定擋下、CLI 照樣放行」的無聲失效,所以 stdout 只准有那一行;kiro 擋不下技能叫用,改印警告後回 0;認不得的代號走 stderr 加 2 這個保守預設 | +| `hooks/version-guard.sh` | PreToolUse:claude matcher `Skill`、codex matcher `Bash`、copilot matcher `skill`、antigravity matcher `^view_file$` 加 `PreInvocation`;kiro `userPromptSubmit`(只注入警告) | 技能使用前的版本前置檢查,擋兩種情況,兩種都擋下該次呼叫並提示更新指令(更新指令依當前 CLI 給;技能名解析交給 `hooks/skill-name.sh`、阻擋輸出形態交給 `hooks/deny.sh`,兩支的規則見上面兩列,這裡不重寫第二套):一是本機**實際載入**版本落後遠端發佈版本,二是技能所屬 plugin 的 manifest 在 `jsc.requires` 宣告的相依 plugin 版本落後——相依那一項讀 `installPath` 底下那份 `plugin.json`,逐項比對相依 plugin 的本機實際載入版本,訊息講明哪一個 plugin、需要哪一版、目前哪一版、怎麼補。相依檢查排在遠端比對之前,全部讀本機檔案,離線也判得動;判定邏輯自己實作,不呼叫 `jsc-cli/tools/check-requires.sh`,免得 hook 散落到別的 domain,也免得跟已宣告相依 `jsc-hooks` 的 `jsc-cli` 做出循環相依。部署那端照樣更新、只回報,阻擋落在這支 hook。兩種都只擋確定落後:超前放行(開發技能組時本機本來就會超前),讀不到本機版本、推導不出站台、查不到遠端版本、解不出安裝路徑、讀不到 manifest、manifest 沒有 `jsc.requires`、讀不到相依 plugin 的本機載入版本也一律放行。遠端版本快取在 `$JSC_HOME/version-cache/{CLI 代號}/{domain}`,一支 CLI 一份;舊路徑 `$JSC_HOME/version-cache/{domain}` 會在第一次讀取時複製到新路徑。逃生門 `JSC_VERSION_GUARD=off`。豁免 `jsc-cli:deploy`、`jsc-hooks:hooks-install`、`jsc-hooks:repair`、`jsc-cli:models`、`jsc-meta:*`、`jsc-ask:ask`、`jsc-gitea:wiki`——共 7 項,兩種擋人情況共用同一份,相依落後不另立短清單;清單的唯一來源是 `hooks/version-guard.sh` 的檔頭,那裡一項一個理由 | +| `hooks/restart-gate.sh` | PreToolUse:claude matcher `Skill`、codex matcher `Bash`、copilot matcher `skill`、antigravity matcher `^view_file$` 加 `PreInvocation`;kiro `userPromptSubmit`(只注入警告) | 部署後強制重啟閘門:`$JSC_HOME/restart-required.d/{CLI 代號}` 一支 CLI 一份,當前 CLI 那份存在時擋下 jsc 技能呼叫,並印出要重新啟動哪一支 CLI(技能名解析交給 `hooks/skill-name.sh`、阻擋輸出形態交給 `hooks/deny.sh`,兩支的規則見上面兩列);別支 CLI 那幾份不影響這一支。狀態檔由 `jsc-cli:deploy` 在 install 或 update 收尾時經 `restart-gate.sh require {install|update} [{domain}...]` 寫入當前 CLI 那一份,在下一個工作階段開始時由 `session-timer.sh` 呼叫 `restart-gate.sh clear` 只清除那一份。判定看檔案在不在:狀態檔讀不到、CLI 代號取不到、技能名取不到都放行(理由與 `version-guard.sh` 一致,只擋確定違規)。舊格式的單一檔案 `$JSC_HOME/restart-required` 存在時一律擋,`clear` 會一併刪掉它(過渡相容,詳見下面「部署後重啟狀態檔」)。豁免 `jsc-cli:deploy`、`jsc-hooks:hooks-install`、`jsc-hooks:repair`、`jsc-gitea:wiki`、`jsc-log:worklog`、`jsc-log:learn`、`jsc-meta:*`、`jsc-ask:ask`、`jsc-git:pr`、`jsc-git:commit`——部署後還要寫得完技能組異動報告與工作日誌,hook 壞掉也要修得回來,整批擋下去這些規則會互相打死。清單認技能名不認呼叫鏈,後三支是為了讓前七支走得完才補進來的:`deploy` 要問模式、報告寫完要開 PR。另有唯讀子指令 `report`,一支 CLI 一行印出每一份狀態檔的內容,看得出還有哪幾支沒重啟。逃生門 `JSC_RESTART_GATE=off` | | `hooks/skill-usage.sh` | PostToolUse(Skill) | 記錄技能使用與呼叫鏈到 `$JSC_HOME/usage/*.jsonl`,供 `jsc-log:stats` 統計 | | `hooks/comment-scope.sh` | UserPromptSubmit、PostToolUse(Write、Edit、MultiEdit)、codex `notify`、kiro `userPromptSubmit`、`tools/jsc-wrap.sh` 收尾 | 程式碼註解不得夾帶文件相關資訊與審查流程痕跡,共三種模式。`prompt`:在每次提示注入規則摘要(禁止項與白名單各一行),五個 CLI 都接得到。無參數:寫檔後的逐檔掃描,從 stdin JSON 取 `file_path`(或環境變數 `JSC_CHANGED_FILE`),只有 claude 的 PostToolUse 接得上。`sweep [dir]`:掃整個 git 工作區這次改過的所有檔案,給沒有 post-tool hook 的四個 CLI 用,找不到 git 就安靜 exit 0。掃描時機每個 CLI 不同——claude 逐檔即時(PostToolUse)、codex 每輪結束(`notify`)、kiro 每輪提示送出時(`userPromptSubmit`,掃的是上一輪寫的檔)、copilot 與 antigravity 只有工作階段結束時由 `tools/jsc-wrap.sh` 收尾掃一次。兩種掃描模式都只看 `git diff HEAD` 的新增行、不翻舊帳,命中就把警告與最多三行證據送到 stderr 並以 exit 2 交回模型就地修正(不擋寫入,檔案已經寫好了)。markdown、純文字、資料檔與二進位檔一律跳過。只實作可用樣式判定的項目,專案代號、客戶名稱這類判不出來的交給 `/jsc-review:code-review`。規則正文的唯一來源在 `jsc-review` 的 `references/comment-scope.md`,本存取庫不留副本。逃生門 `JSC_COMMENT_SCOPE=off` | | `hooks/lang-guard.sh` | UserPromptSubmit、PostToolUse(Write、Edit、MultiEdit)、codex `notify`、kiro `userPromptSubmit`、`tools/jsc-wrap.sh` 收尾 | 所有非程式碼輸出一律繁體中文、UTF-8、無亂碼、無簡體字,共三種模式。`prompt`:在每次提示注入規則摘要(適用範圍與自我檢查各一行),五個 CLI 都接得到。無參數:寫檔後的逐檔掃描,從 stdin JSON 取 `file_path`(或環境變數 `JSC_CHANGED_FILE`),只有 claude 的 PostToolUse 接得上。`sweep [dir]`:掃整個 git 工作區這次改過的所有檔案,給沒有 post-tool hook 的四個 CLI 用,找不到 git 就安靜 exit 0。接線位置與掃描時機跟 `comment-scope.sh` 完全一樣,見下面那張表。偵測三項:簡體字(字表在 `hooks/simplified.txt`,讀不到就安靜跳過這一項)、亂碼(U+FFFD 替代字元與雙重編碼殘骸)、非 UTF-8 編碼(用 `iconv` 判定,沒有 `iconv` 就跳過)。三項都掃整個檔案、不只掃註解行,`.md` 與純文字檔照掃——那些正是「非程式碼輸出」的主場,這兩點跟 `comment-scope.sh` 刻意不同。掃描深度仍只看 `git diff HEAD` 的新增行、不翻舊帳,命中就把警告與最多三行證據送到 stderr 並以 exit 2 交回模型就地修正(不擋寫入)。二進位檔(只認 NUL 位元組)與 `*.lock`、`*.min.js`、`*.map` 這類產生檔跳過;`hooks/simplified.txt`、`hooks/ste100-guard.sh`、`hooks/lang-guard.sh` 也跳過,那三份檔案裡的簡體字與亂碼樣本是被討論的對象,不是被使用。規則正文的唯一來源在 `jsc-meta` 的 `references/ste100.md`。逃生門 `JSC_LANG_GUARD=off` | | `hooks/sdlc-gate.sh` | UserPromptSubmit、PreToolUse(Skill) | SDLC 階段能力標籤閘門與模型鎖:`lock {stage}` 由 jsc-sdlc 階段技能呼叫,從可驗證來源讀出模型 id,比對該階段必要標籤(`$JSC_HOME/model-tags.tsv`),不符就拒絕上鎖;來源優先序為 transcript、hook stdin JSON、Codex 本機 session 記錄,最後才接受 `JSC_MODEL` 人工覆寫,且回報會標明人工覆寫;`check` 在模型不符時以 exit 2 擋下該輪提示(其他 hook 一律 exit 0,此處是刻意例外);`report` 印出階段、必要標籤、模型 id、模型來源與判定結果;`unlock` 為逃生門。另含工作包 PR 閘門:`wp-lock {owner}/{repo} {index} [{工作包代號}]` 記下一筆未結清的工作包 PR、`wp-unlock {owner}/{repo} {index}` 結清那一筆(檔案不存在也算成功)、`wp-claim {owner}/{repo} {工作包代號} [{PR 編號}] [{分析頁頁名}]` 記下這個存取庫目前領取哪一包、`wp-unclaim {owner}/{repo}` 交回、`wp-report` 印出所有未結清、`wp-check {prompt|skill}` 為 hook 模式。狀態檔一個工作包一支,在 `$JSC_HOME/wp/{owner}-{repo}-{index}.pr`,**刻意不綁 session**——PR 沒合併時換一個工作階段照樣要擋;一個工作包一支鎖檔是為了讓好幾個互不相依的工作包能同時記在案,不會互相覆蓋掉對方的鎖。`wp-check prompt` 只注入提醒、絕不擋提示(擋了連「去修那支 PR」的對話都送不出去);`wp-check skill` 在有未結清 PR 時以 exit 2 擋下 `analyze` 與 `maintain`,但一律放行 `implement`(結清 PR 正是 implement 的步驟,擋它會鎖死流程),也放行 `plan`,只注入提醒(plan 是純邏輯階段、不碰程式碼,而這道閘門只知道「有 PR 未合併」、判不出跟新計畫有沒有關聯;放棄的是在製品上限,`analyze` 與 `maintain` 兩道仍在,上限晚一個階段才生效)——這一層是整個存取庫共用的粗粒度提醒,「某個候選工作包能不能挑」的細粒度判斷在 `jsc-sdlc/tools/wp-gate.sh check-deps`,不是這裡。另外會比對歸屬:未結清的 PR 不屬於目前領取的工作包時,`prompt` 多注入一行「那幾支交給領取它的工作階段」,`skill` 在擋下 `analyze`、`maintain` 時一併點名,`plan` 與 `implement` 仍放行但收到同一則提醒。逃生門 `JSC_WP_GATE=off`。這道閘門只讀檔案、不打網路,PR 的真實合併狀態由 `jsc-sdlc/tools/wp-gate.sh` 查證 | -| `hooks/write-guard.sh` | PreToolUse(Write、Edit、MultiEdit)、PreToolUse(Bash) | 寫入與提交閘門,共三種擋人模式,只有 claude 有 PreToolUse,其餘四支 CLI 一條都接不上;另有一個不接 hook 的 `release` 解除模式。`stage`:`sdlc-gate.sh` 的階段鎖鎖在 `plan` 或 `analyze` 時,以 exit 2 擋下 `Write`、`Edit`、`MultiEdit`——那兩個階段的產出是計畫頁與分析頁,不是檔案。階段鎖狀態檔沿用 `sdlc-gate.sh` 那一份,這裡只讀不寫。`review`:目前技能是 `jsc-review:code-review` 或 `jsc-review:api-doc` 時擋下寫入,那兩支只回報發現、不改程式碼。技能名先讀環境變數,取不到才讀 `skill-usage.sh` 記下的那一份;沒有「技能結束」事件可讀,所以紀錄超過 `JSC_WRITE_GUARD_TTL` 秒就當那支技能早已跑完。`jsc-review:comment-cleanup` **刻意不擋**:它本來就要改檔,只是限定僅註解行,而精確判定要解析工具參數裡整份新內容再逐語言判斷哪幾行是註解,判錯會擋掉合法的清理,代價比漏擋大,所以那條界線留給技能內文與後續審查。`commit`:擋下「同一道指令把全部變更一次加進索引再提交」,也擋下含簡體字、亂碼或非 UTF-8 編碼的提交訊息(判定整段轉呼叫 `lang-guard.sh`,字表仍是 `hooks/simplified.txt`,這裡不留第二份樣式)。跨兩次工具呼叫的 `git add -A` 不擋:那要記跨呼叫狀態,而被擋下的人沒有辦法讓那個狀態自己消失,閘門會把解除自己的路徑一起鎖掉。`release`:刪掉 `review` 模式認人用的那份紀錄,一律 exit 0,由 `jsc-review:code-review` 與 `jsc-review:api-doc` 在收尾時各呼叫一次。有這個模式是因為那份紀錄記的是「最近一次載入的技能」不是「還在跑的技能」——稽核收尾後呼叫端本來就要動手改,那時紀錄仍寫著稽核技能,TTL 內每一次寫入都被擋,解除路徑只剩逃生門或空等;閘門不得把解除自己的路徑一起鎖掉。逃生門 `JSC_WRITE_GUARD=off`(`release` 不受它影響,清紀錄擋不到任何人) | +| `hooks/write-guard.sh` | PreToolUse(Write、Edit、MultiEdit)、PreToolUse(Bash) | 寫入與提交閘門,共三種擋人模式,目前只接在 claude 上;codex、copilot、antigravity 三支已經有可用的 pre-tool hook(見上面「各 CLI 的 pre-tool 接線位置」),只是這三種模式還沒接過去,kiro 則是本來就擋不下來。另有一個不接 hook 的 `release` 解除模式。`stage`:`sdlc-gate.sh` 的階段鎖鎖在 `plan` 或 `analyze` 時,以 exit 2 擋下 `Write`、`Edit`、`MultiEdit`——那兩個階段的產出是計畫頁與分析頁,不是檔案。階段鎖狀態檔沿用 `sdlc-gate.sh` 那一份,這裡只讀不寫。`review`:目前技能是 `jsc-review:code-review` 或 `jsc-review:api-doc` 時擋下寫入,那兩支只回報發現、不改程式碼。技能名先讀環境變數,取不到才讀 `skill-usage.sh` 記下的那一份;沒有「技能結束」事件可讀,所以紀錄超過 `JSC_WRITE_GUARD_TTL` 秒就當那支技能早已跑完。`jsc-review:comment-cleanup` **刻意不擋**:它本來就要改檔,只是限定僅註解行,而精確判定要解析工具參數裡整份新內容再逐語言判斷哪幾行是註解,判錯會擋掉合法的清理,代價比漏擋大,所以那條界線留給技能內文與後續審查。`commit`:擋下「同一道指令把全部變更一次加進索引再提交」,也擋下含簡體字、亂碼或非 UTF-8 編碼的提交訊息(判定整段轉呼叫 `lang-guard.sh`,字表仍是 `hooks/simplified.txt`,這裡不留第二份樣式)。跨兩次工具呼叫的 `git add -A` 不擋:那要記跨呼叫狀態,而被擋下的人沒有辦法讓那個狀態自己消失,閘門會把解除自己的路徑一起鎖掉。`release`:刪掉 `review` 模式認人用的那份紀錄,一律 exit 0,由 `jsc-review:code-review` 與 `jsc-review:api-doc` 在收尾時各呼叫一次。有這個模式是因為那份紀錄記的是「最近一次載入的技能」不是「還在跑的技能」——稽核收尾後呼叫端本來就要動手改,那時紀錄仍寫著稽核技能,TTL 內每一次寫入都被擋,解除路徑只剩逃生門或空等;閘門不得把解除自己的路徑一起鎖掉。逃生門 `JSC_WRITE_GUARD=off`(`release` 不受它影響,清紀錄擋不到任何人) | Claude 由 `hooks/hooks.json` 自動接線九支 hook;其他 CLI 用 `hooks-install` 技能接線、改裝包裝啟動器,或降級為規則檔。寫進使用者設定的長期命令一律指向 `$JSC_HOME/current/jsc-hooks`,不指向帶版號的 plugin 快取目錄,也不指向開發存取庫。 -> 覆蓋範圍要據實看待:只有 claude 同時有 PreToolUse、PostToolUse 與 UserPromptSubmit,九支 hook 全接得上,回報 `wired`。codex、copilot、antigravity、kiro 都沒有 pre-tool hook,接不上 `version-guard.sh` 的版本前置檢查,接不上 `restart-gate.sh` 的部署後重啟閘門,也接不上 `write-guard.sh` 的三種模式,SDLC 模型鎖也只剩技能步驟檢查,這四個 CLI 一律回報 `degraded`,靠 `/jsc-cli:deploy` 定期更新。重啟閘門在這四個 CLI 上一次技能呼叫都擋不下來:那一支自己那份狀態檔照樣寫、下一個工作階段開始照樣清,只是中間沒有判定點,重啟得靠 `/jsc-cli:deploy` 收尾的提示自己動手。codex 另外沒有工作階段開始事件,計時改由 `tools/jsc-wrap.sh` 的 `codex` 別名在啟動當下開始;沒走別名啟動時,時間從第一輪回應算起。 +### 各 CLI 的 pre-tool 接線位置 + +五支裡有四支都有能阻擋的 pre-tool hook。先前版本前置檢查與部署後重啟閘門在 codex、copilot、antigravity 上從未生效,原因是接錯位置——不是沒有位置可接。 + +| CLI | 接線位置 | 事件與 matcher | 阻擋形態 | verdict | +| --- | --- | --- | --- | --- | +| claude | `hooks/hooks.json` | `PreToolUse` matcher `Skill` | stderr 加 exit 2 | `wired` | +| codex | `hooks/codex-hooks.json`,由 `.codex-plugin/plugin.json` 的 `hooks` 鍵以**路徑字串**指過去 | `PreToolUse` matcher `Bash` | stderr 加 exit 2 | `wired` | +| copilot | `~/.copilot/settings.json` 的頂層 `hooks` 鍵(合併,不覆寫) | `PreToolUse` matcher `skill` | stderr 加 exit 2 | `wired` | +| antigravity | `~/.gemini/config/hooks.json` 的 `jsc` 段落 | `PreToolUse` matcher `^view_file$`(**Grouped**:`matcher` 加 `hooks` 包一層),加 `PreInvocation`(**Flat**) | stdout 的 `{"decision":"deny",...}` | `wired` | +| kiro | `~/.kiro/agents/jsc.json` 的 `hooks` 鍵,加上 `settings/cli.json` 的 `chat.defaultAgent=jsc` | `agentSpawn`、`userPromptSubmit`、`stop` | stdout 注入警告,擋不下來 | `degraded` | + +四支非 claude 的 CLI,接線命令一律以 `JSC_CLI={代號}` 前綴自帶 CLI 代號。兩道閘門要先認出自己跑在哪一支上,才取得到 `hooks/skill-name.sh` 的技能名與 `hooks/deny.sh` 的阻擋形態;代號取不到時技能名解不出來,兩道閘門一律安靜放行,設定寫得完全正確、matcher 也對,卻一次都擋不下來。antigravity 更嚴重:代號不明時阻擋會退回結束碼形態,而它只認 stdout 的 deny JSON,等於判定擋下了、CLI 卻收不到拒絕。`tools/jsc-wrap.sh` 的別名雖然也會 export `JSC_CLI`,那只在使用者從互動 shell 走別名啟動時才成立,接線不靠它。 + +各列的原因,逐支講白: + +- **codex** 沒有 `Skill` 這個工具,技能是模型自己用 `Bash` 讀 `SKILL.md` 載入的,所以 matcher 是 `Bash`。manifest 的 `hooks` 鍵跟 `skills` 一樣是**路徑字串**(Codex 的 plugin manifest 規格:`"hooks": "./hooks.json"`),寫成內嵌物件解不出來。那個鍵是**覆寫**,codex 只讀它指到的那一份,所以 `hooks/codex-hooks.json` 是從 `hooks/hooks.json` **推導**出來的——整份複製,只把 `"matcher": "Skill"` 換成 `"matcher": "Bash"`。手寫第二份會漏掉 `SessionStart`、`UserPromptSubmit`、`Stop` 那幾組,而且從此兩份各自漂移;推導的話 `hooks/hooks.json` 仍是唯一真實來源,那邊加一支 hook,這邊重跑接線就跟著有。Claude 讀的還是原本那份,一個位元組都沒動。 +- **copilot** 有專用的 `skill` 工具,matcher 就是小寫的 `skill`。事件名只寫 PascalCase 一種:兩種大小寫都吃,兩種同時存在會把同一支 hook 跑兩次。它的 command hook 是 **fail-closed** 的(崩潰或任何非零結束碼都算拒絕,只有逾時 fail-open),所以那條路徑上的腳本錯誤處理要收乾淨。設定位置是 `settings.json` 的頂層 `hooks` 鍵,內嵌定義、以事件名當鍵(`copilot help config` 原文:「In global config.json these act as user-level hooks」,而 `config.json` 第一行自己就寫著 `// User settings belong in settings.json.`)。`$COPILOT_HOME/hooks/` 底下放的是 hook 要跑的**腳本**,不是設定——把設定寫進那裡,檔案好端端在、內容也對,copilot 一次都不會讀。指引檔同理要在 `$COPILOT_HOME` 底下,舊接線寫在 `~/.config/copilot/`,那個位置從來不會被載入。 + + 那份 `settings.json` 同時裝著 `enabledPlugins`(十個 jsc plugin 的啟用狀態)與 `extraKnownMarketplaces`,弄壞會讓外掛整批失效,所以**只合併不覆寫**:寫前備份到 `$JSC_HOME/backup/`,只動 `hooks` 底下 jsc 自己那幾筆條目,寫後回讀核對最上層鍵與別人的 hook 條目,任何一項對不上就還原備份。`purge` 也只挑掉 jsc 那幾筆,第三方的 `SessionStart` 原樣留著。 +- **antigravity** 沒有技能專用工具,系統提示要求模型用 `view_file` 讀 `SKILL.md`,所以 matcher 是 `^view_file$`;**錨點不能省**,省了會連 `view_file_outline` 一起命中。兩個事件的**結構不一樣**,不能寫成同一種形狀:`PreToolUse` 與 `PostToolUse` 是 **Grouped**(handler 要用 `matcher` 加 `hooks` 包一層),`PreInvocation`、`PostInvocation`、`Stop` 是 **Flat**(handler 物件直接排在陣列裡)。這是執行檔內嵌文件的「Supported Event Types」表寫死的,也是實測踩出來的——`PreToolUse` 寫成 Flat 時 antigravity **靜默丟棄整個事件**,hook 名稱照樣登記,連 `actions` 鍵都不生成,不報任何錯,設定檔看起來也完全正常。`matcher` 要留在 group 那一層,不是 handler 那一層。`plugin.json` 不能宣告 hook,只有 `hooks.json` 這一個位置,而那個檔案的最上層是一個安裝來源一個命名空間鍵,寫 `jsc` 那一個不會動到別人的段落。斜線指令與預載技能會把 `SKILL.md` 全文直接注入訊息、不產生工具呼叫,那條路由 `PreInvocation` 接住。**結束碼絕對不可靠**:語意兩邊文件都沒寫,擋人一律靠 stdout 的 deny JSON。 +- **kiro** 的 hook 宣告只認 agent 設定檔的 `hooks` 鍵,合法事件只有 `agentSpawn`、`userPromptSubmit`、`preToolUse`、`postToolUse`、`stop` 五個,欄位是 `command`(必填)、`matcher`、`timeout_ms` 等,**沒有 `on`、`run`、`env`**。舊版把 `on`/`run`/`env` 寫在最上層,`kiro-cli agent validate` 一個錯都不報——**未知的頂層鍵被靜默忽略**——那份檔案卻什麼都沒做。檔案合法不等於接線生效,所以形狀要另外驗。 + + **`kiro-cli agent validate` 一律回結束碼 0**,合法、事件名非法、`hooks` 裡放 `on`/`run`、缺 `command`,四種情況的結束碼全是 0,錯誤只印在輸出(stderr)。拿結束碼當判準會做出一支永遠通過的檢查,跟這一輪在修的錯是同一類。判準是**輸出**:空的才算通過。輸出在講別的事(沒登入、憑證過期)算「驗不了」不是「驗不過」,照 fail-open 放行並據實說明——報成接線失敗的話,沒登入的機器會整批接不了線。 + +- **kiro** 擋不下技能叫用,這是 CLI 的限制,不是我們接錯。技能走 `ResolveSkill` 這個 agent 內部請求,不經工具管線,`preToolUse` 攔不到;`userPromptSubmit` 的非零結束碼也不會擋下那一輪。唯一可用的介入是 `userPromptSubmit` 的 stdout 注入,所以兩道閘門只印警告。hook 宣告只認 **agent 設定檔的 `hooks` 鍵**,`.kiro/hooks/` 目錄不在它的設定目錄常數裡,一份都不會被讀。同一份 agent 檔還要寫 `resources` 的**兩層** `skill://` glob(預設只掃一層,jsc 的技能在 `jsc-{domain}/{name}/SKILL.md` 第二層,少了那一條一支都載不到)與**明列的 `tools`**(自訂 agent 沒宣告時可用工具會受限),並把 `chat.defaultAgent` 設成 `jsc`,那個 agent 才會被選用。 + +> 覆蓋範圍其餘部分仍要據實看待:只有 claude 同時有 PreToolUse、PostToolUse 與 UserPromptSubmit,九支 hook 全接得上。codex、copilot、antigravity 三支目前接上的是版本前置檢查與部署後重啟閘門兩道;`write-guard.sh` 的三種模式還沒接線,SDLC 模型鎖仍只剩技能步驟檢查,註解範圍與繁中編碼仍是 `sweep`。codex 另外沒有工作階段開始事件,計時改由 `tools/jsc-wrap.sh` 的 `codex` 別名在啟動當下開始;沒走別名啟動時,時間從第一輪回應算起。 + +### 驗證等級 + +「形狀」是那支 CLI 真的讀得懂這份設定;「觸發」是 hook 真的被叫用過。兩件事分開記,不得混為一談,回報也照這張表寫: + +| CLI | 形狀 | 觸發 | +| --- | --- | --- | +| claude | 實證(`hooks.json` 長期在用) | 實證 | +| codex | **實證**:`~/.codex/config.toml` 的 `[hooks.state]` 以 `{事件}:{群組}:{條目}` 兩層索引登記,證明它解析的是 Grouped 結構;manifest 的 `hooks` 是路徑字串,出自執行檔內嵌的 `plugin-json-spec.md` | 未驗證 | +| antigravity | **實證**:接線後 `agy -p "/hooks"` 四條全載入,`matcher=^view_file$`,第三方段落完好 | 未驗證(本機對話 quota 用盡) | +| copilot | **未證**:`settings.json` 沒有唯讀的列出管道,位置與條目形態出自 `copilot help config` 的說明與機器上既有的第三方實例 | 未驗證 | +| kiro | **實證**:`kiro-cli agent validate` 通過(輸出為空),並以反證確認它真的在判別——`sessionStart`、`hooks` 裡放 `on`/`run`、缺 `command` 三種都會報錯 | **部分實證**:`agentSpawn` 與 `userPromptSubmit` 實跑觸發過;`preToolUse` 與 `stop` 未驗證(模型額度用盡,09/01 重置) | + +還有兩項未驗證,一併記著:kiro 的 `resources` 兩層 glob **能不能真的修好技能可見性**沒有驗過(要模型跑得動才列得出技能);四支非 claude 的 CLI 上,`write-guard.sh` 三種模式與 SDLC 模型鎖仍未接線,那是還沒做,不是驗不過。 + +> 接線內容與腳本邏輯有 `wire-cli.sh smoke` 逐條斷言(技能名解析、四種阻擋形態、各 CLI 的接線形狀、fail-open、豁免放行、kiro 的注入路徑),觸發不在斷言範圍內。antigravity 的唯讀確認可跑 `agy -p "/hooks"` 與 `agy -p "/skills"`,兩個指令都不吃 quota;kiro 用 `kiro-cli agent validate --path {檔案}`,**看輸出不看結束碼**。 > `comment-scope.sh` 與 `lang-guard.sh` 五個 CLI 都掃得到,接的是同一批位置,但時機不同,不能當成五支一樣: @@ -42,7 +87,7 @@ Claude 由 `hooks/hooks.json` 自動接線九支 hook;其他 CLI 用 `hooks-in | --- | --- | --- | | claude | 逐檔即時,寫完哪個檔就掃哪個 | PostToolUse | | codex | 每輪結束,掃整個 git 工作區 | `config.toml` 的根層 `notify` | -| kiro | 每輪提示送出時,掃整個 git 工作區(掃到的是上一輪寫的檔) | `.kiro/hooks/jsc-hooks.json` 的 `userPromptSubmit` | +| kiro | 每輪提示送出時,掃整個 git 工作區(掃到的是上一輪寫的檔) | `~/.kiro/agents/jsc.json` 的 `userPromptSubmit` | | copilot、antigravity | 工作階段結束時掃一次 | `tools/jsc-wrap.sh` 收尾 | > 上表對 `comment-scope.sh` 與 `lang-guard.sh` 同時成立,兩支接在同一批位置。`sweep` 看的是 `git diff HEAD`,涵蓋範圍與 claude 一樣,差的是回饋速度:claude 當下就叫,其他四個要等到該輪或該階段結束。不在 git 工作區內時 `sweep` 安靜 exit 0,等於沒掃。規則提示(`prompt` 模式)在五個 CLI 都照樣寫進規則檔,三段(STE100、註解範圍、繁中編碼)共用同一個標記段落——晚一輪的警告,價值仍低於一開始就不要寫。判不出來的項目(專案代號、客戶名稱)一律交給 `/jsc-review:code-review` 第 2 組。 @@ -122,7 +167,7 @@ Claude 由 `hooks/hooks.json` 自動接線九支 hook;其他 CLI 用 `hooks-in | `tools/jsc-wrap.sh` | 沒有完整 hook 系統的 CLI 的包裝啟動器:匯出 `JSC_CLI`、`JSC_SESSION_ID`,前後接 `session-timer.sh`,結束時自動跑 `scan-logs.sh` 回填,再依序跑一次 `comment-scope.sh sweep` 與 `lang-guard.sh sweep` 掃整個 git 工作區的註解範圍與繁中編碼(copilot 與 antigravity 沒有任何逐輪事件,整個工作階段只有這裡掃得到)。兩次收尾掃描一律不影響結束碼:包裝器原樣回傳 CLI 自己的結束碼,`sweep` 命中只把警告印到 stderr。`JSC_CLI` 存 CLI 代號,實際執行的是對應的執行檔(antigravity 是 agy、kiro 是 kiro-cli) | | `tools/scan-logs.sh` | 離線回填:解析 copilot、antigravity、codex 的原生日誌,把技能用量與階段界線補進 `$JSC_HOME`,重掃不重複 | | `tools/report-error.sh` | 失敗回報流程:把一筆 hook 或工具異常寫成 wiki 的 `ERROR_{HASH}`,並在 `ERROR_CONTENTS` 附上一列索引。目錄頁一律先讀回舊頁再附加新列、整頁寫回,不整頁覆蓋:只有 `wiki-get` 回 4(頁面真的不存在)才用範本建新頁,回 7(金鑰失效)或 8(其他 API 失敗)代表舊內容未知,放棄目錄頁寫入並以 exit 4 回報,免得拿範本蓋掉所有既有列。wiki 位置由 `jsc-gitea` 的 `gitea.sh wiki-repo ERROR` 解析,解析不出來就安靜降級。由操作者手動執行,或由 `hooks-install` 在 `wire-cli.sh` 回報 `status=failed` 時執行;**不接在失敗的 hook 上自動觸發**(hook 一律安靜 exit 0,自我回報會疊出迴圈) | -| `tools/wire-cli.sh` | 單一 CLI 的 hook 生命週期,共四個用法。`{cli}` 是接線:先建立或更新 `$JSC_HOME/current/jsc-hooks` 指向目前這版 plugin,接著把對應的設定編輯、包裝別名安裝、hook 檔建立成穩定路徑,皆以 ``(或 `# jsc-hooks`)標記整段重寫,重跑等同先移除再重裝;寫完每個檔案會重讀驗證位置正確才回報成功(codex 的 `notify` 必須是根層鍵、kiro 的 JSON 必須成對且 `on`、`run` 在最上層),也會確認寫入路徑能解到既有腳本。檔案系統不能建立 symlink 時,會明確回報並退回目前根目錄,不會靜默寫出壞路徑。`status=wired\|degraded\|skipped\|failed` 回報接線結果。`purge {cli}` 是移除:把該 CLI 的**所有** hook 清掉,含非 jsc 的第三方項目,動到的檔案先原樣備份到 `$JSC_HOME/backup/hooks/{cli}/{yyyyMMdd_HHmmss}/`,備份失敗就不移除;移除標記段落時會先去掉標記行前後空白,所以縮排或尾端補空白的 jsc 區塊一樣會移除;移除後重讀驗證,驗不過自動還原備份,以 `status=purged\|skipped\|failed` 回報。`smoke {cli}` 是執行期冒煙測試:九支 hook 的每個接線模式各跑一次,非零退出即為錯誤,另外用一份暫時的 `$JSC_HOME` 狀態檔把工作包歸屬、部署後重啟閘門與寫入提交閘門的每條判定路徑各跑一次並比對結束碼,再用一份暫時的 `HOME`(假的 `installed_plugins.json` 與各 plugin 的 manifest)把 `version-guard.sh` 相依版本檢查的每條路徑跑一次——相依落後的擋人與訊息內容、相等與超前的放行、豁免技能在相依落後時照樣放行、四種 fail-open、逃生門,另加一條回歸:多行縮排的 manifest,`jsc.requires` 的最後一個鍵也要解得到。驗的是判定結果本身,不只是腳本跑得完(例外有四個:`sdlc-gate.sh check` 的 exit 2 是階段鎖的設計行為,`comment-scope.sh`、`lang-guard.sh` 掃描模式與 `write-guard.sh` 三種模式的 exit 2 是命中違規的設計行為——`sweep` 在髒工作區本來就會回 2,`write-guard.sh` 在機器剛好鎖在 `plan` 階段時也會回 2,都不算 hook 壞掉),以 `status=ok\|failed` 回報。**結果行數由腳本自己數、自己斷言**:`status=` 之後緊接一行 `lines{數量}`,那是其後 `[jsc]` 結果行的實際條數,與腳本內逐類宣告的預期條數比對,不符就回非零。判定路徑增減時只改腳本裡的預期值,散文一律引用這一行,不另外抄一份數字。`status {cli}` 是唯讀盤點:只讀設定檔判斷標記段落在不在,不寫檔也不執行 hook,每個接線點印一行 `item{項目}{路徑}{present\|missing}`,也會把帶版號快取路徑、開發存取庫路徑與不存在的腳本列為缺項;`status claude` 讀 Claude Code 實際載入的 `installed_plugins.json`,不再檢查目前腳本旁邊那份 `hooks.json`。體檢類技能(`/jsc-cli:doctor`)只能用這個子命令,另外三個都會動到環境;那道限制另有程式層把關,`JSC_READONLY=1` 之下只准 `status` 與 `smoke`,`purge` 與接線一律以 exit 6 拒絕並回報 `status=readonly`,環境不會被動到 | +| `tools/wire-cli.sh` | 單一 CLI 的 hook 生命週期,共四個用法。`{cli}` 是接線:先建立或更新 `$JSC_HOME/current/jsc-hooks` 指向目前這版 plugin,接著把對應的設定編輯、包裝別名安裝、hook 檔建立成穩定路徑,皆以 ``(或 `# jsc-hooks`)標記整段重寫,重跑等同先移除再重裝;寫完每個檔案會重讀驗證位置正確才回報成功(codex 的 `notify` 必須是根層鍵、`.codex-plugin/plugin.json` 的 matcher 必須是 `Bash`、copilot 必須是小寫 `skill` 且沒有第二種大小寫的事件名、antigravity 的 matcher 必須帶錨點 `^view_file$` 且有 `PreInvocation`、kiro 的 agent JSON 必須成對且 `hooks`、`resources`、`tools` 在最上層並含兩層 `skill://` glob),也會確認寫入路徑能解到既有腳本。matcher 本身要單獨驗:鍵在、matcher 卻錯的形態最難查,回報會說接好了,實際一次都不會被叫用。檔案系統不能建立 symlink 時,會明確回報並退回目前根目錄,不會靜默寫出壞路徑。`status=wired\|degraded\|skipped\|failed` 回報接線結果。`purge {cli}` 是移除:把該 CLI 的**所有** hook 清掉,含非 jsc 的第三方項目,動到的檔案先原樣備份到 `$JSC_HOME/backup/hooks/{cli}/{yyyyMMdd_HHmmss}/`,備份失敗就不移除;移除標記段落時會先去掉標記行前後空白,所以縮排或尾端補空白的 jsc 區塊一樣會移除;移除後重讀驗證,驗不過自動還原備份,以 `status=purged\|skipped\|failed` 回報。`smoke {cli}` 是執行期冒煙測試:九支 hook 的每個接線模式各跑一次,非零退出即為錯誤,另外把五支 CLI 的真實負載各餵進 `skill-name.sh` 一次驗技能名解析、四種阻擋形態各驗一次 `deny.sh`,再把那些負載直接餵進 `restart-gate.sh` 驗「解析→判定→輸出形態」整條串得起來(含 fail-open、豁免放行與 kiro 的注入路徑)——前兩組分開看都會顯示正常,中間接不上照樣是全程放行,那正是先前三支 CLI 失效的樣子;另外用一份暫時的 `$JSC_HOME` 狀態檔把工作包歸屬、部署後重啟閘門與寫入提交閘門的每條判定路徑各跑一次並比對結束碼,再用一份暫時的 `HOME`(假的 `installed_plugins.json` 與各 plugin 的 manifest)把 `version-guard.sh` 相依版本檢查的每條路徑跑一次——相依落後的擋人與訊息內容、相等與超前的放行、豁免技能在相依落後時照樣放行、四種 fail-open、逃生門,另加一條回歸:多行縮排的 manifest,`jsc.requires` 的最後一個鍵也要解得到。驗的是判定結果本身,不只是腳本跑得完(例外有四個:`sdlc-gate.sh check` 的 exit 2 是階段鎖的設計行為,`comment-scope.sh`、`lang-guard.sh` 掃描模式與 `write-guard.sh` 三種模式的 exit 2 是命中違規的設計行為——`sweep` 在髒工作區本來就會回 2,`write-guard.sh` 在機器剛好鎖在 `plan` 階段時也會回 2,都不算 hook 壞掉),以 `status=ok\|failed` 回報。**結果行數由腳本自己數、自己斷言**:`status=` 之後緊接一行 `lines{數量}`,那是其後 `[jsc]` 結果行的實際條數,與腳本內逐類宣告的預期條數比對,不符就回非零。判定路徑增減時只改腳本裡的預期值,散文一律引用這一行,不另外抄一份數字。`status {cli}` 是唯讀盤點:只讀設定檔判斷段落與 matcher 對不對,不寫檔也不執行 hook,claude、codex、copilot、antigravity 回 `wired`,kiro 回 `degraded` 並在 `reason` 講明那是 CLI 限制;每個接線點印一行 `item{項目}{路徑}{present\|missing\|unverified}`,也會把帶版號快取路徑、開發存取庫路徑與不存在的腳本列為缺項。狀態有三格不是兩格:`unverified` 是「這一項驗不了」,只有 `missing` 才算缺項——`kiro-cli agent validate` 在沒登入時印的是環境問題,不是這個檔案的問題,報 `present` 會讓沒驗到的東西看起來像通過,報 `missing` 會把沒登入算成接線缺漏;`status claude` 讀 Claude Code 實際載入的 `installed_plugins.json`,不再檢查目前腳本旁邊那份 `hooks.json`。體檢類技能(`/jsc-cli:doctor`)只能用這個子命令,另外三個都會動到環境;那道限制另有程式層把關,`JSC_READONLY=1` 之下只准 `status` 與 `smoke`,`purge` 與接線一律以 exit 6 拒絕並回報 `status=readonly`,環境不會被動到 | | `tools/scan-hook-errors.sh` | 掃 CLI 原生紀錄找 hook 的執行期錯誤(接線寫對、跑起來出錯)。只有 claude 有 hook 結果紀錄,掃 `~/.claude/projects/**/*.jsonl` 的 `hook_non_blocking_error` 與非空 `hookErrors`;codex、copilot、antigravity、kiro 沒有等價紀錄,一律回報 `unavailable` 並指向 `wire-cli.sh smoke {cli}`。每筆錯誤附加一行 JSON 到 `$JSC_HOME/errors/hooks.jsonl`,`jsc` 欄位標明是不是 jsc 自己的 hook(第三方 hook 的錯誤只回報,不由 jsc 修正);去重與 `scan-logs.sh` 同法,重掃只讀新增段落,以 `status=clean\|errors\|unavailable` 回報 | ## 失敗回報範本 @@ -143,7 +188,7 @@ Claude 由 `hooks/hooks.json` 自動接線九支 hook;其他 CLI 用 `hooks-in ### `hooks-install` -把九支 hook 接線到所有已安裝的 CLI,每個 CLI 走五道關卡:先 `tools/wire-cli.sh purge {cli}` 備份後移除所有 hook(含非 jsc 的第三方項目,乾淨起跑才分得清後續失敗是誰的),再 `tools/wire-cli.sh {cli}` 接線(claude 由 `hooks.json` 自動接線,無需寫入;其他 CLI 的持久命令會寫成 `$JSC_HOME/current/jsc-hooks` 穩定路徑),接著 `tools/wire-cli.sh status {cli}` 唯讀盤點接線結果,再 `tools/wire-cli.sh smoke {cli}` 驗執行期,最後 `tools/scan-hook-errors.sh --cli {cli}` 掃原生紀錄。第一支 CLI 的管線單獨跑完(`$JSC_HOME/current/jsc-hooks` 連結由它統一更新),其餘各 CLI 的管線才並行。codex、copilot、antigravity 由接線腳本裝上 `tools/jsc-wrap.sh` 包裝別名補上計時與用量回填(結束時自動跑 `tools/scan-logs.sh`),語言規則仍重寫到各自的規則檔(以 `` 標記整段取代,等同先移除再重裝,不重複追加)。codex、copilot、antigravity、kiro 的 SDLC 模型鎖降級為技能步驟檢查,鎖檔仍由 SDLC 技能直接呼叫 `sdlc-gate.sh lock` 寫入;這四個 CLI 沒有 pre-tool hook,版本前置檢查、部署後重啟閘門與 `write-guard.sh` 的三種模式都接不上(重啟閘門在那四支上一次技能呼叫都擋不下來,狀態檔照樣寫、下個工作階段照樣清);也沒有 post-tool hook,`comment-scope.sh` 接不到逐檔即時掃描,改用 `sweep` 掃整個 git 工作區——codex 每輪結束、kiro 每輪提示送出時、copilot 與 antigravity 只有工作階段結束時掃一次,腳本會在 `reason` 裡講明各自的時機,只有 claude 回報 `wired`,也只有 claude 掃得到執行期錯誤紀錄。任一關卡出錯(purge、接線、冒煙失敗,或掃到 `jsc=true` 的執行期錯誤)就先寫 `ERROR_{HASH}`,再交給 `repair` 技能接手並以 `develop` PR 收尾;此時允許中止剩下的安裝,但修正一定要開始。掃到 `jsc=false` 的第三方 hook 錯誤只回報,不轉修正。 +把九支 hook 接線到所有已安裝的 CLI,每個 CLI 走五道關卡:先 `tools/wire-cli.sh purge {cli}` 備份後移除所有 hook(含非 jsc 的第三方項目,乾淨起跑才分得清後續失敗是誰的),再 `tools/wire-cli.sh {cli}` 接線(claude 由 `hooks.json` 自動接線,無需寫入;其他 CLI 的持久命令會寫成 `$JSC_HOME/current/jsc-hooks` 穩定路徑),接著 `tools/wire-cli.sh status {cli}` 唯讀盤點接線結果,再 `tools/wire-cli.sh smoke {cli}` 驗執行期,最後 `tools/scan-hook-errors.sh --cli {cli}` 掃原生紀錄。第一支 CLI 的管線單獨跑完(`$JSC_HOME/current/jsc-hooks` 連結由它統一更新),其餘各 CLI 的管線才並行。codex、copilot、antigravity 由接線腳本裝上 `tools/jsc-wrap.sh` 包裝別名補上計時與用量回填(結束時自動跑 `tools/scan-logs.sh`),語言規則仍重寫到各自的規則檔(以 `` 標記整段取代,等同先移除再重裝,不重複追加)。codex、copilot、antigravity、kiro 的 SDLC 模型鎖降級為技能步驟檢查,鎖檔仍由 SDLC 技能直接呼叫 `sdlc-gate.sh lock` 寫入。版本前置檢查與部署後重啟閘門在 codex、copilot、antigravity 三支都擋得下來,各自接在自己的 pre-tool 位置(見上面「各 CLI 的 pre-tool 接線位置」),三支回報 `wired`;kiro 擋不下技能叫用,只注入警告,回報 `degraded`,那是 CLI 的限制。`write-guard.sh` 的三種模式目前仍只接在 claude。這四個 CLI 都沒有 post-tool hook,`comment-scope.sh` 接不到逐檔即時掃描,改用 `sweep` 掃整個 git 工作區——codex 每輪結束、kiro 每輪提示送出時、copilot 與 antigravity 只有工作階段結束時掃一次,腳本會在 `reason` 裡講明各自的時機,也只有 claude 掃得到執行期錯誤紀錄。antigravity 與 kiro 的 hook 觸發都沒有實跑驗證(前者 quota 用盡、後者未登入),回報時要把「接線已驗」與「觸發未驗」分開講。任一關卡出錯(purge、接線、冒煙失敗,或掃到 `jsc=true` 的執行期錯誤)就先寫 `ERROR_{HASH}`,再交給 `repair` 技能接手並以 `develop` PR 收尾;此時允許中止剩下的安裝,但修正一定要開始。掃到 `jsc=false` 的第三方 hook 錯誤只回報,不轉修正。 ### `repair` @@ -158,6 +203,11 @@ Claude 由 `hooks/hooks.json` 自動接線九支 hook;其他 CLI 用 `hooks-in | `JSC_HOME` | Hook 資料目錄 | 預設 `~/.jsc` | | `JSC_WIKI_REPO_ERROR` | `ERROR_CONTENTS`、`ERROR_{HASH}` 所在的 `{owner}/{repo}` | 退回 `JSC_WIKI_REPO` | | `JSC_WIKI_REPO` | 未逐類設定時的共用 wiki `{owner}/{repo}` | `tools/report-error.sh` 安靜降級,不寫 wiki | +| `COPILOT_HOME` | copilot 的設定根目錄,`hooks/jsc-hooks.json` 與指引檔都寫在它底下 | 預設 `~/.copilot` | +| `KIRO_HOME` | kiro 的設定根目錄,`agents/jsc.json`、`settings/cli.json` 與 `resources` 的 `skill://` glob 都由它推導 | 預設 `~/.kiro` | +| `JSC_ANTIGRAVITY_HOOKS` | antigravity 的 hook 設定檔,`tools/wire-cli.sh` 只寫它的 `jsc` 段落 | 預設 `~/.gemini/config/hooks.json` | +| `JSC_COPILOT_INSTRUCTIONS` | copilot 指引檔位置。舊值 `~/.config/copilot/copilot-instructions.md` 從來不會被載入,不要再指回去 | 預設 `$COPILOT_HOME/copilot-instructions.md` | +| `JSC_ANTIGRAVITY_RULES` | antigravity 全域規則檔位置 | 預設 `~/.antigravity/AGENTS.md` | | `JSC_CLAUDE_SETTINGS_DIR` | `tools/wire-cli.sh purge claude` 要清 `hooks` 鍵的設定檔目錄。指向一份複製品就能完整測過刪鍵邏輯,不必拿使用者本人的設定檔當測試場 | 預設 `~/.claude` | | `JSC_VERSION_GUARD` | 設 `off` 完全略過版本前置檢查(離線工作用) | 啟用檢查 | | `JSC_VERSION_TTL` | 遠端版本查詢的快取秒數 | 預設 600 | @@ -170,7 +220,7 @@ Claude 由 `hooks/hooks.json` 自動接線九支 hook;其他 CLI 用 `hooks-in | `JSC_READONLY` | 設 `1` 時 `tools/wire-cli.sh` 只准 `status` 與 `smoke`,`purge` 與接線一律拒絕並回 exit 6 | 四個用法都可執行 | | `JSC_CHANGED_FILE` | 非 Claude CLI 要掃描的檔案路徑,代替 stdin JSON 的 `file_path`,供 `comment-scope.sh` 與 `lang-guard.sh` 使用 | 安靜降級,不掃描 | | `JSC_TOOL_COMMAND` | 非 Claude CLI 要判定的 Bash 指令字串,代替 stdin JSON 的 `command`,供 `write-guard.sh commit` 使用 | 安靜降級,不判定 | -| `JSC_CLI` / `JSC_SESSION_ID` / `JSC_SKILL` / `JSC_TOOL_NAME` | 非 Claude CLI 接線時由 `tools/jsc-wrap.sh` 或接線設定提供,代替 stdin JSON 的 `session_id`、`skill`、`tool_name`(`version-guard.sh` 也收沒有前綴的 `SKILL`、`TOOL_NAME`) | 安靜降級 | +| `JSC_CLI` / `JSC_SESSION_ID` / `JSC_SKILL` / `JSC_TOOL_NAME` | 非 Claude CLI 接線時由 `tools/jsc-wrap.sh` 或接線設定提供,代替 stdin JSON 的 `session_id`、技能名與 `tool_name`(`hooks/skill-name.sh` 也收沒有前綴的 `SKILL`,而且環境變數蓋過負載解析;`write-guard.sh` 也收 `TOOL_NAME`)。`version-guard.sh` 與 `restart-gate.sh` 已經不篩工具名——五支 CLI 的工具名各不相同(`Skill`、`Bash`、`skill`、`view_file`),拿 Claude 那一個當通用條件會把另外四支整批擋在判定之外 | 安靜降級:`JSC_CLI` 取不到就當查不到 CLI,技能名取不到就由負載解析,兩邊都空就放行 | | `JSC_MODEL` | `sdlc-gate.sh` 找不到 transcript、hook stdin JSON 與 Codex 本機 session 記錄時的人工覆寫模型 id;回報會標明 `人工覆寫:JSC_MODEL` | 找不到可驗證模型來源時拒絕 `lock`,並列出已檢查來源與修復建議 | ## 相關 domain diff --git a/hooks/codex-hooks.json b/hooks/codex-hooks.json new file mode 100644 index 0000000..5b64e35 --- /dev/null +++ b/hooks/codex-hooks.json @@ -0,0 +1,125 @@ +{ + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "sh -c 'JSC_CLI=codex; export JSC_CLI; root=\"${CLAUDE_PLUGIN_ROOT:-${JSC_HOME:-$HOME/.jsc}/current/jsc-hooks}\"; exec sh \"$root/hooks/session-timer.sh\" start'" + } + ] + } + ], + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "sh -c 'JSC_CLI=codex; export JSC_CLI; root=\"${CLAUDE_PLUGIN_ROOT:-${JSC_HOME:-$HOME/.jsc}/current/jsc-hooks}\"; exec sh \"$root/hooks/ste100-guard.sh\"'" + }, + { + "type": "command", + "command": "sh -c 'JSC_CLI=codex; export JSC_CLI; root=\"${CLAUDE_PLUGIN_ROOT:-${JSC_HOME:-$HOME/.jsc}/current/jsc-hooks}\"; exec sh \"$root/hooks/sdlc-gate.sh\" check'" + }, + { + "type": "command", + "command": "sh -c 'JSC_CLI=codex; export JSC_CLI; root=\"${CLAUDE_PLUGIN_ROOT:-${JSC_HOME:-$HOME/.jsc}/current/jsc-hooks}\"; exec sh \"$root/hooks/sdlc-gate.sh\" wp-check prompt'" + }, + { + "type": "command", + "command": "sh -c 'JSC_CLI=codex; export JSC_CLI; root=\"${CLAUDE_PLUGIN_ROOT:-${JSC_HOME:-$HOME/.jsc}/current/jsc-hooks}\"; exec sh \"$root/hooks/comment-scope.sh\" prompt'" + }, + { + "type": "command", + "command": "sh -c 'JSC_CLI=codex; export JSC_CLI; root=\"${CLAUDE_PLUGIN_ROOT:-${JSC_HOME:-$HOME/.jsc}/current/jsc-hooks}\"; exec sh \"$root/hooks/lang-guard.sh\" prompt'" + } + ] + } + ], + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "sh -c 'JSC_CLI=codex; export JSC_CLI; root=\"${CLAUDE_PLUGIN_ROOT:-${JSC_HOME:-$HOME/.jsc}/current/jsc-hooks}\"; exec sh \"$root/hooks/session-timer.sh\" mark'" + } + ] + } + ], + "SessionEnd": [ + { + "hooks": [ + { + "type": "command", + "command": "sh -c 'JSC_CLI=codex; export JSC_CLI; root=\"${CLAUDE_PLUGIN_ROOT:-${JSC_HOME:-$HOME/.jsc}/current/jsc-hooks}\"; exec sh \"$root/hooks/session-timer.sh\" mark'" + } + ] + } + ], + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "sh -c 'JSC_CLI=codex; export JSC_CLI; root=\"${CLAUDE_PLUGIN_ROOT:-${JSC_HOME:-$HOME/.jsc}/current/jsc-hooks}\"; exec sh \"$root/hooks/restart-gate.sh\"'" + }, + { + "type": "command", + "command": "sh -c 'JSC_CLI=codex; export JSC_CLI; root=\"${CLAUDE_PLUGIN_ROOT:-${JSC_HOME:-$HOME/.jsc}/current/jsc-hooks}\"; exec sh \"$root/hooks/version-guard.sh\"'" + }, + { + "type": "command", + "command": "sh -c 'JSC_CLI=codex; export JSC_CLI; root=\"${CLAUDE_PLUGIN_ROOT:-${JSC_HOME:-$HOME/.jsc}/current/jsc-hooks}\"; exec sh \"$root/hooks/sdlc-gate.sh\" wp-check skill'" + } + ] + }, + { + "matcher": "Write|Edit|MultiEdit", + "hooks": [ + { + "type": "command", + "command": "sh -c 'JSC_CLI=codex; export JSC_CLI; root=\"${CLAUDE_PLUGIN_ROOT:-${JSC_HOME:-$HOME/.jsc}/current/jsc-hooks}\"; exec sh \"$root/hooks/write-guard.sh\" stage'" + }, + { + "type": "command", + "command": "sh -c 'JSC_CLI=codex; export JSC_CLI; root=\"${CLAUDE_PLUGIN_ROOT:-${JSC_HOME:-$HOME/.jsc}/current/jsc-hooks}\"; exec sh \"$root/hooks/write-guard.sh\" review'" + } + ] + }, + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "sh -c 'JSC_CLI=codex; export JSC_CLI; root=\"${CLAUDE_PLUGIN_ROOT:-${JSC_HOME:-$HOME/.jsc}/current/jsc-hooks}\"; exec sh \"$root/hooks/write-guard.sh\" commit'" + } + ] + } + ], + "PostToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "sh -c 'JSC_CLI=codex; export JSC_CLI; root=\"${CLAUDE_PLUGIN_ROOT:-${JSC_HOME:-$HOME/.jsc}/current/jsc-hooks}\"; exec sh \"$root/hooks/skill-usage.sh\"'" + } + ] + }, + { + "matcher": "Write|Edit|MultiEdit", + "hooks": [ + { + "type": "command", + "command": "sh -c 'JSC_CLI=codex; export JSC_CLI; root=\"${CLAUDE_PLUGIN_ROOT:-${JSC_HOME:-$HOME/.jsc}/current/jsc-hooks}\"; exec sh \"$root/hooks/comment-scope.sh\"'" + }, + { + "type": "command", + "command": "sh -c 'JSC_CLI=codex; export JSC_CLI; root=\"${CLAUDE_PLUGIN_ROOT:-${JSC_HOME:-$HOME/.jsc}/current/jsc-hooks}\"; exec sh \"$root/hooks/lang-guard.sh\"'" + } + ] + } + ] + } +} diff --git a/hooks/deny.sh b/hooks/deny.sh new file mode 100755 index 0000000..0b79984 --- /dev/null +++ b/hooks/deny.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env sh +# deny.sh — 產出各 CLI 認得的「擋下這一次呼叫」輸出。 +# +# 用途:阻擋形態每支 CLI 都不一樣,判定卻是同一件事。形態留這一份,閘門只管判定: +# 各自留一份輸出邏輯,改了一支忘了另一支,就會出現「判定擋下、CLI 卻沒收到拒絕」的無聲失效。 +# +# 用法:deny.sh {cli} [訊息...] +# 訊息從參數來,沒給參數就讀標準輸入。兩者都空就送一句預設訊息,不會產出空的拒絕。 +# +# 各 CLI 的阻擋形態: +# claude、codex、copilot 訊息寫 stderr,結束碼 2 就是拒絕 +# antigravity stdout 印 {"decision":"deny","reason":"..."}。 +# 結束碼語意兩邊文件都沒寫,**絕對不可靠**,所以固定回 0, +# 拒絕整個靠那一行 JSON。也因此 stdout 只准有那一行, +# 呼叫端要給人看的字一律走 stderr。 +# kiro 擋不下來。技能叫用是 agent 內部請求,preToolUse 攔不到, +# userPromptSubmit 的非零結束碼也不會擋下那一輪。 +# 唯一可用的介入是 stdout 注入,所以改印警告並回 0。 +# +# 結束碼: +# 2 拒絕已經送出(claude、codex、copilot,以及認不得的 CLI 代號)。呼叫端直接把它當自己的結束碼。 +# 0 拒絕已經送出,但形態不靠結束碼(antigravity 的 stdout JSON),或這支 CLI 根本擋不下來(kiro)。 +# 本檔沒有其他結束碼。認不得的代號不另立一種:五支裡有三支是 stderr 加 2,未知代號走這個保守預設, +# 比靜靜放行安全。 +set -u + +CLI="${1:-}" +shift 2>/dev/null || true + +if [ "$#" -gt 0 ]; then + MSG="$*" +else + MSG="" + [ -t 0 ] || MSG=$(cat 2>/dev/null || true) +fi +[ -n "$MSG" ] || MSG='[jsc]:這次呼叫已被 jsc 閘門擋下。' + +# 把訊息壓成一個合法的 JSON 字串值:反斜線與雙引號先跳脫,換行與定位字元改成跳脫序列。 +# 直接把原文塞進 JSON 會做出解不開的負載,antigravity 收到壞 JSON 等於沒收到拒絕。 +json_reason() { + printf '%s' "$MSG" \ + | sed 's/\\/\\\\/g; s/"/\\"/g; s/ /\\t/g' \ + | awk '{ printf "%s%s", sep, $0; sep = "\\n" } END { printf "\n" }' +} + +case "$CLI" in + antigravity) + printf '{"decision":"deny","reason":"%s"}\n' "$(json_reason)" + exit 0 ;; + kiro) + printf '%s\n' "$MSG" + printf '[jsc]:kiro 擋不下技能叫用(CLI 限制),上面這段只是警告,請自己先處理完再繼續。\n' + exit 0 ;; + *) + printf '%s\n' "$MSG" >&2 + exit 2 ;; +esac diff --git a/hooks/restart-gate.sh b/hooks/restart-gate.sh index 7f6632e..4264ed5 100755 --- a/hooks/restart-gate.sh +++ b/hooks/restart-gate.sh @@ -4,16 +4,26 @@ # 技能組更新後,正在跑的 CLI 行程載入的還是舊版:SKILL.md、hook 腳本與 tools 都在啟動當下 # 讀進記憶體。所以部署收尾要求重新啟動,這道閘門負責讓「還沒重啟就繼續用技能」擋在門外。 # -# 結束碼(hook 模式):0=放行 2=擋下該次技能呼叫,訊息走 stderr。 -# 安靜放行(exit 0)的情況:逃生門 JSC_RESTART_GATE=off、工具名取得到但不是 Skill、 -# 取不到技能名、技能名不是 jsc-{domain}:{name}、命中下方豁免清單那 10 支、取不到 CLI 代號、 +# 結束碼(hook 模式):0=放行 2=擋下該次技能呼叫。 +# 擋下時的輸出形態由 deny.sh 依當前 CLI 決定,本檔只負責判定與訊息內容: +# claude、codex、copilot 走 stderr 加 exit 2;antigravity 走 stdout 的 deny JSON,結束碼 +# 固定 0(那支 CLI 的結束碼語意沒有文件,不可靠);kiro 擋不下來,改印警告後 exit 0。 +# 所以「exit 0」在這支腳本有兩種意思:放行,或已經以不靠結束碼的形態擋下。 +# 安靜放行(exit 0)的情況:逃生門 JSC_RESTART_GATE=off、負載裡解不出技能名、 +# 解出來的不是 jsc 技能、命中下方豁免清單那 10 支、取不到 CLI 代號、 # 當前 CLI 那份狀態檔與舊格式狀態檔都不在。 -# 只有「當前 CLI 那份狀態檔存在」或「退回讀到的舊格式狀態檔存在」會 exit 2。 +# 只有「當前 CLI 那份狀態檔存在」或「退回讀到的舊格式狀態檔存在」會走 deny.sh。 # 結束碼(require):0=閘門已掛上 2=取不到 CLI 代號或寫不進狀態檔,兩種都等於沒掛上。 # 結束碼(clear、report):0=永遠成功。clear 檔案不存在也算成功,report 一份都沒有就不印。 # 結束碼(不認得的子命令):0=安靜放行,不中斷宿主 CLI。 # 註:本檔以 `. "$HERE/lib.sh"` 載入共用函式,沒有接 `|| true`。lib.sh 讀不到時 sh 會就地 # 結束並回 2,接在 PreToolUse 上就是無聲擋下每一次技能呼叫,上面那些放行路徑一條都跑不到。 +# hooks/skill-name.sh 與 hooks/deny.sh 同理要一起裝上,但那兩支是以子行程呼叫,讀不到只會 +# 讓技能名解不出來而安靜放行,不會反過來擋人——所以那兩支刻意不用 source 載入。 +# +# 輸入:技能名一律由 skill-name.sh 從當前 CLI 的負載解析,環境變數 JSC_SKILL、SKILL 優先, +# 規則與 version-guard.sh 共用同一份。不再另外篩工具名:工具名每支 CLI 都不一樣 +# (Skill、Bash、skill、view_file),拿 Claude 的那一個當通用條件會把另外四支整批擋在判定之外。 # # 用法: # restart-gate.sh hook 模式:當前 CLI 那份狀態檔存在就擋下該次技能 @@ -184,19 +194,14 @@ read_stdin [ "${JSC_RESTART_GATE:-}" = "off" ] && exit 0 -# 輸入相容:stdin JSON(Claude 格式)與環境變數(其他四支 CLI 接線時設定)都要收, -# 取法比照 version-guard.sh。工具名取不到就當成沒篩,繼續判技能名。 -tool="${JSC_TOOL_NAME:-${TOOL_NAME:-$(json_str tool_name)}}" -[ -z "$tool" ] || [ "$tool" = "Skill" ] || exit 0 - -skill="${JSC_SKILL:-${SKILL:-$(json_str skill)}}" -[ -n "$skill" ] || exit 0 - -# 只管本技能組(jsc-{domain}:{name})。別人的技能不受這道閘門影響。 -case "$skill" in - jsc-*:*) ;; - *) exit 0 ;; -esac +# 技能名解析:交給 skill-name.sh,規則與 version-guard.sh 共用同一份。輸出固定是 +# 「{domain}{技能名}」;用 awk 判 NF==2 才取值,少一欄就當成解析不出來,免得沒有定位字元時 +# cut -f2 把整行當成技能名,拼出一個不存在的技能名去比對豁免清單。 +sn=$(printf '%s' "$STDIN_JSON" | sh "$HERE/skill-name.sh" "$(cli_name)" 2>/dev/null) +sn_domain=$(printf '%s\n' "$sn" | awk -F'\t' 'NF == 2 { print $1; exit }') +sn_name=$(printf '%s\n' "$sn" | awk -F'\t' 'NF == 2 { print $2; exit }') +[ -n "$sn_domain" ] && [ -n "$sn_name" ] || exit 0 +skill="jsc-$sn_domain:$sn_name" # 豁免清單(理由見檔頭) case "$skill" in @@ -236,9 +241,13 @@ info="" # 系統認定就是這一支剛部署過。 [ "$legacy" = yes ] && info="${info}${info:+,}舊格式紀錄,分不出是哪一支 CLI 部署的" -printf '[jsc][重啟閘門][ERR]:技能組已更新%s,%s 還在跑舊版,新版要重新啟動才會載入。本次技能呼叫已擋下。\n' \ - "${info:+($info)}" "$bin" >&2 -printf '重新啟動:結束 %s 再重新開啟一次,狀態檔 %s 會在新工作階段開始時自動清除。\n' \ - "$bin" "$state" >&2 -printf '仍可使用:/jsc-cli:deploy、/jsc-hooks:hooks-install、/jsc-hooks:repair、/jsc-gitea:wiki、/jsc-log:worklog、/jsc-log:learn、/jsc-meta:*、/jsc-ask:ask、/jsc-git:pr、/jsc-git:commit(部署後的異動報告與工作日誌要寫得完,hook 壞掉也要修得回來) | 確定要略過閘門:JSC_RESTART_GATE=off\n' >&2 -exit 2 +# 擋人輸出交給 deny.sh:形態依 CLI 而定,本檔只組訊息。三段訊息整段走同一條管線送過去, +# antigravity 那一支才有辦法把它們壓成同一個 reason 字串;分次呼叫會做出好幾份 deny JSON, +# 那支 CLI 只認第一份,後面兩段使用者永遠看不到。 +{ printf '[jsc][重啟閘門][ERR]:技能組已更新%s,%s 還在跑舊版,新版要重新啟動才會載入。本次技能呼叫已擋下。\n' \ + "${info:+($info)}" "$bin" + printf '重新啟動:結束 %s 再重新開啟一次,狀態檔 %s 會在新工作階段開始時自動清除。\n' \ + "$bin" "$state" + printf '仍可使用:/jsc-cli:deploy、/jsc-hooks:hooks-install、/jsc-hooks:repair、/jsc-gitea:wiki、/jsc-log:worklog、/jsc-log:learn、/jsc-meta:*、/jsc-ask:ask、/jsc-git:pr、/jsc-git:commit(部署後的異動報告與工作日誌要寫得完,hook 壞掉也要修得回來) | 確定要略過閘門:JSC_RESTART_GATE=off\n' +} | sh "$HERE/deny.sh" "$(cli_name)" +exit $? diff --git a/hooks/skill-name.sh b/hooks/skill-name.sh new file mode 100755 index 0000000..fa544de --- /dev/null +++ b/hooks/skill-name.sh @@ -0,0 +1,106 @@ +#!/usr/bin/env sh +# skill-name.sh — 從各 CLI 的 hook 負載解析出「這一次要用哪一支 jsc 技能」。 +# +# 用途:五支 CLI 的負載形態各不相同,但「從負載取出 domain 與技能名」是同一件事。 +# 規則只留這一份:寫在每支閘門裡就會漂移,CLI 換了負載形態也只要改這一個地方。 +# +# 用法:skill-name.sh {claude|codex|copilot|antigravity|kiro} +# 從標準輸入讀該 CLI 的 hook 負載,印出一行「{domain}{技能名}」,例如「sdlcimplement」。 +# 解析不出來就印空字串,由呼叫端安靜放行。 +# +# 各 CLI 的取值來源: +# claude stdin JSON 的 skill 欄位(PreToolUse matcher Skill 才會有) +# codex stdin JSON 的 tool_input.command 裡那條 SKILL.md 路徑。Codex 沒有 Skill 工具, +# 技能是模型自己用 Bash 讀 SKILL.md 載入的,所以要從指令字串裡認路徑 +# copilot stdin JSON 的 toolArgs。那個欄位是**字串化的 JSON**,要先剝一層跳脫才讀得到裡面的值 +# antigravity stdin JSON 的 toolCall.args.AbsolutePath;另外收 PreInvocation 那一輪的提示字串, +# 因為斜線指令會把 SKILL.md 全文直接注入訊息,一個工具呼叫都不產生,PreToolUse 攔不到 +# kiro stdin JSON 的 prompt,取開頭那個「/{技能名}」 +# 五支都先看環境變數 JSC_SKILL、SKILL:接線時用環境變數餵資料的 CLI 要收得到,冒煙測試也走這條。 +# +# 為什麼只認 jsc 技能:呼叫這支腳本的是 jsc 自己的閘門,別人的技能不歸它們管。解不出 jsc-{domain} +# 這個形狀就等同「這一次不是 jsc 技能」,印空字串比印半個結果安全——呼叫端只要判空就好,不必再 +# 自己補一次「這是不是我們的技能」的判斷,那正是會漂移的那一段。 +# +# 結束碼: +# 0 永遠是 0,含「解析不出來」與「CLI 代號不認得」兩種。這支腳本只解析、不判定: +# 閘門那一端一律 fail-open,解析失敗回非零只會讓呼叫端多一條沒必要的錯誤分支。 +# copilot 的 command hook 是 fail-closed 的(非零結束碼等於拒絕),更不能回非零。 +# 本檔沒有其他結束碼。 +set -u + +CLI="${1:-}" + +PAYLOAD="" +[ -t 0 ] || PAYLOAD=$(cat 2>/dev/null || true) + +# 把整份負載併成一行再取「某個欄位之後的內容」。不切逗號:命令字串裡本來就有逗號, +# 切了會把路徑攔腰砍斷。貪婪比對取的是最後一次出現的那個欄位,巢狀負載也指得到裡層那一個。 +after_field() { # $1=欄位名 + printf '%s' "$PAYLOAD" | tr -d '\n' \ + | sed -n "s/.*\"$1\"[[:space:]]*:[[:space:]]*//p" +} + +# 從一段文字取第一條 SKILL.md 路徑。刻意不去解那個 JSON 字串的值:值裡的引號是跳脫過的, +# 照欄位邊界取會在第一個 \" 就被截斷,反而讀不到路徑。認路徑本身的形狀最穩。 +md_path() { # $1=文字 + printf '%s' "$1" | grep -o '/[A-Za-z0-9_./-]*SKILL\.md' | head -n1 +} + +# 從一段文字取第一個 jsc-{domain}:{技能名} 字樣 +token_skill() { # $1=文字 + printf '%s' "$1" | grep -o 'jsc-[a-z0-9][a-z0-9-]*:[a-z0-9][a-z0-9-]*' | head -n1 +} + +# jsc-{domain}:{技能名} → 兩欄輸出 +emit_token() { # $1=技能名字樣 + [ -n "$1" ] || return 0 + _d=${1#jsc-}; _d=${_d%%:*} + _n=${1#*:} + [ -n "$_d" ] && [ -n "$_n" ] || return 0 + printf '%s\t%s\n' "$_d" "$_n" +} + +# SKILL.md 路徑 → 兩欄輸出。domain 取路徑裡最後一段 jsc-{domain},技能名取 SKILL.md 的上一層目錄, +# 所以 {前綴}/jsc-sdlc/skills/implement/SKILL.md 與 {前綴}/jsc-sdlc/implement/SKILL.md 都解得出來。 +emit_path() { # $1=路徑 + [ -n "$1" ] || return 0 + _d=$(printf '%s' "$1" | sed -n 's#.*/jsc-\([a-z0-9][a-z0-9-]*\)/.*#\1#p') + _n=$(printf '%s' "$1" | sed -n 's#.*/\([^/][^/]*\)/SKILL\.md$#\1#p') + [ -n "$_d" ] && [ -n "$_n" ] || return 0 + printf '%s\t%s\n' "$_d" "$_n" +} + +# 環境變數優先。接線時用環境變數餵資料的 CLI 只有這一條路,負載再怎麼解也解不出東西。 +env_skill="${JSC_SKILL:-${SKILL:-}}" +if [ -n "$env_skill" ]; then + emit_token "$(token_skill "$env_skill")" + exit 0 +fi + +case "$CLI" in + claude) + emit_token "$(token_skill "$(after_field skill)")" ;; + codex) + emit_path "$(md_path "$(after_field command)")" ;; + copilot) + # 剝一層字串化 JSON:把 \" 還原成 "、\\ 還原成 \,裡面的技能名才認得出來。 + _args=$(after_field toolArgs | sed 's/\\"/"/g; s/\\\\/\\/g') + _tok=$(token_skill "$_args") + if [ -n "$_tok" ]; then emit_token "$_tok"; else emit_path "$(md_path "$_args")"; fi ;; + antigravity) + _p=$(md_path "$(after_field AbsolutePath)") + if [ -n "$_p" ]; then + emit_path "$_p" + else + # PreInvocation 那一輪沒有工具呼叫,只有提示字串。斜線指令走的就是這條路。 + emit_token "$(token_skill "$(after_field prompt)")" + fi ;; + kiro) + # 提示開頭那個斜線指令。kiro 的技能不走工具管線,userPromptSubmit 是唯一看得到技能名的時點。 + emit_token "$(token_skill "$(after_field prompt)")" ;; + *) + : ;; # 認不得的代號印空字串,理由見檔頭結束碼那一段 +esac + +exit 0 diff --git a/hooks/version-guard.sh b/hooks/version-guard.sh index 17105e1..54896d4 100755 --- a/hooks/version-guard.sh +++ b/hooks/version-guard.sh @@ -6,23 +6,32 @@ # 二、技能所屬 plugin 宣告的相依 plugin 版本落後(manifest 的 jsc.requires)。 # 兩種都會提示更新指令。 # -# 結束碼(hook 模式):0=放行 2=擋下該次技能呼叫,訊息走 stderr。 +# 結束碼(hook 模式):0=放行 2=擋下該次技能呼叫。 +# 擋下時的輸出形態由 deny.sh 依當前 CLI 決定,本檔只負責判定與訊息內容: +# claude、codex、copilot 走 stderr 加 exit 2;antigravity 走 stdout 的 deny JSON,結束碼 +# 固定 0(那支 CLI 的結束碼語意沒有文件,不可靠);kiro 擋不下來,改印警告後 exit 0。 +# 所以「exit 0」在這支腳本有兩種意思:放行,或已經以不靠結束碼的形態擋下。 # 安靜放行(exit 0)的情況要記清楚,這道閘門絕大多數時候走的是這幾條:逃生門 -# JSC_VERSION_GUARD=off、工具名取得到但不是 Skill、取不到技能名、技能名不是 -# jsc-{domain}:{name}、拆不出 domain、命中下方豁免清單那 7 支、解不出安裝路徑、 +# JSC_VERSION_GUARD=off、負載裡解不出技能名、解出來的不是 jsc 技能、 +# 命中下方豁免清單那 7 支、解不出安裝路徑、 # 讀不到 manifest、manifest 沒有 jsc.requires、讀不到相依 plugin 的本機載入版本、 # 讀不到自己的本機實際載入版本、推導不出遠端站台、查不到遠端版本、 # 本機版本等於或超前遠端。 -# 只有「相依確定落後」與「本機落後遠端」這兩條會 exit 2。 +# 只有「相依確定落後」與「本機落後遠端」這兩條會走 deny.sh。 # 結束碼(report、recommend):0=永遠成功,只讀不擋。結論看 stdout,不看結束碼。 # 註:本檔以 `. "$HERE/lib.sh"` 載入共用函式,沒有接 `|| true`。lib.sh 讀不到時 sh 會就地 # 結束並回 2,接在 PreToolUse 上就是無聲擋下每一次技能呼叫,上面那些放行路徑一條都跑不到 # (write-guard.sh 踩過這個坑)。部署時要確認 hooks/lib.sh 跟這支腳本一起裝上。 +# hooks/skill-name.sh 與 hooks/deny.sh 同理要一起裝上,但那兩支是以子行程呼叫,讀不到只會 +# 讓技能名解不出來而安靜放行,不會反過來擋人——所以那兩支刻意不用 source 載入。 # -# 輸入:stdin JSON(Claude 格式)或環境變數,兩者都收。 -# 工具名 JSC_TOOL_NAME、TOOL_NAME、stdin 的 tool_name -# 技能名 JSC_SKILL、SKILL、stdin 的 skill -# 兩邊都拿不到就安靜降級 exit 0。 +# 輸入:技能名一律由 skill-name.sh 從當前 CLI 的負載解析,環境變數 JSC_SKILL、SKILL 優先。 +# 五支 CLI 的負載形態不同(claude 有 skill 欄位、codex 是 Bash 指令裡的 SKILL.md 路徑、 +# copilot 是字串化的 toolArgs、antigravity 是 AbsolutePath、kiro 是提示開頭的斜線指令), +# 取值規則只留 skill-name.sh 那一份,本檔不重寫第二套。解不出來就安靜降級 exit 0。 +# 不再另外篩工具名:工具名每支 CLI 都不一樣(Skill、Bash、skill、view_file), +# 拿 Claude 的那一個當通用條件,等於把另外四支整批擋在判定之外——這正是先前失效的原因。 +# 接線那一端已經用各自的 matcher 篩過一輪,解得出技能名就是該判的那一次。 # # 判準與取值: # - 比對對象是「遠端發佈版本」與「本機**實際載入**的版本」。 @@ -306,24 +315,14 @@ read_stdin [ "${JSC_VERSION_GUARD:-}" = "off" ] && exit 0 -# 輸入相容:stdin JSON(Claude 格式)與環境變數(其他四支 CLI 接線時設定)都要收。 -# 只讀 stdin 的話,用環境變數餵資料的 CLI 一律拿到空值,檢查會整支靜靜放行。 -# 兩者都缺才是真的沒資料,那時照舊安靜降級 exit 0。 -tool="${JSC_TOOL_NAME:-${TOOL_NAME:-$(json_str tool_name)}}" -[ -z "$tool" ] || [ "$tool" = "Skill" ] || exit 0 - -skill="${JSC_SKILL:-${SKILL:-$(json_str skill)}}" -[ -n "$skill" ] || exit 0 - -# 只管本技能組(jsc-{domain}:{name}) -case "$skill" in - jsc-*:*) ;; - *) exit 0 ;; -esac - -domain=${skill#jsc-} -domain=${domain%%:*} -[ -n "$domain" ] || exit 0 +# 技能名解析:交給 skill-name.sh,它一支 CLI 一個子命令,規則只有那一份。 +# 輸出固定是「{domain}{技能名}」;用 awk 判 NF==2 才取值,少一欄就當成解析不出來, +# 免得沒有定位字元時 cut -f2 把整行當成技能名,拼出一個不存在的技能名去比對豁免清單。 +sn=$(printf '%s' "$STDIN_JSON" | sh "$HERE/skill-name.sh" "$(cli_name)" 2>/dev/null) +domain=$(printf '%s\n' "$sn" | awk -F'\t' 'NF == 2 { print $1; exit }') +name=$(printf '%s\n' "$sn" | awk -F'\t' 'NF == 2 { print $2; exit }') +[ -n "$domain" ] && [ -n "$name" ] || exit 0 +skill="jsc-$domain:$name" # 豁免清單 case "$skill" in @@ -348,11 +347,15 @@ update_cmd() { # $1=domain esac } +# 擋人輸出交給 deny.sh:形態依 CLI 而定,本檔只組訊息。訊息整段走管線送過去, +# antigravity 那一支才有辦法把多行訊息壓成同一個 reason 字串;分成好幾次呼叫會做出好幾份 +# deny JSON,那支 CLI 只認第一份,後面幾段訊息使用者永遠看不到。 deny() { # $1=訊息 - printf '[jsc][版本檢查][ERR]:%s\n' "$1" >&2 - printf '更新指令:%s\n' "$(update_cmd "$domain")" >&2 - printf '更新整組:/jsc-cli:deploy | 確定要略過檢查:JSC_VERSION_GUARD=off\n' >&2 - exit 2 + { printf '[jsc][版本檢查][ERR]:%s\n' "$1" + printf '更新指令:%s\n' "$(update_cmd "$domain")" + printf '更新整組:/jsc-cli:deploy | 確定要略過檢查:JSC_VERSION_GUARD=off\n' + } | sh "$HERE/deny.sh" "$(cli_name)" + exit $? } # ── 相依版本檢查:讀技能所屬 plugin 的 manifest,逐項比對相依 plugin 的本機載入版本。 @@ -374,10 +377,12 @@ if [ -n "$plugin_dir" ] && [ -f "$plugin_dir/plugin.json" ]; then printf ' - %s 需要 %s,目前 %s,更新指令:%s\n' "$dep" "$cond" "$cur" "$(update_cmd "$dep_domain")" done) if [ -n "$behind_list" ]; then - printf '[jsc][版本檢查][ERR]:%s 宣告的相依 plugin 版本落後,本次技能呼叫已擋下\n' "$skill" >&2 - printf '%s\n' "$behind_list" >&2 - printf '更新整組:/jsc-cli:deploy | 確定要略過檢查:JSC_VERSION_GUARD=off\n' >&2 - exit 2 + # 逐項清單與結語一起送進 deny.sh,理由同上:一次呼叫、一份拒絕。 + { printf '[jsc][版本檢查][ERR]:%s 宣告的相依 plugin 版本落後,本次技能呼叫已擋下\n' "$skill" + printf '%s\n' "$behind_list" + printf '更新整組:/jsc-cli:deploy | 確定要略過檢查:JSC_VERSION_GUARD=off\n' + } | sh "$HERE/deny.sh" "$(cli_name)" + exit $? fi fi diff --git a/plugin.json b/plugin.json index 597df58..cebe865 100644 --- a/plugin.json +++ b/plugin.json @@ -1,6 +1,6 @@ { "name": "jsc-hooks", - "version": "0.3.3", + "version": "0.3.4", "description": "跨 CLI hooks:STE100 語言強制、工時計時、技能用量記錄、SDLC 模型鎖、版本前置檢查、註解範圍守門、繁中編碼守門、部署後強制重啟、寫入與提交閘門", "skills": "./skills/", "jsc": { diff --git a/references/behaviors.md b/references/behaviors.md index 22d5e85..03a0491 100644 --- a/references/behaviors.md +++ b/references/behaviors.md @@ -8,16 +8,16 @@ | --- | --- | | 觸發時機 | 裝好或更新完 jsc 技能組之後,要把九支 hook 接線到每一支已安裝的 CLI 時用;`jsc-cli:deploy` 收尾會把偵測到的 CLI 清單交給它。不用於撰寫新的 hook,也不用於單獨修一支壞掉的 hook,那是 `jsc-hooks:repair` 的事 | | 關鍵步驟 | 取得 CLI 清單(呼叫端交來的優先,沒有才自己跑 `detect-clis.sh`)、第一支 CLI 單獨跑完整條管線(它負責更新共用的 `$JSC_HOME/current/jsc-hooks` 連結)、其餘 CLI 一支一個 sub agent 並行、每支 CLI 依序走 purge、接線、status、smoke、scan 五道關卡、讀每道關卡自己印的第一行判定、任一關卡出錯就寫 `ERROR_{HASH}` 並轉給 `jsc-hooks:repair`、逐 CLI 回報五道關卡的結果 | -| 外部呼叫 | `tools/wire-cli.sh purge`、`tools/wire-cli.sh {cli}`、`tools/wire-cli.sh status`、`tools/wire-cli.sh smoke`、`tools/scan-hook-errors.sh`、`tools/report-error.sh`、`jsc-cli/tools/detect-clis.sh`、`jsc-hooks:repair` 技能、`jsc-gitea:wiki`(寫 `ERROR_{HASH}` 時經 `report-error.sh`) | -| 完成條件 | 每一支偵測到的 CLI 都有五道關卡各一行判定,沒有任何一道回結束碼 2,smoke 的 `lines` 條數與它自己的斷言相符,codex、copilot、antigravity、kiro 四支據實回報 `degraded` 與 `unavailable`,而且每一筆錯誤都帶一個 `ERROR_{HASH}` 結果與一條對 `develop` 的修正 PR 連結 | -| 可驗證跡象 | 各 CLI 的設定檔多出 `` 標記區塊(codex 的 `config.toml`、copilot 與 antigravity 的別名檔、kiro 的 `.kiro/hooks/jsc-hooks.json`)、`$JSC_HOME/current/jsc-hooks` 符號連結建立或更新、`$JSC_HOME/backup/hooks/{cli}/{時間戳}/` 留下 purge 前的備份、出錯時 wiki 多一頁 `ERROR_{HASH}` 並在 `ERROR_CONTENTS` 補一列、修正路徑留下一條對 `develop` 的 PR | +| 外部呼叫 | `tools/wire-cli.sh purge`、`tools/wire-cli.sh {cli}`、`tools/wire-cli.sh status`、`tools/wire-cli.sh smoke`、`tools/scan-hook-errors.sh`、`tools/report-error.sh`、`jsc-cli/tools/detect-clis.sh`、`jsc-hooks:repair` 技能、`jsc-gitea:wiki`(寫 `ERROR_{HASH}` 時經 `report-error.sh`);接線腳本內部另呼叫 `hooks/skill-name.sh` 與 `hooks/deny.sh` 做冒煙斷言 | +| 完成條件 | 每一支偵測到的 CLI 都有五道關卡各一行判定,沒有任何一道回結束碼 2,smoke 的 `lines` 條數與它自己的斷言相符,claude、codex、copilot、antigravity 回 `wired` 而 kiro 回 `degraded`(CLI 擋不下技能叫用),四支非 claude 的執行期錯誤掃描一律據實回 `unavailable`,各 CLI 的形狀與觸發驗證等級分開寫進回報(codex、antigravity、kiro 形狀實證,copilot 形狀未證;kiro 觸發部分實證,其餘未驗證),而且每一筆錯誤都帶一個 `ERROR_{HASH}` 結果與一條對 `develop` 的修正 PR 連結 | +| 可驗證跡象 | 各 CLI 的設定檔多出 jsc 段落:codex 的 `config.toml` 標記段落、`hooks/codex-hooks.json`(從 `hooks/hooks.json` 推導,matcher `Skill` 換成 `Bash`)與 `.codex-plugin/plugin.json` 指過去的 `hooks` 路徑字串、copilot 的 `~/.copilot/settings.json` 頂層 `hooks` 鍵(matcher `skill`,合併不覆寫,`enabledPlugins` 與第三方條目原樣保留)與 `$COPILOT_HOME` 底下的指引檔、antigravity 的 `~/.gemini/config/hooks.json` 的 `jsc` 段落(`PreToolUse` 為 Grouped、matcher `^view_file$`,`PreInvocation` 維持 Flat)、kiro 的 `~/.kiro/agents/jsc.json`(`hooks` 為 `agentSpawn`、`userPromptSubmit`、`stop` 三個合法事件加 `timeout_ms`、兩層 `skill://` glob 的 `resources`、明列的 `tools`,並通過 `kiro-cli agent validate`)與 `~/.kiro/settings/cli.json` 的 `chat.defaultAgent=jsc`;四支非 claude 的接線命令都以 `JSC_CLI={代號}` 前綴自帶 CLI 代號,缺了它兩道閘門解不出技能名、一律安靜放行,所以 `status` 把它列成單獨一項;另有 `$JSC_HOME/current/jsc-hooks` 符號連結建立或更新、`$JSC_HOME/backup/hooks/{cli}/{時間戳}/` 留下 purge 前的備份、出錯時 wiki 多一頁 `ERROR_{HASH}` 並在 `ERROR_CONTENTS` 補一列、修正路徑留下一條對 `develop` 的 PR | ## repair | 項目 | 內容 | | --- | --- | | 觸發時機 | `hooks-install` 或 `report-error.sh` 回報某一支 hook 失敗時用,或是重新接線之後那支 hook 還是一直失敗時用。不用於例行接線,也不用於與 hook 無關的修改 | -| 關鍵步驟 | 從 `ERROR_{HASH}` 讀失敗情境(沒有頁就讀失敗的 `status=` 那一行,讀不到就停下來問)、跑 `detect-clis.sh`、每一支偵測到的 CLI 各開一個唯讀 sub agent 診斷並交回根因、要改的檔案與驗證指令、挑最小的修正改進 hooks 存取庫、跑 `wire-cli.sh smoke {cli}` 驗到 exit 0、跑 `sync-skill-manifest.sh .` 同步版本、以 `jsc-git:pr` 對 `develop` 開 PR | +| 關鍵步驟 | 從 `ERROR_{HASH}` 讀失敗情境(沒有頁就讀失敗的 `status=` 那一行,讀不到就停下來問)、跑 `detect-clis.sh`、每一支偵測到的 CLI 各開一個唯讀 sub agent 診斷並交回根因、要改的檔案與驗證指令、挑最小的修正改進 hooks 存取庫(技能名解析改 `hooks/skill-name.sh`、阻擋形態改 `hooks/deny.sh`,兩支是唯一真實來源,不在閘門裡各補一份)、跑 `wire-cli.sh smoke {cli}` 驗到 exit 0、跑 `sync-skill-manifest.sh .` 同步版本、以 `jsc-git:pr` 對 `develop` 開 PR | | 外部呼叫 | `jsc-gitea:wiki`、`jsc-cli/tools/detect-clis.sh`、`tools/wire-cli.sh smoke`、`jsc-meta/tools/sync-skill-manifest.sh`、`jsc-git:pr`;診斷階段另以 sub agent 叫用各支已安裝的 AI CLI | | 完成條件 | 修正已經落在磁碟上、`wire-cli.sh smoke` 對受影響的 CLI 回 exit 0、`sync-skill-manifest.sh` 回 exit 0 而且三份 manifest 版本一致,最後拿到一條對 `develop` 的 PR 連結;開不出 PR 時要講明修正已套用但尚未合併、帶上分支名與失敗原因 | | 可驗證跡象 | hooks 存取庫多一個修正提交與一條推上去的分支、`develop` 上多一條 PR、三份 manifest 與 README 技能清單版本一致、`wire-cli.sh smoke` 由失敗轉為 exit 0 | diff --git a/skills/hooks-install/SKILL.md b/skills/hooks-install/SKILL.md index 7841100..4614705 100644 --- a/skills/hooks-install/SKILL.md +++ b/skills/hooks-install/SKILL.md @@ -11,7 +11,41 @@ Install on a clean slate. Every CLI is purged of all hooks first, third-party on The wiring commands stored in user config use `$JSC_HOME/current/jsc-hooks`, not the versioned plugin cache path and not the development checkout. `tools/wire-cli.sh {cli}` creates or refreshes that symlink before it writes `notify`, shell aliases or Kiro hook JSON, then verifies the linked scripts exist. If the filesystem cannot create the symlink, the script must say so and explicitly fall back to the current root; it must never write a silent broken path. The bundled `hooks/hooks.json` follows the same rule: use `${CLAUDE_PLUGIN_ROOT}` only where the host provides it, and fall back to `$JSC_HOME/current/jsc-hooks` for any other CLI reading the same manifest, so an unset Claude-only variable never expands into `/hooks/...`. -Only claude has PreToolUse, PostToolUse and UserPromptSubmit, so only claude reports `wired`. On codex, copilot, antigravity and kiro neither the version guard, the post-deploy restart gate nor any mode of the write and commit guard can be wired at all, and the SDLC gate degrades to the skill-step check, so all four report `degraded` — report that gap as the script words it instead of implying every CLI is covered. On those four the restart gate blocks no skill call whatsoever: the state file is still written and still cleared at the next session start, so the restart itself rests on the `jsc-cli:deploy` closing message. +Four of the five CLIs have a pre-tool hook that can block. The version guard and the restart gate reach codex, copilot and antigravity too — each at its own wiring point, with its own matcher and its own blocking shape. Never say a CLI "has no pre-tool hook"; that claim is wrong and it is what left three CLIs unguarded. + +| CLI | Wiring point | Event and matcher | Blocking shape | Verdict | +| --- | --- | --- | --- | --- | +| claude | `hooks/hooks.json` | `PreToolUse`, matcher `Skill` | stderr plus exit 2 | `wired` | +| codex | `hooks/codex-hooks.json`, pointed at by the `hooks` **path string** in `.codex-plugin/plugin.json` | `PreToolUse`, matcher `Bash` | stderr plus exit 2 | `wired` | +| copilot | `hooks` key of `~/.copilot/settings.json`, merged in place | `PreToolUse`, matcher `skill` | stderr plus exit 2 | `wired` | +| antigravity | `jsc` block of `~/.gemini/config/hooks.json` | `PreToolUse`, matcher `^view_file$` (**Grouped**), plus `PreInvocation` (**Flat**) | `{"decision":"deny",...}` on stdout | `wired` | +| kiro | `hooks` key of `~/.kiro/agents/jsc.json`, plus `chat.defaultAgent=jsc` | `agentSpawn`, `userPromptSubmit`, `stop` | warning injected on stdout; blocks nothing | `degraded` | + +On the four non-claude CLIs every wired command carries its own CLI code as a `JSC_CLI={code}` prefix. A gate has to know which CLI it is running under before it can read a skill name out of `skill-name.sh` or a blocking shape out of `deny.sh`; with no code the skill name resolves to nothing and both gates pass in silence — config correct, matcher correct, blocks never. On antigravity it is worse: an unknown code makes `deny.sh` fall back to the exit-code shape, which that CLI ignores, so the gate decides to block and the CLI never hears it. The `jsc-wrap.sh` alias exports `JSC_CLI` as well, but only when the user starts the CLI through the alias from an interactive shell, so wiring never leans on it. `wire-cli.sh` asserts the prefix per CLI at wiring time, at `status` time and in `smoke`. + +Only claude reaches all nine hooks. On codex, copilot and antigravity the SDLC gate still degrades to the skill-step check, the three write and commit guard modes are still unwired, and the comment and language scans still run as `sweep` — say exactly that, in the words the script prints, instead of implying full coverage. + +kiro is `degraded` because the CLI cannot block a skill call, not because the wiring is short of anything. A skill there is a `ResolveSkill` request inside the agent, off the tool pipeline, so `preToolUse` never sees it and a non-zero exit from `userPromptSubmit` does not stop the turn. Injecting a warning on stdout is the only intervention left. Its hook declarations live in the agent config's `hooks` key — `.kiro/hooks/` is not in kiro's config-directory constants and is never read — and that same agent file needs the two-level `skill://` glob in `resources` (the default glob scans one level, jsc skills sit at `jsc-{domain}/{name}/SKILL.md`) plus an explicit `tools` list, with `chat.defaultAgent` set to `jsc` so the agent is chosen at all. + +Shape is part of the wiring, and a wrong shape fails silently. Two rules are read straight out of the CLIs' own embedded specs and asserted by `wire-cli.sh`. Antigravity splits its events: `PreToolUse` and `PostToolUse` are **Grouped** — handlers wrapped in a `matcher` plus `hooks` group — while `PreInvocation`, `PostInvocation` and `Stop` are **Flat**. A Flat `PreToolUse` is discarded whole: the hook name still registers, no `actions` key is even generated, nothing errors, and the file reads as correct. Codex's plugin manifest takes `hooks` as a **path string**, exactly like `skills`; an inline object does not parse. That path is an override, so `hooks/codex-hooks.json` is **derived** from `hooks/hooks.json` — copied whole, with `"matcher": "Skill"` rewritten to `"matcher": "Bash"` — which keeps every other event and keeps `hooks/hooks.json` the single source of truth. Never hand-write the second file. + +Copilot keeps hook config in the `hooks` key of `settings.json` — inline definitions keyed by event name. `$COPILOT_HOME/hooks/` holds the scripts a hook runs, not the config; a config file written there sits on disk, correct and unread. That same `settings.json` also carries `enabledPlugins` and `extraKnownMarketplaces`, so wiring **merges and never overwrites**: back up first, touch only jsc's own entries under `hooks`, then read back and compare the top-level keys and every foreign hook entry against what was there before, restoring the backup if either moved. `purge` takes out only jsc's entries and leaves the third-party `SessionStart` alone. + +Kiro declares hooks in the agent config's `hooks` key, and its only legal events are `agentSpawn`, `userPromptSubmit`, `preToolUse`, `postToolUse` and `stop`; the fields are `command` (required), `matcher` and `timeout_ms` — there is no `on`, `run` or `env`. The old wiring put `on`/`run`/`env` at the top level, `kiro-cli agent validate` reported nothing at all, and the file did nothing: **unknown top-level keys are ignored in silence**. A valid file is not a wired file, so check the shape separately. + +**`kiro-cli agent validate` always exits 0.** Valid, illegal event name, `on`/`run` inside `hooks`, missing `command` — all four exit 0, and the errors only appear in the output. Reading the exit code builds a check that can never fail, which is the same class of bug as the ones being fixed here. Judge by the output: empty means valid. Output about something else — not logged in, expired credentials — means the check could not run, not that the file is bad; pass it and say so, because failing there would block wiring on every machine that is not logged in. + +**Verification level, per CLI.** "Shape" means the CLI really parses the config; "firing" means a hook really ran. Keep them apart and report them as this table has them: + +| CLI | Shape | Firing | +| --- | --- | --- | +| claude | proven | proven | +| codex | **proven** — `[hooks.state]` in `~/.codex/config.toml` records `{event}:{group}:{entry}`, a two-level index that only a Grouped structure produces; the manifest's `hooks` is a path string per the binary's own `plugin-json-spec.md` | unverified | +| antigravity | **proven** — after wiring, `agy -p "/hooks"` lists all four entries with `matcher=^view_file$`, third-party block intact | unverified (conversation quota exhausted) | +| copilot | **unproven** — `settings.json` has no read-only listing path; the location and entry form come from `copilot help config` and the working third-party entry already on the machine | unverified | +| kiro | **proven** — `kiro-cli agent validate` passes with empty output, and a counter-check confirms it discriminates: `sessionStart`, `on`/`run` inside `hooks`, and a missing `command` each produce an error | **partly proven** — `agentSpawn` and `userPromptSubmit` were observed firing; `preToolUse` and `stop` are unverified (model quota) | + +Two more open items: whether kiro's two-level `resources` glob actually fixes skill visibility is unverified, and on the four non-claude CLIs the three `write-guard.sh` modes and the SDLC model lock are still unwired — not yet done, rather than failing. `wire-cli.sh smoke` asserts wiring content and script logic line by line; firing is outside its reach. `comment-scope.sh` and `lang-guard.sh` both reach all five, wired at the same set of places, but on a different event and at a different moment each. Report the timing per CLI; never state it as one uniform behaviour: @@ -19,7 +53,7 @@ Only claude has PreToolUse, PostToolUse and UserPromptSubmit, so only claude rep | --- | --- | --- | | claude | Per file, the instant it is written | PostToolUse | | codex | End of every turn, over the whole git worktree | `notify` in `config.toml` | -| kiro | On every prompt submit, over the whole git worktree — it sees what the previous turn wrote | `userPromptSubmit` in `.kiro/hooks/jsc-hooks.json` | +| kiro | On every prompt submit, over the whole git worktree — it sees what the previous turn wrote | `userPromptSubmit` in `~/.kiro/agents/jsc.json` | | copilot, antigravity | Once, when the session ends | `tools/jsc-wrap.sh` teardown | The table above holds for both scanners. The `sweep` mode reads `git diff HEAD`, so its coverage matches what claude sees; only the feedback delay differs. Outside a git worktree `sweep` exits 0 in silence and nothing is scanned at all — say so when the user works outside git. Both `prompt` rule reminders still go into every rule file alongside the STE100 block, because a warning that arrives a turn late is worth less than not writing the offending text in the first place. @@ -35,12 +69,12 @@ The detailed flow **MUST run as a sub agent**; the main agent only reports the s 1. Take the CLI list from the caller when it hands one over — `jsc-cli:deploy` passes the list it already detected, and probing the same five executables a second time buys nothing. Run `jsc-cli/tools/detect-clis.sh` yourself only when no list came in; that fallback is what keeps this skill usable when it is called on its own. The script always exits 0 and prints one `namepathversion` line per installed CLI. Done when you hold that list and have said which of the two ways produced it; when it is empty, report that no CLI was detected and stop. 2. Run the five-stage pipeline **purge → wire → status → smoke → scan** once per detected CLI. Run the first CLI's pipeline on its own, because `tools/wire-cli.sh {cli}` is what refreshes the shared `$JSC_HOME/current/jsc-hooks` link and two CLIs must not rewrite it at the same time; once that first pipeline has finished, run every remaining CLI's pipeline in parallel, one sub agent per CLI — the five stages of one CLI stay in this order, but different CLIs touch different config files and share nothing else. Every stage prints its verdict on its first line, so read that line and never infer the outcome from the prose below it. 1. `tools/wire-cli.sh purge {cli}` — backs up every file it touches, removes all hooks, re-reads each file to confirm the removal, and restores the backup by itself when a check fails. Marker matching trims leading and trailing whitespace, so an indented or padded marker block is still removed as the same jsc-owned block. Exit 0 is `purged`, exit 3 is `skipped` (that CLI's executable is not on this machine, so skip its remaining stages too), exit 4 is `failed` and goes to step 3. Exit 2 is a bad CLI name, not a purge outcome — fix the name and rerun the stage. - 2. `tools/wire-cli.sh {cli}` — owns both the wiring and its verification: it refreshes the link, writes the config, alias or hook file inside a `` (or `# jsc-hooks`) marker block, re-reads every file it wrote, confirms the block is present and correctly placed, and confirms the stored runtime paths resolve to existing scripts before it prints a success status. Exit 0 is `wired`, exit 1 is `degraded` and is the expected result on the four non-claude CLIs, exit 3 is `skipped`, exit 4 is `failed` and goes to step 3. Exit 2 is a bad CLI name — fix the name and rerun. - 3. `tools/wire-cli.sh status {cli}` — the read-only inventory of what the previous stage wrote. It writes nothing and runs no hook, so it is safe to run right after wiring. Exit 0 is `wired`, exit 1 is `degraded`, exit 3 is `skipped`, exit 5 is `unwired`, which names every missing item and means the wiring stage has to run again before you continue. Exit 2 is a bad CLI name. For codex this stage is the only one that reads the installed `jsc-hooks` manifest in the Codex plugin cache and reports a stale `UserPromptSubmit` command there, the one that expands `${CLAUDE_PLUGIN_ROOT}` into `/hooks/...`; carry that item into the report. - 4. `tools/wire-cli.sh smoke {cli}` — runs every wired mode of all nine hooks once, plus each decision path of the work-package check, of the restart gate and of the write and commit guard. It catches what the wiring check cannot see: a hook that is wired correctly and still fails when it executes. It prints its own result-line count as `lines{count}` and asserts that count against what it expected to run, so read the number from that line and never restate a number of your own. Exit 0 is `ok`, exit 4 is `failed` — either a hook errored or the line count did not match, and both go to step 3. Exit 2 is a bad CLI name. + 2. `tools/wire-cli.sh {cli}` — owns both the wiring and its verification: it refreshes the link, writes the config, alias or hook file inside a `` (or `# jsc-hooks`) marker block, re-reads every file it wrote, confirms the block is present and correctly placed, and confirms the stored runtime paths resolve to existing scripts before it prints a success status. Exit 0 is `wired` and is the expected result on claude, codex, copilot and antigravity; exit 1 is `degraded` and is expected on kiro alone; exit 3 is `skipped`, exit 4 is `failed` and goes to step 3. Exit 2 is a bad CLI name — fix the name and rerun. The matcher is verified on its own, not just the presence of a key: a key that is there with the wrong matcher reports as wired and fires never. + 3. `tools/wire-cli.sh status {cli}` — the read-only inventory of what the previous stage wrote. It writes nothing and runs no hook, so it is safe to run right after wiring. Exit 0 is `wired` (claude, codex, copilot, antigravity), exit 1 is `degraded` (kiro), exit 3 is `skipped`, exit 5 is `unwired`, which names every missing item and means the wiring stage has to run again before you continue. Exit 2 is a bad CLI name. For codex this stage is the only one that reads the installed `jsc-hooks` manifest in the Codex plugin cache and reports a stale `UserPromptSubmit` command there, the one that expands `${CLAUDE_PLUGIN_ROOT}` into `/hooks/...`; carry that item into the report. + 4. `tools/wire-cli.sh smoke {cli}` — runs every wired mode of all nine hooks once, plus each decision path of the work-package check, of the restart gate and of the write and commit guard. It catches what the wiring check cannot see: a hook that is wired correctly and still fails when it executes. It also runs each CLI's real payload through `skill-name.sh`, each blocking shape through `deny.sh`, and those same payloads straight through `restart-gate.sh` end to end, so a break anywhere along parse, decide and emit is caught — the two ends look healthy on their own while the middle silently passes everything through, which is exactly how three CLIs went unguarded. It prints its own result-line count as `lines{count}` and asserts that count against what it expected to run, so read the number from that line and never restate a number of your own. Exit 0 is `ok`, exit 4 is `failed` — either a hook errored or the line count did not match, and both go to step 3. Exit 2 is a bad CLI name. 5. `tools/scan-hook-errors.sh --cli {cli}` — only claude keeps hook results in its native records and can answer `clean` or `errors`; codex, copilot, antigravity and kiro answer `unavailable`, and their runtime evidence comes from the smoke stage alone. Exit 0 covers both `clean` and `unavailable`, exit 1 is `errors` and every entry with `jsc=true` goes to step 3, exit 2 is a bad CLI name. - Done when every detected CLI has exactly one verdict line per stage, no stage exited 2, the smoke stage's `lines` count matches its own assertion, and the four non-claude CLIs are reported as `unavailable` rather than clean. + Done when every detected CLI has exactly one verdict line per stage, no stage exited 2, the smoke stage's `lines` count matches its own assertion, the four non-claude CLIs are reported as `unavailable` rather than clean on the scan stage, and antigravity and kiro carry the note that their hook firing is unverified. 3. For each error — a failed purge, a failed wiring, an `unwired` status, a failed smoke, or a scanned error with `jsc=true` — run `tools/report-error.sh --hook {script name} --exit {code} --summary "{reason}" --cli {cli}` with the script's `[jsc]` output on stdin, then hand the failure to `jsc-hooks:repair`, which **MUST run as a sub agent** and must finish by opening a PR against `develop`. Aborting the remaining installs here is allowed as long as the repair starts. Exit 0 with an `ERROR_{HASH}` page name and URL on stdout means the page was written; exit 0 with empty output means `JSC_WIKI_REPO_ERROR` and `JSC_WIKI_REPO` are both unset, so carry that reason into step 4 instead; exit 2 means the call itself was malformed — `--hook` or `--summary` is missing — so fix the arguments and rerun the same call; exit 4 means the wiki record did not land, so report the failure text and still start the repair — a page that could not be written is no reason to leave a broken hook wired. Exit 4 covers two cases, and the report has to say which: a failed write, or the script refusing to write the error directory page because it could not read the old one back. That directory is appended to, never overwritten: every row on it is somebody else's error report, so the script reads the page, adds this run's row, and writes the whole page. Only a genuine 404 (`wiki-get` exit 4) means the page is not there yet and lets it build one from the template. An invalid key (exit 7) or any other API failure (exit 8) leaves the old rows unknown, so it skips the directory write and names the code instead — writing a fresh template over a directory it never read would erase every earlier report, with no merge and no backup behind it. A scanned error with `jsc=false` belongs to a third-party hook: report it and leave it alone. Skip this step when every CLI passed all five stages. Done when every error carries one `ERROR_{HASH}` result — a page name and URL, or the recorded reason no page was written — and one repair PR URL against `develop`. 4. Report five results per CLI — purge, wiring, status, smoke, scan — each with the reason its script printed, plus the smoke `lines` count, any `ERROR_{HASH}` page name and every repair PR URL. Done when every detected CLI appears with one verdict per stage and every repair has a PR against `develop`. @@ -48,8 +82,9 @@ The detailed flow **MUST run as a sub agent**; the main agent only reports the s - Every hook script accepts both stdin JSON and environment variables (`JSC_CLI`, `JSC_SESSION_ID`, `JSC_SKILL`, `JSC_TOOL_NAME`, `JSC_TOOL_COMMAND`, `JSC_MODEL`); `jsc-wrap.sh` sets the first two itself. - `session-timer.sh` takes `start` (keep an existing start time), `restart` (always overwrite it, for a CLI with no session id — kiro), `mark` and `report`. `wire-cli.sh` picks the right one per CLI; do not hand-edit the generated hook files. `start` and `restart` also clear the restart gate whenever they decide this SessionStart is a new session, so the wiring of those two events is what lowers the gate after a restart — a CLI wired without them keeps the gate up until the user sets `JSC_RESTART_GATE=off`. +- `hooks/skill-name.sh` is the one place that turns a CLI's hook payload into `{domain}{skill}`, one subcommand per CLI: claude reads the `skill` field, codex reads the `SKILL.md` path inside `tool_input.command` (it has no Skill tool — the model loads a skill by reading the file with Bash), copilot reads `toolArgs` and has to unwrap one layer of stringified JSON, antigravity reads `toolCall.args.AbsolutePath` and also the prompt text (a slash command injects the whole `SKILL.md` and produces no tool call), kiro reads the leading slash command in `prompt`. All five honour `JSC_SKILL` and `SKILL` first. It always exits 0: the gates fail open, and copilot's command hooks are fail-closed, where any non-zero exit means deny. `hooks/deny.sh` is the matching single source for the blocking shape — stderr plus exit 2 for claude, codex and copilot; a single-line `{"decision":"deny","reason":"..."}` on stdout with a fixed exit 0 for antigravity, whose exit-code semantics are undocumented and must never be relied on; a printed warning and exit 0 for kiro, which cannot block. Neither guard keeps a second copy of either rule; a repair goes into these two files. - `restart-gate.sh` blocks jsc skill calls while `$JSC_HOME/restart-required.d/{cli}` exists — one file per CLI, named after the CLI code — so a freshly deployed skill set is not used by a process still running the old one. Each CLI reads only its own file: another CLI's file never blocks this one, and a restart clears only the file of the CLI that restarted. `jsc-cli:deploy` writes the current CLI's file through `restart-gate.sh require {install|update} [{domain}...]` at the end of an install or update; `restart-gate.sh report` prints one line per file, so it is visible which CLIs still owe a restart. A leftover old-format single file at `$JSC_HOME/restart-required` blocks every CLI and is deleted on the next `clear` — transitional only, and `hooks/restart-gate.sh` records when it can be dropped. The gate matches skill names, not call chains, so a nested call to anything off the exemption list is blocked all the same; `hooks/restart-gate.sh` owns that list with a reason per entry, and `jsc-meta/references/guidelines.md`「部署後重啟閘門」carries the same list. Escape hatch: `JSC_RESTART_GATE=off`. -- `write-guard.sh` takes three blocking modes, wired on two PreToolUse matchers, so claude is the only CLI where any of it takes effect, plus a fourth mode, `release`, that is wired nowhere and is called by a skill itself. `stage` reads the stage lock that `sdlc-gate.sh` already owns and blocks `Write`, `Edit` and `MultiEdit` while `plan` or `analyze` holds it, because those two stages produce wiki pages rather than files. `review` reads the current skill — the environment variable first, then the record `skill-usage.sh` keeps — and blocks writes while `jsc-review:code-review` or `jsc-review:api-doc` runs, since both only report findings. It deliberately does **not** block `jsc-review:comment-cleanup`: that skill has to write, limited to comment lines, and deciding that limit needs per-language comment parsing of the whole proposed content, which would block legitimate cleanups more often than it caught bad ones — that boundary stays with the skill text and the later review. `commit` is wired on `Bash` and blocks a single command that stages everything and commits in one go, plus any commit message carrying simplified characters or mojibake, which it decides by calling `lang-guard.sh` rather than keeping a second word list. A `git add -A` split across two separate tool calls is not caught, on purpose: catching it needs cross-call state that the blocked operator has no way to clear. `release` deletes that recorded skill and always exits 0; `jsc-review:code-review` and `jsc-review:api-doc` call it once each as they hand their findings back. It exists because the record says which skill was loaded last, not which one is still running: both audit skills end by leaving the fixing to their caller, and without `release` every write that caller makes stays blocked for the whole TTL, with the escape hatch or a wait as the only way out — a gate must never lock away its own release. Escape hatch: `JSC_WRITE_GUARD=off`, which `release` ignores because clearing a record blocks nobody, plus `JSC_WRITE_GUARD_TTL` for how long a recorded skill counts as still running. +- `write-guard.sh` takes three blocking modes, wired on two PreToolUse matchers on claude only, so claude is still the only CLI where any of it takes effect — codex, copilot and antigravity now have a usable pre-tool hook, but these three modes are not wired there yet; say that, rather than blaming a missing hook, plus a fourth mode, `release`, that is wired nowhere and is called by a skill itself. `stage` reads the stage lock that `sdlc-gate.sh` already owns and blocks `Write`, `Edit` and `MultiEdit` while `plan` or `analyze` holds it, because those two stages produce wiki pages rather than files. `review` reads the current skill — the environment variable first, then the record `skill-usage.sh` keeps — and blocks writes while `jsc-review:code-review` or `jsc-review:api-doc` runs, since both only report findings. It deliberately does **not** block `jsc-review:comment-cleanup`: that skill has to write, limited to comment lines, and deciding that limit needs per-language comment parsing of the whole proposed content, which would block legitimate cleanups more often than it caught bad ones — that boundary stays with the skill text and the later review. `commit` is wired on `Bash` and blocks a single command that stages everything and commits in one go, plus any commit message carrying simplified characters or mojibake, which it decides by calling `lang-guard.sh` rather than keeping a second word list. A `git add -A` split across two separate tool calls is not caught, on purpose: catching it needs cross-call state that the blocked operator has no way to clear. `release` deletes that recorded skill and always exits 0; `jsc-review:code-review` and `jsc-review:api-doc` call it once each as they hand their findings back. It exists because the record says which skill was loaded last, not which one is still running: both audit skills end by leaving the fixing to their caller, and without `release` every write that caller makes stays blocked for the whole TTL, with the escape hatch or a wait as the only way out — a gate must never lock away its own release. Escape hatch: `JSC_WRITE_GUARD=off`, which `release` ignores because clearing a record blocks nobody, plus `JSC_WRITE_GUARD_TTL` for how long a recorded skill counts as still running. - `purge` reaches the user-level config only. Hooks that another plugin ships in its own `hooks.json` stay active, and uninstalling that plugin is the only way to clear them — say so when reporting, and treat their errors as third-party. - Backups land in `$JSC_HOME/backup/hooks/{cli}/{yyyyMMdd_HHmmss}/`, one directory per purge run, under the original file names. Hand that path to the user whenever a purge removed something. - `status claude` reads Claude Code's `installed_plugins.json` and checks the `installPath` that the CLI actually loads. It must not check only the `hooks.json` next to the `wire-cli.sh` that happens to be running, because a development checkout can otherwise hide a broken installed plugin. diff --git a/tools/wire-cli.sh b/tools/wire-cli.sh index d660c2a..0fd2dde 100755 --- a/tools/wire-cli.sh +++ b/tools/wire-cli.sh @@ -25,37 +25,79 @@ # version-guard.sh 的相依版本檢查同法,只是沙箱換成暫時的 HOME(註冊檔路徑由 $HOME 決定): # 相依落後的擋人與訊息內容、相等與超前的放行、豁免技能在相依落後時照樣放行、四種 fail-open、 # 逃生門,再加一條回歸——多行縮排的 manifest,jsc.requires 的最後一個鍵也要解得到。 +# skill-name.sh 與 deny.sh 各有一組:前者把五支 CLI 的真實負載形態各餵一次,驗解得出技能名, +# 再加三條要解不出來的;後者驗四種阻擋形態各自出得來。最後一組是跨 CLI 貫通——把五支 CLI 的 +# 負載直接餵進 restart-gate.sh,驗「解析 → 判定 → 輸出形態」整條串得起來。這一組不能省: +# 前兩組分開看都會顯示正常,中間接不上照樣是全程放行,那正是先前三支 CLI 失效的樣子。 # # smoke 自己數結果行並自我斷言:`lines{數量}` 那一行印的是其後 `[jsc]` 結果行的實際條數, # 與腳本內宣告的預期條數逐類比對,不符就回非零。數字寫在腳本裡、由腳本自己印,散文引用那一行 # 就好,不必在 SKILL.md 或 README 各抄一份——抄了就會在加減判定路徑時漂移。 # -# 接線行為(依 CLI 而定,皆為冪等:重跑只取代既有的 jsc-hooks 標記段落,不會重複疊加): -# claude — 什麼都不用寫,hooks.json 已自動接線九支 hook -# codex — 在 shell rc 檔加上 codex 別名,轉呼叫 tools/jsc-wrap.sh codex(開始計時, +# 接線行為(依 CLI 而定,皆為冪等:重跑只取代既有的 jsc 段落,不會重複疊加): +# claude — 什麼都不用寫,hooks/hooks.json 已自動接線九支 hook(PreToolUse matcher Skill) +# codex — pre-tool hook 寫在 .codex-plugin/plugin.json 的 hooks 鍵,做 Codex 專屬覆寫 +# (PreToolUse matcher Bash,接 restart-gate.sh 與 version-guard.sh)。 +# Claude 用的 hooks/hooks.json 完全不動:那一份是 Skill matcher,而 Codex +# 根本沒有 Skill 這個工具,技能是模型自己用 Bash 讀 SKILL.md 載入的。 +# 另外在 shell rc 檔加上 codex 別名,轉呼叫 tools/jsc-wrap.sh codex(開始計時, # 結束時收尾掃一次註解範圍與繁中編碼);在 config.toml 設 notify(每輪補 # session-timer.sh start 再 mark,最後 comment-scope.sh sweep 與 # lang-guard.sh sweep 掃整個工作區,JSC_CLI=codex);在 AGENTS.md 附加 # STE100、註解範圍與繁中編碼規則段落(prompt 降級) -# copilot — 在 shell rc 檔加上 copilot 別名,轉呼叫 tools/jsc-wrap.sh copilot(結束時 -# 收尾掃一次註解範圍與繁中編碼);在 copilot-instructions.md 附加 STE100、 -# 註解範圍與繁中編碼規則段落 -# antigravity — 在 shell rc 檔加上 agy 別名,轉呼叫 tools/jsc-wrap.sh antigravity(同樣收尾 -# 掃一次);在全域規則檔附加 STE100、註解範圍與繁中編碼規則段落 -# kiro — 在工作區 .kiro/hooks/ 下建立 jsc-hooks.json(每輪 mark 加 STE100、 -# 註解範圍與繁中編碼規則,再 comment-scope.sh sweep 與 lang-guard.sh sweep -# 掃整個工作區)與 jsc-hooks-session-start.json(sessionStart 開始計時), -# 皆帶 JSC_CLI=kiro +# copilot — pre-tool hook 併進 ~/.copilot/settings.json 的頂層 hooks 鍵(PreToolUse +# matcher skill,小寫)。設定只認那個鍵:$COPILOT_HOME/hooks/ 底下放的是 hook +# 要跑的**腳本**,不是設定,寫進去的設定檔一次都不會被讀。事件名只寫一種 +# 大小寫:copilot 兩種都吃,兩種同時存在會把同一支 hook 跑兩次。 +# 那份檔案同時裝著 enabledPlugins 與 extraKnownMarketplaces,弄壞會讓外掛整批 +# 失效,所以**合併不覆寫**:寫前備份,只動 jsc 自己那幾筆,寫後回讀核對最上層鍵 +# 與別人的條目,對不上就還原。另外在 shell rc 檔加上 copilot 別名;指引檔寫在 +# $COPILOT_HOME 底下(舊接線寫在 ~/.config/copilot/,那個位置從來不會被載入) +# antigravity — pre-tool hook 寫在 ~/.gemini/config/hooks.json 的 jsc 段落。那個檔案的最上層 +# 一個安裝來源一個命名空間鍵,寫 jsc 那一個不會動到別人的段落;plugin.json +# 不能宣告 hook,所以只有這一個位置。matcher 是 ^view_file$,錨點不能省 +# (省了會連 view_file_outline 一起命中);另接 PreInvocation,攔斜線指令那條 +# 不產生工具呼叫的路。另外在 shell rc 檔加上 agy 別名、在全域規則檔附加規則段落 +# kiro — hook 宣告寫在 ~/.kiro/agents/jsc.json 的 hooks 鍵(agentSpawn、 +# userPromptSubmit、stop,欄位是 command 與 timeout_ms),並把 settings/cli.json +# 的 chat.defaultAgent 設成 jsc。同一份 agent 檔還要寫 resources 的兩層 +# skill:// glob 與明列的 tools。合法事件只有 agentSpawn、userPromptSubmit、 +# preToolUse、postToolUse、stop 五個;sessionStart 與 sessionEnd 都不合法, +# 欄位也沒有 on、run、env。.kiro/hooks/ 目錄不在 kiro 的設定目錄常數裡,一份都 +# 不會被讀,舊接線寫在那裡等於沒接;purge 仍會清掉那個目錄,免得誤導下一個人。 +# 接線後用 kiro-cli agent validate 驗一次,**判準是輸出不是結束碼**:那支指令 +# 四種情況一律回 0,看結束碼會做出一支永遠通過的檢查。而且最上層的未知鍵會被 +# 靜默忽略——舊版的 on/run/env 就是這樣一個錯都不報、卻什麼都沒做,所以 +# 「validate 通過」不等於「接線生效」,形狀要另外驗 # -# 覆蓋範圍要據實回報,不得暗示每個 CLI 都有保護: -# claude 九支 hook 全接,回報 wired -# codex、copilot、antigravity、kiro 只有別名、notify 或規則檔,接不上 PreToolUse、PostToolUse -# 與 UserPromptSubmit,版本前置檢查、部署後重啟閘門、寫入與 -# 提交閘門與 SDLC 模型鎖都沒接上,一律回報 degraded 並在 -# reason 講明。 -# 重啟閘門在這四個 CLI 上一次技能呼叫都擋不下來:狀態檔照樣 -# 寫、下次工作階段開始照樣清,只是中間沒有任何判定點,重啟 -# 只能靠 /jsc-cli:deploy 收尾的提示自己動手 +# 覆蓋範圍要據實回報,不得暗示每個 CLI 都有保護,也不得再說「沒有 pre-tool hook」—— +# 五支裡有四支都有,先前失效的原因是接錯位置,不是沒有位置可接: +# claude 九支 hook 全接,回報 wired +# codex、copilot、antigravity 版本前置檢查與部署後重啟閘門都擋得下來,回報 wired。 +# SDLC 模型鎖仍是技能步驟檢查,write-guard.sh 三種模式尚未接線, +# 註解範圍與繁中編碼仍是 sweep,reason 要逐項講明。 +# antigravity 的擋人一律走 stdout 的 deny JSON:那支 CLI 的結束碼 +# 語意兩邊文件都沒寫,靠結束碼會變成無聲失效 +# kiro 回報 degraded。技能叫用是 ResolveSkill 這個 agent 內部請求, +# preToolUse 攔不到,userPromptSubmit 的非零結束碼也不會擋下那 +# 一輪,所以兩道閘門只能以 stdout 注入警告。這是 CLI 的限制, +# 不是接線缺漏 +# +# 驗證等級要逐支標明,「形狀」與「觸發」是兩件事,不得混為一談: +# claude 形狀實證、觸發實證 +# codex 形狀實證(config.toml 的 [hooks.state] 以 {事件}:{群組}:{條目} 兩層索引登記, +# 只有 Grouped 結構才會這樣;manifest 的 hooks 是路徑字串,出自執行檔內嵌的 +# plugin-json-spec.md)。觸發未驗證 +# antigravity 形狀實證(接線後 agy -p "/hooks" 四條全載入,matcher 帶錨點,第三方段落完好)。 +# 觸發未驗證,本機對話 quota 用盡 +# copilot 形狀未證。settings.json 沒有唯讀的列出管道,位置與條目形態出自 +# copilot help config 的說明與機器上既有的第三方實例。觸發未驗證 +# kiro 形狀實證(kiro-cli agent validate 通過,並以 sessionStart、hooks 裡放 on/run、 +# 缺 command 三種反證確認它真的在判別)。觸發部分實證:agentSpawn 與 +# userPromptSubmit 實跑觸發過,preToolUse 與 stop 未驗證,模型額度用盡 +# 另有兩項未驗證:kiro 的 resources 兩層 glob 能不能真的修好技能可見性沒驗過(要模型跑得動才 +# 列得出技能);四支非 claude 的 CLI 上 write-guard.sh 三種模式與 SDLC 模型鎖仍未接線,那是 +# 還沒做,不是驗不過。smoke 驗的是接線內容與腳本邏輯,觸發不在它的範圍內。 # # 註解範圍與繁中編碼掃描每個 CLI 的時機都不同(兩支腳本接在同一批位置),回報時不得寫成五支一樣: # claude 掛在 PostToolUse,寫完哪個檔就掃哪個,逐檔即時 @@ -67,14 +109,20 @@ # 一律跟著 comment-scope.sh 接在同一批位置,兩支的掃描時機表完全一致。 # 所有 jsc 標記段落都採整段重寫,重跑等同先移除舊內容再重裝 # -# 寫入後自我驗證,通過才回報成功:每個寫過的檔案重新讀一次,確認標記段落存在且落在 -# 正確位置(codex 的 notify 必須是根層鍵,不能被歸進前一張表;kiro 的 JSON 必須成對 -# 且 on、run 在最上層),內容也要涵蓋這次該接上的每一支腳本(含 comment-scope.sh sweep -# 與 lang-guard.sh sweep)。 +# 寫入後自我驗證,通過才回報成功:每個寫過的檔案重新讀一次,確認段落存在且落在正確位置 +# (codex 的 notify 必須是根層鍵,不能被歸進前一張表;kiro 的 JSON 必須成對且 hooks、 +# resources、tools 都在最上層),內容也要涵蓋這次該接上的每一支腳本(含 comment-scope.sh sweep +# 與 lang-guard.sh sweep)。matcher 本身也單獨驗一次:鍵在、matcher 卻錯的形態最難查—— +# codex 要 Bash、copilot 要小寫 skill、antigravity 要帶錨點的 ^view_file$、kiro 的 resources +# 要有兩層 glob。這四項少驗任何一項,都會出現「回報接好了、實際一次都不會被叫用」。 +# 第五項是 CLI 代號:四支非 claude 的接線命令都要以 JSC_CLI={代號} 前綴自帶代號。閘門靠代號才 +# 取得到技能名與阻擋形態,代號取不到就一律安靜放行——形態跟 matcher 寫錯一模一樣,都是 +# 「設定完全正確、卻一次都擋不下來」。這一項在接線、status 與 smoke 三處各驗一次。 # 腳本說寫好了卻寫錯位置或少接一支,是最難查的失敗,所以驗證放在腳本裡。 # # 輸出: 第一行固定為 `status=... reason=...`(可供程式判讀),其後為人類可讀的繁中說明。 -# 結束碼(接線): 0=wired(已完整接線) 1=degraded(降級為 prompt/技能步驟檢查) +# 結束碼(接線): 0=wired(claude、codex、copilot、antigravity) +# 1=degraded(kiro,CLI 本身擋不下技能叫用) # 2=用法錯誤 3=skipped(該 CLI 未偵測到執行檔,略過) # 4=failed(寫入或驗證沒過,接線沒生效;由 hooks-install 呼叫 report-error.sh 回報) # 結束碼(purge): 0=purged 2=用法錯誤 3=skipped 4=failed @@ -144,6 +192,395 @@ lang_guard_text() { sh "$HOOKS/lang-guard.sh" prompt 2>/dev/null | sed '/^exit / # 三段合在一個標記段落裡,purge 與重跑接線都是整段處理,不必各自再記一組標記。 rules_text() { ste100_text; comment_scope_text; lang_guard_text; } +# --- 各 CLI 的接線位置:路徑只留這一份 --- +# 接線、移除、盤點三段都呼叫同一支函式取路徑。三邊各寫一次字面路徑,改了一處就會出現 +# 「接線寫這裡、盤點看那裡」的假接線報告,那種錯最難查。 +codex_manifest() { printf '%s' "$ROOT/.codex-plugin/plugin.json"; } +codex_hooks_file() { printf '%s' "$ROOT/hooks/codex-hooks.json"; } +# manifest 裡那個鍵的值。Codex 的 plugin manifest 規格寫得很清楚:hooks 跟 skills 一樣是 +# **路徑字串**(`"hooks": "./hooks.json"`),不是內嵌物件。寫成內嵌物件解不出來。 +CODEX_HOOKS_REL="./hooks/codex-hooks.json" +copilot_home() { printf '%s' "${COPILOT_HOME:-$HOME/.copilot}"; } +# copilot 的 hook 設定在 settings.json 的頂層 hooks 鍵,是**內嵌定義**、以事件名當鍵。 +# 不是 $COPILOT_HOME/hooks/*.json——那底下放的是 hook 要執行的**腳本**,不是設定; +# 也不是 config.json,那份檔案第一行自己就寫著「User settings belong in settings.json」。 +copilot_settings() { printf '%s' "${JSC_COPILOT_SETTINGS:-$(copilot_home)/settings.json}"; } +# 指引檔一定要在 $COPILOT_HOME 底下。舊接線寫在 ~/.config/copilot/,那個位置 copilot 從來不讀。 +copilot_instructions() { + printf '%s' "${JSC_COPILOT_INSTRUCTIONS:-$(copilot_home)/copilot-instructions.md}" +} +antigravity_hooks_file() { printf '%s' "${JSC_ANTIGRAVITY_HOOKS:-$HOME/.gemini/config/hooks.json}"; } +kiro_home() { printf '%s' "${KIRO_HOME:-$HOME/.kiro}"; } +kiro_agent_file() { printf '%s/agents/jsc.json' "$(kiro_home)"; } +kiro_cli_settings() { printf '%s/settings/cli.json' "$(kiro_home)"; } + +# --- 各 CLI 的 hook 宣告內容 --- + +# codex:manifest 的 hooks 鍵指到一份 Codex 專屬的 hook 檔,那個鍵是路徑字串。 +codex_hooks_key_block() { + printf ' "hooks": "%s"' "$CODEX_HOOKS_REL" +} + +# Codex 專屬的 hook 檔內容,**從 hooks/hooks.json 推導出來**,不是另外手寫一份。 +# 只改一件事:matcher Skill 換成 Bash。Codex 沒有 Skill 這個工具,技能是模型自己用 Bash 讀 +# SKILL.md 載入的,所以那一組 hook 要擋在 Bash 那一次;先前用 Skill matcher,一次都沒被叫用過。 +# +# 為什麼用推導、不手寫第二份:manifest 的 hooks 鍵是**覆寫**,codex 只會讀它指到的那一份, +# 手寫就等於把 SessionStart、UserPromptSubmit、Stop 那幾組全部漏掉,而且從此兩份各自漂移。 +# 推導的話 hooks/hooks.json 仍是唯一真實來源,那邊加一支 hook,這邊重跑接線就跟著有。 +# Claude 讀的還是原本那份 hooks/hooks.json,一個位元組都沒動。 +# +# 推導改兩件事,第二件跟 matcher 一樣不能省:每一條命令都補上 JSC_CLI=codex。 +# 閘門是靠 lib.sh 的 cli_name() 認出「現在是哪一支 CLI」,再拿那個代號去 skill-name.sh 取技能名、 +# 去 deny.sh 取阻擋形態。JSC_CLI 沒設時 cli_name() 只回 unknown,skill-name.sh 認不得代號就印空字串, +# 兩道閘門一律安靜放行——設定寫得完全正確、matcher 也對,卻一次都擋不下來,跟先前那個接錯位置的 +# 缺陷長得一模一樣。別名那條路(jsc-wrap.sh 會 export JSC_CLI)只在使用者從互動 shell 走別名啟動時 +# 才成立,接線不能靠它;kiro 的 hook 早就是這樣用指令前綴自帶代號的,這裡跟它一致。 +codex_derive_hooks() { # $1=來源 hooks.json + [ -f "$1" ] || return 1 + sed -e 's/"matcher": "Skill"/"matcher": "Bash"/g' \ + -e "s|sh -c 'root=|sh -c 'JSC_CLI=codex; export JSC_CLI; root=|g" "$1" +} + +# copilot 的 hook 條目。事件名只寫 PascalCase 一種:copilot 兩種大小寫都吃,兩種同時存在 +# 會把同一支 hook 跑兩次。matcher 是小寫的 skill,那是 copilot 專用技能工具的工具名。 +# 條目形態照這台機器上既有的那一筆第三方設定(type、bash、timeoutSec),不套 Claude 的形狀。 +# 指令前綴帶 JSC_CLI=copilot:閘門靠 cli_name() 認代號才取得到技能名與阻擋形態,沒設就一律 +# 安靜放行,matcher 對、位置對、卻一次都擋不下來。別名那條路只在走別名啟動時才成立,不能靠它。 +copilot_hook_entries() { + cat < 0 || + index(t, "restart-gate.sh") > 0 || + index(t, "version-guard.sh") > 0) + } + { s = s $0 "\n" } + END { + n = length(s) + if (mode == "wire") { + while ((getline line < newfile) > 0) newtext = newtext (newtext == "" ? "" : "\n") line + close(newfile) + if (newtext == "") exit 1 + } + # 一、找出最上層的 hooks 鍵,取它那個值的字元區間。 + i = 1; depth = 0; hs = 0; he = 0 + while (i <= n) { + c = substr(s, i, 1) + if (c == "\"") { + buf = ""; j = i + 1 + while (j <= n) { + c = substr(s, j, 1) + if (c == "\\") { j += 2; continue } + if (c == "\"") break + buf = buf c; j++ + } + if (j > n) exit 1 + i = j + 1 + k = skip_ws(s, i, n) + if (depth == 1 && buf == "hooks" && substr(s, k, 1) == ":") { + hs = skip_ws(s, k + 1, n) + he = skip_value(s, k + 1, n) + if (he == 0) exit 1 + break + } + continue + } + if (c == "{" || c == "[") depth++ + else if (c == "}" || c == "]") { depth--; if (depth < 0) exit 1 } + i++ + } + if (hs == 0) exit 2 # 沒有 hooks 鍵,交給呼叫端另外新增 + # 二、走訪 hooks 物件裡的每個事件,逐條篩掉 jsc 自己那幾筆。 + cnt = 0; pre = 0 + i = hs + 1 + while (i < he) { + i = skip_ws(s, i, n) + if (substr(s, i, 1) == "}") break + if (substr(s, i, 1) != "\"") { i++; continue } + ev = ""; j = i + 1 + while (j <= n) { + c = substr(s, j, 1) + if (c == "\\") { j += 2; continue } + if (c == "\"") break + ev = ev c; j++ + } + i = skip_ws(s, j + 1, n) + if (substr(s, i, 1) != ":") continue + av = skip_ws(s, i + 1, n) + ae = skip_value(s, i + 1, n) + if (ae == 0) exit 1 + body = "" + if (substr(s, av, 1) == "[") { + p = av + 1 + while (p < ae) { + p = skip_ws(s, p, n) + if (substr(s, p, 1) == "]") break + es = p + ee = skip_value(s, p, n) + if (ee == 0) exit 1 + elem = substr(s, es, ee - es) + if (!isjsc(elem)) body = body (body == "" ? "" : ",\n") " " elem + p = skip_ws(s, ee, n) + if (substr(s, p, 1) == ",") p++ + } + } + cnt++ + evname[cnt] = ev + evbody[cnt] = body + if (ev == "PreToolUse") pre = cnt + i = skip_ws(s, ae, n) + if (substr(s, i, 1) == ",") i++ + } + # 三、把 jsc 的條目接回 PreToolUse;那個事件本來不存在就新增一個。 + if (mode == "wire") { + if (pre == 0) { cnt++; evname[cnt] = "PreToolUse"; evbody[cnt] = ""; pre = cnt } + evbody[pre] = evbody[pre] (evbody[pre] == "" ? "" : ",\n") newtext + } + # 四、重組 hooks 的值。條目被清空的事件整個拿掉,免得留下空陣列。 + out = "{" + first = 1 + for (x = 1; x <= cnt; x++) { + if (evbody[x] == "") continue + out = out (first ? "" : ",") "\n \"" evname[x] "\": [\n" evbody[x] "\n ]" + first = 0 + } + out = out (first ? "}" : "\n }") + printf "%s%s%s", substr(s, 1, hs - 1), out, substr(s, he) + } + ' "$1" +} + +# antigravity:~/.gemini/config/hooks.json 的最上層是命名空間,一個安裝來源一個鍵, +# 所以 jsc 的段落就是 "jsc" 這個鍵,寫它不會動到別人的段落。 +# matcher 一定要寫錨點 ^view_file$:沒有錨點會連 view_file_outline 一起命中。 +# 另外接 PreInvocation:斜線指令與預載技能會把 SKILL.md 全文直接注入訊息,一個工具呼叫都不產生, +# PreToolUse 那一層根本看不到,只有 PreInvocation 攔得到那條路。 +# +# 兩個事件的結構**不一樣**,不能寫成同一種形狀(執行檔內嵌文件的「Supported Event Types」表): +# PreToolUse、PostToolUse Grouped——handler 要用 matcher 加 hooks 包一層 +# PreInvocation、PostInvocation、Stop +# Flat——handler 物件直接排在陣列裡 +# 這一條是實測踩出來的:PreToolUse 寫成 Flat 時 antigravity **靜默丟棄整個事件**, +# hook 名稱照樣登記,連 actions 鍵都不生成,不報任何錯,設定檔看起來也完全正常。 +# 所以 matcher 要留在 group 那一層,不是 handler 那一層。 +# 每條指令都帶 JSC_CLI=antigravity:這支的阻擋形態是 stdout 的 deny JSON,跟結束碼那三支不一樣, +# 代號取不到時 deny.sh 會退回「stderr 加 exit 2」的保守預設,那個形態 antigravity 根本不認, +# 等於判定擋下、CLI 卻收不到拒絕。技能名也一樣要靠代號才解得出來。別名那條路不能當接線的依據。 +antigravity_hooks_block() { + cat </dev/null +} + +# 反過來,PreInvocation 必須維持 Flat:那一組事件不吃 matcher 與 hooks 包裝, +# 跟著改成 Grouped 一樣會被丟掉。兩個方向都要驗,只驗一邊修好一半照樣失效。 +# $1=檔案 +antigravity_flat_ok() { + [ -f "$1" ] || return 1 + awk ' + /"PreInvocation"[[:space:]]*:/ { inflat = 1; next } + inflat && /"hooks"[[:space:]]*:[[:space:]]*\[/ { bad = 1 } + inflat && /^[[:space:]]*\][[:space:]]*$/ { inflat = 0 } + END { exit(bad ? 1 : 0) } + ' "$1" 2>/dev/null +} + +# kiro:hook 宣告只認 agent 設定檔的 hooks 鍵,.kiro/hooks/ 目錄不被讀。 +# 欄位只有 command、matcher、timeout_ms、max_output_size、cache_ttl_seconds,沒有 on、run、env, +# 所以 JSC_CLI 改用指令前綴帶進去。 +# resources 要寫兩層 glob:預設只掃 skills/*/SKILL.md 一層,jsc 的技能在 jsc-{domain}/{name}/ 第二層, +# 少了那一條,這個 agent 一支 jsc 技能都看不到。一層那一條照樣留著,別人的技能不能被我們弄不見。 +# tools 一定要明寫:自訂 agent 沒宣告 tools 時可用工具會受限,模型連讀檔都做不到。 +kiro_agent_json() { + cat </dev/null 2>&1 || return 0 + kiro_validate_output_ok "$("$_kb" agent validate --path "$1" 2>&1)" +} + +# agent 設定檔的形狀。合法事件只有 agentSpawn、userPromptSubmit、preToolUse、postToolUse、stop +# 五個;sessionStart 與 sessionEnd 都會被 validate 報錯,欄位也沒有 on、run、env。 +# 這一項單獨驗是因為**未知的頂層鍵會被靜默忽略**:舊版把 on/run/env 寫在最上層,validate +# 一個錯都不報,那份檔案卻什麼都沒做。檔案合法不等於接線生效。$1=檔案 +kiro_agent_shape_ok() { + [ -f "$1" ] || return 1 + grep -qE '"(sessionStart|sessionEnd)"' "$1" 2>/dev/null && return 1 + grep -qE '^[[:space:]]*"(on|run|env)"[[:space:]]*:' "$1" 2>/dev/null && return 1 + grep -qF '"agentSpawn"' "$1" 2>/dev/null || return 1 + grep -qF '"userPromptSubmit"' "$1" 2>/dev/null || return 1 + grep -qF '"stop"' "$1" 2>/dev/null || return 1 + grep -qF '"timeout_ms"' "$1" 2>/dev/null || return 1 + grep -qF '/skills/*/*/SKILL.md' "$1" 2>/dev/null || return 1 + return 0 +} + # 寫入 CLI 設定時用版本無關的穩定路徑。執行中的腳本仍從自己的 repo 讀規則,避免開發中 # 的檔案和剛建立的連結互相踩到;只有寫進外部設定的命令改走 current 連結。 ensure_stable_root() { @@ -329,6 +766,66 @@ json_top_key() { # 驗證:JSON 語法成對(括號收齊、字串收尾)。鍵不管。 json_pairs_ok() { json_top_key "$1" __no_such_key__ pairs; } +# 印出所有最上層鍵,一行一個,已排序。改寫別人的設定檔前後各取一次來比對: +# 只驗「我們要的鍵在不在」看不出「別的鍵被吃掉了」,而後者才是會讓外掛整批失效的那種錯。 +json_top_keys() { # $1=檔案 + [ -f "$1" ] || return 0 + awk ' + { s = s $0 "\n" } + END { + n = length(s); depth = 0; i = 1 + while (i <= n) { + c = substr(s, i, 1) + if (c == "\"") { + buf = ""; i++ + while (i <= n) { + c = substr(s, i, 1) + if (c == "\\") { i += 2; continue } + if (c == "\"") { i++; break } + buf = buf c; i++ + } + j = i + while (j <= n && substr(s, j, 1) ~ /[ \t\r\n]/) j++ + if (substr(s, j, 1) == ":" && depth == 1) print buf + continue + } + if (c == "{" || c == "[") depth++ + else if (c == "}" || c == "]") depth-- + i++ + } + }' "$1" 2>/dev/null | sort +} + +# 印出 settings.json 的 hooks 底下**不屬於 jsc** 的條目,正規化成一行一筆。 +# 改寫前後各取一次比對,才驗得出「別人的 hook 一筆都沒被動到」。 +copilot_foreign_entries() { # $1=檔案 + [ -f "$1" ] || return 0 + sed -n '/"hooks"/,$p' "$1" 2>/dev/null \ + | grep -E '"(bash|command)"[[:space:]]*:' \ + | grep -vE 'jsc-hooks|restart-gate\.sh|version-guard\.sh' \ + | tr -d ' \t' | sort +} + +# copilot 的 hook 接上了沒:要有 PreToolUse、小寫 skill 的 matcher,兩道閘門都在。 +# 位置錯了也會回 present——這正是上一版踩到的:檔案寫進 $COPILOT_HOME/hooks/, +# 那底下是腳本目錄不是設定目錄,檔案好端端在那裡,copilot 一次都沒讀過。 +# 所以這支函式只認 settings.json 這一個位置。$1=檔案 +copilot_hooks_ok() { + [ -f "$1" ] || return 1 + json_top_key "$1" hooks || return 1 + grep -qF '"matcher": "skill"' "$1" 2>/dev/null || return 1 + grep -qF '"PreToolUse"' "$1" 2>/dev/null || return 1 + grep -qF 'restart-gate.sh' "$1" 2>/dev/null || return 1 + grep -qF 'version-guard.sh' "$1" 2>/dev/null || return 1 + return 0 +} + +# 事件名只准一種大小寫:copilot 兩種都吃,兩種同時存在會把同一支 hook 跑兩次。$1=檔案 +copilot_single_case_ok() { + [ -f "$1" ] || return 1 + ! grep -qE '"(preToolUse|postToolUse|sessionStart|userPromptSubmit)"' "$1" 2>/dev/null +} + # 找出已存在的 shell rc 檔(purge 用)。rc_files 找不到會建立 ~/.bashrc,移除流程不建檔: # 為了清 hook 而生出一個新檔案,是把環境弄得更亂,不是更乾淨。 rc_files_existing() { @@ -434,12 +931,15 @@ remove_toml_root_key() { mv "$file.jsc-tmp" "$file" 2>/dev/null || { rm -f "$file.jsc-tmp"; return 1; } } -# 刪掉 JSON 最上層的 hooks 鍵(含後面多餘的逗號),逐字元追蹤引號與括號深度。 +# 刪掉 JSON 最上層的某個鍵(含後面多餘的逗號),逐字元追蹤引號與括號深度。 # jq 在目標機器上不保證存在,所以要有這條純 awk 的路;只用 sed 刪不了嵌套的 {...}。 # 追蹤引號是必要的:字串裡的 { 與 } 不算深度,漏算就會把整段設定切壞。 -# $1=檔案,結果印到標準輸出;解析不出來(括號不成對、字串沒收尾)就 exit 1,不輸出半份檔案。 +# 鍵名做成參數,是因為現在要刪的最上層鍵不只一種:claude 與 codex 刪 hooks、antigravity 刪 +# jsc 那個命名空間、kiro 刪 chat.defaultAgent。同一套字元掃描抄三份只會三邊漂移。 +# $1=檔案 $2=鍵名(省略時為 hooks),結果印到標準輸出; +# 解析不出來(括號不成對、字串沒收尾)就 exit 1,不輸出半份檔案。 awk_del_hooks() { - awk ' + awk -v delkey="${2:-hooks}" ' function skip_string(s, i, n, c) { i++ while (i <= n) { @@ -485,7 +985,7 @@ awk_del_hooks() { i = j + 1 j = i while (j <= n && substr(s, j, 1) ~ /[ \t\r\n]/) j++ - if (depth == 1 && buf == "hooks" && substr(s, j, 1) == ":") { + if (depth == 1 && buf == delkey && substr(s, j, 1) == ":") { i = skip_value(s, j + 1, n) if (i == 0) exit 1 j = i @@ -514,19 +1014,51 @@ awk_del_hooks() { }' "$1" } -# 刪掉設定檔最上層的 hooks 鍵:有 jq 就用 jq,沒有就走 awk_del_hooks。$1=檔案 +# 刪掉設定檔最上層的某個鍵:有 jq 就用 jq,沒有就走 awk_del_hooks。$1=檔案 $2=鍵名(省略為 hooks) json_del_hooks() { - _f="$1" + _f="$1"; _k="${2:-hooks}" [ -f "$_f" ] || return 0 if command -v jq >/dev/null 2>&1; then - jq 'del(.hooks)' "$_f" > "$_f.jsc-tmp" 2>/dev/null || { rm -f "$_f.jsc-tmp"; return 1; } + jq --arg k "$_k" 'del(.[$k])' "$_f" > "$_f.jsc-tmp" 2>/dev/null || { rm -f "$_f.jsc-tmp"; return 1; } else - awk_del_hooks "$_f" > "$_f.jsc-tmp" 2>/dev/null || { rm -f "$_f.jsc-tmp"; return 1; } + awk_del_hooks "$_f" "$_k" > "$_f.jsc-tmp" 2>/dev/null || { rm -f "$_f.jsc-tmp"; return 1; } fi [ -s "$_f.jsc-tmp" ] || { rm -f "$_f.jsc-tmp"; return 1; } mv "$_f.jsc-tmp" "$_f" 2>/dev/null || { rm -f "$_f.jsc-tmp"; return 1; } } +# 把一個最上層鍵整段寫進 JSON 設定檔(已存在就先刪掉再寫,所以重跑等同先移除再重裝)。 +# $1=檔案 $2=鍵名 $3=完整的鍵值文字(例如 `"hooks": { ... }`,不含結尾逗號) +# 插在第一個大括號那一行之後,不插在檔尾:插檔尾就要先判斷最後一個成員有沒有逗號,判錯就做出壞掉的 +# JSON。空物件另外走一條路:那時插進去再補逗號會做出 `{ ...,}`,同樣是壞 JSON。 +# 內容不經過 awk -v:awk 會把賦值字串裡的 \" 當成跳脫序列解掉,hook 命令裡的 \" 是 JSON 語法的一部分, +# 被解掉就做出一份解不開的設定檔——而且檔案看起來還很正常,只有 CLI 讀的時候才靜靜失敗。 +# 所以這裡改用純 shell 的行操作組檔,內容一個位元組都不轉換。 +json_put_top_key() { + _f="$1"; _k="$2"; _blk="$3" + dir=$(dirname "$_f") + mkdir -p "$dir" 2>/dev/null || return 1 + [ -f "$_f" ] || printf '{}\n' > "$_f" 2>/dev/null || return 1 + json_pairs_ok "$_f" || return 1 + if json_top_key "$_f" "$_k"; then + json_del_hooks "$_f" "$_k" || return 1 + fi + if [ "$(tr -d ' \t\n\r' < "$_f")" = "{}" ]; then + printf '{\n%s\n}\n' "$_blk" > "$_f.jsc-tmp" 2>/dev/null || { rm -f "$_f.jsc-tmp"; return 1; } + else + # 只認「第一行就是那個大括號」這種版面。其餘版面一律拒絕,不硬插:插錯位置做出來的壞 JSON + # 比不寫更難查,呼叫端收到失敗至少會停下來講出檔名。 + [ "$(head -n1 "$_f" | tr -d ' \t\r')" = "{" ] || return 1 + { head -n1 "$_f" + printf '%s,\n' "$_blk" + tail -n +2 "$_f" + } > "$_f.jsc-tmp" 2>/dev/null || { rm -f "$_f.jsc-tmp"; return 1; } + fi + json_pairs_ok "$_f.jsc-tmp" || { rm -f "$_f.jsc-tmp"; return 1; } + json_top_key "$_f.jsc-tmp" "$_k" || { rm -f "$_f.jsc-tmp"; return 1; } + mv "$_f.jsc-tmp" "$_f" 2>/dev/null || { rm -f "$_f.jsc-tmp"; return 1; } +} + # --- 備份:先備份才准移除 --- BACKUP_DIR="" @@ -668,14 +1200,31 @@ if [ "$action" = purge ]; then || pfail "無法從 $agents 移除 jsc-hooks 標記段落" has_block "$agents" "" && pfail "$agents 移除後仍讀得到 jsc-hooks 標記段落" fi - purged "已移除 config.toml 的標記段落與根層 notify、rc 檔的 jsc-hooks 段落、AGENTS.md 的規則段落" + # Codex 專屬覆寫在 plugin manifest 的 hooks 鍵,那是接線寫進去的,purge 要拿得掉。 + cx_manifest=$(codex_manifest) + cx_hooks=$(codex_hooks_file) + if [ -f "$cx_hooks" ]; then + backup_file "$cx_hooks" || pfail "無法備份 $cx_hooks,沒有備份就不移除" + rm -f "$cx_hooks" 2>/dev/null || pfail "無法刪除 $cx_hooks" + [ -f "$cx_hooks" ] && pfail "$cx_hooks 刪除後檔案還在" + fi + if [ -f "$cx_manifest" ] && json_top_key "$cx_manifest" hooks; then + json_pairs_ok "$cx_manifest" || pfail "$cx_manifest 不是成對的 JSON,讀不懂就不動它" + backup_file "$cx_manifest" || pfail "無法備份 $cx_manifest,沒有備份就不移除" + json_del_hooks "$cx_manifest" hooks || pfail "無法從 $cx_manifest 刪除 hooks 鍵" + json_pairs_ok "$cx_manifest" || pfail "$cx_manifest 刪除 hooks 鍵後 JSON 括號不成對" + ! json_top_key "$cx_manifest" hooks || pfail "$cx_manifest 刪除後最上層仍有 hooks 鍵" + fi + purged "已移除 config.toml 的標記段落與根層 notify、rc 檔的 jsc-hooks 段落、AGENTS.md 的規則段落、hooks/codex-hooks.json 與 .codex-plugin/plugin.json 的 hooks 鍵" echo "[jsc] codex:別名要開新的 shell 或重新 source rc 檔才真的失效。" + echo "[jsc] codex:pre-tool hook 的覆寫是 $cx_manifest 的 hooks 鍵加上它指到的 $cx_hooks,兩個都已移除;Claude 用的 hooks/hooks.json 隨 plugin 提供,purge 動不到,也不該動。" exit 0 ;; copilot) bin=$(cli_bin copilot) command -v "$bin" >/dev/null 2>&1 || skip "未偵測到 copilot 執行檔" - instr="${JSC_COPILOT_INSTRUCTIONS:-$HOME/.config/copilot/copilot-instructions.md}" + instr=$(copilot_instructions) + cp_settings=$(copilot_settings) rclist=$(mktemp) || pfail "無法建立暫存檔" rc_files_existing > "$rclist" while IFS= read -r rc; do @@ -693,8 +1242,32 @@ if [ "$action" = purge ]; then || pfail "無法從 $instr 移除 jsc-hooks 標記段落" has_block "$instr" "" && pfail "$instr 移除後仍讀得到 jsc-hooks 標記段落" fi - purged "已移除 rc 檔的 jsc-hooks:copilot 段落與指引檔的規則段落" + # settings.json 只挑掉 jsc 自己那幾筆條目,別人的事件與所有其他頂層鍵原樣保留。 + # 整份刪掉那個鍵會把第三方的 SessionStart 一起清掉,那超出這道移除該負責的範圍; + # 而弄壞那份檔案會讓十個外掛整批失效,所以驗不過就還原。 + if [ -f "$cp_settings" ] && json_top_key "$cp_settings" hooks; then + json_pairs_ok "$cp_settings" || pfail "$cp_settings 不是成對的 JSON,讀不懂就不動它" + cp_top_before=$(json_top_keys "$cp_settings") + cp_others_before=$(copilot_foreign_entries "$cp_settings") + backup_file "$cp_settings" || pfail "無法備份 $cp_settings,沒有備份就不移除" + cp_tmp=$(mktemp) || pfail "無法建立暫存檔" + if copilot_merge_hooks "$cp_settings" purge > "$cp_tmp" 2>/dev/null && [ -s "$cp_tmp" ]; then + cat "$cp_tmp" > "$cp_settings" 2>/dev/null || { rm -f "$cp_tmp"; pfail "無法寫入 $cp_settings"; } + else + rm -f "$cp_tmp"; pfail "$cp_settings 的 hooks 鍵解析不出來,沒有動它" + fi + rm -f "$cp_tmp" + json_pairs_ok "$cp_settings" || pfail "$cp_settings 移除後不是成對的 JSON" + [ "$(json_top_keys "$cp_settings")" = "$cp_top_before" ] \ + || pfail "$cp_settings 的最上層鍵有增減,enabledPlugins 與 extraKnownMarketplaces 一個都不能掉" + [ "$(copilot_foreign_entries "$cp_settings")" = "$cp_others_before" ] \ + || pfail "$cp_settings 裡別人的 hook 條目被動到了" + grep -qF 'restart-gate.sh' "$cp_settings" 2>/dev/null \ + && pfail "$cp_settings 移除後仍讀得到 jsc 的 hook 條目" + fi + purged "已移除 rc 檔的 jsc-hooks:copilot 段落、指引檔的規則段落與 settings.json 的 hooks 鍵底下 jsc 那幾筆條目" echo "[jsc] copilot:別名要開新的 shell 或重新 source rc 檔才真的失效。" + echo "[jsc] copilot:settings.json 裡別人的 hook 條目與所有其他頂層鍵原樣保留,已回讀核對。" exit 0 ;; antigravity) @@ -718,8 +1291,19 @@ if [ "$action" = purge ]; then || pfail "無法從 $rules 移除 jsc-hooks 標記段落" has_block "$rules" "" && pfail "$rules 移除後仍讀得到 jsc-hooks 標記段落" fi - purged "已移除 rc 檔的 jsc-hooks:antigravity 段落與全域規則檔的規則段落" + # hooks.json 只刪 jsc 那個命名空間鍵,別人的段落留著:那個檔案的最上層一個安裝來源一個鍵, + # 整份刪掉會把別人的整合一起清掉,那超出這道移除該負責的範圍。 + ag_hooks=$(antigravity_hooks_file) + if [ -f "$ag_hooks" ] && json_top_key "$ag_hooks" jsc; then + json_pairs_ok "$ag_hooks" || pfail "$ag_hooks 不是成對的 JSON,讀不懂就不動它" + backup_file "$ag_hooks" || pfail "無法備份 $ag_hooks,沒有備份就不移除" + json_del_hooks "$ag_hooks" jsc || pfail "無法從 $ag_hooks 刪除 jsc 段落" + json_pairs_ok "$ag_hooks" || pfail "$ag_hooks 刪除 jsc 段落後 JSON 括號不成對" + ! json_top_key "$ag_hooks" jsc || pfail "$ag_hooks 刪除後最上層仍有 jsc 段落" + fi + purged "已移除 rc 檔的 jsc-hooks:antigravity 段落、全域規則檔的規則段落與 hooks.json 的 jsc 段落" echo "[jsc] antigravity:別名要開新的 shell 或重新 source rc 檔才真的失效。" + echo "[jsc] antigravity:$ag_hooks 底下別的命名空間段落沒有動過。" exit 0 ;; kiro) @@ -734,8 +1318,26 @@ if [ "$action" = purge ]; then left=$(find "$hookdir" -maxdepth 1 -type f 2>/dev/null | wc -l | tr -d ' ') [ "$left" = 0 ] || pfail "$hookdir 底下還有 $left 個 hook 檔沒刪掉" fi - purged "已刪除工作區 .kiro/hooks/ 底下所有 hook 檔,含非 jsc 的第三方項目" - echo "[jsc] kiro:hook 檔綁在工作區,這次只清得到目前目錄的 ./.kiro/hooks/,其他工作區要各自跑一次。" + # 真正生效的位置是 agent 設定檔與預設 agent 設定,兩個都要拿得掉。 + kr_agent=$(kiro_agent_file) + kr_settings=$(kiro_cli_settings) + if [ -f "$kr_agent" ]; then + backup_file "$kr_agent" || pfail "無法備份 $kr_agent,沒有備份就不移除" + rm -f "$kr_agent" 2>/dev/null || pfail "無法刪除 $kr_agent" + [ -f "$kr_agent" ] && pfail "$kr_agent 刪除後檔案還在" + fi + if [ -f "$kr_settings" ] && json_top_key "$kr_settings" chat.defaultAgent; then + json_pairs_ok "$kr_settings" || pfail "$kr_settings 不是成對的 JSON,讀不懂就不動它" + backup_file "$kr_settings" || pfail "無法備份 $kr_settings,沒有備份就不移除" + json_del_hooks "$kr_settings" chat.defaultAgent \ + || pfail "無法從 $kr_settings 刪除 chat.defaultAgent" + json_pairs_ok "$kr_settings" || pfail "$kr_settings 刪除後 JSON 括號不成對" + ! json_top_key "$kr_settings" chat.defaultAgent \ + || pfail "$kr_settings 刪除後仍有 chat.defaultAgent" + fi + purged "已刪除工作區 .kiro/hooks/ 底下所有 hook 檔(含非 jsc 的第三方項目)、~/.kiro/agents/jsc.json 與 settings/cli.json 的 chat.defaultAgent" + echo "[jsc] kiro:舊的 .kiro/hooks/ 綁在工作區,這次只清得到目前目錄那一份;那個目錄本來就不會被 kiro 讀,留著只會誤導下一個人。" + echo "[jsc] kiro:真正生效的 $kr_agent 與 $kr_settings 的 chat.defaultAgent 已一併移除,之後會走回內建的 kiro_default。" exit 0 ;; esac fi @@ -747,6 +1349,7 @@ if [ "$action" = smoke ]; then # 每一類實際跑過的結果行數。收尾時與下面宣告的預期條數逐類比對,加減判定路徑卻忘了改預期 # 就會當場失敗,散文與程式之間不會再各記一份數字。 smoke_n_hook=0; smoke_n_model=0; smoke_n_wp=0; smoke_n_rs=0; smoke_n_wg=0; smoke_n_vg=0 + smoke_n_sn=0; smoke_n_dn=0; smoke_n_cx=0; smoke_n_sh=0 # 預期條數(改動判定路徑時一起改):每一類都要有自己的計數器,印得出結果行卻沒人計數的 # 那一類會讓總數永遠對不上,斷言也就形同虛設。 # hook 模式 九支 hook 的每個接線模式各一條。sdlc-gate.sh、comment-scope.sh、 @@ -756,12 +1359,20 @@ if [ "$action" = smoke ]; then # 重啟閘門 restart-gate.sh 的判定、清除路徑與狀態檔範圍檢查 # 寫入閘門 write-guard.sh 三種擋人模式與 release 解除模式的判定路徑 # 相依版本 version-guard.sh 讀 manifest jsc.requires 的擋人、放行、豁免與 fail-open 路徑 + # 技能名解析 skill-name.sh 五支 CLI 各自的取值來源,加上解不出來的三條 + # 阻擋形態 deny.sh 四種形態(stderr 加 2、stdout deny JSON、kiro 注入、未知代號的保守預設) + # 跨 CLI 貫通 restart-gate.sh 吃五支 CLI 的真實負載,驗判定與輸出形態串得起來 + # 接線形狀 每支 CLI 要寫出去的內容真的產出來一次,驗結構本身(正反案例各一組) SMOKE_EXPECT_HOOK=17 SMOKE_EXPECT_MODEL=4 SMOKE_EXPECT_WP=6 SMOKE_EXPECT_RS=16 SMOKE_EXPECT_WG=21 SMOKE_EXPECT_VG=13 + SMOKE_EXPECT_SN=10 + SMOKE_EXPECT_DN=9 + SMOKE_EXPECT_CX=9 + SMOKE_EXPECT_SH=26 # 跑一支 hook 並判定結果。$1=腳本檔名 $2=子命令(可省略) # $2 不加引號展開:子命令是固定字面字,空字串時要展成「沒有參數」而不是空參數。 @@ -1143,12 +1754,267 @@ if [ "$action" = smoke ]; then printf '[jsc] version-guard.sh:建不出暫存目錄,相依版本判定沒驗到。\n' >> "$smoke_out" fi + # 技能名解析(skill-name.sh):五支 CLI 的負載形態各不相同,取值規則只有這一份,所以每一支 + # 都要有自己的斷言。少驗一支,那支 CLI 的閘門就會靜靜解不出技能名而全程放行——先前 codex、 + # copilot、antigravity 三支失效,外顯就是這個樣子,從結束碼上完全看不出來。 + # JSC_SKILL 與 SKILL 一律清空:留著繼承來的值會蓋過負載解析,測到的就不是負載那條路。 + smoke_sn_case() { # $1=情境 $2=CLI 代號 $3=負載 $4=預期 domain(空=要解不出來) $5=預期技能名 $6=JSC_SKILL + _want="" + [ -n "${4:-}" ] && _want=$(printf '%s\t%s' "$4" "${5:-}") + _out=$(printf '%s' "$3" | env JSC_SKILL="${6:-}" SKILL="" \ + sh "$HOOKS/skill-name.sh" "$2" 2>/dev/null); _rc=$? + smoke_n_sn=$((smoke_n_sn + 1)) + if [ "$_rc" -eq 0 ] && [ "$_out" = "$_want" ]; then + printf '[jsc] skill-name.sh %s(%s):輸出與預期相同。\n' "$2" "$1" >> "$smoke_out" + else + smoke_fails=$((smoke_fails + 1)) + printf '[jsc] skill-name.sh %s(%s):exit %s,輸出「%s」,預期「%s」,技能名解析壞了。\n' \ + "$2" "$1" "$_rc" "$(printf '%s' "$_out" | tr '\t\n' ' ')" \ + "$(printf '%s' "$_want" | tr '\t\n' ' ')" >> "$smoke_out" + fi + } + smoke_sn_case "stdin JSON 的 skill 欄位" claude \ + '{"tool_name":"Skill","tool_input":{"skill":"jsc-sdlc:implement"}}' sdlc implement + smoke_sn_case "環境變數蓋過負載" claude '{}' sdlc implement jsc-sdlc:implement + smoke_sn_case "Bash 指令裡的 SKILL.md 路徑" codex \ + '{"tool_name":"Bash","tool_input":{"command":"cat /root/.codex/plugins/cache/jsc/jsc-sdlc/0.2.0/skills/implement/SKILL.md"}}' \ + sdlc implement + smoke_sn_case "字串化的 toolArgs 要剝兩層" copilot \ + '{"toolName":"skill","toolArgs":"{\"name\":\"jsc-sdlc:implement\"}"}' sdlc implement + smoke_sn_case "toolCall.args.AbsolutePath" antigravity \ + '{"toolCall":{"name":"view_file","args":{"AbsolutePath":"/root/.gemini/config/plugins/jsc-sdlc/skills/implement/SKILL.md","StartLine":1}}}' \ + sdlc implement + # PreInvocation 那一輪沒有工具呼叫,斜線指令只出現在提示字串裡,那條路要單獨驗。 + smoke_sn_case "PreInvocation 的斜線指令" antigravity \ + '{"prompt":"/jsc-sdlc:implement 開始這一包","stepIdx":0}' sdlc implement + smoke_sn_case "提示開頭的斜線指令" kiro '{"prompt":"/jsc-sdlc:implement 開始這一包"}' sdlc implement + smoke_sn_case "解不出技能名就印空字串" codex \ + '{"tool_name":"Bash","tool_input":{"command":"ls -al"}}' "" + smoke_sn_case "別人的技能不是我們該管的" claude '{"tool_name":"Skill","tool_input":{"skill":"design"}}' "" + smoke_sn_case "認不得的 CLI 代號" no-such-cli '{"prompt":"/jsc-sdlc:implement"}' "" + + # 阻擋形態(deny.sh):形態錯了,判定再準也擋不下來,而且從結束碼上看不出差別—— + # antigravity 那一支尤其危險,靠結束碼會變成「判定擋下、CLI 照樣放行」的無聲失效。 + dn_msg=$(printf '第一行訊息\n第二行訊息') + smoke_dn_case() { # $1=情境 $2=CLI 代號 $3=預期結束碼 $4=out 或 err $5=預期字串 + if [ "$4" = out ]; then + _out=$(printf '%s\n' "$dn_msg" | sh "$HOOKS/deny.sh" "$2" 2>/dev/null); _rc=$? + else + _out=$(printf '%s\n' "$dn_msg" | sh "$HOOKS/deny.sh" "$2" 2>&1 >/dev/null); _rc=$? + fi + smoke_n_dn=$((smoke_n_dn + 1)) + if [ "$_rc" -eq "$3" ] && printf '%s' "$_out" | grep -qF "$5"; then + printf '[jsc] deny.sh %s(%s):exit %s,%s 含預期內容,與預期相同。\n' \ + "$2" "$1" "$_rc" "$4" >> "$smoke_out" + else + smoke_fails=$((smoke_fails + 1)) + printf '[jsc] deny.sh %s(%s):exit %s,預期 %s,%s 未含「%s」,阻擋形態壞了:%s\n' \ + "$2" "$1" "$_rc" "$3" "$4" "$5" \ + "$(printf '%s' "$_out" | tr '\n' ' ' | cut -c1-200)" >> "$smoke_out" + fi + } + smoke_dn_case "stderr 加結束碼 2" claude 2 err "第一行訊息" + smoke_dn_case "stderr 加結束碼 2" codex 2 err "第二行訊息" + smoke_dn_case "stderr 加結束碼 2" copilot 2 err "第一行訊息" + smoke_dn_case "stdout 的 deny JSON" antigravity 0 out '"decision":"deny"' + smoke_dn_case "多行訊息壓進同一個 reason" antigravity 0 out '第一行訊息\n第二行訊息' + smoke_dn_case "擋不下來就改印警告" kiro 0 out "kiro 擋不下技能叫用" + smoke_dn_case "認不得的代號走保守預設" no-such-cli 2 err "第一行訊息" + # antigravity 的 stdout 只准有那一行 JSON:多印一行,那支 CLI 就解不出這份負載,等於沒擋。 + smoke_n_dn=$((smoke_n_dn + 1)) + dn_lines=$(printf '%s\n' "$dn_msg" | sh "$HOOKS/deny.sh" antigravity 2>/dev/null | wc -l | tr -d ' ') + if [ "$dn_lines" = 1 ]; then + printf '[jsc] deny.sh antigravity(stdout 只有一行 JSON):與預期相同。\n' >> "$smoke_out" + else + smoke_fails=$((smoke_fails + 1)) + printf '[jsc] deny.sh antigravity(stdout 只有一行 JSON):實際 %s 行,多印的內容會讓那支 CLI 解不出這份負載。\n' \ + "$dn_lines" >> "$smoke_out" + fi + # 訊息也可以從參數進來,呼叫端不必為了一句話開一條管線。 + smoke_n_dn=$((smoke_n_dn + 1)) + dn_arg=$(sh "$HOOKS/deny.sh" claude "參數訊息" 2>&1 >/dev/null); dn_arg_rc=$? + if [ "$dn_arg_rc" -eq 2 ] && printf '%s' "$dn_arg" | grep -qF "參數訊息"; then + printf '[jsc] deny.sh claude(訊息走參數):exit 2,訊息傳得到,與預期相同。\n' >> "$smoke_out" + else + smoke_fails=$((smoke_fails + 1)) + printf '[jsc] deny.sh claude(訊息走參數):exit %s,輸出「%s」,參數那條路壞了。\n' \ + "$dn_arg_rc" "$(printf '%s' "$dn_arg" | tr '\n' ' ' | cut -c1-200)" >> "$smoke_out" + fi + + # 跨 CLI 貫通:把五支 CLI 的真實負載餵進 restart-gate.sh,驗「解析 → 判定 → 輸出形態」整條串得起來。 + # 上面兩段各自驗了頭尾,中間那段接不上照樣是全程放行,而且三段分開看都會顯示正常。 + # 用暫時的 $JSC_HOME 是為了不動到使用者真正的閘門狀態。 + smoke_cx_case() { # $1=情境 $2=CLI 代號 $3=負載 $4=預期結束碼 $5=out|err|none $6=預期字串 + case "$5" in + out) _out=$(printf '%s' "$3" | env JSC_HOME="$cx_home" JSC_CLI="$2" JSC_SKILL="" SKILL="" \ + sh "$HOOKS/restart-gate.sh" 2>/dev/null); _rc=$? ;; + err) _out=$(printf '%s' "$3" | env JSC_HOME="$cx_home" JSC_CLI="$2" JSC_SKILL="" SKILL="" \ + sh "$HOOKS/restart-gate.sh" 2>&1 >/dev/null); _rc=$? ;; + *) _out=$(printf '%s' "$3" | env JSC_HOME="$cx_home" JSC_CLI="$2" JSC_SKILL="" SKILL="" \ + sh "$HOOKS/restart-gate.sh" 2>&1); _rc=$? ;; + esac + smoke_n_cx=$((smoke_n_cx + 1)) + # 預期字串為空時要求輸出也是空的:放行卻印了東西,在 antigravity 上就是一份多餘的 deny JSON。 + if [ "$_rc" -eq "$4" ] && + { { [ -z "${6:-}" ] && [ -z "$_out" ]; } || { [ -n "${6:-}" ] && printf '%s' "$_out" | grep -qF "$6"; }; } + then + printf '[jsc] restart-gate.sh %s(%s):exit %s,輸出形態與預期相同。\n' "$2" "$1" "$_rc" >> "$smoke_out" + else + smoke_fails=$((smoke_fails + 1)) + printf '[jsc] restart-gate.sh %s(%s):exit %s,預期 %s,輸出「%s」,跨 CLI 判定串不起來。\n' \ + "$2" "$1" "$_rc" "$4" "$(printf '%s' "$_out" | tr '\n' ' ' | cut -c1-200)" >> "$smoke_out" + fi + } + if cx_home=$(mktemp -d 2>/dev/null) && mkdir -p "$cx_home/restart-required.d" 2>/dev/null; then + for _c in claude codex copilot antigravity kiro; do + printf 'at=%s\nmode=update\ndomains=hooks\ncli=%s\n' "$(now_iso)" "$_c" \ + > "$cx_home/restart-required.d/$_c" 2>/dev/null + done + smoke_cx_case "claude 的 skill 欄位負載" claude \ + '{"tool_name":"Skill","tool_input":{"skill":"jsc-sdlc:implement"}}' 2 err "還在跑舊版" + smoke_cx_case "codex 的 Bash 負載" codex \ + '{"tool_name":"Bash","tool_input":{"command":"cat /x/jsc-sdlc/skills/implement/SKILL.md"}}' 2 err "還在跑舊版" + smoke_cx_case "copilot 的 toolArgs 負載" copilot \ + '{"toolName":"skill","toolArgs":"{\"name\":\"jsc-sdlc:implement\"}"}' 2 err "還在跑舊版" + smoke_cx_case "antigravity 的 view_file 負載" antigravity \ + '{"toolCall":{"name":"view_file","args":{"AbsolutePath":"/x/jsc-sdlc/skills/implement/SKILL.md"}}}' \ + 0 out '"decision":"deny"' + smoke_cx_case "antigravity 的 PreInvocation 負載" antigravity \ + '{"prompt":"/jsc-sdlc:implement 開始"}' 0 out '"decision":"deny"' + smoke_cx_case "kiro 只注入警告,擋不下來" kiro \ + '{"prompt":"/jsc-sdlc:implement 開始"}' 0 out "kiro 擋不下技能叫用" + smoke_cx_case "fail-open:codex 負載解不出技能名" codex \ + '{"tool_name":"Bash","tool_input":{"command":"ls -al"}}' 0 none "" + smoke_cx_case "豁免技能 jsc-cli:deploy" codex \ + '{"tool_name":"Bash","tool_input":{"command":"cat /x/jsc-cli/skills/deploy/SKILL.md"}}' 0 none "" + smoke_cx_case "豁免技能不吐 deny JSON" antigravity \ + '{"toolCall":{"name":"view_file","args":{"AbsolutePath":"/x/jsc-cli/skills/deploy/SKILL.md"}}}' 0 none "" + rm -rf "$cx_home" + else + smoke_fails=$((smoke_fails + 1)) + printf '[jsc] restart-gate.sh:建不出暫存目錄,跨 CLI 貫通判定沒驗到。\n' >> "$smoke_out" + fi + + # 接線形狀:把每支 CLI 要寫出去的內容真的產出來一次,驗結構本身對不對。這一組是實測踩坑後補的—— + # antigravity 的 PreToolUse 寫成 Flat 時整個事件被靜默丟棄,鍵在、JSON 合法、標記段落也在, + # 從檔案上看不出任何異常,接線與盤點兩邊都回報成功,實際上一次都不會被叫用。 + # 每一條都配一個反向案例:形狀錯的內容要被判成不合格。少了反向那半,一支永遠回 0 的檢查函式 + # 也會讓正向那條通過,等於沒驗。 + smoke_sh_case() { # $1=情境 $2=期望 ok 或 bad $3=判定指令(會被 eval) + smoke_n_sh=$((smoke_n_sh + 1)) + if eval "$3" >/dev/null 2>&1; then _got=ok; else _got=bad; fi + if [ "$_got" = "$2" ]; then + printf '[jsc] 接線形狀(%s):判定為 %s,與預期相同。\n' "$1" "$_got" >> "$smoke_out" + else + smoke_fails=$((smoke_fails + 1)) + printf '[jsc] 接線形狀(%s):判定為 %s,預期 %s,形狀檢查抓不到這種錯。\n' \ + "$1" "$_got" "$2" >> "$smoke_out" + fi + } + if sh_home=$(mktemp -d 2>/dev/null); then + # antigravity:從乾淨的第三方檔開始寫,驗形狀,也驗別人的段落沒被動到。 + sh_ag="$sh_home/hooks.json" + printf '{\n "other": {\n "PreInvocation": [\n { "type": "command", "command": "true" }\n ]\n }\n}\n' > "$sh_ag" + json_put_top_key "$sh_ag" jsc "$(antigravity_hooks_block)" >/dev/null 2>&1 + smoke_sh_case "antigravity 的 PreToolUse 是 Grouped" ok "antigravity_grouped_ok '$sh_ag'" + smoke_sh_case "antigravity 的 PreInvocation 維持 Flat" ok "antigravity_flat_ok '$sh_ag'" + smoke_sh_case "antigravity 寫入後別人的段落還在,JSON 也成對" ok \ + "json_pairs_ok '$sh_ag' && json_top_key '$sh_ag' other" + smoke_sh_case "antigravity 的命令帶得到 JSC_CLI=antigravity" ok \ + "grep -qF 'JSC_CLI=antigravity' '$sh_ag'" + # 反向:把 PreToolUse 寫成 Flat(handler 直接排在陣列裡),檢查函式要判它不合格。 + sh_ag_flat="$sh_home/hooks-flat.json" + printf '{\n "jsc": {\n "PreToolUse": [\n { "matcher": "^view_file$", "type": "command", "command": "true" }\n ],\n "PreInvocation": [\n { "type": "command", "command": "true" }\n ]\n }\n}\n' > "$sh_ag_flat" + smoke_sh_case "Flat 的 PreToolUse 要被判不合格" bad "antigravity_grouped_ok '$sh_ag_flat'" + # codex:manifest 的 hooks 鍵是路徑字串(規格裡跟 skills 同一類),不是內嵌物件; + # 它指到的那份 hook 檔要從 hooks/hooks.json 推導,matcher Skill 換成 Bash 且不留殘餘。 + sh_cx="$sh_home/plugin.json" + printf '{\n "name": "jsc-hooks"\n}\n' > "$sh_cx" + json_put_top_key "$sh_cx" hooks "$(codex_hooks_key_block)" >/dev/null 2>&1 + smoke_sh_case "codex 的 manifest hooks 鍵是路徑字串" ok \ + "json_pairs_ok '$sh_cx' && grep -qF '\"hooks\": \"$CODEX_HOOKS_REL\"' '$sh_cx'" + smoke_sh_case "內嵌物件的 hooks 鍵要被判不合格" bad \ + "printf '{ \"hooks\": { \"PreToolUse\": [] } }' > '$sh_home/inline.json'; grep -qF '\"hooks\": \"$CODEX_HOOKS_REL\"' '$sh_home/inline.json'" + sh_cxh="$sh_home/codex-hooks.json" + codex_derive_hooks "$HOOKS/hooks.json" > "$sh_cxh" 2>/dev/null + smoke_sh_case "codex 專屬 hook 檔的 matcher 換成 Bash 且沒留 Skill" ok \ + "json_pairs_ok '$sh_cxh' && grep -qF '\"matcher\": \"Bash\"' '$sh_cxh' && ! grep -qF '\"matcher\": \"Skill\"' '$sh_cxh'" + smoke_sh_case "推導不會動到 Claude 那一份" ok \ + "grep -qF '\"matcher\": \"Skill\"' '$HOOKS/hooks.json'" + smoke_sh_case "推導保住 hooks.json 的其他事件,沒有只剩 PreToolUse" ok \ + "grep -qF '\"UserPromptSubmit\"' '$sh_cxh' && grep -qF '\"SessionStart\"' '$sh_cxh'" + # 代號跟 matcher 同等重要:取不到代號的閘門解不出技能名,一律安靜放行。三支各驗一條。 + smoke_sh_case "codex 專屬 hook 檔的命令帶得到 JSC_CLI=codex" ok \ + "grep -qF 'JSC_CLI=codex' '$sh_cxh'" + # copilot:設定在 settings.json 的頂層 hooks 鍵,合併不覆寫。那份檔案同時裝著外掛啟用狀態, + # 所以「有沒有接上」與「有沒有弄壞別人的東西」要分開驗,後者的後果嚴重得多。 + sh_cp="$sh_home/settings.json" + sh_cpnew="$sh_home/entries.json" + copilot_hook_entries > "$sh_cpnew" + printf '{\n "enabledPlugins": { "jsc-hooks@jsc": true },\n "extraKnownMarketplaces": { "jsc": {} },\n "hooks": {\n "SessionStart": [\n { "bash": "bash \x27/other/agent-state.sh\x27", "timeoutSec": 10, "type": "command" }\n ]\n }\n}\n' > "$sh_cp" + # 改寫前的樣子存成檔案再比對。存成變數的話多行內容塞進 [ ] 測試會整個散掉, + # 比出來的結果看起來像「別人的鍵不見了」,其實只是比法錯了。 + json_top_keys "$sh_cp" > "$sh_home/top0" + copilot_foreign_entries "$sh_cp" > "$sh_home/oth0" + copilot_merge_hooks "$sh_cp" wire "$sh_cpnew" > "$sh_home/merged.json" 2>/dev/null \ + && cat "$sh_home/merged.json" > "$sh_cp" + smoke_sh_case "copilot 併進 settings.json 後兩道閘門在、matcher 是小寫 skill" ok \ + "copilot_hooks_ok '$sh_cp' && copilot_single_case_ok '$sh_cp'" + smoke_sh_case "併進後最上層鍵與別人的條目一筆都沒少" ok \ + "json_pairs_ok '$sh_cp' && json_top_keys '$sh_cp' | diff -q - '$sh_home/top0' && copilot_foreign_entries '$sh_cp' | diff -q - '$sh_home/oth0'" + smoke_sh_case "copilot 的 jsc 條目帶得到 JSC_CLI=copilot" ok \ + "grep -qF 'JSC_CLI=copilot' '$sh_cp'" + # 重跑要冪等:合併寫成疊加的話,每接一次線就多一份,同一支 hook 跑好幾遍。 + copilot_merge_hooks "$sh_cp" wire "$sh_cpnew" > "$sh_home/merged2.json" 2>/dev/null \ + && cat "$sh_home/merged2.json" > "$sh_cp" + smoke_sh_case "重跑接線不疊加" ok \ + "[ \"\$(grep -c 'restart-gate.sh' '$sh_cp')\" = 1 ]" + copilot_merge_hooks "$sh_cp" purge > "$sh_home/purged.json" 2>/dev/null \ + && cat "$sh_home/purged.json" > "$sh_cp" + smoke_sh_case "purge 只拿掉 jsc 那幾筆,別人的條目與最上層鍵都留著" ok \ + "json_pairs_ok '$sh_cp' && ! grep -q 'restart-gate.sh' '$sh_cp' && json_top_keys '$sh_cp' | diff -q - '$sh_home/top0' && copilot_foreign_entries '$sh_cp' | diff -q - '$sh_home/oth0'" + printf '{ "hooks": { "PreToolUse": [], "preToolUse": [] } }\n' > "$sh_home/dup.json" + smoke_sh_case "兩種大小寫並存要被判不合格" bad "copilot_single_case_ok '$sh_home/dup.json'" + # 位置錯了也要被抓到。舊版把設定寫進 $COPILOT_HOME/hooks/,那底下是腳本目錄不是設定目錄, + # 檔案內容完全正確、copilot 卻一次都沒讀過,只驗內容的檢查一律回 present。 + printf '{ "hooks": { "PreToolUse": [ { "matcher": "skill", "type": "command", "bash": "sh \x22/x/restart-gate.sh\x22" } ] } }\n' > "$sh_home/wrongplace.json" + smoke_sh_case "設定寫錯位置時 settings.json 那一項要判不合格" bad \ + "copilot_hooks_ok '$sh_home/nonexistent-settings.json'" + # kiro:resources 少了兩層 glob,這個 agent 一支 jsc 技能都看不到。 + sh_kr="$sh_home/jsc.json" + kiro_agent_json > "$sh_kr" 2>/dev/null + smoke_sh_case "kiro 的 resources 有兩層 skill:// glob" ok \ + "json_pairs_ok '$sh_kr' && grep -qF '/skills/*/*/SKILL.md' '$sh_kr'" + printf '{ "resources": ["skill://.kiro/skills/*/SKILL.md"] }\n' > "$sh_home/one.json" + smoke_sh_case "只有一層 glob 要被判不合格" bad "grep -qF '/skills/*/*/SKILL.md' '$sh_home/one.json'" + smoke_sh_case "kiro agent 的形狀合格(合法事件、timeout_ms、沒有 on/run/env)" ok \ + "kiro_agent_shape_ok '$sh_kr'" + # 反向:sessionStart 不是合法事件,最上層的 on/run/env 則是會被靜默忽略的未知鍵。 + printf '{ "name": "p", "on": ["sessionStart"], "run": "true", "env": {} }\n' > "$sh_home/legacy.json" + smoke_sh_case "舊的 on/run/env 形狀要被判不合格" bad "kiro_agent_shape_ok '$sh_home/legacy.json'" + # validate 的判準是輸出,不是結束碼。這兩條把那條規則釘住:那支指令四種情況都回 0, + # 拿結束碼當判準會做出一支永遠通過的檢查。 + smoke_sh_case "validate 輸出為空才算通過" ok "kiro_validate_output_ok \"\"" + smoke_sh_case "validate 印了錯誤就算不通過(結束碼仍是 0)" bad \ + "kiro_validate_output_ok \"Error: Json supplied is invalid: data did not match any variant\"" + # 環境問題是「驗不了」,不是「驗不過」。報成失敗的話,沒登入的機器會整批接不了線。 + smoke_sh_case "沒登入的錯誤要放行,不算驗不過" ok \ + "kiro_validate_output_ok \"error: You are not logged in, please log in with kiro-cli login\"" + smoke_sh_case "沒登入時要看得出這一項沒驗到" bad \ + "kiro_validate_ran \"error: You are not logged in, please log in with kiro-cli login\"" + rm -rf "$sh_home" + else + smoke_fails=$((smoke_fails + 1)) + printf '[jsc] 接線形狀:建不出暫存目錄,形狀判定沒驗到。\n' >> "$smoke_out" + fi + # 自我斷言:實際跑過的條數對上宣告的預期條數,再對上真正印出來的行數。三邊一致才算數, # 少跑一條或多印一行都會在這裡現形,散文就不必再自己記一份數字。 smoke_lines=$(wc -l < "$smoke_out" 2>/dev/null | tr -d ' ') [ -n "$smoke_lines" ] || smoke_lines=0 smoke_total=$((SMOKE_EXPECT_HOOK + SMOKE_EXPECT_MODEL + SMOKE_EXPECT_WP \ - + SMOKE_EXPECT_RS + SMOKE_EXPECT_WG + SMOKE_EXPECT_VG)) + + SMOKE_EXPECT_RS + SMOKE_EXPECT_WG + SMOKE_EXPECT_VG \ + + SMOKE_EXPECT_SN + SMOKE_EXPECT_DN + SMOKE_EXPECT_CX + SMOKE_EXPECT_SH)) smoke_mismatch="" [ "$smoke_n_hook" = "$SMOKE_EXPECT_HOOK" ] \ || smoke_mismatch="${smoke_mismatch}hook 模式 $smoke_n_hook 條(預期 $SMOKE_EXPECT_HOOK);" @@ -1162,10 +2028,18 @@ if [ "$action" = smoke ]; then || smoke_mismatch="${smoke_mismatch}寫入閘門 $smoke_n_wg 條(預期 $SMOKE_EXPECT_WG);" [ "$smoke_n_vg" = "$SMOKE_EXPECT_VG" ] \ || smoke_mismatch="${smoke_mismatch}相依版本 $smoke_n_vg 條(預期 $SMOKE_EXPECT_VG);" + [ "$smoke_n_sn" = "$SMOKE_EXPECT_SN" ] \ + || smoke_mismatch="${smoke_mismatch}技能名解析 $smoke_n_sn 條(預期 $SMOKE_EXPECT_SN);" + [ "$smoke_n_dn" = "$SMOKE_EXPECT_DN" ] \ + || smoke_mismatch="${smoke_mismatch}阻擋形態 $smoke_n_dn 條(預期 $SMOKE_EXPECT_DN);" + [ "$smoke_n_cx" = "$SMOKE_EXPECT_CX" ] \ + || smoke_mismatch="${smoke_mismatch}跨 CLI 貫通 $smoke_n_cx 條(預期 $SMOKE_EXPECT_CX);" + [ "$smoke_n_sh" = "$SMOKE_EXPECT_SH" ] \ + || smoke_mismatch="${smoke_mismatch}接線形狀 $smoke_n_sh 條(預期 $SMOKE_EXPECT_SH);" [ "$smoke_lines" = "$smoke_total" ] \ || smoke_mismatch="${smoke_mismatch}結果行數 $smoke_lines 行(預期 $smoke_total);" - smoke_breakdown="hook 模式 $SMOKE_EXPECT_HOOK 條、模型來源 $SMOKE_EXPECT_MODEL 條、工作包 $SMOKE_EXPECT_WP 條、重啟閘門 $SMOKE_EXPECT_RS 條、寫入閘門 $SMOKE_EXPECT_WG 條、相依版本 $SMOKE_EXPECT_VG 條" + smoke_breakdown="hook 模式 $SMOKE_EXPECT_HOOK 條、模型來源 $SMOKE_EXPECT_MODEL 條、工作包 $SMOKE_EXPECT_WP 條、重啟閘門 $SMOKE_EXPECT_RS 條、寫入閘門 $SMOKE_EXPECT_WG 條、相依版本 $SMOKE_EXPECT_VG 條、技能名解析 $SMOKE_EXPECT_SN 條、阻擋形態 $SMOKE_EXPECT_DN 條、跨 CLI 貫通 $SMOKE_EXPECT_CX 條、接線形狀 $SMOKE_EXPECT_SH 條" if [ -n "$smoke_mismatch" ]; then printf 'status=failed reason=%s\n' "冒煙結果行數與預期不符:${smoke_mismatch}判定路徑有增減時要一併改腳本裡的預期條數" printf 'lines\t%s\n' "$smoke_lines" @@ -1176,7 +2050,7 @@ if [ "$action" = smoke ]; then fi if [ "$smoke_fails" -eq 0 ]; then - printf 'status=ok reason=%s\n' "九支 hook 的每個接線模式都跑得完,模型來源、工作包歸屬、部署後重啟閘門、寫入提交閘門與相依版本檢查的每條路徑也各走過一次($smoke_breakdown),沒有執行期錯誤" + printf 'status=ok reason=%s\n' "九支 hook 的每個接線模式都跑得完,模型來源、工作包歸屬、部署後重啟閘門、寫入提交閘門、相依版本檢查、五支 CLI 的技能名解析、四種阻擋形態、跨 CLI 貫通與各 CLI 的接線形狀的每條路徑也各走過一次($smoke_breakdown),沒有執行期錯誤" printf 'lines\t%s\n' "$smoke_lines" cat "$smoke_out"; rm -f "$smoke_out"; exit 0 fi @@ -1195,11 +2069,19 @@ if [ "$action" = status ]; then st_items=$(mktemp) || { printf 'status=failed reason=%s\n' "無法建立暫存檔"; exit 4; } st_missing=0 st_degrade="" + # 接上時的 reason 也要一支 CLI 一句:五支的覆蓋範圍不一樣,共用一句話就會把「codex 接了兩道閘門」 + # 講成「codex 九支全接」。預設值給 claude,其餘各自在下面覆寫。 + st_wired="hooks.json 自動接線全部九支 hook" - # 記一個檢查點。$1=項目名 $2=路徑 $3=present|missing + # 記一個檢查點。$1=項目名 $2=路徑 $3=present|missing|unverified + # + # 三種狀態,不是兩種。unverified 是「這一項驗不了」,跟「驗不過」分開記:只有 missing 算缺項。 + # 少了這一格,驗不了的項目只能在 present 與 missing 之間二選一——報 present 會讓沒驗到的東西 + # 看起來像通過,報 missing 會把環境問題(沒登入、憑證過期)算成接線失敗,兩邊都在說謊。 st_item() { printf 'item\t%s\t%s\t%s\n' "$1" "$2" "$3" >> "$st_items" - [ "$3" = present ] || st_missing=$((st_missing + 1)) + [ "$3" = missing ] && st_missing=$((st_missing + 1)) + return 0 } # 檔案存在且帶有該標記才算接上。$1=項目名 $2=檔案 $3=開頭標記行 @@ -1318,52 +2200,148 @@ if [ "$action" = status ]; then else st_item plugin-user-prompt-root "$_codex_plugin_hooks" missing fi - st_degrade="STE100 降級為 prompt 檔,SDLC 模型鎖降級為技能步驟檢查,無 pre-tool hook 可接版本前置檢查、部署後重啟閘門與寫入提交閘門,註解範圍與繁中編碼改為每輪結束掃整個工作區,不是逐檔即時" ;; + # pre-tool hook 的 Codex 專屬覆寫。三項分開算:鍵在不在、兩支閘門各接上沒有、matcher 對不對。 + # 只驗「有 hooks 鍵」會漏掉「鍵在但 matcher 還是 Skill」,那正是先前失效的形態。 + cx_manifest=$(codex_manifest) + cx_hooks=$(codex_hooks_file) + # 鍵的**值**要單獨算一項:寫成內嵌物件時鍵照樣在,只看鍵在不在會讀成接好了。 + if [ -f "$cx_manifest" ] && grep -qF "\"hooks\": \"$CODEX_HOOKS_REL\"" "$cx_manifest" 2>/dev/null + then st_item plugin-hooks-path "$cx_manifest" present + else st_item plugin-hooks-path "$cx_manifest" missing; fi + if [ -f "$cx_hooks" ] && json_top_key "$cx_hooks" hooks + then st_item codex-hooks-file "$cx_hooks" present + else st_item codex-hooks-file "$cx_hooks" missing; fi + if [ -f "$cx_hooks" ] && grep -qF '"matcher": "Bash"' "$cx_hooks" 2>/dev/null + then st_item codex-bash-matcher "$cx_hooks" present + else st_item codex-bash-matcher "$cx_hooks" missing; fi + # 殘留的 Skill matcher 就是接線沒生效的證據:codex 沒有那個工具,那一組永遠不會被叫用。 + if [ -f "$cx_hooks" ] && grep -qF '"matcher": "Skill"' "$cx_hooks" 2>/dev/null + then st_item codex-no-skill-matcher "$cx_hooks" missing + else st_item codex-no-skill-matcher "$cx_hooks" present; fi + for _g in restart-gate.sh version-guard.sh; do + if [ -f "$cx_hooks" ] && grep -qF "$_g" "$cx_hooks" 2>/dev/null + then st_item "codex-${_g%.sh}" "$cx_hooks" present + else st_item "codex-${_g%.sh}" "$cx_hooks" missing; fi + done + # CLI 代號也是接線的一部分:少了它,閘門認不出自己跑在哪一支 CLI 上,技能名解不出來, + # 兩道閘門一律安靜放行。只驗腳本名在不在會把這種「接了等於沒接」讀成完好。 + if [ -f "$cx_hooks" ] && grep -qF 'JSC_CLI=codex' "$cx_hooks" 2>/dev/null + then st_item codex-cli-code "$cx_hooks" present + else st_item codex-cli-code "$cx_hooks" missing; fi + st_wired="pre-tool hook 接在 hooks/codex-hooks.json 的 Bash matcher 上(manifest 的 hooks 鍵以路徑字串指過去),版本前置檢查與部署後重啟閘門都擋得下來;STE100 與 SDLC 模型鎖仍是 prompt 與技能步驟檢查,write-guard.sh 三種模式尚未接線,註解範圍與繁中編碼每輪結束掃整個工作區" ;; copilot) + cp_instr=$(copilot_instructions) + cp_settings=$(copilot_settings) st_rc_alias alias jsc-hooks:copilot - st_block ste100 "${JSC_COPILOT_INSTRUCTIONS:-$HOME/.config/copilot/copilot-instructions.md}" "" - st_comment_scope comment-scope "${JSC_COPILOT_INSTRUCTIONS:-$HOME/.config/copilot/copilot-instructions.md}" - st_lang_guard lang-guard "${JSC_COPILOT_INSTRUCTIONS:-$HOME/.config/copilot/copilot-instructions.md}" + st_block ste100 "$cp_instr" "" + st_comment_scope comment-scope "$cp_instr" + st_lang_guard lang-guard "$cp_instr" st_runtime_paths alias-paths "$HOME/.bashrc" "# jsc-hooks:copilot" "# /jsc-hooks:copilot" - st_degrade="STE100 降級為 prompt 檔,SDLC 模型鎖降級為技能步驟檢查,無 pre-tool hook 可接版本前置檢查、部署後重啟閘門與寫入提交閘門,註解範圍與繁中編碼只在工作階段結束時掃一次整個工作區" ;; + # 位置本身就是一個檢查點:上一版把設定寫進 $COPILOT_HOME/hooks/,那底下是腳本目錄, + # 檔案好端端在那裡、內容也對,copilot 卻一次都沒讀過,盤點照樣回 present。 + if copilot_hooks_ok "$cp_settings" + then st_item settings-hooks "$cp_settings" present + else st_item settings-hooks "$cp_settings" missing; fi + if copilot_single_case_ok "$cp_settings" + then st_item single-event-case "$cp_settings" present + else st_item single-event-case "$cp_settings" missing; fi + # CLI 代號也是接線的一部分,理由同 codex 那一項:取不到代號的閘門一律安靜放行。 + if [ -f "$cp_settings" ] && grep -qF 'JSC_CLI=copilot' "$cp_settings" 2>/dev/null + then st_item cli-code "$cp_settings" present + else st_item cli-code "$cp_settings" missing; fi + # 那份檔案還裝著外掛啟用狀態,掉了會讓十個 plugin 整批失效,所以單獨算一項。 + for _k in enabledPlugins extraKnownMarketplaces; do + if [ -f "$cp_settings" ] && json_top_key "$cp_settings" "$_k" + then st_item "keep-$_k" "$cp_settings" present + else st_item "keep-$_k" "$cp_settings" missing; fi + done + st_runtime_paths hooks-file-paths "$cp_settings" + st_wired="pre-tool hook 接在 ~/.copilot/settings.json 的 hooks 鍵上(PreToolUse matcher skill),版本前置檢查與部署後重啟閘門都擋得下來;STE100 與 SDLC 模型鎖仍是 prompt 與技能步驟檢查,write-guard.sh 三種模式尚未接線,註解範圍與繁中編碼只在工作階段結束時掃一次整個工作區" ;; antigravity) + ag_hooks=$(antigravity_hooks_file) st_rc_alias alias jsc-hooks:antigravity st_block ste100 "${JSC_ANTIGRAVITY_RULES:-$HOME/.antigravity/AGENTS.md}" "" st_comment_scope comment-scope "${JSC_ANTIGRAVITY_RULES:-$HOME/.antigravity/AGENTS.md}" st_lang_guard lang-guard "${JSC_ANTIGRAVITY_RULES:-$HOME/.antigravity/AGENTS.md}" st_runtime_paths alias-paths "$HOME/.bashrc" "# jsc-hooks:antigravity" "# /jsc-hooks:antigravity" - st_degrade="STE100 降級為 prompt 檔,SDLC 模型鎖降級為技能步驟檢查,無 pre-tool hook 可接版本前置檢查、部署後重啟閘門與寫入提交閘門,註解範圍與繁中編碼只在工作階段結束時掃一次整個工作區" ;; + if [ -f "$ag_hooks" ] && json_top_key "$ag_hooks" jsc + then st_item jsc-namespace "$ag_hooks" present + else st_item jsc-namespace "$ag_hooks" missing; fi + # 錨點單獨算一項:沒有錨點會連 view_file_outline 一起命中,那是接了卻接錯的形態。 + if [ -f "$ag_hooks" ] && grep -qF '"^view_file$"' "$ag_hooks" 2>/dev/null + then st_item anchored-matcher "$ag_hooks" present + else st_item anchored-matcher "$ag_hooks" missing; fi + if [ -f "$ag_hooks" ] && grep -qF '"PreInvocation"' "$ag_hooks" 2>/dev/null + then st_item pre-invocation "$ag_hooks" present + else st_item pre-invocation "$ag_hooks" missing; fi + # 形狀也要各算一項。只看鍵在不在會把「事件被靜默丟棄」讀成接好了——實測就是這樣漏掉的。 + if antigravity_grouped_ok "$ag_hooks" + then st_item pretooluse-grouped "$ag_hooks" present + else st_item pretooluse-grouped "$ag_hooks" missing; fi + if antigravity_flat_ok "$ag_hooks" + then st_item preinvocation-flat "$ag_hooks" present + else st_item preinvocation-flat "$ag_hooks" missing; fi + for _g in restart-gate.sh version-guard.sh; do + if [ -f "$ag_hooks" ] && grep -qF "$_g" "$ag_hooks" 2>/dev/null + then st_item "${_g%.sh}" "$ag_hooks" present + else st_item "${_g%.sh}" "$ag_hooks" missing; fi + done + # CLI 代號單獨算一項:這支的阻擋只認 stdout 的 deny JSON,代號取不到時 deny.sh 會退回 + # 結束碼形態,判定擋下了、CLI 卻收不到拒絕,設定檔看起來一樣完好。 + if [ -f "$ag_hooks" ] && grep -qF 'JSC_CLI=antigravity' "$ag_hooks" 2>/dev/null + then st_item cli-code "$ag_hooks" present + else st_item cli-code "$ag_hooks" missing; fi + st_runtime_paths hooks-file-paths "$ag_hooks" + st_wired="pre-tool hook 接在 ~/.gemini/config/hooks.json 的 jsc 段落(PreToolUse matcher ^view_file\$ 加 PreInvocation),版本前置檢查與部署後重啟閘門都以 stdout 的 deny JSON 擋得下來;hook 觸發本身未實跑驗證。STE100 與 SDLC 模型鎖仍是 prompt 與技能步驟檢查,write-guard.sh 三種模式尚未接線" ;; kiro) - # kiro 的 hook 檔綁在工作區,這裡看的一律是目前工作目錄底下那一份 - for _f in ./.kiro/hooks/jsc-hooks-session-start.json ./.kiro/hooks/jsc-hooks.json; do - if [ -f "$_f" ] && json_top_key "$_f" run; then st_item "$(basename "$_f" .json)" "$_f" present - else st_item "$(basename "$_f" .json)" "$_f" missing; fi + kr_agent=$(kiro_agent_file) + kr_settings=$(kiro_cli_settings) + if [ -f "$kr_agent" ] && json_top_key "$kr_agent" hooks + then st_item agent-hooks "$kr_agent" present + else st_item agent-hooks "$kr_agent" missing; fi + # resources 的兩層 glob 單獨算一項:少了它,這個 agent 一支 jsc 技能都看不到, + # 但 hooks 那一段照樣讀得懂,只看 JSON 成不成對完全看不出這個洞。 + if [ -f "$kr_agent" ] && grep -qF '/skills/*/*/SKILL.md' "$kr_agent" 2>/dev/null + then st_item resources-two-level "$kr_agent" present + else st_item resources-two-level "$kr_agent" missing; fi + if [ -f "$kr_agent" ] && json_top_key "$kr_agent" tools + then st_item agent-tools "$kr_agent" present + else st_item agent-tools "$kr_agent" missing; fi + # 形狀與 validate 各算一項。檔案合法不等於接線生效:未知的頂層鍵會被靜默忽略, + # 舊版的 on/run/env 就是這樣一個錯都不報、卻什麼都沒做。 + if kiro_agent_shape_ok "$kr_agent" + then st_item agent-shape "$kr_agent" present + else st_item agent-shape "$kr_agent" missing; fi + # validate 分三格,不是兩格。判準是輸出不是結束碼(那支指令一律回 0),而輸出還要再分 + # 「在講這個檔案」與「在講別的事」:沒登入時它印一行 error,那是驗不了,不是驗不過。 + # 報成 present 會讓沒驗到的東西看起來像通過,報成 missing 會把沒登入算成接線缺漏。 + kr_val="" + if [ -f "$kr_agent" ]; then + kr_val=$("$(cli_bin kiro)" agent validate --path "$kr_agent" 2>&1 || true) + fi + if [ ! -f "$kr_agent" ]; then st_item agent-validate "$kr_agent" missing + elif ! kiro_validate_output_ok "$kr_val"; then st_item agent-validate "$kr_agent" missing + elif kiro_validate_ran "$kr_val"; then st_item agent-validate "$kr_agent" present + else st_item agent-validate "$kr_agent" unverified; fi + for _e in agentSpawn userPromptSubmit stop; do + if [ -f "$kr_agent" ] && grep -qF "\"$_e\"" "$kr_agent" 2>/dev/null + then st_item "event-$_e" "$kr_agent" present + else st_item "event-$_e" "$kr_agent" missing; fi done - # userPromptSubmit 那一筆的 run 串了好幾支腳本,只驗 JSON 讀得懂會漏掉少接的那一支。 - # comment-scope.sh 的 prompt 與 sweep 是兩件事,各算一項,才看得出舊版接線少了哪一個。 - if [ -f ./.kiro/hooks/jsc-hooks.json ] && - grep -qF 'comment-scope.sh\" prompt' ./.kiro/hooks/jsc-hooks.json 2>/dev/null - then st_item comment-scope ./.kiro/hooks/jsc-hooks.json present - else st_item comment-scope ./.kiro/hooks/jsc-hooks.json missing; fi - if [ -f ./.kiro/hooks/jsc-hooks.json ] && - grep -qF 'comment-scope.sh\" sweep' ./.kiro/hooks/jsc-hooks.json 2>/dev/null - then st_item comment-scope-sweep ./.kiro/hooks/jsc-hooks.json present - else st_item comment-scope-sweep ./.kiro/hooks/jsc-hooks.json missing; fi - # lang-guard.sh 的兩個模式同理各算一項 - if [ -f ./.kiro/hooks/jsc-hooks.json ] && - grep -qF 'lang-guard.sh\" prompt' ./.kiro/hooks/jsc-hooks.json 2>/dev/null - then st_item lang-guard ./.kiro/hooks/jsc-hooks.json present - else st_item lang-guard ./.kiro/hooks/jsc-hooks.json missing; fi - if [ -f ./.kiro/hooks/jsc-hooks.json ] && - grep -qF 'lang-guard.sh\" sweep' ./.kiro/hooks/jsc-hooks.json 2>/dev/null - then st_item lang-guard-sweep ./.kiro/hooks/jsc-hooks.json present - else st_item lang-guard-sweep ./.kiro/hooks/jsc-hooks.json missing; fi - st_runtime_paths session-runtime-paths ./.kiro/hooks/jsc-hooks-session-start.json - st_runtime_paths hook-runtime-paths ./.kiro/hooks/jsc-hooks.json - st_degrade="SDLC 模型鎖降級為技能步驟檢查,無 pre-tool hook 可接版本前置檢查、部署後重啟閘門與寫入提交閘門,註解範圍與繁中編碼改為每輪提示送出時掃整個工作區,不是逐檔即時" ;; + for _g in session-timer.sh restart-gate.sh version-guard.sh ste100-guard.sh comment-scope.sh lang-guard.sh; do + if [ -f "$kr_agent" ] && grep -qF "$_g" "$kr_agent" 2>/dev/null + then st_item "${_g%.sh}" "$kr_agent" present + else st_item "${_g%.sh}" "$kr_agent" missing; fi + done + # agent 檔寫好了不代表被選用:不設預設 agent 就一路走內建的 kiro_default,那一份改不了。 + if [ -f "$kr_settings" ] && grep -qF '"chat.defaultAgent": "jsc"' "$kr_settings" 2>/dev/null + then st_item default-agent "$kr_settings" present + else st_item default-agent "$kr_settings" missing; fi + st_runtime_paths agent-runtime-paths "$kr_agent" + st_degrade="kiro 擋不下技能叫用——技能走 ResolveSkill 這個 agent 內部請求,preToolUse 攔不到,userPromptSubmit 的非零結束碼也不會擋下那一輪,所以版本前置檢查與部署後重啟閘門只能以 stdout 注入警告。這是 CLI 的限制,不是接線缺漏:hook、resources 與預設 agent 都已就位;hook 觸發本身未實跑驗證" ;; esac if [ "$st_missing" -gt 0 ]; then @@ -1374,7 +2352,7 @@ if [ "$action" = status ]; then printf 'status=degraded reason=%s\n' "$st_degrade" cat "$st_items"; rm -f "$st_items"; exit 1 fi - printf 'status=wired reason=%s\n' "hooks.json 自動接線全部九支 hook" + printf 'status=wired reason=%s\n' "$st_wired" cat "$st_items"; rm -f "$st_items"; exit 0 fi @@ -1437,23 +2415,57 @@ case "$cli" in has_block "$agents" "" || fail "$agents 寫入後讀不到 jsc-hooks 標記段落" has_comment_scope "$agents" || fail "$agents 寫入後讀不到註解範圍規則" has_lang_guard "$agents" || fail "$agents 寫入後讀不到繁中與編碼規則" - printf 'status=degraded reason=%s\n' "STE100 降級為 prompt 檔,SDLC 模型鎖降級為技能步驟檢查,無 pre-tool hook 可接版本前置檢查、部署後重啟閘門與寫入提交閘門,註解範圍與繁中編碼改為每輪結束掃整個工作區,不是逐檔即時" + # pre-tool hook:Codex 專屬覆寫寫在 .codex-plugin/plugin.json 的 hooks 鍵,matcher 是 Bash。 + # 這一段是版本前置檢查與部署後重啟閘門在 codex 上真正生效的地方,先前用 Skill matcher, + # 而 Codex 根本沒有 Skill 這個工具,所以兩道閘門從來沒有被叫用過一次。 + cx_manifest=$(codex_manifest) + cx_hooks=$(codex_hooks_file) + [ -f "$cx_manifest" ] || fail "找不到 $cx_manifest,codex 接不到 pre-tool hook" + [ -f "$HOOKS/hooks.json" ] || fail "找不到 $HOOKS/hooks.json,推導不出 codex 專屬的 hook 檔" + [ -f "$cx_hooks" ] && { backup_file "$cx_hooks" || fail "無法備份 $cx_hooks,沒有備份就不覆寫"; } + codex_derive_hooks "$HOOKS/hooks.json" > "$cx_hooks" 2>/dev/null \ + || fail "無法從 $HOOKS/hooks.json 推導出 $cx_hooks" + json_pairs_ok "$cx_hooks" || fail "$cx_hooks 推導後不是成對的 JSON" + json_top_key "$cx_hooks" hooks || fail "$cx_hooks 最上層讀不到 hooks 鍵" + grep -qF '"matcher": "Bash"' "$cx_hooks" 2>/dev/null \ + || fail "$cx_hooks 沒有 Bash matcher,codex 沒有 Skill 工具,擋不到技能載入那一次" + grep -qF '"matcher": "Skill"' "$cx_hooks" 2>/dev/null \ + && fail "$cx_hooks 還留著 Skill matcher,codex 沒有那個工具,那一組永遠不會被叫用" + for _g in restart-gate.sh version-guard.sh; do + grep -qF "$_g" "$cx_hooks" 2>/dev/null \ + || fail "$cx_hooks 沒有接上 $_g,那道閘門在 codex 上不會生效" + done + # 代號單獨驗一項:少了它,兩道閘門認不出現在跑的是哪一支 CLI,技能名解不出來就整批安靜放行。 + grep -qF 'JSC_CLI=codex' "$cx_hooks" 2>/dev/null \ + || fail "$cx_hooks 的命令沒有帶 JSC_CLI=codex,閘門取不到 CLI 代號就解不出技能名,接了也一律放行" + # Claude 那一份是唯一真實來源,推導完要確認它自己沒被動到。 + grep -qF '"matcher": "Skill"' "$HOOKS/hooks.json" 2>/dev/null \ + || fail "$HOOKS/hooks.json 的 Skill matcher 不見了,Claude 那一份不該被這段接線動到" + backup_file "$cx_manifest" || fail "無法備份 $cx_manifest,沒有備份就不改寫" + json_put_top_key "$cx_manifest" hooks "$(codex_hooks_key_block)" \ + || fail "無法把 hooks 鍵寫進 $cx_manifest" + json_top_key "$cx_manifest" hooks || fail "$cx_manifest 寫入後最上層讀不到 hooks 鍵" + grep -qF "\"hooks\": \"$CODEX_HOOKS_REL\"" "$cx_manifest" 2>/dev/null \ + || fail "$cx_manifest 的 hooks 鍵不是指向 $CODEX_HOOKS_REL 的路徑字串;Codex 的 manifest 規格裡這個鍵跟 skills 一樣是路徑,寫成內嵌物件解不出來" + printf 'status=wired reason=%s\n' "pre-tool hook 已接在 hooks/codex-hooks.json 的 Bash matcher 上(manifest 的 hooks 鍵以路徑字串指過去),版本前置檢查與部署後重啟閘門都擋得下來;STE100 與 SDLC 模型鎖仍是 prompt 與技能步驟檢查,write-guard.sh 三種模式尚未接線,註解範圍與繁中編碼每輪結束掃整個工作區,不是逐檔即時" echo "$WIRE_PATH_NOTE" echo "[jsc] codex:已在 shell rc 加上 $bin 別名,轉呼叫 tools/jsc-wrap.sh codex,啟動當下開始計時。" echo "[jsc] codex:別名要開新的 shell 或重新 source rc 檔才生效。" echo "[jsc] codex:已設定 $config 的 notify(根層鍵,已驗證),每輪補 session-timer.sh start 再 mark,最後跑 comment-scope.sh sweep 與 lang-guard.sh sweep。" echo "[jsc] codex:已在 $agents 寫入 STE100、註解範圍與繁中編碼規則段落(prompt 降級)。" + echo "[jsc] codex:已產出 $cx_hooks(從 hooks/hooks.json 推導,matcher Skill 換成 Bash),並把 $cx_manifest 的 hooks 鍵指到 $CODEX_HOOKS_REL。" + echo "[jsc] codex:那個鍵是路徑字串,不是內嵌物件——Codex 的 plugin manifest 規格裡 hooks 跟 skills 一樣是路徑。" + echo "[jsc] codex:Claude 用的 hooks/hooks.json 沒有動過,那一份仍是 Skill matcher,兩支 CLI 各讀各的。codex 那一份是推導出來的,hooks.json 加了新 hook,重跑接線就會跟著有。" echo "[jsc] codex:SDLC 模型鎖降級為技能步驟檢查,鎖檔仍由 sdlc-gate.sh lock 寫入。" - echo "[jsc] codex:版本前置檢查接不上(codex 沒有 pre-tool hook),改由 /jsc-cli:deploy 定期更新。" - echo "[jsc] codex:部署後重啟閘門也接不上(同樣是沒有 pre-tool hook),一次技能呼叫都擋不下來;狀態檔照樣寫、下次工作階段開始照樣清,重啟要自己動手。" - echo "[jsc] codex:write-guard.sh 的階段寫入、稽核寫入與提交檢查三種模式也接不上(同樣是沒有 pre-tool hook),一次寫入或提交都擋不下來,那三條規則在這裡只剩 SKILL.md 的散文。" + echo "[jsc] codex:write-guard.sh 的階段寫入、稽核寫入與提交檢查三種模式還沒接線,那三條規則在這裡目前只剩 SKILL.md 的散文。" echo "[jsc] codex:註解範圍與繁中編碼除了規則提示,每輪結束會由 notify 各掃一次整個 git 工作區(codex 沒有 post-tool hook,接不到逐檔即時掃描),回饋比 claude 晚一輪。" - exit 1 ;; + exit 0 ;; copilot) bin=$(cli_bin copilot) command -v "$bin" >/dev/null 2>&1 || skip "未偵測到 copilot 執行檔" - instr="${JSC_COPILOT_INSTRUCTIONS:-$HOME/.config/copilot/copilot-instructions.md}" + instr=$(copilot_instructions) + cp_settings=$(copilot_settings) alias_line="alias $bin='sh \"$WIRE_TOOLS/jsc-wrap.sh\" copilot'" write_alias_rc "jsc-hooks:copilot" "$alias_line" || fail "無法把 $bin 別名寫進 shell rc 檔" replace_block "$instr" "" "" "$(rules_text)" \ @@ -1461,17 +2473,61 @@ case "$cli" in has_block "$instr" "" || fail "$instr 寫入後讀不到 jsc-hooks 標記段落" has_comment_scope "$instr" || fail "$instr 寫入後讀不到註解範圍規則" has_lang_guard "$instr" || fail "$instr 寫入後讀不到繁中與編碼規則" - printf 'status=degraded reason=%s\n' "STE100 降級為 prompt 檔,SDLC 模型鎖降級為技能步驟檢查,無 pre-tool hook 可接版本前置檢查、部署後重啟閘門與寫入提交閘門,註解範圍與繁中編碼只在工作階段結束時掃一次整個工作區" + # pre-tool hook:併進 settings.json 的頂層 hooks 鍵,合併不覆寫。 + # 那份檔案同時裝著 enabledPlugins 與 extraKnownMarketplaces,弄壞會讓外掛整批失效, + # 所以寫前備份、寫後逐項回讀驗證,任何一項不過就還原。 + [ -f "$cp_settings" ] || printf '{\n}\n' > "$cp_settings" 2>/dev/null \ + || fail "無法建立 $cp_settings" + json_pairs_ok "$cp_settings" || fail "$cp_settings 不是成對的 JSON,讀不懂就不動它,請先修好這個檔案" + # 動這份檔案之前先記下要保住的東西,寫完拿同一份清單回頭核對。 + cp_top_before=$(json_top_keys "$cp_settings") + cp_others_before=$(copilot_foreign_entries "$cp_settings") + backup_file "$cp_settings" || fail "無法備份 $cp_settings,沒有備份就不改寫" + cp_new=$(mktemp) || fail "無法建立暫存檔" + copilot_hook_entries > "$cp_new" + cp_tmp=$(mktemp) || { rm -f "$cp_new"; fail "無法建立暫存檔"; } + copilot_merge_hooks "$cp_settings" wire "$cp_new" > "$cp_tmp" 2>/dev/null + cp_rc=$? + if [ "$cp_rc" = 2 ]; then + # 還沒有 hooks 鍵,直接新增一個;那條路徑由 json_put_top_key 走,不必再解一次舊值。 + rm -f "$cp_tmp" + json_put_top_key "$cp_settings" hooks \ + "$(printf ' "hooks": {\n "PreToolUse": [\n%s\n ]\n }' "$(cat "$cp_new")")" \ + || { rm -f "$cp_new"; restore_backups; fail "無法把 hooks 鍵寫進 $cp_settings"; } + elif [ "$cp_rc" = 0 ] && [ -s "$cp_tmp" ]; then + cat "$cp_tmp" > "$cp_settings" 2>/dev/null || { rm -f "$cp_new" "$cp_tmp"; restore_backups; fail "無法寫入 $cp_settings"; } + rm -f "$cp_tmp" + else + rm -f "$cp_new" "$cp_tmp"; restore_backups + fail "$cp_settings 的 hooks 鍵解析不出來,已還原備份;這份檔案還裝著 enabledPlugins 與 extraKnownMarketplaces,讀不懂就不動它" + fi + rm -f "$cp_new" + # 回讀驗證:先驗沒弄壞什麼,再驗接上了什麼。順序刻意這樣排—— + # 弄壞那份檔案的後果(十個外掛整批失效)比沒接上嚴重得多。 + json_pairs_ok "$cp_settings" || { restore_backups; fail "$cp_settings 寫入後不是成對的 JSON,已還原備份"; } + [ "$(json_top_keys "$cp_settings")" = "$cp_top_before" ] \ + || { restore_backups; fail "$cp_settings 的最上層鍵有增減,已還原備份;enabledPlugins 與 extraKnownMarketplaces 一個都不能掉"; } + [ "$(copilot_foreign_entries "$cp_settings")" = "$cp_others_before" ] \ + || { restore_backups; fail "$cp_settings 裡別人的 hook 條目被動到了,已還原備份"; } + copilot_hooks_ok "$cp_settings" \ + || { restore_backups; fail "$cp_settings 的 hooks 鍵沒有接上 PreToolUse matcher skill 的兩道閘門,已還原備份"; } + copilot_single_case_ok "$cp_settings" \ + || { restore_backups; fail "$cp_settings 同時出現兩種大小寫的事件名,copilot 兩種都吃,同一支 hook 會跑兩次,已還原備份"; } + # 代號單獨驗一項:少了它,兩道閘門認不出現在跑的是哪一支 CLI,技能名解不出來就整批安靜放行。 + grep -qF 'JSC_CLI=copilot' "$cp_settings" 2>/dev/null \ + || { restore_backups; fail "$cp_settings 的 jsc 條目沒有帶 JSC_CLI=copilot,閘門取不到 CLI 代號就解不出技能名,接了也一律放行,已還原備份"; } + printf 'status=wired reason=%s\n' "pre-tool hook 已併進 ~/.copilot/settings.json 的 hooks 鍵(PreToolUse matcher skill,合併不覆寫),版本前置檢查與部署後重啟閘門都擋得下來;STE100 與 SDLC 模型鎖仍是 prompt 與技能步驟檢查,write-guard.sh 三種模式尚未接線,註解範圍與繁中編碼只在工作階段結束時掃一次整個工作區" echo "$WIRE_PATH_NOTE" echo "[jsc] copilot:已在 shell rc 加上 $bin 別名,轉呼叫 tools/jsc-wrap.sh copilot。" - echo "[jsc] copilot:已在 $instr 寫入 STE100、註解範圍與繁中編碼規則段落(prompt 降級)。" + echo "[jsc] copilot:已在 $instr 寫入 STE100、註解範圍與繁中編碼規則段落(prompt 降級)。指引檔一定要在 \$COPILOT_HOME 底下,舊位置 ~/.config/copilot/ 從來沒有被載入過。" + echo "[jsc] copilot:已把 PreToolUse matcher skill 的兩道閘門併進 $cp_settings 的 hooks 鍵(合併,不覆寫)。" + echo "[jsc] copilot:那份檔案還裝著 enabledPlugins 與 extraKnownMarketplaces,寫前已備份、寫後已回讀核對最上層鍵與別人的 hook 條目,一筆都沒動到。" + echo "[jsc] copilot:\$COPILOT_HOME/hooks/ 底下是 hook 要跑的**腳本**,不是設定;設定只認 settings.json 的頂層 hooks 鍵。" echo "[jsc] copilot:別名要開新的 shell 或重新 source rc 檔才生效。" echo "[jsc] copilot:SDLC 模型鎖降級為技能步驟檢查,鎖檔仍由 sdlc-gate.sh lock 寫入。" - echo "[jsc] copilot:版本前置檢查接不上(copilot 沒有 pre-tool hook),改由 /jsc-cli:deploy 定期更新。" - echo "[jsc] copilot:部署後重啟閘門也接不上(同樣是沒有 pre-tool hook),一次技能呼叫都擋不下來;狀態檔照樣寫、下次工作階段開始照樣清,重啟要自己動手。" - echo "[jsc] copilot:write-guard.sh 的階段寫入、稽核寫入與提交檢查三種模式也接不上(同樣是沒有 pre-tool hook),一次寫入或提交都擋不下來,那三條規則在這裡只剩 SKILL.md 的散文。" + echo "[jsc] copilot:write-guard.sh 的階段寫入、稽核寫入與提交檢查三種模式還沒接線,那三條規則在這裡目前只剩 SKILL.md 的散文。" echo "[jsc] copilot:註解範圍與繁中編碼除了規則提示,工作階段結束時由 jsc-wrap.sh 收尾各掃一次整個 git 工作區(copilot 連逐輪事件都沒有),回饋要等到離開 CLI 才看得到。" - exit 1 ;; + exit 0 ;; antigravity) bin=$(cli_bin antigravity) @@ -1484,74 +2540,90 @@ case "$cli" in has_block "$rules" "" || fail "$rules 寫入後讀不到 jsc-hooks 標記段落" has_comment_scope "$rules" || fail "$rules 寫入後讀不到註解範圍規則" has_lang_guard "$rules" || fail "$rules 寫入後讀不到繁中與編碼規則" - printf 'status=degraded reason=%s\n' "STE100 降級為 prompt 檔,SDLC 模型鎖降級為技能步驟檢查,無 pre-tool hook 可接版本前置檢查、部署後重啟閘門與寫入提交閘門,註解範圍與繁中編碼只在工作階段結束時掃一次整個工作區" + # pre-tool hook:~/.gemini/config/hooks.json 的最上層一個安裝來源一個命名空間鍵, + # 寫 jsc 那一個不會動到別人的段落。plugin.json 不能宣告 hook,所以只有這一個位置。 + ag_hooks=$(antigravity_hooks_file) + [ -f "$ag_hooks" ] && { backup_file "$ag_hooks" || fail "無法備份 $ag_hooks,沒有備份就不改寫"; } + json_put_top_key "$ag_hooks" jsc "$(antigravity_hooks_block)" \ + || fail "無法把 jsc 段落寫進 $ag_hooks" + json_top_key "$ag_hooks" jsc || fail "$ag_hooks 寫入後最上層讀不到 jsc 段落" + grep -qF 'restart-gate.sh' "$ag_hooks" 2>/dev/null \ + || fail "$ag_hooks 沒有接上 restart-gate.sh,部署後重啟閘門在 antigravity 上不會生效" + grep -qF 'version-guard.sh' "$ag_hooks" 2>/dev/null \ + || fail "$ag_hooks 沒有接上 version-guard.sh,版本前置檢查在 antigravity 上不會生效" + grep -qF '"^view_file$"' "$ag_hooks" 2>/dev/null \ + || fail "$ag_hooks 的 matcher 少了錨點,沒有錨點會連 view_file_outline 一起命中" + grep -qF '"PreInvocation"' "$ag_hooks" 2>/dev/null \ + || fail "$ag_hooks 沒有接 PreInvocation,斜線指令那條路擋不到" + antigravity_grouped_ok "$ag_hooks" \ + || fail "$ag_hooks 的 PreToolUse 不是 Grouped 形狀(matcher 要跟 hooks 包一層),antigravity 會靜默丟棄整個事件" + antigravity_flat_ok "$ag_hooks" \ + || fail "$ag_hooks 的 PreInvocation 被包成 Grouped,那一組事件只吃 Flat,包了會被丟掉" + # 代號單獨驗一項:少了它,deny.sh 會退回結束碼形態,而 antigravity 只認 stdout 的 deny JSON, + # 判定擋下了、CLI 卻收不到拒絕;技能名也一樣解不出來。 + grep -qF 'JSC_CLI=antigravity' "$ag_hooks" 2>/dev/null \ + || fail "$ag_hooks 的命令沒有帶 JSC_CLI=antigravity,閘門取不到 CLI 代號就解不出技能名,阻擋形態也會退回這支 CLI 不認的結束碼" + printf 'status=wired reason=%s\n' "pre-tool hook 已接在 ~/.gemini/config/hooks.json 的 jsc 段落(PreToolUse matcher ^view_file\$ 加 PreInvocation),版本前置檢查與部署後重啟閘門都以 stdout 的 deny JSON 擋得下來;STE100 與 SDLC 模型鎖仍是 prompt 與技能步驟檢查,write-guard.sh 三種模式尚未接線,註解範圍與繁中編碼只在工作階段結束時掃一次整個工作區" echo "$WIRE_PATH_NOTE" echo "[jsc] antigravity:已在 shell rc 加上 $bin 別名,轉呼叫 tools/jsc-wrap.sh antigravity。" echo "[jsc] antigravity:已在 $rules 寫入 STE100、註解範圍與繁中編碼規則段落(prompt 降級)。" + echo "[jsc] antigravity:已在 $ag_hooks 寫入 jsc 段落,接上 restart-gate.sh 與 version-guard.sh;擋人一律走 stdout 的 deny JSON,這支 CLI 的結束碼語意沒有文件,不可靠。" + echo "[jsc] antigravity:技能沒有專用工具,模型是用 view_file 讀 SKILL.md,所以 matcher 是 ^view_file\$;錨點不能省,省了會連 view_file_outline 一起命中。" + echo "[jsc] antigravity:斜線指令與預載技能會把 SKILL.md 全文直接注入訊息,不產生工具呼叫,那條路由 PreInvocation 接住。" + echo "[jsc] antigravity:hook 觸發本身沒有實跑驗證(本機對話 quota 用盡),接線內容與腳本邏輯已驗,觸發未驗;唯讀確認可跑 agy -p \"/hooks\" 與 agy -p \"/skills\",那兩個指令不吃 quota。" echo "[jsc] antigravity:別名要開新的 shell 或重新 source rc 檔才生效。" echo "[jsc] antigravity:SDLC 模型鎖降級為技能步驟檢查,鎖檔仍由 sdlc-gate.sh lock 寫入。" - echo "[jsc] antigravity:版本前置檢查接不上(antigravity 沒有 pre-tool hook),改由 /jsc-cli:deploy 定期更新。" - echo "[jsc] antigravity:部署後重啟閘門也接不上(同樣是沒有 pre-tool hook),一次技能呼叫都擋不下來;狀態檔照樣寫、下次工作階段開始照樣清,重啟要自己動手。" - echo "[jsc] antigravity:write-guard.sh 的階段寫入、稽核寫入與提交檢查三種模式也接不上(同樣是沒有 pre-tool hook),一次寫入或提交都擋不下來,那三條規則在這裡只剩 SKILL.md 的散文。" + echo "[jsc] antigravity:write-guard.sh 的階段寫入、稽核寫入與提交檢查三種模式還沒接線,那三條規則在這裡目前只剩 SKILL.md 的散文。" echo "[jsc] antigravity:註解範圍與繁中編碼除了規則提示,工作階段結束時由 jsc-wrap.sh 收尾各掃一次整個 git 工作區(antigravity 連逐輪事件都沒有),回饋要等到離開 CLI 才看得到。" - exit 1 ;; + exit 0 ;; kiro) command -v "$(cli_bin kiro)" >/dev/null 2>&1 || skip "未偵測到 kiro-cli 執行檔" - hookdir="./.kiro/hooks" - hookfile="$hookdir/jsc-hooks.json" - startfile="$hookdir/jsc-hooks-session-start.json" - mkdir -p "$hookdir" 2>/dev/null || fail "無法建立 $hookdir" - # 計時要分兩個檔:kiro 的一個 hook 檔只有一組 run,所有事件共用。 - # sessionStart 單獨一檔跑 restart,才算得出這一次工作階段的花費時間; - # kiro 給不到 session id,紀錄共用 default,不覆寫起始時間就會把上一階段算進來。 - cat > "$startfile" 2>/dev/null < "$hookfile" 2>/dev/null </dev/null || fail "$f 缺少 JSC_CLI=kiro" - grep -qF 'session-timer.sh' "$f" 2>/dev/null || fail "$f 的 run 沒有接到 session-timer.sh" + kr_agent=$(kiro_agent_file) + kr_settings=$(kiro_cli_settings) + # hook 宣告只認 agent 設定檔的 hooks 鍵。設定目錄常數裡沒有 .kiro/hooks/, + # 那個目錄底下的檔案一份都不會被讀,先前的接線等於寫進空氣裡。 + mkdir -p "$(dirname "$kr_agent")" 2>/dev/null || fail "無法建立 $(dirname "$kr_agent")" + [ -f "$kr_agent" ] && { backup_file "$kr_agent" || fail "無法備份 $kr_agent,沒有備份就不覆寫"; } + kiro_agent_json > "$kr_agent" 2>/dev/null || fail "無法寫入 $kr_agent" + json_pairs_ok "$kr_agent" || fail "$kr_agent 寫入後不是成對的 JSON" + json_top_key "$kr_agent" hooks || fail "$kr_agent 最上層讀不到 hooks 鍵,kiro 只認這個位置" + json_top_key "$kr_agent" resources || fail "$kr_agent 最上層讀不到 resources" + json_top_key "$kr_agent" tools || fail "$kr_agent 最上層讀不到 tools,自訂 agent 沒宣告會限制可用工具" + kiro_agent_shape_ok "$kr_agent" \ + || fail "$kr_agent 的形狀不合格:合法事件只有 agentSpawn、userPromptSubmit、preToolUse、postToolUse、stop,欄位沒有 on、run、env,而且 resources 要有兩層 glob" + # 用真的 kiro-cli 驗一次。判準看輸出、不看結束碼——validate 一律回 0,看結束碼等於沒驗。 + kr_val=$("$(cli_bin kiro)" agent validate --path "$kr_agent" 2>&1) + kiro_validate_output_ok "$kr_val" \ + || fail "$kr_agent 沒通過 kiro-cli agent validate:$(printf '%s' "$kr_val" | tr '\n' ' ' | cut -c1-200)" + # 兩層 glob 是技能看不看得到的關鍵:預設只掃一層,jsc 的技能在第二層。 + grep -qF '/skills/*/*/SKILL.md' "$kr_agent" 2>/dev/null \ + || fail "$kr_agent 的 resources 少了兩層 glob,jsc 技能一支都載不到" + for _s in session-timer.sh restart-gate.sh version-guard.sh ste100-guard.sh comment-scope.sh lang-guard.sh; do + grep -qF "$_s" "$kr_agent" 2>/dev/null || fail "$kr_agent 的 hooks 沒有接到 $_s" done - grep -qF '"sessionStart"' "$startfile" 2>/dev/null || fail "$startfile 沒有接在 sessionStart" - grep -qF '"userPromptSubmit"' "$hookfile" 2>/dev/null || fail "$hookfile 沒有接在 userPromptSubmit" - grep -qF 'comment-scope.sh' "$hookfile" 2>/dev/null || fail "$hookfile 的 run 沒有接到 comment-scope.sh" - # 兩個模式接的是兩件事,只驗腳本名會漏掉少接的那一個,所以各驗一次 - grep -qF 'comment-scope.sh\" prompt' "$hookfile" 2>/dev/null \ - || fail "$hookfile 的 run 沒有接到 comment-scope.sh prompt,每輪不會注入註解範圍規則" - grep -qF 'comment-scope.sh\" sweep' "$hookfile" 2>/dev/null \ - || fail "$hookfile 的 run 沒有接到 comment-scope.sh sweep,kiro 每輪不會掃註解範圍" - grep -qF 'lang-guard.sh' "$hookfile" 2>/dev/null || fail "$hookfile 的 run 沒有接到 lang-guard.sh" - grep -qF 'lang-guard.sh\" prompt' "$hookfile" 2>/dev/null \ - || fail "$hookfile 的 run 沒有接到 lang-guard.sh prompt,每輪不會注入繁中與編碼規則" - grep -qF 'lang-guard.sh\" sweep' "$hookfile" 2>/dev/null \ - || fail "$hookfile 的 run 沒有接到 lang-guard.sh sweep,kiro 每輪不會掃簡體字與亂碼" - printf 'status=degraded reason=%s\n' "SDLC 模型鎖降級為技能步驟檢查,無 pre-tool hook 可接版本前置檢查、部署後重啟閘門與寫入提交閘門,註解範圍與繁中編碼改為每輪提示送出時掃整個工作區,不是逐檔即時" + # 這個 agent 要被選用才算數,所以還要設 chat.defaultAgent。內建 agent 改不了, + # 不設就一路走內建的 kiro_default,寫好的 agent 檔一次都不會被讀。 + [ -f "$kr_settings" ] && { backup_file "$kr_settings" || fail "無法備份 $kr_settings,沒有備份就不改寫"; } + json_put_top_key "$kr_settings" chat.defaultAgent ' "chat.defaultAgent": "jsc"' \ + || fail "無法把 chat.defaultAgent 寫進 $kr_settings" + grep -qF '"chat.defaultAgent": "jsc"' "$kr_settings" 2>/dev/null \ + || fail "$kr_settings 寫入後讀不到 chat.defaultAgent=jsc" + printf 'status=degraded reason=%s\n' "kiro 擋不下技能叫用——技能走 ResolveSkill 這個 agent 內部請求,preToolUse 攔不到,userPromptSubmit 的非零結束碼也不會擋下那一輪,所以版本前置檢查與部署後重啟閘門只能以 stdout 注入警告。這是 CLI 的限制,不是接線缺漏:hook、resources 與預設 agent 都已寫進 ~/.kiro/agents/jsc.json 與 settings/cli.json" echo "$WIRE_PATH_NOTE" - echo "[jsc] kiro:已建立 $startfile(sessionStart 開始計時)與 $hookfile(JSC_CLI=kiro),兩份都已驗證。" - echo "[jsc] kiro:SDLC 模型鎖降級為技能步驟檢查,鎖檔仍由 sdlc-gate.sh lock 寫入。" - echo "[jsc] kiro:版本前置檢查接不上(kiro 沒有 pre-tool hook),改由 /jsc-cli:deploy 定期更新。" - echo "[jsc] kiro:部署後重啟閘門也接不上(同樣是沒有 pre-tool hook),一次技能呼叫都擋不下來;狀態檔照樣寫、下次工作階段開始照樣清,重啟要自己動手。" - echo "[jsc] kiro:write-guard.sh 的階段寫入、稽核寫入與提交檢查三種模式也接不上(同樣是沒有 pre-tool hook),一次寫入或提交都擋不下來,那三條規則在這裡只剩 SKILL.md 的散文。" - echo "[jsc] kiro:註解範圍與繁中編碼除了規則提示,每輪提示送出時會各掃一次整個 git 工作區(kiro 沒有 post-tool hook),掃到的是上一輪寫的檔。" + echo "[jsc] kiro:已建立 $kr_agent(hooks、resources、tools 三段齊全)並把 $kr_settings 的 chat.defaultAgent 設成 jsc。" + echo "[jsc] kiro:resources 寫了一層與兩層兩條 skill:// glob。預設只掃一層,jsc 的技能在 jsc-{domain}/{name}/SKILL.md 第二層,少了那一條一支都載不到;一層那一條留著,別人的技能不受影響。" + echo "[jsc] kiro:tools 明寫成 [\"*\"]。自訂 agent 沒宣告 tools 時可用工具會受限,模型連讀檔都做不到。" + if kiro_validate_ran "$kr_val"; then + echo "[jsc] kiro:已用 kiro-cli agent validate 驗過(輸出為空)。判準是**輸出**不是結束碼——那支指令四種情況一律回 0,看結束碼會做出一支永遠通過的檢查。" + else + echo "[jsc] kiro:kiro-cli agent validate 這一項**沒驗到**(它印的不是這個檔案的問題,通常是沒登入):$(printf '%s' "$kr_val" | tr '\n' ' ' | cut -c1-160)" + echo "[jsc] kiro:驗不了不等於驗不過,所以照樣放行;要補驗就登入後重跑接線。" + fi + echo "[jsc] kiro:合法事件只有 agentSpawn、userPromptSubmit、preToolUse、postToolUse、stop;sessionStart 與 sessionEnd 都不合法。舊版寫在最上層的 on/run/env 是**未知鍵,被靜默忽略**,validate 一個錯都不報,那份檔案卻什麼都沒做。" + echo "[jsc] kiro:版本前置檢查與部署後重啟閘門只注入警告,擋不下技能叫用,那是 CLI 的限制。要真的擋,只能靠使用者看到警告後自己停手。" + echo "[jsc] kiro:hook 觸發與 agent 設定讀取都沒有實跑驗證(本機未登入),接線內容與腳本邏輯已驗,觸發未驗。" + echo "[jsc] kiro:舊的 .kiro/hooks/ 接線已作廢,那個目錄不在 kiro 的設定目錄常數裡,一份都不會被讀。" exit 1 ;; + esac