發佈 jsc-hooks 0.0.8:技能版本前置檢查 #13

Merged
admin merged 3 commits from develop into master 2026-08-25 04:56:23 +00:00
7 changed files with 209 additions and 13 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "jsc-hooks", "name": "jsc-hooks",
"version": "0.0.7", "version": "0.0.8",
"description": "跨 CLI hooks:STE100 語言強制、工時計時、技能用量記錄、SDLC 模型鎖", "description": "跨 CLI hooks:STE100 語言強制、工時計時、技能用量記錄、SDLC 模型鎖",
"skills": "./skills", "skills": "./skills",
"author": { "author": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "jsc-hooks", "name": "jsc-hooks",
"version": "0.0.7", "version": "0.0.8",
"description": "跨 CLI hooks:STE100 語言強制、工時計時、技能用量記錄、SDLC 模型鎖", "description": "跨 CLI hooks:STE100 語言強制、工時計時、技能用量記錄、SDLC 模型鎖",
"skills": "./skills" "skills": "./skills"
} }
+3
View File
@@ -24,6 +24,7 @@ Marketplace 統一為 `jsc`(https://gitea.jsc.idv.tw/plugins/meta.git),安
| --- | --- | --- | | --- | --- | --- |
| `hooks/ste100-guard.sh` | UserPromptSubmit | 注入 STE100 繁體中文輸出規則(hook > prompt 強制層) | | `hooks/ste100-guard.sh` | UserPromptSubmit | 注入 STE100 繁體中文輸出規則(hook > prompt 強制層) |
| `hooks/session-timer.sh` | SessionStart / Stop / SessionEnd | 記錄工作階段起訖;`report` 子指令供 `jsc-log:worklog` 取花費時間 | | `hooks/session-timer.sh` | SessionStart / Stop / SessionEnd | 記錄工作階段起訖;`report` 子指令供 `jsc-log:worklog` 取花費時間 |
| `hooks/version-guard.sh` | PreToolUse(Skill) | 技能使用前的版本前置檢查:本機**實際載入**版本落後遠端發佈版本就以 exit 2 擋下該次呼叫並提示更新指令。只擋落後(超前放行,開發技能組時本機本來就會超前);遠端查不到一律擋(fail-closed),逃生門 `JSC_VERSION_GUARD=off`。豁免 `jsc-cli:deploy`、`jsc-hooks:hooks-install`、`jsc-cli:models`、`jsc-meta:*` |
| `hooks/skill-usage.sh` | PostToolUse(Skill) | 記錄技能使用與呼叫鏈到 `$JSC_HOME/usage/*.jsonl`,供 `jsc-log:stats` 統計 | | `hooks/skill-usage.sh` | PostToolUse(Skill) | 記錄技能使用與呼叫鏈到 `$JSC_HOME/usage/*.jsonl`,供 `jsc-log:stats` 統計 |
| `hooks/sdlc-gate.sh` | UserPromptSubmit | SDLC 階段能力標籤閘門與模型鎖:`lock {stage}` 由 jsc-sdlc 階段技能呼叫,從 transcript 讀出實際模型 id 比對該階段必要標籤(`$JSC_HOME/model-tags.tsv`),不符就拒絕上鎖;`check` 在模型不符時以 exit 2 擋下該輪提示(其他 hook 一律 exit 0,此處是刻意例外);`unlock` 為逃生門 | | `hooks/sdlc-gate.sh` | UserPromptSubmit | SDLC 階段能力標籤閘門與模型鎖:`lock {stage}` 由 jsc-sdlc 階段技能呼叫,從 transcript 讀出實際模型 id 比對該階段必要標籤(`$JSC_HOME/model-tags.tsv`),不符就拒絕上鎖;`check` 在模型不符時以 exit 2 擋下該輪提示(其他 hook 一律 exit 0,此處是刻意例外);`unlock` 為逃生門 |
@@ -64,6 +65,8 @@ Claude 由 `hooks/hooks.json` 自動接線;其他 CLI 用 `hooks-install` 技
| 變數 | 用途 | 未設定時 | | 變數 | 用途 | 未設定時 |
| --- | --- | --- | | --- | --- | --- |
| `JSC_HOME` | Hook 資料目錄 | 預設 `~/.jsc` | | `JSC_HOME` | Hook 資料目錄 | 預設 `~/.jsc` |
| `JSC_VERSION_GUARD` | 設 `off` 完全略過版本前置檢查(離線工作用) | 啟用檢查 |
| `JSC_VERSION_TTL` | 遠端版本查詢的快取秒數 | 預設 600 |
| `JSC_CLI` / `JSC_SESSION_ID` / `JSC_SKILL` | 非 Claude CLI 接線時由 `tools/jsc-wrap.sh` 或接線設定提供 | 安靜降級 | | `JSC_CLI` / `JSC_SESSION_ID` / `JSC_SKILL` | 非 Claude CLI 接線時由 `tools/jsc-wrap.sh` 或接線設定提供 | 安靜降級 |
| `JSC_MODEL` | 非 Claude CLI 的目前模型,供 `sdlc-gate.sh` 比對;優先序在 transcript 實際值與 stdin `model` 之後 | 改讀 `~/.claude/settings.json`,再不行就安靜降級 | | `JSC_MODEL` | 非 Claude CLI 的目前模型,供 `sdlc-gate.sh` 比對;優先序在 transcript 實際值與 stdin `model` 之後 | 改讀 `~/.claude/settings.json`,再不行就安靜降級 |
+56 -8
View File
@@ -1,22 +1,70 @@
{ {
"hooks": { "hooks": {
"SessionStart": [ "SessionStart": [
{ "hooks": [ { "type": "command", "command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/session-timer.sh\" start" } ] } {
"hooks": [
{
"type": "command",
"command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/session-timer.sh\" start"
}
]
}
], ],
"UserPromptSubmit": [ "UserPromptSubmit": [
{ "hooks": [ {
{ "type": "command", "command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/ste100-guard.sh\"" }, "hooks": [
{ "type": "command", "command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/sdlc-gate.sh\" check" } {
] } "type": "command",
"command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/ste100-guard.sh\""
},
{
"type": "command",
"command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/sdlc-gate.sh\" check"
}
]
}
], ],
"Stop": [ "Stop": [
{ "hooks": [ { "type": "command", "command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/session-timer.sh\" mark" } ] } {
"hooks": [
{
"type": "command",
"command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/session-timer.sh\" mark"
}
]
}
], ],
"SessionEnd": [ "SessionEnd": [
{ "hooks": [ { "type": "command", "command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/session-timer.sh\" mark" } ] } {
"hooks": [
{
"type": "command",
"command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/session-timer.sh\" mark"
}
]
}
],
"PreToolUse": [
{
"matcher": "Skill",
"hooks": [
{
"type": "command",
"command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/version-guard.sh\""
}
]
}
], ],
"PostToolUse": [ "PostToolUse": [
{ "matcher": "Skill", "hooks": [ { "type": "command", "command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/skill-usage.sh\"" } ] } {
"matcher": "Skill",
"hooks": [
{
"type": "command",
"command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/skill-usage.sh\""
}
]
}
] ]
} }
} }
+144
View File
@@ -0,0 +1,144 @@
#!/usr/bin/env sh
# version-guard.sh — 技能使用前的版本前置檢查(PreToolUse,matcher: Skill)。
#
# 本機版本落後遠端發佈版本時擋下該次技能呼叫,並提示更新指令。
#
# 判準與取值:
# - 比對對象是「遠端發佈版本」與「本機**實際載入**的版本」。
# 實際載入版本要從 installed_plugins.json 的 installPath 讀該版目錄下的
# plugin.json,不能只看註冊在 installed_plugins.json 的版本欄位——那兩者
# 可能不同,只看註冊值會放過真正被載入的舊版。
# - 只擋落後。本機版本等於或超前遠端一律放行:開發技能組時本機本來就會
# 超前 master,擋下去會讓維護者自己動不了。
# - 遠端版本查不到(離線、站台維護、repo 改名)一律**擋**(fail-closed),
# 避免「查不到就當作沒事」而讓落後版本靜靜跑下去。逃生門見下。
#
# 豁免(這些技能永遠放行):
# jsc-cli:deploy 更新整組技能的入口,擋了就沒有任何方法更新,會死鎖
# jsc-hooks:hooks-install 更新後要重新接線,擋了會讓更新做一半卡住
# jsc-cli:models SDLC 閘門依賴它產生 model-tags.tsv
# jsc-meta:* 開發技能組本身的工具,擋了就修不了技能組
#
# 逃生門:JSC_VERSION_GUARD=off 完全略過檢查(離線工作時用)。
#
# 快取:$JSC_HOME/version-cache/{domain},單行「{版本} {epoch}」,
# 預設 600 秒內不重查(JSC_VERSION_TTL 可調)。
HERE=$(dirname "$0"); . "$HERE/lib.sh"
read_stdin
[ "${JSC_VERSION_GUARD:-}" = "off" ] && exit 0
# 只管 Skill 工具
tool=$(json_str tool_name)
[ -z "$tool" ] || [ "$tool" = "Skill" ] || exit 0
skill=$(json_str skill)
[ -n "$skill" ] || exit 0
# 只管本技能組(jsc-{domain}:{name})
case "$skill" in
jsc-*:*) ;;
*) exit 0 ;;
esac
domain=${skill#jsc-}
domain=${domain%%:*}
[ -n "$domain" ] || exit 0
# 豁免清單
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
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 '更新整組:/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 不可用)"
# 遠端站台與 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 也未設定)"
# 快取
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
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
deny "jsc-$domain 本機版本 $local_ver 落後遠端發佈版本 $remote_ver,本次技能呼叫已擋下"
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "jsc-hooks", "name": "jsc-hooks",
"version": "0.0.7", "version": "0.0.8",
"description": "跨 CLI hooks:STE100 語言強制、工時計時、技能用量記錄、SDLC 模型鎖", "description": "跨 CLI hooks:STE100 語言強制、工時計時、技能用量記錄、SDLC 模型鎖",
"skills": "./skills/" "skills": "./skills/"
} }
+3 -2
View File
@@ -1,11 +1,12 @@
--- ---
name: hooks-install name: hooks-install
description: Wire jsc hooks (STE100 guard, session timer, skill usage logger, SDLC model gate) into every installed AI CLI. Detect CLIs via jsc-cli detect-clis.sh, then apply native hook config, the jsc-wrap.sh launcher, or an instruction-file fallback per CLI. Use after installing or updating the jsc plugin set; not for writing new hooks. description: Wire jsc hooks (STE100 guard, session timer, skill usage logger, SDLC model gate, plugin version guard) into every installed AI CLI. Detect CLIs via jsc-cli detect-clis.sh, then apply native hook config, the jsc-wrap.sh launcher, or an instruction-file fallback per CLI. Use after installing or updating the jsc plugin set; not for writing new hooks.
--- ---
# hooks-install — wire jsc hooks into every installed CLI # hooks-install — wire jsc hooks into every installed CLI
Goal: make the four hooks (`ste100-guard.sh`, `session-timer.sh`, `skill-usage.sh`, `sdlc-gate.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.
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. 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 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.