feat(spec-version-guard): 新增版本前置檢查規範、腳本與 hook #9

Merged
admin merged 2 commits from develop into master 2026-08-17 06:57:14 +00:00
13 changed files with 295 additions and 12 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "jsc-shared", "name": "jsc-shared",
"version": "0.2.0", "version": "0.2.1",
"description": "JSC 跨 AI 助理共用規範 skills plugin(Claude Code / Codex / Antigravity / OpenCode / GitHub Copilot CLI),`skills/` 為唯一真實來源,並提供整組 plugin 的安裝/更新/移除管理(plugins-install 一次安裝或更新 jsc-code/jsc-doc/jsc-persona/jsc-shared,plugins-uninstall 一次移除四個 JSC plugin)。安裝與更新一律以 Gitea 遠端 repo 的 README 與檔案為準,不依賴既有本機存取庫;所有 skills 以 SKILL.md 為共通標準;於 Claude Code 以 /jsc-shared: 前綴呼叫。", "description": "JSC 跨 AI 助理共用規範 skills plugin(Claude Code / Codex / Antigravity / OpenCode / GitHub Copilot CLI),`skills/` 為唯一真實來源,並提供整組 plugin 的安裝/更新/移除管理(plugins-install 一次安裝或更新 jsc-code/jsc-doc/jsc-persona/jsc-shared,plugins-uninstall 一次移除四個 JSC plugin)。安裝與更新一律以 Gitea 遠端 repo 的 README 與檔案為準,不依賴既有本機存取庫;所有 skills 以 SKILL.md 為共通標準;於 Claude Code 以 /jsc-shared: 前綴呼叫。",
"skills": "./skills", "skills": "./skills",
"author": { "author": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "jsc-shared", "name": "jsc-shared",
"version": "0.2.0", "version": "0.2.1",
"description": "JSC 跨 AI 助理共用規範 skills plugin,`skills/` 為唯一真實來源,並提供整組 plugin 的安裝/更新/移除管理(plugins-install 一次安裝或更新 jsc-code/jsc-doc/jsc-persona/jsc-shared,plugins-uninstall 一次移除四個 JSC plugin)。安裝與更新一律以 Gitea 遠端 repo 的 README 與檔案為準,不依賴既有本機存取庫;所有 skills 以 SKILL.md 為共通標準。", "description": "JSC 跨 AI 助理共用規範 skills plugin,`skills/` 為唯一真實來源,並提供整組 plugin 的安裝/更新/移除管理(plugins-install 一次安裝或更新 jsc-code/jsc-doc/jsc-persona/jsc-shared,plugins-uninstall 一次移除四個 JSC plugin)。安裝與更新一律以 Gitea 遠端 repo 的 README 與檔案為準,不依賴既有本機存取庫;所有 skills 以 SKILL.md 為共通標準。",
"skills": "./skills" "skills": "./skills"
} }
+17
View File
@@ -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 版本…"
}
]
}
]
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "jsc-shared", "name": "jsc-shared",
"version": "0.2.0", "version": "0.2.1",
"description": "JSC 跨 AI 助理共用規範 skills plugin,`skills/` 為唯一真實來源,並提供整組 plugin 的安裝/更新/移除管理(plugins-install 一次安裝或更新 jsc-code/jsc-doc/jsc-persona/jsc-shared,plugins-uninstall 一次移除四個 JSC plugin)。安裝與更新一律以 Gitea 遠端 repo 的 README 與檔案為準,不依賴既有本機存取庫;所有 skills 以 SKILL.md 為共通標準;於 Antigravity 以 /jsc-shared: 前綴呼叫。", "description": "JSC 跨 AI 助理共用規範 skills plugin,`skills/` 為唯一真實來源,並提供整組 plugin 的安裝/更新/移除管理(plugins-install 一次安裝或更新 jsc-code/jsc-doc/jsc-persona/jsc-shared,plugins-uninstall 一次移除四個 JSC plugin)。安裝與更新一律以 Gitea 遠端 repo 的 README 與檔案為準,不依賴既有本機存取庫;所有 skills 以 SKILL.md 為共通標準;於 Antigravity 以 /jsc-shared: 前綴呼叫。",
"skills": "./skills" "skills": "./skills"
} }
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "jsc-shared", "name": "jsc-shared",
"shortName": "shared", "shortName": "shared",
"version": "0.1.7", "version": "0.2.1",
"descriptionCore": "JSC 跨 AI 助理共用規範 skills plugin,`skills/` 為唯一真實來源,並提供整組 plugin 的安裝/更新/移除管理(plugins-install 一次安裝或更新 jsc-code/jsc-doc/jsc-persona/jsc-shared,plugins-uninstall 一次移除四個 JSC plugin)。安裝與更新一律以 Gitea 遠端 repo 的 README 與檔案為準,不依賴既有本機存取庫;所有 skills 以 SKILL.md 為共通標準。", "descriptionCore": "JSC 跨 AI 助理共用規範 skills plugin,`skills/` 為唯一真實來源,並提供整組 plugin 的安裝/更新/移除管理(plugins-install 一次安裝或更新 jsc-code/jsc-doc/jsc-persona/jsc-shared,plugins-uninstall 一次移除四個 JSC plugin)。安裝與更新一律以 Gitea 遠端 repo 的 README 與檔案為準,不依賴既有本機存取庫;所有 skills 以 SKILL.md 為共通標準。",
"assistants": ["Claude Code", "Codex", "Antigravity", "OpenCode", "GitHub Copilot CLI"], "assistants": ["Claude Code", "Codex", "Antigravity", "OpenCode", "GitHub Copilot CLI"],
"cliPrefix": "/jsc-shared:", "cliPrefix": "/jsc-shared:",
+175
View File
@@ -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 <path>] [--host <host>] [--timeout-ms <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();
+1 -1
View File
@@ -21,7 +21,7 @@ argument-hint: "[--wiki-repo <owner/repo>] [--wiki-index CONTENTS] [--wiki-page
先載入 `/jsc-shared:spec-preflight` 並依其流程處理;載入不到即代表 shared plugin 未安裝, 先載入 `/jsc-shared:spec-preflight` 並依其流程處理;載入不到即代表 shared plugin 未安裝,
依該 spec 詢問使用者是否安裝 `https://gitea.jsc.idv.tw/plugins/shared.git`,不安裝則中斷本 skill。 依該 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`
## 參數 ## 參數
+1 -1
View File
@@ -23,7 +23,7 @@ argument-hint: "[--refresh] [--task <analysis|implement|review|summary|persona>]
先載入 `/jsc-shared:spec-preflight` 並依其流程處理;載入不到即代表 shared plugin 未安裝, 先載入 `/jsc-shared:spec-preflight` 並依其流程處理;載入不到即代表 shared plugin 未安裝,
依該 spec 詢問使用者是否安裝 `https://gitea.jsc.idv.tw/plugins/shared.git`,不安裝則中斷本 skill。 依該 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` 當次實際載入到的內容為準。 `spec-model` 是本 skill 的行為本體(標籤體系、任務對映表、來源優先序、快取設計、強制切換規則五節),下文只描述**本 skill 如何呼叫這五節**,不重抄內容;標籤字彙、對映表、來源優先序、快取欄位、錯誤訊息格式如與本檔敘述有出入,一律以 `spec-model` 當次實際載入到的內容為準。
+1 -1
View File
@@ -20,7 +20,7 @@ argument-hint: "[--wiki-repo <owner/repo>] [--index <英文系統名稱>_<中文
先載入 `/jsc-shared:spec-preflight` 並依其流程處理;載入不到即代表 shared plugin 未安裝, 先載入 `/jsc-shared:spec-preflight` 並依其流程處理;載入不到即代表 shared plugin 未安裝,
依該 spec 詢問使用者是否安裝 `https://gitea.jsc.idv.tw/plugins/shared.git`,不安裝則中斷本 skill。 依該 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 特有補充: 本 skill 特有補充:
+1 -1
View File
@@ -20,7 +20,7 @@ argument-hint: "[--assistant <助理清單,逗號分隔,或 all>] [--plugins
## 共用規範(必要前置) ## 共用規範(必要前置)
先載入 `/jsc-shared:spec-preflight` 並依其流程處理。 先載入 `/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 特有補充: 本 skill 特有補充:
+16 -3
View File
@@ -11,9 +11,21 @@ description: JSC plugins 共用「規範前置載入流程」:每個 skill 執
每個 skill 執行前,依下列順序以 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 之間的順序。 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`)未安裝**,不視為暫時性錯誤、不重試、不略過繼續: 只要**任一** spec(包含本規範自身)載入不到,一律判定為**shared plugin(`jsc-shared`)未安裝**,不視為暫時性錯誤、不重試、不略過繼續:
@@ -53,11 +65,12 @@ description: JSC plugins 共用「規範前置載入流程」:每個 skill 執
先載入 `/jsc-shared:spec-preflight` 並依其流程處理;載入不到即代表 shared plugin 未安裝, 先載入 `/jsc-shared:spec-preflight` 並依其流程處理;載入不到即代表 shared plugin 未安裝,
依該 spec 詢問使用者是否安裝 `https://gitea.jsc.idv.tw/plugins/shared.git`,不安裝則中斷本 skill。 依該 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 看本文)。 - 最後一行的規範清單**只列名稱**,不附一行摘要(摘要是規範內容的重抄,會與本文漂移不一致;需要摘要時直接載入該 spec 看本文)。
- 清單順序建議照該 skill 內文實際用到的先後排列,方便對照。 - 清單順序建議照該 skill 內文實際用到的先後排列,方便對照;`spec-version-guard` 因為是最前置的檢查,習慣上放在清單最前面。
## 適用範圍 ## 適用範圍
+78
View File
@@ -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/<name>.git` | `GET https://<host>/api/v1/repos/plugins/<name>/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]: <plugin 名> 遠端發佈版本為 <remote-version>,
當前實際載入版本為 <loaded-version>(或「查無法取得遠端版本」)。
請執行 /jsc-shared:plugins-install 更新後重新開啟工作階段。
```
- 找不到遠端版本時,`<remote-version>` 替換為「查無法取得遠端版本」,訊息其餘部分不變。
- 阻擋後一律導向 `/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` 的職責,不在本規範重複描述實作細節。
+1 -1
View File
@@ -22,7 +22,7 @@ argument-hint: "[--source <需求描述|檔案路徑|議題編號>] [--impl-mode
先載入 `/jsc-shared:spec-preflight` 並依其流程處理;載入不到即代表 shared plugin 未安裝, 先載入 `/jsc-shared:spec-preflight` 並依其流程處理;載入不到即代表 shared plugin 未安裝,
依該 spec 詢問使用者是否安裝 `https://gitea.jsc.idv.tw/plugins/shared.git`,不安裝則中斷本 skill。 依該 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 特有補充: 本 skill 特有補充: