Merge pull request 'fix/skillset-audit-compliance-and-guard-fixes' (#15) from fix/skillset-audit-compliance-and-guard-fixes into develop
Reviewed-on: #15
This commit was merged in pull request #15.
This commit is contained in:
@@ -43,7 +43,7 @@
|
||||
"source": "url",
|
||||
"url": "https://gitea.jsc.idv.tw/plugins/hooks.git"
|
||||
},
|
||||
"description": "跨 CLI hooks:STE100 語言強制、工時計時、技能用量記錄"
|
||||
"description": "跨 CLI hooks:STE100 語言強制、工時計時、技能用量記錄、SDLC 模型鎖、版本前置檢查"
|
||||
},
|
||||
{
|
||||
"name": "jsc-log",
|
||||
@@ -75,7 +75,7 @@
|
||||
"source": "url",
|
||||
"url": "https://gitea.jsc.idv.tw/plugins/review.git"
|
||||
},
|
||||
"description": "程式碼審查:Refactoring 壞味道六組 + 註解規範 + 淺模組"
|
||||
"description": "程式碼審查:Refactoring 壞味道六組、註解規範、淺模組"
|
||||
},
|
||||
{
|
||||
"name": "jsc-sdlc",
|
||||
@@ -83,7 +83,7 @@
|
||||
"source": "url",
|
||||
"url": "https://gitea.jsc.idv.tw/plugins/sdlc.git"
|
||||
},
|
||||
"description": "開發生命週期:規劃/分析/實作/維護(wiki 追蹤)"
|
||||
"description": "開發生命週期:規劃、分析、實作、維護(wiki 追蹤)"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
"source": "url",
|
||||
"url": "https://gitea.jsc.idv.tw/plugins/hooks.git"
|
||||
},
|
||||
"description": "跨 CLI hooks:STE100 語言強制、工時計時、技能用量記錄"
|
||||
"description": "跨 CLI hooks:STE100 語言強制、工時計時、技能用量記錄、SDLC 模型鎖、版本前置檢查"
|
||||
},
|
||||
{
|
||||
"name": "jsc-log",
|
||||
@@ -75,7 +75,7 @@
|
||||
"source": "url",
|
||||
"url": "https://gitea.jsc.idv.tw/plugins/review.git"
|
||||
},
|
||||
"description": "程式碼審查:Refactoring 壞味道六組 + 註解規範 + 淺模組"
|
||||
"description": "程式碼審查:Refactoring 壞味道六組、註解規範、淺模組"
|
||||
},
|
||||
{
|
||||
"name": "jsc-sdlc",
|
||||
@@ -83,7 +83,7 @@
|
||||
"source": "url",
|
||||
"url": "https://gitea.jsc.idv.tw/plugins/sdlc.git"
|
||||
},
|
||||
"description": "開發生命週期:規劃/分析/實作/維護(wiki 追蹤)"
|
||||
"description": "開發生命週期:規劃、分析、實作、維護(wiki 追蹤)"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "jsc-hooks",
|
||||
"version": "0.0.9",
|
||||
"description": "跨 CLI hooks:STE100 語言強制、工時計時、技能用量記錄、SDLC 模型鎖",
|
||||
"version": "0.0.11",
|
||||
"description": "跨 CLI hooks:STE100 語言強制、工時計時、技能用量記錄、SDLC 模型鎖、版本前置檢查",
|
||||
"skills": "./skills",
|
||||
"author": {
|
||||
"name": "JSC"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "jsc-hooks",
|
||||
"version": "0.0.9",
|
||||
"description": "跨 CLI hooks:STE100 語言強制、工時計時、技能用量記錄、SDLC 模型鎖",
|
||||
"version": "0.0.11",
|
||||
"description": "跨 CLI hooks:STE100 語言強制、工時計時、技能用量記錄、SDLC 模型鎖、版本前置檢查",
|
||||
"skills": "./skills"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# jsc-hooks — 給 AI 助理的指引
|
||||
|
||||
本 repo 是 jsc 技能組的 `hooks` domain(跨 CLI hooks:STE100 語言強制、工時計時、技能用量記錄、SDLC 模型鎖),可同時被 Claude Code / Codex / Copilot / Antigravity / Kiro 使用。
|
||||
本 repo 是 jsc 技能組的 `hooks` domain(跨 CLI hooks:STE100 語言強制、工時計時、技能用量記錄、SDLC 模型鎖、版本前置檢查),可同時被 Claude Code / Codex / Copilot / Antigravity / Kiro 使用。
|
||||
|
||||
## 規則
|
||||
|
||||
|
||||
@@ -23,29 +23,35 @@ Marketplace 統一為 `jsc`(https://gitea.jsc.idv.tw/plugins/meta.git),安
|
||||
| 腳本 | 事件 | 作用 |
|
||||
| --- | --- | --- |
|
||||
| `hooks/ste100-guard.sh` | UserPromptSubmit | 注入 STE100 繁體中文輸出規則(hook > prompt 強制層) |
|
||||
| `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/session-timer.sh` | SessionStart / Stop / SessionEnd | 記錄工作階段起訖。子指令:`start` 記起始時間(已有紀錄就不動,給 claude 這種每階段有自己 session id 的 CLI)、`restart` 一律覆寫起始時間(給接不到 session id 的 kiro,不覆寫會把上一階段算進來)、`mark` 更新最後活動時間、`report` 供 `jsc-log:worklog` 取花費時間 |
|
||||
| `hooks/version-guard.sh` | PreToolUse(Skill) | 技能使用前的版本前置檢查:本機**實際載入**版本落後遠端發佈版本就以 exit 2 擋下該次呼叫並提示更新指令(更新指令依當前 CLI 給)。只擋落後這一種情況:超前放行(開發技能組時本機本來就會超前),讀不到本機版本、推導不出站台、查不到遠端版本也一律放行。逃生門 `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/sdlc-gate.sh` | UserPromptSubmit | SDLC 階段能力標籤閘門與模型鎖:`lock {stage}` 由 jsc-sdlc 階段技能呼叫,從 transcript 讀出實際模型 id 比對該階段必要標籤(`$JSC_HOME/model-tags.tsv`),不符就拒絕上鎖;`check` 在模型不符時以 exit 2 擋下該輪提示(其他 hook 一律 exit 0,此處是刻意例外);`unlock` 為逃生門 |
|
||||
|
||||
Claude 由 `hooks/hooks.json` 自動接線;其他 CLI 用 `hooks-install` 技能接線、改裝包裝啟動器,或降級為規則檔。
|
||||
Claude 由 `hooks/hooks.json` 自動接線五支 hook;其他 CLI 用 `hooks-install` 技能接線、改裝包裝啟動器,或降級為規則檔。
|
||||
|
||||
> 覆蓋範圍要據實看待:只有 claude 同時有 PreToolUse 與 UserPromptSubmit,五支 hook 全接得上,回報 `wired`。codex、copilot、antigravity、kiro 都沒有 pre-tool hook,接不上 `version-guard.sh` 的版本前置檢查,SDLC 模型鎖也只剩技能步驟檢查,這四個 CLI 一律回報 `degraded`,靠 `/jsc-cli:deploy` 定期更新。codex 另外沒有工作階段開始事件,計時改由 `tools/jsc-wrap.sh` 的 `codex` 別名在啟動當下開始;沒走別名啟動時,時間從第一輪回應算起。
|
||||
|
||||
> `version-guard.sh report` 是非 hook 的子指令:印出每個已安裝 jsc plugin 的
|
||||
> 「{domain} {本機} {遠端} {落後|最新|超前|查詢失敗}」,最後一行 `behind {落後個數}`。
|
||||
> 本機沒有 Claude 的 plugin 註冊檔時改印 `noregistry {路徑}` 再接 `behind 0`,
|
||||
> 代表這台機器無法做版本檢查,跟「全部最新」是兩件事。查遠端版本走與 hook 同一份快取
|
||||
> 與同一個 `JSC_VERSION_TTL`,一次部署不會為每個 domain 各打一輪網路。
|
||||
> `jsc-cli:deploy` 用它決定要不要把「更新」設成推薦選項。
|
||||
|
||||
## 工具
|
||||
|
||||
| 腳本 | 用途 |
|
||||
| --- | --- |
|
||||
| `tools/jsc-wrap.sh` | 無 hook 系統 CLI 的包裝啟動器:匯出 `JSC_CLI`、`JSC_SESSION_ID`,前後接 `session-timer.sh`,結束時自動跑 `scan-logs.sh` 回填 |
|
||||
| `tools/jsc-wrap.sh` | 沒有完整 hook 系統的 CLI 的包裝啟動器:匯出 `JSC_CLI`、`JSC_SESSION_ID`,前後接 `session-timer.sh`,結束時自動跑 `scan-logs.sh` 回填。`JSC_CLI` 存 CLI 代號,實際執行的是對應的執行檔(antigravity 是 agy、kiro 是 kiro-cli) |
|
||||
| `tools/scan-logs.sh` | 離線回填:解析 copilot、antigravity、codex 的原生日誌,把技能用量與階段界線補進 `$JSC_HOME`,重掃不重複 |
|
||||
| `tools/wire-cli.sh` | 單一 CLI 的接線流程:`{cli}` 對應的設定編輯、包裝別名安裝、hook 檔建立,皆以 `<!-- jsc-hooks -->`(或 `# jsc-hooks`)標記整段取代,重跑不重複;以 `status=wired\|degraded\|skipped` 回報結果 |
|
||||
| `tools/report-error.sh` | 失敗回報流程:把一筆 hook 或工具異常寫成 wiki 的 `ERROR_{HASH}`,並在 `ERROR_CONTENTS` 附上一列索引。wiki 位置由 `jsc-gitea` 的 `gitea.sh wiki-repo ERROR` 解析,解析不出來就安靜降級。由操作者手動執行,或由 `hooks-install` 在 `wire-cli.sh` 回報 `status=failed` 時執行;**不接在失敗的 hook 上自動觸發**(hook 一律安靜 exit 0,自我回報會疊出迴圈) |
|
||||
| `tools/wire-cli.sh` | 單一 CLI 的接線流程:`{cli}` 對應的設定編輯、包裝別名安裝、hook 檔建立,皆以 `<!-- jsc-hooks -->`(或 `# jsc-hooks`)標記整段取代,重跑不重複。寫完每個檔案會重讀驗證位置正確才回報成功(codex 的 `notify` 必須是根層鍵、kiro 的 JSON 必須成對且 `on`、`run` 在最上層);以 `status=wired\|degraded\|skipped\|failed` 回報結果 |
|
||||
|
||||
## 失敗回報範本
|
||||
|
||||
這兩個模板給外層的 hook 失敗回報流程使用,不改動現有 hook 行為。
|
||||
失敗時若要寫入 wiki,套用這兩個檔案即可。
|
||||
這兩個模板是失敗回報頁的文案來源,由 `tools/report-error.sh` 填欄位後寫進 wiki。
|
||||
用法:`tools/report-error.sh --hook {名稱} --exit {碼} --summary {摘要}`,錯誤輸出摘要走標準輸入。
|
||||
|
||||
| 範本 | 用途 |
|
||||
| --- | --- |
|
||||
@@ -60,7 +66,7 @@ Claude 由 `hooks/hooks.json` 自動接線;其他 CLI 用 `hooks-install` 技
|
||||
|
||||
### `hooks-install`
|
||||
|
||||
把四支 hook 接線到所有已安裝的 CLI:偵測 CLI 後,逐一呼叫 `tools/wire-cli.sh {cli}` 完成接線(claude 由 `hooks.json` 自動接線,無需寫入)。copilot、antigravity 由該腳本裝上 `tools/jsc-wrap.sh` 包裝別名補上計時與用量回填(結束時自動跑 `tools/scan-logs.sh`),語言規則仍追加到各自的規則檔(以 `<!-- jsc-hooks -->` 標記整段取代,不重複追加)。codex、kiro 的 SDLC 模型鎖降級為技能步驟檢查,鎖檔仍由 SDLC 技能直接呼叫 `sdlc-gate.sh lock` 寫入。腳本以 `status=wired|degraded|skipped` 回報結果,供技能對照 verify 表。
|
||||
把五支 hook 接線到所有已安裝的 CLI:偵測 CLI 後,逐一呼叫 `tools/wire-cli.sh {cli}` 完成接線(claude 由 `hooks.json` 自動接線,無需寫入)。codex、copilot、antigravity 由該腳本裝上 `tools/jsc-wrap.sh` 包裝別名補上計時與用量回填(結束時自動跑 `tools/scan-logs.sh`),語言規則仍追加到各自的規則檔(以 `<!-- jsc-hooks -->` 標記整段取代,不重複追加)。codex、copilot、antigravity、kiro 的 SDLC 模型鎖降級為技能步驟檢查,鎖檔仍由 SDLC 技能直接呼叫 `sdlc-gate.sh lock` 寫入;這四個 CLI 沒有 pre-tool hook,版本前置檢查接不上,腳本會在 `reason` 裡講明,只有 claude 回報 `wired`。腳本第一行以 `status=wired|degraded|skipped|failed reason=...` 回報結果;`failed` 由技能改呼叫 `tools/report-error.sh` 寫一頁 `ERROR_{HASH}`。
|
||||
|
||||
<!-- JSC-SKILLS:END -->
|
||||
|
||||
@@ -69,9 +75,11 @@ Claude 由 `hooks/hooks.json` 自動接線;其他 CLI 用 `hooks-install` 技
|
||||
| 變數 | 用途 | 未設定時 |
|
||||
| --- | --- | --- |
|
||||
| `JSC_HOME` | Hook 資料目錄 | 預設 `~/.jsc` |
|
||||
| `JSC_WIKI_REPO_ERROR` | `ERROR_CONTENTS`、`ERROR_{HASH}` 所在的 `{owner}/{repo}` | 退回 `JSC_WIKI_REPO` |
|
||||
| `JSC_WIKI_REPO` | 未逐類設定時的共用 wiki `{owner}/{repo}` | `tools/report-error.sh` 安靜降級,不寫 wiki |
|
||||
| `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` / `JSC_TOOL_NAME` | 非 Claude CLI 接線時由 `tools/jsc-wrap.sh` 或接線設定提供,代替 stdin JSON 的 `session_id`、`skill`、`tool_name`(`version-guard.sh` 也收沒有前綴的 `SKILL`、`TOOL_NAME`) | 安靜降級 |
|
||||
| `JSC_MODEL` | 非 Claude CLI 的目前模型,供 `sdlc-gate.sh` 比對;優先序在 transcript 實際值與 stdin `model` 之後 | 改讀 `~/.claude/settings.json`,再不行就安靜降級 |
|
||||
|
||||
## 相關 domain
|
||||
|
||||
@@ -8,6 +8,10 @@
|
||||
JSC_HOME="${JSC_HOME:-$HOME/.jsc}"
|
||||
mkdir -p "$JSC_HOME/sessions" "$JSC_HOME/usage" 2>/dev/null || true
|
||||
|
||||
# 呼叫端腳本所在目錄。source 不會改變 $0,所以這裡取到的是 hooks/ 或 tools/。
|
||||
JSC_SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" 2>/dev/null && pwd)
|
||||
JSC_SCRIPT_DIR="${JSC_SCRIPT_DIR:-.}"
|
||||
|
||||
# 讀完 stdin(可能為空;非阻塞宿主)
|
||||
read_stdin() {
|
||||
if [ -t 0 ]; then STDIN_JSON=""; else STDIN_JSON=$(cat 2>/dev/null || true); fi
|
||||
@@ -47,5 +51,37 @@ cli_name() {
|
||||
else printf 'unknown'; fi
|
||||
}
|
||||
|
||||
# 找出 jsc-gitea 的 tools/gitea.sh 絕對路徑。所有 gitea 操作一律經由它(技能準則),
|
||||
# 不可自行拼 API 呼叫:token 取用與 tea 金鑰退回都寫在那支腳本裡。
|
||||
# 找不到就回傳 1,由呼叫端安靜降級(hook 一律 exit 0,不中斷宿主 CLI)。
|
||||
jsc_gitea_sh() {
|
||||
if [ -n "${JSC_GITEA_TOOLS:-}" ] && [ -f "$JSC_GITEA_TOOLS/gitea.sh" ]; then
|
||||
printf '%s\n' "$JSC_GITEA_TOOLS/gitea.sh"; return 0
|
||||
fi
|
||||
_root="${CLAUDE_PLUGIN_ROOT:-$JSC_SCRIPT_DIR/..}"
|
||||
# 開發用的並排存取庫版面:{workspace}/hooks 旁邊就是 {workspace}/gitea
|
||||
for _c in "$_root/../gitea/tools/gitea.sh" "$_root/../jsc-gitea/tools/gitea.sh"; do
|
||||
[ -f "$_c" ] && { printf '%s\n' "$_c"; return 0; }
|
||||
done
|
||||
# 已安裝版面:每個 plugin 各有版本目錄,取排序最後的一份(通常即最新版)
|
||||
_c=$(ls -d "$_root"/../../jsc-gitea/*/tools/gitea.sh \
|
||||
"$_root"/../../gitea/*/tools/gitea.sh \
|
||||
"$HOME"/.claude/plugins/cache/*/jsc-gitea/*/tools/gitea.sh 2>/dev/null \
|
||||
| sort | tail -n1)
|
||||
[ -n "$_c" ] && [ -f "$_c" ] && { printf '%s\n' "$_c"; return 0; }
|
||||
_c=$(command -v gitea.sh 2>/dev/null || true)
|
||||
[ -n "$_c" ] && { printf '%s\n' "$_c"; return 0; }
|
||||
return 1
|
||||
}
|
||||
|
||||
# 每個 CLI 代號對應的實際執行檔(antigravity 是 agy、kiro 是 kiro-cli,其餘同名)
|
||||
cli_bin() { # $1=CLI 代號
|
||||
case "$1" in
|
||||
antigravity) printf 'agy' ;;
|
||||
kiro) printf 'kiro-cli' ;;
|
||||
*) printf '%s' "$1" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
now_epoch() { date +%s; }
|
||||
now_iso() { date -u +%Y-%m-%dT%H:%M:%SZ; }
|
||||
|
||||
+10
-4
@@ -35,15 +35,21 @@ stage_tags() { # $1=階段
|
||||
awk -F'\t' -v s="$1" '$1 == "stage" && $2 == s { print $3; exit }' "$TAGS_TSV"
|
||||
}
|
||||
|
||||
# 某模型的能力標籤。模型鍵與實際 id 雙向包含即視為同一家族
|
||||
#(例:表列 claude-haiku-4-5 對得上 claude-haiku-4-5-20251001);多筆命中取最長鍵。
|
||||
# 某模型的能力標籤。查法:先找完全相同的鍵,沒有才退回「表列鍵是實際 id 的前綴」
|
||||
# 之中最長的一筆(例:表列 claude-haiku-4-5 對得上 claude-haiku-4-5-20251001)。
|
||||
# 只認前綴這個方向。反向包含(實際 id 是表列鍵的前綴)會讓 gpt-5.x 命中更長的
|
||||
# gpt-5.x-mini,最長鍵勝出就把 mini 的標籤發給 gpt-5.x,把夠格的模型擋掉。
|
||||
model_tags() { # $1=模型 id
|
||||
[ -s "$TAGS_TSV" ] || return 0
|
||||
awk -F'\t' -v m="$1" '
|
||||
$1 == "model" && (index(m, $2) > 0 || index($2, m) > 0) {
|
||||
$1 == "model" && $2 == m { exact = $3 }
|
||||
$1 == "model" && $2 != m && substr(m, 1, length($2)) == $2 {
|
||||
if (length($2) > best_len) { best_len = length($2); best = $3 }
|
||||
}
|
||||
END { if (best != "") print best }
|
||||
END {
|
||||
if (exact != "") print exact
|
||||
else if (best != "") print best
|
||||
}
|
||||
' "$TAGS_TSV"
|
||||
}
|
||||
|
||||
|
||||
+11
-1
@@ -1,9 +1,16 @@
|
||||
#!/usr/bin/env sh
|
||||
# session-timer.sh — 記錄工作階段花費時間(供 jsc-log:worklog 取用)。
|
||||
# 用法:
|
||||
# session-timer.sh start # SessionStart:記錄起始時間
|
||||
# session-timer.sh start # SessionStart:記錄起始時間(已有紀錄就不動)
|
||||
# session-timer.sh restart # SessionStart:一律覆寫起始時間
|
||||
# session-timer.sh mark # Stop/SessionEnd:更新最後活動時間
|
||||
# session-timer.sh report [sid] # 印出 {sid} {seconds};無紀錄印 0
|
||||
#
|
||||
# start 與 restart 的差別在「同一個 session id 會不會重複開始」:
|
||||
# start 給 Claude 這種每個工作階段都有自己 session id 的 CLI。續接同一階段時
|
||||
# SessionStart 會再觸發一次,覆寫起始時間會讓花費時間歸零。
|
||||
# restart 給接不到 session id 的 CLI(kiro)。那些 CLI 的紀錄共用 default,
|
||||
# 不覆寫就會把上一個工作階段的起始時間算進來,花費時間虛胖。
|
||||
HERE=$(dirname "$0"); . "$HERE/lib.sh"
|
||||
read_stdin
|
||||
sid=$(session_id)
|
||||
@@ -11,6 +18,9 @@ case "${1:-mark}" in
|
||||
start)
|
||||
f="$JSC_HOME/sessions/$sid.start"
|
||||
[ -f "$f" ] || now_epoch > "$f" ;;
|
||||
restart)
|
||||
now_epoch > "$JSC_HOME/sessions/$sid.start"
|
||||
rm -f "$JSC_HOME/sessions/$sid.end" ;;
|
||||
mark)
|
||||
now_epoch > "$JSC_HOME/sessions/$sid.end" ;;
|
||||
report)
|
||||
|
||||
Regular → Executable
+180
-158
@@ -3,15 +3,22 @@
|
||||
#
|
||||
# 本機版本落後遠端發佈版本時擋下該次技能呼叫,並提示更新指令。
|
||||
#
|
||||
# 輸入:stdin JSON(Claude 格式)或環境變數,兩者都收。
|
||||
# 工具名 JSC_TOOL_NAME、TOOL_NAME、stdin 的 tool_name
|
||||
# 技能名 JSC_SKILL、SKILL、stdin 的 skill
|
||||
# 兩邊都拿不到就安靜降級 exit 0。
|
||||
#
|
||||
# 判準與取值:
|
||||
# - 比對對象是「遠端發佈版本」與「本機**實際載入**的版本」。
|
||||
# 實際載入版本要從 installed_plugins.json 的 installPath 讀該版目錄下的
|
||||
# plugin.json,不能只看註冊在 installed_plugins.json 的版本欄位——那兩者
|
||||
# 可能不同,只看註冊值會放過真正被載入的舊版。
|
||||
# 實際載入版本只認 installed_plugins.json 的 installPath 底下那份 plugin.json,
|
||||
# 不看註冊在 installed_plugins.json 的版本欄位——那兩者可能不同,註冊值比較新時
|
||||
# 會放過真正被載入的舊版。讀不到那份檔案就當查不到,安靜放行。
|
||||
# - 只擋落後。本機版本等於或超前遠端一律放行:開發技能組時本機本來就會
|
||||
# 超前 master,擋下去會讓維護者自己動不了。
|
||||
# - 遠端版本查不到(離線、站台維護、repo 改名)一律**擋**(fail-closed),
|
||||
# 避免「查不到就當作沒事」而讓落後版本靜靜跑下去。逃生門見下。
|
||||
# 超前預設分支,擋下去會讓維護者自己動不了。
|
||||
# - **只有「本機落後遠端」會擋**。查不到資料一律放行(exit 0):本機版本、
|
||||
# Gitea 站台、遠端版本全部來自 Claude 的 plugin 檔案與 Gitea API,沒裝
|
||||
# Claude 或離線的機器一筆都讀不到。那種情況擋下去,等於在沒有任何版本
|
||||
# 證據時停掉每一次技能呼叫,護欄變成故障點。
|
||||
#
|
||||
# 豁免(這些技能永遠放行):
|
||||
# jsc-cli:deploy 更新整組技能的入口,擋了就沒有任何方法更新,會死鎖
|
||||
@@ -22,84 +29,148 @@
|
||||
# 逃生門:JSC_VERSION_GUARD=off 完全略過檢查(離線工作時用)。
|
||||
#
|
||||
# 快取:$JSC_HOME/version-cache/{domain},單行「{版本} {epoch}」,
|
||||
# 預設 600 秒內不重查(JSC_VERSION_TTL 可調)。
|
||||
# 預設 600 秒內不重查(JSC_VERSION_TTL 可調)。hook 與 report 共用同一份。
|
||||
#
|
||||
# 另有一個非 hook 的子指令:
|
||||
# version-guard.sh report 把每個已安裝 jsc-* plugin 的版本比對印成 TSV,每行
|
||||
# 「{domain}<TAB>{本機}<TAB>{遠端}<TAB>{落後|最新|超前|查詢失敗}」,
|
||||
# 最後一行「behind<TAB>{落後個數}」。供 jsc-cli:deploy 判斷要不要
|
||||
# 把「更新」設成推薦選項。report 只讀不擋,永遠 exit 0。
|
||||
# 本機沒有 Claude 的 plugin 註冊檔時改印「noregistry<TAB>{路徑}」
|
||||
# 再接 behind 0:那代表這台機器無法做版本檢查,跟「全部最新」是兩件事。
|
||||
HERE=$(dirname "$0"); . "$HERE/lib.sh"
|
||||
|
||||
REG="$HOME/.claude/plugins/installed_plugins.json"
|
||||
MK="$HOME/.claude/plugins/known_marketplaces.json"
|
||||
|
||||
# 從檔案取 JSON 字串欄位。與 lib.sh 的 json_str 同一種 naive 解析,只是來源是檔案:
|
||||
# 先把換行換成空白、再以逗號斷行,這樣每行最多一個欄位,取值不會被貪婪比對吃掉。
|
||||
file_json_str() { # $1=檔案 $2=欄位名
|
||||
[ -f "$1" ] || return 0
|
||||
tr '\n' ' ' < "$1" | tr ',' '\n' \
|
||||
| sed -n "s/.*\"$2\"[[:space:]]*:[[:space:]]*\"\([^\"]*\)\".*/\1/p" | head -n1
|
||||
}
|
||||
|
||||
# 本機實際載入版本:先取該 plugin 的 installPath,再讀那個目錄下的 plugin.json。
|
||||
# 只認 installPath 底下那份檔案。註冊在 installed_plugins.json 的 version 欄位不當備援:
|
||||
# 註冊值可能比實際載入的版本新,拿它來比對會放過真正被載入的舊版,護欄形同虛設。
|
||||
# 讀不到那份檔案就當「查不到本機載入版本」,由呼叫端安靜放行。
|
||||
local_version() { # $1=domain
|
||||
[ -f "$REG" ] && [ -r "$REG" ] || return 0
|
||||
_seg=$(tr -d '\n' < "$REG" \
|
||||
| sed -n "s/.*\"jsc-$1@jsc\"[[:space:]]*:[[:space:]]*\[\([^]]*\)\].*/\1/p")
|
||||
[ -n "$_seg" ] || return 0
|
||||
_path=$(printf '%s' "$_seg" | tr ',' '\n' \
|
||||
| sed -n 's/.*"installPath"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n1)
|
||||
[ -n "$_path" ] || return 0
|
||||
file_json_str "$_path/plugin.json" version
|
||||
}
|
||||
|
||||
# 遠端站台與 owner:從已註冊的 jsc marketplace 來源推導,其次 GITEA_HOST。
|
||||
# 印出「{host} {owner}」;推導不出來時 host 為空字串。
|
||||
remote_host_owner() {
|
||||
_url=""
|
||||
if [ -f "$MK" ]; then
|
||||
_url=$(tr -d '\n' < "$MK" | sed -n 's/.*"jsc"[[:space:]]*:[[:space:]]*{//p' \
|
||||
| tr ',' '\n' \
|
||||
| sed -n 's/.*"url"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n1)
|
||||
fi
|
||||
_h=$(printf '%s' "$_url" | sed -n 's#^\(https\{0,1\}://[^/]*\)/.*#\1#p')
|
||||
_o=$(printf '%s' "$_url" | sed -n 's#^https\{0,1\}://[^/]*/\([^/]*\)/.*#\1#p')
|
||||
if [ -z "$_h" ] || [ -z "$_o" ]; then
|
||||
_h="${GITEA_HOST:-}"; _o="${JSC_GITEA_OWNER:-plugins}"
|
||||
fi
|
||||
printf '%s %s' "$_h" "$_o"
|
||||
}
|
||||
|
||||
# 遠端發佈版本:一律經由 jsc-gitea 的 gitea.sh(技能準則),它會帶 GITEA_TOKEN,
|
||||
# 並在缺 token 或 401/403 時退回 tea 的登入金鑰,私有存取庫才讀得到。
|
||||
# 不指定 ref:Gitea 的 raw 端點預設就取該存取庫的預設分支,比在這裡寫死分支名準。
|
||||
# 查不到就回傳空字串,由呼叫端放行。
|
||||
remote_version() { # $1=domain $2=host $3=owner
|
||||
_gsh=$(jsc_gitea_sh) || return 0
|
||||
_v=""
|
||||
for _try in 1 2; do # 暫時性網路失敗不該誤判成版本問題,失敗重試一次
|
||||
_body=$(GITEA_HOST="$2" sh "$_gsh" api GET "/repos/$3/$1/raw/plugin.json" 2>/dev/null) \
|
||||
&& _v=$(printf '%s' "$_body" | tr '\n' ' ' | tr ',' '\n' \
|
||||
| sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n1)
|
||||
[ -n "$_v" ] && break
|
||||
sleep 1
|
||||
done
|
||||
printf '%s' "$_v"
|
||||
}
|
||||
|
||||
TTL="${JSC_VERSION_TTL:-600}"
|
||||
cache_dir="$JSC_HOME/version-cache"
|
||||
|
||||
# 帶快取的遠端版本查詢。hook 與 report 共用同一份快取與同一個 TTL:
|
||||
# report 每個 domain 各打一次網路(還帶重試),/jsc-cli:deploy 一跑就是全部 domain,
|
||||
# 不共用快取等於每次部署都付一輪網路成本。
|
||||
cached_remote_version() { # $1=domain $2=host $3=owner
|
||||
_cache="$cache_dir/$1"
|
||||
_now=$(now_epoch)
|
||||
if [ -f "$_cache" ]; then
|
||||
_cv=$(cut -d' ' -f1 "$_cache" 2>/dev/null)
|
||||
_ca=$(cut -d' ' -f2 "$_cache" 2>/dev/null)
|
||||
if [ -n "$_cv" ] && [ -n "$_ca" ] && [ $((_now - _ca)) -lt "$TTL" ]; then
|
||||
printf '%s' "$_cv"; return 0
|
||||
fi
|
||||
fi
|
||||
_rv=$(remote_version "$1" "$2" "$3")
|
||||
if [ -n "$_rv" ]; then
|
||||
mkdir -p "$cache_dir" 2>/dev/null || true
|
||||
printf '%s %s\n' "$_rv" "$_now" > "$_cache" 2>/dev/null || true
|
||||
fi
|
||||
printf '%s' "$_rv"
|
||||
}
|
||||
|
||||
# 語意化比較:印出 -1($1 落後)、0(相等)、1($1 超前)
|
||||
ver_cmp() { # $1=版本 A $2=版本 B
|
||||
awk -v a="$1" -v b="$2" '
|
||||
BEGIN {
|
||||
n = split(a, x, "."); m = split(b, y, ".")
|
||||
for (i = 1; i <= 3; i++) {
|
||||
xi = (i <= n ? x[i] + 0 : 0); yi = (i <= m ? y[i] + 0 : 0)
|
||||
if (xi < yi) { print -1; exit }
|
||||
if (xi > yi) { print 1; exit }
|
||||
}
|
||||
print 0
|
||||
}'
|
||||
}
|
||||
|
||||
# ── report:一次比對所有已安裝的 jsc plugin(非 hook 模式,不讀 stdin)
|
||||
if [ "${1:-}" = "report" ]; then
|
||||
python3 - <<'PY'
|
||||
import json, os, re, subprocess, sys
|
||||
|
||||
home = os.path.expanduser("~")
|
||||
try:
|
||||
reg = json.load(open(f"{home}/.claude/plugins/installed_plugins.json")).get("plugins", {})
|
||||
except Exception:
|
||||
print("behind\t0"); sys.exit(0)
|
||||
|
||||
host = owner = ""
|
||||
try:
|
||||
mk = json.load(open(f"{home}/.claude/plugins/known_marketplaces.json"))
|
||||
m = re.match(r"(https?://[^/]+)/([^/]+)/[^/]+?(?:\.git)?/?$",
|
||||
(mk.get("jsc", {}).get("source", {}) or {}).get("url", ""))
|
||||
if m:
|
||||
host, owner = m.group(1), m.group(2)
|
||||
except Exception:
|
||||
pass
|
||||
if not host:
|
||||
host = os.environ.get("GITEA_HOST", "")
|
||||
owner = os.environ.get("JSC_GITEA_OWNER", "plugins")
|
||||
|
||||
|
||||
def ver(v):
|
||||
parts = ((v or "").split(".") + ["0", "0", "0"])[:3]
|
||||
return tuple(int(p) if p.isdigit() else 0 for p in parts)
|
||||
|
||||
|
||||
behind = 0
|
||||
for key in sorted(reg):
|
||||
m = re.match(r"^jsc-([^@]+)@", key)
|
||||
if not m:
|
||||
continue
|
||||
domain = m.group(1)
|
||||
# 本機版本一律以 installPath 底下那份 plugin.json 為準(實際載入版本),
|
||||
# 讀不到才退回註冊欄位。
|
||||
local = ""
|
||||
for e in reg[key]:
|
||||
try:
|
||||
local = json.load(open(os.path.join(e.get("installPath", ""), "plugin.json"))).get("version", "")
|
||||
break
|
||||
except Exception:
|
||||
local = e.get("version", "")
|
||||
remote = ""
|
||||
if host:
|
||||
url = f"{host}/{owner}/{domain}/raw/branch/master/plugin.json"
|
||||
for _ in range(2): # 暫時性網路失敗不該誤判成版本問題,失敗重試一次
|
||||
try:
|
||||
out = subprocess.run(["curl", "-sS", "--max-time", "10", url],
|
||||
capture_output=True, text=True, timeout=20).stdout
|
||||
mm = re.search(r'"version"\s*:\s*"([^"]+)"', out)
|
||||
if mm:
|
||||
remote = mm.group(1)
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
if not remote:
|
||||
state = "查詢失敗"
|
||||
elif ver(local) < ver(remote):
|
||||
state = "落後"; behind += 1
|
||||
elif ver(local) > ver(remote):
|
||||
state = "超前"
|
||||
else:
|
||||
state = "最新"
|
||||
print(f"{domain}\t{local or '?'}\t{remote or '?'}\t{state}")
|
||||
print(f"behind\t{behind}")
|
||||
PY
|
||||
# 註冊檔不存在或讀不到就明講。這裡不能只印 behind 0:呼叫端會把它讀成「都是最新」,
|
||||
# 於是把「這台機器無法做版本檢查」誤報成「不用更新」。
|
||||
# 舊版用 `tr -d '\n' < "$REG" 2>/dev/null`,那個 2>/dev/null 只蓋住 tr 的 stderr,
|
||||
# 蓋不住 shell 開檔失敗的訊息,所以沒裝 Claude 的機器會先漏一行 cannot open。
|
||||
if [ ! -f "$REG" ] || [ ! -r "$REG" ]; then
|
||||
printf 'noregistry\t%s\n' "$REG"
|
||||
printf 'behind\t0\n'
|
||||
exit 0
|
||||
fi
|
||||
ho=$(remote_host_owner)
|
||||
r_host=$(printf '%s' "$ho" | cut -d' ' -f1)
|
||||
r_owner=$(printf '%s' "$ho" | cut -d' ' -f2)
|
||||
behind=0
|
||||
domains=$(tr -d '\n' < "$REG" | tr ',' '\n' \
|
||||
| sed -n 's/.*"jsc-\([a-z0-9][a-z0-9-]*\)@[^"]*"[[:space:]]*:.*/\1/p' | sort -u)
|
||||
for d in $domains; do
|
||||
lv=$(local_version "$d")
|
||||
rv=""
|
||||
[ -n "$r_host" ] && rv=$(cached_remote_version "$d" "$r_host" "$r_owner")
|
||||
if [ -z "$rv" ]; then
|
||||
st="查詢失敗"
|
||||
else
|
||||
case "$(ver_cmp "$lv" "$rv")" in
|
||||
-1) st="落後"; behind=$((behind + 1)) ;;
|
||||
1) st="超前" ;;
|
||||
*) st="最新" ;;
|
||||
esac
|
||||
fi
|
||||
printf '%s\t%s\t%s\t%s\n' "$d" "${lv:-?}" "${rv:-?}" "$st"
|
||||
done
|
||||
printf 'behind\t%s\n' "$behind"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
@@ -107,11 +178,13 @@ read_stdin
|
||||
|
||||
[ "${JSC_VERSION_GUARD:-}" = "off" ] && exit 0
|
||||
|
||||
# 只管 Skill 工具
|
||||
tool=$(json_str tool_name)
|
||||
# 輸入相容:stdin JSON(Claude 格式)與環境變數(其他四支 CLI 接線時設定)都要收。
|
||||
# 只讀 stdin 的話,用環境變數餵資料的 CLI 一律拿到空值,檢查會整支靜靜放行。
|
||||
# 兩者都缺才是真的沒資料,那時照舊安靜降級 exit 0。
|
||||
tool="${JSC_TOOL_NAME:-${TOOL_NAME:-$(json_str tool_name)}}"
|
||||
[ -z "$tool" ] || [ "$tool" = "Skill" ] || exit 0
|
||||
|
||||
skill=$(json_str skill)
|
||||
skill="${JSC_SKILL:-${SKILL:-$(json_str skill)}}"
|
||||
[ -n "$skill" ] || exit 0
|
||||
|
||||
# 只管本技能組(jsc-{domain}:{name})
|
||||
@@ -129,95 +202,44 @@ case "$skill" in
|
||||
jsc-cli:deploy|jsc-hooks:hooks-install|jsc-cli:models|jsc-meta:*) exit 0 ;;
|
||||
esac
|
||||
|
||||
TTL="${JSC_VERSION_TTL:-600}"
|
||||
cache_dir="$JSC_HOME/version-cache"
|
||||
mkdir -p "$cache_dir" 2>/dev/null || true
|
||||
# 更新指令依實際 CLI 給。印別的 CLI 的指令等於沒給指令,使用者照著打只會失敗。
|
||||
update_cmd() { # $1=domain
|
||||
case "$(cli_name)" in
|
||||
claude)
|
||||
printf 'claude plugin marketplace update jsc && claude plugin update jsc-%s@jsc' "$1" ;;
|
||||
codex)
|
||||
printf 'codex plugin marketplace upgrade jsc' ;;
|
||||
copilot)
|
||||
printf 'copilot plugin marketplace update jsc && copilot plugin update jsc-%s@jsc' "$1" ;;
|
||||
antigravity)
|
||||
printf 'git -C ~/plugins/%s pull && agy plugin uninstall jsc-%s && agy plugin install ~/plugins/%s' "$1" "$1" "$1" ;;
|
||||
kiro)
|
||||
printf 'kiro-cli plugin marketplace update jsc && kiro-cli plugin update jsc-%s@jsc' "$1" ;;
|
||||
*)
|
||||
printf '用你的 CLI 的 plugin 更新指令更新 jsc-%s@jsc' "$1" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
deny() { # $1=訊息
|
||||
printf '[jsc][版本檢查][ERR]:%s\n' "$1" >&2
|
||||
printf '更新指令:claude plugin marketplace update jsc && claude plugin update jsc-%s@jsc\n' "$domain" >&2
|
||||
printf '更新指令:%s\n' "$(update_cmd "$domain")" >&2
|
||||
printf '更新整組:/jsc-cli:deploy | 確定要略過檢查:JSC_VERSION_GUARD=off\n' >&2
|
||||
exit 2
|
||||
}
|
||||
|
||||
# 本機實際載入版本:從 installPath 的 plugin.json 讀,不用註冊欄位
|
||||
local_ver=$(python3 - "$domain" <<'PY' 2>/dev/null
|
||||
import json, os, sys
|
||||
domain = sys.argv[1]
|
||||
try:
|
||||
reg = json.load(open(os.path.expanduser("~/.claude/plugins/installed_plugins.json")))
|
||||
except Exception:
|
||||
sys.exit(0)
|
||||
entries = reg.get("plugins", {}).get(f"jsc-{domain}@jsc") or []
|
||||
for e in entries:
|
||||
p = os.path.join(e.get("installPath", ""), "plugin.json")
|
||||
try:
|
||||
print(json.load(open(p)).get("version", "")); sys.exit(0)
|
||||
except Exception:
|
||||
continue
|
||||
# 讀不到實際檔案時退回註冊版本,並在後面標記為次要來源
|
||||
if entries and entries[0].get("version"):
|
||||
print(entries[0]["version"])
|
||||
PY
|
||||
)
|
||||
[ -n "$local_ver" ] || deny "讀不到本機 jsc-$domain 的實際載入版本(installed_plugins.json 或該版目錄的 plugin.json 不可用)"
|
||||
# 讀不到本機實際載入版本就放行:沒有版本證據時擋下等於停掉每一次技能呼叫
|
||||
local_ver=$(local_version "$domain")
|
||||
[ -n "$local_ver" ] || exit 0
|
||||
|
||||
# 遠端站台與 owner:從已註冊的 jsc marketplace 來源推導,其次 GITEA_HOST
|
||||
remote_src=$(python3 - <<'PY' 2>/dev/null
|
||||
import json, os, re
|
||||
try:
|
||||
d = json.load(open(os.path.expanduser("~/.claude/plugins/known_marketplaces.json")))
|
||||
except Exception:
|
||||
raise SystemExit
|
||||
url = (d.get("jsc", {}).get("source", {}) or {}).get("url", "")
|
||||
m = re.match(r"(https?://[^/]+)/([^/]+)/[^/]+?(?:\.git)?/?$", url)
|
||||
if m:
|
||||
print(m.group(1), m.group(2))
|
||||
PY
|
||||
)
|
||||
host=$(printf '%s' "$remote_src" | cut -d' ' -f1)
|
||||
owner=$(printf '%s' "$remote_src" | cut -d' ' -f2)
|
||||
if [ -z "$host" ] || [ -z "$owner" ]; then
|
||||
host="${GITEA_HOST:-}"; owner="${JSC_GITEA_OWNER:-plugins}"
|
||||
fi
|
||||
[ -n "$host" ] || deny "推導不出 Gitea 站台(known_marketplaces.json 無 jsc 來源,GITEA_HOST 也未設定)"
|
||||
ho=$(remote_host_owner)
|
||||
host=$(printf '%s' "$ho" | cut -d' ' -f1)
|
||||
owner=$(printf '%s' "$ho" | cut -d' ' -f2)
|
||||
[ -n "$host" ] || exit 0
|
||||
|
||||
# 快取
|
||||
cache="$cache_dir/$domain"
|
||||
now=$(now_epoch)
|
||||
remote_ver=""
|
||||
if [ -f "$cache" ]; then
|
||||
c_ver=$(cut -d' ' -f1 "$cache" 2>/dev/null)
|
||||
c_at=$(cut -d' ' -f2 "$cache" 2>/dev/null)
|
||||
if [ -n "$c_ver" ] && [ -n "$c_at" ] && [ $((now - c_at)) -lt "$TTL" ]; then
|
||||
remote_ver="$c_ver"
|
||||
fi
|
||||
fi
|
||||
# 遠端版本(走 hook 與 report 共用的快取與 TTL)
|
||||
remote_ver=$(cached_remote_version "$domain" "$host" "$owner")
|
||||
[ -n "$remote_ver" ] || exit 0
|
||||
|
||||
if [ -z "$remote_ver" ]; then
|
||||
url="$host/$owner/$domain/raw/branch/master/plugin.json"
|
||||
# 暫時性網路失敗不該誤判成版本問題,所以失敗重試一次再放棄
|
||||
for _try in 1 2; do
|
||||
body=$(curl -sS --max-time 10 "$url" 2>/dev/null) && \
|
||||
remote_ver=$(printf '%s' "$body" | sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n1)
|
||||
[ -n "$remote_ver" ] && break
|
||||
sleep 1
|
||||
done
|
||||
[ -n "$remote_ver" ] || deny "查不到 jsc-$domain 的遠端發佈版本($url)。無法確認本機是否為最新,依 fail-closed 規則擋下"
|
||||
printf '%s %s\n' "$remote_ver" "$now" > "$cache" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# 語意化比較:只擋「本機 < 遠端」
|
||||
cmp=$(awk -v a="$local_ver" -v b="$remote_ver" '
|
||||
BEGIN {
|
||||
n = split(a, x, "."); m = split(b, y, ".")
|
||||
for (i = 1; i <= 3; i++) {
|
||||
xi = (i <= n ? x[i] + 0 : 0); yi = (i <= m ? y[i] + 0 : 0)
|
||||
if (xi < yi) { print -1; exit }
|
||||
if (xi > yi) { print 1; exit }
|
||||
}
|
||||
print 0
|
||||
}')
|
||||
|
||||
[ "$cmp" = "-1" ] || exit 0
|
||||
# 只擋「本機 < 遠端」這一種情況
|
||||
[ "$(ver_cmp "$local_ver" "$remote_ver")" = "-1" ] || exit 0
|
||||
deny "jsc-$domain 本機版本 $local_ver 落後遠端發佈版本 $remote_ver,本次技能呼叫已擋下"
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "jsc-hooks",
|
||||
"version": "0.0.9",
|
||||
"description": "跨 CLI hooks:STE100 語言強制、工時計時、技能用量記錄、SDLC 模型鎖",
|
||||
"version": "0.0.11",
|
||||
"description": "跨 CLI hooks:STE100 語言強制、工時計時、技能用量記錄、SDLC 模型鎖、版本前置檢查",
|
||||
"skills": "./skills/"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
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.
|
||||
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.
|
||||
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 detailed flow **MUST run as a sub agent**; the main agent only reports the summary.
|
||||
|
||||
## 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.
|
||||
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:
|
||||
|
||||
| CLI | Exit / status | Verify |
|
||||
| --- | --- | --- |
|
||||
| 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.
|
||||
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`.
|
||||
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.
|
||||
|
||||
## 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`.
|
||||
|
||||
+5
-1
@@ -3,20 +3,24 @@
|
||||
# 用法: jsc-wrap.sh {cli} [args...]
|
||||
# 行為: 匯出 JSC_CLI 與 JSC_SESSION_ID → session-timer start → 執行 CLI →
|
||||
# 結束後 session-timer mark 並以 scan-logs.sh 回填用量,最後回傳 CLI 的結束碼。
|
||||
# 注意: JSC_CLI 存的是 CLI 代號(antigravity、kiro),實際執行的是 cli_bin 對應的
|
||||
# 執行檔(agy、kiro-cli)。直接拿代號當指令跑會 127,因為沒有這兩個執行檔。
|
||||
HERE=$(cd "$(dirname "$0")" && pwd)
|
||||
HOOKS="$HERE/../hooks"
|
||||
. "$HOOKS/lib.sh"
|
||||
cli="${1:-}"
|
||||
if [ -z "$cli" ]; then
|
||||
echo "用法:jsc-wrap.sh {cli} [args...]" >&2
|
||||
exit 2
|
||||
fi
|
||||
shift
|
||||
bin=$(cli_bin "$cli")
|
||||
JSC_CLI="$cli"
|
||||
# 未提供 session id 就自動產生({cli}-時間戳-PID),讓計時與用量共用同一個 session
|
||||
[ -n "${JSC_SESSION_ID:-}" ] || JSC_SESSION_ID="$cli-$(date +%Y%m%d%H%M%S)-$$"
|
||||
export JSC_CLI JSC_SESSION_ID
|
||||
sh "$HOOKS/session-timer.sh" start </dev/null
|
||||
"$cli" "$@"
|
||||
"$bin" "$@"
|
||||
rc=$?
|
||||
# 收尾:補記結束時間,並從原生日誌回填技能用量
|
||||
sh "$HOOKS/session-timer.sh" mark </dev/null
|
||||
|
||||
Executable
+136
@@ -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
@@ -1,24 +1,39 @@
|
||||
#!/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}
|
||||
# 行為(依 CLI 而定,皆為冪等:重跑只取代既有的 jsc-hooks 標記段落,不會重複疊加):
|
||||
# claude — 什麼都不用寫,hooks.json 已自動接線四支 hook
|
||||
# codex — 在 config.toml 設 notify(呼叫 session-timer.sh mark,JSC_CLI=codex);
|
||||
# claude — 什麼都不用寫,hooks.json 已自動接線五支 hook
|
||||
# codex — 在 shell rc 檔加上 codex 別名,轉呼叫 tools/jsc-wrap.sh codex(開始計時);
|
||||
# 在 config.toml 設 notify(每輪補 session-timer.sh start 再 mark,JSC_CLI=codex);
|
||||
# 在 AGENTS.md 附加 STE100 規則段落(prompt 降級)
|
||||
# copilot — 在 shell rc 檔加上 copilot 別名,轉呼叫 tools/jsc-wrap.sh copilot;
|
||||
# 在 copilot-instructions.md 附加 STE100 規則段落
|
||||
# antigravity — 在 shell rc 檔加上 agy 別名,轉呼叫 tools/jsc-wrap.sh antigravity;
|
||||
# 在全域規則檔附加 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/技能步驟檢查)
|
||||
# 2=用法錯誤 3=skipped(該 CLI 未偵測到執行檔,略過)
|
||||
# 4=failed(寫入或驗證沒過,接線沒生效;由 hooks-install 呼叫 report-error.sh 回報)
|
||||
set -u
|
||||
HERE=$(cd "$(dirname "$0")" && pwd)
|
||||
ROOT=$(cd "$HERE/.." && pwd)
|
||||
HOOKS="$ROOT/hooks"
|
||||
# cli_bin(CLI 代號 → 實際執行檔)的唯一來源在 lib.sh,包裝啟動器也用同一份
|
||||
. "$HOOKS/lib.sh"
|
||||
|
||||
cli="${1:-}"
|
||||
case "$cli" in
|
||||
@@ -28,25 +43,20 @@ case "$cli" in
|
||||
exit 2 ;;
|
||||
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_text() { sh "$HOOKS/ste100-guard.sh" 2>/dev/null | sed '/^exit /d'; }
|
||||
|
||||
# 以標記整段取代(冪等);標記不存在就在檔尾新增;檔案不存在就建立。
|
||||
# 適用 markdown 規則檔與 shell rc 檔:這兩種檔案沒有「區段」概念,附在檔尾就對了。
|
||||
# $1=檔案 $2=開頭標記行 $3=結尾標記行 $4=標記之間要寫入的內容
|
||||
replace_block() {
|
||||
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")
|
||||
if grep -qF "$bopen" "$file" 2>/dev/null; then
|
||||
awk -v bopen="$bopen" -v bshut="$bshut" -v block="$block" '
|
||||
@@ -54,14 +64,85 @@ replace_block() {
|
||||
$0==bshut { skip=0; next }
|
||||
skip { next }
|
||||
{ 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
|
||||
printf '\n%s\n' "$block" >> "$file"
|
||||
# 包一層子 shell 才蓋得住 shell 自己的開檔失敗訊息(>> 失敗時那行不走命令的 stderr)
|
||||
( printf '\n%s\n' "$block" >> "$file" ) 2>/dev/null || return 1
|
||||
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 為預設(自動建立)。
|
||||
# 印出找到/建立的 rc 檔路徑,一行一個。
|
||||
# 印出找到或建立的 rc 檔路徑,一行一個。
|
||||
rc_files() {
|
||||
found=""
|
||||
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"
|
||||
}
|
||||
|
||||
# 把別名寫進每個 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
|
||||
printf 'status=skipped reason=%s\n' "$1"
|
||||
echo "[jsc] 略過:$1"
|
||||
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
|
||||
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 自動接線"
|
||||
echo "[jsc] claude:由 hooks/hooks.json 自動接線全部四支 hook,無需寫入設定。"
|
||||
echo "[jsc] claude:由 hooks/hooks.json 自動接線全部五支 hook,無需寫入設定。"
|
||||
exit 0 ;;
|
||||
|
||||
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}"
|
||||
config="$CODEX_HOME/config.toml"
|
||||
agents="$CODEX_HOME/AGENTS.md"
|
||||
notify_line="notify = [\"env\", \"JSC_CLI=codex\", \"sh\", \"$HOOKS/session-timer.sh\", \"mark\"]"
|
||||
replace_block "$config" "# jsc-hooks" "# /jsc-hooks" "$notify_line" \
|
||||
|| skip "無法寫入 $config"
|
||||
# codex 的 notify 只在每一輪結束時觸發,沒有工作階段開始事件,所以計時分兩段接:
|
||||
# 1. shell 別名走 jsc-wrap.sh:啟動當下就 session-timer start,並給這次工作階段
|
||||
# 一個 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)" \
|
||||
|| skip "無法寫入 $agents"
|
||||
printf 'status=degraded reason=%s\n' "STE100 降級為 prompt 檔,SDLC 模型鎖降級為技能步驟檢查"
|
||||
echo "[jsc] codex:已設定 $config 的 notify 呼叫 session-timer.sh mark。"
|
||||
|| fail "無法寫入 $agents"
|
||||
has_block "$agents" "<!-- jsc-hooks -->" || fail "$agents 寫入後讀不到 jsc-hooks 標記段落"
|
||||
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:SDLC 模型鎖降級為技能步驟檢查,鎖檔仍由 sdlc-gate.sh lock 寫入。"
|
||||
echo "[jsc] codex:版本前置檢查接不上(codex 沒有 pre-tool hook),改由 /jsc-cli:deploy 定期更新。"
|
||||
exit 1 ;;
|
||||
|
||||
copilot)
|
||||
@@ -103,47 +226,73 @@ case "$cli" in
|
||||
command -v "$bin" >/dev/null 2>&1 || skip "未偵測到 copilot 執行檔"
|
||||
instr="${JSC_COPILOT_INSTRUCTIONS:-$HOME/.config/copilot/copilot-instructions.md}"
|
||||
alias_line="alias $bin='sh \"$HERE/jsc-wrap.sh\" copilot'"
|
||||
rc_files | while IFS= read -r rc; do
|
||||
replace_block "$rc" "# jsc-hooks:copilot" "# /jsc-hooks:copilot" "$alias_line"
|
||||
done
|
||||
write_alias_rc "jsc-hooks:copilot" "$alias_line" || fail "無法把 $bin 別名寫進 shell rc 檔"
|
||||
replace_block "$instr" "<!-- jsc-hooks -->" "<!-- /jsc-hooks -->" "$(ste100_text)" \
|
||||
|| skip "無法寫入 $instr"
|
||||
printf 'status=wired reason=%s\n' "wrapper 別名 + 規則檔已接線"
|
||||
|| fail "無法寫入 $instr"
|
||||
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:已在 $instr 寫入 STE100 規則段落。"
|
||||
exit 0 ;;
|
||||
echo "[jsc] copilot:已在 $instr 寫入 STE100 規則段落(prompt 降級)。"
|
||||
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)
|
||||
bin=$(cli_bin antigravity)
|
||||
command -v "$bin" >/dev/null 2>&1 || skip "未偵測到 antigravity(agy)執行檔"
|
||||
rules="${JSC_ANTIGRAVITY_RULES:-$HOME/.antigravity/AGENTS.md}"
|
||||
alias_line="alias $bin='sh \"$HERE/jsc-wrap.sh\" antigravity'"
|
||||
rc_files | while IFS= read -r rc; do
|
||||
replace_block "$rc" "# jsc-hooks:antigravity" "# /jsc-hooks:antigravity" "$alias_line"
|
||||
done
|
||||
write_alias_rc "jsc-hooks:antigravity" "$alias_line" || fail "無法把 $bin 別名寫進 shell rc 檔"
|
||||
replace_block "$rules" "<!-- jsc-hooks -->" "<!-- /jsc-hooks -->" "$(ste100_text)" \
|
||||
|| skip "無法寫入 $rules"
|
||||
printf 'status=wired reason=%s\n' "wrapper 別名 + 規則檔已接線"
|
||||
|| fail "無法寫入 $rules"
|
||||
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:已在 $rules 寫入 STE100 規則段落。"
|
||||
exit 0 ;;
|
||||
echo "[jsc] antigravity:已在 $rules 寫入 STE100 規則段落(prompt 降級)。"
|
||||
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)
|
||||
command -v "$(cli_bin kiro)" >/dev/null 2>&1 || skip "未偵測到 kiro-cli 執行檔"
|
||||
hookdir="./.kiro/hooks"
|
||||
hookfile="$hookdir/jsc-hooks.json"
|
||||
mkdir -p "$hookdir" 2>/dev/null || skip "無法建立 $hookdir"
|
||||
cat > "$hookfile" 2>/dev/null <<EOF || skip "無法寫入 $hookfile"
|
||||
startfile="$hookdir/jsc-hooks-session-start.json"
|
||||
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",
|
||||
"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" },
|
||||
"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
|
||||
printf 'status=degraded reason=%s\n' "SDLC 模型鎖降級為技能步驟檢查"
|
||||
echo "[jsc] kiro:已建立 $hookfile(JSC_CLI=kiro)。"
|
||||
for f in "$startfile" "$hookfile"; do
|
||||
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:版本前置檢查接不上(kiro 沒有 pre-tool hook),改由 /jsc-cli:deploy 定期更新。"
|
||||
exit 1 ;;
|
||||
esac
|
||||
|
||||
Reference in New Issue
Block a user