From b0e352ae15f8535b522821e514e4fc83fa90bfb6 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Tue, 1 Sep 2026 14:08:14 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat(heartbeat):=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E5=8A=A9=E7=90=86=E5=BF=83=E8=B7=B3=E7=9A=84=E5=AF=AB=E5=85=A5?= =?UTF-8?q?=E3=80=81=E5=88=A4=E5=AE=9A=E8=88=87=E5=9B=9E=E5=A0=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit What: - 新增 hooks/heartbeat.sh,四個子命令:write 寫心跳、check 判定新鮮、report 印現況、clear 清除。 - README 的 hooks 表補一列,事件欄註明不接線;環境變數表補上心跳門檻那一個。 Why: - 助理是背景行程,別人要知道它還在不在跑,唯一的依據就是它留下的心跳。閘門要判、status 技能要印、巡檢要記,三邊都需要同一份判定。 - 判定散在三個地方一定會漂移,狀態跟訊息就會對不上。所以判定只寫一份,check 與 report 共用同一個探測函式。 How: - 新鮮的判準是「檔案存在,而且時間戳距現在小於門檻」。門檻預設 300 秒,是心跳週期的五倍,一次網路或磁碟卡頓不會誤判;環境變數可以覆寫,壞值退回預設而不報錯——變數打錯字不該讓判定整個歪掉。 - 絕不看 pid 存活。五支 CLI 與容器裡的行程互相看不到彼此的 pid,看了也證明不了什麼,pid 只當擋人訊息的線索。 - check 用結束碼分四種狀態:新鮮、過期、不存在、時間戳壞掉。前三種的處置各不相同,擋人訊息要說的話也不一樣;第四種既不是「跑過停了」也不是「沒啟動過」,併進任何一邊都會讓訊息說錯話,而且絕不能退回判成新鮮。 - 寫入走暫存檔再更名。直接覆寫的話,剛好讀到寫一半的檔案會少掉時間戳,助理活著卻被判成壞了。 - 這一支不接線,只是被助理與閘門呼叫的工具。接線是後續獨立的一步,先接會在心跳還沒跑起來時就擋死整組技能。 Who: 助理落地的第一塊:先有心跳,閘門才判得動,主體才有東西可寫。 --- README.md | 2 + hooks/heartbeat.sh | 176 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 178 insertions(+) create mode 100755 hooks/heartbeat.sh diff --git a/README.md b/README.md index 80536d9..6e24274 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ 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/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/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/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` | @@ -217,6 +218,7 @@ Claude 由 `hooks/hooks.json` 自動接線九支 hook;其他 CLI 用 `hooks-in | `JSC_LANG_GUARD` | 設 `off` 完全略過繁中與編碼檢查(`lang-guard.sh` 三種模式都直接結束) | 啟用檢查 | | `JSC_WRITE_GUARD` | 設 `off` 完全略過寫入與提交閘門(`write-guard.sh` 三種模式都直接結束) | 啟用閘門 | | `JSC_WRITE_GUARD_TTL` | `write-guard.sh review` 判定「稽核技能還在跑」的時效秒數 | 預設 900 | +| `JSC_ASSISTANT_HEARTBEAT_TTL` | `hooks/heartbeat.sh check` 判定心跳新鮮的門檻秒數。值不是正整數就退回預設值 | 預設 300(心跳週期 60 秒的五倍) | | `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_TOOL_COMMAND` | 非 Claude CLI 要判定的 Bash 指令字串,代替 stdin JSON 的 `command`,供 `write-guard.sh commit` 使用 | 安靜降級,不判定 | diff --git a/hooks/heartbeat.sh b/hooks/heartbeat.sh new file mode 100755 index 0000000..522cab8 --- /dev/null +++ b/hooks/heartbeat.sh @@ -0,0 +1,176 @@ +#!/usr/bin/env sh +# heartbeat.sh — 助理心跳檔的讀寫工具。 +# +# 助理在背景跑,前景會話看不到它。心跳檔就是它還在跑的唯一證據:助理主體與系統排程每 60 秒 +# 寫一次,`jsc-assist:status` 與擋人訊息讀這一份,判斷助理在不在。 +# +# 這支不是 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}{ts}{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 -- 2.53.0 From b5107563dd11576eaaff38e751f5926ada1f45bc Mon Sep 17 00:00:00 2001 From: Jeffery Date: Tue, 1 Sep 2026 14:18:49 +0800 Subject: [PATCH 2/3] =?UTF-8?q?chore(manifest):=20=E5=BF=83=E8=B7=B3?= =?UTF-8?q?=E8=85=B3=E6=9C=AC=E7=9A=84=E7=89=88=E6=9C=AC=E8=99=9F=E8=A3=9C?= =?UTF-8?q?=E4=B8=8A=EF=BC=8C=E6=AA=94=E9=A0=AD=E6=94=B9=E6=8C=87=E6=AD=A3?= =?UTF-8?q?=E7=A2=BA=E7=9A=84=E6=8A=80=E8=83=BD=E5=90=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit What: - 三份 manifest 的版本一起提升。 - heartbeat.sh 檔頭引用的技能名由 status 改成 assistant。 Why: - 上一筆加了 heartbeat.sh 卻沒有動版本號。版本不動,別的 domain 就沒有辦法用相依宣告要求「要有這支腳本的那一版」——宣告寫得出來,卻保證不了內容。助理宣告的下限本來會落在一個不含這支腳本的版本上。 - 檔頭寫的技能名是助理落地初期那一支獨立技能。助理主體把三個操作收攏成一支之後,那個名字就不存在了,照著找會找不到東西。 How: - 版本由 sync-skill-manifest.sh 同步,三份一致。 - 這一支仍然不接線,只是被助理與閘門呼叫的工具。 Who: 助理主體實作時,從相依宣告那一側回頭抓到的兩個缺口。 --- .claude-plugin/plugin.json | 2 +- .codex-plugin/plugin.json | 2 +- hooks/heartbeat.sh | 2 +- plugin.json | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 0b0e8e3..3e70d65 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "jsc-hooks", - "version": "0.3.6", + "version": "0.3.7", "description": "跨 CLI hooks:STE100 語言強制、工時計時、技能用量記錄、SDLC 模型鎖、版本前置檢查、註解範圍守門、繁中編碼守門、部署後強制重啟、寫入與提交閘門", "skills": "./skills", "author": { diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index f7beb60..2105b09 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.3.6", + "version": "0.3.7", "description": "跨 CLI hooks:STE100 語言強制、工時計時、技能用量記錄、SDLC 模型鎖、版本前置檢查、註解範圍守門、繁中編碼守門、部署後強制重啟、寫入與提交閘門", "skills": "./skills", "jsc": { diff --git a/hooks/heartbeat.sh b/hooks/heartbeat.sh index 522cab8..beac7a2 100755 --- a/hooks/heartbeat.sh +++ b/hooks/heartbeat.sh @@ -2,7 +2,7 @@ # heartbeat.sh — 助理心跳檔的讀寫工具。 # # 助理在背景跑,前景會話看不到它。心跳檔就是它還在跑的唯一證據:助理主體與系統排程每 60 秒 -# 寫一次,`jsc-assist:status` 與擋人訊息讀這一份,判斷助理在不在。 +# 寫一次,`jsc-assist:assistant` 與擋人訊息讀這一份,判斷助理在不在。 # # 這支不是 hook,不接在任何事件上,只被工具端呼叫,所以它擋不到任何人。它只做判定,擋不擋 # 由呼叫端自己決定——助理不參與閘門判定,只負責維持心跳。 diff --git a/plugin.json b/plugin.json index 7c40fa4..7740c5d 100644 --- a/plugin.json +++ b/plugin.json @@ -1,6 +1,6 @@ { "name": "jsc-hooks", - "version": "0.3.6", + "version": "0.3.7", "description": "跨 CLI hooks:STE100 語言強制、工時計時、技能用量記錄、SDLC 模型鎖、版本前置檢查、註解範圍守門、繁中編碼守門、部署後強制重啟、寫入與提交閘門", "skills": "./skills/", "jsc": { -- 2.53.0 From 800a8992395c04acc9aea25b53728943cea7a4e4 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Tue, 1 Sep 2026 15:19:59 +0800 Subject: [PATCH 3/3] =?UTF-8?q?feat(assistant-gate):=20=E5=8A=A9=E7=90=86?= =?UTF-8?q?=E9=81=8B=E8=A1=8C=E9=96=98=E9=96=80=EF=BC=8C=E9=80=99=E4=B8=80?= =?UTF-8?q?=E7=89=88=E5=B0=9A=E6=9C=AA=E6=8E=A5=E7=B7=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit What: - 新增 hooks/assistant-gate.sh:心跳新鮮就放行,心跳不存在、過期或時間戳壞掉就擋下該次技能呼叫。 - 豁免清單十一支、逃生門一個。README 的 hooks 表與環境變數表跟著補。 - 這一版刻意不接線,接線檔一個字都沒動。 Why: - 助理沒在跑的時候,技能會以為背景有人收尾,實際上沒有。這道閘門把那個落差擋在門外。 - 不接線是因為這台機器的穩定路徑指向開發存放庫,寫進接線檔就立刻對五支 CLI 生效。而現在還沒有心跳,接線的那一秒整組技能會全部鎖死,連修的路徑都走不到。接線的前提是助理已經在跑、心跳穩定。 How: - 這是整組 hook 裡唯一一道 fail-closed 的閘門。其餘的原則都是資料不足就放行,這一道相反。代價是狀態檔寫不進去時全組停擺,所以逃生門與豁免清單不是選配,是能上線的前提。 - 豁免清單只收解鎖路徑,不收收尾規則。這一點與重啟閘門的方向相反:重啟閘門解鎖靠閘門外的動作,收尾規則要寫得完;這一道解鎖靠跑一支技能,清單收寬了閘門就等於沒有。 - 清單認技能名不認呼叫鏈,所以豁免技能轉呼叫的下一層也要收進來。最要緊的是 wiki 那一支:巡檢要先把結果寫上監控頁才寫心跳,只豁免助理自己會做出「沒心跳就擋 wiki、擋了巡檢跑不完、跑不完就還是沒心跳」的自咬環。 - 心跳判定回「檔案系統問不出來」時放行,不擋。那是判不出事實,不在這道閘門的職權裡;而且那一刻正是磁碟或權限壞掉的訊號,擋下去連豁免那幾支也修不動——它們同樣要寫狀態檔。磁碟壞掉要人去清磁碟,不是把技能組鎖起來。時間戳壞掉則照擋,那是確定沒有可信心跳的證據,而且修法就在豁免清單裡。 - 逃生門的判斷擺在載入共用函式庫之前。函式庫讀不到時 sh 會就地結束並回擋人的那個碼,逃生門也會跟著跑不到,人就繞不過去。 - 擋人一律經 deny.sh 輸出。三支走標準錯誤加結束碼,另外兩支靠標準輸出的內容擋,結束碼固定是零;自己印訊息會在那兩支上無聲失效。 - 心跳不存在、過期、時間戳壞掉三種情況講三種話。使用者要做的事不一樣:一個是從沒啟動過,一個是跑過停了,一個是檔案壞了要重建。 Who: 助理落地的最後一塊。接線與準則那一節另外處理。 --- README.md | 2 + hooks/assistant-gate.sh | 204 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 206 insertions(+) create mode 100755 hooks/assistant-gate.sh diff --git a/README.md b/README.md index 6e24274..2b92568 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,7 @@ Marketplace 統一為 `jsc`(https://gitea.jsc.idv.tw/plugins/meta.git),安 | `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/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/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` | @@ -219,6 +220,7 @@ Claude 由 `hooks/hooks.json` 自動接線九支 hook;其他 CLI 用 `hooks-in | `JSC_WRITE_GUARD` | 設 `off` 完全略過寫入與提交閘門(`write-guard.sh` 三種模式都直接結束) | 啟用閘門 | | `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_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` 使用 | 安靜降級,不判定 | diff --git a/hooks/assistant-gate.sh b/hooks/assistant-gate.sh new file mode 100755 index 0000000..eddb6b1 --- /dev/null +++ b/hooks/assistant-gate.sh @@ -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}{技能名}」;用 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 +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) + +# 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 $? -- 2.53.0