Files
hooks/tools/wire-cli.sh
jiantw83andClaude Opus 5 15f7dcc5fb test(smoke): 接線腳本冒煙補上相依版本檢查的十三條判定斷言
接線腳本的 smoke 多一個沙箱,逐條跑相依版本檢查的判定路徑並比對結束碼:
相依落後的擋人與訊息內容、相依相等與超前的放行、豁免技能在相依落後時
照樣放行、同一個 plugin 底下非豁免技能照樣被擋、四種 fail-open、
逃生門蓋過相依落後,另加一條回歸——多行縮排的 manifest,jsc.requires
的最後一個鍵也要解得到。

原本的 hook 模式只驗那支腳本跑得完,相依版本這一段一條判定路徑都沒走到。
新的擋人情況判錯方向,不是把每一次技能呼叫鎖死,就是整道護欄形同虛設,
沒有斷言就看不出來。多行縮排是真實 manifest 的樣子,解析漏掉最後一個鍵
會讓落後的相依靜靜被放行,那一條非釘住不可。

沙箱自備一份暫時的 HOME,註冊檔路徑由 $HOME 決定,不覆寫就會讀到使用者
真正的安裝清單。假的 installed_plugins.json 與各 plugin 的 manifest 都放進
那份 HOME,註冊欄位故意寫成很新的版本、installPath 底下的 manifest 寫舊版,
把「只認 installPath 底下那份檔案」的規則一起釘住。GITEA_HOST 一律清空,
放行的案例才不會往下走到遠端比對真的連網。擋人的兩條另外比訊息字串,
比的是同一次執行留下的輸出。新增計數器 smoke_n_vg 與預期值
SMOKE_EXPECT_VG,一併納入總數、逐類比對訊息與結果摘要,判定路徑增減時
只改腳本裡的預期值。

所屬功能:版本前置檢查的相依版本閘門。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 13:41:25 +08:00

