fix/skillset-audit-compliance-and-guard-fixes #15

Merged
admin merged 3 commits from fix/skillset-audit-compliance-and-guard-fixes into develop 2026-08-25 07:15:00 +00:00
8 changed files with 580 additions and 224 deletions
Showing only changes of commit fedcfc8b05 - Show all commits
+36
View File
@@ -8,6 +8,10 @@
JSC_HOME="${JSC_HOME:-$HOME/.jsc}" JSC_HOME="${JSC_HOME:-$HOME/.jsc}"
mkdir -p "$JSC_HOME/sessions" "$JSC_HOME/usage" 2>/dev/null || true 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(可能為空;非阻塞宿主) # 讀完 stdin(可能為空;非阻塞宿主)
read_stdin() { read_stdin() {
if [ -t 0 ]; then STDIN_JSON=""; else STDIN_JSON=$(cat 2>/dev/null || true); fi 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 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_epoch() { date +%s; }
now_iso() { date -u +%Y-%m-%dT%H:%M:%SZ; } 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" awk -F'\t' -v s="$1" '$1 == "stage" && $2 == s { print $3; exit }' "$TAGS_TSV"
} }
# 某模型的能力標籤。模型鍵與實際 id 雙向包含即視為同一家族 # 某模型的能力標籤。查法:先找完全相同的鍵,沒有才退回「表列鍵是實際 id 的前綴」
#(例:表列 claude-haiku-4-5 對得上 claude-haiku-4-5-20251001);多筆命中取最長鍵。 # 之中最長的一筆(例:表列 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 model_tags() { # $1=模型 id
[ -s "$TAGS_TSV" ] || return 0 [ -s "$TAGS_TSV" ] || return 0
awk -F'\t' -v m="$1" ' 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 } 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" ' "$TAGS_TSV"
} }
+11 -1
View File
@@ -1,9 +1,16 @@
#!/usr/bin/env sh #!/usr/bin/env sh
# session-timer.sh — 記錄工作階段花費時間(供 jsc-log:worklog 取用)。 # 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 mark # Stop/SessionEnd:更新最後活動時間
# session-timer.sh report [sid] # 印出 {sid} {seconds};無紀錄印 0 # 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" HERE=$(dirname "$0"); . "$HERE/lib.sh"
read_stdin read_stdin
sid=$(session_id) sid=$(session_id)
@@ -11,6 +18,9 @@ case "${1:-mark}" in
start) start)
f="$JSC_HOME/sessions/$sid.start" f="$JSC_HOME/sessions/$sid.start"
[ -f "$f" ] || now_epoch > "$f" ;; [ -f "$f" ] || now_epoch > "$f" ;;
restart)
now_epoch > "$JSC_HOME/sessions/$sid.start"
rm -f "$JSC_HOME/sessions/$sid.end" ;;
mark) mark)
now_epoch > "$JSC_HOME/sessions/$sid.end" ;; now_epoch > "$JSC_HOME/sessions/$sid.end" ;;
report) 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 讀該版目錄下的 # 實際載入版本只認 installed_plugins.json 的 installPath 底下那份 plugin.json,
# plugin.json,不能只看註冊在 installed_plugins.json 的版本欄位——那兩者 # 不看註冊在 installed_plugins.json 的版本欄位——那兩者可能不同,註冊值比較新時
# 可能不同,只看註冊值會放過真正被載入的舊版。 # 會放過真正被載入的舊版。讀不到那份檔案就當查不到,安靜放行。
# - 只擋落後。本機版本等於或超前遠端一律放行:開發技能組時本機本來就會 # - 只擋落後。本機版本等於或超前遠端一律放行:開發技能組時本機本來就會
# 超前 master,擋下去會讓維護者自己動不了。 # 超前預設分支,擋下去會讓維護者自己動不了。
# - 遠端版本查不到(離線、站台維護、repo 改名)一律**擋**(fail-closed), # - **只有「本機落後遠端」會擋**。查不到資料一律放行(exit 0):本機版本、
# 避免「查不到就當作沒事」而讓落後版本靜靜跑下去。逃生門見下。 # Gitea 站台、遠端版本全部來自 Claude 的 plugin 檔案與 Gitea API,沒裝
# Claude 或離線的機器一筆都讀不到。那種情況擋下去,等於在沒有任何版本
# 證據時停掉每一次技能呼叫,護欄變成故障點。
# #
# 豁免(這些技能永遠放行): # 豁免(這些技能永遠放行):
# jsc-cli:deploy 更新整組技能的入口,擋了就沒有任何方法更新,會死鎖 # jsc-cli:deploy 更新整組技能的入口,擋了就沒有任何方法更新,會死鎖
@@ -22,84 +29,148 @@
# 逃生門:JSC_VERSION_GUARD=off 完全略過檢查(離線工作時用)。 # 逃生門:JSC_VERSION_GUARD=off 完全略過檢查(離線工作時用)。
# #
# 快取:$JSC_HOME/version-cache/{domain},單行「{版本} {epoch}」, # 快取:$JSC_HOME/version-cache/{domain},單行「{版本} {epoch}」,
# 預設 600 秒內不重查(JSC_VERSION_TTL 可調)。 # 預設 600 秒內不重查(JSC_VERSION_TTL 可調)。hook 與 report 共用同一份。
# #
# 另有一個非 hook 的子指令: # 另有一個非 hook 的子指令:
# version-guard.sh report 把每個已安裝 jsc-* plugin 的版本比對印成 TSV,每行 # version-guard.sh report 把每個已安裝 jsc-* plugin 的版本比對印成 TSV,每行
# 「{domain}<TAB>{本機}<TAB>{遠端}<TAB>{落後|最新|超前|查詢失敗}」, # 「{domain}<TAB>{本機}<TAB>{遠端}<TAB>{落後|最新|超前|查詢失敗}」,
# 最後一行「behind<TAB>{落後個數}」。供 jsc-cli:deploy 判斷要不要 # 最後一行「behind<TAB>{落後個數}」。供 jsc-cli:deploy 判斷要不要
# 把「更新」設成推薦選項。report 只讀不擋,永遠 exit 0。 # 把「更新」設成推薦選項。report 只讀不擋,永遠 exit 0。
# 本機沒有 Claude 的 plugin 註冊檔時改印「noregistry<TAB>{路徑}」
# 再接 behind 0:那代表這台機器無法做版本檢查,跟「全部最新」是兩件事。
HERE=$(dirname "$0"); . "$HERE/lib.sh" 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) # ── report:一次比對所有已安裝的 jsc plugin(非 hook 模式,不讀 stdin)
if [ "${1:-}" = "report" ]; then if [ "${1:-}" = "report" ]; then
python3 - <<'PY' # 註冊檔不存在或讀不到就明講。這裡不能只印 behind 0:呼叫端會把它讀成「都是最新」,
import json, os, re, subprocess, sys # 於是把「這台機器無法做版本檢查」誤報成「不用更新」。
# 舊版用 `tr -d '\n' < "$REG" 2>/dev/null`,那個 2>/dev/null 只蓋住 tr 的 stderr,
home = os.path.expanduser("~") # 蓋不住 shell 開檔失敗的訊息,所以沒裝 Claude 的機器會先漏一行 cannot open。
try: if [ ! -f "$REG" ] || [ ! -r "$REG" ]; then
reg = json.load(open(f"{home}/.claude/plugins/installed_plugins.json")).get("plugins", {}) printf 'noregistry\t%s\n' "$REG"
except Exception: printf 'behind\t0\n'
print("behind\t0"); sys.exit(0) exit 0
fi
host = owner = "" ho=$(remote_host_owner)
try: r_host=$(printf '%s' "$ho" | cut -d' ' -f1)
mk = json.load(open(f"{home}/.claude/plugins/known_marketplaces.json")) r_owner=$(printf '%s' "$ho" | cut -d' ' -f2)
m = re.match(r"(https?://[^/]+)/([^/]+)/[^/]+?(?:\.git)?/?$", behind=0
(mk.get("jsc", {}).get("source", {}) or {}).get("url", "")) domains=$(tr -d '\n' < "$REG" | tr ',' '\n' \
if m: | sed -n 's/.*"jsc-\([a-z0-9][a-z0-9-]*\)@[^"]*"[[:space:]]*:.*/\1/p' | sort -u)
host, owner = m.group(1), m.group(2) for d in $domains; do
except Exception: lv=$(local_version "$d")
pass rv=""
if not host: [ -n "$r_host" ] && rv=$(cached_remote_version "$d" "$r_host" "$r_owner")
host = os.environ.get("GITEA_HOST", "") if [ -z "$rv" ]; then
owner = os.environ.get("JSC_GITEA_OWNER", "plugins") st="查詢失敗"
else
case "$(ver_cmp "$lv" "$rv")" in
def ver(v): -1) st="落後"; behind=$((behind + 1)) ;;
parts = ((v or "").split(".") + ["0", "0", "0"])[:3] 1) st="超前" ;;
return tuple(int(p) if p.isdigit() else 0 for p in parts) *) st="最新" ;;
esac
fi
behind = 0 printf '%s\t%s\t%s\t%s\n' "$d" "${lv:-?}" "${rv:-?}" "$st"
for key in sorted(reg): done
m = re.match(r"^jsc-([^@]+)@", key) printf 'behind\t%s\n' "$behind"
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
exit 0 exit 0
fi fi
@@ -107,11 +178,13 @@ read_stdin
[ "${JSC_VERSION_GUARD:-}" = "off" ] && exit 0 [ "${JSC_VERSION_GUARD:-}" = "off" ] && exit 0
# 只管 Skill 工具 # 輸入相容:stdin JSON(Claude 格式)與環境變數(其他四支 CLI 接線時設定)都要收。
tool=$(json_str tool_name) # 只讀 stdin 的話,用環境變數餵資料的 CLI 一律拿到空值,檢查會整支靜靜放行。
# 兩者都缺才是真的沒資料,那時照舊安靜降級 exit 0。
tool="${JSC_TOOL_NAME:-${TOOL_NAME:-$(json_str tool_name)}}"
[ -z "$tool" ] || [ "$tool" = "Skill" ] || exit 0 [ -z "$tool" ] || [ "$tool" = "Skill" ] || exit 0
skill=$(json_str skill) skill="${JSC_SKILL:-${SKILL:-$(json_str skill)}}"
[ -n "$skill" ] || exit 0 [ -n "$skill" ] || exit 0
# 只管本技能組(jsc-{domain}:{name}) # 只管本技能組(jsc-{domain}:{name})
@@ -129,95 +202,44 @@ case "$skill" in
jsc-cli:deploy|jsc-hooks:hooks-install|jsc-cli:models|jsc-meta:*) exit 0 ;; jsc-cli:deploy|jsc-hooks:hooks-install|jsc-cli:models|jsc-meta:*) exit 0 ;;
esac esac
TTL="${JSC_VERSION_TTL:-600}" # 更新指令依實際 CLI 給。印別的 CLI 的指令等於沒給指令,使用者照著打只會失敗。
cache_dir="$JSC_HOME/version-cache" update_cmd() { # $1=domain
mkdir -p "$cache_dir" 2>/dev/null || true 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=訊息 deny() { # $1=訊息
printf '[jsc][版本檢查][ERR]:%s\n' "$1" >&2 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 printf '更新整組:/jsc-cli:deploy | 確定要略過檢查:JSC_VERSION_GUARD=off\n' >&2
exit 2 exit 2
} }
# 本機實際載入版本:從 installPath 的 plugin.json 讀,不用註冊欄位 # 讀不到本機實際載入版本就放行:沒有版本證據時擋下等於停掉每一次技能呼叫
local_ver=$(python3 - "$domain" <<'PY' 2>/dev/null local_ver=$(local_version "$domain")
import json, os, sys [ -n "$local_ver" ] || exit 0
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 不可用)"
# 遠端站台與 owner:從已註冊的 jsc marketplace 來源推導,其次 GITEA_HOST ho=$(remote_host_owner)
remote_src=$(python3 - <<'PY' 2>/dev/null host=$(printf '%s' "$ho" | cut -d' ' -f1)
import json, os, re owner=$(printf '%s' "$ho" | cut -d' ' -f2)
try: [ -n "$host" ] || exit 0
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 也未設定)"
# 快取 # 遠端版本(走 hook 與 report 共用的快取與 TTL)
cache="$cache_dir/$domain" remote_ver=$(cached_remote_version "$domain" "$host" "$owner")
now=$(now_epoch) [ -n "$remote_ver" ] || exit 0
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
if [ -z "$remote_ver" ]; then # 只擋「本機 < 遠端」這一種情況
url="$host/$owner/$domain/raw/branch/master/plugin.json" [ "$(ver_cmp "$local_ver" "$remote_ver")" = "-1" ] || exit 0
# 暫時性網路失敗不該誤判成版本問題,所以失敗重試一次再放棄
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
deny "jsc-$domain 本機版本 $local_ver 落後遠端發佈版本 $remote_ver,本次技能呼叫已擋下" deny "jsc-$domain 本機版本 $local_ver 落後遠端發佈版本 $remote_ver,本次技能呼叫已擋下"
+8 -15
View File
@@ -6,28 +6,21 @@ description: Wire jsc hooks (STE100 guard, session timer, skill usage logger, SD
# hooks-install — wire jsc hooks into every installed CLI # hooks-install — wire jsc hooks into every installed CLI
Goal: make the five hooks (`ste100-guard.sh`, `session-timer.sh`, `skill-usage.sh`, `sdlc-gate.sh`, `version-guard.sh`) effective in every CLI. Goal: make the five hooks (`ste100-guard.sh`, `session-timer.sh`, `skill-usage.sh`, `sdlc-gate.sh`, `version-guard.sh`) effective in every CLI.
`version-guard.sh` runs on PreToolUse(Skill) and blocks a skill whose locally loaded plugin version is behind the published one. Where a CLI has no pre-tool hook, that guard cannot be wired — say so in the report rather than implying every CLI is covered. Only claude has both PreToolUse and UserPromptSubmit, so only claude reports `wired`. On codex, copilot, antigravity and kiro the version guard cannot be wired at all and the SDLC gate degrades to the skill-step check, so all four report `degraded` — report that gap as the script words it instead of implying every CLI is covered.
Claude wiring is automatic via `hooks.json`. On codex and kiro the SDLC gate degrades to the skill-step check only; the lock file still works because the SDLC skills call `sdlc-gate.sh lock {stage}` directly — that call is where the capability-tag comparison happens, so the gate keeps its force even where the prompt hook cannot be wired. The lock file still works on those four because the SDLC skills call `sdlc-gate.sh lock {stage}` directly — that call is where the capability-tag comparison happens, so the gate keeps its force even where the prompt hook cannot be wired.
The gate needs `$JSC_HOME/model-tags.tsv`; when it is missing, report that `jsc-cli:models` (or `jsc-cli/tools/model-tags.sh sync`) must run once, because `sdlc-gate.sh lock` refuses to lock without it. The gate needs `$JSC_HOME/model-tags.tsv`; when it is missing, report that `jsc-cli:models` (or `jsc-cli/tools/model-tags.sh sync`) must run once, because `sdlc-gate.sh lock` refuses to lock without it.
The detailed flow **MUST run as a sub agent**; the main agent only reports the summary. The detailed flow **MUST run as a sub agent**; the main agent only reports the summary.
## Steps ## Steps
1. Run `jsc-cli/tools/detect-clis.sh`. Done when you hold the list of installed CLIs; when the list is empty, report that and stop. 1. Run `jsc-cli/tools/detect-clis.sh`. Done when you hold the list of installed CLIs; when the list is empty, report that and stop.
2. For each installed CLI, run `tools/wire-cli.sh {cli}`. The script performs the config edit, wrapper alias install, or hook file creation for that CLI, and replaces its `<!-- jsc-hooks -->` (or `# jsc-hooks`) marker block idempotently — reruns never duplicate content. Read its exit code and first output line (`status=wired|degraded|skipped reason=...`), then confirm against the table below: 2. For each installed CLI, run `tools/wire-cli.sh {cli}`. The script owns both the wiring and its verification: it writes the config, alias or hook file inside a `<!-- jsc-hooks -->` (or `# jsc-hooks`) marker block, re-reads every file it wrote, and confirms the block is present and correctly placed before it prints a success status. Trust its first line, `status=wired|degraded|skipped|failed reason=...`. Exit 2 means a bad CLI name, not a wiring outcome — fix the name and rerun. Done when every installed CLI has printed exactly one `status=` line and none exited 2.
3. For each CLI whose status is `failed`, record it: run `tools/report-error.sh --hook wire-cli.sh --exit 4 --summary "{the reason field}" --cli {cli}` and feed the script's `[jsc]` output in on stdin. Done when each `failed` CLI has either an `ERROR_{HASH}` page name on stdout, or an empty exit 0 meaning `JSC_WIKI_REPO_ERROR` and `JSC_WIKI_REPO` are both unset — in that second case carry the reason into step 4 instead. Skip this step when no CLI reported `failed`.
| CLI | Exit / status | Verify | 4. Report the exact `status=` line `tools/wire-cli.sh` printed for each CLI, plus the `ERROR_{HASH}` page for any `failed` one. Done when every detected CLI has exactly one reported status: wired, degraded, skipped or failed, each with its reason.
| --- | --- | --- |
| claude | `status=wired` (exit 0) — hooks.json auto-wires everything, nothing to write | `claude plugin list` shows `jsc-hooks` and `/hooks` shows the registrations |
| codex | `status=degraded` (exit 1) — notify + AGENTS.md prompt fallback | `~/.codex/config.toml` contains the `notify` entry and `AGENTS.md` contains the block |
| copilot | `status=wired` (exit 0) when the CLI is detected, `status=skipped` (exit 3) otherwise | the alias resolves to `jsc-wrap.sh copilot` and `copilot-instructions.md` contains the block |
| antigravity | `status=wired` (exit 0) when the CLI is detected, `status=skipped` (exit 3) otherwise | the alias resolves to `jsc-wrap.sh antigravity` and the rules file contains the block |
| kiro | `status=degraded` (exit 1) | the hook file exists under `.kiro/hooks/` and names the script |
A row counts as done only when its verify check passes. Exit 2 means bad usage (wrong CLI name), not a wiring outcome.
3. Report the exact `status=` line `tools/wire-cli.sh` printed for each CLI; do not reinterpret or recompute the outcome by hand. Done when every detected CLI has exactly one reported status: wired, degraded, or skipped with a reason.
## Notes ## Notes
- The hook scripts accept both stdin JSON and environment variables (`JSC_CLI`, `JSC_SESSION_ID`, `JSC_SKILL`, `JSC_MODEL`); `jsc-wrap.sh` sets the first two itself. - Every hook script accepts both stdin JSON and environment variables (`JSC_CLI`, `JSC_SESSION_ID`, `JSC_SKILL`, `JSC_TOOL_NAME`, `JSC_MODEL`); `jsc-wrap.sh` sets the first two itself.
- `session-timer.sh` takes `start` (keep an existing start time), `restart` (always overwrite it, for a CLI with no session id — kiro), `mark` and `report`. `wire-cli.sh` picks the right one per CLI; do not hand-edit the generated hook files.
- `tools/report-error.sh` is operator- or skill-invoked only. Never wire it to fire from a failing hook: hooks stay silent and exit 0, and a failing hook that reports itself can loop.
- Data lands in `$JSC_HOME` (default `~/.jsc`), consumed by `jsc-log:worklog` and `jsc-log:stats`. - Data lands in `$JSC_HOME` (default `~/.jsc`), consumed by `jsc-log:worklog` and `jsc-log:stats`.
+5 -1
View File
@@ -3,20 +3,24 @@
# 用法: jsc-wrap.sh {cli} [args...] # 用法: jsc-wrap.sh {cli} [args...]
# 行為: 匯出 JSC_CLI 與 JSC_SESSION_ID → session-timer start → 執行 CLI → # 行為: 匯出 JSC_CLI 與 JSC_SESSION_ID → session-timer start → 執行 CLI →
# 結束後 session-timer mark 並以 scan-logs.sh 回填用量,最後回傳 CLI 的結束碼。 # 結束後 session-timer mark 並以 scan-logs.sh 回填用量,最後回傳 CLI 的結束碼。
# 注意: JSC_CLI 存的是 CLI 代號(antigravity、kiro),實際執行的是 cli_bin 對應的
# 執行檔(agy、kiro-cli)。直接拿代號當指令跑會 127,因為沒有這兩個執行檔。
HERE=$(cd "$(dirname "$0")" && pwd) HERE=$(cd "$(dirname "$0")" && pwd)
HOOKS="$HERE/../hooks" HOOKS="$HERE/../hooks"
. "$HOOKS/lib.sh"
cli="${1:-}" cli="${1:-}"
if [ -z "$cli" ]; then if [ -z "$cli" ]; then
echo "用法:jsc-wrap.sh {cli} [args...]" >&2 echo "用法:jsc-wrap.sh {cli} [args...]" >&2
exit 2 exit 2
fi fi
shift shift
bin=$(cli_bin "$cli")
JSC_CLI="$cli" JSC_CLI="$cli"
# 未提供 session id 就自動產生({cli}-時間戳-PID),讓計時與用量共用同一個 session # 未提供 session id 就自動產生({cli}-時間戳-PID),讓計時與用量共用同一個 session
[ -n "${JSC_SESSION_ID:-}" ] || JSC_SESSION_ID="$cli-$(date +%Y%m%d%H%M%S)-$$" [ -n "${JSC_SESSION_ID:-}" ] || JSC_SESSION_ID="$cli-$(date +%Y%m%d%H%M%S)-$$"
export JSC_CLI JSC_SESSION_ID export JSC_CLI JSC_SESSION_ID
sh "$HOOKS/session-timer.sh" start </dev/null sh "$HOOKS/session-timer.sh" start </dev/null
"$cli" "$@" "$bin" "$@"
rc=$? rc=$?
# 收尾:補記結束時間,並從原生日誌回填技能用量 # 收尾:補記結束時間,並從原生日誌回填技能用量
sh "$HOOKS/session-timer.sh" mark </dev/null sh "$HOOKS/session-timer.sh" mark </dev/null
+136
View File
@@ -0,0 +1,136 @@
#!/usr/bin/env sh
# report-error.sh — 失敗回報流程:把一筆 hook 或工具異常寫成 wiki 的 ERROR_{HASH},
# 並在 ERROR_CONTENTS 附上一列索引。頁面內容套用 templates/ 的兩份範本,
# 範本是文案的唯一來源,本腳本只填欄位。
#
# 用法:
# report-error.sh --hook {名稱} --exit {碼} --summary {摘要}
# [--repo {owner}/{repo}] [--cli {名稱}] [--session {id}]
# [--source {stdin|env|command}] [--symptom {現象}]
# [--cause {可能原因}] [--action {處理結果}]
# 相關輸出(stdout/stderr 摘要)由標準輸入讀入,可省略。
#
# 輸出:
# 成功印出「{頁名} {網址}」一行。
# wiki 位置解析不出來(JSC_WIKI_REPO_ERROR 與 JSC_WIKI_REPO 都沒設,或找不到
# gitea.sh)時安靜降級:不輸出、exit 0。回報失敗不該再變成一次失敗。
# 寫入 wiki 失敗才以 exit 4 回報,訊息走 stderr。
#
# 頁名:
# ERROR_{HASH},HASH 取「{owner}/{repo} {hook} {時間}」的 SHA-1 前 8 碼(共用 hash 規則)。
# 時間放進 hash:同一種失敗再發生時要另開新頁,不覆寫舊紀錄。
#
# 誰來呼叫:
# 由操作者手動執行,或由技能步驟執行(`jsc-hooks:hooks-install` 在 wire-cli.sh 回報
# status=failed 時呼叫)。**不接在失敗的 hook 上自動觸發**:hook 一律安靜 exit 0,
# 而且自我回報要走網路寫 wiki,失敗的 hook 再去回報自己會疊出迴圈。
set -u
HERE=$(cd "$(dirname "$0")" && pwd)
ROOT=$(cd "$HERE/.." && pwd)
. "$ROOT/hooks/lib.sh"
STDIN_JSON="" # 本腳本的標準輸入是錯誤輸出摘要,不是 JSON
hook=""; code=""; summary=""; repo=""; cli=""; session=""
source_kind=""; symptom=""; cause=""; action=""
while [ $# -gt 0 ]; do
case "$1" in
--hook) hook="${2:-}"; shift 2 ;;
--exit) code="${2:-}"; shift 2 ;;
--summary) summary="${2:-}"; shift 2 ;;
--repo) repo="${2:-}"; shift 2 ;;
--cli) cli="${2:-}"; shift 2 ;;
--session) session="${2:-}"; shift 2 ;;
--source) source_kind="${2:-}"; shift 2 ;;
--symptom) symptom="${2:-}"; shift 2 ;;
--cause) cause="${2:-}"; shift 2 ;;
--action) action="${2:-}"; shift 2 ;;
*) shift ;;
esac
done
if [ -z "$hook" ] || [ -z "$summary" ]; then
echo "用法:report-error.sh --hook {名稱} --exit {碼} --summary {摘要} [...]" >&2
exit 2
fi
gsh=$(jsc_gitea_sh) || exit 0
wrepo=$(sh "$gsh" wiki-repo ERROR 2>/dev/null) || exit 0
[ -n "$wrepo" ] || exit 0
# 存取庫名稱未指定就取工作目錄的 origin(只用來標記異常屬於哪個存取庫)
if [ -z "$repo" ]; then
origin=$(git config --get remote.origin.url 2>/dev/null || true)
repo=$(printf '%s' "$origin" \
| sed -n 's#.*[/:]\([^/]*\)/\([^/]*\)$#\1/\2#p' | sed 's/\.git$//')
fi
[ -n "$repo" ] || repo="-"
[ -n "$cli" ] || cli=$(cli_name)
[ -n "$session" ] || session=$(session_id)
[ -n "$code" ] || code="-"
[ -n "$source_kind" ] || source_kind="command"
[ -n "$symptom" ] || symptom="$summary"
[ -n "$cause" ] || cause="待查"
[ -n "$action" ] || action="待處理"
ts=$(date +'%Y-%m-%d %H:%M:%S')
ticket_ts=$(date +'%Y%m%d_%H%M%S')
if [ -t 0 ]; then detail=""; else detail=$(cat 2>/dev/null | tr '\n' ' ' | cut -c1-500); fi
[ -n "$detail" ] || detail="(無)"
# 摘要與相關輸出都落在 markdown 表格欄位裡,半形 | 會把欄位切斷,改成全形
detail=$(printf '%s' "$detail" | sed 's/|/|/g')
summary=$(printf '%s' "$summary" | sed 's/|/|/g')
hash=$(sh "$gsh" hash-id "$repo $hook $ts" 2>/dev/null) || exit 0
[ -n "$hash" ] || exit 0
page="ERROR_$hash"
# sed 取代值要先轉義:& 與分隔字元 | 會被 sed 當語法,換行會整行斷掉
esc() { printf '%s' "$1" | tr '\n' ' ' | sed 's/[\\&|]/\\&/g'; }
fill() { sed "s|$1|$(esc "$2")|g"; }
tmp_page=$(mktemp) || exit 0
tmp_list=$(mktemp) || { rm -f "$tmp_page"; exit 0; }
trap 'rm -f "$tmp_page" "$tmp_list"' EXIT
fill '{HASH}' "$hash" < "$ROOT/templates/error-page.md" \
| fill '{yyyy-MM-dd HH:mm:ss}' "$ts" \
| fill '{owner}/{repo}' "$repo" \
| fill '{cli}' "$cli" \
| fill '{session_id}' "$session" \
| fill '{hook_name}' "$hook" \
| fill '{exit_code}' "$code" \
| fill '{error_summary}' "$summary" \
| fill '{現象描述}' "$symptom" \
| fill '{可能原因}' "$cause" \
| fill '{處理方式}' "$action" \
| fill '{stdin / env / command}' "$source_kind" \
| fill '{stdout / stderr 摘要}' "$detail" \
| fill '{yyyyMMdd}_{HHmmss}' "$ticket_ts" > "$tmp_page"
row=$(printf '| %s | [[%s|%s]] | %s | %s | %s | %s |' \
"$ts" "$hook 異常 $ts" "$page" "$repo" "$hook" "$code" "$summary")
# 目錄頁:已存在就把新列附在文末(最新一筆在最後);不存在就用範本建立
if sh "$gsh" wiki-get "$wrepo" ERROR_CONTENTS > "$tmp_list" 2>/dev/null \
&& [ -s "$tmp_list" ]; then
printf '%s\n' "$row" >> "$tmp_list"
else
fill '{yyyy-MM-dd HH:mm:ss}' "$ts" < "$ROOT/templates/error-contents.md" \
| fill '{HASH}' "$hash" \
| fill '{error title}' "$hook 異常 $ts" \
| fill '{owner}/{repo}' "$repo" \
| fill '{hook_name}' "$hook" \
| fill '{exit_code}' "$code" \
| fill '{error_summary}' "$summary" > "$tmp_list"
fi
if ! sh "$gsh" wiki-put "$wrepo" "$page" "$tmp_page" >/dev/null 2>&1; then
echo "[jsc] 寫入 $page 失敗($wrepo)。" >&2
exit 4
fi
if ! sh "$gsh" wiki-put "$wrepo" ERROR_CONTENTS "$tmp_list" >/dev/null 2>&1; then
echo "[jsc] 寫入 ERROR_CONTENTS 失敗($wrepo),$page 已建立。" >&2
exit 4
fi
url=$(sh "$gsh" wiki-url "$wrepo" "$page" 2>/dev/null || true)
printf '%s %s\n' "$page" "$url"
+194 -45
View File
@@ -1,24 +1,39 @@
#!/usr/bin/env sh #!/usr/bin/env sh
# wire-cli.sh — 把 jsc 四支 hook 接線到單一 CLI(供 hooks-install 技能呼叫)。 # wire-cli.sh — 把 jsc 五支 hook 接線到單一 CLI(供 hooks-install 技能呼叫)。
# 用法: wire-cli.sh {claude|codex|copilot|antigravity|kiro} # 用法: wire-cli.sh {claude|codex|copilot|antigravity|kiro}
# 行為(依 CLI 而定,皆為冪等:重跑只取代既有的 jsc-hooks 標記段落,不會重複疊加): # 行為(依 CLI 而定,皆為冪等:重跑只取代既有的 jsc-hooks 標記段落,不會重複疊加):
# claude — 什麼都不用寫,hooks.json 已自動接線四支 hook # claude — 什麼都不用寫,hooks.json 已自動接線五支 hook
# codex — 在 config.toml 設 notify(呼叫 session-timer.sh mark,JSC_CLI=codex); # codex — 在 shell rc 檔加上 codex 別名,轉呼叫 tools/jsc-wrap.sh codex(開始計時);
# 在 config.toml 設 notify(每輪補 session-timer.sh start 再 mark,JSC_CLI=codex);
# 在 AGENTS.md 附加 STE100 規則段落(prompt 降級) # 在 AGENTS.md 附加 STE100 規則段落(prompt 降級)
# copilot — 在 shell rc 檔加上 copilot 別名,轉呼叫 tools/jsc-wrap.sh copilot; # copilot — 在 shell rc 檔加上 copilot 別名,轉呼叫 tools/jsc-wrap.sh copilot;
# 在 copilot-instructions.md 附加 STE100 規則段落 # 在 copilot-instructions.md 附加 STE100 規則段落
# antigravity — 在 shell rc 檔加上 agy 別名,轉呼叫 tools/jsc-wrap.sh antigravity; # antigravity — 在 shell rc 檔加上 agy 別名,轉呼叫 tools/jsc-wrap.sh antigravity;
# 在全域規則檔附加 STE100 規則段落 # 在全域規則檔附加 STE100 規則段落
# kiro — 在工作區 .kiro/hooks/ 下建立 jsc-hooks.json(JSC_CLI=kiro) # kiro — 在工作區 .kiro/hooks/ 下建立 jsc-hooks.json(每輪 mark 加 STE100)
# 與 jsc-hooks-session-start.json(sessionStart 開始計時),皆帶 JSC_CLI=kiro
# #
# 輸出: 第一行固定為 `status={wired|degraded|skipped} reason=...`(可供程式判讀), # 覆蓋範圍要據實回報,不得暗示每個 CLI 都有保護:
# claude 五支 hook 全接,回報 wired
# codex、copilot、antigravity、kiro 只有別名或規則檔,接不上 PreToolUse 與
# UserPromptSubmit,版本前置檢查與 SDLC 模型鎖
# 都沒接上,一律回報 degraded 並在 reason 講明
#
# 寫入後自我驗證,通過才回報成功:每個寫過的檔案重新讀一次,確認標記段落存在且落在
# 正確位置(codex 的 notify 必須是根層鍵,不能被歸進前一張表;kiro 的 JSON 必須成對
# 且 on、run 在最上層)。腳本說寫好了卻寫錯位置,是最難查的失敗,所以驗證放在腳本裡。
#
# 輸出: 第一行固定為 `status={wired|degraded|skipped|failed} reason=...`(可供程式判讀),
# 其後為人類可讀的繁中說明。 # 其後為人類可讀的繁中說明。
# 結束碼: 0=wired(已完整接線) 1=degraded(降級為 prompt/技能步驟檢查) # 結束碼: 0=wired(已完整接線) 1=degraded(降級為 prompt/技能步驟檢查)
# 2=用法錯誤 3=skipped(該 CLI 未偵測到執行檔,略過) # 2=用法錯誤 3=skipped(該 CLI 未偵測到執行檔,略過)
# 4=failed(寫入或驗證沒過,接線沒生效;由 hooks-install 呼叫 report-error.sh 回報)
set -u set -u
HERE=$(cd "$(dirname "$0")" && pwd) HERE=$(cd "$(dirname "$0")" && pwd)
ROOT=$(cd "$HERE/.." && pwd) ROOT=$(cd "$HERE/.." && pwd)
HOOKS="$ROOT/hooks" HOOKS="$ROOT/hooks"
# cli_bin(CLI 代號 → 實際執行檔)的唯一來源在 lib.sh,包裝啟動器也用同一份
. "$HOOKS/lib.sh"
cli="${1:-}" cli="${1:-}"
case "$cli" in case "$cli" in
@@ -28,25 +43,20 @@ case "$cli" in
exit 2 ;; exit 2 ;;
esac esac
# 每個 CLI 對應的實際執行檔名稱(antigravity 的執行檔是 agy,其餘與 CLI 代號同名)
cli_bin() {
case "$1" in
antigravity) printf 'agy' ;;
kiro) printf 'kiro-cli' ;;
*) printf '%s' "$1" ;;
esac
}
# STE100 規則段落的唯一來源:ste100-guard.sh 的實際輸出 # STE100 規則段落的唯一來源:ste100-guard.sh 的實際輸出
ste100_text() { sh "$HOOKS/ste100-guard.sh" 2>/dev/null | sed '/^exit /d'; } ste100_text() { sh "$HOOKS/ste100-guard.sh" 2>/dev/null | sed '/^exit /d'; }
# 以標記整段取代(冪等);標記不存在就在檔尾新增;檔案不存在就建立。 # 以標記整段取代(冪等);標記不存在就在檔尾新增;檔案不存在就建立。
# 適用 markdown 規則檔與 shell rc 檔:這兩種檔案沒有「區段」概念,附在檔尾就對了。
# $1=檔案 $2=開頭標記行 $3=結尾標記行 $4=標記之間要寫入的內容 # $1=檔案 $2=開頭標記行 $3=結尾標記行 $4=標記之間要寫入的內容
replace_block() { replace_block() {
file="$1"; bopen="$2"; bshut="$3"; content="$4" file="$1"; bopen="$2"; bshut="$3"; content="$4"
dir=$(dirname "$file") dir=$(dirname "$file")
mkdir -p "$dir" 2>/dev/null || return 1 mkdir -p "$dir" 2>/dev/null || return 1
touch "$file" 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") block=$(printf '%s\n%s\n%s' "$bopen" "$content" "$bshut")
if grep -qF "$bopen" "$file" 2>/dev/null; then if grep -qF "$bopen" "$file" 2>/dev/null; then
awk -v bopen="$bopen" -v bshut="$bshut" -v block="$block" ' awk -v bopen="$bopen" -v bshut="$bshut" -v block="$block" '
@@ -54,14 +64,85 @@ replace_block() {
$0==bshut { skip=0; next } $0==bshut { skip=0; next }
skip { next } skip { next }
{ print } { print }
' "$file" > "$file.jsc-tmp" 2>/dev/null && mv "$file.jsc-tmp" "$file" ' "$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 else
printf '\n%s\n' "$block" >> "$file" # 包一層子 shell 才蓋得住 shell 自己的開檔失敗訊息(>> 失敗時那行不走命令的 stderr)
( printf '\n%s\n' "$block" >> "$file" ) 2>/dev/null || return 1
fi 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=鍵名)。
# 解析失敗(括號不成對、字串沒收尾)也回傳非 0,所以這支同時當語法檢查用。
json_top_key() {
awk -v k="$2" '
{ 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++
}
exit((found && !bad && depth == 0) ? 0 : 1)
}' "$1" 2>/dev/null
}
# 找出可寫入別名的 shell rc 檔;都不存在就以 ~/.bashrc 為預設(自動建立)。 # 找出可寫入別名的 shell rc 檔;都不存在就以 ~/.bashrc 為預設(自動建立)。
# 印出找到/建立的 rc 檔路徑,一行一個。 # 印出找到或建立的 rc 檔路徑,一行一個。
rc_files() { rc_files() {
found="" found=""
for f in "$HOME/.bashrc" "$HOME/.zshrc" "$HOME/.config/fish/config.fish"; do for f in "$HOME/.bashrc" "$HOME/.zshrc" "$HOME/.config/fish/config.fish"; do
@@ -70,32 +151,74 @@ rc_files() {
[ -n "$found" ] || printf '%s\n' "$HOME/.bashrc" [ -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 ]
}
skip() { # $1=reason skip() { # $1=reason
printf 'status=skipped reason=%s\n' "$1" printf 'status=skipped reason=%s\n' "$1"
echo "[jsc] 略過:$1" echo "[jsc] 略過:$1"
exit 3 exit 3
} }
fail() { # $1=reason
printf 'status=failed reason=%s\n' "$1"
echo "[jsc] 接線沒生效:$1" >&2
echo "[jsc] 請以 tools/report-error.sh 回報這次失敗,再修好原因重跑本腳本。" >&2
exit 4
}
case "$cli" in case "$cli" in
claude) claude)
bin=$(cli_bin claude)
command -v "$bin" >/dev/null 2>&1 || skip "未偵測到 claude 執行檔"
[ -f "$HOOKS/hooks.json" ] || fail "找不到 $HOOKS/hooks.json,claude 接不到任何 hook"
printf 'status=wired reason=%s\n' "hooks.json 自動接線" printf 'status=wired reason=%s\n' "hooks.json 自動接線"
echo "[jsc] claude:由 hooks/hooks.json 自動接線全部四支 hook,無需寫入設定。" echo "[jsc] claude:由 hooks/hooks.json 自動接線全部五支 hook,無需寫入設定。"
exit 0 ;; exit 0 ;;
codex) codex)
command -v "$(cli_bin codex)" >/dev/null 2>&1 || skip "未偵測到 codex 執行檔" bin=$(cli_bin codex)
command -v "$bin" >/dev/null 2>&1 || skip "未偵測到 codex 執行檔"
CODEX_HOME="${CODEX_HOME:-$HOME/.codex}" CODEX_HOME="${CODEX_HOME:-$HOME/.codex}"
config="$CODEX_HOME/config.toml" config="$CODEX_HOME/config.toml"
agents="$CODEX_HOME/AGENTS.md" agents="$CODEX_HOME/AGENTS.md"
notify_line="notify = [\"env\", \"JSC_CLI=codex\", \"sh\", \"$HOOKS/session-timer.sh\", \"mark\"]" # codex 的 notify 只在每一輪結束時觸發,沒有工作階段開始事件,所以計時分兩段接:
replace_block "$config" "# jsc-hooks" "# /jsc-hooks" "$notify_line" \ # 1. shell 別名走 jsc-wrap.sh:啟動當下就 session-timer start,並給這次工作階段
|| skip "無法寫入 $config" # 一個 JSC_SESSION_ID,codex 內觸發的 notify 會沿用同一個 id。
# 2. notify 每輪先補 start 再 mark。start 已有紀錄就不動,所以沒走別名啟動時
# 仍拿得到起始時間(從第一輪算起)。少了這一段,worklog 只會拿到 0 秒。
timer="sh '$HOOKS/session-timer.sh'"
notify_line="notify = [\"env\", \"JSC_CLI=codex\", \"sh\", \"-c\", \"$timer start </dev/null; $timer mark </dev/null\"]"
alias_line="alias $bin='sh \"$HERE/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 會靜靜失效)"
write_alias_rc "jsc-hooks:codex" "$alias_line" || fail "無法把 $bin 別名寫進 shell rc 檔"
replace_block "$agents" "<!-- jsc-hooks -->" "<!-- /jsc-hooks -->" "$(ste100_text)" \ replace_block "$agents" "<!-- jsc-hooks -->" "<!-- /jsc-hooks -->" "$(ste100_text)" \
|| skip "無法寫入 $agents" || fail "無法寫入 $agents"
printf 'status=degraded reason=%s\n' "STE100 降級為 prompt 檔,SDLC 模型鎖降級為技能步驟檢查" has_block "$agents" "<!-- jsc-hooks -->" || fail "$agents 寫入後讀不到 jsc-hooks 標記段落"
echo "[jsc] codex:已設定 $config 的 notify 呼叫 session-timer.sh mark。" printf 'status=degraded reason=%s\n' "STE100 降級為 prompt 檔,SDLC 模型鎖降級為技能步驟檢查,無 pre-tool hook 可接版本前置檢查"
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。"
echo "[jsc] codex:已在 $agents 寫入 STE100 規則段落(prompt 降級)。" echo "[jsc] codex:已在 $agents 寫入 STE100 規則段落(prompt 降級)。"
echo "[jsc] codex:SDLC 模型鎖降級為技能步驟檢查,鎖檔仍由 sdlc-gate.sh lock 寫入。" echo "[jsc] codex:SDLC 模型鎖降級為技能步驟檢查,鎖檔仍由 sdlc-gate.sh lock 寫入。"
echo "[jsc] codex:版本前置檢查接不上(codex 沒有 pre-tool hook),改由 /jsc-cli:deploy 定期更新。"
exit 1 ;; exit 1 ;;
copilot) copilot)
@@ -103,47 +226,73 @@ case "$cli" in
command -v "$bin" >/dev/null 2>&1 || skip "未偵測到 copilot 執行檔" command -v "$bin" >/dev/null 2>&1 || skip "未偵測到 copilot 執行檔"
instr="${JSC_COPILOT_INSTRUCTIONS:-$HOME/.config/copilot/copilot-instructions.md}" instr="${JSC_COPILOT_INSTRUCTIONS:-$HOME/.config/copilot/copilot-instructions.md}"
alias_line="alias $bin='sh \"$HERE/jsc-wrap.sh\" copilot'" alias_line="alias $bin='sh \"$HERE/jsc-wrap.sh\" copilot'"
rc_files | while IFS= read -r rc; do write_alias_rc "jsc-hooks:copilot" "$alias_line" || fail "無法把 $bin 別名寫進 shell rc 檔"
replace_block "$rc" "# jsc-hooks:copilot" "# /jsc-hooks:copilot" "$alias_line"
done
replace_block "$instr" "<!-- jsc-hooks -->" "<!-- /jsc-hooks -->" "$(ste100_text)" \ replace_block "$instr" "<!-- jsc-hooks -->" "<!-- /jsc-hooks -->" "$(ste100_text)" \
|| skip "無法寫入 $instr" || fail "無法寫入 $instr"
printf 'status=wired reason=%s\n' "wrapper 別名 + 規則檔已接線" has_block "$instr" "<!-- jsc-hooks -->" || fail "$instr 寫入後讀不到 jsc-hooks 標記段落"
printf 'status=degraded reason=%s\n' "STE100 降級為 prompt 檔,SDLC 模型鎖降級為技能步驟檢查,無 pre-tool hook 可接版本前置檢查"
echo "[jsc] copilot:已在 shell rc 加上 $bin 別名,轉呼叫 tools/jsc-wrap.sh copilot。" echo "[jsc] copilot:已在 shell rc 加上 $bin 別名,轉呼叫 tools/jsc-wrap.sh copilot。"
echo "[jsc] copilot:已在 $instr 寫入 STE100 規則段落。" echo "[jsc] copilot:已在 $instr 寫入 STE100 規則段落(prompt 降級)。"
exit 0 ;; echo "[jsc] copilot:別名要開新的 shell 或重新 source rc 檔才生效。"
echo "[jsc] copilot:SDLC 模型鎖降級為技能步驟檢查,鎖檔仍由 sdlc-gate.sh lock 寫入。"
echo "[jsc] copilot:版本前置檢查接不上(copilot 沒有 pre-tool hook),改由 /jsc-cli:deploy 定期更新。"
exit 1 ;;
antigravity) antigravity)
bin=$(cli_bin antigravity) bin=$(cli_bin antigravity)
command -v "$bin" >/dev/null 2>&1 || skip "未偵測到 antigravity(agy)執行檔" command -v "$bin" >/dev/null 2>&1 || skip "未偵測到 antigravity(agy)執行檔"
rules="${JSC_ANTIGRAVITY_RULES:-$HOME/.antigravity/AGENTS.md}" rules="${JSC_ANTIGRAVITY_RULES:-$HOME/.antigravity/AGENTS.md}"
alias_line="alias $bin='sh \"$HERE/jsc-wrap.sh\" antigravity'" alias_line="alias $bin='sh \"$HERE/jsc-wrap.sh\" antigravity'"
rc_files | while IFS= read -r rc; do write_alias_rc "jsc-hooks:antigravity" "$alias_line" || fail "無法把 $bin 別名寫進 shell rc 檔"
replace_block "$rc" "# jsc-hooks:antigravity" "# /jsc-hooks:antigravity" "$alias_line"
done
replace_block "$rules" "<!-- jsc-hooks -->" "<!-- /jsc-hooks -->" "$(ste100_text)" \ replace_block "$rules" "<!-- jsc-hooks -->" "<!-- /jsc-hooks -->" "$(ste100_text)" \
|| skip "無法寫入 $rules" || fail "無法寫入 $rules"
printf 'status=wired reason=%s\n' "wrapper 別名 + 規則檔已接線" has_block "$rules" "<!-- jsc-hooks -->" || fail "$rules 寫入後讀不到 jsc-hooks 標記段落"
printf 'status=degraded reason=%s\n' "STE100 降級為 prompt 檔,SDLC 模型鎖降級為技能步驟檢查,無 pre-tool hook 可接版本前置檢查"
echo "[jsc] antigravity:已在 shell rc 加上 $bin 別名,轉呼叫 tools/jsc-wrap.sh antigravity。" echo "[jsc] antigravity:已在 shell rc 加上 $bin 別名,轉呼叫 tools/jsc-wrap.sh antigravity。"
echo "[jsc] antigravity:已在 $rules 寫入 STE100 規則段落。" echo "[jsc] antigravity:已在 $rules 寫入 STE100 規則段落(prompt 降級)。"
exit 0 ;; echo "[jsc] antigravity:別名要開新的 shell 或重新 source rc 檔才生效。"
echo "[jsc] antigravity:SDLC 模型鎖降級為技能步驟檢查,鎖檔仍由 sdlc-gate.sh lock 寫入。"
echo "[jsc] antigravity:版本前置檢查接不上(antigravity 沒有 pre-tool hook),改由 /jsc-cli:deploy 定期更新。"
exit 1 ;;
kiro) kiro)
command -v "$(cli_bin kiro)" >/dev/null 2>&1 || skip "未偵測到 kiro-cli 執行檔" command -v "$(cli_bin kiro)" >/dev/null 2>&1 || skip "未偵測到 kiro-cli 執行檔"
hookdir="./.kiro/hooks" hookdir="./.kiro/hooks"
hookfile="$hookdir/jsc-hooks.json" hookfile="$hookdir/jsc-hooks.json"
mkdir -p "$hookdir" 2>/dev/null || skip "無法建立 $hookdir" startfile="$hookdir/jsc-hooks-session-start.json"
cat > "$hookfile" 2>/dev/null <<EOF || skip "無法寫入 $hookfile" 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 \\"$HOOKS/session-timer.sh\\" restart </dev/null"
}
EOF
cat > "$hookfile" 2>/dev/null <<EOF || fail "無法寫入 $hookfile"
{ {
"name": "jsc-hooks", "name": "jsc-hooks",
"description": "jsc session timer + STE100 guard bridge (auto-generated by jsc-hooks:hooks-install, do not edit by hand)", "description": "jsc session timer + STE100 guard bridge (auto-generated by jsc-hooks:hooks-install, do not edit by hand)",
"on": ["sessionStart", "sessionEnd", "userPromptSubmit"], "on": ["sessionEnd", "userPromptSubmit"],
"env": { "JSC_CLI": "kiro" }, "env": { "JSC_CLI": "kiro" },
"run": "sh \\"$HOOKS/session-timer.sh\\" mark; sh \\"$HOOKS/ste100-guard.sh\\"" "run": "sh \\"$HOOKS/session-timer.sh\\" mark </dev/null; sh \\"$HOOKS/ste100-guard.sh\\" </dev/null"
} }
EOF EOF
printf 'status=degraded reason=%s\n' "SDLC 模型鎖降級為技能步驟檢查" for f in "$startfile" "$hookfile"; do
echo "[jsc] kiro:已建立 $hookfile(JSC_CLI=kiro)。" 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"
printf 'status=degraded reason=%s\n' "SDLC 模型鎖降級為技能步驟檢查,無 pre-tool hook 可接版本前置檢查"
echo "[jsc] kiro:已建立 $startfile(sessionStart 開始計時)與 $hookfile(JSC_CLI=kiro),兩份都已驗證。"
echo "[jsc] kiro:SDLC 模型鎖降級為技能步驟檢查,鎖檔仍由 sdlc-gate.sh lock 寫入。" echo "[jsc] kiro:SDLC 模型鎖降級為技能步驟檢查,鎖檔仍由 sdlc-gate.sh lock 寫入。"
echo "[jsc] kiro:版本前置檢查接不上(kiro 沒有 pre-tool hook),改由 /jsc-cli:deploy 定期更新。"
exit 1 ;; exit 1 ;;
esac esac