#!/usr/bin/env sh # wire-cli.sh — 單一 CLI 的 hook 生命週期:先清、再接、再冒煙(供 hooks-install 技能呼叫)。 # 用法: # wire-cli.sh {claude|codex|copilot|antigravity|kiro} 接線 # wire-cli.sh purge {claude|codex|copilot|antigravity|kiro} 備份後移除該 CLI 的所有 hook # wire-cli.sh smoke {claude|codex|copilot|antigravity|kiro} 跑一輪九支 hook,驗執行期 # wire-cli.sh status {claude|codex|copilot|antigravity|kiro} 唯讀盤點接線現況,不寫檔也不執行 hook # # JSC_READONLY=1 時只准 status 與 smoke,purge 與接線一律拒絕並回 exit 6。體檢類技能全程帶著 # 這個變數跑,「子命令打錯一個字就重新接線或刪檔」的風險就由程式擋掉,不靠呼叫端自我約束。 # # purge 移除的是「所有 hook」,含非 jsc 的第三方項目。安裝一律先 purge 再接線:混著別人的 # hook 接線,出錯時分不清是誰的 hook 壞掉,也修不了。移除前每個要動的檔案先原樣複製到 # $JSC_HOME/backup/hooks/{cli}/{yyyyMMdd_HHmmss}/,備份失敗就不移除。 # # smoke 在接線之後跑,補上接線驗證看不到的那一半:接線只證明設定寫對位置,證不了 hook # 跑起來不出錯(缺 node、路徑錯、權限不足都只在真的執行時才現形)。 # 有分支的判定另外驗結果,不只驗跑得完:sdlc-gate.sh 的工作包歸屬比對會自備一份暫時的 # $JSC_HOME 狀態檔,把「查無歸屬」「自己的工作包」「別的工作包的阻擋與放行」與逃生門各跑一次,比對結束碼。 # restart-gate.sh 的部署後重啟閘門同法:當前 CLI 沒有狀態檔、只有別支 CLI 有狀態檔、當前 # CLI 那份存在、豁免技能、逃生門、取不到技能名各跑一次,再驗狀態檔本身在不在——清除只刪自己 # 那一份、別支那一份留著、舊格式的單一狀態檔照樣擋得下來且清得掉。 # write-guard.sh 的四種模式同法:階段鎖、稽核技能、提交指令與 release 解除各自的判定路徑, # 都用暫時的 $JSC_HOME 跑過一次並比對結束碼。 # 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 段落,不會重複疊加): # 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 — 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 都有保護,也不得再說「沒有 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,寫完哪個檔就掃哪個,逐檔即時 # codex 掛在每輪結束的 notify,掃整個 git 工作區這輪改過的檔(sweep) # kiro 掛在 userPromptSubmit,掃整個 git 工作區,掃到的是上一輪寫的檔 # copilot、antigravity 沒有任何逐輪事件,只有工作階段結束時由 jsc-wrap.sh 收尾掃一次 # 時機晚一點、涵蓋範圍一樣:sweep 看的是 git diff,那一輪寫過的檔一個都不會漏。真正的差別 # 在回饋速度——claude 當下就叫,其他四個要等到該輪或該階段結束。lang-guard.sh 的三種模式 # 一律跟著 comment-scope.sh 接在同一批位置,兩支的掃描時機表完全一致。 # 所有 jsc 標記段落都採整段重寫,重跑等同先移除舊內容再重裝 # # 寫入後自我驗證,通過才回報成功:每個寫過的檔案重新讀一次,確認段落存在且落在正確位置 # (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(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 # 結束碼(smoke): 0=ok 2=用法錯誤 4=failed(含結果行數與預期不符) # 結束碼(status): 0=wired 1=degraded 2=用法錯誤 3=skipped 5=unwired(該接的段落缺了至少一項) # 結束碼(唯讀模式): 6=readonly(JSC_READONLY=1 之下拒絕 purge 與接線),status 與 smoke 不受影響 # status 之外的動作都會寫檔,體檢類技能(/jsc-cli:doctor)只能呼叫 status。判讀邏輯跟接線 # 共用同一組檔案位置與標記字串,分兩份實作就會各自漂移,體檢說沒接、實際上接著。 # # JSC_CLAUDE_SETTINGS_DIR 可覆寫 claude 使用者層設定檔目錄(預設 ~/.claude)。 # 有這個逃生門才測得動 purge 的 JSON 刪鍵:預設路徑是使用者自己的設定檔,拿真檔案試刪 # 等於拿使用者的環境當測試場。指向一份複製品就能完整跑過 purge claude 而不動到本人設定。 set -u HERE=$(cd "$(dirname "$0")" && pwd) ROOT=$(cd "$HERE/.." && pwd) HOOKS="$ROOT/hooks" JSC_HOME="${JSC_HOME:-$HOME/.jsc}" WIRE_ROOT="$ROOT" WIRE_HOOKS="$HOOKS" WIRE_TOOLS="$HERE" WIRE_PATH_NOTE="" # cli_bin(CLI 代號 → 實際執行檔)的唯一來源在 lib.sh,包裝啟動器也用同一份 . "$HOOKS/lib.sh" usage() { echo "用法:wire-cli.sh [purge|smoke|status] {claude|codex|copilot|antigravity|kiro}" >&2 exit 2 } # 第一個參數是子命令時走新流程,否則沿用原本的「wire-cli.sh {cli}」接線。 action=wire case "${1:-}" in purge|smoke|status) action="$1"; shift ;; esac cli="${1:-}" case "$cli" in claude|codex|copilot|antigravity|kiro) ;; *) usage ;; esac # 唯讀契約在程式層把關,不靠呼叫端記得只打 status。子命令解析完就判:預設動作是接線, # 所以少打一個子命令就會直接改環境,這個判定要擋的正是那一次手滑。 if [ "${JSC_READONLY:-}" = "1" ]; then case "$action" in status|smoke) ;; *) printf 'status=readonly reason=%s\n' "JSC_READONLY=1 之下只准 status 與 smoke,$action 會動到接線,已拒絕" echo "[jsc] 目前是唯讀模式(JSC_READONLY=1),purge 與接線一律拒絕,環境沒有被動過。" >&2 echo "[jsc] 要盤點接線現況請用 wire-cli.sh status $cli;要驗執行期請用 wire-cli.sh smoke $cli。" >&2 echo "[jsc] 確定要改接線,請在沒有 JSC_READONLY 的環境重跑,或改走 /jsc-hooks:hooks-install。" >&2 exit 6 ;; esac fi # STE100 規則段落的唯一來源:ste100-guard.sh 的實際輸出 ste100_text() { sh "$HOOKS/ste100-guard.sh" 2>/dev/null | sed '/^exit /d'; } # 註解範圍規則段落的唯一來源:comment-scope.sh prompt 的實際輸出。 # 規則正文不在這裡抄一份:抄了就會跟腳本各自漂移,兩邊講的規則對不起來。 comment_scope_text() { sh "$HOOKS/comment-scope.sh" prompt 2>/dev/null | sed '/^exit /d'; } # 繁中與編碼規則段落的唯一來源:lang-guard.sh prompt 的實際輸出。理由同上,不在這裡抄一份。 lang_guard_text() { sh "$HOOKS/lang-guard.sh" prompt 2>/dev/null | sed '/^exit /d'; } # 寫進規則檔的完整段落:語言規則加註解範圍規則加繁中編碼規則,共用同一組 jsc-hooks 標記。 # 三段合在一個標記段落裡,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() { _link="$JSC_HOME/current/jsc-hooks" _parent=$(dirname "$_link") if mkdir -p "$_parent" 2>/dev/null && { [ ! -e "$_link" ] || [ -L "$_link" ]; } && ln -sfn "$ROOT" "$_link" 2>/dev/null && [ -f "$_link/hooks/session-timer.sh" ] && [ -f "$_link/tools/jsc-wrap.sh" ]; then WIRE_ROOT="$_link" WIRE_HOOKS="$_link/hooks" WIRE_TOOLS="$_link/tools" WIRE_PATH_NOTE="[jsc] 接線命令使用穩定路徑 $_link,已指向 $ROOT。" return 0 fi WIRE_ROOT="$ROOT" WIRE_HOOKS="$HOOKS" WIRE_TOOLS="$HERE" WIRE_PATH_NOTE="[jsc] 無法建立穩定路徑 $_link,已明確退回目前路徑 $ROOT;下次 plugin 換版後請重新接線。" return 0 } verify_wire_script() { [ -f "$1" ] || fail "接線路徑不存在:$1" } verify_wire_root() { verify_wire_script "$WIRE_HOOKS/session-timer.sh" verify_wire_script "$WIRE_HOOKS/comment-scope.sh" verify_wire_script "$WIRE_HOOKS/lang-guard.sh" verify_wire_script "$WIRE_TOOLS/jsc-wrap.sh" } # 讀 Claude Code 真正載入的 jsc-hooks installPath。status claude 要看載入路徑,不看 # 目前這支 wire-cli.sh 所在位置,否則用開發 repo 跑 status 會蓋掉壞掉的安裝快取。 claude_loaded_root() { _reg="$HOME/.claude/plugins/installed_plugins.json" [ -f "$_reg" ] && [ -r "$_reg" ] || return 1 _seg=$(tr -d '\n' < "$_reg" \ | sed -n 's/.*"jsc-hooks@jsc"[[:space:]]*:[[:space:]]*\[\([^]]*\)\].*/\1/p') [ -n "$_seg" ] || return 1 printf '%s' "$_seg" | tr ',' '\n' \ | sed -n 's/.*"installPath"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n1 } bad_runtime_path() { case "$1" in /root/plugins/hooks/*) return 0 ;; */cache/*/jsc-hooks/[0-9]*/*) return 0 ;; esac [ -e "$1" ] || return 0 return 1 } # 驗證:規則檔真的收到註解範圍那一段了嗎。比對字串取自腳本的第一行實際輸出, # 不是另外抄一句關鍵字——抄的關鍵字改腳本時不會跟著改,驗證就會永遠通過。 # $1=檔案 has_comment_scope() { _first=$(comment_scope_text | head -n1) [ -n "$_first" ] || return 1 grep -qF "$_first" "$1" 2>/dev/null } # 驗證:規則檔真的收到繁中與編碼那一段了嗎。同樣取腳本的第一行實際輸出來比對,理由同上。 # $1=檔案 has_lang_guard() { _first=$(lang_guard_text | head -n1) [ -n "$_first" ] || return 1 grep -qF "$_first" "$1" 2>/dev/null } codex_plugin_hooks_json() { _base="${CODEX_HOME:-$HOME/.codex}/plugins/cache/jsc/jsc-hooks" [ -d "$_base" ] || return 1 find "$_base" -path '*/hooks/hooks.json' -type f 2>/dev/null | sort | tail -n1 } codex_plugin_hooks_safe() { _hooks_json=$(codex_plugin_hooks_json || true) [ -n "$_hooks_json" ] || return 0 ! grep -qF 'command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/' "$_hooks_json" 2>/dev/null } # 以標記整段取代(冪等);標記不存在就在檔尾新增;檔案不存在就建立。 # 適用 markdown 規則檔與 shell rc 檔:這兩種檔案沒有「區段」概念,附在檔尾就對了。 # $1=檔案 $2=開頭標記行 $3=結尾標記行 $4=標記之間要寫入的內容 replace_block() { file="$1"; bopen="$2"; bshut="$3"; content="$4" dir=$(dirname "$file") mkdir -p "$dir" 2>/dev/null || return 1 touch "$file" 2>/dev/null || return 1 # touch 對目錄也會成功,所以要另外確認它真的是一般檔案;不然接著的寫入才失敗, # 而 shell 開檔失敗的訊息蓋不掉,會漏一行 cannot create 給使用者看。 [ -f "$file" ] || return 1 block=$(printf '%s\n%s\n%s' "$bopen" "$content" "$bshut") if grep -qF "$bopen" "$file" 2>/dev/null; then awk -v bopen="$bopen" -v bshut="$bshut" -v block="$block" ' $0==bopen { print block; skip=1; next } $0==bshut { skip=0; next } skip { next } { print } ' "$file" > "$file.jsc-tmp" 2>/dev/null || { rm -f "$file.jsc-tmp"; return 1; } mv "$file.jsc-tmp" "$file" 2>/dev/null || { rm -f "$file.jsc-tmp"; return 1; } else # 包一層子 shell 才蓋得住 shell 自己的開檔失敗訊息(>> 失敗時那行不走命令的 stderr) ( printf '\n%s\n' "$block" >> "$file" ) 2>/dev/null || return 1 fi } # TOML 版的整段取代:標記段落一律放在第一個表頭(`[table]`、`[[array]]`)之前。 # TOML 的根層鍵只在第一個表頭之前有效,附在檔尾會被歸進最後那張表——檔案照樣解析 # 得過,codex 卻永遠讀不到 notify,hook 靜靜失效。所以位置本身就是正確性的一部分。 # 舊版寫錯位置的段落也會被這支函式移到正確位置(先整段刪除,再插到表頭之前)。 # $1=檔案 $2=開頭標記行 $3=結尾標記行 $4=標記之間要寫入的內容 replace_block_toml() { file="$1"; bopen="$2"; bshut="$3"; content="$4" dir=$(dirname "$file") mkdir -p "$dir" 2>/dev/null || return 1 touch "$file" 2>/dev/null || return 1 # touch 對目錄也會成功,所以要另外確認它真的是一般檔案;不然接著的寫入才失敗, # 而 shell 開檔失敗的訊息蓋不掉,會漏一行 cannot create 給使用者看。 [ -f "$file" ] || return 1 block=$(printf '%s\n%s\n%s' "$bopen" "$content" "$bshut") awk -v bopen="$bopen" -v bshut="$bshut" -v block="$block" ' $0==bopen { skip=1; next } $0==bshut { skip=0; next } skip { next } # 表頭樣式:整行只有 [name] 或 [[name]]。多行陣列裡的 [1, 2], 不會命中。 !done && /^[ \t]*\[\[?[^][]+\]\]?[ \t]*$/ { print block; print ""; done=1 } { print } END { if (!done) print block } ' "$file" > "$file.jsc-tmp" 2>/dev/null || { rm -f "$file.jsc-tmp"; return 1; } mv "$file.jsc-tmp" "$file" 2>/dev/null || { rm -f "$file.jsc-tmp"; return 1; } } # 驗證:檔案裡有這段標記嗎($1=檔案 $2=開頭標記行) has_block() { grep -qF "$2" "$1" 2>/dev/null; } # 驗證:TOML 的某個鍵是不是落在根層(第一個表頭之前)。$1=檔案 $2=鍵名 toml_root_key() { awk -v k="$2" ' /^[ \t]*\[\[?[^][]+\]\]?[ \t]*$/ { intable=1; next } !intable && $0 ~ "^[ \t]*" k "[ \t]*=" { found=1 } END { exit(found ? 0 : 1) } ' "$1" 2>/dev/null } # 驗證:JSON 括號成對,且某個鍵出現在最上層($1=檔案 $2=鍵名 $3=要求)。 # 解析失敗(括號不成對、字串沒收尾)也回傳非 0,所以這支同時當語法檢查用。 # $3=key(預設)要求該鍵存在;$3=pairs 只檢查語法,不管鍵在不在——purge 之後要驗的是 # 「鍵不見了而且檔案還是合法 JSON」,這兩件事得分開問,不然刪壞檔也會被當成刪成功。 json_top_key() { awk -v k="$2" -v want="${3:-key}" ' { s = s $0 "\n" } END { n = length(s); depth = 0; i = 1; found = 0; bad = 0 while (i <= n) { c = substr(s, i, 1) if (c == "\"") { buf = ""; i++; closed = 0 while (i <= n) { c = substr(s, i, 1) if (c == "\\") { i += 2; continue } if (c == "\"") { i++; closed = 1; break } buf = buf c; i++ } if (!closed) { bad = 1; break } j = i while (j <= n && substr(s, j, 1) ~ /[ \t\r\n]/) j++ if (substr(s, j, 1) == ":" && depth == 1 && buf == k) found = 1 continue } if (c == "{" || c == "[") depth++ else if (c == "}" || c == "]") { depth--; if (depth < 0) { bad = 1; break } } i++ } if (bad || depth != 0) exit(1) exit((want == "pairs" || found) ? 0 : 1) }' "$1" 2>/dev/null } # 驗證: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() { for f in "$HOME/.bashrc" "$HOME/.zshrc" "$HOME/.config/fish/config.fish"; do [ -f "$f" ] && printf '%s\n' "$f" done return 0 } # 找出可寫入別名的 shell rc 檔;都不存在就以 ~/.bashrc 為預設(自動建立)。 # 印出找到或建立的 rc 檔路徑,一行一個。 rc_files() { found="" for f in "$HOME/.bashrc" "$HOME/.zshrc" "$HOME/.config/fish/config.fish"; do [ -f "$f" ] && { printf '%s\n' "$f"; found=1; } done [ -n "$found" ] || printf '%s\n' "$HOME/.bashrc" } # 把別名寫進每個 rc 檔並逐檔驗證。$1=標記名(不含 # 與 /)$2=別名內容 # 迴圈不可以放在管線右邊:那會變成子 shell,寫入失敗的旗標傳不回來, # 明明沒寫成功也照樣回報 wired。改成從暫存檔讀,迴圈就留在本 shell。 write_alias_rc() { _mark="$1"; _line="$2"; _ok=1 _list=$(mktemp) || return 1 rc_files > "$_list" || { rm -f "$_list"; return 1; } while IFS= read -r rc; do [ -n "$rc" ] || continue replace_block "$rc" "# $_mark" "# /$_mark" "$_line" || { _ok=0; continue; } grep -qF "$_line" "$rc" 2>/dev/null || _ok=0 done < "$_list" rm -f "$_list" [ "$_ok" = 1 ] } # --- 移除(purge)用的函式 --- # 以標記整段移除(冪等)。與 replace_block 對稱:同一組標記,一支寫入、一支移除。 # 標記不存在就當成已移除、回傳成功——purge 重跑不該因為「上次已經清掉了」而失敗。 # $1=檔案 $2=開頭標記行 $3=結尾標記行 remove_block() { file="$1"; bopen="$2"; bshut="$3" [ -f "$file" ] || return 0 grep -qF "$bopen" "$file" 2>/dev/null || return 0 awk -v bopen="$bopen" -v bshut="$bshut" ' function trim(s) { sub(/^[ \t]+/, "", s) sub(/[ \t]+$/, "", s) return s } trim($0)==bopen { skip=1; next } trim($0)==bshut { skip=0; next } skip { next } { print } ' "$file" > "$file.jsc-tmp" 2>/dev/null || { rm -f "$file.jsc-tmp"; return 1; } mv "$file.jsc-tmp" "$file" 2>/dev/null || { rm -f "$file.jsc-tmp"; return 1; } } # 移除 rc 檔裡所有 `# jsc-hooks*` 標記段落,不管後面接哪個 CLI 名。 # 舊版接線可能留下已改名的段落,逐一指名會漏掉,所以用前綴一次掃乾淨。 # $1=檔案 remove_rc_blocks() { file="$1" [ -f "$file" ] || return 0 grep -q '^# jsc-hooks' "$file" 2>/dev/null || return 0 awk ' function trim(s) { sub(/^[ \t]+/, "", s) sub(/[ \t]+$/, "", s) return s } trim($0) ~ /^# jsc-hooks/ { skip=1; next } trim($0) ~ /^# \/jsc-hooks/ { skip=0; next } skip { next } { print } ' "$file" > "$file.jsc-tmp" 2>/dev/null || { rm -f "$file.jsc-tmp"; return 1; } mv "$file.jsc-tmp" "$file" 2>/dev/null || { rm -f "$file.jsc-tmp"; return 1; } } # 移除 TOML 的根層鍵(第一個表頭之前的那個鍵),含非 jsc 設的值。 # 值可能是多行陣列或多行行內表,所以要追括號深度,收齊才停;只刪一行會留下孤兒括號。 # $1=檔案 $2=鍵名 remove_toml_root_key() { file="$1"; key="$2" [ -f "$file" ] || return 0 awk -v k="$key" ' BEGIN { intable=0; drop=0; depth=0 } /^[ \t]*\[\[?[^][]+\]\]?[ \t]*$/ { intable=1 } { if (drop) { depth += gsub(/[[{]/, "&") - gsub(/[]}]/, "&") if (depth <= 0) drop=0 next } if (!intable && $0 ~ "^[ \t]*" k "[ \t]*=") { depth = gsub(/[[{]/, "&") - gsub(/[]}]/, "&") if (depth > 0) drop=1 next } print } ' "$file" > "$file.jsc-tmp" 2>/dev/null || { rm -f "$file.jsc-tmp"; return 1; } mv "$file.jsc-tmp" "$file" 2>/dev/null || { rm -f "$file.jsc-tmp"; return 1; } } # 刪掉 JSON 最上層的某個鍵(含後面多餘的逗號),逐字元追蹤引號與括號深度。 # jq 在目標機器上不保證存在,所以要有這條純 awk 的路;只用 sed 刪不了嵌套的 {...}。 # 追蹤引號是必要的:字串裡的 { 與 } 不算深度,漏算就會把整段設定切壞。 # 鍵名做成參數,是因為現在要刪的最上層鍵不只一種:claude 與 codex 刪 hooks、antigravity 刪 # jsc 那個命名空間、kiro 刪 chat.defaultAgent。同一套字元掃描抄三份只會三邊漂移。 # $1=檔案 $2=鍵名(省略時為 hooks),結果印到標準輸出; # 解析不出來(括號不成對、字串沒收尾)就 exit 1,不輸出半份檔案。 awk_del_hooks() { awk -v delkey="${2:-hooks}" ' function skip_string(s, i, n, c) { i++ while (i <= n) { c = substr(s, i, 1) if (c == "\\") { i += 2; continue } if (c == "\"") return i + 1 i++ } return 0 } function skip_value(s, i, n, c, d) { while (i <= n && substr(s, i, 1) ~ /[ \t\r\n]/) i++ c = substr(s, i, 1) if (c == "\"") return skip_string(s, i, n) if (c == "{" || c == "[") { d = 0 while (i <= n) { c = substr(s, i, 1) if (c == "\"") { i = skip_string(s, i, n); if (i == 0) return 0; continue } if (c == "{" || c == "[") { d++; i++; continue } if (c == "}" || c == "]") { d--; i++; if (d == 0) return i; continue } i++ } return 0 } while (i <= n && substr(s, i, 1) !~ /[,}\]\t\r\n ]/) i++ return i } { s = s $0 "\n" } END { n = length(s); i = 1; depth = 0; out = "" while (i <= n) { c = substr(s, i, 1) if (c == "\"") { start = i; 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 j = i while (j <= n && substr(s, j, 1) ~ /[ \t\r\n]/) j++ if (depth == 1 && buf == delkey && substr(s, j, 1) == ":") { i = skip_value(s, j + 1, n) if (i == 0) exit 1 j = i while (j <= n && substr(s, j, 1) ~ /[ \t\r\n]/) j++ if (substr(s, j, 1) == ",") { # 後面還有成員:連逗號一起吃掉,並把 hooks 那行留下的縮排收乾淨 i = j + 1 sub(/[ \t]+$/, "", out) while (i <= n && substr(s, i, 1) ~ /[ \t\r]/) i++ if (substr(s, i, 1) == "\n") i++ } else { # hooks 是最後一個成員:改刪前一個逗號,不刪會留下「, }」這種壞掉的 JSON sub(/,[ \t\r\n]*$/, "", out) } continue } out = out substr(s, start, i - start) continue } if (c == "{" || c == "[") depth++ else if (c == "}" || c == "]") { depth--; if (depth < 0) exit 1 } out = out c; i++ } if (depth != 0) exit 1 printf "%s", out }' "$1" } # 刪掉設定檔最上層的某個鍵:有 jq 就用 jq,沒有就走 awk_del_hooks。$1=檔案 $2=鍵名(省略為 hooks) json_del_hooks() { _f="$1"; _k="${2:-hooks}" [ -f "$_f" ] || return 0 if command -v jq >/dev/null 2>&1; then 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" "$_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="" BACKUP_STAMP=$(date +%Y%m%d_%H%M%S) BACKUP_LIST="" # 每行「{備份檔}{原檔}」,還原時反向複製回去 # 備份目錄延後建立:沒有檔案要動時不留空目錄。 # 只設全域變數、不印路徑:呼叫端若寫成 $(backup_dir) 就變成子 shell,設好的 BACKUP_DIR # 與 BACKUP_LIST 傳不回本 shell,接著的備份與還原全部失準。 ensure_backup_dir() { [ -z "$BACKUP_DIR" ] || return 0 _d="$JSC_HOME/backup/hooks/$cli/$BACKUP_STAMP" mkdir -p "$_d" 2>/dev/null || return 1 BACKUP_LIST=$(mktemp) || return 1 BACKUP_DIR="$_d" return 0 } # 原樣複製一份到備份目錄,保留原檔名;同名就加 -1、-2 後綴(不同目錄可能有同名檔)。 # 複製失敗回傳 1,呼叫端必須就此停手:沒有備份就移除,等於把使用者的設定弄不見。 backup_file() { # $1=檔案 _src="$1" [ -f "$_src" ] || return 0 ensure_backup_dir || return 1 _base=$(basename "$_src") _dst="$BACKUP_DIR/$_base"; _n=0 while [ -e "$_dst" ]; do _n=$((_n + 1)); _dst="$BACKUP_DIR/$_base-$_n" done cp "$_src" "$_dst" 2>/dev/null || return 1 [ -f "$_dst" ] || return 1 printf '%s\t%s\n' "$_dst" "$_src" >> "$BACKUP_LIST" || return 1 return 0 } # 還原這次所有備份(驗證沒過時用)。已刪除的檔案會被複製回來。 restore_backups() { [ -n "$BACKUP_LIST" ] && [ -f "$BACKUP_LIST" ] || return 0 while IFS="$(printf '\t')" read -r _b _o; do [ -n "$_b" ] && [ -n "$_o" ] || continue mkdir -p "$(dirname "$_o")" 2>/dev/null || true cp "$_b" "$_o" 2>/dev/null || true done < "$BACKUP_LIST" return 0 } skip() { # $1=reason printf 'status=skipped reason=%s\n' "$1" echo "[jsc] 略過:$1" exit 3 } fail() { # $1=reason printf 'status=failed reason=%s\n' "$1" echo "[jsc] 接線沒生效:$1" >&2 echo "[jsc] 請先以 tools/report-error.sh 回報這次失敗,再交給 /jsc-hooks:repair 自動修正並開 develop PR。" >&2 exit 4 } # purge 專用的失敗出口:先把備份還原回去,再回報。移除做一半的環境比沒動過更難修。 pfail() { # $1=reason restore_backups printf 'status=failed reason=%s\n' "$1" echo "[jsc] 移除沒完成,已從備份還原:$1" >&2 [ -n "$BACKUP_DIR" ] && echo "[jsc] 備份目錄:$BACKUP_DIR" >&2 echo "[jsc] 請先以 tools/report-error.sh 回報這次失敗,再交給 /jsc-hooks:repair 自動修正並開 develop PR。" >&2 exit 4 } purged() { # $1=reason printf 'status=purged reason=%s\n' "$1" if [ -n "$BACKUP_DIR" ]; then echo "[jsc] $cli:已移除全部 hook,移除前的原檔備份在 $BACKUP_DIR。" else echo "[jsc] $cli:沒有找到任何 hook 設定,已是乾淨狀態,未建立備份目錄。" fi } if [ "$action" = purge ]; then case "$cli" in claude) bin=$(cli_bin claude) command -v "$bin" >/dev/null 2>&1 || skip "未偵測到 claude 執行檔" # claude 的 hook 全部宣告在使用者層設定檔的 hooks 鍵裡,清掉那個鍵就等於清掉所有 hook。 cdir="${JSC_CLAUDE_SETTINGS_DIR:-$HOME/.claude}" done_files="" for f in "$cdir/settings.json" "$cdir/settings.local.json"; do [ -f "$f" ] || continue # 先問語法:讀不懂的設定檔不能刪鍵,也不能當成「沒有 hooks 鍵」帶過—— # 那會回報 purged 卻留著整套 hook,比直接說失敗更難查。 json_pairs_ok "$f" || pfail "$f 不是成對的 JSON,讀不懂就不動它,請先修好這個檔案" json_top_key "$f" hooks || continue backup_file "$f" || pfail "無法備份 $f,沒有備份就不移除" json_del_hooks "$f" || pfail "無法從 $f 刪除 hooks 鍵" json_pairs_ok "$f" || pfail "$f 刪除 hooks 鍵後 JSON 括號不成對" ! json_top_key "$f" hooks || pfail "$f 刪除後最上層仍有 hooks 鍵" done_files="$done_files $f" done if [ -n "$done_files" ]; then purged "已從 claude 使用者層設定檔刪除 hooks 鍵,含非 jsc 的第三方項目" echo "[jsc] claude:已處理的設定檔:$done_files" else purged "claude 使用者層設定檔沒有 hooks 鍵,沒有 hook 要移除" fi echo "[jsc] claude:其他 plugin 自帶的 hooks.json 不在使用者設定檔裡,purge 動不到;要靠移除該 plugin 才能清掉。" echo "[jsc] claude:jsc 自己的 hooks/hooks.json 同樣隨 plugin 提供,移除 jsc-hooks plugin 才會消失。" exit 0 ;; codex) bin=$(cli_bin codex) command -v "$bin" >/dev/null 2>&1 || skip "未偵測到 codex 執行檔" CODEX_HOME="${CODEX_HOME:-$HOME/.codex}" config="$CODEX_HOME/config.toml" agents="$CODEX_HOME/AGENTS.md" if [ -f "$config" ]; then if has_block "$config" "# jsc-hooks" || toml_root_key "$config" notify; then backup_file "$config" || pfail "無法備份 $config,沒有備份就不移除" remove_block "$config" "# jsc-hooks" "# /jsc-hooks" \ || pfail "無法從 $config 移除 jsc-hooks 標記段落" remove_toml_root_key "$config" notify || pfail "無法從 $config 移除根層 notify" has_block "$config" "# jsc-hooks" && pfail "$config 移除後仍讀得到 jsc-hooks 標記段落" toml_root_key "$config" notify && pfail "$config 移除後仍有根層 notify" fi fi # rc 檔清所有 `# jsc-hooks*` 段落:別名段落沒有分 CLI 的必要,一次清乾淨最可靠。 rclist=$(mktemp) || pfail "無法建立暫存檔" rc_files_existing > "$rclist" while IFS= read -r rc; do [ -n "$rc" ] || continue grep -q '^# jsc-hooks' "$rc" 2>/dev/null || continue backup_file "$rc" || pfail "無法備份 $rc,沒有備份就不移除" remove_rc_blocks "$rc" || pfail "無法從 $rc 移除 jsc-hooks 標記段落" grep -q '^# jsc-hooks' "$rc" 2>/dev/null && pfail "$rc 移除後仍有 jsc-hooks 標記段落" done < "$rclist" rm -f "$rclist" if [ -f "$agents" ] && has_block "$agents" ""; then backup_file "$agents" || pfail "無法備份 $agents,沒有備份就不移除" remove_block "$agents" "" "" \ || pfail "無法從 $agents 移除 jsc-hooks 標記段落" has_block "$agents" "" && pfail "$agents 移除後仍讀得到 jsc-hooks 標記段落" fi # 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=$(copilot_instructions) cp_settings=$(copilot_settings) rclist=$(mktemp) || pfail "無法建立暫存檔" rc_files_existing > "$rclist" while IFS= read -r rc; do [ -n "$rc" ] || continue has_block "$rc" "# jsc-hooks:copilot" || continue backup_file "$rc" || pfail "無法備份 $rc,沒有備份就不移除" remove_block "$rc" "# jsc-hooks:copilot" "# /jsc-hooks:copilot" \ || pfail "無法從 $rc 移除 jsc-hooks:copilot 段落" has_block "$rc" "# jsc-hooks:copilot" && pfail "$rc 移除後仍有 jsc-hooks:copilot 段落" done < "$rclist" rm -f "$rclist" if [ -f "$instr" ] && has_block "$instr" ""; then backup_file "$instr" || pfail "無法備份 $instr,沒有備份就不移除" remove_block "$instr" "" "" \ || pfail "無法從 $instr 移除 jsc-hooks 標記段落" has_block "$instr" "" && pfail "$instr 移除後仍讀得到 jsc-hooks 標記段落" fi # 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) bin=$(cli_bin antigravity) command -v "$bin" >/dev/null 2>&1 || skip "未偵測到 antigravity(agy)執行檔" rules="${JSC_ANTIGRAVITY_RULES:-$HOME/.antigravity/AGENTS.md}" rclist=$(mktemp) || pfail "無法建立暫存檔" rc_files_existing > "$rclist" while IFS= read -r rc; do [ -n "$rc" ] || continue has_block "$rc" "# jsc-hooks:antigravity" || continue backup_file "$rc" || pfail "無法備份 $rc,沒有備份就不移除" remove_block "$rc" "# jsc-hooks:antigravity" "# /jsc-hooks:antigravity" \ || pfail "無法從 $rc 移除 jsc-hooks:antigravity 段落" has_block "$rc" "# jsc-hooks:antigravity" && pfail "$rc 移除後仍有 jsc-hooks:antigravity 段落" done < "$rclist" rm -f "$rclist" if [ -f "$rules" ] && has_block "$rules" ""; then backup_file "$rules" || pfail "無法備份 $rules,沒有備份就不移除" remove_block "$rules" "" "" \ || pfail "無法從 $rules 移除 jsc-hooks 標記段落" has_block "$rules" "" && pfail "$rules 移除後仍讀得到 jsc-hooks 標記段落" fi # 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) command -v "$(cli_bin kiro)" >/dev/null 2>&1 || skip "未偵測到 kiro-cli 執行檔" hookdir="./.kiro/hooks" if [ -d "$hookdir" ]; then for f in "$hookdir"/*; do [ -f "$f" ] || continue backup_file "$f" || pfail "無法備份 $f,沒有備份就不移除" rm -f "$f" 2>/dev/null || pfail "無法刪除 $f" done left=$(find "$hookdir" -maxdepth 1 -type f 2>/dev/null | wc -l | tr -d ' ') [ "$left" = 0 ] || pfail "$hookdir 底下還有 $left 個 hook 檔沒刪掉" fi # 真正生效的位置是 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 if [ "$action" = smoke ]; then smoke_out=$(mktemp) || { printf 'status=failed reason=%s\n' "無法建立暫存檔"; exit 4; } smoke_fails=0 # 每一類實際跑過的結果行數。收尾時與下面宣告的預期條數逐類比對,加減判定路徑卻忘了改預期 # 就會當場失敗,散文與程式之間不會再各記一份數字。 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、 # lang-guard.sh 與 write-guard.sh 各有多個模式,所以比 hook 支數多 # 模型與階段鎖 sdlc-gate.sh 取模型代號的四條來源判定路徑,加上「不知道能力就擋下」的 # 三種情形、check 的 fail-closed,以及階段鎖狀態檔的 CLI 區隔與舊格式相容 # 工作包 sdlc-gate.sh wp-check skill 的歸屬判定路徑 # 重啟閘門 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=12 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 不加引號展開:子命令是固定字面字,空字串時要展成「沒有參數」而不是空參數。 smoke_one() { _h="$1"; _s="${2:-}" # 技能名一律清空:冒煙要驗的是「沒有技能情境時腳本跑得完」。留著繼承來的 JSC_SKILL, # sdlc-gate.sh wp-check skill 會拿它當真實呼叫判定,有未結清 PR 時就誤報成執行期錯誤。 # JSC_CHANGED_FILE 同理清空:留著繼承來的檔名,comment-scope.sh 與 lang-guard.sh 會真的 # 去掃那個檔,掃到違規就 exit 2,冒煙測試變成看環境臉色,測不出腳本本身跑不跑得完。 _out=$(printf '{}' | JSC_CLI="$cli" JSC_SKILL="" SKILL="" JSC_CHANGED_FILE="" \ sh "$HOOKS/$_h" $_s 2>&1); _rc=$? smoke_n_hook=$((smoke_n_hook + 1)) if [ "$_rc" -eq 0 ]; then printf '[jsc] %s%s:exit 0,正常。\n' "$_h" "${_s:+ $_s}" >> "$smoke_out" elif [ "$_h" = sdlc-gate.sh ] && [ "$_s" = check ] && [ "$_rc" -eq 2 ]; then # 唯一放行的非零退出:sdlc-gate.sh check 的 exit 2 是刻意設計的階段鎖阻擋 # (見 hooks/lib.sh 開頭)——鎖存在且模型不符時就該擋下該輪提示。那是 hook 正常 # 工作,不是執行期錯誤;把它算成錯誤會讓每個正在上鎖的工作階段都誤報一次失敗。 printf '[jsc] %s check:exit 2,SDLC 階段鎖擋下該輪提示,屬設計行為,不算錯誤。\n' "$_h" >> "$smoke_out" elif [ "$_h" = comment-scope.sh ] && [ "$_rc" -eq 2 ]; then # 同一類放行:comment-scope.sh 掃描模式的 exit 2 是「掃到違規註解」的設計行為。 # 無參數模式冒煙時取不到檔名,正常會走 exit 0;sweep 則看工作區乾不乾淨——工作區剛好 # 有違規註解就回 2。那是 hook 正常工作,不是 hook 壞掉,不能因此判定接線失敗。 printf '[jsc] %s%s:exit 2,掃到違規註解並發出警告,屬設計行為,不算錯誤。\n' "$_h" "${_s:+ $_s}" >> "$smoke_out" elif [ "$_h" = lang-guard.sh ] && [ "$_rc" -eq 2 ]; then # 沿用同一條例外:lang-guard.sh 掃描模式的 exit 2 是「掃到簡體字、亂碼或編碼問題」的 # 設計行為。sweep 一樣看工作區乾不乾淨,髒工作區本來就會回 2,不是 hook 壞掉。 printf '[jsc] %s%s:exit 2,掃到簡體字或亂碼並發出警告,屬設計行為,不算錯誤。\n' "$_h" "${_s:+ $_s}" >> "$smoke_out" elif [ "$_h" = write-guard.sh ] && [ "$_rc" -eq 2 ]; then # 同一條例外:write-guard.sh 的 exit 2 是「擋下這次寫入或提交」的設計行為。這一輪用的是 # 真正的 $JSC_HOME,機器上剛好鎖在 plan 階段、或最近一次呼叫的是稽核技能時本來就會回 2, # 不是 hook 壞掉。三種模式的判定結果由下面自備狀態檔的那一段逐條驗。 printf '[jsc] %s%s:exit 2,擋下這次寫入或提交,屬設計行為,不算錯誤。\n' "$_h" "${_s:+ $_s}" >> "$smoke_out" else smoke_fails=$((smoke_fails + 1)) printf '[jsc] %s%s:exit %s,執行期出錯:%s\n' "$_h" "${_s:+ $_s}" "$_rc" \ "$(printf '%s' "$_out" | tr '\n' ' ' | cut -c1-200)" >> "$smoke_out" fi } smoke_one session-timer.sh mark smoke_one sdlc-gate.sh check smoke_one version-guard.sh smoke_one restart-gate.sh smoke_one skill-usage.sh smoke_one ste100-guard.sh # sdlc-gate.sh 有兩個 hook 模式,接在不同事件上,兩個都要驗:wp-check prompt 一律 exit 0, # wp-check skill 在取不到技能名時放行(上面已清空技能名),所以兩者都不需要白名單例外。 smoke_one sdlc-gate.sh "wp-check prompt" smoke_one sdlc-gate.sh "wp-check skill" # 第四個參數是這個案例要用的 CLI 代號,不跟著這一輪接線的 $cli 走:模型偵測鏈已經依 CLI # 分流,transcript 只有 claude 讀得到、codex session 記錄只有 codex 讀得到。拿 $cli 去跑 # 每一個案例,換一支 CLI 冒煙就會有一半的案例本來就不該命中,測的東西跟著變。 smoke_model_case() { # $1=情境 $2=來源模式 $3=預期來源片段 $4=案例用的 CLI 代號 _name="$1"; _mode="$2"; _want="$3"; _mcli="$4" # 先計數再開工:底下建不出暫存目錄那條路徑也會印一條結果行,計數放在後面就會漏掉它。 smoke_n_model=$((smoke_n_model + 1)) _home=$(mktemp -d 2>/dev/null) || { smoke_fails=$((smoke_fails + 1)) printf '[jsc] sdlc-gate.sh model:建不出暫存目錄,模型來源判定沒驗到。\n' >> "$smoke_out" return } mkdir -p "$_home/sessions" "$_home/codex/sessions/2026/08/28" 2>/dev/null printf 'stage\tplan\treasoning-max\nmodel\tgpt-5.x\treasoning-max,reasoning-high,coding\nmodel\tgpt-5.x-mini\tcoding,fast,cheap\n' > "$_home/model-tags.tsv" _tp="$_home/transcript.jsonl" _cf="$_home/codex/sessions/2026/08/28/rollout-2026-08-28T00-00-00-smoke-model.jsonl" printf '{"type":"assistant","model":"gpt-5.x"}\n' > "$_tp" printf '{"type":"turn_context","payload":{"model":"gpt-5.x"}}\n' > "$_cf" case "$_mode" in transcript) _json='{"transcript_path":"'"$_tp"'","session_id":"smoke-model"}'; _env="" ;; codex) _json='{"session_id":"smoke-model"}'; _env="" ;; stdin) _json='{"model":"gpt-5.x","session_id":"smoke-model-stdin"}'; _env="" ;; override) _json='{"session_id":"smoke-model-override"}'; _env="JSC_MODEL=gpt-5.x" ;; esac if [ "$_mode" = override ]; then _out=$(printf '%s' "$_json" | JSC_HOME="$_home" CODEX_HOME="$_home/codex" JSC_CLI="$_mcli" JSC_MODEL="gpt-5.x" sh "$HOOKS/sdlc-gate.sh" lock plan 2>&1); _rc=$? else _out=$(printf '%s' "$_json" | JSC_HOME="$_home" CODEX_HOME="$_home/codex" JSC_CLI="$_mcli" sh "$HOOKS/sdlc-gate.sh" lock plan 2>&1); _rc=$? fi _rep=$(JSC_HOME="$_home" CODEX_HOME="$_home/codex" JSC_CLI="$_mcli" \ sh "$HOOKS/sdlc-gate.sh" report /dev/null) if [ "$_rc" -eq 0 ] && printf '%s\n%s' "$_out" "$_rep" | grep -qF "$_want"; then printf '[jsc] sdlc-gate.sh model(%s):exit 0,來源含 %s,與預期相同。\n' "$_name" "$_want" >> "$smoke_out" else smoke_fails=$((smoke_fails + 1)) printf '[jsc] sdlc-gate.sh model(%s):exit %s,來源未命中 %s,輸出:%s\n' \ "$_name" "$_rc" "$_want" "$(printf '%s %s' "$_out" "$_rep" | tr '\n' ' ' | cut -c1-200)" >> "$smoke_out" fi rm -rf "$_home" } smoke_model_case "transcript 實查" transcript "transcript:" claude smoke_model_case "Codex session 記錄" codex "codex-session:" codex smoke_model_case "stdin model 欄位" stdin "hook-stdin" claude # 人工覆寫對每一支 CLI 都保留,所以這一條照這一輪接線的 CLI 跑。 smoke_model_case "JSC_MODEL 人工覆寫" override "人工覆寫:JSC_MODEL" "$cli" # 「不知道能力就擋下」的三種情形。判定結果與訊息一起驗:只比結束碼的話,訊息漏掉逃生門 # 一樣是綠燈,而被擋住的人就真的出不來了——那才是這道 fail-closed 真正的風險。 smoke_model_deny_case() { # $1=情境 $2=案例用的 CLI 代號 $3=JSC_MODEL 值 $4=預期訊息片段 smoke_n_model=$((smoke_n_model + 1)) _home=$(mktemp -d 2>/dev/null) || { smoke_fails=$((smoke_fails + 1)) printf '[jsc] sdlc-gate.sh model(%s):建不出暫存目錄,擋人判定沒驗到。\n' "$1" >> "$smoke_out" return } mkdir -p "$_home/sessions" "$_home/codex/sessions/2026/08/28" 2>/dev/null printf 'stage\tplan\treasoning-max\nmodel\tgpt-5.x\treasoning-max,reasoning-high,coding\n' > "$_home/model-tags.tsv" # codex 的 session 記錄照樣擺著:claude 情境要驗的正是「明明有這一份,也不去讀」。 printf '{"type":"turn_context","payload":{"model":"gpt-5.x"}}\n' \ > "$_home/codex/sessions/2026/08/28/rollout-2026-08-28T00-00-00-smoke-deny.jsonl" # claude 的三個環境特徵一併清空:JSC_CLI 是空字串時 cli_name() 會退去看它們,只要有一個 # 留著繼承來的值,「判不出 CLI」那一條就會被測成 claude。三個要一起清,漏一個等於沒清。 _out=$(printf '{"session_id":"smoke-model-deny"}' \ | env JSC_HOME="$_home" CODEX_HOME="$_home/codex" JSC_CLI="$2" \ CLAUDE_PLUGIN_ROOT="" CLAUDECODE="" CLAUDE_CODE_SESSION_ID="" \ JSC_MODEL="$3" sh "$HOOKS/sdlc-gate.sh" lock plan 2>&1); _rc=$? if [ "$_rc" -eq 1 ] && printf '%s' "$_out" | grep -qF "$4" \ && printf '%s' "$_out" | grep -qF 'JSC_MODEL={模型 id}'; then printf '[jsc] sdlc-gate.sh model(%s):exit 1,擋下且訊息帶得出逃生門,與預期相同。\n' "$1" >> "$smoke_out" else smoke_fails=$((smoke_fails + 1)) printf '[jsc] sdlc-gate.sh model(%s):exit %s,預期 exit 1 且訊息含「%s」與逃生門,能力閘門壞了:%s\n' \ "$1" "$_rc" "$4" "$(printf '%s' "$_out" | tr '\n' ' ' | cut -c1-200)" >> "$smoke_out" fi rm -rf "$_home" } smoke_model_deny_case "claude 情境不去讀 codex 紀錄" claude "" "判定不出目前實際使用的模型" smoke_model_deny_case "判不出是哪一支 CLI" "" "" "判不出是哪一支 CLI" smoke_model_deny_case "模型不在能力標籤表上" claude "mystery-model-not-listed" "不在能力標籤表上" # check 的 fail-closed:階段鎖在、判不出模型,要 exit 2 擋下該輪提示並帶出逃生門。 # 這一條原本是「只提醒不擋」,改成擋下之後逃生門就是唯一的出路,所以訊息一起驗。 smoke_n_model=$((smoke_n_model + 1)) if ck_home=$(mktemp -d 2>/dev/null) && mkdir -p "$ck_home/sessions" 2>/dev/null; then printf 'stage\tplan\treasoning-max\nmodel\tgpt-5.x\treasoning-max,coding\n' > "$ck_home/model-tags.tsv" printf 'plan\treasoning-max\tgpt-5.x\tsmoke\n' > "$ck_home/sessions/claude-smoke-check.stage" ck_out=$(printf '{"session_id":"smoke-check"}' \ | env JSC_HOME="$ck_home" JSC_CLI=claude CLAUDE_PLUGIN_ROOT="" JSC_MODEL="" \ JSC_SESSION_ID=smoke-check sh "$HOOKS/sdlc-gate.sh" check 2>&1); ck_rc=$? if [ "$ck_rc" -eq 2 ] && printf '%s' "$ck_out" | grep -qF 'JSC_MODEL={模型 id}' \ && printf '%s' "$ck_out" | grep -qF 'unlock'; then printf '[jsc] sdlc-gate.sh check(判不出模型時擋下):exit 2,兩條逃生門都印出來,與預期相同。\n' >> "$smoke_out" else smoke_fails=$((smoke_fails + 1)) printf '[jsc] sdlc-gate.sh check(判不出模型時擋下):exit %s,預期 exit 2 且訊息含 JSC_MODEL 與 unlock 兩條逃生門,輸出:%s\n' \ "$ck_rc" "$(printf '%s' "$ck_out" | tr '\n' ' ' | cut -c1-200)" >> "$smoke_out" fi rm -rf "$ck_home" else smoke_fails=$((smoke_fails + 1)) printf '[jsc] sdlc-gate.sh check(判不出模型時擋下):建不出暫存目錄,沒驗到。\n' >> "$smoke_out" fi # 階段鎖狀態檔的 CLI 區隔:兩支 CLI 各上一次鎖,兩份狀態檔要分得開,解鎖也只解自己那一份。 # session id 判不出時會退回 default,不分 CLI 就會共用同一支檔案,一支上的鎖擋到另一支。 smoke_st_case() { # $1=情境 $2=條件成立就通過(已求值的結果字串 ok 或其他) smoke_n_model=$((smoke_n_model + 1)) if [ "$2" = ok ]; then printf '[jsc] sdlc-gate.sh 階段鎖(%s):與預期相同。\n' "$1" >> "$smoke_out" else smoke_fails=$((smoke_fails + 1)) printf '[jsc] sdlc-gate.sh 階段鎖(%s):不如預期,狀態檔的 CLI 區隔壞了。\n' "$1" >> "$smoke_out" fi } if st_home=$(mktemp -d 2>/dev/null) && mkdir -p "$st_home/sessions" 2>/dev/null; then printf 'stage\tplan\treasoning-max\nstage\timplement\tcoding\nmodel\tgpt-5.x\treasoning-max,coding\n' \ > "$st_home/model-tags.tsv" # 兩支各上一次鎖,而且刻意上不同的階段:檔名分開之外,內容也要各記各的。 # 不寫成迴圈是為了不動到腳本的位置參數——這一段跑在函式外,set -- 會蓋掉整支腳本的 $@。 env JSC_HOME="$st_home" JSC_CLI=claude CLAUDE_PLUGIN_ROOT="" JSC_MODEL=gpt-5.x \ JSC_SESSION_ID=smoke-stage sh "$HOOKS/sdlc-gate.sh" lock plan /dev/null 2>&1 env JSC_HOME="$st_home" JSC_CLI=codex CLAUDE_PLUGIN_ROOT="" JSC_MODEL=gpt-5.x \ JSC_SESSION_ID=smoke-stage sh "$HOOKS/sdlc-gate.sh" lock implement /dev/null 2>&1 st_a="$st_home/sessions/claude-smoke-stage.stage" st_b="$st_home/sessions/codex-smoke-stage.stage" st_r=bad; { [ -f "$st_a" ] && [ -f "$st_b" ]; } && st_r=ok smoke_st_case "兩支 CLI 各寫出自己那一份" "$st_r" st_r=bad [ "$(cut -f1 "$st_a" 2>/dev/null)" = plan ] \ && [ "$(cut -f1 "$st_b" 2>/dev/null)" = implement ] && st_r=ok smoke_st_case "兩份互不覆蓋,各自記自己的階段" "$st_r" env JSC_HOME="$st_home" JSC_CLI=claude CLAUDE_PLUGIN_ROOT="" \ JSC_SESSION_ID=smoke-stage sh "$HOOKS/sdlc-gate.sh" unlock /dev/null 2>&1 st_r=bad; { [ ! -f "$st_a" ] && [ -f "$st_b" ]; } && st_r=ok smoke_st_case "解鎖只解自己那一份" "$st_r" # 舊格式 sessions/{sid}.stage 的往後相容:新路徑沒有時要讀得到它,unlock 也要清得掉。 rm -f "$st_b" printf 'plan\treasoning-max\tgpt-5.x\tsmoke\n' > "$st_home/sessions/smoke-legacy.stage" st_out=$(env JSC_HOME="$st_home" JSC_CLI=claude CLAUDE_PLUGIN_ROOT="" JSC_MODEL=gpt-5.x \ JSC_SESSION_ID=smoke-legacy sh "$HOOKS/sdlc-gate.sh" report /dev/null) st_r=bad; printf '%s' "$st_out" | grep -qF 'smoke-legacy plan reasoning-max' && st_r=ok smoke_st_case "舊格式狀態檔仍讀得到" "$st_r" rm -rf "$st_home" else smoke_fails=$((smoke_fails + 4)) printf '[jsc] sdlc-gate.sh 階段鎖:建不出暫存目錄,狀態檔的 CLI 區隔沒驗到。\n' >> "$smoke_out" printf '[jsc] sdlc-gate.sh 階段鎖:同上,兩份互不覆蓋沒驗到。\n' >> "$smoke_out" printf '[jsc] sdlc-gate.sh 階段鎖:同上,解鎖範圍沒驗到。\n' >> "$smoke_out" printf '[jsc] sdlc-gate.sh 階段鎖:同上,舊格式相容沒驗到。\n' >> "$smoke_out" smoke_n_model=$((smoke_n_model + 4)) fi # 工作包歸屬判定(wp-check 的第二段):上面兩支清空技能名,只走得到「沒有未結清 PR」與 # 「取不到技能名」兩條捷徑,歸屬比對整段都沒跑到。這裡自備一份暫時的 $JSC_HOME 狀態檔, # 把每條判定路徑各跑一次,驗的是判定結果本身,不只是腳本跑得完。用暫時目錄是為了不動到 # 使用者真正的 $JSC_HOME/wp/——冒煙測試不該在別人的鎖檔上留下痕跡。 # 一律 &1); _rc=$? smoke_n_wp=$((smoke_n_wp + 1)) if [ "$_rc" -eq "$3" ]; then printf '[jsc] sdlc-gate.sh wp-check skill(%s):exit %s,與預期相同。\n' "$1" "$_rc" >> "$smoke_out" else smoke_fails=$((smoke_fails + 1)) printf '[jsc] sdlc-gate.sh wp-check skill(%s):exit %s,預期 %s,歸屬判定壞了:%s\n' \ "$1" "$_rc" "$3" "$(printf '%s' "$_out" | tr '\n' ' ' | cut -c1-200)" >> "$smoke_out" fi } if wp_home=$(mktemp -d 2>/dev/null) && mkdir -p "$wp_home/wp" 2>/dev/null; then smoke_wp_case "狀態檔不存在" plan 0 printf 'repo=jsc/smoke\nwp=WP-03\npr=12\n' > "$wp_home/wp/jsc-smoke.claim" printf 'repo=jsc/smoke\nindex=12\nwp=WP-03\n' > "$wp_home/wp/jsc-smoke-12.pr" smoke_wp_case "PR 屬於領取中的工作包,技能 implement" implement 0 printf 'repo=jsc/smoke\nindex=9\nwp=WP-01\n' > "$wp_home/wp/jsc-smoke-9.pr" smoke_wp_case "PR 屬於別的工作包,技能 analyze" analyze 2 # plan 只提醒不擋,理由見 sdlc-gate.sh 檔頭「plan 已從擋人名單移出」。 smoke_wp_case "PR 屬於別的工作包,技能 plan" plan 0 smoke_wp_case "PR 屬於別的工作包,技能 implement" implement 0 smoke_wp_case "逃生門 JSC_WP_GATE=off" plan 0 off rm -rf "$wp_home" else smoke_fails=$((smoke_fails + 1)) printf '[jsc] sdlc-gate.sh wp-check skill:建不出暫存目錄,工作包歸屬判定沒驗到。\n' >> "$smoke_out" fi # 部署後重啟閘門(restart-gate.sh):上面那支只走得到「狀態檔不存在」與「取不到技能名」, # 擋人那一條完全沒跑到。這裡同樣自備一份暫時的 $JSC_HOME,把每條判定路徑各跑一次並比對 # 結束碼,另外驗三件狀態檔設計本身的事:別支 CLI 那一份不影響這一支、清除只刪自己那一份、 # 舊格式的單一狀態檔照樣擋得下來。用暫時目錄是為了不動到使用者真正的 # $JSC_HOME/restart-required.d/——冒煙測試不該把別人的閘門拆掉。 # 一律 &1); _rc=$? smoke_n_rs=$((smoke_n_rs + 1)) if smoke_deny_ok "$3" "$_rc" "$_out"; then printf '[jsc] restart-gate.sh(%s):exit %s,與預期相同。\n' "$1" "$_rc" >> "$smoke_out" else smoke_fails=$((smoke_fails + 1)) printf '[jsc] restart-gate.sh(%s):exit %s,預期 %s,重啟閘門判定壞了:%s\n' \ "$1" "$_rc" "$(smoke_deny_desc "$3")" \ "$(printf '%s' "$_out" | tr '\n' ' ' | cut -c1-200)" >> "$smoke_out" fi } # 狀態檔在不在也要比:一支 CLI 一份的重點就在「該留的留、該刪的刪」,只看結束碼看不出來。 smoke_rs_file() { # $1=情境 $2=狀態檔 $3=exist 或 absent smoke_n_rs=$((smoke_n_rs + 1)) if { [ "$3" = exist ] && [ -f "$2" ]; } || { [ "$3" = absent ] && [ ! -f "$2" ]; }; then printf '[jsc] restart-gate.sh(%s):狀態檔 %s,與預期相同。\n' "$1" "$3" >> "$smoke_out" else smoke_fails=$((smoke_fails + 1)) printf '[jsc] restart-gate.sh(%s):狀態檔預期 %s,實際不是,一支 CLI 一份的範圍壞了:%s\n' \ "$1" "$3" "$2" >> "$smoke_out" fi } if rs_home=$(mktemp -d 2>/dev/null); then rs_dir="$rs_home/restart-required.d" # 別支 CLI 的代號。取一個不在五支之列的固定值,才不會跟這一輪的 $cli 撞在一起。 rs_other=smoke-other smoke_rs_case "當前 CLI 沒有狀態檔" jsc-sdlc:implement 0 JSC_HOME="$rs_home" JSC_CLI="$rs_other" \ sh "$HOOKS/restart-gate.sh" require update hooks /dev/null smoke_rs_case "只有別支 CLI 有狀態檔" jsc-sdlc:implement 0 JSC_HOME="$rs_home" JSC_CLI="$cli" \ sh "$HOOKS/restart-gate.sh" require update hooks cli /dev/null smoke_rs_file "require 寫出當前 CLI 那一份" "$rs_dir/$cli" exist smoke_rs_case "當前 CLI 那份存在,技能 jsc-sdlc:implement" jsc-sdlc:implement deny smoke_rs_case "當前 CLI 那份存在,豁免技能 jsc-cli:deploy" jsc-cli:deploy 0 smoke_rs_case "當前 CLI 那份存在,豁免技能 jsc-gitea:wiki" jsc-gitea:wiki 0 smoke_rs_case "當前 CLI 那份存在,豁免技能 jsc-log:worklog" jsc-log:worklog 0 smoke_rs_case "當前 CLI 那份存在,豁免技能 jsc-meta:skill-check" jsc-meta:skill-check 0 smoke_rs_case "逃生門 JSC_RESTART_GATE=off" jsc-sdlc:implement 0 off smoke_rs_case "取不到技能名" "" 0 # 清除機制:session-timer.sh 判定為新工作階段時會呼叫 restart-gate.sh clear。 # 這裡走的就是那條路徑(暫時 $JSC_HOME 底下沒有起始檔,等同行程新起的第一次)。 JSC_HOME="$rs_home" JSC_CLI="$cli" JSC_SESSION_ID=smoke-restart \ sh "$HOOKS/session-timer.sh" start /dev/null smoke_rs_file "新工作階段開始後清掉自己那一份" "$rs_dir/$cli" absent smoke_rs_file "清除不動別支 CLI 那一份" "$rs_dir/$rs_other" exist smoke_rs_case "清除後放行" jsc-sdlc:implement 0 # 舊格式的單一狀態檔(過渡相容):沒有 per-CLI 資訊,所以一律擋,clear 一併刪掉。 printf 'at=%s\nmode=update\ndomains=hooks\ncli=%s\n' "$(now_iso)" "$rs_other" \ > "$rs_home/restart-required" 2>/dev/null smoke_rs_case "舊格式單一狀態檔存在" jsc-sdlc:implement deny JSC_HOME="$rs_home" JSC_CLI="$cli" JSC_SESSION_ID=smoke-restart-legacy \ sh "$HOOKS/session-timer.sh" restart /dev/null smoke_rs_file "清除一併刪掉舊格式狀態檔" "$rs_home/restart-required" absent smoke_rs_case "舊格式狀態檔清除後放行" jsc-sdlc:implement 0 rm -rf "$rs_home" else smoke_fails=$((smoke_fails + 1)) printf '[jsc] restart-gate.sh:建不出暫存目錄,部署後重啟閘門判定沒驗到。\n' >> "$smoke_out" fi # comment-scope.sh 有三個接在不同事件的模式,三個都要驗:prompt 一律 exit 0, # 無參數模式在取不到檔名時安靜 exit 0(上面已清空 JSC_CHANGED_FILE,stdin 也只有 {}), # sweep 掃目前工作目錄所在的 git 工作區——乾淨或非 git 目錄回 0,有違規註解回 2, # 後者由上面的白名單放行(見 smoke_one)。 smoke_one comment-scope.sh prompt smoke_one comment-scope.sh smoke_one comment-scope.sh sweep # lang-guard.sh 同樣有三個接在不同事件的模式,三個都要驗,判定理由與 comment-scope.sh 相同: # prompt 一律 exit 0,無參數模式取不到檔名時安靜 exit 0,sweep 在髒工作區回 2 由白名單放行。 smoke_one lang-guard.sh prompt smoke_one lang-guard.sh smoke_one lang-guard.sh sweep # write-guard.sh 三種模式接在兩個 matcher 上,三個都要驗。這一輪用真正的 $JSC_HOME,只確認 # 腳本跑得完;擋下時的 exit 2 由上面的白名單放行,判定結果本身在下一段用暫時狀態檔逐條驗。 smoke_one write-guard.sh stage smoke_one write-guard.sh review smoke_one write-guard.sh commit # 寫入與提交閘門(write-guard.sh):上面三支只證明跑得完,三種模式的判定路徑一條都沒走到。 # 這裡自備一份暫時的 $JSC_HOME 與暫時的指令字串,把每條路徑各跑一次並比對結束碼。用暫時目錄 # 是為了不動到使用者真正的階段鎖與技能紀錄——冒煙測試不該把別人的階段鎖讀成擋人的理由。 # 一律 &1); _rc=$? smoke_n_wg=$((smoke_n_wg + 1)) if [ "$_rc" -eq "$3" ]; then printf '[jsc] write-guard.sh %s(%s):exit %s,與預期相同。\n' "$2" "$1" "$_rc" >> "$smoke_out" else smoke_fails=$((smoke_fails + 1)) printf '[jsc] write-guard.sh %s(%s):exit %s,預期 %s,寫入閘門判定壞了:%s\n' \ "$2" "$1" "$_rc" "$3" "$(printf '%s' "$_out" | tr '\n' ' ' | cut -c1-200)" >> "$smoke_out" fi } if wg_home=$(mktemp -d 2>/dev/null) && mkdir -p "$wg_home/sessions" 2>/dev/null; then wg_state="$wg_home/sessions/smoke-write.stage" wg_last="$wg_home/sessions/smoke-write.lastskill" # stage:階段鎖狀態檔的格式沿用 sdlc-gate.sh 那一份,這裡照樣寫三欄。 # 檔名刻意用舊格式的 sessions/{sid}.stage,順帶把 write-guard 這一側的往後相容也蓋到: # 新路徑不存在時要退回舊路徑讀得出來,否則既有的鎖會在換路徑那一刻整批失效。 smoke_wg_case "沒有階段鎖" stage 0 printf 'plan\treasoning-max\tsmoke\n' > "$wg_state" smoke_wg_case "階段鎖 plan" stage 2 printf 'analyze\treasoning-max\tsmoke\n' > "$wg_state" smoke_wg_case "階段鎖 analyze" stage 2 printf 'implement\tcoding\tsmoke\n' > "$wg_state" smoke_wg_case "階段鎖 implement" stage 0 printf 'plan\treasoning-max\tsmoke\n' > "$wg_state" smoke_wg_case "逃生門 JSC_WRITE_GUARD=off" stage 0 "" "" JSC_WRITE_GUARD=off rm -f "$wg_state" # review:技能名先看環境變數,取不到才讀 skill-usage.sh 記下的那一份,所以兩條來源都要驗。 smoke_wg_case "沒有技能紀錄" review 0 smoke_wg_case "jsc-review:code-review 執行中" review 2 jsc-review:code-review smoke_wg_case "jsc-review:api-doc 執行中" review 2 jsc-review:api-doc smoke_wg_case "jsc-review:comment-cleanup 本來就要寫檔" review 0 jsc-review:comment-cleanup smoke_wg_case "其他技能 jsc-sdlc:implement" review 0 jsc-sdlc:implement printf 'jsc-review:code-review' > "$wg_last" smoke_wg_case "技能紀錄讀自 skill-usage.sh 那一份" review 2 smoke_wg_case "技能紀錄已過期" review 0 "" "" JSC_WRITE_GUARD_TTL=0 # release:稽核技能收尾時自己按的解除鍵。驗它自己跑得完、清完之後 review 真的不再擋、 # 以及紀錄本來就不在時照樣算成功——收尾呼叫可能被重跑,第二次失敗只會讓呼叫端誤判。 smoke_wg_case "release 清掉技能紀錄" release 0 smoke_wg_case "release 之後 review 不再擋" review 0 smoke_wg_case "release 冪等,紀錄不存在也算成功" release 0 rm -f "$wg_last" # commit:指令改由 JSC_TOOL_COMMAND 餵,不必為了測試去拼一份 stdin JSON。 smoke_wg_case "不是 git 指令" commit 0 "" "ls -al" smoke_wg_case "git add -A 後接單次提交" commit 2 "" "git add -A && git commit -m 修正" smoke_wg_case "只有 git add -A,沒有提交" commit 0 "" "git add -A" # 測試用的簡體字以八進位位元組組出來,不在原始碼裡留簡體字面:留了的話 lang-guard.sh # 每次掃到這一行都會命中自己的測試資料,訊號會被自己的噪音蓋掉。 wg_bad=$(printf '\345\244\215\351\227\256') smoke_wg_case "提交訊息含簡體字" commit 2 "" "git commit -m \"$wg_bad\"" smoke_wg_case "提交訊息為繁體中文" commit 0 "" "git commit -m \"修正問題\"" smoke_wg_case "逃生門 JSC_WRITE_GUARD=off" commit 0 "" "git add -A && git commit -m x" JSC_WRITE_GUARD=off rm -rf "$wg_home" else smoke_fails=$((smoke_fails + 1)) printf '[jsc] write-guard.sh:建不出暫存目錄,寫入與提交閘門判定沒驗到。\n' >> "$smoke_out" fi # 相依版本檢查(version-guard.sh 的第二種擋人情況):上面那支只驗「hook 跑得完」, # manifest 的 jsc.requires 一條判定路徑都沒走到。這裡自備一份暫時的 HOME,把假的 # installed_plugins.json 與各 plugin 的 manifest 都放進去,逐條跑判定並比對結束碼。 # 覆寫 HOME 是必要的:註冊檔路徑由 $HOME 決定,不覆寫就會讀到使用者真正的安裝清單, # 冒煙結果變成看那台機器裝了哪幾版的臉色,也會在別人的安裝目錄上留下痕跡。 # GITEA_HOST 一律清空:放行的案例會繼續往下走到遠端比對,站台推導得出來就會真的連網, # 冒煙不該依賴網路,也不該讓遠端落後把預期放行的案例判成擋下。 # 一律 &1); _rc=$? smoke_n_vg=$((smoke_n_vg + 1)) if smoke_deny_ok "$3" "$_rc" "$vg_out"; then printf '[jsc] version-guard.sh(%s):exit %s,與預期相同。\n' "$1" "$_rc" >> "$smoke_out" else smoke_fails=$((smoke_fails + 1)) printf '[jsc] version-guard.sh(%s):exit %s,預期 %s,相依版本判定壞了:%s\n' \ "$1" "$_rc" "$(smoke_deny_desc "$3")" \ "$(printf '%s' "$vg_out" | tr '\n' ' ' | cut -c1-200)" >> "$smoke_out" fi } # 訊息內容也要比:擋下來卻沒指名是哪一個相依落後,使用者看不出要更新哪一個 plugin, # 只看結束碼看不出這件事。比對的是上一個 smoke_vg_case 留下的那一次輸出。 smoke_vg_msg() { # $1=情境 $2=預期字串 smoke_n_vg=$((smoke_n_vg + 1)) if printf '%s' "$vg_out" | grep -qF "$2"; then printf '[jsc] version-guard.sh(%s):訊息含「%s」,與預期相同。\n' "$1" "$2" >> "$smoke_out" else smoke_fails=$((smoke_fails + 1)) printf '[jsc] version-guard.sh(%s):訊息未含「%s」,落後的相依沒被指名:%s\n' \ "$1" "$2" "$(printf '%s' "$vg_out" | tr '\n' ' ' | cut -c1-200)" >> "$smoke_out" fi } if vg_home=$(mktemp -d 2>/dev/null) \ && mkdir -p "$vg_home/.claude/plugins" "$vg_home/p/sdlc" "$vg_home/p/cli" \ "$vg_home/p/hooks" "$vg_home/p/git" "$vg_home/p/pkg" 2>/dev/null; then vg_reg="$vg_home/.claude/plugins/installed_plugins.json" vg_manifest="$vg_home/p/sdlc/plugin.json" # 假的註冊檔:一個 domain 一列,閘門從這裡取 installPath。jsc-cli 那一列的 version 欄位 # 故意寫成一個很新的版本,而它 installPath 底下的 manifest 寫 0.1.0——閘門只認後者, # 拿註冊欄位當備援會放過真正被載入的舊版,這份資料就是把那條規則一起釘住。 printf '{\n "jsc-sdlc@jsc": [{"name": "jsc-sdlc", "installPath": "%s/p/sdlc", "version": "0.1.0"}],\n "jsc-cli@jsc": [{"name": "jsc-cli", "installPath": "%s/p/cli", "version": "9.9.9"}],\n "jsc-hooks@jsc": [{"name": "jsc-hooks", "installPath": "%s/p/hooks", "version": "0.1.0"}],\n "jsc-git@jsc": [{"name": "jsc-git", "installPath": "%s/p/git", "version": "0.1.0"}],\n "jsc-pkg@jsc": [{"name": "jsc-pkg", "installPath": "%s/p/pkg", "version": "0.1.0"}]\n}\n' \ "$vg_home" "$vg_home" "$vg_home" "$vg_home" "$vg_home" > "$vg_reg" # jsc-cli 自己也宣告一個落後的相依:豁免那一條要證明的是「豁免技能在相依落後的狀態下 # 照樣放行」,它所屬的 plugin 沒有落後的相依就什麼都證不到。 printf '{"name": "jsc-cli", "version": "0.1.0", "jsc": {"requires": {"jsc-hooks": ">=9.9.9"}}}\n' \ > "$vg_home/p/cli/plugin.json" printf '{"name": "jsc-hooks", "version": "0.1.0"}\n' > "$vg_home/p/hooks/plugin.json" # 沒有 jsc.requires 的 manifest,留給 fail-open 那一條用。 printf '{"name": "jsc-git", "version": "0.1.0"}\n' > "$vg_home/p/git/plugin.json" # p/pkg 刻意只有目錄、沒有 plugin.json,那是「讀不到 manifest」那一條的材料。 # 一、相依確定落後:擋下,而且訊息要指名是哪一項、需要哪一版、目前哪一版。 printf '{"name": "jsc-sdlc", "version": "0.1.0", "jsc": {"requires": {"jsc-cli": ">=9.9.9"}}}\n' \ > "$vg_manifest" smoke_vg_case "相依落後" jsc-sdlc:implement deny smoke_vg_msg "相依落後時訊息指名那一項" "jsc-cli 需要 >=9.9.9,目前 0.1.0" # 二、相依相等或超前都放行:只擋落後,本機超前是開發技能組時的常態,擋了維護者自己動不了。 printf '{"name": "jsc-sdlc", "version": "0.1.0", "jsc": {"requires": {"jsc-cli": ">=0.1.0"}}}\n' \ > "$vg_manifest" smoke_vg_case "相依版本相等" jsc-sdlc:implement 0 printf '{"name": "jsc-sdlc", "version": "0.1.0", "jsc": {"requires": {"jsc-cli": ">=0.0.9"}}}\n' \ > "$vg_manifest" smoke_vg_case "相依版本超前" jsc-sdlc:implement 0 # 三、豁免技能:這一條最要緊,它證明閘門不會把更新與修復的唯一路徑鎖死。同一個 jsc-cli # plugin 底下,非豁免的技能在相依落後時被擋、豁免的那一支照樣放行,差別只在豁免清單。 smoke_vg_case "豁免技能 jsc-cli:deploy,相依落後照樣放行" jsc-cli:deploy 0 smoke_vg_case "同 plugin 的非豁免技能 jsc-cli:doctor" jsc-cli:doctor deny # 四、fail-open 四條:沒有證據不等於落後。五支 CLI 只有 claude 讀得到本機載入版本, # 這四條任何一條改成擋人,另外四支就會被整批鎖死。 smoke_vg_case "fail-open:解不出安裝路徑" jsc-log:worklog 0 smoke_vg_case "fail-open:讀不到 manifest" jsc-pkg:pkg-update 0 smoke_vg_case "fail-open:manifest 沒有 jsc.requires" jsc-git:commit 0 printf '{"name": "jsc-sdlc", "version": "0.1.0", "jsc": {"requires": {"jsc-review": ">=9.9.9"}}}\n' \ > "$vg_manifest" smoke_vg_case "fail-open:讀不到相依 plugin 的本機載入版本" jsc-sdlc:implement 0 # 五、逃生門蓋過相依落後:離線工作時要留一條自己解得開的路。 printf '{"name": "jsc-sdlc", "version": "0.1.0", "jsc": {"requires": {"jsc-cli": ">=9.9.9"}}}\n' \ > "$vg_manifest" smoke_vg_case "逃生門 JSC_VERSION_GUARD=off 蓋過相依落後" jsc-sdlc:implement 0 JSC_VERSION_GUARD=off # 回歸:真實 manifest 是多行縮排的,jsc.requires 的最後一個鍵一樣要解得到。解析先把整份 # JSON 併成一行,那條串流結尾沒有換行時,read 會把最後一筆的值讀進去卻回非零,while 迴圈 # 的本體一次都跑不到,落後的相依就靜靜被漏掉。這裡把落後的那一項刻意擺在最後一個鍵, # 前面那一項則是滿足的:漏掉最後一筆就會變成放行,當場被這一條抓出來。 printf '{\n "name": "jsc-sdlc",\n "version": "0.1.0",\n "jsc": {\n "requires": {\n "jsc-hooks": ">=0.0.1",\n "jsc-cli": ">=9.9.9"\n }\n }\n}\n' \ > "$vg_manifest" smoke_vg_case "多行縮排 manifest,落後的相依擺在最後一個鍵" jsc-sdlc:implement deny smoke_vg_msg "最後一個鍵的落後項也列進訊息" "jsc-cli 需要 >=9.9.9,目前 0.1.0" rm -rf "$vg_home" else smoke_fails=$((smoke_fails + 1)) 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_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);" [ "$smoke_n_model" = "$SMOKE_EXPECT_MODEL" ] \ || smoke_mismatch="${smoke_mismatch}模型與階段鎖 $smoke_n_model 條(預期 $SMOKE_EXPECT_MODEL);" [ "$smoke_n_wp" = "$SMOKE_EXPECT_WP" ] \ || smoke_mismatch="${smoke_mismatch}工作包 $smoke_n_wp 條(預期 $SMOKE_EXPECT_WP);" [ "$smoke_n_rs" = "$SMOKE_EXPECT_RS" ] \ || smoke_mismatch="${smoke_mismatch}重啟閘門 $smoke_n_rs 條(預期 $SMOKE_EXPECT_RS);" [ "$smoke_n_wg" = "$SMOKE_EXPECT_WG" ] \ || 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_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" cat "$smoke_out" rm -f "$smoke_out" echo "[jsc] 請先以 tools/report-error.sh 回報,再交給 /jsc-hooks:repair 自動修正並開 develop PR。" >&2 exit 4 fi if [ "$smoke_fails" -eq 0 ]; then 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 printf 'status=failed reason=%s\n' "$smoke_fails 條判定有執行期錯誤" printf 'lines\t%s\n' "$smoke_lines" cat "$smoke_out" rm -f "$smoke_out" echo "[jsc] 請先以 tools/report-error.sh 回報,再交給 /jsc-hooks:repair 自動修正並開 develop PR。" >&2 exit 4 fi if [ "$action" = status ]; then # 唯讀盤點:只讀設定檔判斷標記段落在不在,不寫檔,也不執行任何 hook。 # 檔案位置與標記字串一律沿用底下接線區塊的同一份值,兩邊必須一起改。 command -v "$(cli_bin "$cli")" >/dev/null 2>&1 || skip "未偵測到 $(cli_bin "$cli") 執行檔" 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|unverified # # 三種狀態,不是兩種。unverified 是「這一項驗不了」,跟「驗不過」分開記:只有 missing 算缺項。 # 少了這一格,驗不了的項目只能在 present 與 missing 之間二選一——報 present 會讓沒驗到的東西 # 看起來像通過,報 missing 會把環境問題(沒登入、憑證過期)算成接線失敗,兩邊都在說謊。 st_item() { printf 'item\t%s\t%s\t%s\n' "$1" "$2" "$3" >> "$st_items" [ "$3" = missing ] && st_missing=$((st_missing + 1)) return 0 } # 檔案存在且帶有該標記才算接上。$1=項目名 $2=檔案 $3=開頭標記行 st_block() { if [ -f "$2" ] && has_block "$2" "$3"; then st_item "$1" "$2" present else st_item "$1" "$2" missing; fi } # 註解範圍規則寫進規則檔了嗎。與 STE100 共用同一個標記段落,所以要單獨比對內容: # 標記在、內容卻是舊版只有 STE100 的那一份時,這一項才看得出來缺了。$1=項目名 $2=檔案 st_comment_scope() { if [ -f "$2" ] && has_comment_scope "$2"; then st_item "$1" "$2" present else st_item "$1" "$2" missing; fi } # 繁中與編碼規則寫進規則檔了嗎。同樣與 STE100 共用標記段落,所以要單獨比對內容: # 標記在、內容卻是舊版沒有這一段時,這一項才看得出來缺了。$1=項目名 $2=檔案 st_lang_guard() { if [ -f "$2" ] && has_lang_guard "$2"; then st_item "$1" "$2" present else st_item "$1" "$2" missing; fi } # 別名段落只要任何一個既有 rc 檔帶有標記就算接上。$1=項目名 $2=標記名 st_rc_alias() { for _rc in "$HOME/.bashrc" "$HOME/.zshrc" "$HOME/.config/fish/config.fish"; do [ -f "$_rc" ] || continue if has_block "$_rc" "# $2"; then st_item "$1" "$_rc" present; return 0; fi done st_item "$1" "$HOME/.bashrc" missing } st_runtime_paths() { _item="$1" _file="$2" [ -f "$_file" ] || { st_item "$_item" "$_file" missing; return 0; } _scan="$_file" _tmp="" if [ -n "${3:-}" ]; then _tmp=$(mktemp) || { st_item "$_item" "$_file" missing; return 0; } awk -v bopen="$3" -v bshut="${4:-}" ' $0==bopen { take=1; print; next } bshut != "" && $0==bshut { print; take=0; next } take { print } ' "$_file" > "$_tmp" _scan="$_tmp" fi _bad="" _paths=$(grep -Eo "/[^\"' ;]+/(hooks|tools)/[^\"' ;]+\\.sh" "$_scan" 2>/dev/null || true) for _p in $_paths; do if bad_runtime_path "$_p"; then _bad="$_p" break fi done [ -n "$_tmp" ] && rm -f "$_tmp" [ -z "$_bad" ] && st_item "$_item" "$_file" present || st_item "$_item" "$_bad" missing } case "$cli" in claude) claude_root=$(claude_loaded_root || true) claude_hooks="$claude_root/hooks/hooks.json" if [ -n "$claude_root" ] && [ -f "$claude_hooks" ]; then st_item hooks.json "$claude_hooks" present else st_item hooks.json "${claude_hooks:-$HOME/.claude/plugins/installed_plugins.json}" missing; fi # 九支 hook 全靠這一個檔宣告,只看檔案在不在會漏掉「檔在、某支沒接進去」。 # 後來才加進來的 comment-scope.sh、lang-guard.sh、restart-gate.sh 與 write-guard.sh # 是最可能漏的四支,所以各列一項。 if [ -f "$claude_hooks" ] && grep -qF 'comment-scope.sh' "$claude_hooks" 2>/dev/null then st_item comment-scope "$claude_hooks" present else st_item comment-scope "$claude_hooks" missing; fi if [ -f "$claude_hooks" ] && grep -qF 'lang-guard.sh' "$claude_hooks" 2>/dev/null then st_item lang-guard "$claude_hooks" present else st_item lang-guard "$claude_hooks" missing; fi if [ -f "$claude_hooks" ] && grep -qF 'restart-gate.sh' "$claude_hooks" 2>/dev/null then st_item restart-gate "$claude_hooks" present else st_item restart-gate "$claude_hooks" missing; fi # write-guard.sh 接在兩個 matcher 上,三種模式各自是一件事,只驗腳本名會漏掉少接的那一個 for _m in stage review commit; do if [ -f "$claude_hooks" ] && grep -qF "write-guard.sh\\\" $_m" "$claude_hooks" 2>/dev/null then st_item "write-guard-$_m" "$claude_hooks" present else st_item "write-guard-$_m" "$claude_hooks" missing; fi done ;; codex) config="${CODEX_HOME:-$HOME/.codex}/config.toml" st_block notify "$config" "# jsc-hooks" # 標記在、鍵卻被歸進某張表時 codex 讀不到 notify,等同沒接,所以位置要單獨算一項 if [ -f "$config" ] && toml_root_key "$config" notify; then st_item notify-root "$config" present else st_item notify-root "$config" missing fi # notify 接上了,不代表 sweep 也串進那一行:舊版接線只有計時,掃描是後來才加的 if [ -f "$config" ] && grep -qF 'comment-scope.sh' "$config" 2>/dev/null && grep -qF 'sweep' "$config" 2>/dev/null; then st_item notify-sweep "$config" present else st_item notify-sweep "$config" missing fi # 兩支 sweep 各算一項:只驗其中一支會讓「舊版只接了註解範圍」看起來像接線完整 if [ -f "$config" ] && grep -qF 'lang-guard.sh' "$config" 2>/dev/null && grep -qF 'sweep' "$config" 2>/dev/null; then st_item notify-lang-sweep "$config" present else st_item notify-lang-sweep "$config" missing fi st_rc_alias alias jsc-hooks:codex st_block ste100 "${CODEX_HOME:-$HOME/.codex}/AGENTS.md" "" st_comment_scope comment-scope "${CODEX_HOME:-$HOME/.codex}/AGENTS.md" st_lang_guard lang-guard "${CODEX_HOME:-$HOME/.codex}/AGENTS.md" st_runtime_paths runtime-paths "$config" "# jsc-hooks" "# /jsc-hooks" st_runtime_paths alias-paths "$HOME/.bashrc" "# jsc-hooks:codex" "# /jsc-hooks:codex" _codex_plugin_hooks=$(codex_plugin_hooks_json || true) if codex_plugin_hooks_safe; then st_item plugin-user-prompt-root "${_codex_plugin_hooks:-${CODEX_HOME:-$HOME/.codex}/plugins/cache/jsc/jsc-hooks}" present else st_item plugin-user-prompt-root "$_codex_plugin_hooks" missing fi # 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 "$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" # 位置本身就是一個檢查點:上一版把設定寫進 $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" 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) 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 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 printf 'status=unwired reason=%s\n' "$st_missing 個接線項目缺漏,執行 /jsc-hooks:hooks-install 重新接線" cat "$st_items"; rm -f "$st_items"; exit 5 fi if [ -n "$st_degrade" ]; 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' "$st_wired" cat "$st_items"; rm -f "$st_items"; exit 0 fi ensure_stable_root verify_wire_root case "$cli" in claude) bin=$(cli_bin claude) command -v "$bin" >/dev/null 2>&1 || skip "未偵測到 claude 執行檔" [ -f "$HOOKS/hooks.json" ] || fail "找不到 $HOOKS/hooks.json,claude 接不到任何 hook" grep -qF 'comment-scope.sh' "$HOOKS/hooks.json" 2>/dev/null \ || fail "$HOOKS/hooks.json 沒有接上 comment-scope.sh,註解範圍檢查不會生效" grep -qF 'lang-guard.sh' "$HOOKS/hooks.json" 2>/dev/null \ || fail "$HOOKS/hooks.json 沒有接上 lang-guard.sh,繁中與編碼檢查不會生效" grep -qF 'restart-gate.sh' "$HOOKS/hooks.json" 2>/dev/null \ || fail "$HOOKS/hooks.json 沒有接上 restart-gate.sh,部署後重啟閘門不會生效" for m in stage review commit; do grep -qF "write-guard.sh\\\" $m" "$HOOKS/hooks.json" 2>/dev/null \ || fail "$HOOKS/hooks.json 沒有接上 write-guard.sh $m,這個模式不會生效" done printf 'status=wired reason=%s\n' "hooks.json 自動接線" echo "$WIRE_PATH_NOTE" echo "[jsc] claude:由 hooks/hooks.json 自動接線全部九支 hook,無需寫入設定。" echo "[jsc] claude:只有 claude 有 pre-tool hook,版本前置檢查、部署後重啟閘門與 write-guard.sh 的三種模式只在這裡擋得下來;其他四個 CLI 這幾道閘門都接不上。" echo "[jsc] claude:只有 claude 有 post-tool hook,comment-scope.sh 與 lang-guard.sh 的逐檔即時掃描只在這裡接得上;其他四個 CLI 改用 sweep 掃整個工作區,時機晚一輪或晚到工作階段結束。" exit 0 ;; codex) bin=$(cli_bin codex) command -v "$bin" >/dev/null 2>&1 || skip "未偵測到 codex 執行檔" CODEX_HOME="${CODEX_HOME:-$HOME/.codex}" config="$CODEX_HOME/config.toml" agents="$CODEX_HOME/AGENTS.md" # codex 的 notify 只在每一輪結束時觸發,沒有工作階段開始事件,所以計時分兩段接: # 1. shell 別名走 jsc-wrap.sh:啟動當下就 session-timer start,並給這次工作階段 # 一個 JSC_SESSION_ID,codex 內觸發的 notify 會沿用同一個 id。 # 2. notify 每輪先補 start 再 mark。start 已有紀錄就不動,所以沒走別名啟動時 # 仍拿得到起始時間(從第一輪算起)。少了這一段,worklog 只會拿到 0 秒。 # notify 最後再掛 comment-scope.sh sweep:codex 沒有 post-tool hook,拿不到「剛剛寫了 # 哪個檔」,只能改掃整個 git 工作區的 diff。每輪結束掃一次,時機比 claude 晚,那一輪 # 寫過的檔一個都不會漏。 timer="sh '$WIRE_HOOKS/session-timer.sh'" scope="sh '$WIRE_HOOKS/comment-scope.sh'" lang="sh '$WIRE_HOOKS/lang-guard.sh'" notify_line="notify = [\"env\", \"JSC_CLI=codex\", \"sh\", \"-c\", \"$timer start /dev/null \ || fail "$config 的 notify 沒有接到 comment-scope.sh sweep,codex 每輪結束不會掃註解範圍" grep -qF "$lang sweep" "$config" 2>/dev/null \ || fail "$config 的 notify 沒有接到 lang-guard.sh sweep,codex 每輪結束不會掃簡體字與亂碼" write_alias_rc "jsc-hooks:codex" "$alias_line" || fail "無法把 $bin 別名寫進 shell rc 檔" replace_block "$agents" "" "" "$(rules_text)" \ || fail "無法寫入 $agents" has_block "$agents" "" || fail "$agents 寫入後讀不到 jsc-hooks 標記段落" has_comment_scope "$agents" || fail "$agents 寫入後讀不到註解範圍規則" has_lang_guard "$agents" || fail "$agents 寫入後讀不到繁中與編碼規則" # 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:write-guard.sh 的階段寫入、稽核寫入與提交檢查三種模式還沒接線,那三條規則在這裡目前只剩 SKILL.md 的散文。" echo "[jsc] codex:註解範圍與繁中編碼除了規則提示,每輪結束會由 notify 各掃一次整個 git 工作區(codex 沒有 post-tool hook,接不到逐檔即時掃描),回饋比 claude 晚一輪。" exit 0 ;; copilot) bin=$(cli_bin copilot) command -v "$bin" >/dev/null 2>&1 || skip "未偵測到 copilot 執行檔" 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)" \ || fail "無法寫入 $instr" has_block "$instr" "" || fail "$instr 寫入後讀不到 jsc-hooks 標記段落" has_comment_scope "$instr" || fail "$instr 寫入後讀不到註解範圍規則" has_lang_guard "$instr" || fail "$instr 寫入後讀不到繁中與編碼規則" # 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 降級)。指引檔一定要在 \$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:write-guard.sh 的階段寫入、稽核寫入與提交檢查三種模式還沒接線,那三條規則在這裡目前只剩 SKILL.md 的散文。" echo "[jsc] copilot:註解範圍與繁中編碼除了規則提示,工作階段結束時由 jsc-wrap.sh 收尾各掃一次整個 git 工作區(copilot 連逐輪事件都沒有),回饋要等到離開 CLI 才看得到。" exit 0 ;; antigravity) bin=$(cli_bin antigravity) command -v "$bin" >/dev/null 2>&1 || skip "未偵測到 antigravity(agy)執行檔" rules="${JSC_ANTIGRAVITY_RULES:-$HOME/.antigravity/AGENTS.md}" alias_line="alias $bin='sh \"$WIRE_TOOLS/jsc-wrap.sh\" antigravity'" write_alias_rc "jsc-hooks:antigravity" "$alias_line" || fail "無法把 $bin 別名寫進 shell rc 檔" replace_block "$rules" "" "" "$(rules_text)" \ || fail "無法寫入 $rules" has_block "$rules" "" || fail "$rules 寫入後讀不到 jsc-hooks 標記段落" has_comment_scope "$rules" || fail "$rules 寫入後讀不到註解範圍規則" has_lang_guard "$rules" || fail "$rules 寫入後讀不到繁中與編碼規則" # 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:write-guard.sh 的階段寫入、稽核寫入與提交檢查三種模式還沒接線,那三條規則在這裡目前只剩 SKILL.md 的散文。" echo "[jsc] antigravity:註解範圍與繁中編碼除了規則提示,工作階段結束時由 jsc-wrap.sh 收尾各掃一次整個 git 工作區(antigravity 連逐輪事件都沒有),回饋要等到離開 CLI 才看得到。" exit 0 ;; kiro) command -v "$(cli_bin kiro)" >/dev/null 2>&1 || skip "未偵測到 kiro-cli 執行檔" 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 # 這個 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:已建立 $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