diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index cc19137..9603717 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "jsc-hooks", - "version": "0.2.5", + "version": "0.2.6", "description": "跨 CLI hooks:STE100 語言強制、工時計時、技能用量記錄、SDLC 模型鎖、版本前置檢查、註解範圍守門、繁中編碼守門、部署後強制重啟", "skills": "./skills", "author": { diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index d3b9b46..9a6d4dc 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "jsc-hooks", - "version": "0.2.5", + "version": "0.2.6", "description": "跨 CLI hooks:STE100 語言強制、工時計時、技能用量記錄、SDLC 模型鎖、版本前置檢查、註解範圍守門、繁中編碼守門、部署後強制重啟", "skills": "./skills" } diff --git a/README.md b/README.md index 49b38a8..6bb4ab5 100644 --- a/README.md +++ b/README.md @@ -23,9 +23,9 @@ 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 | 記錄工作階段起訖。子指令:`start` 記起始時間(已有紀錄就不動,給 claude 這種每階段有自己 session id 的 CLI)、`restart` 一律覆寫起始時間(給接不到 session id 的 kiro,不覆寫會把上一階段算進來)、`mark` 更新最後活動時間、`report` 供 `jsc-log:worklog` 取花費時間。`start` 與 `restart` 判定為新工作階段時,另外呼叫 `restart-gate.sh clear` 放下部署後的重啟閘門——新工作階段代表 CLI 行程是新起的,新版一定已經載入 | +| `hooks/session-timer.sh` | SessionStart / Stop / SessionEnd | 記錄工作階段起訖。子指令:`start` 記起始時間(已有紀錄就不動,給 claude 這種每階段有自己 session id 的 CLI)、`restart` 一律覆寫起始時間(給接不到 session id 的 kiro,不覆寫會把上一階段算進來)、`mark` 更新最後活動時間、`report` 供 `jsc-log:worklog` 取花費時間。`start` 與 `restart` 判定為新工作階段時,另外呼叫 `restart-gate.sh clear` 放下部署後的重啟閘門——新工作階段代表 CLI 行程是新起的,新版一定已經載入。清除的範圍只有跑到這支腳本的那一支 CLI 自己那一份狀態檔,別支沒重啟就繼續被擋 | | `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/restart-gate.sh` | PreToolUse(Skill) | 部署後強制重啟閘門:`$JSC_HOME/restart-required` 存在時以 exit 2 擋下 jsc 技能呼叫,並印出要重新啟動哪一支 CLI。狀態檔由 `jsc-cli:deploy` 在 install 或 update 收尾時經 `restart-gate.sh require {install|update} [{domain}...]` 寫入,在下一個工作階段開始時由 `session-timer.sh` 呼叫 `restart-gate.sh clear` 清除。判定看檔案在不在:狀態檔讀不到、技能名取不到都放行(理由與 `version-guard.sh` 一致,只擋確定違規)。豁免 `jsc-cli:deploy`、`jsc-hooks:hooks-install`、`jsc-gitea:wiki`、`jsc-log:worklog`、`jsc-log:learn`、`jsc-meta:*`、`jsc-ask:ask`、`jsc-git:pr`、`jsc-git:commit`——部署後還要寫得完技能組異動報告與工作日誌,整批擋下去兩條規則會互相打死。清單認技能名不認呼叫鏈,後三支是為了讓前六支走得完才補進來的:`deploy` 要問模式、報告寫完要開 PR。另有唯讀子指令 `report` 印出狀態檔內容。逃生門 `JSC_RESTART_GATE=off` | +| `hooks/restart-gate.sh` | PreToolUse(Skill) | 部署後強制重啟閘門:`$JSC_HOME/restart-required.d/{CLI 代號}` 一支 CLI 一份,當前 CLI 那份存在時以 exit 2 擋下 jsc 技能呼叫,並印出要重新啟動哪一支 CLI;別支 CLI 那幾份不影響這一支。狀態檔由 `jsc-cli:deploy` 在 install 或 update 收尾時經 `restart-gate.sh require {install|update} [{domain}...]` 寫入當前 CLI 那一份,在下一個工作階段開始時由 `session-timer.sh` 呼叫 `restart-gate.sh clear` 只清除那一份。判定看檔案在不在:狀態檔讀不到、CLI 代號取不到、技能名取不到都放行(理由與 `version-guard.sh` 一致,只擋確定違規)。舊格式的單一檔案 `$JSC_HOME/restart-required` 存在時一律擋,`clear` 會一併刪掉它(過渡相容,詳見下面「部署後重啟狀態檔」)。豁免 `jsc-cli:deploy`、`jsc-hooks:hooks-install`、`jsc-gitea:wiki`、`jsc-log:worklog`、`jsc-log:learn`、`jsc-meta:*`、`jsc-ask:ask`、`jsc-git:pr`、`jsc-git:commit`——部署後還要寫得完技能組異動報告與工作日誌,整批擋下去兩條規則會互相打死。清單認技能名不認呼叫鏈,後三支是為了讓前六支走得完才補進來的:`deploy` 要問模式、報告寫完要開 PR。另有唯讀子指令 `report`,一支 CLI 一行印出每一份狀態檔的內容,看得出還有哪幾支沒重啟。逃生門 `JSC_RESTART_GATE=off` | | `hooks/skill-usage.sh` | PostToolUse(Skill) | 記錄技能使用與呼叫鏈到 `$JSC_HOME/usage/*.jsonl`,供 `jsc-log:stats` 統計 | | `hooks/comment-scope.sh` | UserPromptSubmit、PostToolUse(Write、Edit、MultiEdit)、codex `notify`、kiro `userPromptSubmit`、`tools/jsc-wrap.sh` 收尾 | 程式碼註解不得夾帶文件相關資訊,共三種模式。`prompt`:在每次提示注入規則摘要(禁止項與白名單各一行),五個 CLI 都接得到。無參數:寫檔後的逐檔掃描,從 stdin JSON 取 `file_path`(或環境變數 `JSC_CHANGED_FILE`),只有 claude 的 PostToolUse 接得上。`sweep [dir]`:掃整個 git 工作區這次改過的所有檔案,給沒有 post-tool hook 的四個 CLI 用,找不到 git 就安靜 exit 0。掃描時機每個 CLI 不同——claude 逐檔即時(PostToolUse)、codex 每輪結束(`notify`)、kiro 每輪提示送出時(`userPromptSubmit`,掃的是上一輪寫的檔)、copilot 與 antigravity 只有工作階段結束時由 `tools/jsc-wrap.sh` 收尾掃一次。兩種掃描模式都只看 `git diff HEAD` 的新增行、不翻舊帳,命中就把警告與最多三行證據送到 stderr 並以 exit 2 交回模型就地修正(不擋寫入,檔案已經寫好了)。markdown、純文字、資料檔與二進位檔一律跳過。只實作可用樣式判定的項目,專案代號、客戶名稱這類判不出來的交給 `/jsc-review:code-review`。規則正文的唯一來源在 `jsc-review` 的 `references/comment-scope.md`,本存取庫不留副本。逃生門 `JSC_COMMENT_SCOPE=off` | | `hooks/lang-guard.sh` | UserPromptSubmit、PostToolUse(Write、Edit、MultiEdit)、codex `notify`、kiro `userPromptSubmit`、`tools/jsc-wrap.sh` 收尾 | 所有非程式碼輸出一律繁體中文、UTF-8、無亂碼、無簡體字,共三種模式。`prompt`:在每次提示注入規則摘要(適用範圍與自我檢查各一行),五個 CLI 都接得到。無參數:寫檔後的逐檔掃描,從 stdin JSON 取 `file_path`(或環境變數 `JSC_CHANGED_FILE`),只有 claude 的 PostToolUse 接得上。`sweep [dir]`:掃整個 git 工作區這次改過的所有檔案,給沒有 post-tool hook 的四個 CLI 用,找不到 git 就安靜 exit 0。接線位置與掃描時機跟 `comment-scope.sh` 完全一樣,見下面那張表。偵測三項:簡體字(字表在 `hooks/simplified.txt`,讀不到就安靜跳過這一項)、亂碼(U+FFFD 替代字元與雙重編碼殘骸)、非 UTF-8 編碼(用 `iconv` 判定,沒有 `iconv` 就跳過)。三項都掃整個檔案、不只掃註解行,`.md` 與純文字檔照掃——那些正是「非程式碼輸出」的主場,這兩點跟 `comment-scope.sh` 刻意不同。掃描深度仍只看 `git diff HEAD` 的新增行、不翻舊帳,命中就把警告與最多三行證據送到 stderr 並以 exit 2 交回模型就地修正(不擋寫入)。二進位檔(只認 NUL 位元組)與 `*.lock`、`*.min.js`、`*.map` 這類產生檔跳過;`hooks/simplified.txt`、`hooks/ste100-guard.sh`、`hooks/lang-guard.sh` 也跳過,那三份檔案裡的簡體字與亂碼樣本是被討論的對象,不是被使用。規則正文的唯一來源在 `jsc-meta` 的 `references/ste100.md`。逃生門 `JSC_LANG_GUARD=off` | @@ -33,7 +33,7 @@ Marketplace 統一為 `jsc`(https://gitea.jsc.idv.tw/plugins/meta.git),安 Claude 由 `hooks/hooks.json` 自動接線八支 hook;其他 CLI 用 `hooks-install` 技能接線、改裝包裝啟動器,或降級為規則檔。 -> 覆蓋範圍要據實看待:只有 claude 同時有 PreToolUse、PostToolUse 與 UserPromptSubmit,八支 hook 全接得上,回報 `wired`。codex、copilot、antigravity、kiro 都沒有 pre-tool hook,接不上 `version-guard.sh` 的版本前置檢查,也接不上 `restart-gate.sh` 的部署後重啟閘門,SDLC 模型鎖也只剩技能步驟檢查,這四個 CLI 一律回報 `degraded`,靠 `/jsc-cli:deploy` 定期更新。重啟閘門在這四個 CLI 上一次技能呼叫都擋不下來:狀態檔照樣寫、下一個工作階段開始照樣清,只是中間沒有判定點,重啟得靠 `/jsc-cli:deploy` 收尾的提示自己動手。codex 另外沒有工作階段開始事件,計時改由 `tools/jsc-wrap.sh` 的 `codex` 別名在啟動當下開始;沒走別名啟動時,時間從第一輪回應算起。 +> 覆蓋範圍要據實看待:只有 claude 同時有 PreToolUse、PostToolUse 與 UserPromptSubmit,八支 hook 全接得上,回報 `wired`。codex、copilot、antigravity、kiro 都沒有 pre-tool hook,接不上 `version-guard.sh` 的版本前置檢查,也接不上 `restart-gate.sh` 的部署後重啟閘門,SDLC 模型鎖也只剩技能步驟檢查,這四個 CLI 一律回報 `degraded`,靠 `/jsc-cli:deploy` 定期更新。重啟閘門在這四個 CLI 上一次技能呼叫都擋不下來:那一支自己那份狀態檔照樣寫、下一個工作階段開始照樣清,只是中間沒有判定點,重啟得靠 `/jsc-cli:deploy` 收尾的提示自己動手。codex 另外沒有工作階段開始事件,計時改由 `tools/jsc-wrap.sh` 的 `codex` 別名在啟動當下開始;沒走別名啟動時,時間從第一輪回應算起。 > `comment-scope.sh` 與 `lang-guard.sh` 五個 CLI 都掃得到,接的是同一批位置,但時機不同,不能當成五支一樣: @@ -61,18 +61,30 @@ Claude 由 `hooks/hooks.json` 自動接線八支 hook;其他 CLI 用 `hooks-in ### 部署後重啟狀態檔 -`$JSC_HOME/restart-required`(`JSC_HOME` 未設定時為 `~/.jsc`)一支檔案,格式與工作包狀態檔同一套:純文字 `key=value`,一行一欄位,順序不拘,不認得的鍵一律忽略。`jsc-hooks` 與 `jsc-cli` 兩邊各自實作也對得上。 +`$JSC_HOME/restart-required.d/{CLI 代號}`(`JSC_HOME` 未設定時為 `~/.jsc`)**一支 CLI 一份**,檔名就是 CLI 代號(`claude`、`codex`、`copilot`、`antigravity`、`kiro`)。格式與工作包狀態檔同一套:純文字 `key=value`,一行一欄位,順序不拘,不認得的鍵一律忽略。`jsc-hooks` 與 `jsc-cli` 兩邊各自實作也對得上。 | 欄位 | 內容 | 範例 | | --- | --- | --- | | `at` | 部署收尾時間,UTC | `at=2026-08-27T02:00:00Z` | | `mode` | 這次部署的模式,`install` 或 `update` | `mode=update` | | `domains` | 這次更新到的 domain,空白分隔 | `domains=hooks cli meta` | -| `cli` | 執行部署的 CLI 代號 | `cli=claude` | +| `cli` | 執行部署的 CLI 代號,與檔名相同 | `cli=claude` | -寫檔的一律是 `jsc-cli:deploy`,經 `restart-gate.sh require {install|update} [{domain}...]` 落地,寫不進去會 exit 2 並講明「這次部署沒有掛上重啟閘門」——沒寫成就沒有閘門,不能讓部署以為掛上了。清除的一律是 `session-timer.sh`:`start` 判定起始檔不存在(這個 session id 第一次開始)、或 `restart`(接不到 session id 的 CLI,每次工作階段開始都算新的)時,呼叫 `restart-gate.sh clear`。判準留在 `session-timer.sh`、狀態檔留在 `restart-gate.sh`,兩邊都不抄對方那一半。 +一支 CLI 一份是為了修兩個實測抓到的洞:一台機器上五支 CLI 各自是獨立行程,各自載入自己記憶體裡的那一版。早先的單一檔案設計裡,並行部署會互相覆寫(後寫的把 `domains` 與 `cli` 蓋掉,欄位不再代表先寫的那一支),而且任一支 CLI 重啟就把五支的閘門一起解除,其餘四支沒重啟卻不再被擋,閘門在多 CLI 環境等於半失效。拆成一支一份之後,寫入、判定、清除三件事都只碰自己那一份。 -欄位只用在擋人訊息上。判定看的是「檔案在不在」——檔案存在就是還沒重啟過的證據,欄位缺了只讓訊息少幾個字。狀態檔讀不到、技能名取不到一律放行,理由與 `version-guard.sh` 相同。 +寫檔的一律是 `jsc-cli:deploy`,經 `restart-gate.sh require {install|update} [{domain}...]` 落地,寫的是當前 CLI 那一份;取不到 CLI 代號或寫不進去都會 exit 2 並講明「這次部署沒有掛上重啟閘門」——沒寫成就沒有閘門,不能讓部署以為掛上了。清除的一律是 `session-timer.sh`:`start` 判定起始檔不存在(這個 session id 第一次開始)、或 `restart`(接不到 session id 的 CLI,每次工作階段開始都算新的)時,呼叫 `restart-gate.sh clear`,只刪呼叫端那一支自己那一份。判準留在 `session-timer.sh`、狀態檔留在 `restart-gate.sh`,兩邊都不抄對方那一半。 + +欄位只用在擋人訊息上。判定看的是「當前 CLI 那份檔案在不在」——檔案存在就是這一支還沒重啟過的證據,欄位缺了只讓訊息少幾個字。別支 CLI 那幾份一律不看。狀態檔讀不到、CLI 代號取不到、技能名取不到一律放行,理由與 `version-guard.sh` 相同。 + +`restart-gate.sh report` 一份狀態檔印一行,欄位以空白分隔,`domains` 可能含空白所以擺最後: + +``` +{CLI 代號} at={ISO 時間} mode={install|update} domains={domain 清單} +``` + +有幾行就代表有幾支 CLI 還沒重啟;一份都沒有就不印。欄位缺值時只留鍵名(例如 `domains=`)。第一欄印 `legacy` 的那一行代表下面說的舊格式單一檔案,它不屬於任何一支 CLI。 + +**舊檔相容(過渡用)。** 舊版把狀態寫進 `$JSC_HOME/restart-required` 單一檔案。改用狀態目錄的第一輪部署,機器上可能還留著那份舊檔,所以判定與清除都認它:舊檔存在就一律擋,視為「每一支 CLI 都有未重啟的部署」,擋人訊息會標明這是舊格式紀錄;`clear` 除了刪當前 CLI 那一份,也一併刪掉舊檔。取捨講白:`clear` 只在新工作階段被呼叫,呼叫到就代表確實有一支 CLI 重新啟動過了;舊檔沒有 per-CLI 資訊,留著會讓五支 CLI 一路被擋到有人手動刪,刪掉是唯一收斂的做法,代價是同一輪部署的其他 CLI 少擋一次,只影響改用狀態目錄的那一輪。這一段相容邏輯在所有機器都跑過一次寫狀態目錄的部署與重啟之後就可以整段移除,屆時舊檔不會再被寫出來。 > `version-guard.sh report` 是非 hook 的子指令:印出每個已安裝 jsc plugin 的 > 「{domain} {本機} {遠端} {落後|最新|超前|查詢失敗}」,最後一行 `behind {落後個數}`。 diff --git a/hooks/restart-gate.sh b/hooks/restart-gate.sh index 0172cd0..4fd556e 100755 --- a/hooks/restart-gate.sh +++ b/hooks/restart-gate.sh @@ -5,15 +5,19 @@ # 讀進記憶體。所以部署收尾要求重新啟動,這道閘門負責讓「還沒重啟就繼續用技能」擋在門外。 # # 用法: -# restart-gate.sh hook 模式:狀態檔存在就擋下該次技能呼叫(exit 2) +# restart-gate.sh hook 模式:當前 CLI 那份狀態檔存在就擋下該次技能 +# 呼叫(exit 2)。別支 CLI 那幾份不看。 # restart-gate.sh require {模式} [{domain}...] -# 寫入狀態檔,掛上閘門。由 jsc-cli:deploy 在 install 或 -# update 收尾時呼叫;模式為 install 或 update,之後接 -# 這次更新的 domain 清單。 -# exit 0 = 已掛上;exit 2 = 寫不進去(沒寫成等於沒掛)。 -# restart-gate.sh clear 清除狀態檔,放下閘門。由 session-timer.sh 在判定為 -# 新工作階段時呼叫(見下方「清除時機」)。檔案不存在也算成功。 -# restart-gate.sh report 印出狀態檔內容;沒有狀態檔就不印,一律 exit 0。 +# 寫入當前 CLI 那份狀態檔,掛上這一支的閘門。由 +# jsc-cli:deploy 在 install 或 update 收尾時呼叫;模式為 +# install 或 update,之後接這次更新的 domain 清單。 +# exit 0 = 已掛上;exit 2 = 取不到 CLI 代號或寫不進去 +# (兩種都等於沒掛上)。 +# restart-gate.sh clear 只清除當前 CLI 那份狀態檔,放下這一支的閘門。由 +# session-timer.sh 在判定為新工作階段時呼叫(見下方 +# 「清除時機」)。檔案不存在也算成功。 +# restart-gate.sh report 印出每一份狀態檔的內容,一支 CLI 一行(格式見下方 +# 「report 輸出格式」);一份都沒有就不印,一律 exit 0。 # # require、clear、report 都不讀標準輸入,只有 hook 模式讀。理由與 sdlc-gate.sh 相同: # read_stdin 在標準輸入是管線又沒人關閉時會一直等,工具端呼叫就整支卡死。新增子命令照這個 @@ -21,14 +25,42 @@ # # --- 狀態檔格式 --- # -# $JSC_HOME/restart-required(JSC_HOME 未設定時為 ~/.jsc),純文字 key=value,一行一欄位, -# 順序不拘,不認得的鍵一律忽略。格式壓到最簡,jsc-hooks 與 jsc-cli 兩邊各自實作也對得上。 +# $JSC_HOME/restart-required.d/{CLI 代號}(JSC_HOME 未設定時為 ~/.jsc),一支 CLI 一份, +# 檔名就是 CLI 代號(claude、codex、copilot、antigravity、kiro)。內容為純文字 key=value, +# 一行一欄位,順序不拘,不認得的鍵一律忽略。格式壓到最簡,jsc-hooks 與 jsc-cli 兩邊各自 +# 實作也對得上。 # at={ISO 時間} 部署收尾時間,UTC # mode={install|update} 這次部署的模式 # domains={domain 清單} 這次更新到的 domain,空白分隔 -# cli={CLI 代號} 執行部署的 CLI -# 欄位只用在擋人訊息上。判定看的是「檔案在不在」——檔案存在就是還沒重啟過的證據,欄位缺了 -# 只讓訊息少幾個字,不影響判定。 +# cli={CLI 代號} 執行部署的 CLI,與檔名相同 +# 欄位只用在擋人訊息上。判定看的是「當前 CLI 那份檔案在不在」——檔案存在就是這一支還沒重啟 +# 過的證據,欄位缺了只讓訊息少幾個字,不影響判定。 +# +# 為什麼一支 CLI 一份:一台機器上五支 CLI 各自是獨立行程,各自載入自己記憶體裡的那一版。 +# 早先的單一檔案設計有兩個實測抓到的洞——並行部署互相覆寫(後寫的把 domains 與 cli 蓋掉, +# 欄位不再代表先寫的那一支),以及任一支 CLI 重啟就把五支的閘門一起解除(其餘四支沒重啟卻 +# 不再被擋,閘門等於半失效)。拆成一支一份之後,寫入、判定、清除三件事都只碰自己那一份。 +# +# --- report 輸出格式 --- +# +# 一行一份狀態檔,欄位以空白分隔,domains 可能含空白所以擺最後: +# {CLI 代號} at={ISO 時間} mode={install|update} domains={domain 清單} +# 有幾行就代表有幾支 CLI 還沒重啟。欄位缺值時只留鍵名(例如 domains=)。第一欄印 legacy 的 +# 那一行代表舊格式的單一狀態檔(見下方「舊檔相容」),它不屬於任何一支 CLI。 +# +# --- 舊檔相容(過渡用) --- +# +# 舊版把狀態寫進 $JSC_HOME/restart-required 單一檔案。改用狀態目錄的第一輪部署,機器上可能 +# 還留著那份舊檔:完全不認它,那一輪的閘門會整輪漏掉,檔案本身也會永遠留著變垃圾。所以: +# 判定:舊檔存在就一律擋,視為「每一支 CLI 都有未重啟的部署」。舊檔沒有 per-CLI 資訊, +# 分不出是哪一支寫的,寧可擋多不擋少。擋人訊息會標明這是舊格式紀錄。 +# 清除:clear 除了刪當前 CLI 那一份,也一併刪掉舊檔。取捨講白:clear 只在新工作階段被 +# 呼叫,呼叫到就代表確實有一支 CLI 重新啟動過了;舊檔沒有 per-CLI 資訊,留著會讓五支 +# CLI 一路被擋到有人手動刪,刪掉是唯一收斂的做法。代價是同一輪部署的其他 CLI 少擋 +# 一次,只影響改用狀態目錄的那一輪。 +# 這一段是過渡用的:所有機器都跑過一次寫狀態目錄的部署與重啟之後,舊檔不會再被寫出來,屆時 +# 可以整段移除——LEGACY_STATE、hook 判定裡的舊檔分支、clear 裡的舊檔刪除、report 的 +# legacy 行、以及本節。 # # --- 清除時機 --- # @@ -36,11 +68,13 @@ # 新舊工作階段的判準(sessions/{sid}.start 在不在)只有那支腳本知道,兩邊各寫一份就會漂移。 # 新的工作階段代表 CLI 行程是新起的,新版一定已經載入,所以清除是對的。續接同一階段 # (SessionStart 再觸發、resume、compact)不會走到那一段,閘門就一路留到真的重新啟動。 +# 清除的範圍就是呼叫端那一支 CLI:那一支重啟了,不代表別支也重啟了。 # # --- 判定原則 --- # # 比照 version-guard.sh:只擋確定違規,查不到基礎資訊一律放行(exit 0)。狀態檔讀不到、 -# 技能名取不到、工具名不是 Skill,三種都放行——沒有證據時擋下等於停掉每一次技能呼叫。 +# CLI 代號取不到、技能名取不到、工具名不是 Skill,四種都放行——沒有證據時擋下等於停掉每一次 +# 技能呼叫。 # # 豁免(這些技能永遠放行,改動前想清楚後果): # jsc-cli:deploy 部署入口本身,也是唯一能把技能組換成新版的路徑,擋了會死鎖 @@ -64,12 +98,32 @@ # 逃生門:JSC_RESTART_GATE=off 完全略過這道閘門。 HERE=$(dirname "$0"); . "$HERE/lib.sh" -STATE="$JSC_HOME/restart-required" +STATE_DIR="$JSC_HOME/restart-required.d" +# 舊格式的單一狀態檔。只為過渡而讀,可移除的時機見檔頭「舊檔相容」。 +LEGACY_STATE="$JSC_HOME/restart-required" -# 從狀態檔取一個欄位;檔案讀不到或欄位不存在就不輸出。 -state_field() { # $1=鍵名 - [ -f "$STATE" ] && [ -r "$STATE" ] || return 0 - sed -n "s/^$1=//p" "$STATE" 2>/dev/null | head -n1 +# 當前 CLI 代號;取不到就不輸出,由呼叫端決定怎麼降級。取法與其他 hook 一致(JSC_CLI 優先, +# 其次 lib.sh 的 cli_name)。不像代號的值一併當成取不到:這個值直接拿去當檔名,帶斜線或 +# 點號開頭的值會把檔案寫到狀態目錄外面去。 +cli_code() { + _c=$(cli_name) + case "$_c" in + ""|unknown) return 0 ;; + .*|*[!A-Za-z0-9._-]*) return 0 ;; + esac + printf '%s' "$_c" +} + +# 從指定狀態檔取一個欄位;檔案讀不到或欄位不存在就不輸出。 +state_field() { # $1=狀態檔 $2=鍵名 + [ -f "$1" ] && [ -r "$1" ] || return 0 + sed -n "s/^$2=//p" "$1" 2>/dev/null | head -n1 +} + +# 一份狀態檔印一行,格式見檔頭「report 輸出格式」。$1=第一欄要印的名稱 $2=狀態檔 +state_line() { + printf '%s at=%s mode=%s domains=%s\n' "$1" \ + "$(state_field "$2" at)" "$(state_field "$2" mode)" "$(state_field "$2" domains)" } case "${1:-}" in @@ -77,19 +131,37 @@ case "${1:-}" in _mode="${2:-update}" _domains="" if [ "$#" -gt 2 ]; then shift 2; _domains="$*"; fi - mkdir -p "$JSC_HOME" 2>/dev/null || true + _cli=$(cli_code) + if [ -z "$_cli" ]; then + # 取不到代號就不知道該寫哪一份,寫成別的檔名也沒用:hook 模式同樣取不到代號,那一份 + # 永遠不會被讀到。沒掛上就要講出來,不能讓部署以為掛上了。 + printf '[jsc][重啟閘門][ERR]:取不到可用的 CLI 代號(JSC_CLI 未設定,或值不是代號),這次部署沒有掛上重啟閘門。\n' >&2 + exit 2 + fi + mkdir -p "$STATE_DIR" 2>/dev/null || true printf 'at=%s\nmode=%s\ndomains=%s\ncli=%s\n' \ - "$(now_iso)" "$_mode" "$_domains" "$(cli_name)" > "$STATE" 2>/dev/null || { + "$(now_iso)" "$_mode" "$_domains" "$_cli" > "$STATE_DIR/$_cli" 2>/dev/null || { # 寫不進去要講出來:沒寫成就沒有閘門,部署卻以為掛上了。 - printf '[jsc][重啟閘門][ERR]:寫不進 %s,這次部署沒有掛上重啟閘門。\n' "$STATE" >&2 + printf '[jsc][重啟閘門][ERR]:寫不進 %s,這次部署沒有掛上重啟閘門。\n' "$STATE_DIR/$_cli" >&2 exit 2 } exit 0 ;; clear) - rm -f "$STATE" 2>/dev/null || true + _cli=$(cli_code) + # 只刪自己那一份。別支 CLI 沒有跟著重啟,它們的閘門要留著。 + [ -n "$_cli" ] && rm -f "$STATE_DIR/$_cli" 2>/dev/null + # 舊檔一併刪,取捨與可移除時機見檔頭「舊檔相容」。 + rm -f "$LEGACY_STATE" 2>/dev/null || true exit 0 ;; report) - if [ -f "$STATE" ] && [ -r "$STATE" ]; then cat "$STATE" 2>/dev/null || true; fi + # 目錄裡一份都沒有時,未展開的樣式字串會由 -f 判斷擋掉。 + for _f in "$STATE_DIR"/*; do + [ -f "$_f" ] && [ -r "$_f" ] || continue + state_line "$(basename "$_f")" "$_f" + done + if [ -f "$LEGACY_STATE" ] && [ -r "$LEGACY_STATE" ]; then + state_line legacy "$LEGACY_STATE" + fi exit 0 ;; "") ;; # 落到下面的 hook 模式 *) exit 0 ;; # 不認得的子命令一律安靜放行,不中斷宿主 CLI @@ -119,26 +191,41 @@ case "$skill" in exit 0 ;; esac -# 狀態檔讀不到就放行:沒有「剛部署過」的證據,就沒有擋人的理由。 -[ -f "$STATE" ] && [ -r "$STATE" ] || exit 0 +# CLI 代號取不到就放行:不知道現在跑的是哪一支,就不知道該讀哪一份狀態檔,等同沒有證據。 +cli=$(cli_code) +[ -n "$cli" ] || exit 0 -at=$(state_field at) -mode=$(state_field mode) -domains=$(state_field domains) +# 只看自己那一份;沒有才退回看舊檔。兩份都沒有就放行——沒有「剛部署過」的證據,就沒有擋人的 +# 理由。別支 CLI 那幾份一律不看:那些是別的行程,重啟與否跟這一支無關。 +state="$STATE_DIR/$cli" +legacy=no +if [ -f "$state" ] && [ -r "$state" ]; then + : +elif [ -f "$LEGACY_STATE" ] && [ -r "$LEGACY_STATE" ]; then + state="$LEGACY_STATE"; legacy=yes +else + exit 0 +fi + +at=$(state_field "$state" at) +mode=$(state_field "$state" mode) +domains=$(state_field "$state" domains) # 重啟方式依實際 CLI 給。印別的 CLI 的執行檔名等於沒給指示。 -bin=$(cli_bin "$(cli_name)") -[ "$bin" = unknown ] && bin="目前的 CLI" +bin=$(cli_bin "$cli") # 訊息裡的部署資訊逐段接起來,缺欄位就少一段,不會留下空括號或多餘的逗號。 info="" [ -n "$at" ] && info="$at" [ -n "$mode" ] && info="${info}${info:+,}模式 $mode" [ -n "$domains" ] && info="${info}${info:+,}domain:$domains" +# 舊格式紀錄要標出來:它分不出是哪一支 CLI 部署的,所以每一支都擋,看到訊息的人才不會以為 +# 系統認定就是這一支剛部署過。 +[ "$legacy" = yes ] && info="${info}${info:+,}舊格式紀錄,分不出是哪一支 CLI 部署的" printf '[jsc][重啟閘門][ERR]:技能組已更新%s,%s 還在跑舊版,新版要重新啟動才會載入。本次技能呼叫已擋下。\n' \ "${info:+($info)}" "$bin" >&2 printf '重新啟動:結束 %s 再重新開啟一次,狀態檔 %s 會在新工作階段開始時自動清除。\n' \ - "$bin" "$STATE" >&2 + "$bin" "$state" >&2 printf '仍可使用:/jsc-cli:deploy、/jsc-hooks:hooks-install、/jsc-gitea:wiki、/jsc-log:worklog、/jsc-log:learn、/jsc-meta:*、/jsc-ask:ask、/jsc-git:pr、/jsc-git:commit(部署後的異動報告與工作日誌要寫得完) | 確定要略過閘門:JSC_RESTART_GATE=off\n' >&2 exit 2 diff --git a/hooks/session-timer.sh b/hooks/session-timer.sh index 5c7895f..c89cb37 100755 --- a/hooks/session-timer.sh +++ b/hooks/session-timer.sh @@ -16,11 +16,14 @@ # (restart-gate.sh clear)。新工作階段代表 CLI 行程是新起的,新版技能組一定已經載入。 # 判準只有這裡知道——start 分支的「起始檔不存在」就是這個 session id 第一次開始, # 所以清除掛在這裡,不在 restart-gate.sh 裡自己再判一次。 +# 清除的範圍是「跑到這一支腳本的那個 CLI 自己那一份狀態檔」,由 restart-gate.sh clear 認定, +# 這裡不必也不能過問:這個工作階段開始的只有一支 CLI,別支沒重啟,閘門要留著。 HERE=$(dirname "$0"); . "$HERE/lib.sh" read_stdin sid=$(session_id) -# 放下部署後的重啟閘門。狀態檔的路徑與格式只留在 restart-gate.sh,這裡不碰檔案。 +# 放下這一支 CLI 的部署後重啟閘門。狀態檔的路徑、範圍與格式只留在 restart-gate.sh, +# 這裡不碰檔案,所以改成一支 CLI 一份狀態檔之後這裡不用跟著改。 # 一律 /dev/null || true diff --git a/plugin.json b/plugin.json index 1b2054b..d8fca76 100644 --- a/plugin.json +++ b/plugin.json @@ -1,6 +1,6 @@ { "name": "jsc-hooks", - "version": "0.2.5", + "version": "0.2.6", "description": "跨 CLI hooks:STE100 語言強制、工時計時、技能用量記錄、SDLC 模型鎖、版本前置檢查、註解範圍守門、繁中編碼守門、部署後強制重啟", "skills": "./skills/" } diff --git a/skills/hooks-install/SKILL.md b/skills/hooks-install/SKILL.md index 6d66c7a..9ea8545 100644 --- a/skills/hooks-install/SKILL.md +++ b/skills/hooks-install/SKILL.md @@ -42,7 +42,7 @@ The detailed flow **MUST run as a sub agent**; the main agent only reports the s - 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. `start` and `restart` also clear the restart gate whenever they decide this SessionStart is a new session, so the wiring of those two events is what lowers the gate after a restart — a CLI wired without them keeps the gate up until the user sets `JSC_RESTART_GATE=off`. -- `restart-gate.sh` blocks jsc skill calls while `$JSC_HOME/restart-required` exists, so a freshly deployed skill set is not used by a process still running the old one. `jsc-cli:deploy` writes that file through `restart-gate.sh require {install|update} [{domain}...]` at the end of an install or update. Exempt skills stay callable — `jsc-cli:deploy`, `jsc-hooks:hooks-install`, `jsc-gitea:wiki`, `jsc-log:worklog`, `jsc-log:learn`, `jsc-meta:*`, `jsc-ask:ask`, `jsc-git:pr`, `jsc-git:commit` — because the change report and the worklog still have to be finished after a deploy, and the first six reach that finish line only through the last three: the deploy asks for its mode, the report closes with a PR. The gate matches skill names, not call chains, so a nested call to anything off the list is blocked all the same. `hooks/restart-gate.sh` owns the list; guidelines.md「部署後重啟閘門」carries the same nine with a reason per entry. Escape hatch: `JSC_RESTART_GATE=off`. +- `restart-gate.sh` blocks jsc skill calls while `$JSC_HOME/restart-required.d/{cli}` exists — one file per CLI, named after the CLI code — so a freshly deployed skill set is not used by a process still running the old one. Each CLI reads only its own file: another CLI's file never blocks this one, and a restart clears only the file of the CLI that restarted. `jsc-cli:deploy` writes the current CLI's file through `restart-gate.sh require {install|update} [{domain}...]` at the end of an install or update; `restart-gate.sh report` prints one line per file, so it is visible which CLIs still owe a restart. A leftover old-format single file at `$JSC_HOME/restart-required` blocks every CLI and is deleted on the next `clear` — transitional only, and `hooks/restart-gate.sh` records when it can be dropped. Exempt skills stay callable — `jsc-cli:deploy`, `jsc-hooks:hooks-install`, `jsc-gitea:wiki`, `jsc-log:worklog`, `jsc-log:learn`, `jsc-meta:*`, `jsc-ask:ask`, `jsc-git:pr`, `jsc-git:commit` — because the change report and the worklog still have to be finished after a deploy, and the first six reach that finish line only through the last three: the deploy asks for its mode, the report closes with a PR. The gate matches skill names, not call chains, so a nested call to anything off the list is blocked all the same. `hooks/restart-gate.sh` owns the list; guidelines.md「部署後重啟閘門」carries the same nine with a reason per entry. Escape hatch: `JSC_RESTART_GATE=off`. - `purge` reaches the user-level config only. Hooks that another plugin ships in its own `hooks.json` stay active, and uninstalling that plugin is the only way to clear them — say so when reporting, and treat their errors as third-party. - Backups land in `$JSC_HOME/backup/hooks/{cli}/{yyyyMMdd_HHmmss}/`, one directory per purge run, under the original file names. Hand that path to the user whenever a purge removed something. - `smoke` treats `sdlc-gate.sh check` exit 2 as healthy: that exit is the stage lock blocking a turn on purpose, not a runtime error. `comment-scope.sh` and `lang-guard.sh` exit 2 count as healthy for the same reason — the scan found something and warned about it. Their no-argument mode has no file name during smoke and exits 0 in silence; `sweep` depends on the worktree it runs in, so it answers 2 whenever that worktree happens to carry an offending comment, a simplified character or a mojibake sequence. None of these is a broken hook. diff --git a/tools/wire-cli.sh b/tools/wire-cli.sh index 3439565..8f8138a 100755 --- a/tools/wire-cli.sh +++ b/tools/wire-cli.sh @@ -14,8 +14,9 @@ # 跑起來不出錯(缺 node、路徑錯、權限不足都只在真的執行時才現形)。 # 有分支的判定另外驗結果,不只驗跑得完:sdlc-gate.sh 的工作包歸屬比對會自備一份暫時的 # $JSC_HOME 狀態檔,把「查無歸屬」「自己的工作包」「別的工作包」與逃生門各跑一次,比對結束碼。 -# restart-gate.sh 的部署後重啟閘門同法:狀態檔不存在、狀態檔存在、豁免技能、逃生門、 -# 取不到技能名五條路徑各跑一次,再驗一次清除機制真的清得掉。 +# restart-gate.sh 的部署後重啟閘門同法:當前 CLI 沒有狀態檔、只有別支 CLI 有狀態檔、當前 +# CLI 那份存在、豁免技能、逃生門、取不到技能名各跑一次,再驗狀態檔本身在不在——清除只刪自己 +# 那一份、別支那一份留著、舊格式的單一狀態檔照樣擋得下來且清得掉。 # # 接線行為(依 CLI 而定,皆為冪等:重跑只取代既有的 jsc-hooks 標記段落,不會重複疊加): # claude — 什麼都不用寫,hooks.json 已自動接線八支 hook @@ -709,9 +710,10 @@ if [ "$action" = smoke ]; then fi # 部署後重啟閘門(restart-gate.sh):上面那支只走得到「狀態檔不存在」與「取不到技能名」, - # 擋人那一條完全沒跑到。這裡同樣自備一份暫時的 $JSC_HOME,把五條判定路徑各跑一次並比對 - # 結束碼,最後再驗清除機制真的把狀態檔清掉。用暫時目錄是為了不動到使用者真正的 - # $JSC_HOME/restart-required——冒煙測試不該把別人的閘門拆掉。 + # 擋人那一條完全沒跑到。這裡同樣自備一份暫時的 $JSC_HOME,把每條判定路徑各跑一次並比對 + # 結束碼,另外驗三件狀態檔設計本身的事:別支 CLI 那一份不影響這一支、清除只刪自己那一份、 + # 舊格式的單一狀態檔照樣擋得下來。用暫時目錄是為了不動到使用者真正的 + # $JSC_HOME/restart-required.d/——冒煙測試不該把別人的閘門拆掉。 # 一律 > "$smoke_out" fi } + # 狀態檔在不在也要比:一支 CLI 一份的重點就在「該留的留、該刪的刪」,只看結束碼看不出來。 + smoke_rs_file() { # $1=情境 $2=狀態檔 $3=exist 或 absent + if { [ "$3" = exist ] && [ -f "$2" ]; } || { [ "$3" = absent ] && [ ! -f "$2" ]; }; then + printf '[jsc] restart-gate.sh(%s):狀態檔 %s,與預期相同。\n' "$1" "$3" >> "$smoke_out" + else + smoke_fails=$((smoke_fails + 1)) + printf '[jsc] restart-gate.sh(%s):狀態檔預期 %s,實際不是,一支 CLI 一份的範圍壞了:%s\n' \ + "$1" "$3" "$2" >> "$smoke_out" + fi + } if rs_home=$(mktemp -d 2>/dev/null); then - smoke_rs_case "狀態檔不存在" jsc-sdlc:implement 0 - JSC_HOME="$rs_home" sh "$HOOKS/restart-gate.sh" require update hooks cli /dev/null - smoke_rs_case "狀態檔存在,技能 jsc-sdlc:implement" jsc-sdlc:implement 2 - smoke_rs_case "狀態檔存在,豁免技能 jsc-cli:deploy" jsc-cli:deploy 0 - smoke_rs_case "狀態檔存在,豁免技能 jsc-gitea:wiki" jsc-gitea:wiki 0 - smoke_rs_case "狀態檔存在,豁免技能 jsc-log:worklog" jsc-log:worklog 0 - smoke_rs_case "狀態檔存在,豁免技能 jsc-meta:skill-check" jsc-meta:skill-check 0 + rs_dir="$rs_home/restart-required.d" + # 別支 CLI 的代號。取一個不在五支之列的固定值,才不會跟這一輪的 $cli 撞在一起。 + rs_other=smoke-other + smoke_rs_case "當前 CLI 沒有狀態檔" jsc-sdlc:implement 0 + JSC_HOME="$rs_home" JSC_CLI="$rs_other" \ + sh "$HOOKS/restart-gate.sh" require update hooks /dev/null + smoke_rs_case "只有別支 CLI 有狀態檔" jsc-sdlc:implement 0 + JSC_HOME="$rs_home" JSC_CLI="$cli" \ + sh "$HOOKS/restart-gate.sh" require update hooks cli /dev/null + smoke_rs_file "require 寫出當前 CLI 那一份" "$rs_dir/$cli" exist + smoke_rs_case "當前 CLI 那份存在,技能 jsc-sdlc:implement" jsc-sdlc:implement 2 + smoke_rs_case "當前 CLI 那份存在,豁免技能 jsc-cli:deploy" jsc-cli:deploy 0 + smoke_rs_case "當前 CLI 那份存在,豁免技能 jsc-gitea:wiki" jsc-gitea:wiki 0 + smoke_rs_case "當前 CLI 那份存在,豁免技能 jsc-log:worklog" jsc-log:worklog 0 + smoke_rs_case "當前 CLI 那份存在,豁免技能 jsc-meta:skill-check" jsc-meta:skill-check 0 smoke_rs_case "逃生門 JSC_RESTART_GATE=off" jsc-sdlc:implement 0 off smoke_rs_case "取不到技能名" "" 0 # 清除機制:session-timer.sh 判定為新工作階段時會呼叫 restart-gate.sh clear。 # 這裡走的就是那條路徑(暫時 $JSC_HOME 底下沒有起始檔,等同行程新起的第一次)。 - if [ -f "$rs_home/restart-required" ]; then - JSC_HOME="$rs_home" JSC_SESSION_ID=smoke-restart \ - sh "$HOOKS/session-timer.sh" start /dev/null - if [ -f "$rs_home/restart-required" ]; then - smoke_fails=$((smoke_fails + 1)) - printf '[jsc] restart-gate.sh(清除機制):新工作階段開始後狀態檔還在,閘門會一直擋。\n' >> "$smoke_out" - else - printf '[jsc] restart-gate.sh(清除機制):新工作階段開始後狀態檔已清除,與預期相同。\n' >> "$smoke_out" - fi - else - smoke_fails=$((smoke_fails + 1)) - printf '[jsc] restart-gate.sh(清除機制):require 沒有寫出狀態檔,清除機制沒驗到。\n' >> "$smoke_out" - fi + JSC_HOME="$rs_home" JSC_CLI="$cli" JSC_SESSION_ID=smoke-restart \ + sh "$HOOKS/session-timer.sh" start /dev/null + smoke_rs_file "新工作階段開始後清掉自己那一份" "$rs_dir/$cli" absent + smoke_rs_file "清除不動別支 CLI 那一份" "$rs_dir/$rs_other" exist + smoke_rs_case "清除後放行" jsc-sdlc:implement 0 + # 舊格式的單一狀態檔(過渡相容):沒有 per-CLI 資訊,所以一律擋,clear 一併刪掉。 + printf 'at=%s\nmode=update\ndomains=hooks\ncli=%s\n' "$(now_iso)" "$rs_other" \ + > "$rs_home/restart-required" 2>/dev/null + smoke_rs_case "舊格式單一狀態檔存在" jsc-sdlc:implement 2 + JSC_HOME="$rs_home" JSC_CLI="$cli" JSC_SESSION_ID=smoke-restart-legacy \ + sh "$HOOKS/session-timer.sh" restart /dev/null + smoke_rs_file "清除一併刪掉舊格式狀態檔" "$rs_home/restart-required" absent + smoke_rs_case "舊格式狀態檔清除後放行" jsc-sdlc:implement 0 rm -rf "$rs_home" else smoke_fails=$((smoke_fails + 1))