現行紀錄只記「被叫用」,欄位是 ts、cli、session、skill,沒有成敗也沒有 結束碼。跑完整輪的技能與開場就中止的技能,在紀錄裡長得一模一樣。hook 成功時更是完全不留紀錄,只有錯誤路徑會寫 wiki,而那條路徑刻意不自動觸發。 事件流走本機檔案,不直接寫 wiki。hook 每次提示都跑,網路寫入會拖垮宿主 CLI;失敗的 hook 自我回報還會疊出迴圈,既有的錯誤回報因此不接在失敗的 hook 上,這裡沿用同一條線。助理巡檢時排空、彙整、寫頁。 hook 端用 EXIT trap 接,一支只加一行。這幾支的 exit 點很多,階段閘門一支 就有五十幾個;逐點改要動到每一條判定路徑,而那些路徑正是閘門的判準,為了 加一行紀錄去動閘門,風險遠大於收益。trap 涵蓋每一條離開路徑,含中途失敗。 狀態預設由結束碼推,推不出來的由 hook 自己覆寫。相依版本檢查與兩道閘門有 這種情形:antigravity 走 deny JSON、kiro 只印警告,兩者擋下時結束碼都是 0, 單看結束碼會把擋下記成放行。 技能的 start 由既有的技能用量 hook 順手發,不必改任何技能文件。end 只能由 技能自己在收尾步驟寫——hook 觸發時技能的實際工作還在後面的模型輪次,看不到 成敗。有 start 沒有配對的 end,就是那一輪中止了。
258 lines
16 KiB
Bash
Executable File
258 lines
16 KiB
Bash
Executable File
#!/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。
|
||
#
|
||
# --- 狀態檔格式 ---
|
||
#
|
||
# $JSC_HOME/restart-required.d/{CLI 代號}(JSC_HOME 未設定時為 ~/.jsc),一支 CLI 一份,
|
||
# 檔名就是 CLI 代號(claude、codex、copilot、antigravity、kiro)。內容為純文字 key=value,
|
||
# 一行一欄位,順序不拘,不認得的鍵一律忽略。格式壓到最簡,jsc-hooks 與 jsc-cli 兩邊各自
|
||
# 實作也對得上。
|
||
# at={ISO 時間} 部署收尾時間,UTC
|
||
# mode={install|update} 這次部署的模式
|
||
# domains={domain 清單} 這次更新到的 domain,空白分隔
|
||
# cli={CLI 代號} 執行部署的 CLI,與檔名相同
|
||
# 欄位只用在擋人訊息上。判定看的是「當前 CLI 那份檔案在不在」——檔案存在就是這一支還沒重啟
|
||
# 過的證據,欄位缺了只讓訊息少幾個字,不影響判定。
|
||
#
|
||
# 為什麼一支 CLI 一份:一台機器上五支 CLI 各自是獨立行程,各自載入自己記憶體裡的那一版。
|
||
# 早先的單一檔案設計有兩個實測抓到的洞——並行部署互相覆寫(後寫的把 domains 與 cli 蓋掉,
|
||
# 欄位不再代表先寫的那一支),以及任一支 CLI 重啟就把五支的閘門一起解除(其餘四支沒重啟卻
|
||
# 不再被擋,閘門等於半失效)。拆成一支一份之後,寫入、判定、清除三件事都只碰自己那一份。
|
||
#
|
||
# --- report 輸出格式 ---
|
||
#
|
||
# 一行一份狀態檔,欄位以空白分隔,domains 可能含空白所以擺最後:
|
||
# {CLI 代號} at={ISO 時間} mode={install|update} domains={domain 清單}
|
||
# 有幾行就代表有幾支 CLI 還沒重啟。欄位缺值時只留鍵名(例如 domains=)。第一欄印 legacy 的
|
||
# 那一行代表舊格式的單一狀態檔(見下方「舊檔相容」),它不屬於任何一支 CLI。
|
||
#
|
||
# --- 舊檔相容(過渡用) ---
|
||
#
|
||
# 舊版把狀態寫進 $JSC_HOME/restart-required 單一檔案。改用狀態目錄的第一輪部署,機器上可能
|
||
# 還留著那份舊檔:完全不認它,那一輪的閘門會整輪漏掉,檔案本身也會永遠留著變垃圾。所以:
|
||
# 判定:舊檔存在就一律擋,視為「每一支 CLI 都有未重啟的部署」。舊檔沒有 per-CLI 資訊,
|
||
# 分不出是哪一支寫的,寧可擋多不擋少。擋人訊息會標明這是舊格式紀錄。
|
||
# 清除:clear 除了刪當前 CLI 那一份,也一併刪掉舊檔。取捨講白:clear 只在新工作階段被
|
||
# 呼叫,呼叫到就代表確實有一支 CLI 重新啟動過了;舊檔沒有 per-CLI 資訊,留著會讓五支
|
||
# CLI 一路被擋到有人手動刪,刪掉是唯一收斂的做法。代價是同一輪部署的其他 CLI 少擋
|
||
# 一次,只影響改用狀態目錄的那一輪。
|
||
# 這一段是過渡用的:所有機器都跑過一次寫狀態目錄的部署與重啟之後,舊檔不會再被寫出來,屆時
|
||
# 可以整段移除——LEGACY_STATE、hook 判定裡的舊檔分支、clear 裡的舊檔刪除、report 的
|
||
# legacy 行、以及本節。
|
||
#
|
||
# --- 清除時機 ---
|
||
#
|
||
# 清除由 session-timer.sh 在「這一次 SessionStart 是新的工作階段」那一刻呼叫,不由本檔自己判定:
|
||
# 新舊工作階段的判準(sessions/{sid}.start 在不在)只有那支腳本知道,兩邊各寫一份就會漂移。
|
||
# 新的工作階段代表 CLI 行程是新起的,新版一定已經載入,所以清除是對的。續接同一階段
|
||
# (SessionStart 再觸發、resume、compact)不會走到那一段,閘門就一路留到真的重新啟動。
|
||
# 清除的範圍就是呼叫端那一支 CLI:那一支重啟了,不代表別支也重啟了。
|
||
#
|
||
# --- 判定原則 ---
|
||
#
|
||
# 比照 version-guard.sh:只擋確定違規,查不到基礎資訊一律放行(exit 0)。狀態檔讀不到、
|
||
# CLI 代號取不到、技能名取不到、工具名不是 Skill,四種都放行——沒有證據時擋下等於停掉每一次
|
||
# 技能呼叫。
|
||
#
|
||
# 豁免(這些技能永遠放行,改動前想清楚後果):
|
||
# jsc-cli:deploy 部署入口本身,也是唯一能把技能組換成新版的路徑,擋了會死鎖
|
||
# jsc-hooks:hooks-install 部署後要重新接線,擋了會讓部署做一半卡住
|
||
# jsc-hooks:repair 接線或執行期出錯時唯一的修復路徑。修 hook 的技能被 hook 擋下,
|
||
# 就沒有任何方法把 hook 修回來,閘門等於把解除自己的路徑一起鎖掉
|
||
# jsc-gitea:wiki 寫技能組異動報告與工作日誌都要它落地,擋了報告寫不完
|
||
# jsc-log:worklog 部署後還要寫得完工作日誌(R1)
|
||
# jsc-log:learn 同上,教訓也要記得完
|
||
# jsc-meta:* 技能組異動報告(R13)由這一組技能產出,另外它們是修技能組的工具
|
||
# jsc-ask:ask 上面幾支都要問使用者,擋了 deploy 連 install 或 update 都問不出來
|
||
# jsc-git:pr 報告與異動收尾要開 PR,擋了收尾做不完
|
||
# jsc-git:commit 同上,pr 的第一步就是它
|
||
# 理由講白:部署後還有兩條規則要收尾——技能組異動報告(R13)與工作日誌(R1)。整批擋下去,
|
||
# 「先重啟」與「先寫完報告」會互相打死,使用者兩件事都做不完。
|
||
#
|
||
# 清單認的是技能名,不是呼叫鏈:豁免技能轉呼叫的下一層若不在清單上,那一層照樣會被擋。
|
||
# 後三支(ask、pr、commit)就是為了這件事補進來的——它們自己不是收尾規則的主體,但前七支
|
||
# 少了它們就走不完:deploy 問不出模式、報告寫完開不了 PR。version-guard.sh 當年把
|
||
# jsc-ask:ask 與 jsc-gitea:wiki 放進豁免,也是同一個原因。
|
||
# 還有巢狀呼叫走不下去時,先重新啟動;真的卡死才下 JSC_RESTART_GATE=off。
|
||
#
|
||
# 逃生門:JSC_RESTART_GATE=off 完全略過這道閘門。
|
||
HERE=$(dirname "$0"); . "$HERE/lib.sh"
|
||
hook_trace "restart-gate ${1:-}"
|
||
|
||
STATE_DIR="$JSC_HOME/restart-required.d"
|
||
# 舊格式的單一狀態檔。只為過渡而讀,可移除的時機見檔頭「舊檔相容」。
|
||
LEGACY_STATE="$JSC_HOME/restart-required"
|
||
|
||
# 當前 CLI 代號;取不到就不輸出,由呼叫端決定怎麼降級。取法與其他 hook 一致(JSC_CLI 優先,
|
||
# 其次 lib.sh 的 cli_name)。不像代號的值一併當成取不到:這個值直接拿去當檔名,帶斜線或
|
||
# 點號開頭的值會把檔案寫到狀態目錄外面去。
|
||
cli_code() {
|
||
_c=$(cli_name)
|
||
case "$_c" in
|
||
""|unknown) return 0 ;;
|
||
.*|*[!A-Za-z0-9._-]*) return 0 ;;
|
||
esac
|
||
printf '%s' "$_c"
|
||
}
|
||
|
||
# 從指定狀態檔取一個欄位;檔案讀不到或欄位不存在就不輸出。
|
||
state_field() { # $1=狀態檔 $2=鍵名
|
||
[ -f "$1" ] && [ -r "$1" ] || return 0
|
||
sed -n "s/^$2=//p" "$1" 2>/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}<TAB>{技能名}」;用 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'
|
||
# 這條路徑是「已經擋下」,但輸出形態依 CLI 而定:antigravity 走 stdout 的 deny JSON、
|
||
# kiro 只印警告,兩者的結束碼都是 0。不覆寫狀態的話,事件流會把擋下記成放行。
|
||
JSC_EVENT_STATUS=blocked
|
||
} | sh "$HERE/deny.sh" "$(cli_name)"
|
||
exit $?
|