釋出 jsc-hooks 0.3.7:助理心跳與運行閘門(閘門未接線) #70

Merged
admin merged 5 commits from develop into master 2026-09-01 07:51:20 +00:00
6 changed files with 387 additions and 3 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "jsc-hooks", "name": "jsc-hooks",
"version": "0.3.6", "version": "0.3.7",
"description": "跨 CLI hooks:STE100 語言強制、工時計時、技能用量記錄、SDLC 模型鎖、版本前置檢查、註解範圍守門、繁中編碼守門、部署後強制重啟、寫入與提交閘門", "description": "跨 CLI hooks:STE100 語言強制、工時計時、技能用量記錄、SDLC 模型鎖、版本前置檢查、註解範圍守門、繁中編碼守門、部署後強制重啟、寫入與提交閘門",
"skills": "./skills", "skills": "./skills",
"author": { "author": {
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"hooks": "./hooks/codex-hooks.json", "hooks": "./hooks/codex-hooks.json",
"name": "jsc-hooks", "name": "jsc-hooks",
"version": "0.3.6", "version": "0.3.7",
"description": "跨 CLI hooks:STE100 語言強制、工時計時、技能用量記錄、SDLC 模型鎖、版本前置檢查、註解範圍守門、繁中編碼守門、部署後強制重啟、寫入與提交閘門", "description": "跨 CLI hooks:STE100 語言強制、工時計時、技能用量記錄、SDLC 模型鎖、版本前置檢查、註解範圍守門、繁中編碼守門、部署後強制重啟、寫入與提交閘門",
"skills": "./skills", "skills": "./skills",
"jsc": { "jsc": {
+4
View File
@@ -28,6 +28,8 @@ Marketplace 統一為 `jsc`(https://gitea.jsc.idv.tw/plugins/meta.git),安
| `hooks/deny.sh` | 不直接接線,由 `version-guard.sh` 與 `restart-gate.sh` 呼叫 | 產出各 CLI 認得的阻擋輸出,訊息從參數或標準輸入進。claude、codex、copilot 訊息寫 stderr 並回 exit 2;antigravity 印 stdout 的 `{"decision":"deny","reason":"..."}` 並固定回 0——那支 CLI 的結束碼語意兩邊文件都沒寫,靠結束碼會變成「判定擋下、CLI 照樣放行」的無聲失效,所以 stdout 只准有那一行;kiro 擋不下技能叫用,改印警告後回 0;認不得的代號走 stderr 加 2 這個保守預設 | | `hooks/deny.sh` | 不直接接線,由 `version-guard.sh` 與 `restart-gate.sh` 呼叫 | 產出各 CLI 認得的阻擋輸出,訊息從參數或標準輸入進。claude、codex、copilot 訊息寫 stderr 並回 exit 2;antigravity 印 stdout 的 `{"decision":"deny","reason":"..."}` 並固定回 0——那支 CLI 的結束碼語意兩邊文件都沒寫,靠結束碼會變成「判定擋下、CLI 照樣放行」的無聲失效,所以 stdout 只准有那一行;kiro 擋不下技能叫用,改印警告後回 0;認不得的代號走 stderr 加 2 這個保守預設 |
| `hooks/version-guard.sh` | PreToolUse:claude matcher `Skill`、codex matcher `Bash`、copilot matcher `skill`、antigravity matcher `^view_file$` 加 `PreInvocation`;kiro `userPromptSubmit`(只注入警告) | 技能使用前的版本前置檢查,擋兩種情況,兩種都擋下該次呼叫並提示更新指令(更新指令依當前 CLI 給;技能名解析交給 `hooks/skill-name.sh`、阻擋輸出形態交給 `hooks/deny.sh`,兩支的規則見上面兩列,這裡不重寫第二套):一是本機**實際載入**版本落後遠端發佈版本,二是技能所屬 plugin 的 manifest 在 `jsc.requires` 宣告的相依 plugin 版本落後——相依那一項讀 `installPath` 底下那份 `plugin.json`,逐項比對相依 plugin 的本機實際載入版本,訊息講明哪一個 plugin、需要哪一版、目前哪一版、怎麼補。相依檢查排在遠端比對之前,全部讀本機檔案,離線也判得動;判定邏輯自己實作,不呼叫 `jsc-cli/tools/check-requires.sh`,免得 hook 散落到別的 domain,也免得跟已宣告相依 `jsc-hooks` 的 `jsc-cli` 做出循環相依。部署那端照樣更新、只回報,阻擋落在這支 hook。兩種都只擋確定落後:超前放行(開發技能組時本機本來就會超前),讀不到本機版本、推導不出站台、查不到遠端版本、解不出安裝路徑、讀不到 manifest、manifest 沒有 `jsc.requires`、讀不到相依 plugin 的本機載入版本也一律放行。遠端版本快取在 `$JSC_HOME/version-cache/{CLI 代號}/{domain}`,一支 CLI 一份;舊路徑 `$JSC_HOME/version-cache/{domain}` 會在第一次讀取時複製到新路徑。逃生門 `JSC_VERSION_GUARD=off`。豁免 `jsc-cli:deploy`、`jsc-hooks:hooks-install`、`jsc-hooks:repair`、`jsc-cli:models`、`jsc-meta:*`、`jsc-ask:ask`、`jsc-gitea:wiki`——共 7 項,兩種擋人情況共用同一份,相依落後不另立短清單;清單的唯一來源是 `hooks/version-guard.sh` 的檔頭,那裡一項一個理由 | | `hooks/version-guard.sh` | PreToolUse:claude matcher `Skill`、codex matcher `Bash`、copilot matcher `skill`、antigravity matcher `^view_file$` 加 `PreInvocation`;kiro `userPromptSubmit`(只注入警告) | 技能使用前的版本前置檢查,擋兩種情況,兩種都擋下該次呼叫並提示更新指令(更新指令依當前 CLI 給;技能名解析交給 `hooks/skill-name.sh`、阻擋輸出形態交給 `hooks/deny.sh`,兩支的規則見上面兩列,這裡不重寫第二套):一是本機**實際載入**版本落後遠端發佈版本,二是技能所屬 plugin 的 manifest 在 `jsc.requires` 宣告的相依 plugin 版本落後——相依那一項讀 `installPath` 底下那份 `plugin.json`,逐項比對相依 plugin 的本機實際載入版本,訊息講明哪一個 plugin、需要哪一版、目前哪一版、怎麼補。相依檢查排在遠端比對之前,全部讀本機檔案,離線也判得動;判定邏輯自己實作,不呼叫 `jsc-cli/tools/check-requires.sh`,免得 hook 散落到別的 domain,也免得跟已宣告相依 `jsc-hooks` 的 `jsc-cli` 做出循環相依。部署那端照樣更新、只回報,阻擋落在這支 hook。兩種都只擋確定落後:超前放行(開發技能組時本機本來就會超前),讀不到本機版本、推導不出站台、查不到遠端版本、解不出安裝路徑、讀不到 manifest、manifest 沒有 `jsc.requires`、讀不到相依 plugin 的本機載入版本也一律放行。遠端版本快取在 `$JSC_HOME/version-cache/{CLI 代號}/{domain}`,一支 CLI 一份;舊路徑 `$JSC_HOME/version-cache/{domain}` 會在第一次讀取時複製到新路徑。逃生門 `JSC_VERSION_GUARD=off`。豁免 `jsc-cli:deploy`、`jsc-hooks:hooks-install`、`jsc-hooks:repair`、`jsc-cli:models`、`jsc-meta:*`、`jsc-ask:ask`、`jsc-gitea:wiki`——共 7 項,兩種擋人情況共用同一份,相依落後不另立短清單;清單的唯一來源是 `hooks/version-guard.sh` 的檔頭,那裡一項一個理由 |
| `hooks/restart-gate.sh` | PreToolUse:claude matcher `Skill`、codex matcher `Bash`、copilot matcher `skill`、antigravity matcher `^view_file$` 加 `PreInvocation`;kiro `userPromptSubmit`(只注入警告) | 部署後強制重啟閘門:`$JSC_HOME/restart-required.d/{CLI 代號}` 一支 CLI 一份,當前 CLI 那份存在時擋下 jsc 技能呼叫,並印出要重新啟動哪一支 CLI(技能名解析交給 `hooks/skill-name.sh`、阻擋輸出形態交給 `hooks/deny.sh`,兩支的規則見上面兩列);別支 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-hooks:repair`、`jsc-gitea:wiki`、`jsc-log:worklog`、`jsc-log:learn`、`jsc-meta:*`、`jsc-ask:ask`、`jsc-git:pr`、`jsc-git:commit`——部署後還要寫得完技能組異動報告與工作日誌,hook 壞掉也要修得回來,整批擋下去這些規則會互相打死。清單認技能名不認呼叫鏈,後三支是為了讓前七支走得完才補進來的:`deploy` 要問模式、報告寫完要開 PR。另有唯讀子指令 `report`,一支 CLI 一行印出每一份狀態檔的內容,看得出還有哪幾支沒重啟。逃生門 `JSC_RESTART_GATE=off` | | `hooks/restart-gate.sh` | PreToolUse:claude matcher `Skill`、codex matcher `Bash`、copilot matcher `skill`、antigravity matcher `^view_file$` 加 `PreInvocation`;kiro `userPromptSubmit`(只注入警告) | 部署後強制重啟閘門:`$JSC_HOME/restart-required.d/{CLI 代號}` 一支 CLI 一份,當前 CLI 那份存在時擋下 jsc 技能呼叫,並印出要重新啟動哪一支 CLI(技能名解析交給 `hooks/skill-name.sh`、阻擋輸出形態交給 `hooks/deny.sh`,兩支的規則見上面兩列);別支 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-hooks:repair`、`jsc-gitea:wiki`、`jsc-log:worklog`、`jsc-log:learn`、`jsc-meta:*`、`jsc-ask:ask`、`jsc-git:pr`、`jsc-git:commit`——部署後還要寫得完技能組異動報告與工作日誌,hook 壞掉也要修得回來,整批擋下去這些規則會互相打死。清單認技能名不認呼叫鏈,後三支是為了讓前七支走得完才補進來的:`deploy` 要問模式、報告寫完要開 PR。另有唯讀子指令 `report`,一支 CLI 一行印出每一份狀態檔的內容,看得出還有哪幾支沒重啟。逃生門 `JSC_RESTART_GATE=off` |
| `hooks/heartbeat.sh` | 不接線,由 `jsc-assist` 的助理主體、系統排程與 `status` 技能呼叫 | 助理心跳檔 `$JSC_HOME/assistant/heartbeat` 的讀寫工具,純文字 key=value,欄位 `ts`、`pid`、`cli`、`session`。四個子命令:`write` 寫入四個欄位(目錄不存在就建,先寫暫存檔再改名,讀的那一端永遠讀到完整的一份)、`check` 判定新不新鮮(什麼都不印,結果只在結束碼)、`report` 印一行現況供 `status` 技能與擋人訊息取用、`clear` 刪除心跳檔(由助理的 `stop` 呼叫,檔案不存在也算成功)。新鮮的判準只有一條:心跳檔存在、而且 `ts` 距現在小於門檻秒數,門檻預設 300(心跳週期 60 秒的五倍,一次卡頓不會誤判),可用 `JSC_ASSISTANT_HEARTBEAT_TTL` 覆寫。**絕不看 pid 存活**:五支 CLI 與容器裡的行程互相看不到彼此的 pid,問了會把活著的判成停了,pid 又會被回收,反過來把停掉的判成還在跑,兩種誤判都不報錯;pid 只當擋人訊息的線索。`check` 的結束碼分四種讓呼叫端各自處置:0 新鮮、1 過期(跑過但停了)、3 心跳檔不存在(從沒啟動過)、4 檔案在但 `ts` 讀不出來(檔案壞了);`write` 與 `clear` 的檔案系統失敗回 5,用法錯誤回 6。四個子命令都不讀標準輸入——這支不是 hook,是被工具端呼叫的腳本,讀了會在管線沒人關閉時整支卡死。逐碼意義與 `report` 的欄位順序見腳本檔頭 |
| `hooks/assistant-gate.sh` | **這一版尚未接線。** 接線位置比照 `restart-gate.sh`(PreToolUse,claude matcher `Skill`、codex matcher `Bash`、copilot matcher `skill`、antigravity matcher `^view_file$` 加 `PreInvocation`;kiro `userPromptSubmit` 只注入警告) | 助理運行閘門:助理沒在跑就擋下 jsc 技能呼叫。判定整段交給 `heartbeat.sh check`,本檔不自己讀心跳檔;訊息細節取自 `heartbeat.sh report`(技能名解析交給 `hooks/skill-name.sh`、阻擋輸出形態交給 `hooks/deny.sh`)。心跳新鮮放行;心跳不存在、過期、`ts` 讀不出來三種都擋,三種的訊息各寫一份——沒啟動過的要去啟動、跑過停了的要去查為什麼停、檔案壞了的要先 `stop` 再 `start` 重建,併成一句就會叫錯人做錯事。`heartbeat.sh` 回 2(腳本沒跑起來)、5(檔案系統失敗)、6(用法錯誤)一律放行:那三碼是判定機制自己壞了,不是「助理沒在跑」的證據,而且 5 正是磁碟滿或權限壞的訊號,擋下去會把全機器整組技能鎖死、連豁免那幾支也修不動。**這是整組技能唯一一道 fail-closed 閘門**(其餘 hook 一律資料不足就放行),所以逃生門與豁免清單是它能上線的前提,不是選配。豁免 `jsc-assist:*`(啟動助理本身就是一次技能呼叫,少了它整組鎖死)、`jsc-hooks:repair`、`jsc-hooks:hooks-install`、`jsc-cli:doctor`、`jsc-cli:setup`、`jsc-cli:deploy`、`jsc-gitea:wiki`、`jsc-ask:ask`、`jsc-git:commit`、`jsc-git:pr`、`jsc-cli:models`。清單認技能名不認呼叫鏈,後五支是為了讓前六支走得完才補進來的,其中 `jsc-gitea:wiki` 最容易漏:巡檢一輪要先把結果寫進 `MONITOR_{HASH}` 才寫心跳,擋了它就變成「沒心跳 → 擋 wiki → 巡檢不完 → 還是沒心跳」自己咬住自己。逃生門 `JSC_ASSISTANT_GATE=off`,判斷擺在載入 `lib.sh` 之前——`lib.sh` 讀不到時 sh 回 2 等於無聲擋下每一次呼叫,逃生門也會跟著跑不到 |
| `hooks/skill-usage.sh` | PostToolUse(Skill) | 記錄技能使用與呼叫鏈到 `$JSC_HOME/usage/*.jsonl`,供 `jsc-log:stats` 統計 | | `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/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` | | `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` |
@@ -217,6 +219,8 @@ Claude 由 `hooks/hooks.json` 自動接線九支 hook;其他 CLI 用 `hooks-in
| `JSC_LANG_GUARD` | 設 `off` 完全略過繁中與編碼檢查(`lang-guard.sh` 三種模式都直接結束) | 啟用檢查 | | `JSC_LANG_GUARD` | 設 `off` 完全略過繁中與編碼檢查(`lang-guard.sh` 三種模式都直接結束) | 啟用檢查 |
| `JSC_WRITE_GUARD` | 設 `off` 完全略過寫入與提交閘門(`write-guard.sh` 三種模式都直接結束) | 啟用閘門 | | `JSC_WRITE_GUARD` | 設 `off` 完全略過寫入與提交閘門(`write-guard.sh` 三種模式都直接結束) | 啟用閘門 |
| `JSC_WRITE_GUARD_TTL` | `write-guard.sh review` 判定「稽核技能還在跑」的時效秒數 | 預設 900 | | `JSC_WRITE_GUARD_TTL` | `write-guard.sh review` 判定「稽核技能還在跑」的時效秒數 | 預設 900 |
| `JSC_ASSISTANT_HEARTBEAT_TTL` | `hooks/heartbeat.sh check` 判定心跳新鮮的門檻秒數。值不是正整數就退回預設值 | 預設 300(心跳週期 60 秒的五倍) |
| `JSC_ASSISTANT_GATE` | 設 `off` 完全略過助理運行閘門(`assistant-gate.sh` 一律放行)。判斷擺在載入 `lib.sh` 之前,那支函式庫讀不到時逃生門照樣有效 | 啟用閘門 |
| `JSC_READONLY` | 設 `1` 時 `tools/wire-cli.sh` 只准 `status` 與 `smoke`,`purge` 與接線一律拒絕並回 exit 6 | 四個用法都可執行 | | `JSC_READONLY` | 設 `1` 時 `tools/wire-cli.sh` 只准 `status` 與 `smoke`,`purge` 與接線一律拒絕並回 exit 6 | 四個用法都可執行 |
| `JSC_CHANGED_FILE` | 非 Claude CLI 要掃描的檔案路徑,代替 stdin JSON 的 `file_path`,供 `comment-scope.sh` 與 `lang-guard.sh` 使用 | 安靜降級,不掃描 | | `JSC_CHANGED_FILE` | 非 Claude CLI 要掃描的檔案路徑,代替 stdin JSON 的 `file_path`,供 `comment-scope.sh` 與 `lang-guard.sh` 使用 | 安靜降級,不掃描 |
| `JSC_TOOL_COMMAND` | 非 Claude CLI 要判定的 Bash 指令字串,代替 stdin JSON 的 `command`,供 `write-guard.sh commit` 使用 | 安靜降級,不判定 | | `JSC_TOOL_COMMAND` | 非 Claude CLI 要判定的 Bash 指令字串,代替 stdin JSON 的 `command`,供 `write-guard.sh commit` 使用 | 安靜降級,不判定 |
+204
View File
@@ -0,0 +1,204 @@
#!/usr/bin/env sh
# assistant-gate.sh — 助理運行閘門(PreToolUse,matcher: Skill)。
#
# 助理在背景跑,前景會話看不到它。技能組有一批規則要靠它落地:巡檢、監控頁、待辦簿。助理停著
# 的時候那些規則沒有人執行,可是技能照樣叫得起來,看起來一切正常。這道閘門負責讓「助理沒在跑
# 就繼續用技能」擋在門外。
#
# --- 這一版尚未接線 ---
#
# 本檔沒有寫進 hooks/hooks.json、hooks/codex-hooks.json 與 tools/wire-cli.sh,五支 CLI 一支都不
# 會叫到它。要驗證請直接跑 `sh hooks/assistant-gate.sh`,餵環境變數與標準輸入。
#
# 接線的前提有兩條,兩條都成立才可以接:
# 1. 助理已經在跑——`jsc-assist:assistant` 的 start 跑過,排程項目確實裝上了。
# 2. 心跳穩定——`heartbeat.sh check` 連續多輪都回 0。排程寫進 crontab 不等於 cron 在跑,
# WSL 預設不啟動 cron,那種機器上心跳一拍都不會有。
# 這兩條沒確認就接線,下一次技能呼叫就會被擋,而且擋的是整台機器的五支 CLI。
#
# 結束碼(hook 模式,本檔只有這一個模式):
# 0 放行,或已經以不靠結束碼的形態擋下。所以「exit 0」在這支腳本有兩種意思。
# 放行的情況:逃生門 JSC_ASSISTANT_GATE=off、負載裡解不出技能名、解出來的不是 jsc 技能、
# 命中下方豁免清單、`heartbeat.sh check` 回 0(心跳新鮮)、`heartbeat.sh check` 回 2、5、6
# (判不出來,理由見下方「心跳結束碼怎麼處置」)。
# 已擋下但不靠結束碼的情況:antigravity 的 stdout deny JSON、kiro 的注入警告。
# 2 擋下該次技能呼叫(claude、codex、copilot,以及認不得的 CLI 代號)。
# 擋下時的輸出形態由 deny.sh 依當前 CLI 決定,本檔只負責判定與訊息內容:
# claude、codex、copilot 走 stderr 加 exit 2;antigravity 走 stdout 的 deny JSON,結束碼
# 固定 0(那支 CLI 的結束碼語意沒有文件,不可靠);kiro 擋不下來,改印警告後 exit 0。
# 本檔沒有其他結束碼,也沒有子命令。帶進來的參數一律忽略。
#
# 輸入:技能名一律由 skill-name.sh 從當前 CLI 的負載解析,環境變數 JSC_SKILL、SKILL 優先,
# 規則與 version-guard.sh、restart-gate.sh 共用同一份。不另外篩工具名:工具名每支 CLI 都不
# 一樣(Skill、Bash、skill、view_file),拿 Claude 的那一個當通用條件會把另外四支整批擋在判定
# 之外。
#
# --- 這是整組技能唯一一道 fail-closed 閘門 ---
#
# 其餘 hook 的原則都是「資料不足就放行」:version-guard.sh 查不到版本放行,restart-gate.sh 讀不
# 到狀態檔放行。這一道相反——心跳不存在就是助理沒在跑,照要求要擋。心跳檔不存在本身就是證據,
# 不是「資料不足」。
#
# 代價講白:$JSC_HOME 寫不進去的時候(磁碟滿、權限壞、掛載掉了)助理寫不出心跳,這道閘門就把
# 全機器五支 CLI 的整組技能一起停掉。所以逃生門與豁免清單不是選配,是這道閘門能上線的前提:
# 逃生門讓人在閘門判錯時當場繞過去,不必先修好環境才動得了技能。
# 豁免清單讓「啟動助理」與「修環境」這兩條路徑永遠走得通,閘門才不會把解除自己的路徑鎖掉。
# 這兩樣任何一樣被拿掉或改窄,這道閘門就不可以接線。
#
# --- 心跳結束碼怎麼處置 ---
#
# 判定一律交給 `heartbeat.sh check`,本檔不自己讀心跳檔——判定寫兩份就會漂移,狀態跟訊息對不上。
# 那支腳本的六個結束碼逐碼處置如下:
# 0 新鮮。放行。
# 1 過期:心跳檔在、ts 也讀得到,但距現在已達門檻。助理跑過、現在停了。擋,訊息講「跑過但
# 停了」,並講出超過門檻幾秒。
# 2 腳本沒跑起來(`. lib.sh` 載入失敗時 sh 自己回這一碼)。放行——這是判定機制自己壞了,
# 不是「助理沒在跑」的證據。
# 3 心跳檔不存在。助理從沒啟動過。擋,訊息講「從沒啟動過」,要人去啟動。與 1 的處置不同:
# 使用者要做的事不一樣,併成同一句話會叫錯人去做錯事。
# 4 心跳檔在、ts 卻讀不出來(缺鍵、空值或不是數字)。**擋。** heartbeat.sh 檔頭寫明呼叫端
# 一律當成不新鮮處置,絕不可以退回當成新鮮。訊息與 1、3 都不同:那是檔案壞了,不是助理
# 停了,修法是先 stop 再 start 把心跳檔重建起來。
# 5 檔案系統操作失敗。**放行。** 理由見下一段。
# 6 用法錯誤(不認得的子命令,或一個都沒給)。放行——本檔固定送 check,收到 6 就代表
# heartbeat.sh 換了介面、或這支閘門叫錯了。那是這一邊的缺陷,不是助理的狀態。
#
# 5 為什麼選放行,不選擋:
# 一、`check` 這條路徑根本不產生 5。5 只由 `write` 與 `clear` 產出。從 check 收到 5,意思是
# 判定機制本身壞了,跟 2 與 6 同一類,不是「助理沒在跑」。
# 二、fail-closed 管的是「助理狀態」這一件事實:確定沒有新鮮心跳才擋。5 的意思是連事實都問
# 不出來,那不在這道閘門的職權裡。
# 三、最要緊的實務理由:5 正是磁碟滿或權限壞的訊號,而那一刻助理自己也寫不出心跳。擋下去的
# 結果是全機器整組技能鎖死,出路只剩豁免清單那幾支——可是那幾支同樣要寫 $JSC_HOME
# (接線狀態、用量、工作階段),環境壞著它們也修不動。磁碟壞掉要人去清磁碟,不是把技能
# 組鎖起來。
# 四、和 4 的差別在有沒有出路:4 是「檔案在、內容壞」,那是確定沒有可信心跳的證據,而且修法
# 就在豁免清單裡(stop 再 start),擋得起;5 是「檔案系統問不出來」,擋了沒有出路。
# 代價據實寫:磁碟壞掉時這道閘門會安靜放行,助理沒在跑也擋不到。那是刻意的取捨——這道閘門
# 不是磁碟監控,環境壞掉由 /jsc-cli:doctor 抓。
#
# 豁免(這些技能永遠放行,改動前想清楚後果):
# jsc-assist:* 啟動助理本身就是一次技能呼叫。少了這一條,助理永遠啟動不了,整組
# 技能鎖死。這是雞生蛋,清單裡最要緊的一條
# jsc-hooks:repair 修 hook 的唯一路徑。修 hook 的技能被 hook 擋下,就沒有任何方法把
# hook 修回來,閘門等於把解除自己的路徑一起鎖掉
# jsc-hooks:hooks-install 重新接線的唯一路徑。這道閘門接錯了要靠它拆掉
# jsc-cli:doctor 環境健檢。心跳寫不出來多半是環境問題,查不了就修不了
# jsc-cli:setup 修設定的唯一路徑,doctor 找到的東西要靠它落地
# jsc-cli:deploy 部署技能組。助理主體本身也是技能,裝不上就啟動不了
# jsc-gitea:wiki 助理巡檢一輪要先把結果寫進 MONITOR_{HASH},寫不成那一輪就不寫心跳
# (no record, no heartbeat)。擋了它,巡檢永遠跑不完、心跳永遠不出現,
# 助理再也啟動不了。jsc-hooks:repair 的第一步也是讀 ERROR_{HASH},讀
# 不到就中止
# jsc-ask:ask 上面幾支都要問使用者:assistant 要問做哪一個操作,setup 與 deploy
# 要問模式。擋了它,start 連要不要跑都問不出來
# jsc-git:commit jsc-hooks:repair 收尾要提交,擋了修好的東西進不了版本控制
# jsc-git:pr 同上,repair 規定收尾要對 develop 開 PR,擋了修復做一半
# jsc-cli:models jsc-cli:setup 遇到 model-tags.tsv 不見時要靠它補回來,擋了那一項修不完
#
# 清單認的是技能名,不是呼叫鏈:豁免技能轉呼叫的下一層若不在清單上,那一層照樣會被擋。後五支
# (wiki、ask、commit、pr、models)就是為了這件事補進來的——它們自己不是啟動助理的主體,但前
# 六支少了它們就走不完。jsc-gitea:wiki 是這裡面最容易漏的一支:只豁免 jsc-assist:* 看起來就夠
# 了,可是巡檢那一輪會轉呼叫 wiki 去寫監控頁,寫不成就不寫心跳,於是「沒心跳 → 擋 wiki →
# 巡檢不完 → 還是沒心跳」自己咬住自己,永遠解不開。
#
# 清單刻意不收 jsc-log:worklog 與 jsc-log:learn:那兩支是部署收尾的規則,跟「把助理啟動起來」
# 這條路徑無關。fail-closed 閘門的豁免清單只收解鎖路徑,收寬了這道閘門就等於沒有。
#
# 逃生門:JSC_ASSISTANT_GATE=off 完全略過這道閘門。
#
# 註:逃生門的判斷擺在載入 lib.sh 之前,這一點與 restart-gate.sh 不同。lib.sh 讀不到時 sh 會就地
# 結束並回 2,接在 PreToolUse 上就是無聲擋下每一次技能呼叫;這道閘門是 fail-closed 的,那個
# 結果方向上不算錯,但逃生門也跟著跑不到,人就沒有辦法自己繞過去。所以先看逃生門,再載入。
# hooks/skill-name.sh、hooks/deny.sh 與 hooks/heartbeat.sh 都以子行程呼叫,讀不到只會讓判定
# 降級成放行,不會反過來擋人。
# 逃生門先看。結束前把標準輸入讀乾淨:不讀就結束,宿主 CLI 會寫進斷掉的管線。
if [ "${JSC_ASSISTANT_GATE:-}" = "off" ]; then
[ -t 0 ] || cat >/dev/null 2>&1
exit 0
fi
HERE=$(dirname "$0"); . "$HERE/lib.sh"
read_stdin
# 技能名解析:交給 skill-name.sh。輸出固定是「{domain}<TAB>{技能名}」;用 awk 判 NF==2 才取值,
# 少一欄就當成解析不出來,免得沒有定位字元時 cut -f2 把整行當成技能名,拼出一個不存在的技能名
# 去比對豁免清單。
sn=$(printf '%s' "$STDIN_JSON" | sh "$HERE/skill-name.sh" "$(cli_name)" 2>/dev/null)
sn_domain=$(printf '%s\n' "$sn" | awk -F'\t' 'NF == 2 { print $1; exit }')
sn_name=$(printf '%s\n' "$sn" | awk -F'\t' 'NF == 2 { print $2; exit }')
[ -n "$sn_domain" ] && [ -n "$sn_name" ] || exit 0
skill="jsc-$sn_domain:$sn_name"
# 豁免清單(理由見檔頭)
case "$skill" in
jsc-assist:*|jsc-hooks:repair|jsc-hooks:hooks-install|jsc-cli:doctor|jsc-cli:setup|jsc-cli:deploy|jsc-gitea:wiki|jsc-ask:ask|jsc-git:commit|jsc-git:pr|jsc-cli:models)
exit 0 ;;
esac
# 心跳判定。補 </dev/null:heartbeat.sh 不讀標準輸入,但這裡的標準輸入已經被 read_stdin 收乾,
# 留著空管線給子行程沒有意義,明確關掉才不會有人往回接一條會等的路。
sh "$HERE/heartbeat.sh" check </dev/null 2>/dev/null
hb=$?
case "$hb" in
0) exit 0 ;; # 新鮮
1|3|4) ;; # 過期、不存在、時間戳壞掉:落到下面組訊息並擋下
*) exit 0 ;; # 2、5、6:判不出來就放行,逐碼理由見檔頭
esac
# 訊息細節一律取自 `heartbeat.sh report`,本檔不自己解析心跳檔:判定與訊息共用同一份探測結果,
# 兩邊各讀一次會出現「擋的理由」與「印的數字」對不上。
REPORT=$(sh "$HERE/heartbeat.sh" report </dev/null 2>/dev/null)
# report 是一行、欄位以空白分隔。除了 file 以外每一欄都不含空白,換行切開再取最穩。
rep_field() { # $1=鍵名(file 除外)
printf '%s' "$REPORT" | tr ' ' '\n' | sed -n "s/^$1=//p" | head -n1
}
# file 擺在最後,路徑可能含空白,所以取「file= 之後的全部」。
rep_file() {
printf '%s' "$REPORT" | sed -n 's/.*[[:space:]]file=//p'
}
hb_age=$(rep_field age)
hb_ttl=$(rep_field ttl)
hb_pid=$(rep_field pid)
hb_file=$(rep_file)
[ -n "$hb_file" ] || hb_file="$JSC_HOME/assistant/heartbeat"
# 超過門檻幾秒。兩個值都是純數字才算,算不出來就不印那一段——寧可少講一個數字,也不要印出
# 算壞的值。
hb_over=""
case "$hb_age$hb_ttl" in
''|*[!0-9]*) ;;
*) hb_over=$(( hb_age - hb_ttl )) ;;
esac
# 三種狀態的第一句話各寫一份。使用者要做的事不一樣:沒啟動過的要去啟動,跑過停了的要去查為
# 什麼停,檔案壞了的要去重建。併成同一句就會叫錯人做錯事。
case "$hb" in
3) first=$(printf '[jsc][助理閘門][ERR]:助理沒有在跑。心跳檔 %s 不存在,助理從沒啟動過。技能 /%s 這一次呼叫已擋下。' \
"$hb_file" "$skill") ;;
1) first=$(printf '[jsc][助理閘門][ERR]:助理跑過,現在停了。上次心跳是 %s 秒前,門檻 %s 秒,已經超過門檻 %s 秒(寫入者 pid=%s,心跳檔 %s)。技能 /%s 這一次呼叫已擋下。' \
"${hb_age:-不明}" "${hb_ttl:-不明}" "${hb_over:-不明}" "${hb_pid:-不明}" "$hb_file" "$skill") ;;
*) first=$(printf '[jsc][助理閘門][ERR]:助理狀態判不出來。心跳檔 %s 在,但 ts 欄位缺了、是空的、或不是數字——檔案壞了,不是助理停了。一律當成沒有心跳處置。技能 /%s 這一次呼叫已擋下。' \
"$hb_file" "$skill") ;;
esac
# 第二句:怎麼把助理弄回來。三種狀態的做法也不同。
case "$hb" in
3) second='啟動助理:/jsc-assist:assistant,操作選 start。它會先跑一輪巡檢,把結果寫上監控頁,再把排程項目裝起來;心跳是那一輪跑完才寫的。' ;;
1) second='重新啟動助理:/jsc-assist:assistant,操作選 start;先用 status 看排程項目還在不在。排程寫進 crontab 不等於 cron 在跑,WSL 預設不啟動 cron,那種機器要先 sudo service cron start,而且每次重開機都要再跑一次。' ;;
*) second='重建心跳:/jsc-assist:assistant,操作先選 stop 再選 start。stop 會把壞掉的心跳檔刪掉,start 跑完一輪巡檢才寫出新的一份。' ;;
esac
# 擋人輸出交給 deny.sh:形態依 CLI 而定,本檔只組訊息。四段訊息整段走同一條管線送過去,
# antigravity 那一支才有辦法把它們壓成同一個 reason 字串;分次呼叫會做出好幾份 deny JSON,
# 那支 CLI 只認第一份,後面三段使用者永遠看不到。
{ printf '%s\n' "$first"
printf '%s\n' "$second"
printf '仍可使用:/jsc-assist:*、/jsc-hooks:repair、/jsc-hooks:hooks-install、/jsc-cli:doctor、/jsc-cli:setup、/jsc-cli:deploy、/jsc-cli:models、/jsc-gitea:wiki、/jsc-ask:ask、/jsc-git:commit、/jsc-git:pr(啟動助理與修環境這兩條路徑要永遠走得通,包括它們轉呼叫的下一層)\n'
printf '確定要略過閘門:JSC_ASSISTANT_GATE=off\n'
} | sh "$HERE/deny.sh" "$(cli_name)"
exit $?
+176
View File
@@ -0,0 +1,176 @@
#!/usr/bin/env sh
# heartbeat.sh — 助理心跳檔的讀寫工具。
#
# 助理在背景跑,前景會話看不到它。心跳檔就是它還在跑的唯一證據:助理主體與系統排程每 60 秒
# 寫一次,`jsc-assist:assistant` 與擋人訊息讀這一份,判斷助理在不在。
#
# 這支不是 hook,不接在任何事件上,只被工具端呼叫,所以它擋不到任何人。它只做判定,擋不擋
# 由呼叫端自己決定——助理不參與閘門判定,只負責維持心跳。
#
# 用法(四個子命令都不讀標準輸入,理由見下方「不讀標準輸入」):
# heartbeat.sh write 寫入心跳檔,四個欄位一次寫齊,目錄不存在就建。由助理主體與系統
# 排程呼叫。
# heartbeat.sh check 判定心跳新不新鮮。什麼都不印,結果只在結束碼;要細節請跑 report。
# heartbeat.sh report 印一行心跳現況,格式見下方「report 輸出格式」。
# heartbeat.sh clear 刪除心跳檔。由助理的 stop 呼叫。檔案不存在也算成功。
#
# 結束碼:
# 0 check 判定新鮮(state=fresh);write 寫成功;clear 清完,檔案已經不在;report 印完
# 1 check:心跳檔在、ts 也讀得到,但距現在已達門檻(state=stale)。助理跑過,現在停了。
# 訊息要叫人去查助理為什麼停
# 2 保留給「腳本沒跑起來」:`. lib.sh` 載入失敗時 sh 自己回這一碼(見最下方註)。判定路徑
# 刻意不用 2,兩者才分得開
# 3 check:心跳檔不存在(state=absent)。助理從沒啟動過。處置與 1 不同,訊息要叫人去啟動
# 4 check:心跳檔在、ts 卻讀不出來(state=invalid,缺鍵、空值或不是數字)。檔案壞了,不是
# 助理停了。呼叫端一律當成不新鮮處置,絕不可以退回當成新鮮
# 5 檔案系統操作失敗:write 寫不進去(磁碟滿、權限壞、目錄建不起來),或 clear 刪不掉、
# 檔案還在。這是嚴重狀況——助理沒有心跳就會被自己那道閘門擋掉,所以一定要吵出來,
# 不能安靜當成成功
# 6 用法錯誤:不認得的子命令,或一個子命令都沒給。刻意不與上面任何一種正常狀態共用碼,
# 共用了呼叫端就分不出「助理沒在跑」與「這支腳本被叫錯」
#
# --- 只看 ts,絕不看 pid 存活 ---
#
# 新鮮的判準只有一條:心跳檔存在,而且 ts 距現在小於門檻秒數。pid 一律不拿來判定。
# 一台機器上五支 CLI 各自是獨立行程,助理也可能跑在容器裡,彼此看不到對方的 pid:拿
# `kill -0` 去問,看不到的行程一律回失敗,活著的助理會被判成停了;pid 還會被回收,別人的
# 行程剛好接到同一個號碼,就反過來把停掉的助理判成還在跑。兩種誤判都不會報錯,查起來也沒有
# 線索。pid 只寫進檔案當擋人訊息的線索,讓人自己去查那個行程。
#
# --- 門檻為什麼是 300 ---
#
# 心跳週期是 60 秒,門檻取五倍。一次網路或磁碟卡頓讓某一拍沒寫成,後面還有四拍補得回來,
# 不會誤判成助理停了。門檻用環境變數 JSC_ASSISTANT_HEARTBEAT_TTL 覆寫,單位是秒;值不是
# 正整數就退回 300——環境變數打錯字不該讓判定整個歪掉。
#
# --- 不讀標準輸入 ---
#
# 這支是被工具端呼叫的腳本,四個子命令一律不讀 stdin。理由與 restart-gate.sh 的子命令相同:
# read_stdin 在標準輸入是管線又沒人關閉時會一直等,工具端呼叫就整支卡死。
#
# --- 狀態檔格式 ---
#
# $JSC_HOME/assistant/heartbeat(JSC_HOME 未設定時為 ~/.jsc),純文字 key=value,一行一欄位,
# 順序不拘,不認得的鍵一律忽略:
# ts={epoch 秒數} 寫入當下的時間。判定只看這一欄
# pid={行程 id} 寫入者的行程 id。只當擋人訊息的線索
# cli={CLI 代號} 寫入者是哪一支 CLI,取自 cli_name()
# session={id} 寫入者的工作階段 id,取自 session_id()
# 寫入走「先寫暫存檔、再改名」:改名是原子的,讀的那一端永遠讀到完整的一份。直接覆寫的話,
# 剛好讀到寫一半的檔案會少掉 ts,判定就從 fresh 掉成 invalid,助理明明活著卻被說成壞了。
#
# --- report 輸出格式 ---
#
# 固定一行,鍵的順序固定,欄位以空白分隔。鍵一個都不會少,缺值就只留鍵名,呼叫端不必判斷
# 有沒有這一欄。路徑擺最後,路徑含空白時才不會把後面的欄位吃掉:
# state={fresh|stale|invalid|absent} ts={epoch} age={秒} ttl={秒} pid={} cli={} session={} file={路徑}
# state 的四種值與 check 的結束碼一一對應:fresh=0、stale=1、absent=3、invalid=4。
# 例(心跳新鮮):
# state=fresh ts=1756684800 age=42 ttl=300 pid=31415 cli=claude session=a1b2c3 file=/root/.jsc/assistant/heartbeat
# 例(心跳檔不存在):
# state=absent ts= age= ttl=300 pid= cli= session= file=/root/.jsc/assistant/heartbeat
# ts 不是數字時 ts 與 age 兩欄都印空的:那個值是垃圾,原樣印出來會夾帶空白把欄位切歪。
#
# 註:本檔以 `. "$HERE/lib.sh"` 載入共用函式,沒有接 `|| true`。載入失敗時 sh 會就地結束並回
# 2。這一點的後果與 restart-gate.sh 不同:那支接在 PreToolUse 上,回 2 等於無聲擋下每一次
# 技能呼叫;這支沒接任何 hook,回 2 只會讓呼叫端收到「心跳判不出來」,擋不到任何人。
HERE=$(dirname "$0"); . "$HERE/lib.sh"
# 這支永遠不讀標準輸入,但 session_id() 會去看 STDIN_JSON。先設成空字串,讓它直接走環境
# 變數那條路,不會因為變數沒定義而拿到不確定的值。
STDIN_JSON=""
STATE_DIR="$JSC_HOME/assistant"
STATE="$STATE_DIR/heartbeat"
DEFAULT_TTL=300
# 門檻秒數。環境變數不是正整數就退回預設值,理由見檔頭「門檻為什麼是 300」。
ttl() {
_t="${JSC_ASSISTANT_HEARTBEAT_TTL:-}"
case "$_t" in
''|*[!0-9]*) printf '%s' "$DEFAULT_TTL"; return 0 ;;
esac
if [ "$_t" -gt 0 ] 2>/dev/null; then printf '%s' "$_t"; else printf '%s' "$DEFAULT_TTL"; fi
}
# 從心跳檔取一個欄位;檔案讀不到或欄位不存在就不輸出。
field() { # $1=鍵名
[ -f "$STATE" ] && [ -r "$STATE" ] || return 0
sed -n "s/^$1=//p" "$STATE" 2>/dev/null | head -n1
}
# 判定心跳狀態,印出「{state}<TAB>{ts}<TAB>{age}」,後兩欄在 absent 與 invalid 時留空。
# check 與 report 共用這一份:兩邊各判一次就會漂移,狀態與訊息對不上。
probe() {
if [ ! -f "$STATE" ] || [ ! -r "$STATE" ]; then
printf 'absent\t\t\n'; return 0
fi
_ts=$(field ts)
case "$_ts" in
''|*[!0-9]*) printf 'invalid\t\t\n'; return 0 ;;
esac
# 去掉開頭的 0:POSIX 算術把 08 當八進位,會直接報錯,錯完 age 是空的,判定就整條歪掉。
while :; do
case "$_ts" in 0?*) _ts=${_ts#0} ;; *) break ;; esac
done
_age=$(( $(now_epoch) - _ts ))
# age 是負的代表 ts 在未來,那是時鐘偏移,不是助理停了,照樣算新鮮。
if [ "$_age" -lt "$(ttl)" ]; then _st=fresh; else _st=stale; fi
printf '%s\t%s\t%s\n' "$_st" "$_ts" "$_age"
}
usage() {
printf 'usage: heartbeat.sh {write|check|report|clear}\n' >&2
exit 6
}
case "${1:-}" in
write)
# 心跳檔的位置被目錄或別的東西佔住時要當場失敗。`mv` 遇到目標是目錄會把暫存檔搬進去,
# 搬得成功、心跳檔卻永遠不存在,寫的那一端拿到 0,讀的那一端說助理沒啟動過。
if [ -e "$STATE" ] && [ ! -f "$STATE" ]; then
printf '[jsc][助理心跳][ERR]:%s 不是一般檔案,心跳寫不進去。\n' "$STATE" >&2
exit 5
fi
mkdir -p "$STATE_DIR" 2>/dev/null || true
_tmp="$STATE.tmp.$$"
# stderr 先轉走再開檔:順序反過來的話,開檔失敗的訊息是 sh 自己印的,那時 stderr 還沒
# 轉走,會漏到呼叫端的畫面上,蓋掉下面那句講得清楚的錯誤訊息。
if ! printf 'ts=%s\npid=%s\ncli=%s\nsession=%s\n' \
"$(now_epoch)" "$$" "$(cli_name)" "$(session_id)" 2>/dev/null > "$_tmp"; then
rm -f "$_tmp" 2>/dev/null
printf '[jsc][助理心跳][ERR]:寫不進 %s,助理這一拍沒有心跳。\n' "$STATE" >&2
exit 5
fi
if ! mv -f "$_tmp" "$STATE" 2>/dev/null; then
rm -f "$_tmp" 2>/dev/null
printf '[jsc][助理心跳][ERR]:換不上 %s,助理這一拍沒有心跳。\n' "$STATE" >&2
exit 5
fi
exit 0 ;;
check)
case "$(probe | cut -f1)" in
fresh) exit 0 ;;
stale) exit 1 ;;
absent) exit 3 ;;
*) exit 4 ;;
esac ;;
report)
_p=$(probe)
printf 'state=%s ts=%s age=%s ttl=%s pid=%s cli=%s session=%s file=%s\n' \
"$(printf '%s' "$_p" | cut -f1)" \
"$(printf '%s' "$_p" | cut -f2)" \
"$(printf '%s' "$_p" | cut -f3)" \
"$(ttl)" "$(field pid)" "$(field cli)" "$(field session)" "$STATE"
exit 0 ;;
clear)
rm -f "$STATE" 2>/dev/null
# 刪不掉就要講出來:檔案還在,別人讀到的心跳會說助理還在跑。
if [ -e "$STATE" ]; then
printf '[jsc][助理心跳][ERR]:刪不掉 %s,心跳檔還在。\n' "$STATE" >&2
exit 5
fi
exit 0 ;;
*) usage ;;
esac
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "jsc-hooks", "name": "jsc-hooks",
"version": "0.3.6", "version": "0.3.7",
"description": "跨 CLI hooks:STE100 語言強制、工時計時、技能用量記錄、SDLC 模型鎖、版本前置檢查、註解範圍守門、繁中編碼守門、部署後強制重啟、寫入與提交閘門", "description": "跨 CLI hooks:STE100 語言強制、工時計時、技能用量記錄、SDLC 模型鎖、版本前置檢查、註解範圍守門、繁中編碼守門、部署後強制重啟、寫入與提交閘門",
"skills": "./skills/", "skills": "./skills/",
"jsc": { "jsc": {