Files
hooks/tools/wire-cli.sh
T
jiantw83 1d86359767 fix(接線盤點): status 列舉每一個接線點,不再只挑四支
README 早就寫明「每個接線點印一行 item」,程式卻只列 comment-scope、
lang-guard、restart-gate 與 write-guard 三種模式,共六項。sdlc-gate 的三個
接線點、session-timer 的兩個、skill-usage 與 version-guard 都沒列出來。

漏列的後果不是少幾行字。reason 那行寫的是「全部九支 hook」,拿這份輸出
驗收接線的人會把沒列到的當成沒接——模型能力鎖就是這樣被誤判成沒接線的。
體檢技能也讀這支的輸出,同樣看不到那四支。

一支腳本接在多個接線點時,每個點各自列一項。只驗腳本名的話,「腳本在、
某個接線點沒接」會被算成完整接線,那正是最難查的一種。
2026-09-02 14:57:04 +08:00

2793 lines
173 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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<TAB>{數量}` 那一行印的是其後 `[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 <<JSCEOF
{
"matcher": "skill",
"type": "command",
"bash": "JSC_CLI=copilot sh \\"$WIRE_HOOKS/restart-gate.sh\\"",
"timeoutSec": 10
},
{
"matcher": "skill",
"type": "command",
"bash": "JSC_CLI=copilot sh \\"$WIRE_HOOKS/version-guard.sh\\"",
"timeoutSec": 10
}
JSCEOF
}
# 把 jsc 的條目併進 settings.json 的 hooks 鍵,或(purge 時)只把 jsc 的條目拿掉。
# $1=檔案 $2=wire 或 purge $3=新條目檔(wire 時必填),結果印到標準輸出。
#
# 為什麼要自己寫這一段,不整份重寫那個鍵:那份檔案同時裝著 enabledPlugins(十個 jsc plugin 的
# 啟用狀態)與 extraKnownMarketplaces,弄壞會讓外掛整批失效。所以只換 hooks 那個值的字元區間,
# 區間以外一個位元組都不動;hooks 裡面也只挑掉 jsc 自己那幾筆,別人的事件與條目原樣保留。
#
# 為什麼不用 jq:目標機器不保證有。這裡用 awk 逐字元追引號與括號深度,跟本檔其他 JSON 動作
# 同一套機制——字串裡的大括號不算深度,漏算就會把整份設定切壞。
# 認 jsc 條目是看內容有沒有我們的腳本名或 jsc-hooks 路徑,寫死在 awk 裡;用 -v 傳樣式會被
# awk 解掉跳脫序列,那個坑本檔踩過一次了。
copilot_merge_hooks() { # $1=檔案 $2=模式 $3=新條目檔
awk -v mode="$2" -v newfile="${3:-}" '
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_ws(s, i, n) {
while (i <= n && substr(s, i, 1) ~ /[ \t\r\n]/) i++
return i
}
function skip_value(s, i, n, c, d) {
i = skip_ws(s, i, n)
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
}
# 這一筆是不是 jsc 自己寫的。別人的條目一律留著。
function isjsc(t) {
return (index(t, "jsc-hooks") > 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 <<JSCEOF
"jsc": {
"PreToolUse": [
{
"matcher": "^view_file\$",
"hooks": [
{
"type": "command",
"command": "JSC_CLI=antigravity sh \\"$WIRE_HOOKS/restart-gate.sh\\"",
"timeout": 10
},
{
"type": "command",
"command": "JSC_CLI=antigravity sh \\"$WIRE_HOOKS/version-guard.sh\\"",
"timeout": 10
}
]
}
],
"PreInvocation": [
{
"type": "command",
"command": "JSC_CLI=antigravity sh \\"$WIRE_HOOKS/restart-gate.sh\\"",
"timeout": 10
},
{
"type": "command",
"command": "JSC_CLI=antigravity sh \\"$WIRE_HOOKS/version-guard.sh\\"",
"timeout": 10
}
]
}
JSCEOF
}
# antigravity 的 PreToolUse 必須是 Grouped 形狀(matcher 加 hooks 包一層)。
# 這一項要單獨驗,不能只驗「檔案裡有 PreToolUse」:形狀錯的時候那個事件被靜默丟棄,
# 鍵在、JSON 合法、標記段落也在,只看有沒有那個鍵一律回 present,錯就這樣漏過去。
# $1=檔案
antigravity_grouped_ok() {
[ -f "$1" ] || return 1
awk '
/"PreToolUse"[[:space:]]*:/ { inpre = 1; next }
/"PreInvocation"[[:space:]]*:/ { inpre = 0 }
inpre && /"hooks"[[:space:]]*:[[:space:]]*\[/ { grouped = 1 }
inpre && /"matcher"[[:space:]]*:/ { matcher = 1 }
END { exit((grouped && matcher) ? 0 : 1) }
' "$1" 2>/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 <<JSCEOF
{
"name": "jsc",
"description": "jsc skill set agent (auto-generated by jsc-hooks:hooks-install, do not edit by hand)",
"tools": ["*"],
"resources": [
"skill://.kiro/skills/*/SKILL.md",
"skill://.kiro/skills/*/*/SKILL.md"
],
"hooks": {
"agentSpawn": [
{ "command": "JSC_CLI=kiro sh \\"$WIRE_HOOKS/session-timer.sh\\" restart </dev/null", "timeout_ms": 10000 }
],
"userPromptSubmit": [
{ "command": "JSC_CLI=kiro sh \\"$WIRE_HOOKS/restart-gate.sh\\"", "timeout_ms": 10000 },
{ "command": "JSC_CLI=kiro sh \\"$WIRE_HOOKS/version-guard.sh\\"", "timeout_ms": 10000 },
{ "command": "JSC_CLI=kiro sh \\"$WIRE_HOOKS/ste100-guard.sh\\" </dev/null", "timeout_ms": 10000 },
{ "command": "JSC_CLI=kiro sh \\"$WIRE_HOOKS/comment-scope.sh\\" prompt </dev/null", "timeout_ms": 10000 },
{ "command": "JSC_CLI=kiro sh \\"$WIRE_HOOKS/comment-scope.sh\\" sweep </dev/null", "timeout_ms": 10000 },
{ "command": "JSC_CLI=kiro sh \\"$WIRE_HOOKS/lang-guard.sh\\" prompt </dev/null", "timeout_ms": 10000 },
{ "command": "JSC_CLI=kiro sh \\"$WIRE_HOOKS/lang-guard.sh\\" sweep </dev/null", "timeout_ms": 10000 },
{ "command": "JSC_CLI=kiro sh \\"$WIRE_HOOKS/session-timer.sh\\" mark </dev/null", "timeout_ms": 10000 }
],
"stop": [
{ "command": "JSC_CLI=kiro sh \\"$WIRE_HOOKS/session-timer.sh\\" mark </dev/null", "timeout_ms": 10000 }
]
}
}
JSCEOF
}
# 解析 kiro-cli agent validate 的輸出。$1=那次執行的 stdout 加 stderr。
#
# **這支函式的存在理由就是一句話:validate 一律回結束碼 0。** 合法、事件名非法、hooks 裡放
# on/run、缺 command,四種情況的結束碼全都是 0,錯誤訊息走 stderr。拿結束碼當判準會做出一支
# 永遠通過的檢查——跟我們這一輪在修的那幾個錯是同一類:看起來檢查過了,其實什麼都沒攔。
# 判準改成看輸出:合法時它一個字都不印,印了東西就是不合格。這比比對錯誤字串穩,
# 訊息文案改版不會讓判準跟著失效。
# 判準分三段,不是「有輸出就當不合格」:
# 輸出是空的 通過。合法時它一個字都不印。
# 輸出在講這個檔案 不通過(Json supplied、is invalid、did not match 這類字樣)。
# 輸出在講別的事 **放行**。沒登入、憑證過期都會讓它印一行 error,那是「驗不了」,
# 不是「驗不過」。把環境問題報成接線失敗,沒登入的機器就整批接不了線,
# 護欄又變成故障點——這一條是實測踩出來的:沙箱沒有憑證時它印
# 「You are not logged in」,接線就整支失敗了。
kiro_validate_output_ok() {
_t=$(printf '%s' "${1:-}" | tr -d '[:space:]')
[ -n "$_t" ] || return 0
printf '%s' "$1" | grep -qE 'Json supplied|is invalid|did not match|missing field|unknown field' && return 1
return 0
}
# 這次的 validate 輸出算不算「驗過了」。空輸出才是真的驗過;有輸出但不是在講這個檔案,
# 代表這一項沒驗到,呼叫端要據實說出來,不能讓它看起來像通過。$1=輸出
kiro_validate_ran() {
[ -z "$(printf '%s' "${1:-}" | tr -d '[:space:]')" ]
}
# 拿真的 kiro-cli 驗一次 agent 設定檔。$1=檔案
# 沒有 kiro 執行檔時回 0(沒有意見),不回 1:那是「驗不了」,不是「驗不過」。
kiro_validate_ok() {
[ -f "$1" ] || return 1
_kb=$(cli_bin kiro)
command -v "$_kb" >/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="" # 每行「{備份檔}<TAB>{原檔}」,還原時反向複製回去
# 備份目錄延後建立:沒有檔案要動時不留空目錄。
# 只設全域變數、不印路徑:呼叫端若寫成 $(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" "<!-- jsc-hooks -->"; then
backup_file "$agents" || pfail "無法備份 $agents,沒有備份就不移除"
remove_block "$agents" "<!-- jsc-hooks -->" "<!-- /jsc-hooks -->" \
|| pfail "無法從 $agents 移除 jsc-hooks 標記段落"
has_block "$agents" "<!-- jsc-hooks -->" && 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" "<!-- jsc-hooks -->"; then
backup_file "$instr" || pfail "無法備份 $instr,沒有備份就不移除"
remove_block "$instr" "<!-- jsc-hooks -->" "<!-- /jsc-hooks -->" \
|| pfail "無法從 $instr 移除 jsc-hooks 標記段落"
has_block "$instr" "<!-- jsc-hooks -->" && 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" "<!-- jsc-hooks -->"; then
backup_file "$rules" || pfail "無法備份 $rules,沒有備份就不移除"
remove_block "$rules" "<!-- jsc-hooks -->" "<!-- /jsc-hooks -->" \
|| pfail "無法從 $rules 移除 jsc-hooks 標記段落"
has_block "$rules" "<!-- jsc-hooks -->" && 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 2>/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 >/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 >/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 >/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 2>/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/——冒煙測試不該在別人的鎖檔上留下痕跡。
# 一律 </dev/null:wp-check 會讀標準輸入,管線沒人關閉時整支卡死。
smoke_wp_case() { # $1=情境 $2=技能名 $3=預期結束碼 $4=JSC_WP_GATE 值(可省略)
_out=$(JSC_HOME="$wp_home" JSC_CLI="$cli" JSC_SKILL="$2" SKILL="$2" \
JSC_WP_GATE="${4:-}" sh "$HOOKS/sdlc-gate.sh" wp-check skill </dev/null 2>&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/——冒煙測試不該把別人的閘門拆掉。
# 一律 </dev/null:hook 模式會讀標準輸入,管線沒人關閉時整支卡死。
#
# 「擋下」的形態依 CLI 而定,事實來源是 hooks/deny.sh 的 case:claude、codex、copilot 與認不得
# 的代號走 stderr 加結束碼 2;antigravity 改印一行 stdout 的 deny JSON,kiro 只能注入警告,
# 這兩支的結束碼都固定 0。所以預期擋下不能寫死 2。
# 也不能一律放寬成 0:那兩支上放行也是 0,放寬之後「該擋沒擋」與「正確擋下」完全同形,
# 這道斷言等於作廢。解法是結束碼與擋人標記一起比,而且預期放行的案例要反過來確認標記沒出現,
# 兩個方向都守得住。既有的 smoke_dn_case 與 smoke_cx_case 已經是這個做法,這裡沿用同一套。
smoke_deny_rc() { # 這支 CLI 擋下時的結束碼
case "$cli" in antigravity|kiro) printf 0 ;; *) printf 2 ;; esac
}
smoke_deny_mark() { # 結束碼分不出擋下與放行時,輸出必定出現的擋人標記;空字串代表結束碼已經夠用
case "$cli" in
antigravity) printf '"decision":"deny"' ;;
kiro) printf 'kiro 擋不下技能叫用' ;;
*) printf '' ;;
esac
}
smoke_deny_ok() { # $1=預期(數字,或字面值 deny)$2=實際結束碼 $3=實際輸出;回 0 代表通過
_d_want=$1; _d_rc=$2; _d_out=$3
_d_mark=$(smoke_deny_mark)
if [ "$_d_want" = deny ]; then
[ "$_d_rc" -eq "$(smoke_deny_rc)" ] || return 1
[ -n "$_d_mark" ] || return 0
printf '%s' "$_d_out" | grep -qF "$_d_mark"
return $?
fi
[ "$_d_rc" -eq "$_d_want" ] || return 1
[ -n "$_d_mark" ] || return 0
! printf '%s' "$_d_out" | grep -qF "$_d_mark"
}
smoke_deny_desc() { # $1=預期;把預期講成失敗訊息看得懂的形態
_d_mark=$(smoke_deny_mark)
if [ "$1" = deny ]; then
if [ -n "$_d_mark" ]; then printf 'exit %s 且輸出含「%s」' "$(smoke_deny_rc)" "$_d_mark"
else printf 'exit %s' "$(smoke_deny_rc)"; fi
else
if [ -n "$_d_mark" ]; then printf 'exit %s 且輸出不含「%s」' "$1" "$_d_mark"
else printf 'exit %s' "$1"; fi
fi
}
smoke_rs_case() { # $1=情境 $2=技能名 $3=預期(結束碼,或 deny)$4=JSC_RESTART_GATE 值(可省略)
_out=$(JSC_HOME="$rs_home" JSC_CLI="$cli" JSC_SKILL="$2" SKILL="$2" \
JSC_RESTART_GATE="${4:-}" sh "$HOOKS/restart-gate.sh" </dev/null 2>&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 2>/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 2>/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 2>/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 2>/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 與暫時的指令字串,把每條路徑各跑一次並比對結束碼。用暫時目錄
# 是為了不動到使用者真正的階段鎖與技能紀錄——冒煙測試不該把別人的階段鎖讀成擋人的理由。
# 一律 </dev/null:三種模式都讀標準輸入,管線沒人關閉時整支卡死。
smoke_wg_case() { # $1=情境 $2=模式 $3=預期結束碼 $4=技能名 $5=指令 $6=額外環境設定(KEY=值)
# 第六個參數省略時仍要餵一個合法的 KEY=值 給 env,否則它會把空字串當成要執行的指令。
# 這個名字沒有任何 hook 讀它,只是佔位。
_extra="${6:-JSC_WRITE_GUARD_UNUSED=1}"
_out=$(env JSC_HOME="$wg_home" JSC_CLI="$cli" JSC_SESSION_ID=smoke-write \
JSC_SKILL="${4:-}" SKILL="${4:-}" JSC_TOOL_COMMAND="${5:-}" "$_extra" \
sh "$HOOKS/write-guard.sh" "$2" </dev/null 2>&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 一律清空:放行的案例會繼續往下走到遠端比對,站台推導得出來就會真的連網,
# 冒煙不該依賴網路,也不該讓遠端落後把預期放行的案例判成擋下。
# 一律 </dev/null:hook 模式會讀標準輸入,管線沒人關閉時整支卡死。
smoke_vg_case() { # $1=情境 $2=技能名 $3=預期(結束碼,或 deny)$4=額外環境設定(KEY=值,可省略)
# 第四個參數省略時仍要餵一個合法的 KEY=值 給 env,否則它會把空字串當成要執行的指令。
# 這個名字沒有任何 hook 讀它,只是佔位。
_extra="${4:-JSC_VERSION_GUARD_UNUSED=1}"
# 輸出留在變數裡給下面的訊息斷言比對:擋人訊息與結束碼是同一次執行的兩件事,
# 為了比訊息再跑一次,比到的就可能不是同一次的結果。
vg_out=$(env HOME="$vg_home" JSC_HOME="$vg_home/.jsc" GITEA_HOST="" JSC_CLI="$cli" \
JSC_TOOL_NAME=Skill JSC_SKILL="$2" SKILL="$2" "$_extra" \
sh "$HOOKS/version-guard.sh" </dev/null 2>&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 全靠這一個檔宣告,只看檔案在不在會漏掉「檔在、某支沒接進去」。
# 每一支都列一項,一支都不省。reason 那行講的是「全部九支」,列舉卻只挑幾支的話,
# 拿這份輸出驗收接線的人會把沒列到的當成沒接——模型能力鎖就是這樣被誤判成沒接線的。
for _h in comment-scope lang-guard restart-gate skill-usage version-guard; do
if [ -f "$claude_hooks" ] && grep -qF "$_h.sh" "$claude_hooks" 2>/dev/null
then st_item "$_h" "$claude_hooks" present
else st_item "$_h" "$claude_hooks" missing; fi
done
# 一支腳本接在多個接線點時,每個點各自是一件事,只驗腳本名會漏掉少接的那一個。
# 三支這種腳本的接線點對照(左邊是項目名,右邊是 hooks.json 裡的子命令):
# write-guard stage、review、commit
# sdlc-gate check(階段模型鎖)、wp-check prompt、wp-check skill(工作包歸屬)
# session-timer start、mark
for _p in "write-guard-stage write-guard.sh\\\" stage" \
"write-guard-review write-guard.sh\\\" review" \
"write-guard-commit write-guard.sh\\\" commit" \
"sdlc-gate-check sdlc-gate.sh\\\" check" \
"sdlc-gate-wp-prompt sdlc-gate.sh\\\" wp-check prompt" \
"sdlc-gate-wp-skill sdlc-gate.sh\\\" wp-check skill" \
"session-timer-start session-timer.sh\\\" start" \
"session-timer-mark session-timer.sh\\\" mark"; do
_name=${_p%% *}; _pat=${_p#* }
if [ -f "$claude_hooks" ] && grep -qF "$_pat" "$claude_hooks" 2>/dev/null
then st_item "$_name" "$claude_hooks" present
else st_item "$_name" "$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" "<!-- jsc-hooks -->"
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" "<!-- jsc-hooks -->"
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}" "<!-- jsc-hooks -->"
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; $timer mark </dev/null; $scope sweep </dev/null; $lang sweep </dev/null\"]"
alias_line="alias $bin='sh \"$WIRE_TOOLS/jsc-wrap.sh\" codex'"
replace_block_toml "$config" "# jsc-hooks" "# /jsc-hooks" "$notify_line" \
|| fail "無法寫入 $config"
has_block "$config" "# jsc-hooks" || fail "$config 寫入後讀不到 jsc-hooks 標記段落"
toml_root_key "$config" notify \
|| fail "$config 的 notify 沒有落在根層(被歸進某張表,codex 讀不到,hook 會靜靜失效)"
grep -qF "$scope sweep" "$config" 2>/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" "<!-- jsc-hooks -->" "<!-- /jsc-hooks -->" "$(rules_text)" \
|| fail "無法寫入 $agents"
has_block "$agents" "<!-- jsc-hooks -->" || 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" "<!-- jsc-hooks -->" "<!-- /jsc-hooks -->" "$(rules_text)" \
|| fail "無法寫入 $instr"
has_block "$instr" "<!-- jsc-hooks -->" || 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" "<!-- jsc-hooks -->" "<!-- /jsc-hooks -->" "$(rules_text)" \
|| fail "無法寫入 $rules"
has_block "$rules" "<!-- jsc-hooks -->" || 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