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
jiantw83 f4871afd19 feat(wiki): 異常目錄頁改走專用存取庫,並修正目錄列連結恆空
What:ERROR_CONTENTS 改由 wiki-repo CONTENTS 解析,ERROR_{HASH} 仍走
wiki-repo ERROR,兩者是兩個不同的存取庫。目錄列的連結改用 wiki-url 的絕對網址。
註解掃描的頁面編號樣式補上 40 碼與 H 加 7 碼兩種形狀。

Why:目錄列的網址原本在異常頁寫入之前就取,而頁名的雜湊帶時間戳、每次都是全新頁,
那時查一定是 404,又被吞掉,所以那一格一直都是空的。註解掃描原本只收 8 碼純十六進位,
舊演算法有十三個首碼會改寫成 H 開頭,等於對絕大多數舊頁編號漏偵測。

How:降級語意分兩層——異常頁的存取庫解不出來就整支安靜降級,只有目錄頁解不出來就
只寫異常頁、跳過索引,兩種都維持 exit 0。「只有 exit 4 才准建新頁、7 與 8 一律中止」
那段原樣保留,那是防止把金鑰失效讀成頁面不存在、拿範本蓋掉整頁既有列。

Who:jsc-hooks
2026-09-02 11:30:49 +08:00
admin ccbf0b8ef7 Merge pull request '能力閘門依 CLI 分流並隔離狀態檔,判不出能力一律擋下' (#72) from fix/sdlc-gate-cli-isolation into develop
Reviewed-on: #72
Reviewed-by: 系統管理員 <1+admin@noreply.localhost>
2026-09-02 03:11:42 +00:00
jiantw83 78dcb5e33b fix(sdlc-gate): 能力閘門依 CLI 分流,判不出能力就擋下
What:
- current_model_report() 的偵測鏈依 CLI 分流,一支只讀自己的紀錄。claude 讀 transcript 與 hook stdin,codex 讀 hook stdin 與自己的 session 記錄,copilot、antigravity、kiro 本機沒有可讀的模型紀錄,判不出是哪一支 CLI 時不採用任何自動來源。JSC_MODEL 人工覆寫五支都保留,而且一律排在最後。
- session_id() 的退路鏈補上 CLAUDE_CODE_SESSION_ID,cli_name() 補上 CLAUDECODE 與 CLAUDE_CODE_SESSION_ID。
- 階段鎖狀態檔改成 sessions/{CLI 代號}-{sid}.stage。舊路徑仍讀得到,unlock 不帶參數時新舊兩份一起清,也收一個狀態檔路徑當參數。
- check 改成 fail-closed:判不出 CLI、判不出模型、模型不在能力標籤表上,三種一律以 exit 2 擋下該輪提示。每一則擋下的訊息都印出兩條逃生門。
- write-guard.sh 的 stage 模式改讀 lib.sh 的路徑函式,擋人訊息把實際讀到的狀態檔路徑寫進解除指令。
- codex_session_file() 取「全樹最新一支」的做法改掉。
- wire-cli.sh 的冒煙夾具跟著補:模型來源四條各自指定 CLI 代號,另加三種擋下情形、check 的 fail-closed、階段鎖的 CLI 區隔與舊格式相容,這一組的判定條數由四條增為十二條。
- README 的 hooks 表、狀態檔分界表與環境變數表跟著改。

Why:
- 使用者回報兩件事:在 claude 底下被 codex 的模型判定,而且這道閘門應該只鎖能力標籤、不鎖模型。查下來根因是同一個——閘門完全不分辨自己跑在哪一支 CLI 上。
- 偵測鏈不分流,claude 拿不到 transcript 就一路掉進 codex 的 session 記錄,拿別支的模型判這一支。順帶每一輪都對一個上千個檔、兩百多 MB 的目錄樹跑一次 find,宿主 CLI 跟著卡。
- session_id() 讀的變數名在目前的 claude 上並不存在,sid 一路退回 default。cli_name() 犯同一類錯:只認 plugin 接線才有的變數,技能以 Bash 工具呼叫腳本時取不到,一律判成 unknown。
- 前三項合起來的後果是:技能呼叫 lock 算出的檔名,跟 hook 算出的檔名永遠不同。階段鎖上了也對不上,所以這道閘門在 claude 上一直沒有真正生效。
- 狀態檔不分 CLI,各支就共用同一支 default.stage,一支上的鎖擋到另一支。那不只擋提示,write-guard.sh 的 stage 模式連 Write、Edit、MultiEdit 一起擋。

How:
- 政策改成 fail-closed:不知道能力就擋下,知道才比對標籤。原本判不出模型只提醒不擋,那等於「換一支讀不到紀錄的 CLI 就能繞過去」,閘門形同虛設。代價是把人鎖在送不出提示的狀態,所以每一則擋下的訊息都要帶兩條逃生門,照抄就脫困。
- 逃生門的路徑照抄進指令裡。擋人的是 hook,解鎖的是人在殼層手動執行,兩邊算出來的檔名未必相同,不點名就解不到真正擋人的那一支。
- unlock 的參數只收 sessions 目錄底下的 .stage 檔,逃生門不該順便變成任意刪檔的工具。判準刻意不綁這支行程算出的 JSC_HOME:擋人的一側跟解鎖的一側未必相同,綁上去就會把照抄訊息的人擋掉,那正是這個逃生門要避免的事。
- 狀態檔用檔名前綴不用子目錄。外部工具以單層的 sessions/*.stage 盤點階段鎖,改成子目錄會讓每一支鎖從那些盤點裡整批消失。
- 舊路徑只讀不搬也不刪,unlock 一併清掉。只清新的那一份,舊格式的鎖就永遠解不開,被它擋住的人沒有逃生門。
- 變數名只補實測看得到的那幾個,而且只補 claude 這一支。其他 CLI 的變數名與環境特徵沒有實測過,猜一個填進來只會多一個錯誤來源,那幾支本來就由接線設定明確帶 JSC_CLI。
- codex_session_file() 原本寫成 xargs ls -t 再 head -n 1,那是錯的:xargs 依參數長度分批,ls -t 只在自己那一批裡排序,取到的是第一批裡最新的,不是全域最新。改成讓 find 一併印出修改時間再全域排序;沒有 -printf 就退回路徑排序,那些檔名以 ISO 時間開頭,字典序等同時間序。
- 冒煙的擋人案例連訊息一起驗,不只比結束碼。訊息漏掉逃生門一樣是綠燈,而那才是這道 fail-closed 真正的風險。
- 四支腳本併成一筆。write-guard.sh 讀 lib.sh 的路徑函式,wire-cli.sh 是驗這些行為的冒煙夾具,拆開會留下跑不過的中間版本:路徑函式與呼叫端分兩筆,中間那一筆狀態檔的讀寫兩側就對不上。
- 一次性代價:升級後每台機器第一次 SessionStart 會清一次重啟閘門。sid 換了名字,看起來像新的工作階段。只發生這一次。
- 三份 manifest 這一筆不動,版本號另外提升。

Who:
SDLC 階段能力閘門。修的是它一直沒有生效的那條路徑,順帶把 codex session 記錄的取檔方式一起修掉。
2026-09-02 10:48:08 +08: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 49c9009090 Merge pull request '釋出助理心跳與運行閘門(閘門未接線)' (#69) from feat/assistant-gate-and-heartbeat/main into develop
Reviewed-on: #69
2026-09-01 07:41:19 +00:00
admin 730f21a0e8 Merge pull request 'feat/assistant-gate-and-heartbeat/heartbeat' (#68) from feat/assistant-gate-and-heartbeat/heartbeat into feat/assistant-gate-and-heartbeat/main
Reviewed-on: #68
2026-09-01 07:24:22 +00:00
jiantw83 800a899239 feat(assistant-gate): 助理運行閘門,這一版尚未接線
What:
- 新增 hooks/assistant-gate.sh:心跳新鮮就放行,心跳不存在、過期或時間戳壞掉就擋下該次技能呼叫。
- 豁免清單十一支、逃生門一個。README 的 hooks 表與環境變數表跟著補。
- 這一版刻意不接線,接線檔一個字都沒動。

Why:
- 助理沒在跑的時候,技能會以為背景有人收尾,實際上沒有。這道閘門把那個落差擋在門外。
- 不接線是因為這台機器的穩定路徑指向開發存放庫,寫進接線檔就立刻對五支 CLI 生效。而現在還沒有心跳,接線的那一秒整組技能會全部鎖死,連修的路徑都走不到。接線的前提是助理已經在跑、心跳穩定。

How:
- 這是整組 hook 裡唯一一道 fail-closed 的閘門。其餘的原則都是資料不足就放行,這一道相反。代價是狀態檔寫不進去時全組停擺,所以逃生門與豁免清單不是選配,是能上線的前提。
- 豁免清單只收解鎖路徑,不收收尾規則。這一點與重啟閘門的方向相反:重啟閘門解鎖靠閘門外的動作,收尾規則要寫得完;這一道解鎖靠跑一支技能,清單收寬了閘門就等於沒有。
- 清單認技能名不認呼叫鏈,所以豁免技能轉呼叫的下一層也要收進來。最要緊的是 wiki 那一支:巡檢要先把結果寫上監控頁才寫心跳,只豁免助理自己會做出「沒心跳就擋 wiki、擋了巡檢跑不完、跑不完就還是沒心跳」的自咬環。
- 心跳判定回「檔案系統問不出來」時放行,不擋。那是判不出事實,不在這道閘門的職權裡;而且那一刻正是磁碟或權限壞掉的訊號,擋下去連豁免那幾支也修不動——它們同樣要寫狀態檔。磁碟壞掉要人去清磁碟,不是把技能組鎖起來。時間戳壞掉則照擋,那是確定沒有可信心跳的證據,而且修法就在豁免清單裡。
- 逃生門的判斷擺在載入共用函式庫之前。函式庫讀不到時 sh 會就地結束並回擋人的那個碼,逃生門也會跟著跑不到,人就繞不過去。
- 擋人一律經 deny.sh 輸出。三支走標準錯誤加結束碼,另外兩支靠標準輸出的內容擋,結束碼固定是零;自己印訊息會在那兩支上無聲失效。
- 心跳不存在、過期、時間戳壞掉三種情況講三種話。使用者要做的事不一樣:一個是從沒啟動過,一個是跑過停了,一個是檔案壞了要重建。

Who:
助理落地的最後一塊。接線與準則那一節另外處理。
2026-09-01 15:19:59 +08:00
jiantw83 b5107563dd chore(manifest): 心跳腳本的版本號補上,檔頭改指正確的技能名
What:
- 三份 manifest 的版本一起提升。
- heartbeat.sh 檔頭引用的技能名由 status 改成 assistant。

Why:
- 上一筆加了 heartbeat.sh 卻沒有動版本號。版本不動,別的 domain 就沒有辦法用相依宣告要求「要有這支腳本的那一版」——宣告寫得出來,卻保證不了內容。助理宣告的下限本來會落在一個不含這支腳本的版本上。
- 檔頭寫的技能名是助理落地初期那一支獨立技能。助理主體把三個操作收攏成一支之後,那個名字就不存在了,照著找會找不到東西。

How:
- 版本由 sync-skill-manifest.sh 同步,三份一致。
- 這一支仍然不接線,只是被助理與閘門呼叫的工具。

Who:
助理主體實作時,從相依宣告那一側回頭抓到的兩個缺口。
2026-09-01 14:18:49 +08:00
jiantw83 b0e352ae15 feat(heartbeat): 新增助理心跳的寫入、判定與回報
What:
- 新增 hooks/heartbeat.sh,四個子命令:write 寫心跳、check 判定新鮮、report 印現況、clear 清除。
- README 的 hooks 表補一列,事件欄註明不接線;環境變數表補上心跳門檻那一個。

Why:
- 助理是背景行程,別人要知道它還在不在跑,唯一的依據就是它留下的心跳。閘門要判、status 技能要印、巡檢要記,三邊都需要同一份判定。
- 判定散在三個地方一定會漂移,狀態跟訊息就會對不上。所以判定只寫一份,check 與 report 共用同一個探測函式。

How:
- 新鮮的判準是「檔案存在,而且時間戳距現在小於門檻」。門檻預設 300 秒,是心跳週期的五倍,一次網路或磁碟卡頓不會誤判;環境變數可以覆寫,壞值退回預設而不報錯——變數打錯字不該讓判定整個歪掉。
- 絕不看 pid 存活。五支 CLI 與容器裡的行程互相看不到彼此的 pid,看了也證明不了什麼,pid 只當擋人訊息的線索。
- check 用結束碼分四種狀態:新鮮、過期、不存在、時間戳壞掉。前三種的處置各不相同,擋人訊息要說的話也不一樣;第四種既不是「跑過停了」也不是「沒啟動過」,併進任何一邊都會讓訊息說錯話,而且絕不能退回判成新鮮。
- 寫入走暫存檔再更名。直接覆寫的話,剛好讀到寫一半的檔案會少掉時間戳,助理活著卻被判成壞了。
- 這一支不接線,只是被助理與閘門呼叫的工具。接線是後續獨立的一步,先接會在心跳還沒跑起來時就擋死整組技能。

Who:
助理落地的第一塊:先有心跳,閘門才判得動,主體才有東西可寫。
2026-09-01 14:08:14 +08: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 9de64ead7e Merge pull request '放行 jsc-assist 的 marketplace 條目到預設分支' (#66) from chore/marketplace-assist-registry/main into develop
Reviewed-on: #66
2026-09-01 04:55:20 +00:00
admin 59844c0a18 Merge pull request 'chore/marketplace-assist-registry/sync-copies' (#65) from chore/marketplace-assist-registry/sync-copies into chore/marketplace-assist-registry/main
Reviewed-on: #65
2026-09-01 04:53:34 +00:00
jiantw83 6f8afb8ac8 chore(marketplace): 把 jsc-assist 登錄進統一 marketplace
What:
- 兩份 marketplace 檔各加一個 jsc-assist 條目,來源網址指向 assist 存放庫。

Why:
- 準則要求每個 domain 存放庫都帶同一份 marketplace 檔,任何一個存放庫都能當註冊入口。副本之間只要有一份沒跟上,稽核就會報出不一致。
- 正本少了這個條目,各 CLI 的安裝指令就找不到 jsc-assist,這個 domain 等於發佈不出去。

How:
- 條目由 meta 的 sync-marketplace.sh 產生,同時寫進正本與每個 domain 存放庫的副本,寫完逐檔比對位元組。這一支存放庫的兩份副本就是那一輪的產物。
- 條目依名稱排序,縮排與非 ASCII 描述的處理都交給同一支腳本,不手改 JSON。
- 這一批是從最新的預設分支重新產生的。前一輪的分支基底早於監控頁型別那批改動,直接合併會把那些改動回退掉,所以整批重做而不是解衝突。

Who:
助理 domain 落地的註冊步驟在這個存放庫的同步。
2026-09-01 12:50:04 +08: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 ea50b7d915 Merge pull request '釋出 jsc-hooks 0.3.6:頁面編號偵測補齊型別' (#63) from feat/monitor-wiki-page-type/main into develop
Reviewed-on: #63
2026-09-01 04:42:59 +00:00
admin 3cf8d82ffd Merge pull request 'feat/monitor-wiki-page-type/page-id-regex' (#62) from feat/monitor-wiki-page-type/page-id-regex into feat/monitor-wiki-page-type/main
Reviewed-on: #62
2026-09-01 04:28:26 +00:00
jiantw83 113df37808 fix(comment-scope): 頁面編號偵測補齊三種缺漏的型別
What:
- wiki 頁面編號的偵測樣式補上 SKILLSET、TOOLING 與 MONITOR 三種型別。
- 三份 manifest 的版本一起提升。

Why:
- 這支 hook 的職責是擋住把文件追蹤資訊寫進程式碼註解,wiki 頁面編號正是禁止項之一。
- 樣式只列到 REPORT,但實際的頁型清單早就有 SKILLSET 與 TOOLING,現在再加 MONITOR。清單漏掉的那幾種,頁面編號寫進註解就攔不到,等於這條規則對它們不存在。
- SKILLSET 與 TOOLING 的缺漏是既有落差,不是這次新增型別才產生的,一併補齊比較省事,也不會留下第二個要記得的地方。

How:
- 型別的排列順序照 gitea.sh 的 resolve_wiki_repo 走,兩邊一致才看得出有沒有漏。
- 規則正文的唯一來源仍是 jsc-review 的註解範圍文件,這裡只補偵測樣式,不重述規則清單。
- 實測過三種型別各自都攔得下來,命中的說明都是「jsc wiki 頁面編號」,不是旁邊那條前綴加流水號的樣式誤撿。

Who:
技能助理落地帶出來的頁型別需求,四個存放庫同一批改。
2026-09-01 12:11:10 +08:00
admin 417fe1a413 Merge pull request 'fix/smoke-deny-assertion-per-cli' (#60) from fix/smoke-deny-assertion-per-cli into develop
Reviewed-on: #60
2026-09-01 03:50:57 +00:00
jiantw83 fbf0aec9f7 fix(smoke): 擋人斷言改依各 CLI 的形態判定
What:
- wire-cli.sh 的 smoke 區段新增四個共用小函式:smoke_deny_rc 給結束碼、smoke_deny_mark 給擋人標記、smoke_deny_ok 做判定、smoke_deny_desc 產生失敗訊息。
- smoke_rs_case 與 smoke_vg_case 的判定改走 smoke_deny_ok,五個呼叫點的預期值由結束碼 2 改成字面值 deny。
- 三份 manifest 的版本一起提升,由 sync-skill-manifest.sh 同步。

Why:
- 兩個函式把「擋下」寫死成結束碼 2,但擋下的形態是由 deny.sh 依 CLI 決定的。claude、codex、copilot 與認不得的代號走 stderr 加結束碼 2;antigravity 改印一行 stdout 的 deny JSON,kiro 只能注入警告,這兩支的結束碼都固定 0。
- 結果是這兩支的 smoke 各有五條判定失敗,回報成執行期錯誤。但擋人訊息其實都正確印出來了,配套的訊息斷言也全部通過,壞的只有結束碼那一項比對——是斷言認錯形態,不是 hook 失效。
- 不能改成一律放寬到 0。那兩支上放行也是 0,放寬之後「該擋沒擋」與「正確擋下」完全同形,這道斷言等於作廢。

How:
- 形態表在 smoke 這側鏡射一份,事實來源仍是 deny.sh 的 case。表只有一份,改一支不會忘了另一支。
- 判定同時比結束碼與擋人標記;預期放行的案例反過來要求標記不得出現,所以「該擋沒擋」與「不該擋卻擋了」兩個方向都守得住。
- 走 stderr 的三支標記為空字串,判定行為與原本完全相同,不產生回歸。
- 斷言條數不增不減,兩個預期條數常數都不必動。
- 反向測試確認斷言仍然有效:拿掉 deny.sh 裡 antigravity 的 deny JSON 輸出,做出該擋卻靜靜放行的情境,smoke 正確判失敗。結束碼相同,靠擋人標記才分得出來。

Who:
接線後的冒煙測試在 antigravity 與 kiro 上判定失敗,追出來的是斷言本身的缺陷。
2026-09-01 11:48:13 +08: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
jiantw83 462264a253 Merge pull request '收攏四支 CLI 的 pre-tool hook 接線修正與共用腳本' (#58) from feat/cli-hook-rewire/main into develop 2026-09-01 00:58:41 +00:00
jiantw83 453b576050 Merge pull request '修正四支 CLI 的 pre-tool hook 接線,補上技能名解析與阻擋輸出共用腳本' (#57) from feat/cli-hook-rewire/four-cli-pre-tool into feat/cli-hook-rewire/main 2026-09-01 00:56:10 +00:00
jiantw83 3cd4b40ad0 chore(release): 版本號同步為 0.3.4
What:plugin.json 與 .claude-plugin/plugin.json 的 version 由 0.3.3 改為 0.3.4。

Why:
- 這一批補上四支 CLI 的 pre-tool hook 接線,屬於新增能力,要發一個新版本。
- 三份 manifest 的版本號必須一致。版本前置檢查比對的就是這個號,對不上會讓 hook 判出錯誤的落後結論。

How:
- 由主流程的 sync-skill-manifest.sh 同步,三份一起改,不手動各寫一次。
- .codex-plugin/plugin.json 那一份的版本號隨接線提交一起進來,因為同一個檔案還加了 hooks 路徑鍵。

Who:jsc-hooks 0.3.4 發佈收尾。
2026-08-31 19:05:48 +08:00
jiantw83 f586a6b69f docs(hooks): 同步四支 CLI 的接線位置與驗證等級
What:README.md、references/behaviors.md 與 skills/hooks-install/SKILL.md 同步這一批的接線事實。

Why:
- 文件寫著「這些 CLI 沒有 pre-tool hook」。那句話是錯的,也正是三支 CLI 長期沒有守門的原因。照著它報告,會讓使用者以為只有 claude 有保護、其餘四支本來就接不上。
- kiro 的 degraded 原本被寫成接線缺東西,實際上是那支 CLI 擋不下技能叫用。兩件事的處理方式完全不同。

How:
- 一支 CLI 一列,列出接線位置、事件與 matcher、阻擋形態與判定,並寫明 claude、codex、copilot、antigravity 回 wired,kiro 回 degraded。
- 新增驗證等級表,把「形狀」與「觸發」分開記:形狀是那支 CLI 真的讀得懂設定,觸發是 hook 真的被叫用過。copilot 的形狀未證、antigravity 與 codex 的觸發未驗證,一律照實列出,不含糊帶過。
- README 補上 hooks/skill-name.sh 與 hooks/deny.sh 兩列,並補 COPILOT_HOME、KIRO_HOME、JSC_ANTIGRAVITY_HOOKS、JSC_COPILOT_INSTRUCTIONS、JSC_ANTIGRAVITY_RULES 幾個環境變數。
- kiro 的接線位置從 .kiro/hooks/jsc-hooks.json 全面改寫成 ~/.kiro/agents/jsc.json。
- 講明 write-guard.sh 三個模式與 SDLC 模型鎖在另外三支上是「還沒接」,不是「沒有 hook 可接」。
- behaviors.md 的完成條件與可驗證跡象跟著改,repair 那一列加上「技能名解析改 skill-name.sh、阻擋形態改 deny.sh,兩支是唯一真實來源」。

Who:codex、copilot、antigravity、kiro 四支 CLI 的 pre-tool hook 接線修正。
2026-08-31 19:05:48 +08:00
jiantw83 eebe2a2873 feat(wire-cli): 接上四支 CLI 的 pre-tool hook
What:
- tools/wire-cli.sh 重寫 codex、copilot、antigravity、kiro 四支的接線、purge、status 與 smoke。
- 新增 hooks/codex-hooks.json,由 hooks/hooks.json 推導產生。
- .codex-plugin/plugin.json 加上 hooks 路徑鍵,指向那份檔案。

Why:這四支其實都有能介入的 pre-tool 事件,原本卻接錯位置,抓到五個同一類的無聲失效——設定看起來正確、CLI 靜默不理、不報錯:
- codex 的 matcher 用 Skill,但 Codex 沒有 Skill 這個工具,技能是模型自己用 Bash 讀 SKILL.md 載入的。
- codex 的 hooks 鍵寫成內嵌物件,實際規格是路徑字串,內嵌物件解析不了。
- antigravity 的 PreToolUse 寫成 Flat,實際要 matcher 加 hooks 包一層的 Grouped。Flat 的那一段整個被丟掉,hook 名稱照樣登記,檔案讀起來還是對的。
- copilot 的設定寫到 $COPILOT_HOME/hooks/,那是 hook 要跑的腳本目錄、不是設定目錄,設定從來不會被讀。
- 三支新接的命令沒帶 JSC_CLI={代號},閘門認不出自己跑在哪支 CLI 上,一次都擋不下來。

How:
- codex:PreToolUse matcher 換成 Bash。codex-hooks.json 由 hooks/hooks.json 整份複製,再把 matcher 的 Skill 改寫成 Bash,其餘事件原樣保留,Claude 那份維持唯一真實來源,第二份絕不手寫。
- copilot:pre-tool hook 併進 ~/.copilot/settings.json 的頂層 hooks 鍵,matcher 用小寫 skill,事件名只寫一種大小寫。那份檔案同時裝著 enabledPlugins 與 extraKnownMarketplaces,所以合併不覆寫:寫前備份、只動 jsc 自己那幾筆、寫後回讀核對最上層鍵與別人的條目,對不上就還原。指引檔改寫到 $COPILOT_HOME 底下。
- antigravity:寫 ~/.gemini/config/hooks.json 的 jsc 段落。PreToolUse 改成 Grouped、matcher 是錨定的 view_file,錨點不能省,省了會連 view_file_outline 一起命中;另接 Flat 的 PreInvocation,攔斜線指令那條不產生工具呼叫的路。
- kiro:hook 宣告搬到 ~/.kiro/agents/jsc.json 的 hooks 鍵,事件只用 agentSpawn、userPromptSubmit、stop,欄位是 command 與 timeout_ms,並把 settings/cli.json 的 chat.defaultAgent 設成 jsc。kiro-cli agent validate 四種情況一律回 0,所以判準看輸出、不看結束碼。
- 四支非 claude 的接線命令一律以 JSC_CLI={代號} 前綴自帶代號,接線時、status 與 smoke 各斷言一次。
- status 從只驗「鍵在不在」改成驗形狀與位置,並新增第三格 unverified,把「驗不了」跟「驗過了」分開,只有 missing 算缺項。
- smoke 新增 26 條接線形狀斷言,每條正向配一條反向,另把五支 CLI 的真實負載直接餵進 restart-gate.sh,驗解析、判定、輸出整條串得起來。前兩段分開看都會顯示正常,中間接不上照樣是全程放行,那正是先前失效的樣子。

Who:codex、copilot、antigravity、kiro 四支 CLI 的 pre-tool hook 接線修正。
2026-08-31 19:05:48 +08:00
jiantw83 0e9308f60e feat(hooks): 技能名解析與阻擋輸出共用化
What:
- 新增 hooks/skill-name.sh。五支 CLI 各一個子命令,從各自的負載解析出這一次要用哪一支 jsc 技能,印一行「{domain}<TAB>{技能名}」。解不出來就印空字串並回 0。
- 新增 hooks/deny.sh。依當前 CLI 產出四種阻擋形態,訊息從參數或標準輸入進。
- version-guard.sh 與 restart-gate.sh 改用這兩支,各自那份工具名判定與技能名取值一併移除。

Why:
- 兩道閘門原本都拿 Claude 的工具名 Skill 當通用條件。另外四支 CLI 的工具名分別是 Bash、skill、view_file,一律被擋在判定之外,兩道閘門在那四支上長期完全失效,而且一聲都不吭。
- 阻擋形態每支 CLI 都不一樣,判定卻是同一件事。各自留一份輸出邏輯,改了一支忘了另一支,就會做出「判定擋下、CLI 照樣放行」的無聲失效。

How:
- 技能名取值規則只留 skill-name.sh 這一份,環境變數 JSC_SKILL、SKILL 優先。claude 讀 skill 欄位、codex 認 tool_input.command 裡那條 SKILL.md 路徑、copilot 先剝一層字串化 JSON 再讀 toolArgs、antigravity 讀 toolCall.args.AbsolutePath 並另收提示字串、kiro 取提示開頭那個斜線指令。
- 閘門端用 awk 判 NF == 2 才取值。少一欄就當成解析不出來,免得沒有定位字元時 cut -f2 把整行當成技能名,拼出一個不存在的技能名去比對豁免清單。
- deny.sh 定形態:claude、codex、copilot 走 stderr 加 exit 2;antigravity 印 stdout 的單行 deny JSON 並固定回 0,因為那支 CLI 的結束碼語意兩邊文件都沒寫、絕不可靠;kiro 擋不下技能叫用,改印警告後回 0;認不得的代號走 stderr 加 2 這個保守預設。
- 訊息整段走同一條管線送進 deny.sh。分次呼叫會做出好幾份 deny JSON,antigravity 只認第一份,後面幾段使用者永遠看不到。
- 豁免清單與 fail-open 原則不變。這兩支刻意以子行程呼叫、不用 source 載入:讀不到只會讓技能名解不出來而安靜放行,不會反過來擋掉每一次呼叫。

Who:codex、copilot、antigravity、kiro 四支 CLI 的 pre-tool hook 接線修正。
2026-08-31 19:05:48 +08: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
jiantw83 9ccf0d555c Merge pull request '收攏技能行為清單與版本前置檢查相依擋人改動' (#55) from feat/skill-behaviors-and-version-block/main into develop 2026-08-31 08:10:36 +00:00
jiantw83 30c395b4d0 Merge pull request '版本前置檢查新增相依落後擋人、新增技能行為清單' (#54) from feat/skill-behaviors-and-version-block/version-guard-requires-block into feat/skill-behaviors-and-version-block/main 2026-08-31 08:09:21 +00:00
jiantw83andClaude Opus 5 6870975e08 chore(plugin): 三份外掛設定檔版本推進到 0.3.3
plugin.json、.claude-plugin/plugin.json 與 .codex-plugin/plugin.json
的版本一起從 0.3.2 推到 0.3.3。

這一輪改了版本前置檢查的行為,多擋一種情況。版本不推上去,版本前置檢查
就看不出機器上載入的是舊版,使用者會拿舊的閘門跑新的流程,相依落後
一樣不會被擋下來。

三份設定檔改成同一個版本,避免不同 CLI 讀到不一樣的宣告。

所屬功能:版本前置檢查的相依版本閘門。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 13:41:25 +08:00
jiantw83andClaude Opus 5 63c8073be1 docs(references): 新增技能行為清單,作為技能驗證的比對基準
新增 references/behaviors.md,逐支列出本存取庫 hooks-install 與 repair
兩支技能的行為:觸發時機、關鍵步驟、外部呼叫、完成條件與可驗證跡象。

技能驗證原本沒有一份基準可比。驗的人只能回頭讀技能內文,讀到的是當下的
寫法,不是講好的行為;技能改壞了、少走一道關卡,比對不出來。

一支技能一張表,一項一列,內容寫成看得出對錯的敘述,可驗證跡象逐條指到
磁碟上真的留得下的東西。技能異動時,在同一個 PR 內一起更新這一頁。

所屬功能:技能驗證基準。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 13:41:25 +08:00
jiantw83andClaude Opus 5 3485213145 docs(readme): 說明文件同步版本閘門的兩種擋人情況與冒煙沙箱
比較表的 version-guard.sh 那一列改寫成擋兩種情況,逐項寫明相依版本檢查
讀哪一份 manifest、比哪一個版本、訊息講到什麼程度,也把新增的四種
fail-open 併進原本的放行清單,並註明兩種擋人情況共用同一份 7 項豁免清單。
wire-cli.sh 那一列補一句新的冒煙沙箱與它涵蓋的判定路徑。

文件停在只擋一種情況的舊敘述,讀的人會以為相依落後照樣放行,被擋下來時
找不到規則的出處。冒煙那一列不提新沙箱,維護者也看不出相依版本這一段
已經有斷言守著。

兩種擋人情況、fail-open 清單與共用的豁免清單都寫進表格,清單的唯一真實
來源仍然指向 hooks/version-guard.sh 的檔頭。冒煙那一句只講沙箱與涵蓋範圍,
不抄斷言條數;條數以腳本自己印出來的 lines 那一行為準,散文不另記一份數字。

所屬功能:版本前置檢查的相依版本閘門。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 13:41:25 +08:00
jiantw83andClaude Opus 5 15f7dcc5fb test(smoke): 接線腳本冒煙補上相依版本檢查的十三條判定斷言
接線腳本的 smoke 多一個沙箱,逐條跑相依版本檢查的判定路徑並比對結束碼:
相依落後的擋人與訊息內容、相依相等與超前的放行、豁免技能在相依落後時
照樣放行、同一個 plugin 底下非豁免技能照樣被擋、四種 fail-open、
逃生門蓋過相依落後,另加一條回歸——多行縮排的 manifest,jsc.requires
的最後一個鍵也要解得到。

原本的 hook 模式只驗那支腳本跑得完,相依版本這一段一條判定路徑都沒走到。
新的擋人情況判錯方向,不是把每一次技能呼叫鎖死,就是整道護欄形同虛設,
沒有斷言就看不出來。多行縮排是真實 manifest 的樣子,解析漏掉最後一個鍵
會讓落後的相依靜靜被放行,那一條非釘住不可。

沙箱自備一份暫時的 HOME,註冊檔路徑由 $HOME 決定,不覆寫就會讀到使用者
真正的安裝清單。假的 installed_plugins.json 與各 plugin 的 manifest 都放進
那份 HOME,註冊欄位故意寫成很新的版本、installPath 底下的 manifest 寫舊版,
把「只認 installPath 底下那份檔案」的規則一起釘住。GITEA_HOST 一律清空,
放行的案例才不會往下走到遠端比對真的連網。擋人的兩條另外比訊息字串,
比的是同一次執行留下的輸出。新增計數器 smoke_n_vg 與預期值
SMOKE_EXPECT_VG,一併納入總數、逐類比對訊息與結果摘要,判定路徑增減時
只改腳本裡的預期值。

所屬功能:版本前置檢查的相依版本閘門。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 13:41:25 +08:00
jiantw83andClaude Opus 5 5c2727bf56 feat(version-guard): 版本前置檢查新增相依版本落後的擋人情況
版本前置檢查多一種擋人情況。技能所屬 plugin 的 manifest 在 jsc.requires
宣告的相依 plugin,只要有一項的本機實際載入版本落後宣告的最低版本,
就以 exit 2 擋下這一次技能呼叫。訊息逐項講明哪一個 plugin、需要哪一版、
目前哪一版、怎麼補。

相依宣告原本只有部署那一端會回報,沒有任何一道閘門擋。機器上因此會出現
版本互相搭不起來的 plugin 組合,技能跑到一半才失敗,使用者也看不出要更新
哪一個 plugin。

判定邏輯自己實作,不呼叫 jsc-cli 的 check-requires.sh。hook 一律專屬存放在
jsc-hooks,不可散落到別的 domain;jsc-cli 也已經宣告相依 jsc-hooks,
反向呼叫會做出循環相依。安裝路徑解析抽成 install_path(),與本機載入版本
共用同一條規則,各寫一份就會漂移。相依檢查排在豁免清單與逃生門之後、
遠端比對之前,全部讀本機檔案,離線的機器也判得動。豁免沿用同一份 7 項清單,
相依落後不另立短清單:這幾支同樣是更新與修復的唯一路徑,用哪一個理由擋
都是死鎖。四種情況一律安靜放行:解不出安裝路徑、讀不到 manifest、
manifest 沒有 jsc.requires、讀不到相依 plugin 的本機載入版本。五支 CLI
只有 claude 讀得到本機載入版本,fail-closed 會把另外四支整批鎖死。

所屬功能:版本前置檢查的相依版本閘門。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 13:41:25 +08: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 5ee91bc045 Merge pull request 'fix(skillset): 技能組稽核修正與第九支 hook' (#52) from fix/skill-check-compliance-and-flow into develop
Reviewed-on: #52
2026-08-31 03:53:56 +00:00
jiantw83andClaude Opus 5 3ae6dba13f fix(smoke): 模型來源判定補上計數器,冒煙預期條數對回實際路徑
重定基底到 develop 後,冒煙測試併入了 sdlc-gate.sh 取模型代號的四條來源
判定,但那支輸出函式沒有自己的計數器,四個預期值也沒跟著加。結果是實際
印出六十四條結果行、四類計數器只算到六十條,收尾的自我斷言當場判定不符,
smoke 一律以 exit 4 收場。

補上第五個計數器 smoke_n_model 與對應的 SMOKE_EXPECT_MODEL,計數放在該
函式開頭,連建不出暫存目錄那條也算得到;總數、逐類比對訊息與結果摘要一併
納入模型來源這一類。五支 CLI 的 smoke 都回 status=ok,行數與各類條數三邊
一致。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 11:26:11 +08:00
jiantw83 99d87f7365 chore(plugin): 推進外掛版本與描述,相依清單補上兩個技能組
三份外掛設定檔的版本一起往上推,描述加上寫入與提交閘門,
相依清單補上 git 與 meta 兩個技能組。

這一輪新增一支 hook,也改了兩道閘門的行為。版本不推上去,
版本前置檢查就看不出機器上載入的是舊版,使用者會拿舊的閘門跑新的流程。
描述是使用者在市集看到的那一行,少一項就會漏掉新閘門。
寫入閘門擋下整包提交時,改法指向 git 技能組的分組流程;
語言規則的正文則在 meta 技能組。這兩個相依本來就成立,只是先前沒有寫進清單。

三份設定檔改成同一個版本,描述與相依清單三份同步,
避免不同 CLI 讀到不一樣的宣告。
2026-08-31 11:17:28 +08:00
jiantw83 04225a7a2d docs(hooks): 每支腳本檔頭補上結束碼宣告,文件對齊九支 hook 的現況
共用函式庫與各支腳本的檔頭各補一段結束碼宣告,逐個子命令寫明哪些情況放行、
哪些情況擋下。說明文件改寫成九支 hook 的現況,補上寫入與提交閘門、唯讀模式、
建議子命令與新的環境變數。異常目錄範本補上「一律附加、不整頁覆蓋」的寫入語意。

呼叫端要靠結束碼決定下一步,但多數腳本只寫用法、沒寫結束碼,
讀的人得自己翻程式碼推,推錯就把安靜降級當成失敗處理。
共用函式庫載不到時,殼層會就地結束並回非零,接在工具呼叫前的閘門遇到這一下
等於無聲擋人,腳本自己的放行路徑一條都跑不到,這件事非寫進每一支檔頭不可。
文件停在八支 hook 的舊敘述,看的人會誤判覆蓋範圍,以為每支 CLI 都擋得住。
目錄頁的寫入語意只寫在腳本裡,換一支工具來寫就會整頁覆蓋。

一支腳本一段檔頭,逐子命令列出結束碼,並各自註明共用函式庫載入失敗會回哪一個碼。
文件的行數一律引用腳本自己印出來的那一行,不另抄一份數字,
判定路徑增減時就不會漂移。覆蓋範圍逐支 CLI 分開寫,接不上的就寫接不上。
2026-08-31 11:17:28 +08:00
jiantw83 e2b08f04a3 fix(gates): 修好會蓋掉異常目錄、把修復路徑鎖死與誤擋計畫階段的缺陷
異常回報依 wiki 讀取的結束碼分流,只有頁面確定不存在才套範本建新頁。
版本閘門與重啟閘門的豁免清單各補上 hook 修復技能。
階段閘門把計畫階段移出擋人名單,改成只注入提醒。
錯誤掃描的自家 hook 判定補齊九支腳本,並加一條路徑判定。
修復技能與接線技能的內文改成真的走得到的路徑與真的存在的關卡數。

原本 wiki 讀取失敗會一路落到套範本那一步,金鑰失效或 API 出狀況時,
就拿一份空白範本蓋掉整份異常目錄,而寫入不做合併也不留備份,蓋掉就救不回來。
兩道閘門把唯一的 hook 修復路徑一起擋住,hook 一壞就沒有任何方法修回來,
閘門等於鎖掉解除自己的路徑。工作包閘門擋下計畫階段是誤擋:
計畫是純邏輯階段、不碰程式碼,而閘門只知道有 PR 未合併,判不出跟新計畫有沒有關聯。
自家 hook 判定只認得早期那五支,後來加的四支出錯會被當成第三方的,只回報不修正。
修復技能裡三個指向流程的路徑指到不存在的位置,照著走一定撲空;
接線流程寫四道關卡,實際上有五道,兩段中文說明也混在英文內文裡。

異常目錄改成先讀回舊頁、把新列附在文末、再整頁寫回;讀不回來就放棄寫目錄頁並回報,
寧可少一列索引,也不覆蓋別人的紀錄。兩份豁免清單各補一項,理由逐項寫在腳本檔頭。
計畫階段改印提醒後放行,放棄的在製品上限與代價一併寫在檔頭。
自家 hook 判定逐支列出腳本名,再加一條安裝路徑判定,日後新增 hook 忘了補清單也還認得出來。
三個路徑改指到擁有它的技能組,關卡數改成五道並逐關寫明結束碼,兩段中文說明改回英文。
版本閘門在同一次改動另補唯讀的建議子命令,把版本比對表收斂成一行結論,
部署技能不必自己再解一次那張表。
2026-08-31 11:15:24 +08:00
jiantw83 b5219f6549 feat(write-guard): 新增寫入與提交閘門,把三條只寫在內文的規則落到程式層
新增第九支 hook,共四種模式。stage 在計畫與分析階段鎖著時擋下寫檔。
review 在稽核類技能執行中擋下寫檔。commit 擋下「一次加入全部變更再提交」的
單一指令,也擋下含簡體字、亂碼或非 UTF-8 編碼的提交訊息。
release 不接 hook,由稽核技能收尾時自己呼叫,清掉認人用的那份紀錄,
讓呼叫端接手修改時不會被剛跑完的稽核擋住。

這三條規則原本只寫在技能內文,靠模型自律。稽核技能會順手改程式碼,
計畫階段會寫出不該寫的檔案,整包提交會把型別與功能分組壓成一次。
規則要真的生效,就得由程式擋。release 模式是必要的:紀錄記的是最近一次
載入的技能,不是還在跑的技能,沒有它,稽核跑完之後呼叫端每一次寫入都被擋,
閘門會把解除自己的路徑一起鎖掉。

接線設定把 stage 與 review 接在寫檔工具,把 commit 接在指令工具。
閘門只讀階段閘門的狀態鎖與技能用量的紀錄,不自己寫狀態檔;
簡繁與編碼判定整段轉呼叫語言守門,不留第二份字表。
接線腳本的接線、唯讀盤點與冒煙各補上這一支,冒煙自備暫時的狀態目錄,
逐條比對每種模式的結束碼。接線腳本另補唯讀模式,體檢類技能全程帶著它跑,
清除與接線一律拒絕並回非零,子命令打錯一個字也改不到環境。
冒煙改成自己數結果行並自我斷言,散文只引用那一行,不再各抄一份數字。

限制據實寫在檔頭:只有 claude 有寫檔前置鉤子,其餘四支 CLI 一條都接不上,
那四支上這三條規則仍只剩技能內文。
2026-08-31 11:15:24 +08:00
admin 6b272974b5 Merge pull request 'fix/codex-model-gate' (#48) from fix/codex-model-gate into develop
Reviewed-on: #48
2026-08-28 10:12:31 +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 ca68de8540 Merge pull request 'fix(codex-hooks): 避免 Claude manifest 在 Codex 變成空路徑' (#50) from fix/codex-claude-hook-manifest into develop
Reviewed-on: #50
2026-08-28 10:09:31 +00:00
jiantw83 955a36adb9 fix(codex-hooks): 避免 Claude manifest 在 Codex 變成空路徑 2026-08-28 18:07:20 +08:00
admin 1e728e3729 Merge pull request 'fix/report-error-confirm' (#49) from fix/report-error-confirm into develop
Reviewed-on: #49
2026-08-28 10:00:49 +00:00
Jeffery a0a15b6ce5 fix(report-error): 寫入前先確認 2026-08-28 17:58:52 +08:00
jiantw83 83f6bdf21c fix(model-gate): 支援 Codex 可驗證模型來源 2026-08-28 16:45:58 +08:00
admin 9785af335a Merge pull request 'fix(wire-cli): purge 容忍標記行空白' (#47) from fix/hook-purge-marker-whitespace into develop
Reviewed-on: #47
2026-08-28 08:07:28 +00:00
jiantw83 49101fefdb fix(wire-cli): purge 容忍標記行空白 2026-08-28 16:03:25 +08: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 2d1c8bbc06 Merge pull request 'feat/plugin-dependencies/main' (#45) from feat/plugin-dependencies/main into develop
Reviewed-on: #45
2026-08-28 04:05:00 +00:00
admin a2a8e8b837 Merge pull request 'feat/plugin-dependencies/declare-requires' (#44) from feat/plugin-dependencies/declare-requires into feat/plugin-dependencies/main
Reviewed-on: #44
2026-08-28 04:03:03 +00:00
jiantw83 8a1d9d116e feat(manifest): 宣告 hooks 相依版本 2026-08-28 11:59:16 +08: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 d5867623df Merge pull request 'fix/hook-stable-runtime-path' (#42) from fix/hook-stable-runtime-path into develop
Reviewed-on: #42
2026-08-28 03:27:49 +00:00
jiantw83 4dc879e202 chore(release): 發布 jsc-hooks 0.2.8 2026-08-28 11:22:13 +08:00
jiantw83 4e23e49159 fix(hooks-install): 使用穩定路徑寫入 hook 接線 2026-08-28 11:22:08 +08: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 de6b012356 Merge pull request 'feat/change-requests/main' (#40) from feat/change-requests/main into develop
Reviewed-on: #40
2026-08-28 01:51:14 +00:00
admin e37506dffb Merge pull request 'feat/change-requests/state-and-comment-scope' (#39) from feat/change-requests/state-and-comment-scope into feat/change-requests/main
Reviewed-on: #39
2026-08-28 01:46:55 +00:00
jiantw83 1477ba7b29 feat(comment-scope): 偵測審查流程痕跡 2026-08-28 09:30:18 +08:00
jiantw83 f6335180fa fix(version-guard): 依 CLI 分離遠端版本快取 2026-08-28 09:30:12 +08: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 852d8553b4 Merge pull request 'fix(hooks): 重啟閘門狀態檔改為一支 CLI 一份,修並行覆寫與全域解除' (#37) from fix/restart-gate-per-cli-state into develop
Reviewed-on: #37
2026-08-27 10:51:20 +00:00
jiantw83 0556512ebf chore(hooks): 三份 manifest 版本升到 0.2.6
What:`plugin.json`、`.claude-plugin/plugin.json`、`.codex-plugin/plugin.json` 的 `version` 從 0.2.5 升到 0.2.6,三份同步,description 不動。

Why:這一輪改掉重啟閘門的狀態檔路徑與範圍,是使用者裝上去就會拿到的行為變更。版本不升,`version-guard.sh` 的版本前置檢查與 `jsc-cli:deploy` 的落後判定都看不出本機還是舊版,機器上就不會被提示更新。

How:只改版號一個欄位。三份必須一致:`plugin.json` 給 marketplace、`.claude-plugin` 給 claude、`.codex-plugin` 給 codex,任一份落後都會讓那一路的版本比對抓錯。修的是既有行為的缺陷、沒有新增子命令也沒有改變呼叫介面,所以走修訂號。

Who:`jsc-hooks` 的三份 plugin manifest,配合這一輪重啟閘門的修正發佈。
2026-08-27 18:49:58 +08:00
jiantw83 c0c944085c docs(restart-gate): 說明文件同步一支 CLI 一份的狀態檔
What:`README.md` 改三處——Hooks 表的 `restart-gate.sh` 與 `session-timer.sh` 兩列、覆蓋範圍的引言、以及「部署後重啟狀態檔」整節。整節改寫後寫明狀態檔路徑是 `$JSC_HOME/restart-required.d/{CLI 代號}`、一支 CLI 一份的理由、`report` 的輸出格式,以及舊檔相容與它可移除的時機。`skills/hooks-install/SKILL.md` 第 45 項改為 `restart-required.d/{cli}`,補上「每支 CLI 只讀自己那一份」、「重啟只清自己那一份」、「`report` 一支 CLI 一行」與舊檔過渡說明。

Why:這兩份是接線與判讀閘門時唯一會被讀到的說明。路徑改了而文件沒改,看文件的人會去看一個不存在的檔案,也會沿用「重啟一支就全清」的舊認知去判斷閘門有沒有生效。一支 CLI 一份的理由(並行覆寫與全域解除兩個實測缺陷)要留在文件裡,下一個人才不會為了少一個目錄又改回單一檔案。

How:只改說明,不動任何判定邏輯。路徑與格式的唯一來源仍是 `hooks/restart-gate.sh`,文件只轉述,舊檔相容的取捨與可移除清單也指回檔頭那一節,兩邊不各自維護一份。覆蓋範圍的引言照舊據實寫:非 claude 的四支 CLI 沒有 pre-tool hook,這道閘門一次都擋不下來,改成一支一份也不會變。

Who:`jsc-hooks` 的存取庫說明與 `jsc-hooks:hooks-install` 技能文件,對齊部署後重啟閘門的實作。
2026-08-27 18:49:58 +08:00
jiantw83 9586e63005 test(restart-gate): 冒煙測試加驗一支 CLI 一份的範圍
What:`tools/wire-cli.sh` 的 `smoke` 重啟閘門段從 9 條情境擴為 16 條,新增 `smoke_rs_file()` 比對狀態檔在不在。新增的情境是:只有別支 CLI 有狀態檔時放行、`require` 寫出的是當前 CLI 那一份、新工作階段開始後只清掉自己那一份、清除不動別支那一份、清除後放行、舊格式單一狀態檔存在時擋下、`clear` 一併刪掉舊檔、舊檔清除後放行。原有的豁免技能、逃生門與取不到技能名三類情境照舊,情境名稱從「狀態檔存在」改寫為「當前 CLI 那份存在」。檔頭的判定路徑清單同步改寫。

Why:只比對結束碼看不出「一支 CLI 一份」有沒有真的成立——別支那一份要留著、自己那一份要刪掉,兩件事都反映在檔案在不在,不反映在結束碼。舊版的冒煙只驗得到「狀態檔不存在」與「狀態檔存在」兩條,正好漏掉這次修掉的兩個缺陷所在的範圍:並行部署的覆寫與清除的全域解除,兩者在單一檔案的年代都跑得出 exit 0 與 exit 2 的正確值。

How:情境沿用暫時的 `$JSC_HOME`,不動使用者真正的狀態目錄——冒煙測試不該把別人的閘門拆掉。別支 CLI 的代號取 `smoke-other` 這個不在五支之列的固定值,才不會跟這一輪的 `$cli` 撞在一起。`smoke_rs_file()` 只比 `exist` 與 `absent` 兩種預期,失敗訊息直接講明「一支 CLI 一份的範圍壞了」,看訊息就知道是範圍出錯,不是腳本跑不動。舊檔那兩條情境走 `session-timer.sh restart` 觸發清除,跟真實路徑一致。

Who:`jsc-hooks` 接線工具的冒煙測試,覆蓋部署後重啟閘門的判定與清除範圍。
2026-08-27 18:49:58 +08:00
jiantw83 7517566156 fix(restart-gate): 重啟閘門狀態檔改為一支 CLI 一份
What:`hooks/restart-gate.sh` 的狀態檔從全機器單一檔案 `$JSC_HOME/restart-required` 改為狀態目錄 `$JSC_HOME/restart-required.d/{CLI 代號}`,一支 CLI 一份,檔名就是 CLI 代號,四行 key=value 的格式不變。新增 `cli_code()` 取當前 CLI 代號(含不能當檔名的髒值防護)與 `state_line()` 統一 `report` 的輸出格式;`require` 只寫自己那一份、hook 判定只讀自己那一份、`clear` 只刪自己那一份、`report` 一支 CLI 一行印出還沒重啟的是哪幾支。舊格式的單一檔案存在時一律擋,訊息標明是舊格式紀錄,`clear` 會一併刪掉它。檔頭註解同步改寫,新增「report 輸出格式」與「舊檔相容(過渡用)」兩節。`hooks/session-timer.sh` 程式碼不動,兩處註解補上「清除範圍只有跑到這支腳本的那一支 CLI」。

Why:這道閘門 2026-08-27 才上線,部署現場實測抓到兩個缺陷。第一,`require` 用 `>` 覆寫單一檔案,並行部署互相覆蓋——kiro 寫入 9 秒後被 codex 蓋掉,`domains=` 與 `cli=` 不再代表 kiro。第二,`clear` 用 `rm -f` 刪整個檔案,任一支 CLI 重啟就解除全部五支的閘門,其餘四支沒重啟卻不再被擋。這台機器就裝了五支 CLI,閘門在多 CLI 環境等於半失效。閘門管的是「這一支 CLI 的行程還在跑舊版」,那是每支 CLI 各自的事實,狀態檔本來就不該共用。

How:寫入、判定、清除三件事都只碰自己那一份,別支那幾份一律不看,`clear` 的範圍也就收斂成呼叫端那一支。取不到 CLI 代號時比照既有的「查不到就放行」原則:hook 模式放行,`require` 回 exit 2 並講明這次沒有掛上閘門——寫到讀不到的檔名等於沒掛,不能讓部署以為掛上了。代號會直接拿去當檔名,所以含 `/`、以 `.` 開頭、或出現 `[A-Za-z0-9._-]` 以外字元的值一律當成取不到,狀態檔就寫不到目錄外面去。舊檔沒有 per-CLI 資訊,分不出是哪一支寫的,判定寧可擋多不擋少;`clear` 只在新工作階段被呼叫,呼叫到就代表確實有一支重新啟動過了,舊檔留著會讓五支一路被擋到有人手動刪,所以一併刪掉。這段相容邏輯與可移除的時機(所有機器都跑過一次寫狀態目錄的部署與重啟之後)寫在檔頭「舊檔相容」與 README。`session-timer.sh` 不必跟著改:狀態檔的路徑、範圍與格式只留在 `restart-gate.sh`,那裡只負責判斷新舊工作階段。

Who:`jsc-hooks` 的部署後強制重啟閘門(R15),以及它與 `session-timer.sh` 的清除分工。
2026-08-27 18:49:58 +08: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 d808b05d45 Merge pull request 'feat(hooks): 新增部署後強制重啟閘門,接線與冒煙同步到八支 hook' (#35) from feat/skillset-governance/main into develop
Reviewed-on: #35
2026-08-27 08:54:38 +00:00
admin dea775fea5 Merge pull request 'docs(hooks-install): 豁免清單補齊為九支,與實作和準則對齊' (#34) from feat/skillset-governance/exempt-list-sync into feat/skillset-governance/main
Reviewed-on: #34
Reviewed-by: 系統管理員 <1+admin@noreply.localhost>
2026-08-27 08:51:31 +00:00
jiantw83 7d67538a2a docs(hooks-install): 豁免清單補齊為九支,與實作和準則對齊
What:`skills/hooks-install/SKILL.md` 的 Notes 段落,重啟閘門的豁免技能清單從六支補到九支,補上 `jsc-ask:ask`、`jsc-git:pr`、`jsc-git:commit`,並寫明「閘門認技能名不認呼叫鏈」以及清單的唯一來源在 `hooks/restart-gate.sh`。

Why:這三支是使用者在同一輪追問後才裁定加入的,實作 `hooks/restart-gate.sh` 與準則 `guidelines.md`「部署後重啟閘門」都已經是九支,只有這份技能文件還停在六支。技能文件是接線時唯一會被讀到的說明,少列三支會讓人以為 `deploy` 問模式、收尾開 PR 都會被擋,反而去下逃生門。

How:只改那一行,補上三支與兩句說明,並指向清單的唯一來源,避免下次又各自維護一份。三份 manifest 版本同步升到 0.2.5。

Who:`jsc-hooks:hooks-install` 技能文件,以及部署後重啟閘門這條規則的說明一致性。
2026-08-27 16:49:02 +08:00
admin fe7a3abe2a Merge pull request 'feat(hooks): 新增部署後強制重啟閘門,接線與冒煙同步到八支 hook' (#33) from feat/skillset-governance/restart-gate into feat/skillset-governance/main
Reviewed-on: #33
2026-08-27 08:38:50 +00:00
jiantw83 4f92c48e10 chore(manifest): 三份 manifest 版本升到 0.2.4,描述補齊八支 hook
What:`plugin.json`、`.claude-plugin/plugin.json`、`.codex-plugin/plugin.json` 的 `version` 由 0.2.3 改為 0.2.4,`description` 的 hook 清單從五支補齊到八支,補上註解範圍守門、繁中編碼守門與部署後強制重啟。

Why:本次新增第八支 hook `restart-gate.sh` 並接進 `hooks.json`,`session-timer.sh` 多了清除閘門這件事,接線與冒煙也跟著改,屬於行為變更,版本要跟著往上走,各 CLI 才知道要更新。`description` 原本只列到「版本前置檢查」,而註解範圍與繁中編碼兩支早就在跑了,`README.md` 與 `AGENTS.md` 也都寫了八支——manifest 是各 CLI 安裝時唯一看得到的說明,落後就會讓人以為這個外掛只有五支 hook。

How:三份只改 `version` 與 `description` 兩個欄位,其餘內容不動,三份保持同一版號與同一段描述。

Who:`jsc-hooks` 外掛的套件描述檔。
2026-08-27 16:34:17 +08:00
jiantw83 c1f2a5d728 docs(hooks): README 與 AGENTS 補上部署後重啟閘門與狀態檔格式
What:`README.md` 五處增修:hook 表新增 `hooks/restart-gate.sh` 一列,`session-timer.sh` 那一列補上會清除閘門、`wire-cli.sh` 那一列補上冒煙新增的判定路徑;覆蓋範圍那段的「七支」改「八支」並補上四個 CLI 接不上重啟閘門的後果;新增「部署後重啟狀態檔」一節,用表列出四個欄位與範例,寫明誰寫誰讀;環境變數表新增 `JSC_RESTART_GATE` 一列;`hooks-install` 段落同步。`AGENTS.md` 的 domain 一句話說明補上「部署後強制重啟閘門」。

Why:狀態檔的格式是 `jsc-hooks` 與 `jsc-cli` 兩邊的介面。介面只寫在腳本註解裡,另一邊改的時候看不到,格式一走鐘閘門就掛不上——這次就真的發生過:`jsc-cli` 寫四欄 TSV、這邊讀 `key=value`,狀態檔存在卻解不出欄位。覆蓋範圍也要據實寫,八支 hook 只有 claude 全接得上。

How:格式壓到最簡的純文字 `key=value`,一行一欄位,順序不拘,不認得的鍵一律忽略,與工作包狀態檔同一套寫法,兩邊各自實作也對得上。表只寫欄位與範例,判定規則寫在表底下:判定看檔案在不在、欄位只用在擋人訊息上、寫的一律是 `jsc-cli:deploy`、清的一律是 `session-timer.sh`,而且判準留在 `session-timer.sh`、狀態檔留在 `restart-gate.sh`,兩邊都不抄對方那一半。

Who:讀 `jsc-hooks` 說明的人,以及 `jsc-cli` 那一側寫 `deploy.sh` 的人。
2026-08-27 16:34:17 +08:00
jiantw83 7f8615a9ed feat(hooks-install): 技能同步到八支 hook 與重啟閘門的降級說法
What:`skills/hooks-install/SKILL.md` 四處增修。`description` 與目標段落的 hook 清單加入 `restart-gate.sh`,七支改八支;降級說明補上「四個 CLI 連部署後重啟閘門也接不上」與後果一句;第 4 步的冒煙說明補上重啟閘門的每條判定路徑;備註新增 `restart-gate.sh` 一條,並在 `session-timer.sh` 那一條補上「`start` 與 `restart` 會清除閘門」。

Why:技能是接線這件事的對外說法。hook 加了一支、技能還寫七支,回報就會少一項,而且降級說法不補會暗示每個 CLI 都擋得下來——這正是準則明文禁止的。`session-timer.sh` 那條也要補:清除閘門掛在那兩個事件上,接線少了它們,閘門會一路擋到使用者自己下逃生門。

How:降級那一段把後果講明白,不只說「接不上」:在那四個 CLI 上一次技能呼叫都擋不下來,狀態檔照樣寫、下一個工作階段開始照樣清,重啟本身只靠 `jsc-cli:deploy` 的收尾訊息。備註那一條寫出豁免清單與逃生門,並講明豁免的理由是異動報告與工作日誌要寫得完,讓接線的人知道哪些技能在閘門升起時仍然叫得動。

Who:`/jsc-hooks:hooks-install` 的接線流程與對使用者的回報。
2026-08-27 16:34:17 +08:00
jiantw83 e370729720 feat(wire-cli): 接線腳本納入部署後重啟閘門,七支改八支
What:`tools/wire-cli.sh` 四處增修。`smoke` 加跑 `restart-gate.sh`,另外自備一份暫時的 `$JSC_HOME`,把八條判定路徑(狀態檔不存在、狀態檔存在且技能為 `jsc-sdlc:implement`、四支豁免技能各一條、逃生門、取不到技能名)各跑一次並比對結束碼,再驗一次清除機制真的清得掉;claude 的接線驗證與 `status` 盤點各加一個 `restart-gate` 檢查點;codex、copilot、antigravity、kiro 四支的 degraded 說法補上「部署後重啟閘門也接不上」;檔頭與各處「七支」一律改為「八支」。

Why:新的 hook 接進 `hooks.json` 只代表宣告在檔案裡。接線驗證不點名就漏得掉——`comment-scope.sh` 與 `lang-guard.sh` 當初就是為同一個原因各列一項。冒煙測試也一樣:不自備狀態檔,只走得到「狀態檔不存在」與「取不到技能名」兩條捷徑,擋人與豁免那幾條一次都跑不到,判定寫了卻沒驗等於沒寫。四個 CLI 的降級說法不補,回報就會暗示每個 CLI 都擋得下來。

How:驗的是判定結果本身,不只是腳本跑得完——每一條路徑都給定預期結束碼,對不上就計入失敗並印出前 200 字的輸出。狀態檔放在暫時目錄,冒煙測試不該把使用者真正的 `$JSC_HOME/restart-required` 拆掉。清除機制那一段刻意走 `session-timer.sh start` 這條真實路徑,不直接呼叫 `clear`:要驗的是「新工作階段會不會清」,不是「`clear` 這個子命令能不能刪檔」。每次呼叫都接 `</dev/null`:hook 模式會讀標準輸入,管線沒人關閉時整支會卡死。建不出暫存目錄也算失敗,不能靜悄悄跳過。四支非 claude 的 CLI 除了 degraded 一句,另外逐支印一行講明後果:一次技能呼叫都擋不下來,狀態檔照樣寫、下次工作階段開始照樣清,只是中間沒有判定點,重啟要靠 `/jsc-cli:deploy` 收尾的提示自己動手。

Who:`jsc-hooks:hooks-install` 的接線、冒煙與盤點三個子命令。
2026-08-27 16:34:17 +08:00
jiantw83 dbf9aac25e feat(restart-gate): 新增部署後強制重啟閘門,並接上新工作階段自動清除
What:新增第八支 hook `hooks/restart-gate.sh`。hook 模式在 `$JSC_HOME/restart-required` 存在時以 exit 2 擋下 jsc 技能呼叫,另有三個子命令:`require {模式} [{domain}...]` 寫入狀態檔掛上閘門、`clear` 清除狀態檔、`report` 印出狀態檔內容。`hooks/hooks.json` 把它接到 PreToolUse(Skill),排在 `version-guard.sh` 前面。`hooks/session-timer.sh` 的 `start` 與 `restart` 在判定為新工作階段時轉呼叫 `clear`。

Why:部署換掉的是磁碟上的技能檔,正在跑的 CLI 行程載入的還是舊版——SKILL.md、hook 腳本與 tools 都在啟動當下讀進記憶體。這段落差期間跑技能,改動看起來沒生效,人會以為部署失敗又重跑一次。所以部署收尾要求重新啟動,這道閘門負責讓「還沒重啟就繼續用技能」擋在門外。

How:判定看的是「檔案在不在」,欄位只用在擋人訊息上——欄位缺了只讓訊息少幾個字,不影響判定。狀態檔用純文字 `key=value`(`at`、`mode`、`domains`、`cli`),格式與工作包狀態檔同一套,`jsc-hooks` 與 `jsc-cli` 兩邊各自實作也對得上。放行原則比照 `version-guard.sh`:只擋確定違規,狀態檔讀不到、技能名取不到、工具名不是 `Skill`、技能不是 `jsc-*:*` 一律 exit 0,沒有證據時擋下等於停掉每一次技能呼叫。九支豁免(`jsc-cli:deploy`、`jsc-hooks:hooks-install`、`jsc-gitea:wiki`、`jsc-log:worklog`、`jsc-log:learn`、`jsc-meta:*`、`jsc-ask:ask`、`jsc-git:pr`、`jsc-git:commit`)的理由是同一件事:部署後還要寫得完技能組異動報告與工作日誌,整批擋下去「先重啟」與「先寫完報告」會互相打死。後三支自己不是收尾規則的主體,是為了讓前六支走得完才補進來的——清單認的是技能名,不是呼叫鏈。清除交給 `session-timer.sh`,不由本檔自己判:新舊工作階段的判準(`sessions/{sid}.start` 在不在)只有那支腳本知道,兩邊各寫一份就會漂移;續接同一階段走不到那一段,閘門就一路留到真的重新啟動。`require`、`clear`、`report` 都不讀標準輸入,只有 hook 模式讀,工具端呼叫一律再補 `</dev/null`,理由與 `sdlc-gate.sh` 相同。接線排在 `version-guard.sh` 前面:還沒重啟的舊版比落後一個版號更該先攔。不認得的子命令一律安靜 exit 0,不中斷宿主 CLI。擋人訊息依實際 CLI 給重啟方式,並附上豁免清單與逃生門 `JSC_RESTART_GATE=off`。

Who:部署後的第一次技能呼叫,以及 `jsc-cli:deploy` 收尾寫入狀態檔的那一端。
2026-08-27 16:34:17 +08: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
jiantw83 e714c6ade2 Merge pull request 'feat(hooks): 工作包閘門加上歸屬比對與領取、交回子指令' (#31) from feat/sdlc-flow-rules/main into develop 2026-08-27 03:39:37 +00:00
admin e53dd802b8 Merge pull request 'feat(hooks): 工作包歸屬比對與狀態檔格式' (#30) from feat/sdlc-flow-rules/wp-scope-gate into feat/sdlc-flow-rules/main
Reviewed-on: #30
2026-08-27 03:26:10 +00:00
jiantw83 be75cc4bed chore(manifest): 三份 manifest 版本升到 0.2.3
What:`plugin.json`、`.claude-plugin/plugin.json`、`.codex-plugin/plugin.json` 的 `version` 由 0.2.2 改為 0.2.3。

Why:本次新增 `wp-claim`、`wp-unclaim` 兩個子命令並改了鎖檔格式,屬於行為變更,版本要跟著往上走,各 CLI 才知道要更新。

How:三份只改 `version` 一個欄位,其餘內容不動,三份保持同一版號。

Who:`jsc-hooks` 外掛的套件描述檔。
2026-08-27 11:20:28 +08:00
jiantw83 29facd385c docs(hooks): README 補上工作包歸屬狀態檔格式
What:README 新增「工作包歸屬狀態檔」一節,用表列出鎖檔與領取檔的檔名、欄位與範例,寫明誰寫誰讀、查無歸屬一律放行、舊版 TSV 鎖檔照樣讀得動;`sdlc-gate.sh` 與 `wire-cli.sh` 兩列的說明同步補上新子命令與冒煙測試的新內容。

Why:狀態檔的格式是 `jsc-hooks` 與 `jsc-sdlc` 兩邊的介面。介面只寫在腳本註解裡,另一邊改的時候看不到,格式一走鐘歸屬就全部誤判。

How:格式壓到最簡的純文字 `key=value`,一行一欄位,順序不拘,不認得的鍵一律忽略,兩邊各自實作也對得上。表只寫欄位與範例,判定規則寫在表底下的三段短說明。

Who:讀 `jsc-hooks` 說明的人,以及 `jsc-sdlc` 那一側寫 `wp-gate.sh` 的人。
2026-08-27 11:20:28 +08:00
jiantw83 3b6dd1b901 test(wire-cli): 冒煙測試補驗工作包歸屬的四條判定路徑
What:`tools/wire-cli.sh` 的 `smoke` 新增一段,用一份暫時的 `$JSC_HOME` 狀態檔把「狀態檔不存在」「PR 屬於領取中的工作包」「PR 屬於別的工作包」「逃生門 `JSC_WP_GATE=off`」四種情境各跑一次,逐一比對結束碼;收尾那句 `status=ok` 的說明也跟著補上這一段。

Why:原本兩支 `wp-check` 冒煙都把技能名清空,只走得到「沒有未結清 PR」與「取不到技能名」兩條捷徑,歸屬比對整段一次都沒跑到。判定寫了卻沒驗,等於沒寫。

How:驗的是判定結果本身,不只是腳本跑得完——每一種情境都給定預期結束碼,對不上就計入失敗並印出前 200 字的輸出。狀態檔放在暫時目錄,冒煙測試不該在使用者真正的 `$JSC_HOME/wp/` 留下痕跡。每次呼叫都接 `</dev/null`:`wp-check` 會讀標準輸入,管線沒人關閉時整支會卡死。建不出暫存目錄也算失敗,不能靜悄悄跳過。

Who:`jsc-hooks:hooks-install` 接線後跑的冒煙測試,以及改動 `sdlc-gate.sh` 歸屬判定的人。
2026-08-27 11:20:28 +08:00
jiantw83 870f52fbaa feat(sdlc-gate): 工作包閘門加上歸屬比對與領取紀錄
What:`hooks/sdlc-gate.sh` 新增 `wp-claim {owner}/{repo} {工作包代號} [{PR 編號}] [{分析頁}]` 與 `wp-unclaim {owner}/{repo}` 兩個子命令,`wp-lock` 新增第四個參數收工作包代號,鎖檔改為純文字 `key=value`,`wp-report` 多印第四欄工作包代號。`wp-check` 逐筆判歸屬:`prompt` 多注入一行點名不屬於這裡的 PR,`skill` 擋下 `plan`、`analyze`、`maintain` 時一併點名,`implement` 仍放行但收到同一則提醒。

Why:同一份分析常有好幾包平行進行,每包各自的 worktree 與 PR。原本的鎖檔只記存取庫與 PR 編號,看不出那支 PR 是誰的,於是任何一個工作階段都可能去改別包的程式碼、回別包的留言。

How:歸屬只比一件事——鎖檔的 `wp` 與同一個存取庫領取檔的 `wp` 取數字比一次,兩邊都有值且不相等就是別包的。`WP-03`、`WP-3`、`3` 先正規化再比,不然同一包的幾種寫法會被當成不同包。任何一邊查不到(沒有分析頁、沒寫代號、領取檔不存在)一律當查無歸屬並放行,理由與 `version-guard.sh` 一致:只擋確定違規,否則會把技能組維護自己鎖死。舊版單行 TSV 鎖檔照樣讀得動,讀出來是查無歸屬,換格式不會讓既有的鎖失效。`implement` 一律放行,連別包的 PR 未結清也放行——結清 PR 正是 `implement` 的步驟,擋它會把流程鎖死。分類迴圈用 here-document 餵資料而不用管線,管線右邊是子 shell,判到的違規會在迴圈結束時全部消失。

Who:跑 SDLC 實作階段的每個工作階段,以及 `jsc-sdlc/tools/wp-gate.sh` 的 `claim`、`lock` 與 `owns`。
2026-08-27 11:20:28 +08: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 9875f917bf Merge pull request 'feat/lang-guard-traditional-chinese-encoding' (#28) from feat/lang-guard-traditional-chinese-encoding into develop
Reviewed-on: #28
2026-08-27 01:58:37 +00:00
jiantw83 667b60c8e9 feat(manifest): 三份 manifest 版本升到 0.2.2
What:三份 manifest 由 0.2.1 升到 0.2.2。

Why:字表擴充後偵測能力大幅改變,本機沒跟著升版,version-guard.sh 就判不出落後,
使用者不會收到更新提示,繼續用覆蓋率只有兩成的舊字表。

How:以 jsc-meta 的 sync-skill-manifest.sh 統一 bump,三份同步成同一個值。

Who:非程式碼輸出的繁中無亂碼檢查。
2026-08-27 09:53:55 +08:00
jiantw83 75fba4715c feat(simplified): 字表擴充到 1006 字並補齊跳過清單
What:簡體字表由 102 字擴充到 1006 字;lang-guard.sh 的跳過清單補上
jsc-meta 的 references/ste100.md 與 tools/ste100-lint.sh。

Why:102 字的舊表覆蓋率太低——拿 21 個常見簡體詞實測只抓到 4 個,「简体字」
「举办」「觉得」「战争」「医药」這類全部漏掉,規則等於形同虛設。跳過清單則是
對稱性問題:規則文件與機檢工具本身會列舉簡體字當範例,不跳過就每次先抓到自己。

How:字表逐字檢查「簡化後才出現的字形」,剔除正體也在用的字(后、台、干、只、
里、面、制、志、出、斗、丑、了、卷、咸、云、留、言、短、困、陷、游、封、吞),
再拿 jsc 全部 11 個存取庫共 221 個檔案的既有正體中文語料回歸驗證,誤報為零。
檔頭寫明排除原則與「改完必須拿語料重驗」的要求。跳過清單比照 ste100-lint.sh 的做法。

Who:非程式碼輸出的繁中無亂碼檢查。
2026-08-27 09:53:55 +08:00
jiantw83 aee2f19699 feat(manifest): 三份 manifest 版本升到 0.2.1
What:把 `plugin.json`、`.claude-plugin/plugin.json`、`.codex-plugin/plugin.json` 三份 manifest 的版本由 0.2.0 升到 0.2.1。

Why:0.2.0 已隨前一支 PR 發佈出去,本次新增的第七支 hook 與五個 CLI 的接線沒有跟著升版,`version-guard.sh` 就看不出本機落後,使用者不會收到更新提示。

How:以 jsc-meta 的 `sync-skill-manifest.sh` 統一 bump,patch 進位,三份同步成同一個值。

Who:繁中與編碼檢查 hook 的跨 CLI 支援。
2026-08-27 09:50:03 +08:00
jiantw83 fe3c5723a5 feat(hooks-install): 文件同步到七支 hook
What:`README.md` 的 hook 一覽表新增 `lang-guard.sh` 一列(事件、三種模式、逃生門),所有「六支 hook」改七支,環境變數表補 `JSC_LANG_GUARD`,`tools/jsc-wrap.sh` 那列補收尾 sweep;`skills/hooks-install/SKILL.md` 六支改七支並補覆蓋範圍與備註,`description` 的括號清單加上這支;`AGENTS.md` 清單同步,並新增一條繁中無亂碼規則。

Why:文件寫六支、實際跑七支,使用者與 sub agent 都會照文件辦事,少接的那一支永遠沒有人發現。`AGENTS.md` 是各 CLI 讀得到的規則檔,新規則不寫進去就只剩 hook 在擋,模型自己不會知道。

How:README 維持 STE100 繁中;SKILL.md 維持整份英文,`description` 保持 4 句、保留觸發時機。規則正文一律不在本存取庫留副本,只指向 `jsc-meta` 的 `references/ste100.md`,字表位置指向 `hooks/simplified.txt`。

Who:`jsc-hooks` 的文件層,讀者是使用者與執行 `hooks-install` 的 sub agent。
2026-08-27 09:50:03 +08:00
jiantw83 bbad6566f7 feat(wire-cli): 把 lang-guard.sh 接到五個 CLI 的既有時機
What:`hooks/hooks.json` 的 `UserPromptSubmit` 加 `lang-guard.sh prompt`、`PostToolUse` 的 `Write|Edit|MultiEdit` 加無參數模式;`tools/wire-cli.sh` 把 codex 的 `notify`、kiro 的 `userPromptSubmit` 串上 sweep,規則檔文字加上 `lang-guard.sh prompt` 的輸出,`smoke` 三種模式各跑一輪,`status` 補盤點項,寫入後驗證一併涵蓋;`tools/jsc-wrap.sh` 收尾再跑一次 sweep;`hooks/ste100-guard.sh` 的注入文字補上適用範圍與 UTF-8 無亂碼。

Why:hook 寫好了不接線等於沒寫。接的位置與 `comment-scope.sh` 完全一樣,因為兩支要解的是同一個問題——只有 claude 有 post-tool hook,其餘四個 CLI 拿不到「剛剛寫了哪個檔」,只能改掃整個工作區。各 CLI 的掃描時機本來就不同:claude 逐檔即時、codex 每輪結束、kiro 每輪提示送出時、copilot 與 antigravity 只有工作階段結束,回報時不能寫成五支一樣。

How:規則檔文字取 `lang-guard.sh prompt` 的實際輸出,不在接線腳本裡抄一份規則,抄了兩邊就會各自漂移。寫入後以 `has_lang_guard()` 比對腳本第一行實際輸出,確認規則檔真的收到那一段。`smoke` 沿用既有的 exit 2 例外:掃描模式掃到違規回 2 是設計行為,不是執行期錯誤。`jsc-wrap.sh` 的 sweep 一律加 `|| true`,包裝器原樣回傳 CLI 自己的結束碼——包裝器改掉結束碼,呼叫端的 `cmd && next` 就會誤判。

Who:`jsc-hooks` 的接線層,由 `hooks-install` 技能對 claude、codex、copilot、antigravity、kiro 五個 CLI 執行。
2026-08-27 09:50:03 +08:00
jiantw83 e3a9c67781 feat(lang-guard): 新增第七支 hook,強制非程式碼輸出繁中無亂碼
What:新增 `hooks/lang-guard.sh` 與機檢字表 `hooks/simplified.txt`。腳本比照 `comment-scope.sh` 的結構,一樣三種模式:`prompt` 注入規則摘要、無參數掃剛寫入的單一檔案、`sweep [dir]` 掃整個 git 工作區這次改過的檔案。偵測三項:簡體字、亂碼(U+FFFD 與雙重編碼殘骸)、非 UTF-8 編碼。命中走 stderr 並 exit 2,資料不足或找不到 git 一律安靜 exit 0,逃生門 `JSC_LANG_GUARD=off`。

Why:使用者新增的規則是「非程式碼的輸出一律套用繁中無亂碼」,範圍涵蓋程式碼註解、commit 訊息、PR 描述、wiki 頁、對使用者的回報與各種文件。這條規則原本只有 `ste100-guard.sh` 的提示層,模型看得到卻沒有人檢查,寫出簡體字或亂碼不會有任何回饋。提示層擋不住的事,就要有機檢層。

How:簡體字樣式由字表組出,字表是單一真實來源,腳本裡不留第二份;字表讀不到就安靜跳過這一項,不中斷整支腳本。亂碼一律用位元組比對(`LC_ALL=C`),不靠語系的字元範圍,因為 `grep -E` 的字元範圍在不同語系下行為不一致。掃描深度沿用 `comment-scope.sh`:檔案已追蹤就只掃 `git diff HEAD` 的新增行,不翻舊帳。二進位檔只認 NUL 位元組判定,不拿「非可列印字元」當判準,那會把所有含中文的檔案誤判成二進位。字表刻意排除繁體也在用的字(后、台、干、只、里、面、制、志),並跳過 `simplified.txt`、`ste100-guard.sh`、`lang-guard.sh` 三份以簡體字與亂碼為討論對象的檔案,避免整支 hook 每次先抓到自己。

Who:`jsc-hooks` 的 hook 實作層,供 `hooks/hooks.json` 與 `tools/wire-cli.sh` 接線給五個 CLI 使用。
2026-08-27 09:50:03 +08:00
admin 2f74d6473c Merge pull request 'feat/comment-scope-sweep-all-clis' (#27) from feat/comment-scope-sweep-all-clis into develop
Reviewed-on: #27
2026-08-27 01:28:48 +00:00
jiantw83 542f0aab2c feat(manifest): 三份 manifest 版本升到 0.2.0
What:把 plugin.json、.claude-plugin/plugin.json、.codex-plugin/plugin.json 三份
manifest 的版本由 0.1.9 升到 0.2.0。

Why:0.1.9 已隨前一支 PR 發佈出去,本次新增的 sweep 模式與四支 CLI 的接線
沒有跟著升版,version-guard.sh 就看不出本機落後,使用者不會收到更新提示。

How:以 jsc-meta 的 sync-skill-manifest.sh 統一 bump,minor 進位、patch 歸零,
三份同步成同一個值。

Who:註解範圍檢查 hook 的跨 CLI 支援。
2026-08-27 09:20:44 +08:00
jiantw83 b23c546079 docs(hooks-install): 據實寫明各 CLI 的註解範圍掃描時機
What:`skills/hooks-install/SKILL.md`、`README.md`、`AGENTS.md` 三份文件一併改寫註解範圍的覆蓋範圍說明:`comment-scope.sh` 由兩種模式改為三種,並以表格列出五個 CLI 各自的掃描時機——claude 逐檔即時(PostToolUse)、codex 每輪結束(`notify`)、kiro 每輪提示送出時(`userPromptSubmit`,掃的是上一輪寫的檔)、copilot 與 antigravity 只有工作階段結束時由 `tools/jsc-wrap.sh` 收尾掃一次。README 的 `tools/jsc-wrap.sh` 那列補上收尾 sweep 與「不影響結束碼」的約定,`smoke` 例外說明改成掃描模式通用。

Why:舊文件寫的是「四個 CLI 只剩規則提示」,接上 sweep 之後那句話已經不實。但也不能倒過來寫成五支一樣:時機差一輪或差一整個工作階段,操作者要知道自己現在用的 CLI 什麼時候才會收到警告。文件不同步,操作者會對保護程度有錯誤預期。

How:SKILL.md 全份維持英文,正文改用一張 CLI 對掃描時機的表格,並註明 sweep 讀的是 `git diff HEAD`、涵蓋範圍與 claude 相同、不在 git 工作區內就安靜 exit 0,frontmatter 的 `description` 不動;README.md 維持 STE100 繁中,hook 一覽表那列補上三種模式與各 CLI 時機,原本的降級段落換成同一張表;AGENTS.md 的第 5 條補上三種模式與「不得寫成五支一樣」的要求。

Who:`jsc-hooks` 的文件層與 `hooks-install` 技能,供操作者與後續 sub agent 依循。
2026-08-27 09:16:32 +08:00
jiantw83 0bebdef46f feat(wire-cli): 把 comment-scope sweep 接到其餘四個 CLI
What:`tools/wire-cli.sh` 把 `comment-scope.sh sweep` 接進 codex 的 `config.toml` 根層 `notify` 與 kiro 的 `.kiro/hooks/jsc-hooks.json` 的 `userPromptSubmit`;`tools/jsc-wrap.sh` 在 CLI 結束後、回傳結束碼之前跑一次 sweep,涵蓋 copilot、antigravity 與同樣走包裝器的 codex。`smoke` 多跑一輪 sweep,`status` 多盤點 codex 的 `notify-sweep` 與 kiro 的 `comment-scope-sweep` 兩項。

Why:這四個 CLI 沒有 post-tool hook,接不到逐檔即時掃描,先前只寫得進規則提示,違規註解寫進去沒有人叫。接上 sweep 之後五個 CLI 都掃得到,差別只剩回饋速度。`smoke` 與 `status` 不同步補上,就驗不出來接線少了哪一支。

How:codex 的 notify 串成 `start; mark; sweep`,位置仍由 `replace_block_toml` 保證落在第一個表頭之前,寫入後除了既有的根層鍵檢查,再 grep 一次確認 sweep 真的串進那一行。kiro 的 `run` 尾端接上 sweep,`prompt` 與 `sweep` 各驗一個 grep,只驗腳本名會漏掉少接的那一個。`jsc-wrap.sh` 的 sweep 加 `|| true` 接住 exit 2,包裝器一律原樣回傳 CLI 自己的結束碼——包裝器改掉結束碼,呼叫端的 `cmd && next` 就會誤判。`smoke` 的 exit 2 白名單由「無參數模式」放寬到「所有掃描模式」,因為 sweep 在髒工作區本來就會回 2,那是 hook 正常工作,不是 hook 壞掉。四個 CLI 的 `reason` 與 `[jsc]` 說明改寫成各自真正的掃描時機。

Who:`jsc-hooks` 的接線工具層,供 `jsc-hooks:hooks-install` 與 `jsc-hooks:repair` 呼叫,最終服務 codex、copilot、antigravity、kiro 的使用者。
2026-08-27 09:16:15 +08:00
jiantw83 8ede74e9e2 feat(comment-scope): 新增 sweep 模式,掃整個 git 工作區
What:`hooks/comment-scope.sh` 由兩種模式變三種,新增 `sweep [dir]`:掃整個 git 工作區這次改過的所有檔案,命中就把報告與最多三行證據送到 stderr 並 exit 2,找不到 git 就安靜 exit 0。`prompt` 與無參數單檔掃描兩個模式的判定邏輯一行不動。

Why:只有 claude 有 PostToolUse,拿得到「剛剛寫了哪個檔」。codex、copilot、antigravity、kiro 四個 CLI 都沒有 post-tool 事件,註解範圍檢查在那邊只剩規則提示,違規註解寫進去了不會有人叫。改掃整個工作區的 git diff,時機晚一點,涵蓋範圍一樣。

How:單檔判定抽成 `scan_file()`,兩種掃描模式共用同一份禁止樣式與白名單,不會各自漂移。`sweep` 以 `git rev-parse --show-toplevel` 找庫根,所以在子目錄跑也掃得到整個庫;逐檔報告累積在暫存檔再一次輸出,因為迴圈跑在管線的子行程裡,變數帶不回本 shell。

Who:`jsc-hooks` 的 hook 實作層,供 `tools/wire-cli.sh` 接線給 codex、copilot、antigravity、kiro 四個 CLI 使用。
2026-08-27 09:15:56 +08:00
admin 8f98e42171 Merge pull request 'feat/comment-scope-hook' (#26) from feat/comment-scope-hook into develop
Reviewed-on: #26
2026-08-27 00:56:46 +00:00
admin a4996064ff Merge pull request 'fix/version-guard-exempt-ask-and-wiki' (#25) from fix/version-guard-exempt-ask-and-wiki into develop
Reviewed-on: #25
2026-08-27 00:56:36 +00:00
jiantw83 7690b697c7 feat(manifest): 三份 manifest 版本升到 0.1.9
What:`plugin.json`、`.claude-plugin/plugin.json`、`.codex-plugin/plugin.json` 三份 manifest 的 `version` 由 0.1.8 升到 0.1.9。

Why:本次新增了第六支 hook,屬於功能增修。版本不升,`version-guard.sh` 的版本前置檢查與 `jsc-cli:deploy` 的更新判斷都看不出本機落後,使用者不會收到更新提示。

How:三份檔案同步改同一個版本號,維持三份 manifest 版本一致的既有慣例。

Who:`jsc-hooks` 的發佈中繼資料,供 `hooks/version-guard.sh` 與 `jsc-cli:deploy` 比對版本。
2026-08-26 19:01:36 +08:00
jiantw83 5d60e0f231 feat(hooks-install): 文件同步六支 hook 與降級說明
What:`skills/hooks-install/SKILL.md`、`README.md`、`AGENTS.md` 三份文件一併改口徑為六支 hook,補上 `comment-scope.sh` 的職責、兩種模式、環境變數 `JSC_COMMENT_SCOPE` 與 `JSC_CHANGED_FILE`,以及各 CLI 的覆蓋範圍差異。

Why:文件停在五支會誤導操作者,讓人以為每個 CLI 都受同等保護。實際上只有 claude 有 post-tool hook,其他四個 CLI 只接得到規則提示,這個落差必須據實寫明。

How:SKILL.md 的 `description` 與正文加上 comment scope scanner,並註明 exit 2 在冒煙測試裡算健康;README.md 的 hook 表格新增一列,接線工具那列改寫 `smoke` 的例外說明,環境變數表補兩列,另加一段講明四個 CLI 的降級實情;AGENTS.md 新增一條規則,寫明規則正文的唯一來源在 `jsc-review`,本存取庫不留副本。

Who:`jsc-hooks` 的文件層與 `hooks-install` 技能,供操作者與後續 sub agent 依循。
2026-08-26 19:01:36 +08:00
jiantw83 e2232d26f1 feat(wire-cli): 接線腳本納入註解範圍 hook,五支改六支
What:`tools/wire-cli.sh` 新增 `comment_scope_text()`、`rules_text()` 與 `has_comment_scope()` 三個函式,把 `comment-scope.sh` 的 `prompt` 模式接進 codex、copilot、antigravity 的規則檔與 kiro 的 hook JSON,`smoke` 與 `status` 兩個子命令也補上這支 hook,全腳本由五支 hook 改口徑為六支。

Why:`hooks/hooks.json` 只服務 claude;其他四個 CLI 要靠這支腳本接線,不改就完全接不到新規則。`smoke` 與 `status` 不同步補上,接線成功與否也驗不出來。

How:`comment_scope_text()` 直接取 `comment-scope.sh prompt` 的實際輸出當規則文字,不在本存取庫留規則清單副本;`rules_text()` 把 STE100 與註解範圍併進同一個 `<!-- jsc-hooks -->` 標記段落,重跑等同先移除再重裝;`has_comment_scope()` 供 `status` 判斷標記段落在不在。`smoke` 把 `comment-scope.sh` 無參數模式的 exit 2 視為設計行為,與 `sdlc-gate.sh check` 同列例外。

Who:`jsc-hooks` 的接線工具層,供 `jsc-hooks:hooks-install` 與 `jsc-hooks:repair` 呼叫。
2026-08-26 19:01:36 +08:00
jiantw83 f81ceaf145 feat(comment-scope): 新增註解範圍檢查 hook 並接進 claude 的 hooks.json
What:新增第六支 hook `hooks/comment-scope.sh`,並在 `hooks/hooks.json` 補上兩個接線點:UserPromptSubmit 走 `prompt` 模式、PostToolUse 的 `Write|Edit|MultiEdit` 走掃描模式。

Why:程式碼註解常被寫進工單編號、專案代號、負責人這類文件相關資訊,讓註解變成過期文件。過去只能靠 `/jsc-review:code-review` 事後抓,回饋太慢;把規則搬到寫檔當下,模型可以立刻修正。

How:`prompt` 模式印出規則摘要注入提示。無參數模式從 stdin JSON 取 `file_path`(或環境變數 `JSC_CHANGED_FILE`),只掃 `git diff HEAD` 的新增行,不翻舊帳;markdown、純文字、資料檔與二進位檔一律跳過。命中就把警告與最多三行證據送到 stderr 並以 exit 2 交回模型就地修正,不擋寫入。逃生門為 `JSC_COMMENT_SCOPE=off`。規則正文的唯一來源在 `jsc-review` 的 `references/comment-scope.md`,本存取庫不留副本。

Who:`jsc-hooks` 的 hook 層,服務 `jsc-hooks:hooks-install` 的接線流程與 `jsc-review:code-review` 的註解契約檢查。
2026-08-26 19:01:36 +08:00
jiantw83 961af918bf chore(gitignore): 忽略本機接線檔目錄 .kiro/
What:在 .gitignore 的暫存分類底下新增 .kiro/ 一行,讓該目錄維持不追蹤。

Why:.kiro/ 是 jsc-hooks:hooks-install 在本機產生的接線檔,內容含本機絕對路徑。這種檔案因人而異,提交進存取庫會讓別人拉到錯的路徑,也會每次接線就冒出雜訊變更。

How:於 .gitignore 加上獨立註解區塊與 .kiro/ 樣式,只忽略不刪檔;本機既有目錄原地保留,git status 不再列出。

Who:hooks 存取庫的版本控管設定,配合 jsc-hooks:hooks-install 的接線產出。
2026-08-26 18:40:11 +08:00
jiantw83 0c83495ba8 fix(version-guard): 把 jsc-ask:ask 與 jsc-gitea:wiki 加進豁免清單
What:版本守門 hook 的豁免清單新增 jsc-ask:ask 與 jsc-gitea:wiki 兩支技能,並在檔頭註解補上理由。

Why:jsc-cli:deploy 要問使用者 install/update/uninstall,一定會呼叫 jsc-ask:ask;ask 問完又一定寫回 wiki 才算完成。這兩支只要版本落後就被擋下,訊息還指回 /jsc-cli:deploy,deploy 卡在問不出模式那一步,形成死鎖,跟直接擋 deploy 是同一種問題。

How:在 case "$skill" 的豁免比對加上 jsc-ask:ask 與 jsc-gitea:wiki 兩個樣式,維持原本 exit 0 的略過行為;檔頭豁免清單註解同步補上兩條說明,講清楚死鎖成因。

Who:hooks/version-guard.sh 的外掛版本守門功能,影響 jsc-cli:deploy 的部署流程。
2026-08-26 18:40:11 +08: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 fcd77013ea Merge pull request 'fix/per-package-work-package-lock-file' (#23) from fix/per-package-work-package-lock-file into develop
Reviewed-on: #23
Reviewed-by: 系統管理員 <1+admin@noreply.localhost>
2026-08-26 10:07:27 +00:00
jiantw83 221742486b fix(sdlc-gate): 工作包鎖檔改依索引區分,避免平行工作包互相覆蓋並修正提示文字 2026-08-26 17:59:21 +08: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 388d967aab Merge pull request 'feat/hooks-wire-cli-status' (#21) from feat/hooks-wire-cli-status into develop
Reviewed-on: #21
2026-08-26 02:51:19 +00:00
jiantw83 f1bcdc3d72 feat(wire-cli): 新增唯讀的接線盤點子命令
What: wire-cli.sh 新增 status 子命令,只讀設定檔判斷 jsc 標記段落在不在,不寫檔也不執行 hook。每個接線點印一行 item,以 status=wired、degraded、unwired、skipped 回報。
Why: 體檢類技能要問「hook 接線在不在」,但既有三個子命令都會動到環境:不帶子命令會重新接線、purge 會刪掉、smoke 會真的執行 hook。體檢不該有副作用。
How: 沿用底下接線區塊的同一組檔案位置與標記字串,共用 has_block、toml_root_key、json_top_key 三個判讀函式。codex 的 notify 另外單獨檢查是否落在根層,標記在、鍵被歸進表裡時 codex 讀不到,等同沒接。
Who: 供 jsc-cli:doctor 呼叫的接線檢查。
2026-08-26 10:44:47 +08: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 88c7d37c2d Merge pull request 'feat/work-package-pr-gate-hook-enforcement' (#20) from feat/work-package-pr-gate-hook-enforcement into develop
Reviewed-on: #20
2026-08-25 11:03:24 +00:00
jiantw83 65d3c13d22 chore(hooks): 三份 manifest 同步升版到 0.1.7 2026-08-25 18:59:29 +08:00
jiantw83 26a5a4690d docs(hooks): 補上工作包 PR 閘門的 hook 說明與逃生門 2026-08-25 18:59:29 +08:00
jiantw83 b9ad321930 feat(sdlc-gate): 新增工作包 PR 閘門,未結清就擋下別的階段技能 2026-08-25 18:59:29 +08:00
admin 291909cfed Merge pull request 'feat/hooks-install-purge-first-and-repair-on-any-hook-error' (#18) from feat/hooks-install-purge-first-and-repair-on-any-hook-error into develop
Reviewed-on: #18
Reviewed-by: 系統管理員 <1+admin@noreply.localhost>
2026-08-25 10:20:02 +00:00
jiantw83 e62511b57f chore(hooks): 三份 manifest 同步升版到 0.1.6 2026-08-25 18:18:10 +08:00
jiantw83 6ecb7d0387 docs(hooks): 同步 purge、smoke 與錯誤掃描的工具與技能說明 2026-08-25 18:18:10 +08:00
jiantw83 b45a98af87 feat(hooks-install): 安裝改為先清空所有 hook 再接線並驗執行期 2026-08-25 18:18:10 +08: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
admin 881aabee83 Merge pull request 'feat/auto-repair-failed-hook-wiring' (#16) from feat/auto-repair-failed-hook-wiring into develop
Reviewed-on: #16
Reviewed-by: 系統管理員 <1+admin@noreply.localhost>
2026-08-25 08:58:41 +00:00
jiantw83 ad2f944a66 fix(hooks): 修正版本號為單位數格式 2026-08-25 16:39:49 +08:00
jiantw83 c68292acbc feat(hooks): 自動接手失敗接線與修復流程 2026-08-25 16:33:38 +08:00
admin 8fc1f18c31 Merge pull request 'fix/skillset-audit-compliance-and-guard-fixes' (#15) from fix/skillset-audit-compliance-and-guard-fixes into develop
Reviewed-on: #15
2026-08-25 07:15:00 +00:00
jiantw83andClaude Opus 5 2982ca4e9f chore(hooks): 三份 manifest 同步升版並同步 marketplace 正本
What:三份 plugin manifest 版本同步 bump,兩份 marketplace 檔與 plugins/meta 正本對齊。

Why:準則要求技能異動必須同步升版;marketplace 副本必須與正本完全一致。

How:以 jsc-meta 的 tools/sync-skill-manifest.sh 升版,marketplace 檔由正本複製。

Who:jsc-meta:skill-check 例行稽核(2026-08-25)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 14:58:54 +08:00
jiantw83andClaude Opus 5 83170e1b43 docs(hooks): 同步文件與參考資料
What:更新 README、AGENTS.md、templates 與 references,讓文件敘述與實際行為一致。

Why:稽核發現多處文件與程式行為分歧,違反「每個意義只有單一真實來源」。

How:以實際程式行為為準改寫敘述,重複的規則收成單一來源並以一行指引指過去。

Who:jsc-meta:skill-check 例行稽核(2026-08-25)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 14:58:54 +08:00
jiantw83andClaude Opus 5 fedcfc8b05 fix(hooks): 補齊稽核缺失並修掉護欄失效
What:依 jsc-meta:skill-check 的稽核結果修正技能與工具——補上每個步驟的可檢核完成條件、
把留在內文的標準輸入輸出流程下放 tools/、修正查表與退碼路由造成的誤判。

Why:稽核發現這些缺失會讓技能在實際執行時走錯分支或靜默通過。
完成條件缺漏是最常被違反的一項;退碼誤判與查表錯誤則會讓良性狀況被當成失敗。

How:逐項對照 references/guidelines.md 的審核檢查清單修正,新增的工具都有
documented exit codes,並以真實執行驗證每條路徑。

Who:jsc-meta:skill-check 例行稽核(2026-08-25)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 14:58:54 +08:00
admin cc9c80d760 Merge pull request '發佈 jsc-hooks 0.0.9:version-guard 新增 report' (#14) from develop into master
Reviewed-on: #14
Reviewed-by: 系統管理員 <1+admin@noreply.localhost>
2026-08-25 05:05:50 +00:00
36 changed files with 7780 additions and 364 deletions
+11 -3
View File
@@ -13,6 +13,14 @@
},
"description": "決策樹問詢與問詢紀錄(QUESTION_* wiki 頁)"
},
{
"name": "jsc-assist",
"source": {
"source": "url",
"url": "https://gitea.jsc.idv.tw/plugins/assist.git"
},
"description": "助理:事件收攏、健康巡檢與待辦簿(MONITOR_* wiki 頁)"
},
{
"name": "jsc-cli",
"source": {
@@ -43,7 +51,7 @@
"source": "url",
"url": "https://gitea.jsc.idv.tw/plugins/hooks.git"
},
"description": "跨 CLI hooks:STE100 語言強制、工時計時、技能用量記錄"
"description": "跨 CLI hooks:STE100 語言強制、工時計時、技能用量記錄、SDLC 模型鎖、版本前置檢查"
},
{
"name": "jsc-log",
@@ -75,7 +83,7 @@
"source": "url",
"url": "https://gitea.jsc.idv.tw/plugins/review.git"
},
"description": "程式碼審查:Refactoring 壞味道六組 + 註解規範 + 淺模組"
"description": "程式碼審查:Refactoring 壞味道六組、註解規範、淺模組"
},
{
"name": "jsc-sdlc",
@@ -83,7 +91,7 @@
"source": "url",
"url": "https://gitea.jsc.idv.tw/plugins/sdlc.git"
},
"description": "開發生命週期:規劃/分析/實作/維護(wiki 追蹤)"
"description": "開發生命週期:規劃、分析、實作、維護(wiki 追蹤)"
}
]
}
+11 -3
View File
@@ -13,6 +13,14 @@
},
"description": "決策樹問詢與問詢紀錄(QUESTION_* wiki 頁)"
},
{
"name": "jsc-assist",
"source": {
"source": "url",
"url": "https://gitea.jsc.idv.tw/plugins/assist.git"
},
"description": "助理:事件收攏、健康巡檢與待辦簿(MONITOR_* wiki 頁)"
},
{
"name": "jsc-cli",
"source": {
@@ -43,7 +51,7 @@
"source": "url",
"url": "https://gitea.jsc.idv.tw/plugins/hooks.git"
},
"description": "跨 CLI hooks:STE100 語言強制、工時計時、技能用量記錄"
"description": "跨 CLI hooks:STE100 語言強制、工時計時、技能用量記錄、SDLC 模型鎖、版本前置檢查"
},
{
"name": "jsc-log",
@@ -75,7 +83,7 @@
"source": "url",
"url": "https://gitea.jsc.idv.tw/plugins/review.git"
},
"description": "程式碼審查:Refactoring 壞味道六組 + 註解規範 + 淺模組"
"description": "程式碼審查:Refactoring 壞味道六組、註解規範、淺模組"
},
{
"name": "jsc-sdlc",
@@ -83,7 +91,7 @@
"source": "url",
"url": "https://gitea.jsc.idv.tw/plugins/sdlc.git"
},
"description": "開發生命週期:規劃/分析/實作/維護(wiki 追蹤)"
"description": "開發生命週期:規劃、分析、實作、維護(wiki 追蹤)"
}
]
}
+12 -3
View File
@@ -1,7 +1,7 @@
{
"name": "jsc-hooks",
"version": "0.0.9",
"description": "跨 CLI hooks:STE100 語言強制、工時計時、技能用量記錄、SDLC 模型鎖",
"version": "0.5.0",
"description": "跨 CLI hooks:STE100 語言強制、工時計時、技能用量記錄、SDLC 模型鎖、版本前置檢查、註解範圍守門、繁中編碼守門、部署後強制重啟、寫入與提交閘門",
"skills": "./skills",
"author": {
"name": "JSC"
@@ -13,5 +13,14 @@
"hooks",
"skills",
"cross-tool"
]
],
"jsc": {
"requires": {
"jsc-cli": ">=0.2.1",
"jsc-gitea": ">=0.1.7",
"jsc-git": ">=0.1.1",
"jsc-meta": ">=0.2.3",
"jsc-review": ">=0.0.8"
}
}
}
+13 -3
View File
@@ -1,6 +1,16 @@
{
"hooks": "./hooks/codex-hooks.json",
"name": "jsc-hooks",
"version": "0.0.9",
"description": "跨 CLI hooks:STE100 語言強制、工時計時、技能用量記錄、SDLC 模型鎖",
"skills": "./skills"
"version": "0.5.0",
"description": "跨 CLI hooks:STE100 語言強制、工時計時、技能用量記錄、SDLC 模型鎖、版本前置檢查、註解範圍守門、繁中編碼守門、部署後強制重啟、寫入與提交閘門",
"skills": "./skills",
"jsc": {
"requires": {
"jsc-cli": ">=0.2.1",
"jsc-gitea": ">=0.1.7",
"jsc-git": ">=0.1.1",
"jsc-meta": ">=0.2.3",
"jsc-review": ">=0.0.8"
}
}
}
+3
View File
@@ -8,3 +8,6 @@ Thumbs.db
# 暫存
*.tmp
*.log
# 本機接線檔(jsc-hooks:hooks-install 產生,內含本機絕對路徑)
.kiro/
+5 -2
View File
@@ -1,6 +1,6 @@
# jsc-hooks — 給 AI 助理的指引
本 repo 是 jsc 技能組的 `hooks` domain(跨 CLI hooks:STE100 語言強制、工時計時、技能用量記錄、SDLC 模型鎖),可同時被 Claude Code / Codex / Copilot / Antigravity / Kiro 使用。
本 repo 是 jsc 技能組的 `hooks` domain(跨 CLI hooks:STE100 語言強制、工時計時、技能用量記錄、SDLC 模型鎖、版本前置檢查、部署後強制重啟閘門、註解範圍檢查、繁中與編碼檢查、寫入與提交閘門),可同時被 Claude Code / Codex / Copilot / Antigravity / Kiro 使用。
## 規則
@@ -8,7 +8,10 @@
2. 技能位於 `skills/{name}/SKILL.md`;處理任務前先比對需求與各技能的 `description`,相符就載入並依其步驟執行。
3. 技能準則的唯一來源:`plugins/meta` 存取庫的 `references/guidelines.md`。
4. 所有 hook 只放在 `jsc-hooks`;gitea 操作一律經由 `jsc-gitea` 的 `tools/gitea.sh`;問使用者一律依 `jsc-ask:ask` 的決策樹規則。
5. 主 agent 不需要處理細節的流程,一律建立 sub agent 處理。
5. 註解範圍規則正文的唯一來源:`jsc-review` 的 `references/comment-scope.md`。本存取庫只放 `hooks/comment-scope.sh` 的判定實作,不留規則清單副本,接線腳本要用規則文字時一律取腳本的實際輸出。`comment-scope.sh` 有 `prompt`、無參數逐檔掃描、`sweep` 掃整個 git 工作區三種模式;掃描時機每個 CLI 都不同(claude 逐檔即時、codex 每輪結束、kiro 每輪提示送出時、copilot 與 antigravity 只有工作階段結束時),談覆蓋範圍時一律據實分開講,不得寫成五支一樣。
6. 所有非程式碼輸出一律繁體中文、UTF-8、無亂碼、無簡體字:程式碼註解、commit 訊息、PR 描述、wiki 頁、對使用者的回報、README 與各種文件都算。規則正文的唯一來源同樣是 `plugins/meta` 的 `references/ste100.md`,本存取庫只放 `hooks/lang-guard.sh` 的判定實作與 `hooks/simplified.txt` 的機檢字表。那份字表是本存取庫的單一真實來源,刻意排除繁體也在用的字(后、台、干、只、里、面、制、志),增刪前先確認不會製造誤報。`lang-guard.sh` 的三種模式與掃描時機跟 `comment-scope.sh` 一致,但它掃整個檔案而不只掃註解行,`.md` 與純文字檔也照掃。
7. `hooks/write-guard.sh` 的三種模式只有 claude 接得上(其餘四支 CLI 沒有 PreToolUse),談覆蓋範圍時據實講,不得暗示每支 CLI 都擋得住。它只讀 `sdlc-gate.sh` 的階段鎖與 `skill-usage.sh` 的技能紀錄,不自己寫狀態檔;提交訊息的簡繁與編碼判定一律轉呼叫 `hooks/lang-guard.sh`,本檔不留第二份樣式。
8. 主 agent 不需要處理細節的流程,一律建立 sub agent 處理。
## 呼叫慣例
+169 -12
View File
@@ -23,34 +23,167 @@ Marketplace 統一為 `jsc`(https://gitea.jsc.idv.tw/plugins/meta.git),安
| 腳本 | 事件 | 作用 |
| --- | --- | --- |
| `hooks/ste100-guard.sh` | UserPromptSubmit | 注入 STE100 繁體中文輸出規則(hook > prompt 強制層) |
| `hooks/session-timer.sh` | SessionStart / Stop / SessionEnd | 記錄工作階段起訖;`report` 子指令供 `jsc-log:worklog` 取花費時間 |
| `hooks/version-guard.sh` | PreToolUse(Skill) | 技能使用前的版本前置檢查:本機**實際載入**版本落後遠端發佈版本就以 exit 2 擋下該次呼叫並提示更新指令。只擋落後(超前放行,開發技能組時本機本來就會超前);遠端查不到一律擋(fail-closed),逃生門 `JSC_VERSION_GUARD=off`。豁免 `jsc-cli:deploy`、`jsc-hooks:hooks-install`、`jsc-cli:models`、`jsc-meta:*` |
| `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/deny.sh` | 不直接接線,由 `version-guard.sh` 與 `restart-gate.sh` 呼叫 | 產出各 CLI 認得的阻擋輸出,訊息從參數或標準輸入進。claude、codex、copilot 訊息寫 stderr 並回 exit 2;antigravity 印 stdout 的 `{"decision":"deny","reason":"..."}` 並固定回 0——那支 CLI 的結束碼語意兩邊文件都沒寫,靠結束碼會變成「判定擋下、CLI 照樣放行」的無聲失效,所以 stdout 只准有那一行;kiro 擋不下技能叫用,改印警告後回 0;認不得的代號走 stderr 加 2 這個保守預設 |
| `hooks/version-guard.sh` | PreToolUse:claude matcher `Skill`、codex matcher `Bash`、copilot matcher `skill`、antigravity matcher `^view_file$` 加 `PreInvocation`;kiro `userPromptSubmit`(只注入警告) | 技能使用前的版本前置檢查,擋兩種情況,兩種都擋下該次呼叫並提示更新指令(更新指令依當前 CLI 給;技能名解析交給 `hooks/skill-name.sh`、阻擋輸出形態交給 `hooks/deny.sh`,兩支的規則見上面兩列,這裡不重寫第二套):一是本機**實際載入**版本落後遠端發佈版本,二是技能所屬 plugin 的 manifest 在 `jsc.requires` 宣告的相依 plugin 版本落後——相依那一項讀 `installPath` 底下那份 `plugin.json`,逐項比對相依 plugin 的本機實際載入版本,訊息講明哪一個 plugin、需要哪一版、目前哪一版、怎麼補。相依檢查排在遠端比對之前,全部讀本機檔案,離線也判得動;判定邏輯自己實作,不呼叫 `jsc-cli/tools/check-requires.sh`,免得 hook 散落到別的 domain,也免得跟已宣告相依 `jsc-hooks` 的 `jsc-cli` 做出循環相依。部署那端照樣更新、只回報,阻擋落在這支 hook。兩種都只擋確定落後:超前放行(開發技能組時本機本來就會超前),讀不到本機版本、推導不出站台、查不到遠端版本、解不出安裝路徑、讀不到 manifest、manifest 沒有 `jsc.requires`、讀不到相依 plugin 的本機載入版本也一律放行。遠端版本快取在 `$JSC_HOME/version-cache/{CLI 代號}/{domain}`,一支 CLI 一份;舊路徑 `$JSC_HOME/version-cache/{domain}` 會在第一次讀取時複製到新路徑。逃生門 `JSC_VERSION_GUARD=off`。豁免 `jsc-cli:deploy`、`jsc-hooks:hooks-install`、`jsc-hooks:repair`、`jsc-cli:models`、`jsc-meta:*`、`jsc-ask:ask`、`jsc-gitea:wiki`——共 7 項,兩種擋人情況共用同一份,相依落後不另立短清單;清單的唯一來源是 `hooks/version-guard.sh` 的檔頭,那裡一項一個理由 |
| `hooks/restart-gate.sh` | PreToolUse:claude matcher `Skill`、codex matcher `Bash`、copilot matcher `skill`、antigravity matcher `^view_file$` 加 `PreInvocation`;kiro `userPromptSubmit`(只注入警告) | 部署後強制重啟閘門:`$JSC_HOME/restart-required.d/{CLI 代號}` 一支 CLI 一份,當前 CLI 那份存在時擋下 jsc 技能呼叫,並印出要重新啟動哪一支 CLI(技能名解析交給 `hooks/skill-name.sh`、阻擋輸出形態交給 `hooks/deny.sh`,兩支的規則見上面兩列);別支 CLI 那幾份不影響這一支。狀態檔由 `jsc-cli:deploy` 在 install 或 update 收尾時經 `restart-gate.sh require {install|update} [{domain}...]` 寫入當前 CLI 那一份,在下一個工作階段開始時由 `session-timer.sh` 呼叫 `restart-gate.sh clear` 只清除那一份。判定看檔案在不在:狀態檔讀不到、CLI 代號取不到、技能名取不到都放行(理由與 `version-guard.sh` 一致,只擋確定違規)。舊格式的單一檔案 `$JSC_HOME/restart-required` 存在時一律擋,`clear` 會一併刪掉它(過渡相容,詳見下面「部署後重啟狀態檔」)。豁免 `jsc-cli:deploy`、`jsc-hooks:hooks-install`、`jsc-hooks:repair`、`jsc-gitea:wiki`、`jsc-log:worklog`、`jsc-log:learn`、`jsc-meta:*`、`jsc-ask:ask`、`jsc-git:pr`、`jsc-git:commit`——部署後還要寫得完技能組異動報告與工作日誌,hook 壞掉也要修得回來,整批擋下去這些規則會互相打死。清單認技能名不認呼叫鏈,後三支是為了讓前七支走得完才補進來的:`deploy` 要問模式、報告寫完要開 PR。另有唯讀子指令 `report`,一支 CLI 一行印出每一份狀態檔的內容,看得出還有哪幾支沒重啟。逃生門 `JSC_RESTART_GATE=off` |
| `hooks/heartbeat.sh` | 不接線,由 `jsc-assist` 的助理主體、系統排程與 `status` 技能呼叫 | 助理心跳檔 `$JSC_HOME/assistant/heartbeat` 的讀寫工具,純文字 key=value,欄位 `ts`、`pid`、`cli`、`session`。四個子命令:`write` 寫入四個欄位(目錄不存在就建,先寫暫存檔再改名,讀的那一端永遠讀到完整的一份)、`check` 判定新不新鮮(什麼都不印,結果只在結束碼)、`report` 印一行現況供 `status` 技能與擋人訊息取用、`clear` 刪除心跳檔(由助理的 `stop` 呼叫,檔案不存在也算成功)。新鮮的判準只有一條:心跳檔存在、而且 `ts` 距現在小於門檻秒數,門檻預設 300(心跳週期 60 秒的五倍,一次卡頓不會誤判),可用 `JSC_ASSISTANT_HEARTBEAT_TTL` 覆寫。**絕不看 pid 存活**:五支 CLI 與容器裡的行程互相看不到彼此的 pid,問了會把活著的判成停了,pid 又會被回收,反過來把停掉的判成還在跑,兩種誤判都不報錯;pid 只當擋人訊息的線索。`check` 的結束碼分四種讓呼叫端各自處置:0 新鮮、1 過期(跑過但停了)、3 心跳檔不存在(從沒啟動過)、4 檔案在但 `ts` 讀不出來(檔案壞了);`write` 與 `clear` 的檔案系統失敗回 5,用法錯誤回 6。四個子命令都不讀標準輸入——這支不是 hook,是被工具端呼叫的腳本,讀了會在管線沒人關閉時整支卡死。逐碼意義與 `report` 的欄位順序見腳本檔頭 |
| `hooks/assistant-gate.sh` | **這一版尚未接線。** 接線位置比照 `restart-gate.sh`(PreToolUse,claude matcher `Skill`、codex matcher `Bash`、copilot matcher `skill`、antigravity matcher `^view_file$` 加 `PreInvocation`;kiro `userPromptSubmit` 只注入警告) | 助理運行閘門:助理沒在跑就擋下 jsc 技能呼叫。判定整段交給 `heartbeat.sh check`,本檔不自己讀心跳檔;訊息細節取自 `heartbeat.sh report`(技能名解析交給 `hooks/skill-name.sh`、阻擋輸出形態交給 `hooks/deny.sh`)。心跳新鮮放行;心跳不存在、過期、`ts` 讀不出來三種都擋,三種的訊息各寫一份——沒啟動過的要去啟動、跑過停了的要去查為什麼停、檔案壞了的要先 `stop` 再 `start` 重建,併成一句就會叫錯人做錯事。`heartbeat.sh` 回 2(腳本沒跑起來)、5(檔案系統失敗)、6(用法錯誤)一律放行:那三碼是判定機制自己壞了,不是「助理沒在跑」的證據,而且 5 正是磁碟滿或權限壞的訊號,擋下去會把全機器整組技能鎖死、連豁免那幾支也修不動。**這是整組技能唯一一道 fail-closed 閘門**(其餘 hook 一律資料不足就放行),所以逃生門與豁免清單是它能上線的前提,不是選配。豁免 `jsc-assist:*`(啟動助理本身就是一次技能呼叫,少了它整組鎖死)、`jsc-hooks:repair`、`jsc-hooks:hooks-install`、`jsc-cli:doctor`、`jsc-cli:setup`、`jsc-cli:deploy`、`jsc-gitea:wiki`、`jsc-ask:ask`、`jsc-git:commit`、`jsc-git:pr`、`jsc-cli:models`。清單認技能名不認呼叫鏈,後五支是為了讓前六支走得完才補進來的,其中 `jsc-gitea:wiki` 最容易漏:巡檢一輪要先把結果寫進 `MONITOR_{HASH}` 才寫心跳,擋了它就變成「沒心跳 → 擋 wiki → 巡檢不完 → 還是沒心跳」自己咬住自己。逃生門 `JSC_ASSISTANT_GATE=off`,判斷擺在載入 `lib.sh` 之前——`lib.sh` 讀不到時 sh 回 2 等於無聲擋下每一次呼叫,逃生門也會跟著跑不到 |
| `hooks/skill-usage.sh` | PostToolUse(Skill) | 記錄技能使用與呼叫鏈到 `$JSC_HOME/usage/*.jsonl`,供 `jsc-log:stats` 統計 |
| `hooks/sdlc-gate.sh` | UserPromptSubmit | SDLC 階段能力標籤閘門與模型鎖:`lock {stage}` 由 jsc-sdlc 階段技能呼叫,從 transcript 讀出實際模型 id 比對該階段必要標籤(`$JSC_HOME/model-tags.tsv`),不符就拒絕上鎖;`check` 在模型不符時以 exit 2 擋下該輪提示(其他 hook 一律 exit 0,此處是刻意例外);`unlock` 為逃生門 |
| `hooks/comment-scope.sh` | UserPromptSubmit、PostToolUse(Write、Edit、MultiEdit)、codex `notify`、kiro `userPromptSubmit`、`tools/jsc-wrap.sh` 收尾 | 程式碼註解不得夾帶文件相關資訊與審查流程痕跡,共三種模式。`prompt`:在每次提示注入規則摘要(禁止項與白名單各一行),五個 CLI 都接得到。無參數:寫檔後的逐檔掃描,從 stdin JSON 取 `file_path`(或環境變數 `JSC_CHANGED_FILE`),只有 claude 的 PostToolUse 接得上。`sweep [dir]`:掃整個 git 工作區這次改過的所有檔案,給沒有 post-tool hook 的四個 CLI 用,找不到 git 就安靜 exit 0。掃描時機每個 CLI 不同——claude 逐檔即時(PostToolUse)、codex 每輪結束(`notify`)、kiro 每輪提示送出時(`userPromptSubmit`,掃的是上一輪寫的檔)、copilot 與 antigravity 只有工作階段結束時由 `tools/jsc-wrap.sh` 收尾掃一次。兩種掃描模式都只看 `git diff HEAD` 的新增行、不翻舊帳,命中就把警告與最多三行證據送到 stderr 並以 exit 2 交回模型就地修正(不擋寫入,檔案已經寫好了)。markdown、純文字、資料檔與二進位檔一律跳過。只實作可用樣式判定的項目,專案代號、客戶名稱這類判不出來的交給 `/jsc-review:code-review`。規則正文的唯一來源在 `jsc-review` 的 `references/comment-scope.md`,本存取庫不留副本。逃生門 `JSC_COMMENT_SCOPE=off` |
| `hooks/lang-guard.sh` | UserPromptSubmit、PostToolUse(Write、Edit、MultiEdit)、codex `notify`、kiro `userPromptSubmit`、`tools/jsc-wrap.sh` 收尾 | 所有非程式碼輸出一律繁體中文、UTF-8、無亂碼、無簡體字,共三種模式。`prompt`:在每次提示注入規則摘要(適用範圍與自我檢查各一行),五個 CLI 都接得到。無參數:寫檔後的逐檔掃描,從 stdin JSON 取 `file_path`(或環境變數 `JSC_CHANGED_FILE`),只有 claude 的 PostToolUse 接得上。`sweep [dir]`:掃整個 git 工作區這次改過的所有檔案,給沒有 post-tool hook 的四個 CLI 用,找不到 git 就安靜 exit 0。接線位置與掃描時機跟 `comment-scope.sh` 完全一樣,見下面那張表。偵測三項:簡體字(字表在 `hooks/simplified.txt`,讀不到就安靜跳過這一項)、亂碼(U+FFFD 替代字元與雙重編碼殘骸)、非 UTF-8 編碼(用 `iconv` 判定,沒有 `iconv` 就跳過)。三項都掃整個檔案、不只掃註解行,`.md` 與純文字檔照掃——那些正是「非程式碼輸出」的主場,這兩點跟 `comment-scope.sh` 刻意不同。掃描深度仍只看 `git diff HEAD` 的新增行、不翻舊帳,命中就把警告與最多三行證據送到 stderr 並以 exit 2 交回模型就地修正(不擋寫入)。二進位檔(只認 NUL 位元組)與 `*.lock`、`*.min.js`、`*.map` 這類產生檔跳過;`hooks/simplified.txt`、`hooks/ste100-guard.sh`、`hooks/lang-guard.sh` 也跳過,那三份檔案裡的簡體字與亂碼樣本是被討論的對象,不是被使用。規則正文的唯一來源在 `jsc-meta` 的 `references/ste100.md`。逃生門 `JSC_LANG_GUARD=off` |
| `hooks/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` 不受它影響,清紀錄擋不到任何人) |
Claude 由 `hooks/hooks.json` 自動接線;其他 CLI 用 `hooks-install` 技能接線、改裝包裝啟動器,或降級為規則檔。
Claude 由 `hooks/hooks.json` 自動接線十支 hook;其他 CLI 用 `hooks-install` 技能接線、改裝包裝啟動器,或降級為規則檔。寫進使用者設定的長期命令一律指向 `$JSC_HOME/current/jsc-hooks`,不指向帶版號的 plugin 快取目錄,也不指向開發存取庫。
### 各 CLI 的 pre-tool 接線位置
五支裡有四支都有能阻擋的 pre-tool hook。先前版本前置檢查與部署後重啟閘門在 codex、copilot、antigravity 上從未生效,原因是接錯位置——不是沒有位置可接。
| CLI | 接線位置 | 事件與 matcher | 阻擋形態 | verdict |
| --- | --- | --- | --- | --- |
| claude | `hooks/hooks.json` | `PreToolUse` matcher `Skill` | stderr 加 exit 2 | `wired` |
| codex | `hooks/codex-hooks.json`,由 `.codex-plugin/plugin.json` 的 `hooks` 鍵以**路徑字串**指過去 | `PreToolUse` matcher `Bash` | stderr 加 exit 2 | `wired` |
| copilot | `~/.copilot/settings.json` 的頂層 `hooks` 鍵(合併,不覆寫) | `PreToolUse` matcher `skill` | stderr 加 exit 2 | `wired` |
| antigravity | `~/.gemini/config/hooks.json` 的 `jsc` 段落 | `PreToolUse` matcher `^view_file$`(**Grouped**:`matcher` 加 `hooks` 包一層),加 `PreInvocation`(**Flat**) | stdout 的 `{"decision":"deny",...}` | `wired` |
| kiro | `~/.kiro/agents/jsc.json` 的 `hooks` 鍵,加上 `settings/cli.json` 的 `chat.defaultAgent=jsc` | `agentSpawn`、`userPromptSubmit`、`stop` | stdout 注入警告,擋不下來 | `degraded` |
四支非 claude 的 CLI,接線命令一律以 `JSC_CLI={代號}` 前綴自帶 CLI 代號。兩道閘門要先認出自己跑在哪一支上,才取得到 `hooks/skill-name.sh` 的技能名與 `hooks/deny.sh` 的阻擋形態;代號取不到時技能名解不出來,兩道閘門一律安靜放行,設定寫得完全正確、matcher 也對,卻一次都擋不下來。antigravity 更嚴重:代號不明時阻擋會退回結束碼形態,而它只認 stdout 的 deny JSON,等於判定擋下了、CLI 卻收不到拒絕。`tools/jsc-wrap.sh` 的別名雖然也會 export `JSC_CLI`,那只在使用者從互動 shell 走別名啟動時才成立,接線不靠它。
各列的原因,逐支講白:
- **codex** 沒有 `Skill` 這個工具,技能是模型自己用 `Bash` 讀 `SKILL.md` 載入的,所以 matcher 是 `Bash`。manifest 的 `hooks` 鍵跟 `skills` 一樣是**路徑字串**(Codex 的 plugin manifest 規格:`"hooks": "./hooks.json"`),寫成內嵌物件解不出來。那個鍵是**覆寫**,codex 只讀它指到的那一份,所以 `hooks/codex-hooks.json` 是從 `hooks/hooks.json` **推導**出來的——整份複製,只把 `"matcher": "Skill"` 換成 `"matcher": "Bash"`。手寫第二份會漏掉 `SessionStart`、`UserPromptSubmit`、`Stop` 那幾組,而且從此兩份各自漂移;推導的話 `hooks/hooks.json` 仍是唯一真實來源,那邊加一支 hook,這邊重跑接線就跟著有。Claude 讀的還是原本那份,一個位元組都沒動。
- **copilot** 有專用的 `skill` 工具,matcher 就是小寫的 `skill`。事件名只寫 PascalCase 一種:兩種大小寫都吃,兩種同時存在會把同一支 hook 跑兩次。它的 command hook 是 **fail-closed** 的(崩潰或任何非零結束碼都算拒絕,只有逾時 fail-open),所以那條路徑上的腳本錯誤處理要收乾淨。設定位置是 `settings.json` 的頂層 `hooks` 鍵,內嵌定義、以事件名當鍵(`copilot help config` 原文:「In global config.json these act as user-level hooks」,而 `config.json` 第一行自己就寫著 `// User settings belong in settings.json.`)。`$COPILOT_HOME/hooks/` 底下放的是 hook 要跑的**腳本**,不是設定——把設定寫進那裡,檔案好端端在、內容也對,copilot 一次都不會讀。指引檔同理要在 `$COPILOT_HOME` 底下,舊接線寫在 `~/.config/copilot/`,那個位置從來不會被載入。
那份 `settings.json` 同時裝著 `enabledPlugins`(十個 jsc plugin 的啟用狀態)與 `extraKnownMarketplaces`,弄壞會讓外掛整批失效,所以**只合併不覆寫**:寫前備份到 `$JSC_HOME/backup/`,只動 `hooks` 底下 jsc 自己那幾筆條目,寫後回讀核對最上層鍵與別人的 hook 條目,任何一項對不上就還原備份。`purge` 也只挑掉 jsc 那幾筆,第三方的 `SessionStart` 原樣留著。
- **antigravity** 沒有技能專用工具,系統提示要求模型用 `view_file` 讀 `SKILL.md`,所以 matcher 是 `^view_file$`;**錨點不能省**,省了會連 `view_file_outline` 一起命中。兩個事件的**結構不一樣**,不能寫成同一種形狀:`PreToolUse` 與 `PostToolUse` 是 **Grouped**(handler 要用 `matcher` 加 `hooks` 包一層),`PreInvocation`、`PostInvocation`、`Stop` 是 **Flat**(handler 物件直接排在陣列裡)。這是執行檔內嵌文件的「Supported Event Types」表寫死的,也是實測踩出來的——`PreToolUse` 寫成 Flat 時 antigravity **靜默丟棄整個事件**,hook 名稱照樣登記,連 `actions` 鍵都不生成,不報任何錯,設定檔看起來也完全正常。`matcher` 要留在 group 那一層,不是 handler 那一層。`plugin.json` 不能宣告 hook,只有 `hooks.json` 這一個位置,而那個檔案的最上層是一個安裝來源一個命名空間鍵,寫 `jsc` 那一個不會動到別人的段落。斜線指令與預載技能會把 `SKILL.md` 全文直接注入訊息、不產生工具呼叫,那條路由 `PreInvocation` 接住。**結束碼絕對不可靠**:語意兩邊文件都沒寫,擋人一律靠 stdout 的 deny JSON。
- **kiro** 的 hook 宣告只認 agent 設定檔的 `hooks` 鍵,合法事件只有 `agentSpawn`、`userPromptSubmit`、`preToolUse`、`postToolUse`、`stop` 五個,欄位是 `command`(必填)、`matcher`、`timeout_ms` 等,**沒有 `on`、`run`、`env`**。舊版把 `on`/`run`/`env` 寫在最上層,`kiro-cli agent validate` 一個錯都不報——**未知的頂層鍵被靜默忽略**——那份檔案卻什麼都沒做。檔案合法不等於接線生效,所以形狀要另外驗。
**`kiro-cli agent validate` 一律回結束碼 0**,合法、事件名非法、`hooks` 裡放 `on`/`run`、缺 `command`,四種情況的結束碼全是 0,錯誤只印在輸出(stderr)。拿結束碼當判準會做出一支永遠通過的檢查,跟這一輪在修的錯是同一類。判準是**輸出**:空的才算通過。輸出在講別的事(沒登入、憑證過期)算「驗不了」不是「驗不過」,照 fail-open 放行並據實說明——報成接線失敗的話,沒登入的機器會整批接不了線。
- **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` 別名在啟動當下開始;沒走別名啟動時,時間從第一輪回應算起。
### 驗證等級
「形狀」是那支 CLI 真的讀得懂這份設定;「觸發」是 hook 真的被叫用過。兩件事分開記,不得混為一談,回報也照這張表寫:
| CLI | 形狀 | 觸發 |
| --- | --- | --- |
| claude | 實證(`hooks.json` 長期在用) | 實證 |
| codex | **實證**:`~/.codex/config.toml` 的 `[hooks.state]` 以 `{事件}:{群組}:{條目}` 兩層索引登記,證明它解析的是 Grouped 結構;manifest 的 `hooks` 是路徑字串,出自執行檔內嵌的 `plugin-json-spec.md` | 未驗證 |
| antigravity | **實證**:接線後 `agy -p "/hooks"` 四條全載入,`matcher=^view_file$`,第三方段落完好 | 未驗證(本機對話 quota 用盡) |
| copilot | **未證**:`settings.json` 沒有唯讀的列出管道,位置與條目形態出自 `copilot help config` 的說明與機器上既有的第三方實例 | 未驗證 |
| kiro | **實證**:`kiro-cli agent validate` 通過(輸出為空),並以反證確認它真的在判別——`sessionStart`、`hooks` 裡放 `on`/`run`、缺 `command` 三種都會報錯 | **部分實證**:`agentSpawn` 與 `userPromptSubmit` 實跑觸發過;`preToolUse` 與 `stop` 未驗證(模型額度用盡,09/01 重置) |
還有兩項未驗證,一併記著:kiro 的 `resources` 兩層 glob **能不能真的修好技能可見性**沒有驗過(要模型跑得動才列得出技能);四支非 claude 的 CLI 上,`write-guard.sh` 三種模式與 SDLC 模型鎖仍未接線,那是還沒做,不是驗不過。
> 接線內容與腳本邏輯有 `wire-cli.sh smoke` 逐條斷言(技能名解析、四種阻擋形態、各 CLI 的接線形狀、fail-open、豁免放行、kiro 的注入路徑),觸發不在斷言範圍內。antigravity 的唯讀確認可跑 `agy -p "/hooks"` 與 `agy -p "/skills"`,兩個指令都不吃 quota;kiro 用 `kiro-cli agent validate --path {檔案}`,**看輸出不看結束碼**。
> `comment-scope.sh` 與 `lang-guard.sh` 五個 CLI 都掃得到,接的是同一批位置,但時機不同,不能當成五支一樣:
| CLI | 掃描時機 | 接在哪裡 |
| --- | --- | --- |
| claude | 逐檔即時,寫完哪個檔就掃哪個 | PostToolUse |
| codex | 每輪結束,掃整個 git 工作區 | `config.toml` 的根層 `notify` |
| kiro | 每輪提示送出時,掃整個 git 工作區(掃到的是上一輪寫的檔) | `~/.kiro/agents/jsc.json` 的 `userPromptSubmit` |
| copilot、antigravity | 工作階段結束時掃一次 | `tools/jsc-wrap.sh` 收尾 |
> 上表對 `comment-scope.sh` 與 `lang-guard.sh` 同時成立,兩支接在同一批位置。`sweep` 看的是 `git diff HEAD`,涵蓋範圍與 claude 一樣,差的是回饋速度:claude 當下就叫,其他四個要等到該輪或該階段結束。不在 git 工作區內時 `sweep` 安靜 exit 0,等於沒掃。規則提示(`prompt` 模式)在五個 CLI 都照樣寫進規則檔,三段(STE100、註解範圍、繁中編碼)共用同一個標記段落——晚一輪的警告,價值仍低於一開始就不要寫。判不出來的項目(專案代號、客戶名稱)一律交給 `/jsc-review:code-review` 第 2 組。
### 工作包歸屬狀態檔
`$JSC_HOME/wp/` 底下兩種檔案,都是純文字 `key=value`,一行一欄位,順序不拘,不認得的鍵一律忽略。格式壓到最簡,`jsc-hooks` 與 `jsc-sdlc` 兩邊各自實作也對得上。
| 檔案 | 欄位 | 範例(一行一欄位) |
| --- | --- | --- |
| 鎖檔 `{owner}-{repo}-{index}.pr` | `repo`、`index`、`wp`、`locked` | `repo=jsc/demo`、`index=12`、`wp=WP-03`、`locked=2026-08-27T02:00:00Z` |
| 領取檔 `{owner}-{repo}.claim` | `repo`、`wp`、`pr`、`analyze`、`claimed` | `repo=jsc/demo`、`wp=WP-03`、`pr=12`、`analyze=ANALYZE_1A2B3C4D`、`claimed=2026-08-27T02:00:00Z` |
寫檔的一律是 `jsc-sdlc`(領工作包時呼叫 `wp-claim`,開完 PR 呼叫 `wp-lock`),hook 只讀檔比對:把鎖檔的 `wp` 和同一個存取庫領取檔的 `wp` 取數字比一次,兩邊都有值且不相等,那支 PR 就不是這裡該處理的。
歸屬查不到就放行(exit 0,只注入提醒):沒有分析頁、`wp` 沒寫、領取檔不存在,三種都算這一類。理由與 `version-guard.sh` 一致——只擋確定違規,否則會把技能組維護自己鎖死。舊版鎖檔是單行 TSV(`{repo}<TAB>{index}<TAB>{上鎖時間}`,沒有工作包欄位),照樣讀得動,讀出來是查無歸屬。
### 部署後重啟狀態檔
`$JSC_HOME/restart-required.d/{CLI 代號}`(`JSC_HOME` 未設定時為 `~/.jsc`)**一支 CLI 一份**,檔名就是 CLI 代號(`claude`、`codex`、`copilot`、`antigravity`、`kiro`)。格式與工作包狀態檔同一套:純文字 `key=value`,一行一欄位,順序不拘,不認得的鍵一律忽略。`jsc-hooks` 與 `jsc-cli` 兩邊各自實作也對得上。
| 欄位 | 內容 | 範例 |
| --- | --- | --- |
| `at` | 部署收尾時間,UTC | `at=2026-08-27T02:00:00Z` |
| `mode` | 這次部署的模式,`install` 或 `update` | `mode=update` |
| `domains` | 這次更新到的 domain,空白分隔 | `domains=hooks cli meta` |
| `cli` | 執行部署的 CLI 代號,與檔名相同 | `cli=claude` |
一支 CLI 一份是為了修兩個實測抓到的洞:一台機器上五支 CLI 各自是獨立行程,各自載入自己記憶體裡的那一版。早先的單一檔案設計裡,並行部署會互相覆寫(後寫的把 `domains` 與 `cli` 蓋掉,欄位不再代表先寫的那一支),而且任一支 CLI 重啟就把五支的閘門一起解除,其餘四支沒重啟卻不再被擋,閘門在多 CLI 環境等於半失效。拆成一支一份之後,寫入、判定、清除三件事都只碰自己那一份。
寫檔的一律是 `jsc-cli:deploy`,經 `restart-gate.sh require {install|update} [{domain}...]` 落地,寫的是當前 CLI 那一份;取不到 CLI 代號或寫不進去都會 exit 2 並講明「這次部署沒有掛上重啟閘門」——沒寫成就沒有閘門,不能讓部署以為掛上了。清除的一律是 `session-timer.sh`:`start` 判定起始檔不存在(這個 session id 第一次開始)、或 `restart`(接不到 session id 的 CLI,每次工作階段開始都算新的)時,呼叫 `restart-gate.sh clear`,只刪呼叫端那一支自己那一份。判準留在 `session-timer.sh`、狀態檔留在 `restart-gate.sh`,兩邊都不抄對方那一半。
欄位只用在擋人訊息上。判定看的是「當前 CLI 那份檔案在不在」——檔案存在就是這一支還沒重啟過的證據,欄位缺了只讓訊息少幾個字。別支 CLI 那幾份一律不看。狀態檔讀不到、CLI 代號取不到、技能名取不到一律放行,理由與 `version-guard.sh` 相同。
`restart-gate.sh report` 一份狀態檔印一行,欄位以空白分隔,`domains` 可能含空白所以擺最後:
```
{CLI 代號} at={ISO 時間} mode={install|update} domains={domain 清單}
```
有幾行就代表有幾支 CLI 還沒重啟;一份都沒有就不印。欄位缺值時只留鍵名(例如 `domains=`)。第一欄印 `legacy` 的那一行代表下面說的舊格式單一檔案,它不屬於任何一支 CLI。
**舊檔相容(過渡用)。** 舊版把狀態寫進 `$JSC_HOME/restart-required` 單一檔案。改用狀態目錄的第一輪部署,機器上可能還留著那份舊檔,所以判定與清除都認它:舊檔存在就一律擋,視為「每一支 CLI 都有未重啟的部署」,擋人訊息會標明這是舊格式紀錄;`clear` 除了刪當前 CLI 那一份,也一併刪掉舊檔。取捨講白:`clear` 只在新工作階段被呼叫,呼叫到就代表確實有一支 CLI 重新啟動過了;舊檔沒有 per-CLI 資訊,留著會讓五支 CLI 一路被擋到有人手動刪,刪掉是唯一收斂的做法,代價是同一輪部署的其他 CLI 少擋一次,只影響改用狀態目錄的那一輪。這一段相容邏輯在所有機器都跑過一次寫狀態目錄的部署與重啟之後就可以整段移除,屆時舊檔不會再被寫出來。
> `version-guard.sh report` 是非 hook 的子指令:印出每個已安裝 jsc plugin 的
> 「{domain} {本機} {遠端} {落後|最新|超前|查詢失敗}」,最後一行 `behind {落後個數}`。
> 本機沒有 Claude 的 plugin 註冊檔時改印 `noregistry {路徑}` 再接 `behind 0`,
> 代表這台機器無法做版本檢查,跟「全部最新」是兩件事。查遠端版本走與 hook 同一份快取
> 與同一個 `JSC_VERSION_TTL`,但快取依 CLI 分開,一次部署不會為同一支 CLI 的每個 domain 重複打一輪網路。
> `jsc-cli:deploy` 用它決定要不要把「更新」設成推薦選項。
>
> `version-guard.sh recommend` 是同一份比對的結論版,只印一行 `recommend<TAB>update|none|unverifiable`,
> 永遠 exit 0。判定規則寫在腳本檔頭:任一 plugin 落後就 `update`;查不到本機註冊檔、
> 一列 domain 都沒有、或每一列都查詢失敗都是 `unverifiable`;其餘是 `none`。查詢失敗那幾列
> 不計入——查不到不等於最新。證據表要另外看就再呼叫一次 `report`,兩者刻意不混印,
> 呼叫端取第二欄的解析才不會被表格內容打亂。
### 狀態檔盤點
同一台主機上的不同 CLI 不共用檢查紀錄。真正與 CLI 無關的資料才共用。
| 路徑 | CLI 維度 | 設計判定 | 理由 |
| --- | --- | --- | --- |
| `$JSC_HOME/version-cache/{CLI 代號}/{domain}` | 有 | 正確 | 版本檢查由當前 CLI 觸發;不同 CLI 的安裝來源與載入版本可能不同,所以快取分開。舊路徑 `$JSC_HOME/version-cache/{domain}` 只作第一次相容讀取。 |
| `$JSC_HOME/errors/scan-state/{CLI 代號}-*.offset` | 有 | 正確 | 原生日誌位置與格式依 CLI 不同,掃描位移不能共用。 |
| `$JSC_HOME/usage/scan-state/{CLI 代號}-*.offset` | 有 | 正確 | 離線回填逐 CLI 掃不同日誌,位移檔以 CLI 前綴隔離。 |
| `$JSC_HOME/usage/skills.jsonl`、`$JSC_HOME/usage/chains.jsonl` | 每筆有 `cli` 欄位 | 正確 | 統計要能跨 CLI 彙整,也要能用欄位篩選。 |
| `$JSC_HOME/sessions/{CLI 代號}-{sid}.stage` | 有 | 正確 | session id 判不出時會退回 `default`,不分 CLI 就會共用同一支 `default.stage`,一支上的階段鎖會擋到另一支——那不只擋提示,`write-guard.sh` 的 `stage` 模式還會連寫檔一起擋。用檔名前綴不用子目錄,是因為外部工具以單層的 `sessions/*.stage` 盤點階段鎖。舊路徑 `$JSC_HOME/sessions/{sid}.stage` 只作往後相容的讀取,`unlock` 會一併清掉。 |
| `$JSC_HOME/sessions/` 的其餘檔案(`.start`、`.end`、`.lastskill`) | 以 session id 分 | 正確 | 工作階段 id 由 CLI 或包裝器提供,實質上分離;同 id 才代表同一工作階段。 |
| `$JSC_HOME/restart-required.d/{CLI 代號}` | 有 | 正確 | 重啟只清當前 CLI,那一支沒有重啟就不能被另一支解除。 |
| `$JSC_HOME/wp/` | 無 | 正確 | 工作包 PR 狀態屬於存取庫與 PR,不屬於 CLI;換 CLI 也要看到同一支未結清 PR。 |
| `$JSC_HOME/model-tags.tsv`、`$JSC_HOME/models.conf`、`$JSC_HOME/html-styles.conf` | 無 | 正確 | 這些是全機共用設定,不是檢查紀錄。 |
## 工具
| 腳本 | 用途 |
| --- | --- |
| `tools/jsc-wrap.sh` | 無 hook 系統 CLI 的包裝啟動器:匯出 `JSC_CLI`、`JSC_SESSION_ID`,前後接 `session-timer.sh`,結束時自動跑 `scan-logs.sh` 回填 |
| `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/wire-cli.sh` | 單一 CLI 的接線流程:`{cli}` 對應的設定編輯、包裝別名安裝、hook 檔建立,皆以 `<!-- jsc-hooks -->`(或 `# jsc-hooks`)標記整段取代,重跑不重複;以 `status=wired\|degraded\|skipped` 回報結果 |
| `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/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` 回報 |
## 失敗回報範本
這兩個模板給外層的 hook 失敗回報流程使用,不改動現有 hook 行為。
失敗時若要寫入 wiki,套用這兩個檔案即可。
這兩個模板是失敗回報頁的文案來源,由 `tools/report-error.sh` 填欄位後寫進 wiki。
用法:`tools/report-error.sh --hook {名稱} --exit {碼} --summary {摘要}`,錯誤輸出摘要走標準輸入。
| 範本 | 用途 |
| --- | --- |
| `templates/error-page.md` | 單筆 hook 異常頁 `ERROR_{HASH}`,記錄當次失敗的觸發條件、錯誤摘要與處理結果。 |
| `templates/error-contents.md` | 異常目錄 `ERROR_CONTENTS`,彙整所有異常頁,方便先看最新問題再往下追。 |
| `templates/error-contents.md` | 異常目錄 `ERROR_CONTENTS`,彙整所有異常頁,方便先看最新問題再往下追。版面是 H1 頁名加 `>` 引言,之後一筆一個 H2 區塊,標題就是異常頁頁名,欄位一行一條;範本只留一個示範區塊,用 `{佔位符}` 寫。落在目錄專用存取庫,一律 upsert 附加,連結一律寫成 `[{文字}]({連結})` 且先過 `link-check.sh` 驗過才寫。 |
## Skills 目錄
@@ -60,7 +193,11 @@ Claude 由 `hooks/hooks.json` 自動接線;其他 CLI 用 `hooks-install` 技
### `hooks-install`
把四支 hook 接線到所有已安裝的 CLI:偵測 CLI 後,逐一呼叫 `tools/wire-cli.sh {cli}` 完成接線(claude 由 `hooks.json` 自動接線,無需寫入)。copilot、antigravity 由該腳本裝上 `tools/jsc-wrap.sh` 包裝別名補上計時與用量回填(結束時自動跑 `tools/scan-logs.sh`),語言規則仍追加到各自的規則檔(以 `<!-- jsc-hooks -->` 標記整段取代,不重複追加)。codex、kiro 的 SDLC 模型鎖降級為技能步驟檢查,鎖檔仍由 SDLC 技能直接呼叫 `sdlc-gate.sh lock` 寫入。腳本以 `status=wired|degraded|skipped` 回報結果,供技能對照 verify 表。
把十支 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`
接手 `hooks-install` 或 `report-error.sh` 留下的失敗:讀 `ERROR_{HASH}` 與相關檔案後,把診斷拆給安裝中的其他 AI agent CLI 當 sub agent,彙整建議修正、實作 hooks repo 的修補、同步 manifest,最後以 `develop` 為基底開 PR。只有在真的沒有可用 CLI 時,才退回主 agent 自己判讀。
<!-- JSC-SKILLS:END -->
@@ -69,10 +206,30 @@ Claude 由 `hooks/hooks.json` 自動接線;其他 CLI 用 `hooks-install` 技
| 變數 | 用途 | 未設定時 |
| --- | --- | --- |
| `JSC_HOME` | Hook 資料目錄 | 預設 `~/.jsc` |
| `JSC_WIKI_REPO_ERROR` | 異常內容頁 `ERROR_{HASH}` 所在的 `{owner}/{repo}` | 退回 `JSC_WIKI_REPO`;還是解不出來就整支 `tools/report-error.sh` 安靜降級,不寫 wiki |
| `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 |
| `COPILOT_HOME` | copilot 的設定根目錄,`hooks/jsc-hooks.json` 與指引檔都寫在它底下 | 預設 `~/.copilot` |
| `KIRO_HOME` | kiro 的設定根目錄,`agents/jsc.json`、`settings/cli.json` 與 `resources` 的 `skill://` glob 都由它推導 | 預設 `~/.kiro` |
| `JSC_ANTIGRAVITY_HOOKS` | antigravity 的 hook 設定檔,`tools/wire-cli.sh` 只寫它的 `jsc` 段落 | 預設 `~/.gemini/config/hooks.json` |
| `JSC_COPILOT_INSTRUCTIONS` | copilot 指引檔位置。舊值 `~/.config/copilot/copilot-instructions.md` 從來不會被載入,不要再指回去 | 預設 `$COPILOT_HOME/copilot-instructions.md` |
| `JSC_ANTIGRAVITY_RULES` | antigravity 全域規則檔位置 | 預設 `~/.antigravity/AGENTS.md` |
| `JSC_CLAUDE_SETTINGS_DIR` | `tools/wire-cli.sh purge claude` 要清 `hooks` 鍵的設定檔目錄。指向一份複製品就能完整測過刪鍵邏輯,不必拿使用者本人的設定檔當測試場 | 預設 `~/.claude` |
| `JSC_VERSION_GUARD` | 設 `off` 完全略過版本前置檢查(離線工作用) | 啟用檢查 |
| `JSC_VERSION_TTL` | 遠端版本查詢的快取秒數 | 預設 600 |
| `JSC_CLI` / `JSC_SESSION_ID` / `JSC_SKILL` | 非 Claude CLI 接線時由 `tools/jsc-wrap.sh` 或接線設定提供 | 安靜降級 |
| `JSC_MODEL` | 非 Claude CLI 的目前模型,供 `sdlc-gate.sh` 比對;優先序在 transcript 實際值與 stdin `model` 之後 | 改讀 `~/.claude/settings.json`,再不行就安靜降級 |
| `JSC_WP_GATE` | 設 `off` 完全略過工作包 PR 閘門(`wp-check` 一律放行) | 啟用閘門 |
| `JSC_RESTART_GATE` | 設 `off` 完全略過部署後重啟閘門(`restart-gate.sh` 一律放行) | 啟用閘門 |
| `JSC_COMMENT_SCOPE` | 設 `off` 完全略過註解範圍檢查(`comment-scope.sh` 三種模式都直接結束) | 啟用檢查 |
| `JSC_LANG_GUARD` | 設 `off` 完全略過繁中與編碼檢查(`lang-guard.sh` 三種模式都直接結束) | 啟用檢查 |
| `JSC_WRITE_GUARD` | 設 `off` 完全略過寫入與提交閘門(`write-guard.sh` 三種模式都直接結束) | 啟用閘門 |
| `JSC_WRITE_GUARD_TTL` | `write-guard.sh review` 判定「稽核技能還在跑」的時效秒數 | 預設 900 |
| `JSC_ASSISTANT_HEARTBEAT_TTL` | `hooks/heartbeat.sh check` 判定心跳新鮮的門檻秒數。值不是正整數就退回預設值 | 預設 300(心跳週期 60 秒的五倍) |
| `JSC_ASSISTANT_GATE` | 設 `off` 完全略過助理運行閘門(`assistant-gate.sh` 一律放行)。判斷擺在載入 `lib.sh` 之前,那支函式庫讀不到時逃生門照樣有效 | 啟用閘門 |
| `JSC_READONLY` | 設 `1` 時 `tools/wire-cli.sh` 只准 `status` 與 `smoke`,`purge` 與接線一律拒絕並回 exit 6 | 四個用法都可執行 |
| `JSC_CHANGED_FILE` | 非 Claude CLI 要掃描的檔案路徑,代替 stdin JSON 的 `file_path`,供 `comment-scope.sh` 與 `lang-guard.sh` 使用 | 安靜降級,不掃描 |
| `JSC_TOOL_COMMAND` | 非 Claude CLI 要判定的 Bash 指令字串,代替 stdin JSON 的 `command`,供 `write-guard.sh commit` 使用 | 安靜降級,不判定 |
| `JSC_CLI` / `JSC_SESSION_ID` / `JSC_SKILL` / `JSC_TOOL_NAME` | 非 Claude CLI 接線時由 `tools/jsc-wrap.sh` 或接線設定提供,代替 stdin JSON 的 `session_id`、技能名與 `tool_name`(`hooks/skill-name.sh` 也收沒有前綴的 `SKILL`,而且環境變數蓋過負載解析;`write-guard.sh` 也收 `TOOL_NAME`)。`version-guard.sh` 與 `restart-gate.sh` 已經不篩工具名——五支 CLI 的工具名各不相同(`Skill`、`Bash`、`skill`、`view_file`),拿 Claude 那一個當通用條件會把另外四支整批擋在判定之外 | 安靜降級:`JSC_CLI` 取不到就當查不到 CLI,技能名取不到就由負載解析,兩邊都空就放行 |
| `JSC_MODEL` | `sdlc-gate.sh` 在當前 CLI 自己的模型來源都失敗時的人工覆寫模型 id,五支 CLI 都適用;回報會標明 `人工覆寫:JSC_MODEL` | 找不到可驗證模型來源時 `lock` 拒絕、`check` 擋下該輪提示,並列出已檢查來源與兩條逃生門 |
## 相關 domain
+208
View File
@@ -0,0 +1,208 @@
#!/usr/bin/env sh
# assistant-gate.sh — 助理運行閘門(PreToolUse,matcher: Skill)。
#
# 助理在背景跑,前景會話看不到它。技能組有一批規則要靠它落地:巡檢、監控頁、待辦簿。助理停著
# 的時候那些規則沒有人執行,可是技能照樣叫得起來,看起來一切正常。這道閘門負責讓「助理沒在跑
# 就繼續用技能」擋在門外。
#
# --- 這一版尚未接線 ---
#
# 本檔沒有寫進 hooks/hooks.json、hooks/codex-hooks.json 與 tools/wire-cli.sh,五支 CLI 一支都不
# 會叫到它。要驗證請直接跑 `sh hooks/assistant-gate.sh`,餵環境變數與標準輸入。
#
# 接線的前提有兩條,兩條都成立才可以接:
# 1. 助理已經在跑——`jsc-assist:assistant` 的 start 跑過,排程項目確實裝上了。
# 2. 心跳穩定——`heartbeat.sh check` 連續多輪都回 0。排程寫進 crontab 不等於 cron 在跑,
# WSL 預設不啟動 cron,那種機器上心跳一拍都不會有。
# 這兩條沒確認就接線,下一次技能呼叫就會被擋,而且擋的是整台機器的五支 CLI。
#
# 結束碼(hook 模式,本檔只有這一個模式):
# 0 放行,或已經以不靠結束碼的形態擋下。所以「exit 0」在這支腳本有兩種意思。
# 放行的情況:逃生門 JSC_ASSISTANT_GATE=off、負載裡解不出技能名、解出來的不是 jsc 技能、
# 命中下方豁免清單、`heartbeat.sh check` 回 0(心跳新鮮)、`heartbeat.sh check` 回 2、5、6
# (判不出來,理由見下方「心跳結束碼怎麼處置」)。
# 已擋下但不靠結束碼的情況:antigravity 的 stdout deny JSON、kiro 的注入警告。
# 2 擋下該次技能呼叫(claude、codex、copilot,以及認不得的 CLI 代號)。
# 擋下時的輸出形態由 deny.sh 依當前 CLI 決定,本檔只負責判定與訊息內容:
# claude、codex、copilot 走 stderr 加 exit 2;antigravity 走 stdout 的 deny JSON,結束碼
# 固定 0(那支 CLI 的結束碼語意沒有文件,不可靠);kiro 擋不下來,改印警告後 exit 0。
# 本檔沒有其他結束碼,也沒有子命令。帶進來的參數一律忽略。
#
# 輸入:技能名一律由 skill-name.sh 從當前 CLI 的負載解析,環境變數 JSC_SKILL、SKILL 優先,
# 規則與 version-guard.sh、restart-gate.sh 共用同一份。不另外篩工具名:工具名每支 CLI 都不
# 一樣(Skill、Bash、skill、view_file),拿 Claude 的那一個當通用條件會把另外四支整批擋在判定
# 之外。
#
# --- 這是整組技能唯一一道 fail-closed 閘門 ---
#
# 其餘 hook 的原則都是「資料不足就放行」:version-guard.sh 查不到版本放行,restart-gate.sh 讀不
# 到狀態檔放行。這一道相反——心跳不存在就是助理沒在跑,照要求要擋。心跳檔不存在本身就是證據,
# 不是「資料不足」。
#
# 代價講白:$JSC_HOME 寫不進去的時候(磁碟滿、權限壞、掛載掉了)助理寫不出心跳,這道閘門就把
# 全機器五支 CLI 的整組技能一起停掉。所以逃生門與豁免清單不是選配,是這道閘門能上線的前提:
# 逃生門讓人在閘門判錯時當場繞過去,不必先修好環境才動得了技能。
# 豁免清單讓「啟動助理」與「修環境」這兩條路徑永遠走得通,閘門才不會把解除自己的路徑鎖掉。
# 這兩樣任何一樣被拿掉或改窄,這道閘門就不可以接線。
#
# --- 心跳結束碼怎麼處置 ---
#
# 判定一律交給 `heartbeat.sh check`,本檔不自己讀心跳檔——判定寫兩份就會漂移,狀態跟訊息對不上。
# 那支腳本的六個結束碼逐碼處置如下:
# 0 新鮮。放行。
# 1 過期:心跳檔在、ts 也讀得到,但距現在已達門檻。助理跑過、現在停了。擋,訊息講「跑過但
# 停了」,並講出超過門檻幾秒。
# 2 腳本沒跑起來(`. lib.sh` 載入失敗時 sh 自己回這一碼)。放行——這是判定機制自己壞了,
# 不是「助理沒在跑」的證據。
# 3 心跳檔不存在。助理從沒啟動過。擋,訊息講「從沒啟動過」,要人去啟動。與 1 的處置不同:
# 使用者要做的事不一樣,併成同一句話會叫錯人去做錯事。
# 4 心跳檔在、ts 卻讀不出來(缺鍵、空值或不是數字)。**擋。** heartbeat.sh 檔頭寫明呼叫端
# 一律當成不新鮮處置,絕不可以退回當成新鮮。訊息與 1、3 都不同:那是檔案壞了,不是助理
# 停了,修法是先 stop 再 start 把心跳檔重建起來。
# 5 檔案系統操作失敗。**放行。** 理由見下一段。
# 6 用法錯誤(不認得的子命令,或一個都沒給)。放行——本檔固定送 check,收到 6 就代表
# heartbeat.sh 換了介面、或這支閘門叫錯了。那是這一邊的缺陷,不是助理的狀態。
#
# 5 為什麼選放行,不選擋:
# 一、`check` 這條路徑根本不產生 5。5 只由 `write` 與 `clear` 產出。從 check 收到 5,意思是
# 判定機制本身壞了,跟 2 與 6 同一類,不是「助理沒在跑」。
# 二、fail-closed 管的是「助理狀態」這一件事實:確定沒有新鮮心跳才擋。5 的意思是連事實都問
# 不出來,那不在這道閘門的職權裡。
# 三、最要緊的實務理由:5 正是磁碟滿或權限壞的訊號,而那一刻助理自己也寫不出心跳。擋下去的
# 結果是全機器整組技能鎖死,出路只剩豁免清單那幾支——可是那幾支同樣要寫 $JSC_HOME
# (接線狀態、用量、工作階段),環境壞著它們也修不動。磁碟壞掉要人去清磁碟,不是把技能
# 組鎖起來。
# 四、和 4 的差別在有沒有出路:4 是「檔案在、內容壞」,那是確定沒有可信心跳的證據,而且修法
# 就在豁免清單裡(stop 再 start),擋得起;5 是「檔案系統問不出來」,擋了沒有出路。
# 代價據實寫:磁碟壞掉時這道閘門會安靜放行,助理沒在跑也擋不到。那是刻意的取捨——這道閘門
# 不是磁碟監控,環境壞掉由 /jsc-cli:doctor 抓。
#
# 豁免(這些技能永遠放行,改動前想清楚後果):
# jsc-assist:* 啟動助理本身就是一次技能呼叫。少了這一條,助理永遠啟動不了,整組
# 技能鎖死。這是雞生蛋,清單裡最要緊的一條
# jsc-hooks:repair 修 hook 的唯一路徑。修 hook 的技能被 hook 擋下,就沒有任何方法把
# hook 修回來,閘門等於把解除自己的路徑一起鎖掉
# jsc-hooks:hooks-install 重新接線的唯一路徑。這道閘門接錯了要靠它拆掉
# jsc-cli:doctor 環境健檢。心跳寫不出來多半是環境問題,查不了就修不了
# jsc-cli:setup 修設定的唯一路徑,doctor 找到的東西要靠它落地
# jsc-cli:deploy 部署技能組。助理主體本身也是技能,裝不上就啟動不了
# jsc-gitea:wiki 助理巡檢一輪要先把結果寫進 MONITOR_{HASH},寫不成那一輪就不寫心跳
# (no record, no heartbeat)。擋了它,巡檢永遠跑不完、心跳永遠不出現,
# 助理再也啟動不了。jsc-hooks:repair 的第一步也是讀 ERROR_{HASH},讀
# 不到就中止
# jsc-ask:ask 上面幾支都要問使用者:assistant 要問做哪一個操作,setup 與 deploy
# 要問模式。擋了它,start 連要不要跑都問不出來
# jsc-git:commit jsc-hooks:repair 收尾要提交,擋了修好的東西進不了版本控制
# jsc-git:pr 同上,repair 規定收尾要對 develop 開 PR,擋了修復做一半
# jsc-cli:models jsc-cli:setup 遇到 model-tags.tsv 不見時要靠它補回來,擋了那一項修不完
#
# 清單認的是技能名,不是呼叫鏈:豁免技能轉呼叫的下一層若不在清單上,那一層照樣會被擋。後五支
# (wiki、ask、commit、pr、models)就是為了這件事補進來的——它們自己不是啟動助理的主體,但前
# 六支少了它們就走不完。jsc-gitea:wiki 是這裡面最容易漏的一支:只豁免 jsc-assist:* 看起來就夠
# 了,可是巡檢那一輪會轉呼叫 wiki 去寫監控頁,寫不成就不寫心跳,於是「沒心跳 → 擋 wiki →
# 巡檢不完 → 還是沒心跳」自己咬住自己,永遠解不開。
#
# 清單刻意不收 jsc-log:worklog 與 jsc-log:learn:那兩支是部署收尾的規則,跟「把助理啟動起來」
# 這條路徑無關。fail-closed 閘門的豁免清單只收解鎖路徑,收寬了這道閘門就等於沒有。
#
# 逃生門:JSC_ASSISTANT_GATE=off 完全略過這道閘門。
#
# 註:逃生門的判斷擺在載入 lib.sh 之前,這一點與 restart-gate.sh 不同。lib.sh 讀不到時 sh 會就地
# 結束並回 2,接在 PreToolUse 上就是無聲擋下每一次技能呼叫;這道閘門是 fail-closed 的,那個
# 結果方向上不算錯,但逃生門也跟著跑不到,人就沒有辦法自己繞過去。所以先看逃生門,再載入。
# hooks/skill-name.sh、hooks/deny.sh 與 hooks/heartbeat.sh 都以子行程呼叫,讀不到只會讓判定
# 降級成放行,不會反過來擋人。
# 逃生門先看。結束前把標準輸入讀乾淨:不讀就結束,宿主 CLI 會寫進斷掉的管線。
if [ "${JSC_ASSISTANT_GATE:-}" = "off" ]; then
[ -t 0 ] || cat >/dev/null 2>&1
exit 0
fi
HERE=$(dirname "$0"); . "$HERE/lib.sh"
hook_trace "assistant-gate ${1:-}"
read_stdin
# 技能名解析:交給 skill-name.sh。輸出固定是「{domain}<TAB>{技能名}」;用 awk 判 NF==2 才取值,
# 少一欄就當成解析不出來,免得沒有定位字元時 cut -f2 把整行當成技能名,拼出一個不存在的技能名
# 去比對豁免清單。
sn=$(printf '%s' "$STDIN_JSON" | sh "$HERE/skill-name.sh" "$(cli_name)" 2>/dev/null)
sn_domain=$(printf '%s\n' "$sn" | awk -F'\t' 'NF == 2 { print $1; exit }')
sn_name=$(printf '%s\n' "$sn" | awk -F'\t' 'NF == 2 { print $2; exit }')
[ -n "$sn_domain" ] && [ -n "$sn_name" ] || exit 0
skill="jsc-$sn_domain:$sn_name"
# 豁免清單(理由見檔頭)
case "$skill" in
jsc-assist:*|jsc-hooks:repair|jsc-hooks:hooks-install|jsc-cli:doctor|jsc-cli:setup|jsc-cli:deploy|jsc-gitea:wiki|jsc-ask:ask|jsc-git:commit|jsc-git:pr|jsc-cli:models)
exit 0 ;;
esac
# 心跳判定。補 </dev/null:heartbeat.sh 不讀標準輸入,但這裡的標準輸入已經被 read_stdin 收乾,
# 留著空管線給子行程沒有意義,明確關掉才不會有人往回接一條會等的路。
sh "$HERE/heartbeat.sh" check </dev/null 2>/dev/null
hb=$?
case "$hb" in
0) exit 0 ;; # 新鮮
1|3|4) ;; # 過期、不存在、時間戳壞掉:落到下面組訊息並擋下
*) exit 0 ;; # 2、5、6:判不出來就放行,逐碼理由見檔頭
esac
# 訊息細節一律取自 `heartbeat.sh report`,本檔不自己解析心跳檔:判定與訊息共用同一份探測結果,
# 兩邊各讀一次會出現「擋的理由」與「印的數字」對不上。
REPORT=$(sh "$HERE/heartbeat.sh" report </dev/null 2>/dev/null)
# report 是一行、欄位以空白分隔。除了 file 以外每一欄都不含空白,換行切開再取最穩。
rep_field() { # $1=鍵名(file 除外)
printf '%s' "$REPORT" | tr ' ' '\n' | sed -n "s/^$1=//p" | head -n1
}
# file 擺在最後,路徑可能含空白,所以取「file= 之後的全部」。
rep_file() {
printf '%s' "$REPORT" | sed -n 's/.*[[:space:]]file=//p'
}
hb_age=$(rep_field age)
hb_ttl=$(rep_field ttl)
hb_pid=$(rep_field pid)
hb_file=$(rep_file)
[ -n "$hb_file" ] || hb_file="$JSC_HOME/assistant/heartbeat"
# 超過門檻幾秒。兩個值都是純數字才算,算不出來就不印那一段——寧可少講一個數字,也不要印出
# 算壞的值。
hb_over=""
case "$hb_age$hb_ttl" in
''|*[!0-9]*) ;;
*) hb_over=$(( hb_age - hb_ttl )) ;;
esac
# 三種狀態的第一句話各寫一份。使用者要做的事不一樣:沒啟動過的要去啟動,跑過停了的要去查為
# 什麼停,檔案壞了的要去重建。併成同一句就會叫錯人做錯事。
case "$hb" in
3) first=$(printf '[jsc][助理閘門][ERR]:助理沒有在跑。心跳檔 %s 不存在,助理從沒啟動過。技能 /%s 這一次呼叫已擋下。' \
"$hb_file" "$skill") ;;
1) first=$(printf '[jsc][助理閘門][ERR]:助理跑過,現在停了。上次心跳是 %s 秒前,門檻 %s 秒,已經超過門檻 %s 秒(寫入者 pid=%s,心跳檔 %s)。技能 /%s 這一次呼叫已擋下。' \
"${hb_age:-不明}" "${hb_ttl:-不明}" "${hb_over:-不明}" "${hb_pid:-不明}" "$hb_file" "$skill") ;;
*) first=$(printf '[jsc][助理閘門][ERR]:助理狀態判不出來。心跳檔 %s 在,但 ts 欄位缺了、是空的、或不是數字——檔案壞了,不是助理停了。一律當成沒有心跳處置。技能 /%s 這一次呼叫已擋下。' \
"$hb_file" "$skill") ;;
esac
# 第二句:怎麼把助理弄回來。三種狀態的做法也不同。
case "$hb" in
3) second='啟動助理:/jsc-assist:assistant,操作選 start。它會先跑一輪巡檢,把結果寫上監控頁,再把排程項目裝起來;心跳是那一輪跑完才寫的。' ;;
1) second='重新啟動助理:/jsc-assist:assistant,操作選 start;先用 status 看排程項目還在不在。排程寫進 crontab 不等於 cron 在跑,WSL 預設不啟動 cron,那種機器要先 sudo service cron start,而且每次重開機都要再跑一次。' ;;
*) second='重建心跳:/jsc-assist:assistant,操作先選 stop 再選 start。stop 會把壞掉的心跳檔刪掉,start 跑完一輪巡檢才寫出新的一份。' ;;
esac
# 擋人輸出交給 deny.sh:形態依 CLI 而定,本檔只組訊息。四段訊息整段走同一條管線送過去,
# antigravity 那一支才有辦法把它們壓成同一個 reason 字串;分次呼叫會做出好幾份 deny JSON,
# 那支 CLI 只認第一份,後面三段使用者永遠看不到。
{ printf '%s\n' "$first"
printf '%s\n' "$second"
printf '仍可使用:/jsc-assist:*、/jsc-hooks:repair、/jsc-hooks:hooks-install、/jsc-cli:doctor、/jsc-cli:setup、/jsc-cli:deploy、/jsc-cli:models、/jsc-gitea:wiki、/jsc-ask:ask、/jsc-git:commit、/jsc-git:pr(啟動助理與修環境這兩條路徑要永遠走得通,包括它們轉呼叫的下一層)\n'
printf '確定要略過閘門:JSC_ASSISTANT_GATE=off\n'
# 這條路徑是「已經擋下」,但輸出形態依 CLI 而定:antigravity 走 stdout 的 deny JSON、
# kiro 只印警告,兩者的結束碼都是 0。不覆寫狀態的話,事件流會把擋下記成放行。
JSC_EVENT_STATUS=blocked
} | sh "$HERE/deny.sh" "$(cli_name)"
exit $?
+129
View File
@@ -0,0 +1,129 @@
{
"hooks": {
"SessionStart": [
{
"hooks": [
{
"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'"
},
{
"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\"'"
}
]
}
],
"UserPromptSubmit": [
{
"hooks": [
{
"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/ste100-guard.sh\"'"
},
{
"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/sdlc-gate.sh\" check'"
},
{
"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/sdlc-gate.sh\" wp-check prompt'"
},
{
"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/comment-scope.sh\" prompt'"
},
{
"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/lang-guard.sh\" prompt'"
}
]
}
],
"Stop": [
{
"hooks": [
{
"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\" mark'"
}
]
}
],
"SessionEnd": [
{
"hooks": [
{
"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\" mark'"
}
]
}
],
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"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/restart-gate.sh\"'"
},
{
"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/version-guard.sh\"'"
},
{
"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/sdlc-gate.sh\" wp-check skill'"
}
]
},
{
"matcher": "Write|Edit|MultiEdit",
"hooks": [
{
"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/write-guard.sh\" stage'"
},
{
"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/write-guard.sh\" review'"
}
]
},
{
"matcher": "Bash",
"hooks": [
{
"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/write-guard.sh\" commit'"
}
]
}
],
"PostToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"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/skill-usage.sh\"'"
}
]
},
{
"matcher": "Write|Edit|MultiEdit",
"hooks": [
{
"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/comment-scope.sh\"'"
},
{
"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/lang-guard.sh\"'"
}
]
}
]
}
}
+160
View File
@@ -0,0 +1,160 @@
#!/usr/bin/env sh
# comment-scope.sh — 程式碼註解不得夾帶文件相關資訊與暫時性流程資訊(hook > prompt 的強制層)。
# 規則正文的唯一來源:jsc-review 的 references/comment-scope.md。本腳本只實作可用樣式判定的項目;
# 專案代號、客戶名稱這類無法用樣式判定的,交給 jsc-review:code-review 第 2 組人工審查。
#
# 用法:
# comment-scope.sh prompt 注入規則摘要(UserPromptSubmit 或規則檔取文字用)
# comment-scope.sh 掃描剛寫入的單一檔案(PostToolUse)
# comment-scope.sh sweep [dir] 掃描整個工作區這次改過的所有檔案(沒有 post-tool hook 的 CLI 用)
#
# 為什麼要有 sweep:只有 claude 接得到 PostToolUse,逐檔精準掃得到。codex 只有每輪結束的
# notify、kiro 只有 userPromptSubmit、copilot 與 antigravity 只有包裝別名,這四個都拿不到
# 「剛剛寫了哪個檔」,只能改成掃整個工作區的 git diff。時機晚一點,涵蓋範圍一樣。
#
# 輸入相容:
# Claude: PostToolUse 的 stdin JSON,取 tool_input.file_path。
# 其他 CLI: 環境變數 JSC_CHANGED_FILE。
# 兩者都取不到就安靜降級(exit 0)。
#
# 掃描範圍:檔案在 git 工作區內就只掃 `git diff HEAD` 的新增行,不翻舊帳;
# 不在 git 內或檔案尚未追蹤才整檔掃描。sweep 一律只看 git diff。
#
# 結束碼:0=沒命中或資料不足;2=命中,訊息走 stderr 交回模型自行修正(不擋寫入,檔案已經寫好了)。
# 逃生門:JSC_COMMENT_SCOPE=off。
set -u
. "$(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
if [ "${1:-}" = "prompt" ]; then
echo "[jsc] 程式碼註解只寫「為什麼這樣寫」,不寫「這件事記在哪份文件」。禁止寫入:議題與 PR 編號、變更單編號、wiki 頁編號與網址、工作包編號、TDD 待辦編號、使用者故事與驗收條件與測試案例編號、規格章節與稽核項編號、commit hash 與分支名、版本號與 Sprint 與里程碑、人名與認領者與 @ 提及、工時估算、專案代號與客戶名稱、產生來源署名、外部文件連結、審查流程痕跡。"
echo "[jsc] 註解可以寫:日期與時間戳、需求變更歷程、RFC 與 ISO 標準編號、CVE 編號、第三方套件 issue 連結、授權標頭與 SPDX 標記、@deprecated 與 @since 等語言原生標記。命中禁止項就把編號指向的內容搬進註解,再刪掉編號。規則正文見 jsc-review 的 references/comment-scope.md。"
exit 0
fi
hit() { # $1=樣式 $2=說明;命中就把說明與最多三行證據印到 stdout
m=$(printf '%s\n' "$cleaned" | grep -nE "$1" | head -n 3)
[ -n "$m" ] || return 0
printf ' %s\n' "$2"
printf '%s\n' "$m" | sed 's/^/ /'
}
scan_file() { # $1=檔案路徑;命中就把報告印到 stdout 並回傳 1,沒命中回傳 0
f=$1
[ -f "$f" ] || return 0
# 非程式碼檔不受本規則限制:markdown、純文字、資料檔沒有「程式碼註解」。
case "$f" in
*.md|*.markdown|*.txt|*.rst|*.json|*.csv|*.tsv|*.svg|*.lock|*.log|*COMMIT_EDITMSG) return 0 ;;
esac
# 二進位檔跳過。只認 NUL 位元組——拿「非可列印字元」當判準會把所有含中文的檔案誤判成二進位。
raw=$(head -c 1024 "$f" 2>/dev/null | wc -c)
txt=$(head -c 1024 "$f" 2>/dev/null | LC_ALL=C tr -d '\000' | wc -c)
[ "$raw" = "$txt" ] || return 0
d=$(dirname -- "$f")
if git -C "$d" rev-parse --is-inside-work-tree >/dev/null 2>&1 &&
git -C "$d" ls-files --error-unmatch -- "$f" >/dev/null 2>&1; then
lines=$(git -C "$d" diff HEAD -- "$f" 2>/dev/null | sed -n 's/^+[^+]/&/p' | cut -c2-)
[ -n "$lines" ] || return 0
else
lines=$(cat "$f" 2>/dev/null)
fi
# 只留註解行:行首註解符號,或行中出現 // 與 # 的行尾註解。
comments=$(printf '%s\n' "$lines" | grep -E '^[[:space:]]*(//|#|--|\*|/\*|<!--|;|%)|[[:space:]](//|#)[[:space:]]' || true)
[ -n "$comments" ] || return 0
# 白名單先剪掉,再比對禁止樣式。剪掉而不是整行放行——同一行可能一半合規、一半違規。
cleaned=$(printf '%s\n' "$comments" | sed -E \
-e 's#SPDX-License-Identifier:[^[:space:]]*##g' \
-e 's#CVE-[0-9]{4}-[0-9]+##g' \
-e 's#(RFC|ISO|IEEE|ANSI|ECMA|UTF|SHA|MD|AES|RSA|HMAC|PBKDF|TLS|SSL|HTTP|BIG|EUC|JIS|GB|RS|IPV|X)-?[0-9]+(-[0-9]+)?##g' \
-e 's#@(deprecated|since|param|returns?|throws|type|typedef|example|see|link|inheritdoc|override|nullable|internal)##g' \
-e 's#https?://(github|gitlab|bitbucket)\.com/[^[:space:]]*##g' \
-e 's#https?://[^[:space:]]*[{<][^[:space:]]*##g' \
-e 's#[0-9]{4}[-/][0-9]{1,2}[-/][0-9]{1,2}##g')
out=$(
hit '(^|[^[:alnum:]_/])#[0-9]+' '議題編號(#123)'
hit '(^|[^[:alnum:]_])![0-9]+' 'PR、MR 編號(!45)'
hit '[A-Z]{2,6}-[0-9]{1,6}' '工作包、故事、驗收、測試案例、變更單、議題編號(前綴加流水號)'
# 頁名樣式:十五種頁型,尾段是 CONTENTS(目錄頁)、40 碼大寫十六進位(內容頁),
# 或尚未遷移的舊頁編號。舊頁那兩條要留著,不然舊頁編號會漏偵測。
# 舊頁為什麼有 H 開頭這一條:舊的短碼演算法只要首碼落在 0-9ABC 就改寫成 H 加原前 7 碼,
# 十六個十六進位首碼有十三個會命中,所以既有舊頁名大多是 H 開頭,只收 [0-9A-F]{8} 會漏掉。
# 這條式子在別處另有兩份各自獨立的定義,稽核時才比對一致。刻意不共用函式:hook 要能
# 自足執行,執行期相依別的 plugin 路徑,那條路徑一缺,整支 hook 就掃不動了。
hit '(QUESTION|PLAN|ANALYZE|DELIVER|MAINTAIN|REPO|LOG|LEARN|ERROR|CHECK|REPORT|SKILLSET|TOOLING|MONITOR|CONTENTS)_(CONTENTS|[0-9A-F]{8}|H[0-9A-F]{7}|[0-9A-F]{40})' 'jsc wiki 頁面編號'
hit '(todo|TODO|待辦)[[:space:]]*#?[0-9]+' 'TDD 待辦編號'
hit '([Ss]print|里程碑|[Mm]ilestone)[[:space:]]*[0-9]+' 'Sprint、里程碑編號'
hit '(^|[^[:alnum:].])v[0-9]+\.[0-9]+|版本[[:space:]]*v?[0-9]+\.[0-9]+' '版本號'
hit '(commit|提交|hash|SHA)[[:space:]:]*[0-9a-f]{7,40}' 'commit hash'
hit '(branch|分支)[[:space:]:]*[a-z]+/[a-z0-9-]+' '分支名稱'
hit '(^|[[:space:]])@[a-zA-Z][a-zA-Z0-9_.-]{2,}' '人名、認領者、@ 提及(含 @author)'
hit 'https?://[^[:space:]]*(wiki|confluence|atlassian|notion\.so|docs\.google|sharepoint)' 'wiki 或外部文件連結'
hit 'https?://[^[:space:]]*/(issues|pulls)/[0-9]+' '內部議題、PR 連結'
hit '([Gg]enerated (with|by)|Co-Authored-By|本檔(案)?由|AI (產生|生成|撰寫))' '產生來源署名'
hit '預估[[:space:]]*[0-9]+[[:space:]]*(小時|分鐘|人日|人天|天)|[0-9]+[[:space:]]*(人日|人天|工時)' '工時估算'
hit '(規格書|需求書|準則|規範|清單|guidelines)[^。]{0,8}第[[:space:]]*[0-9]+|(規格書|需求書|SRS)[^。]{0,6}[0-9]+(\.[0-9]+)+' '規格文件章節、稽核檢查項編號'
hit '([Cc]ode[[:space:]]+[Rr]eview|[Rr]eview[[:space:]]*(round|finding|findings|feedback|fix|status)|審查(流程|輪次|狀態))' '審查流程字樣'
hit '((審查|[Rr]eview|留言|修正)[^。]{0,12}(第[[:space:]]*[0-9一二三四五六七八九十]+[[:space:]]*輪|追加|後續)|第[[:space:]]*[0-9一二三四五六七八九十]+[[:space:]]*輪[^。]{0,12}(審查|[Rr]eview|留言|修正|追加|後續|檢查)|[Rr]ound[[:space:]]*#?[0-9]+)' '審查輪次描述'
hit '([Ff]inding|問題|缺陷)[[:space:]]*#?[0-9]+' '問題、發現、缺陷編號'
hit '([Hh]ermes|H[0-9]{4}|[Cc]ode[ -]?[Rr]eview[[:space:]]*[Bb]ot)' '審查者代稱或工具名'
hit '(真缺陷|BLOCKING|已解決|未解決)' '審查狀態標籤'
)
[ -n "$out" ] || return 0
printf '%s\n' "$f"
printf '%s\n' "$out"
return 1
}
advice() {
printf ' 修法:把編號指向的內容搬進註解,然後刪掉編號。搬不動就代表那件事不該用註解表達。\n'
printf ' 規則正文與白名單見 jsc-review 的 references/comment-scope.md。誤判時用 JSC_COMMENT_SCOPE=off 關閉。\n'
}
if [ "${1:-}" = "sweep" ]; then
target=${2:-.}
[ -d "$target" ] || exit 0
root=$(git -C "$target" rev-parse --show-toplevel 2>/dev/null) || exit 0
[ -n "$root" ] || exit 0
changed=$(git -C "$root" diff --name-only HEAD 2>/dev/null)
[ -n "$changed" ] || exit 0
# 報告累積在暫存檔:迴圈跑在管線的子行程裡,變數帶不回來。
tmp=${TMPDIR:-/tmp}/jsc-comment-scope.$$
: > "$tmp" 2>/dev/null || exit 0
printf '%s\n' "$changed" | while IFS= read -r rel; do
[ -n "$rel" ] || continue
scan_file "$root/$rel" >> "$tmp" 2>/dev/null
done
if [ -s "$tmp" ]; then
{
printf '[jsc] 工作區有註解夾帶文件相關資訊或審查流程痕跡,請就地修正:\n'
sed 's/^/ /' "$tmp"
advice
} >&2
rm -f "$tmp"
exit 2
fi
rm -f "$tmp"
exit 0
fi
read_stdin 2>/dev/null || STDIN_JSON=""
file=$(json_str file_path 2>/dev/null || true)
[ -n "$file" ] || file="${JSC_CHANGED_FILE:-}"
[ -n "$file" ] || exit 0
report=$(scan_file "$file") && exit 0
{
printf '[jsc] 程式碼註解夾帶了文件相關資訊或審查流程痕跡,請就地修正:\n'
printf '%s\n' "$report"
advice
} >&2
exit 2
Executable
+57
View File
@@ -0,0 +1,57 @@
#!/usr/bin/env sh
# deny.sh — 產出各 CLI 認得的「擋下這一次呼叫」輸出。
#
# 用途:阻擋形態每支 CLI 都不一樣,判定卻是同一件事。形態留這一份,閘門只管判定:
# 各自留一份輸出邏輯,改了一支忘了另一支,就會出現「判定擋下、CLI 卻沒收到拒絕」的無聲失效。
#
# 用法:deny.sh {cli} [訊息...]
# 訊息從參數來,沒給參數就讀標準輸入。兩者都空就送一句預設訊息,不會產出空的拒絕。
#
# 各 CLI 的阻擋形態:
# claude、codex、copilot 訊息寫 stderr,結束碼 2 就是拒絕
# antigravity stdout 印 {"decision":"deny","reason":"..."}。
# 結束碼語意兩邊文件都沒寫,**絕對不可靠**,所以固定回 0,
# 拒絕整個靠那一行 JSON。也因此 stdout 只准有那一行,
# 呼叫端要給人看的字一律走 stderr。
# kiro 擋不下來。技能叫用是 agent 內部請求,preToolUse 攔不到,
# userPromptSubmit 的非零結束碼也不會擋下那一輪。
# 唯一可用的介入是 stdout 注入,所以改印警告並回 0。
#
# 結束碼:
# 2 拒絕已經送出(claude、codex、copilot,以及認不得的 CLI 代號)。呼叫端直接把它當自己的結束碼。
# 0 拒絕已經送出,但形態不靠結束碼(antigravity 的 stdout JSON),或這支 CLI 根本擋不下來(kiro)。
# 本檔沒有其他結束碼。認不得的代號不另立一種:五支裡有三支是 stderr 加 2,未知代號走這個保守預設,
# 比靜靜放行安全。
set -u
CLI="${1:-}"
shift 2>/dev/null || true
if [ "$#" -gt 0 ]; then
MSG="$*"
else
MSG=""
[ -t 0 ] || MSG=$(cat 2>/dev/null || true)
fi
[ -n "$MSG" ] || MSG='[jsc]:這次呼叫已被 jsc 閘門擋下。'
# 把訊息壓成一個合法的 JSON 字串值:反斜線與雙引號先跳脫,換行與定位字元改成跳脫序列。
# 直接把原文塞進 JSON 會做出解不開的負載,antigravity 收到壞 JSON 等於沒收到拒絕。
json_reason() {
printf '%s' "$MSG" \
| sed 's/\\/\\\\/g; s/"/\\"/g; s/ /\\t/g' \
| awk '{ printf "%s%s", sep, $0; sep = "\\n" } END { printf "\n" }'
}
case "$CLI" in
antigravity)
printf '{"decision":"deny","reason":"%s"}\n' "$(json_reason)"
exit 0 ;;
kiro)
printf '%s\n' "$MSG"
printf '[jsc]:kiro 擋不下技能叫用(CLI 限制),上面這段只是警告,請自己先處理完再繼續。\n'
exit 0 ;;
*)
printf '%s\n' "$MSG" >&2
exit 2 ;;
esac
+177
View File
@@ -0,0 +1,177 @@
#!/usr/bin/env sh
# heartbeat.sh — 助理心跳檔的讀寫工具。
#
# 助理在背景跑,前景會話看不到它。心跳檔就是它還在跑的唯一證據:助理主體與系統排程每 60 秒
# 寫一次,`jsc-assist:assistant` 與擋人訊息讀這一份,判斷助理在不在。
#
# 這支不是 hook,不接在任何事件上,只被工具端呼叫,所以它擋不到任何人。它只做判定,擋不擋
# 由呼叫端自己決定——助理不參與閘門判定,只負責維持心跳。
#
# 用法(四個子命令都不讀標準輸入,理由見下方「不讀標準輸入」):
# heartbeat.sh write 寫入心跳檔,四個欄位一次寫齊,目錄不存在就建。由助理主體與系統
# 排程呼叫。
# heartbeat.sh check 判定心跳新不新鮮。什麼都不印,結果只在結束碼;要細節請跑 report。
# heartbeat.sh report 印一行心跳現況,格式見下方「report 輸出格式」。
# heartbeat.sh clear 刪除心跳檔。由助理的 stop 呼叫。檔案不存在也算成功。
#
# 結束碼:
# 0 check 判定新鮮(state=fresh);write 寫成功;clear 清完,檔案已經不在;report 印完
# 1 check:心跳檔在、ts 也讀得到,但距現在已達門檻(state=stale)。助理跑過,現在停了。
# 訊息要叫人去查助理為什麼停
# 2 保留給「腳本沒跑起來」:`. lib.sh` 載入失敗時 sh 自己回這一碼(見最下方註)。判定路徑
# 刻意不用 2,兩者才分得開
# 3 check:心跳檔不存在(state=absent)。助理從沒啟動過。處置與 1 不同,訊息要叫人去啟動
# 4 check:心跳檔在、ts 卻讀不出來(state=invalid,缺鍵、空值或不是數字)。檔案壞了,不是
# 助理停了。呼叫端一律當成不新鮮處置,絕不可以退回當成新鮮
# 5 檔案系統操作失敗:write 寫不進去(磁碟滿、權限壞、目錄建不起來),或 clear 刪不掉、
# 檔案還在。這是嚴重狀況——助理沒有心跳就會被自己那道閘門擋掉,所以一定要吵出來,
# 不能安靜當成成功
# 6 用法錯誤:不認得的子命令,或一個子命令都沒給。刻意不與上面任何一種正常狀態共用碼,
# 共用了呼叫端就分不出「助理沒在跑」與「這支腳本被叫錯」
#
# --- 只看 ts,絕不看 pid 存活 ---
#
# 新鮮的判準只有一條:心跳檔存在,而且 ts 距現在小於門檻秒數。pid 一律不拿來判定。
# 一台機器上五支 CLI 各自是獨立行程,助理也可能跑在容器裡,彼此看不到對方的 pid:拿
# `kill -0` 去問,看不到的行程一律回失敗,活著的助理會被判成停了;pid 還會被回收,別人的
# 行程剛好接到同一個號碼,就反過來把停掉的助理判成還在跑。兩種誤判都不會報錯,查起來也沒有
# 線索。pid 只寫進檔案當擋人訊息的線索,讓人自己去查那個行程。
#
# --- 門檻為什麼是 300 ---
#
# 心跳週期是 60 秒,門檻取五倍。一次網路或磁碟卡頓讓某一拍沒寫成,後面還有四拍補得回來,
# 不會誤判成助理停了。門檻用環境變數 JSC_ASSISTANT_HEARTBEAT_TTL 覆寫,單位是秒;值不是
# 正整數就退回 300——環境變數打錯字不該讓判定整個歪掉。
#
# --- 不讀標準輸入 ---
#
# 這支是被工具端呼叫的腳本,四個子命令一律不讀 stdin。理由與 restart-gate.sh 的子命令相同:
# read_stdin 在標準輸入是管線又沒人關閉時會一直等,工具端呼叫就整支卡死。
#
# --- 狀態檔格式 ---
#
# $JSC_HOME/assistant/heartbeat(JSC_HOME 未設定時為 ~/.jsc),純文字 key=value,一行一欄位,
# 順序不拘,不認得的鍵一律忽略:
# ts={epoch 秒數} 寫入當下的時間。判定只看這一欄
# pid={行程 id} 寫入者的行程 id。只當擋人訊息的線索
# cli={CLI 代號} 寫入者是哪一支 CLI,取自 cli_name()
# session={id} 寫入者的工作階段 id,取自 session_id()
# 寫入走「先寫暫存檔、再改名」:改名是原子的,讀的那一端永遠讀到完整的一份。直接覆寫的話,
# 剛好讀到寫一半的檔案會少掉 ts,判定就從 fresh 掉成 invalid,助理明明活著卻被說成壞了。
#
# --- report 輸出格式 ---
#
# 固定一行,鍵的順序固定,欄位以空白分隔。鍵一個都不會少,缺值就只留鍵名,呼叫端不必判斷
# 有沒有這一欄。路徑擺最後,路徑含空白時才不會把後面的欄位吃掉:
# state={fresh|stale|invalid|absent} ts={epoch} age={秒} ttl={秒} pid={} cli={} session={} file={路徑}
# state 的四種值與 check 的結束碼一一對應:fresh=0、stale=1、absent=3、invalid=4。
# 例(心跳新鮮):
# state=fresh ts=1756684800 age=42 ttl=300 pid=31415 cli=claude session=a1b2c3 file=/root/.jsc/assistant/heartbeat
# 例(心跳檔不存在):
# state=absent ts= age= ttl=300 pid= cli= session= file=/root/.jsc/assistant/heartbeat
# ts 不是數字時 ts 與 age 兩欄都印空的:那個值是垃圾,原樣印出來會夾帶空白把欄位切歪。
#
# 註:本檔以 `. "$HERE/lib.sh"` 載入共用函式,沒有接 `|| true`。載入失敗時 sh 會就地結束並回
# 2。這一點的後果與 restart-gate.sh 不同:那支接在 PreToolUse 上,回 2 等於無聲擋下每一次
# 技能呼叫;這支沒接任何 hook,回 2 只會讓呼叫端收到「心跳判不出來」,擋不到任何人。
HERE=$(dirname "$0"); . "$HERE/lib.sh"
hook_trace "heartbeat ${1:-}"
# 這支永遠不讀標準輸入,但 session_id() 會去看 STDIN_JSON。先設成空字串,讓它直接走環境
# 變數那條路,不會因為變數沒定義而拿到不確定的值。
STDIN_JSON=""
STATE_DIR="$JSC_HOME/assistant"
STATE="$STATE_DIR/heartbeat"
DEFAULT_TTL=300
# 門檻秒數。環境變數不是正整數就退回預設值,理由見檔頭「門檻為什麼是 300」。
ttl() {
_t="${JSC_ASSISTANT_HEARTBEAT_TTL:-}"
case "$_t" in
''|*[!0-9]*) printf '%s' "$DEFAULT_TTL"; return 0 ;;
esac
if [ "$_t" -gt 0 ] 2>/dev/null; then printf '%s' "$_t"; else printf '%s' "$DEFAULT_TTL"; fi
}
# 從心跳檔取一個欄位;檔案讀不到或欄位不存在就不輸出。
field() { # $1=鍵名
[ -f "$STATE" ] && [ -r "$STATE" ] || return 0
sed -n "s/^$1=//p" "$STATE" 2>/dev/null | head -n1
}
# 判定心跳狀態,印出「{state}<TAB>{ts}<TAB>{age}」,後兩欄在 absent 與 invalid 時留空。
# check 與 report 共用這一份:兩邊各判一次就會漂移,狀態與訊息對不上。
probe() {
if [ ! -f "$STATE" ] || [ ! -r "$STATE" ]; then
printf 'absent\t\t\n'; return 0
fi
_ts=$(field ts)
case "$_ts" in
''|*[!0-9]*) printf 'invalid\t\t\n'; return 0 ;;
esac
# 去掉開頭的 0:POSIX 算術把 08 當八進位,會直接報錯,錯完 age 是空的,判定就整條歪掉。
while :; do
case "$_ts" in 0?*) _ts=${_ts#0} ;; *) break ;; esac
done
_age=$(( $(now_epoch) - _ts ))
# age 是負的代表 ts 在未來,那是時鐘偏移,不是助理停了,照樣算新鮮。
if [ "$_age" -lt "$(ttl)" ]; then _st=fresh; else _st=stale; fi
printf '%s\t%s\t%s\n' "$_st" "$_ts" "$_age"
}
usage() {
printf 'usage: heartbeat.sh {write|check|report|clear}\n' >&2
exit 6
}
case "${1:-}" in
write)
# 心跳檔的位置被目錄或別的東西佔住時要當場失敗。`mv` 遇到目標是目錄會把暫存檔搬進去,
# 搬得成功、心跳檔卻永遠不存在,寫的那一端拿到 0,讀的那一端說助理沒啟動過。
if [ -e "$STATE" ] && [ ! -f "$STATE" ]; then
printf '[jsc][助理心跳][ERR]:%s 不是一般檔案,心跳寫不進去。\n' "$STATE" >&2
exit 5
fi
mkdir -p "$STATE_DIR" 2>/dev/null || true
_tmp="$STATE.tmp.$$"
# stderr 先轉走再開檔:順序反過來的話,開檔失敗的訊息是 sh 自己印的,那時 stderr 還沒
# 轉走,會漏到呼叫端的畫面上,蓋掉下面那句講得清楚的錯誤訊息。
if ! printf 'ts=%s\npid=%s\ncli=%s\nsession=%s\n' \
"$(now_epoch)" "$$" "$(cli_name)" "$(session_id)" 2>/dev/null > "$_tmp"; then
rm -f "$_tmp" 2>/dev/null
printf '[jsc][助理心跳][ERR]:寫不進 %s,助理這一拍沒有心跳。\n' "$STATE" >&2
exit 5
fi
if ! mv -f "$_tmp" "$STATE" 2>/dev/null; then
rm -f "$_tmp" 2>/dev/null
printf '[jsc][助理心跳][ERR]:換不上 %s,助理這一拍沒有心跳。\n' "$STATE" >&2
exit 5
fi
exit 0 ;;
check)
case "$(probe | cut -f1)" in
fresh) exit 0 ;;
stale) exit 1 ;;
absent) exit 3 ;;
*) exit 4 ;;
esac ;;
report)
_p=$(probe)
printf 'state=%s ts=%s age=%s ttl=%s pid=%s cli=%s session=%s file=%s\n' \
"$(printf '%s' "$_p" | cut -f1)" \
"$(printf '%s' "$_p" | cut -f2)" \
"$(printf '%s' "$_p" | cut -f3)" \
"$(ttl)" "$(field pid)" "$(field cli)" "$(field session)" "$STATE"
exit 0 ;;
clear)
rm -f "$STATE" 2>/dev/null
# 刪不掉就要講出來:檔案還在,別人讀到的心跳會說助理還在跑。
if [ -e "$STATE" ]; then
printf '[jsc][助理心跳][ERR]:刪不掉 %s,心跳檔還在。\n' "$STATE" >&2
exit 5
fi
exit 0 ;;
*) usage ;;
esac
+66 -7
View File
@@ -5,7 +5,11 @@
"hooks": [
{
"type": "command",
"command": "sh \"${CLAUDE_PLUGIN_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\"'"
}
]
}
@@ -15,11 +19,23 @@
"hooks": [
{
"type": "command",
"command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/ste100-guard.sh\""
"command": "sh -c 'root=\"${CLAUDE_PLUGIN_ROOT:-${JSC_HOME:-$HOME/.jsc}/current/jsc-hooks}\"; exec sh \"$root/hooks/ste100-guard.sh\"'"
},
{
"type": "command",
"command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/sdlc-gate.sh\" check"
"command": "sh -c 'root=\"${CLAUDE_PLUGIN_ROOT:-${JSC_HOME:-$HOME/.jsc}/current/jsc-hooks}\"; exec sh \"$root/hooks/sdlc-gate.sh\" check'"
},
{
"type": "command",
"command": "sh -c 'root=\"${CLAUDE_PLUGIN_ROOT:-${JSC_HOME:-$HOME/.jsc}/current/jsc-hooks}\"; exec sh \"$root/hooks/sdlc-gate.sh\" wp-check prompt'"
},
{
"type": "command",
"command": "sh -c 'root=\"${CLAUDE_PLUGIN_ROOT:-${JSC_HOME:-$HOME/.jsc}/current/jsc-hooks}\"; exec sh \"$root/hooks/comment-scope.sh\" prompt'"
},
{
"type": "command",
"command": "sh -c 'root=\"${CLAUDE_PLUGIN_ROOT:-${JSC_HOME:-$HOME/.jsc}/current/jsc-hooks}\"; exec sh \"$root/hooks/lang-guard.sh\" prompt'"
}
]
}
@@ -29,7 +45,7 @@
"hooks": [
{
"type": "command",
"command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/session-timer.sh\" mark"
"command": "sh -c 'root=\"${CLAUDE_PLUGIN_ROOT:-${JSC_HOME:-$HOME/.jsc}/current/jsc-hooks}\"; exec sh \"$root/hooks/session-timer.sh\" mark'"
}
]
}
@@ -39,7 +55,7 @@
"hooks": [
{
"type": "command",
"command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/session-timer.sh\" mark"
"command": "sh -c 'root=\"${CLAUDE_PLUGIN_ROOT:-${JSC_HOME:-$HOME/.jsc}/current/jsc-hooks}\"; exec sh \"$root/hooks/session-timer.sh\" mark'"
}
]
}
@@ -50,7 +66,37 @@
"hooks": [
{
"type": "command",
"command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/version-guard.sh\""
"command": "sh -c 'root=\"${CLAUDE_PLUGIN_ROOT:-${JSC_HOME:-$HOME/.jsc}/current/jsc-hooks}\"; exec sh \"$root/hooks/restart-gate.sh\"'"
},
{
"type": "command",
"command": "sh -c 'root=\"${CLAUDE_PLUGIN_ROOT:-${JSC_HOME:-$HOME/.jsc}/current/jsc-hooks}\"; exec sh \"$root/hooks/version-guard.sh\"'"
},
{
"type": "command",
"command": "sh -c 'root=\"${CLAUDE_PLUGIN_ROOT:-${JSC_HOME:-$HOME/.jsc}/current/jsc-hooks}\"; exec sh \"$root/hooks/sdlc-gate.sh\" wp-check skill'"
}
]
},
{
"matcher": "Write|Edit|MultiEdit",
"hooks": [
{
"type": "command",
"command": "sh -c 'root=\"${CLAUDE_PLUGIN_ROOT:-${JSC_HOME:-$HOME/.jsc}/current/jsc-hooks}\"; exec sh \"$root/hooks/write-guard.sh\" stage'"
},
{
"type": "command",
"command": "sh -c 'root=\"${CLAUDE_PLUGIN_ROOT:-${JSC_HOME:-$HOME/.jsc}/current/jsc-hooks}\"; exec sh \"$root/hooks/write-guard.sh\" review'"
}
]
},
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "sh -c 'root=\"${CLAUDE_PLUGIN_ROOT:-${JSC_HOME:-$HOME/.jsc}/current/jsc-hooks}\"; exec sh \"$root/hooks/write-guard.sh\" commit'"
}
]
}
@@ -61,7 +107,20 @@
"hooks": [
{
"type": "command",
"command": "sh \"${CLAUDE_PLUGIN_ROOT}/hooks/skill-usage.sh\""
"command": "sh -c 'root=\"${CLAUDE_PLUGIN_ROOT:-${JSC_HOME:-$HOME/.jsc}/current/jsc-hooks}\"; exec sh \"$root/hooks/skill-usage.sh\"'"
}
]
},
{
"matcher": "Write|Edit|MultiEdit",
"hooks": [
{
"type": "command",
"command": "sh -c 'root=\"${CLAUDE_PLUGIN_ROOT:-${JSC_HOME:-$HOME/.jsc}/current/jsc-hooks}\"; exec sh \"$root/hooks/comment-scope.sh\"'"
},
{
"type": "command",
"command": "sh -c 'root=\"${CLAUDE_PLUGIN_ROOT:-${JSC_HOME:-$HOME/.jsc}/current/jsc-hooks}\"; exec sh \"$root/hooks/lang-guard.sh\"'"
}
]
}
+179
View File
@@ -0,0 +1,179 @@
#!/usr/bin/env sh
# lang-guard.sh — 非程式碼輸出一律繁體中文、UTF-8、無亂碼、無簡體字(hook > prompt 的強制層)。
# 規則正文的唯一來源:jsc-meta 的 references/ste100.md。本腳本只實作可用樣式判定的三項:
# 簡體字、亂碼、非 UTF-8 編碼;用詞、標點、語氣那些判不出來的交給 ste100-guard.sh 的提示層。
#
# 用法:
# lang-guard.sh prompt 注入規則摘要(UserPromptSubmit 或規則檔取文字用)
# lang-guard.sh 掃描剛寫入的單一檔案(PostToolUse)
# lang-guard.sh sweep [dir] 掃描整個工作區這次改過的所有檔案(沒有 post-tool hook 的 CLI 用)
#
# 為什麼要有 sweep:只有 claude 接得到 PostToolUse,逐檔精準掃得到。codex 只有每輪結束的
# notify、kiro 只有 userPromptSubmit、copilot 與 antigravity 只有包裝別名,這四個都拿不到
# 「剛剛寫了哪個檔」,只能改成掃整個工作區的 git diff。時機晚一點,涵蓋範圍一樣。
#
# 輸入相容:
# Claude: PostToolUse 的 stdin JSON,取 tool_input.file_path。
# 其他 CLI: 環境變數 JSC_CHANGED_FILE。
# 兩者都取不到就安靜降級(exit 0)。
#
# 掃描範圍跟 comment-scope.sh 有兩點刻意不同,不要照抄那支的判斷:
# 1. 三項檢查都掃整個檔案,不是只掃註解行。程式碼的任何位置都不該出現簡體字或亂碼——
# 字串常值、識別字、資料內容一樣算輸出。comment-scope.sh 只掃註解,是因為它抓的是
# 註解夾帶文件編號,那件事只發生在註解裡。
# 2. markdown 與純文字檔要掃。它們正是「非程式碼輸出」的主場:README、wiki 頁、PR 描述、
# commit 訊息都是這類檔案。comment-scope.sh 刻意跳過 .md,因為那裡沒有程式碼註解。
#
# 掃描深度:檔案在 git 工作區內且已追蹤,就只掃 `git diff HEAD` 的新增行,不翻舊帳;
# 不在 git 內或檔案尚未追蹤才整檔掃描。sweep 一律只看 git diff。
#
# 結束碼:0=沒命中或資料不足;2=命中,訊息走 stderr 交回模型自行修正(不擋寫入,檔案已經寫好了)。
# 逃生門:JSC_LANG_GUARD=off。
set -u
. "$(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
HERE=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
WORDLIST="$HERE/simplified.txt"
if [ "${1:-}" = "prompt" ]; then
echo "[jsc] 所有非程式碼輸出一律繁體中文、UTF-8、無亂碼、無簡體字。適用範圍:程式碼註解、commit 訊息、PR 描述與標題、wiki 頁、對使用者的回報、README 與各種文件、錯誤訊息與日誌文字。程式碼本身的關鍵字、識別字、API 欄位名維持原樣,但其中的中文一樣要是繁體。"
echo "[jsc] 送出前自我檢查:1)簡體字(例:应、为、这、说、发、国)一律換成繁體;2)替代字元「U+FFFD」與雙重編碼亂碼(Ã、â 開頭的怪序列)代表編碼壞掉,重寫那段而不是保留;3)檔案一律存成 UTF-8,不加 BOM。規則正文見 jsc-meta 的 references/ste100.md。"
exit 0
fi
# 簡體字樣式:由 hooks/simplified.txt 組出,字表是單一真實來源,腳本裡不留第二份。
# 讀不到字表就回傳 1,呼叫端安靜跳過這一項,不中斷整支腳本——少抓一項,好過整支 hook 死掉。
simplified_pattern() {
[ -f "$WORDLIST" ] || return 1
_p=$(sed -e 's/#.*//' -e 's/[[:space:]]//g' "$WORDLIST" 2>/dev/null \
| grep -v '^$' | tr '\n' '|' | sed 's/|$//')
[ -n "$_p" ] || return 1
printf '%s' "$_p"
}
# 亂碼樣式一律用位元組比對(LC_ALL=C),不靠語系的字元範圍:
# 替代字元 U+FFFD(EF BF BD)本身;
# � ——「U+FFFD 的 UTF-8 位元組再被當成 Latin-1 讀一次」的雙重編碼殘骸;
# U+00C0–U+00FF 的字後面緊接 U+0080–U+00BF 的字(ä、æ¸、è©、ç”),這是 UTF-8 被當成
# Latin-1 讀一次再存回 UTF-8 的固定長相。中文的 UTF-8 前導位元組落在 E4–E9,被誤讀後
# 就變成 ä–é 開頭、後面接 U+0080–U+00BF 的兩三個字,所以前導字要收整個 Latin-1 字母段,
# 只收「Ã」會漏掉最常見的中文亂碼;
# â 後面接任何非 ASCII 字(’、“),引號與破折號被雙重編碼時的典型長相。
# 用位元組比對是因為 grep -E 的字元範圍在不同語系下行為不一致,位元組範圍到哪都一樣。
# 誤報防線:正常的西歐文字(câmara、crème、naïve)重音字後面接的是 ASCII 字母,不會命中。
mojibake_pattern() {
_fffd=$(printf '\357\277\275')
_lo=$(printf '\200'); _hi=$(printf '\277')
_c3=$(printf '\303'); _c2=$(printf '\302') # U+00C0–U+00FF 與 U+0080–U+00BF 的前導位元組
_bx=$(printf '\303\242') # 「â」
_l2=$(printf '\302'); _h2=$(printf '\364')
printf '%s|%s|%s[%s-%s]%s[%s-%s]|%s[%s-%s]' \
"$_fffd" '�' \
"$_c3" "$_lo" "$_hi" "$_c2" "$_lo" "$_hi" \
"$_bx" "$_l2" "$_h2"
}
hit() { # $1=樣式 $2=說明;命中就把說明與最多三行證據印到 stdout
m=$(printf '%s\n' "$lines" | LC_ALL=C grep -nE "$1" 2>/dev/null | head -n 3)
[ -n "$m" ] || return 0
printf ' %s\n' "$2"
printf '%s\n' "$m" | sed 's/^/ /'
}
scan_file() { # $1=檔案路徑;命中就把報告印到 stdout 並回傳 1,沒命中回傳 0
f=$1
[ -f "$f" ] || return 0
# 產生檔與壓縮輸出跳過:內容不是人寫的,抓到也沒有人要改。
case "$f" in
*.lock|*.min.js|*.min.css|*.map) return 0 ;;
esac
# 字表與兩支語言規則腳本跳過:這幾份檔案裡的簡體字與亂碼樣本是「被討論的對象」,
# 不是被使用。同一個道理,jsc-meta 的 ste100-lint.sh 也跳過 references/ste100.md。
# 不跳過的話,這支 hook 每次都會先抓到自己,訊號全被自己的噪音蓋掉。
case "$f" in
*/simplified.txt|simplified.txt) return 0 ;;
*/ste100-guard.sh|ste100-guard.sh) return 0 ;;
*/lang-guard.sh|lang-guard.sh) return 0 ;;
*/references/ste100.md|ste100.md) return 0 ;;
*/ste100-lint.sh|ste100-lint.sh) return 0 ;;
esac
# 二進位檔跳過。只認 NUL 位元組——拿「非可列印字元」當判準會把所有含中文的檔案誤判成二進位。
raw=$(head -c 1024 "$f" 2>/dev/null | wc -c)
txt=$(head -c 1024 "$f" 2>/dev/null | LC_ALL=C tr -d '\000' | wc -c)
[ "$raw" = "$txt" ] || return 0
d=$(dirname -- "$f")
if git -C "$d" rev-parse --is-inside-work-tree >/dev/null 2>&1 &&
git -C "$d" ls-files --error-unmatch -- "$f" >/dev/null 2>&1; then
lines=$(git -C "$d" diff HEAD -- "$f" 2>/dev/null | sed -n 's/^+[^+]/&/p' | cut -c2-)
[ -n "$lines" ] || return 0
else
lines=$(cat "$f" 2>/dev/null)
fi
out=$(
_sp=$(simplified_pattern) && hit "$_sp" '簡體字,改成繁體'
hit "$(mojibake_pattern)" '亂碼:替代字元或雙重編碼殘骸,重寫這一段'
# 編碼檢查沒有行號可指,命中就整檔報一行。沒有 iconv 就跳過這一項。
if command -v iconv >/dev/null 2>&1; then
printf '%s\n' "$lines" | iconv -f UTF-8 -t UTF-8 >/dev/null 2>&1 \
|| printf ' %s\n' '非 UTF-8 編碼,整檔轉存成 UTF-8(不加 BOM)'
fi
)
[ -n "$out" ] || return 0
printf '%s\n' "$f"
printf '%s\n' "$out"
return 1
}
advice() {
printf ' 修法:簡體字換成對應繁體字;亂碼那段重打,不要留著半壞的字元;檔案存成 UTF-8。\n'
printf ' 規則正文見 jsc-meta 的 references/ste100.md,字表在 hooks/simplified.txt。誤判時用 JSC_LANG_GUARD=off 關閉。\n'
}
if [ "${1:-}" = "sweep" ]; then
target=${2:-.}
[ -d "$target" ] || exit 0
root=$(git -C "$target" rev-parse --show-toplevel 2>/dev/null) || exit 0
[ -n "$root" ] || exit 0
changed=$(git -C "$root" diff --name-only HEAD 2>/dev/null)
[ -n "$changed" ] || exit 0
# 報告累積在暫存檔:迴圈跑在管線的子行程裡,變數帶不回來。
tmp=${TMPDIR:-/tmp}/jsc-lang-guard.$$
: > "$tmp" 2>/dev/null || exit 0
printf '%s\n' "$changed" | while IFS= read -r rel; do
[ -n "$rel" ] || continue
scan_file "$root/$rel" >> "$tmp" 2>/dev/null
done
if [ -s "$tmp" ]; then
{
printf '[jsc] 工作區有簡體字、亂碼或編碼問題,請就地修正:\n'
sed 's/^/ /' "$tmp"
advice
} >&2
rm -f "$tmp"
exit 2
fi
rm -f "$tmp"
exit 0
fi
read_stdin 2>/dev/null || STDIN_JSON=""
file=$(json_str file_path 2>/dev/null || true)
[ -n "$file" ] || file="${JSC_CHANGED_FILE:-}"
[ -n "$file" ] || exit 0
report=$(scan_file "$file") && exit 0
{
printf '[jsc] 這個檔案有簡體字、亂碼或編碼問題,請就地修正:\n'
printf '%s\n' "$report"
advice
} >&2
exit 2
+330 -15
View File
@@ -2,12 +2,35 @@
# lib.sh — jsc hooks 共用函式。所有 hook 腳本 source 此檔。
# 輸入相容:Claude 式 stdin JSON、或環境變數(codex/copilot/antigravity/kiro 接線時設定)。
# 缺資料時安靜降級,hook 預設 exit 0,不可中斷宿主 CLI。
# 唯一例外:sdlc-gate.sh check 在「SDLC 階段鎖存在且模型不符」時會 exit 2 擋下該輪提示;
# 其餘情況(無鎖、資料不足無法判定)仍照舊 exit 0。
# 唯一例外:sdlc-gate.sh check 在「SDLC 階段鎖存在,而且不知道目前模型的能力」時會 exit 2
# 擋下該輪提示。不知道能力有三種:判不出是哪一支 CLI、判不出模型、模型不在能力標籤表上。
# 這是刻意的 fail-closed——放行等於閘門不存在。沒有階段鎖時仍照舊 exit 0。
#
# 結束碼:不適用。本檔是被 source 的共用函式庫,不是可執行入口,內部一次 exit 都沒有。
# 載入成功回 0(最後一行是函式定義);拿 `sh lib.sh` 直接跑也只是定義完函式回 0,不做事。
# 呼叫端真正要防的是「載入失敗」:POSIX sh 找不到這個檔時,`.` 會讓整支腳本就地結束並回 2。
# 接在 PreToolUse 的 hook 遇到這一下,等於無聲擋掉每一次工具呼叫,而且腳本自己的放行路徑
# 一條都跑不到。要安靜降級的呼叫端請寫 `. "$HERE/lib.sh" 2>/dev/null || true`
# (comment-scope.sh 就是這樣接);其餘直接載入的腳本,各自檔頭都標了這一條。
JSC_HOME="${JSC_HOME:-$HOME/.jsc}"
mkdir -p "$JSC_HOME/sessions" "$JSC_HOME/usage" 2>/dev/null || true
# 呼叫端腳本所在目錄。source 不會改變 $0,所以這裡取到的是 hooks/ 或 tools/。
JSC_SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" 2>/dev/null && pwd)
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(可能為空;非阻塞宿主)
read_stdin() {
if [ -t 0 ]; then STDIN_JSON=""; else STDIN_JSON=$(cat 2>/dev/null || true); fi
@@ -20,32 +43,324 @@ json_str() { # $1=欄位名
}
# 目前 session id:stdin JSON > 環境變數 > 固定值
#
# 環境變數這一段的順序:
# JSC_SESSION_ID jsc 自己的指定值(tools/jsc-wrap.sh 會設),人工指定優先於偵測。
# CLAUDE_CODE_SESSION_ID claude 實際匯出的名字,實測確認過。
# CLAUDE_SESSION_ID 只留作往後相容,排在實測名之後。這個名字在目前的 claude 上並不
# 存在,兩個都設到的話,該信的是實測看得到的那一個;只設到這一個
# 的版本仍然退得下來,所以留著不會有損失。
# 為什麼這一條要修:退路鏈原本只找 CLAUDE_SESSION_ID,那個名字取不到值,於是沒有 stdin 的
# 執行路徑(技能以 Bash 呼叫 sdlc-gate.sh lock)一律退回 default,而 hook 有 stdin、拿得到
# 真正的 id。同一個工作階段的兩條路徑因此算出兩支不同的狀態檔,階段鎖上了也永遠對不上。
# 這裡只補退路鏈的變數名,不動取值順序以外的行為:其他 CLI 的變數名沒有實測過,猜一個填進來
# 只會多一個錯誤來源。
session_id() {
sid=$(json_str session_id)
[ -n "$sid" ] || sid="${JSC_SESSION_ID:-${CLAUDE_SESSION_ID:-default}}"
[ -n "$sid" ] || sid="${JSC_SESSION_ID:-${CLAUDE_CODE_SESSION_ID:-${CLAUDE_SESSION_ID:-default}}}"
printf '%s' "$sid"
}
# 目前實際使用的模型 id:讀 transcript 最後一筆帶 model 的訊息。
# 這是 shell 唯一能「驗證」的模型來源——模型自我回報無法驗證,等同沒有閘門。
# 取不到(無 transcript_path、檔案不存在、尚無 assistant 訊息)時不輸出,由呼叫端決定如何降級。
transcript_model() {
tp=$(json_str transcript_path)
[ -n "$tp" ] && [ -f "$tp" ] || return 0
# 只掃尾端若干行即可命中最近一輪;`<...>` 這類佔位模型名(例如 <synthetic>)排除。
tail -n 500 "$tp" 2>/dev/null \
| grep -o '"model":"[^"]*"' \
| sed 's/^"model":"//; s/"$//' \
# 階段鎖狀態檔名要用的 CLI 代號。這個值直接拼進檔名,所以不像代號的字元一律換掉;
# 帶斜線或空白的值會把檔案寫到別的地方去,理由與 restart-gate.sh 的做法相同。
stage_state_cli() {
cli_name | sed 's/[^A-Za-z0-9._-]/_/g'
}
# 階段鎖狀態檔的舊路徑:$JSC_HOME/sessions/{sid}.stage。只作往後相容的讀取來源,
# 以及 unlock 的清除對象,新的寫入一律不走這裡。
stage_state_legacy_file() {
printf '%s/sessions/%s.stage' "$JSC_HOME" "$(session_id)"
}
# 階段鎖狀態檔(寫入用):$JSC_HOME/sessions/{cli}-{sid}.stage。
# 為什麼要帶 CLI 代號:session_id() 判不出工作階段時會退回 default,各 CLI 於是共用同一支
# default.stage,一支上的階段鎖就會擋到另一支。那不只擋提示——write-guard.sh 的 stage 模式
# 在 plan 或 analyze 持鎖時連 Write、Edit、MultiEdit 一起擋掉。
# 為什麼用檔名前綴而不是子目錄:外部工具以單層的 sessions/*.stage 盤點階段鎖,改成子目錄
# 會讓每一支鎖從那些盤點裡整批消失;前綴照樣列得到,只是多一段代號。
stage_state_file() {
printf '%s/sessions/%s-%s.stage' "$JSC_HOME" "$(stage_state_cli)" "$(session_id)"
}
# 階段鎖狀態檔(讀取用):新路徑優先,沒有才回舊路徑。舊檔記的是實際工作,只讀不搬也不刪。
# 兩邊都沒有時回新路徑,讓呼叫端一律用「檔案在不在」判斷有沒有鎖。
stage_state_read_file() {
_sf=$(stage_state_file)
if [ -f "$_sf" ]; then
printf '%s' "$_sf"
else
_so=$(stage_state_legacy_file)
if [ -f "$_so" ]; then printf '%s' "$_so"; else printf '%s' "$_sf"; fi
fi
}
MODEL_SOURCE_CHECKS=""
model_checked() {
MODEL_SOURCE_CHECKS="${MODEL_SOURCE_CHECKS:+$MODEL_SOURCE_CHECKS;}$1"
}
model_clean() {
printf '%s' "$1" | tr -d '\r' | sed 's/^[[:space:]]*//; s/[[:space:]]*$//'
}
json_model_value() {
printf '%s' "$1" \
| grep -o '"\(model\|model_id\|model_slug\|modelName\|current_model\|currentModel\)"[[:space:]]*:[[:space:]]*"[^"]*"' \
| sed 's/^"[^"]*"[[:space:]]*:[[:space:]]*"//; s/"$//' \
| grep -v '^<' \
| tail -n 1
}
# 目前 CLI 名稱:環境變數 > 依 stdin 特徵猜測 > unknown
model_from_file() { # $1=檔案
[ -f "$1" ] && [ -r "$1" ] || return 1
json_model_value "$(tail -n 1000 "$1" 2>/dev/null)" | tail -n 1
}
# transcript 是首選。它是 CLI 寫下的執行紀錄,不採用對話裡模型自己的宣稱。
transcript_model() {
tp=$(json_str transcript_path)
[ -n "$tp" ] || tp="${JSC_TRANSCRIPT_PATH:-}"
[ -n "$tp" ] && [ -f "$tp" ] || return 0
model_from_file "$tp"
}
codex_session_file() { # $1=CODEX_HOME $2=session id
ch="$1"; sid="$2"
[ -d "$ch/sessions" ] || return 1
if [ -n "$sid" ] && [ "$sid" != default ]; then
find "$ch/sessions" -type f -name "*$sid*.jsonl" 2>/dev/null | sort | tail -n 1
return 0
fi
# 全樹最新的一支。原本寫成 `xargs ls -t | head -n 1`,那是錯的:xargs 會依參數長度分批,
# ls -t 只在自己那一批裡排序,取到的是「第一批裡最新的」而不是全域最新。改成讓 find 一併
# 印出修改時間,所有檔案在同一輪比較,批次邊界就影響不到結果。
newest=$(find "$ch/sessions" -type f -name '*.jsonl' -printf '%T@\t%p\n' 2>/dev/null \
| sort -rn | head -n 1 | cut -f2-)
# find 沒有 -printf(非 GNU)時退回路徑排序。codex 的 session 依「年/月/日」分目錄,
# 檔名又以 ISO 時間開頭,字典序等同時間序,仍然是全域比較,不會被分批切斷。
[ -n "$newest" ] || newest=$(find "$ch/sessions" -type f -name '*.jsonl' 2>/dev/null \
| sort | tail -n 1)
[ -n "$newest" ] || return 1
printf '%s\n' "$newest"
}
codex_model() {
ch="${CODEX_HOME:-$HOME/.codex}"
[ -d "$ch" ] || return 1
sf=$(codex_session_file "$ch" "$(session_id)" 2>/dev/null || true)
if [ -n "$sf" ]; then
m=$(model_from_file "$sf" 2>/dev/null || true)
[ -n "$m" ] && { printf '%s\t%s\n' "$m" "codex-session:$sf"; return 0; }
fi
for f in "$ch/history.jsonl" "$ch/session_index.jsonl"; do
[ -f "$f" ] || continue
m=$(model_from_file "$f" 2>/dev/null || true)
[ -n "$m" ] && { printf '%s\t%s\n' "$m" "codex-jsonl:$f"; return 0; }
done
return 1
}
# 目前模型的判定。輸出單行「{模型 id}<TAB>{來源}<TAB>{已檢查來源}」,判不出時前兩欄留空。
#
# 偵測鏈依 CLI 分流:一支 CLI 只讀自己的紀錄。兩個理由。
# 一是正確性:跨過去讀別支的紀錄,拿到的是別支的模型,用它判定這一支等於沒有判準。
# 二是速度:別支的 session 目錄可能有上千個檔案,每一輪 hook 都掃一次會把宿主 CLI 拖住。
# 判不出是哪一支 CLI 時不猜任何來源——不知道是誰,就不知道該讀誰的紀錄。
# JSC_MODEL 這個人工覆寫對所有 CLI 都保留,而且一律排在最後:可驗證的紀錄優先於人工宣告。
current_model_report() {
MODEL_SOURCE_CHECKS=""
_mcli=$(cli_name)
model_checked "JSC_CLI:$_mcli"
case "$_mcli" in
claude)
# transcript 是 claude 自己寫下的執行紀錄,也是唯一逐輪更新的來源。
tp=$(json_str transcript_path)
[ -n "$tp" ] || tp="${JSC_TRANSCRIPT_PATH:-}"
model_checked "transcript_path:${tp:-未提供}"
if [ -n "$tp" ] && [ -f "$tp" ]; then
m=$(transcript_model)
[ -n "$m" ] && { printf '%s\t%s\t%s\n' "$(model_clean "$m")" "transcript:$tp" "$MODEL_SOURCE_CHECKS"; return 0; }
fi
model_checked "hook-stdin:model/model_id/model_slug/modelName/current_model/currentModel"
m=$(json_model_value "$STDIN_JSON")
[ -n "$m" ] && { printf '%s\t%s\t%s\n' "$(model_clean "$m")" "hook-stdin" "$MODEL_SOURCE_CHECKS"; return 0; }
;;
codex)
# hook 負載是這一輪由 codex 自己餵進來的,不是別支 CLI 的紀錄,所以照收;
# 而且它反映當下這一輪,比落在檔案裡的紀錄新,排在 session 記錄前面。
model_checked "hook-stdin:model/model_id/model_slug/modelName/current_model/currentModel"
m=$(json_model_value "$STDIN_JSON")
[ -n "$m" ] && { printf '%s\t%s\t%s\n' "$(model_clean "$m")" "hook-stdin" "$MODEL_SOURCE_CHECKS"; return 0; }
model_checked "codex:${CODEX_HOME:-$HOME/.codex}/sessions、history.jsonl、session_index.jsonl"
cm=$(codex_model 2>/dev/null || true)
if [ -n "$cm" ]; then
m=$(printf '%s' "$cm" | cut -f1)
src=$(printf '%s' "$cm" | cut -f2)
[ -n "$m" ] && { printf '%s\t%s\t%s\n' "$(model_clean "$m")" "$src" "$MODEL_SOURCE_CHECKS"; return 0; }
fi
;;
copilot|antigravity|kiro)
# 這三支由 tools/jsc-wrap.sh 包起來跑,只餵得到環境變數:沒有 transcript、沒有 hook
# 負載,本機也沒有可讀的模型紀錄。這裡如實記成「沒有來源」,不去翻別支 CLI 的檔案。
model_checked "$_mcli:本機沒有可讀的模型紀錄,只認 JSC_MODEL"
;;
*)
model_checked "未知 CLI:判不出是哪一支,不採用任何自動來源"
;;
esac
model_checked "JSC_MODEL"
if [ -n "${JSC_MODEL:-}" ]; then
printf '%s\t%s\t%s\n' "$(model_clean "$JSC_MODEL")" "人工覆寫:JSC_MODEL" "$MODEL_SOURCE_CHECKS"
return 0
fi
printf '\t\t%s\n' "$MODEL_SOURCE_CHECKS"
}
# 目前 CLI 名稱:環境變數 > 依環境特徵判斷 > unknown
#
# claude 的判準有三個,任一個成立就算。理由與 session_id() 那條退路鏈同源:CLAUDE_PLUGIN_ROOT
# 只有 plugin 接線的 hook 執行環境才有,技能以 Bash 工具呼叫腳本時並不存在,於是同一個工作
# 階段的兩條路徑一條認得出 claude、一條回 unknown,狀態檔名跟著分岔。CLAUDECODE 與
# CLAUDE_CODE_SESSION_ID 兩者在工具呼叫的環境裡都看得到(實測確認),補上去兩條路徑才算得出
# 同一個代號。只補 claude 這一支:其他 CLI 的環境特徵沒有實測過,猜一個填進來只會多一個
# 錯誤來源,那幾支本來就由接線設定明確帶 JSC_CLI。
cli_name() {
if [ -n "${JSC_CLI:-}" ]; then printf '%s' "$JSC_CLI"
elif [ -n "${CLAUDE_PLUGIN_ROOT:-}" ]; then printf 'claude'
elif [ -n "${CLAUDE_PLUGIN_ROOT:-}" ] || [ -n "${CLAUDECODE:-}" ] \
|| [ -n "${CLAUDE_CODE_SESSION_ID:-}" ]; then printf 'claude'
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 操作一律經由它(技能準則),
# 不可自行拼 API 呼叫:token 取用與 tea 金鑰退回都寫在那支腳本裡。
# 找不到就回傳 1,由呼叫端安靜降級(hook 一律 exit 0,不中斷宿主 CLI)。
#
# 每一條候選路徑都先湊出來、最後統一過 jsc_abs_path 才交出去,理由見該函式的說明:
# 這裡的候選帶著 `..`,而那些 `..` 要穿過安裝版面的符號連結,交出去的原樣路徑
# 只有 `[ -f ]` 認得,被呼叫的腳本自己 `cd` 過去會失敗。正規化失敗時退回原樣路徑,
# 讓「找得到」這件事的判準不因為多了一道正規化而變嚴。
jsc_gitea_sh() {
_c=""
_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
if [ -z "$_c" ]; then
for _p in "$_root/../gitea/tools/gitea.sh" "$_root/../jsc-gitea/tools/gitea.sh"; do
[ -f "$_p" ] && { _c="$_p"; break; }
done
fi
# 已安裝版面:每個 plugin 各有版本目錄,取排序最後的一份(通常即最新版)
if [ -z "$_c" ]; then
_p=$(ls -d "$_root"/../../jsc-gitea/*/tools/gitea.sh \
"$_root"/../../gitea/*/tools/gitea.sh \
"$HOME"/.claude/plugins/cache/*/jsc-gitea/*/tools/gitea.sh 2>/dev/null \
| sort | tail -n1)
[ -n "$_p" ] && [ -f "$_p" ] && _c="$_p"
fi
[ -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_bin() { # $1=CLI 代號
case "$1" in
antigravity) printf 'agy' ;;
kiro) printf 'kiro-cli' ;;
*) printf '%s' "$1" ;;
esac
}
now_epoch() { date +%s; }
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
}
+257
View File
@@ -0,0 +1,257 @@
#!/usr/bin/env sh
# restart-gate.sh — 部署後強制重啟閘門(PreToolUse,matcher: Skill)。
#
# 技能組更新後,正在跑的 CLI 行程載入的還是舊版:SKILL.md、hook 腳本與 tools 都在啟動當下
# 讀進記憶體。所以部署收尾要求重新啟動,這道閘門負責讓「還沒重啟就繼續用技能」擋在門外。
#
# 結束碼(hook 模式):0=放行 2=擋下該次技能呼叫。
# 擋下時的輸出形態由 deny.sh 依當前 CLI 決定,本檔只負責判定與訊息內容:
# claude、codex、copilot 走 stderr 加 exit 2;antigravity 走 stdout 的 deny JSON,結束碼
# 固定 0(那支 CLI 的結束碼語意沒有文件,不可靠);kiro 擋不下來,改印警告後 exit 0。
# 所以「exit 0」在這支腳本有兩種意思:放行,或已經以不靠結束碼的形態擋下。
# 安靜放行(exit 0)的情況:逃生門 JSC_RESTART_GATE=off、負載裡解不出技能名、
# 解出來的不是 jsc 技能、命中下方豁免清單那 10 支、取不到 CLI 代號、
# 當前 CLI 那份狀態檔與舊格式狀態檔都不在。
# 只有「當前 CLI 那份狀態檔存在」或「退回讀到的舊格式狀態檔存在」會走 deny.sh。
# 結束碼(require):0=閘門已掛上 2=取不到 CLI 代號或寫不進狀態檔,兩種都等於沒掛上。
# 結束碼(clear、report):0=永遠成功。clear 檔案不存在也算成功,report 一份都沒有就不印。
# 結束碼(不認得的子命令):0=安靜放行,不中斷宿主 CLI。
# 註:本檔以 `. "$HERE/lib.sh"` 載入共用函式,沒有接 `|| true`。lib.sh 讀不到時 sh 會就地
# 結束並回 2,接在 PreToolUse 上就是無聲擋下每一次技能呼叫,上面那些放行路徑一條都跑不到。
# hooks/skill-name.sh 與 hooks/deny.sh 同理要一起裝上,但那兩支是以子行程呼叫,讀不到只會
# 讓技能名解不出來而安靜放行,不會反過來擋人——所以那兩支刻意不用 source 載入。
#
# 輸入:技能名一律由 skill-name.sh 從當前 CLI 的負載解析,環境變數 JSC_SKILL、SKILL 優先,
# 規則與 version-guard.sh 共用同一份。不再另外篩工具名:工具名每支 CLI 都不一樣
# (Skill、Bash、skill、view_file),拿 Claude 的那一個當通用條件會把另外四支整批擋在判定之外。
#
# 用法:
# restart-gate.sh hook 模式:當前 CLI 那份狀態檔存在就擋下該次技能
# 呼叫(exit 2)。別支 CLI 那幾份不看。
# restart-gate.sh require {模式} [{domain}...]
# 寫入當前 CLI 那份狀態檔,掛上這一支的閘門。由
# jsc-cli:deploy 在 install 或 update 收尾時呼叫;模式為
# install 或 update,之後接這次更新的 domain 清單。
# exit 0 = 已掛上;exit 2 = 取不到 CLI 代號或寫不進去
# (兩種都等於沒掛上)。
# restart-gate.sh clear 只清除當前 CLI 那份狀態檔,放下這一支的閘門。由
# session-timer.sh 在判定為新工作階段時呼叫(見下方
# 「清除時機」)。檔案不存在也算成功。
# restart-gate.sh report 印出每一份狀態檔的內容,一支 CLI 一行(格式見下方
# 「report 輸出格式」);一份都沒有就不印,一律 exit 0。
#
# require、clear、report 都不讀標準輸入,只有 hook 模式讀。理由與 sdlc-gate.sh 相同:
# read_stdin 在標準輸入是管線又沒人關閉時會一直等,工具端呼叫就整支卡死。新增子命令照這個
# 原則歸類,工具端呼叫一律再補 </dev/null。
#
# --- 狀態檔格式 ---
#
# $JSC_HOME/restart-required.d/{CLI 代號}(JSC_HOME 未設定時為 ~/.jsc),一支 CLI 一份,
# 檔名就是 CLI 代號(claude、codex、copilot、antigravity、kiro)。內容為純文字 key=value,
# 一行一欄位,順序不拘,不認得的鍵一律忽略。格式壓到最簡,jsc-hooks 與 jsc-cli 兩邊各自
# 實作也對得上。
# at={ISO 時間} 部署收尾時間,UTC
# mode={install|update} 這次部署的模式
# domains={domain 清單} 這次更新到的 domain,空白分隔
# cli={CLI 代號} 執行部署的 CLI,與檔名相同
# 欄位只用在擋人訊息上。判定看的是「當前 CLI 那份檔案在不在」——檔案存在就是這一支還沒重啟
# 過的證據,欄位缺了只讓訊息少幾個字,不影響判定。
#
# 為什麼一支 CLI 一份:一台機器上五支 CLI 各自是獨立行程,各自載入自己記憶體裡的那一版。
# 早先的單一檔案設計有兩個實測抓到的洞——並行部署互相覆寫(後寫的把 domains 與 cli 蓋掉,
# 欄位不再代表先寫的那一支),以及任一支 CLI 重啟就把五支的閘門一起解除(其餘四支沒重啟卻
# 不再被擋,閘門等於半失效)。拆成一支一份之後,寫入、判定、清除三件事都只碰自己那一份。
#
# --- report 輸出格式 ---
#
# 一行一份狀態檔,欄位以空白分隔,domains 可能含空白所以擺最後:
# {CLI 代號} at={ISO 時間} mode={install|update} domains={domain 清單}
# 有幾行就代表有幾支 CLI 還沒重啟。欄位缺值時只留鍵名(例如 domains=)。第一欄印 legacy 的
# 那一行代表舊格式的單一狀態檔(見下方「舊檔相容」),它不屬於任何一支 CLI。
#
# --- 舊檔相容(過渡用) ---
#
# 舊版把狀態寫進 $JSC_HOME/restart-required 單一檔案。改用狀態目錄的第一輪部署,機器上可能
# 還留著那份舊檔:完全不認它,那一輪的閘門會整輪漏掉,檔案本身也會永遠留著變垃圾。所以:
# 判定:舊檔存在就一律擋,視為「每一支 CLI 都有未重啟的部署」。舊檔沒有 per-CLI 資訊,
# 分不出是哪一支寫的,寧可擋多不擋少。擋人訊息會標明這是舊格式紀錄。
# 清除:clear 除了刪當前 CLI 那一份,也一併刪掉舊檔。取捨講白:clear 只在新工作階段被
# 呼叫,呼叫到就代表確實有一支 CLI 重新啟動過了;舊檔沒有 per-CLI 資訊,留著會讓五支
# CLI 一路被擋到有人手動刪,刪掉是唯一收斂的做法。代價是同一輪部署的其他 CLI 少擋
# 一次,只影響改用狀態目錄的那一輪。
# 這一段是過渡用的:所有機器都跑過一次寫狀態目錄的部署與重啟之後,舊檔不會再被寫出來,屆時
# 可以整段移除——LEGACY_STATE、hook 判定裡的舊檔分支、clear 裡的舊檔刪除、report 的
# legacy 行、以及本節。
#
# --- 清除時機 ---
#
# 清除由 session-timer.sh 在「這一次 SessionStart 是新的工作階段」那一刻呼叫,不由本檔自己判定:
# 新舊工作階段的判準(sessions/{sid}.start 在不在)只有那支腳本知道,兩邊各寫一份就會漂移。
# 新的工作階段代表 CLI 行程是新起的,新版一定已經載入,所以清除是對的。續接同一階段
# (SessionStart 再觸發、resume、compact)不會走到那一段,閘門就一路留到真的重新啟動。
# 清除的範圍就是呼叫端那一支 CLI:那一支重啟了,不代表別支也重啟了。
#
# --- 判定原則 ---
#
# 比照 version-guard.sh:只擋確定違規,查不到基礎資訊一律放行(exit 0)。狀態檔讀不到、
# CLI 代號取不到、技能名取不到、工具名不是 Skill,四種都放行——沒有證據時擋下等於停掉每一次
# 技能呼叫。
#
# 豁免(這些技能永遠放行,改動前想清楚後果):
# jsc-cli:deploy 部署入口本身,也是唯一能把技能組換成新版的路徑,擋了會死鎖
# jsc-hooks:hooks-install 部署後要重新接線,擋了會讓部署做一半卡住
# jsc-hooks:repair 接線或執行期出錯時唯一的修復路徑。修 hook 的技能被 hook 擋下,
# 就沒有任何方法把 hook 修回來,閘門等於把解除自己的路徑一起鎖掉
# jsc-gitea:wiki 寫技能組異動報告與工作日誌都要它落地,擋了報告寫不完
# jsc-log:worklog 部署後還要寫得完工作日誌(R1)
# jsc-log:learn 同上,教訓也要記得完
# jsc-meta:* 技能組異動報告(R13)由這一組技能產出,另外它們是修技能組的工具
# jsc-ask:ask 上面幾支都要問使用者,擋了 deploy 連 install 或 update 都問不出來
# jsc-git:pr 報告與異動收尾要開 PR,擋了收尾做不完
# jsc-git:commit 同上,pr 的第一步就是它
# 理由講白:部署後還有兩條規則要收尾——技能組異動報告(R13)與工作日誌(R1)。整批擋下去,
# 「先重啟」與「先寫完報告」會互相打死,使用者兩件事都做不完。
#
# 清單認的是技能名,不是呼叫鏈:豁免技能轉呼叫的下一層若不在清單上,那一層照樣會被擋。
# 後三支(ask、pr、commit)就是為了這件事補進來的——它們自己不是收尾規則的主體,但前七支
# 少了它們就走不完:deploy 問不出模式、報告寫完開不了 PR。version-guard.sh 當年把
# jsc-ask:ask 與 jsc-gitea:wiki 放進豁免,也是同一個原因。
# 還有巢狀呼叫走不下去時,先重新啟動;真的卡死才下 JSC_RESTART_GATE=off。
#
# 逃生門:JSC_RESTART_GATE=off 完全略過這道閘門。
HERE=$(dirname "$0"); . "$HERE/lib.sh"
hook_trace "restart-gate ${1:-}"
STATE_DIR="$JSC_HOME/restart-required.d"
# 舊格式的單一狀態檔。只為過渡而讀,可移除的時機見檔頭「舊檔相容」。
LEGACY_STATE="$JSC_HOME/restart-required"
# 當前 CLI 代號;取不到就不輸出,由呼叫端決定怎麼降級。取法與其他 hook 一致(JSC_CLI 優先,
# 其次 lib.sh 的 cli_name)。不像代號的值一併當成取不到:這個值直接拿去當檔名,帶斜線或
# 點號開頭的值會把檔案寫到狀態目錄外面去。
cli_code() {
_c=$(cli_name)
case "$_c" in
""|unknown) return 0 ;;
.*|*[!A-Za-z0-9._-]*) return 0 ;;
esac
printf '%s' "$_c"
}
# 從指定狀態檔取一個欄位;檔案讀不到或欄位不存在就不輸出。
state_field() { # $1=狀態檔 $2=鍵名
[ -f "$1" ] && [ -r "$1" ] || return 0
sed -n "s/^$2=//p" "$1" 2>/dev/null | head -n1
}
# 一份狀態檔印一行,格式見檔頭「report 輸出格式」。$1=第一欄要印的名稱 $2=狀態檔
state_line() {
printf '%s at=%s mode=%s domains=%s\n' "$1" \
"$(state_field "$2" at)" "$(state_field "$2" mode)" "$(state_field "$2" domains)"
}
case "${1:-}" in
require)
_mode="${2:-update}"
_domains=""
if [ "$#" -gt 2 ]; then shift 2; _domains="$*"; fi
_cli=$(cli_code)
if [ -z "$_cli" ]; then
# 取不到代號就不知道該寫哪一份,寫成別的檔名也沒用:hook 模式同樣取不到代號,那一份
# 永遠不會被讀到。沒掛上就要講出來,不能讓部署以為掛上了。
printf '[jsc][重啟閘門][ERR]:取不到可用的 CLI 代號(JSC_CLI 未設定,或值不是代號),這次部署沒有掛上重啟閘門。\n' >&2
exit 2
fi
mkdir -p "$STATE_DIR" 2>/dev/null || true
printf 'at=%s\nmode=%s\ndomains=%s\ncli=%s\n' \
"$(now_iso)" "$_mode" "$_domains" "$_cli" > "$STATE_DIR/$_cli" 2>/dev/null || {
# 寫不進去要講出來:沒寫成就沒有閘門,部署卻以為掛上了。
printf '[jsc][重啟閘門][ERR]:寫不進 %s,這次部署沒有掛上重啟閘門。\n' "$STATE_DIR/$_cli" >&2
exit 2
}
exit 0 ;;
clear)
_cli=$(cli_code)
# 只刪自己那一份。別支 CLI 沒有跟著重啟,它們的閘門要留著。
[ -n "$_cli" ] && rm -f "$STATE_DIR/$_cli" 2>/dev/null
# 舊檔一併刪,取捨與可移除時機見檔頭「舊檔相容」。
rm -f "$LEGACY_STATE" 2>/dev/null || true
exit 0 ;;
report)
# 目錄裡一份都沒有時,未展開的樣式字串會由 -f 判斷擋掉。
for _f in "$STATE_DIR"/*; do
[ -f "$_f" ] && [ -r "$_f" ] || continue
state_line "$(basename "$_f")" "$_f"
done
if [ -f "$LEGACY_STATE" ] && [ -r "$LEGACY_STATE" ]; then
state_line legacy "$LEGACY_STATE"
fi
exit 0 ;;
"") ;; # 落到下面的 hook 模式
*) exit 0 ;; # 不認得的子命令一律安靜放行,不中斷宿主 CLI
esac
read_stdin
[ "${JSC_RESTART_GATE:-}" = "off" ] && exit 0
# 技能名解析:交給 skill-name.sh,規則與 version-guard.sh 共用同一份。輸出固定是
# 「{domain}<TAB>{技能名}」;用 awk 判 NF==2 才取值,少一欄就當成解析不出來,免得沒有定位字元時
# cut -f2 把整行當成技能名,拼出一個不存在的技能名去比對豁免清單。
sn=$(printf '%s' "$STDIN_JSON" | sh "$HERE/skill-name.sh" "$(cli_name)" 2>/dev/null)
sn_domain=$(printf '%s\n' "$sn" | awk -F'\t' 'NF == 2 { print $1; exit }')
sn_name=$(printf '%s\n' "$sn" | awk -F'\t' 'NF == 2 { print $2; exit }')
[ -n "$sn_domain" ] && [ -n "$sn_name" ] || exit 0
skill="jsc-$sn_domain:$sn_name"
# 豁免清單(理由見檔頭)
case "$skill" in
jsc-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)
exit 0 ;;
esac
# CLI 代號取不到就放行:不知道現在跑的是哪一支,就不知道該讀哪一份狀態檔,等同沒有證據。
cli=$(cli_code)
[ -n "$cli" ] || exit 0
# 只看自己那一份;沒有才退回看舊檔。兩份都沒有就放行——沒有「剛部署過」的證據,就沒有擋人的
# 理由。別支 CLI 那幾份一律不看:那些是別的行程,重啟與否跟這一支無關。
state="$STATE_DIR/$cli"
legacy=no
if [ -f "$state" ] && [ -r "$state" ]; then
:
elif [ -f "$LEGACY_STATE" ] && [ -r "$LEGACY_STATE" ]; then
state="$LEGACY_STATE"; legacy=yes
else
exit 0
fi
at=$(state_field "$state" at)
mode=$(state_field "$state" mode)
domains=$(state_field "$state" domains)
# 重啟方式依實際 CLI 給。印別的 CLI 的執行檔名等於沒給指示。
bin=$(cli_bin "$cli")
# 訊息裡的部署資訊逐段接起來,缺欄位就少一段,不會留下空括號或多餘的逗號。
info=""
[ -n "$at" ] && info="$at"
[ -n "$mode" ] && info="${info}${info:+,}模式 $mode"
[ -n "$domains" ] && info="${info}${info:+,}domain:$domains"
# 舊格式紀錄要標出來:它分不出是哪一支 CLI 部署的,所以每一支都擋,看到訊息的人才不會以為
# 系統認定就是這一支剛部署過。
[ "$legacy" = yes ] && info="${info}${info:+,}舊格式紀錄,分不出是哪一支 CLI 部署的"
# 擋人輸出交給 deny.sh:形態依 CLI 而定,本檔只組訊息。三段訊息整段走同一條管線送過去,
# antigravity 那一支才有辦法把它們壓成同一個 reason 字串;分次呼叫會做出好幾份 deny JSON,
# 那支 CLI 只認第一份,後面兩段使用者永遠看不到。
{ printf '[jsc][重啟閘門][ERR]:技能組已更新%s,%s 還在跑舊版,新版要重新啟動才會載入。本次技能呼叫已擋下。\n' \
"${info:+($info)}" "$bin"
printf '重新啟動:結束 %s 再重新開啟一次,狀態檔 %s 會在新工作階段開始時自動清除。\n' \
"$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'
# 這條路徑是「已經擋下」,但輸出形態依 CLI 而定:antigravity 走 stdout 的 deny JSON、
# kiro 只印警告,兩者的結束碼都是 0。不覆寫狀態的話,事件流會把擋下記成放行。
JSC_EVENT_STATUS=blocked
} | sh "$HERE/deny.sh" "$(cli_name)"
exit $?
+433 -38
View File
@@ -3,27 +3,139 @@
#
# 判準是階段的「能力標籤」,不是模型名稱:
# - 標籤資料讀 $JSC_HOME/model-tags.tsv,由 jsc-cli/tools/model-tags.sh sync 產生。
# - 目前模型 id 取自 transcript 記錄的實際值(lib.sh 的 transcript_model),
# 不採用模型自我回報——自我回報無法驗證,等同沒有閘門。
# - 目前模型 id 取自可驗證紀錄,而且只讀屬於當前 CLI 的那一份(判定鏈見 lib.sh 的
# current_model_report)。claude 讀 transcript 與 hook stdin,codex 讀 hook stdin 與
# 自己的 session 記錄,其餘 CLI 本機沒有可讀的紀錄。只有這些來源都失敗時,才接受
# JSC_MODEL 這個人工覆寫。
# - 不採用對話內容裡模型自稱的 id。自我回報無法驗證,等同沒有閘門。
# - 鎖存的是「階段的必要標籤」,不是「上鎖那一刻的模型」。鎖當時的模型只留作記錄,
# 否則用不合格的模型起跑就會把自己鎖成合格,閘門永遠通過。
#
# 狀態檔:$JSC_HOME/sessions/{sid}.stage,單行「{stage}<TAB>{必要標籤}<TAB>{上鎖時的模型}」。
# 政策是 fail-closed:不知道能力就擋下,知道才比對能力標籤。不知道能力有三種,一律擋下——
# 判不出是哪一支 CLI、判不出目前模型、模型判得出來但不在能力標籤表上。放行任何一種,用不
# 合格的模型跑階段就查不出來,閘門形同虛設。代價是被擋住的人可能不知道怎麼脫困,所以每一則
# 擋下的訊息都要帶逃生門:設 JSC_MODEL 人工指明模型,或執行本檔的 unlock 解除該階段的鎖。
#
# 狀態檔:$JSC_HOME/sessions/{cli}-{sid}.stage,單行
# 「{stage}<TAB>{必要標籤}<TAB>{上鎖時的模型}<TAB>{模型來源}」。
# 檔名帶 CLI 代號的理由見 lib.sh 的 stage_state_file:session id 判不出時會退回 default,
# 不分 CLI 就會共用同一支 default.stage,一支上的鎖擋到另一支。舊路徑
# $JSC_HOME/sessions/{sid}.stage 仍讀得到(往後相容),unlock 也會一併清掉,
# 但新的寫入一律走新路徑。
#
# 用法:
# sdlc-gate.sh lock {stage} 階段閘門:比對實際模型與該階段必要標籤,通過才上鎖。
# exit 0 = 通過並已上鎖;exit 1 = 未通過,呼叫端必須停止流程。
# sdlc-gate.sh unlock 移除狀態檔(被擋住又確定要放行時的逃生門)。
# sdlc-gate.sh unlock [{狀態檔}]
# 移除狀態檔(被擋住又確定要放行時的逃生門)。不帶參數時清掉這個
# 工作階段的新舊兩份;帶參數時清掉指定的那一份。
# 為什麼要收參數:hook 拿得到 stdin 的 session id 與 JSC_CLI,
# 手動執行拿不到,兩邊算出來的檔名可能不同,逃生門就按不到真正
# 擋人的那一支。所以擋人訊息會把實際讀到的路徑一起印出來,
# 照抄就解得開。參數只收 sessions 目錄底下的 .stage 檔,
# 避免這個逃生門變成任意刪檔的工具。
# sdlc-gate.sh check hook 模式(UserPromptSubmit):模型不符即擋下該輪提示。
# sdlc-gate.sh report 印出 {sid} {stage} {必要標籤} {上鎖時的模型};無鎖不印。
# sdlc-gate.sh report 印出 {sid} {stage} {必要標籤} {模型 id} {模型來源} {判定};無鎖不印。
#
# exit code 例外:其他 jsc hook 一律 exit 0 不中斷宿主 CLI;本檔 check 是刻意的例外——
# 鎖存在且模型不符時 exit 2 擋下該輪提示。只用提示注入的話模型可以無視,閘門形同虛設。
# sdlc-gate.sh wp-lock {owner}/{repo} {index} [{工作包代號}]
# 記下一筆未結清的工作包 PR;第四個參數是這支 PR 做的是哪一包,
# 省略就是「查無歸屬」,之後的歸屬比對一律放行。
# exit 0 = 已記下;exit 2 = 用法錯誤或寫不進狀態檔(沒記下等於沒鎖)。
# sdlc-gate.sh wp-unlock {owner}/{repo} {index} 結清後移除該工作包的狀態檔;檔案不存在也算成功。
# exit 0 = 已結清;exit 2 = 用法錯誤。
# sdlc-gate.sh wp-claim {owner}/{repo} {工作包代號} [{PR 編號}] [{分析頁}]
# 領取工作包:記下這個存取庫目前歸誰做,供歸屬比對用。
# 由 jsc-sdlc 在領取工作包時呼叫,本檔不自己判斷歸誰。
# exit 0 = 已記下;exit 2 = 用法錯誤或寫不進狀態檔。
# sdlc-gate.sh wp-unclaim {owner}/{repo} 交回工作包,移除領取紀錄;檔案不存在也算成功。
# sdlc-gate.sh wp-report 印出 {owner}/{repo} {index} {上鎖時間} {工作包代號},每個未結清
# 工作包各一行;沒有未結清就不印,exit 0。查無歸屬時第四欄留白。
# sdlc-gate.sh wp-check prompt hook 模式(UserPromptSubmit):注入提醒,一律 exit 0。
# sdlc-gate.sh wp-check skill hook 模式(PreToolUse,matcher Skill):命中 analyze 或 maintain
# 時 exit 2 擋下該次呼叫;plan 與 implement 只注入提醒後 exit 0,
# 其餘技能一律 exit 0。
#
# 結束碼(一個子命令一列):
# lock 0=通過並已上鎖 1=未通過,呼叫端必須停止流程(階段名不合法、讀不到該
# 階段的必要標籤、判定不出目前模型、模型不在標籤表上、缺標籤、寫不進狀態檔)
# unlock 0=永遠成功,狀態檔不存在也算;不帶參數時新舊兩種路徑都會清掉
# 2=參數不是 sessions 目錄底下的 .stage 檔
# check 0=放行 2=擋下該輪提示。安靜放行只剩三種:沒有階段鎖、舊格式狀態檔讀不出
# 必要標籤、必要標籤是 any。判定不出目前模型改為擋下,見上面的 fail-closed
# report 0=永遠成功,無鎖就不印
# wp-lock 0=已記下 2=存取庫不是 {owner}/{repo}、PR 編號不是數字、寫不進狀態檔
# wp-unlock 0=已結清,狀態檔不存在也算 2=存取庫或 PR 編號格式錯誤
# wp-claim 0=已記下 2=存取庫格式錯誤、工作包代號不帶數字、寫不進領取檔
# wp-unclaim 0=已交回,領取檔不存在也算 2=存取庫格式錯誤
# wp-report 0=永遠成功,沒有未結清就不印
# wp-check prompt 0=永遠放行,只注入提醒
# wp-check skill 0=放行 2=擋下該次技能呼叫。安靜放行:逃生門 JSC_WP_GATE=off、沒有未結清
# 的工作包、取不到技能名、技能是 plan 或 implement、其餘不在名單上的技能。
# 只有 analyze 與 maintain 會 exit 2
# 不認得的子命令 0=安靜放行
# 註:本檔以 `. "$HERE/lib.sh"` 載入共用函式,沒有接 `|| true`。lib.sh 讀不到時 sh 會就地結束
# 並回 2;check 接在 UserPromptSubmit、wp-check skill 接在 PreToolUse,那一下都是無聲擋人,
# 上面那些放行路徑一條都跑不到。
#
# 鎖檔一個工作包一支($JSC_HOME/wp/{owner}-{repo}-{index}.pr),不是整個存取庫共用一支:
# SDLC 實作可能同時有好幾個互不相依的工作包平行進行,各自開各自的 PR。整庫共用一支鎖檔
# 只留得住「最後一個 lock 的那一包」,先前還沒合併的那幾包會被覆蓋掉,鎖跟著憑空消失。
# 這支鎖檔管的是「plan/analyze/maintain 能不能在這個存取庫上動」(見下方 wp-check skill),
# 跟「implement 挑下一個工作包能不能挑到某一包」是兩件事——後者的判斷依據是該包在分析頁
# WBS 表的相依欄,走 jsc-sdlc/tools/wp-gate.sh check-deps,不靠這支鎖檔。
#
# --- plan 已從擋人名單移出,被放棄的保護寫在這裡 ---
#
# plan 原本跟 analyze、maintain 一起被擋。現在改成只提醒、照樣放行,放棄的是「在製品上限」:
# 手上的工作包還沒結清就不准開新計畫。放棄的理由有兩個。一是 plan 是純邏輯階段,產出是計畫頁,
# 不碰程式碼,開一份新計畫不會動到那支未結清的 PR。二是這道閘門手上只有「這個存取庫有 PR
# 未合併」這一個事實,判不出新計畫跟那支 PR 有沒有關聯,擋下去多半是誤擋。
# 代價要據實看待:沒有東西再擋住計畫越積越多,計畫的產出速度可以快過實作。
# analyze 與 maintain 兩道仍在,上限只是晚一個階段才生效。
#
# --- 狀態檔格式(與 jsc-sdlc/tools/wp-gate.sh 對齊,兩邊都靠這段註解對格式) ---
#
# 兩種檔案都放在 $JSC_HOME/wp/ 底下,都是純文字 key=value,一行一欄位,順序不拘,
# 不認得的鍵一律忽略。格式刻意做到最簡,兩邊各自實作也對得上。
#
# 鎖檔 {owner}-{repo}-{index}.pr 一支未結清的工作包 PR
# repo={owner}/{repo} 存取庫
# index={PR 編號} PR 編號,純數字
# wp={工作包代號} 這支 PR 做的是哪一包,值取自分析頁 WBS 表那一欄的原字串;
# 查不到就留空
# locked={ISO 時間} 上鎖時間,UTC
#
# 領取檔 {owner}-{repo}.claim 這個存取庫目前領取中的工作包,一個存取庫一支
# repo={owner}/{repo} 存取庫
# wp={工作包代號} 目前領取的是哪一包
# pr={PR 編號} 這一包的 PR;還沒開 PR 就留空
# analyze={分析頁頁名} 歸屬認定的來源分析頁;只作記錄,本檔不去讀那一頁
# claimed={ISO 時間} 領取時間,UTC
#
# 舊版鎖檔是單行 TSV「{repo}<TAB>{index}<TAB>{上鎖時間}」,沒有工作包欄位。舊檔照樣讀得動,
# 讀出來的歸屬是空的,也就是查無歸屬、一律放行——不會因為換格式就把既有的鎖判成違規。
#
# 歸屬比對只做一件事:把鎖檔的 wp 跟同一個存取庫領取檔的 wp 比數字。兩邊都有值且不相等,
# 那支 PR 就不屬於目前領取的工作包。任何一邊查不到(沒有分析頁、沒寫工作包代號、領取檔不存在)
# 一律當查無歸屬並放行,理由與 version-guard.sh 相同:只擋確定違規,否則會把技能組維護鎖死。
#
# exit code 例外:其他 jsc hook 一律 exit 0 不中斷宿主 CLI;本檔 check 與 wp-check skill 是
# 刻意的例外——鎖存在且不合規時 exit 2 擋下。只用提示注入的話模型可以無視,閘門形同虛設。
# 無鎖、或資料不足無法判定時,仍照舊 exit 0 安靜降級。
HERE=$(dirname "$0"); . "$HERE/lib.sh"
read_stdin
hook_trace "sdlc-gate ${1:-}"
# 只有需要 stdin JSON 的子命令才讀它:模型判定要 transcript_path,session 判定要 session_id。
# wp-lock、wp-unlock、wp-claim、wp-unclaim、wp-report 兩者都不需要,而 read_stdin 在標準輸入
# 是管線又沒人關閉時會一直等——工具腳本(jsc-sdlc 的 wp-gate.sh)轉呼叫這些子命令時就這樣整支
# 卡死。新增子命令一律照這個原則歸類:不需要 stdin 就加進下面這一列。
case "${1:-}" in
wp-lock|wp-unlock|wp-claim|wp-unclaim|wp-report) STDIN_JSON="" ;;
*) read_stdin ;;
esac
sid=$(session_id)
state="$JSC_HOME/sessions/$sid.stage"
# 讀寫兩條路徑分開:寫入一律走帶 CLI 代號的新路徑,讀取則允許退回舊路徑(往後相容)。
state=$(stage_state_read_file)
state_new=$(stage_state_file)
TAGS_TSV="$JSC_HOME/model-tags.tsv"
STAGES="plan analyze implement maintain"
@@ -35,15 +147,21 @@ stage_tags() { # $1=階段
awk -F'\t' -v s="$1" '$1 == "stage" && $2 == s { print $3; exit }' "$TAGS_TSV"
}
# 某模型的能力標籤。模型鍵與實際 id 雙向包含即視為同一家族
#(例:表列 claude-haiku-4-5 對得上 claude-haiku-4-5-20251001);多筆命中取最長鍵。
# 某模型的能力標籤。查法:先找完全相同的鍵,沒有才退回「表列鍵是實際 id 的前綴」
# 之中最長的一筆(例:表列 claude-haiku-4-5 對得上 claude-haiku-4-5-20251001)。
# 只認前綴這個方向。反向包含(實際 id 是表列鍵的前綴)會讓 gpt-5.x 命中更長的
# gpt-5.x-mini,最長鍵勝出就把 mini 的標籤發給 gpt-5.x,把夠格的模型擋掉。
model_tags() { # $1=模型 id
[ -s "$TAGS_TSV" ] || return 0
awk -F'\t' -v m="$1" '
$1 == "model" && (index(m, $2) > 0 || index($2, m) > 0) {
$1 == "model" && $2 == m { exact = $3 }
$1 == "model" && $2 != m && substr(m, 1, length($2)) == $2 {
if (length($2) > best_len) { best_len = length($2); best = $3 }
}
END { if (best != "") print best }
END {
if (exact != "") print exact
else if (best != "") print best
}
' "$TAGS_TSV"
}
@@ -73,17 +191,133 @@ eligible_models() { # $1=必要標籤
' "$TAGS_TSV"
}
# 目前模型:transcript 實際值 > stdin JSON model > JSC_MODEL > ~/.claude/settings.json。
# transcript 排最前面,因為那是唯一可驗證的實際值,其餘都只是宣告值。
current_model() {
m=$(transcript_model)
[ -n "$m" ] || m=$(json_str model)
[ -n "$m" ] || m="${JSC_MODEL:-}"
if [ -z "$m" ] && { [ -z "${JSC_CLI:-}" ] || [ "${JSC_CLI:-}" = "claude" ]; }; then
m=$(tr -d '\n' < "$HOME/.claude/settings.json" 2>/dev/null \
| sed -n 's/.*"model"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n1)
model_info() {
current_model_report
}
model_verdict() { # $1=stage $2=req $3=model
stage="$1"; req="$2"; cur="$3"
[ -n "$cur" ] || { printf 'unknown-model'; return 0; }
[ "$req" = "any" ] && { printf 'pass'; return 0; }
have=$(model_tags "$cur")
[ -n "$have" ] || { printf 'unknown-model'; return 0; }
miss=$(missing_tags "$req" "$have")
[ -z "$miss" ] && printf 'pass' || printf 'fail:%s' "$miss"
}
# 逃生門說明。被擋住的人多半不熟這套東西,訊息要讓他照著做就脫困,所以兩條路都寫成
# 可以照抄的指令,而且把「可以填什麼」一併列出來。
gate_escape_hint() { # $1=必要標籤 $2=擋人的那一支狀態檔(可省略)
_ok=$(eligible_models "$1")
echo "逃生門二選一:"
echo " 1. 人工指明模型:設環境變數 JSC_MODEL={模型 id} 之後重來一次。標籤表上合格的模型 id:${_ok:-(表上沒有合格模型,請先補表)}。"
# 路徑照抄進指令裡:手動執行時算出來的檔名跟 hook 算出來的可能不同,不點名就解不到那一支。
if [ -n "${2:-}" ]; then
echo " 2. 解除這個階段的鎖:執行 sh $0 unlock $2。解除之後這道閘門就不再擋。"
else
echo " 2. 解除這個階段的鎖:執行 sh $0 unlock。解除之後這道閘門就不再擋。"
fi
printf '%s' "$m"
}
# 判不出 CLI 時追加一句。這是「不知道能力」的第一種,成因與修法都跟另外兩種不同:
# 不知道是哪一支 CLI,就不知道該讀誰的模型紀錄,接線設定要把代號帶上。
gate_cli_hint() {
[ "$(cli_name)" = unknown ] || return 0
echo "另外:目前判不出是哪一支 CLI(環境變數 JSC_CLI 沒有設),所以連該讀哪一份模型紀錄都不知道。接線設定應以 JSC_CLI={CLI 代號} 帶上。"
}
model_fail_message() { # $1=stage $2=checked $3=必要標籤
{
echo "[jsc][SDLC 閘門][ERR]:判定不出目前實際使用的模型,無法驗證是否符合階段「$1」(需要 ${3:-未知})。已檢查來源:${2:-無}。本次不進行任何工作。"
gate_cli_hint
gate_escape_hint "${3:-any}"
} >&2
}
# --- 工作包 PR 閘門(狀態檔:$JSC_HOME/wp/{owner}-{repo}-{index}.pr,一個工作包一支) ---
#
# 刻意不綁 session:PR 沒合併就是沒合併,換一個工作階段照樣要擋。綁 session 等於給閘門
# 留一道「開新對話就自動繞過」的門,規則就不再是強制的。
#
# 一律只讀檔案,絕不打網路:hook 要快、也要能離線用。PR 的真實合併狀態由
# jsc-sdlc/tools/wp-gate.sh 去查並負責結清,本檔只反映已記錄的未結清狀態。
WP_DIR="$JSC_HOME/wp"
wp_state_file() { # $1={owner}/{repo} $2=index
printf '%s/%s-%s.pr' "$WP_DIR" "$(printf '%s' "$1" | tr '/' '-')" "$2"
}
wp_claim_file() { # $1={owner}/{repo}
printf '%s/%s.claim' "$WP_DIR" "$(printf '%s' "$1" | tr '/' '-')"
}
# 取 key=value 檔案裡某個鍵的值;沒有那個鍵就不輸出。值裡的等號原樣保留。
wp_field() { # $1=檔案 $2=鍵名
[ -f "$1" ] || return 0
awk -v k="$2" '
index($0, k "=") == 1 {
v = substr($0, length(k) + 2)
gsub(/^[ \t]+|[ \t\r]+$/, "", v)
print v; exit
}' "$1" 2>/dev/null
}
# 讀一支鎖檔,印出「{owner}/{repo} {index} {上鎖時間} {工作包代號}」。
# 新格式(key=value)與舊格式(單行 TSV,無工作包欄位)都認:換格式不該讓既有的鎖失效。
wp_read_lock() { # $1=鎖檔
[ -f "$1" ] || return 0
awk '
/^[a-z][a-z]*=/ {
k = substr($0, 1, index($0, "=") - 1)
v = substr($0, index($0, "=") + 1)
gsub(/^[ \t]+|[ \t\r]+$/, "", v)
f[k] = v; kv = 1; next
}
NR == 1 { split($0, t, "\t") }
END {
if (kv) { repo = f["repo"]; idx = f["index"]; at = f["locked"]; wp = f["wp"] }
else { repo = t[1]; idx = t[2]; at = t[3]; wp = "" }
gsub(/\r/, "", at)
if (repo != "") print repo " " idx " " at (wp != "" ? " " wp : "")
}' "$1" 2>/dev/null
}
# 未結清清單,每行「{owner}/{repo} {index} {上鎖時間} {工作包代號}」;沒有就不輸出。
# 第四欄可能是空的,代表查無歸屬。
wp_pending() {
[ -d "$WP_DIR" ] || return 0
for _f in "$WP_DIR"/*.pr; do
[ -f "$_f" ] || continue
_line=$(wp_read_lock "$_f")
[ -n "$_line" ] && printf '%s\n' "$_line"
done
}
# 某個存取庫目前領取中的工作包代號;沒有領取檔或沒寫代號就不輸出(查無歸屬)。
wp_claimed() { # $1={owner}/{repo}
wp_field "$(wp_claim_file "$1")" wp
}
# 工作包代號取數值:去掉英文前綴與前導零,只留數字。範本補零到兩位,呼叫端不一定補,
# 也可能只給數字;比字串會讓同一包的幾種寫法互相認不得,歸屬就誤判成不同包。
wp_num() { # $1=工作包代號
printf '%s' "${1:-}" | tr -dc '0-9' | sed 's/^0*//'
}
# 未結清清單濃縮成一句可讀的「{repo} 第 {index} 號」,多筆用頓號串起。只吃前兩欄,
# 第四欄的工作包代號另外由歸屬比對處理,混進這句話只會把提醒句拉長。
wp_brief() { # 標準輸入 = wp_pending 的輸出
awk '{ out = (out == "" ? $1 " 第 " $2 " 號" : out "、" $1 " 第 " $2 " 號") } END { print out }'
}
# 存取庫參數格式檢查;不合格就回 1,由呼叫端印訊息後 exit 2。
wp_valid_repo() { # $1=參數
case "${1:-}" in
*/*/*|/*|*/) return 1 ;;
*/*) return 0 ;;
*) return 1 ;;
esac
}
case "${1:-check}" in
@@ -100,16 +334,22 @@ case "${1:-check}" in
exit 1
fi
cur=$(current_model)
info=$(model_info)
cur=$(printf '%s' "$info" | cut -f1)
src=$(printf '%s' "$info" | cut -f2)
checked=$(printf '%s' "$info" | cut -f3-)
if [ -z "$cur" ]; then
echo "[jsc][SDLC 閘門][ERR]:判定不出目前實際使用的模型,無法驗證是否符合階段「$stage」。請確認 transcript 可讀,或設定 JSC_MODEL 後重跑,本次不進行任何工作。" >&2
model_fail_message "$stage" "$checked" "$req"
exit 1
fi
if [ "$req" != "any" ]; then
have=$(model_tags "$cur")
if [ -z "$have" ]; then
echo "[jsc][SDLC 閘門][ERR]:模型「$cur」不在能力標籤表上,無法判定是否夠格跑階段「$stage」(需要 $req)。請把該模型補進 jsc-cli/references/model-tags.md 後執行 model-tags.sh sync,本次不進行任何工作。" >&2
{
echo "[jsc][SDLC 閘門][ERR]:模型「$cur」(來源 ${src:-未知})不在能力標籤表上,無法判定是否夠格跑階段「$stage」(需要 $req)。請把該模型補進 jsc-cli/references/model-tags.md 後執行 model-tags.sh sync,本次不進行任何工作。"
gate_escape_hint "$req"
} >&2
exit 1
fi
miss=$(missing_tags "$req" "$have")
@@ -120,13 +360,29 @@ case "${1:-check}" in
fi
fi
printf '%s\t%s\t%s\n' "$stage" "$req" "$cur" > "$state" 2>/dev/null || {
echo "[jsc][SDLC 閘門][ERR]:寫不進狀態檔 $state,階段鎖未生效。" >&2; exit 1; }
echo "[jsc][SDLC 閘門][OK]:階段「$stage」通過(需要 $req,目前模型 $cur),已上鎖。"
printf '%s\t%s\t%s\t%s\n' "$stage" "$req" "$cur" "$src" > "$state_new" 2>/dev/null || {
echo "[jsc][SDLC 閘門][ERR]:寫不進狀態檔 $state_new,階段鎖未生效。" >&2; exit 1; }
echo "[jsc][SDLC 閘門][OK]:階段「$stage」通過(需要 $req,目前模型 $cur,來源 $src),已上鎖。"
exit 0 ;;
unlock)
rm -f "$state" 2>/dev/null || true
target="${2:-}"
if [ -n "$target" ]; then
# 只認「上一層目錄叫 sessions、副檔名是 .stage」的路徑:逃生門不該順便變成任意刪檔的
# 工具。判準刻意不寫成「必須在這支行程算出來的 $JSC_HOME 底下」——擋人的是 hook,
# 解鎖的是人在殼層手動執行,兩邊的 JSC_HOME 未必相同,綁上去就會把照抄訊息的人擋掉,
# 那正是這個逃生門要避免的事。
case "$target" in
*/sessions/*.stage) ;;
*)
echo "[jsc][SDLC 閘門][ERR]:unlock 的參數只收 sessions 目錄底下的 .stage 檔,收到「$target」。不帶參數則清掉這個工作階段自己那一份。" >&2
exit 2 ;;
esac
rm -f "$target" 2>/dev/null || true
exit 0
fi
# 新舊兩種路徑都要清。只清新的話,舊格式的鎖永遠解不開,被它擋住的人就沒有逃生門。
rm -f "$state_new" "$(stage_state_legacy_file)" 2>/dev/null || true
exit 0 ;;
check)
@@ -137,16 +393,28 @@ case "${1:-check}" in
[ -n "$stage" ] && [ -n "$req" ] && [ "$stage" != "$req" ] || exit 0
[ "$req" = "any" ] && exit 0
cur=$(current_model)
# 判定不出模型時只提醒,不擋——否則使用者會被鎖在無法送出提示的狀態。
info=$(model_info)
cur=$(printf '%s' "$info" | cut -f1)
src=$(printf '%s' "$info" | cut -f2)
checked=$(printf '%s' "$info" | cut -f3-)
# 判定不出模型就擋下。這是刻意改成 fail-closed:放行等於「換一支讀不到紀錄的 CLI
# 就能繞過閘門」,閘門形同虛設。原本只提醒不擋,怕的是把人鎖在送不出提示的狀態——
# 那個風險是真的,所以訊息一定要把兩條逃生門寫清楚,照著做就能脫困。
if [ -z "$cur" ]; then
echo "[jsc] SDLC 階段「${stage}」需要標籤「${req}」,但判定不出目前模型。請自行確認模型是否合格。"
exit 0
{
echo "[jsc][SDLC 閘門][ERR]:SDLC 階段「${stage}」需要能力標籤「${req}」,但判定不出目前用的是哪個模型,也就無法確認夠不夠格。已檢查來源:${checked:-無}。本輪提示已擋下。"
gate_cli_hint
gate_escape_hint "$req" "$state"
} >&2
exit 2
fi
have=$(model_tags "$cur")
if [ -z "$have" ]; then
echo "[jsc][SDLC 閘門][ERR]:模型「${cur}」不在能力標籤表上,無法確認是否夠格跑階段「${stage}」(需要 ${req})。請補進 jsc-cli/references/model-tags.md 並執行 model-tags.sh sync;確定要放行請執行 jsc-hooks/hooks/sdlc-gate.sh unlock。本輪提示已擋下。" >&2
{
echo "[jsc][SDLC 閘門][ERR]:模型「${cur}」(來源 ${src:-未知})不在能力標籤表上,無法確認是否夠格跑階段「${stage}」(需要 ${req})。請補進 jsc-cli/references/model-tags.md 並執行 model-tags.sh sync。本輪提示已擋下。"
gate_escape_hint "$req" "$state"
} >&2
exit 2
fi
@@ -154,14 +422,141 @@ case "${1:-check}" in
[ -z "$miss" ] && exit 0
ok=$(eligible_models "$req")
echo "[jsc][SDLC 閘門][ERR]:SDLC 階段「${stage}」需要標籤「${req}」,目前模型「${cur}」缺少「${miss}」。請切換到下列任一模型後重送:${ok:-(表上無合格模型,請補表)}。要結束本階段的鎖請執行 jsc-hooks/hooks/sdlc-gate.sh unlock。本輪提示已擋下。" >&2
{
echo "[jsc][SDLC 閘門][ERR]:SDLC 階段「${stage}」需要標籤「${req}」,目前模型「${cur}」(來源 ${src:-未知})缺少「${miss}」。請切換到下列任一模型後重送:${ok:-(表上無合格模型,請補表)}。本輪提示已擋下。"
echo "要結束本階段的鎖請執行:sh $0 unlock $state"
} >&2
exit 2 ;;
report)
if [ -f "$state" ]; then
line=$(sed -n '1p' "$state" 2>/dev/null | tr '\t' ' ')
[ -n "$line" ] && echo "$sid $line"
stage=$(cut -f1 "$state" 2>/dev/null | head -n1)
req=$(cut -f2 "$state" 2>/dev/null | head -n1)
locked_model=$(cut -f3 "$state" 2>/dev/null | head -n1)
locked_source=$(cut -f4 "$state" 2>/dev/null | head -n1)
info=$(model_info)
cur=$(printf '%s' "$info" | cut -f1)
src=$(printf '%s' "$info" | cut -f2)
[ -n "$cur" ] || cur="$locked_model"
[ -n "$src" ] || src="$locked_source"
verdict=$(model_verdict "$stage" "$req" "$cur")
[ -n "$stage" ] && echo "$sid $stage $req ${cur:-未知} ${src:-未知} $verdict"
fi
exit 0 ;;
wp-lock)
repo="${2:-}"; idx="${3:-}"; wp="${4:-}"
wp_valid_repo "$repo" || {
echo "[jsc][工作包閘門][ERR]:存取庫須為 {owner}/{repo} 格式,收到「${repo:-空值}」。" >&2; exit 2; }
case "$idx" in
''|*[!0-9]*)
echo "[jsc][工作包閘門][ERR]:PR 編號須為數字,收到「${idx:-空值}」。" >&2; exit 2 ;;
esac
mkdir -p "$WP_DIR" 2>/dev/null || true
wpf=$(wp_state_file "$repo" "$idx")
printf 'repo=%s\nindex=%s\nwp=%s\nlocked=%s\n' "$repo" "$idx" "$wp" "$(now_iso)" > "$wpf" 2>/dev/null || {
echo "[jsc][工作包閘門][ERR]:寫不進狀態檔 $wpf,工作包鎖未生效。" >&2; exit 2; }
echo "[jsc][工作包閘門][OK]:已記下 $repo 第 $idx 號 PR 未結清${wp:+(工作包 $wp)}。相依於它的工作包在它結清前不得開始;其餘互不相依的工作包不受影響。"
[ -n "$wp" ] || echo "[jsc][工作包閘門]:這筆沒帶工作包編號,歸屬比對查不到來源,之後一律放行。要擋跨工作包的 PR,請在 wp-lock 帶上第四個參數。"
exit 0 ;;
wp-claim)
repo="${2:-}"; wp="${3:-}"; pr="${4:-}"; page="${5:-}"
wp_valid_repo "$repo" || {
echo "[jsc][工作包閘門][ERR]:存取庫須為 {owner}/{repo} 格式,收到「${repo:-空值}」。" >&2; exit 2; }
[ -n "$(wp_num "$wp")" ] || {
echo "[jsc][工作包閘門][ERR]:工作包代號須帶數字(例:分析頁上那個 WP 開頭的代號),收到「${wp:-空值}」。" >&2; exit 2; }
mkdir -p "$WP_DIR" 2>/dev/null || true
cf=$(wp_claim_file "$repo")
printf 'repo=%s\nwp=%s\npr=%s\nanalyze=%s\nclaimed=%s\n' \
"$repo" "$wp" "$pr" "$page" "$(now_iso)" > "$cf" 2>/dev/null || {
echo "[jsc][工作包閘門][ERR]:寫不進領取檔 $cf,歸屬比對不會生效。" >&2; exit 2; }
echo "[jsc][工作包閘門][OK]:已記下 $repo 領取中的工作包為 $wp。這個存取庫的其他工作包 PR 一律不由這裡結清。"
exit 0 ;;
wp-unclaim)
repo="${2:-}"
wp_valid_repo "$repo" || {
echo "[jsc][工作包閘門][ERR]:存取庫須為 {owner}/{repo} 格式,收到「${repo:-空值}」。" >&2; exit 2; }
# 冪等,理由同 wp-unlock:交回流程可能被重跑,第二次失敗只會讓呼叫端誤判。
rm -f "$(wp_claim_file "$repo")" 2>/dev/null || true
exit 0 ;;
wp-unlock)
repo="${2:-}"; idx="${3:-}"
wp_valid_repo "$repo" || {
echo "[jsc][工作包閘門][ERR]:存取庫須為 {owner}/{repo} 格式,收到「${repo:-空值}」。" >&2; exit 2; }
case "$idx" in
''|*[!0-9]*)
echo "[jsc][工作包閘門][ERR]:PR 編號須為數字,收到「${idx:-空值}」。" >&2; exit 2 ;;
esac
# 冪等:狀態檔不存在也算成功。結清流程可能被重跑,第二次失敗只會讓呼叫端誤判。
rm -f "$(wp_state_file "$repo" "$idx")" 2>/dev/null || true
exit 0 ;;
wp-report)
wp_pending
exit 0 ;;
wp-check)
[ "${JSC_WP_GATE:-}" = "off" ] && exit 0
pending=$(wp_pending)
[ -n "$pending" ] || exit 0
brief=$(printf '%s\n' "$pending" | wp_brief)
# 逐筆判歸屬:鎖檔的工作包代號對上同一個存取庫領取中的代號,才算自己這一包的 PR。
# 兩邊任一邊查不到代號就算查無歸屬,歸進 mine 這一側處理——查不到不是違規的證據。
# 迴圈用 here-document 餵資料,不用管線:管線右邊是子 shell,分類結果傳不回來,
# 判到的違規會在迴圈結束的瞬間全部消失。
mine=''; foreign=''
while read -r _r _i _t _w; do
[ -n "$_r" ] || continue
_a=$(wp_num "$_w"); _b=$(wp_num "$(wp_claimed "$_r")")
if [ -n "$_a" ] && [ -n "$_b" ] && [ "$_a" != "$_b" ]; then
foreign="${foreign}${foreign:+、}$_r 第 $_i 號($_w)"
else
mine="${mine}${mine:+、}$_r 第 $_i 號"
fi
done <<WP_PENDING_EOF
$pending
WP_PENDING_EOF
case "${2:-prompt}" in
prompt)
# 只注入提醒,一律 exit 0。擋提示會連「去把那支 PR 修好」的對話都送不出去,
# 把使用者鎖在門外,連逃生門都下不了指令。
# 這裡只列得出「哪些工作包還沒結清」,不知道候選包相依於誰——沒有上下文可以判斷。
# 真正「這一包能不能挑」的判斷在 jsc-sdlc/tools/wp-gate.sh check-deps,這則只是提醒。
echo "[jsc] ${brief} PR 尚未合併。相依於它的工作包不能開始,其餘互不相依的工作包不受影響——是否可挑,見 wp-gate.sh check-deps。"
[ -n "$foreign" ] && echo "[jsc] 其中 ${foreign}不屬於這個存取庫領取中的工作包。那幾支交給領取它的工作階段結清:這裡不要改它的程式碼,也不要回它的留言。"
exit 0 ;;
skill)
# 技能名取法比照 version-guard.sh:環境變數優先,非 Claude CLI 只餵得到環境變數。
skill="${JSC_SKILL:-${SKILL:-$(json_str skill)}}"
# 取不到技能名就安靜降級放行,不能拿沒有的資料當擋人的理由。
[ -n "$skill" ] || exit 0
# 帶前綴(jsc-sdlc:plan)與裸名(plan)都要認。
sname=${skill##*:}
case "$sname" in
analyze|maintain)
echo "[jsc][工作包閘門][ERR]:${brief} PR 尚未合併,禁止在此存取庫執行「${sname}」。${foreign:+其中 ${foreign}還不屬於領取中的工作包,更不該由這裡處理。}請先把該 PR 結清(合併或關閉),或執行 jsc-hooks/hooks/sdlc-gate.sh wp-unlock {owner}/{repo} {index} 解除;確定要整體放行請設 JSC_WP_GATE=off。本次技能呼叫已擋下。" >&2
exit 2 ;;
plan)
# plan 只提醒不擋,理由見檔頭「plan 已從擋人名單移出」。放棄的是在製品上限,
# 換來的是不再誤擋跟那支 PR 無關的新計畫;analyze 與 maintain 兩道仍在。
echo "[jsc][工作包閘門]:${brief} PR 尚未合併。plan 是純邏輯階段、不碰程式碼,這裡只提醒不擋,但新計畫排進實作前請先把它結清。"
[ -n "$foreign" ] && echo "[jsc][工作包閘門]:${foreign}不屬於這個存取庫領取中的工作包。那幾支交給領取它的工作階段結清:這裡不要改它的程式碼,也不要回它的留言。"
exit 0 ;;
implement)
# implement 一律放行,連別包的 PR 未結清也放行:結清 PR 正是 implement 步驟 2 要做
# 的事,擋掉就沒有任何路徑能解除這道鎖。領取檔不綁工作階段,擋下去連領取那一包的
# 工作階段都會被自己的舊紀錄擋住,等於把流程鎖死。所以這裡只注入歸屬提醒。
[ -n "$foreign" ] && echo "[jsc][工作包閘門]:${foreign}不屬於這個存取庫領取中的工作包。這裡只結清自己領取那一包的 PR${mine:+(${mine})};別包的 PR 不要改、留言也不要回,交給領取它的工作階段。"
exit 0 ;;
esac
# 其餘技能一律放行:這道閘門管的是 SDLC 階段,不是整台機器的技能呼叫。
exit 0 ;;
esac
exit 0 ;;
esac
exit 0
+175
View File
@@ -0,0 +1,175 @@
#!/usr/bin/env sh
# session-reminder.sh — 工作階段開始時,把助理算好的未讀提醒帶到前景。
#
# 用法:
# session-reminder.sh # SessionStart:印出未讀提醒,並記下這個工作階段提過了
# session-reminder.sh peek # 只印,不記。人要重看一次時用,也給檢核用
#
# 結束碼:0=一律成功,只有這一種。這一支接在工作階段開始那個事件上,它的產出走 stdout,
# 不走結束碼——那個事件的 stdout 會成為額外 context。佇列不在、讀不到、格式對不上,
# 一律印一行說明然後 exit 0;一支在每個工作階段開頭都會跑的 hook 絕對不可以擋人。
# 唯一的非零來源是 `. lib.sh` 載入失敗,那時 sh 自己回 2。
#
# --- 這一支不做判定 ---
#
# 提醒該不該送、哪幾筆該送,全由助理那一輪算完寫進佇列(jsc-assist 的 tools/patrol.sh
# 每一輪重寫 $JSC_HOME/assistant/reminders.tsv)。這裡只把那份檔案印出來。
# 不自己判的理由有兩個。一是快:這一支跑在每一個工作階段的開頭,讀一個檔案就回來。
# 二是不漂移:自己拿 due 欄與 next_run 去跟現在比,就是第二套到期判定,跟助理那一套遲早
# 對不上,而對不上的那一天兩邊都說自己是對的。
#
# --- 「沒有提醒」與「沒有人算提醒」不可以長得一樣 ---
#
# 只印佇列換來一個新的失效模式:助理停了,佇列就不再更新,而一份舊佇列讀起來跟新的一模一樣。
# 所以佇列檔頭帶著那一輪的時間戳,這裡算出它多舊,超過心跳門檻就明說「這份提醒是多久以前
# 算的、助理現在的心跳是什麼狀態」。門檻與狀態都取 heartbeat.sh 印的那一行,不自己定一套。
#
# 助理的狀態目錄根本不存在時,這一支一個字都不印:那代表這台機器從沒啟動過助理,
# 每個工作階段開頭都催一次「你要不要啟動助理」不是提醒,是噪音。
#
# --- 逐筆點名與只算總數,分開兩種 ---
#
# 逾期與使用者自己登錄的提醒逐筆點名:人看到就做得了。委派清單種入的內建項只算一個總數,
# 因為那幾筆等的是接線不是人,每一輪都到期、每一輪都一樣。分種類的判定在寫佇列那一邊,
# 這裡只照它標好的種類決定怎麼印。
#
# --- 一個工作階段只提一次 ---
#
# 記號檔是 $JSC_HOME/sessions/{工作階段}.reminded。工作階段開始那個事件在續接同一階段時
# 會再觸發,沒有記號就會每次都再提一次同一批。
# 接不到工作階段代號的 CLI(記號都落在 default 上)另有一條路:session-timer.sh 的 restart
# 分支會把這個記號刪掉。判定「這是不是新的工作階段」只有那一支知道,所以刪除掛在那裡,
# 這裡不自己再判一次。
HERE=$(dirname "$0"); . "$HERE/lib.sh"
hook_trace "session-reminder ${1:-}"
read_stdin
sid=$(session_id)
MODE="${1:-show}"
STATE_DIR="$JSC_HOME/assistant"
QUEUE="$STATE_DIR/reminders.tsv"
MARK="$JSC_HOME/sessions/$sid.reminded"
MAX_ROWS=8
# 助理沒啟動過就整支安靜退出。
[ -d "$STATE_DIR" ] || exit 0
# 這個工作階段提過了就不再提。peek 一律印,那是人自己要重看。
if [ "$MODE" != peek ] && [ -f "$MARK" ]; then
exit 0
fi
mark_done() {
[ "$MODE" = peek ] && return 0
mkdir -p "$JSC_HOME/sessions" 2>/dev/null || return 0
now_epoch >"$MARK" 2>/dev/null || true
return 0
}
if [ ! -f "$QUEUE" ]; then
echo "[jsc] 助理的狀態目錄在,但還沒有提醒佇列($QUEUE)。跑過一輪巡檢才會產生,所以這裡沒有提醒**不代表沒有事要做**——要現在看就跑 /jsc-assist:assistant status。"
mark_done
exit 0
fi
TAB=$(printf '\t')
# 檔頭。第一行不是 round 就是格式不對,或者檔案被別的東西蓋掉了,兩種都照實說。
_head=$(head -n1 "$QUEUE" 2>/dev/null)
_kind=$(printf '%s' "$_head" | cut -f1)
if [ "$_kind" != round ]; then
echo "[jsc] 提醒佇列($QUEUE)的第一行不是輪次資訊,這一份讀不了。助理下一輪會重寫;在那之前要看待辦就跑 /jsc-assist:assistant status。"
mark_done
exit 0
fi
_iso=$(printf '%s' "$_head" | cut -f3)
_failing=$(printf '%s' "$_head" | cut -f4)
_epoch=$(printf '%s' "$_head" | cut -f5)
_tasks=$(printf '%s' "$_head" | cut -f6)
case "${_failing:-}" in ''|*[!0-9]*) _failing=0 ;; esac
case "${_tasks:-}" in ''|*[!0-9]*) _tasks=0 ;; esac
# 佇列有多舊。門檻與心跳狀態一律取 heartbeat.sh 印的那一行:那是這台機器判定「助理還在跑」
# 的唯一一套規則,這裡再定一套就會出現兩個說法。
_age=''
case "${_epoch:-}" in
''|*[!0-9]*) ;;
*) _age=$(( $(now_epoch) - _epoch )) ;;
esac
_hb=$(sh "$HERE/heartbeat.sh" report </dev/null 2>/dev/null || true)
_hbstate=$(printf '%s' "$_hb" | sed -n 's/^state=\([a-z]*\).*/\1/p')
_ttl=$(printf '%s' "$_hb" | sed -n 's/.*[[:space:]]ttl=\([0-9]*\).*/\1/p')
case "${_ttl:-}" in ''|*[!0-9]*) _ttl=300 ;; esac
# 兩種列分開數。逐筆點名的是逾期與使用者自己登錄的提醒;內建項只算一個總數。
#
# 為什麼內建項不逐筆點名:那幾筆等的是接線,不是人。動作是「只提醒」的內建項到現在還沒有
# 執行入口,所以每一輪都到期、每一輪都一樣。實測踩到:這台機器八筆全是那一種,於是每一個
# 工作階段開頭固定吐八行一模一樣的東西——那不是提醒,是噪音,而這一支自己的註解裡就寫著
# 「對著一個刻意的決定每個工作階段催一次,那是噪音不是提醒」。
# 分種類的判定在寫佇列那一邊(那裡讀得到 spec_key),這裡只照它標好的種類決定怎麼印。
_rows=$(awk -F"$TAB" '$1 == "overdue" || $1 == "remind" { n++ } END { print n + 0 }' "$QUEUE" 2>/dev/null)
case "${_rows:-}" in ''|*[!0-9]*) _rows=0 ;; esac
_builtin=$(awk -F"$TAB" '$1 == "builtin" { n++ } END { print n + 0 }' "$QUEUE" 2>/dev/null)
case "${_builtin:-}" in ''|*[!0-9]*) _builtin=0 ;; esac
_stale=0
if [ -n "$_age" ] && [ "$_age" -gt "$_ttl" ]; then _stale=1; fi
[ "$_hbstate" = fresh ] || _stale=1
if [ "$_rows" -eq 0 ] && [ "$_failing" -eq 0 ] && [ "$_builtin" -eq 0 ]; then
# 佇列是新的而且空的:這才是真的「沒有提醒」,安靜退出。
# 過期又空的那一種要分兩路。待辦簿有東西,就代表「有事而現在沒有人在算它到期沒到期」,
# 那要說一句;零筆就只是助理閒著,人自己按停也算這一種——對著一個刻意的決定每個工作階段
# 催一次,那是噪音不是提醒。
if [ "$_stale" -eq 1 ] && [ "$_tasks" -gt 0 ]; then
_agetxt='年紀算不出來'
[ -n "$_age" ] && _agetxt="$(( _age / 60 )) 分鐘前"
echo "[jsc] 助理的提醒清單是 ${_iso:-未知時間}($_agetxt)那一輪算的,那一輪沒有提醒;心跳現在是「${_hbstate:-讀不到}」。待辦簿還有 $_tasks 筆,**助理停著的時候沒有人再判它們到期了沒有**,所以這裡安靜不等於沒事。要現況就跑 /jsc-assist:assistant status。"
fi
# 記號一律記:不記的話同一個工作階段每次觸發都會再讀一次、再說一次同一句話。
mark_done
exit 0
fi
if [ "$_stale" -eq 1 ]; then
_agetxt='年紀算不出來'
[ -n "$_age" ] && _agetxt="$(( _age / 60 )) 分鐘前算的"
echo "[jsc] 下面這批提醒是 ${_iso:-未知時間}($_agetxt)那一輪算出來的,助理現在的心跳是「${_hbstate:-讀不到}」,門檻 $_ttl 秒。**助理停著的時候這份清單不會更新**,所以它現在說什麼都只描述那一輪,不描述現在。要現況就跑 /jsc-assist:assistant status。"
fi
# 逾期排前面:那幾筆的截止時間已經過了,比「該做了」更急。
#
# 先把兩種併成一份有序清單,再用 head 截筆數,不在迴圈裡自己數。
# 理由是那個迴圈接在管線後面,殼會把它放進子殼跑——在子殼裡加的計數,回到外面就沒了,
# 於是那道「只列前幾筆」的上限看起來寫了,實際上一次都沒生效。
_ordered=$(
awk -F"$TAB" '$1 == "overdue" { print }' "$QUEUE" 2>/dev/null
awk -F"$TAB" '$1 == "remind" { print }' "$QUEUE" 2>/dev/null
)
printf '%s\n' "$_ordered" | head -n "$MAX_ROWS" | while IFS="$TAB" read -r _k _id _why _title; do
[ -n "$_id" ] || continue
if [ "$_k" = overdue ]; then
echo "[jsc] 逾期 $_id:${_title:--} —— 已經逾期 ${_why:--}"
else
echo "[jsc] 到期 $_id:${_title:--} —— ${_why:--}"
fi
done
if [ "$_rows" -gt "$MAX_ROWS" ]; then
echo "[jsc] 這一批共 $_rows 筆,上面只列了 $MAX_ROWS 筆。其餘的跑 /jsc-assist:assistant status 看得到全部。"
fi
# 內建項一行講完,逐筆的內容留在監控頁與狀態查詢那邊。
# 這一行會一直出現,直到那幾筆有執行入口或被改掉——講明它是常態,讀的人才不會每次都
# 當成新消息。
if [ "$_builtin" -gt 0 ]; then
echo "[jsc] 另有 $_builtin 筆委派清單的內建檢查項到期,動作是只提醒、還沒有執行入口,所以每一輪都會再到期一次。**這一行會一直出現,直到那幾筆接上入口或被改掉**——要逐筆看就跑 /jsc-assist:assistant status。"
fi
if [ "$_failing" -gt 0 ]; then
echo "[jsc] 另有 $_failing 筆待辦連續失敗,每一輪都在重試而且不會自動暫停。跑 /jsc-assist:assistant status 看是哪幾筆。"
fi
mark_done
exit 0
+46 -2
View File
@@ -1,16 +1,60 @@
#!/usr/bin/env sh
# session-timer.sh — 記錄工作階段花費時間(供 jsc-log:worklog 取用)。
# 用法:
# session-timer.sh start # SessionStart:記錄起始時間
# session-timer.sh start # SessionStart:記錄起始時間(已有紀錄就不動)
# session-timer.sh restart # SessionStart:一律覆寫起始時間
# session-timer.sh mark # Stop/SessionEnd:更新最後活動時間
# session-timer.sh report [sid] # 印出 {sid} {seconds};無紀錄印 0
#
# 結束碼:0=一律成功,四個子命令都走到最後那一行 exit 0,不認得的子命令也一樣(case 沒有
# 相符分支就直接落到那一行)。這支接在 SessionStart 與 Stop/SessionEnd 上,本來就不擋人:
# 狀態檔寫不進去只是少一筆計時,report 讀不到起始時間就印 0,都照樣 exit 0。
# 唯一的非零來源是 lib.sh 載入失敗:本檔以 `. "$HERE/lib.sh"` 載入,沒有接 `|| true`,
# 檔案不在時 sh 會就地結束並回 2。這兩個事件不擋工具呼叫,回 2 只會在宿主留下一筆 hook 錯誤。
#
# start 與 restart 的差別在「同一個 session id 會不會重複開始」:
# start 給 Claude 這種每個工作階段都有自己 session id 的 CLI。續接同一階段時
# SessionStart 會再觸發一次,覆寫起始時間會讓花費時間歸零。
# restart 給接不到 session id 的 CLI(kiro)。那些 CLI 的紀錄共用 default,
# 不覆寫就會把上一個工作階段的起始時間算進來,花費時間虛胖。
#
# restart 另外清掉提醒記號($JSC_HOME/sessions/{代號}.reminded):那個記號讓提醒一個工作
# 階段只提一次,而共用 default 代號的 CLI 不清就等於只提第一次、往後永遠不提。
#
# 這兩個子命令另外兼一件事:判定為「新的工作階段」時清除部署後的重啟閘門
# (restart-gate.sh clear)。新工作階段代表 CLI 行程是新起的,新版技能組一定已經載入。
# 判準只有這裡知道——start 分支的「起始檔不存在」就是這個 session id 第一次開始,
# 所以清除掛在這裡,不在 restart-gate.sh 裡自己再判一次。
# 清除的範圍是「跑到這一支腳本的那個 CLI 自己那一份狀態檔」,由 restart-gate.sh clear 認定,
# 這裡不必也不能過問:這個工作階段開始的只有一支 CLI,別支沒重啟,閘門要留著。
HERE=$(dirname "$0"); . "$HERE/lib.sh"
hook_trace "session-timer ${1:-}"
read_stdin
sid=$(session_id)
# 放下這一支 CLI 的部署後重啟閘門。狀態檔的路徑、範圍與格式只留在 restart-gate.sh,
# 這裡不碰檔案,所以改成一支 CLI 一份狀態檔之後這裡不用跟著改。
# 一律 </dev/null:clear 不讀標準輸入,但這裡的標準輸入是宿主餵進來的管線,不關掉會卡住。
clear_restart_gate() {
sh "$HERE/restart-gate.sh" clear </dev/null 2>/dev/null || true
}
case "${1:-mark}" in
start)
f="$JSC_HOME/sessions/$sid.start"
[ -f "$f" ] || now_epoch > "$f" ;;
if [ ! -f "$f" ]; then
now_epoch > "$f"
clear_restart_gate # 起始檔不存在=這個工作階段第一次開始,也就是行程新起的那一次
fi ;;
restart)
now_epoch > "$JSC_HOME/sessions/$sid.start"
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 每次工作階段開始都算新的,一律清
mark)
now_epoch > "$JSC_HOME/sessions/$sid.end" ;;
report)
+1019
View File
File diff suppressed because it is too large Load Diff
+106
View File
@@ -0,0 +1,106 @@
#!/usr/bin/env sh
# skill-name.sh — 從各 CLI 的 hook 負載解析出「這一次要用哪一支 jsc 技能」。
#
# 用途:五支 CLI 的負載形態各不相同,但「從負載取出 domain 與技能名」是同一件事。
# 規則只留這一份:寫在每支閘門裡就會漂移,CLI 換了負載形態也只要改這一個地方。
#
# 用法:skill-name.sh {claude|codex|copilot|antigravity|kiro}
# 從標準輸入讀該 CLI 的 hook 負載,印出一行「{domain}<TAB>{技能名}」,例如「sdlc<TAB>implement」。
# 解析不出來就印空字串,由呼叫端安靜放行。
#
# 各 CLI 的取值來源:
# claude stdin JSON 的 skill 欄位(PreToolUse matcher Skill 才會有)
# codex stdin JSON 的 tool_input.command 裡那條 SKILL.md 路徑。Codex 沒有 Skill 工具,
# 技能是模型自己用 Bash 讀 SKILL.md 載入的,所以要從指令字串裡認路徑
# copilot stdin JSON 的 toolArgs。那個欄位是**字串化的 JSON**,要先剝一層跳脫才讀得到裡面的值
# antigravity stdin JSON 的 toolCall.args.AbsolutePath;另外收 PreInvocation 那一輪的提示字串,
# 因為斜線指令會把 SKILL.md 全文直接注入訊息,一個工具呼叫都不產生,PreToolUse 攔不到
# kiro stdin JSON 的 prompt,取開頭那個「/{技能名}」
# 五支都先看環境變數 JSC_SKILL、SKILL:接線時用環境變數餵資料的 CLI 要收得到,冒煙測試也走這條。
#
# 為什麼只認 jsc 技能:呼叫這支腳本的是 jsc 自己的閘門,別人的技能不歸它們管。解不出 jsc-{domain}
# 這個形狀就等同「這一次不是 jsc 技能」,印空字串比印半個結果安全——呼叫端只要判空就好,不必再
# 自己補一次「這是不是我們的技能」的判斷,那正是會漂移的那一段。
#
# 結束碼:
# 0 永遠是 0,含「解析不出來」與「CLI 代號不認得」兩種。這支腳本只解析、不判定:
# 閘門那一端一律 fail-open,解析失敗回非零只會讓呼叫端多一條沒必要的錯誤分支。
# copilot 的 command hook 是 fail-closed 的(非零結束碼等於拒絕),更不能回非零。
# 本檔沒有其他結束碼。
set -u
CLI="${1:-}"
PAYLOAD=""
[ -t 0 ] || PAYLOAD=$(cat 2>/dev/null || true)
# 把整份負載併成一行再取「某個欄位之後的內容」。不切逗號:命令字串裡本來就有逗號,
# 切了會把路徑攔腰砍斷。貪婪比對取的是最後一次出現的那個欄位,巢狀負載也指得到裡層那一個。
after_field() { # $1=欄位名
printf '%s' "$PAYLOAD" | tr -d '\n' \
| sed -n "s/.*\"$1\"[[:space:]]*:[[:space:]]*//p"
}
# 從一段文字取第一條 SKILL.md 路徑。刻意不去解那個 JSON 字串的值:值裡的引號是跳脫過的,
# 照欄位邊界取會在第一個 \" 就被截斷,反而讀不到路徑。認路徑本身的形狀最穩。
md_path() { # $1=文字
printf '%s' "$1" | grep -o '/[A-Za-z0-9_./-]*SKILL\.md' | head -n1
}
# 從一段文字取第一個 jsc-{domain}:{技能名} 字樣
token_skill() { # $1=文字
printf '%s' "$1" | grep -o 'jsc-[a-z0-9][a-z0-9-]*:[a-z0-9][a-z0-9-]*' | head -n1
}
# jsc-{domain}:{技能名} → 兩欄輸出
emit_token() { # $1=技能名字樣
[ -n "$1" ] || return 0
_d=${1#jsc-}; _d=${_d%%:*}
_n=${1#*:}
[ -n "$_d" ] && [ -n "$_n" ] || return 0
printf '%s\t%s\n' "$_d" "$_n"
}
# SKILL.md 路徑 → 兩欄輸出。domain 取路徑裡最後一段 jsc-{domain},技能名取 SKILL.md 的上一層目錄,
# 所以 {前綴}/jsc-sdlc/skills/implement/SKILL.md 與 {前綴}/jsc-sdlc/implement/SKILL.md 都解得出來。
emit_path() { # $1=路徑
[ -n "$1" ] || return 0
_d=$(printf '%s' "$1" | sed -n 's#.*/jsc-\([a-z0-9][a-z0-9-]*\)/.*#\1#p')
_n=$(printf '%s' "$1" | sed -n 's#.*/\([^/][^/]*\)/SKILL\.md$#\1#p')
[ -n "$_d" ] && [ -n "$_n" ] || return 0
printf '%s\t%s\n' "$_d" "$_n"
}
# 環境變數優先。接線時用環境變數餵資料的 CLI 只有這一條路,負載再怎麼解也解不出東西。
env_skill="${JSC_SKILL:-${SKILL:-}}"
if [ -n "$env_skill" ]; then
emit_token "$(token_skill "$env_skill")"
exit 0
fi
case "$CLI" in
claude)
emit_token "$(token_skill "$(after_field skill)")" ;;
codex)
emit_path "$(md_path "$(after_field command)")" ;;
copilot)
# 剝一層字串化 JSON:把 \" 還原成 "、\\ 還原成 \,裡面的技能名才認得出來。
_args=$(after_field toolArgs | sed 's/\\"/"/g; s/\\\\/\\/g')
_tok=$(token_skill "$_args")
if [ -n "$_tok" ]; then emit_token "$_tok"; else emit_path "$(md_path "$_args")"; fi ;;
antigravity)
_p=$(md_path "$(after_field AbsolutePath)")
if [ -n "$_p" ]; then
emit_path "$_p"
else
# PreInvocation 那一輪沒有工具呼叫,只有提示字串。斜線指令走的就是這條路。
emit_token "$(token_skill "$(after_field prompt)")"
fi ;;
kiro)
# 提示開頭那個斜線指令。kiro 的技能不走工具管線,userPromptSubmit 是唯一看得到技能名的時點。
emit_token "$(token_skill "$(after_field prompt)")" ;;
*)
: ;; # 認不得的代號印空字串,理由見檔頭結束碼那一段
esac
exit 0
+13
View File
@@ -5,7 +5,14 @@
# 產出:
# $JSC_HOME/usage/skills.jsonl {ts,cli,session,skill}
# $JSC_HOME/usage/chains.jsonl {ts,cli,session,from,to}(同 session 內前一技能 → 本技能)
#
# 結束碼:0=一律成功,只有這一種正常碼。取不到技能名(JSC_SKILL 與 stdin JSON 都沒有)就
# 安靜降級,不寫任何紀錄直接 exit 0;寫得成紀錄也是 exit 0。這支接在 PostToolUse,
# 技能已經跑完了,結束碼擋不掉任何事,所以連寫檔失敗都不回報。
# 唯一的非零來源同 session-timer.sh:本檔以 `. "$HERE/lib.sh"` 載入,沒有接 `|| true`,
# lib.sh 讀不到時 sh 會就地結束並回 2。
HERE=$(dirname "$0"); . "$HERE/lib.sh"
hook_trace "skill-usage ${1:-}"
read_stdin
skill="${JSC_SKILL:-$(json_str skill)}"
[ -n "$skill" ] || exit 0
@@ -19,4 +26,10 @@ if [ -n "$last" ]; then
"$ts" "$cli" "$sid" "$last" "$skill" >> "$JSC_HOME/usage/chains.jsonl"
fi
printf '%s' "$skill" > "$last_f"
# 技能的 start 事件在這裡發,不必改任何 SKILL.md:這支接在技能指示載入之後,那一刻
# 就是「技能開始跑」。end 只能由技能自己在收尾步驟寫——本 hook 觸發時,技能的實際工作
# 還在後面的模型輪次,看不到成敗。有 start 沒有配對的 end,就是那一輪中止了。
emit_event skill "$skill" start ok 0
exit 0
+5 -1
View File
@@ -3,6 +3,10 @@
# Claude: UserPromptSubmit 的 stdout 會成為額外 context。
# 其他 CLI: 由 hooks-install 以各自的規則檔(AGENTS.md 等)落地,本腳本仍可被 wrapper 呼叫。
# 完整規則的唯一來源:jsc-meta 的 references/ste100.md。
echo "[jsc] 輸出規則:STE100 繁體中文,擬人台灣感。短句、一句一指令、主動語態、術語一致;台灣用語(預設、支援、相容、資訊);全形標點;去 AI 味(不用「總的來說」「首先/其次/最後」開場收尾套路、不諂媚);直接講重點,UTF-8 無亂碼。完整規則見 jsc-meta 的 references/ste100.md。"
#
# 結束碼:0=一律放行,而且只有這一種。本檔只把規則文字印到 stdout,不讀輸入、不碰檔案、
# 不載入 lib.sh,所以沒有任何擋人路徑,也沒有會冒出非零碼的降級路徑。
# UserPromptSubmit 的 stdout 會成為額外 context,這支的產出走的是 stdout,不是結束碼。
echo "[jsc] 輸出規則:STE100 繁體中文,擬人台灣感。適用範圍是所有非程式碼輸出——程式碼註解、commit 訊息、PR 描述、wiki 頁、對使用者的回報、README 與各種文件都算。短句、一句一指令、主動語態、術語一致;台灣用語(預設、支援、相容、資訊);全形標點;去 AI 味(不用「總的來說」「首先/其次/最後」開場收尾套路、不諂媚);直接講重點。一律 UTF-8 無亂碼、無簡體字,檔案不加 BOM。完整規則見 jsc-meta 的 references/ste100.md。"
echo "[jsc] 送出前自我檢查,命中任一項就先改再送出:1)簡體字(例:应、为、这、说、后、发);2)句尾用半形標點(. , ! ?),應為全形(。,!?);3)套路句(「總的來說」「綜上所述」「首先…其次…最後」);4)中文並列用半形「/」,應改頓號「、」;5)諂媚開場(「好問題」「當然可以」)。"
exit 0
Regular → Executable
+367 -182
View File
@@ -1,214 +1,238 @@
#!/usr/bin/env sh
# version-guard.sh — 技能使用前的版本前置檢查(PreToolUse,matcher: Skill)。
#
# 本機版本落後遠端發佈版本時擋下該次技能呼叫,並提示更新指令。
# 這道閘門擋兩種情況,兩種都會 exit 2:
# 一、本機版本落後遠端發佈版本。
# 二、技能所屬 plugin 宣告的相依 plugin 版本落後(manifest 的 jsc.requires)。
# 兩種都會提示更新指令。
#
# 結束碼(hook 模式):0=放行 2=擋下該次技能呼叫。
# 擋下時的輸出形態由 deny.sh 依當前 CLI 決定,本檔只負責判定與訊息內容:
# claude、codex、copilot 走 stderr 加 exit 2;antigravity 走 stdout 的 deny JSON,結束碼
# 固定 0(那支 CLI 的結束碼語意沒有文件,不可靠);kiro 擋不下來,改印警告後 exit 0。
# 所以「exit 0」在這支腳本有兩種意思:放行,或已經以不靠結束碼的形態擋下。
# 安靜放行(exit 0)的情況要記清楚,這道閘門絕大多數時候走的是這幾條:逃生門
# JSC_VERSION_GUARD=off、負載裡解不出技能名、解出來的不是 jsc 技能、
# 命中下方豁免清單那 7 支、解不出安裝路徑、
# 讀不到 manifest、manifest 沒有 jsc.requires、讀不到相依 plugin 的本機載入版本、
# 讀不到自己的本機實際載入版本、推導不出遠端站台、查不到遠端版本、
# 本機版本等於或超前遠端。
# 只有「相依確定落後」與「本機落後遠端」這兩條會走 deny.sh。
# 結束碼(report、recommend):0=永遠成功,只讀不擋。結論看 stdout,不看結束碼。
# 註:本檔以 `. "$HERE/lib.sh"` 載入共用函式,沒有接 `|| true`。lib.sh 讀不到時 sh 會就地
# 結束並回 2,接在 PreToolUse 上就是無聲擋下每一次技能呼叫,上面那些放行路徑一條都跑不到
# (write-guard.sh 踩過這個坑)。部署時要確認 hooks/lib.sh 跟這支腳本一起裝上。
# hooks/skill-name.sh 與 hooks/deny.sh 同理要一起裝上,但那兩支是以子行程呼叫,讀不到只會
# 讓技能名解不出來而安靜放行,不會反過來擋人——所以那兩支刻意不用 source 載入。
#
# 輸入:技能名一律由 skill-name.sh 從當前 CLI 的負載解析,環境變數 JSC_SKILL、SKILL 優先。
# 五支 CLI 的負載形態不同(claude 有 skill 欄位、codex 是 Bash 指令裡的 SKILL.md 路徑、
# copilot 是字串化的 toolArgs、antigravity 是 AbsolutePath、kiro 是提示開頭的斜線指令),
# 取值規則只留 skill-name.sh 那一份,本檔不重寫第二套。解不出來就安靜降級 exit 0。
# 不再另外篩工具名:工具名每支 CLI 都不一樣(Skill、Bash、skill、view_file),
# 拿 Claude 的那一個當通用條件,等於把另外四支整批擋在判定之外——這正是先前失效的原因。
# 接線那一端已經用各自的 matcher 篩過一輪,解得出技能名就是該判的那一次。
#
# 判準與取值:
# - 比對對象是「遠端發佈版本」與「本機**實際載入**的版本」。
# 實際載入版本要從 installed_plugins.json 的 installPath 讀該版目錄下的
# plugin.json,不能只看註冊在 installed_plugins.json 的版本欄位——那兩者
# 可能不同,只看註冊值會放過真正被載入的舊版。
# 實際載入版本只認 installed_plugins.json 的 installPath 底下那份 plugin.json,
# 不看註冊在 installed_plugins.json 的版本欄位——那兩者可能不同,註冊值比較新時
# 會放過真正被載入的舊版。讀不到那份檔案就當查不到,安靜放行。
# - 只擋落後。本機版本等於或超前遠端一律放行:開發技能組時本機本來就會
# 超前 master,擋下去會讓維護者自己動不了。
# - 遠端版本查不到(離線、站台維護、repo 改名)一律**擋**(fail-closed),
# 避免「查不到就當作沒事」而讓落後版本靜靜跑下去。逃生門見下。
# 超前預設分支,擋下去會讓維護者自己動不了。
# - 查不到資料一律放行(exit 0):本機版本、Gitea 站台、遠端版本全部來自
# Claude 的 plugin 檔案與 Gitea API,沒裝 Claude 或離線的機器一筆都讀不到。
# 那種情況擋下去,等於在沒有任何版本證據時停掉每一次技能呼叫,護欄變成故障點。
#
# 豁免(這些技能永遠放行):
# 相依版本檢查(第二種擋人情況):
# - 取值來源是技能所屬 plugin 的 manifest,也就是 installPath 底下那份 plugin.json,
# 讀它的 jsc.requires,一項是一個「相依 plugin: 最低版本」。
# - 相依 plugin 的現況一律取「本機實際載入版本」,規則與上面同一條:只認
# installed_plugins.json 的 installPath 底下那份 plugin.json,不拿註冊欄位當備援。
# - 只要有一項落後宣告的最低版本就 exit 2,訊息逐項講明哪一個 plugin、需要哪一版、
# 目前哪一版、怎麼補。
# - 判定同樣 fail-open:解不出安裝路徑、讀不到 manifest、manifest 沒有 jsc.requires、
# 讀不到某一項相依的本機載入版本,四種都安靜放行。五支 CLI 只有 claude 讀得到
# 本機載入版本,fail-closed 會把另外四支整批鎖死。
# - 這段邏輯自己實作,不去呼叫 jsc-cli 的 check-requires.sh。兩個理由:技能準則要求
# 所有 hook 專屬存放於 jsc-hooks,不可散落到別的 domain;而且 jsc-cli 已經宣告相依
# jsc-hooks,反向呼叫會做出循環相依。部署那端照樣更新、只回報,阻擋落在這支 hook。
# - 檢查順序刻意排在遠端比對之前:相依檢查全部讀本機檔案,不必連網,離線機器也判得動。
#
# 豁免(這些技能永遠放行,兩種擋人情況一起豁免):
# 共 7 項,jsc-meta:* 算一項。相依版本落後不另立一份短清單:這幾支同樣是修復與更新的
# 唯一路徑,用哪一個理由擋都是死鎖。這張表的唯一真實來源就是這段檔頭與下方豁免清單。
# jsc-cli:deploy 更新整組技能的入口,擋了就沒有任何方法更新,會死鎖
# jsc-hooks:hooks-install 更新後要重新接線,擋了會讓更新做一半卡住
# jsc-cli:models SDLC 閘門依賴它產生 model-tags.tsv
# jsc-meta:* 開發技能組本身的工具,擋了就修不了技能組
# jsc-ask:ask deploy 問「install/update/uninstall」一定會呼叫它;
# 它本身落後版本被擋下,訊息又指回 /jsc-cli:deploy,
# deploy 卡在問不出模式那一步,跟直接擋 deploy 是同一種死鎖
# jsc-gitea:wiki jsc-ask:ask 問完一定寫回 wiki 才算完成,理由同上一條,
# 擋在這一步一樣是 deploy 做不完
# jsc-hooks:repair hook 壞掉時唯一的修復路徑。修 hook 的技能被 hook 擋下,
# 就沒有任何方法把 hook 修回來,跟直接擋 deploy 是同一種死鎖
#
# 逃生門:JSC_VERSION_GUARD=off 完全略過檢查(離線工作時用)。
#
# 快取:$JSC_HOME/version-cache/{domain},單行「{版本} {epoch}」,
# 預設 600 秒內不重查(JSC_VERSION_TTL 可調)。
# 快取:$JSC_HOME/version-cache/{CLI 代號}/{domain},單行「{版本} {epoch}」,
# 預設 600 秒內不重查(JSC_VERSION_TTL 可調)。hook 與 report 在同一支 CLI 內共用。
# 舊路徑 $JSC_HOME/version-cache/{domain} 會在第一次讀取時複製到當前 CLI 的新路徑。
#
# 另有一個非 hook 的子指令:
# 另有兩個非 hook 的子指令:
# version-guard.sh report 把每個已安裝 jsc-* plugin 的版本比對印成 TSV,每行
# 「{domain}<TAB>{本機}<TAB>{遠端}<TAB>{落後|最新|超前|查詢失敗}」,
# 最後一行「behind<TAB>{落後個數}」。供 jsc-cli:deploy 判斷要不要
# 把「更新」設成推薦選項。report 只讀不擋,永遠 exit 0。
# 本機沒有 Claude 的 plugin 註冊檔時改印「noregistry<TAB>{路徑}」
# 再接 behind 0:那代表這台機器無法做版本檢查,跟「全部最新」是兩件事。
#
# version-guard.sh recommend
# 把 report 那張表收斂成一個結論,只印一行、只有這一種格式:
# recommend<TAB>update|none|unverifiable
# 判定規則(呼叫端不必自己再判一次):
# update 任何一個 plugin 落後就是 update,一個就夠,不等多數
# unverifiable 查不到註冊資訊(noregistry),或一列 domain 都沒有,
# 或每一列都是查詢失敗——沒有任何一項查得到的證據
# none 至少有一列查得到結果,而且沒有任何一項落後
# 查詢失敗的那幾列不計入:查不到不等於最新,也不等於落後,只是沒有證據。
# 輸出格式必須穩定,別的 domain 的技能直接讀第二欄;證據表要另外看的話
# 再呼叫一次 report,這裡刻意不混印,免得 cut 取值被表格內容打亂。
# recommend 只讀不擋,永遠 exit 0:判定結果只看那一行的第二欄。
HERE=$(dirname "$0"); . "$HERE/lib.sh"
hook_trace "version-guard ${1:-}"
# ── report:一次比對所有已安裝的 jsc plugin(非 hook 模式,不讀 stdin)
if [ "${1:-}" = "report" ]; then
python3 - <<'PY'
import json, os, re, subprocess, sys
REG="$HOME/.claude/plugins/installed_plugins.json"
MK="$HOME/.claude/plugins/known_marketplaces.json"
home = os.path.expanduser("~")
try:
reg = json.load(open(f"{home}/.claude/plugins/installed_plugins.json")).get("plugins", {})
except Exception:
print("behind\t0"); sys.exit(0)
host = owner = ""
try:
mk = json.load(open(f"{home}/.claude/plugins/known_marketplaces.json"))
m = re.match(r"(https?://[^/]+)/([^/]+)/[^/]+?(?:\.git)?/?$",
(mk.get("jsc", {}).get("source", {}) or {}).get("url", ""))
if m:
host, owner = m.group(1), m.group(2)
except Exception:
pass
if not host:
host = os.environ.get("GITEA_HOST", "")
owner = os.environ.get("JSC_GITEA_OWNER", "plugins")
def ver(v):
parts = ((v or "").split(".") + ["0", "0", "0"])[:3]
return tuple(int(p) if p.isdigit() else 0 for p in parts)
behind = 0
for key in sorted(reg):
m = re.match(r"^jsc-([^@]+)@", key)
if not m:
continue
domain = m.group(1)
# 本機版本一律以 installPath 底下那份 plugin.json 為準(實際載入版本),
# 讀不到才退回註冊欄位。
local = ""
for e in reg[key]:
try:
local = json.load(open(os.path.join(e.get("installPath", ""), "plugin.json"))).get("version", "")
break
except Exception:
local = e.get("version", "")
remote = ""
if host:
url = f"{host}/{owner}/{domain}/raw/branch/master/plugin.json"
for _ in range(2): # 暫時性網路失敗不該誤判成版本問題,失敗重試一次
try:
out = subprocess.run(["curl", "-sS", "--max-time", "10", url],
capture_output=True, text=True, timeout=20).stdout
mm = re.search(r'"version"\s*:\s*"([^"]+)"', out)
if mm:
remote = mm.group(1)
break
except Exception:
pass
if not remote:
state = "查詢失敗"
elif ver(local) < ver(remote):
state = "落後"; behind += 1
elif ver(local) > ver(remote):
state = "超前"
else:
state = "最新"
print(f"{domain}\t{local or '?'}\t{remote or '?'}\t{state}")
print(f"behind\t{behind}")
PY
exit 0
fi
read_stdin
[ "${JSC_VERSION_GUARD:-}" = "off" ] && exit 0
# 只管 Skill 工具
tool=$(json_str tool_name)
[ -z "$tool" ] || [ "$tool" = "Skill" ] || exit 0
skill=$(json_str skill)
[ -n "$skill" ] || exit 0
# 只管本技能組(jsc-{domain}:{name})
case "$skill" in
jsc-*:*) ;;
*) exit 0 ;;
esac
domain=${skill#jsc-}
domain=${domain%%:*}
[ -n "$domain" ] || exit 0
# 豁免清單
case "$skill" in
jsc-cli:deploy|jsc-hooks:hooks-install|jsc-cli:models|jsc-meta:*) exit 0 ;;
esac
TTL="${JSC_VERSION_TTL:-600}"
cache_dir="$JSC_HOME/version-cache"
mkdir -p "$cache_dir" 2>/dev/null || true
deny() { # $1=訊息
printf '[jsc][版本檢查][ERR]:%s\n' "$1" >&2
printf '更新指令:claude plugin marketplace update jsc && claude plugin update jsc-%s@jsc\n' "$domain" >&2
printf '更新整組:/jsc-cli:deploy | 確定要略過檢查:JSC_VERSION_GUARD=off\n' >&2
exit 2
# 從檔案取 JSON 字串欄位。與 lib.sh 的 json_str 同一種 naive 解析,只是來源是檔案:
# 先把換行換成空白、再以逗號斷行,這樣每行最多一個欄位,取值不會被貪婪比對吃掉。
file_json_str() { # $1=檔案 $2=欄位名
[ -f "$1" ] || return 0
tr '\n' ' ' < "$1" | tr ',' '\n' \
| sed -n "s/.*\"$2\"[[:space:]]*:[[:space:]]*\"\([^\"]*\)\".*/\1/p" | head -n1
}
# 本機實際載入版本:從 installPath 的 plugin.json 讀,不用註冊欄位
local_ver=$(python3 - "$domain" <<'PY' 2>/dev/null
import json, os, sys
domain = sys.argv[1]
try:
reg = json.load(open(os.path.expanduser("~/.claude/plugins/installed_plugins.json")))
except Exception:
sys.exit(0)
entries = reg.get("plugins", {}).get(f"jsc-{domain}@jsc") or []
for e in entries:
p = os.path.join(e.get("installPath", ""), "plugin.json")
try:
print(json.load(open(p)).get("version", "")); sys.exit(0)
except Exception:
continue
# 讀不到實際檔案時退回註冊版本,並在後面標記為次要來源
if entries and entries[0].get("version"):
print(entries[0]["version"])
PY
)
[ -n "$local_ver" ] || deny "讀不到本機 jsc-$domain 的實際載入版本(installed_plugins.json 或該版目錄的 plugin.json 不可用)"
# 該 plugin 的安裝路徑:從註冊檔取 installPath。版本與 manifest 都從這個目錄取,
# 抽成一支函式是為了讓兩邊共用同一條解析規則,各寫一份就會漂移。
# 取不到就回傳空字串,由呼叫端安靜放行。
install_path() { # $1=domain
[ -f "$REG" ] && [ -r "$REG" ] || return 0
_seg=$(tr -d '\n' < "$REG" \
| sed -n "s/.*\"jsc-$1@jsc\"[[:space:]]*:[[:space:]]*\[\([^]]*\)\].*/\1/p")
[ -n "$_seg" ] || return 0
printf '%s' "$_seg" | tr ',' '\n' \
| sed -n 's/.*"installPath"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n1
}
# 遠端站台與 owner:從已註冊的 jsc marketplace 來源推導,其次 GITEA_HOST
remote_src=$(python3 - <<'PY' 2>/dev/null
import json, os, re
try:
d = json.load(open(os.path.expanduser("~/.claude/plugins/known_marketplaces.json")))
except Exception:
raise SystemExit
url = (d.get("jsc", {}).get("source", {}) or {}).get("url", "")
m = re.match(r"(https?://[^/]+)/([^/]+)/[^/]+?(?:\.git)?/?$", url)
if m:
print(m.group(1), m.group(2))
PY
)
host=$(printf '%s' "$remote_src" | cut -d' ' -f1)
owner=$(printf '%s' "$remote_src" | cut -d' ' -f2)
if [ -z "$host" ] || [ -z "$owner" ]; then
host="${GITEA_HOST:-}"; owner="${JSC_GITEA_OWNER:-plugins}"
fi
[ -n "$host" ] || deny "推導不出 Gitea 站台(known_marketplaces.json 無 jsc 來源,GITEA_HOST 也未設定)"
# 本機實際載入版本:先取該 plugin 的 installPath,再讀那個目錄下的 plugin.json。
# 只認 installPath 底下那份檔案。註冊在 installed_plugins.json 的 version 欄位不當備援:
# 註冊值可能比實際載入的版本新,拿它來比對會放過真正被載入的舊版,護欄形同虛設。
# 讀不到那份檔案就當「查不到本機載入版本」,由呼叫端安靜放行。
local_version() { # $1=domain
_lp=$(install_path "$1")
[ -n "$_lp" ] || return 0
file_json_str "$_lp/plugin.json" version
}
# 快取
cache="$cache_dir/$domain"
now=$(now_epoch)
remote_ver=""
if [ -f "$cache" ]; then
c_ver=$(cut -d' ' -f1 "$cache" 2>/dev/null)
c_at=$(cut -d' ' -f2 "$cache" 2>/dev/null)
if [ -n "$c_ver" ] && [ -n "$c_at" ] && [ $((now - c_at)) -lt "$TTL" ]; then
remote_ver="$c_ver"
fi
fi
# manifest 宣告的相依版本:印出每行一項「{相依 plugin} {版本條件}」,例如「jsc-cli >=0.2.1」。
# 解析方式沿用本檔的 naive JSON 取值:先取 "requires" 後面那一對大括號裡的內容,
# 再以逗號斷行,這樣每行最多一組鍵值。jsc.requires 底下只有一層字串對字串,夠用。
# 只認 jsc- 開頭的鍵:這道閘門管的是本技能組自己的 plugin,別的來源查不到本機載入版本,
# 收進來也只會走到 fail-open 那條路。沒有宣告、讀不到檔案都印空字串,由呼叫端安靜放行。
requires_pairs() { # $1=manifest 路徑
[ -f "$1" ] && [ -r "$1" ] || return 0
# 補一個換行再往下送:tr -d '\n' 之後整份 JSON 只剩一行,而且結尾沒有換行,
# 這種缺行尾的串流走到最後一筆時,read 會把值讀進去卻回非零,while 迴圈的本體
# 一次都跑不到,落後的相依就靜靜被漏掉。補在源頭,後面每一段都拿得到完整的行。
{ tr -d '\n' < "$1"; printf '\n'; } \
| sed -n 's/.*"requires"[[:space:]]*:[[:space:]]*{\([^}]*\)}.*/\1/p' \
| tr ',' '\n' \
| sed -n 's/.*"\(jsc-[A-Za-z0-9_-]*\)"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1 \2/p'
}
if [ -z "$remote_ver" ]; then
url="$host/$owner/$domain/raw/branch/master/plugin.json"
# 暫時性網路失敗不該誤判成版本問題,所以失敗重試一次再放棄
for _try in 1 2; do
body=$(curl -sS --max-time 10 "$url" 2>/dev/null) && \
remote_ver=$(printf '%s' "$body" | sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n1)
[ -n "$remote_ver" ] && break
# 遠端站台與 owner:從已註冊的 jsc marketplace 來源推導,其次 GITEA_HOST。
# 印出「{host} {owner}」;推導不出來時 host 為空字串。
remote_host_owner() {
_url=""
if [ -f "$MK" ]; then
_url=$(tr -d '\n' < "$MK" | sed -n 's/.*"jsc"[[:space:]]*:[[:space:]]*{//p' \
| tr ',' '\n' \
| sed -n 's/.*"url"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n1)
fi
_h=$(printf '%s' "$_url" | sed -n 's#^\(https\{0,1\}://[^/]*\)/.*#\1#p')
_o=$(printf '%s' "$_url" | sed -n 's#^https\{0,1\}://[^/]*/\([^/]*\)/.*#\1#p')
if [ -z "$_h" ] || [ -z "$_o" ]; then
_h="${GITEA_HOST:-}"; _o="${JSC_GITEA_OWNER:-plugins}"
fi
printf '%s %s' "$_h" "$_o"
}
# 遠端發佈版本:一律經由 jsc-gitea 的 gitea.sh(技能準則),它會帶 GITEA_TOKEN,
# 並在缺 token 或 401/403 時退回 tea 的登入金鑰,私有存取庫才讀得到。
# 不指定 ref:Gitea 的 raw 端點預設就取該存取庫的預設分支,比在這裡寫死分支名準。
# 查不到就回傳空字串,由呼叫端放行。
remote_version() { # $1=domain $2=host $3=owner
_gsh=$(jsc_gitea_sh) || return 0
_v=""
for _try in 1 2; do # 暫時性網路失敗不該誤判成版本問題,失敗重試一次
_body=$(GITEA_HOST="$2" sh "$_gsh" api GET "/repos/$3/$1/raw/plugin.json" 2>/dev/null) \
&& _v=$(printf '%s' "$_body" | tr '\n' ' ' | tr ',' '\n' \
| sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n1)
[ -n "$_v" ] && break
sleep 1
done
[ -n "$remote_ver" ] || deny "查不到 jsc-$domain 的遠端發佈版本($url)。無法確認本機是否為最新,依 fail-closed 規則擋下"
printf '%s %s\n' "$remote_ver" "$now" > "$cache" 2>/dev/null || true
fi
printf '%s' "$_v"
}
# 語意化比較:只擋「本機 < 遠端」
cmp=$(awk -v a="$local_ver" -v b="$remote_ver" '
TTL="${JSC_VERSION_TTL:-600}"
cache_root="$JSC_HOME/version-cache"
cache_cli() {
_cli=$(cli_name | sed 's/[^A-Za-z0-9._-]/_/g')
[ -n "$_cli" ] || _cli=unknown
printf '%s' "$_cli"
}
cache_file() { # $1=domain
printf '%s/%s/%s' "$cache_root" "$(cache_cli)" "$1"
}
legacy_cache_file() { # $1=domain
printf '%s/%s' "$cache_root" "$1"
}
# 帶快取的遠端版本查詢。hook 與 report 共用同一份快取與同一個 TTL:
# report 每個 domain 各打一次網路(還帶重試),/jsc-cli:deploy 一跑就是全部 domain,
# 不共用快取等於每次部署都付一輪網路成本。同主機的不同 CLI 不共用快取,避免其中一支
# 讀到另一支留下的檢查紀錄。
cached_remote_version() { # $1=domain $2=host $3=owner
_cache=$(cache_file "$1")
_legacy=$(legacy_cache_file "$1")
if [ ! -f "$_cache" ] && [ -f "$_legacy" ]; then
mkdir -p "${_cache%/*}" 2>/dev/null || true
cat "$_legacy" > "$_cache" 2>/dev/null || true
fi
_now=$(now_epoch)
if [ -f "$_cache" ]; then
_cv=$(cut -d' ' -f1 "$_cache" 2>/dev/null)
_ca=$(cut -d' ' -f2 "$_cache" 2>/dev/null)
if [ -n "$_cv" ] && [ -n "$_ca" ] && [ $((_now - _ca)) -lt "$TTL" ]; then
printf '%s' "$_cv"; return 0
fi
fi
_rv=$(remote_version "$1" "$2" "$3")
if [ -n "$_rv" ]; then
mkdir -p "${_cache%/*}" 2>/dev/null || true
printf '%s %s\n' "$_rv" "$_now" > "$_cache" 2>/dev/null || true
fi
printf '%s' "$_rv"
}
# 語意化比較:印出 -1($1 落後)、0(相等)、1($1 超前)
ver_cmp() { # $1=版本 A $2=版本 B
awk -v a="$1" -v b="$2" '
BEGIN {
n = split(a, x, "."); m = split(b, y, ".")
for (i = 1; i <= 3; i++) {
@@ -217,7 +241,168 @@ cmp=$(awk -v a="$local_ver" -v b="$remote_ver" '
if (xi > yi) { print 1; exit }
}
print 0
}')
}'
}
[ "$cmp" = "-1" ] || exit 0
# ── report:一次比對所有已安裝的 jsc plugin(非 hook 模式,不讀 stdin)
# 抽成函式是為了讓 recommend 讀同一份輸出。判定規則只寫在這一支腳本裡,recommend 直接解析
# 這裡印出來的表;兩邊各實作一次比對邏輯就會漂移,結論與證據對不起來。
do_report() {
# 註冊檔不存在或讀不到就明講。這裡不能只印 behind 0:呼叫端會把它讀成「都是最新」,
# 於是把「這台機器無法做版本檢查」誤報成「不用更新」。
# 舊版用 `tr -d '\n' < "$REG" 2>/dev/null`,那個 2>/dev/null 只蓋住 tr 的 stderr,
# 蓋不住 shell 開檔失敗的訊息,所以沒裝 Claude 的機器會先漏一行 cannot open。
if [ ! -f "$REG" ] || [ ! -r "$REG" ]; then
printf 'noregistry\t%s\n' "$REG"
printf 'behind\t0\n'
return 0
fi
ho=$(remote_host_owner)
r_host=$(printf '%s' "$ho" | cut -d' ' -f1)
r_owner=$(printf '%s' "$ho" | cut -d' ' -f2)
behind=0
domains=$(tr -d '\n' < "$REG" | tr ',' '\n' \
| sed -n 's/.*"jsc-\([a-z0-9][a-z0-9-]*\)@[^"]*"[[:space:]]*:.*/\1/p' | sort -u)
for d in $domains; do
lv=$(local_version "$d")
rv=""
[ -n "$r_host" ] && rv=$(cached_remote_version "$d" "$r_host" "$r_owner")
if [ -z "$rv" ]; then
st="查詢失敗"
else
case "$(ver_cmp "$lv" "$rv")" in
-1) st="落後"; behind=$((behind + 1)) ;;
1) st="超前" ;;
*) st="最新" ;;
esac
fi
printf '%s\t%s\t%s\t%s\n' "$d" "${lv:-?}" "${rv:-?}" "$st"
done
printf 'behind\t%s\n' "$behind"
return 0
}
if [ "${1:-}" = "report" ]; then
do_report
exit 0
fi
# ── recommend:把 report 那張表收斂成一個結論(非 hook 模式,不讀 stdin)
# 規則的唯一來源就是這一段,jsc-cli:deploy 只讀第二欄,不再自己解那張表。
if [ "${1:-}" = "recommend" ]; then
rep=$(do_report)
verdict=none
if printf '%s\n' "$rep" | grep -q '^noregistry '; then
# 沒有本機註冊檔,一項都比不了。這跟「全部最新」是兩件事,不能推薦 none。
verdict=unverifiable
else
behind_n=$(printf '%s\n' "$rep" | sed -n 's/^behind //p' | head -n1)
# 查得到結果的列:狀態欄是落後、最新或超前三種之一。查詢失敗那幾列不算證據。
known=$(printf '%s\n' "$rep" | awk -F'\t' '$1 != "behind" && $1 != "noregistry" && ($4 == "落後" || $4 == "最新" || $4 == "超前")' | wc -l | tr -d ' ')
if [ -n "$behind_n" ] && [ "$behind_n" -gt 0 ] 2>/dev/null; then
verdict=update
elif [ "${known:-0}" -gt 0 ] 2>/dev/null; then
verdict=none
else
# 一列 domain 都沒有,或每一列都查詢失敗:兩種都是「沒有任何查得到的證據」。
verdict=unverifiable
fi
fi
printf 'recommend\t%s\n' "$verdict"
exit 0
fi
read_stdin
[ "${JSC_VERSION_GUARD:-}" = "off" ] && exit 0
# 技能名解析:交給 skill-name.sh,它一支 CLI 一個子命令,規則只有那一份。
# 輸出固定是「{domain}<TAB>{技能名}」;用 awk 判 NF==2 才取值,少一欄就當成解析不出來,
# 免得沒有定位字元時 cut -f2 把整行當成技能名,拼出一個不存在的技能名去比對豁免清單。
sn=$(printf '%s' "$STDIN_JSON" | sh "$HERE/skill-name.sh" "$(cli_name)" 2>/dev/null)
domain=$(printf '%s\n' "$sn" | awk -F'\t' 'NF == 2 { print $1; exit }')
name=$(printf '%s\n' "$sn" | awk -F'\t' 'NF == 2 { print $2; exit }')
[ -n "$domain" ] && [ -n "$name" ] || exit 0
skill="jsc-$domain:$name"
# 豁免清單
case "$skill" in
jsc-cli:deploy|jsc-hooks:hooks-install|jsc-hooks:repair|jsc-cli:models|jsc-meta:*|jsc-ask:ask|jsc-gitea:wiki) exit 0 ;;
esac
# 更新指令依實際 CLI 給。印別的 CLI 的指令等於沒給指令,使用者照著打只會失敗。
update_cmd() { # $1=domain
case "$(cli_name)" in
claude)
printf 'claude plugin marketplace update jsc && claude plugin update jsc-%s@jsc' "$1" ;;
codex)
printf 'codex plugin marketplace upgrade jsc' ;;
copilot)
printf 'copilot plugin marketplace update jsc && copilot plugin update jsc-%s@jsc' "$1" ;;
antigravity)
printf 'git -C ~/plugins/%s pull && agy plugin uninstall jsc-%s && agy plugin install ~/plugins/%s' "$1" "$1" "$1" ;;
kiro)
printf 'kiro-cli plugin marketplace update jsc && kiro-cli plugin update jsc-%s@jsc' "$1" ;;
*)
printf '用你的 CLI 的 plugin 更新指令更新 jsc-%s@jsc' "$1" ;;
esac
}
# 擋人輸出交給 deny.sh:形態依 CLI 而定,本檔只組訊息。訊息整段走管線送過去,
# antigravity 那一支才有辦法把多行訊息壓成同一個 reason 字串;分成好幾次呼叫會做出好幾份
# deny JSON,那支 CLI 只認第一份,後面幾段訊息使用者永遠看不到。
deny() { # $1=訊息
{ printf '[jsc][版本檢查][ERR]:%s\n' "$1"
printf '更新指令:%s\n' "$(update_cmd "$domain")"
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)"
exit $?
}
# ── 相依版本檢查:讀技能所屬 plugin 的 manifest,逐項比對相依 plugin 的本機載入版本。
# 排在遠端比對之前,因為這一段全部讀本機檔案,離線機器也判得動。
# 每一層取不到值都直接跳過,理由見檔頭「相依版本檢查」那一段的 fail-open 說明。
plugin_dir=$(install_path "$domain")
if [ -n "$plugin_dir" ] && [ -f "$plugin_dir/plugin.json" ]; then
# 迴圈放在命令替換裡收結果。POSIX sh 的管線各跑在自己的子行程,
# 在迴圈裡累加變數帶不回來,只有印出來的內容帶得回來。
behind_list=$(requires_pairs "$plugin_dir/plugin.json" | while IFS=' ' read -r dep cond; do
[ -n "$dep" ] && [ -n "$cond" ] || continue
# 只認 >= 這一種寫法,與 manifest 現行宣告一致;其餘寫法就把整串當最低版本。
min=${cond#>=}
dep_domain=${dep#jsc-}
cur=$(local_version "$dep_domain")
# 讀不到相依的本機載入版本就跳過這一項:那是沒有證據,不是落後。
[ -n "$cur" ] || continue
[ "$(ver_cmp "$cur" "$min")" = "-1" ] || continue
printf ' - %s 需要 %s,目前 %s,更新指令:%s\n' "$dep" "$cond" "$cur" "$(update_cmd "$dep_domain")"
done)
if [ -n "$behind_list" ]; then
# 逐項清單與結語一起送進 deny.sh,理由同上:一次呼叫、一份拒絕。
{ printf '[jsc][版本檢查][ERR]:%s 宣告的相依 plugin 版本落後,本次技能呼叫已擋下\n' "$skill"
printf '%s\n' "$behind_list"
printf '更新整組:/jsc-cli:deploy | 確定要略過檢查:JSC_VERSION_GUARD=off\n'
} | sh "$HERE/deny.sh" "$(cli_name)"
exit $?
fi
fi
# 讀不到本機實際載入版本就放行:沒有版本證據時擋下等於停掉每一次技能呼叫
local_ver=$(local_version "$domain")
[ -n "$local_ver" ] || exit 0
ho=$(remote_host_owner)
host=$(printf '%s' "$ho" | cut -d' ' -f1)
owner=$(printf '%s' "$ho" | cut -d' ' -f2)
[ -n "$host" ] || exit 0
# 遠端版本(走 hook 與 report 共用的快取與 TTL)
remote_ver=$(cached_remote_version "$domain" "$host" "$owner")
[ -n "$remote_ver" ] || exit 0
# 只擋「本機 < 遠端」這一種情況
[ "$(ver_cmp "$local_ver" "$remote_ver")" = "-1" ] || exit 0
deny "jsc-$domain 本機版本 $local_ver 落後遠端發佈版本 $remote_ver,本次技能呼叫已擋下"
+285
View File
@@ -0,0 +1,285 @@
#!/usr/bin/env sh
# write-guard.sh — 寫入與提交的前置閘門(PreToolUse)。三種擋人模式接在兩個 matcher 上,
# 另有一個給技能收尾呼叫的解除模式。
#
# 用法:
# write-guard.sh stage matcher Write|Edit|MultiEdit:plan 與 analyze 階段鎖存在時擋下寫檔
# write-guard.sh review matcher Write|Edit|MultiEdit:稽核類技能執行中擋下寫檔
# write-guard.sh commit matcher Bash:擋下 git add -A 後的單次提交,以及含簡體字或亂碼的提交訊息
# write-guard.sh release 不接 hook,由稽核技能收尾時自己呼叫:清掉 review 模式認人用的那份
# 紀錄,一律 exit 0,紀錄本來就不存在也算成功
#
# 輸入相容(比照其他 hook,stdin JSON 與環境變數都收,缺欄位一律安靜降級 exit 0):
# 工具名 JSC_TOOL_NAME、TOOL_NAME、stdin 的 tool_name
# 技能名 JSC_SKILL、SKILL、stdin 的 skill
# 指令 JSC_TOOL_COMMAND、stdin 的 command
#
# 結束碼:0=放行、解除完成、資料不足或不認得的模式;2=擋下,訊息走 stderr。
# 逃生門:JSC_WRITE_GUARD=off,三種擋人模式全部略過(release 不受影響,清紀錄擋不到任何人)。
# review 模式另有 JSC_WRITE_GUARD_TTL(預設 900 秒),見下方「稽核技能的時效」。
#
# 覆蓋範圍要據實看待:只有 claude 有 PreToolUse,這道閘門只在 claude 上擋得下來。
# codex、copilot、antigravity、kiro 都沒有 pre-tool 事件,三種模式在那四支上一次都擋不到,
# 規則只剩 SKILL.md 的散文,回報時不得暗示每支 CLI 都擋得住。
#
# --- stage 模式 ---
#
# 階段鎖狀態檔沿用 sdlc-gate.sh 那一份($JSC_HOME/sessions/{cli}-{sid}.stage,單行
# 「{階段}<TAB>{必要標籤}<TAB>{上鎖時的模型}」),這裡只讀不寫:判準與格式留在 sdlc-gate.sh,
# 兩邊各存一份就會漂移。路徑一律取自 lib.sh 的 stage_state_read_file,連「新路徑優先、舊路徑
# 往後相容」這條規則也不在這裡抄第二份。plan 與 analyze 是純邏輯階段,產出是 wiki 頁不是
# 程式碼,所以那兩個階段鎖著時 Write、Edit、MultiEdit 一律擋下;implement 與 maintain
# 本來就要寫檔,放行。
#
# --- review 模式 ---
#
# 目前技能取自 skill-usage.sh 已經記下的那一份($JSC_HOME/sessions/{sid}.lastskill),
# 環境變數餵得到技能名時優先用環境變數。code-review 與 api-doc 只回報發現、不改程式碼,
# 執行中出現寫入就是越權,擋下。
#
# comment-cleanup 不擋:它本來就要改檔,只是限定「僅註解行」。要精確判定得先解析工具參數裡
# 帶跳脫字元的整份新內容,再逐語言判斷哪幾行是註解——判錯就會擋掉合法的清理,代價比漏擋大。
# 這一支因此只放行,寫入範圍由 SKILL.md 的散文與後續審查把關,不在這裡硬做。
#
# 稽核技能的時效:沒有「技能結束」事件可讀,只有「最近一次呼叫的技能」這個事實。不設界線的話,
# 稽核技能跑完之後每一次寫檔都會被擋到下一支技能被呼叫為止。所以紀錄超過 JSC_WRITE_GUARD_TTL
# 秒就當那支技能早已跑完,放行;取不到紀錄時間也放行。
#
# --- release 模式 ---
#
# 介面:write-guard.sh release,不吃其他參數、不接任何 hook 事件,由呼叫端自己執行。
# 呼叫端是 jsc-review:code-review 與 jsc-review:api-doc,兩支在收尾(把發現清單交回呼叫端)
# 那一步各呼叫一次。做的事只有一件:刪掉 $JSC_HOME/sessions/{sid}.lastskill。
#
# 為什麼要有這個模式:review 模式靠那份紀錄認人,而 skill-usage.sh 記的是「最近一次載入的
# 技能」,不是「還在跑的技能」。code-review 的契約是只回報、修不修由呼叫端決定,稽核結束後
# 呼叫端本來就要動手改——那一刻紀錄仍寫著 code-review,JSC_WRITE_GUARD_TTL 內每一次寫入
# 都被擋,解除路徑只剩逃生門或空等。閘門不得把解除自己的路徑一起鎖掉,所以補一個由呼叫端
# 自己按的解除鍵。
# 只刪那一份紀錄,不碰階段鎖:階段鎖歸 sdlc-gate.sh unlock 管,兩件事混在一起會互相解除。
#
# --- commit 模式 ---
#
# 擋兩件事:
# 1. 同一道指令裡同時有「git add -A(或 --all、.)」與「git commit」。那等於把所有待提交
# 變更併成一次提交,型別與功能分組就消失了。跨兩次工具呼叫的同一組動作不擋——那要記
# 跨呼叫狀態,而被擋下的人沒有辦法讓那個狀態自己消失,閘門會把解除自己的路徑一起鎖掉。
# 2. 提交訊息含簡體字、亂碼或非 UTF-8 編碼。判定整段轉呼叫 lang-guard.sh(字表在
# hooks/simplified.txt),這裡不抄第二份樣式;連帶地 JSC_LANG_GUARD=off 也會關掉這一項,
# 因為那本來就是同一條規則。
set -u
HERE=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
# lib.sh 讀不到就直接放行。這裡不能寫成「. lib.sh || true」:dash 的 `.` 找不到檔案時會結束
# 整支 shell,後面的 || true 一次都跑不到,2>/dev/null 還把原因蓋掉,三種模式全部變成無訊息
# 的 exit 2——而 PreToolUse 的 exit 2 正是「擋下」,等於每一次寫檔與提交都被無聲擋死。
[ -r "$HERE/lib.sh" ] || exit 0
. "$HERE/lib.sh"
hook_trace "write-guard ${1:-}"
# lib.sh 沒載到時這個變數就沒人設,下面兩個模式都要用它組狀態檔路徑,補一份同樣的預設值。
JSC_HOME="${JSC_HOME:-$HOME/.jsc}"
mode="${1:-}"
case "$mode" in
stage|review|commit|release) ;;
*) exit 0 ;; # 不認得的模式一律安靜放行,不中斷宿主 CLI
esac
read_stdin 2>/dev/null || STDIN_JSON=""
# ── release:稽核技能收尾時清掉「目前技能」紀錄,解除 review 模式的擋下
#
# 排在逃生門之前,也不受 JSC_WRITE_GUARD=off 影響:清一筆紀錄從來不會擋到任何人,
# 而收尾呼叫失敗才是真的麻煩——閘門關著的機器上跑過一輪,紀錄留著,下次開啟就自鎖。
if [ "$mode" = release ]; then
rm -f "$JSC_HOME/sessions/$(session_id).lastskill" 2>/dev/null || true
exit 0
fi
[ "${JSC_WRITE_GUARD:-on}" = "off" ] && exit 0
deny() { # $1=擋下的理由 $2=修法
printf '[jsc][寫入閘門][ERR]:%s\n' "$1" >&2
printf '%s\n' "$2" >&2
printf '確定要略過這道閘門:JSC_WRITE_GUARD=off\n' >&2
exit 2
}
# 檔案的最後修改時間(epoch 秒)。GNU 與 BSD 的取法不同,三種都試過才放棄;
# 取不到就不輸出,呼叫端當成無法判定並放行。
file_mtime() { # $1=檔案
_m=$(date -r "$1" +%s 2>/dev/null)
[ -n "$_m" ] || _m=$(stat -c %Y "$1" 2>/dev/null)
[ -n "$_m" ] || _m=$(stat -f %m "$1" 2>/dev/null)
printf '%s' "$_m"
}
# 只在寫檔類工具上判定。工具名取不到就當成沒有篩選條件,交給後面的狀態判定——
# 接線的 matcher 已經先篩過一輪,這裡再擋一次只會把用環境變數餵資料的 CLI 全部放掉。
write_tool_or_exit() {
_t="${JSC_TOOL_NAME:-${TOOL_NAME:-$(json_str tool_name)}}"
case "$_t" in
""|Write|Edit|MultiEdit) return 0 ;;
*) exit 0 ;;
esac
}
# ── stage:plan 與 analyze 階段鎖存在時擋下寫檔
if [ "$mode" = stage ]; then
write_tool_or_exit
state=$(stage_state_read_file)
[ -f "$state" ] && [ -r "$state" ] || exit 0
stage=$(cut -f1 "$state" 2>/dev/null | head -n1)
case "$stage" in
plan|analyze) ;;
*) exit 0 ;; # implement 與 maintain 本來就要寫檔;讀不出階段也放行
esac
# 解除指令帶上實際讀到的狀態檔路徑:擋人的是 hook,解鎖的是人在殼層手動執行,兩邊算出來
# 的檔名未必相同,不點名就會解到別的地方去。
deny "目前鎖在 SDLC「$stage」階段,這個階段只產出計畫或分析頁,不寫檔案。本次寫入已擋下。" \
"改法:把結論寫進該階段的 wiki 頁;真的要動程式碼請先進入 implement 階段。
解除階段鎖:sh $HERE/sdlc-gate.sh unlock $state"
fi
# ── review:稽核類技能執行中擋下寫檔
if [ "$mode" = review ]; then
write_tool_or_exit
skill="${JSC_SKILL:-${SKILL:-$(json_str skill)}}"
if [ -z "$skill" ]; then
last="$JSC_HOME/sessions/$(session_id).lastskill"
if [ -f "$last" ] && [ -r "$last" ]; then
mt=$(file_mtime "$last")
now=$(now_epoch)
if [ -n "$mt" ] && [ -n "$now" ]; then
age=$((now - mt))
[ "$age" -lt "${JSC_WRITE_GUARD_TTL:-900}" ] && skill=$(cat "$last" 2>/dev/null)
fi
fi
fi
[ -n "$skill" ] || exit 0
case "$skill" in
jsc-review:code-review|code-review)
deny "jsc-review:code-review 執行中。這支技能只回報發現,修不修由呼叫端決定,執行中不寫檔。本次寫入已擋下。" \
"改法:先讓稽核跑完並收下 file:line、嚴重度與重構手法,再由呼叫端決定要不要改。" ;;
jsc-review:api-doc|api-doc)
deny "jsc-review:api-doc 執行中。這支技能只稽核 Swagger 文件屬性,從不修改程式碼。本次寫入已擋下。" \
"改法:先讓稽核跑完並收下缺漏清單,再由呼叫端決定要不要補。" ;;
esac
exit 0
fi
# ── commit:擋下 git add -A 後的單次提交,與含簡體字或亂碼的提交訊息
# 從 stdin JSON 取帶跳脫字元的字串欄位。lib.sh 的 json_str 以 [^"]* 比對,遇到訊息裡的 \"
# 就在那裡截斷,提交訊息會少掉後半段——而訊息內容正是這裡要檢查的東西,所以自己解一次跳脫。
json_escaped_str() { # $1=欄位名
printf '%s' "$STDIN_JSON" | awk -v key="$1" '
{ s = s $0 "\n" }
END {
n = length(s); i = 1; found = 0
while (i <= n) {
if (substr(s, i, 1) != "\"") { i++; continue }
buf = ""; i++
while (i <= n) {
c = substr(s, i, 1)
if (c == "\\") {
e = substr(s, i + 1, 1)
if (e == "n") buf = buf "\n"
else if (e == "t") buf = buf "\t"
else if (e == "r") buf = buf "\r"
else if (e == "u") { i += 6; continue }
else buf = buf e
i += 2; continue
}
if (c == "\"") { i++; break }
buf = buf c; i++
}
if (found) { printf "%s", buf; exit }
j = i
while (j <= n && substr(s, j, 1) ~ /[ \t\r\n]/) j++
if (buf == key && substr(s, j, 1) == ":") {
k = j + 1
while (k <= n && substr(s, k, 1) ~ /[ \t\r\n]/) k++
if (substr(s, k, 1) != "\"") { i = k; continue }
found = 1; i = k
}
}
}'
}
cmd="${JSC_TOOL_COMMAND:-$(json_escaped_str command)}"
[ -n "$cmd" ] || exit 0
case "$cmd" in
*git*) ;;
*) exit 0 ;; # 不是 git 指令就不關這道閘門的事
esac
# 把所有待提交變更一次加進索引的三種寫法。-A 也認 -vA 這類併寫的短旗標。
has_add_all() { # $1=指令
printf '%s' "$1" \
| grep -qE 'git[[:space:]]+add[[:space:]]+(-[A-Za-z]*A([[:space:]]|$)|--all([[:space:]]|$)|\.([[:space:]]|$))'
}
has_commit() { # $1=指令
printf '%s' "$1" | grep -qE 'git[[:space:]]+commit([[:space:]]|$)'
}
if has_add_all "$cmd" && has_commit "$cmd"; then
deny "這道指令把全部變更一次加進索引再提交,型別與功能分組會全部消失。本次執行已擋下。" \
"改法:依 conventional type 與功能分組,逐組 git add {檔案} 再各自 git commit。
分組與訊息格式交給 /jsc-git:commit 處理。"
fi
# 提交訊息:取 -m 後面那一段。帶引號就讀到成對的引號為止,沒帶引號就讀到下一個空白。
_sq=$(printf '\047')
commit_message() { # $1=指令
printf '%s' "$1" | awk -v sq="$_sq" '
{
s = $0; n = length(s)
i = index(s, "-m")
if (i == 0) exit
i += 2
while (i <= n && substr(s, i, 1) ~ /[ \t=]/) i++
q = substr(s, i, 1)
if (q == "\"" || q == sq) {
i++
while (i <= n) {
c = substr(s, i, 1)
if (c == "\\") { out = out substr(s, i + 1, 1); i += 2; continue }
if (c == q) break
out = out c; i++
}
} else {
while (i <= n && substr(s, i, 1) !~ /[ \t]/) { out = out substr(s, i, 1); i++ }
}
printf "%s", out
}'
}
has_commit "$cmd" || exit 0
msg=$(commit_message "$cmd")
[ -n "$msg" ] || exit 0
# 簡體字、亂碼與編碼判定整段轉呼叫 lang-guard.sh,樣式與字表都不在這裡留第二份。
# 那支腳本吃的是檔案,所以訊息先落成暫存檔;建不出暫存檔就放行,回報不該再變成一次失敗。
lang_hits() { # $1=文字;有問題就把證據印到 stdout 並回傳 1
[ -f "$HERE/lang-guard.sh" ] || return 0
_t=$(mktemp 2>/dev/null) || return 0
printf '%s\n' "$1" > "$_t" 2>/dev/null || { rm -f "$_t"; return 0; }
_o=$(JSC_CHANGED_FILE="$_t" sh "$HERE/lang-guard.sh" </dev/null 2>&1)
_rc=$?
rm -f "$_t"
# 只有 exit 2 是「確定命中」。exit 0 是乾淨或資料不足,其餘結束碼代表那支腳本自己出狀況,
# 兩種都放行:拿判不出來的結果擋提交,等於把護欄變成故障點。
[ "$_rc" -eq 2 ] || return 0
# 只留命中證據:開頭那句與 lang-guard.sh 自己的修法兩行由本檔的訊息取代,重複印只是噪音。
printf '%s\n' "$_o" | grep -vF "$_t" | grep -v '^\[jsc\]' \
| grep -v '^ 修法:' | grep -v '^ 規則正文' | head -n 4
return 1
}
evidence=$(lang_hits "$msg") && exit 0
deny "提交訊息有簡體字、亂碼或編碼問題。本次執行已擋下。
$evidence" \
"改法:訊息改寫成繁體中文、UTF-8、無亂碼。字表在 jsc-hooks 的 hooks/simplified.txt,
規則正文見 jsc-meta 的 references/ste100.md。"
+12 -3
View File
@@ -1,6 +1,15 @@
{
"name": "jsc-hooks",
"version": "0.0.9",
"description": "跨 CLI hooks:STE100 語言強制、工時計時、技能用量記錄、SDLC 模型鎖",
"skills": "./skills/"
"version": "0.5.0",
"description": "跨 CLI hooks:STE100 語言強制、工時計時、技能用量記錄、SDLC 模型鎖、版本前置檢查、註解範圍守門、繁中編碼守門、部署後強制重啟、寫入與提交閘門",
"skills": "./skills/",
"jsc": {
"requires": {
"jsc-cli": ">=0.2.1",
"jsc-gitea": ">=0.1.7",
"jsc-git": ">=0.1.1",
"jsc-meta": ">=0.2.3",
"jsc-review": ">=0.0.8"
}
}
}
+23
View File
@@ -0,0 +1,23 @@
# jsc-hooks 技能行為清單
本頁記錄 jsc-hooks 每支技能的行為基準,供技能驗證比對。技能異動時,在同一個 PR 內一起更新這一頁。
## hooks-install
| 項目 | 內容 |
| --- | --- |
| 觸發時機 | 裝好或更新完 jsc 技能組之後,要把十支 hook 接線到每一支已安裝的 CLI 時用;`jsc-cli:deploy` 收尾會把偵測到的 CLI 清單交給它。不用於撰寫新的 hook,也不用於單獨修一支壞掉的 hook,那是 `jsc-hooks:repair` 的事 |
| 關鍵步驟 | 先跑前置步驟解出兩個字面絕對路徑:`readlink -f "$JSC_HOME/current"` 解出連結農場根目錄(跨 domain 呼叫用它),`readlink -f "$JSC_HOME/current/jsc-hooks"` 解出 jsc-hooks 的實體根目錄(只有 `wire-cli.sh` 從這裡跑。它會改寫自己正踩著的那條連結,但它自己已經把 `HERE` 與 `ROOT` 解成實體路徑,`ln -sfn` 不會再把連結指向自己,所以從實體根目錄跑現在是多一層保險、不是唯一防線;照做的理由是舊版腳本還在別的機器上跑,那些版本走連結跑仍會把連結寫成指向自己、全機器 hook 一起失效)、兩個路徑各解一次不重解、各自在同一步用 `[ -d ]` 查過印出來的目錄真的存在(`JSC_HOME` 沒設時第一條會印出 `/current`、結束碼 0,非空又是絕對路徑,只查前三項擋不下來),任何一條解不出來、不是絕對路徑、或目錄不存在就停手回報是哪一條沒解出來並叫人跑 `jsc-cli:deploy`,不接任何線也不猜路徑、不退回帶版本號的快取路徑、之後每一次腳本呼叫都用解出來的字面絕對路徑開頭、取得 CLI 清單(呼叫端交來的優先,沒有才自己跑 `detect-clis.sh`)、第一支 CLI 單獨跑完整條管線(它負責更新共用的 `{連結農場根}/jsc-hooks` 連結)、其餘 CLI 一支一個 sub agent 並行、每支 CLI 依序走 purge、接線、status、smoke、scan 五道關卡、讀每道關卡自己印的第一行判定、任一關卡出錯就寫 `ERROR_{HASH}` 並轉給 `jsc-hooks:repair`(異常頁與索引目錄頁分屬兩個存取庫,各自解析:異常頁由 `report-error.sh` 走 `wiki-repo ERROR`,目錄頁由 `wiki-contents.sh` 走 `wiki-repo CONTENTS`;只解不出目錄頁的存取庫時異常頁照寫、索引跳過,回報要講明那一頁沒被索引)、目錄頁上這一筆是一個 H2 區塊,標題就是異常頁頁名,時間、頁名、存取庫名稱、觸發 hook、退出碼、摘要各一條條列,寫成 `- {欄位名}:{值}`,頁上不留 markdown 表格、「頁名」那一條指向異常頁的連結一律寫成 `[{文字}]({連結})`,網址取 `gitea.sh wiki-url`,寫進去之前先過 `jsc-gitea/tools/link-check.sh`,結束碼 0 才寫連結、驗不過那一條只留純文字頁名而那一條與異常頁照寫(`report-error.sh` 內部做完,結束碼不變)、目錄頁的讀回、比對與整頁寫回一律交給 `jsc-gitea/tools/wiki-contents.sh upsert ERROR 2 {頁名} {區塊檔} {範本}`,`report-error.sh` 只組自己那一個區塊,找得到同名 H2 就整塊換掉、找不到就附加到頁尾、逐 CLI 回報五道關卡的結果、最後由主代理呼叫一次 `tools/report-status.sh skill-end jsc-hooks:hooks-install {status} {結束碼} {detail}` 記下整輪怎麼結束。腳本在同一個存取庫,用 `tools/` 相對路徑;這一筆只由主代理寫一次,寫在並行的各 CLI sub agent 裡會變成五筆互相矛盾的結局。腳本不在就安靜跳過,回報失敗不得變成接線失敗 |
| 外部呼叫 | `readlink -f`(前置步驟解兩個根目錄,各一次)、`tools/wire-cli.sh purge`、`tools/wire-cli.sh {cli}`、`tools/wire-cli.sh status`、`tools/wire-cli.sh smoke`、`tools/scan-hook-errors.sh`、`tools/report-error.sh`、`jsc-cli/tools/detect-clis.sh`、`jsc-hooks:repair` 技能、`jsc-gitea:wiki`(寫 `ERROR_{HASH}` 時經 `report-error.sh`)、`jsc-gitea/tools/gitea.sh wiki-url` 與 `jsc-gitea/tools/link-check.sh`(同樣經 `report-error.sh`,取目錄頁那一條的網址並驗它連得到)、`jsc-gitea/tools/wiki-contents.sh upsert`(同樣經 `report-error.sh`,把那一個 H2 區塊 upsert 進索引目錄頁);接線腳本內部另呼叫 `hooks/skill-name.sh` 與 `hooks/deny.sh` 做冒煙斷言 |
| 完成條件 | 前置步驟解出的兩個根目錄都是一條存在的絕對路徑(各自用 `[ -d ]` 查過),而且整個流程沒有任何一次腳本呼叫帶著未展開的變數或波浪號,每一支偵測到的 CLI 都有五道關卡各一行判定,沒有任何一道回結束碼 2,smoke 的 `lines` 條數與它自己的斷言相符,claude、codex、copilot、antigravity 回 `wired` 而 kiro 回 `degraded`(CLI 擋不下技能叫用),四支非 claude 的執行期錯誤掃描一律據實回 `unavailable`,各 CLI 的形狀與觸發驗證等級分開寫進回報(codex、antigravity、kiro 形狀實證,copilot 形狀未證;kiro 觸發部分實證,其餘未驗證),每一筆錯誤都帶一個 `ERROR_{HASH}` 結果與一條對 `develop` 的修正 PR 連結,而且目錄頁那一條的連結驗不過時,回報要講明那一條只有純文字頁名、沒有連結 |
| 可驗證跡象 | 各 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
| 項目 | 內容 |
| --- | --- |
| 觸發時機 | `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、收尾呼叫 `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 |
| 完成條件 | 修正已經落在磁碟上、`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。收尾在 `$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` 不在那台機器上就沒有這一筆,修正結果一字不變 |
+106 -17
View File
@@ -1,33 +1,122 @@
---
name: hooks-install
description: Wire jsc hooks (STE100 guard, session timer, skill usage logger, SDLC model gate, plugin version guard) into every installed AI CLI. Detect CLIs via jsc-cli detect-clis.sh, then apply native hook config, the jsc-wrap.sh launcher, or an instruction-file fallback per CLI. Use after installing or updating the jsc plugin set; not for writing new hooks.
description: Wire jsc hooks (STE100 guard, session timer, skill usage logger, SDLC model gate, plugin version guard, post-deploy restart gate, comment scope scanner, language guard, write and commit guard) into every installed AI CLI, purging all pre-existing hooks first — third-party ones included, backed up before removal. Drive it per CLI through tools/wire-cli.sh purge, tools/wire-cli.sh, tools/wire-cli.sh status, tools/wire-cli.sh smoke and tools/scan-hook-errors.sh. Hand any hook error, wiring or runtime, to jsc-hooks:repair, which must finish with a PR against develop; aborting the rest of the install to start that repair is allowed. Use after installing or updating the jsc plugin set; not for writing new hooks.
---
# hooks-install — wire jsc hooks into every installed CLI
Goal: make the five hooks (`ste100-guard.sh`, `session-timer.sh`, `skill-usage.sh`, `sdlc-gate.sh`, `version-guard.sh`) effective in every CLI.
`version-guard.sh` runs on PreToolUse(Skill) and blocks a skill whose locally loaded plugin version is behind the published one. Where a CLI has no pre-tool hook, that guard cannot be wired — say so in the report rather than implying every CLI is covered.
Claude wiring is automatic via `hooks.json`. On codex and kiro the SDLC gate degrades to the skill-step check only; the lock file still works because the SDLC skills call `sdlc-gate.sh lock {stage}` directly — that call is where the capability-tag comparison happens, so the gate keeps its force even where the prompt hook cannot be wired.
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.
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.
| CLI | Wiring point | Event and matcher | Blocking shape | Verdict |
| --- | --- | --- | --- | --- |
| claude | `hooks/hooks.json` | `PreToolUse`, matcher `Skill` | stderr plus exit 2 | `wired` |
| codex | `hooks/codex-hooks.json`, pointed at by the `hooks` **path string** in `.codex-plugin/plugin.json` | `PreToolUse`, matcher `Bash` | stderr plus exit 2 | `wired` |
| copilot | `hooks` key of `~/.copilot/settings.json`, merged in place | `PreToolUse`, matcher `skill` | stderr plus exit 2 | `wired` |
| antigravity | `jsc` block of `~/.gemini/config/hooks.json` | `PreToolUse`, matcher `^view_file$` (**Grouped**), plus `PreInvocation` (**Flat**) | `{"decision":"deny",...}` on stdout | `wired` |
| kiro | `hooks` key of `~/.kiro/agents/jsc.json`, plus `chat.defaultAgent=jsc` | `agentSpawn`, `userPromptSubmit`, `stop` | warning injected on stdout; blocks nothing | `degraded` |
On the four non-claude CLIs every wired command carries its own CLI code as a `JSC_CLI={code}` prefix. A gate has to know which CLI it is running under before it can read a skill name out of `skill-name.sh` or a blocking shape out of `deny.sh`; with no code the skill name resolves to nothing and both gates pass in silence — config correct, matcher correct, blocks never. On antigravity it is worse: an unknown code makes `deny.sh` fall back to the exit-code shape, which that CLI ignores, so the gate decides to block and the CLI never hears it. The `jsc-wrap.sh` alias exports `JSC_CLI` as well, but only when the user starts the CLI through the alias from an interactive shell, so wiring never leans on it. `wire-cli.sh` asserts the prefix per CLI at wiring time, at `status` time and in `smoke`.
Only claude reaches all nine hooks. On codex, copilot and antigravity the SDLC gate still degrades to the skill-step check, the three write and commit guard modes are still unwired, and the comment and language scans still run as `sweep` — say exactly that, in the words the script prints, instead of implying full coverage.
kiro is `degraded` because the CLI cannot block a skill call, not because the wiring is short of anything. A skill there is a `ResolveSkill` request inside the agent, off the tool pipeline, so `preToolUse` never sees it and a non-zero exit from `userPromptSubmit` does not stop the turn. Injecting a warning on stdout is the only intervention left. Its hook declarations live in the agent config's `hooks` key — `.kiro/hooks/` is not in kiro's config-directory constants and is never read — and that same agent file needs the two-level `skill://` glob in `resources` (the default glob scans one level, jsc skills sit at `jsc-{domain}/{name}/SKILL.md`) plus an explicit `tools` list, with `chat.defaultAgent` set to `jsc` so the agent is chosen at all.
Shape is part of the wiring, and a wrong shape fails silently. Two rules are read straight out of the CLIs' own embedded specs and asserted by `wire-cli.sh`. Antigravity splits its events: `PreToolUse` and `PostToolUse` are **Grouped** — handlers wrapped in a `matcher` plus `hooks` group — while `PreInvocation`, `PostInvocation` and `Stop` are **Flat**. A Flat `PreToolUse` is discarded whole: the hook name still registers, no `actions` key is even generated, nothing errors, and the file reads as correct. Codex's plugin manifest takes `hooks` as a **path string**, exactly like `skills`; an inline object does not parse. That path is an override, so `hooks/codex-hooks.json` is **derived** from `hooks/hooks.json` — copied whole, with `"matcher": "Skill"` rewritten to `"matcher": "Bash"` — which keeps every other event and keeps `hooks/hooks.json` the single source of truth. Never hand-write the second file.
Copilot keeps hook config in the `hooks` key of `settings.json` — inline definitions keyed by event name. `$COPILOT_HOME/hooks/` holds the scripts a hook runs, not the config; a config file written there sits on disk, correct and unread. That same `settings.json` also carries `enabledPlugins` and `extraKnownMarketplaces`, so wiring **merges and never overwrites**: back up first, touch only jsc's own entries under `hooks`, then read back and compare the top-level keys and every foreign hook entry against what was there before, restoring the backup if either moved. `purge` takes out only jsc's entries and leaves the third-party `SessionStart` alone.
Kiro declares hooks in the agent config's `hooks` key, and its only legal events are `agentSpawn`, `userPromptSubmit`, `preToolUse`, `postToolUse` and `stop`; the fields are `command` (required), `matcher` and `timeout_ms` — there is no `on`, `run` or `env`. The old wiring put `on`/`run`/`env` at the top level, `kiro-cli agent validate` reported nothing at all, and the file did nothing: **unknown top-level keys are ignored in silence**. A valid file is not a wired file, so check the shape separately.
**`kiro-cli agent validate` always exits 0.** Valid, illegal event name, `on`/`run` inside `hooks`, missing `command` — all four exit 0, and the errors only appear in the output. Reading the exit code builds a check that can never fail, which is the same class of bug as the ones being fixed here. Judge by the output: empty means valid. Output about something else — not logged in, expired credentials — means the check could not run, not that the file is bad; pass it and say so, because failing there would block wiring on every machine that is not logged in.
**Verification level, per CLI.** "Shape" means the CLI really parses the config; "firing" means a hook really ran. Keep them apart and report them as this table has them:
| CLI | Shape | Firing |
| --- | --- | --- |
| claude | proven | proven |
| codex | **proven** — `[hooks.state]` in `~/.codex/config.toml` records `{event}:{group}:{entry}`, a two-level index that only a Grouped structure produces; the manifest's `hooks` is a path string per the binary's own `plugin-json-spec.md` | unverified |
| antigravity | **proven** — after wiring, `agy -p "/hooks"` lists all four entries with `matcher=^view_file$`, third-party block intact | unverified (conversation quota exhausted) |
| copilot | **unproven** — `settings.json` has no read-only listing path; the location and entry form come from `copilot help config` and the working third-party entry already on the machine | unverified |
| kiro | **proven** — `kiro-cli agent validate` passes with empty output, and a counter-check confirms it discriminates: `sessionStart`, `on`/`run` inside `hooks`, and a missing `command` each produce an error | **partly proven** — `agentSpawn` and `userPromptSubmit` were observed firing; `preToolUse` and `stop` are unverified (model quota) |
Two more open items: whether kiro's two-level `resources` glob actually fixes skill visibility is unverified, and on the four non-claude CLIs the three `write-guard.sh` modes and the SDLC model lock are still unwired — not yet done, rather than failing. `wire-cli.sh smoke` asserts wiring content and script logic line by line; firing is outside its reach.
`comment-scope.sh` and `lang-guard.sh` both reach all five, wired at the same set of places, but on a different event and at a different moment each. Report the timing per CLI; never state it as one uniform behaviour:
| CLI | Scanning moment | Wired through |
| --- | --- | --- |
| claude | Per file, the instant it is written | PostToolUse |
| codex | End of every turn, over the whole git worktree | `notify` in `config.toml` |
| kiro | On every prompt submit, over the whole git worktree — it sees what the previous turn wrote | `userPromptSubmit` in `~/.kiro/agents/jsc.json` |
| copilot, antigravity | Once, when the session ends | `tools/jsc-wrap.sh` teardown |
The table above holds for both scanners. The `sweep` mode reads `git diff HEAD`, so its coverage matches what claude sees; only the feedback delay differs. Outside a git worktree `sweep` exits 0 in silence and nothing is scanned at all — say so when the user works outside git. Both `prompt` rule reminders still go into every rule file alongside the STE100 block, because a warning that arrives a turn late is worth less than not writing the offending text in the first place.
The lock file still works on those four because the SDLC skills call `sdlc-gate.sh lock {stage}` directly — that call is where the capability-tag comparison happens, so the gate keeps its force even where the prompt hook cannot be wired.
The gate needs `$JSC_HOME/model-tags.tsv`; when it is missing, report that `jsc-cli:models` (or `jsc-cli/tools/model-tags.sh sync`) must run once, because `sdlc-gate.sh lock` refuses to lock without it.
Treat any hook error as repair work, whether it appeared while wiring or while running. Stopping the remaining installs to start that repair is the right call; leaving a broken hook wired is not.
The detailed flow **MUST run as a sub agent**; the main agent only reports the summary.
## Steps
1. Run `jsc-cli/tools/detect-clis.sh`. Done when you hold the list of installed CLIs; when the list is empty, report that and stop.
2. For each installed CLI, run `tools/wire-cli.sh {cli}`. The script performs the config edit, wrapper alias install, or hook file creation for that CLI, and replaces its `<!-- jsc-hooks -->` (or `# jsc-hooks`) marker block idempotently — reruns never duplicate content. Read its exit code and first output line (`status=wired|degraded|skipped reason=...`), then confirm against the table below:
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 `{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. `{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. `{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. `{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. `{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. `{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.
| CLI | Exit / status | Verify |
| --- | --- | --- |
| claude | `status=wired` (exit 0) — hooks.json auto-wires everything, nothing to write | `claude plugin list` shows `jsc-hooks` and `/hooks` shows the registrations |
| codex | `status=degraded` (exit 1) — notify + AGENTS.md prompt fallback | `~/.codex/config.toml` contains the `notify` entry and `AGENTS.md` contains the block |
| copilot | `status=wired` (exit 0) when the CLI is detected, `status=skipped` (exit 3) otherwise | the alias resolves to `jsc-wrap.sh copilot` and `copilot-instructions.md` contains the block |
| antigravity | `status=wired` (exit 0) when the CLI is detected, `status=skipped` (exit 3) otherwise | the alias resolves to `jsc-wrap.sh antigravity` and the rules file contains the block |
| kiro | `status=degraded` (exit 1) | the hook file exists under `.kiro/hooks/` and names the script |
A row counts as done only when its verify check passes. Exit 2 means bad usage (wrong CLI name), not a wiring outcome.
3. Report the exact `status=` line `tools/wire-cli.sh` printed for each CLI; do not reinterpret or recompute the outcome by hand. Done when every detected CLI has exactly one reported status: wired, degraded, or skipped with a reason.
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 `{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`.
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
- The hook scripts accept both stdin JSON and environment variables (`JSC_CLI`, `JSC_SESSION_ID`, `JSC_SKILL`, `JSC_MODEL`); `jsc-wrap.sh` sets the first two itself.
- Every hook script accepts both stdin JSON and environment variables (`JSC_CLI`, `JSC_SESSION_ID`, `JSC_SKILL`, `JSC_TOOL_NAME`, `JSC_TOOL_COMMAND`, `JSC_MODEL`); `jsc-wrap.sh` sets the first two itself.
- `session-timer.sh` takes `start` (keep an existing start time), `restart` (always overwrite it, for a CLI with no session id — kiro), `mark` and `report`. `wire-cli.sh` picks the right one per CLI; do not hand-edit the generated hook files. `start` and `restart` also clear the restart gate whenever they decide this SessionStart is a new session, so the wiring of those two events is what lowers the gate after a restart — a CLI wired without them keeps the gate up until the user sets `JSC_RESTART_GATE=off`.
- `hooks/skill-name.sh` is the one place that turns a CLI's hook payload into `{domain}<TAB>{skill}`, one subcommand per CLI: claude reads the `skill` field, codex reads the `SKILL.md` path inside `tool_input.command` (it has no Skill tool — the model loads a skill by reading the file with Bash), copilot reads `toolArgs` and has to unwrap one layer of stringified JSON, antigravity reads `toolCall.args.AbsolutePath` and also the prompt text (a slash command injects the whole `SKILL.md` and produces no tool call), kiro reads the leading slash command in `prompt`. All five honour `JSC_SKILL` and `SKILL` first. It always exits 0: the gates fail open, and copilot's command hooks are fail-closed, where any non-zero exit means deny. `hooks/deny.sh` is the matching single source for the blocking shape — stderr plus exit 2 for claude, codex and copilot; a single-line `{"decision":"deny","reason":"..."}` on stdout with a fixed exit 0 for antigravity, whose exit-code semantics are undocumented and must never be relied on; a printed warning and exit 0 for kiro, which cannot block. Neither guard keeps a second copy of either rule; a repair goes into these two files.
- `restart-gate.sh` blocks jsc skill calls while `$JSC_HOME/restart-required.d/{cli}` exists — one file per CLI, named after the CLI code — so a freshly deployed skill set is not used by a process still running the old one. Each CLI reads only its own file: another CLI's file never blocks this one, and a restart clears only the file of the CLI that restarted. `jsc-cli:deploy` writes the current CLI's file through `restart-gate.sh require {install|update} [{domain}...]` at the end of an install or update; `restart-gate.sh report` prints one line per file, so it is visible which CLIs still owe a restart. A leftover old-format single file at `$JSC_HOME/restart-required` blocks every CLI and is deleted on the next `clear` — transitional only, and `hooks/restart-gate.sh` records when it can be dropped. The gate matches skill names, not call chains, so a nested call to anything off the exemption list is blocked all the same; `hooks/restart-gate.sh` owns that list with a reason per entry, and `jsc-meta/references/guidelines.md`「部署後重啟閘門」carries the same list. Escape hatch: `JSC_RESTART_GATE=off`.
- `write-guard.sh` takes three blocking modes, wired on two PreToolUse matchers on claude only, so claude is still the only CLI where any of it takes effect — codex, copilot and antigravity now have a usable pre-tool hook, but these three modes are not wired there yet; say that, rather than blaming a missing hook, plus a fourth mode, `release`, that is wired nowhere and is called by a skill itself. `stage` reads the stage lock that `sdlc-gate.sh` already owns and blocks `Write`, `Edit` and `MultiEdit` while `plan` or `analyze` holds it, because those two stages produce wiki pages rather than files. `review` reads the current skill — the environment variable first, then the record `skill-usage.sh` keeps — and blocks writes while `jsc-review:code-review` or `jsc-review:api-doc` runs, since both only report findings. It deliberately does **not** block `jsc-review:comment-cleanup`: that skill has to write, limited to comment lines, and deciding that limit needs per-language comment parsing of the whole proposed content, which would block legitimate cleanups more often than it caught bad ones — that boundary stays with the skill text and the later review. `commit` is wired on `Bash` and blocks a single command that stages everything and commits in one go, plus any commit message carrying simplified characters or mojibake, which it decides by calling `lang-guard.sh` rather than keeping a second word list. A `git add -A` split across two separate tool calls is not caught, on purpose: catching it needs cross-call state that the blocked operator has no way to clear. `release` deletes that recorded skill and always exits 0; `jsc-review:code-review` and `jsc-review:api-doc` call it once each as they hand their findings back. It exists because the record says which skill was loaded last, not which one is still running: both audit skills end by leaving the fixing to their caller, and without `release` every write that caller makes stays blocked for the whole TTL, with the escape hatch or a wait as the only way out — a gate must never lock away its own release. Escape hatch: `JSC_WRITE_GUARD=off`, which `release` ignores because clearing a record blocks nobody, plus `JSC_WRITE_GUARD_TTL` for how long a recorded skill counts as still running.
- `purge` reaches the user-level config only. Hooks that another plugin ships in its own `hooks.json` stay active, and uninstalling that plugin is the only way to clear them — say so when reporting, and treat their errors as third-party.
- Backups land in `$JSC_HOME/backup/hooks/{cli}/{yyyyMMdd_HHmmss}/`, one directory per purge run, under the original file names. Hand that path to the user whenever a purge removed something.
- `status claude` reads Claude Code's `installed_plugins.json` and checks the `installPath` that the CLI actually loads. It must not check only the `hooks.json` next to the `wire-cli.sh` that happens to be running, because a development checkout can otherwise hide a broken installed plugin.
- Never set `JSC_READONLY=1` for this skill. `wire-cli.sh` refuses `purge` and wiring with exit 6 under that variable, which is exactly what a health check wants and exactly what an install must not have.
- `smoke` treats `sdlc-gate.sh check` exit 2 as healthy: that exit is the stage lock blocking a turn on purpose, not a runtime error. `comment-scope.sh`, `lang-guard.sh` and `write-guard.sh` exit 2 count as healthy for the same reason — the check found something and said so. Their no-argument mode has no file name during smoke and exits 0 in silence; `sweep` depends on the worktree it runs in, so it answers 2 whenever that worktree happens to carry an offending comment, a simplified character or a mojibake sequence, and `write-guard.sh` answers 2 whenever the machine happens to hold a `plan` stage lock or a recent audit skill. None of these is a broken hook.
- `comment-scope.sh` takes three modes: `prompt` (inject the rule summary at UserPromptSubmit), no argument at all (scan the file just written at PostToolUse, reading `file_path` from stdin JSON or `JSC_CHANGED_FILE`), and `sweep [dir]` (scan every file the git worktree changed, for the four CLIs with no post-tool hook). All scanning modes read only the lines a diff added, skip markdown and binary files, and turn off entirely with `JSC_COMMENT_SCOPE=off`. The rule text itself lives in one place only, `jsc-review`'s `references/comment-scope.md`; never restate the list anywhere in this repo.
- `lang-guard.sh` takes the same three modes as `comment-scope.sh` and is wired at the same places, but it scans differently on purpose: it reads the whole file rather than comment lines only, and it does scan `.md` and plain-text files, because those are exactly the non-code output the rule targets. It flags three things — simplified characters (word list in `hooks/simplified.txt`, the single source of truth for this repo; a missing list skips that check in silence), mojibake (U+FFFD and double-encoding remnants), and non-UTF-8 encoding (decided by `iconv`; no `iconv` skips that check). It skips binaries, generated files, and the three files whose subject is those very characters (`simplified.txt`, `ste100-guard.sh`, `lang-guard.sh`). Turn it off with `JSC_LANG_GUARD=off`. The rule text lives only in `jsc-meta`'s `references/ste100.md`.
- `jsc-wrap.sh` runs both sweeps after the CLI exits and always returns the CLI's own exit code. A `sweep` hit warns on stderr and changes nothing else — never let a language or comment warning turn a successful CLI run into a failed one.
- `tools/report-error.sh` is operator- or skill-invoked only. Never wire it to fire from a failing hook: hooks stay silent and exit 0, and a failing hook that reports itself can loop.
- Data lands in `$JSC_HOME` (default `~/.jsc`), consumed by `jsc-log:worklog` and `jsc-log:stats`.
+22
View File
@@ -0,0 +1,22 @@
---
name: repair
description: Repair failed hook wiring by delegating diagnosis to installed AI agent CLIs as subagents, patching the hooks repo, syncing manifests, and opening a PR against develop. Use when hooks-install or report-error.sh reports a failed hook, or when a hook keeps failing after rewiring; not for routine wiring or unrelated work.
---
# repair — repair a failed hook
Single source of guidelines: `jsc-meta`'s `references/guidelines.md`.
This skill is exempt from the version guard and the post-deploy restart gate, because it is the only path back from a broken hook. `hooks/version-guard.sh` and `hooks/restart-gate.sh` own those two exemption lists.
## Flow
1. Read the failure context from `ERROR_{HASH}` through `jsc-gitea:wiki`, or from the failed `status=` line when no page was written. A wiki read that fails stops the skill: report which page could not be read and ask for the failure output instead of guessing. Done when the failure context names the script, the exit code and the CLI, and the PR base branch is fixed at `develop`.
2. Run `jsc-cli/tools/detect-clis.sh`. It always exits 0 and prints one `name<TAB>path<TAB>version` line per installed CLI; empty output means no CLI is installed. Delegate diagnosis to one subagent per detected CLI — each **MUST run as a sub agent**, must receive the failure context, must stay read-only, and must return root cause, the files to change, and the verification command to run. Done when every detected CLI has returned one proposal, or the output was empty and the main agent has recorded that it diagnoses alone.
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.
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.
+15 -4
View File
@@ -1,9 +1,20 @@
# 異常目錄 — ERROR_CONTENTS
> 由 `jsc-hooks` 的失敗回報流程維護。新異常附加在文末,查問題時先看最新一筆。
>
> 存放位置:本頁落在 `JSC_WIKI_REPO_CONTENTS` 解出的目錄專用存取庫,與異常頁的存取庫是兩個不同的存取庫。
>
> 寫入語意:一個區塊代表一次 hook 異常回報。H2 標題就是那一筆的異常頁頁名 `ERROR_{HASH}`,欄位是標題底下的一層條列,一個欄位一條。寫入前先讀回整頁,同一筆異常已經有區塊就整塊換掉,沒有才在文末附加一個新區塊,最後整頁寫回。一律 upsert 附加,禁止整頁覆蓋,也不得改動別人的區塊。
>
> 連結寫法:一律寫成 `[{文字}]({絕對網址})`,網址取 `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} | [[{error title}|ERROR_{HASH}]] | {owner}/{repo} | {hook_name} | {exit_code} | {error_summary} |
- 時間:{yyyy-MM-dd HH:mm:ss}
- 頁名:[{頁名}]({wiki-url 印出的絕對網址})
- 存取庫名稱:{owner}/{repo}
- 觸發 hook:{hook_name}
- 退出碼:{exit_code}
- 摘要:{error_summary}
+17 -2
View File
@@ -2,23 +2,38 @@
# jsc-wrap.sh — 無 hook 系統 CLI 的包裝啟動器(例:copilot、antigravity)。
# 用法: jsc-wrap.sh {cli} [args...]
# 行為: 匯出 JSC_CLI 與 JSC_SESSION_ID → session-timer start → 執行 CLI →
# 結束後 session-timer mark 並以 scan-logs.sh 回填用量,最後回傳 CLI 的結束碼。
# 結束後 session-timer mark、以 scan-logs.sh 回填用量、以 comment-scope.sh sweep
# 掃一次整個工作區的註解範圍,再以 lang-guard.sh sweep 掃一次繁中與編碼,
# 最後回傳 CLI 的結束碼。
# 注意: JSC_CLI 存的是 CLI 代號(antigravity、kiro),實際執行的是 cli_bin 對應的
# 執行檔(agy、kiro-cli)。直接拿代號當指令跑會 127,因為沒有這兩個執行檔。
HERE=$(cd "$(dirname "$0")" && pwd)
HOOKS="$HERE/../hooks"
. "$HOOKS/lib.sh"
cli="${1:-}"
if [ -z "$cli" ]; then
echo "用法:jsc-wrap.sh {cli} [args...]" >&2
exit 2
fi
shift
bin=$(cli_bin "$cli")
JSC_CLI="$cli"
# 未提供 session id 就自動產生({cli}-時間戳-PID),讓計時與用量共用同一個 session
[ -n "${JSC_SESSION_ID:-}" ] || JSC_SESSION_ID="$cli-$(date +%Y%m%d%H%M%S)-$$"
export JSC_CLI JSC_SESSION_ID
sh "$HOOKS/session-timer.sh" start </dev/null
"$cli" "$@"
"$bin" "$@"
rc=$?
# 收尾:補記結束時間,並從原生日誌回填技能用量
sh "$HOOKS/session-timer.sh" mark </dev/null
sh "$HERE/scan-logs.sh" --cli "$cli" --session "$JSC_SESSION_ID" </dev/null || true
# 註解範圍收尾掃描:copilot 與 antigravity 連逐輪事件都沒有,整個工作階段只有這裡掃得到。
# 掃的是啟動當下的工作目錄,也就是使用者跑 CLI 的那個 git 工作區。
# `|| true` 不可省:sweep 掃到違規會 exit 2,接住它才不會蓋掉底下要回傳的 CLI 結束碼。
# 警告只走 sweep 自己的 stderr,包裝器一律原樣回傳 $rc——包裝器改掉結束碼,呼叫端的
# `cmd && next` 就會誤判,註解檢查不該有這種副作用。
sh "$HOOKS/comment-scope.sh" sweep </dev/null || true
# 繁中與編碼收尾掃描:同一個理由、同一種寫法。`|| true` 一樣不可省,lang-guard.sh 掃到
# 簡體字或亂碼也會 exit 2,接住它才不會蓋掉底下要回傳的 CLI 結束碼。
sh "$HOOKS/lang-guard.sh" sweep </dev/null || true
exit "$rc"
+239
View File
@@ -0,0 +1,239 @@
#!/usr/bin/env sh
# report-error.sh — 失敗回報流程:把一筆 hook 或工具異常寫成 wiki 的 ERROR_{HASH},
# 並在異常目錄頁附上一個索引區塊。頁面內容套用 templates/ 的兩份範本,
# 範本是文案的唯一來源,本腳本只填欄位;真正寫進 wiki 前,還會先走
# Gitea 寫入確認。
# 目錄頁的讀回、比對與整頁寫回一律交給 jsc-gitea 的 tools/wiki-contents.sh,本腳本只組出
# 自己那一個區塊。目錄頁版面只留一份正本,十幾個目錄頁才不會各長一種樣子。
#
# 用法:
# report-error.sh --hook {名稱} --exit {碼} --summary {摘要}
# [--repo {owner}/{repo}] [--cli {名稱}] [--session {id}]
# [--source {stdin|env|command}] [--symptom {現象}]
# [--cause {可能原因}] [--action {處理結果}]
# 相關輸出(stdout/stderr 摘要)由標準輸入讀入,可省略。
#
# 輸出:
# 成功印出「{頁名} {網址}」一行。網址在異常頁寫成功之後才取,頁名的 hash 帶時間戳,
# 每次都是全新的頁,寫之前查一定是 404。取不到網址時只印頁名,原因走 stderr,仍然 exit 0。
# 下列四種情形安靜降級:不寫任何頁、不輸出任何內容、exit 0。回報失敗不該再變成一次失敗。
# 1. 找不到 gitea.sh
# 2. 解析不出異常頁的 wiki 存取庫(JSC_WIKI_REPO_ERROR 與 JSC_WIKI_REPO 都沒設)
# 3. 算不出 HASH(hash-id 失敗或回空字串)
# 4. 建不出暫存檔(mktemp 失敗)
# 異常頁與目錄頁分屬兩個存取庫,各解各的:解不出異常頁的存取庫就整支降級;解得出
# 異常頁、只解不出目錄頁的存取庫(wiki-contents.sh 回 3),就只寫異常頁、跳過目錄頁
# 更新,印出頁名,仍然 exit 0。找不到 wiki-contents.sh 也走同一條降級路。
# 一份寫得成的異常紀錄,不該因為目錄頁沒地方放就整份丟掉。
# 寫入 wiki 失敗才以 exit 4 回報,訊息走 stderr。
#
# 連結:
# 目錄頁那個區塊裡的「頁名」那一條指向異常頁,一律寫成 [{文字}]({連結}),網址取
# jsc-gitea 的 gitea.sh wiki-url,不自己組路徑。
# 寫入前先把那個網址交給 jsc-gitea 的 link-check.sh,結束碼 0 才把連結寫進那一條。
# 驗不過就只留純文字頁名:那一條照寫、異常頁照寫、結束碼照舊。這一段一律不改結束碼,
# 本腳本是失敗回報路徑,回報失敗不該再變成一次失敗。
#
# 結束碼: 0=已寫入異常頁並印出頁名(取得網址就一併印出),或以上列四種安靜降級原因之一
# 結束、沒有寫出任何頁也沒有任何輸出——回報失敗不該再變成一次失敗
# 2=用法錯誤(缺 --hook 或 --summary)
# 4=寫入 wiki 失敗(異常頁與索引目錄頁,任一支寫不進去就算),或目錄頁的舊內容
# 讀不回來(wiki-contents.sh 回 7 金鑰失效、8 其他 API 失敗)而放棄寫入;
# 訊息走 stderr。讀不回來就不寫,是為了不拿範本蓋掉一份還在的目錄頁
# 註: 本檔以 `. "$ROOT/hooks/lib.sh"` 載入共用函式,沒有接 `|| true`。lib.sh 讀不到時 sh 會
# 就地結束並回 2,跟用法錯誤同碼;分不出是哪一種時,先確認 hooks/lib.sh 在不在。
#
# 頁名:
# ERROR_{HASH},HASH 取「{owner}/{repo} {hook} {時間}」的 SHA-1 完整 40 碼大寫十六進位
# (共用 hash 規則)。三段以空白相連當 hash 輸入。
# 時間放進 hash:同一種失敗再發生時要另開新頁,不覆寫舊紀錄。
# 索引目錄頁的頁名固定,不帶 HASH。
#
# 誰來呼叫:
# 由操作者手動執行,或由技能步驟執行(`jsc-hooks:hooks-install` 在 wire-cli.sh 回報
# status=failed 時呼叫)。**不接在失敗的 hook 上自動觸發**:hook 一律安靜 exit 0,
# 而且自我回報要走網路寫 wiki,失敗的 hook 再去回報自己會疊出迴圈。
set -u
HERE=$(cd "$(dirname "$0")" && pwd)
ROOT=$(cd "$HERE/.." && pwd)
. "$ROOT/hooks/lib.sh"
STDIN_JSON="" # 本腳本的標準輸入是錯誤輸出摘要,不是 JSON
hook=""; code=""; summary=""; repo=""; cli=""; session=""
source_kind=""; symptom=""; cause=""; action=""
while [ $# -gt 0 ]; do
case "$1" in
--hook) hook="${2:-}"; shift 2 ;;
--exit) code="${2:-}"; shift 2 ;;
--summary) summary="${2:-}"; shift 2 ;;
--repo) repo="${2:-}"; shift 2 ;;
--cli) cli="${2:-}"; shift 2 ;;
--session) session="${2:-}"; shift 2 ;;
--source) source_kind="${2:-}"; shift 2 ;;
--symptom) symptom="${2:-}"; shift 2 ;;
--cause) cause="${2:-}"; shift 2 ;;
--action) action="${2:-}"; shift 2 ;;
*) shift ;;
esac
done
if [ -z "$hook" ] || [ -z "$summary" ]; then
echo "用法:report-error.sh --hook {名稱} --exit {碼} --summary {摘要} [...]" >&2
exit 2
fi
gsh=$(jsc_gitea_sh) || exit 0
# 異常頁與索引目錄頁落在兩個不同的存取庫,各解各的:異常頁走自己的型別,目錄頁走目錄專用
# 型別。異常頁的存取庫解不出來就整支降級,連異常都沒地方寫,做下去也沒意義。
wrepo=$(sh "$gsh" wiki-repo ERROR 2>/dev/null) || exit 0
[ -n "$wrepo" ] || exit 0
# 目錄頁的存取庫不在這裡解:讀回、比對、整頁寫回都由 wiki-contents.sh 做,它自己解目錄專用
# 存取庫,這邊再解一次就會有兩份規則。解不出來時它回 3,本腳本照原本的語意降級。
# 存取庫名稱未指定就取工作目錄的 origin(只用來標記異常屬於哪個存取庫)
if [ -z "$repo" ]; then
origin=$(git config --get remote.origin.url 2>/dev/null || true)
# 尾綴的 .git 在這裡剝掉:hash 工具刻意不做輸入正規化,同一份輸入要算出同一個 HASH,
# 正規化就是呼叫端的責任。帶不帶 .git 會算出兩個不同的頁,同一個存取庫就分裂成兩份紀錄。
repo=$(printf '%s' "$origin" \
| sed -n 's#.*[/:]\([^/]*\)/\([^/]*\)$#\1/\2#p' | sed 's/\.git$//')
fi
[ -n "$repo" ] || repo="-"
[ -n "$cli" ] || cli=$(cli_name)
[ -n "$session" ] || session=$(session_id)
[ -n "$code" ] || code="-"
[ -n "$source_kind" ] || source_kind="command"
[ -n "$symptom" ] || symptom="$summary"
[ -n "$cause" ] || cause="待查"
[ -n "$action" ] || action="待處理"
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
[ -n "$detail" ] || detail="(無)"
# 相關輸出落在異常頁的表格欄位裡,半形 | 會把欄位切斷,改成全形
detail=$(printf '%s' "$detail" | sed 's/|/|/g')
# 摘要在目錄頁是一整條條列,換行會把一條拆成兩行,先併成一行
summary=$(printf '%s' "$summary" | tr '\n' ' ')
hash=$(sh "$gsh" hash-id "$repo $hook $ts" 2>/dev/null) || exit 0
[ -n "$hash" ] || exit 0
page="ERROR_$hash"
# sed 取代值要先轉義:& 與分隔字元 | 會被 sed 當語法,換行會整行斷掉
esc() { printf '%s' "$1" | tr '\n' ' ' | sed 's/[\\&|]/\\&/g'; }
fill() { sed "s|$1|$(esc "$2")|g"; }
tmp_page=$(mktemp) || exit 0
tmp_entry=$(mktemp) || { rm -f "$tmp_page"; exit 0; }
trap 'rm -f "$tmp_page" "$tmp_entry"' EXIT
fill '{HASH}' "$hash" < "$ROOT/templates/error-page.md" \
| fill '{yyyy-MM-dd HH:mm:ss}' "$ts" \
| fill '{owner}/{repo}' "$repo" \
| fill '{cli}' "$cli" \
| fill '{session_id}' "$session" \
| fill '{hook_name}' "$hook" \
| fill '{exit_code}' "$code" \
| fill '{error_summary}' "$summary" \
| fill '{現象描述}' "$symptom" \
| fill '{可能原因}' "$cause" \
| fill '{處理方式}' "$action" \
| fill '{stdin / env / command}' "$source_kind" \
| fill '{stdout / stderr 摘要}' "$detail" \
| fill '{yyyyMMdd}_{HHmmss}' "$ticket_ts" > "$tmp_page"
# 異常頁先寫,網址後取。頁名的 hash 帶時間戳,每次回報都是一個全新的頁,寫進去之前查網址
# 一定是 404,拿到的必然是空字串;目錄頁那一條會變成沒有連結的死字,stdout 也少一半。
if ! sh "$gsh" wiki-put "$wrepo" "$page" "$tmp_page" >/dev/null 2>&1; then
echo "[jsc] 寫入 $page 失敗($wrepo)。" >&2
exit 4
fi
# 目錄頁那個區塊的「頁名」那一條指向異常頁,連結一律寫成 [{文字}]({連結}),網址取
# gitea.sh wiki-url。
# wiki 自己那種雙中括號寫法只在同一個 wiki 裡解得開,寫錯不會報錯,畫面上看起來像正常
# 文字或死連結,巡不到也修不了。
# 網址取不到不算失敗:異常頁已經寫成功了,只是這一條少一個連結。這裡把原因記下來走 stderr,
# 結束碼照舊——安靜降級仍是 exit 0,回報失敗不該再變成一次失敗。
url=$(sh "$gsh" wiki-url "$wrepo" "$page" 2>/dev/null)
url_code=$?
url_note=''
if [ "$url_code" -ne 0 ]; then
url=''
case "$url_code" in
4) url_note='頁面查不到(wiki-url 回 4),寫入後尚未生效' ;;
5) url_note='回應裡沒有 html_url(wiki-url 回 5)' ;;
7) url_note='金鑰失效或權限不足(wiki-url 回 7)' ;;
*) url_note="wiki-url 結束碼 $url_code" ;;
esac
echo "[jsc] 取不到 $page 的網址:$url_note。目錄頁那一條與輸出只留頁名。" >&2
fi
emit() { # 異常頁已經寫成功,頁名一定要印;網址取不到就只印頁名,不印一個空欄位
if [ -n "$url" ]; then printf '%s %s\n' "$page" "$url"; else printf '%s\n' "$page"; fi
}
# 連結先驗證連得到,才寫進目錄頁那一條。沒驗過的連結寫進去,異常頁一樣會在目錄頁長出
# 死連結,而目錄頁是別人查問題的入口。驗不過就只留純文字頁名,那一條照寫。
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
# 目錄頁上這一筆是一個 H2 區塊:標題就是異常頁頁名,欄位一行一條,順序與範本的示範區塊
# 一致。標題不放連結也不放網址——頁名只由存取庫名稱、hook 與時間決定,換主機或改存取庫
# 都動不到它,比對鍵才找得到既有那一筆。
{
printf '## %s\n\n' "$page"
printf -- '- 時間:%s\n' "$ts"
printf -- '- 頁名:%s\n' "$link_text"
printf -- '- 存取庫名稱:%s\n' "$repo"
printf -- '- 觸發 hook:%s\n' "$hook"
printf -- '- 退出碼:%s\n' "$code"
printf -- '- 摘要:%s\n' "$summary"
} > "$tmp_entry"
# 目錄頁交給 wiki-contents.sh:它負責解目錄專用存取庫、讀回舊頁、比對 H2 標題找既有區塊、
# 附加或整塊換掉,再整頁寫回,還會把舊的表格頁轉成條列。頁上每一個區塊都是別人回報的異常,
# 那套「讀得回舊內容才寫」的判斷只留一份正本,才不會每個目錄頁各寫一套、錯一次少一筆紀錄。
# 傳進去的那個 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
exit 0
fi
sh "$wcs" upsert ERROR 2 "$page" "$tmp_entry" "$ROOT/templates/error-contents.md" >/dev/null
wc_code=$?
case "$wc_code" in
0) ;;
# 3 是目錄頁的存取庫沒設定。異常頁已經寫成功,一份寫得成的異常紀錄不該因為索引沒地方放
# 就整份丟掉,所以只跳過目錄頁,結束碼照舊回 0。
3) echo "[jsc] 目錄頁的 wiki 存取庫解不出來(wiki-contents.sh 回 3),只寫異常頁,跳過目錄;$page 已建立($wrepo)。" >&2 ;;
# 其餘結束碼都代表這一筆沒進到目錄頁:7 金鑰失效、8 其他 API 失敗都是舊內容未知而中止,
# 1 是組不出頁面內容或寫入失敗,2 是用法錯誤,4 是頁面不存在又沒收到範本。
*)
echo "[jsc] 目錄頁寫入失敗(wiki-contents.sh 結束碼 $wc_code),$page 已建立。" >&2
exit 4 ;;
esac
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
+197
View File
@@ -0,0 +1,197 @@
#!/usr/bin/env sh
# scan-hook-errors.sh — 掃 CLI 的原生紀錄,找出 hook 的「執行期」錯誤:接線寫對了、
# hook 也真的被觸發了,但跑起來出錯(缺執行檔、路徑錯、權限不足)。
# 用法: scan-hook-errors.sh --cli {claude|codex|copilot|antigravity|kiro}
#
# 覆蓋範圍要據實回報,不得暗示每個 CLI 都掃得到:
# claude 有 hook 結果紀錄,掃 ~/.claude/projects/**/*.jsonl 兩種紀錄——
# `"type":"hook_non_blocking_error"` 的 attachment
# (hookName、hookEvent、exitCode、stderr、command、timestamp)
# 與非空的 `"hookErrors":[...]`
# codex、copilot、antigravity、kiro 原生紀錄只留工作階段與提示內容,沒有記下 hook 的退出碼與
# stderr,一律回報 unavailable,改用 wire-cli.sh smoke {cli}
# 主動跑一輪驗執行期
#
# 去重: 以 $JSC_HOME/errors/scan-state/ 記住每個日誌檔已掃描的位元組數(同 tools/scan-logs.sh),
# 重掃只讀新增段落。兩種紀錄各自成筆,不互相配對:同一次失敗在不同 transcript 條目
# 裡各留一筆,靠位置猜配對只會把真錯誤併掉。
#
# 產出: 每筆錯誤附加一行 JSON 到 $JSC_HOME/errors/hooks.jsonl(格式比照 hooks/skill-usage.sh):
# {ts,cli,hook,event,exit,detail,jsc}
# jsc 欄位:command 或 stderr 命中十支 hook 腳本任一支,或命中 jsc-hooks 路徑,就是 true,
# 否則 false。分得出來才用得上——非 jsc 的 hook 錯誤不是 jsc 該修的,hooks-install 只回報、
# 不轉 jsc-hooks:repair。
#
# 輸出: 第一行 `status={clean|errors|unavailable} reason=...`(可供程式判讀),
# errors 時其後每筆一行人類可讀的繁中摘要(hook 名、退出碼、是否屬 jsc)。
# 結束碼: 0=clean 或 unavailable、1=errors、2=用法錯誤
set -u
HERE=$(cd "$(dirname "$0")" && pwd)
. "$HERE/../hooks/lib.sh"
cli=""
while [ $# -gt 0 ]; do
case "$1" in
--cli) cli="${2:-}"; shift 2 ;;
*) shift ;;
esac
done
case "$cli" in
claude|codex|copilot|antigravity|kiro) ;;
*)
echo "用法:scan-hook-errors.sh --cli {claude|codex|copilot|antigravity|kiro}" >&2
exit 2 ;;
esac
ERRDIR="$JSC_HOME/errors"
STATE="$ERRDIR/scan-state"
OUT="$ERRDIR/hooks.jsonl"
mkdir -p "$STATE" 2>/dev/null || true
case "$cli" in
codex|copilot|antigravity|kiro)
printf 'status=unavailable reason=%s\n' "$cli 沒有 hook 結果紀錄,執行期錯誤掃不到"
echo "[jsc] $cli:原生紀錄只留工作階段與提示內容,沒有記下 hook 的退出碼與 stderr。"
echo "[jsc] $cli:改跑 tools/wire-cli.sh smoke $cli,主動執行十支 hook 驗執行期。"
exit 0 ;;
esac
# 印出日誌檔自上次掃描後的新增內容,並更新位移(位移即去重機制)
new_content() { # $1=file
key=$(printf '%s' "$1" | cksum | tr ' \t' '--')
off_f="$STATE/$cli-$key.offset"
off=$(cat "$off_f" 2>/dev/null || echo 0)
size=$(wc -c < "$1" 2>/dev/null || echo 0)
[ "$size" -gt "$off" ] 2>/dev/null || return 0
tail -c +"$((off + 1))" "$1" 2>/dev/null
echo "$size" > "$off_f"
}
# 從新增內容萃取錯誤,每筆一行 TSV:hook、event、exit、jsc、ts、detail。
# 欄位用手寫掃描取,不靠正規式一次抓完:stderr 裡有轉義引號,正規式會抓過頭。
extract_errors() {
awk '
# 取 JSON 字串或純量欄位;字串保留原本的轉義序列,寫回 jsonl 時才不必重新轉義。
function jstr(s, name, p, i, c, out) {
p = index(s, "\"" name "\":")
if (p == 0) return ""
i = p + length(name) + 3
while (substr(s, i, 1) == " ") i++
if (substr(s, i, 1) != "\"") {
out = ""
while (i <= length(s) && substr(s, i, 1) !~ /[,}\]]/) { out = out substr(s, i, 1); i++ }
return out
}
i++
out = ""
while (i <= length(s)) {
c = substr(s, i, 1)
if (c == "\\") { out = out substr(s, i, 2); i += 2; continue }
if (c == "\"") break
out = out c; i++
}
return out
}
# 判定這筆錯誤是不是 jsc 自己的 hook。腳本名逐支列,再補一條 jsc-hooks 路徑判定:
# 接線寫進設定的命令一律走 $JSC_HOME/current/jsc-hooks,路徑本身就是證據,新增 hook 時
# 就算忘了補進下面這張清單也還認得出來。認錯邊的代價不對稱——漏認會把 jsc 的錯誤當成
# 第三方的,只回報不修正。
function is_jsc(t) {
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, "version-guard.sh") || index(t, "restart-gate.sh") \
|| index(t, "comment-scope.sh") || index(t, "lang-guard.sh") \
|| index(t, "write-guard.sh") || index(t, "jsc-hooks")) return "true"
return "false"
}
# 摘要收斂成單行短字串:TSV 欄位不能有 tab,jsonl 欄位不能有裸換行;
# 截斷可能切到半個轉義序列,尾端的反斜線要清掉才是合法 JSON 字串。
function clean(t, x) {
x = t
gsub(/\t/, " ", x)
gsub(/\\n/, " ", x)
x = substr(x, 1, 300)
sub(/\\+$/, "", x)
if (x == "") x = "(無錯誤輸出)"
return x
}
# hookErrors 的內容是 JSON 陣列原文,元素外面那對引號是結構、不是文字。
# 直接寫進 jsonl 會多出一對裸引號把字串切斷,所以先拆成純文字,多筆用分號串起來。
function unarray(a) {
gsub(/","/, "; ", a)
sub(/^"/, "", a)
sub(/"$/, "", a)
return a
}
function emit(hook, ev, ec, own, ts, detail) {
if (hook == "") hook = "unknown"
if (ev == "") ev = "-"
if (ec == "") ec = "-"
printf "%s\t%s\t%s\t%s\t%s\t%s\n", hook, ev, ec, own, ts, clean(detail)
}
/"type":"hook_non_blocking_error"/ {
err = jstr($0, "stderr"); cmd = jstr($0, "command")
detail = (err != "" ? err : cmd)
emit(jstr($0, "hookName"), jstr($0, "hookEvent"), jstr($0, "exitCode"), \
is_jsc(cmd " " err), jstr($0, "timestamp"), detail)
next
}
# 非空的 hookErrors:只有訊息陣列,沒有 hook 名與退出碼,欄位據實留空。
/"hookErrors":\[[^]]/ {
p = index($0, "\"hookErrors\":[")
rest = substr($0, p + length("\"hookErrors\":["))
q = index(rest, "]")
arr = (q > 1 ? substr(rest, 1, q - 1) : rest)
emit("", "", "", is_jsc(arr), jstr($0, "timestamp"), unarray(arr))
}
'
}
d="$HOME/.claude/projects"
if [ ! -d "$d" ]; then
printf 'status=clean reason=%s\n' "找不到 $d,沒有 claude 紀錄可掃"
echo "[jsc] claude:這台機器沒有 transcript 目錄,掃不到紀錄跟沒有錯誤是兩件事,請改跑 tools/wire-cli.sh smoke claude。"
exit 0
fi
recs=$(mktemp) || { printf 'status=clean reason=%s\n' "無法建立暫存檔"; exit 0; }
files=$(mktemp) || { rm -f "$recs"; printf 'status=clean reason=%s\n' "無法建立暫存檔"; exit 0; }
trap 'rm -f "$recs" "$files"' EXIT
find "$d" -type f -name '*.jsonl' 2>/dev/null | sort > "$files"
# 迴圈從檔案讀,不放在管線右邊:管線會開子 shell,計數與旗標傳不回本 shell。
while IFS= read -r f; do
[ -n "$f" ] || continue
new_content "$f" | extract_errors >> "$recs"
done < "$files"
total=0; jsc_n=0
human=$(mktemp) || { printf 'status=clean reason=%s\n' "無法建立暫存檔"; exit 0; }
TAB=$(printf '\t')
while IFS="$TAB" read -r hook ev ec isjsc ts detail; do
[ -n "$hook" ] || continue
[ -n "$ts" ] || ts=$(now_iso)
total=$((total + 1))
printf '{"ts":"%s","cli":"%s","hook":"%s","event":"%s","exit":"%s","detail":"%s","jsc":%s}\n' \
"$ts" "$cli" "$hook" "$ev" "$ec" "$detail" "$isjsc" >> "$OUT"
if [ "$isjsc" = true ]; then
jsc_n=$((jsc_n + 1)); own="屬 jsc"
else
own="非 jsc 的第三方 hook"
fi
printf '[jsc] %s(%s 事件)exit %s,%s:%s\n' "$hook" "$ev" "$ec" "$own" "$detail" >> "$human"
done < "$recs"
if [ "$total" -eq 0 ]; then
rm -f "$human"
printf 'status=clean reason=%s\n' "claude 紀錄裡沒有新的 hook 執行期錯誤"
exit 0
fi
printf 'status=errors reason=%s\n' "掃到 $total 筆 hook 執行期錯誤,其中 $jsc_n 筆屬 jsc"
cat "$human"
rm -f "$human"
echo "[jsc] 屬 jsc 的錯誤請先以 tools/report-error.sh 回報,再交給 /jsc-hooks:repair 自動修正並開 develop PR。"
echo "[jsc] 非 jsc 的第三方 hook 錯誤只回報,不由 jsc 修正。"
echo "[jsc] 全部紀錄已附加到 $OUT。"
exit 1
+5
View File
@@ -4,6 +4,11 @@
# 用法: scan-logs.sh --cli {name} [--session {sid}]
# 支援: copilot(~/.config/copilot/)、antigravity(全域日誌目錄)、codex(~/.codex/sessions/、~/.codex/log/)。
# 去重: 以 $JSC_HOME/usage/scan-state/ 記住每個日誌檔已掃描的位元組數,重掃只讀新增段落。
# 結束碼: 0=回填完成,或找不到該 CLI 的日誌目錄、尚未支援該 CLI(兩種都只印一行說明就結束,
# 回填不到資料不算失敗)
# 2=用法錯誤(沒給 --cli)
# 註: 本檔以 `. "$HERE/../hooks/lib.sh"` 載入共用函式,沒有接 `|| true`。lib.sh 讀不到時
# sh 會就地結束並回 2,跟用法錯誤同碼;分不出是哪一種時,先確認 hooks/lib.sh 在不在。
HERE=$(cd "$(dirname "$0")" && pwd); . "$HERE/../hooks/lib.sh"
cli=""; sid=""
+2787 -76
View File
File diff suppressed because it is too large Load Diff