From 2329e4d7099132e4bc3ccd29d68c102e00acd577 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Mon, 17 Aug 2026 14:53:35 +0800 Subject: [PATCH] =?UTF-8?q?feat(spec-version-guard):=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E7=89=88=E6=9C=AC=E5=89=8D=E7=BD=AE=E6=AA=A2=E6=9F=A5=E8=A6=8F?= =?UTF-8?q?=E7=AF=84=E3=80=81=E5=85=B1=E7=94=A8=E8=85=B3=E6=9C=AC=E8=88=87?= =?UTF-8?q?=20hook?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 spec-version-guard 規範(定義遠端發佈版本 vs 當前實際載入版本的比對規則、 fail-closed、錯誤訊息格式)與 scripts/version-guard.mjs(hook/CLI 雙模式, hook 模式輸出 Claude Code/Copilot 相容的 PreToolUse deny JSON);spec-preflight 的載入順序補上版本檢查第 0 步;新增 hooks/hooks.json 掛 PreToolUse; do-wiki/models/plan-wiki/plugins-uninstall/todo-wiki 五個 skill 檔頭引用新規範 (plugins-install 刻意排除,避免版本落後時擋住自己的修復手段)。 Co-Authored-By: Claude Sonnet 5 --- hooks/hooks.json | 17 +++ scripts/version-guard.mjs | 175 +++++++++++++++++++++++++++++ skills/do-wiki/SKILL.md | 2 +- skills/models/SKILL.md | 2 +- skills/plan-wiki/SKILL.md | 2 +- skills/plugins-uninstall/SKILL.md | 2 +- skills/spec-preflight/SKILL.md | 19 +++- skills/spec-version-guard/SKILL.md | 78 +++++++++++++ skills/todo-wiki/SKILL.md | 2 +- 9 files changed, 291 insertions(+), 8 deletions(-) create mode 100644 hooks/hooks.json create mode 100644 scripts/version-guard.mjs create mode 100644 skills/spec-version-guard/SKILL.md diff --git a/hooks/hooks.json b/hooks/hooks.json new file mode 100644 index 0000000..d03c35e --- /dev/null +++ b/hooks/hooks.json @@ -0,0 +1,17 @@ +{ + "hooks": { + "PreToolUse": [ + { + "matcher": "Read|Write|Edit|MultiEdit|NotebookEdit|Glob|Grep|LS|Bash", + "hooks": [ + { + "type": "command", + "command": "node --use-system-ca \"${CLAUDE_PLUGIN_ROOT}/scripts/version-guard.mjs\"", + "timeout": 15, + "statusMessage": "檢查 jsc-shared 版本…" + } + ] + } + ] + } +} diff --git a/scripts/version-guard.mjs b/scripts/version-guard.mjs new file mode 100644 index 0000000..2d6a188 --- /dev/null +++ b/scripts/version-guard.mjs @@ -0,0 +1,175 @@ +#!/usr/bin/env node +'use strict'; +/** + * version-guard.mjs — 依 /jsc-shared:spec-version-guard 規範,比對「遠端發佈版本(Gitea + * master 的 plugin.json)」與「當前實際載入版本(本次呼叫實際讀到的 plugin 目錄)」, + * 兩者不同或查不到遠端版本一律 fail-closed(exit 非 0)。可被 hook(PreToolUse/SessionStart) + * 與 agent/CLI 兩種路徑呼叫。 + * + * 用法: + * node version-guard.mjs [--plugin-root ] [--host ] [--timeout-ms ] + * + * --plugin-root 本地 plugin 根目錄(含 plugin.json)。省略時使用 $CLAUDE_PLUGIN_ROOT; + * 兩者皆無則視為無法判定,fail-closed。 + * --host Gitea 主機,省略時依序取 $GITEA_HOST,再退回 gitea.jsc.idv.tw。 + * --timeout-ms 遠端查詢逾時毫秒數,預設 5000。 + * + * 兩種呼叫模式(自動偵測,不需額外旗標): + * 1. hook 模式:stdin 餵進合法 JSON(Claude Code/Copilot 的 PreToolUse event)時, + * 改用 `persona/hooks/_hook.mjs` 已驗證可行的 stdout JSON 回應機制 + * (`{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"deny"|"allow",...}}`), + * exit code 固定 0(是否阻擋由 JSON 內容決定,不是 exit code)。 + * 2. CLI/測試模式:stdin 沒有合法 JSON(互動終端機或空管線)時,用 exit code 表示結果 + * (0=相符,非 0=阻擋),訊息走 stderr——供人工測試與端到端驗證使用。 + * + * 更新時間:2026/08/17 14:25:00 + * 相依:Node.js 標準內建功能(fetch/fs/path),無外部套件;需 Node ≥ 20.12 並以 + * `node --use-system-ca` 啟動,讓 fetch 走系統信任的 CA store(而非 Node 內建的 + * Mozilla CA 清單)——在企業網路/代理攔截 TLS 的環境下,curl 等工具通常已信任 + * 系統憑證,但 Node fetch 預設不會,若不加這個旗標會誤判成「查無法取得遠端版本」 + * 而非真正的版本落差;呼叫端(hooks.json)的 command 必須帶上此旗標。 + * 機密:不涉及 token(遠端 raw plugin.json 端點匿名可讀),輸出仍套用 + * shared/scripts/lib/redact-patterns.json 的遮蔽規則以防萬一。 + * 退出碼(CLI/測試模式):0=版本相符;2=版本不符、查不到遠端、或本地版本無法判定(皆為 fail-closed)。 + * hook 模式固定 exit 0,改用 stdout JSON 的 permissionDecision 表示阻擋與否(見上方兩種呼叫模式)。 + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { log } from './lib/log.mjs'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const REDACT_RULES = JSON.parse(fs.readFileSync(path.join(__dirname, 'lib', 'redact-patterns.json'), 'utf8')).rules; + +function redact(text) { + let out = text; + for (const rule of REDACT_RULES) { + out = out.replace(new RegExp(rule.pattern, 'g'), rule.replacement); + } + return out; +} + +function parseArgs(argv) { + const args = { pluginRoot: null, host: null, timeoutMs: 5000 }; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === '--plugin-root') args.pluginRoot = argv[++i]; + else if (a === '--host') args.host = argv[++i]; + else if (a === '--timeout-ms') args.timeoutMs = Number(argv[++i]); + else { + log('ERR', `未知參數:${redact(a)}`, '版本檢查'); + process.exit(1); + } + } + return args; +} + +function resolvePluginRoot(args) { + return args.pluginRoot || process.env.CLAUDE_PLUGIN_ROOT || null; +} + +function resolveHost(args) { + return args.host || process.env.GITEA_HOST || 'gitea.jsc.idv.tw'; +} + +/** 從本地 plugin.json 的 name(例如 "jsc-shared")推得 repo 短名("shared")。 */ +function shortNameFromPluginName(name) { + return name.startsWith('jsc-') ? name.slice('jsc-'.length) : name; +} + +async function fetchRemoteVersion(host, shortName, timeoutMs) { + const url = `https://${host}/api/v1/repos/plugins/${shortName}/raw/plugin.json?ref=master`; + const res = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) }); + if (!res.ok) { + throw new Error(`HTTP ${res.status}`); + } + const body = await res.json(); + if (typeof body.version !== 'string') { + throw new Error('遠端 plugin.json 缺少 version 欄位'); + } + return body.version; +} + +function blockMessage(pluginName, remoteVersion, loadedVersion) { + const remoteText = remoteVersion || '查無法取得遠端版本'; + return ( + `${pluginName} 遠端發佈版本為 ${remoteText},當前實際載入版本為 ${loadedVersion}。\n` + + '請執行 /jsc-shared:plugins-install 更新後重新開啟工作階段。' + ); +} + +/** 嘗試把 stdin 當 hook event JSON 讀(比照 persona/hooks/_hook.mjs 的 readEvent());讀不到回傳 null。 */ +function readHookEvent() { + try { + const raw = fs.readFileSync(0, 'utf8'); + if (!raw.trim()) return null; + return JSON.parse(raw); + } catch { + return null; + } +} + +/** hook 模式的回應:exit code 固定 0,是否阻擋由 stdout JSON 的 permissionDecision 決定。 */ +function respondHook(decision, reason) { + process.stdout.write( + `${JSON.stringify({ + hookSpecificOutput: { + hookEventName: 'PreToolUse', + permissionDecision: decision, + ...(reason ? { permissionDecisionReason: reason } : {}), + }, + })}\n` + ); + process.exit(0); +} + +async function main() { + const args = parseArgs(process.argv.slice(2)); + const hookEvent = readHookEvent(); + const isHookMode = hookEvent !== null; + + function fail(message) { + log('ERR', redact(message), '版本檢查'); + if (isHookMode) respondHook('deny', redact(message)); + process.exit(2); + } + + const pluginRoot = resolvePluginRoot(args); + if (!pluginRoot) { + fail('無法判定本地 plugin 根目錄(未帶 --plugin-root 且 $CLAUDE_PLUGIN_ROOT 未設定),fail-closed 阻擋。'); + return; + } + + const pluginJsonPath = path.join(pluginRoot, 'plugin.json'); + let localMeta; + try { + localMeta = JSON.parse(fs.readFileSync(pluginJsonPath, 'utf8')); + } catch (err) { + fail(`讀取本地 plugin.json 失敗(${String(err.message)}),fail-closed 阻擋。`); + return; + } + + const loadedVersion = localMeta.version; + const shortName = shortNameFromPluginName(localMeta.name); + const host = resolveHost(args); + + let remoteVersion = null; + try { + remoteVersion = await fetchRemoteVersion(host, shortName, args.timeoutMs); + } catch (err) { + fail(`${blockMessage(localMeta.name, null, loadedVersion)}(遠端查詢失敗:${String(err.message)})`); + return; + } + + if (remoteVersion !== loadedVersion) { + fail(blockMessage(localMeta.name, remoteVersion, loadedVersion)); + return; + } + + log('INF', `${localMeta.name} 版本相符(${loadedVersion}),放行。`, '版本檢查'); + if (isHookMode) respondHook('allow'); + process.exit(0); +} + +main(); diff --git a/skills/do-wiki/SKILL.md b/skills/do-wiki/SKILL.md index 908ef17..94214a3 100644 --- a/skills/do-wiki/SKILL.md +++ b/skills/do-wiki/SKILL.md @@ -21,7 +21,7 @@ argument-hint: "[--wiki-repo ] [--wiki-index CONTENTS] [--wiki-page 先載入 `/jsc-shared:spec-preflight` 並依其流程處理;載入不到即代表 shared plugin 未安裝, 依該 spec 詢問使用者是否安裝 `https://gitea.jsc.idv.tw/plugins/shared.git`,不安裝則中斷本 skill。 -本 skill 需要的規範:`spec-model`、`spec-output`、`spec-execution`、`spec-todo-list`、`spec-ask-user`、`spec-time-log`、`spec-gitea`、`spec-wiki-contents`、`spec-no-scratch-files`、`spec-skill-invocation` +本 skill 需要的規範:`spec-version-guard`、`spec-model`、`spec-output`、`spec-execution`、`spec-todo-list`、`spec-ask-user`、`spec-time-log`、`spec-gitea`、`spec-wiki-contents`、`spec-no-scratch-files`、`spec-skill-invocation` ## 參數 diff --git a/skills/models/SKILL.md b/skills/models/SKILL.md index 625bbe8..2678482 100644 --- a/skills/models/SKILL.md +++ b/skills/models/SKILL.md @@ -23,7 +23,7 @@ argument-hint: "[--refresh] [--task ] 先載入 `/jsc-shared:spec-preflight` 並依其流程處理;載入不到即代表 shared plugin 未安裝, 依該 spec 詢問使用者是否安裝 `https://gitea.jsc.idv.tw/plugins/shared.git`,不安裝則中斷本 skill。 -本 skill 需要的規範:`spec-model`、`spec-output`、`spec-execution`、`spec-time-log`、`spec-skill-invocation` +本 skill 需要的規範:`spec-version-guard`、`spec-model`、`spec-output`、`spec-execution`、`spec-time-log`、`spec-skill-invocation` `spec-model` 是本 skill 的行為本體(標籤體系、任務對映表、來源優先序、快取設計、強制切換規則五節),下文只描述**本 skill 如何呼叫這五節**,不重抄內容;標籤字彙、對映表、來源優先序、快取欄位、錯誤訊息格式如與本檔敘述有出入,一律以 `spec-model` 當次實際載入到的內容為準。 diff --git a/skills/plan-wiki/SKILL.md b/skills/plan-wiki/SKILL.md index e9238a2..6f290c6 100644 --- a/skills/plan-wiki/SKILL.md +++ b/skills/plan-wiki/SKILL.md @@ -20,7 +20,7 @@ argument-hint: "[--wiki-repo ] [--index <英文系統名稱>_<中文 先載入 `/jsc-shared:spec-preflight` 並依其流程處理;載入不到即代表 shared plugin 未安裝, 依該 spec 詢問使用者是否安裝 `https://gitea.jsc.idv.tw/plugins/shared.git`,不安裝則中斷本 skill。 -本 skill 需要的規範:`spec-output`、`spec-execution`、`spec-gitea`、`spec-wiki-contents`、`spec-ask-user`、`spec-time-log`、`spec-no-scratch-files`、`spec-skill-invocation` +本 skill 需要的規範:`spec-version-guard`、`spec-output`、`spec-execution`、`spec-gitea`、`spec-wiki-contents`、`spec-ask-user`、`spec-time-log`、`spec-no-scratch-files`、`spec-skill-invocation` 本 skill 特有補充: diff --git a/skills/plugins-uninstall/SKILL.md b/skills/plugins-uninstall/SKILL.md index 9ee85c8..8ee262d 100644 --- a/skills/plugins-uninstall/SKILL.md +++ b/skills/plugins-uninstall/SKILL.md @@ -20,7 +20,7 @@ argument-hint: "[--assistant <助理清單,逗號分隔,或 all>] [--plugins ## 共用規範(必要前置) 先載入 `/jsc-shared:spec-preflight` 並依其流程處理。 -本 skill 需要的規範:`spec-output`、`spec-execution`、`spec-git-safety`、`spec-plugin-cli` +本 skill 需要的規範:`spec-version-guard`、`spec-output`、`spec-execution`、`spec-git-safety`、`spec-plugin-cli` 本 skill 特有補充: diff --git a/skills/spec-preflight/SKILL.md b/skills/spec-preflight/SKILL.md index a8354ee..de33639 100644 --- a/skills/spec-preflight/SKILL.md +++ b/skills/spec-preflight/SKILL.md @@ -11,9 +11,21 @@ description: JSC plugins 共用「規範前置載入流程」:每個 skill 執 每個 skill 執行前,依下列順序以 Skill 工具載入,**順序不可顛倒**: -1. **先載入本規範自身**:`/jsc-shared:spec-preflight`。這一步本身就是探測——載入成功代表 shared plugin 已安裝,可以繼續往下載入其他 spec;載入失敗直接進入〔載入失敗的處理〕。 +0. **先依 `/jsc-shared:spec-version-guard` 執行版本檢查**:確認當前實際載入的 `jsc-shared`(以及該 skill 所屬 plugin 自己)版本沒有落後於 Gitea `master` 現行版本;不符即中斷本 skill,見〔「版本不符」與「shared 未安裝」是兩種不同中斷原因〕。這一步排在所有 spec 載入之前,避免用舊版規則做事。 +1. **再載入本規範自身**:`/jsc-shared:spec-preflight`。這一步本身就是探測——載入成功代表 shared plugin 已安裝,可以繼續往下載入其他 spec;載入失敗直接進入〔載入失敗的處理〕。 2. **再依該 skill 自己列出的規範清單,逐一載入其他 `/jsc-shared:spec-xxx`**。清單與載入順序由各 skill 自己在檔頭決定(通常照該 skill 內文實際用到的先後順序排列),本規範不代為規定其他 spec 之間的順序。 +## 「版本不符」與「shared 未安裝」是兩種不同中斷原因 + +第 0 步(版本檢查)與第 1 步(本規範是否載入得到)失敗時的原因完全不同,**中斷訊息不可混用**: + +| 情境 | 代表什麼 | 中斷訊息依據 | +| --- | --- | --- | +| 第 0 步版本檢查不符或查不到遠端版本 | shared plugin **已安裝**,但版本落後於 Gitea `master`,或遠端查詢失敗(fail-closed) | 依 `/jsc-shared:spec-version-guard`〔錯誤訊息格式〕,導向 `/jsc-shared:plugins-install` 更新 | +| 第 1 步載入不到本規範自身 | shared plugin **根本未安裝** | 依本規範〔載入失敗的處理〕,導向安裝 `https://gitea.jsc.idv.tw/plugins/shared.git` | + +不得把「版本落後」誤報成「未安裝」(使用者會照著安裝流程走卻發現早就裝了),也不得把「未安裝」誤報成「版本落後」(使用者會照著更新指令走卻發現裝不了,因為根本沒有 marketplace)。 + ## 載入失敗的處理 只要**任一** spec(包含本規範自身)載入不到,一律判定為**shared plugin(`jsc-shared`)未安裝**,不視為暫時性錯誤、不重試、不略過繼續: @@ -53,11 +65,12 @@ description: JSC plugins 共用「規範前置載入流程」:每個 skill 執 先載入 `/jsc-shared:spec-preflight` 並依其流程處理;載入不到即代表 shared plugin 未安裝, 依該 spec 詢問使用者是否安裝 `https://gitea.jsc.idv.tw/plugins/shared.git`,不安裝則中斷本 skill。 -本 skill 需要的規範:`spec-output`、`spec-execution`、`spec-gitea`、…(依各 skill 實際需要的規範清單列出) +preflight 的第一件事是依 `/jsc-shared:spec-version-guard` 比對遠端與當前實際載入版本,不符即中斷本 skill。 +本 skill 需要的規範:`spec-version-guard`、`spec-output`、`spec-execution`、`spec-gitea`、…(依各 skill 實際需要的規範清單列出) ``` - 最後一行的規範清單**只列名稱**,不附一行摘要(摘要是規範內容的重抄,會與本文漂移不一致;需要摘要時直接載入該 spec 看本文)。 -- 清單順序建議照該 skill 內文實際用到的先後排列,方便對照。 +- 清單順序建議照該 skill 內文實際用到的先後排列,方便對照;`spec-version-guard` 因為是最前置的檢查,習慣上放在清單最前面。 ## 適用範圍 diff --git a/skills/spec-version-guard/SKILL.md b/skills/spec-version-guard/SKILL.md new file mode 100644 index 0000000..b22a240 --- /dev/null +++ b/skills/spec-version-guard/SKILL.md @@ -0,0 +1,78 @@ +--- +name: spec-version-guard +description: JSC plugins 共用「plugin 版本前置檢查規範」:定義三種版本(遠端發佈版本/助理註冊版本/當前實際載入版本)的差異、規定比對對象只能是「遠端發佈版本 vs 當前實際載入版本」不得只看註冊版本、查不到遠端版本一律 fail-closed 阻擋、錯誤訊息格式依 spec-time-log、阻擋後導向 plugins-install 或 spec-plugin-cli 更新指令。當其他 skill 內文引用 spec-version-guard 或 /jsc-shared:spec-version-guard、或執行任何 JSC skill 前需要先確認本機版本是否落後於 Gitea master 時載入此 skill。單獨被使用者呼叫時,直接說明本規範內容。 +--- + +# spec-version-guard — 共用 plugin 版本前置檢查規範 + +任何 JSC skill 在做任何實質步驟之前,都必須先確認自己實際載入的內容是否落後於已發佈的版本;落後時繼續執行等於用舊規則做事,可能直接違反已經改掉的規則(例如舊版 `todo-wiki` 用連字號命名 TODO 頁、完全不知道 `spec-wiki-contents` 存在)。本規範定義這個檢查該比對什麼、比對不到時怎麼辦、以及失敗訊息長什麼樣子。 + +## 三種版本,只有兩種能拿來比 + +| 版本 | 意義 | 取得方式 | +| --- | --- | --- | +| 遠端發佈版本 | Gitea `master`(發佈分支)上 `plugin.json` 的 `version` 欄位;四個 JSC plugin 各自對應 `plugins/.git` | `GET https:///api/v1/repos/plugins//raw/plugin.json?ref=master` | +| 助理註冊版本 | 各助理自己的 plugin 安裝清單記錄的版本(例如 Claude Code 的 `~/.claude/plugins/installed_plugins.json`、`claude plugin list` 的輸出) | 助理原生指令或設定檔 | +| **當前實際載入版本** | 本次 session/本次 hook 呼叫,實際從磁碟讀進來執行的那份 plugin 內容的版本 | 見〔取得「當前實際載入版本」〕 | + +**唯一合法的比對對象是「遠端發佈版本 vs 當前實際載入版本」**,**不得只比對「遠端發佈版本 vs 助理註冊版本」**。原因:助理的安裝清單只記錄「上次安裝/更新時寫入的版本號」,跟「這次 session 實際從哪個目錄讀取內容」是兩回事——同一個助理的快取目錄底下可能同時存在好幾個版本的 plugin 副本(例如 Claude Code 的 `~/.claude/plugins/cache/shared/jsc-shared/` 下並存 `0.1.2`/`0.1.3`/`0.1.4`/`0.1.5`/`0.2.0` 五個版本目錄),註冊表可能已經寫著最新版號,但本次 session 因為快取或會話啟動時機問題,實際載入的還是舊目錄。只比註冊版本會誤判「一切正常」,抓不到這種落差。 + +## 取得「當前實際載入版本」 + +- **Claude Code/GitHub Copilot CLI**:hook 情境下讀 `${CLAUDE_PLUGIN_ROOT}/plugin.json` 的 `version`(`CLAUDE_PLUGIN_ROOT` 由這兩家助理在呼叫 hook 時注入,指向這次 session 實際掛載的 plugin 目錄,不是註冊表路徑);skill/command 執行情境下,可從呼叫端提供的「Base directory for this skill」之類的實際路徑取得同等資訊。 +- **Codex**:無對應環境變數時,由呼叫端傳入 plugin root 路徑(見 `shared/scripts/version-guard.mjs` 的 `--plugin-root` 參數),或依 skill 執行時的實際路徑推得。 +- **Antigravity/OpenCode**:兩者走「clone 到固定目錄+本地路徑安裝」(見 `/jsc-shared:spec-plugin-cli`),當前實際載入版本=該固定 clone 目錄下 `plugin.json` 的 `version`。 +- 一律**不得**用「助理安裝清單版本」或「使用者記得自己上次更新到幾版」頂替上述任何一種取得方式。 + +## 強制機制形態:規範+腳本+hook 雙層,僅部分助理有真阻擋 + +依實測與官方文件比對(見〔各助理 hook 真阻擋能力〕),五家助理只有 **Claude Code** 與 **GitHub Copilot CLI** 具備官方文件保證、可真正中止工具呼叫的 hook 事件(`PreToolUse`/`preToolUse`,兩者格式相容,可共用同一份 `hooks/hooks.json`);**Codex**(hook 能擋,但每次腳本內容變動都要人工 `/hooks` 重新信任,不算全自動生效)、**Antigravity**(只有社群 issue 佐證、無官方文件)、**OpenCode**(已知 bug:subagent 發出的工具呼叫會繞過 hook)三家都**不掛 hook**,改為完全依賴本規範的規範層自律(讀到 `model:`/`spec-version-guard` frontmatter 或本規範內容時,執行者自行比對版本並中止,而非靠工具強制擋下)。 + +### 各助理 hook 真阻擋能力(研究結論,供設計依據) + +| 助理 | 可用 hook 事件 | 是否真中止 | 本規範採用方式 | +| --- | --- | --- | --- | +| Claude Code | `PreToolUse` | ✅ 真中止(`persona/hooks/guard.mjs` 已驗證) | 掛 hook | +| GitHub Copilot CLI | `preToolUse`/`PreToolUse` | ✅ 真中止,且腳本錯誤/逾時預設視為 deny(fail-closed,與本規範精神一致) | 掛 hook | +| Codex | 同名事件技術上可掛 | ⚠️ 需人工 `/hooks` 逐次重新信任,不算自動 | 不掛 hook,僅規範層 | +| Antigravity | JSON deny 型 hook | ⚠️ 僅社群 issue 佐證,無官方文件 | 不掛 hook,僅規範層 | +| OpenCode | `tool.execute.before` | ⚠️ 已知會被 subagent 呼叫繞過 | 不掛 hook,僅規範層 | + +此表為使用者於 2026/08/17 依上述研究結果裁示採用「只對 Claude Code/Copilot 掛 hook,其餘三家靠規範自律」。 + +### hook 設計:matcher 與檢查範圍 + +Claude Code/Copilot 官方文件都**沒有明確記載** `Skill` 工具呼叫的 `tool_input` 欄位結構、也沒有說明 `PreToolUse` payload 是否帶有「這次呼叫屬於哪個 plugin」的資訊(已實際查證官方文件,查無此欄位說明)。在無法可靠辨識「這次工具呼叫是不是我這個 plugin 自己的 skill」的前提下,本規範採兩個保守決定: + +1. **matcher 沿用 `persona/hooks/hooks.json` 已驗證可真正阻擋的既有 pattern**——`Read|Write|Edit|MultiEdit|NotebookEdit|Glob|Grep|LS|Bash`,而不是賭一個沒有文件佐證的 `Skill` 工具名稱;這樣至少能保證 hook 真的會被觸發,不會因為 matcher 打錯字而變成裝了跟沒裝一樣。 +2. **腳本一律無條件檢查自己這個 plugin 的版本**,不嘗試判斷「這次工具呼叫是不是我的 skill 觸發的」。四個 JSC plugin 大量互相引用彼此的 `spec-*` 規範,任一個過期都有風險,因此「呼叫任何一個 JSC skill 時,四個 plugin 的 hook 都一起檢查一次自己的版本」是刻意的保守設計,不是失誤。 + +這兩個決定都還沒有實機驗證(`Skill` 工具呼叫時 `PreToolUse` 是否真的會帶著這個 matcher 一起觸發、四個 hook 同時觸發會不會互相干擾),留給第 14/23 項的端到端驗證實測確認;若實測發現行為與預期不符,以實測結果為準修正本節,不得憑本節文字繼續假設它一定成立。 + +## fail-closed:查不到遠端版本一律阻擋 + +- 遠端查詢失敗(逾時、網路錯誤、404、host 不可達)時,**視同版本不符**,一律阻擋,不得因為「連不上網」就放行——寧可誤擋,不可誤放。 +- 查詢必須設逾時(建議 5 秒),避免拖住工作階段啟動或每次工具呼叫。 + +## 錯誤訊息格式 + +阻擋時的訊息依 `/jsc-shared:spec-time-log` 的 `[yyyy/MM/dd HH:mm:ss][階段][等級]: 訊息` 格式,階段固定為「版本檢查」,等級固定 `ERR`: + +``` +[yyyy/MM/dd HH:mm:ss][版本檢查][ERR]: 遠端發佈版本為 , +當前實際載入版本為 (或「查無法取得遠端版本」)。 +請執行 /jsc-shared:plugins-install 更新後重新開啟工作階段。 +``` + +- 找不到遠端版本時,`` 替換為「查無法取得遠端版本」,訊息其餘部分不變。 +- 阻擋後一律導向 `/jsc-shared:plugins-install`(一次更新四個 JSC plugin)或 `/jsc-shared:spec-plugin-cli` 對應助理小節的「更新」指令,不得只說「請更新」而不給出具體指令。 + +## 適用範圍 + +依 `/jsc-shared:spec-preflight`〔載入順序〕,本規範的檢查排在**所有其他 spec 載入之前**執行(見 `spec-preflight` 第 0 步);規範檔本身(`spec-*`)依 `spec-preflight`〔適用範圍〕不引用 preflight,因此也不引用本規範,避免循環依賴。 + +**`/jsc-shared:plugins-install` 額外排除,理由是避免自我鎖死**:`plugins-install` 正是 `spec-version-guard` 阻擋後指引使用者執行的修復手段;若 `plugins-install` 自己也引用本規範,遇到 `shared` 版本落後時,它會在修復自己之前就先被自己判定版本不符而擋下——使用者永遠跑不到能修好問題的那個 skill。因此 `plugins-install` 不列入〔給實作端的備註〕所述「六個非 spec skill」的檔頭清單,其餘 skill(含 `plugins-uninstall`)不受此例外影響。 + +## 給實作端(腳本、hook)的備註 + +本規範只定義「比對什麼、失敗了算什麼、訊息長怎樣」;實際取值、發 HTTP 請求、決定 hook 事件的程式碼是共用腳本 `shared/scripts/version-guard.mjs` 與四個 plugin 各自的 `hooks/hooks.json` 的職責,不在本規範重複描述實作細節。 diff --git a/skills/todo-wiki/SKILL.md b/skills/todo-wiki/SKILL.md index 60d6076..01376e5 100644 --- a/skills/todo-wiki/SKILL.md +++ b/skills/todo-wiki/SKILL.md @@ -22,7 +22,7 @@ argument-hint: "[--source <需求描述|檔案路徑|議題編號>] [--impl-mode 先載入 `/jsc-shared:spec-preflight` 並依其流程處理;載入不到即代表 shared plugin 未安裝, 依該 spec 詢問使用者是否安裝 `https://gitea.jsc.idv.tw/plugins/shared.git`,不安裝則中斷本 skill。 -本 skill 需要的規範:`spec-model`、`spec-output`、`spec-execution`、`spec-issue-read`、`spec-todo-list`、`spec-ask-user`、`spec-time-log`、`spec-gitea`、`spec-wiki-contents`、`spec-no-scratch-files`、`spec-skill-invocation` +本 skill 需要的規範:`spec-version-guard`、`spec-model`、`spec-output`、`spec-execution`、`spec-issue-read`、`spec-todo-list`、`spec-ask-user`、`spec-time-log`、`spec-gitea`、`spec-wiki-contents`、`spec-no-scratch-files`、`spec-skill-invocation` 本 skill 特有補充: