#!/usr/bin/env sh # restart-gate.sh — 部署後強制重啟閘門(PreToolUse,matcher: Skill)。 # # 技能組更新後,正在跑的 CLI 行程載入的還是舊版:SKILL.md、hook 腳本與 tools 都在啟動當下 # 讀進記憶體。所以部署收尾要求重新啟動,這道閘門負責讓「還沒重啟就繼續用技能」擋在門外。 # # 結束碼(hook 模式):0=放行 2=擋下該次技能呼叫。 # 擋下時的輸出形態由 deny.sh 依當前 CLI 決定,本檔只負責判定與訊息內容: # claude、codex、copilot 走 stderr 加 exit 2;antigravity 走 stdout 的 deny JSON,結束碼 # 固定 0(那支 CLI 的結束碼語意沒有文件,不可靠);kiro 擋不下來,改印警告後 exit 0。 # 所以「exit 0」在這支腳本有兩種意思:放行,或已經以不靠結束碼的形態擋下。 # 安靜放行(exit 0)的情況:逃生門 JSC_RESTART_GATE=off、負載裡解不出技能名、 # 解出來的不是 jsc 技能、命中下方豁免清單那 10 支、取不到 CLI 代號、 # 當前 CLI 那份狀態檔與舊格式狀態檔都不在。 # 只有「當前 CLI 那份狀態檔存在」或「退回讀到的舊格式狀態檔存在」會走 deny.sh。 # 結束碼(require):0=閘門已掛上 2=取不到 CLI 代號或寫不進狀態檔,兩種都等於沒掛上。 # 結束碼(clear、report):0=永遠成功。clear 檔案不存在也算成功,report 一份都沒有就不印。 # 結束碼(不認得的子命令):0=安靜放行,不中斷宿主 CLI。 # 註:本檔以 `. "$HERE/lib.sh"` 載入共用函式,沒有接 `|| true`。lib.sh 讀不到時 sh 會就地 # 結束並回 2,接在 PreToolUse 上就是無聲擋下每一次技能呼叫,上面那些放行路徑一條都跑不到。 # hooks/skill-name.sh 與 hooks/deny.sh 同理要一起裝上,但那兩支是以子行程呼叫,讀不到只會 # 讓技能名解不出來而安靜放行,不會反過來擋人——所以那兩支刻意不用 source 載入。 # # 輸入:技能名一律由 skill-name.sh 從當前 CLI 的負載解析,環境變數 JSC_SKILL、SKILL 優先, # 規則與 version-guard.sh 共用同一份。不再另外篩工具名:工具名每支 CLI 都不一樣 # (Skill、Bash、skill、view_file),拿 Claude 的那一個當通用條件會把另外四支整批擋在判定之外。 # # 用法: # restart-gate.sh hook 模式:當前 CLI 那份狀態檔存在就擋下該次技能 # 呼叫(exit 2)。別支 CLI 那幾份不看。 # restart-gate.sh require {模式} [{domain}...] # 寫入當前 CLI 那份狀態檔,掛上這一支的閘門。由 # jsc-cli:deploy 在 install 或 update 收尾時呼叫;模式為 # install 或 update,之後接這次更新的 domain 清單。 # exit 0 = 已掛上;exit 2 = 取不到 CLI 代號或寫不進去 # (兩種都等於沒掛上)。 # restart-gate.sh clear 只清除當前 CLI 那份狀態檔,放下這一支的閘門。由 # session-timer.sh 在判定為新工作階段時呼叫(見下方 # 「清除時機」)。檔案不存在也算成功。 # restart-gate.sh report 印出每一份狀態檔的內容,一支 CLI 一行(格式見下方 # 「report 輸出格式」);一份都沒有就不印,一律 exit 0。 # # require、clear、report 都不讀標準輸入,只有 hook 模式讀。理由與 sdlc-gate.sh 相同: # read_stdin 在標準輸入是管線又沒人關閉時會一直等,工具端呼叫就整支卡死。新增子命令照這個 # 原則歸類,工具端呼叫一律再補 /dev/null | head -n1 } # 一份狀態檔印一行,格式見檔頭「report 輸出格式」。$1=第一欄要印的名稱 $2=狀態檔 state_line() { printf '%s at=%s mode=%s domains=%s\n' "$1" \ "$(state_field "$2" at)" "$(state_field "$2" mode)" "$(state_field "$2" domains)" } case "${1:-}" in require) _mode="${2:-update}" _domains="" if [ "$#" -gt 2 ]; then shift 2; _domains="$*"; fi _cli=$(cli_code) if [ -z "$_cli" ]; then # 取不到代號就不知道該寫哪一份,寫成別的檔名也沒用:hook 模式同樣取不到代號,那一份 # 永遠不會被讀到。沒掛上就要講出來,不能讓部署以為掛上了。 printf '[jsc][重啟閘門][ERR]:取不到可用的 CLI 代號(JSC_CLI 未設定,或值不是代號),這次部署沒有掛上重啟閘門。\n' >&2 exit 2 fi mkdir -p "$STATE_DIR" 2>/dev/null || true printf 'at=%s\nmode=%s\ndomains=%s\ncli=%s\n' \ "$(now_iso)" "$_mode" "$_domains" "$_cli" > "$STATE_DIR/$_cli" 2>/dev/null || { # 寫不進去要講出來:沒寫成就沒有閘門,部署卻以為掛上了。 printf '[jsc][重啟閘門][ERR]:寫不進 %s,這次部署沒有掛上重啟閘門。\n' "$STATE_DIR/$_cli" >&2 exit 2 } exit 0 ;; clear) _cli=$(cli_code) # 只刪自己那一份。別支 CLI 沒有跟著重啟,它們的閘門要留著。 [ -n "$_cli" ] && rm -f "$STATE_DIR/$_cli" 2>/dev/null # 舊檔一併刪,取捨與可移除時機見檔頭「舊檔相容」。 rm -f "$LEGACY_STATE" 2>/dev/null || true exit 0 ;; report) # 目錄裡一份都沒有時,未展開的樣式字串會由 -f 判斷擋掉。 for _f in "$STATE_DIR"/*; do [ -f "$_f" ] && [ -r "$_f" ] || continue state_line "$(basename "$_f")" "$_f" done if [ -f "$LEGACY_STATE" ] && [ -r "$LEGACY_STATE" ]; then state_line legacy "$LEGACY_STATE" fi exit 0 ;; "") ;; # 落到下面的 hook 模式 *) exit 0 ;; # 不認得的子命令一律安靜放行,不中斷宿主 CLI esac read_stdin [ "${JSC_RESTART_GATE:-}" = "off" ] && exit 0 # 技能名解析:交給 skill-name.sh,規則與 version-guard.sh 共用同一份。輸出固定是 # 「{domain}{技能名}」;用 awk 判 NF==2 才取值,少一欄就當成解析不出來,免得沒有定位字元時 # cut -f2 把整行當成技能名,拼出一個不存在的技能名去比對豁免清單。 sn=$(printf '%s' "$STDIN_JSON" | sh "$HERE/skill-name.sh" "$(cli_name)" 2>/dev/null) sn_domain=$(printf '%s\n' "$sn" | awk -F'\t' 'NF == 2 { print $1; exit }') sn_name=$(printf '%s\n' "$sn" | awk -F'\t' 'NF == 2 { print $2; exit }') [ -n "$sn_domain" ] && [ -n "$sn_name" ] || exit 0 skill="jsc-$sn_domain:$sn_name" # 豁免清單(理由見檔頭) case "$skill" in jsc-cli:deploy|jsc-hooks:hooks-install|jsc-hooks:repair|jsc-gitea:wiki|jsc-log:worklog|jsc-log:learn|jsc-meta:*|jsc-ask:ask|jsc-git:pr|jsc-git:commit) exit 0 ;; esac # CLI 代號取不到就放行:不知道現在跑的是哪一支,就不知道該讀哪一份狀態檔,等同沒有證據。 cli=$(cli_code) [ -n "$cli" ] || exit 0 # 只看自己那一份;沒有才退回看舊檔。兩份都沒有就放行——沒有「剛部署過」的證據,就沒有擋人的 # 理由。別支 CLI 那幾份一律不看:那些是別的行程,重啟與否跟這一支無關。 state="$STATE_DIR/$cli" legacy=no if [ -f "$state" ] && [ -r "$state" ]; then : elif [ -f "$LEGACY_STATE" ] && [ -r "$LEGACY_STATE" ]; then state="$LEGACY_STATE"; legacy=yes else exit 0 fi at=$(state_field "$state" at) mode=$(state_field "$state" mode) domains=$(state_field "$state" domains) # 重啟方式依實際 CLI 給。印別的 CLI 的執行檔名等於沒給指示。 bin=$(cli_bin "$cli") # 訊息裡的部署資訊逐段接起來,缺欄位就少一段,不會留下空括號或多餘的逗號。 info="" [ -n "$at" ] && info="$at" [ -n "$mode" ] && info="${info}${info:+,}模式 $mode" [ -n "$domains" ] && info="${info}${info:+,}domain:$domains" # 舊格式紀錄要標出來:它分不出是哪一支 CLI 部署的,所以每一支都擋,看到訊息的人才不會以為 # 系統認定就是這一支剛部署過。 [ "$legacy" = yes ] && info="${info}${info:+,}舊格式紀錄,分不出是哪一支 CLI 部署的" # 擋人輸出交給 deny.sh:形態依 CLI 而定,本檔只組訊息。三段訊息整段走同一條管線送過去, # antigravity 那一支才有辦法把它們壓成同一個 reason 字串;分次呼叫會做出好幾份 deny JSON, # 那支 CLI 只認第一份,後面兩段使用者永遠看不到。 { printf '[jsc][重啟閘門][ERR]:技能組已更新%s,%s 還在跑舊版,新版要重新啟動才會載入。本次技能呼叫已擋下。\n' \ "${info:+($info)}" "$bin" printf '重新啟動:結束 %s 再重新開啟一次,狀態檔 %s 會在新工作階段開始時自動清除。\n' \ "$bin" "$state" printf '仍可使用:/jsc-cli:deploy、/jsc-hooks:hooks-install、/jsc-hooks:repair、/jsc-gitea:wiki、/jsc-log:worklog、/jsc-log:learn、/jsc-meta:*、/jsc-ask:ask、/jsc-git:pr、/jsc-git:commit(部署後的異動報告與工作日誌要寫得完,hook 壞掉也要修得回來) | 確定要略過閘門:JSC_RESTART_GATE=off\n' } | sh "$HERE/deny.sh" "$(cli_name)" exit $?