fix(hooks): 補齊稽核缺失並修掉護欄失效

What:依 jsc-meta:skill-check 的稽核結果修正技能與工具——補上每個步驟的可檢核完成條件、
把留在內文的標準輸入輸出流程下放 tools/、修正查表與退碼路由造成的誤判。

Why:稽核發現這些缺失會讓技能在實際執行時走錯分支或靜默通過。
完成條件缺漏是最常被違反的一項;退碼誤判與查表錯誤則會讓良性狀況被當成失敗。

How:逐項對照 references/guidelines.md 的審核檢查清單修正,新增的工具都有
documented exit codes,並以真實執行驗證每條路徑。

Who:jsc-meta:skill-check 例行稽核(2026-08-25)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-25 14:58:54 +08:00
co-authored by Claude Opus 5
parent cc9c80d760
commit fedcfc8b05
8 changed files with 580 additions and 224 deletions
+36
View File
@@ -8,6 +8,10 @@
JSC_HOME="${JSC_HOME:-$HOME/.jsc}"
mkdir -p "$JSC_HOME/sessions" "$JSC_HOME/usage" 2>/dev/null || true
# 呼叫端腳本所在目錄。source 不會改變 $0,所以這裡取到的是 hooks/ 或 tools/。
JSC_SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" 2>/dev/null && pwd)
JSC_SCRIPT_DIR="${JSC_SCRIPT_DIR:-.}"
# 讀完 stdin(可能為空;非阻塞宿主)
read_stdin() {
if [ -t 0 ]; then STDIN_JSON=""; else STDIN_JSON=$(cat 2>/dev/null || true); fi
@@ -47,5 +51,37 @@ cli_name() {
else printf 'unknown'; fi
}
# 找出 jsc-gitea 的 tools/gitea.sh 絕對路徑。所有 gitea 操作一律經由它(技能準則),
# 不可自行拼 API 呼叫:token 取用與 tea 金鑰退回都寫在那支腳本裡。
# 找不到就回傳 1,由呼叫端安靜降級(hook 一律 exit 0,不中斷宿主 CLI)。
jsc_gitea_sh() {
if [ -n "${JSC_GITEA_TOOLS:-}" ] && [ -f "$JSC_GITEA_TOOLS/gitea.sh" ]; then
printf '%s\n' "$JSC_GITEA_TOOLS/gitea.sh"; return 0
fi
_root="${CLAUDE_PLUGIN_ROOT:-$JSC_SCRIPT_DIR/..}"
# 開發用的並排存取庫版面:{workspace}/hooks 旁邊就是 {workspace}/gitea
for _c in "$_root/../gitea/tools/gitea.sh" "$_root/../jsc-gitea/tools/gitea.sh"; do
[ -f "$_c" ] && { printf '%s\n' "$_c"; return 0; }
done
# 已安裝版面:每個 plugin 各有版本目錄,取排序最後的一份(通常即最新版)
_c=$(ls -d "$_root"/../../jsc-gitea/*/tools/gitea.sh \
"$_root"/../../gitea/*/tools/gitea.sh \
"$HOME"/.claude/plugins/cache/*/jsc-gitea/*/tools/gitea.sh 2>/dev/null \
| sort | tail -n1)
[ -n "$_c" ] && [ -f "$_c" ] && { printf '%s\n' "$_c"; return 0; }
_c=$(command -v gitea.sh 2>/dev/null || true)
[ -n "$_c" ] && { printf '%s\n' "$_c"; return 0; }
return 1
}
# 每個 CLI 代號對應的實際執行檔(antigravity 是 agy、kiro 是 kiro-cli,其餘同名)
cli_bin() { # $1=CLI 代號
case "$1" in
antigravity) printf 'agy' ;;
kiro) printf 'kiro-cli' ;;
*) printf '%s' "$1" ;;
esac
}
now_epoch() { date +%s; }
now_iso() { date -u +%Y-%m-%dT%H:%M:%SZ; }
+10 -4
View File
@@ -35,15 +35,21 @@ stage_tags() { # $1=階段
awk -F'\t' -v s="$1" '$1 == "stage" && $2 == s { print $3; exit }' "$TAGS_TSV"
}
# 某模型的能力標籤。模型鍵與實際 id 雙向包含即視為同一家族
#(例:表列 claude-haiku-4-5 對得上 claude-haiku-4-5-20251001);多筆命中取最長鍵。
# 某模型的能力標籤。查法:先找完全相同的鍵,沒有才退回「表列鍵是實際 id 的前綴」
# 之中最長的一筆(例:表列 claude-haiku-4-5 對得上 claude-haiku-4-5-20251001)。
# 只認前綴這個方向。反向包含(實際 id 是表列鍵的前綴)會讓 gpt-5.x 命中更長的
# gpt-5.x-mini,最長鍵勝出就把 mini 的標籤發給 gpt-5.x,把夠格的模型擋掉。
model_tags() { # $1=模型 id
[ -s "$TAGS_TSV" ] || return 0
awk -F'\t' -v m="$1" '
$1 == "model" && (index(m, $2) > 0 || index($2, m) > 0) {
$1 == "model" && $2 == m { exact = $3 }
$1 == "model" && $2 != m && substr(m, 1, length($2)) == $2 {
if (length($2) > best_len) { best_len = length($2); best = $3 }
}
END { if (best != "") print best }
END {
if (exact != "") print exact
else if (best != "") print best
}
' "$TAGS_TSV"
}
+11 -1
View File
@@ -1,9 +1,16 @@
#!/usr/bin/env sh
# session-timer.sh — 記錄工作階段花費時間(供 jsc-log:worklog 取用)。
# 用法:
# session-timer.sh start # SessionStart:記錄起始時間
# session-timer.sh start # SessionStart:記錄起始時間(已有紀錄就不動)
# session-timer.sh restart # SessionStart:一律覆寫起始時間
# session-timer.sh mark # Stop/SessionEnd:更新最後活動時間
# session-timer.sh report [sid] # 印出 {sid} {seconds};無紀錄印 0
#
# start 與 restart 的差別在「同一個 session id 會不會重複開始」:
# start 給 Claude 這種每個工作階段都有自己 session id 的 CLI。續接同一階段時
# SessionStart 會再觸發一次,覆寫起始時間會讓花費時間歸零。
# restart 給接不到 session id 的 CLI(kiro)。那些 CLI 的紀錄共用 default,
# 不覆寫就會把上一個工作階段的起始時間算進來,花費時間虛胖。
HERE=$(dirname "$0"); . "$HERE/lib.sh"
read_stdin
sid=$(session_id)
@@ -11,6 +18,9 @@ case "${1:-mark}" in
start)
f="$JSC_HOME/sessions/$sid.start"
[ -f "$f" ] || now_epoch > "$f" ;;
restart)
now_epoch > "$JSC_HOME/sessions/$sid.start"
rm -f "$JSC_HOME/sessions/$sid.end" ;;
mark)
now_epoch > "$JSC_HOME/sessions/$sid.end" ;;
report)
Regular → Executable
+180 -158
View File
@@ -3,15 +3,22 @@
#
# 本機版本落後遠端發佈版本時擋下該次技能呼叫,並提示更新指令。
#
# 輸入:stdin JSON(Claude 格式)或環境變數,兩者都收。
# 工具名 JSC_TOOL_NAME、TOOL_NAME、stdin 的 tool_name
# 技能名 JSC_SKILL、SKILL、stdin 的 skill
# 兩邊都拿不到就安靜降級 exit 0。
#
# 判準與取值:
# - 比對對象是「遠端發佈版本」與「本機**實際載入**的版本」。
# 實際載入版本要從 installed_plugins.json 的 installPath 讀該版目錄下的
# plugin.json,不能只看註冊在 installed_plugins.json 的版本欄位——那兩者
# 可能不同,只看註冊值會放過真正被載入的舊版。
# 實際載入版本只認 installed_plugins.json 的 installPath 底下那份 plugin.json,
# 不看註冊在 installed_plugins.json 的版本欄位——那兩者可能不同,註冊值比較新時
# 會放過真正被載入的舊版。讀不到那份檔案就當查不到,安靜放行。
# - 只擋落後。本機版本等於或超前遠端一律放行:開發技能組時本機本來就會
# 超前 master,擋下去會讓維護者自己動不了。
# - 遠端版本查不到(離線、站台維護、repo 改名)一律**擋**(fail-closed),
# 避免「查不到就當作沒事」而讓落後版本靜靜跑下去。逃生門見下。
# 超前預設分支,擋下去會讓維護者自己動不了。
# - **只有「本機落後遠端」會擋**。查不到資料一律放行(exit 0):本機版本、
# Gitea 站台、遠端版本全部來自 Claude 的 plugin 檔案與 Gitea API,沒裝
# Claude 或離線的機器一筆都讀不到。那種情況擋下去,等於在沒有任何版本
# 證據時停掉每一次技能呼叫,護欄變成故障點。
#
# 豁免(這些技能永遠放行):
# jsc-cli:deploy 更新整組技能的入口,擋了就沒有任何方法更新,會死鎖
@@ -22,84 +29,148 @@
# 逃生門:JSC_VERSION_GUARD=off 完全略過檢查(離線工作時用)。
#
# 快取:$JSC_HOME/version-cache/{domain},單行「{版本} {epoch}」,
# 預設 600 秒內不重查(JSC_VERSION_TTL 可調)。
# 預設 600 秒內不重查(JSC_VERSION_TTL 可調)。hook 與 report 共用同一份。
#
# 另有一個非 hook 的子指令:
# version-guard.sh report 把每個已安裝 jsc-* plugin 的版本比對印成 TSV,每行
# 「{domain}<TAB>{本機}<TAB>{遠端}<TAB>{落後|最新|超前|查詢失敗}」,
# 最後一行「behind<TAB>{落後個數}」。供 jsc-cli:deploy 判斷要不要
# 把「更新」設成推薦選項。report 只讀不擋,永遠 exit 0。
# 本機沒有 Claude 的 plugin 註冊檔時改印「noregistry<TAB>{路徑}」
# 再接 behind 0:那代表這台機器無法做版本檢查,跟「全部最新」是兩件事。
HERE=$(dirname "$0"); . "$HERE/lib.sh"
REG="$HOME/.claude/plugins/installed_plugins.json"
MK="$HOME/.claude/plugins/known_marketplaces.json"
# 從檔案取 JSON 字串欄位。與 lib.sh 的 json_str 同一種 naive 解析,只是來源是檔案:
# 先把換行換成空白、再以逗號斷行,這樣每行最多一個欄位,取值不會被貪婪比對吃掉。
file_json_str() { # $1=檔案 $2=欄位名
[ -f "$1" ] || return 0
tr '\n' ' ' < "$1" | tr ',' '\n' \
| sed -n "s/.*\"$2\"[[:space:]]*:[[:space:]]*\"\([^\"]*\)\".*/\1/p" | head -n1
}
# 本機實際載入版本:先取該 plugin 的 installPath,再讀那個目錄下的 plugin.json。
# 只認 installPath 底下那份檔案。註冊在 installed_plugins.json 的 version 欄位不當備援:
# 註冊值可能比實際載入的版本新,拿它來比對會放過真正被載入的舊版,護欄形同虛設。
# 讀不到那份檔案就當「查不到本機載入版本」,由呼叫端安靜放行。
local_version() { # $1=domain
[ -f "$REG" ] && [ -r "$REG" ] || return 0
_seg=$(tr -d '\n' < "$REG" \
| sed -n "s/.*\"jsc-$1@jsc\"[[:space:]]*:[[:space:]]*\[\([^]]*\)\].*/\1/p")
[ -n "$_seg" ] || return 0
_path=$(printf '%s' "$_seg" | tr ',' '\n' \
| sed -n 's/.*"installPath"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n1)
[ -n "$_path" ] || return 0
file_json_str "$_path/plugin.json" version
}
# 遠端站台與 owner:從已註冊的 jsc marketplace 來源推導,其次 GITEA_HOST。
# 印出「{host} {owner}」;推導不出來時 host 為空字串。
remote_host_owner() {
_url=""
if [ -f "$MK" ]; then
_url=$(tr -d '\n' < "$MK" | sed -n 's/.*"jsc"[[:space:]]*:[[:space:]]*{//p' \
| tr ',' '\n' \
| sed -n 's/.*"url"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n1)
fi
_h=$(printf '%s' "$_url" | sed -n 's#^\(https\{0,1\}://[^/]*\)/.*#\1#p')
_o=$(printf '%s' "$_url" | sed -n 's#^https\{0,1\}://[^/]*/\([^/]*\)/.*#\1#p')
if [ -z "$_h" ] || [ -z "$_o" ]; then
_h="${GITEA_HOST:-}"; _o="${JSC_GITEA_OWNER:-plugins}"
fi
printf '%s %s' "$_h" "$_o"
}
# 遠端發佈版本:一律經由 jsc-gitea 的 gitea.sh(技能準則),它會帶 GITEA_TOKEN,
# 並在缺 token 或 401/403 時退回 tea 的登入金鑰,私有存取庫才讀得到。
# 不指定 ref:Gitea 的 raw 端點預設就取該存取庫的預設分支,比在這裡寫死分支名準。
# 查不到就回傳空字串,由呼叫端放行。
remote_version() { # $1=domain $2=host $3=owner
_gsh=$(jsc_gitea_sh) || return 0
_v=""
for _try in 1 2; do # 暫時性網路失敗不該誤判成版本問題,失敗重試一次
_body=$(GITEA_HOST="$2" sh "$_gsh" api GET "/repos/$3/$1/raw/plugin.json" 2>/dev/null) \
&& _v=$(printf '%s' "$_body" | tr '\n' ' ' | tr ',' '\n' \
| sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n1)
[ -n "$_v" ] && break
sleep 1
done
printf '%s' "$_v"
}
TTL="${JSC_VERSION_TTL:-600}"
cache_dir="$JSC_HOME/version-cache"
# 帶快取的遠端版本查詢。hook 與 report 共用同一份快取與同一個 TTL:
# report 每個 domain 各打一次網路(還帶重試),/jsc-cli:deploy 一跑就是全部 domain,
# 不共用快取等於每次部署都付一輪網路成本。
cached_remote_version() { # $1=domain $2=host $3=owner
_cache="$cache_dir/$1"
_now=$(now_epoch)
if [ -f "$_cache" ]; then
_cv=$(cut -d' ' -f1 "$_cache" 2>/dev/null)
_ca=$(cut -d' ' -f2 "$_cache" 2>/dev/null)
if [ -n "$_cv" ] && [ -n "$_ca" ] && [ $((_now - _ca)) -lt "$TTL" ]; then
printf '%s' "$_cv"; return 0
fi
fi
_rv=$(remote_version "$1" "$2" "$3")
if [ -n "$_rv" ]; then
mkdir -p "$cache_dir" 2>/dev/null || true
printf '%s %s\n' "$_rv" "$_now" > "$_cache" 2>/dev/null || true
fi
printf '%s' "$_rv"
}
# 語意化比較:印出 -1($1 落後)、0(相等)、1($1 超前)
ver_cmp() { # $1=版本 A $2=版本 B
awk -v a="$1" -v b="$2" '
BEGIN {
n = split(a, x, "."); m = split(b, y, ".")
for (i = 1; i <= 3; i++) {
xi = (i <= n ? x[i] + 0 : 0); yi = (i <= m ? y[i] + 0 : 0)
if (xi < yi) { print -1; exit }
if (xi > yi) { print 1; exit }
}
print 0
}'
}
# ── report:一次比對所有已安裝的 jsc plugin(非 hook 模式,不讀 stdin)
if [ "${1:-}" = "report" ]; then
python3 - <<'PY'
import json, os, re, subprocess, sys
home = os.path.expanduser("~")
try:
reg = json.load(open(f"{home}/.claude/plugins/installed_plugins.json")).get("plugins", {})
except Exception:
print("behind\t0"); sys.exit(0)
host = owner = ""
try:
mk = json.load(open(f"{home}/.claude/plugins/known_marketplaces.json"))
m = re.match(r"(https?://[^/]+)/([^/]+)/[^/]+?(?:\.git)?/?$",
(mk.get("jsc", {}).get("source", {}) or {}).get("url", ""))
if m:
host, owner = m.group(1), m.group(2)
except Exception:
pass
if not host:
host = os.environ.get("GITEA_HOST", "")
owner = os.environ.get("JSC_GITEA_OWNER", "plugins")
def ver(v):
parts = ((v or "").split(".") + ["0", "0", "0"])[:3]
return tuple(int(p) if p.isdigit() else 0 for p in parts)
behind = 0
for key in sorted(reg):
m = re.match(r"^jsc-([^@]+)@", key)
if not m:
continue
domain = m.group(1)
# 本機版本一律以 installPath 底下那份 plugin.json 為準(實際載入版本),
# 讀不到才退回註冊欄位。
local = ""
for e in reg[key]:
try:
local = json.load(open(os.path.join(e.get("installPath", ""), "plugin.json"))).get("version", "")
break
except Exception:
local = e.get("version", "")
remote = ""
if host:
url = f"{host}/{owner}/{domain}/raw/branch/master/plugin.json"
for _ in range(2): # 暫時性網路失敗不該誤判成版本問題,失敗重試一次
try:
out = subprocess.run(["curl", "-sS", "--max-time", "10", url],
capture_output=True, text=True, timeout=20).stdout
mm = re.search(r'"version"\s*:\s*"([^"]+)"', out)
if mm:
remote = mm.group(1)
break
except Exception:
pass
if not remote:
state = "查詢失敗"
elif ver(local) < ver(remote):
state = "落後"; behind += 1
elif ver(local) > ver(remote):
state = "超前"
else:
state = "最新"
print(f"{domain}\t{local or '?'}\t{remote or '?'}\t{state}")
print(f"behind\t{behind}")
PY
# 註冊檔不存在或讀不到就明講。這裡不能只印 behind 0:呼叫端會把它讀成「都是最新」,
# 於是把「這台機器無法做版本檢查」誤報成「不用更新」。
# 舊版用 `tr -d '\n' < "$REG" 2>/dev/null`,那個 2>/dev/null 只蓋住 tr 的 stderr,
# 蓋不住 shell 開檔失敗的訊息,所以沒裝 Claude 的機器會先漏一行 cannot open。
if [ ! -f "$REG" ] || [ ! -r "$REG" ]; then
printf 'noregistry\t%s\n' "$REG"
printf 'behind\t0\n'
exit 0
fi
ho=$(remote_host_owner)
r_host=$(printf '%s' "$ho" | cut -d' ' -f1)
r_owner=$(printf '%s' "$ho" | cut -d' ' -f2)
behind=0
domains=$(tr -d '\n' < "$REG" | tr ',' '\n' \
| sed -n 's/.*"jsc-\([a-z0-9][a-z0-9-]*\)@[^"]*"[[:space:]]*:.*/\1/p' | sort -u)
for d in $domains; do
lv=$(local_version "$d")
rv=""
[ -n "$r_host" ] && rv=$(cached_remote_version "$d" "$r_host" "$r_owner")
if [ -z "$rv" ]; then
st="查詢失敗"
else
case "$(ver_cmp "$lv" "$rv")" in
-1) st="落後"; behind=$((behind + 1)) ;;
1) st="超前" ;;
*) st="最新" ;;
esac
fi
printf '%s\t%s\t%s\t%s\n' "$d" "${lv:-?}" "${rv:-?}" "$st"
done
printf 'behind\t%s\n' "$behind"
exit 0
fi
@@ -107,11 +178,13 @@ read_stdin
[ "${JSC_VERSION_GUARD:-}" = "off" ] && exit 0
# 只管 Skill 工具
tool=$(json_str tool_name)
# 輸入相容:stdin JSON(Claude 格式)與環境變數(其他四支 CLI 接線時設定)都要收。
# 只讀 stdin 的話,用環境變數餵資料的 CLI 一律拿到空值,檢查會整支靜靜放行。
# 兩者都缺才是真的沒資料,那時照舊安靜降級 exit 0。
tool="${JSC_TOOL_NAME:-${TOOL_NAME:-$(json_str tool_name)}}"
[ -z "$tool" ] || [ "$tool" = "Skill" ] || exit 0
skill=$(json_str skill)
skill="${JSC_SKILL:-${SKILL:-$(json_str skill)}}"
[ -n "$skill" ] || exit 0
# 只管本技能組(jsc-{domain}:{name})
@@ -129,95 +202,44 @@ case "$skill" in
jsc-cli:deploy|jsc-hooks:hooks-install|jsc-cli:models|jsc-meta:*) exit 0 ;;
esac
TTL="${JSC_VERSION_TTL:-600}"
cache_dir="$JSC_HOME/version-cache"
mkdir -p "$cache_dir" 2>/dev/null || true
# 更新指令依實際 CLI 給。印別的 CLI 的指令等於沒給指令,使用者照著打只會失敗。
update_cmd() { # $1=domain
case "$(cli_name)" in
claude)
printf 'claude plugin marketplace update jsc && claude plugin update jsc-%s@jsc' "$1" ;;
codex)
printf 'codex plugin marketplace upgrade jsc' ;;
copilot)
printf 'copilot plugin marketplace update jsc && copilot plugin update jsc-%s@jsc' "$1" ;;
antigravity)
printf 'git -C ~/plugins/%s pull && agy plugin uninstall jsc-%s && agy plugin install ~/plugins/%s' "$1" "$1" "$1" ;;
kiro)
printf 'kiro-cli plugin marketplace update jsc && kiro-cli plugin update jsc-%s@jsc' "$1" ;;
*)
printf '用你的 CLI 的 plugin 更新指令更新 jsc-%s@jsc' "$1" ;;
esac
}
deny() { # $1=訊息
printf '[jsc][版本檢查][ERR]:%s\n' "$1" >&2
printf '更新指令:claude plugin marketplace update jsc && claude plugin update jsc-%s@jsc\n' "$domain" >&2
printf '更新指令:%s\n' "$(update_cmd "$domain")" >&2
printf '更新整組:/jsc-cli:deploy | 確定要略過檢查:JSC_VERSION_GUARD=off\n' >&2
exit 2
}
# 本機實際載入版本:從 installPath 的 plugin.json 讀,不用註冊欄位
local_ver=$(python3 - "$domain" <<'PY' 2>/dev/null
import json, os, sys
domain = sys.argv[1]
try:
reg = json.load(open(os.path.expanduser("~/.claude/plugins/installed_plugins.json")))
except Exception:
sys.exit(0)
entries = reg.get("plugins", {}).get(f"jsc-{domain}@jsc") or []
for e in entries:
p = os.path.join(e.get("installPath", ""), "plugin.json")
try:
print(json.load(open(p)).get("version", "")); sys.exit(0)
except Exception:
continue
# 讀不到實際檔案時退回註冊版本,並在後面標記為次要來源
if entries and entries[0].get("version"):
print(entries[0]["version"])
PY
)
[ -n "$local_ver" ] || deny "讀不到本機 jsc-$domain 的實際載入版本(installed_plugins.json 或該版目錄的 plugin.json 不可用)"
# 讀不到本機實際載入版本就放行:沒有版本證據時擋下等於停掉每一次技能呼叫
local_ver=$(local_version "$domain")
[ -n "$local_ver" ] || exit 0
# 遠端站台與 owner:從已註冊的 jsc marketplace 來源推導,其次 GITEA_HOST
remote_src=$(python3 - <<'PY' 2>/dev/null
import json, os, re
try:
d = json.load(open(os.path.expanduser("~/.claude/plugins/known_marketplaces.json")))
except Exception:
raise SystemExit
url = (d.get("jsc", {}).get("source", {}) or {}).get("url", "")
m = re.match(r"(https?://[^/]+)/([^/]+)/[^/]+?(?:\.git)?/?$", url)
if m:
print(m.group(1), m.group(2))
PY
)
host=$(printf '%s' "$remote_src" | cut -d' ' -f1)
owner=$(printf '%s' "$remote_src" | cut -d' ' -f2)
if [ -z "$host" ] || [ -z "$owner" ]; then
host="${GITEA_HOST:-}"; owner="${JSC_GITEA_OWNER:-plugins}"
fi
[ -n "$host" ] || deny "推導不出 Gitea 站台(known_marketplaces.json 無 jsc 來源,GITEA_HOST 也未設定)"
ho=$(remote_host_owner)
host=$(printf '%s' "$ho" | cut -d' ' -f1)
owner=$(printf '%s' "$ho" | cut -d' ' -f2)
[ -n "$host" ] || exit 0
# 快取
cache="$cache_dir/$domain"
now=$(now_epoch)
remote_ver=""
if [ -f "$cache" ]; then
c_ver=$(cut -d' ' -f1 "$cache" 2>/dev/null)
c_at=$(cut -d' ' -f2 "$cache" 2>/dev/null)
if [ -n "$c_ver" ] && [ -n "$c_at" ] && [ $((now - c_at)) -lt "$TTL" ]; then
remote_ver="$c_ver"
fi
fi
# 遠端版本(走 hook 與 report 共用的快取與 TTL)
remote_ver=$(cached_remote_version "$domain" "$host" "$owner")
[ -n "$remote_ver" ] || exit 0
if [ -z "$remote_ver" ]; then
url="$host/$owner/$domain/raw/branch/master/plugin.json"
# 暫時性網路失敗不該誤判成版本問題,所以失敗重試一次再放棄
for _try in 1 2; do
body=$(curl -sS --max-time 10 "$url" 2>/dev/null) && \
remote_ver=$(printf '%s' "$body" | sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n1)
[ -n "$remote_ver" ] && break
sleep 1
done
[ -n "$remote_ver" ] || deny "查不到 jsc-$domain 的遠端發佈版本($url)。無法確認本機是否為最新,依 fail-closed 規則擋下"
printf '%s %s\n' "$remote_ver" "$now" > "$cache" 2>/dev/null || true
fi
# 語意化比較:只擋「本機 < 遠端」
cmp=$(awk -v a="$local_ver" -v b="$remote_ver" '
BEGIN {
n = split(a, x, "."); m = split(b, y, ".")
for (i = 1; i <= 3; i++) {
xi = (i <= n ? x[i] + 0 : 0); yi = (i <= m ? y[i] + 0 : 0)
if (xi < yi) { print -1; exit }
if (xi > yi) { print 1; exit }
}
print 0
}')
[ "$cmp" = "-1" ] || exit 0
# 只擋「本機 < 遠端」這一種情況
[ "$(ver_cmp "$local_ver" "$remote_ver")" = "-1" ] || exit 0
deny "jsc-$domain 本機版本 $local_ver 落後遠端發佈版本 $remote_ver,本次技能呼叫已擋下"