diff --git a/README.md b/README.md index 15c1e19..43a1988 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ 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 行程是新起的,新版一定已經載入。清除的範圍只有跑到這支腳本的那一支 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 自己那一份狀態檔,別支沒重啟就繼續被擋 | | `hooks/session-reminder.sh` | SessionStart | 把助理算好的未讀提醒帶到前景。只讀 `$JSC_HOME/assistant/reminders.tsv`(`jsc-assist` 的巡檢每一輪重寫),逾期的排前面、使用者自己登錄的到期提醒在後,最多列 8 筆;委派清單種入的內建項只印一行總數(那幾筆等的是接線不是人,每一輪都到期、每一輪都一樣,逐筆吐出來就是噪音),另加一行「有幾筆待辦連續失敗」。**這一支一個判定都不做**:自己拿 `due` 欄與 `next_run` 去跟現在比就是第二套到期判定,跟助理那一套遲早對不上。一個工作階段只提一次,記號是 `$JSC_HOME/sessions/{代號}.reminded`,接不到 session id 的 CLI 由 `session-timer.sh restart` 清掉那個記號。佇列檔頭帶那一輪的時間戳與 epoch,超過心跳門檻或心跳不新鮮就明說「這批提醒是多久以前算的、助理現在的心跳是什麼狀態」——一份沒有人更新的佇列讀起來跟新的一模一樣,而「沒有提醒」與「沒有人算提醒」不可以長得一樣。助理狀態目錄不存在時一個字都不印:那台機器從沒啟動過助理,每個工作階段催一次不是提醒是噪音。子指令 `peek` 只印不記號,給人重看與檢核用。永遠 exit 0 | | `hooks/skill-name.sh` | 不直接接線,由 `version-guard.sh` 與 `restart-gate.sh` 呼叫 | 從各 CLI 的 hook 負載解析出這一次要用哪一支 jsc 技能,一支 CLI 一個子命令,印一行「{domain}{技能名}」,解析不出來就印空字串。取值來源:claude 讀 stdin JSON 的 `skill` 欄位、codex 讀 `tool_input.command` 裡那條 `SKILL.md` 路徑(Codex 沒有 Skill 工具,技能是模型自己用 Bash 讀 `SKILL.md` 載入的)、copilot 讀 `toolArgs`(字串化的 JSON,要先剝一層跳脫)、antigravity 讀 `toolCall.args.AbsolutePath` 另收提示字串(斜線指令不產生工具呼叫)、kiro 讀 `prompt` 開頭那個斜線指令;五支都先看環境變數 `JSC_SKILL`、`SKILL`。永遠 exit 0:閘門那一端一律 fail-open,而且 copilot 的 command hook 是 fail-closed 的,回非零等於拒絕。規則只有這一份,兩支閘門都不重寫第二套 | | `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 這個保守預設 | @@ -121,7 +121,11 @@ Claude 由 `hooks/hooks.json` 自動接線十支 hook;其他 CLI 用 `hooks-in 一支 CLI 一份是為了修兩個實測抓到的洞:一台機器上五支 CLI 各自是獨立行程,各自載入自己記憶體裡的那一版。早先的單一檔案設計裡,並行部署會互相覆寫(後寫的把 `domains` 與 `cli` 蓋掉,欄位不再代表先寫的那一支),而且任一支 CLI 重啟就把五支的閘門一起解除,其餘四支沒重啟卻不再被擋,閘門在多 CLI 環境等於半失效。拆成一支一份之後,寫入、判定、清除三件事都只碰自己那一份。 -寫檔的一律是 `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`,兩邊都不抄對方那一半。 +寫檔的一律是 `jsc-cli:deploy`,經 `restart-gate.sh require {install|update} [{domain}...]` 落地,寫的是當前 CLI 那一份;取不到 CLI 代號或寫不進去都會 exit 2 並講明「這次部署沒有掛上重啟閘門」——沒寫成就沒有閘門,不能讓部署以為掛上了。問清除的一律是 `session-timer.sh`:`start` 與 `restart` 都在工作階段開始時呼叫一次 `restart-gate.sh clear`,並把手上那個工作階段代號帶進去,只刪呼叫端那一支自己那一份。 + +**判準是「載入舊程式碼那個行程還在不在」,寫在 `restart-gate.sh`。** `require` 落地時一併記下 `session=` 與 `pid=`——掛上閘門那一刻的工作階段代號,與那一支 CLI 自己的行程代號(往上追祖先,比對命令名等於 CLI 代號)。清除時記到行程代號就只認它:還活著就不清,不管代號換沒換,因為舊程式碼還在它的記憶體裡;它走了就清,續接原代號的 resume 也算。追不到行程代號時退回結束記號,要求代號換了而且舊的那個工作階段寫出過 `.end`。舊版寫的閘門沒有這兩個欄位,一律清,維持改版前的行為。核對命令名不只看行程還在:行程代號會被回收,回收後那個號碼照樣「活著」。 + +判準原本寫在 `session-timer.sh`,用的是「起始檔不存在=行程是新起的」。實測打掉了那個等式:還沒重啟的工作階段生出來的子行程(`claude -p`、外掛子命令、子代理)拿到的是一個沒見過的代號,一觸發就把閘門清掉;反過來,續接原代號的 resume 行程確實換過了卻連問都不會問。那個條件兩頭都會答錯,所以判定整段搬到 `restart-gate.sh`,`session-timer.sh` 只負責問。 欄位只用在擋人訊息上。判定看的是「當前 CLI 那份檔案在不在」——檔案存在就是這一支還沒重啟過的證據,欄位缺了只讓訊息少幾個字。別支 CLI 那幾份一律不看。狀態檔讀不到、CLI 代號取不到、技能名取不到一律放行,理由與 `version-guard.sh` 相同。 @@ -133,7 +137,7 @@ Claude 由 `hooks/hooks.json` 自動接線十支 hook;其他 CLI 用 `hooks-in 有幾行就代表有幾支 CLI 還沒重啟;一份都沒有就不印。欄位缺值時只留鍵名(例如 `domains=`)。第一欄印 `legacy` 的那一行代表下面說的舊格式單一檔案,它不屬於任何一支 CLI。 -**舊檔相容(過渡用)。** 舊版把狀態寫進 `$JSC_HOME/restart-required` 單一檔案。改用狀態目錄的第一輪部署,機器上可能還留著那份舊檔,所以判定與清除都認它:舊檔存在就一律擋,視為「每一支 CLI 都有未重啟的部署」,擋人訊息會標明這是舊格式紀錄;`clear` 除了刪當前 CLI 那一份,也一併刪掉舊檔。取捨講白:`clear` 只在新工作階段被呼叫,呼叫到就代表確實有一支 CLI 重新啟動過了;舊檔沒有 per-CLI 資訊,留著會讓五支 CLI 一路被擋到有人手動刪,刪掉是唯一收斂的做法,代價是同一輪部署的其他 CLI 少擋一次,只影響改用狀態目錄的那一輪。這一段相容邏輯在所有機器都跑過一次寫狀態目錄的部署與重啟之後就可以整段移除,屆時舊檔不會再被寫出來。 +**舊檔相容(過渡用)。** 舊版把狀態寫進 `$JSC_HOME/restart-required` 單一檔案。改用狀態目錄的第一輪部署,機器上可能還留著那份舊檔,所以判定與清除都認它:舊檔存在就一律擋,視為「每一支 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 21f1277..b8a83e6 100755 --- a/hooks/restart-gate.sh +++ b/hooks/restart-gate.sh @@ -34,9 +34,11 @@ # install 或 update,之後接這次更新的 domain 清單。 # exit 0 = 已掛上;exit 2 = 取不到 CLI 代號或寫不進去 # (兩種都等於沒掛上)。 -# restart-gate.sh clear 只清除當前 CLI 那份狀態檔,放下這一支的閘門。由 -# session-timer.sh 在判定為新工作階段時呼叫(見下方 -# 「清除時機」)。檔案不存在也算成功。 +# restart-gate.sh clear [{工作階段代號}] +# 只清除當前 CLI 那份狀態檔,放下這一支的閘門。由 +# session-timer.sh 在判定為新工作階段時呼叫,並把它 +# 手上那個工作階段代號一起帶進來。清除有條件,判準見 +# 下方「清除時機」。檔案不存在、條件不成立都算成功。 # restart-gate.sh report 印出每一份狀態檔的內容,一支 CLI 一行(格式見下方 # 「report 輸出格式」);一份都沒有就不印,一律 exit 0。 # @@ -54,8 +56,12 @@ # mode={install|update} 這次部署的模式 # domains={domain 清單} 這次更新到的 domain,空白分隔 # cli={CLI 代號} 執行部署的 CLI,與檔名相同 -# 欄位只用在擋人訊息上。判定看的是「當前 CLI 那份檔案在不在」——檔案存在就是這一支還沒重啟 -# 過的證據,欄位缺了只讓訊息少幾個字,不影響判定。 +# session={代號} 掛上閘門那一刻的工作階段代號 +# pid={行程代號} 掛上閘門那一刻那一支 CLI 的行程代號;追不到時為空 +# 前四個欄位只用在擋人訊息上。判定看的是「當前 CLI 那份檔案在不在」——檔案存在就是這一支還沒 +# 重啟過的證據,欄位缺了只讓訊息少幾個字,不影響判定。 +# 後兩個欄位只給清除那一邊用(見下方「清除時機」),不進 report 的輸出:那一行的 domains +# 擺在最後而且可能含空白,後面再接欄位會讓現有的讀法把新欄位讀成 domain 名。 # # 為什麼一支 CLI 一份:一台機器上五支 CLI 各自是獨立行程,各自載入自己記憶體裡的那一版。 # 早先的單一檔案設計有兩個實測抓到的洞——並行部署互相覆寫(後寫的把 domains 與 cli 蓋掉, @@ -85,11 +91,28 @@ # # --- 清除時機 --- # -# 清除由 session-timer.sh 在「這一次 SessionStart 是新的工作階段」那一刻呼叫,不由本檔自己判定: -# 新舊工作階段的判準(sessions/{sid}.start 在不在)只有那支腳本知道,兩邊各寫一份就會漂移。 -# 新的工作階段代表 CLI 行程是新起的,新版一定已經載入,所以清除是對的。續接同一階段 -# (SessionStart 再觸發、resume、compact)不會走到那一段,閘門就一路留到真的重新啟動。 -# 清除的範圍就是呼叫端那一支 CLI:那一支重啟了,不代表別支也重啟了。 +# 清除由 session-timer.sh 在「這一次 SessionStart 是新的工作階段」那一刻呼叫。清除的範圍就是 +# 呼叫端那一支 CLI:那一支重啟了,不代表別支也重啟了。 +# +# 「新的工作階段」不等於「行程是新起的」。這句話原本被當成等式,實測打掉了它:一個還沒重啟的 +# 工作階段自己生出來的子行程(`claude -p`、外掛子命令、子代理),拿到的是一個沒見過的工作階段 +# 代號,於是替人把閘門放下了,而人一次都沒重啟。實測的路徑是掛上閘門之後餵一個新代號進 +# session-timer.sh start,閘門當場消失。那一天這台機器上真的發生過:部署掛上的閘門兩分鐘後 +# 被三個子行程之一清掉,收尾那句「請重新啟動」於是只剩人自己記得。 +# +# 所以判準改成問行程本身: +# 記到行程代號時,只認它。那個行程還活著就不清——不管工作階段代號換沒換,舊程式碼都還在 +# 它的記憶體裡。它走了就清,續接原代號的 resume 也算,因為行程確實換過了。 +# 追不到行程代號時(CLI 的命令名對不上代號,例如包在執行器底下的那幾支)退回結束記號: +# 要求代號換了、而且舊的那個工作階段寫出過 .end。這一路擋得住子行程,代價是被強制砍掉的 +# 行程不會留下 .end,那一份閘門要等下一次部署覆寫。 +# 舊版寫的閘門沒有這兩個欄位,一律清,維持改版前的行為:認不出來就不要把人鎖在門外。 +# +# 為什麼核對命令名不只看 kill -0:行程代號會被回收,回收後那個號碼照樣「活著」。不核對的話, +# 剛好撞上回收就會把「還沒重啟」讀成「已經重啟」。 +# +# 排程那條路碰巧沒踩到這個洞,因為 cron 條目帶著 JSC_CLI=cron,清的是 cron 自己那一份。 +# 那是巧合擋下來的,不是判準擋下來的——把判準修對,才不必靠某個環境變數剛好設對。 # # --- 判定原則 --- # @@ -144,6 +167,62 @@ state_field() { # $1=狀態檔 $2=鍵名 sed -n "s/^$2=//p" "$1" 2>/dev/null | head -n1 } +# 一個行程的父行程代號。/proc 讀得到就走 /proc,否則退回 ps。兩邊都問不到就不輸出。 +# +# 為什麼讀 status 而不讀 stat:stat 的第二欄是命令名,命令名帶空白或括號時欄位會錯位, +# 於是「第四欄是 ppid」這句話在那些行程上不成立。status 一行一鍵,沒有這個問題。 +proc_ppid() { # $1=行程代號 + if [ -r "/proc/$1/status" ]; then + sed -n 's/^PPid:[[:space:]]*//p' "/proc/$1/status" 2>/dev/null | head -n1 + else + ps -o ppid= -p "$1" 2>/dev/null | tr -d ' \t' + fi +} + +# 一個行程的命令名,不含路徑。問不到就不輸出。 +proc_name() { # $1=行程代號 + if [ -r "/proc/$1/comm" ]; then + head -n1 "/proc/$1/comm" 2>/dev/null + else + ps -o comm= -p "$1" 2>/dev/null | sed 's|.*/||; s/[[:space:]]*$//' + fi +} + +# 往上找到那一支 CLI 自己的行程代號;找不到就不輸出。 +# +# 為什麼要找它:閘門要問的是「載入舊程式碼那個行程還在不在」,而 require 是被那個行程底下 +# 好幾層的殼叫起來的,`$$` 是殼自己、殼一結束就死,拿它當存活訊號等於永遠回「已經走了」。 +# +# 判準是命令名等於 CLI 代號。實測這台機器上 claude 的行程命令名就是 `claude`。其他 CLI 的 +# 命令名沒有實測過(可能是 `node` 之類的執行器),對不上就回空值,由呼叫端退回別的訊號—— +# 猜一個對應表填進來只會多一個錯誤來源。 +# 上追層數設 24 層:一輪部署經過的殼層數遠少於這個數,而追到 pid 1 或問不到父代號就會先停。 +cli_pid() { # $1=CLI 代號 + _cp="$$" + _cpn=1 + while [ "$_cpn" -le 24 ]; do + [ -n "$_cp" ] && [ "$_cp" != 0 ] && [ "$_cp" != 1 ] || return 0 + if [ "$(proc_name "$_cp")" = "$1" ]; then printf '%s' "$_cp"; return 0; fi + _cp=$(proc_ppid "$_cp") + _cpn=$((_cpn + 1)) + done + return 0 +} + +# 那個行程還活著嗎。活著回 0,走了或問不到回 1。 +# +# 除了存活還核對命令名:行程代號會被回收,回收後那個號碼照樣「活著」,只是換成別的行程。 +# 不核對的話,剛好撞上回收就會把「還沒重啟」讀成「已經重啟」,而那正是這道閘門要防的事。 +# 命令名問不到時當成不在:問不到就沒有證據說它還在。 +pid_alive() { # $1=行程代號 $2=命令名 + [ -n "${1:-}" ] || return 1 + case "$1" in ''|*[!0-9]*) return 1 ;; esac + kill -0 "$1" 2>/dev/null || return 1 + [ -n "${2:-}" ] || return 0 + [ "$(proc_name "$1")" = "$2" ] || return 1 + return 0 +} + # 一份狀態檔印一行,格式見檔頭「report 輸出格式」。$1=第一欄要印的名稱 $2=狀態檔 state_line() { printf '%s at=%s mode=%s domains=%s\n' "$1" \ @@ -163,8 +242,11 @@ case "${1:-}" in 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" > "$STATE_DIR/$_cli" 2>/dev/null || { + # session 與 pid 記的是「掛上閘門的那一刻,載入舊程式碼的是誰」。清除那一邊靠它們判斷 + # 那個行程走了沒有,理由見檔頭「清除時機」。 + printf 'at=%s\nmode=%s\ndomains=%s\ncli=%s\nsession=%s\npid=%s\n' \ + "$(now_iso)" "$_mode" "$_domains" "$_cli" "$(session_id)" "$(cli_pid "$_cli")" \ + > "$STATE_DIR/$_cli" 2>/dev/null || { # 寫不進去要講出來:沒寫成就沒有閘門,部署卻以為掛上了。 printf '[jsc][重啟閘門][ERR]:寫不進 %s,這次部署沒有掛上重啟閘門。\n' "$STATE_DIR/$_cli" >&2 exit 2 @@ -172,8 +254,24 @@ case "${1:-}" in exit 0 ;; clear) _cli=$(cli_code) - # 只刪自己那一份。別支 CLI 沒有跟著重啟,它們的閘門要留著。 - [ -n "$_cli" ] && rm -f "$STATE_DIR/$_cli" 2>/dev/null + if [ -n "$_cli" ] && [ -f "$STATE_DIR/$_cli" ]; then + # 判準與取捨見檔頭「清除時機」。只刪自己那一份:別支 CLI 沒有跟著重啟,它們的閘門要留著。 + _gpid=$(state_field "$STATE_DIR/$_cli" pid) + _gsess=$(state_field "$STATE_DIR/$_cli" session) + _cur="${2:-$(session_id)}" + if [ -n "$_gpid" ]; then + # 有記到行程代號,就用它當唯一判準:那個行程還活著,代表舊程式碼還在記憶體裡。 + pid_alive "$_gpid" "$_cli" || rm -f "$STATE_DIR/$_cli" 2>/dev/null + elif [ -z "$_gsess" ]; then + # 舊版寫的閘門沒有這兩個欄位。一律清,維持改版前的行為:認不出來就不要把人鎖在門外。 + rm -f "$STATE_DIR/$_cli" 2>/dev/null + elif [ "$_cur" != "$_gsess" ] && [ -f "$JSC_HOME/sessions/$_gsess.end" ]; then + # 追不到行程代號時退回結束記號。這一路要求「代號換了」而且「舊的那個工作階段寫出過 + # 結束記號」兩件事同時成立:少了前一個,同一個工作階段每次觸發都會把自己的閘門清掉; + # 少了後一個,子行程照樣清得掉,那正是這道判定要防的事。 + rm -f "$STATE_DIR/$_cli" 2>/dev/null + fi + fi # 舊檔一併刪,取捨與可移除時機見檔頭「舊檔相容」。 rm -f "$LEGACY_STATE" 2>/dev/null || true exit 0 ;; diff --git a/hooks/session-timer.sh b/hooks/session-timer.sh index cf3aa98..8e7e04b 100755 --- a/hooks/session-timer.sh +++ b/hooks/session-timer.sh @@ -21,10 +21,10 @@ # restart 另外清掉提醒記號($JSC_HOME/sessions/{代號}.reminded):那個記號讓提醒一個工作 # 階段只提一次,而共用 default 代號的 CLI 不清就等於只提第一次、往後永遠不提。 # -# 這兩個子命令另外兼一件事:判定為「新的工作階段」時清除部署後的重啟閘門 -# (restart-gate.sh clear)。新工作階段代表 CLI 行程是新起的,新版技能組一定已經載入。 -# 判準只有這裡知道——start 分支的「起始檔不存在」就是這個 session id 第一次開始, -# 所以清除掛在這裡,不在 restart-gate.sh 裡自己再判一次。 +# 這兩個子命令另外兼一件事:工作階段開始時問一次要不要放下部署後的重啟閘門 +# (restart-gate.sh clear)。**只是問,判定不在這裡。** 這裡曾經自己判過,用的是「起始檔 +# 不存在=行程是新起的」,而那個等式不成立:還沒重啟的工作階段生出來的子行程拿到的也是沒 +# 見過的代號。判準改成看行程還活著沒有,寫在 restart-gate.sh 的「清除時機」。 # 清除的範圍是「跑到這一支腳本的那個 CLI 自己那一份狀態檔」,由 restart-gate.sh clear 認定, # 這裡不必也不能過問:這個工作階段開始的只有一支 CLI,別支沒重啟,閘門要留著。 HERE=$(dirname "$0"); . "$HERE/lib.sh" @@ -34,18 +34,22 @@ sid=$(session_id) # 放下這一支 CLI 的部署後重啟閘門。狀態檔的路徑、範圍與格式只留在 restart-gate.sh, # 這裡不碰檔案,所以改成一支 CLI 一份狀態檔之後這裡不用跟著改。 +# 工作階段代號要帶進去:那一邊沒有標準輸入可讀,自己算會退回環境變數,跟這裡算出來的可能 +# 不是同一個值。 # 一律 /dev/null || true + sh "$HERE/restart-gate.sh" clear "$sid" /dev/null || true } case "${1:-mark}" in start) f="$JSC_HOME/sessions/$sid.start" - if [ ! -f "$f" ]; then - now_epoch > "$f" - clear_restart_gate # 起始檔不存在=這個工作階段第一次開始,也就是行程新起的那一次 - fi ;; + # 起始檔只補不覆寫:它是這個工作階段的計時起點,重寫會把已經累積的時間歸零。 + [ -f "$f" ] || now_epoch > "$f" + # 閘門一律問,不再由「起始檔在不在」決定要不要問。理由見 restart-gate.sh 的「清除時機」: + # 那個條件兩頭都會答錯——續接原代號的 resume 行程確實換過卻不會問,而還沒重啟的工作階段 + # 生出來的子行程拿到沒見過的代號、一問就把閘門清掉。判定改由那一邊看行程存活決定。 + clear_restart_gate ;; restart) now_epoch > "$JSC_HOME/sessions/$sid.start" rm -f "$JSC_HOME/sessions/$sid.end" @@ -54,7 +58,7 @@ case "${1:-mark}" in # 都會被當成「已經提過」,那支 CLI 從此再也收不到任何提醒。 # 清除掛在這裡不掛在提醒那一支:「這是不是新的工作階段」的判準只有這一支知道。 rm -f "$JSC_HOME/sessions/$sid.reminded" - clear_restart_gate ;; # 接不到 session id 的 CLI 每次工作階段開始都算新的,一律清 + clear_restart_gate ;; # 走的是同一道判定:清不清由行程存活決定,不由這裡斷言 mark) now_epoch > "$JSC_HOME/sessions/$sid.end" ;; report) diff --git a/plugin.json b/plugin.json index a555db0..6f0d2e2 100644 --- a/plugin.json +++ b/plugin.json @@ -1,6 +1,6 @@ { "name": "jsc-hooks", - "version": "0.5.0", + "version": "0.5.1", "description": "跨 CLI hooks:STE100 語言強制、工時計時、技能用量記錄、SDLC 模型鎖、版本前置檢查、註解範圍守門、繁中編碼守門、部署後強制重啟、寫入與提交閘門", "skills": "./skills/", "jsc": { diff --git a/skills/hooks-install/SKILL.md b/skills/hooks-install/SKILL.md index d81433a..2b7501e 100644 --- a/skills/hooks-install/SKILL.md +++ b/skills/hooks-install/SKILL.md @@ -108,7 +108,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_TOOL_COMMAND`, `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`. - `hooks/skill-name.sh` is the one place that turns a CLI's hook payload into `{domain}{skill}`, one subcommand per CLI: claude reads the `skill` field, codex reads the `SKILL.md` path inside `tool_input.command` (it has no Skill tool — the model loads a skill by reading the file with Bash), copilot reads `toolArgs` and has to unwrap one layer of stringified JSON, antigravity reads `toolCall.args.AbsolutePath` and also the prompt text (a slash command injects the whole `SKILL.md` and produces no tool call), kiro reads the leading slash command in `prompt`. All five honour `JSC_SKILL` and `SKILL` first. It always exits 0: the gates fail open, and copilot's command hooks are fail-closed, where any non-zero exit means deny. `hooks/deny.sh` is the matching single source for the blocking shape — stderr plus exit 2 for claude, codex and copilot; a single-line `{"decision":"deny","reason":"..."}` on stdout with a fixed exit 0 for antigravity, whose exit-code semantics are undocumented and must never be relied on; a printed warning and exit 0 for kiro, which cannot block. Neither guard keeps a second copy of either rule; a repair goes into these two files. -- `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. The gate matches skill names, not call chains, so a nested call to anything off the exemption list is blocked all the same; `hooks/restart-gate.sh` owns that list with a reason per entry, and `jsc-meta/references/guidelines.md`「部署後重啟閘門」carries the same list. 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. **What counts as a restart is whether the process that installed the gate is gone**, not whether a session id looks new — a session id nobody has seen before is also what a child process of the un-restarted session gets, and that child used to clear the gate on the person's behalf. `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. The gate matches skill names, not call chains, so a nested call to anything off the exemption list is blocked all the same; `hooks/restart-gate.sh` owns that list with a reason per entry, and `jsc-meta/references/guidelines.md`「部署後重啟閘門」carries the same list. Escape hatch: `JSC_RESTART_GATE=off`. - `write-guard.sh` takes three blocking modes, wired on two PreToolUse matchers on claude only, so claude is still the only CLI where any of it takes effect — codex, copilot and antigravity now have a usable pre-tool hook, but these three modes are not wired there yet; say that, rather than blaming a missing hook, plus a fourth mode, `release`, that is wired nowhere and is called by a skill itself. `stage` reads the stage lock that `sdlc-gate.sh` already owns and blocks `Write`, `Edit` and `MultiEdit` while `plan` or `analyze` holds it, because those two stages produce wiki pages rather than files. `review` reads the current skill — the environment variable first, then the record `skill-usage.sh` keeps — and blocks writes while `jsc-review:code-review` or `jsc-review:api-doc` runs, since both only report findings. It deliberately does **not** block `jsc-review:comment-cleanup`: that skill has to write, limited to comment lines, and deciding that limit needs per-language comment parsing of the whole proposed content, which would block legitimate cleanups more often than it caught bad ones — that boundary stays with the skill text and the later review. `commit` is wired on `Bash` and blocks a single command that stages everything and commits in one go, plus any commit message carrying simplified characters or mojibake, which it decides by calling `lang-guard.sh` rather than keeping a second word list. A `git add -A` split across two separate tool calls is not caught, on purpose: catching it needs cross-call state that the blocked operator has no way to clear. `release` deletes that recorded skill and always exits 0; `jsc-review:code-review` and `jsc-review:api-doc` call it once each as they hand their findings back. It exists because the record says which skill was loaded last, not which one is still running: both audit skills end by leaving the fixing to their caller, and without `release` every write that caller makes stays blocked for the whole TTL, with the escape hatch or a wait as the only way out — a gate must never lock away its own release. Escape hatch: `JSC_WRITE_GUARD=off`, which `release` ignores because clearing a record blocks nobody, plus `JSC_WRITE_GUARD_TTL` for how long a recorded skill counts as still running. - `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. diff --git a/tools/wire-cli.sh b/tools/wire-cli.sh index adb0305..dc7ecea 100755 --- a/tools/wire-cli.sh +++ b/tools/wire-cli.sh @@ -1416,7 +1416,7 @@ if [ "$action" = smoke ]; then SMOKE_EXPECT_HOOK=18 SMOKE_EXPECT_MODEL=12 SMOKE_EXPECT_WP=6 - SMOKE_EXPECT_RS=16 + SMOKE_EXPECT_RS=21 SMOKE_EXPECT_WG=21 SMOKE_EXPECT_VG=13 SMOKE_EXPECT_SN=10 @@ -1720,6 +1720,19 @@ if [ "$action" = smoke ]; then "$(printf '%s' "$_out" | tr '\n' ' ' | cut -c1-200)" >> "$smoke_out" fi } + # 狀態檔裡有沒有記到那個欄位也要比。清除的判準整個掛在 session 與 pid 兩個欄位上:require + # 少寫了它們,清除那一邊會退回「認不出來就清」的相容路徑,也就是改版前那個會被子行程清掉的 + # 行為——而每一條行為斷言照樣全綠,因為那條相容路徑本來就該清。 + smoke_rs_key() { # $1=情境 $2=狀態檔 $3=鍵名 + smoke_n_rs=$((smoke_n_rs + 1)) + if [ -n "$(sed -n "s/^$3=//p" "$2" 2>/dev/null | head -n1)" ]; 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 沒有值,清除判定會退回相容路徑而被子行程清掉:%s\n' \ + "$1" "$3" "$2" >> "$smoke_out" + fi + } # 狀態檔在不在也要比:一支 CLI 一份的重點就在「該留的留、該刪的刪」,只看結束碼看不出來。 smoke_rs_file() { # $1=情境 $2=狀態檔 $3=exist 或 absent smoke_n_rs=$((smoke_n_rs + 1)) @@ -1742,6 +1755,7 @@ if [ "$action" = smoke ]; then 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_key "require 記下掛上閘門時的工作階段" "$rs_dir/$cli" session smoke_rs_case "當前 CLI 那份存在,技能 jsc-sdlc:implement" jsc-sdlc:implement deny smoke_rs_case "當前 CLI 那份存在,豁免技能 jsc-cli:deploy" jsc-cli:deploy 0 smoke_rs_case "當前 CLI 那份存在,豁免技能 jsc-gitea:wiki" jsc-gitea:wiki 0 @@ -1749,13 +1763,39 @@ if [ "$action" = smoke ]; then 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 底下沒有起始檔,等同行程新起的第一次)。 + # 清除機制:session-timer.sh 在工作階段開始時呼叫 restart-gate.sh clear,清不清由那一邊 + # 看行程存活決定。三條路徑各驗一次,理由見 hooks/restart-gate.sh 的「清除時機」。 + # + # 一、還沒重啟的工作階段生出來的子行程:工作階段代號沒見過,但 require 記到的那個行程還 + # 活著(就是現在跑冒煙的這一個)。閘門必須留著。這一條是實測抓到的洞:改版前它會被清掉, + # 於是部署收尾那句「請重新啟動」沒人再說得出口,而人一次都沒重啟。 + JSC_HOME="$rs_home" JSC_CLI="$cli" JSC_SESSION_ID=smoke-child \ + sh "$HOOKS/session-timer.sh" start /dev/null + smoke_rs_file "還沒重啟,子行程清不掉自己那一份" "$rs_dir/$cli" exist + smoke_rs_case "子行程清不掉,照樣擋下" jsc-sdlc:implement deny + # 二、記到的行程真的走了。拿一個剛結束並回收過的行程代號來寫,比寫死一個「應該不存在」的 + # 號碼可靠:那種號碼哪天被別的行程佔走,這條斷言就會反過來變成偽陽性。 + ( exit 0 ) & rs_dead=$! + wait "$rs_dead" 2>/dev/null || true + printf 'at=%s\nmode=update\ndomains=hooks cli\ncli=%s\nsession=smoke-gone\npid=%s\n' \ + "$(now_iso)" "$cli" "$rs_dead" > "$rs_dir/$cli" 2>/dev/null 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 "記到的行程走了就清掉自己那一份" "$rs_dir/$cli" absent smoke_rs_file "清除不動別支 CLI 那一份" "$rs_dir/$rs_other" exist smoke_rs_case "清除後放行" jsc-sdlc:implement 0 + # 三、追不到行程代號時退回結束記號(CLI 的命令名對不上代號的那幾支走這條)。要求「代號換了」 + # 而且「舊的那個工作階段寫出過結束記號」兩件事同時成立,所以先驗少了結束記號會留著。 + mkdir -p "$rs_home/sessions" 2>/dev/null || true + printf 'at=%s\nmode=update\ndomains=hooks cli\ncli=%s\nsession=smoke-noend\npid=\n' \ + "$(now_iso)" "$cli" > "$rs_dir/$cli" 2>/dev/null + JSC_HOME="$rs_home" JSC_CLI="$cli" JSC_SESSION_ID=smoke-noend-child \ + sh "$HOOKS/session-timer.sh" start /dev/null + smoke_rs_file "追不到行程代號又沒有結束記號,留著" "$rs_dir/$cli" exist + now_epoch > "$rs_home/sessions/smoke-noend.end" 2>/dev/null + JSC_HOME="$rs_home" JSC_CLI="$cli" JSC_SESSION_ID=smoke-noend-next \ + sh "$HOOKS/session-timer.sh" start /dev/null + smoke_rs_file "追不到行程代號但有結束記號,清掉" "$rs_dir/$cli" absent # 舊格式的單一狀態檔(過渡相容):沒有 per-CLI 資訊,所以一律擋,clear 一併刪掉。 printf 'at=%s\nmode=update\ndomains=hooks\ncli=%s\n' "$(now_iso)" "$rs_other" \ > "$rs_home/restart-required" 2>/dev/null