diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 4536ccf..9ba4250 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "jsc-hooks", - "version": "0.4.2", + "version": "0.4.6", "description": "跨 CLI hooks:STE100 語言強制、工時計時、技能用量記錄、SDLC 模型鎖、版本前置檢查、註解範圍守門、繁中編碼守門、部署後強制重啟、寫入與提交閘門", "skills": "./skills", "author": { diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index a99e8d2..663ad2a 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,7 +1,7 @@ { "hooks": "./hooks/codex-hooks.json", "name": "jsc-hooks", - "version": "0.4.2", + "version": "0.4.6", "description": "跨 CLI hooks:STE100 語言強制、工時計時、技能用量記錄、SDLC 模型鎖、版本前置檢查、註解範圍守門、繁中編碼守門、部署後強制重啟、寫入與提交閘門", "skills": "./skills", "jsc": { diff --git a/hooks/lib.sh b/hooks/lib.sh index 9eebf2f..73e8359 100755 --- a/hooks/lib.sh +++ b/hooks/lib.sh @@ -20,6 +20,17 @@ mkdir -p "$JSC_HOME/sessions" "$JSC_HOME/usage" 2>/dev/null || true JSC_SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" 2>/dev/null && pwd) JSC_SCRIPT_DIR="${JSC_SCRIPT_DIR:-.}" +# stdin JSON 的預設值。這一行要在任何讀取它的函式之前。 +# +# 為什麼一定要有:hook_trace() 裝的 EXIT trap 會在腳本結束時經由 emit_event() 呼叫 +# session_id(),而 session_id() 第一件事就是拿 json_str() 去讀這個變數。腳本在 read_stdin +# 之前就離開(只印規則的子命令、掃整個工作區的用法、逃生門關閉、模式不認得)時,變數還 +# 沒人設過,開了 `set -u` 的腳本收尾就會往標準錯誤吐一行「參數未設定」。事件其實照樣寫得 +# 進去,訊息卻讓呼叫端誤判本體失敗——而以「安靜回 0」為通過判準的流程,會因此整條失準。 +# 補在這裡而不是各腳本各補一次:讀這個變數的是共用函式,補在共用處才涵蓋每一條離開路徑。 +# 用 `${STDIN_JSON-}` 而不是直接指派空字串:呼叫端已經帶值進來時要原樣保留。 +STDIN_JSON="${STDIN_JSON-}" + # 讀完 stdin(可能為空;非阻塞宿主) read_stdin() { if [ -t 0 ]; then STDIN_JSON=""; else STDIN_JSON=$(cat 2>/dev/null || true); fi @@ -229,27 +240,61 @@ cli_name() { else printf 'unknown'; fi } +# 把一個檔案路徑正規化成不含 `..` 的實體路徑。解不出來就回傳 1。 +# +# 為什麼一定要正規化:路徑裡的 `..` 一旦要穿過符號連結,兩種解法會給出不同的答案。 +# 核心與 `[ -f ]` 走實體解析:先跟著連結走到目標,再從目標往上退。 +# shell 的 `cd` 走邏輯解析:把 `..` 當純文字消去,退回的是連結自己的上層目錄。 +# 找別的 plugin 是靠自己的位置往上退幾層再往下找,而安裝版面的腳本目錄正是經由一條符號 +# 連結被叫到的,退層數一超過連結目標底下的深度就會踩到這個差異:這裡的 `[ -f ]` 說檔案 +# 在、把路徑交出去,被呼叫的腳本自己 `cd` 過去卻找不到那個目錄,回一個空輸出與非零結束 +# 碼。呼叫端只看得到「查詢失敗」,看不出是路徑寫法的問題,於是整道閘門無聲失效。 +# +# 為什麼用 `cd -P` 加 `pwd -P` 而不是 readlink:這兩個都是 shell 內建,不必在 PATH 上找 +# 外部執行檔。這些函式會在 cron 那種只剩幾段 PATH 的環境下跑,少一個外部相依就少一個 +# 解不出來的理由。`-P` 是逐段跟著連結走的那一種解法,跟核心的答案一致。 +jsc_abs_path() { # $1=檔案路徑 + [ -n "${1:-}" ] || return 1 + _ap_dir=$(CDPATH= cd -P -- "$(dirname -- "$1")" 2>/dev/null && pwd -P) || return 1 + [ -n "$_ap_dir" ] || return 1 + case "$_ap_dir" in + */) printf '%s%s\n' "$_ap_dir" "$(basename -- "$1")" ;; + *) printf '%s/%s\n' "$_ap_dir" "$(basename -- "$1")" ;; + esac +} + # 找出 jsc-gitea 的 tools/gitea.sh 絕對路徑。所有 gitea 操作一律經由它(技能準則), # 不可自行拼 API 呼叫:token 取用與 tea 金鑰退回都寫在那支腳本裡。 # 找不到就回傳 1,由呼叫端安靜降級(hook 一律 exit 0,不中斷宿主 CLI)。 +# +# 每一條候選路徑都先湊出來、最後統一過 jsc_abs_path 才交出去,理由見該函式的說明: +# 這裡的候選帶著 `..`,而那些 `..` 要穿過安裝版面的符號連結,交出去的原樣路徑 +# 只有 `[ -f ]` 認得,被呼叫的腳本自己 `cd` 過去會失敗。正規化失敗時退回原樣路徑, +# 讓「找得到」這件事的判準不因為多了一道正規化而變嚴。 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 + _c="" _root="${CLAUDE_PLUGIN_ROOT:-$JSC_SCRIPT_DIR/..}" + if [ -n "${JSC_GITEA_TOOLS:-}" ] && [ -f "$JSC_GITEA_TOOLS/gitea.sh" ]; then + _c="$JSC_GITEA_TOOLS/gitea.sh" + fi # 開發用的並排存取庫版面:{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 + if [ -z "$_c" ]; then + for _p in "$_root/../gitea/tools/gitea.sh" "$_root/../jsc-gitea/tools/gitea.sh"; do + [ -f "$_p" ] && { _c="$_p"; break; } + done + fi # 已安裝版面:每個 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 + if [ -z "$_c" ]; then + _p=$(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 "$_p" ] && [ -f "$_p" ] && _c="$_p" + fi + [ -n "$_c" ] || _c=$(command -v gitea.sh 2>/dev/null || true) + [ -n "$_c" ] || return 1 + _n=$(jsc_abs_path "$_c") && [ -n "$_n" ] && _c="$_n" + printf '%s\n' "$_c" } # 每個 CLI 代號對應的實際執行檔(antigravity 是 agy、kiro 是 kiro-cli,其餘同名) diff --git a/plugin.json b/plugin.json index 6d1ce6a..a7d14b9 100644 --- a/plugin.json +++ b/plugin.json @@ -1,6 +1,6 @@ { "name": "jsc-hooks", - "version": "0.4.2", + "version": "0.4.6", "description": "跨 CLI hooks:STE100 語言強制、工時計時、技能用量記錄、SDLC 模型鎖、版本前置檢查、註解範圍守門、繁中編碼守門、部署後強制重啟、寫入與提交閘門", "skills": "./skills/", "jsc": { diff --git a/references/behaviors.md b/references/behaviors.md index 72e440e..ae980de 100644 --- a/references/behaviors.md +++ b/references/behaviors.md @@ -7,10 +7,10 @@ | 項目 | 內容 | | --- | --- | | 觸發時機 | 裝好或更新完 jsc 技能組之後,要把九支 hook 接線到每一支已安裝的 CLI 時用;`jsc-cli:deploy` 收尾會把偵測到的 CLI 清單交給它。不用於撰寫新的 hook,也不用於單獨修一支壞掉的 hook,那是 `jsc-hooks:repair` 的事 | -| 關鍵步驟 | 取得 CLI 清單(呼叫端交來的優先,沒有才自己跑 `detect-clis.sh`)、第一支 CLI 單獨跑完整條管線(它負責更新共用的 `$JSC_HOME/current/jsc-hooks` 連結)、其餘 CLI 一支一個 sub agent 並行、每支 CLI 依序走 purge、接線、status、smoke、scan 五道關卡、讀每道關卡自己印的第一行判定、任一關卡出錯就寫 `ERROR_{HASH}` 並轉給 `jsc-hooks:repair`(異常頁與索引目錄頁分屬兩個存取庫,各自解析;只解不出目錄頁的存取庫時異常頁照寫、索引跳過,回報要講明那一頁沒被索引)、目錄頁那一列指向異常頁的連結一律寫成 `[{文字}]({連結})`,網址取 `gitea.sh wiki-url`,寫進去之前先過 `jsc-gitea/tools/link-check.sh`,結束碼 0 才寫連結、驗不過那一格只留純文字頁名而那一列與異常頁照寫(`report-error.sh` 內部做完,結束碼不變)、逐 CLI 回報五道關卡的結果、最後由主代理呼叫一次 `tools/report-status.sh skill-end jsc-hooks:hooks-install {status} {結束碼} {detail}` 記下整輪怎麼結束。腳本在同一個存取庫,用 `tools/` 相對路徑;這一筆只由主代理寫一次,寫在並行的各 CLI sub agent 裡會變成五筆互相矛盾的結局。腳本不在就安靜跳過,回報失敗不得變成接線失敗 | -| 外部呼叫 | `tools/wire-cli.sh purge`、`tools/wire-cli.sh {cli}`、`tools/wire-cli.sh status`、`tools/wire-cli.sh smoke`、`tools/scan-hook-errors.sh`、`tools/report-error.sh`、`jsc-cli/tools/detect-clis.sh`、`jsc-hooks:repair` 技能、`jsc-gitea:wiki`(寫 `ERROR_{HASH}` 時經 `report-error.sh`)、`jsc-gitea/tools/gitea.sh wiki-url` 與 `jsc-gitea/tools/link-check.sh`(同樣經 `report-error.sh`,取目錄頁那一列的網址並驗它連得到);接線腳本內部另呼叫 `hooks/skill-name.sh` 與 `hooks/deny.sh` 做冒煙斷言 | -| 完成條件 | 每一支偵測到的 CLI 都有五道關卡各一行判定,沒有任何一道回結束碼 2,smoke 的 `lines` 條數與它自己的斷言相符,claude、codex、copilot、antigravity 回 `wired` 而 kiro 回 `degraded`(CLI 擋不下技能叫用),四支非 claude 的執行期錯誤掃描一律據實回 `unavailable`,各 CLI 的形狀與觸發驗證等級分開寫進回報(codex、antigravity、kiro 形狀實證,copilot 形狀未證;kiro 觸發部分實證,其餘未驗證),每一筆錯誤都帶一個 `ERROR_{HASH}` 結果與一條對 `develop` 的修正 PR 連結,而且目錄頁那一列的連結驗不過時,回報要講明那一列只有純文字頁名、沒有連結。以上收完之後還要走完最後一步:呼叫 `report-status.sh skill-end`,狀態五選一——每一支偵測到的 CLI 五道關卡全過而且一律回 `wired` 是 `ok`,實務上等於那台機器沒有 kiro;跑完整輪但有一部分沒到 `wired` 是 `degraded`,涵蓋 kiro 只能降級接線,以及某支 CLI 接線失敗、已經轉交 `jsc-hooks:repair` 而且拿到一條對 `develop` 的 PR,還有被跳過的 CLI;關卡失敗而沒人接手是 `failed`,也就是修正技能起不來或回不出 PR,壞掉的 hook 還掛在那裡;`JSC_READONLY=1` 讓 `wire-cli.sh` 回 exit 6、一支 CLI 都沒動是 `blocked`;偵測不到任何 CLI 而主動停止是 `aborted`。腳本不在磁碟上就跳過,這一步照樣算走完 | -| 可驗證跡象 | 各 CLI 的設定檔多出 jsc 段落:codex 的 `config.toml` 標記段落、`hooks/codex-hooks.json`(從 `hooks/hooks.json` 推導,matcher `Skill` 換成 `Bash`)與 `.codex-plugin/plugin.json` 指過去的 `hooks` 路徑字串、copilot 的 `~/.copilot/settings.json` 頂層 `hooks` 鍵(matcher `skill`,合併不覆寫,`enabledPlugins` 與第三方條目原樣保留)與 `$COPILOT_HOME` 底下的指引檔、antigravity 的 `~/.gemini/config/hooks.json` 的 `jsc` 段落(`PreToolUse` 為 Grouped、matcher `^view_file$`,`PreInvocation` 維持 Flat)、kiro 的 `~/.kiro/agents/jsc.json`(`hooks` 為 `agentSpawn`、`userPromptSubmit`、`stop` 三個合法事件加 `timeout_ms`、兩層 `skill://` glob 的 `resources`、明列的 `tools`,並通過 `kiro-cli agent validate`)與 `~/.kiro/settings/cli.json` 的 `chat.defaultAgent=jsc`;四支非 claude 的接線命令都以 `JSC_CLI={代號}` 前綴自帶 CLI 代號,缺了它兩道閘門解不出技能名、一律安靜放行,所以 `status` 把它列成單獨一項;另有 `$JSC_HOME/current/jsc-hooks` 符號連結建立或更新、`$JSC_HOME/backup/hooks/{cli}/{時間戳}/` 留下 purge 前的備份、出錯時 wiki 多一頁 `ERROR_{HASH}`(落在 `JSC_WIKI_REPO_ERROR` 解出的存取庫)並在索引目錄頁補一列(落在 `JSC_WIKI_REPO_CONTENTS` 解出的另一個存取庫,那一列的第 2 格寫成 `[{頁名}]({絕對網址})`,網址取自 `gitea.sh wiki-url` 且已經過 `link-check.sh` 驗到結束碼 0;驗不過那一格只有純文字頁名,`report-error.sh` 在 stderr 留一行 `[jsc]` 講明是哪一種原因)、修正路徑留下一條對 `develop` 的 PR 。接線完成後 `$JSC_HOME/usage/events.jsonl` 會逐行長出 `{kind:hook}` 事件,每支 hook 每次執行一筆,欄位含 `status` 與實際結束碼;跑過技能之後另有 `{kind:skill,phase:start}`。這支技能自己收尾時,同一個 `$JSC_HOME/usage/events.jsonl` 尾端會多一筆 `{kind:skill,phase:end}`,`name` 是 `jsc-hooks:hooks-install`,整輪只有一筆,`status` 與那次結局相符,`exit` 是決定結局的那道關卡的結束碼;`report-status.sh` 不在那台機器上就沒有這一筆,接線結果一字不變。事件寫不進去不影響任何 hook 的結束碼,也不影響本技能的結局 | +| 關鍵步驟 | 先跑前置步驟解出兩個字面絕對路徑:`readlink -f "$JSC_HOME/current"` 解出連結農場根目錄(跨 domain 呼叫用它),`readlink -f "$JSC_HOME/current/jsc-hooks"` 解出 jsc-hooks 的實體根目錄(只有 `wire-cli.sh` 從這裡跑。它會改寫自己正踩著的那條連結,但它自己已經把 `HERE` 與 `ROOT` 解成實體路徑,`ln -sfn` 不會再把連結指向自己,所以從實體根目錄跑現在是多一層保險、不是唯一防線;照做的理由是舊版腳本還在別的機器上跑,那些版本走連結跑仍會把連結寫成指向自己、全機器 hook 一起失效)、兩個路徑各解一次不重解、各自在同一步用 `[ -d ]` 查過印出來的目錄真的存在(`JSC_HOME` 沒設時第一條會印出 `/current`、結束碼 0,非空又是絕對路徑,只查前三項擋不下來),任何一條解不出來、不是絕對路徑、或目錄不存在就停手回報是哪一條沒解出來並叫人跑 `jsc-cli:deploy`,不接任何線也不猜路徑、不退回帶版本號的快取路徑、之後每一次腳本呼叫都用解出來的字面絕對路徑開頭、取得 CLI 清單(呼叫端交來的優先,沒有才自己跑 `detect-clis.sh`)、第一支 CLI 單獨跑完整條管線(它負責更新共用的 `{連結農場根}/jsc-hooks` 連結)、其餘 CLI 一支一個 sub agent 並行、每支 CLI 依序走 purge、接線、status、smoke、scan 五道關卡、讀每道關卡自己印的第一行判定、任一關卡出錯就寫 `ERROR_{HASH}` 並轉給 `jsc-hooks:repair`(異常頁與索引目錄頁分屬兩個存取庫,各自解析;只解不出目錄頁的存取庫時異常頁照寫、索引跳過,回報要講明那一頁沒被索引)、目錄頁那一列指向異常頁的連結一律寫成 `[{文字}]({連結})`,網址取 `gitea.sh wiki-url`,寫進去之前先過 `jsc-gitea/tools/link-check.sh`,結束碼 0 才寫連結、驗不過那一格只留純文字頁名而那一列與異常頁照寫(`report-error.sh` 內部做完,結束碼不變)、逐 CLI 回報五道關卡的結果、最後由主代理呼叫一次 `tools/report-status.sh skill-end jsc-hooks:hooks-install {status} {結束碼} {detail}` 記下整輪怎麼結束。腳本在同一個存取庫,用 `tools/` 相對路徑;這一筆只由主代理寫一次,寫在並行的各 CLI sub agent 裡會變成五筆互相矛盾的結局。腳本不在就安靜跳過,回報失敗不得變成接線失敗 | +| 外部呼叫 | `readlink -f`(前置步驟解兩個根目錄,各一次)、`tools/wire-cli.sh purge`、`tools/wire-cli.sh {cli}`、`tools/wire-cli.sh status`、`tools/wire-cli.sh smoke`、`tools/scan-hook-errors.sh`、`tools/report-error.sh`、`jsc-cli/tools/detect-clis.sh`、`jsc-hooks:repair` 技能、`jsc-gitea:wiki`(寫 `ERROR_{HASH}` 時經 `report-error.sh`)、`jsc-gitea/tools/gitea.sh wiki-url` 與 `jsc-gitea/tools/link-check.sh`(同樣經 `report-error.sh`,取目錄頁那一列的網址並驗它連得到);接線腳本內部另呼叫 `hooks/skill-name.sh` 與 `hooks/deny.sh` 做冒煙斷言 | +| 完成條件 | 前置步驟解出的兩個根目錄都是一條存在的絕對路徑(各自用 `[ -d ]` 查過),而且整個流程沒有任何一次腳本呼叫帶著未展開的變數或波浪號,每一支偵測到的 CLI 都有五道關卡各一行判定,沒有任何一道回結束碼 2,smoke 的 `lines` 條數與它自己的斷言相符,claude、codex、copilot、antigravity 回 `wired` 而 kiro 回 `degraded`(CLI 擋不下技能叫用),四支非 claude 的執行期錯誤掃描一律據實回 `unavailable`,各 CLI 的形狀與觸發驗證等級分開寫進回報(codex、antigravity、kiro 形狀實證,copilot 形狀未證;kiro 觸發部分實證,其餘未驗證),每一筆錯誤都帶一個 `ERROR_{HASH}` 結果與一條對 `develop` 的修正 PR 連結,而且目錄頁那一列的連結驗不過時,回報要講明那一列只有純文字頁名、沒有連結 | +| 可驗證跡象 | 各 CLI 的設定檔多出 jsc 段落:codex 的 `config.toml` 標記段落、`hooks/codex-hooks.json`(從 `hooks/hooks.json` 推導,matcher `Skill` 換成 `Bash`)與 `.codex-plugin/plugin.json` 指過去的 `hooks` 路徑字串、copilot 的 `~/.copilot/settings.json` 頂層 `hooks` 鍵(matcher `skill`,合併不覆寫,`enabledPlugins` 與第三方條目原樣保留)與 `$COPILOT_HOME` 底下的指引檔、antigravity 的 `~/.gemini/config/hooks.json` 的 `jsc` 段落(`PreToolUse` 為 Grouped、matcher `^view_file$`,`PreInvocation` 維持 Flat)、kiro 的 `~/.kiro/agents/jsc.json`(`hooks` 為 `agentSpawn`、`userPromptSubmit`、`stop` 三個合法事件加 `timeout_ms`、兩層 `skill://` glob 的 `resources`、明列的 `tools`,並通過 `kiro-cli agent validate`)與 `~/.kiro/settings/cli.json` 的 `chat.defaultAgent=jsc`;四支非 claude 的接線命令都以 `JSC_CLI={代號}` 前綴自帶 CLI 代號,缺了它兩道閘門解不出技能名、一律安靜放行,所以 `status` 把它列成單獨一項;另有 `{連結農場根}/jsc-hooks` 符號連結建立或更新,而且它指向 jsc-hooks 的實體根目錄、不是指向自己(`readlink -f` 解得出一個存在的目錄,裡面有 `hooks/session-timer.sh` 與 `tools/jsc-wrap.sh`)、各 CLI 設定裡存下來的接線命令也都是展開後的字面絕對路徑,只有存放庫自帶的 `hooks/hooks.json` 保留 `${JSC_HOME:-$HOME/.jsc}/current/jsc-hooks` 這段變數寫法,由 hook 自己的 shell 在執行當下展開、`$JSC_HOME/backup/hooks/{cli}/{時間戳}/` 留下 purge 前的備份、出錯時 wiki 多一頁 `ERROR_{HASH}`(落在 `JSC_WIKI_REPO_ERROR` 解出的存取庫)並在索引目錄頁補一列(落在 `JSC_WIKI_REPO_CONTENTS` 解出的另一個存取庫,那一列的第 2 格寫成 `[{頁名}]({絕對網址})`,網址取自 `gitea.sh wiki-url` 且已經過 `link-check.sh` 驗到結束碼 0;驗不過那一格只有純文字頁名,`report-error.sh` 在 stderr 留一行 `[jsc]` 講明是哪一種原因)、修正路徑留下一條對 `develop` 的 PR 。接線完成後 `$JSC_HOME/usage/events.jsonl` 會逐行長出 `{kind:hook}` 事件,每支 hook 每次執行一筆,欄位含 `status` 與實際結束碼;跑過技能之後另有 `{kind:skill,phase:start}`。事件寫不進去不影響任何 hook 的結束碼這支技能自己收尾時,同一個 `$JSC_HOME/usage/events.jsonl` 尾端會多一筆 `{kind:skill,phase:end}`,`name` 是 `jsc-hooks:hooks-install`,整輪只有一筆,`status` 與那次結局相符,`exit` 是決定結局的那道關卡的結束碼;`report-status.sh` 不在那台機器上就沒有這一筆,接線結果一字不變。事件寫不進去不影響任何 hook 的結束碼,也不影響本技能的結局 | ## repair @@ -20,4 +20,4 @@ | 關鍵步驟 | 從 `ERROR_{HASH}` 讀失敗情境(沒有頁就讀失敗的 `status=` 那一行,讀不到就停下來問)、跑 `detect-clis.sh`、每一支偵測到的 CLI 各開一個唯讀 sub agent 診斷並交回根因、要改的檔案與驗證指令、挑最小的修正改進 hooks 存取庫(技能名解析改 `hooks/skill-name.sh`、阻擋形態改 `hooks/deny.sh`,兩支是唯一真實來源,不在閘門裡各補一份)、跑 `wire-cli.sh smoke {cli}` 驗到 exit 0、跑 `sync-skill-manifest.sh .` 同步版本、以 `jsc-git:pr` 對 `develop` 開 PR、收尾呼叫 `tools/report-status.sh skill-end jsc-hooks:repair {status} {結束碼} {detail}` 記下這次修正怎麼結束(腳本在同一個存取庫,用 `tools/` 相對路徑,比照 `tools/wire-cli.sh`;檔案不在就安靜跳過,回報失敗不得變成修正失敗) | | 外部呼叫 | `jsc-gitea:wiki`、`jsc-cli/tools/detect-clis.sh`、`tools/wire-cli.sh smoke`、`jsc-meta/tools/sync-skill-manifest.sh`、`jsc-git:pr`;診斷階段另以 sub agent 叫用各支已安裝的 AI CLI | | 完成條件 | 修正已經落在磁碟上、`wire-cli.sh smoke` 對受影響的 CLI 回 exit 0、`sync-skill-manifest.sh` 回 exit 0 而且三份 manifest 版本一致,最後拿到一條對 `develop` 的 PR 連結;開不出 PR 時要講明修正已套用但尚未合併、帶上分支名與失敗原因。每一條路線都要走完最後一步:呼叫 `report-status.sh skill-end`,狀態五選一——修正落地、smoke 回 exit 0、三份 manifest 版本一致而且拿到 PR 連結是 `ok`;smoke 過了但東西沒送出去是 `degraded`,也就是開不出 PR 只剩分支,或 manifest 沒對齊;修不好是 `failed`,也就是診斷繞回去以後 smoke 還是回 exit 4,或同步版本踩到環境錯誤,壞掉的接線還是壞的;沒有可修的項目是 `aborted`,也就是讀不到任何失敗情境。本技能豁免版本閘門與部署後重啟閘門,沒有別的閘門擋得住它,所以不會用 `blocked`。腳本不在磁碟上就跳過,這一步照樣算走完 | -| 可驗證跡象 | hooks 存取庫多一個修正提交與一條推上去的分支、`develop` 上多一條 PR、三份 manifest 與 README 技能清單版本一致、`wire-cli.sh smoke` 由失敗轉為 exit 0;不論走哪一條路線,`$JSC_HOME/usage/events.jsonl` 尾端都會多一筆 `{kind:skill,phase:end}` 事件,`name` 是 `jsc-hooks:repair`,`status` 與那次結局相符,`exit` 是決定結局的那支工具的結束碼;`report-status.sh` 不在那台機器上就沒有這一筆,修正結果一字不變 | +| 可驗證跡象 | hooks 存取庫多一個修正提交與一條推上去的分支、`develop` 上多一條 PR、三份 manifest 與 README 技能清單版本一致、`wire-cli.sh smoke` 由失敗轉為 exit 0。收尾在 `$JSC_HOME/usage/events.jsonl` 留下這一輪的 `skill-end` 事件,`status` 取 `ok`、`blocked`、`failed`、`degraded` 或 `aborted`,中途停下的那幾輪也照寫——只有 start 沒有配對 end 會被讀成中斷;不論走哪一條路線,`$JSC_HOME/usage/events.jsonl` 尾端都會多一筆 `{kind:skill,phase:end}` 事件,`name` 是 `jsc-hooks:repair`,`status` 與那次結局相符,`exit` 是決定結局的那支工具的結束碼;`report-status.sh` 不在那台機器上就沒有這一筆,修正結果一字不變 | diff --git a/skills/hooks-install/SKILL.md b/skills/hooks-install/SKILL.md index 57d7403..b4f55af 100644 --- a/skills/hooks-install/SKILL.md +++ b/skills/hooks-install/SKILL.md @@ -7,9 +7,30 @@ description: Wire jsc hooks (STE100 guard, session timer, skill usage logger, SD Goal: make the nine hooks (`ste100-guard.sh`, `session-timer.sh`, `skill-usage.sh`, `sdlc-gate.sh`, `version-guard.sh`, `restart-gate.sh`, `comment-scope.sh`, `lang-guard.sh`, `write-guard.sh`) effective in every CLI, with nothing else wired alongside them. +## Path rule + +**Every script call in this skill is written as a literal absolute path.** A path that still carries `$JSC_HOME`, any other unexpanded variable, or a `~` cannot be resolved statically by the permission layer, so it is treated as unknown and always asks for approval. An unattended round has nobody to approve, so it stops at the first script and the whole install never starts. + +Measured on this machine: `$JSC_HOME/current/jsc-assist/tools/patrol.sh` and `~/.jsc/current/...` were both blocked and the command never ran; the same script at `/root/.jsc/current/...` ran. Adding an allow rule that itself starts with `$JSC_HOME` changed nothing on a retest, because the rule is matched against the expanded command — widening the permission list is not the fix. + +Do not trade this back for portability. A variable-form path in this file buys no portability; it buys a round that dies before its first stage. Portability lives in the prerequisite below, which resolves the roots once, on the machine, at run time. + +## Prerequisite — resolve the roots once + +Run these two before any other call in this skill, and only here: + +1. `readlink -f "$JSC_HOME/current"` prints the link farm as a literal absolute path. Call it `{JSC_ROOT}`. Every cross-domain call is written `{JSC_ROOT}/jsc-{domain}/...` with that path substituted in. +2. `readlink -f "$JSC_HOME/current/jsc-hooks"` prints the physical root behind the `jsc-hooks` link. Call it `{HOOKS_ROOT}`. `tools/wire-cli.sh` is called from there and from nowhere else. That script rewrites the very `{JSC_ROOT}/jsc-hooks` link it would be running through and derives its own root from `$0`, so running it through the link makes `ln -sfn` point that link at itself. The loop takes every CLI's hooks down at once, and it has happened. + +**Both results are checked before anything else runs: `[ -d "{JSC_ROOT}" ]` and `[ -d "{HOOKS_ROOT}" ]`, each in the same approved step as its own `readlink`.** A `readlink` that printed something is not a `readlink` that found something. With `JSC_HOME` unset the first call prints `/current` and exits 0 — non-empty, absolute, and wrong — and every literal path built from it then names a place that is not there; the second call has the same hole one level down. An empty result, a non-zero exit, a path that is not absolute, or a directory that does not exist stops the skill here: report which of the two roots did not resolve and what the command printed, say `jsc-cli:deploy` has to run to restore `current` and its `jsc-hooks` link, and wire nothing. Never guess a root, never fall back to a versioned plugin cache path, and never create either root here — a run that pushes on wires every CLI to scripts that are not there, and `purge` has already removed the hooks that worked. + +Resolve both once, here. Do not re-resolve per call, and do not add a tool that prints these paths — two `readlink` runs and their two checks are the whole step. The main agent resolves them and hands both literal paths to every sub agent it starts, so a sub agent never resolves anything itself. Done when you hold two literal absolute paths, both naming directories that exist, and every later call starts with one of them. + +## Wiring + Install on a clean slate. Every CLI is purged of all hooks first, third-party ones included, so a later failure has exactly one owner. `tools/wire-cli.sh purge` backs up every file it touches before it removes anything, so the removal stays reversible. -The wiring commands stored in user config use `$JSC_HOME/current/jsc-hooks`, not the versioned plugin cache path and not the development checkout. `tools/wire-cli.sh {cli}` creates or refreshes that symlink before it writes `notify`, shell aliases or Kiro hook JSON, then verifies the linked scripts exist. If the filesystem cannot create the symlink, the script must say so and explicitly fall back to the current root; it must never write a silent broken path. The bundled `hooks/hooks.json` follows the same rule: use `${CLAUDE_PLUGIN_ROOT}` only where the host provides it, and fall back to `$JSC_HOME/current/jsc-hooks` for any other CLI reading the same manifest, so an unset Claude-only variable never expands into `/hooks/...`. +The wiring commands stored in user config use `{JSC_ROOT}/jsc-hooks`, not the versioned plugin cache path and not the development checkout. `wire-cli.sh` expands that root itself, so what lands in each CLI's config is already a literal absolute path. `{HOOKS_ROOT}/tools/wire-cli.sh {cli}` creates or refreshes that symlink before it writes `notify`, shell aliases or Kiro hook JSON, then verifies the linked scripts exist. If the filesystem cannot create the symlink, the script must say so and explicitly fall back to the current root; it must never write a silent broken path. The bundled `hooks/hooks.json` follows the same rule with one deliberate exception: use `${CLAUDE_PLUGIN_ROOT}` only where the host provides it, and fall back to the manifest's own text, `${JSC_HOME:-$HOME/.jsc}/current/jsc-hooks`, for any other CLI reading the same manifest, so an unset Claude-only variable never expands into `/hooks/...`. That one stays in variable form on purpose: it is file content shipped with the repo, expanded by the hook's own shell on whatever machine reads it, and it never passes through the permission layer. It is not a path anyone types at a prompt, so the path rule above does not reach it. Four of the five CLIs have a pre-tool hook that can block. The version guard and the restart gate reach codex, copilot and antigravity too — each at its own wiring point, with its own matcher and its own blocking shape. Never say a CLI "has no pre-tool hook"; that claim is wrong and it is what left three CLIs unguarded. @@ -66,16 +87,16 @@ The detailed flow **MUST run as a sub agent**; the main agent only reports the s ## Steps -1. Take the CLI list from the caller when it hands one over — `jsc-cli:deploy` passes the list it already detected, and probing the same five executables a second time buys nothing. Run `jsc-cli/tools/detect-clis.sh` yourself only when no list came in; that fallback is what keeps this skill usable when it is called on its own. The script always exits 0 and prints one `namepathversion` line per installed CLI. Done when you hold that list and have said which of the two ways produced it; when it is empty, report that no CLI was detected and stop. -2. Run the five-stage pipeline **purge → wire → status → smoke → scan** once per detected CLI. Run the first CLI's pipeline on its own, because `tools/wire-cli.sh {cli}` is what refreshes the shared `$JSC_HOME/current/jsc-hooks` link and two CLIs must not rewrite it at the same time; once that first pipeline has finished, run every remaining CLI's pipeline in parallel, one sub agent per CLI — the five stages of one CLI stay in this order, but different CLIs touch different config files and share nothing else. Every stage prints its verdict on its first line, so read that line and never infer the outcome from the prose below it. - 1. `tools/wire-cli.sh purge {cli}` — backs up every file it touches, removes all hooks, re-reads each file to confirm the removal, and restores the backup by itself when a check fails. Marker matching trims leading and trailing whitespace, so an indented or padded marker block is still removed as the same jsc-owned block. Exit 0 is `purged`, exit 3 is `skipped` (that CLI's executable is not on this machine, so skip its remaining stages too), exit 4 is `failed` and goes to step 3. Exit 2 is a bad CLI name, not a purge outcome — fix the name and rerun the stage. - 2. `tools/wire-cli.sh {cli}` — owns both the wiring and its verification: it refreshes the link, writes the config, alias or hook file inside a `` (or `# jsc-hooks`) marker block, re-reads every file it wrote, confirms the block is present and correctly placed, and confirms the stored runtime paths resolve to existing scripts before it prints a success status. Exit 0 is `wired` and is the expected result on claude, codex, copilot and antigravity; exit 1 is `degraded` and is expected on kiro alone; exit 3 is `skipped`, exit 4 is `failed` and goes to step 3. Exit 2 is a bad CLI name — fix the name and rerun. The matcher is verified on its own, not just the presence of a key: a key that is there with the wrong matcher reports as wired and fires never. - 3. `tools/wire-cli.sh status {cli}` — the read-only inventory of what the previous stage wrote. It writes nothing and runs no hook, so it is safe to run right after wiring. Exit 0 is `wired` (claude, codex, copilot, antigravity), exit 1 is `degraded` (kiro), exit 3 is `skipped`, exit 5 is `unwired`, which names every missing item and means the wiring stage has to run again before you continue. Exit 2 is a bad CLI name. For codex this stage is the only one that reads the installed `jsc-hooks` manifest in the Codex plugin cache and reports a stale `UserPromptSubmit` command there, the one that expands `${CLAUDE_PLUGIN_ROOT}` into `/hooks/...`; carry that item into the report. - 4. `tools/wire-cli.sh smoke {cli}` — runs every wired mode of all nine hooks once, plus each decision path of the work-package check, of the restart gate and of the write and commit guard. It catches what the wiring check cannot see: a hook that is wired correctly and still fails when it executes. It also runs each CLI's real payload through `skill-name.sh`, each blocking shape through `deny.sh`, and those same payloads straight through `restart-gate.sh` end to end, so a break anywhere along parse, decide and emit is caught — the two ends look healthy on their own while the middle silently passes everything through, which is exactly how three CLIs went unguarded. It prints its own result-line count as `lines{count}` and asserts that count against what it expected to run, so read the number from that line and never restate a number of your own. Exit 0 is `ok`, exit 4 is `failed` — either a hook errored or the line count did not match, and both go to step 3. Exit 2 is a bad CLI name. - 5. `tools/scan-hook-errors.sh --cli {cli}` — only claude keeps hook results in its native records and can answer `clean` or `errors`; codex, copilot, antigravity and kiro answer `unavailable`, and their runtime evidence comes from the smoke stage alone. Exit 0 covers both `clean` and `unavailable`, exit 1 is `errors` and every entry with `jsc=true` goes to step 3, exit 2 is a bad CLI name. +1. Take the CLI list from the caller when it hands one over — `jsc-cli:deploy` passes the list it already detected, and probing the same five executables a second time buys nothing. Run `{JSC_ROOT}/jsc-cli/tools/detect-clis.sh` yourself only when no list came in; that fallback is what keeps this skill usable when it is called on its own. The script always exits 0 and prints one `namepathversion` line per installed CLI. Done when you hold that list and have said which of the two ways produced it; when it is empty, report that no CLI was detected and stop. +2. Run the five-stage pipeline **purge → wire → status → smoke → scan** once per detected CLI. Run the first CLI's pipeline on its own, because `{HOOKS_ROOT}/tools/wire-cli.sh {cli}` is what refreshes the shared `{JSC_ROOT}/jsc-hooks` link and two CLIs must not rewrite it at the same time; once that first pipeline has finished, run every remaining CLI's pipeline in parallel, one sub agent per CLI — the five stages of one CLI stay in this order, but different CLIs touch different config files and share nothing else. Every stage prints its verdict on its first line, so read that line and never infer the outcome from the prose below it. + 1. `{HOOKS_ROOT}/tools/wire-cli.sh purge {cli}` — backs up every file it touches, removes all hooks, re-reads each file to confirm the removal, and restores the backup by itself when a check fails. Marker matching trims leading and trailing whitespace, so an indented or padded marker block is still removed as the same jsc-owned block. Exit 0 is `purged`, exit 3 is `skipped` (that CLI's executable is not on this machine, so skip its remaining stages too), exit 4 is `failed` and goes to step 3. Exit 2 is a bad CLI name, not a purge outcome — fix the name and rerun the stage. + 2. `{HOOKS_ROOT}/tools/wire-cli.sh {cli}` — owns both the wiring and its verification: it refreshes the link, writes the config, alias or hook file inside a `` (or `# jsc-hooks`) marker block, re-reads every file it wrote, confirms the block is present and correctly placed, and confirms the stored runtime paths resolve to existing scripts before it prints a success status. Exit 0 is `wired` and is the expected result on claude, codex, copilot and antigravity; exit 1 is `degraded` and is expected on kiro alone; exit 3 is `skipped`, exit 4 is `failed` and goes to step 3. Exit 2 is a bad CLI name — fix the name and rerun. The matcher is verified on its own, not just the presence of a key: a key that is there with the wrong matcher reports as wired and fires never. + 3. `{HOOKS_ROOT}/tools/wire-cli.sh status {cli}` — the read-only inventory of what the previous stage wrote. It writes nothing and runs no hook, so it is safe to run right after wiring. Exit 0 is `wired` (claude, codex, copilot, antigravity), exit 1 is `degraded` (kiro), exit 3 is `skipped`, exit 5 is `unwired`, which names every missing item and means the wiring stage has to run again before you continue. Exit 2 is a bad CLI name. For codex this stage is the only one that reads the installed `jsc-hooks` manifest in the Codex plugin cache and reports a stale `UserPromptSubmit` command there, the one that expands `${CLAUDE_PLUGIN_ROOT}` into `/hooks/...`; carry that item into the report. + 4. `{HOOKS_ROOT}/tools/wire-cli.sh smoke {cli}` — runs every wired mode of all nine hooks once, plus each decision path of the work-package check, of the restart gate and of the write and commit guard. It catches what the wiring check cannot see: a hook that is wired correctly and still fails when it executes. It also runs each CLI's real payload through `skill-name.sh`, each blocking shape through `deny.sh`, and those same payloads straight through `restart-gate.sh` end to end, so a break anywhere along parse, decide and emit is caught — the two ends look healthy on their own while the middle silently passes everything through, which is exactly how three CLIs went unguarded. It prints its own result-line count as `lines{count}` and asserts that count against what it expected to run, so read the number from that line and never restate a number of your own. Exit 0 is `ok`, exit 4 is `failed` — either a hook errored or the line count did not match, and both go to step 3. Exit 2 is a bad CLI name. + 5. `{JSC_ROOT}/jsc-hooks/tools/scan-hook-errors.sh --cli {cli}` — only claude keeps hook results in its native records and can answer `clean` or `errors`; codex, copilot, antigravity and kiro answer `unavailable`, and their runtime evidence comes from the smoke stage alone. Exit 0 covers both `clean` and `unavailable`, exit 1 is `errors` and every entry with `jsc=true` goes to step 3, exit 2 is a bad CLI name. Done when every detected CLI has exactly one verdict line per stage, no stage exited 2, the smoke stage's `lines` count matches its own assertion, the four non-claude CLIs are reported as `unavailable` rather than clean on the scan stage, and antigravity and kiro carry the note that their hook firing is unverified. -3. For each error — a failed purge, a failed wiring, an `unwired` status, a failed smoke, or a scanned error with `jsc=true` — run `tools/report-error.sh --hook {script name} --exit {code} --summary "{reason}" --cli {cli}` with the script's `[jsc]` output on stdin, then hand the failure to `jsc-hooks:repair`, which **MUST run as a sub agent** and must finish by opening a PR against `develop`. Aborting the remaining installs here is allowed as long as the repair starts. The error page and the error directory page live in two different wiki repos, resolved separately: the page through `wiki-repo ERROR`, the directory through `wiki-repo CONTENTS`. Exit 0 with an `ERROR_{HASH}` page name and URL on stdout means the page was written; the same exit 0 with a `[jsc]` line on stderr still means the page landed, and that line says what is missing — the directory repo would not resolve, so nothing indexes the page; the page URL could not be read back, so the page name comes out on its own; or the URL failed the reachability check, so the directory row carries the page name as plain text with no link — carry that note into step 4. Every link on that row is written as `[{text}]({url})` with the URL from `gitea.sh wiki-url`, and the script checks it with `jsc-gitea/tools/link-check.sh` before writing: exit 0 writes the link, anything else keeps the row and drops the link, and none of it changes the exit code — this is the failure-reporting path, so a failed report must never become a second failure. Exit 0 with no output at all means the run ended on one of the quiet-degradation reasons listed in the script's own header — no `gitea.sh` on the path, the error page's wiki repo unresolved, the hash not computed, or a temp file not created — so no page was written at all and that reason goes into step 4 instead; exit 2 means the call itself was malformed — `--hook` or `--summary` is missing — so fix the arguments and rerun the same call; exit 4 means the wiki record did not land, so report the failure text and still start the repair — a page that could not be written is no reason to leave a broken hook wired. Exit 4 covers two cases, and the report has to say which: a failed write, or the script refusing to write the error directory page because it could not read the old one back. That directory is appended to, never overwritten: every row on it is somebody else's error report, so the script reads the page, adds this run's row, and writes the whole page. Only a genuine 404 (`wiki-get` exit 4) means the page is not there yet and lets it build one from the template. An invalid key (exit 7) or any other API failure (exit 8) leaves the old rows unknown, so it skips the directory write and names the code instead — writing a fresh template over a directory it never read would erase every earlier report, with no merge and no backup behind it. A scanned error with `jsc=false` belongs to a third-party hook: report it and leave it alone. Skip this step when every CLI passed all five stages. Done when every error carries one `ERROR_{HASH}` result — a page name with its URL, a page name plus the reason the URL is missing, or the recorded reason no page was written — and one repair PR URL against `develop`. +3. For each error — a failed purge, a failed wiring, an `unwired` status, a failed smoke, or a scanned error with `jsc=true` — run `{JSC_ROOT}/jsc-hooks/tools/report-error.sh --hook {script name} --exit {code} --summary "{reason}" --cli {cli}` with the script's `[jsc]` output on stdin, then hand the failure to `jsc-hooks:repair`, which **MUST run as a sub agent** and must finish by opening a PR against `develop`. Aborting the remaining installs here is allowed as long as the repair starts. The error page and the error directory page live in two different wiki repos, resolved separately: the page through `wiki-repo ERROR`, the directory through `wiki-repo CONTENTS`. Exit 0 with an `ERROR_{HASH}` page name and URL on stdout means the page was written; the same exit 0 with a `[jsc]` line on stderr still means the page landed, and that line says what is missing — the directory repo would not resolve, so nothing indexes the page; the page URL could not be read back, so the page name comes out on its own; or the URL failed the reachability check, so the directory row carries the page name as plain text with no link — carry that note into step 4. Every link on that row is written as `[{text}]({url})` with the URL from `gitea.sh wiki-url`, and the script checks it with `jsc-gitea/tools/link-check.sh` before writing: exit 0 writes the link, anything else keeps the row and drops the link, and none of it changes the exit code — this is the failure-reporting path, so a failed report must never become a second failure. Exit 0 with no output at all means the run ended on one of the quiet-degradation reasons listed in the script's own header — no `gitea.sh` on the path, the error page's wiki repo unresolved, the hash not computed, or a temp file not created — so no page was written at all and that reason goes into step 4 instead; exit 2 means the call itself was malformed — `--hook` or `--summary` is missing — so fix the arguments and rerun the same call; exit 4 means the wiki record did not land, so report the failure text and still start the repair — a page that could not be written is no reason to leave a broken hook wired. Exit 4 covers two cases, and the report has to say which: a failed write, or the script refusing to write the error directory page because it could not read the old one back. That directory is appended to, never overwritten: every row on it is somebody else's error report, so the script reads the page, adds this run's row, and writes the whole page. Only a genuine 404 (`wiki-get` exit 4) means the page is not there yet and lets it build one from the template. An invalid key (exit 7) or any other API failure (exit 8) leaves the old rows unknown, so it skips the directory write and names the code instead — writing a fresh template over a directory it never read would erase every earlier report, with no merge and no backup behind it. A scanned error with `jsc=false` belongs to a third-party hook: report it and leave it alone. Skip this step when every CLI passed all five stages. Done when every error carries one `ERROR_{HASH}` result — a page name with its URL, a page name plus the reason the URL is missing, or the recorded reason no page was written — and one repair PR URL against `develop`. 4. Report five results per CLI — purge, wiring, status, smoke, scan — each with the reason its script printed, plus the smoke `lines` count, any `ERROR_{HASH}` page name and every repair PR URL. Done when every detected CLI appears with one verdict per stage and every repair has a PR against `develop`. 5. Record how the whole install ended. Run `tools/report-status.sh skill-end jsc-hooks:hooks-install {status} {exit} "{detail}"` — the script is in this same repo, so it takes the plain `tools/` path that every other stage above uses. **The main agent makes this one call, after every per-CLI report is in.** The per-CLI pipelines run as parallel sub agents and one skill run is one event, so a call inside those sub agents would write one line per CLI and turn the install's outcome into five contradictory ones. The gate that records a skill's start fires when the skill is loaded and can never see how it ended; without this line a finished install and an install abandoned halfway look identical afterwards, which is the whole reason the closing step exists. - `{status}` is one of five. `ok`: every detected CLI passed all five stages and every one of them reported `wired` — in practice that means kiro was not on the machine. `degraded`: the pipeline ran to the end and part of it did not reach `wired`. That covers kiro, which is `degraded` by design because the CLI cannot block a skill call, and it covers a CLI whose stage failed and was handed to `jsc-hooks:repair` with a PR against `develop` — the failure has an owner and a fix in flight, so the install is incomplete, not broken. A `skipped` CLI belongs here too. `failed`: a stage failed and the failure was left with nobody holding it — `jsc-hooks:repair` could not be started, or it came back with no PR — so a broken hook stays wired and nothing is going to fix it. `blocked`: `wire-cli.sh` refused with exit 6 under `JSC_READONLY=1`, so no CLI was purged or wired at all. `aborted`: step 1 detected no CLI, so there was nothing to wire and the run stopped on a precondition rather than on an error. diff --git a/tools/wire-cli.sh b/tools/wire-cli.sh index f9c818d..f649fbd 100755 --- a/tools/wire-cli.sh +++ b/tools/wire-cli.sh @@ -4,7 +4,9 @@ # wire-cli.sh {claude|codex|copilot|antigravity|kiro} 接線 # wire-cli.sh purge {claude|codex|copilot|antigravity|kiro} 備份後移除該 CLI 的所有 hook # wire-cli.sh smoke {claude|codex|copilot|antigravity|kiro} 跑一輪九支 hook,驗執行期 -# wire-cli.sh status {claude|codex|copilot|antigravity|kiro} 唯讀盤點接線現況,不寫檔也不執行 hook +# wire-cli.sh status {claude|codex|copilot|antigravity|kiro} [--verdict] +# 唯讀盤點接線現況,不寫檔也不執行 hook。 +# --verdict 只換結束碼語意,輸出一字不變 # # JSC_READONLY=1 時只准 status 與 smoke,purge 與接線一律拒絕並回 exit 6。體檢類技能全程帶著 # 這個變數跑,「子命令打錯一個字就重新接線或刪檔」的風險就由程式擋掉,不靠呼叫端自我約束。 @@ -128,6 +130,9 @@ # 結束碼(purge): 0=purged 2=用法錯誤 3=skipped 4=failed # 結束碼(smoke): 0=ok 2=用法錯誤 4=failed(含結果行數與預期不符) # 結束碼(status): 0=wired 1=degraded 2=用法錯誤 3=skipped 5=unwired(該接的段落缺了至少一項) +# 結束碼(status --verdict): 0=該接的都接了(含 degraded——先天限制不算缺漏) +# 2=用法錯誤 3=skipped 5=unwired +# 給拿結束碼判成敗的呼叫端用,例如助理的內建檢查項。理由見下方 --verdict 那一段。 # 結束碼(唯讀模式): 6=readonly(JSC_READONLY=1 之下拒絕 purge 與接線),status 與 smoke 不受影響 # status 之外的動作都會寫檔,體檢類技能(/jsc-cli:doctor)只能呼叫 status。判讀邏輯跟接線 # 共用同一組檔案位置與標記字串,分兩份實作就會各自漂移,體檢說沒接、實際上接著。 @@ -136,8 +141,14 @@ # 有這個逃生門才測得動 purge 的 JSON 刪鍵:預設路徑是使用者自己的設定檔,拿真檔案試刪 # 等於拿使用者的環境當測試場。指向一份複製品就能完整跑過 purge claude 而不動到本人設定。 set -u -HERE=$(cd "$(dirname "$0")" && pwd) -ROOT=$(cd "$HERE/.." && pwd) +# 這兩行一定要實體解析(`cd -P` 加 `pwd -P`),不能拿邏輯路徑。 +# ROOT 會被 ensure_stable_root() 當成 `ln -sfn "$ROOT" "$_link"` 的目標,而這支腳本本身 +# 常常就是經由那條連結被叫起來的。邏輯解析會把連結原樣留在路徑裡,於是 ROOT 等於連結 +# 自己,連結被改成指向自己,之後每一支 hook 的接線路徑都解不開,全機器 hook 一起失效。 +# 這件事實際發生過。實體解析永遠退到連結指向的那個實際目錄,這個失敗模式就不可能成立。 +# 事後才用 `[ -f ]` 檢查連結通不通不夠:那要等連結已經被寫壞才攔得到。 +HERE=$(CDPATH= cd -P -- "$(dirname -- "$0")" && pwd -P) +ROOT=$(CDPATH= cd -P -- "$HERE/.." && pwd -P) HOOKS="$ROOT/hooks" JSC_HOME="${JSC_HOME:-$HOME/.jsc}" WIRE_ROOT="$ROOT" @@ -163,6 +174,30 @@ case "$cli" in claude|codex|copilot|antigravity|kiro) ;; *) usage ;; esac +shift 2>/dev/null || true + +# --verdict:輸出一字不變,只有結束碼換一套語意——該接的都接了就回 0,先天限制不算。 +# +# 為什麼要有這個旗標。status 的結束碼帶的是狀態:0 是接好、1 是接好但這支 CLI 做不到、 +# 5 是有東西沒接。那是給人看的三分法,也是對的。問題出在被當成檢查用:助理的內建檢查項 +# 照結束碼判成敗,非零就是那一筆失敗、失敗次數加一。 +# 於是有先天限制的那一支 CLI 每一輪都讓那一筆失敗一次,一天 96 次,而沒有人修得動—— +# 那支 CLI 擋不下技能叫用是它的架構,不是接線缺漏,16 個接線項目全部就位。 +# 那個計數存在的理由是指出「有一筆壞掉的項目每輪重試而沒人知道」,被這樣填滿就等於用 +# 一個修不動的數字把真的壞掉蓋掉。 +# 修在這裡而不是修在讀的那一邊:狀態與成敗是兩種語意,混在同一個通道上才是根因。這個 +# 旗標把成敗那一種單獨拉出來,`status` 保持原樣給人看。 +VERDICT_ONLY=0 +while [ "$#" -gt 0 ]; do + case "$1" in + --verdict) VERDICT_ONLY=1; shift ;; + *) usage ;; + esac +done +case "$action:$VERDICT_ONLY" in + status:1|*:0) ;; + *) printf '[jsc] --verdict 只有 status 用得到,%s 不收這個旗標。\n' "$action" >&2; exit 2 ;; +esac # 唯讀契約在程式層把關,不靠呼叫端記得只打 status。子命令解析完就判:預設動作是接線, # 所以少打一個子命令就會直接改環境,這個判定要擋的正是那一次手滑。 @@ -2513,7 +2548,10 @@ if [ "$action" = status ]; then fi if [ -n "$st_degrade" ]; then printf 'status=degraded reason=%s\n' "$st_degrade" - cat "$st_items"; rm -f "$st_items"; exit 1 + cat "$st_items"; rm -f "$st_items" + # --verdict 之下先天限制不算失敗:輸出照印,讓人看得到,但結束碼說「該接的都接了」。 + [ "$VERDICT_ONLY" -eq 1 ] && exit 0 + exit 1 fi printf 'status=wired reason=%s\n' "$st_wired" cat "$st_items"; rm -f "$st_items"; exit 0