65 Commits
Author SHA1 Message Date
admin bab45c0656 Merge pull request '釋出:逐支點名抽自接線那一邊,內建項提醒收成一行' (#94) from develop into master
Reviewed-on: #94
Reviewed-by: 系統管理員 <1+admin@noreply.localhost>
2026-09-07 05:45:01 +00: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 28fd6664b6 Merge pull request '釋出:逐支點名改成從接線那一邊抽名單' (#92) from develop into master
Reviewed-on: #92
Reviewed-by: 系統管理員 <1+admin@noreply.localhost>
2026-09-07 04:46:57 +00: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 d5c4f924ee Merge pull request '釋出:工作階段開始時帶出助理的未讀提醒' (#90) from develop into master
Reviewed-on: #90
Reviewed-by: 系統管理員 <1+admin@noreply.localhost>
2026-09-07 04:17:16 +00: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 e77c0afb4b Merge pull request '釋出:兩支技能補收尾事件,異常目錄頁改成 H2 區塊條列' (#88) from develop into master
Reviewed-on: #88
Reviewed-by: 系統管理員 <1+admin@noreply.localhost>
2026-09-07 01:25:40 +00: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
jiantw83andClaude Opus 5 5493539046 Merge develop
三份 manifest 取 develop 的版號再往上升一版。

行為清單那一節第二次解:另一張同樣動這一節的先合併了,所以基底換過,重新
比對一次。這一次 develop 那一邊多了收尾事件的說明,分支這一邊還是「目錄頁
從表格列改成大標題區塊」。逐列用差異區間比對過,兩邊在共同基底上動到的位置
仍然完全不重疊,所以照基底的座標把兩組改動一起套回去。

合完之後三方的內容都在:這張的區塊語意、另一張的收尾事件、develop 的根目錄
解析。技能本文這一次自動合併就過了,沒有衝突。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 09:16:22 +08:00
admin b7f9f1293e Merge pull request 'jsc-hooks 兩支技能補上收尾的 skill-end 事件' (#78) from feat/hooks-skill-end into develop
Reviewed-on: #78
2026-09-07 01:14:07 +00:00
jiantw83andClaude Opus 5 3f0e5df673 chore(plugin): 版號讓到 0.4.7
0.4.6 讓給另一張同時要合併的。兩張都動到行為清單同一節,順序定下來之後
這一張排第二,版號跟著讓開。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 09:11:21 +08:00
jiantw83andClaude Opus 5 7f6e0d9299 Merge develop
三份 manifest 取 develop 的版號再往上升一版:分支停在一個比 develop 舊的版號。

行為清單那一節四列兩邊都改過,但改的是不同層面:分支改「目錄頁怎麼寫」——索引
從表格列變成一個報告一個大標題區塊、寫入語意委派給共用工具;develop 改「根目錄
怎麼解」——前置步驟解出兩條字面絕對路徑再拿去叫工具。逐列用差異區間比對過,
兩邊在共同基底上動到的位置完全不重疊,所以照基底的座標把兩組改動一起套回去,
不是取其中一邊。

技能本文那一段同理:分支把整段改寫成區塊語意,develop 只在指令路徑前面補了
根目錄前綴。取分支那一版再補上前綴。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 09:10:57 +08:00
jiantw83andClaude Opus 5 f1255038f6 Merge develop
三份 manifest 取 develop 的版號再往上升一版:分支停在一個比 develop 舊的版號,
取它等於把版號往回退。

行為清單兩支技能的節整節取 develop 那一版,再把分支新增的收尾說明逐段原文
接回去。develop 那一邊在這條分支開出去之後改寫了根目錄解析與外部呼叫兩欄,
分支那一邊只在關鍵步驟與可驗證跡象兩欄的尾端附加收尾事件的說明——兩件事互不
相干,取任一邊都會弄丟另一邊。

解這一段時弄壞過一次:第一版拿字元級差異把新增片段逐塊疊回去,中文被切碎重組,
「決定結局的那支工具的結束碼」變成「決定結局的那工具的結束碼」,關鍵步驟那一
整列還整列消失。行為清單檢核抓到表格只剩四列才發現。改成整節從 develop 重取、
只接分支相對它自己基底的那一段尾端附加,切點在共同前綴的盡頭,不切中文。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 09:08:36 +08:00
admin c16d30a0cf Merge pull request '釋出:status 加 --verdict,把狀態與成敗兩種語意分開' (#87) from develop into master
Reviewed-on: #87
Reviewed-by: 系統管理員 <1+admin@noreply.localhost>
2026-09-04 11:17:03 +00:00
admin 27a3865e87 Merge pull request 'status 加 --verdict,把狀態與成敗兩種語意分開' (#86) from feat/wire-cli-status-verdict-flag into develop
Reviewed-on: #86
2026-09-04 10:09:51 +00:00
jiantw83andClaude Opus 5 d6f1e0e7ba feat(接線): status 加 --verdict,把狀態與成敗兩種語意分開
status 的結束碼帶的是狀態:0 是接好、1 是接好但這支 CLI 做不到、5 是有東西
沒接。那是給人看的三分法,本身沒有錯。

問題出在被當成檢查用。助理的內建檢查項照結束碼判成敗,非零就是那一筆失敗、
失敗次數加一。於是有先天限制的那一支 CLI 每一輪都讓那一筆失敗一次,一天 96
次,而沒有人修得動——那支 CLI 擋不下技能叫用是它的架構限制,不是接線缺漏,
16 個接線項目全部就位。

那個計數存在的理由是指出「有一筆壞掉的項目每輪重試而沒人知道」。被一個修不動
的數字填滿,就等於用假的壞掉把真的壞掉蓋掉。

修在這一邊而不是修在讀的那一邊:狀態與成敗是兩種語意,混在同一個通道上才是
根因。這個旗標把成敗那一種單獨拉出來,status 保持原樣給人看。

--verdict 之下輸出一字不變——degraded 那一行照印,人看得到——只有結束碼換一套
語意:該接的都接了就回 0,先天限制不算;真的缺項目照樣回 5。

只有 status 收這個旗標,別的子命令帶了回 2:另外三個子命令的結束碼本來就是
成敗語意,多一個旗標只會讓人以為它們也有兩套。

實測:五支 CLI 兩種模式各跑一次,只有帶先天限制那一支從 1 變 0;輸出逐字
相同;暫時拿掉一個接線項目之後 --verdict 回 5,還原後回 0;旗標的三條錯誤
路徑都回 2。

三份 manifest 版號 0.4.4 升到 0.4.5。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 18:04:51 +08:00
admin 03451f49f9 Merge pull request '釋出共用函式庫的收尾修正至 master,版本 0.4.3 升到 0.4.4' (#85) from develop into master
Reviewed-on: #85
Reviewed-by: 系統管理員 <1+admin@noreply.localhost>
2026-09-03 05:37:26 +00:00
admin 8e37ab3191 Merge pull request '共用函式庫補上 stdin JSON 的載入期預設值,四條收尾路徑不再吐未設定訊息' (#84) from fix/stdin-json-default-in-shared-lib into develop
Reviewed-on: #84
2026-09-03 05:35:21 +00:00
jiantw83 1684e5636d chore(plugin): 三份 manifest 升版至 0.4.4
共用函式庫的收尾修正要靠版號才傳得到機器端。

三份 manifest 由 sync-skill-manifest.sh 同步,只動版本欄位。
2026-09-03 13:06:35 +08:00
jiantw83 858d3521bd fix(lib): 補上 stdin JSON 的載入期預設值,收尾不再吐未設定訊息
有幾條路徑在收尾時會往標準錯誤吐一行「參數未設定」,掃描結果其實是對的,但那行訊息讓人以為掃描失敗。註解範圍掃描的流程規定「安靜地回 0 才算通過」,這一行正好讓「安靜」這個判準失效。

根因在共用函式庫:hook_trace 裝的 EXIT trap 會在腳本結束時經由 emit_event 呼叫 session_id,而它第一件事就是讀 stdin JSON 那個變數。腳本在讀取標準輸入之前就離開時,那個變數還沒人設過,開了 set -u 的腳本收尾就報錯。

訊息裡的檔名有誤導性:dash 回報行號用被 source 檔的行號、檔名卻用呼叫端的名字,所以看起來像是呼叫端的第 30 行出錯,實際上在函式庫裡。用一支探針腳本確認過行號的來源。

修法是在共用函式庫載入期給那個變數一個預設值,寫在任何讀取它的函式之前。修在共用處而不是各腳本各補一次:讀它的是共用函式,補在共用處才涵蓋每一條離開路徑,也涵蓋往後新增的腳本。用帶預設的展開而不是直接指派空字串,呼叫端已經帶值進來時原樣保留。

這個缺陷不只一處。凡是「有 set -u、裝了 hook_trace、又在讀取標準輸入之前離開」的路徑都會中,實測四條路徑修前都吐、修後都安靜。

驗證三項:掃描回 0 且標準錯誤零位元組;陽性對照仍正確回 2 並印出命中,證明掃描還有作用;事件記錄仍然正常,且事件裡的工作階段欄位取自標準輸入而不是預設值。事件驗證用隔離的環境做,沒有污染正式事件流。
2026-09-03 13:06:35 +08:00
admin 76288785ca Merge pull request '釋出跨外掛路徑的實體解析修正至 master,版本 0.4.2 升到 0.4.3' (#83) from develop into master
Reviewed-on: #83
Reviewed-by: 系統管理員 <1+admin@noreply.localhost>
2026-09-03 04:41:52 +00:00
admin 2704a7fa8f Merge pull request '跨外掛路徑改實體解析,修好失效的版本閘門,並讓接線腳本不再可能把連結指向自己' (#82) from fix/physical-path-resolution-across-plugins into develop
Reviewed-on: #82
2026-09-03 04:40:20 +00:00
jiantw83 e5a9b05164 chore(plugin): 三份 manifest 升版至 0.4.3
路徑解析的修正要靠版號才傳得到機器端,版本前置檢查才會要求更新。

三份 manifest 由 sync-skill-manifest.sh 同步,只動版本欄位。
2026-09-03 12:19:27 +08:00
jiantw83 95c7bb1eee fix(lib): 跨外掛路徑解析改實體解析,修好版本閘門
版本閘門對 11 個 domain 全部回「查詢失敗」,等於完全失效——它擋不下任何版本落後的技能呼叫,而且是無聲的:report 照印表格,只是每一列都寫查詢失敗。

根因是 jsc_gitea_sh 回傳的路徑帶著 ..,而那些 .. 要穿過 current/jsc-hooks 這條符號連結。兩種解析方式對它的答案不同:核心與 [ -f ] 用實體解析、跟著連結走,判定檔案存在;shell 的 cd 用邏輯解析、純文字消去 ..,落到一個不存在的目錄。所以 [ -f ] 檢查通過、路徑交了出去,gitea.sh 的 cd 卻失敗,回結束碼 2 與空輸出,呼叫端就判成查不到。

新增 jsc_abs_path,用 cd -P 加 pwd -P 把路徑正規化成不含 .. 的實體路徑。挑這個做法是因為兩者都是 shell 內建,不必在 PATH 上找執行檔——這些函式會在 cron 那種只剩幾段 PATH 的環境下跑,少一個外部相依就少一個解不出來的理由。jsc_gitea_sh 的四條候選改成尾端統一正規化,正規化失敗就退回原樣路徑,「找得到」的判準不變。

wire-cli.sh 的 HERE 與 ROOT 一併改成實體解析。那是同一個根因的另一種發作方式:ROOT 會被 ln -sfn 當成目標,而這支腳本常常就是經由那條連結被叫起來的,邏輯解析會讓 ROOT 等於連結自己,連結被改成指向自己,全機器 hook 一起失效。這件事實際發生過。原本靠兩道防線擋著:事後的 [ -f ] 檢查,以及文件要求呼叫端先解出實體根目錄。前者要等連結已經被寫壞才攔得到,後者靠人記得。改成實體解析之後這個失敗模式不可能成立。

行為契約第 10 列跟著改:從實體根目錄跑 wire-cli.sh 的規定保留,但性質從必要條件降成多一層保險,並寫明保險為什麼還值得買——舊版腳本還在別的機器上跑。

驗證用同形佈局做:暫存區搭一套一樣形狀的連結農場,先塞原版重現失敗、再塞改版確認修好。真實環境的連結與快取全程沒有動過。
2026-09-03 12:19:27 +08:00
admin 7b17c5e4f8 Merge pull request '釋出 hook 接線的路徑修正與本機事件流至 master,版本 0.3.8 升到 0.4.2' (#81) from develop into master
Reviewed-on: #81
Reviewed-by: 系統管理員 <1+admin@noreply.localhost>
2026-09-03 03:17:13 +00:00
admin f0fd92eb33 Merge pull request 'hooks-install 的腳本呼叫改用執行期解出的字面絕對路徑,讓無人看管的輪次不再卡在第一支腳本' (#80) from fix/literal-absolute-paths-for-hooks-wiring into develop
Reviewed-on: #80
2026-09-03 02:52:04 +00:00
jiantw83 70526244c7 chore(plugin): 三份 manifest 升版至 0.4.2
hooks-install 的路徑處理與行為清單都變了,版號要帶得出這批變更,版本前置檢查才會要求機器端更新。

三份 manifest 由 sync-skill-manifest.sh 同步,只動版本欄位,內容一致。

受影響的是靠版號判斷要不要更新的每一台機器。
2026-09-03 10:32:22 +08:00
jiantw83 221dca70e6 fix(behaviors): repair 的可驗證跡象補上收尾事件
行為清單檢查拿 HEAD 的內容跑就過不了,它點名 repair 那一列的可驗證跡象沒寫到收尾的 skill-end 事件。這是既有欠帳,跟這一批的路徑處理沒有關係,所以單獨一筆,之後要回退哪一邊都不會牽連另一邊。

補上的內容照準則寫:收尾在本機事件流留下這一輪的 skill-end,status 從 ok、blocked、failed、degraded、aborted 五個裡取一個,中途停下的那幾輪也照寫——只有 start 沒有配對的 end,會被讀成中斷。

受影響的是驗收 repair 有沒有跑完的人,還有把行為清單檢查掛在流程裡的每一個存放庫。
2026-09-03 10:32:22 +08:00
jiantw83 358c30c7b8 fix(hooks-install): 腳本呼叫一律寫成執行期解出的字面絕對路徑
無人看管的輪次會在第一支腳本就被擋下,整輪還沒開始就結束。2026-09-02 到 09-03 以非互動模式比照排程環境實測七種寫法,結論很乾淨:權限層比對的是還沒展開的字面字串,帶變數或帶波浪號的路徑一律解不出來,一律要人核准;路徑中段的萬用字元也不匹配,所以帶版本號的快取路徑放不進允許清單。連 readlink、ls 這種只讀指令都要有自己的規則。放寬允許清單救不了這件事,只有把路徑寫成字面絕對值才跑得動。

技能因此多兩節。路徑守則寫明每一次腳本呼叫都要是字面絕對路徑,並說清楚為什麼不能為了可攜性換回變數寫法——變數寫法買不到可攜性,只買到一輪還沒跑到第一階段就死掉。前置步驟把可攜性挪到執行期:兩個根目錄各以 readlink 解一次,只在這裡解,之後不重解,也不為此新增腳本。主代理人解完把兩條字面路徑交給每一個 sub agent,sub agent 自己不解。技能內九處腳本呼叫都改成由這兩個根目錄開頭。

兩條 readlink 各自在同一步用 [ -d ] 查過印出來的目錄真的存在。JSC_HOME 沒設時第一條會印出 /current、結束碼 0,非空又是絕對路徑,只查前三項擋不下來。

解兩個根目錄不是重複。連結農場根目錄給跨 domain 呼叫用;jsc-hooks 的實體根目錄只給接線腳本用,而這一條的理由與權限無關:那支腳本從自身位置推出自己的根目錄,又會改寫自己正踩著的那條連結。走連結跑下去,連結會被指向自己,全機器的 hook 一起失效——這件事實際發生過。

存放庫自帶的 hook 設定檔刻意保留變數寫法,技能文件也把這個例外寫明。那是檔案內容,由 hook 自己的 shell 在執行當下展開,不經過權限層,也不是誰在提示裡打出來的路徑;改成實體路徑等於把某一台機器的路徑寫死進要發佈的檔案。

行為清單的 hooks-install 四列跟著校準。

受影響的是每一個跑 hooks-install 的人,最直接的是排程觸發、沒有人在旁邊核准的那些輪次。
2026-09-03 10:32:22 +08:00
jiantw83 bd1a705ea8 chore(plugin 版本): 三份 manifest 升版至 0.4.2
What:
三份 plugin manifest 的版號由 0.4.1 同步升到 0.4.2,README 的技能目錄確認沒有新增或移除小節。

Why:
版本閘門比對相依版本時看的是 manifest 版號。改了內容卻不升版,安裝端拿到新檔案卻還是舊版判定,落後的一方擋不下來。

How:
以 jsc-meta 的技能清單同步工具一次改三份,結束碼 0,避免三份版號各自手改而對不起來。

Who:
安裝或更新 jsc 技能組的操作者。
2026-09-02 18:02:20 +08:00
jiantw83 02caedf91e feat(異常目錄頁): 索引改成 H2 區塊條列並委派共用工具
What:
異常目錄頁的版面從 markdown 表格改成一筆異常一個 H2 區塊,標題就是那一筆的異常頁頁名,時間、頁名、存取庫名稱、觸發 hook、退出碼、摘要六個欄位改成標題底下的一層條列,頁上不再留任何表格。失敗回報腳本不再自己讀回舊頁、附加新列、整頁寫回,改成只組出自己那一個區塊,交給 jsc-gitea 的目錄頁工具做讀回、比對與整頁寫回。範本、技能敘述、行為清單與說明文件一併對齊。

Why:
目錄頁有十幾份,各自在自己的腳本裡寫一套「讀得回舊內容才寫」的判斷,錯一次就少一筆紀錄,而且每一頁長出來的樣子都不一樣。版面與寫入語意收回一份正本之後,改一次全部跟著改。表格欄位遇到換行或半形豎線還會被切斷,條列沒有這個問題。

How:
失敗回報腳本移除目錄專用存取庫的解析與整頁組裝,改為組出區塊檔之後呼叫共用工具的 upsert,並依它的結束碼分流:3 是目錄頁的存取庫沒設定,找不到那支腳本也走同一條,兩者都只寫異常頁、跳過目錄頁、仍回 0;其餘非零一律以 4 回報。摘要不再替換半形豎線,改成把換行併成一行。傳進去的欄位序號 2 指舊表格版持有內容頁連結的那一欄,只供舊頁自動轉條列時取標題用。

Who:
使用 jsc-hooks 失敗回報流程的操作者,以及所有讀異常目錄頁追問題的人。
2026-09-02 18:01:33 +08:00
jiantw83 44d1d00c0c chore(plugin 版本): 三份 manifest 升版至 0.4.2 2026-09-02 16:05:54 +08:00
jiantw83 4afb282c78 feat(狀態回報): 兩支技能的收尾寫一筆 skill-end
hook 在原理上看不到技能的成敗:它接在技能工具呼叫上,而實際工作發生在
之後的模型輪次。start 由技能用量 hook 順手發,end 只能由技能自己寫。
有 start 沒有配對的 end,就是那一輪中止了。
2026-09-02 16:05:54 +08:00
admin 9adb39d21a Merge pull request '技能與 hook 的執行結果寫進本機事件流,供助理排空' (#77) from feat/status-event-stream into develop
Reviewed-on: #77
2026-09-02 07:48:47 +00:00
jiantw83 106922d530 chore(plugin 版本): 三份 manifest 升版至 0.4.1 2026-09-02 15:40:17 +08:00
jiantw83 a3ef205489 feat(狀態回報): 技能與 hook 的執行結果寫進本機事件流
現行紀錄只記「被叫用」,欄位是 ts、cli、session、skill,沒有成敗也沒有
結束碼。跑完整輪的技能與開場就中止的技能,在紀錄裡長得一模一樣。hook
成功時更是完全不留紀錄,只有錯誤路徑會寫 wiki,而那條路徑刻意不自動觸發。

事件流走本機檔案,不直接寫 wiki。hook 每次提示都跑,網路寫入會拖垮宿主
CLI;失敗的 hook 自我回報還會疊出迴圈,既有的錯誤回報因此不接在失敗的
hook 上,這裡沿用同一條線。助理巡檢時排空、彙整、寫頁。

hook 端用 EXIT trap 接,一支只加一行。這幾支的 exit 點很多,階段閘門一支
就有五十幾個;逐點改要動到每一條判定路徑,而那些路徑正是閘門的判準,為了
加一行紀錄去動閘門,風險遠大於收益。trap 涵蓋每一條離開路徑,含中途失敗。

狀態預設由結束碼推,推不出來的由 hook 自己覆寫。相依版本檢查與兩道閘門有
這種情形:antigravity 走 deny JSON、kiro 只印警告,兩者擋下時結束碼都是 0,
單看結束碼會把擋下記成放行。

技能的 start 由既有的技能用量 hook 順手發,不必改任何技能文件。end 只能由
技能自己在收尾步驟寫——hook 觸發時技能的實際工作還在後面的模型輪次,看不到
成敗。有 start 沒有配對的 end,就是那一輪中止了。
2026-09-02 15:40:17 +08:00
admin 97c56212df Merge pull request '接線盤點列舉每一個接線點,模型能力鎖不再被誤判成沒接線' (#76) from fix/wire-status-itemize-all-hooks into develop
Reviewed-on: #76
Reviewed-by: 系統管理員 <1+admin@noreply.localhost>
2026-09-02 07:32:01 +00:00
jiantw83 c0ce4c5c3c chore(plugin 版本): 三份 manifest 升版至 0.4.0 2026-09-02 14:57:04 +08:00
jiantw83 1d86359767 fix(接線盤點): status 列舉每一個接線點,不再只挑四支
README 早就寫明「每個接線點印一行 item」,程式卻只列 comment-scope、
lang-guard、restart-gate 與 write-guard 三種模式,共六項。sdlc-gate 的三個
接線點、session-timer 的兩個、skill-usage 與 version-guard 都沒列出來。

漏列的後果不是少幾行字。reason 那行寫的是「全部九支 hook」,拿這份輸出
驗收接線的人會把沒列到的當成沒接——模型能力鎖就是這樣被誤判成沒接線的。
體檢技能也讀這支的輸出,同樣看不到那四支。

一支腳本接在多個接線點時,每個點各自列一項。只驗腳本名的話,「腳本在、
某個接線點沒接」會被算成完整接線,那正是最難查的一種。
2026-09-02 14:57:04 +08:00
admin 56aa3e832f Merge pull request '連結一律寫成 [文字](絕對網址),並在寫入前驗證連得到' (#75) from feat/link-verification/main into develop
Reviewed-on: #75
2026-09-02 06:46:41 +00:00
jiantw83 9136894837 chore(plugin 版本): 三份 manifest 升版至 0.3.9 2026-09-02 14:27:18 +08:00
jiantw83 096a85e638 feat(link): 連結一律寫成 [文字](絕對網址),寫入前先驗證連得到
取消 [[頁名]] 與 [[顯示文字|頁名]] 兩種同 wiki 寫法,不再分「同存取庫」與
「跨存取庫」兩條規則。那種寫法只在自己那個 wiki 內解析,寫錯不報錯,畫面上
看起來像普通文字或死連結,巡不到也修不了。

連結寫進頁面前先過 jsc-gitea 的 link-check.sh,結束碼 0 才寫。驗證一律走 API,
不看網頁狀態碼:私有存取庫的網頁網址對未登入請求一律回 404,拿狀態碼判會把
好連結判成壞的。認證失敗回 7,與死連結的 1 分開,免得金鑰一過期就把還在的頁
整批判死。
2026-09-02 14:27:18 +08:00
admin f9f2d08b6b Merge pull request 'release: wiki 目錄頁專用存取庫、HASH 完整 40 碼、閘門依 CLI 分流' (#74) from develop into master
Reviewed-on: #74
Reviewed-by: 系統管理員 <1+admin@noreply.localhost>
2026-09-02 04:20:34 +00:00
admin 2a6097528a Merge pull request 'feat(wiki): 異常目錄頁改走專用存取庫,並修正目錄列連結恆空' (#73) from feat/wiki-contents-repo/main into develop
Reviewed-on: #73
2026-09-02 04:20:18 +00:00
admin af67f79f83 Merge pull request '釋出 jsc-hooks 0.3.7:助理心跳與運行閘門(閘門未接線)' (#70) from develop into master
Reviewed-on: #70
Reviewed-by: 系統管理員 <1+admin@noreply.localhost>
2026-09-01 07:51:20 +00:00
admin 34a3dfb92a Merge pull request '釋出 jsc-assist 的 marketplace 條目' (#67) from develop into master
Reviewed-on: #67
Reviewed-by: 系統管理員 <1+admin@noreply.localhost>
2026-09-01 04:57:34 +00:00
admin c6bf5a9b03 Merge pull request '釋出 jsc-hooks 0.3.6:頁面編號偵測補齊型別與 smoke 斷言修正' (#64) from develop into master
Reviewed-on: #64
Reviewed-by: 系統管理員 <1+admin@noreply.localhost>
2026-09-01 04:45:04 +00:00
admin 56703c4a54 Merge pull request '釋出 jsc-hooks 0.3.4:四支 CLI 的 pre-tool hook 接線修正' (#59) from develop into master
Reviewed-on: #59
Reviewed-by: 系統管理員 <1+admin@noreply.localhost>
2026-09-01 01:03:07 +00:00
admin 510142c3f6 Merge pull request '釋出 jsc-hooks 0.3.3:版本前置檢查新增相依落後擋人,新增技能行為清單' (#56) from develop into master
Reviewed-on: #56
Reviewed-by: 系統管理員 <1+admin@noreply.localhost>
2026-08-31 08:18:51 +00:00
admin 3608581dac Merge pull request 'chore(release): 放行技能組稽核修正到預設分支' (#53) from develop into master
Reviewed-on: #53
Reviewed-by: 系統管理員 <1+admin@noreply.localhost>
2026-08-31 03:56:23 +00:00
admin 1a89461b09 Merge pull request 'release: 發布 Codex hook 路徑修補' (#51) from release/codex-hook-root into master
Reviewed-on: #51
Reviewed-by: 系統管理員 <1+admin@noreply.localhost>
2026-08-28 10:11:32 +00:00
admin 727b35703f Merge pull request 'release: 發布 jsc-hooks 相依版本宣告' (#46) from develop into master
Reviewed-on: #46
Reviewed-by: 系統管理員 <1+admin@noreply.localhost>
2026-08-28 06:51:57 +00:00
admin 88af088881 Merge pull request 'release: 發布 jsc-hooks 0.2.8' (#43) from develop into master
Reviewed-on: #43
Reviewed-by: 系統管理員 <1+admin@noreply.localhost>
2026-08-28 03:38:35 +00:00
admin b385cb2c1b Merge pull request 'develop' (#41) from develop into master
Reviewed-on: #41
Reviewed-by: 系統管理員 <1+admin@noreply.localhost>
2026-08-28 01:58:30 +00:00
admin 668880e0f4 Merge pull request 'release: v0.2.6 develop 到 master' (#38) from develop into master
Reviewed-on: #38
Reviewed-by: 系統管理員 <1+admin@noreply.localhost>
2026-08-27 10:58:03 +00:00
admin e8b14c947a Merge pull request 'release: v0.2.5 develop 到 master' (#36) from develop into master
Reviewed-on: #36
Reviewed-by: 系統管理員 <1+admin@noreply.localhost>
2026-08-27 09:00:46 +00:00
admin 6a19dd483b Merge pull request 'release: v0.2.3 develop 到 master' (#32) from develop into master
Reviewed-on: #32
Reviewed-by: 系統管理員 <1+admin@noreply.localhost>
2026-08-27 04:14:14 +00:00
admin 7110caedb3 Merge pull request 'release: v0.2.2 develop 到 master' (#29) from develop into master
Reviewed-on: #29
Reviewed-by: 系統管理員 <1+admin@noreply.localhost>
2026-08-27 02:07:48 +00:00
admin 6682a3c59c Merge pull request 'develop 進 master:工作包鎖檔改依索引區分' (#24) from develop into master
Reviewed-on: #24
Reviewed-by: 系統管理員 <1+admin@noreply.localhost>
2026-08-26 10:15:25 +00:00
admin 49a06ade18 Merge pull request 'release: develop 併入 master(wire-cli status)' (#22) from develop into master
Reviewed-on: #22
Reviewed-by: 系統管理員 <1+admin@noreply.localhost>
2026-08-26 02:59:39 +00:00
admin 829ea50770 Merge pull request 'hooks 0.1.7 發佈:安裝先全清、出錯轉修正、工作包 PR 閘門' (#19) from develop into master
Reviewed-on: #19
Reviewed-by: 系統管理員 <1+admin@noreply.localhost>
2026-08-25 11:09:28 +00:00
admin 45facc85ba Merge pull request 'develop-to-master' (#17) from develop into master
Reviewed-on: #17
Reviewed-by: 系統管理員 <1+admin@noreply.localhost>
2026-08-25 09:03:57 +00:00
26 changed files with 727 additions and 135 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "jsc-hooks", "name": "jsc-hooks",
"version": "0.3.8", "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.3.8", "version": "0.5.0",
"description": "跨 CLI hooks:STE100 語言強制、工時計時、技能用量記錄、SDLC 模型鎖、版本前置檢查、註解範圍守門、繁中編碼守門、部署後強制重啟、寫入與提交閘門", "description": "跨 CLI hooks:STE100 語言強制、工時計時、技能用量記錄、SDLC 模型鎖、版本前置檢查、註解範圍守門、繁中編碼守門、部署後強制重啟、寫入與提交閘門",
"skills": "./skills", "skills": "./skills",
"jsc": { "jsc": {
+9 -7
View File
@@ -24,6 +24,7 @@ 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 行程是新起的,新版一定已經載入。清除的範圍只有跑到這支腳本的那一支 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` 別名在啟動當下開始;沒走別名啟動時,時間從第一輪回應算起。
### 驗證等級 ### 驗證等級
@@ -169,8 +170,9 @@ 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}`,並在 `ERROR_CONTENTS` 附上一列索引。目錄頁一律先讀回舊頁再附加新列、整頁寫回,不整頁覆蓋:只有 `wiki-get` 回 4(頁面真的不存在)才用範本建新頁,回 7(金鑰失效)或 8(其他 API 失敗)代表舊內容未知,放棄目錄頁寫入並以 exit 4 回報,免得拿範本蓋掉所有既有列。目錄頁指向異常頁的連結用 `gitea.sh wiki-url` 的絕對網址,跨存取庫的 `[[頁名]]` 解不開。網址在異常頁寫成功之後才取:頁名的 hash 帶時間戳,每次回報都是全新的頁,寫進去之前查一定是 404,先查就只拿得到空字串。取不到網址時只印頁名,原因走 stderr,結束碼照舊回 0。wiki 位置分兩次解析:異常頁走 `jsc-gitea` 的 `gitea.sh wiki-repo ERROR`,目錄頁走 `gitea.sh wiki-repo CONTENTS`,兩者是兩個不同的存取庫。異常頁的存取庫解不出來就整支安靜降級;只有目錄頁的存取庫解不出來,就只寫異常頁、跳過目錄頁更新,仍回 exit 0。由操作者手動執行,或由 `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/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` 回報 |
## 失敗回報範本 ## 失敗回報範本
@@ -181,7 +183,7 @@ Claude 由 `hooks/hooks.json` 自動接線九支 hook;其他 CLI 用 `hooks-in
| 範本 | 用途 | | 範本 | 用途 |
| --- | --- | | --- | --- |
| `templates/error-page.md` | 單筆 hook 異常頁 `ERROR_{HASH}`,記錄當次失敗的觸發條件、錯誤摘要與處理結果。 | | `templates/error-page.md` | 單筆 hook 異常頁 `ERROR_{HASH}`,記錄當次失敗的觸發條件、錯誤摘要與處理結果。 |
| `templates/error-contents.md` | 異常目錄 `ERROR_CONTENTS`,彙整所有異常頁,方便先看最新問題再往下追。落在目錄專用存取庫,一律 upsert 附加,連結用絕對網址。 | | `templates/error-contents.md` | 異常目錄 `ERROR_CONTENTS`,彙整所有異常頁,方便先看最新問題再往下追。版面是 H1 頁名加 `>` 引言,之後一筆一個 H2 區塊,標題就是異常頁頁名,欄位一行一條;範本只留一個示範區塊,用 `{佔位符}` 寫。落在目錄專用存取庫,一律 upsert 附加,連結一律寫成 `[{文字}]({連結})` 且先過 `link-check.sh` 驗過才寫。 |
## Skills 目錄 ## Skills 目錄
@@ -191,7 +193,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`
@@ -205,7 +207,7 @@ Claude 由 `hooks/hooks.json` 自動接線九支 hook;其他 CLI 用 `hooks-in
| --- | --- | --- | | --- | --- | --- |
| `JSC_HOME` | Hook 資料目錄 | 預設 `~/.jsc` | | `JSC_HOME` | Hook 資料目錄 | 預設 `~/.jsc` |
| `JSC_WIKI_REPO_ERROR` | 異常內容頁 `ERROR_{HASH}` 所在的 `{owner}/{repo}` | 退回 `JSC_WIKI_REPO`;還是解不出來就整支 `tools/report-error.sh` 安靜降級,不寫 wiki | | `JSC_WIKI_REPO_ERROR` | 異常內容頁 `ERROR_{HASH}` 所在的 `{owner}/{repo}` | 退回 `JSC_WIKI_REPO`;還是解不出來就整支 `tools/report-error.sh` 安靜降級,不寫 wiki |
| `JSC_WIKI_REPO_CONTENTS` | 目錄頁 `ERROR_CONTENTS` 所在的 `{owner}/{repo}`。目錄頁與內容頁分屬兩個不同的存取庫,各解各的 | 退回 `JSC_WIKI_REPO`;還是解不出來就只寫內容頁、跳過目錄頁更新,`tools/report-error.sh` 仍回 exit 0 | | `JSC_WIKI_REPO_CONTENTS` | 目錄頁 `ERROR_CONTENTS` 所在的 `{owner}/{repo}`,由 `wiki-contents.sh` 解析。目錄頁與內容頁分屬兩個不同的存取庫,各解各的 | 退回 `JSC_WIKI_REPO`;還是解不出來時 `wiki-contents.sh` 回 3,只寫內容頁、跳過目錄頁更新,`tools/report-error.sh` 仍回 exit 0 |
| `JSC_WIKI_REPO` | 未逐類設定時的共用 wiki `{owner}/{repo}` | `tools/report-error.sh` 安靜降級,不寫 wiki | | `JSC_WIKI_REPO` | 未逐類設定時的共用 wiki `{owner}/{repo}` | `tools/report-error.sh` 安靜降級,不寫 wiki |
| `COPILOT_HOME` | copilot 的設定根目錄,`hooks/jsc-hooks.json` 與指引檔都寫在它底下 | 預設 `~/.copilot` | | `COPILOT_HOME` | copilot 的設定根目錄,`hooks/jsc-hooks.json` 與指引檔都寫在它底下 | 預設 `~/.copilot` |
| `KIRO_HOME` | kiro 的設定根目錄,`agents/jsc.json`、`settings/cli.json` 與 `resources` 的 `skill://` glob 都由它推導 | 預設 `~/.kiro` | | `KIRO_HOME` | kiro 的設定根目錄,`agents/jsc.json`、`settings/cli.json` 與 `resources` 的 `skill://` glob 都由它推導 | 預設 `~/.kiro` |
+4
View File
@@ -120,6 +120,7 @@ if [ "${JSC_ASSISTANT_GATE:-}" = "off" ]; then
fi fi
HERE=$(dirname "$0"); . "$HERE/lib.sh" HERE=$(dirname "$0"); . "$HERE/lib.sh"
hook_trace "assistant-gate ${1:-}"
read_stdin read_stdin
@@ -200,5 +201,8 @@ esac
printf '%s\n' "$second" printf '%s\n' "$second"
printf '仍可使用:/jsc-assist:*、/jsc-hooks:repair、/jsc-hooks:hooks-install、/jsc-cli:doctor、/jsc-cli:setup、/jsc-cli:deploy、/jsc-cli:models、/jsc-gitea:wiki、/jsc-ask:ask、/jsc-git:commit、/jsc-git:pr(啟動助理與修環境這兩條路徑要永遠走得通,包括它們轉呼叫的下一層)\n' printf '仍可使用:/jsc-assist:*、/jsc-hooks:repair、/jsc-hooks:hooks-install、/jsc-cli:doctor、/jsc-cli:setup、/jsc-cli:deploy、/jsc-cli:models、/jsc-gitea:wiki、/jsc-ask:ask、/jsc-git:commit、/jsc-git:pr(啟動助理與修環境這兩條路徑要永遠走得通,包括它們轉呼叫的下一層)\n'
printf '確定要略過閘門:JSC_ASSISTANT_GATE=off\n' printf '確定要略過閘門:JSC_ASSISTANT_GATE=off\n'
# 這條路徑是「已經擋下」,但輸出形態依 CLI 而定:antigravity 走 stdout 的 deny JSON、
# kiro 只印警告,兩者的結束碼都是 0。不覆寫狀態的話,事件流會把擋下記成放行。
JSC_EVENT_STATUS=blocked
} | sh "$HERE/deny.sh" "$(cli_name)" } | sh "$HERE/deny.sh" "$(cli_name)"
exit $? exit $?
+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\"'"
} }
] ]
} }
+1
View File
@@ -25,6 +25,7 @@
set -u set -u
. "$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)/lib.sh" 2>/dev/null || true . "$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)/lib.sh" 2>/dev/null || true
command -v hook_trace >/dev/null 2>&1 && hook_trace "comment-scope ${1:-}"
[ "${JSC_COMMENT_SCOPE:-on}" = "off" ] && exit 0 [ "${JSC_COMMENT_SCOPE:-on}" = "off" ] && exit 0
+1
View File
@@ -75,6 +75,7 @@
# 2。這一點的後果與 restart-gate.sh 不同:那支接在 PreToolUse 上,回 2 等於無聲擋下每一次 # 2。這一點的後果與 restart-gate.sh 不同:那支接在 PreToolUse 上,回 2 等於無聲擋下每一次
# 技能呼叫;這支沒接任何 hook,回 2 只會讓呼叫端收到「心跳判不出來」,擋不到任何人。 # 技能呼叫;這支沒接任何 hook,回 2 只會讓呼叫端收到「心跳判不出來」,擋不到任何人。
HERE=$(dirname "$0"); . "$HERE/lib.sh" HERE=$(dirname "$0"); . "$HERE/lib.sh"
hook_trace "heartbeat ${1:-}"
# 這支永遠不讀標準輸入,但 session_id() 會去看 STDIN_JSON。先設成空字串,讓它直接走環境 # 這支永遠不讀標準輸入,但 session_id() 會去看 STDIN_JSON。先設成空字串,讓它直接走環境
# 變數那條路,不會因為變數沒定義而拿到不確定的值。 # 變數那條路,不會因為變數沒定義而拿到不確定的值。
+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\"'"
} }
] ]
} }
+1
View File
@@ -32,6 +32,7 @@
set -u set -u
. "$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)/lib.sh" 2>/dev/null || true . "$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)/lib.sh" 2>/dev/null || true
command -v hook_trace >/dev/null 2>&1 && hook_trace "lang-guard ${1:-}"
[ "${JSC_LANG_GUARD:-on}" = "off" ] && exit 0 [ "${JSC_LANG_GUARD:-on}" = "off" ] && exit 0
+115 -14
View File
@@ -20,6 +20,17 @@ mkdir -p "$JSC_HOME/sessions" "$JSC_HOME/usage" 2>/dev/null || true
JSC_SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" 2>/dev/null && pwd) JSC_SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" 2>/dev/null && pwd)
JSC_SCRIPT_DIR="${JSC_SCRIPT_DIR:-.}" JSC_SCRIPT_DIR="${JSC_SCRIPT_DIR:-.}"
# stdin JSON 的預設值。這一行要在任何讀取它的函式之前。
#
# 為什麼一定要有:hook_trace() 裝的 EXIT trap 會在腳本結束時經由 emit_event() 呼叫
# session_id(),而 session_id() 第一件事就是拿 json_str() 去讀這個變數。腳本在 read_stdin
# 之前就離開(只印規則的子命令、掃整個工作區的用法、逃生門關閉、模式不認得)時,變數還
# 沒人設過,開了 `set -u` 的腳本收尾就會往標準錯誤吐一行「參數未設定」。事件其實照樣寫得
# 進去,訊息卻讓呼叫端誤判本體失敗——而以「安靜回 0」為通過判準的流程,會因此整條失準。
# 補在這裡而不是各腳本各補一次:讀這個變數的是共用函式,補在共用處才涵蓋每一條離開路徑。
# 用 `${STDIN_JSON-}` 而不是直接指派空字串:呼叫端已經帶值進來時要原樣保留。
STDIN_JSON="${STDIN_JSON-}"
# 讀完 stdin(可能為空;非阻塞宿主) # 讀完 stdin(可能為空;非阻塞宿主)
read_stdin() { read_stdin() {
if [ -t 0 ]; then STDIN_JSON=""; else STDIN_JSON=$(cat 2>/dev/null || true); fi if [ -t 0 ]; then STDIN_JSON=""; else STDIN_JSON=$(cat 2>/dev/null || true); fi
@@ -229,27 +240,61 @@ cli_name() {
else printf 'unknown'; fi else printf 'unknown'; fi
} }
# 把一個檔案路徑正規化成不含 `..` 的實體路徑。解不出來就回傳 1。
#
# 為什麼一定要正規化:路徑裡的 `..` 一旦要穿過符號連結,兩種解法會給出不同的答案。
# 核心與 `[ -f ]` 走實體解析:先跟著連結走到目標,再從目標往上退。
# shell 的 `cd` 走邏輯解析:把 `..` 當純文字消去,退回的是連結自己的上層目錄。
# 找別的 plugin 是靠自己的位置往上退幾層再往下找,而安裝版面的腳本目錄正是經由一條符號
# 連結被叫到的,退層數一超過連結目標底下的深度就會踩到這個差異:這裡的 `[ -f ]` 說檔案
# 在、把路徑交出去,被呼叫的腳本自己 `cd` 過去卻找不到那個目錄,回一個空輸出與非零結束
# 碼。呼叫端只看得到「查詢失敗」,看不出是路徑寫法的問題,於是整道閘門無聲失效。
#
# 為什麼用 `cd -P` 加 `pwd -P` 而不是 readlink:這兩個都是 shell 內建,不必在 PATH 上找
# 外部執行檔。這些函式會在 cron 那種只剩幾段 PATH 的環境下跑,少一個外部相依就少一個
# 解不出來的理由。`-P` 是逐段跟著連結走的那一種解法,跟核心的答案一致。
jsc_abs_path() { # $1=檔案路徑
[ -n "${1:-}" ] || return 1
_ap_dir=$(CDPATH= cd -P -- "$(dirname -- "$1")" 2>/dev/null && pwd -P) || return 1
[ -n "$_ap_dir" ] || return 1
case "$_ap_dir" in
*/) printf '%s%s\n' "$_ap_dir" "$(basename -- "$1")" ;;
*) printf '%s/%s\n' "$_ap_dir" "$(basename -- "$1")" ;;
esac
}
# 找出 jsc-gitea 的 tools/gitea.sh 絕對路徑。所有 gitea 操作一律經由它(技能準則), # 找出 jsc-gitea 的 tools/gitea.sh 絕對路徑。所有 gitea 操作一律經由它(技能準則),
# 不可自行拼 API 呼叫:token 取用與 tea 金鑰退回都寫在那支腳本裡。 # 不可自行拼 API 呼叫:token 取用與 tea 金鑰退回都寫在那支腳本裡。
# 找不到就回傳 1,由呼叫端安靜降級(hook 一律 exit 0,不中斷宿主 CLI)。 # 找不到就回傳 1,由呼叫端安靜降級(hook 一律 exit 0,不中斷宿主 CLI)。
#
# 每一條候選路徑都先湊出來、最後統一過 jsc_abs_path 才交出去,理由見該函式的說明:
# 這裡的候選帶著 `..`,而那些 `..` 要穿過安裝版面的符號連結,交出去的原樣路徑
# 只有 `[ -f ]` 認得,被呼叫的腳本自己 `cd` 過去會失敗。正規化失敗時退回原樣路徑,
# 讓「找得到」這件事的判準不因為多了一道正規化而變嚴。
jsc_gitea_sh() { jsc_gitea_sh() {
if [ -n "${JSC_GITEA_TOOLS:-}" ] && [ -f "$JSC_GITEA_TOOLS/gitea.sh" ]; then _c=""
printf '%s\n' "$JSC_GITEA_TOOLS/gitea.sh"; return 0
fi
_root="${CLAUDE_PLUGIN_ROOT:-$JSC_SCRIPT_DIR/..}" _root="${CLAUDE_PLUGIN_ROOT:-$JSC_SCRIPT_DIR/..}"
if [ -n "${JSC_GITEA_TOOLS:-}" ] && [ -f "$JSC_GITEA_TOOLS/gitea.sh" ]; then
_c="$JSC_GITEA_TOOLS/gitea.sh"
fi
# 開發用的並排存取庫版面:{workspace}/hooks 旁邊就是 {workspace}/gitea # 開發用的並排存取庫版面:{workspace}/hooks 旁邊就是 {workspace}/gitea
for _c in "$_root/../gitea/tools/gitea.sh" "$_root/../jsc-gitea/tools/gitea.sh"; do if [ -z "$_c" ]; then
[ -f "$_c" ] && { printf '%s\n' "$_c"; return 0; } for _p in "$_root/../gitea/tools/gitea.sh" "$_root/../jsc-gitea/tools/gitea.sh"; do
done [ -f "$_p" ] && { _c="$_p"; break; }
done
fi
# 已安裝版面:每個 plugin 各有版本目錄,取排序最後的一份(通常即最新版) # 已安裝版面:每個 plugin 各有版本目錄,取排序最後的一份(通常即最新版)
_c=$(ls -d "$_root"/../../jsc-gitea/*/tools/gitea.sh \ if [ -z "$_c" ]; then
"$_root"/../../gitea/*/tools/gitea.sh \ _p=$(ls -d "$_root"/../../jsc-gitea/*/tools/gitea.sh \
"$HOME"/.claude/plugins/cache/*/jsc-gitea/*/tools/gitea.sh 2>/dev/null \ "$_root"/../../gitea/*/tools/gitea.sh \
| sort | tail -n1) "$HOME"/.claude/plugins/cache/*/jsc-gitea/*/tools/gitea.sh 2>/dev/null \
[ -n "$_c" ] && [ -f "$_c" ] && { printf '%s\n' "$_c"; return 0; } | sort | tail -n1)
_c=$(command -v gitea.sh 2>/dev/null || true) [ -n "$_p" ] && [ -f "$_p" ] && _c="$_p"
[ -n "$_c" ] && { printf '%s\n' "$_c"; return 0; } fi
return 1 [ -n "$_c" ] || _c=$(command -v gitea.sh 2>/dev/null || true)
[ -n "$_c" ] || return 1
_n=$(jsc_abs_path "$_c") && [ -n "$_n" ] && _c="$_n"
printf '%s\n' "$_c"
} }
# 每個 CLI 代號對應的實際執行檔(antigravity 是 agy、kiro 是 kiro-cli,其餘同名) # 每個 CLI 代號對應的實際執行檔(antigravity 是 agy、kiro 是 kiro-cli,其餘同名)
@@ -263,3 +308,59 @@ cli_bin() { # $1=CLI 代號
now_epoch() { date +%s; } now_epoch() { date +%s; }
now_iso() { date -u +%Y-%m-%dT%H:%M:%SZ; } now_iso() { date -u +%Y-%m-%dT%H:%M:%SZ; }
# --- 執行狀態事件流 ---
#
# 每支 hook 與每支技能的執行結果都寫進 $JSC_HOME/usage/events.jsonl,助理巡檢時排空。
# 為什麼不直接寫 wiki:hook 每次提示都跑,網路寫入會拖垮宿主 CLI;而且失敗的 hook
# 自我回報會疊出迴圈,report-error.sh 因此刻意不接在失敗的 hook 上,這裡沿用同一條線。
#
# 兩條硬規則,違反哪一條這套機制都會反過來害到被它記錄的東西:
# 一、一行一次 printf,且長度壓在 4096 位元組內。五支 CLI 併發時,單次 O_APPEND
# 寫入才不會互相插隊;拆成多次 printf 就會交錯成無法解析的行。detail 因此要截斷。
# 二、寫入失敗一律吞掉,不得改變呼叫端的結束碼。回報機制自己壞掉,不可以讓被回報的
# 東西跟著壞——hook 的結束碼是閘門的判準,被記錄動到就等於閘門行為被記錄改寫。
# JSON 字串值跳脫:只處理反斜線、雙引號與會拆行的字元。這三類不處理就會寫出解析不了的行。
json_escape() {
printf '%s' "$1" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' | tr -d '\n\r\t'
}
# emit_event <kind> <name> <phase> <status> <exit> [ms] [detail]
emit_event() {
_ek="$1"; _en="$2"; _ep="$3"; _es="$4"; _ex="$5"; _em="${6:-}"; _ed="${7:-}"
# detail 截到 200 字元:長內容是硬規則一的主要威脅,來源不可信就先砍再寫。
[ -n "$_ed" ] && _ed=$(printf '%s' "$_ed" | cut -c1-200)
_ems=""
[ -n "$_em" ] && _ems=$(printf ',"ms":%s' "$_em")
_eds=""
[ -n "$_ed" ] && _eds=$(printf ',"detail":"%s"' "$(json_escape "$_ed")")
printf '{"ts":"%s","cli":"%s","session":"%s","kind":"%s","name":"%s","phase":"%s","status":"%s","exit":%s%s%s}\n' \
"$(now_iso)" "$(cli_name)" "$(session_id)" "$_ek" "$(json_escape "$_en")" \
"$_ep" "$_es" "$_ex" "$_ems" "$_eds" \
>> "$JSC_HOME/usage/events.jsonl" 2>/dev/null || true
}
# 結束碼推 status。各 hook 的 2 一律是「擋下」的設計行為,不是壞掉。
hook_status_of() {
case "$1" in
0) printf ok ;;
2) printf blocked ;;
*) printf failed ;;
esac
}
# hook_trace <名稱> — 裝一個 EXIT trap,腳本不論從哪一個 exit 離開都記一筆。
#
# 為什麼用 trap 而不是逐點改:這幾支 hook 的 exit 點很多,sdlc-gate.sh 一支就有五十幾個。
# 逐點換成「記錄再離開」要改動每一條判定路徑,而那些路徑正是閘門的判準;為了加一行紀錄
# 去動閘門,風險遠大於收益。trap 只加一行,且涵蓋每一條離開路徑,含 set -e 的中途失敗。
#
# 狀態預設由結束碼推。推不出來的由 hook 自己在離開前設 JSC_EVENT_STATUS 覆寫——
# version-guard.sh 就有這種情形:antigravity 走 stdout 的 deny JSON、kiro 只印警告,
# 兩者擋下時結束碼都是 0,單看結束碼會把「已經擋下」記成「放行」。
hook_trace() {
JSC_EVENT_NAME="$1"
JSC_EVENT_STATUS=""
trap '_rc=$?; emit_event hook "$JSC_EVENT_NAME" end "${JSC_EVENT_STATUS:-$(hook_status_of "$_rc")}" "$_rc"' EXIT
}
+4
View File
@@ -120,6 +120,7 @@
# #
# 逃生門:JSC_RESTART_GATE=off 完全略過這道閘門。 # 逃生門:JSC_RESTART_GATE=off 完全略過這道閘門。
HERE=$(dirname "$0"); . "$HERE/lib.sh" HERE=$(dirname "$0"); . "$HERE/lib.sh"
hook_trace "restart-gate ${1:-}"
STATE_DIR="$JSC_HOME/restart-required.d" STATE_DIR="$JSC_HOME/restart-required.d"
# 舊格式的單一狀態檔。只為過渡而讀,可移除的時機見檔頭「舊檔相容」。 # 舊格式的單一狀態檔。只為過渡而讀,可移除的時機見檔頭「舊檔相容」。
@@ -249,5 +250,8 @@ info=""
printf '重新啟動:結束 %s 再重新開啟一次,狀態檔 %s 會在新工作階段開始時自動清除。\n' \ printf '重新啟動:結束 %s 再重新開啟一次,狀態檔 %s 會在新工作階段開始時自動清除。\n' \
"$bin" "$state" "$bin" "$state"
printf '仍可使用:/jsc-cli:deploy、/jsc-hooks:hooks-install、/jsc-hooks:repair、/jsc-gitea:wiki、/jsc-log:worklog、/jsc-log:learn、/jsc-meta:*、/jsc-ask:ask、/jsc-git:pr、/jsc-git:commit(部署後的異動報告與工作日誌要寫得完,hook 壞掉也要修得回來) | 確定要略過閘門:JSC_RESTART_GATE=off\n' printf '仍可使用:/jsc-cli:deploy、/jsc-hooks:hooks-install、/jsc-hooks:repair、/jsc-gitea:wiki、/jsc-log:worklog、/jsc-log:learn、/jsc-meta:*、/jsc-ask:ask、/jsc-git:pr、/jsc-git:commit(部署後的異動報告與工作日誌要寫得完,hook 壞掉也要修得回來) | 確定要略過閘門:JSC_RESTART_GATE=off\n'
# 這條路徑是「已經擋下」,但輸出形態依 CLI 而定:antigravity 走 stdout 的 deny JSON、
# kiro 只印警告,兩者的結束碼都是 0。不覆寫狀態的話,事件流會把擋下記成放行。
JSC_EVENT_STATUS=blocked
} | sh "$HERE/deny.sh" "$(cli_name)" } | sh "$HERE/deny.sh" "$(cli_name)"
exit $? exit $?
+1
View File
@@ -123,6 +123,7 @@
# 刻意的例外——鎖存在且不合規時 exit 2 擋下。只用提示注入的話模型可以無視,閘門形同虛設。 # 刻意的例外——鎖存在且不合規時 exit 2 擋下。只用提示注入的話模型可以無視,閘門形同虛設。
# 無鎖、或資料不足無法判定時,仍照舊 exit 0 安靜降級。 # 無鎖、或資料不足無法判定時,仍照舊 exit 0 安靜降級。
HERE=$(dirname "$0"); . "$HERE/lib.sh" HERE=$(dirname "$0"); . "$HERE/lib.sh"
hook_trace "sdlc-gate ${1:-}"
# 只有需要 stdin JSON 的子命令才讀它:模型判定要 transcript_path,session 判定要 session_id。 # 只有需要 stdin JSON 的子命令才讀它:模型判定要 transcript_path,session 判定要 session_id。
# wp-lock、wp-unlock、wp-claim、wp-unclaim、wp-report 兩者都不需要,而 read_stdin 在標準輸入 # wp-lock、wp-unlock、wp-claim、wp-unclaim、wp-report 兩者都不需要,而 read_stdin 在標準輸入
# 是管線又沒人關閉時會一直等——工具腳本(jsc-sdlc 的 wp-gate.sh)轉呼叫這些子命令時就這樣整支 # 是管線又沒人關閉時會一直等——工具腳本(jsc-sdlc 的 wp-gate.sh)轉呼叫這些子命令時就這樣整支
+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
+9
View File
@@ -18,6 +18,9 @@
# restart 給接不到 session id 的 CLI(kiro)。那些 CLI 的紀錄共用 default, # restart 給接不到 session id 的 CLI(kiro)。那些 CLI 的紀錄共用 default,
# 不覆寫就會把上一個工作階段的起始時間算進來,花費時間虛胖。 # 不覆寫就會把上一個工作階段的起始時間算進來,花費時間虛胖。
# #
# restart 另外清掉提醒記號($JSC_HOME/sessions/{代號}.reminded):那個記號讓提醒一個工作
# 階段只提一次,而共用 default 代號的 CLI 不清就等於只提第一次、往後永遠不提。
#
# 這兩個子命令另外兼一件事:判定為「新的工作階段」時清除部署後的重啟閘門 # 這兩個子命令另外兼一件事:判定為「新的工作階段」時清除部署後的重啟閘門
# (restart-gate.sh clear)。新工作階段代表 CLI 行程是新起的,新版技能組一定已經載入。 # (restart-gate.sh clear)。新工作階段代表 CLI 行程是新起的,新版技能組一定已經載入。
# 判準只有這裡知道——start 分支的「起始檔不存在」就是這個 session id 第一次開始, # 判準只有這裡知道——start 分支的「起始檔不存在」就是這個 session id 第一次開始,
@@ -25,6 +28,7 @@
# 清除的範圍是「跑到這一支腳本的那個 CLI 自己那一份狀態檔」,由 restart-gate.sh clear 認定, # 清除的範圍是「跑到這一支腳本的那個 CLI 自己那一份狀態檔」,由 restart-gate.sh clear 認定,
# 這裡不必也不能過問:這個工作階段開始的只有一支 CLI,別支沒重啟,閘門要留著。 # 這裡不必也不能過問:這個工作階段開始的只有一支 CLI,別支沒重啟,閘門要留著。
HERE=$(dirname "$0"); . "$HERE/lib.sh" HERE=$(dirname "$0"); . "$HERE/lib.sh"
hook_trace "session-timer ${1:-}"
read_stdin read_stdin
sid=$(session_id) sid=$(session_id)
@@ -45,6 +49,11 @@ case "${1:-mark}" in
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"
# 提醒記號一起清。那個記號讓提醒一個工作階段只提一次,而接不到 session id 的 CLI
# 全部共用 default 這一個代號——不清的話第一個工作階段提過之後,往後每一個工作階段
# 都會被當成「已經提過」,那支 CLI 從此再也收不到任何提醒。
# 清除掛在這裡不掛在提醒那一支:「這是不是新的工作階段」的判準只有這一支知道。
rm -f "$JSC_HOME/sessions/$sid.reminded"
clear_restart_gate ;; # 接不到 session id 的 CLI 每次工作階段開始都算新的,一律清 clear_restart_gate ;; # 接不到 session id 的 CLI 每次工作階段開始都算新的,一律清
mark) mark)
now_epoch > "$JSC_HOME/sessions/$sid.end" ;; now_epoch > "$JSC_HOME/sessions/$sid.end" ;;
+7
View File
@@ -12,6 +12,7 @@
# 唯一的非零來源同 session-timer.sh:本檔以 `. "$HERE/lib.sh"` 載入,沒有接 `|| true`, # 唯一的非零來源同 session-timer.sh:本檔以 `. "$HERE/lib.sh"` 載入,沒有接 `|| true`,
# lib.sh 讀不到時 sh 會就地結束並回 2。 # lib.sh 讀不到時 sh 會就地結束並回 2。
HERE=$(dirname "$0"); . "$HERE/lib.sh" HERE=$(dirname "$0"); . "$HERE/lib.sh"
hook_trace "skill-usage ${1:-}"
read_stdin read_stdin
skill="${JSC_SKILL:-$(json_str skill)}" skill="${JSC_SKILL:-$(json_str skill)}"
[ -n "$skill" ] || exit 0 [ -n "$skill" ] || exit 0
@@ -25,4 +26,10 @@ if [ -n "$last" ]; then
"$ts" "$cli" "$sid" "$last" "$skill" >> "$JSC_HOME/usage/chains.jsonl" "$ts" "$cli" "$sid" "$last" "$skill" >> "$JSC_HOME/usage/chains.jsonl"
fi fi
printf '%s' "$skill" > "$last_f" printf '%s' "$skill" > "$last_f"
# 技能的 start 事件在這裡發,不必改任何 SKILL.md:這支接在技能指示載入之後,那一刻
# 就是「技能開始跑」。end 只能由技能自己在收尾步驟寫——本 hook 觸發時,技能的實際工作
# 還在後面的模型輪次,看不到成敗。有 start 沒有配對的 end,就是那一輪中止了。
emit_event skill "$skill" start ok 0
exit 0 exit 0
+4
View File
@@ -101,6 +101,7 @@
# 再呼叫一次 report,這裡刻意不混印,免得 cut 取值被表格內容打亂。 # 再呼叫一次 report,這裡刻意不混印,免得 cut 取值被表格內容打亂。
# recommend 只讀不擋,永遠 exit 0:判定結果只看那一行的第二欄。 # recommend 只讀不擋,永遠 exit 0:判定結果只看那一行的第二欄。
HERE=$(dirname "$0"); . "$HERE/lib.sh" HERE=$(dirname "$0"); . "$HERE/lib.sh"
hook_trace "version-guard ${1:-}"
REG="$HOME/.claude/plugins/installed_plugins.json" REG="$HOME/.claude/plugins/installed_plugins.json"
MK="$HOME/.claude/plugins/known_marketplaces.json" MK="$HOME/.claude/plugins/known_marketplaces.json"
@@ -354,6 +355,9 @@ deny() { # $1=訊息
{ printf '[jsc][版本檢查][ERR]:%s\n' "$1" { printf '[jsc][版本檢查][ERR]:%s\n' "$1"
printf '更新指令:%s\n' "$(update_cmd "$domain")" printf '更新指令:%s\n' "$(update_cmd "$domain")"
printf '更新整組:/jsc-cli:deploy | 確定要略過檢查:JSC_VERSION_GUARD=off\n' printf '更新整組:/jsc-cli:deploy | 確定要略過檢查:JSC_VERSION_GUARD=off\n'
# 這條路徑是「已經擋下」,但輸出形態依 CLI 而定:antigravity 走 stdout 的 deny JSON、
# kiro 只印警告,兩者的結束碼都是 0。不覆寫狀態的話,事件流會把擋下記成放行。
JSC_EVENT_STATUS=blocked
} | sh "$HERE/deny.sh" "$(cli_name)" } | sh "$HERE/deny.sh" "$(cli_name)"
exit $? exit $?
} }
+1
View File
@@ -75,6 +75,7 @@ HERE=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
# 的 exit 2——而 PreToolUse 的 exit 2 正是「擋下」,等於每一次寫檔與提交都被無聲擋死。 # 的 exit 2——而 PreToolUse 的 exit 2 正是「擋下」,等於每一次寫檔與提交都被無聲擋死。
[ -r "$HERE/lib.sh" ] || exit 0 [ -r "$HERE/lib.sh" ] || exit 0
. "$HERE/lib.sh" . "$HERE/lib.sh"
hook_trace "write-guard ${1:-}"
# lib.sh 沒載到時這個變數就沒人設,下面兩個模式都要用它組狀態檔路徑,補一份同樣的預設值。 # lib.sh 沒載到時這個變數就沒人設,下面兩個模式都要用它組狀態檔路徑,補一份同樣的預設值。
JSC_HOME="${JSC_HOME:-$HOME/.jsc}" JSC_HOME="${JSC_HOME:-$HOME/.jsc}"
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "jsc-hooks", "name": "jsc-hooks",
"version": "0.3.8", "version": "0.5.0",
"description": "跨 CLI hooks:STE100 語言強制、工時計時、技能用量記錄、SDLC 模型鎖、版本前置檢查、註解範圍守門、繁中編碼守門、部署後強制重啟、寫入與提交閘門", "description": "跨 CLI hooks:STE100 語言強制、工時計時、技能用量記錄、SDLC 模型鎖、版本前置檢查、註解範圍守門、繁中編碼守門、部署後強制重啟、寫入與提交閘門",
"skills": "./skills/", "skills": "./skills/",
"jsc": { "jsc": {
+8 -8
View File
@@ -6,18 +6,18 @@
| 項目 | 內容 | | 項目 | 內容 |
| --- | --- | | --- | --- |
| 觸發時機 | 裝好或更新完 jsc 技能組之後,要把九支 hook 接線到每一支已安裝的 CLI 時用;`jsc-cli:deploy` 收尾會把偵測到的 CLI 清單交給它。不用於撰寫新的 hook,也不用於單獨修一支壞掉的 hook,那是 `jsc-hooks:repair` 的事 | | 觸發時機 | 裝好或更新完 jsc 技能組之後,要把十支 hook 接線到每一支已安裝的 CLI 時用;`jsc-cli:deploy` 收尾會把偵測到的 CLI 清單交給它。不用於撰寫新的 hook,也不用於單獨修一支壞掉的 hook,那是 `jsc-hooks:repair` 的事 |
| 關鍵步驟 | 取得 CLI 清單(呼叫端交來的優先,沒有才自己跑 `detect-clis.sh`)、第一支 CLI 單獨跑完整條管線(它負責更新共用的 `$JSC_HOME/current/jsc-hooks` 連結)、其餘 CLI 一支一個 sub agent 並行、每支 CLI 依序走 purge、接線、status、smoke、scan 五道關卡、讀每道關卡自己印的第一行判定、任一關卡出錯就寫 `ERROR_{HASH}` 並轉給 `jsc-hooks:repair`(異常頁與索引目錄頁分屬兩個存取庫,各自解析;只解不出目錄頁的存取庫時異常頁照寫、索引跳過,回報要講明那一頁沒被索引)、逐 CLI 回報五道關卡的結果 | | 關鍵步驟 | 先跑前置步驟解出兩個字面絕對路徑:`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 裡會變成五筆互相矛盾的結局。腳本不在就安靜跳過,回報失敗不得變成接線失敗 |
| 外部呼叫 | `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`);接線腳本內部另呼叫 `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` 做冒煙斷言 |
| 完成條件 | 每一支偵測到的 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 連結,而且目錄頁那一條的連結驗不過時,回報要講明那一條只有純文字頁名、沒有連結 |
| 可驗證跡象 | 各 CLI 的設定檔多出 jsc 段落:codex 的 `config.toml` 標記段落、`hooks/codex-hooks.json`(從 `hooks/hooks.json` 推導,matcher `Skill` 換成 `Bash`)與 `.codex-plugin/plugin.json` 指過去的 `hooks` 路徑字串、copilot 的 `~/.copilot/settings.json` 頂層 `hooks` 鍵(matcher `skill`,合併不覆寫,`enabledPlugins` 與第三方條目原樣保留)與 `$COPILOT_HOME` 底下的指引檔、antigravity 的 `~/.gemini/config/hooks.json` 的 `jsc` 段落(`PreToolUse` 為 Grouped、matcher `^view_file$`,`PreInvocation` 維持 Flat)、kiro 的 `~/.kiro/agents/jsc.json`(`hooks` 為 `agentSpawn`、`userPromptSubmit`、`stop` 三個合法事件加 `timeout_ms`、兩層 `skill://` glob 的 `resources`、明列的 `tools`,並通過 `kiro-cli agent validate`)與 `~/.kiro/settings/cli.json` 的 `chat.defaultAgent=jsc`;四支非 claude 的接線命令都以 `JSC_CLI={代號}` 前綴自帶 CLI 代號,缺了它兩道閘門解不出技能名、一律安靜放行,所以 `status` 把它列成單獨一項;另有 `$JSC_HOME/current/jsc-hooks` 符號連結建立或更新、`$JSC_HOME/backup/hooks/{cli}/{時間戳}/` 留下 purge 前的備份、出錯時 wiki 多一頁 `ERROR_{HASH}`(落在 `JSC_WIKI_REPO_ERROR` 解出的存取庫)並在索引目錄頁補一列(落在 `JSC_WIKI_REPO_CONTENTS` 解出的另一個存取庫,那一列指向異常頁的連結是絕對網址)、修正路徑留下一條對 `develop` 的 PR | | 可驗證跡象 | 各 CLI 的設定檔多出 jsc 段落:codex 的 `config.toml` 標記段落、`hooks/codex-hooks.json`(從 `hooks/hooks.json` 推導,matcher `Skill` 換成 `Bash`)與 `.codex-plugin/plugin.json` 指過去的 `hooks` 路徑字串、copilot 的 `~/.copilot/settings.json` 頂層 `hooks` 鍵(matcher `skill`,合併不覆寫,`enabledPlugins` 與第三方條目原樣保留)與 `$COPILOT_HOME` 底下的指引檔、antigravity 的 `~/.gemini/config/hooks.json` 的 `jsc` 段落(`PreToolUse` 為 Grouped、matcher `^view_file$`,`PreInvocation` 維持 Flat)、kiro 的 `~/.kiro/agents/jsc.json`(`hooks` 為 `agentSpawn`、`userPromptSubmit`、`stop` 三個合法事件加 `timeout_ms`、兩層 `skill://` glob 的 `resources`、明列的 `tools`,並通過 `kiro-cli agent validate`)與 `~/.kiro/settings/cli.json` 的 `chat.defaultAgent=jsc`;四支非 claude 的接線命令都以 `JSC_CLI={代號}` 前綴自帶 CLI 代號,缺了它兩道閘門解不出技能名、一律安靜放行,所以 `status` 把它列成單獨一項;另有 `{連結農場根}/jsc-hooks` 符號連結建立或更新,而且它指向 jsc-hooks 的實體根目錄、不是指向自己(`readlink -f` 解得出一個存在的目錄,裡面有 `hooks/session-timer.sh` 與 `tools/jsc-wrap.sh`)、各 CLI 設定裡存下來的接線命令也都是展開後的字面絕對路徑,只有存放庫自帶的 `hooks/hooks.json` 保留 `${JSC_HOME:-$HOME/.jsc}/current/jsc-hooks` 這段變數寫法,由 hook 自己的 shell 在執行當下展開、`$JSC_HOME/backup/hooks/{cli}/{時間戳}/` 留下 purge 前的備份、出錯時 wiki 多一頁 `ERROR_{HASH}`(落在 `JSC_WIKI_REPO_ERROR` 解出的存取庫)並在索引目錄頁補一個 H2 區塊(落在 `JSC_WIKI_REPO_CONTENTS` 解出的另一個存取庫,由 `wiki-contents.sh` upsert 進去;H2 標題就是那一頁的頁名,底下六條條列依序是時間、頁名、存取庫名稱、觸發 hook、退出碼、摘要,「頁名」那一條寫成 `[{頁名}]({絕對網址})`,網址取自 `gitea.sh wiki-url` 且已經過 `link-check.sh` 驗到結束碼 0;驗不過那一條只有純文字頁名,`report-error.sh` 在 stderr 留一行 `[jsc]` 講明是哪一種原因;那一頁上不會有 markdown 表格)、修正路徑留下一條對 `develop` 的 PR 。接線完成後 `$JSC_HOME/usage/events.jsonl` 會逐行長出 `{kind:hook}` 事件,每支 hook 每次執行一筆,欄位含 `status` 與實際結束碼;跑過技能之後另有 `{kind:skill,phase:start}`。事件寫不進去不影響任何 hook 的結束碼這支技能自己收尾時,同一個 `$JSC_HOME/usage/events.jsonl` 尾端會多一筆 `{kind:skill,phase:end}`,`name` 是 `jsc-hooks:hooks-install`,整輪只有一筆,`status` 與那次結局相符,`exit` 是決定結局的那道關卡的結束碼;`report-status.sh` 不在那台機器上就沒有這一筆,接線結果一字不變。事件寫不進去不影響任何 hook 的結束碼,也不影響本技能的結局 |
## repair ## repair
| 項目 | 內容 | | 項目 | 內容 |
| --- | --- | | --- | --- |
| 觸發時機 | `hooks-install` 或 `report-error.sh` 回報某一支 hook 失敗時用,或是重新接線之後那支 hook 還是一直失敗時用。不用於例行接線,也不用於與 hook 無關的修改 | | 觸發時機 | `hooks-install` 或 `report-error.sh` 回報某一支 hook 失敗時用,或是重新接線之後那支 hook 還是一直失敗時用。不用於例行接線,也不用於與 hook 無關的修改 |
| 關鍵步驟 | 從 `ERROR_{HASH}` 讀失敗情境(沒有頁就讀失敗的 `status=` 那一行,讀不到就停下來問)、跑 `detect-clis.sh`、每一支偵測到的 CLI 各開一個唯讀 sub agent 診斷並交回根因、要改的檔案與驗證指令、挑最小的修正改進 hooks 存取庫(技能名解析改 `hooks/skill-name.sh`、阻擋形態改 `hooks/deny.sh`,兩支是唯一真實來源,不在閘門裡各補一份)、跑 `wire-cli.sh smoke {cli}` 驗到 exit 0、跑 `sync-skill-manifest.sh .` 同步版本、以 `jsc-git:pr` 對 `develop` 開 PR | | 關鍵步驟 | 從 `ERROR_{HASH}` 讀失敗情境(沒有頁就讀失敗的 `status=` 那一行,讀不到就停下來問)、跑 `detect-clis.sh`、每一支偵測到的 CLI 各開一個唯讀 sub agent 診斷並交回根因、要改的檔案與驗證指令、挑最小的修正改進 hooks 存取庫(技能名解析改 `hooks/skill-name.sh`、阻擋形態改 `hooks/deny.sh`,兩支是唯一真實來源,不在閘門裡各補一份)、跑 `wire-cli.sh smoke {cli}` 驗到 exit 0、跑 `sync-skill-manifest.sh .` 同步版本、以 `jsc-git:pr` 對 `develop` 開 PR、收尾呼叫 `tools/report-status.sh skill-end jsc-hooks:repair {status} {結束碼} {detail}` 記下這次修正怎麼結束(腳本在同一個存取庫,用 `tools/` 相對路徑,比照 `tools/wire-cli.sh`;檔案不在就安靜跳過,回報失敗不得變成修正失敗) |
| 外部呼叫 | `jsc-gitea:wiki`、`jsc-cli/tools/detect-clis.sh`、`tools/wire-cli.sh smoke`、`jsc-meta/tools/sync-skill-manifest.sh`、`jsc-git:pr`;診斷階段另以 sub agent 叫用各支已安裝的 AI CLI | | 外部呼叫 | `jsc-gitea:wiki`、`jsc-cli/tools/detect-clis.sh`、`tools/wire-cli.sh smoke`、`jsc-meta/tools/sync-skill-manifest.sh`、`jsc-git:pr`;診斷階段另以 sub agent 叫用各支已安裝的 AI CLI |
| 完成條件 | 修正已經落在磁碟上、`wire-cli.sh smoke` 對受影響的 CLI 回 exit 0、`sync-skill-manifest.sh` 回 exit 0 而且三份 manifest 版本一致,最後拿到一條對 `develop` 的 PR 連結;開不出 PR 時要講明修正已套用但尚未合併、帶上分支名與失敗原因 | | 完成條件 | 修正已經落在磁碟上、`wire-cli.sh smoke` 對受影響的 CLI 回 exit 0、`sync-skill-manifest.sh` 回 exit 0 而且三份 manifest 版本一致,最後拿到一條對 `develop` 的 PR 連結;開不出 PR 時要講明修正已套用但尚未合併、帶上分支名與失敗原因。每一條路線都要走完最後一步:呼叫 `report-status.sh skill-end`,狀態五選一——修正落地、smoke 回 exit 0、三份 manifest 版本一致而且拿到 PR 連結是 `ok`;smoke 過了但東西沒送出去是 `degraded`,也就是開不出 PR 只剩分支,或 manifest 沒對齊;修不好是 `failed`,也就是診斷繞回去以後 smoke 還是回 exit 4,或同步版本踩到環境錯誤,壞掉的接線還是壞的;沒有可修的項目是 `aborted`,也就是讀不到任何失敗情境。本技能豁免版本閘門與部署後重啟閘門,沒有別的閘門擋得住它,所以不會用 `blocked`。腳本不在磁碟上就跳過,這一步照樣算走完 |
| 可驗證跡象 | hooks 存取庫多一個修正提交與一條推上去的分支、`develop` 上多一條 PR、三份 manifest 與 README 技能清單版本一致、`wire-cli.sh smoke` 由失敗轉為 exit 0 | | 可驗證跡象 | hooks 存取庫多一個修正提交與一條推上去的分支、`develop` 上多一條 PR、三份 manifest 與 README 技能清單版本一致、`wire-cli.sh smoke` 由失敗轉為 exit 0。收尾在 `$JSC_HOME/usage/events.jsonl` 留下這一輪的 `skill-end` 事件,`status` 取 `ok`、`blocked`、`failed`、`degraded` 或 `aborted`,中途停下的那幾輪也照寫——只有 start 沒有配對 end 會被讀成中斷;不論走哪一條路線,`$JSC_HOME/usage/events.jsonl` 尾端都會多一筆 `{kind:skill,phase:end}` 事件,`name` 是 `jsc-hooks:repair`,`status` 與那次結局相符,`exit` 是決定結局的那支工具的結束碼;`report-status.sh` 不在那台機器上就沒有這一筆,修正結果一字不變 |
+34 -9
View File
@@ -7,9 +7,30 @@ description: Wire jsc hooks (STE100 guard, session timer, skill usage logger, SD
Goal: make the nine hooks (`ste100-guard.sh`, `session-timer.sh`, `skill-usage.sh`, `sdlc-gate.sh`, `version-guard.sh`, `restart-gate.sh`, `comment-scope.sh`, `lang-guard.sh`, `write-guard.sh`) effective in every CLI, with nothing else wired alongside them. Goal: make the nine hooks (`ste100-guard.sh`, `session-timer.sh`, `skill-usage.sh`, `sdlc-gate.sh`, `version-guard.sh`, `restart-gate.sh`, `comment-scope.sh`, `lang-guard.sh`, `write-guard.sh`) effective in every CLI, with nothing else wired alongside them.
## Path rule
**Every script call in this skill is written as a literal absolute path.** A path that still carries `$JSC_HOME`, any other unexpanded variable, or a `~` cannot be resolved statically by the permission layer, so it is treated as unknown and always asks for approval. An unattended round has nobody to approve, so it stops at the first script and the whole install never starts.
Measured on this machine: `$JSC_HOME/current/jsc-assist/tools/patrol.sh` and `~/.jsc/current/...` were both blocked and the command never ran; the same script at `/root/.jsc/current/...` ran. Adding an allow rule that itself starts with `$JSC_HOME` changed nothing on a retest, because the rule is matched against the expanded command — widening the permission list is not the fix.
Do not trade this back for portability. A variable-form path in this file buys no portability; it buys a round that dies before its first stage. Portability lives in the prerequisite below, which resolves the roots once, on the machine, at run time.
## Prerequisite — resolve the roots once
Run these two before any other call in this skill, and only here:
1. `readlink -f "$JSC_HOME/current"` prints the link farm as a literal absolute path. Call it `{JSC_ROOT}`. Every cross-domain call is written `{JSC_ROOT}/jsc-{domain}/...` with that path substituted in.
2. `readlink -f "$JSC_HOME/current/jsc-hooks"` prints the physical root behind the `jsc-hooks` link. Call it `{HOOKS_ROOT}`. `tools/wire-cli.sh` is called from there and from nowhere else. That script rewrites the very `{JSC_ROOT}/jsc-hooks` link it would be running through and derives its own root from `$0`, so running it through the link makes `ln -sfn` point that link at itself. The loop takes every CLI's hooks down at once, and it has happened.
**Both results are checked before anything else runs: `[ -d "{JSC_ROOT}" ]` and `[ -d "{HOOKS_ROOT}" ]`, each in the same approved step as its own `readlink`.** A `readlink` that printed something is not a `readlink` that found something. With `JSC_HOME` unset the first call prints `/current` and exits 0 — non-empty, absolute, and wrong — and every literal path built from it then names a place that is not there; the second call has the same hole one level down. An empty result, a non-zero exit, a path that is not absolute, or a directory that does not exist stops the skill here: report which of the two roots did not resolve and what the command printed, say `jsc-cli:deploy` has to run to restore `current` and its `jsc-hooks` link, and wire nothing. Never guess a root, never fall back to a versioned plugin cache path, and never create either root here — a run that pushes on wires every CLI to scripts that are not there, and `purge` has already removed the hooks that worked.
Resolve both once, here. Do not re-resolve per call, and do not add a tool that prints these paths — two `readlink` runs and their two checks are the whole step. The main agent resolves them and hands both literal paths to every sub agent it starts, so a sub agent never resolves anything itself. Done when you hold two literal absolute paths, both naming directories that exist, and every later call starts with one of them.
## Wiring
Install on a clean slate. Every CLI is purged of all hooks first, third-party ones included, so a later failure has exactly one owner. `tools/wire-cli.sh purge` backs up every file it touches before it removes anything, so the removal stays reversible. Install on a clean slate. Every CLI is purged of all hooks first, third-party ones included, so a later failure has exactly one owner. `tools/wire-cli.sh purge` backs up every file it touches before it removes anything, so the removal stays reversible.
The wiring commands stored in user config use `$JSC_HOME/current/jsc-hooks`, not the versioned plugin cache path and not the development checkout. `tools/wire-cli.sh {cli}` creates or refreshes that symlink before it writes `notify`, shell aliases or Kiro hook JSON, then verifies the linked scripts exist. If the filesystem cannot create the symlink, the script must say so and explicitly fall back to the current root; it must never write a silent broken path. The bundled `hooks/hooks.json` follows the same rule: use `${CLAUDE_PLUGIN_ROOT}` only where the host provides it, and fall back to `$JSC_HOME/current/jsc-hooks` for any other CLI reading the same manifest, so an unset Claude-only variable never expands into `/hooks/...`. The wiring commands stored in user config use `{JSC_ROOT}/jsc-hooks`, not the versioned plugin cache path and not the development checkout. `wire-cli.sh` expands that root itself, so what lands in each CLI's config is already a literal absolute path. `{HOOKS_ROOT}/tools/wire-cli.sh {cli}` creates or refreshes that symlink before it writes `notify`, shell aliases or Kiro hook JSON, then verifies the linked scripts exist. If the filesystem cannot create the symlink, the script must say so and explicitly fall back to the current root; it must never write a silent broken path. The bundled `hooks/hooks.json` follows the same rule with one deliberate exception: use `${CLAUDE_PLUGIN_ROOT}` only where the host provides it, and fall back to the manifest's own text, `${JSC_HOME:-$HOME/.jsc}/current/jsc-hooks`, for any other CLI reading the same manifest, so an unset Claude-only variable never expands into `/hooks/...`. That one stays in variable form on purpose: it is file content shipped with the repo, expanded by the hook's own shell on whatever machine reads it, and it never passes through the permission layer. It is not a path anyone types at a prompt, so the path rule above does not reach it.
Four of the five CLIs have a pre-tool hook that can block. The version guard and the restart gate reach codex, copilot and antigravity too — each at its own wiring point, with its own matcher and its own blocking shape. Never say a CLI "has no pre-tool hook"; that claim is wrong and it is what left three CLIs unguarded. Four of the five CLIs have a pre-tool hook that can block. The version guard and the restart gate reach codex, copilot and antigravity too — each at its own wiring point, with its own matcher and its own blocking shape. Never say a CLI "has no pre-tool hook"; that claim is wrong and it is what left three CLIs unguarded.
@@ -66,17 +87,21 @@ The detailed flow **MUST run as a sub agent**; the main agent only reports the s
## Steps ## Steps
1. Take the CLI list from the caller when it hands one over — `jsc-cli:deploy` passes the list it already detected, and probing the same five executables a second time buys nothing. Run `jsc-cli/tools/detect-clis.sh` yourself only when no list came in; that fallback is what keeps this skill usable when it is called on its own. The script always exits 0 and prints one `name<TAB>path<TAB>version` line per installed CLI. Done when you hold that list and have said which of the two ways produced it; when it is empty, report that no CLI was detected and stop. 1. Take the CLI list from the caller when it hands one over — `jsc-cli:deploy` passes the list it already detected, and probing the same five executables a second time buys nothing. Run `{JSC_ROOT}/jsc-cli/tools/detect-clis.sh` yourself only when no list came in; that fallback is what keeps this skill usable when it is called on its own. The script always exits 0 and prints one `name<TAB>path<TAB>version` line per installed CLI. Done when you hold that list and have said which of the two ways produced it; when it is empty, report that no CLI was detected and stop.
2. Run the five-stage pipeline **purge → wire → status → smoke → scan** once per detected CLI. Run the first CLI's pipeline on its own, because `tools/wire-cli.sh {cli}` is what refreshes the shared `$JSC_HOME/current/jsc-hooks` link and two CLIs must not rewrite it at the same time; once that first pipeline has finished, run every remaining CLI's pipeline in parallel, one sub agent per CLI — the five stages of one CLI stay in this order, but different CLIs touch different config files and share nothing else. Every stage prints its verdict on its first line, so read that line and never infer the outcome from the prose below it. 2. Run the five-stage pipeline **purge → wire → status → smoke → scan** once per detected CLI. Run the first CLI's pipeline on its own, because `{HOOKS_ROOT}/tools/wire-cli.sh {cli}` is what refreshes the shared `{JSC_ROOT}/jsc-hooks` link and two CLIs must not rewrite it at the same time; once that first pipeline has finished, run every remaining CLI's pipeline in parallel, one sub agent per CLI — the five stages of one CLI stay in this order, but different CLIs touch different config files and share nothing else. Every stage prints its verdict on its first line, so read that line and never infer the outcome from the prose below it.
1. `tools/wire-cli.sh purge {cli}` — backs up every file it touches, removes all hooks, re-reads each file to confirm the removal, and restores the backup by itself when a check fails. Marker matching trims leading and trailing whitespace, so an indented or padded marker block is still removed as the same jsc-owned block. Exit 0 is `purged`, exit 3 is `skipped` (that CLI's executable is not on this machine, so skip its remaining stages too), exit 4 is `failed` and goes to step 3. Exit 2 is a bad CLI name, not a purge outcome — fix the name and rerun the stage. 1. `{HOOKS_ROOT}/tools/wire-cli.sh purge {cli}` — backs up every file it touches, removes all hooks, re-reads each file to confirm the removal, and restores the backup by itself when a check fails. Marker matching trims leading and trailing whitespace, so an indented or padded marker block is still removed as the same jsc-owned block. Exit 0 is `purged`, exit 3 is `skipped` (that CLI's executable is not on this machine, so skip its remaining stages too), exit 4 is `failed` and goes to step 3. Exit 2 is a bad CLI name, not a purge outcome — fix the name and rerun the stage.
2. `tools/wire-cli.sh {cli}` — owns both the wiring and its verification: it refreshes the link, writes the config, alias or hook file inside a `<!-- jsc-hooks -->` (or `# jsc-hooks`) marker block, re-reads every file it wrote, confirms the block is present and correctly placed, and confirms the stored runtime paths resolve to existing scripts before it prints a success status. Exit 0 is `wired` and is the expected result on claude, codex, copilot and antigravity; exit 1 is `degraded` and is expected on kiro alone; exit 3 is `skipped`, exit 4 is `failed` and goes to step 3. Exit 2 is a bad CLI name — fix the name and rerun. The matcher is verified on its own, not just the presence of a key: a key that is there with the wrong matcher reports as wired and fires never. 2. `{HOOKS_ROOT}/tools/wire-cli.sh {cli}` — owns both the wiring and its verification: it refreshes the link, writes the config, alias or hook file inside a `<!-- jsc-hooks -->` (or `# jsc-hooks`) marker block, re-reads every file it wrote, confirms the block is present and correctly placed, and confirms the stored runtime paths resolve to existing scripts before it prints a success status. Exit 0 is `wired` and is the expected result on claude, codex, copilot and antigravity; exit 1 is `degraded` and is expected on kiro alone; exit 3 is `skipped`, exit 4 is `failed` and goes to step 3. Exit 2 is a bad CLI name — fix the name and rerun. The matcher is verified on its own, not just the presence of a key: a key that is there with the wrong matcher reports as wired and fires never.
3. `tools/wire-cli.sh status {cli}` — the read-only inventory of what the previous stage wrote. It writes nothing and runs no hook, so it is safe to run right after wiring. Exit 0 is `wired` (claude, codex, copilot, antigravity), exit 1 is `degraded` (kiro), exit 3 is `skipped`, exit 5 is `unwired`, which names every missing item and means the wiring stage has to run again before you continue. Exit 2 is a bad CLI name. For codex this stage is the only one that reads the installed `jsc-hooks` manifest in the Codex plugin cache and reports a stale `UserPromptSubmit` command there, the one that expands `${CLAUDE_PLUGIN_ROOT}` into `/hooks/...`; carry that item into the report. 3. `{HOOKS_ROOT}/tools/wire-cli.sh status {cli}` — the read-only inventory of what the previous stage wrote. It writes nothing and runs no hook, so it is safe to run right after wiring. Exit 0 is `wired` (claude, codex, copilot, antigravity), exit 1 is `degraded` (kiro), exit 3 is `skipped`, exit 5 is `unwired`, which names every missing item and means the wiring stage has to run again before you continue. Exit 2 is a bad CLI name. For codex this stage is the only one that reads the installed `jsc-hooks` manifest in the Codex plugin cache and reports a stale `UserPromptSubmit` command there, the one that expands `${CLAUDE_PLUGIN_ROOT}` into `/hooks/...`; carry that item into the report.
4. `tools/wire-cli.sh smoke {cli}` — runs every wired mode of all nine hooks once, plus each decision path of the work-package check, of the restart gate and of the write and commit guard. It catches what the wiring check cannot see: a hook that is wired correctly and still fails when it executes. It also runs each CLI's real payload through `skill-name.sh`, each blocking shape through `deny.sh`, and those same payloads straight through `restart-gate.sh` end to end, so a break anywhere along parse, decide and emit is caught — the two ends look healthy on their own while the middle silently passes everything through, which is exactly how three CLIs went unguarded. It prints its own result-line count as `lines<TAB>{count}` and asserts that count against what it expected to run, so read the number from that line and never restate a number of your own. Exit 0 is `ok`, exit 4 is `failed` — either a hook errored or the line count did not match, and both go to step 3. Exit 2 is a bad CLI name. 4. `{HOOKS_ROOT}/tools/wire-cli.sh smoke {cli}` — runs every wired mode of all nine hooks once, plus each decision path of the work-package check, of the restart gate and of the write and commit guard. It catches what the wiring check cannot see: a hook that is wired correctly and still fails when it executes. It also runs each CLI's real payload through `skill-name.sh`, each blocking shape through `deny.sh`, and those same payloads straight through `restart-gate.sh` end to end, so a break anywhere along parse, decide and emit is caught — the two ends look healthy on their own while the middle silently passes everything through, which is exactly how three CLIs went unguarded. It prints its own result-line count as `lines<TAB>{count}` and asserts that count against what it expected to run, so read the number from that line and never restate a number of your own. Exit 0 is `ok`, exit 4 is `failed` — either a hook errored or the line count did not match, and both go to step 3. Exit 2 is a bad CLI name.
5. `tools/scan-hook-errors.sh --cli {cli}` — only claude keeps hook results in its native records and can answer `clean` or `errors`; codex, copilot, antigravity and kiro answer `unavailable`, and their runtime evidence comes from the smoke stage alone. Exit 0 covers both `clean` and `unavailable`, exit 1 is `errors` and every entry with `jsc=true` goes to step 3, exit 2 is a bad CLI name. 5. `{JSC_ROOT}/jsc-hooks/tools/scan-hook-errors.sh --cli {cli}` — only claude keeps hook results in its native records and can answer `clean` or `errors`; codex, copilot, antigravity and kiro answer `unavailable`, and their runtime evidence comes from the smoke stage alone. Exit 0 covers both `clean` and `unavailable`, exit 1 is `errors` and every entry with `jsc=true` goes to step 3, exit 2 is a bad CLI name.
Done when every detected CLI has exactly one verdict line per stage, no stage exited 2, the smoke stage's `lines` count matches its own assertion, the four non-claude CLIs are reported as `unavailable` rather than clean on the scan stage, and antigravity and kiro carry the note that their hook firing is unverified. Done when every detected CLI has exactly one verdict line per stage, no stage exited 2, the smoke stage's `lines` count matches its own assertion, the four non-claude CLIs are reported as `unavailable` rather than clean on the scan stage, and antigravity and kiro carry the note that their hook firing is unverified.
3. For each error — a failed purge, a failed wiring, an `unwired` status, a failed smoke, or a scanned error with `jsc=true` — run `tools/report-error.sh --hook {script name} --exit {code} --summary "{reason}" --cli {cli}` with the script's `[jsc]` output on stdin, then hand the failure to `jsc-hooks:repair`, which **MUST run as a sub agent** and must finish by opening a PR against `develop`. Aborting the remaining installs here is allowed as long as the repair starts. The error page and the error directory page live in two different wiki repos, resolved separately: the page through `wiki-repo ERROR`, the directory through `wiki-repo CONTENTS`. Exit 0 with an `ERROR_{HASH}` page name and URL on stdout means the page was written; the same exit 0 with a `[jsc]` line on stderr still means the page landed, and that line says what is missing — either the directory repo would not resolve, so nothing indexes the page, or the page URL could not be read back, so the page name comes out on its own — carry that note into step 4. Exit 0 with no output at all means the run ended on one of the quiet-degradation reasons listed in the script's own header — no `gitea.sh` on the path, the error page's wiki repo unresolved, the hash not computed, or a temp file not created — so no page was written at all and that reason goes into step 4 instead; exit 2 means the call itself was malformed — `--hook` or `--summary` is missing — so fix the arguments and rerun the same call; exit 4 means the wiki record did not land, so report the failure text and still start the repair — a page that could not be written is no reason to leave a broken hook wired. Exit 4 covers two cases, and the report has to say which: a failed write, or the script refusing to write the error directory page because it could not read the old one back. That directory is appended to, never overwritten: every row on it is somebody else's error report, so the script reads the page, adds this run's row, and writes the whole page. Only a genuine 404 (`wiki-get` exit 4) means the page is not there yet and lets it build one from the template. An invalid key (exit 7) or any other API failure (exit 8) leaves the old rows unknown, so it skips the directory write and names the code instead — writing a fresh template over a directory it never read would erase every earlier report, with no merge and no backup behind it. A scanned error with `jsc=false` belongs to a third-party hook: report it and leave it alone. Skip this step when every CLI passed all five stages. Done when every error carries one `ERROR_{HASH}` result — a page name with its URL, a page name plus the reason the URL is missing, or the recorded reason no page was written — and one repair PR URL against `develop`. 3. For each error — a failed purge, a failed wiring, an `unwired` status, a failed smoke, or a scanned error with `jsc=true` — run `{JSC_ROOT}/jsc-hooks/tools/report-error.sh --hook {script name} --exit {code} --summary "{reason}" --cli {cli}` with the script's `[jsc]` output on stdin, then hand the failure to `jsc-hooks:repair`, which **MUST run as a sub agent** and must finish by opening a PR against `develop`. Aborting the remaining installs here is allowed as long as the repair starts. The error page and the error directory page live in two different wiki repos, resolved separately: the page through `wiki-repo ERROR` in `report-error.sh` itself, the directory through `wiki-repo CONTENTS` inside `wiki-contents.sh`. Exit 0 with an `ERROR_{HASH}` page name and URL on stdout means the page was written; the same exit 0 with a `[jsc]` line on stderr still means the page landed, and that line says what is missing — the directory repo would not resolve (or `wiki-contents.sh` is not on disk), so nothing indexes the page; the page URL could not be read back, so the page name comes out on its own; or the URL failed the reachability check, so the directory entry's page-name bullet carries the page name as plain text with no link — carry that note into step 4. One error report is one H2 block on the directory page: the heading is that report's own page name, `ERROR_{HASH}`, and the fields sit under it as one bullet each — time, page name, repo, hook, exit code, summary, written `- {field}:{value}` — with no markdown table anywhere on the page. Every link inside that block is written as `[{text}]({url})` with the URL from `gitea.sh wiki-url`, and the script checks it with `jsc-gitea/tools/link-check.sh` before writing: exit 0 writes the link, anything else keeps the bullet and drops the link, and none of it changes the exit code — this is the failure-reporting path, so a failed report must never become a second failure. Exit 0 with no output at all means the run ended on one of the quiet-degradation reasons listed in the script's own header — no `gitea.sh` on the path, the error page's wiki repo unresolved, the hash not computed, or a temp file not created — so no page was written at all and that reason goes into step 4 instead; exit 2 means the call itself was malformed — `--hook` or `--summary` is missing — so fix the arguments and rerun the same call; exit 4 means the wiki record did not land, so report the failure text and still start the repair — a page that could not be written is no reason to leave a broken hook wired. Exit 4 covers two cases, and the report has to say which: a failed write, or the directory page left untouched because the old one could not be read back. The directory page itself is not assembled here: `report-error.sh` builds only its own block and hands it to `jsc-gitea/tools/wiki-contents.sh upsert ERROR 2 {page name} {block file} {template}`, so the read, the key match and the whole-page write have exactly one owner. That page is upserted, never overwritten: every block on it is somebody else's error report, so the tool reads the page, replaces the block whose heading equals this page name or appends a new block at the end, and writes the page back. Only a genuine 404 (`wiki-get` exit 4) means the page is not there yet and lets it build one from the template. An invalid key (exit 7) or any other API failure (exit 8) leaves the old blocks unknown, so it skips the directory write and names the code instead — writing a fresh template over a directory it never read would erase every earlier report, with no merge and no backup behind it. A scanned error with `jsc=false` belongs to a third-party hook: report it and leave it alone. Skip this step when every CLI passed all five stages. Done when every error carries one `ERROR_{HASH}` result — a page name with its URL, a page name plus the reason the URL is missing, or the recorded reason no page was written — and one repair PR URL against `develop`.
4. Report five results per CLI — purge, wiring, status, smoke, scan — each with the reason its script printed, plus the smoke `lines` count, any `ERROR_{HASH}` page name and every repair PR URL. Done when every detected CLI appears with one verdict per stage and every repair has a PR against `develop`. 4. Report five results per CLI — purge, wiring, status, smoke, scan — each with the reason its script printed, plus the smoke `lines` count, any `ERROR_{HASH}` page name and every repair PR URL. Done when every detected CLI appears with one verdict per stage and every repair has a PR against `develop`.
5. Record how the whole install ended. Run `tools/report-status.sh skill-end jsc-hooks:hooks-install {status} {exit} "{detail}"` — the script is in this same repo, so it takes the plain `tools/` path that every other stage above uses. **The main agent makes this one call, after every per-CLI report is in.** The per-CLI pipelines run as parallel sub agents and one skill run is one event, so a call inside those sub agents would write one line per CLI and turn the install's outcome into five contradictory ones. The gate that records a skill's start fires when the skill is loaded and can never see how it ended; without this line a finished install and an install abandoned halfway look identical afterwards, which is the whole reason the closing step exists.
- `{status}` is one of five. `ok`: every detected CLI passed all five stages and every one of them reported `wired` — in practice that means kiro was not on the machine. `degraded`: the pipeline ran to the end and part of it did not reach `wired`. That covers kiro, which is `degraded` by design because the CLI cannot block a skill call, and it covers a CLI whose stage failed and was handed to `jsc-hooks:repair` with a PR against `develop` — the failure has an owner and a fix in flight, so the install is incomplete, not broken. A `skipped` CLI belongs here too. `failed`: a stage failed and the failure was left with nobody holding it — `jsc-hooks:repair` could not be started, or it came back with no PR — so a broken hook stays wired and nothing is going to fix it. `blocked`: `wire-cli.sh` refused with exit 6 under `JSC_READONLY=1`, so no CLI was purged or wired at all. `aborted`: step 1 detected no CLI, so there was nothing to wire and the run stopped on a precondition rather than on an error.
- Take `{exit}` from the stage that decided the ending — the `wire-cli.sh`, `smoke` or `scan-hook-errors.sh` code — and otherwise use 0 for `ok` and 1 for every other status. `{detail}` is optional, one line, at most 200 characters: the CLI count per verdict, or the CLI and stage that failed. Never fold the five per-CLI reports into it; those go to the user in step 4.
- Reporting never changes the install. `report-status.sh` swallows its own write failures and always exits 0, and an absent file is skipped in silence — the same rule the nine hooks follow, and for the same reason: a reporter that can fail the thing it reports on is worse than no reporter. Done when the one call was made, or the script was absent and this step was skipped without a word.
## Notes ## Notes
+4
View File
@@ -16,3 +16,7 @@ This skill is exempt from the version guard and the post-deploy restart gate, be
3. Pick the smallest repair that makes the wiring pass, then apply it in the `hooks` repo. When the fix touches wiring behaviour, update `hooks/tools/wire-cli.sh`, `hooks/skills/hooks-install/SKILL.md` and `hooks/README.md` in the same change. Run `tools/wire-cli.sh smoke {cli}` for the affected CLI: exit 0 means the repair holds, exit 4 means it does not — go back to step 2 with the new output, exit 2 means a bad CLI name, so fix the name and rerun. Done when the fix is on disk and smoke exits 0. 3. Pick the smallest repair that makes the wiring pass, then apply it in the `hooks` repo. When the fix touches wiring behaviour, update `hooks/tools/wire-cli.sh`, `hooks/skills/hooks-install/SKILL.md` and `hooks/README.md` in the same change. Run `tools/wire-cli.sh smoke {cli}` for the affected CLI: exit 0 means the repair holds, exit 4 means it does not — go back to step 2 with the new output, exit 2 means a bad CLI name, so fix the name and rerun. Done when the fix is on disk and smoke exits 0.
4. Run `jsc-meta/tools/sync-skill-manifest.sh .` from the repo root. Exit 0 means the README skill list and all three manifests carry the same new version. Exit 1 means a missing path, a missing `JSC-SKILLS` marker or an unreadable manifest — fix the named file and rerun. Exit 2 means a usage error, so pass exactly one path. Any other exit code is an environment fault, never a successful sync: stop and report it. Done when the script exits 0 and the three manifests show the same version. 4. Run `jsc-meta/tools/sync-skill-manifest.sh .` from the repo root. Exit 0 means the README skill list and all three manifests carry the same new version. Exit 1 means a missing path, a missing `JSC-SKILLS` marker or an unreadable manifest — fix the named file and rerun. Exit 2 means a usage error, so pass exactly one path. Any other exit code is an environment fault, never a successful sync: stop and report it. Done when the script exits 0 and the three manifests show the same version.
5. Commit, push and open a PR with `jsc-git:pr` against `develop`. When `jsc-git:pr` returns no PR URL, report the repair as applied but unmerged, name the branch that holds it, and hand back the failure reason — never claim a PR exists. Done when a PR URL comes back, or the branch name and the failure reason are both reported. 5. Commit, push and open a PR with `jsc-git:pr` against `develop`. When `jsc-git:pr` returns no PR URL, report the repair as applied but unmerged, name the branch that holds it, and hand back the failure reason — never claim a PR exists. Done when a PR URL comes back, or the branch name and the failure reason are both reported.
6. Record how the repair ended. Run `tools/report-status.sh skill-end jsc-hooks:repair {status} {exit} "{detail}"` — the script is in this same repo, so it takes the plain `tools/` path, like `tools/wire-cli.sh` in step 3. This step runs on every route out of steps 1 to 5, the ones that stopped early included. What records a skill's start fires when the skill is loaded and cannot see how it ended, so a repair that never writes this line is indistinguishable afterwards from one that was abandoned with a hook still broken.
- `{status}` is one of five. `ok`: the fix is on disk, `wire-cli.sh smoke` exits 0 for every affected CLI, `sync-skill-manifest.sh` exits 0 with all three manifests on the same version, and a PR URL against `develop` came back. `degraded`: the repair holds — smoke exits 0 — but the change did not get all the way out, because `jsc-git:pr` returned no PR URL and the fix is sitting on a branch, or `sync-skill-manifest.sh` left the manifests unaligned. `failed`: the hook could not be repaired. Smoke still exits 4 after the diagnosis loop of step 2 came back around, or step 4 hit an environment fault, so the broken wiring is still broken. `aborted`: there was nothing to repair — step 1 found no failure context, no `ERROR_{HASH}` page and no failed `status=` line, so the run stopped on a precondition rather than on a fault. `blocked` does not arise: this skill is exempt from the version guard and the post-deploy restart gate, which are the only two gates that could hold it, and that exemption exists precisely because it is the way back from a broken hook.
- Take `{exit}` from whatever decided the ending — the `wire-cli.sh smoke` or `sync-skill-manifest.sh` code — and otherwise use 0 for `ok` and 1 for every other status. `{detail}` is optional, one line, at most 200 characters: the hook and CLI that were repaired, or the branch that holds an unmerged fix. Diagnosis output and diffs never go on this line.
- Reporting never changes the repair. `report-status.sh` swallows its own write failures and always exits 0, and an absent file is skipped in silence, so a missing reporter can never be the reason a repaired hook reads as unrepaired. Done when the call was made, or the script was absent and this step was skipped without a word.
+11 -6
View File
@@ -4,12 +4,17 @@
> >
> 存放位置:本頁落在 `JSC_WIKI_REPO_CONTENTS` 解出的目錄專用存取庫,與異常頁的存取庫是兩個不同的存取庫。 > 存放位置:本頁落在 `JSC_WIKI_REPO_CONTENTS` 解出的目錄專用存取庫,與異常頁的存取庫是兩個不同的存取庫。
> >
> 寫入語意:一列代表一次 hook 異常回報。寫入前先讀回整頁,同一筆異常已經有列就更新那一列,沒有才在文末附加一列,最後整頁寫回。一律 upsert 附加,禁止整頁覆蓋,也不得改動別人的列。 > 寫入語意:一個區塊代表一次 hook 異常回報。H2 標題就是那一筆的異常頁頁名 `ERROR_{HASH}`,欄位是標題底下的一層條列,一個欄位一條。寫入前先讀回整頁,同一筆異常已經有區塊就整塊換掉,沒有才在文末附加一個新區塊,最後整頁寫回。一律 upsert 附加,禁止整頁覆蓋,也不得改動別人的區塊。
> >
> 連結寫法:指向異常頁的連結一律用 `gitea.sh wiki-url` 產出的絕對網址。跨存取庫的 `[[頁名]]` 解不開,只會留下死連結。 > 連結寫法:一律寫成 `[{文字}]({絕對網址})`,網址取 `jsc-gitea/tools/gitea.sh wiki-url` 印出的那一個,不自己組路徑。wiki 自己那種雙中括號寫法只在同一個 wiki 裡解得開,寫錯不會報錯,畫面上看起來像正常文字或死連結。
>
> 寫入前驗證:要放進條列的連結,先交給 `jsc-gitea/tools/link-check.sh`,結束碼 0 才把連結寫進那一條。驗不過就只留純文字頁名,那一條照寫,異常紀錄不因為一條連結整份丟掉。驗證走 API,不看網頁狀態碼——私有存取庫的網頁網址對未登入請求一律回 404,拿狀態碼判會把還在的頁判成死連結。結束碼 7 是金鑰失效,不算死連結,也不改寫任何既有區塊。
## 異常清單 ## ERROR_{HASH}
| 時間 | 頁名 | 存取庫名稱 | 觸發 hook | 退出碼 | 摘要 | - 時間:{yyyy-MM-dd HH:mm:ss}
| --- | --- | --- | --- | --- | --- | - 頁名:[{頁名}]({wiki-url 印出的絕對網址})
| {yyyy-MM-dd HH:mm:ss} | [{error title}]({error url}) | {owner}/{repo} | {hook_name} | {exit_code} | {error_summary} | - 存取庫名稱:{owner}/{repo}
- 觸發 hook:{hook_name}
- 退出碼:{exit_code}
- 摘要:{error_summary}
+81 -49
View File
@@ -1,8 +1,10 @@
#!/usr/bin/env sh #!/usr/bin/env sh
# report-error.sh — 失敗回報流程:把一筆 hook 或工具異常寫成 wiki 的 ERROR_{HASH}, # report-error.sh — 失敗回報流程:把一筆 hook 或工具異常寫成 wiki 的 ERROR_{HASH},
# 並在 ERROR_CONTENTS 附上一列索引。頁面內容套用 templates/ 的兩份範本, # 並在異常目錄頁附上一個索引區塊。頁面內容套用 templates/ 的兩份範本,
# 範本是文案的唯一來源,本腳本只填欄位;真正寫進 wiki 前,還會先走 # 範本是文案的唯一來源,本腳本只填欄位;真正寫進 wiki 前,還會先走
# Gitea 寫入確認。 # Gitea 寫入確認。
# 目錄頁的讀回、比對與整頁寫回一律交給 jsc-gitea 的 tools/wiki-contents.sh,本腳本只組出
# 自己那一個區塊。目錄頁版面只留一份正本,十幾個目錄頁才不會各長一種樣子。
# #
# 用法: # 用法:
# report-error.sh --hook {名稱} --exit {碼} --summary {摘要} # report-error.sh --hook {名稱} --exit {碼} --summary {摘要}
@@ -20,16 +22,24 @@
# 3. 算不出 HASH(hash-id 失敗或回空字串) # 3. 算不出 HASH(hash-id 失敗或回空字串)
# 4. 建不出暫存檔(mktemp 失敗) # 4. 建不出暫存檔(mktemp 失敗)
# 異常頁與目錄頁分屬兩個存取庫,各解各的:解不出異常頁的存取庫就整支降級;解得出 # 異常頁與目錄頁分屬兩個存取庫,各解各的:解不出異常頁的存取庫就整支降級;解得出
# 異常頁、只解不出目錄頁的存取庫,就只寫異常頁、跳過目錄頁更新,印出頁名,仍然 exit 0。 # 異常頁、只解不出目錄頁的存取庫(wiki-contents.sh 回 3),就只寫異常頁、跳過目錄頁
# 更新,印出頁名,仍然 exit 0。找不到 wiki-contents.sh 也走同一條降級路。
# 一份寫得成的異常紀錄,不該因為目錄頁沒地方放就整份丟掉。 # 一份寫得成的異常紀錄,不該因為目錄頁沒地方放就整份丟掉。
# 寫入 wiki 失敗才以 exit 4 回報,訊息走 stderr。 # 寫入 wiki 失敗才以 exit 4 回報,訊息走 stderr。
# #
# 連結:
# 目錄頁那個區塊裡的「頁名」那一條指向異常頁,一律寫成 [{文字}]({連結}),網址取
# jsc-gitea 的 gitea.sh wiki-url,不自己組路徑。
# 寫入前先把那個網址交給 jsc-gitea 的 link-check.sh,結束碼 0 才把連結寫進那一條。
# 驗不過就只留純文字頁名:那一條照寫、異常頁照寫、結束碼照舊。這一段一律不改結束碼,
# 本腳本是失敗回報路徑,回報失敗不該再變成一次失敗。
#
# 結束碼: 0=已寫入異常頁並印出頁名(取得網址就一併印出),或以上列四種安靜降級原因之一 # 結束碼: 0=已寫入異常頁並印出頁名(取得網址就一併印出),或以上列四種安靜降級原因之一
# 結束、沒有寫出任何頁也沒有任何輸出——回報失敗不該再變成一次失敗 # 結束、沒有寫出任何頁也沒有任何輸出——回報失敗不該再變成一次失敗
# 2=用法錯誤(缺 --hook 或 --summary) # 2=用法錯誤(缺 --hook 或 --summary)
# 4=寫入 wiki 失敗(異常頁與索引目錄頁,任一支寫不進去就算),或目錄頁的舊內容 # 4=寫入 wiki 失敗(異常頁與索引目錄頁,任一支寫不進去就算),或目錄頁的舊內容
# 讀不回來(wiki-get 回 7 金鑰失效、8 其他 API 失敗)而放棄寫入;訊息走 stderr。 # 讀不回來(wiki-contents.sh 回 7 金鑰失效、8 其他 API 失敗)而放棄寫入;
# 讀不回來就不寫,是為了不拿範本蓋掉一份還在的目錄頁 # 訊息走 stderr。讀不回來就不寫,是為了不拿範本蓋掉一份還在的目錄頁
# 註: 本檔以 `. "$ROOT/hooks/lib.sh"` 載入共用函式,沒有接 `|| true`。lib.sh 讀不到時 sh 會 # 註: 本檔以 `. "$ROOT/hooks/lib.sh"` 載入共用函式,沒有接 `|| true`。lib.sh 讀不到時 sh 會
# 就地結束並回 2,跟用法錯誤同碼;分不出是哪一種時,先確認 hooks/lib.sh 在不在。 # 就地結束並回 2,跟用法錯誤同碼;分不出是哪一種時,先確認 hooks/lib.sh 在不在。
# #
@@ -76,9 +86,8 @@ gsh=$(jsc_gitea_sh) || exit 0
# 型別。異常頁的存取庫解不出來就整支降級,連異常都沒地方寫,做下去也沒意義。 # 型別。異常頁的存取庫解不出來就整支降級,連異常都沒地方寫,做下去也沒意義。
wrepo=$(sh "$gsh" wiki-repo ERROR 2>/dev/null) || exit 0 wrepo=$(sh "$gsh" wiki-repo ERROR 2>/dev/null) || exit 0
[ -n "$wrepo" ] || exit 0 [ -n "$wrepo" ] || exit 0
# 目錄頁的存取庫解不出來不算失敗:異常頁照寫,只跳過目錄頁更新,仍然 exit 0。 # 目錄頁的存取庫不在這裡解:讀回、比對、整頁寫回都由 wiki-contents.sh 做,它自己解目錄專用
# 一份寫得成的異常紀錄,不該因為索引沒地方放就整份丟掉。 # 存取庫,這邊再解一次就會有兩份規則。解不出來時它回 3,本腳本照原本的語意降級。
crepo=$(sh "$gsh" wiki-repo CONTENTS 2>/dev/null || true)
# 存取庫名稱未指定就取工作目錄的 origin(只用來標記異常屬於哪個存取庫) # 存取庫名稱未指定就取工作目錄的 origin(只用來標記異常屬於哪個存取庫)
if [ -z "$repo" ]; then if [ -z "$repo" ]; then
@@ -101,9 +110,10 @@ ts=$(date +'%Y-%m-%d %H:%M:%S')
ticket_ts=$(date +'%Y%m%d_%H%M%S') ticket_ts=$(date +'%Y%m%d_%H%M%S')
if [ -t 0 ]; then detail=""; else detail=$(cat 2>/dev/null | tr '\n' ' ' | cut -c1-500); fi if [ -t 0 ]; then detail=""; else detail=$(cat 2>/dev/null | tr '\n' ' ' | cut -c1-500); fi
[ -n "$detail" ] || detail="(無)" [ -n "$detail" ] || detail="(無)"
# 摘要與相關輸出都落在 markdown 表格欄位裡,半形 | 會把欄位切斷,改成全形 # 相關輸出落在異常頁的表格欄位裡,半形 | 會把欄位切斷,改成全形
detail=$(printf '%s' "$detail" | sed 's/|/|/g') detail=$(printf '%s' "$detail" | sed 's/|/|/g')
summary=$(printf '%s' "$summary" | sed 's/|/|/g') # 摘要在目錄頁是一整條條列,換行會把一條拆成兩行,先併成一行
summary=$(printf '%s' "$summary" | tr '\n' ' ')
hash=$(sh "$gsh" hash-id "$repo $hook $ts" 2>/dev/null) || exit 0 hash=$(sh "$gsh" hash-id "$repo $hook $ts" 2>/dev/null) || exit 0
[ -n "$hash" ] || exit 0 [ -n "$hash" ] || exit 0
@@ -114,8 +124,8 @@ esc() { printf '%s' "$1" | tr '\n' ' ' | sed 's/[\\&|]/\\&/g'; }
fill() { sed "s|$1|$(esc "$2")|g"; } fill() { sed "s|$1|$(esc "$2")|g"; }
tmp_page=$(mktemp) || exit 0 tmp_page=$(mktemp) || exit 0
tmp_list=$(mktemp) || { rm -f "$tmp_page"; exit 0; } tmp_entry=$(mktemp) || { rm -f "$tmp_page"; exit 0; }
trap 'rm -f "$tmp_page" "$tmp_list"' EXIT trap 'rm -f "$tmp_page" "$tmp_entry"' EXIT
fill '{HASH}' "$hash" < "$ROOT/templates/error-page.md" \ fill '{HASH}' "$hash" < "$ROOT/templates/error-page.md" \
| fill '{yyyy-MM-dd HH:mm:ss}' "$ts" \ | fill '{yyyy-MM-dd HH:mm:ss}' "$ts" \
@@ -133,15 +143,17 @@ fill '{HASH}' "$hash" < "$ROOT/templates/error-page.md" \
| fill '{yyyyMMdd}_{HHmmss}' "$ticket_ts" > "$tmp_page" | fill '{yyyyMMdd}_{HHmmss}' "$ticket_ts" > "$tmp_page"
# 異常頁先寫,網址後取。頁名的 hash 帶時間戳,每次回報都是一個全新的頁,寫進去之前查網址 # 異常頁先寫,網址後取。頁名的 hash 帶時間戳,每次回報都是一個全新的頁,寫進去之前查網址
# 一定是 404,拿到的必然是空字串;目錄頁那一列會變成沒有連結的死字,stdout 也少一半。 # 一定是 404,拿到的必然是空字串;目錄頁那一條會變成沒有連結的死字,stdout 也少一半。
if ! sh "$gsh" wiki-put "$wrepo" "$page" "$tmp_page" >/dev/null 2>&1; then if ! sh "$gsh" wiki-put "$wrepo" "$page" "$tmp_page" >/dev/null 2>&1; then
echo "[jsc] 寫入 $page 失敗($wrepo)。" >&2 echo "[jsc] 寫入 $page 失敗($wrepo)。" >&2
exit 4 exit 4
fi fi
# 目錄頁與異常頁分屬兩個存取庫,wiki 的 [[頁名]] 只在同一個存取庫內解得開,跨庫一律解成 # 目錄頁那個區塊的「頁名」那一條指向異常頁,連結一律寫成 [{文字}]({連結}),網址取
# 死連結。所以目錄頁指向異常頁的連結一律用絕對網址。 # gitea.sh wiki-url。
# 網址取不到不算失敗:異常頁已經寫成功了,只是這一列少一條連結。這裡把原因記下來走 stderr, # wiki 自己那種雙中括號寫法只在同一個 wiki 裡解得開,寫錯不會報錯,畫面上看起來像正常
# 文字或死連結,巡不到也修不了。
# 網址取不到不算失敗:異常頁已經寫成功了,只是這一條少一個連結。這裡把原因記下來走 stderr,
# 結束碼照舊——安靜降級仍是 exit 0,回報失敗不該再變成一次失敗。 # 結束碼照舊——安靜降級仍是 exit 0,回報失敗不該再變成一次失敗。
url=$(sh "$gsh" wiki-url "$wrepo" "$page" 2>/dev/null) url=$(sh "$gsh" wiki-url "$wrepo" "$page" 2>/dev/null)
url_code=$? url_code=$?
@@ -154,54 +166,74 @@ if [ "$url_code" -ne 0 ]; then
7) url_note='金鑰失效或權限不足(wiki-url 回 7)' ;; 7) url_note='金鑰失效或權限不足(wiki-url 回 7)' ;;
*) url_note="wiki-url 結束碼 $url_code" ;; *) url_note="wiki-url 結束碼 $url_code" ;;
esac esac
echo "[jsc] 取不到 $page 的網址:$url_note。目錄頁那一列與輸出只留頁名。" >&2 echo "[jsc] 取不到 $page 的網址:$url_note。目錄頁那一條與輸出只留頁名。" >&2
fi fi
emit() { # 異常頁已經寫成功,頁名一定要印;網址取不到就只印頁名,不印一個空欄位 emit() { # 異常頁已經寫成功,頁名一定要印;網址取不到就只印頁名,不印一個空欄位
if [ -n "$url" ]; then printf '%s %s\n' "$page" "$url"; else printf '%s\n' "$page"; fi if [ -n "$url" ]; then printf '%s %s\n' "$page" "$url"; else printf '%s\n' "$page"; fi
} }
row=$(printf '| %s | [%s](%s) | %s | %s | %s | %s |' \ # 連結先驗證連得到,才寫進目錄頁那一條。沒驗過的連結寫進去,異常頁一樣會在目錄頁長出
"$ts" "$hook 異常 $ts" "$url" "$repo" "$hook" "$code" "$summary") # 死連結,而目錄頁是別人查問題的入口。驗不過就只留純文字頁名,那一條照寫。
link_text="$page"
if [ -n "$url" ]; then
# link-check.sh 與 gitea.sh 同一個 tools 目錄,路徑直接由已經解出來的那一支推得,
# 不另寫一套搜尋,兩邊才不會一支解到開發版面、一支解到安裝版面。
lcs="$(dirname "$gsh")/link-check.sh"
if [ ! -f "$lcs" ]; then
echo "[jsc] 找不到 $lcs,這一條的連結沒驗過,只留頁名;$page 已建立。" >&2
else
sh "$lcs" "$url" >/dev/null 2>&1
lc_code=$?
case "$lc_code" in
0) link_text=$(printf '[%s](%s)' "$page" "$url") ;;
# 7 是金鑰失效,不是死連結。金鑰過期時私有存取庫的回應與「頁不存在」分不出來,
# 把它當成死連結就會連還在的頁一起判死。
7) echo "[jsc] 連結驗證遇上金鑰失效(link-check.sh 回 7),不判成死連結,這一條只留頁名;$page 已建立。" >&2 ;;
3) echo "[jsc] GITEA_HOST 未設定(link-check.sh 回 3),連結沒驗過,這一條只留頁名;$page 已建立。" >&2 ;;
*) echo "[jsc] 連結驗不過(link-check.sh 結束碼 $lc_code),這一條只留頁名;$page 已建立。" >&2 ;;
esac
fi
fi
build_contents() { # 用範本建一份全新的目錄頁;只有確定舊頁不存在時才可以呼叫 # 目錄頁上這一筆是一個 H2 區塊:標題就是異常頁頁名,欄位一行一條,順序與範本的示範區塊
fill '{yyyy-MM-dd HH:mm:ss}' "$ts" < "$ROOT/templates/error-contents.md" \ # 一致。標題不放連結也不放網址——頁名只由存取庫名稱、hook 與時間決定,換主機或改存取庫
| fill '{error url}' "$url" \ # 都動不到它,比對鍵才找得到既有那一筆。
| fill '{error title}' "$hook 異常 $ts" \ {
| fill '{owner}/{repo}' "$repo" \ printf '## %s\n\n' "$page"
| fill '{hook_name}' "$hook" \ printf -- '- 時間:%s\n' "$ts"
| fill '{exit_code}' "$code" \ printf -- '- 頁名:%s\n' "$link_text"
| fill '{error_summary}' "$summary" > "$tmp_list" printf -- '- 存取庫名稱:%s\n' "$repo"
} printf -- '- 觸發 hook:%s\n' "$hook"
printf -- '- 退出碼:%s\n' "$code"
printf -- '- 摘要:%s\n' "$summary"
} > "$tmp_entry"
if [ -z "$crepo" ]; then # 目錄頁交給 wiki-contents.sh:它負責解目錄專用存取庫、讀回舊頁、比對 H2 標題找既有區塊、
echo "[jsc] 目錄頁的 wiki 存取庫解不出來,只寫異常頁,跳過目錄;$page 已建立($wrepo)。" >&2 # 附加或整塊換掉,再整頁寫回,還會把舊的表格頁轉成條列。頁上每一個區塊都是別人回報的異常,
# 那套「讀得回舊內容才寫」的判斷只留一份正本,才不會每個目錄頁各寫一套、錯一次少一筆紀錄。
# 傳進去的那個 2 是 key-col:舊表格版目錄頁裡持有身分的欄位序號。舊版第 2 欄是頁名,
# 舊頁自動轉條列時要靠它取 H2 標題;頁面已經是條列格式時這個參數用不到。
# 路徑由已經解出來的 gitea.sh 推得,與 link-check.sh 同一套做法,三支同一個 tools 目錄。
wcs="$(dirname "$gsh")/wiki-contents.sh"
if [ ! -f "$wcs" ]; then
echo "[jsc] 找不到 $wcs,只寫異常頁,跳過目錄;$page 已建立($wrepo)。" >&2
emit emit
exit 0 exit 0
fi fi
# 異常目錄頁一律附加,不整頁覆蓋。頁上每一列都是別人回報的異常,本腳本沒有從別處讀過 sh "$wcs" upsert ERROR 2 "$page" "$tmp_entry" "$ROOT/templates/error-contents.md" >/dev/null
# 那些列,所以先把舊頁讀回來、把新列附在文末(最新一筆在最後),再整頁寫回。 wc_code=$?
# 這個語意完全靠「讀得回舊內容」撐著,因此依 wiki-get 的結束碼分流: case "$wc_code" in
# 0 → 讀到既有內容,附加新列(讀得到但整頁是空的,沒有既有列會被蓋掉,套範本才安全) 0) ;;
# 4 → 頁面真的還不存在,只有這個碼可以用範本建立新頁 # 3 是目錄頁的存取庫沒設定。異常頁已經寫成功,一份寫得成的異常紀錄不該因為索引沒地方放
# 7 → 金鑰失效或權限不足,舊內容未知,放棄目錄頁寫入 # 就整份丟掉,所以只跳過目錄頁,結束碼照舊回 0。
# 8 → 其他 API 失敗,舊內容一樣未知,處置同 7 3) echo "[jsc] 目錄頁的 wiki 存取庫解不出來(wiki-contents.sh 回 3),只寫異常頁,跳過目錄;$page 已建立($wrepo)。" >&2 ;;
# 為什麼 7 與 8 不能當成「頁面不存在」:拿範本蓋掉一份讀不回來的目錄頁,等於刪光所有既有 # 其餘結束碼都代表這一筆沒進到目錄頁:7 金鑰失效、8 其他 API 失敗都是舊內容未知而中止,
# 列,而 wiki-put 不做合併、也不留備份,蓋掉就救不回來。 # 1 是組不出頁面內容或寫入失敗,2 是用法錯誤,4 是頁面不存在又沒收到範本。
sh "$gsh" wiki-get "$crepo" ERROR_CONTENTS > "$tmp_list" 2>/dev/null
get_code=$?
case "$get_code" in
0) if [ -s "$tmp_list" ]; then printf '%s\n' "$row" >> "$tmp_list"; else build_contents; fi ;;
4) build_contents ;;
*) *)
echo "[jsc] 讀取目錄頁失敗($crepo,wiki-get 結束碼 $get_code),舊內容未知,不寫目錄頁;$page 已建立。" >&2 echo "[jsc] 目錄頁寫入失敗(wiki-contents.sh 結束碼 $wc_code),$page 已建立。" >&2
exit 4 ;; exit 4 ;;
esac esac
if ! sh "$gsh" wiki-put "$crepo" ERROR_CONTENTS "$tmp_list" >/dev/null 2>&1; then
echo "[jsc] 寫入目錄頁失敗($crepo),$page 已建立。" >&2
exit 4
fi
emit emit
+130
View File
@@ -0,0 +1,130 @@
#!/usr/bin/env sh
# report-status.sh — 技能與 hook 的執行狀態事件流。
#
# 為什麼要有這支:現行 usage/skills.jsonl 只記「被叫用」,欄位是 {ts,cli,session,skill},
# 沒有成敗、沒有結束碼。跑完整輪的技能與開場就中止的技能,在紀錄裡長得一模一樣。
# hook 成功時更是完全不留紀錄,只有錯誤路徑會寫 wiki,而那條路徑刻意不自動觸發。
#
# 為什麼不直接寫 wiki:hook 每次提示都跑,網路寫入會拖垮宿主 CLI;失敗的 hook 自我回報
# 還會疊出迴圈。所以一律先寫本機事件流,助理巡檢時排空、彙整、寫 MONITOR 頁。
#
# 用法:
# report-status.sh skill-start <名稱>
# report-status.sh skill-end <名稱> <status> [結束碼] [detail]
# report-status.sh hook-end <名稱> <status> <結束碼> [detail]
# report-status.sh drain # 印出上次排空之後的新事件
# report-status.sh rotate # 超過上限就輪替,只留一份舊的
#
# <名稱>: 技能寫 {domain}:{skill},hook 寫 {腳本檔名} 加子命令,例如 sdlc-gate check。
# <status>: ok、blocked、failed、degraded、aborted 五選一。
# ok 完成條件全部達成
# blocked 被閘門或前置條件擋下,沒有做事
# failed 做到一半失敗
# degraded 做完了但有部分沒達成
# aborted 使用者中止,或前提不成立而主動停止
#
# 規則:
# - 三個記錄子命令一律回 0,寫檔失敗也是 0。回報機制自己壞掉,不可以讓被回報的東西
# 跟著壞——hook 的結束碼是閘門的判準,被記錄動到就等於閘門行為被記錄改寫。
# 參數檢查是例外:那是呼叫端的程式錯誤,寫進去只會汙染事件流,所以先擋下來。
# - 本檔不讀 stdin。技能由 Bash 呼叫它,stdin 可能是還沒關閉的管線,讀下去會卡住宿主。
# 所有資訊一律走參數。
# - 輪替不放在每次寫入。每次提示都寫事件,順手 stat 一次檔案就是每次提示多一次系統呼叫;
# 改由巡檢排空之後呼叫 rotate,成本落在本來就週期性執行的地方。
#
# 結束碼: 0=成功(記錄子命令一律 0) 2=用法錯誤 3=drain 沒有新事件
set -eu
HERE=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
# lib.sh 在同一個存取庫,不是跨 plugin 依賴。hook 必須自足,事件寫入的函式因此留在 lib.sh,
# 這支只是把它包成命令列介面給技能用。
STDIN_JSON=""
. "$HERE/../hooks/lib.sh"
EVENTS="$JSC_HOME/usage/events.jsonl"
OFFSET="$JSC_HOME/usage/scan-state/events.offset"
MAX_BYTES=5242880
usage() {
cat >&2 <<'EOF'
用法:
report-status.sh skill-start <名稱>
report-status.sh skill-end <名稱> <status> [結束碼] [detail]
report-status.sh hook-end <名稱> <status> <結束碼> [detail]
report-status.sh drain
report-status.sh rotate
status: ok、blocked、failed、degraded、aborted
結束碼: 0=成功 2=用法錯誤 3=drain 沒有新事件
EOF
exit 2
}
valid_status() {
case "$1" in
ok|blocked|failed|degraded|aborted) ;;
*) echo "[jsc][狀態回報][ERR]:status 須為 ok、blocked、failed、degraded、aborted 五選一,收到「$1」。" >&2; exit 2 ;;
esac
}
valid_exit() {
case "$1" in
''|*[!0-9]*) echo "[jsc][狀態回報][ERR]:結束碼須為非負整數,收到「$1」。" >&2; exit 2 ;;
esac
}
cmd="${1:-}"; [ -n "$cmd" ] || usage
shift || true
case "$cmd" in
skill-start)
name="${1:-}"; [ -n "$name" ] || usage
# start 沒有成敗可言,狀態欄固定 ok、結束碼固定 0。判讀靠的是「有沒有配對的 end」:
# 有 start 沒 end 就是中止,那正是現行紀錄分不出來的那一種。
emit_event skill "$name" start ok 0
;;
skill-end)
name="${1:-}"; status="${2:-}"
[ -n "$name" ] && [ -n "$status" ] || usage
valid_status "$status"
code="${3:-0}"; valid_exit "$code"
emit_event skill "$name" end "$status" "$code" "" "${4:-}"
;;
hook-end)
name="${1:-}"; status="${2:-}"; code="${3:-}"
[ -n "$name" ] && [ -n "$status" ] && [ -n "$code" ] || usage
valid_status "$status"; valid_exit "$code"
emit_event hook "$name" end "$status" "$code" "" "${4:-}"
;;
drain)
[ -f "$EVENTS" ] || exit 3
size=$(wc -c < "$EVENTS" 2>/dev/null || echo 0)
old=0
[ -f "$OFFSET" ] && old=$(cat "$OFFSET" 2>/dev/null || echo 0)
case "$old" in ''|*[!0-9]*) old=0 ;; esac
# 檔案比已存位移還小就是輪替過,從頭讀。不比對 inode:五支 CLI 與容器裡的行程
# 看到的 inode 不保證一致,用大小判斷才在每個環境都成立。
[ "$size" -lt "$old" ] && old=0
[ "$size" -eq "$old" ] && exit 3
mkdir -p "$(dirname "$OFFSET")" 2>/dev/null || true
# tail -c +N 從第 N 個位元組起(1 起算),所以位移要加一。
# 不用 dd bs=1 skip=:那是一個位元組一次系統呼叫,位移到了幾 MB 就是幾百萬次,
# 每輪巡檢都排空一次的話會慢到不能用。
tail -c "+$((old + 1))" "$EVENTS" 2>/dev/null || true
printf '%s' "$size" > "$OFFSET" 2>/dev/null || true
;;
rotate)
[ -f "$EVENTS" ] || exit 0
size=$(wc -c < "$EVENTS" 2>/dev/null || echo 0)
if [ "$size" -gt "$MAX_BYTES" ]; then
mv "$EVENTS" "$EVENTS.1" 2>/dev/null || true
: > "$EVENTS" 2>/dev/null || true
# 位移歸零:新檔從頭算起,不歸零的話下一次 drain 會跳過開頭那一段。
printf '0' > "$OFFSET" 2>/dev/null || true
printf '已輪替:%s -> %s.1(原大小 %s 位元組)\n' "$EVENTS" "$EVENTS" "$size"
fi
;;
*) usage ;;
esac
exit 0
+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") \
+113 -37
View File
@@ -3,8 +3,10 @@
# 用法: # 用法:
# 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} 唯讀盤點接線現況,不寫檔也不執行 hook # wire-cli.sh status {claude|codex|copilot|antigravity|kiro} [--verdict]
# 唯讀盤點接線現況,不寫檔也不執行 hook。
# --verdict 只換結束碼語意,輸出一字不變
# #
# JSC_READONLY=1 時只准 status 與 smoke,purge 與接線一律拒絕並回 exit 6。體檢類技能全程帶著 # JSC_READONLY=1 時只准 status 與 smoke,purge 與接線一律拒絕並回 exit 6。體檢類技能全程帶著
# 這個變數跑,「子命令打錯一個字就重新接線或刪檔」的風險就由程式擋掉,不靠呼叫端自我約束。 # 這個變數跑,「子命令打錯一個字就重新接線或刪檔」的風險就由程式擋掉,不靠呼叫端自我約束。
@@ -35,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
@@ -72,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 要逐項講明。
@@ -128,6 +130,9 @@
# 結束碼(purge): 0=purged 2=用法錯誤 3=skipped 4=failed # 結束碼(purge): 0=purged 2=用法錯誤 3=skipped 4=failed
# 結束碼(smoke): 0=ok 2=用法錯誤 4=failed(含結果行數與預期不符) # 結束碼(smoke): 0=ok 2=用法錯誤 4=failed(含結果行數與預期不符)
# 結束碼(status): 0=wired 1=degraded 2=用法錯誤 3=skipped 5=unwired(該接的段落缺了至少一項) # 結束碼(status): 0=wired 1=degraded 2=用法錯誤 3=skipped 5=unwired(該接的段落缺了至少一項)
# 結束碼(status --verdict): 0=該接的都接了(含 degraded——先天限制不算缺漏)
# 2=用法錯誤 3=skipped 5=unwired
# 給拿結束碼判成敗的呼叫端用,例如助理的內建檢查項。理由見下方 --verdict 那一段。
# 結束碼(唯讀模式): 6=readonly(JSC_READONLY=1 之下拒絕 purge 與接線),status 與 smoke 不受影響 # 結束碼(唯讀模式): 6=readonly(JSC_READONLY=1 之下拒絕 purge 與接線),status 與 smoke 不受影響
# status 之外的動作都會寫檔,體檢類技能(/jsc-cli:doctor)只能呼叫 status。判讀邏輯跟接線 # status 之外的動作都會寫檔,體檢類技能(/jsc-cli:doctor)只能呼叫 status。判讀邏輯跟接線
# 共用同一組檔案位置與標記字串,分兩份實作就會各自漂移,體檢說沒接、實際上接著。 # 共用同一組檔案位置與標記字串,分兩份實作就會各自漂移,體檢說沒接、實際上接著。
@@ -136,8 +141,14 @@
# 有這個逃生門才測得動 purge 的 JSON 刪鍵:預設路徑是使用者自己的設定檔,拿真檔案試刪 # 有這個逃生門才測得動 purge 的 JSON 刪鍵:預設路徑是使用者自己的設定檔,拿真檔案試刪
# 等於拿使用者的環境當測試場。指向一份複製品就能完整跑過 purge claude 而不動到本人設定。 # 等於拿使用者的環境當測試場。指向一份複製品就能完整跑過 purge claude 而不動到本人設定。
set -u set -u
HERE=$(cd "$(dirname "$0")" && pwd) # 這兩行一定要實體解析(`cd -P` 加 `pwd -P`),不能拿邏輯路徑。
ROOT=$(cd "$HERE/.." && pwd) # ROOT 會被 ensure_stable_root() 當成 `ln -sfn "$ROOT" "$_link"` 的目標,而這支腳本本身
# 常常就是經由那條連結被叫起來的。邏輯解析會把連結原樣留在路徑裡,於是 ROOT 等於連結
# 自己,連結被改成指向自己,之後每一支 hook 的接線路徑都解不開,全機器 hook 一起失效。
# 這件事實際發生過。實體解析永遠退到連結指向的那個實際目錄,這個失敗模式就不可能成立。
# 事後才用 `[ -f ]` 檢查連結通不通不夠:那要等連結已經被寫壞才攔得到。
HERE=$(CDPATH= cd -P -- "$(dirname -- "$0")" && pwd -P)
ROOT=$(CDPATH= cd -P -- "$HERE/.." && pwd -P)
HOOKS="$ROOT/hooks" HOOKS="$ROOT/hooks"
JSC_HOME="${JSC_HOME:-$HOME/.jsc}" JSC_HOME="${JSC_HOME:-$HOME/.jsc}"
WIRE_ROOT="$ROOT" WIRE_ROOT="$ROOT"
@@ -163,6 +174,30 @@ case "$cli" in
claude|codex|copilot|antigravity|kiro) ;; claude|codex|copilot|antigravity|kiro) ;;
*) usage ;; *) usage ;;
esac esac
shift 2>/dev/null || true
# --verdict:輸出一字不變,只有結束碼換一套語意——該接的都接了就回 0,先天限制不算。
#
# 為什麼要有這個旗標。status 的結束碼帶的是狀態:0 是接好、1 是接好但這支 CLI 做不到、
# 5 是有東西沒接。那是給人看的三分法,也是對的。問題出在被當成檢查用:助理的內建檢查項
# 照結束碼判成敗,非零就是那一筆失敗、失敗次數加一。
# 於是有先天限制的那一支 CLI 每一輪都讓那一筆失敗一次,一天 96 次,而沒有人修得動——
# 那支 CLI 擋不下技能叫用是它的架構,不是接線缺漏,16 個接線項目全部就位。
# 那個計數存在的理由是指出「有一筆壞掉的項目每輪重試而沒人知道」,被這樣填滿就等於用
# 一個修不動的數字把真的壞掉蓋掉。
# 修在這裡而不是修在讀的那一邊:狀態與成敗是兩種語意,混在同一個通道上才是根因。這個
# 旗標把成敗那一種單獨拉出來,`status` 保持原樣給人看。
VERDICT_ONLY=0
while [ "$#" -gt 0 ]; do
case "$1" in
--verdict) VERDICT_ONLY=1; shift ;;
*) usage ;;
esac
done
case "$action:$VERDICT_ONLY" in
status:1|*:0) ;;
*) printf '[jsc] --verdict 只有 status 用得到,%s 不收這個旗標。\n' "$action" >&2; exit 2 ;;
esac
# 唯讀契約在程式層把關,不靠呼叫端記得只打 status。子命令解析完就判:預設動作是接線, # 唯讀契約在程式層把關,不靠呼叫端記得只打 status。子命令解析完就判:預設動作是接線,
# 所以少打一個子命令就會直接改環境,這個判定要擋的正是那一次手滑。 # 所以少打一個子命令就會直接改環境,這個判定要擋的正是那一次手滑。
@@ -247,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
{ {
@@ -509,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 },
@@ -1352,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 區隔與舊格式相容
@@ -1364,7 +1413,7 @@ 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=16
@@ -1415,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
@@ -2205,7 +2255,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
@@ -2225,8 +2275,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
# #
@@ -2301,23 +2351,34 @@ 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 全靠這一個檔宣告,只看檔案在不在會漏掉「檔在、某支沒接進去」。
# 後來才加進來的 comment-scope.sh、lang-guard.sh、restart-gate.sh 與 write-guard.sh # 每一支都列一項,一支都不省。reason 那行講的是「全部十支」,列舉卻只挑幾支的話,
# 是最可能漏的四支,所以各列一項。 # 拿這份輸出驗收接線的人會把沒列到的當成沒接——模型能力鎖就是這樣被誤判成沒接線的。
if [ -f "$claude_hooks" ] && grep -qF 'comment-scope.sh' "$claude_hooks" 2>/dev/null # 別支 CLI 的那幾段同樣逐支點名,但名單各自取自「那一支的接線實際會寫出去的內容」,
then st_item comment-scope "$claude_hooks" present # 因為每一支能接到的範圍不同:codex 拿的是從這一份推導出來的複本(十支全帶),
else st_item comment-scope "$claude_hooks" missing; fi # copilot 與 antigravity 只接得上兩道閘門,其餘幾支在那兩支 CLI 上是沒有事件可掛。
if [ -f "$claude_hooks" ] && grep -qF 'lang-guard.sh' "$claude_hooks" 2>/dev/null for _h in comment-scope lang-guard restart-gate skill-usage session-reminder version-guard; do
then st_item lang-guard "$claude_hooks" present if [ -f "$claude_hooks" ] && grep -qF "$_h.sh" "$claude_hooks" 2>/dev/null
else st_item lang-guard "$claude_hooks" missing; fi then st_item "$_h" "$claude_hooks" present
if [ -f "$claude_hooks" ] && grep -qF 'restart-gate.sh' "$claude_hooks" 2>/dev/null else st_item "$_h" "$claude_hooks" missing; fi
then st_item restart-gate "$claude_hooks" present done
else st_item restart-gate "$claude_hooks" missing; fi # 一支腳本接在多個接線點時,每個點各自是一件事,只驗腳本名會漏掉少接的那一個。
# write-guard.sh 接在兩個 matcher 上,三種模式各自是一件事,只驗腳本名會漏掉少接的那一個 # 三支這種腳本的接線點對照(左邊是項目名,右邊是 hooks.json 裡的子命令):
for _m in stage review commit; do # write-guard stage、review、commit
if [ -f "$claude_hooks" ] && grep -qF "write-guard.sh\\\" $_m" "$claude_hooks" 2>/dev/null # sdlc-gate check(階段模型鎖)、wp-check prompt、wp-check skill(工作包歸屬)
then st_item "write-guard-$_m" "$claude_hooks" present # session-timer start、mark
else st_item "write-guard-$_m" "$claude_hooks" missing; fi for _p in "write-guard-stage write-guard.sh\\\" stage" \
"write-guard-review write-guard.sh\\\" review" \
"write-guard-commit write-guard.sh\\\" commit" \
"sdlc-gate-check sdlc-gate.sh\\\" check" \
"sdlc-gate-wp-prompt sdlc-gate.sh\\\" wp-check prompt" \
"sdlc-gate-wp-skill sdlc-gate.sh\\\" wp-check skill" \
"session-timer-start session-timer.sh\\\" start" \
"session-timer-mark session-timer.sh\\\" mark"; do
_name=${_p%% *}; _pat=${_p#* }
if [ -f "$claude_hooks" ] && grep -qF "$_pat" "$claude_hooks" 2>/dev/null
then st_item "$_name" "$claude_hooks" present
else st_item "$_name" "$claude_hooks" missing; fi
done ;; done ;;
codex) codex)
@@ -2373,7 +2434,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
@@ -2401,6 +2464,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
@@ -2438,7 +2508,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
@@ -2486,7 +2557,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
@@ -2505,7 +2576,10 @@ if [ "$action" = status ]; then
fi fi
if [ -n "$st_degrade" ]; then if [ -n "$st_degrade" ]; then
printf 'status=degraded reason=%s\n' "$st_degrade" printf 'status=degraded reason=%s\n' "$st_degrade"
cat "$st_items"; rm -f "$st_items"; exit 1 cat "$st_items"; rm -f "$st_items"
# --verdict 之下先天限制不算失敗:輸出照印,讓人看得到,但結束碼說「該接的都接了」。
[ "$VERDICT_ONLY" -eq 1 ] && exit 0
exit 1
fi fi
printf 'status=wired reason=%s\n' "$st_wired" printf 'status=wired reason=%s\n' "$st_wired"
cat "$st_items"; rm -f "$st_items"; exit 0 cat "$st_items"; rm -f "$st_items"; exit 0
@@ -2531,7 +2605,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 ;;
@@ -2586,9 +2660,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 \
@@ -2754,7 +2830,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 改不了,