1558 lines
92 KiB
Bash
Executable File
Raw Permalink 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 的最後一個鍵也要解得到。
#
# smoke 自己數結果行並自我斷言:`lines<TAB>{數量}` 那一行印的是其後 `[jsc]` 結果行的實際條數,
# 與腳本內宣告的預期條數逐類比對,不符就回非零。數字寫在腳本裡、由腳本自己印,散文引用那一行
# 就好,不必在 SKILL.md 或 README 各抄一份——抄了就會在加減判定路徑時漂移。
#
# 接線行為(依 CLI 而定,皆為冪等:重跑只取代既有的 jsc-hooks 標記段落,不會重複疊加):
# claude — 什麼都不用寫,hooks.json 已自動接線九支 hook
# codex — 在 shell rc 檔加上 codex 別名,轉呼叫 tools/jsc-wrap.sh codex(開始計時,
# 結束時收尾掃一次註解範圍與繁中編碼);在 config.toml 設 notify(每輪補
# session-timer.sh start 再 mark,最後 comment-scope.sh sweep 與
# lang-guard.sh sweep 掃整個工作區,JSC_CLI=codex);在 AGENTS.md 附加
# STE100、註解範圍與繁中編碼規則段落(prompt 降級)
# copilot — 在 shell rc 檔加上 copilot 別名,轉呼叫 tools/jsc-wrap.sh copilot(結束時
# 收尾掃一次註解範圍與繁中編碼);在 copilot-instructions.md 附加 STE100、
# 註解範圍與繁中編碼規則段落
# antigravity — 在 shell rc 檔加上 agy 別名,轉呼叫 tools/jsc-wrap.sh antigravity(同樣收尾
# 掃一次);在全域規則檔附加 STE100、註解範圍與繁中編碼規則段落
# kiro — 在工作區 .kiro/hooks/ 下建立 jsc-hooks.json(每輪 mark 加 STE100、
# 註解範圍與繁中編碼規則,再 comment-scope.sh sweep 與 lang-guard.sh sweep
# 掃整個工作區)與 jsc-hooks-session-start.json(sessionStart 開始計時),
# 皆帶 JSC_CLI=kiro
#
# 覆蓋範圍要據實回報,不得暗示每個 CLI 都有保護:
# claude 九支 hook 全接,回報 wired
# codex、copilot、antigravity、kiro 只有別名、notify 或規則檔,接不上 PreToolUse、PostToolUse
# 與 UserPromptSubmit,版本前置檢查、部署後重啟閘門、寫入與
# 提交閘門與 SDLC 模型鎖都沒接上,一律回報 degraded 並在
# reason 講明。
# 重啟閘門在這四個 CLI 上一次技能呼叫都擋不下來:狀態檔照樣
# 寫、下次工作階段開始照樣清,只是中間沒有任何判定點,重啟
# 只能靠 /jsc-cli:deploy 收尾的提示自己動手
#
# 註解範圍與繁中編碼掃描每個 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 必須成對
# 且 on、run 在最上層),內容也要涵蓋這次該接上的每一支腳本(含 comment-scope.sh sweep
# 與 lang-guard.sh sweep)。
# 腳本說寫好了卻寫錯位置或少接一支,是最難查的失敗,所以驗證放在腳本裡。
#
# 輸出: 第一行固定為 `status=... reason=...`(可供程式判讀),其後為人類可讀的繁中說明。
# 結束碼(接線): 0=wired(已完整接線) 1=degraded(降級為 prompt/技能步驟檢查)
# 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 設定時用版本無關的穩定路徑。執行中的腳本仍從自己的 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; }
# 找出已存在的 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 最上層的 hooks 鍵(含後面多餘的逗號),逐字元追蹤引號與括號深度。
# jq 在目標機器上不保證存在,所以要有這條純 awk 的路;只用 sed 刪不了嵌套的 {...}。
# 追蹤引號是必要的:字串裡的 { 與 } 不算深度,漏算就會把整段設定切壞。
# $1=檔案,結果印到標準輸出;解析不出來(括號不成對、字串沒收尾)就 exit 1,不輸出半份檔案。
awk_del_hooks() {
awk '
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 == "hooks" && 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"
}
# 刪掉設定檔最上層的 hooks 鍵:有 jq 就用 jq,沒有就走 awk_del_hooks。$1=檔案
json_del_hooks() {
_f="$1"
[ -f "$_f" ] || return 0
if command -v jq >/dev/null 2>&1; then
jq 'del(.hooks)' "$_f" > "$_f.jsc-tmp" 2>/dev/null || { rm -f "$_f.jsc-tmp"; return 1; }
else
awk_del_hooks "$_f" > "$_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; }
}
# --- 備份:先備份才准移除 ---
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
purged "已移除 config.toml 的標記段落與根層 notify、rc 檔的 jsc-hooks 段落、AGENTS.md 的規則段落"
echo "[jsc] codex:別名要開新的 shell 或重新 source rc 檔才真的失效。"
exit 0 ;;
copilot)
bin=$(cli_bin copilot)
command -v "$bin" >/dev/null 2>&1 || skip "未偵測到 copilot 執行檔"
instr="${JSC_COPILOT_INSTRUCTIONS:-$HOME/.config/copilot/copilot-instructions.md}"
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
purged "已移除 rc 檔的 jsc-hooks:copilot 段落與指引檔的規則段落"
echo "[jsc] copilot:別名要開新的 shell 或重新 source rc 檔才真的失效。"
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
purged "已移除 rc 檔的 jsc-hooks:antigravity 段落與全域規則檔的規則段落"
echo "[jsc] antigravity:別名要開新的 shell 或重新 source rc 檔才真的失效。"
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
purged "已刪除工作區 .kiro/hooks/ 底下所有 hook 檔,含非 jsc 的第三方項目"
echo "[jsc] kiro:hook 檔綁在工作區,這次只清得到目前目錄的 ./.kiro/hooks/,其他工作區要各自跑一次。"
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
# 預期條數(改動判定路徑時一起改):每一類都要有自己的計數器,印得出結果行卻沒人計數的
# 那一類會讓總數永遠對不上,斷言也就形同虛設。
# hook 模式 九支 hook 的每個接線模式各一條。sdlc-gate.sh、comment-scope.sh、
# lang-guard.sh 與 write-guard.sh 各有多個模式,所以比 hook 支數多
# 模型來源 sdlc-gate.sh lock 取模型代號的四條來源判定路徑
# 工作包 sdlc-gate.sh wp-check skill 的歸屬判定路徑
# 重啟閘門 restart-gate.sh 的判定、清除路徑與狀態檔範圍檢查
# 寫入閘門 write-guard.sh 三種擋人模式與 release 解除模式的判定路徑
# 相依版本 version-guard.sh 讀 manifest jsc.requires 的擋人、放行、豁免與 fail-open 路徑
SMOKE_EXPECT_HOOK=17
SMOKE_EXPECT_MODEL=4
SMOKE_EXPECT_WP=6
SMOKE_EXPECT_RS=16
SMOKE_EXPECT_WG=21
SMOKE_EXPECT_VG=13
# 跑一支 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"
smoke_model_case() { # $1=情境 $2=來源模式 $3=預期來源片段
_name="$1"; _mode="$2"; _want="$3"
# 先計數再開工:底下建不出暫存目錄那條路徑也會印一條結果行,計數放在後面就會漏掉它。
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="$cli" 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="$cli" sh "$HOOKS/sdlc-gate.sh" lock plan 2>&1); _rc=$?
fi
_rep=$(JSC_HOME="$_home" CODEX_HOME="$_home/codex" JSC_CLI="$cli" \
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:"
smoke_model_case "Codex session 記錄" codex "codex-session:"
smoke_model_case "stdin model 欄位" stdin "hook-stdin"
smoke_model_case "JSC_MODEL 人工覆寫" override "人工覆寫:JSC_MODEL"
# 工作包歸屬判定(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 模式會讀標準輸入,管線沒人關閉時整支卡死。
smoke_rs_case() { # $1=情境 $2=技能名 $3=預期結束碼 $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 [ "$_rc" -eq "$3" ]; 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" "$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 2
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 2
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 那一份,這裡照樣寫三欄。
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=預期結束碼 $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 [ "$_rc" -eq "$3" ]; 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" "$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 2
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 2
# 四、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 2
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
# 自我斷言:實際跑過的條數對上宣告的預期條數,再對上真正印出來的行數。三邊一致才算數,
# 少跑一條或多印一行都會在這裡現形,散文就不必再自己記一份數字。
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_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_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 條"
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 的每個接線模式都跑得完,模型來源、工作包歸屬、部署後重啟閘門、寫入提交閘門與相依版本檢查的每條路徑也各走過一次($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=""
# 記一個檢查點。$1=項目名 $2=路徑 $3=present|missing
st_item() {
printf 'item\t%s\t%s\t%s\n' "$1" "$2" "$3" >> "$st_items"
[ "$3" = present ] || st_missing=$((st_missing + 1))
}
# 檔案存在且帶有該標記才算接上。$1=項目名 $2=檔案 $3=開頭標記行
st_block() {
if [ -f "$2" ] && has_block "$2" "$3"; then st_item "$1" "$2" present
else st_item "$1" "$2" missing; fi
}
# 註解範圍規則寫進規則檔了嗎。與 STE100 共用同一個標記段落,所以要單獨比對內容:
# 標記在、內容卻是舊版只有 STE100 的那一份時,這一項才看得出來缺了。$1=項目名 $2=檔案
st_comment_scope() {
if [ -f "$2" ] && has_comment_scope "$2"; then st_item "$1" "$2" present
else st_item "$1" "$2" missing; fi
}
# 繁中與編碼規則寫進規則檔了嗎。同樣與 STE100 共用標記段落,所以要單獨比對內容:
# 標記在、內容卻是舊版沒有這一段時,這一項才看得出來缺了。$1=項目名 $2=檔案
st_lang_guard() {
if [ -f "$2" ] && has_lang_guard "$2"; then st_item "$1" "$2" present
else st_item "$1" "$2" missing; fi
}
# 別名段落只要任何一個既有 rc 檔帶有標記就算接上。$1=項目名 $2=標記名
st_rc_alias() {
for _rc in "$HOME/.bashrc" "$HOME/.zshrc" "$HOME/.config/fish/config.fish"; do
[ -f "$_rc" ] || continue
if has_block "$_rc" "# $2"; then st_item "$1" "$_rc" present; return 0; fi
done
st_item "$1" "$HOME/.bashrc" missing
}
st_runtime_paths() {
_item="$1"
_file="$2"
[ -f "$_file" ] || { st_item "$_item" "$_file" missing; return 0; }
_scan="$_file"
_tmp=""
if [ -n "${3:-}" ]; then
_tmp=$(mktemp) || { st_item "$_item" "$_file" missing; return 0; }
awk -v bopen="$3" -v bshut="${4:-}" '
$0==bopen { take=1; print; next }
bshut != "" && $0==bshut { print; take=0; next }
take { print }
' "$_file" > "$_tmp"
_scan="$_tmp"
fi
_bad=""
_paths=$(grep -Eo "/[^\"' ;]+/(hooks|tools)/[^\"' ;]+\\.sh" "$_scan" 2>/dev/null || true)
for _p in $_paths; do
if bad_runtime_path "$_p"; then
_bad="$_p"
break
fi
done
[ -n "$_tmp" ] && rm -f "$_tmp"
[ -z "$_bad" ] && st_item "$_item" "$_file" present || st_item "$_item" "$_bad" missing
}
case "$cli" in
claude)
claude_root=$(claude_loaded_root || true)
claude_hooks="$claude_root/hooks/hooks.json"
if [ -n "$claude_root" ] && [ -f "$claude_hooks" ]; then st_item hooks.json "$claude_hooks" present
else st_item hooks.json "${claude_hooks:-$HOME/.claude/plugins/installed_plugins.json}" missing; fi
# 九支 hook 全靠這一個檔宣告,只看檔案在不在會漏掉「檔在、某支沒接進去」。
# 後來才加進來的 comment-scope.sh、lang-guard.sh、restart-gate.sh 與 write-guard.sh
# 是最可能漏的四支,所以各列一項。
if [ -f "$claude_hooks" ] && grep -qF 'comment-scope.sh' "$claude_hooks" 2>/dev/null
then st_item comment-scope "$claude_hooks" present
else st_item comment-scope "$claude_hooks" missing; fi
if [ -f "$claude_hooks" ] && grep -qF 'lang-guard.sh' "$claude_hooks" 2>/dev/null
then st_item lang-guard "$claude_hooks" present
else st_item lang-guard "$claude_hooks" missing; fi
if [ -f "$claude_hooks" ] && grep -qF 'restart-gate.sh' "$claude_hooks" 2>/dev/null
then st_item restart-gate "$claude_hooks" present
else st_item restart-gate "$claude_hooks" missing; fi
# write-guard.sh 接在兩個 matcher 上,三種模式各自是一件事,只驗腳本名會漏掉少接的那一個
for _m in stage review commit; do
if [ -f "$claude_hooks" ] && grep -qF "write-guard.sh\\\" $_m" "$claude_hooks" 2>/dev/null
then st_item "write-guard-$_m" "$claude_hooks" present
else st_item "write-guard-$_m" "$claude_hooks" missing; fi
done ;;
codex)
config="${CODEX_HOME:-$HOME/.codex}/config.toml"
st_block notify "$config" "# jsc-hooks"
# 標記在、鍵卻被歸進某張表時 codex 讀不到 notify,等同沒接,所以位置要單獨算一項
if [ -f "$config" ] && toml_root_key "$config" notify; then
st_item notify-root "$config" present
else
st_item notify-root "$config" missing
fi
# notify 接上了,不代表 sweep 也串進那一行:舊版接線只有計時,掃描是後來才加的
if [ -f "$config" ] && grep -qF 'comment-scope.sh' "$config" 2>/dev/null &&
grep -qF 'sweep' "$config" 2>/dev/null; then
st_item notify-sweep "$config" present
else
st_item notify-sweep "$config" missing
fi
# 兩支 sweep 各算一項:只驗其中一支會讓「舊版只接了註解範圍」看起來像接線完整
if [ -f "$config" ] && grep -qF 'lang-guard.sh' "$config" 2>/dev/null &&
grep -qF 'sweep' "$config" 2>/dev/null; then
st_item notify-lang-sweep "$config" present
else
st_item notify-lang-sweep "$config" missing
fi
st_rc_alias alias jsc-hooks:codex
st_block ste100 "${CODEX_HOME:-$HOME/.codex}/AGENTS.md" "<!-- 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
st_degrade="STE100 降級為 prompt 檔,SDLC 模型鎖降級為技能步驟檢查,無 pre-tool hook 可接版本前置檢查、部署後重啟閘門與寫入提交閘門,註解範圍與繁中編碼改為每輪結束掃整個工作區,不是逐檔即時" ;;
copilot)
st_rc_alias alias jsc-hooks:copilot
st_block ste100 "${JSC_COPILOT_INSTRUCTIONS:-$HOME/.config/copilot/copilot-instructions.md}" "<!-- jsc-hooks -->"
st_comment_scope comment-scope "${JSC_COPILOT_INSTRUCTIONS:-$HOME/.config/copilot/copilot-instructions.md}"
st_lang_guard lang-guard "${JSC_COPILOT_INSTRUCTIONS:-$HOME/.config/copilot/copilot-instructions.md}"
st_runtime_paths alias-paths "$HOME/.bashrc" "# jsc-hooks:copilot" "# /jsc-hooks:copilot"
st_degrade="STE100 降級為 prompt 檔,SDLC 模型鎖降級為技能步驟檢查,無 pre-tool hook 可接版本前置檢查、部署後重啟閘門與寫入提交閘門,註解範圍與繁中編碼只在工作階段結束時掃一次整個工作區" ;;
antigravity)
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"
st_degrade="STE100 降級為 prompt 檔,SDLC 模型鎖降級為技能步驟檢查,無 pre-tool hook 可接版本前置檢查、部署後重啟閘門與寫入提交閘門,註解範圍與繁中編碼只在工作階段結束時掃一次整個工作區" ;;
kiro)
# kiro 的 hook 檔綁在工作區,這裡看的一律是目前工作目錄底下那一份
for _f in ./.kiro/hooks/jsc-hooks-session-start.json ./.kiro/hooks/jsc-hooks.json; do
if [ -f "$_f" ] && json_top_key "$_f" run; then st_item "$(basename "$_f" .json)" "$_f" present
else st_item "$(basename "$_f" .json)" "$_f" missing; fi
done
# userPromptSubmit 那一筆的 run 串了好幾支腳本,只驗 JSON 讀得懂會漏掉少接的那一支。
# comment-scope.sh 的 prompt 與 sweep 是兩件事,各算一項,才看得出舊版接線少了哪一個。
if [ -f ./.kiro/hooks/jsc-hooks.json ] &&
grep -qF 'comment-scope.sh\" prompt' ./.kiro/hooks/jsc-hooks.json 2>/dev/null
then st_item comment-scope ./.kiro/hooks/jsc-hooks.json present
else st_item comment-scope ./.kiro/hooks/jsc-hooks.json missing; fi
if [ -f ./.kiro/hooks/jsc-hooks.json ] &&
grep -qF 'comment-scope.sh\" sweep' ./.kiro/hooks/jsc-hooks.json 2>/dev/null
then st_item comment-scope-sweep ./.kiro/hooks/jsc-hooks.json present
else st_item comment-scope-sweep ./.kiro/hooks/jsc-hooks.json missing; fi
# lang-guard.sh 的兩個模式同理各算一項
if [ -f ./.kiro/hooks/jsc-hooks.json ] &&
grep -qF 'lang-guard.sh\" prompt' ./.kiro/hooks/jsc-hooks.json 2>/dev/null
then st_item lang-guard ./.kiro/hooks/jsc-hooks.json present
else st_item lang-guard ./.kiro/hooks/jsc-hooks.json missing; fi
if [ -f ./.kiro/hooks/jsc-hooks.json ] &&
grep -qF 'lang-guard.sh\" sweep' ./.kiro/hooks/jsc-hooks.json 2>/dev/null
then st_item lang-guard-sweep ./.kiro/hooks/jsc-hooks.json present
else st_item lang-guard-sweep ./.kiro/hooks/jsc-hooks.json missing; fi
st_runtime_paths session-runtime-paths ./.kiro/hooks/jsc-hooks-session-start.json
st_runtime_paths hook-runtime-paths ./.kiro/hooks/jsc-hooks.json
st_degrade="SDLC 模型鎖降級為技能步驟檢查,無 pre-tool hook 可接版本前置檢查、部署後重啟閘門與寫入提交閘門,註解範圍與繁中編碼改為每輪提示送出時掃整個工作區,不是逐檔即時" ;;
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' "hooks.json 自動接線全部九支 hook"
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 寫入後讀不到繁中與編碼規則"
printf 'status=degraded reason=%s\n' "STE100 降級為 prompt 檔,SDLC 模型鎖降級為技能步驟檢查,無 pre-tool hook 可接版本前置檢查、部署後重啟閘門與寫入提交閘門,註解範圍與繁中編碼改為每輪結束掃整個工作區,不是逐檔即時"
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:SDLC 模型鎖降級為技能步驟檢查,鎖檔仍由 sdlc-gate.sh lock 寫入。"
echo "[jsc] codex:版本前置檢查接不上(codex 沒有 pre-tool hook),改由 /jsc-cli:deploy 定期更新。"
echo "[jsc] codex:部署後重啟閘門也接不上(同樣是沒有 pre-tool hook),一次技能呼叫都擋不下來;狀態檔照樣寫、下次工作階段開始照樣清,重啟要自己動手。"
echo "[jsc] codex:write-guard.sh 的階段寫入、稽核寫入與提交檢查三種模式也接不上(同樣是沒有 pre-tool hook),一次寫入或提交都擋不下來,那三條規則在這裡只剩 SKILL.md 的散文。"
echo "[jsc] codex:註解範圍與繁中編碼除了規則提示,每輪結束會由 notify 各掃一次整個 git 工作區(codex 沒有 post-tool hook,接不到逐檔即時掃描),回饋比 claude 晚一輪。"
exit 1 ;;
copilot)
bin=$(cli_bin copilot)
command -v "$bin" >/dev/null 2>&1 || skip "未偵測到 copilot 執行檔"
instr="${JSC_COPILOT_INSTRUCTIONS:-$HOME/.config/copilot/copilot-instructions.md}"
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 寫入後讀不到繁中與編碼規則"
printf 'status=degraded reason=%s\n' "STE100 降級為 prompt 檔,SDLC 模型鎖降級為技能步驟檢查,無 pre-tool hook 可接版本前置檢查、部署後重啟閘門與寫入提交閘門,註解範圍與繁中編碼只在工作階段結束時掃一次整個工作區"
echo "$WIRE_PATH_NOTE"
echo "[jsc] copilot:已在 shell rc 加上 $bin 別名,轉呼叫 tools/jsc-wrap.sh copilot。"
echo "[jsc] copilot:已在 $instr 寫入 STE100、註解範圍與繁中編碼規則段落(prompt 降級)。"
echo "[jsc] copilot:別名要開新的 shell 或重新 source rc 檔才生效。"
echo "[jsc] copilot:SDLC 模型鎖降級為技能步驟檢查,鎖檔仍由 sdlc-gate.sh lock 寫入。"
echo "[jsc] copilot:版本前置檢查接不上(copilot 沒有 pre-tool hook),改由 /jsc-cli:deploy 定期更新。"
echo "[jsc] copilot:部署後重啟閘門也接不上(同樣是沒有 pre-tool hook),一次技能呼叫都擋不下來;狀態檔照樣寫、下次工作階段開始照樣清,重啟要自己動手。"
echo "[jsc] copilot:write-guard.sh 的階段寫入、稽核寫入與提交檢查三種模式也接不上(同樣是沒有 pre-tool hook),一次寫入或提交都擋不下來,那三條規則在這裡只剩 SKILL.md 的散文。"
echo "[jsc] copilot:註解範圍與繁中編碼除了規則提示,工作階段結束時由 jsc-wrap.sh 收尾各掃一次整個 git 工作區(copilot 連逐輪事件都沒有),回饋要等到離開 CLI 才看得到。"
exit 1 ;;
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 寫入後讀不到繁中與編碼規則"
printf 'status=degraded reason=%s\n' "STE100 降級為 prompt 檔,SDLC 模型鎖降級為技能步驟檢查,無 pre-tool hook 可接版本前置檢查、部署後重啟閘門與寫入提交閘門,註解範圍與繁中編碼只在工作階段結束時掃一次整個工作區"
echo "$WIRE_PATH_NOTE"
echo "[jsc] antigravity:已在 shell rc 加上 $bin 別名,轉呼叫 tools/jsc-wrap.sh antigravity。"
echo "[jsc] antigravity:已在 $rules 寫入 STE100、註解範圍與繁中編碼規則段落(prompt 降級)。"
echo "[jsc] antigravity:別名要開新的 shell 或重新 source rc 檔才生效。"
echo "[jsc] antigravity:SDLC 模型鎖降級為技能步驟檢查,鎖檔仍由 sdlc-gate.sh lock 寫入。"
echo "[jsc] antigravity:版本前置檢查接不上(antigravity 沒有 pre-tool hook),改由 /jsc-cli:deploy 定期更新。"
echo "[jsc] antigravity:部署後重啟閘門也接不上(同樣是沒有 pre-tool hook),一次技能呼叫都擋不下來;狀態檔照樣寫、下次工作階段開始照樣清,重啟要自己動手。"
echo "[jsc] antigravity:write-guard.sh 的階段寫入、稽核寫入與提交檢查三種模式也接不上(同樣是沒有 pre-tool hook),一次寫入或提交都擋不下來,那三條規則在這裡只剩 SKILL.md 的散文。"
echo "[jsc] antigravity:註解範圍與繁中編碼除了規則提示,工作階段結束時由 jsc-wrap.sh 收尾各掃一次整個 git 工作區(antigravity 連逐輪事件都沒有),回饋要等到離開 CLI 才看得到。"
exit 1 ;;
kiro)
command -v "$(cli_bin kiro)" >/dev/null 2>&1 || skip "未偵測到 kiro-cli 執行檔"
hookdir="./.kiro/hooks"
hookfile="$hookdir/jsc-hooks.json"
startfile="$hookdir/jsc-hooks-session-start.json"
mkdir -p "$hookdir" 2>/dev/null || fail "無法建立 $hookdir"
# 計時要分兩個檔:kiro 的一個 hook 檔只有一組 run,所有事件共用。
# sessionStart 單獨一檔跑 restart,才算得出這一次工作階段的花費時間;
# kiro 給不到 session id,紀錄共用 default,不覆寫起始時間就會把上一階段算進來。
cat > "$startfile" 2>/dev/null <<EOF || fail "無法寫入 $startfile"
{
"name": "jsc-hooks-session-start",
"description": "jsc session timer start (auto-generated by jsc-hooks:hooks-install, do not edit by hand)",
"on": ["sessionStart"],
"env": { "JSC_CLI": "kiro" },
"run": "sh \\"$WIRE_HOOKS/session-timer.sh\\" restart </dev/null"
}
EOF
# userPromptSubmit 那一輪的 run 最後再掛 comment-scope.sh sweep 與 lang-guard.sh sweep:
# kiro 沒有 post-tool hook,拿不到「剛剛寫了哪個檔」,只能改掃整個 git 工作區的 diff。
# 掃到的是上一輪寫的檔——提示送出時,上一輪的寫入早就落地了,時機合理。
cat > "$hookfile" 2>/dev/null <<EOF || fail "無法寫入 $hookfile"
{
"name": "jsc-hooks",
"description": "jsc session timer + STE100 guard + comment scope + language guard bridge (auto-generated by jsc-hooks:hooks-install, do not edit by hand)",
"on": ["sessionEnd", "userPromptSubmit"],
"env": { "JSC_CLI": "kiro" },
"run": "sh \\"$WIRE_HOOKS/session-timer.sh\\" mark </dev/null; sh \\"$WIRE_HOOKS/ste100-guard.sh\\" </dev/null; sh \\"$WIRE_HOOKS/comment-scope.sh\\" prompt </dev/null; sh \\"$WIRE_HOOKS/comment-scope.sh\\" sweep </dev/null; sh \\"$WIRE_HOOKS/lang-guard.sh\\" prompt </dev/null; sh \\"$WIRE_HOOKS/lang-guard.sh\\" sweep </dev/null"
}
EOF
for f in "$startfile" "$hookfile"; do
json_top_key "$f" on || fail "$f 不是成對的 JSON,或 on 不在最上層"
json_top_key "$f" run || fail "$f 不是成對的 JSON,或 run 不在最上層"
grep -qF '"JSC_CLI": "kiro"' "$f" 2>/dev/null || fail "$f 缺少 JSC_CLI=kiro"
grep -qF 'session-timer.sh' "$f" 2>/dev/null || fail "$f 的 run 沒有接到 session-timer.sh"
done
grep -qF '"sessionStart"' "$startfile" 2>/dev/null || fail "$startfile 沒有接在 sessionStart"
grep -qF '"userPromptSubmit"' "$hookfile" 2>/dev/null || fail "$hookfile 沒有接在 userPromptSubmit"
grep -qF 'comment-scope.sh' "$hookfile" 2>/dev/null || fail "$hookfile 的 run 沒有接到 comment-scope.sh"
# 兩個模式接的是兩件事,只驗腳本名會漏掉少接的那一個,所以各驗一次
grep -qF 'comment-scope.sh\" prompt' "$hookfile" 2>/dev/null \
|| fail "$hookfile 的 run 沒有接到 comment-scope.sh prompt,每輪不會注入註解範圍規則"
grep -qF 'comment-scope.sh\" sweep' "$hookfile" 2>/dev/null \
|| fail "$hookfile 的 run 沒有接到 comment-scope.sh sweep,kiro 每輪不會掃註解範圍"
grep -qF 'lang-guard.sh' "$hookfile" 2>/dev/null || fail "$hookfile 的 run 沒有接到 lang-guard.sh"
grep -qF 'lang-guard.sh\" prompt' "$hookfile" 2>/dev/null \
|| fail "$hookfile 的 run 沒有接到 lang-guard.sh prompt,每輪不會注入繁中與編碼規則"
grep -qF 'lang-guard.sh\" sweep' "$hookfile" 2>/dev/null \
|| fail "$hookfile 的 run 沒有接到 lang-guard.sh sweep,kiro 每輪不會掃簡體字與亂碼"
printf 'status=degraded reason=%s\n' "SDLC 模型鎖降級為技能步驟檢查,無 pre-tool hook 可接版本前置檢查、部署後重啟閘門與寫入提交閘門,註解範圍與繁中編碼改為每輪提示送出時掃整個工作區,不是逐檔即時"
echo "$WIRE_PATH_NOTE"
echo "[jsc] kiro:已建立 $startfile(sessionStart 開始計時)與 $hookfile(JSC_CLI=kiro),兩份都已驗證。"
echo "[jsc] kiro:SDLC 模型鎖降級為技能步驟檢查,鎖檔仍由 sdlc-gate.sh lock 寫入。"
echo "[jsc] kiro:版本前置檢查接不上(kiro 沒有 pre-tool hook),改由 /jsc-cli:deploy 定期更新。"
echo "[jsc] kiro:部署後重啟閘門也接不上(同樣是沒有 pre-tool hook),一次技能呼叫都擋不下來;狀態檔照樣寫、下次工作階段開始照樣清,重啟要自己動手。"
echo "[jsc] kiro:write-guard.sh 的階段寫入、稽核寫入與提交檢查三種模式也接不上(同樣是沒有 pre-tool hook),一次寫入或提交都擋不下來,那三條規則在這裡只剩 SKILL.md 的散文。"
echo "[jsc] kiro:註解範圍與繁中編碼除了規則提示,每輪提示送出時會各掃一次整個 git 工作區(kiro 沒有 post-tool hook),掃到的是上一輪寫的檔。"
exit 1 ;;
esac