Files
hooks/hooks/restart-gate.sh
T
jiantw83 e2b08f04a3 fix(gates): 修好會蓋掉異常目錄、把修復路徑鎖死與誤擋計畫階段的缺陷
異常回報依 wiki 讀取的結束碼分流,只有頁面確定不存在才套範本建新頁。
版本閘門與重啟閘門的豁免清單各補上 hook 修復技能。
階段閘門把計畫階段移出擋人名單,改成只注入提醒。
錯誤掃描的自家 hook 判定補齊九支腳本,並加一條路徑判定。
修復技能與接線技能的內文改成真的走得到的路徑與真的存在的關卡數。

原本 wiki 讀取失敗會一路落到套範本那一步,金鑰失效或 API 出狀況時,
就拿一份空白範本蓋掉整份異常目錄,而寫入不做合併也不留備份,蓋掉就救不回來。
兩道閘門把唯一的 hook 修復路徑一起擋住,hook 一壞就沒有任何方法修回來,
閘門等於鎖掉解除自己的路徑。工作包閘門擋下計畫階段是誤擋:
計畫是純邏輯階段、不碰程式碼,而閘門只知道有 PR 未合併,判不出跟新計畫有沒有關聯。
自家 hook 判定只認得早期那五支,後來加的四支出錯會被當成第三方的,只回報不修正。
修復技能裡三個指向流程的路徑指到不存在的位置,照著走一定撲空;
接線流程寫四道關卡,實際上有五道,兩段中文說明也混在英文內文裡。

異常目錄改成先讀回舊頁、把新列附在文末、再整頁寫回;讀不回來就放棄寫目錄頁並回報,
寧可少一列索引,也不覆蓋別人的紀錄。兩份豁免清單各補一項,理由逐項寫在腳本檔頭。
計畫階段改印提醒後放行,放棄的在製品上限與代價一併寫在檔頭。
自家 hook 判定逐支列出腳本名,再加一條安裝路徑判定,日後新增 hook 忘了補清單也還認得出來。
三個路徑改指到擁有它的技能組,關卡數改成五道並逐關寫明結束碼,兩段中文說明改回英文。
版本閘門在同一次改動另補唯讀的建議子命令,把版本比對表收斂成一行結論,
部署技能不必自己再解一次那張表。
2026-08-31 11:15:24 +08:00

245 lines
14 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
# restart-gate.sh — 部署後強制重啟閘門(PreToolUse,matcher: Skill)。
#
# 技能組更新後,正在跑的 CLI 行程載入的還是舊版:SKILL.md、hook 腳本與 tools 都在啟動當下
# 讀進記憶體。所以部署收尾要求重新啟動,這道閘門負責讓「還沒重啟就繼續用技能」擋在門外。
#
# 結束碼(hook 模式):0=放行 2=擋下該次技能呼叫,訊息走 stderr。
# 安靜放行(exit 0)的情況:逃生門 JSC_RESTART_GATE=off、工具名取得到但不是 Skill、
# 取不到技能名、技能名不是 jsc-{domain}:{name}、命中下方豁免清單那 10 支、取不到 CLI 代號、
# 當前 CLI 那份狀態檔與舊格式狀態檔都不在。
# 只有「當前 CLI 那份狀態檔存在」或「退回讀到的舊格式狀態檔存在」會 exit 2。
# 結束碼(require):0=閘門已掛上 2=取不到 CLI 代號或寫不進狀態檔,兩種都等於沒掛上。
# 結束碼(clear、report):0=永遠成功。clear 檔案不存在也算成功,report 一份都沒有就不印。
# 結束碼(不認得的子命令):0=安靜放行,不中斷宿主 CLI。
# 註:本檔以 `. "$HERE/lib.sh"` 載入共用函式,沒有接 `|| true`。lib.sh 讀不到時 sh 會就地
# 結束並回 2,接在 PreToolUse 上就是無聲擋下每一次技能呼叫,上面那些放行路徑一條都跑不到。
#
# 用法:
# 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"
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
# 輸入相容:stdin JSON(Claude 格式)與環境變數(其他四支 CLI 接線時設定)都要收,
# 取法比照 version-guard.sh。工具名取不到就當成沒篩,繼續判技能名。
tool="${JSC_TOOL_NAME:-${TOOL_NAME:-$(json_str tool_name)}}"
[ -z "$tool" ] || [ "$tool" = "Skill" ] || exit 0
skill="${JSC_SKILL:-${SKILL:-$(json_str skill)}}"
[ -n "$skill" ] || exit 0
# 只管本技能組(jsc-{domain}:{name})。別人的技能不受這道閘門影響。
case "$skill" in
jsc-*:*) ;;
*) exit 0 ;;
esac
# 豁免清單(理由見檔頭)
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 部署的"
printf '[jsc][重啟閘門][ERR]:技能組已更新%s,%s 還在跑舊版,新版要重新啟動才會載入。本次技能呼叫已擋下。\n' \
"${info:+($info)}" "$bin" >&2
printf '重新啟動:結束 %s 再重新開啟一次,狀態檔 %s 會在新工作階段開始時自動清除。\n' \
"$bin" "$state" >&2
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' >&2
exit 2