Author SHA1 Message Date
jiantw83 8150ca8722 fix(restart-gate): 閘門改問行程還在不在,不問代號見過沒有
判準原本是「這個工作階段代號我沒見過=行程是新起的」。那個等式不
成立:還沒重啟的工作階段自己生出來的子行程,拿到的也是沒見過的代
號,於是替人把閘門放下了,而人一次都沒重啟。

這台機器上真的發生過。部署掛上的閘門兩分鐘後就不見了,那段時間有
三個子工作階段冒出來,收尾那句「請重新啟動」於是只剩人自己記得。
排程那條路碰巧沒踩到,因為 cron 條目帶著 JSC_CLI=cron,清的是別
一份——巧合擋下來的,不是判準擋下來的。

反方向也會答錯:續接原代號的 resume,行程確實換過了,舊寫法卻連
問都不會問。

所以 require 一併記下掛上閘門時的工作階段代號與那一支 CLI 的行程
代號,清除只認行程存活:還活著就不清,走了就清。追不到行程代號時
退回結束記號,要求代號換了而且舊階段寫出過 .end。核對命令名不只
看行程還在,因為行程代號會被回收。

判定整段搬到 restart-gate.sh,session-timer.sh 只負責問。冒煙那
一組原本把舊語意寫成斷言,改成逐條驗三條清除路徑,並加一條驗
require 真的把欄位寫下來——少了那個欄位會無聲退回相容路徑,而每
一條行為斷言照樣全綠。
2026-09-07 14:25:08 +08:00
admin e0de77230f Merge pull request '內建項收成一行,不逐筆吐' (#93) from fix/collapse-builtin-reminders into develop
Reviewed-on: #93
2026-09-07 05:42:26 +00:00
jiantw83andClaude Opus 5 1f9f329cd3 fix(session-reminder): 內建項收成一行,不逐筆吐
實測踩到:這台機器的佇列八筆全是委派清單種入的內建項,於是每一個工作
階段開頭固定吐八行一模一樣的東西。那不是提醒,是噪音——而這一支自己的
註解裡就寫著「對著一個刻意的決定每個工作階段催一次,那是噪音不是提醒」。

逾期與使用者自己登錄的提醒照舊逐筆點名:人看到就做得了。內建項改成一行
總數,並在那一行講明它會一直出現,直到那幾筆接上入口或被改掉——講明它是
常態,讀的人才不會每次都當成新消息。

分種類的判定在寫佇列那一邊,那裡讀得到 spec_key;這裡只照它標好的種類
決定怎麼印,維持「只印不判」那條界線。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 13:39:57 +08:00
admin 7a569f4e46 Merge pull request '逐支點名改成從接線那一邊抽名單,五支 CLI 都驗得到' (#91) from fix/per-hook-inventory-every-cli into develop
Reviewed-on: #91
2026-09-07 04:43:49 +00:00
jiantw83andClaude Opus 5 f83e1c542f fix(wire-cli): 逐支點名改成從接線那一邊抽名單,五支 CLI 都驗得到
第十支 hook 上線之後實測發現:claude 與 kiro 的盤點點名得出它,codex、
copilot、antigravity 三段的盤點卻各自只驗自己寫死的那兩支。那三支的接線
盤點看不出第十支在不在,而 smoke 那一行還自稱「十支 hook」——兩句話都是
真的,範圍不同,讀的人分不出來。

根因是「接線寫了哪幾支」與「盤點驗了哪幾支」本來是兩份手寫清單。加一支
hook 要記得回來改第二個地方,而漏改的那一天不會有任何東西叫。

改成從接線那一邊實際會寫出去的內容抽名字,兩邊只留一份清單:

- codex 讀的是從 hooks/hooks.json 推導出來的複本,所以名單取自來源那一份,
  十支全驗;推導漏掉一支時,盤點說得出漏了哪一支。
- copilot 與 antigravity 只接得上兩道閘門,名單取自各自的條目產生函式。
- copilot 原本一支 hook 都沒點名,只驗「hooks 鍵在」——鍵在而某一支條目掉了
  照樣回 present,那正是這一整支腳本一直在防的形態。

codex 接線後的驗證也一起改:原本只確認那兩道閘門有接上,現在逐支對照來源。
推導是整份複製再改 matcher,漏掉任何一支都算推導壞了。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 12:39:07 +08:00
admin 6948593316 Merge pull request '工作階段開始時把助理的未讀提醒帶到前景' (#89) from feat/session-start-reminders into develop
Reviewed-on: #89
2026-09-07 04:14:13 +00:00
jiantw83andClaude Opus 5 cd4cd8b936 feat(hooks): 工作階段開始時把助理的未讀提醒帶到前景
助理算得出哪幾筆到期、哪幾筆逾期,但那些結果只寫在監控頁上——人要自己
去翻,或自己跑一次狀態查詢。這一支把它帶到前景。

session-reminder.sh 只讀助理那一輪寫好的提醒佇列,一個判定都不做。
自己拿 due 欄與 next_run 去跟現在比就是第二套到期判定,跟助理那一套遲早
對不上,而對不上的那一天兩邊都說自己是對的。它也要快:這一支跑在每一個
工作階段的開頭。

只印佇列換來一個新的失效模式,正面處理:助理停了,佇列就不再更新,而一份
舊佇列讀起來跟新的一模一樣。所以佇列檔頭帶那一輪的時間戳與 epoch,這裡
算出它多舊;超過心跳門檻或心跳不新鮮,就明說這批提醒是多久以前算的、
助理現在的心跳是什麼狀態。門檻與狀態都取 heartbeat.sh 印的那一行。

佇列空又過期的那一種分兩路:待辦簿有東西才說話,零筆就安靜——人自己按停
也算零筆那一種,對著一個刻意的決定每個工作階段催一次是噪音不是提醒。
助理狀態目錄根本不存在時整支安靜退出,那台機器從沒啟動過助理。

一個工作階段只提一次,記號是 sessions/{代號}.reminded。接不到 session id
的 CLI 全部共用 default,所以 session-timer.sh 的 restart 分支順手清掉那個
記號——不清的話那支 CLI 從第二個工作階段起再也收不到提醒。清除掛在那裡
不掛在這裡:「這是不是新的工作階段」的判準只有那一支知道。

接線與檢核一起改,不留一支沒人驗的 hook:hooks.json 與推導出來的
codex-hooks.json 各加一條、kiro 的 agentSpawn 加一條、claude 的 status
逐支列舉加一項、冒煙測試加一條並把預期條數從 17 改成 18、執行期錯誤掃描
的 jsc 判定加一支腳本名,還有散在文件與回報字串裡的「九支 hook」十處
全部改成十支。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 11:14:41 +08:00
admin 248bc90fc2 Merge pull request '異常目錄頁改成 H2 區塊條列,寫入語意委派共用工具' (#79) from feat/contents-list/main into develop
Reviewed-on: #79
2026-09-07 01:22:56 +00:00
13 changed files with 430 additions and 61 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "jsc-hooks", "name": "jsc-hooks",
"version": "0.4.7", "version": "0.5.0",
"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.4.7", "version": "0.5.0",
"description": "跨 CLI hooks:STE100 語言強制、工時計時、技能用量記錄、SDLC 模型鎖、版本前置檢查、註解範圍守門、繁中編碼守門、部署後強制重啟、寫入與提交閘門", "description": "跨 CLI hooks:STE100 語言強制、工時計時、技能用量記錄、SDLC 模型鎖、版本前置檢查、註解範圍守門、繁中編碼守門、部署後強制重啟、寫入與提交閘門",
"skills": "./skills", "skills": "./skills",
"jsc": { "jsc": {
+12 -7
View File
@@ -23,7 +23,8 @@ Marketplace 統一為 `jsc`(https://gitea.jsc.idv.tw/plugins/meta.git),安
| 腳本 | 事件 | 作用 | | 腳本 | 事件 | 作用 |
| --- | --- | --- | | --- | --- | --- |
| `hooks/ste100-guard.sh` | UserPromptSubmit | 注入 STE100 繁體中文輸出規則(hook > prompt 強制層) | | `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}<TAB>{技能名}」,解析不出來就印空字串。取值來源: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/skill-name.sh` | 不直接接線,由 `version-guard.sh` 與 `restart-gate.sh` 呼叫 | 從各 CLI 的 hook 負載解析出這一次要用哪一支 jsc 技能,一支 CLI 一個子命令,印一行「{domain}<TAB>{技能名}」,解析不出來就印空字串。取值來源: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 這個保守預設 | | `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` 的檔頭,那裡一項一個理由 |
@@ -36,7 +37,7 @@ Marketplace 統一為 `jsc`(https://gitea.jsc.idv.tw/plugins/meta.git),安
| `hooks/sdlc-gate.sh` | UserPromptSubmit、PreToolUse(Skill) | SDLC 階段能力標籤閘門與模型鎖:`lock {stage}` 由 jsc-sdlc 階段技能呼叫,從可驗證來源讀出模型 id,比對該階段必要標籤(`$JSC_HOME/model-tags.tsv`),不符就拒絕上鎖。**來源依 CLI 分流**,一支 CLI 只讀自己的紀錄:claude 讀 transcript 與 hook stdin JSON,codex 讀 hook stdin JSON 與自己的 session 記錄,copilot、antigravity、kiro 本機沒有可讀的模型紀錄,判不出 CLI 時不採用任何自動來源;所有 CLI 最後都接受 `JSC_MODEL` 人工覆寫,且回報會標明人工覆寫。政策是 **fail-closed**:不知道能力就擋下,三種情形一律擋——判不出 CLI、判不出模型、模型不在能力標籤表上;每一則擋下的訊息都會印出兩條逃生門(設 `JSC_MODEL`,或執行 `sdlc-gate.sh unlock {狀態檔}`)。`check` 在上述任一情形以 exit 2 擋下該輪提示(其他 hook 一律 exit 0,此處是刻意例外);`report` 印出階段、必要標籤、模型 id、模型來源與判定結果;`unlock` 為逃生門,不帶參數清這個工作階段的新舊兩份,帶參數只清指定的那一支。階段鎖狀態檔是 `$JSC_HOME/sessions/{CLI 代號}-{sid}.stage`,舊路徑 `$JSC_HOME/sessions/{sid}.stage` 仍讀得到。另含工作包 PR 閘門:`wp-lock {owner}/{repo} {index} [{工作包代號}]` 記下一筆未結清的工作包 PR、`wp-unlock {owner}/{repo} {index}` 結清那一筆(檔案不存在也算成功)、`wp-claim {owner}/{repo} {工作包代號} [{PR 編號}] [{分析頁頁名}]` 記下這個存取庫目前領取哪一包、`wp-unclaim {owner}/{repo}` 交回、`wp-report` 印出所有未結清、`wp-check {prompt|skill}` 為 hook 模式。狀態檔一個工作包一支,在 `$JSC_HOME/wp/{owner}-{repo}-{index}.pr`,**刻意不綁 session**——PR 沒合併時換一個工作階段照樣要擋;一個工作包一支鎖檔是為了讓好幾個互不相依的工作包能同時記在案,不會互相覆蓋掉對方的鎖。`wp-check prompt` 只注入提醒、絕不擋提示(擋了連「去修那支 PR」的對話都送不出去);`wp-check skill` 在有未結清 PR 時以 exit 2 擋下 `analyze` 與 `maintain`,但一律放行 `implement`(結清 PR 正是 implement 的步驟,擋它會鎖死流程),也放行 `plan`,只注入提醒(plan 是純邏輯階段、不碰程式碼,而這道閘門只知道「有 PR 未合併」、判不出跟新計畫有沒有關聯;放棄的是在製品上限,`analyze` 與 `maintain` 兩道仍在,上限晚一個階段才生效)——這一層是整個存取庫共用的粗粒度提醒,「某個候選工作包能不能挑」的細粒度判斷在 `jsc-sdlc/tools/wp-gate.sh check-deps`,不是這裡。另外會比對歸屬:未結清的 PR 不屬於目前領取的工作包時,`prompt` 多注入一行「那幾支交給領取它的工作階段」,`skill` 在擋下 `analyze`、`maintain` 時一併點名,`plan` 與 `implement` 仍放行但收到同一則提醒。逃生門 `JSC_WP_GATE=off`。這道閘門只讀檔案、不打網路,PR 的真實合併狀態由 `jsc-sdlc/tools/wp-gate.sh` 查證 | | `hooks/sdlc-gate.sh` | UserPromptSubmit、PreToolUse(Skill) | SDLC 階段能力標籤閘門與模型鎖:`lock {stage}` 由 jsc-sdlc 階段技能呼叫,從可驗證來源讀出模型 id,比對該階段必要標籤(`$JSC_HOME/model-tags.tsv`),不符就拒絕上鎖。**來源依 CLI 分流**,一支 CLI 只讀自己的紀錄:claude 讀 transcript 與 hook stdin JSON,codex 讀 hook stdin JSON 與自己的 session 記錄,copilot、antigravity、kiro 本機沒有可讀的模型紀錄,判不出 CLI 時不採用任何自動來源;所有 CLI 最後都接受 `JSC_MODEL` 人工覆寫,且回報會標明人工覆寫。政策是 **fail-closed**:不知道能力就擋下,三種情形一律擋——判不出 CLI、判不出模型、模型不在能力標籤表上;每一則擋下的訊息都會印出兩條逃生門(設 `JSC_MODEL`,或執行 `sdlc-gate.sh unlock {狀態檔}`)。`check` 在上述任一情形以 exit 2 擋下該輪提示(其他 hook 一律 exit 0,此處是刻意例外);`report` 印出階段、必要標籤、模型 id、模型來源與判定結果;`unlock` 為逃生門,不帶參數清這個工作階段的新舊兩份,帶參數只清指定的那一支。階段鎖狀態檔是 `$JSC_HOME/sessions/{CLI 代號}-{sid}.stage`,舊路徑 `$JSC_HOME/sessions/{sid}.stage` 仍讀得到。另含工作包 PR 閘門:`wp-lock {owner}/{repo} {index} [{工作包代號}]` 記下一筆未結清的工作包 PR、`wp-unlock {owner}/{repo} {index}` 結清那一筆(檔案不存在也算成功)、`wp-claim {owner}/{repo} {工作包代號} [{PR 編號}] [{分析頁頁名}]` 記下這個存取庫目前領取哪一包、`wp-unclaim {owner}/{repo}` 交回、`wp-report` 印出所有未結清、`wp-check {prompt|skill}` 為 hook 模式。狀態檔一個工作包一支,在 `$JSC_HOME/wp/{owner}-{repo}-{index}.pr`,**刻意不綁 session**——PR 沒合併時換一個工作階段照樣要擋;一個工作包一支鎖檔是為了讓好幾個互不相依的工作包能同時記在案,不會互相覆蓋掉對方的鎖。`wp-check prompt` 只注入提醒、絕不擋提示(擋了連「去修那支 PR」的對話都送不出去);`wp-check skill` 在有未結清 PR 時以 exit 2 擋下 `analyze` 與 `maintain`,但一律放行 `implement`(結清 PR 正是 implement 的步驟,擋它會鎖死流程),也放行 `plan`,只注入提醒(plan 是純邏輯階段、不碰程式碼,而這道閘門只知道「有 PR 未合併」、判不出跟新計畫有沒有關聯;放棄的是在製品上限,`analyze` 與 `maintain` 兩道仍在,上限晚一個階段才生效)——這一層是整個存取庫共用的粗粒度提醒,「某個候選工作包能不能挑」的細粒度判斷在 `jsc-sdlc/tools/wp-gate.sh check-deps`,不是這裡。另外會比對歸屬:未結清的 PR 不屬於目前領取的工作包時,`prompt` 多注入一行「那幾支交給領取它的工作階段」,`skill` 在擋下 `analyze`、`maintain` 時一併點名,`plan` 與 `implement` 仍放行但收到同一則提醒。逃生門 `JSC_WP_GATE=off`。這道閘門只讀檔案、不打網路,PR 的真實合併狀態由 `jsc-sdlc/tools/wp-gate.sh` 查證 |
| `hooks/write-guard.sh` | PreToolUse(Write、Edit、MultiEdit)、PreToolUse(Bash) | 寫入與提交閘門,共三種擋人模式,目前只接在 claude 上;codex、copilot、antigravity 三支已經有可用的 pre-tool hook(見上面「各 CLI 的 pre-tool 接線位置」),只是這三種模式還沒接過去,kiro 則是本來就擋不下來。另有一個不接 hook 的 `release` 解除模式。`stage`:`sdlc-gate.sh` 的階段鎖鎖在 `plan` 或 `analyze` 時,以 exit 2 擋下 `Write`、`Edit`、`MultiEdit`——那兩個階段的產出是計畫頁與分析頁,不是檔案。階段鎖狀態檔沿用 `sdlc-gate.sh` 那一份,這裡只讀不寫。`review`:目前技能是 `jsc-review:code-review` 或 `jsc-review:api-doc` 時擋下寫入,那兩支只回報發現、不改程式碼。技能名先讀環境變數,取不到才讀 `skill-usage.sh` 記下的那一份;沒有「技能結束」事件可讀,所以紀錄超過 `JSC_WRITE_GUARD_TTL` 秒就當那支技能早已跑完。`jsc-review:comment-cleanup` **刻意不擋**:它本來就要改檔,只是限定僅註解行,而精確判定要解析工具參數裡整份新內容再逐語言判斷哪幾行是註解,判錯會擋掉合法的清理,代價比漏擋大,所以那條界線留給技能內文與後續審查。`commit`:擋下「同一道指令把全部變更一次加進索引再提交」,也擋下含簡體字、亂碼或非 UTF-8 編碼的提交訊息(判定整段轉呼叫 `lang-guard.sh`,字表仍是 `hooks/simplified.txt`,這裡不留第二份樣式)。跨兩次工具呼叫的 `git add -A` 不擋:那要記跨呼叫狀態,而被擋下的人沒有辦法讓那個狀態自己消失,閘門會把解除自己的路徑一起鎖掉。`release`:刪掉 `review` 模式認人用的那份紀錄,一律 exit 0,由 `jsc-review:code-review` 與 `jsc-review:api-doc` 在收尾時各呼叫一次。有這個模式是因為那份紀錄記的是「最近一次載入的技能」不是「還在跑的技能」——稽核收尾後呼叫端本來就要動手改,那時紀錄仍寫著稽核技能,TTL 內每一次寫入都被擋,解除路徑只剩逃生門或空等;閘門不得把解除自己的路徑一起鎖掉。逃生門 `JSC_WRITE_GUARD=off`(`release` 不受它影響,清紀錄擋不到任何人) | | `hooks/write-guard.sh` | PreToolUse(Write、Edit、MultiEdit)、PreToolUse(Bash) | 寫入與提交閘門,共三種擋人模式,目前只接在 claude 上;codex、copilot、antigravity 三支已經有可用的 pre-tool hook(見上面「各 CLI 的 pre-tool 接線位置」),只是這三種模式還沒接過去,kiro 則是本來就擋不下來。另有一個不接 hook 的 `release` 解除模式。`stage`:`sdlc-gate.sh` 的階段鎖鎖在 `plan` 或 `analyze` 時,以 exit 2 擋下 `Write`、`Edit`、`MultiEdit`——那兩個階段的產出是計畫頁與分析頁,不是檔案。階段鎖狀態檔沿用 `sdlc-gate.sh` 那一份,這裡只讀不寫。`review`:目前技能是 `jsc-review:code-review` 或 `jsc-review:api-doc` 時擋下寫入,那兩支只回報發現、不改程式碼。技能名先讀環境變數,取不到才讀 `skill-usage.sh` 記下的那一份;沒有「技能結束」事件可讀,所以紀錄超過 `JSC_WRITE_GUARD_TTL` 秒就當那支技能早已跑完。`jsc-review:comment-cleanup` **刻意不擋**:它本來就要改檔,只是限定僅註解行,而精確判定要解析工具參數裡整份新內容再逐語言判斷哪幾行是註解,判錯會擋掉合法的清理,代價比漏擋大,所以那條界線留給技能內文與後續審查。`commit`:擋下「同一道指令把全部變更一次加進索引再提交」,也擋下含簡體字、亂碼或非 UTF-8 編碼的提交訊息(判定整段轉呼叫 `lang-guard.sh`,字表仍是 `hooks/simplified.txt`,這裡不留第二份樣式)。跨兩次工具呼叫的 `git add -A` 不擋:那要記跨呼叫狀態,而被擋下的人沒有辦法讓那個狀態自己消失,閘門會把解除自己的路徑一起鎖掉。`release`:刪掉 `review` 模式認人用的那份紀錄,一律 exit 0,由 `jsc-review:code-review` 與 `jsc-review:api-doc` 在收尾時各呼叫一次。有這個模式是因為那份紀錄記的是「最近一次載入的技能」不是「還在跑的技能」——稽核收尾後呼叫端本來就要動手改,那時紀錄仍寫著稽核技能,TTL 內每一次寫入都被擋,解除路徑只剩逃生門或空等;閘門不得把解除自己的路徑一起鎖掉。逃生門 `JSC_WRITE_GUARD=off`(`release` 不受它影響,清紀錄擋不到任何人) |
Claude 由 `hooks/hooks.json` 自動接線九支 hook;其他 CLI 用 `hooks-install` 技能接線、改裝包裝啟動器,或降級為規則檔。寫進使用者設定的長期命令一律指向 `$JSC_HOME/current/jsc-hooks`,不指向帶版號的 plugin 快取目錄,也不指向開發存取庫。 Claude 由 `hooks/hooks.json` 自動接線十支 hook;其他 CLI 用 `hooks-install` 技能接線、改裝包裝啟動器,或降級為規則檔。寫進使用者設定的長期命令一律指向 `$JSC_HOME/current/jsc-hooks`,不指向帶版號的 plugin 快取目錄,也不指向開發存取庫。
### 各 CLI 的 pre-tool 接線位置 ### 各 CLI 的 pre-tool 接線位置
@@ -65,7 +66,7 @@ Claude 由 `hooks/hooks.json` 自動接線九支 hook;其他 CLI 用 `hooks-in
- **kiro** 擋不下技能叫用,這是 CLI 的限制,不是我們接錯。技能走 `ResolveSkill` 這個 agent 內部請求,不經工具管線,`preToolUse` 攔不到;`userPromptSubmit` 的非零結束碼也不會擋下那一輪。唯一可用的介入是 `userPromptSubmit` 的 stdout 注入,所以兩道閘門只印警告。hook 宣告只認 **agent 設定檔的 `hooks` 鍵**,`.kiro/hooks/` 目錄不在它的設定目錄常數裡,一份都不會被讀。同一份 agent 檔還要寫 `resources` 的**兩層** `skill://` glob(預設只掃一層,jsc 的技能在 `jsc-{domain}/{name}/SKILL.md` 第二層,少了那一條一支都載不到)與**明列的 `tools`**(自訂 agent 沒宣告時可用工具會受限),並把 `chat.defaultAgent` 設成 `jsc`,那個 agent 才會被選用。 - **kiro** 擋不下技能叫用,這是 CLI 的限制,不是我們接錯。技能走 `ResolveSkill` 這個 agent 內部請求,不經工具管線,`preToolUse` 攔不到;`userPromptSubmit` 的非零結束碼也不會擋下那一輪。唯一可用的介入是 `userPromptSubmit` 的 stdout 注入,所以兩道閘門只印警告。hook 宣告只認 **agent 設定檔的 `hooks` 鍵**,`.kiro/hooks/` 目錄不在它的設定目錄常數裡,一份都不會被讀。同一份 agent 檔還要寫 `resources` 的**兩層** `skill://` glob(預設只掃一層,jsc 的技能在 `jsc-{domain}/{name}/SKILL.md` 第二層,少了那一條一支都載不到)與**明列的 `tools`**(自訂 agent 沒宣告時可用工具會受限),並把 `chat.defaultAgent` 設成 `jsc`,那個 agent 才會被選用。
> 覆蓋範圍其餘部分仍要據實看待:只有 claude 同時有 PreToolUse、PostToolUse 與 UserPromptSubmit,九支 hook 全接得上。codex、copilot、antigravity 三支目前接上的是版本前置檢查與部署後重啟閘門兩道;`write-guard.sh` 的三種模式還沒接線,SDLC 模型鎖仍只剩技能步驟檢查,註解範圍與繁中編碼仍是 `sweep`。codex 另外沒有工作階段開始事件,計時改由 `tools/jsc-wrap.sh` 的 `codex` 別名在啟動當下開始;沒走別名啟動時,時間從第一輪回應算起。 > 覆蓋範圍其餘部分仍要據實看待:只有 claude 同時有 PreToolUse、PostToolUse 與 UserPromptSubmit,十支 hook 全接得上。codex、copilot、antigravity 三支目前接上的是版本前置檢查與部署後重啟閘門兩道;`write-guard.sh` 的三種模式還沒接線,SDLC 模型鎖仍只剩技能步驟檢查,註解範圍與繁中編碼仍是 `sweep`。codex 另外沒有工作階段開始事件,計時改由 `tools/jsc-wrap.sh` 的 `codex` 別名在啟動當下開始;沒走別名啟動時,時間從第一輪回應算起。
### 驗證等級 ### 驗證等級
@@ -120,7 +121,11 @@ Claude 由 `hooks/hooks.json` 自動接線九支 hook;其他 CLI 用 `hooks-in
一支 CLI 一份是為了修兩個實測抓到的洞:一台機器上五支 CLI 各自是獨立行程,各自載入自己記憶體裡的那一版。早先的單一檔案設計裡,並行部署會互相覆寫(後寫的把 `domains` 與 `cli` 蓋掉,欄位不再代表先寫的那一支),而且任一支 CLI 重啟就把五支的閘門一起解除,其餘四支沒重啟卻不再被擋,閘門在多 CLI 環境等於半失效。拆成一支一份之後,寫入、判定、清除三件事都只碰自己那一份。 一支 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` 相同。 欄位只用在擋人訊息上。判定看的是「當前 CLI 那份檔案在不在」——檔案存在就是這一支還沒重啟過的證據,欄位缺了只讓訊息少幾個字。別支 CLI 那幾份一律不看。狀態檔讀不到、CLI 代號取不到、技能名取不到一律放行,理由與 `version-guard.sh` 相同。
@@ -132,7 +137,7 @@ Claude 由 `hooks/hooks.json` 自動接線九支 hook;其他 CLI 用 `hooks-in
有幾行就代表有幾支 CLI 還沒重啟;一份都沒有就不印。欄位缺值時只留鍵名(例如 `domains=`)。第一欄印 `legacy` 的那一行代表下面說的舊格式單一檔案,它不屬於任何一支 CLI。 有幾行就代表有幾支 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 的 > `version-guard.sh report` 是非 hook 的子指令:印出每個已安裝 jsc plugin 的
> 「{domain} {本機} {遠端} {落後|最新|超前|查詢失敗}」,最後一行 `behind {落後個數}`。 > 「{domain} {本機} {遠端} {落後|最新|超前|查詢失敗}」,最後一行 `behind {落後個數}`。
@@ -170,7 +175,7 @@ Claude 由 `hooks/hooks.json` 自動接線九支 hook;其他 CLI 用 `hooks-in
| `tools/jsc-wrap.sh` | 沒有完整 hook 系統的 CLI 的包裝啟動器:匯出 `JSC_CLI`、`JSC_SESSION_ID`,前後接 `session-timer.sh`,結束時自動跑 `scan-logs.sh` 回填,再依序跑一次 `comment-scope.sh sweep` 與 `lang-guard.sh sweep` 掃整個 git 工作區的註解範圍與繁中編碼(copilot 與 antigravity 沒有任何逐輪事件,整個工作階段只有這裡掃得到)。兩次收尾掃描一律不影響結束碼:包裝器原樣回傳 CLI 自己的結束碼,`sweep` 命中只把警告印到 stderr。`JSC_CLI` 存 CLI 代號,實際執行的是對應的執行檔(antigravity 是 agy、kiro 是 kiro-cli) | | `tools/jsc-wrap.sh` | 沒有完整 hook 系統的 CLI 的包裝啟動器:匯出 `JSC_CLI`、`JSC_SESSION_ID`,前後接 `session-timer.sh`,結束時自動跑 `scan-logs.sh` 回填,再依序跑一次 `comment-scope.sh sweep` 與 `lang-guard.sh sweep` 掃整個 git 工作區的註解範圍與繁中編碼(copilot 與 antigravity 沒有任何逐輪事件,整個工作階段只有這裡掃得到)。兩次收尾掃描一律不影響結束碼:包裝器原樣回傳 CLI 自己的結束碼,`sweep` 命中只把警告印到 stderr。`JSC_CLI` 存 CLI 代號,實際執行的是對應的執行檔(antigravity 是 agy、kiro 是 kiro-cli) |
| `tools/scan-logs.sh` | 離線回填:解析 copilot、antigravity、codex 的原生日誌,把技能用量與階段界線補進 `$JSC_HOME`,重掃不重複 | | `tools/scan-logs.sh` | 離線回填:解析 copilot、antigravity、codex 的原生日誌,把技能用量與階段界線補進 `$JSC_HOME`,重掃不重複 |
| `tools/report-error.sh` | 失敗回報流程:把一筆 hook 或工具異常寫成 wiki 的 `ERROR_{HASH}`,並在異常目錄頁附上一個索引區塊。目錄頁一筆一個 H2 區塊,標題就是那一頁的頁名 `ERROR_{HASH}`,欄位是標題底下的一層條列(時間、頁名、存取庫名稱、觸發 hook、退出碼、摘要各一條,格式 `- {欄位名}:{值}`),頁上不留 markdown 表格。目錄頁的讀回、比對與整頁寫回交給 `jsc-gitea` 的 `tools/wiki-contents.sh upsert ERROR 2 {頁名} {區塊檔} {範本}`,本腳本只組自己那一個區塊:同一筆已經有區塊就整塊換掉,沒有才附加到頁尾,一律 upsert,不整頁覆蓋,也不動別人的區塊;那個 `2` 是舊表格版目錄頁裡持有身分的欄位序號(第 2 欄是頁名),舊頁自動轉條列時要靠它取標題。「讀得回舊內容才寫」的判斷由 `wiki-contents.sh` 一手包辦,只有 `wiki-get` 回 4(頁面真的不存在)才用範本建新頁。區塊裡「頁名」那一條指向異常頁,連結一律寫成 `[{文字}]({連結})`,網址取 `jsc-gitea` 的 `gitea.sh wiki-url` 印出的那一個,不自己組路徑。寫進那一條之前,先把那個網址交給 `jsc-gitea` 的 `tools/link-check.sh` 驗一次,結束碼 0 才寫連結;`link-check.sh` 與 `wiki-contents.sh` 的路徑都由已經解出來的 `gitea.sh` 推得,三支同一個 tools 目錄。驗不過(含找不到 `link-check.sh`、`GITEA_HOST` 未設定回 3、金鑰失效回 7)就只在那一條留純文字頁名,那一條照寫、異常頁照寫、結束碼照舊,原因走 stderr——回報失敗不該再變成一次失敗。網址在異常頁寫成功之後才取:頁名的 hash 帶時間戳,每次回報都是全新的頁,寫進去之前查一定是 404,先查就只拿得到空字串。取不到網址時只印頁名,原因走 stderr,結束碼照舊回 0。wiki 位置分兩處解析:異常頁走 `jsc-gitea` 的 `gitea.sh wiki-repo ERROR`,目錄頁由 `wiki-contents.sh` 自己走 `wiki-repo CONTENTS`,兩者是兩個不同的存取庫。異常頁的存取庫解不出來就整支安靜降級;只有目錄頁的存取庫解不出來(`wiki-contents.sh` 回 3)或那支腳本不在磁碟上,就只寫異常頁、跳過目錄頁更新,仍回 exit 0;其餘結束碼(1 組不出內容或寫入失敗、2 用法錯誤、4 沒範本、7 金鑰失效、8 其他 API 失敗)都以 exit 4 回報。由操作者手動執行,或由 `hooks-install` 在 `wire-cli.sh` 回報 `status=failed` 時執行;**不接在失敗的 hook 上自動觸發**(hook 一律安靜 exit 0,自我回報會疊出迴圈) | | `tools/report-error.sh` | 失敗回報流程:把一筆 hook 或工具異常寫成 wiki 的 `ERROR_{HASH}`,並在異常目錄頁附上一個索引區塊。目錄頁一筆一個 H2 區塊,標題就是那一頁的頁名 `ERROR_{HASH}`,欄位是標題底下的一層條列(時間、頁名、存取庫名稱、觸發 hook、退出碼、摘要各一條,格式 `- {欄位名}:{值}`),頁上不留 markdown 表格。目錄頁的讀回、比對與整頁寫回交給 `jsc-gitea` 的 `tools/wiki-contents.sh upsert ERROR 2 {頁名} {區塊檔} {範本}`,本腳本只組自己那一個區塊:同一筆已經有區塊就整塊換掉,沒有才附加到頁尾,一律 upsert,不整頁覆蓋,也不動別人的區塊;那個 `2` 是舊表格版目錄頁裡持有身分的欄位序號(第 2 欄是頁名),舊頁自動轉條列時要靠它取標題。「讀得回舊內容才寫」的判斷由 `wiki-contents.sh` 一手包辦,只有 `wiki-get` 回 4(頁面真的不存在)才用範本建新頁。區塊裡「頁名」那一條指向異常頁,連結一律寫成 `[{文字}]({連結})`,網址取 `jsc-gitea` 的 `gitea.sh wiki-url` 印出的那一個,不自己組路徑。寫進那一條之前,先把那個網址交給 `jsc-gitea` 的 `tools/link-check.sh` 驗一次,結束碼 0 才寫連結;`link-check.sh` 與 `wiki-contents.sh` 的路徑都由已經解出來的 `gitea.sh` 推得,三支同一個 tools 目錄。驗不過(含找不到 `link-check.sh`、`GITEA_HOST` 未設定回 3、金鑰失效回 7)就只在那一條留純文字頁名,那一條照寫、異常頁照寫、結束碼照舊,原因走 stderr——回報失敗不該再變成一次失敗。網址在異常頁寫成功之後才取:頁名的 hash 帶時間戳,每次回報都是全新的頁,寫進去之前查一定是 404,先查就只拿得到空字串。取不到網址時只印頁名,原因走 stderr,結束碼照舊回 0。wiki 位置分兩處解析:異常頁走 `jsc-gitea` 的 `gitea.sh wiki-repo ERROR`,目錄頁由 `wiki-contents.sh` 自己走 `wiki-repo CONTENTS`,兩者是兩個不同的存取庫。異常頁的存取庫解不出來就整支安靜降級;只有目錄頁的存取庫解不出來(`wiki-contents.sh` 回 3)或那支腳本不在磁碟上,就只寫異常頁、跳過目錄頁更新,仍回 exit 0;其餘結束碼(1 組不出內容或寫入失敗、2 用法錯誤、4 沒範本、7 金鑰失效、8 其他 API 失敗)都以 exit 4 回報。由操作者手動執行,或由 `hooks-install` 在 `wire-cli.sh` 回報 `status=failed` 時執行;**不接在失敗的 hook 上自動觸發**(hook 一律安靜 exit 0,自我回報會疊出迴圈) |
| `tools/wire-cli.sh` | 單一 CLI 的 hook 生命週期,共四個用法。`{cli}` 是接線:先建立或更新 `$JSC_HOME/current/jsc-hooks` 指向目前這版 plugin,接著把對應的設定編輯、包裝別名安裝、hook 檔建立成穩定路徑,皆以 `<!-- jsc-hooks -->`(或 `# jsc-hooks`)標記整段重寫,重跑等同先移除再重裝;寫完每個檔案會重讀驗證位置正確才回報成功(codex 的 `notify` 必須是根層鍵、`.codex-plugin/plugin.json` 的 matcher 必須是 `Bash`、copilot 必須是小寫 `skill` 且沒有第二種大小寫的事件名、antigravity 的 matcher 必須帶錨點 `^view_file$` 且有 `PreInvocation`、kiro 的 agent JSON 必須成對且 `hooks`、`resources`、`tools` 在最上層並含兩層 `skill://` glob),也會確認寫入路徑能解到既有腳本。matcher 本身要單獨驗:鍵在、matcher 卻錯的形態最難查,回報會說接好了,實際一次都不會被叫用。檔案系統不能建立 symlink 時,會明確回報並退回目前根目錄,不會靜默寫出壞路徑。`status=wired\|degraded\|skipped\|failed` 回報接線結果。`purge {cli}` 是移除:把該 CLI 的**所有** hook 清掉,含非 jsc 的第三方項目,動到的檔案先原樣備份到 `$JSC_HOME/backup/hooks/{cli}/{yyyyMMdd_HHmmss}/`,備份失敗就不移除;移除標記段落時會先去掉標記行前後空白,所以縮排或尾端補空白的 jsc 區塊一樣會移除;移除後重讀驗證,驗不過自動還原備份,以 `status=purged\|skipped\|failed` 回報。`smoke {cli}` 是執行期冒煙測試:九支 hook 的每個接線模式各跑一次,非零退出即為錯誤,另外把五支 CLI 的真實負載各餵進 `skill-name.sh` 一次驗技能名解析、四種阻擋形態各驗一次 `deny.sh`,再把那些負載直接餵進 `restart-gate.sh` 驗「解析→判定→輸出形態」整條串得起來(含 fail-open、豁免放行與 kiro 的注入路徑)——前兩組分開看都會顯示正常,中間接不上照樣是全程放行,那正是先前三支 CLI 失效的樣子;另外用一份暫時的 `$JSC_HOME` 狀態檔把模型來源與階段鎖、工作包歸屬、部署後重啟閘門與寫入提交閘門的每條判定路徑各跑一次並比對結束碼(模型來源的每個案例各自指定 CLI 代號,不跟著這一輪接線的 CLI 走——偵測鏈已依 CLI 分流;「不知道能力就擋下」的三種情形連訊息裡的逃生門一起驗,只比結束碼的話訊息漏掉逃生門也是綠燈),再用一份暫時的 `HOME`(假的 `installed_plugins.json` 與各 plugin 的 manifest)把 `version-guard.sh` 相依版本檢查的每條路徑跑一次——相依落後的擋人與訊息內容、相等與超前的放行、豁免技能在相依落後時照樣放行、四種 fail-open、逃生門,另加一條回歸:多行縮排的 manifest,`jsc.requires` 的最後一個鍵也要解得到。驗的是判定結果本身,不只是腳本跑得完(例外有四個:`sdlc-gate.sh check` 的 exit 2 是階段鎖的設計行為,`comment-scope.sh`、`lang-guard.sh` 掃描模式與 `write-guard.sh` 三種模式的 exit 2 是命中違規的設計行為——`sweep` 在髒工作區本來就會回 2,`write-guard.sh` 在機器剛好鎖在 `plan` 階段時也會回 2,都不算 hook 壞掉),以 `status=ok\|failed` 回報。**結果行數由腳本自己數、自己斷言**:`status=` 之後緊接一行 `lines<TAB>{數量}`,那是其後 `[jsc]` 結果行的實際條數,與腳本內逐類宣告的預期條數比對,不符就回非零。判定路徑增減時只改腳本裡的預期值,散文一律引用這一行,不另外抄一份數字。`status {cli}` 是唯讀盤點:只讀設定檔判斷段落與 matcher 對不對,不寫檔也不執行 hook,claude、codex、copilot、antigravity 回 `wired`,kiro 回 `degraded` 並在 `reason` 講明那是 CLI 限制;每個接線點印一行 `item<TAB>{項目}<TAB>{路徑}<TAB>{present\|missing\|unverified}`,也會把帶版號快取路徑、開發存取庫路徑與不存在的腳本列為缺項。狀態有三格不是兩格:`unverified` 是「這一項驗不了」,只有 `missing` 才算缺項——`kiro-cli agent validate` 在沒登入時印的是環境問題,不是這個檔案的問題,報 `present` 會讓沒驗到的東西看起來像通過,報 `missing` 會把沒登入算成接線缺漏;`status claude` 讀 Claude Code 實際載入的 `installed_plugins.json`,不再檢查目前腳本旁邊那份 `hooks.json`。體檢類技能(`/jsc-cli:doctor`)只能用這個子命令,另外三個都會動到環境;那道限制另有程式層把關,`JSC_READONLY=1` 之下只准 `status` 與 `smoke`,`purge` 與接線一律以 exit 6 拒絕並回報 `status=readonly`,環境不會被動到 | | `tools/wire-cli.sh` | 單一 CLI 的 hook 生命週期,共四個用法。`{cli}` 是接線:先建立或更新 `$JSC_HOME/current/jsc-hooks` 指向目前這版 plugin,接著把對應的設定編輯、包裝別名安裝、hook 檔建立成穩定路徑,皆以 `<!-- jsc-hooks -->`(或 `# jsc-hooks`)標記整段重寫,重跑等同先移除再重裝;寫完每個檔案會重讀驗證位置正確才回報成功(codex 的 `notify` 必須是根層鍵、`.codex-plugin/plugin.json` 的 matcher 必須是 `Bash`、copilot 必須是小寫 `skill` 且沒有第二種大小寫的事件名、antigravity 的 matcher 必須帶錨點 `^view_file$` 且有 `PreInvocation`、kiro 的 agent JSON 必須成對且 `hooks`、`resources`、`tools` 在最上層並含兩層 `skill://` glob),也會確認寫入路徑能解到既有腳本。matcher 本身要單獨驗:鍵在、matcher 卻錯的形態最難查,回報會說接好了,實際一次都不會被叫用。檔案系統不能建立 symlink 時,會明確回報並退回目前根目錄,不會靜默寫出壞路徑。`status=wired\|degraded\|skipped\|failed` 回報接線結果。`purge {cli}` 是移除:把該 CLI 的**所有** hook 清掉,含非 jsc 的第三方項目,動到的檔案先原樣備份到 `$JSC_HOME/backup/hooks/{cli}/{yyyyMMdd_HHmmss}/`,備份失敗就不移除;移除標記段落時會先去掉標記行前後空白,所以縮排或尾端補空白的 jsc 區塊一樣會移除;移除後重讀驗證,驗不過自動還原備份,以 `status=purged\|skipped\|failed` 回報。`smoke {cli}` 是執行期冒煙測試:十支 hook 的每個接線模式各跑一次,非零退出即為錯誤,另外把五支 CLI 的真實負載各餵進 `skill-name.sh` 一次驗技能名解析、四種阻擋形態各驗一次 `deny.sh`,再把那些負載直接餵進 `restart-gate.sh` 驗「解析→判定→輸出形態」整條串得起來(含 fail-open、豁免放行與 kiro 的注入路徑)——前兩組分開看都會顯示正常,中間接不上照樣是全程放行,那正是先前三支 CLI 失效的樣子;另外用一份暫時的 `$JSC_HOME` 狀態檔把模型來源與階段鎖、工作包歸屬、部署後重啟閘門與寫入提交閘門的每條判定路徑各跑一次並比對結束碼(模型來源的每個案例各自指定 CLI 代號,不跟著這一輪接線的 CLI 走——偵測鏈已依 CLI 分流;「不知道能力就擋下」的三種情形連訊息裡的逃生門一起驗,只比結束碼的話訊息漏掉逃生門也是綠燈),再用一份暫時的 `HOME`(假的 `installed_plugins.json` 與各 plugin 的 manifest)把 `version-guard.sh` 相依版本檢查的每條路徑跑一次——相依落後的擋人與訊息內容、相等與超前的放行、豁免技能在相依落後時照樣放行、四種 fail-open、逃生門,另加一條回歸:多行縮排的 manifest,`jsc.requires` 的最後一個鍵也要解得到。驗的是判定結果本身,不只是腳本跑得完(例外有四個:`sdlc-gate.sh check` 的 exit 2 是階段鎖的設計行為,`comment-scope.sh`、`lang-guard.sh` 掃描模式與 `write-guard.sh` 三種模式的 exit 2 是命中違規的設計行為——`sweep` 在髒工作區本來就會回 2,`write-guard.sh` 在機器剛好鎖在 `plan` 階段時也會回 2,都不算 hook 壞掉),以 `status=ok\|failed` 回報。**結果行數由腳本自己數、自己斷言**:`status=` 之後緊接一行 `lines<TAB>{數量}`,那是其後 `[jsc]` 結果行的實際條數,與腳本內逐類宣告的預期條數比對,不符就回非零。判定路徑增減時只改腳本裡的預期值,散文一律引用這一行,不另外抄一份數字。`status {cli}` 是唯讀盤點:只讀設定檔判斷段落與 matcher 對不對,不寫檔也不執行 hook,claude、codex、copilot、antigravity 回 `wired`,kiro 回 `degraded` 並在 `reason` 講明那是 CLI 限制;每個接線點印一行 `item<TAB>{項目}<TAB>{路徑}<TAB>{present\|missing\|unverified}`,也會把帶版號快取路徑、開發存取庫路徑與不存在的腳本列為缺項。狀態有三格不是兩格:`unverified` 是「這一項驗不了」,只有 `missing` 才算缺項——`kiro-cli agent validate` 在沒登入時印的是環境問題,不是這個檔案的問題,報 `present` 會讓沒驗到的東西看起來像通過,報 `missing` 會把沒登入算成接線缺漏;`status claude` 讀 Claude Code 實際載入的 `installed_plugins.json`,不再檢查目前腳本旁邊那份 `hooks.json`。體檢類技能(`/jsc-cli:doctor`)只能用這個子命令,另外三個都會動到環境;那道限制另有程式層把關,`JSC_READONLY=1` 之下只准 `status` 與 `smoke`,`purge` 與接線一律以 exit 6 拒絕並回報 `status=readonly`,環境不會被動到 |
| `tools/report-status.sh` | 技能與 hook 的執行狀態事件流,寫進 `$JSC_HOME/usage/events.jsonl`,一次一行。`skill-start`、`skill-end`、`hook-end` 三個記錄子命令;`drain` 印出上次排空之後的新事件(位移存在 `usage/scan-state/events.offset`,檔案比位移小就當作輪替過、從頭讀,不比對 inode——五支 CLI 與容器裡的行程看到的 inode 不保證一致);`rotate` 超過 5 MiB 就改名成 `.1` 並把位移歸零,只留一份舊的。`status` 是 `ok`、`blocked`、`failed`、`degraded`、`aborted` 五選一。**三個記錄子命令一律回 0,寫檔失敗也是 0**:回報機制自己壞掉,不可以讓被回報的東西跟著壞——hook 的結束碼是閘門的判準,被記錄動到就等於閘門行為被記錄改寫。參數檢查是例外,那是呼叫端的程式錯誤,寫進去只會汙染事件流,所以以 2 擋在記錄之前。本檔不讀 stdin:技能由 Bash 呼叫它,stdin 可能是還沒關閉的管線,讀下去會卡住宿主,所有資訊一律走參數。輪替不放在每次寫入,那等於每次提示多一次系統呼叫;改由巡檢排空之後呼叫。為什麼不直接寫 wiki:hook 每次提示都跑,網路寫入會拖垮宿主 CLI,而且失敗的 hook 自我回報會疊出迴圈,`report-error.sh` 因此刻意不接在失敗的 hook 上,這裡沿用同一條線 | | `tools/report-status.sh` | 技能與 hook 的執行狀態事件流,寫進 `$JSC_HOME/usage/events.jsonl`,一次一行。`skill-start`、`skill-end`、`hook-end` 三個記錄子命令;`drain` 印出上次排空之後的新事件(位移存在 `usage/scan-state/events.offset`,檔案比位移小就當作輪替過、從頭讀,不比對 inode——五支 CLI 與容器裡的行程看到的 inode 不保證一致);`rotate` 超過 5 MiB 就改名成 `.1` 並把位移歸零,只留一份舊的。`status` 是 `ok`、`blocked`、`failed`、`degraded`、`aborted` 五選一。**三個記錄子命令一律回 0,寫檔失敗也是 0**:回報機制自己壞掉,不可以讓被回報的東西跟著壞——hook 的結束碼是閘門的判準,被記錄動到就等於閘門行為被記錄改寫。參數檢查是例外,那是呼叫端的程式錯誤,寫進去只會汙染事件流,所以以 2 擋在記錄之前。本檔不讀 stdin:技能由 Bash 呼叫它,stdin 可能是還沒關閉的管線,讀下去會卡住宿主,所有資訊一律走參數。輪替不放在每次寫入,那等於每次提示多一次系統呼叫;改由巡檢排空之後呼叫。為什麼不直接寫 wiki:hook 每次提示都跑,網路寫入會拖垮宿主 CLI,而且失敗的 hook 自我回報會疊出迴圈,`report-error.sh` 因此刻意不接在失敗的 hook 上,這裡沿用同一條線 |
| `tools/scan-hook-errors.sh` | 掃 CLI 原生紀錄找 hook 的執行期錯誤(接線寫對、跑起來出錯)。只有 claude 有 hook 結果紀錄,掃 `~/.claude/projects/**/*.jsonl` 的 `hook_non_blocking_error` 與非空 `hookErrors`;codex、copilot、antigravity、kiro 沒有等價紀錄,一律回報 `unavailable` 並指向 `wire-cli.sh smoke {cli}`。每筆錯誤附加一行 JSON 到 `$JSC_HOME/errors/hooks.jsonl`,`jsc` 欄位標明是不是 jsc 自己的 hook(第三方 hook 的錯誤只回報,不由 jsc 修正);去重與 `scan-logs.sh` 同法,重掃只讀新增段落,以 `status=clean\|errors\|unavailable` 回報 | | `tools/scan-hook-errors.sh` | 掃 CLI 原生紀錄找 hook 的執行期錯誤(接線寫對、跑起來出錯)。只有 claude 有 hook 結果紀錄,掃 `~/.claude/projects/**/*.jsonl` 的 `hook_non_blocking_error` 與非空 `hookErrors`;codex、copilot、antigravity、kiro 沒有等價紀錄,一律回報 `unavailable` 並指向 `wire-cli.sh smoke {cli}`。每筆錯誤附加一行 JSON 到 `$JSC_HOME/errors/hooks.jsonl`,`jsc` 欄位標明是不是 jsc 自己的 hook(第三方 hook 的錯誤只回報,不由 jsc 修正);去重與 `scan-logs.sh` 同法,重掃只讀新增段落,以 `status=clean\|errors\|unavailable` 回報 |
@@ -192,7 +197,7 @@ Claude 由 `hooks/hooks.json` 自動接線九支 hook;其他 CLI 用 `hooks-in
### `hooks-install` ### `hooks-install`
把九支 hook 接線到所有已安裝的 CLI,每個 CLI 走五道關卡:先 `tools/wire-cli.sh purge {cli}` 備份後移除所有 hook(含非 jsc 的第三方項目,乾淨起跑才分得清後續失敗是誰的),再 `tools/wire-cli.sh {cli}` 接線(claude 由 `hooks.json` 自動接線,無需寫入;其他 CLI 的持久命令會寫成 `$JSC_HOME/current/jsc-hooks` 穩定路徑),接著 `tools/wire-cli.sh status {cli}` 唯讀盤點接線結果,再 `tools/wire-cli.sh smoke {cli}` 驗執行期,最後 `tools/scan-hook-errors.sh --cli {cli}` 掃原生紀錄。第一支 CLI 的管線單獨跑完(`$JSC_HOME/current/jsc-hooks` 連結由它統一更新),其餘各 CLI 的管線才並行。codex、copilot、antigravity 由接線腳本裝上 `tools/jsc-wrap.sh` 包裝別名補上計時與用量回填(結束時自動跑 `tools/scan-logs.sh`),語言規則仍重寫到各自的規則檔(以 `<!-- jsc-hooks -->` 標記整段取代,等同先移除再重裝,不重複追加)。codex、copilot、antigravity、kiro 的 SDLC 模型鎖降級為技能步驟檢查,鎖檔仍由 SDLC 技能直接呼叫 `sdlc-gate.sh lock` 寫入。版本前置檢查與部署後重啟閘門在 codex、copilot、antigravity 三支都擋得下來,各自接在自己的 pre-tool 位置(見上面「各 CLI 的 pre-tool 接線位置」),三支回報 `wired`;kiro 擋不下技能叫用,只注入警告,回報 `degraded`,那是 CLI 的限制。`write-guard.sh` 的三種模式目前仍只接在 claude。這四個 CLI 都沒有 post-tool hook,`comment-scope.sh` 接不到逐檔即時掃描,改用 `sweep` 掃整個 git 工作區——codex 每輪結束、kiro 每輪提示送出時、copilot 與 antigravity 只有工作階段結束時掃一次,腳本會在 `reason` 裡講明各自的時機,也只有 claude 掃得到執行期錯誤紀錄。antigravity 與 kiro 的 hook 觸發都沒有實跑驗證(前者 quota 用盡、後者未登入),回報時要把「接線已驗」與「觸發未驗」分開講。任一關卡出錯(purge、接線、冒煙失敗,或掃到 `jsc=true` 的執行期錯誤)就先寫 `ERROR_{HASH}`,再交給 `repair` 技能接手並以 `develop` PR 收尾;此時允許中止剩下的安裝,但修正一定要開始。掃到 `jsc=false` 的第三方 hook 錯誤只回報,不轉修正。 把十支 hook 接線到所有已安裝的 CLI,每個 CLI 走五道關卡:先 `tools/wire-cli.sh purge {cli}` 備份後移除所有 hook(含非 jsc 的第三方項目,乾淨起跑才分得清後續失敗是誰的),再 `tools/wire-cli.sh {cli}` 接線(claude 由 `hooks.json` 自動接線,無需寫入;其他 CLI 的持久命令會寫成 `$JSC_HOME/current/jsc-hooks` 穩定路徑),接著 `tools/wire-cli.sh status {cli}` 唯讀盤點接線結果,再 `tools/wire-cli.sh smoke {cli}` 驗執行期,最後 `tools/scan-hook-errors.sh --cli {cli}` 掃原生紀錄。第一支 CLI 的管線單獨跑完(`$JSC_HOME/current/jsc-hooks` 連結由它統一更新),其餘各 CLI 的管線才並行。codex、copilot、antigravity 由接線腳本裝上 `tools/jsc-wrap.sh` 包裝別名補上計時與用量回填(結束時自動跑 `tools/scan-logs.sh`),語言規則仍重寫到各自的規則檔(以 `<!-- jsc-hooks -->` 標記整段取代,等同先移除再重裝,不重複追加)。codex、copilot、antigravity、kiro 的 SDLC 模型鎖降級為技能步驟檢查,鎖檔仍由 SDLC 技能直接呼叫 `sdlc-gate.sh lock` 寫入。版本前置檢查與部署後重啟閘門在 codex、copilot、antigravity 三支都擋得下來,各自接在自己的 pre-tool 位置(見上面「各 CLI 的 pre-tool 接線位置」),三支回報 `wired`;kiro 擋不下技能叫用,只注入警告,回報 `degraded`,那是 CLI 的限制。`write-guard.sh` 的三種模式目前仍只接在 claude。這四個 CLI 都沒有 post-tool hook,`comment-scope.sh` 接不到逐檔即時掃描,改用 `sweep` 掃整個 git 工作區——codex 每輪結束、kiro 每輪提示送出時、copilot 與 antigravity 只有工作階段結束時掃一次,腳本會在 `reason` 裡講明各自的時機,也只有 claude 掃得到執行期錯誤紀錄。antigravity 與 kiro 的 hook 觸發都沒有實跑驗證(前者 quota 用盡、後者未登入),回報時要把「接線已驗」與「觸發未驗」分開講。任一關卡出錯(purge、接線、冒煙失敗,或掃到 `jsc=true` 的執行期錯誤)就先寫 `ERROR_{HASH}`,再交給 `repair` 技能接手並以 `develop` PR 收尾;此時允許中止剩下的安裝,但修正一定要開始。掃到 `jsc=false` 的第三方 hook 錯誤只回報,不轉修正。
### `repair` ### `repair`
+4
View File
@@ -6,6 +6,10 @@
{ {
"type": "command", "type": "command",
"command": "sh -c 'JSC_CLI=codex; export JSC_CLI; root=\"${CLAUDE_PLUGIN_ROOT:-${JSC_HOME:-$HOME/.jsc}/current/jsc-hooks}\"; exec sh \"$root/hooks/session-timer.sh\" start'" "command": "sh -c 'JSC_CLI=codex; export JSC_CLI; root=\"${CLAUDE_PLUGIN_ROOT:-${JSC_HOME:-$HOME/.jsc}/current/jsc-hooks}\"; exec sh \"$root/hooks/session-timer.sh\" start'"
},
{
"type": "command",
"command": "sh -c 'JSC_CLI=codex; export JSC_CLI; root=\"${CLAUDE_PLUGIN_ROOT:-${JSC_HOME:-$HOME/.jsc}/current/jsc-hooks}\"; exec sh \"$root/hooks/session-reminder.sh\"'"
} }
] ]
} }
+4
View File
@@ -6,6 +6,10 @@
{ {
"type": "command", "type": "command",
"command": "sh -c 'root=\"${CLAUDE_PLUGIN_ROOT:-${JSC_HOME:-$HOME/.jsc}/current/jsc-hooks}\"; exec sh \"$root/hooks/session-timer.sh\" start'" "command": "sh -c 'root=\"${CLAUDE_PLUGIN_ROOT:-${JSC_HOME:-$HOME/.jsc}/current/jsc-hooks}\"; exec sh \"$root/hooks/session-timer.sh\" start'"
},
{
"type": "command",
"command": "sh -c 'root=\"${CLAUDE_PLUGIN_ROOT:-${JSC_HOME:-$HOME/.jsc}/current/jsc-hooks}\"; exec sh \"$root/hooks/session-reminder.sh\"'"
} }
] ]
} }
+112 -14
View File
@@ -34,9 +34,11 @@
# install 或 update,之後接這次更新的 domain 清單。 # install 或 update,之後接這次更新的 domain 清單。
# exit 0 = 已掛上;exit 2 = 取不到 CLI 代號或寫不進去 # exit 0 = 已掛上;exit 2 = 取不到 CLI 代號或寫不進去
# (兩種都等於沒掛上)。 # (兩種都等於沒掛上)。
# restart-gate.sh clear 只清除當前 CLI 那份狀態檔,放下這一支的閘門。由 # restart-gate.sh clear [{工作階段代號}]
# session-timer.sh 在判定為新工作階段時呼叫(見下方 # 只清除當前 CLI 那份狀態檔,放下這一支的閘門。由
# 「清除時機」)。檔案不存在也算成功。 # session-timer.sh 在判定為新工作階段時呼叫,並把它
# 手上那個工作階段代號一起帶進來。清除有條件,判準見
# 下方「清除時機」。檔案不存在、條件不成立都算成功。
# restart-gate.sh report 印出每一份狀態檔的內容,一支 CLI 一行(格式見下方 # restart-gate.sh report 印出每一份狀態檔的內容,一支 CLI 一行(格式見下方
# 「report 輸出格式」);一份都沒有就不印,一律 exit 0。 # 「report 輸出格式」);一份都沒有就不印,一律 exit 0。
# #
@@ -54,8 +56,12 @@
# mode={install|update} 這次部署的模式 # mode={install|update} 這次部署的模式
# domains={domain 清單} 這次更新到的 domain,空白分隔 # domains={domain 清單} 這次更新到的 domain,空白分隔
# cli={CLI 代號} 執行部署的 CLI,與檔名相同 # cli={CLI 代號} 執行部署的 CLI,與檔名相同
# 欄位只用在擋人訊息上。判定看的是「當前 CLI 那份檔案在不在」——檔案存在就是這一支還沒重啟 # session={代號} 掛上閘門那一刻的工作階段代號
# 過的證據,欄位缺了只讓訊息少幾個字,不影響判定。 # pid={行程代號} 掛上閘門那一刻那一支 CLI 的行程代號;追不到時為空
# 前四個欄位只用在擋人訊息上。判定看的是「當前 CLI 那份檔案在不在」——檔案存在就是這一支還沒
# 重啟過的證據,欄位缺了只讓訊息少幾個字,不影響判定。
# 後兩個欄位只給清除那一邊用(見下方「清除時機」),不進 report 的輸出:那一行的 domains
# 擺在最後而且可能含空白,後面再接欄位會讓現有的讀法把新欄位讀成 domain 名。
# #
# 為什麼一支 CLI 一份:一台機器上五支 CLI 各自是獨立行程,各自載入自己記憶體裡的那一版。 # 為什麼一支 CLI 一份:一台機器上五支 CLI 各自是獨立行程,各自載入自己記憶體裡的那一版。
# 早先的單一檔案設計有兩個實測抓到的洞——並行部署互相覆寫(後寫的把 domains 與 cli 蓋掉, # 早先的單一檔案設計有兩個實測抓到的洞——並行部署互相覆寫(後寫的把 domains 與 cli 蓋掉,
@@ -85,11 +91,28 @@
# #
# --- 清除時機 --- # --- 清除時機 ---
# #
# 清除由 session-timer.sh 在「這一次 SessionStart 是新的工作階段」那一刻呼叫,不由本檔自己判定: # 清除由 session-timer.sh 在「這一次 SessionStart 是新的工作階段」那一刻呼叫。清除的範圍就是
# 新舊工作階段的判準(sessions/{sid}.start 在不在)只有那支腳本知道,兩邊各寫一份就會漂移。 # 呼叫端那一支 CLI:那一支重啟了,不代表別支也重啟了。
# 新的工作階段代表 CLI 行程是新起的,新版一定已經載入,所以清除是對的。續接同一階段 #
# (SessionStart 再觸發、resume、compact)不會走到那一段,閘門就一路留到真的重新啟動。 # 「新的工作階段」不等於「行程是新起的」。這句話原本被當成等式,實測打掉了它:一個還沒重啟的
# 清除的範圍就是呼叫端那一支 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 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=狀態檔 # 一份狀態檔印一行,格式見檔頭「report 輸出格式」。$1=第一欄要印的名稱 $2=狀態檔
state_line() { state_line() {
printf '%s at=%s mode=%s domains=%s\n' "$1" \ printf '%s at=%s mode=%s domains=%s\n' "$1" \
@@ -163,8 +242,11 @@ case "${1:-}" in
exit 2 exit 2
fi fi
mkdir -p "$STATE_DIR" 2>/dev/null || true mkdir -p "$STATE_DIR" 2>/dev/null || true
printf 'at=%s\nmode=%s\ndomains=%s\ncli=%s\n' \ # session 與 pid 記的是「掛上閘門的那一刻,載入舊程式碼的是誰」。清除那一邊靠它們判斷
"$(now_iso)" "$_mode" "$_domains" "$_cli" > "$STATE_DIR/$_cli" 2>/dev/null || { # 那個行程走了沒有,理由見檔頭「清除時機」。
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 printf '[jsc][重啟閘門][ERR]:寫不進 %s,這次部署沒有掛上重啟閘門。\n' "$STATE_DIR/$_cli" >&2
exit 2 exit 2
@@ -172,8 +254,24 @@ case "${1:-}" in
exit 0 ;; exit 0 ;;
clear) clear)
_cli=$(cli_code) _cli=$(cli_code)
# 只刪自己那一份。別支 CLI 沒有跟著重啟,它們的閘門要留著。 if [ -n "$_cli" ] && [ -f "$STATE_DIR/$_cli" ]; then
[ -n "$_cli" ] && rm -f "$STATE_DIR/$_cli" 2>/dev/null # 判準與取捨見檔頭「清除時機」。只刪自己那一份:別支 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 rm -f "$LEGACY_STATE" 2>/dev/null || true
exit 0 ;; exit 0 ;;
+175
View File
@@ -0,0 +1,175 @@
#!/usr/bin/env sh
# session-reminder.sh — 工作階段開始時,把助理算好的未讀提醒帶到前景。
#
# 用法:
# session-reminder.sh # SessionStart:印出未讀提醒,並記下這個工作階段提過了
# session-reminder.sh peek # 只印,不記。人要重看一次時用,也給檢核用
#
# 結束碼:0=一律成功,只有這一種。這一支接在工作階段開始那個事件上,它的產出走 stdout,
# 不走結束碼——那個事件的 stdout 會成為額外 context。佇列不在、讀不到、格式對不上,
# 一律印一行說明然後 exit 0;一支在每個工作階段開頭都會跑的 hook 絕對不可以擋人。
# 唯一的非零來源是 `. lib.sh` 載入失敗,那時 sh 自己回 2。
#
# --- 這一支不做判定 ---
#
# 提醒該不該送、哪幾筆該送,全由助理那一輪算完寫進佇列(jsc-assist 的 tools/patrol.sh
# 每一輪重寫 $JSC_HOME/assistant/reminders.tsv)。這裡只把那份檔案印出來。
# 不自己判的理由有兩個。一是快:這一支跑在每一個工作階段的開頭,讀一個檔案就回來。
# 二是不漂移:自己拿 due 欄與 next_run 去跟現在比,就是第二套到期判定,跟助理那一套遲早
# 對不上,而對不上的那一天兩邊都說自己是對的。
#
# --- 「沒有提醒」與「沒有人算提醒」不可以長得一樣 ---
#
# 只印佇列換來一個新的失效模式:助理停了,佇列就不再更新,而一份舊佇列讀起來跟新的一模一樣。
# 所以佇列檔頭帶著那一輪的時間戳,這裡算出它多舊,超過心跳門檻就明說「這份提醒是多久以前
# 算的、助理現在的心跳是什麼狀態」。門檻與狀態都取 heartbeat.sh 印的那一行,不自己定一套。
#
# 助理的狀態目錄根本不存在時,這一支一個字都不印:那代表這台機器從沒啟動過助理,
# 每個工作階段開頭都催一次「你要不要啟動助理」不是提醒,是噪音。
#
# --- 逐筆點名與只算總數,分開兩種 ---
#
# 逾期與使用者自己登錄的提醒逐筆點名:人看到就做得了。委派清單種入的內建項只算一個總數,
# 因為那幾筆等的是接線不是人,每一輪都到期、每一輪都一樣。分種類的判定在寫佇列那一邊,
# 這裡只照它標好的種類決定怎麼印。
#
# --- 一個工作階段只提一次 ---
#
# 記號檔是 $JSC_HOME/sessions/{工作階段}.reminded。工作階段開始那個事件在續接同一階段時
# 會再觸發,沒有記號就會每次都再提一次同一批。
# 接不到工作階段代號的 CLI(記號都落在 default 上)另有一條路:session-timer.sh 的 restart
# 分支會把這個記號刪掉。判定「這是不是新的工作階段」只有那一支知道,所以刪除掛在那裡,
# 這裡不自己再判一次。
HERE=$(dirname "$0"); . "$HERE/lib.sh"
hook_trace "session-reminder ${1:-}"
read_stdin
sid=$(session_id)
MODE="${1:-show}"
STATE_DIR="$JSC_HOME/assistant"
QUEUE="$STATE_DIR/reminders.tsv"
MARK="$JSC_HOME/sessions/$sid.reminded"
MAX_ROWS=8
# 助理沒啟動過就整支安靜退出。
[ -d "$STATE_DIR" ] || exit 0
# 這個工作階段提過了就不再提。peek 一律印,那是人自己要重看。
if [ "$MODE" != peek ] && [ -f "$MARK" ]; then
exit 0
fi
mark_done() {
[ "$MODE" = peek ] && return 0
mkdir -p "$JSC_HOME/sessions" 2>/dev/null || return 0
now_epoch >"$MARK" 2>/dev/null || true
return 0
}
if [ ! -f "$QUEUE" ]; then
echo "[jsc] 助理的狀態目錄在,但還沒有提醒佇列($QUEUE)。跑過一輪巡檢才會產生,所以這裡沒有提醒**不代表沒有事要做**——要現在看就跑 /jsc-assist:assistant status。"
mark_done
exit 0
fi
TAB=$(printf '\t')
# 檔頭。第一行不是 round 就是格式不對,或者檔案被別的東西蓋掉了,兩種都照實說。
_head=$(head -n1 "$QUEUE" 2>/dev/null)
_kind=$(printf '%s' "$_head" | cut -f1)
if [ "$_kind" != round ]; then
echo "[jsc] 提醒佇列($QUEUE)的第一行不是輪次資訊,這一份讀不了。助理下一輪會重寫;在那之前要看待辦就跑 /jsc-assist:assistant status。"
mark_done
exit 0
fi
_iso=$(printf '%s' "$_head" | cut -f3)
_failing=$(printf '%s' "$_head" | cut -f4)
_epoch=$(printf '%s' "$_head" | cut -f5)
_tasks=$(printf '%s' "$_head" | cut -f6)
case "${_failing:-}" in ''|*[!0-9]*) _failing=0 ;; esac
case "${_tasks:-}" in ''|*[!0-9]*) _tasks=0 ;; esac
# 佇列有多舊。門檻與心跳狀態一律取 heartbeat.sh 印的那一行:那是這台機器判定「助理還在跑」
# 的唯一一套規則,這裡再定一套就會出現兩個說法。
_age=''
case "${_epoch:-}" in
''|*[!0-9]*) ;;
*) _age=$(( $(now_epoch) - _epoch )) ;;
esac
_hb=$(sh "$HERE/heartbeat.sh" report </dev/null 2>/dev/null || true)
_hbstate=$(printf '%s' "$_hb" | sed -n 's/^state=\([a-z]*\).*/\1/p')
_ttl=$(printf '%s' "$_hb" | sed -n 's/.*[[:space:]]ttl=\([0-9]*\).*/\1/p')
case "${_ttl:-}" in ''|*[!0-9]*) _ttl=300 ;; esac
# 兩種列分開數。逐筆點名的是逾期與使用者自己登錄的提醒;內建項只算一個總數。
#
# 為什麼內建項不逐筆點名:那幾筆等的是接線,不是人。動作是「只提醒」的內建項到現在還沒有
# 執行入口,所以每一輪都到期、每一輪都一樣。實測踩到:這台機器八筆全是那一種,於是每一個
# 工作階段開頭固定吐八行一模一樣的東西——那不是提醒,是噪音,而這一支自己的註解裡就寫著
# 「對著一個刻意的決定每個工作階段催一次,那是噪音不是提醒」。
# 分種類的判定在寫佇列那一邊(那裡讀得到 spec_key),這裡只照它標好的種類決定怎麼印。
_rows=$(awk -F"$TAB" '$1 == "overdue" || $1 == "remind" { n++ } END { print n + 0 }' "$QUEUE" 2>/dev/null)
case "${_rows:-}" in ''|*[!0-9]*) _rows=0 ;; esac
_builtin=$(awk -F"$TAB" '$1 == "builtin" { n++ } END { print n + 0 }' "$QUEUE" 2>/dev/null)
case "${_builtin:-}" in ''|*[!0-9]*) _builtin=0 ;; esac
_stale=0
if [ -n "$_age" ] && [ "$_age" -gt "$_ttl" ]; then _stale=1; fi
[ "$_hbstate" = fresh ] || _stale=1
if [ "$_rows" -eq 0 ] && [ "$_failing" -eq 0 ] && [ "$_builtin" -eq 0 ]; then
# 佇列是新的而且空的:這才是真的「沒有提醒」,安靜退出。
# 過期又空的那一種要分兩路。待辦簿有東西,就代表「有事而現在沒有人在算它到期沒到期」,
# 那要說一句;零筆就只是助理閒著,人自己按停也算這一種——對著一個刻意的決定每個工作階段
# 催一次,那是噪音不是提醒。
if [ "$_stale" -eq 1 ] && [ "$_tasks" -gt 0 ]; then
_agetxt='年紀算不出來'
[ -n "$_age" ] && _agetxt="$(( _age / 60 )) 分鐘前"
echo "[jsc] 助理的提醒清單是 ${_iso:-未知時間}($_agetxt)那一輪算的,那一輪沒有提醒;心跳現在是「${_hbstate:-讀不到}」。待辦簿還有 $_tasks 筆,**助理停著的時候沒有人再判它們到期了沒有**,所以這裡安靜不等於沒事。要現況就跑 /jsc-assist:assistant status。"
fi
# 記號一律記:不記的話同一個工作階段每次觸發都會再讀一次、再說一次同一句話。
mark_done
exit 0
fi
if [ "$_stale" -eq 1 ]; then
_agetxt='年紀算不出來'
[ -n "$_age" ] && _agetxt="$(( _age / 60 )) 分鐘前算的"
echo "[jsc] 下面這批提醒是 ${_iso:-未知時間}($_agetxt)那一輪算出來的,助理現在的心跳是「${_hbstate:-讀不到}」,門檻 $_ttl 秒。**助理停著的時候這份清單不會更新**,所以它現在說什麼都只描述那一輪,不描述現在。要現況就跑 /jsc-assist:assistant status。"
fi
# 逾期排前面:那幾筆的截止時間已經過了,比「該做了」更急。
#
# 先把兩種併成一份有序清單,再用 head 截筆數,不在迴圈裡自己數。
# 理由是那個迴圈接在管線後面,殼會把它放進子殼跑——在子殼裡加的計數,回到外面就沒了,
# 於是那道「只列前幾筆」的上限看起來寫了,實際上一次都沒生效。
_ordered=$(
awk -F"$TAB" '$1 == "overdue" { print }' "$QUEUE" 2>/dev/null
awk -F"$TAB" '$1 == "remind" { print }' "$QUEUE" 2>/dev/null
)
printf '%s\n' "$_ordered" | head -n "$MAX_ROWS" | while IFS="$TAB" read -r _k _id _why _title; do
[ -n "$_id" ] || continue
if [ "$_k" = overdue ]; then
echo "[jsc] 逾期 $_id:${_title:--} —— 已經逾期 ${_why:--}"
else
echo "[jsc] 到期 $_id:${_title:--} —— ${_why:--}"
fi
done
if [ "$_rows" -gt "$MAX_ROWS" ]; then
echo "[jsc] 這一批共 $_rows 筆,上面只列了 $MAX_ROWS 筆。其餘的跑 /jsc-assist:assistant status 看得到全部。"
fi
# 內建項一行講完,逐筆的內容留在監控頁與狀態查詢那邊。
# 這一行會一直出現,直到那幾筆有執行入口或被改掉——講明它是常態,讀的人才不會每次都
# 當成新消息。
if [ "$_builtin" -gt 0 ]; then
echo "[jsc] 另有 $_builtin 筆委派清單的內建檢查項到期,動作是只提醒、還沒有執行入口,所以每一輪都會再到期一次。**這一行會一直出現,直到那幾筆接上入口或被改掉**——要逐筆看就跑 /jsc-assist:assistant status。"
fi
if [ "$_failing" -gt 0 ]; then
echo "[jsc] 另有 $_failing 筆待辦連續失敗,每一輪都在重試而且不會自動暫停。跑 /jsc-assist:assistant status 看是哪幾筆。"
fi
mark_done
exit 0
+22 -10
View File
@@ -18,10 +18,13 @@
# restart 給接不到 session id 的 CLI(kiro)。那些 CLI 的紀錄共用 default, # restart 給接不到 session id 的 CLI(kiro)。那些 CLI 的紀錄共用 default,
# 不覆寫就會把上一個工作階段的起始時間算進來,花費時間虛胖。 # 不覆寫就會把上一個工作階段的起始時間算進來,花費時間虛胖。
# #
# 這兩個子命令另外兼一件事:判定為「新的工作階段」時清除部署後的重啟閘門 # restart 另外清掉提醒記號($JSC_HOME/sessions/{代號}.reminded):那個記號讓提醒一個工作
# (restart-gate.sh clear)。新工作階段代表 CLI 行程是新起的,新版技能組一定已經載入。 # 階段只提一次,而共用 default 代號的 CLI 不清就等於只提第一次、往後永遠不提。
# 判準只有這裡知道——start 分支的「起始檔不存在」就是這個 session id 第一次開始, #
# 所以清除掛在這裡,不在 restart-gate.sh 裡自己再判一次。 # 這兩個子命令另外兼一件事:工作階段開始時問一次要不要放下部署後的重啟閘門
# (restart-gate.sh clear)。**只是問,判定不在這裡。** 這裡曾經自己判過,用的是「起始檔
# 不存在=行程是新起的」,而那個等式不成立:還沒重啟的工作階段生出來的子行程拿到的也是沒
# 見過的代號。判準改成看行程還活著沒有,寫在 restart-gate.sh 的「清除時機」。
# 清除的範圍是「跑到這一支腳本的那個 CLI 自己那一份狀態檔」,由 restart-gate.sh clear 認定, # 清除的範圍是「跑到這一支腳本的那個 CLI 自己那一份狀態檔」,由 restart-gate.sh clear 認定,
# 這裡不必也不能過問:這個工作階段開始的只有一支 CLI,別支沒重啟,閘門要留著。 # 這裡不必也不能過問:這個工作階段開始的只有一支 CLI,別支沒重啟,閘門要留著。
HERE=$(dirname "$0"); . "$HERE/lib.sh" HERE=$(dirname "$0"); . "$HERE/lib.sh"
@@ -31,22 +34,31 @@ sid=$(session_id)
# 放下這一支 CLI 的部署後重啟閘門。狀態檔的路徑、範圍與格式只留在 restart-gate.sh, # 放下這一支 CLI 的部署後重啟閘門。狀態檔的路徑、範圍與格式只留在 restart-gate.sh,
# 這裡不碰檔案,所以改成一支 CLI 一份狀態檔之後這裡不用跟著改。 # 這裡不碰檔案,所以改成一支 CLI 一份狀態檔之後這裡不用跟著改。
# 工作階段代號要帶進去:那一邊沒有標準輸入可讀,自己算會退回環境變數,跟這裡算出來的可能
# 不是同一個值。
# 一律 </dev/null:clear 不讀標準輸入,但這裡的標準輸入是宿主餵進來的管線,不關掉會卡住。 # 一律 </dev/null:clear 不讀標準輸入,但這裡的標準輸入是宿主餵進來的管線,不關掉會卡住。
clear_restart_gate() { clear_restart_gate() {
sh "$HERE/restart-gate.sh" clear </dev/null 2>/dev/null || true sh "$HERE/restart-gate.sh" clear "$sid" </dev/null 2>/dev/null || true
} }
case "${1:-mark}" in case "${1:-mark}" in
start) start)
f="$JSC_HOME/sessions/$sid.start" f="$JSC_HOME/sessions/$sid.start"
if [ ! -f "$f" ]; then # 起始檔只補不覆寫:它是這個工作階段的計時起點,重寫會把已經累積的時間歸零。
now_epoch > "$f" [ -f "$f" ] || now_epoch > "$f"
clear_restart_gate # 起始檔不存在=這個工作階段第一次開始,也就是行程新起的那一次 # 閘門一律問,不再由「起始檔在不在」決定要不要問。理由見 restart-gate.sh 的「清除時機」:
fi ;; # 那個條件兩頭都會答錯——續接原代號的 resume 行程確實換過卻不會問,而還沒重啟的工作階段
# 生出來的子行程拿到沒見過的代號、一問就把閘門清掉。判定改由那一邊看行程存活決定。
clear_restart_gate ;;
restart) restart)
now_epoch > "$JSC_HOME/sessions/$sid.start" now_epoch > "$JSC_HOME/sessions/$sid.start"
rm -f "$JSC_HOME/sessions/$sid.end" rm -f "$JSC_HOME/sessions/$sid.end"
clear_restart_gate ;; # 接不到 session id 的 CLI 每次工作階段開始都算新的,一律清 # 提醒記號一起清。那個記號讓提醒一個工作階段只提一次,而接不到 session id 的 CLI
# 全部共用 default 這一個代號——不清的話第一個工作階段提過之後,往後每一個工作階段
# 都會被當成「已經提過」,那支 CLI 從此再也收不到任何提醒。
# 清除掛在這裡不掛在提醒那一支:「這是不是新的工作階段」的判準只有這一支知道。
rm -f "$JSC_HOME/sessions/$sid.reminded"
clear_restart_gate ;; # 走的是同一道判定:清不清由行程存活決定,不由這裡斷言
mark) mark)
now_epoch > "$JSC_HOME/sessions/$sid.end" ;; now_epoch > "$JSC_HOME/sessions/$sid.end" ;;
report) report)
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "jsc-hooks", "name": "jsc-hooks",
"version": "0.4.7", "version": "0.5.1",
"description": "跨 CLI hooks:STE100 語言強制、工時計時、技能用量記錄、SDLC 模型鎖、版本前置檢查、註解範圍守門、繁中編碼守門、部署後強制重啟、寫入與提交閘門", "description": "跨 CLI hooks:STE100 語言強制、工時計時、技能用量記錄、SDLC 模型鎖、版本前置檢查、註解範圍守門、繁中編碼守門、部署後強制重啟、寫入與提交閘門",
"skills": "./skills/", "skills": "./skills/",
"jsc": { "jsc": {
+1 -1
View File
@@ -6,7 +6,7 @@
| 項目 | 內容 | | 項目 | 內容 |
| --- | --- | | --- | --- |
| 觸發時機 | 裝好或更新完 jsc 技能組之後,要把九支 hook 接線到每一支已安裝的 CLI 時用;`jsc-cli:deploy` 收尾會把偵測到的 CLI 清單交給它。不用於撰寫新的 hook,也不用於單獨修一支壞掉的 hook,那是 `jsc-hooks:repair` 的事 | | 觸發時機 | 裝好或更新完 jsc 技能組之後,要把十支 hook 接線到每一支已安裝的 CLI 時用;`jsc-cli:deploy` 收尾會把偵測到的 CLI 清單交給它。不用於撰寫新的 hook,也不用於單獨修一支壞掉的 hook,那是 `jsc-hooks:repair` 的事 |
| 關鍵步驟 | 先跑前置步驟解出兩個字面絕對路徑:`readlink -f "$JSC_HOME/current"` 解出連結農場根目錄(跨 domain 呼叫用它),`readlink -f "$JSC_HOME/current/jsc-hooks"` 解出 jsc-hooks 的實體根目錄(只有 `wire-cli.sh` 從這裡跑。它會改寫自己正踩著的那條連結,但它自己已經把 `HERE` 與 `ROOT` 解成實體路徑,`ln -sfn` 不會再把連結指向自己,所以從實體根目錄跑現在是多一層保險、不是唯一防線;照做的理由是舊版腳本還在別的機器上跑,那些版本走連結跑仍會把連結寫成指向自己、全機器 hook 一起失效)、兩個路徑各解一次不重解、各自在同一步用 `[ -d ]` 查過印出來的目錄真的存在(`JSC_HOME` 沒設時第一條會印出 `/current`、結束碼 0,非空又是絕對路徑,只查前三項擋不下來),任何一條解不出來、不是絕對路徑、或目錄不存在就停手回報是哪一條沒解出來並叫人跑 `jsc-cli:deploy`,不接任何線也不猜路徑、不退回帶版本號的快取路徑、之後每一次腳本呼叫都用解出來的字面絕對路徑開頭、取得 CLI 清單(呼叫端交來的優先,沒有才自己跑 `detect-clis.sh`)、第一支 CLI 單獨跑完整條管線(它負責更新共用的 `{連結農場根}/jsc-hooks` 連結)、其餘 CLI 一支一個 sub agent 並行、每支 CLI 依序走 purge、接線、status、smoke、scan 五道關卡、讀每道關卡自己印的第一行判定、任一關卡出錯就寫 `ERROR_{HASH}` 並轉給 `jsc-hooks:repair`(異常頁與索引目錄頁分屬兩個存取庫,各自解析:異常頁由 `report-error.sh` 走 `wiki-repo ERROR`,目錄頁由 `wiki-contents.sh` 走 `wiki-repo CONTENTS`;只解不出目錄頁的存取庫時異常頁照寫、索引跳過,回報要講明那一頁沒被索引)、目錄頁上這一筆是一個 H2 區塊,標題就是異常頁頁名,時間、頁名、存取庫名稱、觸發 hook、退出碼、摘要各一條條列,寫成 `- {欄位名}:{值}`,頁上不留 markdown 表格、「頁名」那一條指向異常頁的連結一律寫成 `[{文字}]({連結})`,網址取 `gitea.sh wiki-url`,寫進去之前先過 `jsc-gitea/tools/link-check.sh`,結束碼 0 才寫連結、驗不過那一條只留純文字頁名而那一條與異常頁照寫(`report-error.sh` 內部做完,結束碼不變)、目錄頁的讀回、比對與整頁寫回一律交給 `jsc-gitea/tools/wiki-contents.sh upsert ERROR 2 {頁名} {區塊檔} {範本}`,`report-error.sh` 只組自己那一個區塊,找得到同名 H2 就整塊換掉、找不到就附加到頁尾、逐 CLI 回報五道關卡的結果、最後由主代理呼叫一次 `tools/report-status.sh skill-end jsc-hooks:hooks-install {status} {結束碼} {detail}` 記下整輪怎麼結束。腳本在同一個存取庫,用 `tools/` 相對路徑;這一筆只由主代理寫一次,寫在並行的各 CLI sub agent 裡會變成五筆互相矛盾的結局。腳本不在就安靜跳過,回報失敗不得變成接線失敗 | | 關鍵步驟 | 先跑前置步驟解出兩個字面絕對路徑:`readlink -f "$JSC_HOME/current"` 解出連結農場根目錄(跨 domain 呼叫用它),`readlink -f "$JSC_HOME/current/jsc-hooks"` 解出 jsc-hooks 的實體根目錄(只有 `wire-cli.sh` 從這裡跑。它會改寫自己正踩著的那條連結,但它自己已經把 `HERE` 與 `ROOT` 解成實體路徑,`ln -sfn` 不會再把連結指向自己,所以從實體根目錄跑現在是多一層保險、不是唯一防線;照做的理由是舊版腳本還在別的機器上跑,那些版本走連結跑仍會把連結寫成指向自己、全機器 hook 一起失效)、兩個路徑各解一次不重解、各自在同一步用 `[ -d ]` 查過印出來的目錄真的存在(`JSC_HOME` 沒設時第一條會印出 `/current`、結束碼 0,非空又是絕對路徑,只查前三項擋不下來),任何一條解不出來、不是絕對路徑、或目錄不存在就停手回報是哪一條沒解出來並叫人跑 `jsc-cli:deploy`,不接任何線也不猜路徑、不退回帶版本號的快取路徑、之後每一次腳本呼叫都用解出來的字面絕對路徑開頭、取得 CLI 清單(呼叫端交來的優先,沒有才自己跑 `detect-clis.sh`)、第一支 CLI 單獨跑完整條管線(它負責更新共用的 `{連結農場根}/jsc-hooks` 連結)、其餘 CLI 一支一個 sub agent 並行、每支 CLI 依序走 purge、接線、status、smoke、scan 五道關卡、讀每道關卡自己印的第一行判定、任一關卡出錯就寫 `ERROR_{HASH}` 並轉給 `jsc-hooks:repair`(異常頁與索引目錄頁分屬兩個存取庫,各自解析:異常頁由 `report-error.sh` 走 `wiki-repo ERROR`,目錄頁由 `wiki-contents.sh` 走 `wiki-repo CONTENTS`;只解不出目錄頁的存取庫時異常頁照寫、索引跳過,回報要講明那一頁沒被索引)、目錄頁上這一筆是一個 H2 區塊,標題就是異常頁頁名,時間、頁名、存取庫名稱、觸發 hook、退出碼、摘要各一條條列,寫成 `- {欄位名}:{值}`,頁上不留 markdown 表格、「頁名」那一條指向異常頁的連結一律寫成 `[{文字}]({連結})`,網址取 `gitea.sh wiki-url`,寫進去之前先過 `jsc-gitea/tools/link-check.sh`,結束碼 0 才寫連結、驗不過那一條只留純文字頁名而那一條與異常頁照寫(`report-error.sh` 內部做完,結束碼不變)、目錄頁的讀回、比對與整頁寫回一律交給 `jsc-gitea/tools/wiki-contents.sh upsert ERROR 2 {頁名} {區塊檔} {範本}`,`report-error.sh` 只組自己那一個區塊,找得到同名 H2 就整塊換掉、找不到就附加到頁尾、逐 CLI 回報五道關卡的結果、最後由主代理呼叫一次 `tools/report-status.sh skill-end jsc-hooks:hooks-install {status} {結束碼} {detail}` 記下整輪怎麼結束。腳本在同一個存取庫,用 `tools/` 相對路徑;這一筆只由主代理寫一次,寫在並行的各 CLI sub agent 裡會變成五筆互相矛盾的結局。腳本不在就安靜跳過,回報失敗不得變成接線失敗 |
| 外部呼叫 | `readlink -f`(前置步驟解兩個根目錄,各一次)、`tools/wire-cli.sh purge`、`tools/wire-cli.sh {cli}`、`tools/wire-cli.sh status`、`tools/wire-cli.sh smoke`、`tools/scan-hook-errors.sh`、`tools/report-error.sh`、`jsc-cli/tools/detect-clis.sh`、`jsc-hooks:repair` 技能、`jsc-gitea:wiki`(寫 `ERROR_{HASH}` 時經 `report-error.sh`)、`jsc-gitea/tools/gitea.sh wiki-url` 與 `jsc-gitea/tools/link-check.sh`(同樣經 `report-error.sh`,取目錄頁那一條的網址並驗它連得到)、`jsc-gitea/tools/wiki-contents.sh upsert`(同樣經 `report-error.sh`,把那一個 H2 區塊 upsert 進索引目錄頁);接線腳本內部另呼叫 `hooks/skill-name.sh` 與 `hooks/deny.sh` 做冒煙斷言 | | 外部呼叫 | `readlink -f`(前置步驟解兩個根目錄,各一次)、`tools/wire-cli.sh purge`、`tools/wire-cli.sh {cli}`、`tools/wire-cli.sh status`、`tools/wire-cli.sh smoke`、`tools/scan-hook-errors.sh`、`tools/report-error.sh`、`jsc-cli/tools/detect-clis.sh`、`jsc-hooks:repair` 技能、`jsc-gitea:wiki`(寫 `ERROR_{HASH}` 時經 `report-error.sh`)、`jsc-gitea/tools/gitea.sh wiki-url` 與 `jsc-gitea/tools/link-check.sh`(同樣經 `report-error.sh`,取目錄頁那一條的網址並驗它連得到)、`jsc-gitea/tools/wiki-contents.sh upsert`(同樣經 `report-error.sh`,把那一個 H2 區塊 upsert 進索引目錄頁);接線腳本內部另呼叫 `hooks/skill-name.sh` 與 `hooks/deny.sh` 做冒煙斷言 |
| 完成條件 | 前置步驟解出的兩個根目錄都是一條存在的絕對路徑(各自用 `[ -d ]` 查過),而且整個流程沒有任何一次腳本呼叫帶著未展開的變數或波浪號,每一支偵測到的 CLI 都有五道關卡各一行判定,沒有任何一道回結束碼 2,smoke 的 `lines` 條數與它自己的斷言相符,claude、codex、copilot、antigravity 回 `wired` 而 kiro 回 `degraded`(CLI 擋不下技能叫用),四支非 claude 的執行期錯誤掃描一律據實回 `unavailable`,各 CLI 的形狀與觸發驗證等級分開寫進回報(codex、antigravity、kiro 形狀實證,copilot 形狀未證;kiro 觸發部分實證,其餘未驗證),每一筆錯誤都帶一個 `ERROR_{HASH}` 結果與一條對 `develop` 的修正 PR 連結,而且目錄頁那一條的連結驗不過時,回報要講明那一條只有純文字頁名、沒有連結 | | 完成條件 | 前置步驟解出的兩個根目錄都是一條存在的絕對路徑(各自用 `[ -d ]` 查過),而且整個流程沒有任何一次腳本呼叫帶著未展開的變數或波浪號,每一支偵測到的 CLI 都有五道關卡各一行判定,沒有任何一道回結束碼 2,smoke 的 `lines` 條數與它自己的斷言相符,claude、codex、copilot、antigravity 回 `wired` 而 kiro 回 `degraded`(CLI 擋不下技能叫用),四支非 claude 的執行期錯誤掃描一律據實回 `unavailable`,各 CLI 的形狀與觸發驗證等級分開寫進回報(codex、antigravity、kiro 形狀實證,copilot 形狀未證;kiro 觸發部分實證,其餘未驗證),每一筆錯誤都帶一個 `ERROR_{HASH}` 結果與一條對 `develop` 的修正 PR 連結,而且目錄頁那一條的連結驗不過時,回報要講明那一條只有純文字頁名、沒有連結 |
+1 -1
View File
@@ -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. - 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`. - `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}<TAB>{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. - `hooks/skill-name.sh` is the one place that turns a CLI's hook payload into `{domain}<TAB>{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. - `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. - `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. - 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.
+3 -2
View File
@@ -18,7 +18,7 @@
# #
# 產出: 每筆錯誤附加一行 JSON 到 $JSC_HOME/errors/hooks.jsonl(格式比照 hooks/skill-usage.sh): # 產出: 每筆錯誤附加一行 JSON 到 $JSC_HOME/errors/hooks.jsonl(格式比照 hooks/skill-usage.sh):
# {ts,cli,hook,event,exit,detail,jsc} # {ts,cli,hook,event,exit,detail,jsc}
# jsc 欄位:command 或 stderr 命中九支 hook 腳本任一支,或命中 jsc-hooks 路徑,就是 true, # jsc 欄位:command 或 stderr 命中十支 hook 腳本任一支,或命中 jsc-hooks 路徑,就是 true,
# 否則 false。分得出來才用得上——非 jsc 的 hook 錯誤不是 jsc 該修的,hooks-install 只回報、 # 否則 false。分得出來才用得上——非 jsc 的 hook 錯誤不是 jsc 該修的,hooks-install 只回報、
# 不轉 jsc-hooks:repair。 # 不轉 jsc-hooks:repair。
# #
@@ -52,7 +52,7 @@ case "$cli" in
codex|copilot|antigravity|kiro) codex|copilot|antigravity|kiro)
printf 'status=unavailable reason=%s\n' "$cli 沒有 hook 結果紀錄,執行期錯誤掃不到" printf 'status=unavailable reason=%s\n' "$cli 沒有 hook 結果紀錄,執行期錯誤掃不到"
echo "[jsc] $cli:原生紀錄只留工作階段與提示內容,沒有記下 hook 的退出碼與 stderr。" echo "[jsc] $cli:原生紀錄只留工作階段與提示內容,沒有記下 hook 的退出碼與 stderr。"
echo "[jsc] $cli:改跑 tools/wire-cli.sh smoke $cli,主動執行九支 hook 驗執行期。" echo "[jsc] $cli:改跑 tools/wire-cli.sh smoke $cli,主動執行十支 hook 驗執行期。"
exit 0 ;; exit 0 ;;
esac esac
@@ -98,6 +98,7 @@ extract_errors() {
# 第三方的,只回報不修正。 # 第三方的,只回報不修正。
function is_jsc(t) { function is_jsc(t) {
if (index(t, "ste100-guard.sh") || index(t, "session-timer.sh") \ if (index(t, "ste100-guard.sh") || index(t, "session-timer.sh") \
|| index(t, "session-reminder.sh") \
|| index(t, "skill-usage.sh") || index(t, "sdlc-gate.sh") \ || index(t, "skill-usage.sh") || index(t, "sdlc-gate.sh") \
|| index(t, "version-guard.sh") || index(t, "restart-gate.sh") \ || index(t, "version-guard.sh") || index(t, "restart-gate.sh") \
|| index(t, "comment-scope.sh") || index(t, "lang-guard.sh") \ || index(t, "comment-scope.sh") || index(t, "lang-guard.sh") \
+93 -23
View File
@@ -3,7 +3,7 @@
# 用法: # 用法:
# wire-cli.sh {claude|codex|copilot|antigravity|kiro} 接線 # wire-cli.sh {claude|codex|copilot|antigravity|kiro} 接線
# wire-cli.sh purge {claude|codex|copilot|antigravity|kiro} 備份後移除該 CLI 的所有 hook # wire-cli.sh purge {claude|codex|copilot|antigravity|kiro} 備份後移除該 CLI 的所有 hook
# wire-cli.sh smoke {claude|codex|copilot|antigravity|kiro} 跑一輪九支 hook,驗執行期 # wire-cli.sh smoke {claude|codex|copilot|antigravity|kiro} 跑一輪十支 hook,驗執行期
# wire-cli.sh status {claude|codex|copilot|antigravity|kiro} [--verdict] # wire-cli.sh status {claude|codex|copilot|antigravity|kiro} [--verdict]
# 唯讀盤點接線現況,不寫檔也不執行 hook。 # 唯讀盤點接線現況,不寫檔也不執行 hook。
# --verdict 只換結束碼語意,輸出一字不變 # --verdict 只換結束碼語意,輸出一字不變
@@ -37,7 +37,7 @@
# 就好,不必在 SKILL.md 或 README 各抄一份——抄了就會在加減判定路徑時漂移。 # 就好,不必在 SKILL.md 或 README 各抄一份——抄了就會在加減判定路徑時漂移。
# #
# 接線行為(依 CLI 而定,皆為冪等:重跑只取代既有的 jsc 段落,不會重複疊加): # 接線行為(依 CLI 而定,皆為冪等:重跑只取代既有的 jsc 段落,不會重複疊加):
# claude — 什麼都不用寫,hooks/hooks.json 已自動接線九支 hook(PreToolUse matcher Skill) # claude — 什麼都不用寫,hooks/hooks.json 已自動接線十支 hook(PreToolUse matcher Skill)
# codex — pre-tool hook 寫在 .codex-plugin/plugin.json 的 hooks 鍵,做 Codex 專屬覆寫 # codex — pre-tool hook 寫在 .codex-plugin/plugin.json 的 hooks 鍵,做 Codex 專屬覆寫
# (PreToolUse matcher Bash,接 restart-gate.sh 與 version-guard.sh)。 # (PreToolUse matcher Bash,接 restart-gate.sh 與 version-guard.sh)。
# Claude 用的 hooks/hooks.json 完全不動:那一份是 Skill matcher,而 Codex # Claude 用的 hooks/hooks.json 完全不動:那一份是 Skill matcher,而 Codex
@@ -74,7 +74,7 @@
# #
# 覆蓋範圍要據實回報,不得暗示每個 CLI 都有保護,也不得再說「沒有 pre-tool hook」—— # 覆蓋範圍要據實回報,不得暗示每個 CLI 都有保護,也不得再說「沒有 pre-tool hook」——
# 五支裡有四支都有,先前失效的原因是接錯位置,不是沒有位置可接: # 五支裡有四支都有,先前失效的原因是接錯位置,不是沒有位置可接:
# claude 九支 hook 全接,回報 wired # claude 十支 hook 全接,回報 wired
# codex、copilot、antigravity 版本前置檢查與部署後重啟閘門都擋得下來,回報 wired。 # codex、copilot、antigravity 版本前置檢查與部署後重啟閘門都擋得下來,回報 wired。
# SDLC 模型鎖仍是技能步驟檢查,write-guard.sh 三種模式尚未接線, # SDLC 模型鎖仍是技能步驟檢查,write-guard.sh 三種模式尚未接線,
# 註解範圍與繁中編碼仍是 sweep,reason 要逐項講明。 # 註解範圍與繁中編碼仍是 sweep,reason 要逐項講明。
@@ -282,6 +282,19 @@ codex_derive_hooks() { # $1=來源 hooks.json
# 條目形態照這台機器上既有的那一筆第三方設定(type、bash、timeoutSec),不套 Claude 的形狀。 # 條目形態照這台機器上既有的那一筆第三方設定(type、bash、timeoutSec),不套 Claude 的形狀。
# 指令前綴帶 JSC_CLI=copilot:閘門靠 cli_name() 認代號才取得到技能名與阻擋形態,沒設就一律 # 指令前綴帶 JSC_CLI=copilot:閘門靠 cli_name() 認代號才取得到技能名與阻擋形態,沒設就一律
# 安靜放行,matcher 對、位置對、卻一次都擋不下來。別名那條路只在走別名啟動時才成立,不能靠它。 # 安靜放行,matcher 對、位置對、卻一次都擋不下來。別名那條路只在走別名啟動時才成立,不能靠它。
# 列出一份檔案或一段設定裡引用到的 hook 腳本名,一行一個、去重。
#
# 這一支存在的理由是「接線寫了哪幾支」與「盤點驗了哪幾支」本來是兩份手寫清單。
# 2026-09-07 實測踩到:新加第十支 hook 之後,claude 與 kiro 的盤點點名得出它,
# codex、copilot、antigravity 三段的盤點卻各自只驗自己寫死的那兩支——那三支的接線
# 盤點看不出第十支在不在,而 smoke 那一行還自稱「十支 hook」。兩句話都是真的,
# 範圍不同,讀的人分不出來。
# 改成從接線那一邊實際會寫出去的內容抽名字,兩邊就只有一份清單:往後加一支 hook,
# 盤點自動跟著驗,不必記得回來改第二個地方。
hook_scripts_of() { # 讀標準輸入,印出 {名稱}.sh
grep -oE '[a-z][a-z0-9-]*\.sh' 2>/dev/null | sort -u
}
copilot_hook_entries() { copilot_hook_entries() {
cat <<JSCEOF cat <<JSCEOF
{ {
@@ -544,7 +557,8 @@ kiro_agent_json() {
], ],
"hooks": { "hooks": {
"agentSpawn": [ "agentSpawn": [
{ "command": "JSC_CLI=kiro sh \\"$WIRE_HOOKS/session-timer.sh\\" restart </dev/null", "timeout_ms": 10000 } { "command": "JSC_CLI=kiro sh \\"$WIRE_HOOKS/session-timer.sh\\" restart </dev/null", "timeout_ms": 10000 },
{ "command": "JSC_CLI=kiro sh \\"$WIRE_HOOKS/session-reminder.sh\\" </dev/null", "timeout_ms": 10000 }
], ],
"userPromptSubmit": [ "userPromptSubmit": [
{ "command": "JSC_CLI=kiro sh \\"$WIRE_HOOKS/restart-gate.sh\\"", "timeout_ms": 10000 }, { "command": "JSC_CLI=kiro sh \\"$WIRE_HOOKS/restart-gate.sh\\"", "timeout_ms": 10000 },
@@ -1387,7 +1401,7 @@ if [ "$action" = smoke ]; then
smoke_n_sn=0; smoke_n_dn=0; smoke_n_cx=0; smoke_n_sh=0 smoke_n_sn=0; smoke_n_dn=0; smoke_n_cx=0; smoke_n_sh=0
# 預期條數(改動判定路徑時一起改):每一類都要有自己的計數器,印得出結果行卻沒人計數的 # 預期條數(改動判定路徑時一起改):每一類都要有自己的計數器,印得出結果行卻沒人計數的
# 那一類會讓總數永遠對不上,斷言也就形同虛設。 # 那一類會讓總數永遠對不上,斷言也就形同虛設。
# hook 模式 九支 hook 的每個接線模式各一條。sdlc-gate.sh、comment-scope.sh、 # hook 模式 十支 hook 的每個接線模式各一條。sdlc-gate.sh、comment-scope.sh、
# lang-guard.sh 與 write-guard.sh 各有多個模式,所以比 hook 支數多 # lang-guard.sh 與 write-guard.sh 各有多個模式,所以比 hook 支數多
# 模型與階段鎖 sdlc-gate.sh 取模型代號的四條來源判定路徑,加上「不知道能力就擋下」的 # 模型與階段鎖 sdlc-gate.sh 取模型代號的四條來源判定路徑,加上「不知道能力就擋下」的
# 三種情形、check 的 fail-closed,以及階段鎖狀態檔的 CLI 區隔與舊格式相容 # 三種情形、check 的 fail-closed,以及階段鎖狀態檔的 CLI 區隔與舊格式相容
@@ -1399,10 +1413,10 @@ if [ "$action" = smoke ]; then
# 阻擋形態 deny.sh 四種形態(stderr 加 2、stdout deny JSON、kiro 注入、未知代號的保守預設) # 阻擋形態 deny.sh 四種形態(stderr 加 2、stdout deny JSON、kiro 注入、未知代號的保守預設)
# 跨 CLI 貫通 restart-gate.sh 吃五支 CLI 的真實負載,驗判定與輸出形態串得起來 # 跨 CLI 貫通 restart-gate.sh 吃五支 CLI 的真實負載,驗判定與輸出形態串得起來
# 接線形狀 每支 CLI 要寫出去的內容真的產出來一次,驗結構本身(正反案例各一組) # 接線形狀 每支 CLI 要寫出去的內容真的產出來一次,驗結構本身(正反案例各一組)
SMOKE_EXPECT_HOOK=17 SMOKE_EXPECT_HOOK=18
SMOKE_EXPECT_MODEL=12 SMOKE_EXPECT_MODEL=12
SMOKE_EXPECT_WP=6 SMOKE_EXPECT_WP=6
SMOKE_EXPECT_RS=16 SMOKE_EXPECT_RS=21
SMOKE_EXPECT_WG=21 SMOKE_EXPECT_WG=21
SMOKE_EXPECT_VG=13 SMOKE_EXPECT_VG=13
SMOKE_EXPECT_SN=10 SMOKE_EXPECT_SN=10
@@ -1450,6 +1464,7 @@ if [ "$action" = smoke ]; then
} }
smoke_one session-timer.sh mark smoke_one session-timer.sh mark
smoke_one session-reminder.sh
smoke_one sdlc-gate.sh check smoke_one sdlc-gate.sh check
smoke_one version-guard.sh smoke_one version-guard.sh
smoke_one restart-gate.sh smoke_one restart-gate.sh
@@ -1705,6 +1720,19 @@ if [ "$action" = smoke ]; then
"$(printf '%s' "$_out" | tr '\n' ' ' | cut -c1-200)" >> "$smoke_out" "$(printf '%s' "$_out" | tr '\n' ' ' | cut -c1-200)" >> "$smoke_out"
fi 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 一份的重點就在「該留的留、該刪的刪」,只看結束碼看不出來。 # 狀態檔在不在也要比:一支 CLI 一份的重點就在「該留的留、該刪的刪」,只看結束碼看不出來。
smoke_rs_file() { # $1=情境 $2=狀態檔 $3=exist 或 absent smoke_rs_file() { # $1=情境 $2=狀態檔 $3=exist 或 absent
smoke_n_rs=$((smoke_n_rs + 1)) smoke_n_rs=$((smoke_n_rs + 1))
@@ -1727,6 +1755,7 @@ if [ "$action" = smoke ]; then
JSC_HOME="$rs_home" JSC_CLI="$cli" \ JSC_HOME="$rs_home" JSC_CLI="$cli" \
sh "$HOOKS/restart-gate.sh" require update hooks cli </dev/null 2>/dev/null sh "$HOOKS/restart-gate.sh" require update hooks cli </dev/null 2>/dev/null
smoke_rs_file "require 寫出當前 CLI 那一份" "$rs_dir/$cli" exist 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-sdlc:implement" jsc-sdlc:implement deny
smoke_rs_case "當前 CLI 那份存在,豁免技能 jsc-cli:deploy" jsc-cli:deploy 0 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-gitea:wiki" jsc-gitea:wiki 0
@@ -1734,13 +1763,39 @@ if [ "$action" = smoke ]; then
smoke_rs_case "當前 CLI 那份存在,豁免技能 jsc-meta:skill-check" jsc-meta:skill-check 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 "逃生門 JSC_RESTART_GATE=off" jsc-sdlc:implement 0 off
smoke_rs_case "取不到技能名" "" 0 smoke_rs_case "取不到技能名" "" 0
# 清除機制:session-timer.sh 判定為新工作階段時會呼叫 restart-gate.sh clear。 # 清除機制:session-timer.sh 在工作階段開始時呼叫 restart-gate.sh clear,清不清由那一邊
# 這裡走的就是那條路徑(暫時 $JSC_HOME 底下沒有起始檔,等同行程新起的第一次)。 # 看行程存活決定。三條路徑各驗一次,理由見 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 2>/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 \ JSC_HOME="$rs_home" JSC_CLI="$cli" JSC_SESSION_ID=smoke-restart \
sh "$HOOKS/session-timer.sh" start </dev/null 2>/dev/null sh "$HOOKS/session-timer.sh" start </dev/null 2>/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_file "清除不動別支 CLI 那一份" "$rs_dir/$rs_other" exist
smoke_rs_case "清除後放行" jsc-sdlc:implement 0 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 2>/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 2>/dev/null
smoke_rs_file "追不到行程代號但有結束記號,清掉" "$rs_dir/$cli" absent
# 舊格式的單一狀態檔(過渡相容):沒有 per-CLI 資訊,所以一律擋,clear 一併刪掉。 # 舊格式的單一狀態檔(過渡相容):沒有 per-CLI 資訊,所以一律擋,clear 一併刪掉。
printf 'at=%s\nmode=update\ndomains=hooks\ncli=%s\n' "$(now_iso)" "$rs_other" \ printf 'at=%s\nmode=update\ndomains=hooks\ncli=%s\n' "$(now_iso)" "$rs_other" \
> "$rs_home/restart-required" 2>/dev/null > "$rs_home/restart-required" 2>/dev/null
@@ -2240,7 +2295,7 @@ if [ "$action" = smoke ]; then
fi fi
if [ "$smoke_fails" -eq 0 ]; then if [ "$smoke_fails" -eq 0 ]; then
printf 'status=ok reason=%s\n' "九支 hook 的每個接線模式都跑得完,模型與階段鎖、工作包歸屬、部署後重啟閘門、寫入提交閘門、相依版本檢查、五支 CLI 的技能名解析、四種阻擋形態、跨 CLI 貫通與各 CLI 的接線形狀的每條路徑也各走過一次($smoke_breakdown),沒有執行期錯誤" printf 'status=ok reason=%s\n' "十支 hook 的每個接線模式都跑得完,模型與階段鎖、工作包歸屬、部署後重啟閘門、寫入提交閘門、相依版本檢查、五支 CLI 的技能名解析、四種阻擋形態、跨 CLI 貫通與各 CLI 的接線形狀的每條路徑也各走過一次($smoke_breakdown),沒有執行期錯誤"
printf 'lines\t%s\n' "$smoke_lines" printf 'lines\t%s\n' "$smoke_lines"
cat "$smoke_out"; rm -f "$smoke_out"; exit 0 cat "$smoke_out"; rm -f "$smoke_out"; exit 0
fi fi
@@ -2260,8 +2315,8 @@ if [ "$action" = status ]; then
st_missing=0 st_missing=0
st_degrade="" st_degrade=""
# 接上時的 reason 也要一支 CLI 一句:五支的覆蓋範圍不一樣,共用一句話就會把「codex 接了兩道閘門」 # 接上時的 reason 也要一支 CLI 一句:五支的覆蓋範圍不一樣,共用一句話就會把「codex 接了兩道閘門」
# 講成「codex 九支全接」。預設值給 claude,其餘各自在下面覆寫。 # 講成「codex 十支全接」。預設值給 claude,其餘各自在下面覆寫。
st_wired="hooks.json 自動接線全部九支 hook" st_wired="hooks.json 自動接線全部十支 hook"
# 記一個檢查點。$1=項目名 $2=路徑 $3=present|missing|unverified # 記一個檢查點。$1=項目名 $2=路徑 $3=present|missing|unverified
# #
@@ -2336,10 +2391,13 @@ if [ "$action" = status ]; then
claude_hooks="$claude_root/hooks/hooks.json" claude_hooks="$claude_root/hooks/hooks.json"
if [ -n "$claude_root" ] && [ -f "$claude_hooks" ]; then st_item hooks.json "$claude_hooks" present if [ -n "$claude_root" ] && [ -f "$claude_hooks" ]; then st_item hooks.json "$claude_hooks" present
else st_item hooks.json "${claude_hooks:-$HOME/.claude/plugins/installed_plugins.json}" missing; fi else st_item hooks.json "${claude_hooks:-$HOME/.claude/plugins/installed_plugins.json}" missing; fi
# 九支 hook 全靠這一個檔宣告,只看檔案在不在會漏掉「檔在、某支沒接進去」。 # 十支 hook 全靠這一個檔宣告,只看檔案在不在會漏掉「檔在、某支沒接進去」。
# 每一支都列一項,一支都不省。reason 那行講的是「全部九支」,列舉卻只挑幾支的話, # 每一支都列一項,一支都不省。reason 那行講的是「全部十支」,列舉卻只挑幾支的話,
# 拿這份輸出驗收接線的人會把沒列到的當成沒接——模型能力鎖就是這樣被誤判成沒接線的。 # 拿這份輸出驗收接線的人會把沒列到的當成沒接——模型能力鎖就是這樣被誤判成沒接線的。
for _h in comment-scope lang-guard restart-gate skill-usage version-guard; do # 別支 CLI 的那幾段同樣逐支點名,但名單各自取自「那一支的接線實際會寫出去的內容」,
# 因為每一支能接到的範圍不同:codex 拿的是從這一份推導出來的複本(十支全帶),
# copilot 與 antigravity 只接得上兩道閘門,其餘幾支在那兩支 CLI 上是沒有事件可掛。
for _h in comment-scope lang-guard restart-gate skill-usage session-reminder version-guard; do
if [ -f "$claude_hooks" ] && grep -qF "$_h.sh" "$claude_hooks" 2>/dev/null if [ -f "$claude_hooks" ] && grep -qF "$_h.sh" "$claude_hooks" 2>/dev/null
then st_item "$_h" "$claude_hooks" present then st_item "$_h" "$claude_hooks" present
else st_item "$_h" "$claude_hooks" missing; fi else st_item "$_h" "$claude_hooks" missing; fi
@@ -2416,7 +2474,9 @@ if [ "$action" = status ]; then
if [ -f "$cx_hooks" ] && grep -qF '"matcher": "Skill"' "$cx_hooks" 2>/dev/null if [ -f "$cx_hooks" ] && grep -qF '"matcher": "Skill"' "$cx_hooks" 2>/dev/null
then st_item codex-no-skill-matcher "$cx_hooks" missing then st_item codex-no-skill-matcher "$cx_hooks" missing
else st_item codex-no-skill-matcher "$cx_hooks" present; fi else st_item codex-no-skill-matcher "$cx_hooks" present; fi
for _g in restart-gate.sh version-guard.sh; do # 逐支對照來源那一份。codex 讀的是從 hooks/hooks.json 推導出來的複本,所以「該有哪幾支」
# 的答案在來源檔裡,不在這裡寫死——推導漏掉一支的時候,這一項才說得出是漏了哪一支。
for _g in $(hook_scripts_of <"$HOOKS/hooks.json"); do
if [ -f "$cx_hooks" ] && grep -qF "$_g" "$cx_hooks" 2>/dev/null if [ -f "$cx_hooks" ] && grep -qF "$_g" "$cx_hooks" 2>/dev/null
then st_item "codex-${_g%.sh}" "$cx_hooks" present then st_item "codex-${_g%.sh}" "$cx_hooks" present
else st_item "codex-${_g%.sh}" "$cx_hooks" missing; fi else st_item "codex-${_g%.sh}" "$cx_hooks" missing; fi
@@ -2444,6 +2504,13 @@ if [ "$action" = status ]; then
if copilot_single_case_ok "$cp_settings" if copilot_single_case_ok "$cp_settings"
then st_item single-event-case "$cp_settings" present then st_item single-event-case "$cp_settings" present
else st_item single-event-case "$cp_settings" missing; fi else st_item single-event-case "$cp_settings" missing; fi
# 逐支對照接線那一段自己會寫出去的條目。原本這一段一支 hook 都沒點名,只驗「hooks 鍵在」
# ——鍵在而某一支條目掉了,照樣回 present,那正是這一整支腳本一直在防的形態。
for _g in $(copilot_hook_entries | hook_scripts_of); do
if [ -f "$cp_settings" ] && grep -qF "$_g" "$cp_settings" 2>/dev/null
then st_item "${_g%.sh}" "$cp_settings" present
else st_item "${_g%.sh}" "$cp_settings" missing; fi
done
# CLI 代號也是接線的一部分,理由同 codex 那一項:取不到代號的閘門一律安靜放行。 # CLI 代號也是接線的一部分,理由同 codex 那一項:取不到代號的閘門一律安靜放行。
if [ -f "$cp_settings" ] && grep -qF 'JSC_CLI=copilot' "$cp_settings" 2>/dev/null if [ -f "$cp_settings" ] && grep -qF 'JSC_CLI=copilot' "$cp_settings" 2>/dev/null
then st_item cli-code "$cp_settings" present then st_item cli-code "$cp_settings" present
@@ -2481,7 +2548,8 @@ if [ "$action" = status ]; then
if antigravity_flat_ok "$ag_hooks" if antigravity_flat_ok "$ag_hooks"
then st_item preinvocation-flat "$ag_hooks" present then st_item preinvocation-flat "$ag_hooks" present
else st_item preinvocation-flat "$ag_hooks" missing; fi else st_item preinvocation-flat "$ag_hooks" missing; fi
for _g in restart-gate.sh version-guard.sh; do # 逐支對照接線那一段自己會寫出去的內容,不在這裡另寫一份清單。
for _g in $(antigravity_hooks_block | hook_scripts_of); do
if [ -f "$ag_hooks" ] && grep -qF "$_g" "$ag_hooks" 2>/dev/null if [ -f "$ag_hooks" ] && grep -qF "$_g" "$ag_hooks" 2>/dev/null
then st_item "${_g%.sh}" "$ag_hooks" present then st_item "${_g%.sh}" "$ag_hooks" present
else st_item "${_g%.sh}" "$ag_hooks" missing; fi else st_item "${_g%.sh}" "$ag_hooks" missing; fi
@@ -2529,7 +2597,7 @@ if [ "$action" = status ]; then
then st_item "event-$_e" "$kr_agent" present then st_item "event-$_e" "$kr_agent" present
else st_item "event-$_e" "$kr_agent" missing; fi else st_item "event-$_e" "$kr_agent" missing; fi
done done
for _g in session-timer.sh restart-gate.sh version-guard.sh ste100-guard.sh comment-scope.sh lang-guard.sh; do for _g in session-timer.sh session-reminder.sh restart-gate.sh version-guard.sh ste100-guard.sh comment-scope.sh lang-guard.sh; do
if [ -f "$kr_agent" ] && grep -qF "$_g" "$kr_agent" 2>/dev/null if [ -f "$kr_agent" ] && grep -qF "$_g" "$kr_agent" 2>/dev/null
then st_item "${_g%.sh}" "$kr_agent" present then st_item "${_g%.sh}" "$kr_agent" present
else st_item "${_g%.sh}" "$kr_agent" missing; fi else st_item "${_g%.sh}" "$kr_agent" missing; fi
@@ -2577,7 +2645,7 @@ case "$cli" in
done done
printf 'status=wired reason=%s\n' "hooks.json 自動接線" printf 'status=wired reason=%s\n' "hooks.json 自動接線"
echo "$WIRE_PATH_NOTE" echo "$WIRE_PATH_NOTE"
echo "[jsc] claude:由 hooks/hooks.json 自動接線全部九支 hook,無需寫入設定。" echo "[jsc] claude:由 hooks/hooks.json 自動接線全部十支 hook,無需寫入設定。"
echo "[jsc] claude:只有 claude 有 pre-tool hook,版本前置檢查、部署後重啟閘門與 write-guard.sh 的三種模式只在這裡擋得下來;其他四個 CLI 這幾道閘門都接不上。" echo "[jsc] claude:只有 claude 有 pre-tool hook,版本前置檢查、部署後重啟閘門與 write-guard.sh 的三種模式只在這裡擋得下來;其他四個 CLI 這幾道閘門都接不上。"
echo "[jsc] claude:只有 claude 有 post-tool hook,comment-scope.sh 與 lang-guard.sh 的逐檔即時掃描只在這裡接得上;其他四個 CLI 改用 sweep 掃整個工作區,時機晚一輪或晚到工作階段結束。" echo "[jsc] claude:只有 claude 有 post-tool hook,comment-scope.sh 與 lang-guard.sh 的逐檔即時掃描只在這裡接得上;其他四個 CLI 改用 sweep 掃整個工作區,時機晚一輪或晚到工作階段結束。"
exit 0 ;; exit 0 ;;
@@ -2632,9 +2700,11 @@ case "$cli" in
|| fail "$cx_hooks 沒有 Bash matcher,codex 沒有 Skill 工具,擋不到技能載入那一次" || fail "$cx_hooks 沒有 Bash matcher,codex 沒有 Skill 工具,擋不到技能載入那一次"
grep -qF '"matcher": "Skill"' "$cx_hooks" 2>/dev/null \ grep -qF '"matcher": "Skill"' "$cx_hooks" 2>/dev/null \
&& fail "$cx_hooks 還留著 Skill matcher,codex 沒有那個工具,那一組永遠不會被叫用" && fail "$cx_hooks 還留著 Skill matcher,codex 沒有那個工具,那一組永遠不會被叫用"
for _g in restart-gate.sh version-guard.sh; do # 逐支對照來源那一份,不只驗那兩道閘門:推導是整份複製再改 matcher,漏掉任何一支都算
# 推導壞了,而漏掉的那一支在 codex 上就是永遠不會被叫用。
for _g in $(hook_scripts_of <"$HOOKS/hooks.json"); do
grep -qF "$_g" "$cx_hooks" 2>/dev/null \ grep -qF "$_g" "$cx_hooks" 2>/dev/null \
|| fail "$cx_hooks 沒有接上 $_g,那道閘門在 codex 上不會生效" || fail "$cx_hooks 沒有接上 $_g,那一支在 codex 上不會生效(來源是 $HOOKS/hooks.json,推導漏了它)"
done done
# 代號單獨驗一項:少了它,兩道閘門認不出現在跑的是哪一支 CLI,技能名解不出來就整批安靜放行。 # 代號單獨驗一項:少了它,兩道閘門認不出現在跑的是哪一支 CLI,技能名解不出來就整批安靜放行。
grep -qF 'JSC_CLI=codex' "$cx_hooks" 2>/dev/null \ grep -qF 'JSC_CLI=codex' "$cx_hooks" 2>/dev/null \
@@ -2800,7 +2870,7 @@ case "$cli" in
# 兩層 glob 是技能看不看得到的關鍵:預設只掃一層,jsc 的技能在第二層。 # 兩層 glob 是技能看不看得到的關鍵:預設只掃一層,jsc 的技能在第二層。
grep -qF '/skills/*/*/SKILL.md' "$kr_agent" 2>/dev/null \ grep -qF '/skills/*/*/SKILL.md' "$kr_agent" 2>/dev/null \
|| fail "$kr_agent 的 resources 少了兩層 glob,jsc 技能一支都載不到" || fail "$kr_agent 的 resources 少了兩層 glob,jsc 技能一支都載不到"
for _s in session-timer.sh restart-gate.sh version-guard.sh ste100-guard.sh comment-scope.sh lang-guard.sh; do for _s in session-timer.sh session-reminder.sh restart-gate.sh version-guard.sh ste100-guard.sh comment-scope.sh lang-guard.sh; do
grep -qF "$_s" "$kr_agent" 2>/dev/null || fail "$kr_agent 的 hooks 沒有接到 $_s" grep -qF "$_s" "$kr_agent" 2>/dev/null || fail "$kr_agent 的 hooks 沒有接到 $_s"
done done
# 這個 agent 要被選用才算數,所以還要設 chat.defaultAgent。內建 agent 改不了, # 這個 agent 要被選用才算數,所以還要設 chat.defaultAgent。內建 agent 改不了,