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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 11:14:41 +08:00
admin 248bc90fc2 Merge pull request '異常目錄頁改成 H2 區塊條列,寫入語意委派共用工具' (#79) from feat/contents-list/main into develop
Reviewed-on: #79
2026-09-07 01:22:56 +00:00
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 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 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 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 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
17 changed files with 650 additions and 164 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "jsc-hooks",
"version": "0.4.1",
"version": "0.5.0",
"description": "跨 CLI hooks:STE100 語言強制、工時計時、技能用量記錄、SDLC 模型鎖、版本前置檢查、註解範圍守門、繁中編碼守門、部署後強制重啟、寫入與提交閘門",
"skills": "./skills",
"author": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"hooks": "./hooks/codex-hooks.json",
"name": "jsc-hooks",
"version": "0.4.1",
"version": "0.5.0",
"description": "跨 CLI hooks:STE100 語言強制、工時計時、技能用量記錄、SDLC 模型鎖、版本前置檢查、註解範圍守門、繁中編碼守門、部署後強制重啟、寫入與提交閘門",
"skills": "./skills",
"jsc": {
+15 -10
View File
@@ -23,7 +23,8 @@ Marketplace 統一為 `jsc`(https://gitea.jsc.idv.tw/plugins/meta.git),安
| 腳本 | 事件 | 作用 |
| --- | --- | --- |
| `hooks/ste100-guard.sh` | UserPromptSubmit | 注入 STE100 繁體中文輸出規則(hook > prompt 強制層) |
| `hooks/session-timer.sh` | SessionStart / Stop / SessionEnd | 記錄工作階段起訖。子指令:`start` 記起始時間(已有紀錄就不動,給 claude 這種每階段有自己 session id 的 CLI)、`restart` 一律覆寫起始時間(給接不到 session id 的 kiro,不覆寫會把上一階段算進來)、`mark` 更新最後活動時間、`report` 供 `jsc-log:worklog` 取花費時間。`start` 與 `restart` 判定為新工作階段時,另外呼叫 `restart-gate.sh clear` 放下部署後的重啟閘門——新工作階段代表 CLI 行程是新起的,新版一定已經載入。清除的範圍只有跑到這支腳本的那一支 CLI 自己那一份狀態檔,別支沒重啟就繼續被擋 |
| `hooks/session-timer.sh` | SessionStart / Stop / SessionEnd | 記錄工作階段起訖。子指令:`start` 記起始時間(已有紀錄就不動,給 claude 這種每階段有自己 session id 的 CLI)、`restart` 一律覆寫起始時間(給接不到 session id 的 kiro,不覆寫會把上一階段算進來)、`mark` 更新最後活動時間、`report` 供 `jsc-log:worklog` 取花費時間。`start` 與 `restart` 一律呼叫一次 `restart-gate.sh clear` 問要不要放下部署後的重啟閘門,**只是問,清不清由那一邊看行程存活決定**:這裡曾經自己判過,用的是「起始檔不存在=行程是新起的」,而還沒重啟的工作階段生出來的子行程拿到的也是沒見過的代號,於是替人把閘門放下了。清除的範圍只有跑到這支腳本的那一支 CLI 自己那一份狀態檔,別支沒重啟就繼續被擋 |
| `hooks/session-reminder.sh` | SessionStart | 把助理算好的未讀提醒帶到前景。只讀 `$JSC_HOME/assistant/reminders.tsv`(`jsc-assist` 的巡檢每一輪重寫),逾期的排前面、使用者自己登錄的到期提醒在後,最多列 8 筆;委派清單種入的內建項只印一行總數(那幾筆等的是接線不是人,每一輪都到期、每一輪都一樣,逐筆吐出來就是噪音),另加一行「有幾筆待辦連續失敗」。**這一支一個判定都不做**:自己拿 `due` 欄與 `next_run` 去跟現在比就是第二套到期判定,跟助理那一套遲早對不上。一個工作階段只提一次,記號是 `$JSC_HOME/sessions/{代號}.reminded`,接不到 session id 的 CLI 由 `session-timer.sh restart` 清掉那個記號。佇列檔頭帶那一輪的時間戳與 epoch,超過心跳門檻或心跳不新鮮就明說「這批提醒是多久以前算的、助理現在的心跳是什麼狀態」——一份沒有人更新的佇列讀起來跟新的一模一樣,而「沒有提醒」與「沒有人算提醒」不可以長得一樣。助理狀態目錄不存在時一個字都不印:那台機器從沒啟動過助理,每個工作階段催一次不是提醒是噪音。子指令 `peek` 只印不記號,給人重看與檢核用。永遠 exit 0 |
| `hooks/skill-name.sh` | 不直接接線,由 `version-guard.sh` 與 `restart-gate.sh` 呼叫 | 從各 CLI 的 hook 負載解析出這一次要用哪一支 jsc 技能,一支 CLI 一個子命令,印一行「{domain}<TAB>{技能名}」,解析不出來就印空字串。取值來源:claude 讀 stdin JSON 的 `skill` 欄位、codex 讀 `tool_input.command` 裡那條 `SKILL.md` 路徑(Codex 沒有 Skill 工具,技能是模型自己用 Bash 讀 `SKILL.md` 載入的)、copilot 讀 `toolArgs`(字串化的 JSON,要先剝一層跳脫)、antigravity 讀 `toolCall.args.AbsolutePath` 另收提示字串(斜線指令不產生工具呼叫)、kiro 讀 `prompt` 開頭那個斜線指令;五支都先看環境變數 `JSC_SKILL`、`SKILL`。永遠 exit 0:閘門那一端一律 fail-open,而且 copilot 的 command hook 是 fail-closed 的,回非零等於拒絕。規則只有這一份,兩支閘門都不重寫第二套 |
| `hooks/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` 的檔頭,那裡一項一個理由 |
@@ -36,7 +37,7 @@ Marketplace 統一為 `jsc`(https://gitea.jsc.idv.tw/plugins/meta.git),安
| `hooks/sdlc-gate.sh` | UserPromptSubmit、PreToolUse(Skill) | SDLC 階段能力標籤閘門與模型鎖:`lock {stage}` 由 jsc-sdlc 階段技能呼叫,從可驗證來源讀出模型 id,比對該階段必要標籤(`$JSC_HOME/model-tags.tsv`),不符就拒絕上鎖。**來源依 CLI 分流**,一支 CLI 只讀自己的紀錄:claude 讀 transcript 與 hook stdin JSON,codex 讀 hook stdin JSON 與自己的 session 記錄,copilot、antigravity、kiro 本機沒有可讀的模型紀錄,判不出 CLI 時不採用任何自動來源;所有 CLI 最後都接受 `JSC_MODEL` 人工覆寫,且回報會標明人工覆寫。政策是 **fail-closed**:不知道能力就擋下,三種情形一律擋——判不出 CLI、判不出模型、模型不在能力標籤表上;每一則擋下的訊息都會印出兩條逃生門(設 `JSC_MODEL`,或執行 `sdlc-gate.sh unlock {狀態檔}`)。`check` 在上述任一情形以 exit 2 擋下該輪提示(其他 hook 一律 exit 0,此處是刻意例外);`report` 印出階段、必要標籤、模型 id、模型來源與判定結果;`unlock` 為逃生門,不帶參數清這個工作階段的新舊兩份,帶參數只清指定的那一支。階段鎖狀態檔是 `$JSC_HOME/sessions/{CLI 代號}-{sid}.stage`,舊路徑 `$JSC_HOME/sessions/{sid}.stage` 仍讀得到。另含工作包 PR 閘門:`wp-lock {owner}/{repo} {index} [{工作包代號}]` 記下一筆未結清的工作包 PR、`wp-unlock {owner}/{repo} {index}` 結清那一筆(檔案不存在也算成功)、`wp-claim {owner}/{repo} {工作包代號} [{PR 編號}] [{分析頁頁名}]` 記下這個存取庫目前領取哪一包、`wp-unclaim {owner}/{repo}` 交回、`wp-report` 印出所有未結清、`wp-check {prompt|skill}` 為 hook 模式。狀態檔一個工作包一支,在 `$JSC_HOME/wp/{owner}-{repo}-{index}.pr`,**刻意不綁 session**——PR 沒合併時換一個工作階段照樣要擋;一個工作包一支鎖檔是為了讓好幾個互不相依的工作包能同時記在案,不會互相覆蓋掉對方的鎖。`wp-check prompt` 只注入提醒、絕不擋提示(擋了連「去修那支 PR」的對話都送不出去);`wp-check skill` 在有未結清 PR 時以 exit 2 擋下 `analyze` 與 `maintain`,但一律放行 `implement`(結清 PR 正是 implement 的步驟,擋它會鎖死流程),也放行 `plan`,只注入提醒(plan 是純邏輯階段、不碰程式碼,而這道閘門只知道「有 PR 未合併」、判不出跟新計畫有沒有關聯;放棄的是在製品上限,`analyze` 與 `maintain` 兩道仍在,上限晚一個階段才生效)——這一層是整個存取庫共用的粗粒度提醒,「某個候選工作包能不能挑」的細粒度判斷在 `jsc-sdlc/tools/wp-gate.sh check-deps`,不是這裡。另外會比對歸屬:未結清的 PR 不屬於目前領取的工作包時,`prompt` 多注入一行「那幾支交給領取它的工作階段」,`skill` 在擋下 `analyze`、`maintain` 時一併點名,`plan` 與 `implement` 仍放行但收到同一則提醒。逃生門 `JSC_WP_GATE=off`。這道閘門只讀檔案、不打網路,PR 的真實合併狀態由 `jsc-sdlc/tools/wp-gate.sh` 查證 |
| `hooks/write-guard.sh` | PreToolUse(Write、Edit、MultiEdit)、PreToolUse(Bash) | 寫入與提交閘門,共三種擋人模式,目前只接在 claude 上;codex、copilot、antigravity 三支已經有可用的 pre-tool hook(見上面「各 CLI 的 pre-tool 接線位置」),只是這三種模式還沒接過去,kiro 則是本來就擋不下來。另有一個不接 hook 的 `release` 解除模式。`stage`:`sdlc-gate.sh` 的階段鎖鎖在 `plan` 或 `analyze` 時,以 exit 2 擋下 `Write`、`Edit`、`MultiEdit`——那兩個階段的產出是計畫頁與分析頁,不是檔案。階段鎖狀態檔沿用 `sdlc-gate.sh` 那一份,這裡只讀不寫。`review`:目前技能是 `jsc-review:code-review` 或 `jsc-review:api-doc` 時擋下寫入,那兩支只回報發現、不改程式碼。技能名先讀環境變數,取不到才讀 `skill-usage.sh` 記下的那一份;沒有「技能結束」事件可讀,所以紀錄超過 `JSC_WRITE_GUARD_TTL` 秒就當那支技能早已跑完。`jsc-review:comment-cleanup` **刻意不擋**:它本來就要改檔,只是限定僅註解行,而精確判定要解析工具參數裡整份新內容再逐語言判斷哪幾行是註解,判錯會擋掉合法的清理,代價比漏擋大,所以那條界線留給技能內文與後續審查。`commit`:擋下「同一道指令把全部變更一次加進索引再提交」,也擋下含簡體字、亂碼或非 UTF-8 編碼的提交訊息(判定整段轉呼叫 `lang-guard.sh`,字表仍是 `hooks/simplified.txt`,這裡不留第二份樣式)。跨兩次工具呼叫的 `git add -A` 不擋:那要記跨呼叫狀態,而被擋下的人沒有辦法讓那個狀態自己消失,閘門會把解除自己的路徑一起鎖掉。`release`:刪掉 `review` 模式認人用的那份紀錄,一律 exit 0,由 `jsc-review:code-review` 與 `jsc-review:api-doc` 在收尾時各呼叫一次。有這個模式是因為那份紀錄記的是「最近一次載入的技能」不是「還在跑的技能」——稽核收尾後呼叫端本來就要動手改,那時紀錄仍寫著稽核技能,TTL 內每一次寫入都被擋,解除路徑只剩逃生門或空等;閘門不得把解除自己的路徑一起鎖掉。逃生門 `JSC_WRITE_GUARD=off`(`release` 不受它影響,清紀錄擋不到任何人) |
Claude 由 `hooks/hooks.json` 自動接線九支 hook;其他 CLI 用 `hooks-install` 技能接線、改裝包裝啟動器,或降級為規則檔。寫進使用者設定的長期命令一律指向 `$JSC_HOME/current/jsc-hooks`,不指向帶版號的 plugin 快取目錄,也不指向開發存取庫。
Claude 由 `hooks/hooks.json` 自動接線十支 hook;其他 CLI 用 `hooks-install` 技能接線、改裝包裝啟動器,或降級為規則檔。寫進使用者設定的長期命令一律指向 `$JSC_HOME/current/jsc-hooks`,不指向帶版號的 plugin 快取目錄,也不指向開發存取庫。
### 各 CLI 的 pre-tool 接線位置
@@ -65,7 +66,7 @@ Claude 由 `hooks/hooks.json` 自動接線九支 hook;其他 CLI 用 `hooks-in
- **kiro** 擋不下技能叫用,這是 CLI 的限制,不是我們接錯。技能走 `ResolveSkill` 這個 agent 內部請求,不經工具管線,`preToolUse` 攔不到;`userPromptSubmit` 的非零結束碼也不會擋下那一輪。唯一可用的介入是 `userPromptSubmit` 的 stdout 注入,所以兩道閘門只印警告。hook 宣告只認 **agent 設定檔的 `hooks` 鍵**,`.kiro/hooks/` 目錄不在它的設定目錄常數裡,一份都不會被讀。同一份 agent 檔還要寫 `resources` 的**兩層** `skill://` glob(預設只掃一層,jsc 的技能在 `jsc-{domain}/{name}/SKILL.md` 第二層,少了那一條一支都載不到)與**明列的 `tools`**(自訂 agent 沒宣告時可用工具會受限),並把 `chat.defaultAgent` 設成 `jsc`,那個 agent 才會被選用。
> 覆蓋範圍其餘部分仍要據實看待:只有 claude 同時有 PreToolUse、PostToolUse 與 UserPromptSubmit,九支 hook 全接得上。codex、copilot、antigravity 三支目前接上的是版本前置檢查與部署後重啟閘門兩道;`write-guard.sh` 的三種模式還沒接線,SDLC 模型鎖仍只剩技能步驟檢查,註解範圍與繁中編碼仍是 `sweep`。codex 另外沒有工作階段開始事件,計時改由 `tools/jsc-wrap.sh` 的 `codex` 別名在啟動當下開始;沒走別名啟動時,時間從第一輪回應算起。
> 覆蓋範圍其餘部分仍要據實看待:只有 claude 同時有 PreToolUse、PostToolUse 與 UserPromptSubmit,十支 hook 全接得上。codex、copilot、antigravity 三支目前接上的是版本前置檢查與部署後重啟閘門兩道;`write-guard.sh` 的三種模式還沒接線,SDLC 模型鎖仍只剩技能步驟檢查,註解範圍與繁中編碼仍是 `sweep`。codex 另外沒有工作階段開始事件,計時改由 `tools/jsc-wrap.sh` 的 `codex` 別名在啟動當下開始;沒走別名啟動時,時間從第一輪回應算起。
### 驗證等級
@@ -120,7 +121,11 @@ Claude 由 `hooks/hooks.json` 自動接線九支 hook;其他 CLI 用 `hooks-in
一支 CLI 一份是為了修兩個實測抓到的洞:一台機器上五支 CLI 各自是獨立行程,各自載入自己記憶體裡的那一版。早先的單一檔案設計裡,並行部署會互相覆寫(後寫的把 `domains` 與 `cli` 蓋掉,欄位不再代表先寫的那一支),而且任一支 CLI 重啟就把五支的閘門一起解除,其餘四支沒重啟卻不再被擋,閘門在多 CLI 環境等於半失效。拆成一支一份之後,寫入、判定、清除三件事都只碰自己那一份。
寫檔的一律是 `jsc-cli:deploy`,經 `restart-gate.sh require {install|update} [{domain}...]` 落地,寫的是當前 CLI 那一份;取不到 CLI 代號或寫不進去都會 exit 2 並講明「這次部署沒有掛上重啟閘門」——沒寫成就沒有閘門,不能讓部署以為掛上了。清除的一律是 `session-timer.sh`:`start` 判定起始檔不存在(這個 session id 第一次開始)、或 `restart`(接不到 session id 的 CLI,每次工作階段開始都算新的)時,呼叫 `restart-gate.sh clear`,只刪呼叫端那一支自己那一份。判準留在 `session-timer.sh`、狀態檔留在 `restart-gate.sh`,兩邊都不抄對方那一半。
寫檔的一律是 `jsc-cli:deploy`,經 `restart-gate.sh require {install|update} [{domain}...]` 落地,寫的是當前 CLI 那一份;取不到 CLI 代號或寫不進去都會 exit 2 並講明「這次部署沒有掛上重啟閘門」——沒寫成就沒有閘門,不能讓部署以為掛上了。問清除的一律是 `session-timer.sh`:`start` 與 `restart` 都在工作階段開始時呼叫一次 `restart-gate.sh clear`,並把手上那個工作階段代號帶進去,只刪呼叫端那一支自己那一份。
**判準是「載入舊程式碼那個行程還在不在」,寫在 `restart-gate.sh`。** `require` 落地時一併記下 `session=` 與 `pid=`——掛上閘門那一刻的工作階段代號,與那一支 CLI 自己的行程代號(往上追祖先,比對命令名等於 CLI 代號)。清除時記到行程代號就只認它:還活著就不清,不管代號換沒換,因為舊程式碼還在它的記憶體裡;它走了就清,續接原代號的 resume 也算。追不到行程代號時退回結束記號,要求代號換了而且舊的那個工作階段寫出過 `.end`。舊版寫的閘門沒有這兩個欄位,一律清,維持改版前的行為。核對命令名不只看行程還在:行程代號會被回收,回收後那個號碼照樣「活著」。
判準原本寫在 `session-timer.sh`,用的是「起始檔不存在=行程是新起的」。實測打掉了那個等式:還沒重啟的工作階段生出來的子行程(`claude -p`、外掛子命令、子代理)拿到的是一個沒見過的代號,一觸發就把閘門清掉;反過來,續接原代號的 resume 行程確實換過了卻連問都不會問。那個條件兩頭都會答錯,所以判定整段搬到 `restart-gate.sh`,`session-timer.sh` 只負責問。
欄位只用在擋人訊息上。判定看的是「當前 CLI 那份檔案在不在」——檔案存在就是這一支還沒重啟過的證據,欄位缺了只讓訊息少幾個字。別支 CLI 那幾份一律不看。狀態檔讀不到、CLI 代號取不到、技能名取不到一律放行,理由與 `version-guard.sh` 相同。
@@ -132,7 +137,7 @@ Claude 由 `hooks/hooks.json` 自動接線九支 hook;其他 CLI 用 `hooks-in
有幾行就代表有幾支 CLI 還沒重啟;一份都沒有就不印。欄位缺值時只留鍵名(例如 `domains=`)。第一欄印 `legacy` 的那一行代表下面說的舊格式單一檔案,它不屬於任何一支 CLI。
**舊檔相容(過渡用)。** 舊版把狀態寫進 `$JSC_HOME/restart-required` 單一檔案。改用狀態目錄的第一輪部署,機器上可能還留著那份舊檔,所以判定與清除都認它:舊檔存在就一律擋,視為「每一支 CLI 都有未重啟的部署」,擋人訊息會標明這是舊格式紀錄;`clear` 除了刪當前 CLI 那一份,也一併刪掉舊檔。取捨講白:`clear` 只在新工作階段被呼叫,呼叫到就代表確實有一支 CLI 重新啟動過了;舊檔沒有 per-CLI 資訊,留著會讓五支 CLI 一路被擋到有人手動刪,刪掉是唯一收斂的做法,代價是同一輪部署的其他 CLI 少擋一次,只影響改用狀態目錄的那一輪。這一段相容邏輯在所有機器都跑過一次寫狀態目錄的部署與重啟之後就可以整段移除,屆時舊檔不會再被寫出來。
**舊檔相容(過渡用)。** 舊版把狀態寫進 `$JSC_HOME/restart-required` 單一檔案。改用狀態目錄的第一輪部署,機器上可能還留著那份舊檔,所以判定與清除都認它:舊檔存在就一律擋,視為「每一支 CLI 都有未重啟的部署」,擋人訊息會標明這是舊格式紀錄;`clear` 除了刪當前 CLI 那一份,也一併刪掉舊檔。取捨講白:舊檔沒有 per-CLI 資訊,也沒有行程代號可以問,留著會讓五支 CLI 一路被擋到有人手動刪,刪掉是唯一收斂的做法。所以它不走上面那道行程存活判定,一被問到就刪,代價是同一輪部署的其他 CLI 少擋一次,只影響改用狀態目錄的那一輪。這一段相容邏輯在所有機器都跑過一次寫狀態目錄的部署與重啟之後就可以整段移除,屆時舊檔不會再被寫出來。
> `version-guard.sh report` 是非 hook 的子指令:印出每個已安裝 jsc plugin 的
> 「{domain} {本機} {遠端} {落後|最新|超前|查詢失敗}」,最後一行 `behind {落後個數}`。
@@ -169,8 +174,8 @@ Claude 由 `hooks/hooks.json` 自動接線九支 hook;其他 CLI 用 `hooks-in
| --- | --- |
| `tools/jsc-wrap.sh` | 沒有完整 hook 系統的 CLI 的包裝啟動器:匯出 `JSC_CLI`、`JSC_SESSION_ID`,前後接 `session-timer.sh`,結束時自動跑 `scan-logs.sh` 回填,再依序跑一次 `comment-scope.sh sweep` 與 `lang-guard.sh sweep` 掃整個 git 工作區的註解範圍與繁中編碼(copilot 與 antigravity 沒有任何逐輪事件,整個工作階段只有這裡掃得到)。兩次收尾掃描一律不影響結束碼:包裝器原樣回傳 CLI 自己的結束碼,`sweep` 命中只把警告印到 stderr。`JSC_CLI` 存 CLI 代號,實際執行的是對應的執行檔(antigravity 是 agy、kiro 是 kiro-cli) |
| `tools/scan-logs.sh` | 離線回填:解析 copilot、antigravity、codex 的原生日誌,把技能用量與階段界線補進 `$JSC_HOME`,重掃不重複 |
| `tools/report-error.sh` | 失敗回報流程:把一筆 hook 或工具異常寫成 wiki 的 `ERROR_{HASH}`,並在 `ERROR_CONTENTS` 附上一列索引。目錄頁一律先讀回舊頁再附加新列、整頁寫回,不整頁覆蓋:只有 `wiki-get` 回 4(頁面真的不存在)才用範本建新頁,回 7(金鑰失效)或 8(其他 API 失敗)代表舊內容未知,放棄目錄頁寫入並以 exit 4 回報,免得拿範本蓋掉所有既有列。目錄頁那一列指向異常頁,連結一律寫成 `[{文字}]({連結})`,網址取 `jsc-gitea` 的 `gitea.sh wiki-url` 印出的那一個,不自己組路徑。寫進那一格之前,先把那個網址交給 `jsc-gitea` 的 `tools/link-check.sh` 驗一次,結束碼 0 才寫連結;`link-check.sh` 的路徑由已經解出來的 `gitea.sh` 推得,兩支同一個 tools 目錄。驗不過(含找不到 `link-check.sh`、`GITEA_HOST` 未設定回 3、金鑰失效回 7)就只在那一格留純文字頁名,那一列照寫、異常頁照寫、結束碼照舊,原因走 stderr——回報失敗不該再變成一次失敗。網址在異常頁寫成功之後才取:頁名的 hash 帶時間戳,每次回報都是全新的頁,寫進去之前查一定是 404,先查就只拿得到空字串。取不到網址時只印頁名,原因走 stderr,結束碼照舊回 0。wiki 位置分兩次解析:異常頁走 `jsc-gitea` 的 `gitea.sh wiki-repo ERROR`,目錄頁走 `gitea.sh wiki-repo CONTENTS`,兩者是兩個不同的存取庫。異常頁的存取庫解不出來就整支安靜降級;只有目錄頁的存取庫解不出來,就只寫異常頁、跳過目錄頁更新,仍回 exit 0。由操作者手動執行,或由 `hooks-install` 在 `wire-cli.sh` 回報 `status=failed` 時執行;**不接在失敗的 hook 上自動觸發**(hook 一律安靜 exit 0,自我回報會疊出迴圈) |
| `tools/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-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` 回報 |
@@ -182,7 +187,7 @@ Claude 由 `hooks/hooks.json` 自動接線九支 hook;其他 CLI 用 `hooks-in
| 範本 | 用途 |
| --- | --- |
| `templates/error-page.md` | 單筆 hook 異常頁 `ERROR_{HASH}`,記錄當次失敗的觸發條件、錯誤摘要與處理結果。 |
| `templates/error-contents.md` | 異常目錄 `ERROR_CONTENTS`,彙整所有異常頁,方便先看最新問題再往下追。落在目錄專用存取庫,一律 upsert 附加,連結一律寫成 `[{文字}]({連結})` 且先過 `link-check.sh` 驗過才寫。 |
| `templates/error-contents.md` | 異常目錄 `ERROR_CONTENTS`,彙整所有異常頁,方便先看最新問題再往下追。版面是 H1 頁名加 `>` 引言,之後一筆一個 H2 區塊,標題就是異常頁頁名,欄位一行一條;範本只留一個示範區塊,用 `{佔位符}` 寫。落在目錄專用存取庫,一律 upsert 附加,連結一律寫成 `[{文字}]({連結})` 且先過 `link-check.sh` 驗過才寫。 |
## Skills 目錄
@@ -192,7 +197,7 @@ Claude 由 `hooks/hooks.json` 自動接線九支 hook;其他 CLI 用 `hooks-in
### `hooks-install`
把九支 hook 接線到所有已安裝的 CLI,每個 CLI 走五道關卡:先 `tools/wire-cli.sh purge {cli}` 備份後移除所有 hook(含非 jsc 的第三方項目,乾淨起跑才分得清後續失敗是誰的),再 `tools/wire-cli.sh {cli}` 接線(claude 由 `hooks.json` 自動接線,無需寫入;其他 CLI 的持久命令會寫成 `$JSC_HOME/current/jsc-hooks` 穩定路徑),接著 `tools/wire-cli.sh status {cli}` 唯讀盤點接線結果,再 `tools/wire-cli.sh smoke {cli}` 驗執行期,最後 `tools/scan-hook-errors.sh --cli {cli}` 掃原生紀錄。第一支 CLI 的管線單獨跑完(`$JSC_HOME/current/jsc-hooks` 連結由它統一更新),其餘各 CLI 的管線才並行。codex、copilot、antigravity 由接線腳本裝上 `tools/jsc-wrap.sh` 包裝別名補上計時與用量回填(結束時自動跑 `tools/scan-logs.sh`),語言規則仍重寫到各自的規則檔(以 `<!-- jsc-hooks -->` 標記整段取代,等同先移除再重裝,不重複追加)。codex、copilot、antigravity、kiro 的 SDLC 模型鎖降級為技能步驟檢查,鎖檔仍由 SDLC 技能直接呼叫 `sdlc-gate.sh lock` 寫入。版本前置檢查與部署後重啟閘門在 codex、copilot、antigravity 三支都擋得下來,各自接在自己的 pre-tool 位置(見上面「各 CLI 的 pre-tool 接線位置」),三支回報 `wired`;kiro 擋不下技能叫用,只注入警告,回報 `degraded`,那是 CLI 的限制。`write-guard.sh` 的三種模式目前仍只接在 claude。這四個 CLI 都沒有 post-tool hook,`comment-scope.sh` 接不到逐檔即時掃描,改用 `sweep` 掃整個 git 工作區——codex 每輪結束、kiro 每輪提示送出時、copilot 與 antigravity 只有工作階段結束時掃一次,腳本會在 `reason` 裡講明各自的時機,也只有 claude 掃得到執行期錯誤紀錄。antigravity 與 kiro 的 hook 觸發都沒有實跑驗證(前者 quota 用盡、後者未登入),回報時要把「接線已驗」與「觸發未驗」分開講。任一關卡出錯(purge、接線、冒煙失敗,或掃到 `jsc=true` 的執行期錯誤)就先寫 `ERROR_{HASH}`,再交給 `repair` 技能接手並以 `develop` PR 收尾;此時允許中止剩下的安裝,但修正一定要開始。掃到 `jsc=false` 的第三方 hook 錯誤只回報,不轉修正。
把十支 hook 接線到所有已安裝的 CLI,每個 CLI 走五道關卡:先 `tools/wire-cli.sh purge {cli}` 備份後移除所有 hook(含非 jsc 的第三方項目,乾淨起跑才分得清後續失敗是誰的),再 `tools/wire-cli.sh {cli}` 接線(claude 由 `hooks.json` 自動接線,無需寫入;其他 CLI 的持久命令會寫成 `$JSC_HOME/current/jsc-hooks` 穩定路徑),接著 `tools/wire-cli.sh status {cli}` 唯讀盤點接線結果,再 `tools/wire-cli.sh smoke {cli}` 驗執行期,最後 `tools/scan-hook-errors.sh --cli {cli}` 掃原生紀錄。第一支 CLI 的管線單獨跑完(`$JSC_HOME/current/jsc-hooks` 連結由它統一更新),其餘各 CLI 的管線才並行。codex、copilot、antigravity 由接線腳本裝上 `tools/jsc-wrap.sh` 包裝別名補上計時與用量回填(結束時自動跑 `tools/scan-logs.sh`),語言規則仍重寫到各自的規則檔(以 `<!-- jsc-hooks -->` 標記整段取代,等同先移除再重裝,不重複追加)。codex、copilot、antigravity、kiro 的 SDLC 模型鎖降級為技能步驟檢查,鎖檔仍由 SDLC 技能直接呼叫 `sdlc-gate.sh lock` 寫入。版本前置檢查與部署後重啟閘門在 codex、copilot、antigravity 三支都擋得下來,各自接在自己的 pre-tool 位置(見上面「各 CLI 的 pre-tool 接線位置」),三支回報 `wired`;kiro 擋不下技能叫用,只注入警告,回報 `degraded`,那是 CLI 的限制。`write-guard.sh` 的三種模式目前仍只接在 claude。這四個 CLI 都沒有 post-tool hook,`comment-scope.sh` 接不到逐檔即時掃描,改用 `sweep` 掃整個 git 工作區——codex 每輪結束、kiro 每輪提示送出時、copilot 與 antigravity 只有工作階段結束時掃一次,腳本會在 `reason` 裡講明各自的時機,也只有 claude 掃得到執行期錯誤紀錄。antigravity 與 kiro 的 hook 觸發都沒有實跑驗證(前者 quota 用盡、後者未登入),回報時要把「接線已驗」與「觸發未驗」分開講。任一關卡出錯(purge、接線、冒煙失敗,或掃到 `jsc=true` 的執行期錯誤)就先寫 `ERROR_{HASH}`,再交給 `repair` 技能接手並以 `develop` PR 收尾;此時允許中止剩下的安裝,但修正一定要開始。掃到 `jsc=false` 的第三方 hook 錯誤只回報,不轉修正。
### `repair`
@@ -206,7 +211,7 @@ Claude 由 `hooks/hooks.json` 自動接線九支 hook;其他 CLI 用 `hooks-in
| --- | --- | --- |
| `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}`。目錄頁與內容頁分屬兩個不同的存取庫,各解各的 | 退回 `JSC_WIKI_REPO`;還是解不出來就只寫內容頁、跳過目錄頁更新,`tools/report-error.sh` 仍回 exit 0 |
| `JSC_WIKI_REPO_CONTENTS` | 目錄頁 `ERROR_CONTENTS` 所在的 `{owner}/{repo}`,由 `wiki-contents.sh` 解析。目錄頁與內容頁分屬兩個不同的存取庫,各解各的 | 退回 `JSC_WIKI_REPO`;還是解不出來時 `wiki-contents.sh` 回 3,只寫內容頁、跳過目錄頁更新,`tools/report-error.sh` 仍回 exit 0 |
| `JSC_WIKI_REPO` | 未逐類設定時的共用 wiki `{owner}/{repo}` | `tools/report-error.sh` 安靜降級,不寫 wiki |
| `COPILOT_HOME` | copilot 的設定根目錄,`hooks/jsc-hooks.json` 與指引檔都寫在它底下 | 預設 `~/.copilot` |
| `KIRO_HOME` | kiro 的設定根目錄,`agents/jsc.json`、`settings/cli.json` 與 `resources` 的 `skill://` glob 都由它推導 | 預設 `~/.kiro` |
+4
View File
@@ -6,6 +6,10 @@
{
"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\"'"
}
]
}
+4
View File
@@ -6,6 +6,10 @@
{
"type": "command",
"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\"'"
}
]
}
+59 -14
View File
@@ -20,6 +20,17 @@ mkdir -p "$JSC_HOME/sessions" "$JSC_HOME/usage" 2>/dev/null || true
JSC_SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" 2>/dev/null && pwd)
JSC_SCRIPT_DIR="${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
@@ -229,27 +240,61 @@ cli_name() {
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() {
if [ -n "${JSC_GITEA_TOOLS:-}" ] && [ -f "$JSC_GITEA_TOOLS/gitea.sh" ]; then
printf '%s\n' "$JSC_GITEA_TOOLS/gitea.sh"; return 0
fi
_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
for _c in "$_root/../gitea/tools/gitea.sh" "$_root/../jsc-gitea/tools/gitea.sh"; do
[ -f "$_c" ] && { printf '%s\n' "$_c"; return 0; }
done
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 各有版本目錄,取排序最後的一份(通常即最新版)
_c=$(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 "$_c" ] && [ -f "$_c" ] && { printf '%s\n' "$_c"; return 0; }
_c=$(command -v gitea.sh 2>/dev/null || true)
[ -n "$_c" ] && { printf '%s\n' "$_c"; return 0; }
return 1
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,其餘同名)
+112 -14
View File
@@ -34,9 +34,11 @@
# install 或 update,之後接這次更新的 domain 清單。
# exit 0 = 已掛上;exit 2 = 取不到 CLI 代號或寫不進去
# (兩種都等於沒掛上)。
# restart-gate.sh clear 只清除當前 CLI 那份狀態檔,放下這一支的閘門。由
# session-timer.sh 在判定為新工作階段時呼叫(見下方
# 「清除時機」)。檔案不存在也算成功。
# restart-gate.sh clear [{工作階段代號}]
# 只清除當前 CLI 那份狀態檔,放下這一支的閘門。由
# session-timer.sh 在判定為新工作階段時呼叫,並把它
# 手上那個工作階段代號一起帶進來。清除有條件,判準見
# 下方「清除時機」。檔案不存在、條件不成立都算成功。
# restart-gate.sh report 印出每一份狀態檔的內容,一支 CLI 一行(格式見下方
# 「report 輸出格式」);一份都沒有就不印,一律 exit 0。
#
@@ -54,8 +56,12 @@
# mode={install|update} 這次部署的模式
# domains={domain 清單} 這次更新到的 domain,空白分隔
# cli={CLI 代號} 執行部署的 CLI,與檔名相同
# 欄位只用在擋人訊息上。判定看的是「當前 CLI 那份檔案在不在」——檔案存在就是這一支還沒重啟
# 過的證據,欄位缺了只讓訊息少幾個字,不影響判定。
# session={代號} 掛上閘門那一刻的工作階段代號
# pid={行程代號} 掛上閘門那一刻那一支 CLI 的行程代號;追不到時為空
# 前四個欄位只用在擋人訊息上。判定看的是「當前 CLI 那份檔案在不在」——檔案存在就是這一支還沒
# 重啟過的證據,欄位缺了只讓訊息少幾個字,不影響判定。
# 後兩個欄位只給清除那一邊用(見下方「清除時機」),不進 report 的輸出:那一行的 domains
# 擺在最後而且可能含空白,後面再接欄位會讓現有的讀法把新欄位讀成 domain 名。
#
# 為什麼一支 CLI 一份:一台機器上五支 CLI 各自是獨立行程,各自載入自己記憶體裡的那一版。
# 早先的單一檔案設計有兩個實測抓到的洞——並行部署互相覆寫(後寫的把 domains 與 cli 蓋掉,
@@ -85,11 +91,28 @@
#
# --- 清除時機 ---
#
# 清除由 session-timer.sh 在「這一次 SessionStart 是新的工作階段」那一刻呼叫,不由本檔自己判定:
# 新舊工作階段的判準(sessions/{sid}.start 在不在)只有那支腳本知道,兩邊各寫一份就會漂移。
# 新的工作階段代表 CLI 行程是新起的,新版一定已經載入,所以清除是對的。續接同一階段
# (SessionStart 再觸發、resume、compact)不會走到那一段,閘門就一路留到真的重新啟動。
# 清除的範圍就是呼叫端那一支 CLI:那一支重啟了,不代表別支也重啟了。
# 清除由 session-timer.sh 在「這一次 SessionStart 是新的工作階段」那一刻呼叫。清除的範圍就是
# 呼叫端那一支 CLI:那一支重啟了,不代表別支也重啟了。
#
# 「新的工作階段」不等於「行程是新起的」。這句話原本被當成等式,實測打掉了它:一個還沒重啟的
# 工作階段自己生出來的子行程(`claude -p`、外掛子命令、子代理),拿到的是一個沒見過的工作階段
# 代號,於是替人把閘門放下了,而人一次都沒重啟。實測的路徑是掛上閘門之後餵一個新代號進
# session-timer.sh start,閘門當場消失。那一天這台機器上真的發生過:部署掛上的閘門兩分鐘後
# 被三個子行程之一清掉,收尾那句「請重新啟動」於是只剩人自己記得。
#
# 所以判準改成問行程本身:
# 記到行程代號時,只認它。那個行程還活著就不清——不管工作階段代號換沒換,舊程式碼都還在
# 它的記憶體裡。它走了就清,續接原代號的 resume 也算,因為行程確實換過了。
# 追不到行程代號時(CLI 的命令名對不上代號,例如包在執行器底下的那幾支)退回結束記號:
# 要求代號換了、而且舊的那個工作階段寫出過 .end。這一路擋得住子行程,代價是被強制砍掉的
# 行程不會留下 .end,那一份閘門要等下一次部署覆寫。
# 舊版寫的閘門沒有這兩個欄位,一律清,維持改版前的行為:認不出來就不要把人鎖在門外。
#
# 為什麼核對命令名不只看 kill -0:行程代號會被回收,回收後那個號碼照樣「活著」。不核對的話,
# 剛好撞上回收就會把「還沒重啟」讀成「已經重啟」。
#
# 排程那條路碰巧沒踩到這個洞,因為 cron 條目帶著 JSC_CLI=cron,清的是 cron 自己那一份。
# 那是巧合擋下來的,不是判準擋下來的——把判準修對,才不必靠某個環境變數剛好設對。
#
# --- 判定原則 ---
#
@@ -144,6 +167,62 @@ state_field() { # $1=狀態檔 $2=鍵名
sed -n "s/^$2=//p" "$1" 2>/dev/null | head -n1
}
# 一個行程的父行程代號。/proc 讀得到就走 /proc,否則退回 ps。兩邊都問不到就不輸出。
#
# 為什麼讀 status 而不讀 stat:stat 的第二欄是命令名,命令名帶空白或括號時欄位會錯位,
# 於是「第四欄是 ppid」這句話在那些行程上不成立。status 一行一鍵,沒有這個問題。
proc_ppid() { # $1=行程代號
if [ -r "/proc/$1/status" ]; then
sed -n 's/^PPid:[[:space:]]*//p' "/proc/$1/status" 2>/dev/null | head -n1
else
ps -o ppid= -p "$1" 2>/dev/null | tr -d ' \t'
fi
}
# 一個行程的命令名,不含路徑。問不到就不輸出。
proc_name() { # $1=行程代號
if [ -r "/proc/$1/comm" ]; then
head -n1 "/proc/$1/comm" 2>/dev/null
else
ps -o comm= -p "$1" 2>/dev/null | sed 's|.*/||; s/[[:space:]]*$//'
fi
}
# 往上找到那一支 CLI 自己的行程代號;找不到就不輸出。
#
# 為什麼要找它:閘門要問的是「載入舊程式碼那個行程還在不在」,而 require 是被那個行程底下
# 好幾層的殼叫起來的,`$$` 是殼自己、殼一結束就死,拿它當存活訊號等於永遠回「已經走了」。
#
# 判準是命令名等於 CLI 代號。實測這台機器上 claude 的行程命令名就是 `claude`。其他 CLI 的
# 命令名沒有實測過(可能是 `node` 之類的執行器),對不上就回空值,由呼叫端退回別的訊號——
# 猜一個對應表填進來只會多一個錯誤來源。
# 上追層數設 24 層:一輪部署經過的殼層數遠少於這個數,而追到 pid 1 或問不到父代號就會先停。
cli_pid() { # $1=CLI 代號
_cp="$$"
_cpn=1
while [ "$_cpn" -le 24 ]; do
[ -n "$_cp" ] && [ "$_cp" != 0 ] && [ "$_cp" != 1 ] || return 0
if [ "$(proc_name "$_cp")" = "$1" ]; then printf '%s' "$_cp"; return 0; fi
_cp=$(proc_ppid "$_cp")
_cpn=$((_cpn + 1))
done
return 0
}
# 那個行程還活著嗎。活著回 0,走了或問不到回 1。
#
# 除了存活還核對命令名:行程代號會被回收,回收後那個號碼照樣「活著」,只是換成別的行程。
# 不核對的話,剛好撞上回收就會把「還沒重啟」讀成「已經重啟」,而那正是這道閘門要防的事。
# 命令名問不到時當成不在:問不到就沒有證據說它還在。
pid_alive() { # $1=行程代號 $2=命令名
[ -n "${1:-}" ] || return 1
case "$1" in ''|*[!0-9]*) return 1 ;; esac
kill -0 "$1" 2>/dev/null || return 1
[ -n "${2:-}" ] || return 0
[ "$(proc_name "$1")" = "$2" ] || return 1
return 0
}
# 一份狀態檔印一行,格式見檔頭「report 輸出格式」。$1=第一欄要印的名稱 $2=狀態檔
state_line() {
printf '%s at=%s mode=%s domains=%s\n' "$1" \
@@ -163,8 +242,11 @@ case "${1:-}" in
exit 2
fi
mkdir -p "$STATE_DIR" 2>/dev/null || true
printf 'at=%s\nmode=%s\ndomains=%s\ncli=%s\n' \
"$(now_iso)" "$_mode" "$_domains" "$_cli" > "$STATE_DIR/$_cli" 2>/dev/null || {
# session 與 pid 記的是「掛上閘門的那一刻,載入舊程式碼的是誰」。清除那一邊靠它們判斷
# 那個行程走了沒有,理由見檔頭「清除時機」。
printf 'at=%s\nmode=%s\ndomains=%s\ncli=%s\nsession=%s\npid=%s\n' \
"$(now_iso)" "$_mode" "$_domains" "$_cli" "$(session_id)" "$(cli_pid "$_cli")" \
> "$STATE_DIR/$_cli" 2>/dev/null || {
# 寫不進去要講出來:沒寫成就沒有閘門,部署卻以為掛上了。
printf '[jsc][重啟閘門][ERR]:寫不進 %s,這次部署沒有掛上重啟閘門。\n' "$STATE_DIR/$_cli" >&2
exit 2
@@ -172,8 +254,24 @@ case "${1:-}" in
exit 0 ;;
clear)
_cli=$(cli_code)
# 只刪自己那一份。別支 CLI 沒有跟著重啟,它們的閘門要留著。
[ -n "$_cli" ] && rm -f "$STATE_DIR/$_cli" 2>/dev/null
if [ -n "$_cli" ] && [ -f "$STATE_DIR/$_cli" ]; then
# 判準與取捨見檔頭「清除時機」。只刪自己那一份:別支 CLI 沒有跟著重啟,它們的閘門要留著。
_gpid=$(state_field "$STATE_DIR/$_cli" pid)
_gsess=$(state_field "$STATE_DIR/$_cli" session)
_cur="${2:-$(session_id)}"
if [ -n "$_gpid" ]; then
# 有記到行程代號,就用它當唯一判準:那個行程還活著,代表舊程式碼還在記憶體裡。
pid_alive "$_gpid" "$_cli" || rm -f "$STATE_DIR/$_cli" 2>/dev/null
elif [ -z "$_gsess" ]; then
# 舊版寫的閘門沒有這兩個欄位。一律清,維持改版前的行為:認不出來就不要把人鎖在門外。
rm -f "$STATE_DIR/$_cli" 2>/dev/null
elif [ "$_cur" != "$_gsess" ] && [ -f "$JSC_HOME/sessions/$_gsess.end" ]; then
# 追不到行程代號時退回結束記號。這一路要求「代號換了」而且「舊的那個工作階段寫出過
# 結束記號」兩件事同時成立:少了前一個,同一個工作階段每次觸發都會把自己的閘門清掉;
# 少了後一個,子行程照樣清得掉,那正是這道判定要防的事。
rm -f "$STATE_DIR/$_cli" 2>/dev/null
fi
fi
# 舊檔一併刪,取捨與可移除時機見檔頭「舊檔相容」。
rm -f "$LEGACY_STATE" 2>/dev/null || true
exit 0 ;;
+175
View File
@@ -0,0 +1,175 @@
#!/usr/bin/env sh
# session-reminder.sh — 工作階段開始時,把助理算好的未讀提醒帶到前景。
#
# 用法:
# session-reminder.sh # SessionStart:印出未讀提醒,並記下這個工作階段提過了
# session-reminder.sh peek # 只印,不記。人要重看一次時用,也給檢核用
#
# 結束碼:0=一律成功,只有這一種。這一支接在工作階段開始那個事件上,它的產出走 stdout,
# 不走結束碼——那個事件的 stdout 會成為額外 context。佇列不在、讀不到、格式對不上,
# 一律印一行說明然後 exit 0;一支在每個工作階段開頭都會跑的 hook 絕對不可以擋人。
# 唯一的非零來源是 `. lib.sh` 載入失敗,那時 sh 自己回 2。
#
# --- 這一支不做判定 ---
#
# 提醒該不該送、哪幾筆該送,全由助理那一輪算完寫進佇列(jsc-assist 的 tools/patrol.sh
# 每一輪重寫 $JSC_HOME/assistant/reminders.tsv)。這裡只把那份檔案印出來。
# 不自己判的理由有兩個。一是快:這一支跑在每一個工作階段的開頭,讀一個檔案就回來。
# 二是不漂移:自己拿 due 欄與 next_run 去跟現在比,就是第二套到期判定,跟助理那一套遲早
# 對不上,而對不上的那一天兩邊都說自己是對的。
#
# --- 「沒有提醒」與「沒有人算提醒」不可以長得一樣 ---
#
# 只印佇列換來一個新的失效模式:助理停了,佇列就不再更新,而一份舊佇列讀起來跟新的一模一樣。
# 所以佇列檔頭帶著那一輪的時間戳,這裡算出它多舊,超過心跳門檻就明說「這份提醒是多久以前
# 算的、助理現在的心跳是什麼狀態」。門檻與狀態都取 heartbeat.sh 印的那一行,不自己定一套。
#
# 助理的狀態目錄根本不存在時,這一支一個字都不印:那代表這台機器從沒啟動過助理,
# 每個工作階段開頭都催一次「你要不要啟動助理」不是提醒,是噪音。
#
# --- 逐筆點名與只算總數,分開兩種 ---
#
# 逾期與使用者自己登錄的提醒逐筆點名:人看到就做得了。委派清單種入的內建項只算一個總數,
# 因為那幾筆等的是接線不是人,每一輪都到期、每一輪都一樣。分種類的判定在寫佇列那一邊,
# 這裡只照它標好的種類決定怎麼印。
#
# --- 一個工作階段只提一次 ---
#
# 記號檔是 $JSC_HOME/sessions/{工作階段}.reminded。工作階段開始那個事件在續接同一階段時
# 會再觸發,沒有記號就會每次都再提一次同一批。
# 接不到工作階段代號的 CLI(記號都落在 default 上)另有一條路:session-timer.sh 的 restart
# 分支會把這個記號刪掉。判定「這是不是新的工作階段」只有那一支知道,所以刪除掛在那裡,
# 這裡不自己再判一次。
HERE=$(dirname "$0"); . "$HERE/lib.sh"
hook_trace "session-reminder ${1:-}"
read_stdin
sid=$(session_id)
MODE="${1:-show}"
STATE_DIR="$JSC_HOME/assistant"
QUEUE="$STATE_DIR/reminders.tsv"
MARK="$JSC_HOME/sessions/$sid.reminded"
MAX_ROWS=8
# 助理沒啟動過就整支安靜退出。
[ -d "$STATE_DIR" ] || exit 0
# 這個工作階段提過了就不再提。peek 一律印,那是人自己要重看。
if [ "$MODE" != peek ] && [ -f "$MARK" ]; then
exit 0
fi
mark_done() {
[ "$MODE" = peek ] && return 0
mkdir -p "$JSC_HOME/sessions" 2>/dev/null || return 0
now_epoch >"$MARK" 2>/dev/null || true
return 0
}
if [ ! -f "$QUEUE" ]; then
echo "[jsc] 助理的狀態目錄在,但還沒有提醒佇列($QUEUE)。跑過一輪巡檢才會產生,所以這裡沒有提醒**不代表沒有事要做**——要現在看就跑 /jsc-assist:assistant status。"
mark_done
exit 0
fi
TAB=$(printf '\t')
# 檔頭。第一行不是 round 就是格式不對,或者檔案被別的東西蓋掉了,兩種都照實說。
_head=$(head -n1 "$QUEUE" 2>/dev/null)
_kind=$(printf '%s' "$_head" | cut -f1)
if [ "$_kind" != round ]; then
echo "[jsc] 提醒佇列($QUEUE)的第一行不是輪次資訊,這一份讀不了。助理下一輪會重寫;在那之前要看待辦就跑 /jsc-assist:assistant status。"
mark_done
exit 0
fi
_iso=$(printf '%s' "$_head" | cut -f3)
_failing=$(printf '%s' "$_head" | cut -f4)
_epoch=$(printf '%s' "$_head" | cut -f5)
_tasks=$(printf '%s' "$_head" | cut -f6)
case "${_failing:-}" in ''|*[!0-9]*) _failing=0 ;; esac
case "${_tasks:-}" in ''|*[!0-9]*) _tasks=0 ;; esac
# 佇列有多舊。門檻與心跳狀態一律取 heartbeat.sh 印的那一行:那是這台機器判定「助理還在跑」
# 的唯一一套規則,這裡再定一套就會出現兩個說法。
_age=''
case "${_epoch:-}" in
''|*[!0-9]*) ;;
*) _age=$(( $(now_epoch) - _epoch )) ;;
esac
_hb=$(sh "$HERE/heartbeat.sh" report </dev/null 2>/dev/null || true)
_hbstate=$(printf '%s' "$_hb" | sed -n 's/^state=\([a-z]*\).*/\1/p')
_ttl=$(printf '%s' "$_hb" | sed -n 's/.*[[:space:]]ttl=\([0-9]*\).*/\1/p')
case "${_ttl:-}" in ''|*[!0-9]*) _ttl=300 ;; esac
# 兩種列分開數。逐筆點名的是逾期與使用者自己登錄的提醒;內建項只算一個總數。
#
# 為什麼內建項不逐筆點名:那幾筆等的是接線,不是人。動作是「只提醒」的內建項到現在還沒有
# 執行入口,所以每一輪都到期、每一輪都一樣。實測踩到:這台機器八筆全是那一種,於是每一個
# 工作階段開頭固定吐八行一模一樣的東西——那不是提醒,是噪音,而這一支自己的註解裡就寫著
# 「對著一個刻意的決定每個工作階段催一次,那是噪音不是提醒」。
# 分種類的判定在寫佇列那一邊(那裡讀得到 spec_key),這裡只照它標好的種類決定怎麼印。
_rows=$(awk -F"$TAB" '$1 == "overdue" || $1 == "remind" { n++ } END { print n + 0 }' "$QUEUE" 2>/dev/null)
case "${_rows:-}" in ''|*[!0-9]*) _rows=0 ;; esac
_builtin=$(awk -F"$TAB" '$1 == "builtin" { n++ } END { print n + 0 }' "$QUEUE" 2>/dev/null)
case "${_builtin:-}" in ''|*[!0-9]*) _builtin=0 ;; esac
_stale=0
if [ -n "$_age" ] && [ "$_age" -gt "$_ttl" ]; then _stale=1; fi
[ "$_hbstate" = fresh ] || _stale=1
if [ "$_rows" -eq 0 ] && [ "$_failing" -eq 0 ] && [ "$_builtin" -eq 0 ]; then
# 佇列是新的而且空的:這才是真的「沒有提醒」,安靜退出。
# 過期又空的那一種要分兩路。待辦簿有東西,就代表「有事而現在沒有人在算它到期沒到期」,
# 那要說一句;零筆就只是助理閒著,人自己按停也算這一種——對著一個刻意的決定每個工作階段
# 催一次,那是噪音不是提醒。
if [ "$_stale" -eq 1 ] && [ "$_tasks" -gt 0 ]; then
_agetxt='年紀算不出來'
[ -n "$_age" ] && _agetxt="$(( _age / 60 )) 分鐘前"
echo "[jsc] 助理的提醒清單是 ${_iso:-未知時間}($_agetxt)那一輪算的,那一輪沒有提醒;心跳現在是「${_hbstate:-讀不到}」。待辦簿還有 $_tasks 筆,**助理停著的時候沒有人再判它們到期了沒有**,所以這裡安靜不等於沒事。要現況就跑 /jsc-assist:assistant status。"
fi
# 記號一律記:不記的話同一個工作階段每次觸發都會再讀一次、再說一次同一句話。
mark_done
exit 0
fi
if [ "$_stale" -eq 1 ]; then
_agetxt='年紀算不出來'
[ -n "$_age" ] && _agetxt="$(( _age / 60 )) 分鐘前算的"
echo "[jsc] 下面這批提醒是 ${_iso:-未知時間}($_agetxt)那一輪算出來的,助理現在的心跳是「${_hbstate:-讀不到}」,門檻 $_ttl 秒。**助理停著的時候這份清單不會更新**,所以它現在說什麼都只描述那一輪,不描述現在。要現況就跑 /jsc-assist:assistant status。"
fi
# 逾期排前面:那幾筆的截止時間已經過了,比「該做了」更急。
#
# 先把兩種併成一份有序清單,再用 head 截筆數,不在迴圈裡自己數。
# 理由是那個迴圈接在管線後面,殼會把它放進子殼跑——在子殼裡加的計數,回到外面就沒了,
# 於是那道「只列前幾筆」的上限看起來寫了,實際上一次都沒生效。
_ordered=$(
awk -F"$TAB" '$1 == "overdue" { print }' "$QUEUE" 2>/dev/null
awk -F"$TAB" '$1 == "remind" { print }' "$QUEUE" 2>/dev/null
)
printf '%s\n' "$_ordered" | head -n "$MAX_ROWS" | while IFS="$TAB" read -r _k _id _why _title; do
[ -n "$_id" ] || continue
if [ "$_k" = overdue ]; then
echo "[jsc] 逾期 $_id:${_title:--} —— 已經逾期 ${_why:--}"
else
echo "[jsc] 到期 $_id:${_title:--} —— ${_why:--}"
fi
done
if [ "$_rows" -gt "$MAX_ROWS" ]; then
echo "[jsc] 這一批共 $_rows 筆,上面只列了 $MAX_ROWS 筆。其餘的跑 /jsc-assist:assistant status 看得到全部。"
fi
# 內建項一行講完,逐筆的內容留在監控頁與狀態查詢那邊。
# 這一行會一直出現,直到那幾筆有執行入口或被改掉——講明它是常態,讀的人才不會每次都
# 當成新消息。
if [ "$_builtin" -gt 0 ]; then
echo "[jsc] 另有 $_builtin 筆委派清單的內建檢查項到期,動作是只提醒、還沒有執行入口,所以每一輪都會再到期一次。**這一行會一直出現,直到那幾筆接上入口或被改掉**——要逐筆看就跑 /jsc-assist:assistant status。"
fi
if [ "$_failing" -gt 0 ]; then
echo "[jsc] 另有 $_failing 筆待辦連續失敗,每一輪都在重試而且不會自動暫停。跑 /jsc-assist:assistant status 看是哪幾筆。"
fi
mark_done
exit 0
+22 -10
View File
@@ -18,10 +18,13 @@
# restart 給接不到 session id 的 CLI(kiro)。那些 CLI 的紀錄共用 default,
# 不覆寫就會把上一個工作階段的起始時間算進來,花費時間虛胖。
#
# 這兩個子命令另外兼一件事:判定為「新的工作階段」時清除部署後的重啟閘門
# (restart-gate.sh clear)。新工作階段代表 CLI 行程是新起的,新版技能組一定已經載入。
# 判準只有這裡知道——start 分支的「起始檔不存在」就是這個 session id 第一次開始,
# 所以清除掛在這裡,不在 restart-gate.sh 裡自己再判一次。
# restart 另外清掉提醒記號($JSC_HOME/sessions/{代號}.reminded):那個記號讓提醒一個工作
# 階段只提一次,而共用 default 代號的 CLI 不清就等於只提第一次、往後永遠不提。
#
# 這兩個子命令另外兼一件事:工作階段開始時問一次要不要放下部署後的重啟閘門
# (restart-gate.sh clear)。**只是問,判定不在這裡。** 這裡曾經自己判過,用的是「起始檔
# 不存在=行程是新起的」,而那個等式不成立:還沒重啟的工作階段生出來的子行程拿到的也是沒
# 見過的代號。判準改成看行程還活著沒有,寫在 restart-gate.sh 的「清除時機」。
# 清除的範圍是「跑到這一支腳本的那個 CLI 自己那一份狀態檔」,由 restart-gate.sh clear 認定,
# 這裡不必也不能過問:這個工作階段開始的只有一支 CLI,別支沒重啟,閘門要留著。
HERE=$(dirname "$0"); . "$HERE/lib.sh"
@@ -31,22 +34,31 @@ 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
sh "$HERE/restart-gate.sh" clear "$sid" </dev/null 2>/dev/null || true
}
case "${1:-mark}" in
start)
f="$JSC_HOME/sessions/$sid.start"
if [ ! -f "$f" ]; then
now_epoch > "$f"
clear_restart_gate # 起始檔不存在=這個工作階段第一次開始,也就是行程新起的那一次
fi ;;
# 起始檔只補不覆寫:它是這個工作階段的計時起點,重寫會把已經累積的時間歸零。
[ -f "$f" ] || now_epoch > "$f"
# 閘門一律問,不再由「起始檔在不在」決定要不要問。理由見 restart-gate.sh 的「清除時機」:
# 那個條件兩頭都會答錯——續接原代號的 resume 行程確實換過卻不會問,而還沒重啟的工作階段
# 生出來的子行程拿到沒見過的代號、一問就把閘門清掉。判定改由那一邊看行程存活決定。
clear_restart_gate ;;
restart)
now_epoch > "$JSC_HOME/sessions/$sid.start"
rm -f "$JSC_HOME/sessions/$sid.end"
clear_restart_gate ;; # 接不到 session id 的 CLI 每次工作階段開始都算新的,一律清
# 提醒記號一起清。那個記號讓提醒一個工作階段只提一次,而接不到 session id 的 CLI
# 全部共用 default 這一個代號——不清的話第一個工作階段提過之後,往後每一個工作階段
# 都會被當成「已經提過」,那支 CLI 從此再也收不到任何提醒。
# 清除掛在這裡不掛在提醒那一支:「這是不是新的工作階段」的判準只有這一支知道。
rm -f "$JSC_HOME/sessions/$sid.reminded"
clear_restart_gate ;; # 走的是同一道判定:清不清由行程存活決定,不由這裡斷言
mark)
now_epoch > "$JSC_HOME/sessions/$sid.end" ;;
report)
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "jsc-hooks",
"version": "0.4.1",
"version": "0.5.1",
"description": "跨 CLI hooks:STE100 語言強制、工時計時、技能用量記錄、SDLC 模型鎖、版本前置檢查、註解範圍守門、繁中編碼守門、部署後強制重啟、寫入與提交閘門",
"skills": "./skills/",
"jsc": {
+8 -8
View File
@@ -6,18 +6,18 @@
| 項目 | 內容 |
| --- | --- |
| 觸發時機 | 裝好或更新完 jsc 技能組之後,要把九支 hook 接線到每一支已安裝的 CLI 時用;`jsc-cli:deploy` 收尾會把偵測到的 CLI 清單交給它。不用於撰寫新的 hook,也不用於單獨修一支壞掉的 hook,那是 `jsc-hooks:repair` 的事 |
| 關鍵步驟 | 取得 CLI 清單(呼叫端交來的優先,沒有才自己跑 `detect-clis.sh`)、第一支 CLI 單獨跑完整條管線(它負責更新共用的 `$JSC_HOME/current/jsc-hooks` 連結)、其餘 CLI 一支一個 sub agent 並行、每支 CLI 依序走 purge、接線、status、smoke、scan 五道關卡、讀每道關卡自己印的第一行判定、任一關卡出錯就寫 `ERROR_{HASH}` 並轉給 `jsc-hooks:repair`(異常頁與索引目錄頁分屬兩個存取庫,各自解析;只解不出目錄頁的存取庫時異常頁照寫、索引跳過,回報要講明那一頁沒被索引)、目錄頁那一列指向異常頁的連結一律寫成 `[{文字}]({連結})`,網址取 `gitea.sh wiki-url`,寫進去之前先過 `jsc-gitea/tools/link-check.sh`,結束碼 0 才寫連結、驗不過那一格只留純文字頁名而那一列與異常頁照寫(`report-error.sh` 內部做完,結束碼不變)、逐 CLI 回報五道關卡的結果 |
| 外部呼叫 | `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`,取目錄頁那一列的網址並驗它連得到);接線腳本內部另呼叫 `hooks/skill-name.sh` 與 `hooks/deny.sh` 做冒煙斷言 |
| 完成條件 | 每一支偵測到的 CLI 都有五道關卡各一行判定,沒有任何一道回結束碼 2,smoke 的 `lines` 條數與它自己的斷言相符,claude、codex、copilot、antigravity 回 `wired` 而 kiro 回 `degraded`(CLI 擋不下技能叫用),四支非 claude 的執行期錯誤掃描一律據實回 `unavailable`,各 CLI 的形狀與觸發驗證等級分開寫進回報(codex、antigravity、kiro 形狀實證,copilot 形狀未證;kiro 觸發部分實證,其餘未驗證),每一筆錯誤都帶一個 `ERROR_{HASH}` 結果與一條對 `develop` 的修正 PR 連結,而且目錄頁那一列的連結驗不過時,回報要講明那一列只有純文字頁名、沒有連結 |
| 可驗證跡象 | 各 CLI 的設定檔多出 jsc 段落:codex 的 `config.toml` 標記段落、`hooks/codex-hooks.json`(從 `hooks/hooks.json` 推導,matcher `Skill` 換成 `Bash`)與 `.codex-plugin/plugin.json` 指過去的 `hooks` 路徑字串、copilot 的 `~/.copilot/settings.json` 頂層 `hooks` 鍵(matcher `skill`,合併不覆寫,`enabledPlugins` 與第三方條目原樣保留)與 `$COPILOT_HOME` 底下的指引檔、antigravity 的 `~/.gemini/config/hooks.json` 的 `jsc` 段落(`PreToolUse` 為 Grouped、matcher `^view_file$`,`PreInvocation` 維持 Flat)、kiro 的 `~/.kiro/agents/jsc.json`(`hooks` 為 `agentSpawn`、`userPromptSubmit`、`stop` 三個合法事件加 `timeout_ms`、兩層 `skill://` glob 的 `resources`、明列的 `tools`,並通過 `kiro-cli agent validate`)與 `~/.kiro/settings/cli.json` 的 `chat.defaultAgent=jsc`;四支非 claude 的接線命令都以 `JSC_CLI={代號}` 前綴自帶 CLI 代號,缺了它兩道閘門解不出技能名、一律安靜放行,所以 `status` 把它列成單獨一項;另有 `$JSC_HOME/current/jsc-hooks` 符號連結建立或更新、`$JSC_HOME/backup/hooks/{cli}/{時間戳}/` 留下 purge 前的備份、出錯時 wiki 多一頁 `ERROR_{HASH}`(落在 `JSC_WIKI_REPO_ERROR` 解出的存取庫)並在索引目錄頁補一列(落在 `JSC_WIKI_REPO_CONTENTS` 解出的另一個存取庫,那一列的第 2 格寫成 `[{頁名}]({絕對網址})`,網址取自 `gitea.sh wiki-url` 且已經過 `link-check.sh` 驗到結束碼 0;驗不過那一格只有純文字頁名,`report-error.sh` 在 stderr 留一行 `[jsc]` 講明是哪一種原因)、修正路徑留下一條對 `develop` 的 PR 。接線完成後 `$JSC_HOME/usage/events.jsonl` 會逐行長出 `{kind:hook}` 事件,每支 hook 每次執行一筆,欄位含 `status` 與實際結束碼;跑過技能之後另有 `{kind:skill,phase:start}`。事件寫不進去不影響任何 hook 的結束碼 |
| 觸發時機 | 裝好或更新完 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 |
| 關鍵步驟 | 從 `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 時要講明修正已套用但尚未合併、帶上分支名與失敗原因 |
| 可驗證跡象 | hooks 存取庫多一個修正提交與一條推上去的分支、`develop` 上多一條 PR、三份 manifest 與 README 技能清單版本一致、`wire-cli.sh smoke` 由失敗轉為 exit 0 |
| 完成條件 | 修正已經落在磁碟上、`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` 不在那台機器上就沒有這一筆,修正結果一字不變 |
+35 -10
View File
@@ -7,9 +7,30 @@ description: Wire jsc hooks (STE100 guard, session timer, skill usage logger, SD
Goal: make the nine hooks (`ste100-guard.sh`, `session-timer.sh`, `skill-usage.sh`, `sdlc-gate.sh`, `version-guard.sh`, `restart-gate.sh`, `comment-scope.sh`, `lang-guard.sh`, `write-guard.sh`) effective in every CLI, with nothing else wired alongside them.
## 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_HOME/current/jsc-hooks`, not the versioned plugin cache path and not the development checkout. `tools/wire-cli.sh {cli}` creates or refreshes that symlink before it writes `notify`, shell aliases or Kiro hook JSON, then verifies the linked scripts exist. If the filesystem cannot create the symlink, the script must say so and explicitly fall back to the current root; it must never write a silent broken path. The bundled `hooks/hooks.json` follows the same rule: use `${CLAUDE_PLUGIN_ROOT}` only where the host provides it, and fall back to `$JSC_HOME/current/jsc-hooks` for any other CLI reading the same manifest, so an unset Claude-only variable never expands into `/hooks/...`.
The wiring commands stored in user config use `{JSC_ROOT}/jsc-hooks`, not the versioned plugin cache path and not the development checkout. `wire-cli.sh` expands that root itself, so what lands in each CLI's config is already a literal absolute path. `{HOOKS_ROOT}/tools/wire-cli.sh {cli}` creates or refreshes that symlink before it writes `notify`, shell aliases or Kiro hook JSON, then verifies the linked scripts exist. If the filesystem cannot create the symlink, the script must say so and explicitly fall back to the current root; it must never write a silent broken path. The bundled `hooks/hooks.json` follows the same rule with one deliberate exception: use `${CLAUDE_PLUGIN_ROOT}` only where the host provides it, and fall back to the manifest's own text, `${JSC_HOME:-$HOME/.jsc}/current/jsc-hooks`, for any other CLI reading the same manifest, so an unset Claude-only variable never expands into `/hooks/...`. That one stays in variable form on purpose: it is file content shipped with the repo, expanded by the hook's own shell on whatever machine reads it, and it never passes through the permission layer. It is not a path anyone types at a prompt, so the path rule above does not reach it.
Four of the five CLIs have a pre-tool hook that can block. The version guard and the restart gate reach codex, copilot and antigravity too — each at its own wiring point, with its own matcher and its own blocking shape. Never say a CLI "has no pre-tool hook"; that claim is wrong and it is what left three CLIs unguarded.
@@ -66,24 +87,28 @@ The detailed flow **MUST run as a sub agent**; the main agent only reports the s
## Steps
1. Take the CLI list from the caller when it hands one over — `jsc-cli:deploy` passes the list it already detected, and probing the same five executables a second time buys nothing. Run `jsc-cli/tools/detect-clis.sh` yourself only when no list came in; that fallback is what keeps this skill usable when it is called on its own. The script always exits 0 and prints one `name<TAB>path<TAB>version` line per installed CLI. Done when you hold that list and have said which of the two ways produced it; when it is empty, report that no CLI was detected and stop.
2. Run the five-stage pipeline **purge → wire → status → smoke → scan** once per detected CLI. Run the first CLI's pipeline on its own, because `tools/wire-cli.sh {cli}` is what refreshes the shared `$JSC_HOME/current/jsc-hooks` link and two CLIs must not rewrite it at the same time; once that first pipeline has finished, run every remaining CLI's pipeline in parallel, one sub agent per CLI — the five stages of one CLI stay in this order, but different CLIs touch different config files and share nothing else. Every stage prints its verdict on its first line, so read that line and never infer the outcome from the prose below it.
1. `tools/wire-cli.sh purge {cli}` — backs up every file it touches, removes all hooks, re-reads each file to confirm the removal, and restores the backup by itself when a check fails. Marker matching trims leading and trailing whitespace, so an indented or padded marker block is still removed as the same jsc-owned block. Exit 0 is `purged`, exit 3 is `skipped` (that CLI's executable is not on this machine, so skip its remaining stages too), exit 4 is `failed` and goes to step 3. Exit 2 is a bad CLI name, not a purge outcome — fix the name and rerun the stage.
2. `tools/wire-cli.sh {cli}` — owns both the wiring and its verification: it refreshes the link, writes the config, alias or hook file inside a `<!-- jsc-hooks -->` (or `# jsc-hooks`) marker block, re-reads every file it wrote, confirms the block is present and correctly placed, and confirms the stored runtime paths resolve to existing scripts before it prints a success status. Exit 0 is `wired` and is the expected result on claude, codex, copilot and antigravity; exit 1 is `degraded` and is expected on kiro alone; exit 3 is `skipped`, exit 4 is `failed` and goes to step 3. Exit 2 is a bad CLI name — fix the name and rerun. The matcher is verified on its own, not just the presence of a key: a key that is there with the wrong matcher reports as wired and fires never.
3. `tools/wire-cli.sh status {cli}` — the read-only inventory of what the previous stage wrote. It writes nothing and runs no hook, so it is safe to run right after wiring. Exit 0 is `wired` (claude, codex, copilot, antigravity), exit 1 is `degraded` (kiro), exit 3 is `skipped`, exit 5 is `unwired`, which names every missing item and means the wiring stage has to run again before you continue. Exit 2 is a bad CLI name. For codex this stage is the only one that reads the installed `jsc-hooks` manifest in the Codex plugin cache and reports a stale `UserPromptSubmit` command there, the one that expands `${CLAUDE_PLUGIN_ROOT}` into `/hooks/...`; carry that item into the report.
4. `tools/wire-cli.sh smoke {cli}` — runs every wired mode of all nine hooks once, plus each decision path of the work-package check, of the restart gate and of the write and commit guard. It catches what the wiring check cannot see: a hook that is wired correctly and still fails when it executes. It also runs each CLI's real payload through `skill-name.sh`, each blocking shape through `deny.sh`, and those same payloads straight through `restart-gate.sh` end to end, so a break anywhere along parse, decide and emit is caught — the two ends look healthy on their own while the middle silently passes everything through, which is exactly how three CLIs went unguarded. It prints its own result-line count as `lines<TAB>{count}` and asserts that count against what it expected to run, so read the number from that line and never restate a number of your own. Exit 0 is `ok`, exit 4 is `failed` — either a hook errored or the line count did not match, and both go to step 3. Exit 2 is a bad CLI name.
5. `tools/scan-hook-errors.sh --cli {cli}` — only claude keeps hook results in its native records and can answer `clean` or `errors`; codex, copilot, antigravity and kiro answer `unavailable`, and their runtime evidence comes from the smoke stage alone. Exit 0 covers both `clean` and `unavailable`, exit 1 is `errors` and every entry with `jsc=true` goes to step 3, exit 2 is a bad CLI name.
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.
Done when every detected CLI has exactly one verdict line per stage, no stage exited 2, the smoke stage's `lines` count matches its own assertion, the four non-claude CLIs are reported as `unavailable` rather than clean on the scan stage, and antigravity and kiro carry the note that their hook firing is unverified.
3. For each error — a failed purge, a failed wiring, an `unwired` status, a failed smoke, or a scanned error with `jsc=true` — run `tools/report-error.sh --hook {script name} --exit {code} --summary "{reason}" --cli {cli}` with the script's `[jsc]` output on stdin, then hand the failure to `jsc-hooks:repair`, which **MUST run as a sub agent** and must finish by opening a PR against `develop`. Aborting the remaining installs here is allowed as long as the repair starts. The error page and the error directory page live in two different wiki repos, resolved separately: the page through `wiki-repo ERROR`, the directory through `wiki-repo CONTENTS`. Exit 0 with an `ERROR_{HASH}` page name and URL on stdout means the page was written; the same exit 0 with a `[jsc]` line on stderr still means the page landed, and that line says what is missing — the directory repo would not resolve, 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 row carries the page name as plain text with no link — carry that note into step 4. Every link on that row 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 row 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 script refusing to write the error directory page because it could not read the old one back. That directory is appended to, never overwritten: every row on it is somebody else's error report, so the script reads the page, adds this run's row, and writes the whole page. Only a genuine 404 (`wiki-get` exit 4) means the page is not there yet and lets it build one from the template. An invalid key (exit 7) or any other API failure (exit 8) leaves the old rows unknown, so it skips the directory write and names the code instead — writing a fresh template over a directory it never read would erase every earlier report, with no merge and no backup behind it. A scanned error with `jsc=false` belongs to a third-party hook: report it and leave it alone. Skip this step when every CLI passed all five stages. Done when every error carries one `ERROR_{HASH}` result — a page name with its URL, a page name plus the reason the URL is missing, or the recorded reason no page was written — and one repair PR URL against `develop`.
3. For each error — a failed purge, a failed wiring, an `unwired` status, a failed smoke, or a scanned error with `jsc=true` — run `{JSC_ROOT}/jsc-hooks/tools/report-error.sh --hook {script name} --exit {code} --summary "{reason}" --cli {cli}` with the script's `[jsc]` output on stdin, then hand the failure to `jsc-hooks:repair`, which **MUST run as a sub agent** and must finish by opening a PR against `develop`. Aborting the remaining installs here is allowed as long as the repair starts. The error page and the error directory page live in two different wiki repos, resolved separately: the page through `wiki-repo ERROR` in `report-error.sh` itself, the directory through `wiki-repo CONTENTS` inside `wiki-contents.sh`. Exit 0 with an `ERROR_{HASH}` page name and URL on stdout means the page was written; the same exit 0 with a `[jsc]` line on stderr still means the page landed, and that line says what is missing — the directory repo would not resolve (or `wiki-contents.sh` is not on disk), so nothing indexes the page; the page URL could not be read back, so the page name comes out on its own; or the URL failed the reachability check, so the directory entry's page-name bullet carries the page name as plain text with no link — carry that note into step 4. One error report is one H2 block on the directory page: the heading is that report's own page name, `ERROR_{HASH}`, and the fields sit under it as one bullet each — time, page name, repo, hook, exit code, summary, written `- {field}:{value}` — with no markdown table anywhere on the page. Every link inside that block is written as `[{text}]({url})` with the URL from `gitea.sh wiki-url`, and the script checks it with `jsc-gitea/tools/link-check.sh` before writing: exit 0 writes the link, anything else keeps the bullet and drops the link, and none of it changes the exit code — this is the failure-reporting path, so a failed report must never become a second failure. Exit 0 with no output at all means the run ended on one of the quiet-degradation reasons listed in the script's own header — no `gitea.sh` on the path, the error page's wiki repo unresolved, the hash not computed, or a temp file not created — so no page was written at all and that reason goes into step 4 instead; exit 2 means the call itself was malformed — `--hook` or `--summary` is missing — so fix the arguments and rerun the same call; exit 4 means the wiki record did not land, so report the failure text and still start the repair — a page that could not be written is no reason to leave a broken hook wired. Exit 4 covers two cases, and the report has to say which: a failed write, or the directory page left untouched because the old one could not be read back. The directory page itself is not assembled here: `report-error.sh` builds only its own block and hands it to `jsc-gitea/tools/wiki-contents.sh upsert ERROR 2 {page name} {block file} {template}`, so the read, the key match and the whole-page write have exactly one owner. That page is upserted, never overwritten: every block on it is somebody else's error report, so the tool reads the page, replaces the block whose heading equals this page name or appends a new block at the end, and writes the page back. Only a genuine 404 (`wiki-get` exit 4) means the page is not there yet and lets it build one from the template. An invalid key (exit 7) or any other API failure (exit 8) leaves the old blocks unknown, so it skips the directory write and names the code instead — writing a fresh template over a directory it never read would erase every earlier report, with no merge and no backup behind it. A scanned error with `jsc=false` belongs to a third-party hook: report it and leave it alone. Skip this step when every CLI passed all five stages. Done when every error carries one `ERROR_{HASH}` result — a page name with its URL, a page name plus the reason the URL is missing, or the recorded reason no page was written — and one repair PR URL against `develop`.
4. Report five results per CLI — purge, wiring, status, smoke, scan — each with the reason its script printed, plus the smoke `lines` count, any `ERROR_{HASH}` page name and every repair PR URL. Done when every detected CLI appears with one verdict per stage and every repair has a PR against `develop`.
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
- 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`.
- `restart-gate.sh` blocks jsc skill calls while `$JSC_HOME/restart-required.d/{cli}` exists — one file per CLI, named after the CLI code — so a freshly deployed skill set is not used by a process still running the old one. Each CLI reads only its own file: another CLI's file never blocks this one, and a restart clears only the file of the CLI that restarted. **What counts as a restart is whether the process that installed the gate is gone**, not whether a session id looks new — a session id nobody has seen before is also what a child process of the un-restarted session gets, and that child used to clear the gate on the person's behalf. `jsc-cli:deploy` writes the current CLI's file through `restart-gate.sh require {install|update} [{domain}...]` at the end of an install or update; `restart-gate.sh report` prints one line per file, so it is visible which CLIs still owe a restart. A leftover old-format single file at `$JSC_HOME/restart-required` blocks every CLI and is deleted on the next `clear` — transitional only, and `hooks/restart-gate.sh` records when it can be dropped. The gate matches skill names, not call chains, so a nested call to anything off the exemption list is blocked all the same; `hooks/restart-gate.sh` owns that list with a reason per entry, and `jsc-meta/references/guidelines.md`「部署後重啟閘門」carries the same list. Escape hatch: `JSC_RESTART_GATE=off`.
- `write-guard.sh` takes three blocking modes, wired on two PreToolUse matchers on claude only, so claude is still the only CLI where any of it takes effect — codex, copilot and antigravity now have a usable pre-tool hook, but these three modes are not wired there yet; say that, rather than blaming a missing hook, plus a fourth mode, `release`, that is wired nowhere and is called by a skill itself. `stage` reads the stage lock that `sdlc-gate.sh` already owns and blocks `Write`, `Edit` and `MultiEdit` while `plan` or `analyze` holds it, because those two stages produce wiki pages rather than files. `review` reads the current skill — the environment variable first, then the record `skill-usage.sh` keeps — and blocks writes while `jsc-review:code-review` or `jsc-review:api-doc` runs, since both only report findings. It deliberately does **not** block `jsc-review:comment-cleanup`: that skill has to write, limited to comment lines, and deciding that limit needs per-language comment parsing of the whole proposed content, which would block legitimate cleanups more often than it caught bad ones — that boundary stays with the skill text and the later review. `commit` is wired on `Bash` and blocks a single command that stages everything and commits in one go, plus any commit message carrying simplified characters or mojibake, which it decides by calling `lang-guard.sh` rather than keeping a second word list. A `git add -A` split across two separate tool calls is not caught, on purpose: catching it needs cross-call state that the blocked operator has no way to clear. `release` deletes that recorded skill and always exits 0; `jsc-review:code-review` and `jsc-review:api-doc` call it once each as they hand their findings back. It exists because the record says which skill was loaded last, not which one is still running: both audit skills end by leaving the fixing to their caller, and without `release` every write that caller makes stays blocked for the whole TTL, with the escape hatch or a wait as the only way out — a gate must never lock away its own release. Escape hatch: `JSC_WRITE_GUARD=off`, which `release` ignores because clearing a record blocks nobody, plus `JSC_WRITE_GUARD_TTL` for how long a recorded skill counts as still running.
- `purge` reaches the user-level config only. Hooks that another plugin ships in its own `hooks.json` stay active, and uninstalling that plugin is the only way to clear them — say so when reporting, and treat their errors as third-party.
- Backups land in `$JSC_HOME/backup/hooks/{cli}/{yyyyMMdd_HHmmss}/`, one directory per purge run, under the original file names. Hand that path to the user whenever a purge removed something.
+4
View File
@@ -16,3 +16,7 @@ This skill is exempt from the version guard and the post-deploy restart gate, be
3. Pick the smallest repair that makes the wiring pass, then apply it in the `hooks` repo. When the fix touches wiring behaviour, update `hooks/tools/wire-cli.sh`, `hooks/skills/hooks-install/SKILL.md` and `hooks/README.md` in the same change. Run `tools/wire-cli.sh smoke {cli}` for the affected CLI: exit 0 means the repair holds, exit 4 means it does not — go back to step 2 with the new output, exit 2 means a bad CLI name, so fix the name and rerun. Done when the fix is on disk and smoke exits 0.
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.
+9 -6
View File
@@ -4,14 +4,17 @@
>
> 存放位置:本頁落在 `JSC_WIKI_REPO_CONTENTS` 解出的目錄專用存取庫,與異常頁的存取庫是兩個不同的存取庫。
>
> 寫入語意:一列代表一次 hook 異常回報。寫入前先讀回整頁,同一筆異常已經有列就更新那一列,沒有才在文末附加一列,最後整頁寫回。一律 upsert 附加,禁止整頁覆蓋,也不得改動別人的列。
> 寫入語意:一個區塊代表一次 hook 異常回報。H2 標題就是那一筆的異常頁頁名 `ERROR_{HASH}`,欄位是標題底下的一層條列,一個欄位一條。寫入前先讀回整頁,同一筆異常已經有區塊就整塊換掉,沒有才在文末附加一個新區塊,最後整頁寫回。一律 upsert 附加,禁止整頁覆蓋,也不得改動別人的區塊。
>
> 連結寫法:一律寫成 `[{文字}]({絕對網址})`,網址取 `jsc-gitea/tools/gitea.sh wiki-url` 印出的那一個,不自己組路徑。wiki 自己那種雙中括號寫法只在同一個 wiki 裡解得開,寫錯不會報錯,畫面上看起來像正常文字或死連結。
>
> 寫入前驗證:這一列要放進去的連結,先交給 `jsc-gitea/tools/link-check.sh`,結束碼 0 才把連結寫進那一格。驗不過就只留純文字頁名,那一列照寫,異常紀錄不因為一條連結整份丟掉。驗證走 API,不看網頁狀態碼——私有存取庫的網頁網址對未登入請求一律回 404,拿狀態碼判會把還在的頁判成死連結。結束碼 7 是金鑰失效,不算死連結,也不改寫任何既有列。
> 寫入前驗證:要放進條列的連結,先交給 `jsc-gitea/tools/link-check.sh`,結束碼 0 才把連結寫進那一條。驗不過就只留純文字頁名,那一條照寫,異常紀錄不因為一條連結整份丟掉。驗證走 API,不看網頁狀態碼——私有存取庫的網頁網址對未登入請求一律回 404,拿狀態碼判會把還在的頁判成死連結。結束碼 7 是金鑰失效,不算死連結,也不改寫任何既有區塊。
## 異常清單
## ERROR_{HASH}
| 時間 | 頁名 | 存取庫名稱 | 觸發 hook | 退出碼 | 摘要 |
| --- | --- | --- | --- | --- | --- |
| {yyyy-MM-dd HH:mm:ss} | [{頁名}]({wiki-url 印出的絕對網址}) | {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}
+62 -60
View File
@@ -1,8 +1,10 @@
#!/usr/bin/env sh
# report-error.sh — 失敗回報流程:把一筆 hook 或工具異常寫成 wiki 的 ERROR_{HASH},
# 並在 ERROR_CONTENTS 附上一列索引。頁面內容套用 templates/ 的兩份範本,
# 並在異常目錄頁附上一個索引區塊。頁面內容套用 templates/ 的兩份範本,
# 範本是文案的唯一來源,本腳本只填欄位;真正寫進 wiki 前,還會先走
# Gitea 寫入確認。
# 目錄頁的讀回、比對與整頁寫回一律交給 jsc-gitea 的 tools/wiki-contents.sh,本腳本只組出
# 自己那一個區塊。目錄頁版面只留一份正本,十幾個目錄頁才不會各長一種樣子。
#
# 用法:
# report-error.sh --hook {名稱} --exit {碼} --summary {摘要}
@@ -20,23 +22,24 @@
# 3. 算不出 HASH(hash-id 失敗或回空字串)
# 4. 建不出暫存檔(mktemp 失敗)
# 異常頁與目錄頁分屬兩個存取庫,各解各的:解不出異常頁的存取庫就整支降級;解得出
# 異常頁、只解不出目錄頁的存取庫,就只寫異常頁、跳過目錄頁更新,印出頁名,仍然 exit 0。
# 異常頁、只解不出目錄頁的存取庫(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 才把連結寫進那一列。
# 驗不過就只留純文字頁名:那一列照寫、異常頁照寫、結束碼照舊。這一段一律不改結束碼,
# 目錄頁那個區塊裡的「頁名」那一條指向異常頁,一律寫成 [{文字}]({連結}),網址取
# jsc-gitea 的 gitea.sh wiki-url,不自己組路徑。
# 寫入前先把那個網址交給 jsc-gitea 的 link-check.sh,結束碼 0 才把連結寫進那一條。
# 驗不過就只留純文字頁名:那一條照寫、異常頁照寫、結束碼照舊。這一段一律不改結束碼,
# 本腳本是失敗回報路徑,回報失敗不該再變成一次失敗。
#
# 結束碼: 0=已寫入異常頁並印出頁名(取得網址就一併印出),或以上列四種安靜降級原因之一
# 結束、沒有寫出任何頁也沒有任何輸出——回報失敗不該再變成一次失敗
# 2=用法錯誤(缺 --hook 或 --summary)
# 4=寫入 wiki 失敗(異常頁與索引目錄頁,任一支寫不進去就算),或目錄頁的舊內容
# 讀不回來(wiki-get 回 7 金鑰失效、8 其他 API 失敗)而放棄寫入;訊息走 stderr。
# 讀不回來就不寫,是為了不拿範本蓋掉一份還在的目錄頁
# 讀不回來(wiki-contents.sh 回 7 金鑰失效、8 其他 API 失敗)而放棄寫入;
# 訊息走 stderr。讀不回來就不寫,是為了不拿範本蓋掉一份還在的目錄頁
# 註: 本檔以 `. "$ROOT/hooks/lib.sh"` 載入共用函式,沒有接 `|| true`。lib.sh 讀不到時 sh 會
# 就地結束並回 2,跟用法錯誤同碼;分不出是哪一種時,先確認 hooks/lib.sh 在不在。
#
@@ -83,9 +86,8 @@ gsh=$(jsc_gitea_sh) || exit 0
# 型別。異常頁的存取庫解不出來就整支降級,連異常都沒地方寫,做下去也沒意義。
wrepo=$(sh "$gsh" wiki-repo ERROR 2>/dev/null) || exit 0
[ -n "$wrepo" ] || exit 0
# 目錄頁的存取庫解不出來不算失敗:異常頁照寫,只跳過目錄頁更新,仍然 exit 0。
# 一份寫得成的異常紀錄,不該因為索引沒地方放就整份丟掉。
crepo=$(sh "$gsh" wiki-repo CONTENTS 2>/dev/null || true)
# 目錄頁的存取庫不在這裡解:讀回、比對、整頁寫回都由 wiki-contents.sh 做,它自己解目錄專用
# 存取庫,這邊再解一次就會有兩份規則。解不出來時它回 3,本腳本照原本的語意降級。
# 存取庫名稱未指定就取工作目錄的 origin(只用來標記異常屬於哪個存取庫)
if [ -z "$repo" ]; then
@@ -108,9 +110,10 @@ 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="(無)"
# 摘要與相關輸出都落在 markdown 表格欄位裡,半形 | 會把欄位切斷,改成全形
# 相關輸出落在異常頁的表格欄位裡,半形 | 會把欄位切斷,改成全形
detail=$(printf '%s' "$detail" | sed 's/|/|/g')
summary=$(printf '%s' "$summary" | sed 's/|/|/g')
# 摘要在目錄頁是一整條條列,換行會把一條拆成兩行,先併成一行
summary=$(printf '%s' "$summary" | tr '\n' ' ')
hash=$(sh "$gsh" hash-id "$repo $hook $ts" 2>/dev/null) || exit 0
[ -n "$hash" ] || exit 0
@@ -121,8 +124,8 @@ esc() { printf '%s' "$1" | tr '\n' ' ' | sed 's/[\\&|]/\\&/g'; }
fill() { sed "s|$1|$(esc "$2")|g"; }
tmp_page=$(mktemp) || exit 0
tmp_list=$(mktemp) || { rm -f "$tmp_page"; exit 0; }
trap 'rm -f "$tmp_page" "$tmp_list"' EXIT
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" \
@@ -140,16 +143,17 @@ fill '{HASH}' "$hash" < "$ROOT/templates/error-page.md" \
| fill '{yyyyMMdd}_{HHmmss}' "$ticket_ts" > "$tmp_page"
# 異常頁先寫,網址後取。頁名的 hash 帶時間戳,每次回報都是一個全新的頁,寫進去之前查網址
# 一定是 404,拿到的必然是空字串;目錄頁那一列會變成沒有連結的死字,stdout 也少一半。
# 一定是 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。
# 目錄頁那個區塊的「頁名」那一條指向異常頁,連結一律寫成 [{文字}]({連結}),網址取
# gitea.sh wiki-url。
# wiki 自己那種雙中括號寫法只在同一個 wiki 裡解得開,寫錯不會報錯,畫面上看起來像正常
# 文字或死連結,巡不到也修不了。
# 網址取不到不算失敗:異常頁已經寫成功了,只是這一列少一條連結。這裡把原因記下來走 stderr,
# 網址取不到不算失敗:異常頁已經寫成功了,只是這一條少一個連結。這裡把原因記下來走 stderr,
# 結束碼照舊——安靜降級仍是 exit 0,回報失敗不該再變成一次失敗。
url=$(sh "$gsh" wiki-url "$wrepo" "$page" 2>/dev/null)
url_code=$?
@@ -162,76 +166,74 @@ if [ "$url_code" -ne 0 ]; then
7) url_note='金鑰失效或權限不足(wiki-url 回 7)' ;;
*) url_note="wiki-url 結束碼 $url_code" ;;
esac
echo "[jsc] 取不到 $page 的網址:$url_note。目錄頁那一列與輸出只留頁名。" >&2
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_cell="$page"
# 連結先驗證連得到,才寫進目錄頁那一條。沒驗過的連結寫進去,異常頁一樣會在目錄頁長出
# 死連結,而目錄頁是別人查問題的入口。驗不過就只留純文字頁名,那一條照寫。
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
echo "[jsc] 找不到 $lcs,這一條的連結沒驗過,只留頁名;$page 已建立。" >&2
else
sh "$lcs" "$url" >/dev/null 2>&1
lc_code=$?
case "$lc_code" in
0) link_cell=$(printf '[%s](%s)' "$page" "$url") ;;
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 ;;
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
row=$(printf '| %s | %s | %s | %s | %s | %s |' \
"$ts" "$link_cell" "$repo" "$hook" "$code" "$summary")
# 目錄頁上這一筆是一個 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"
build_contents() { # 用範本建一份全新的目錄頁;只有確定舊頁不存在時才可以呼叫
# 範本的示範列整列換成本次這一列,不逐格填。連結那一格已經驗過也組好了,拆成頁名與
# 網址兩個佔位再填,會在驗不過的時候留下一個空網址的死連結。
ROW="$row" awk '
index($0, "| {yyyy-MM-dd HH:mm:ss} |") == 1 { print ENVIRON["ROW"]; next }
{ print }
' "$ROOT/templates/error-contents.md" > "$tmp_list"
}
if [ -z "$crepo" ]; then
echo "[jsc] 目錄頁的 wiki 存取庫解不出來,只寫異常頁,跳過目錄;$page 已建立($wrepo)。" >&2
# 目錄頁交給 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
# 異常目錄頁一律附加,不整頁覆蓋。頁上每一列都是別人回報的異常,本腳本沒有從別處讀過
# 那些列,所以先把舊頁讀回來、把新列附在文末(最新一筆在最後),再整頁寫回。
# 這個語意完全靠「讀得回舊內容」撐著,因此依 wiki-get 的結束碼分流:
# 0 → 讀到既有內容,附加新列(讀得到但整頁是空的,沒有既有列會被蓋掉,套範本才安全)
# 4 → 頁面真的還不存在,只有這個碼可以用範本建立新頁
# 7 → 金鑰失效或權限不足,舊內容未知,放棄目錄頁寫入
# 8 → 其他 API 失敗,舊內容一樣未知,處置同 7
# 為什麼 7 與 8 不能當成「頁面不存在」:拿範本蓋掉一份讀不回來的目錄頁,等於刪光所有既有
# 列,而 wiki-put 不做合併、也不留備份,蓋掉就救不回來。
sh "$gsh" wiki-get "$crepo" ERROR_CONTENTS > "$tmp_list" 2>/dev/null
get_code=$?
case "$get_code" in
0) if [ -s "$tmp_list" ]; then printf '%s\n' "$row" >> "$tmp_list"; else build_contents; fi ;;
4) build_contents ;;
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] 讀取目錄頁失敗($crepo,wiki-get 結束碼 $get_code),舊內容未知,不寫目錄頁;$page 已建立。" >&2
echo "[jsc] 目錄頁寫入失敗(wiki-contents.sh 結束碼 $wc_code),$page 已建立。" >&2
exit 4 ;;
esac
if ! sh "$gsh" wiki-put "$crepo" ERROR_CONTENTS "$tmp_list" >/dev/null 2>&1; then
echo "[jsc] 寫入目錄頁失敗($crepo),$page 已建立。" >&2
exit 4
fi
emit
+3 -2
View File
@@ -18,7 +18,7 @@
#
# 產出: 每筆錯誤附加一行 JSON 到 $JSC_HOME/errors/hooks.jsonl(格式比照 hooks/skill-usage.sh):
# {ts,cli,hook,event,exit,detail,jsc}
# jsc 欄位:command 或 stderr 命中九支 hook 腳本任一支,或命中 jsc-hooks 路徑,就是 true,
# jsc 欄位:command 或 stderr 命中十支 hook 腳本任一支,或命中 jsc-hooks 路徑,就是 true,
# 否則 false。分得出來才用得上——非 jsc 的 hook 錯誤不是 jsc 該修的,hooks-install 只回報、
# 不轉 jsc-hooks:repair。
#
@@ -52,7 +52,7 @@ 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 驗執行期。"
echo "[jsc] $cli:改跑 tools/wire-cli.sh smoke $cli,主動執行十支 hook 驗執行期。"
exit 0 ;;
esac
@@ -98,6 +98,7 @@ extract_errors() {
# 第三方的,只回報不修正。
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") \
+135 -27
View File
@@ -3,8 +3,10 @@
# 用法:
# wire-cli.sh {claude|codex|copilot|antigravity|kiro} 接線
# wire-cli.sh purge {claude|codex|copilot|antigravity|kiro} 備份後移除該 CLI 的所有 hook
# wire-cli.sh smoke {claude|codex|copilot|antigravity|kiro} 跑一輪九支 hook,驗執行期
# wire-cli.sh status {claude|codex|copilot|antigravity|kiro} 唯讀盤點接線現況,不寫檔也不執行 hook
# wire-cli.sh smoke {claude|codex|copilot|antigravity|kiro} 跑一輪十支 hook,驗執行期
# wire-cli.sh status {claude|codex|copilot|antigravity|kiro} [--verdict]
# 唯讀盤點接線現況,不寫檔也不執行 hook。
# --verdict 只換結束碼語意,輸出一字不變
#
# JSC_READONLY=1 時只准 status 與 smoke,purge 與接線一律拒絕並回 exit 6。體檢類技能全程帶著
# 這個變數跑,「子命令打錯一個字就重新接線或刪檔」的風險就由程式擋掉,不靠呼叫端自我約束。
@@ -35,7 +37,7 @@
# 就好,不必在 SKILL.md 或 README 各抄一份——抄了就會在加減判定路徑時漂移。
#
# 接線行為(依 CLI 而定,皆為冪等:重跑只取代既有的 jsc 段落,不會重複疊加):
# claude — 什麼都不用寫,hooks/hooks.json 已自動接線九支 hook(PreToolUse matcher Skill)
# claude — 什麼都不用寫,hooks/hooks.json 已自動接線十支 hook(PreToolUse matcher Skill)
# codex — pre-tool hook 寫在 .codex-plugin/plugin.json 的 hooks 鍵,做 Codex 專屬覆寫
# (PreToolUse matcher Bash,接 restart-gate.sh 與 version-guard.sh)。
# Claude 用的 hooks/hooks.json 完全不動:那一份是 Skill matcher,而 Codex
@@ -72,7 +74,7 @@
#
# 覆蓋範圍要據實回報,不得暗示每個 CLI 都有保護,也不得再說「沒有 pre-tool hook」——
# 五支裡有四支都有,先前失效的原因是接錯位置,不是沒有位置可接:
# claude 九支 hook 全接,回報 wired
# claude 十支 hook 全接,回報 wired
# codex、copilot、antigravity 版本前置檢查與部署後重啟閘門都擋得下來,回報 wired。
# SDLC 模型鎖仍是技能步驟檢查,write-guard.sh 三種模式尚未接線,
# 註解範圍與繁中編碼仍是 sweep,reason 要逐項講明。
@@ -128,6 +130,9 @@
# 結束碼(purge): 0=purged 2=用法錯誤 3=skipped 4=failed
# 結束碼(smoke): 0=ok 2=用法錯誤 4=failed(含結果行數與預期不符)
# 結束碼(status): 0=wired 1=degraded 2=用法錯誤 3=skipped 5=unwired(該接的段落缺了至少一項)
# 結束碼(status --verdict): 0=該接的都接了(含 degraded——先天限制不算缺漏)
# 2=用法錯誤 3=skipped 5=unwired
# 給拿結束碼判成敗的呼叫端用,例如助理的內建檢查項。理由見下方 --verdict 那一段。
# 結束碼(唯讀模式): 6=readonly(JSC_READONLY=1 之下拒絕 purge 與接線),status 與 smoke 不受影響
# status 之外的動作都會寫檔,體檢類技能(/jsc-cli:doctor)只能呼叫 status。判讀邏輯跟接線
# 共用同一組檔案位置與標記字串,分兩份實作就會各自漂移,體檢說沒接、實際上接著。
@@ -136,8 +141,14 @@
# 有這個逃生門才測得動 purge 的 JSON 刪鍵:預設路徑是使用者自己的設定檔,拿真檔案試刪
# 等於拿使用者的環境當測試場。指向一份複製品就能完整跑過 purge claude 而不動到本人設定。
set -u
HERE=$(cd "$(dirname "$0")" && pwd)
ROOT=$(cd "$HERE/.." && pwd)
# 這兩行一定要實體解析(`cd -P` 加 `pwd -P`),不能拿邏輯路徑。
# ROOT 會被 ensure_stable_root() 當成 `ln -sfn "$ROOT" "$_link"` 的目標,而這支腳本本身
# 常常就是經由那條連結被叫起來的。邏輯解析會把連結原樣留在路徑裡,於是 ROOT 等於連結
# 自己,連結被改成指向自己,之後每一支 hook 的接線路徑都解不開,全機器 hook 一起失效。
# 這件事實際發生過。實體解析永遠退到連結指向的那個實際目錄,這個失敗模式就不可能成立。
# 事後才用 `[ -f ]` 檢查連結通不通不夠:那要等連結已經被寫壞才攔得到。
HERE=$(CDPATH= cd -P -- "$(dirname -- "$0")" && pwd -P)
ROOT=$(CDPATH= cd -P -- "$HERE/.." && pwd -P)
HOOKS="$ROOT/hooks"
JSC_HOME="${JSC_HOME:-$HOME/.jsc}"
WIRE_ROOT="$ROOT"
@@ -163,6 +174,30 @@ case "$cli" in
claude|codex|copilot|antigravity|kiro) ;;
*) usage ;;
esac
shift 2>/dev/null || true
# --verdict:輸出一字不變,只有結束碼換一套語意——該接的都接了就回 0,先天限制不算。
#
# 為什麼要有這個旗標。status 的結束碼帶的是狀態:0 是接好、1 是接好但這支 CLI 做不到、
# 5 是有東西沒接。那是給人看的三分法,也是對的。問題出在被當成檢查用:助理的內建檢查項
# 照結束碼判成敗,非零就是那一筆失敗、失敗次數加一。
# 於是有先天限制的那一支 CLI 每一輪都讓那一筆失敗一次,一天 96 次,而沒有人修得動——
# 那支 CLI 擋不下技能叫用是它的架構,不是接線缺漏,16 個接線項目全部就位。
# 那個計數存在的理由是指出「有一筆壞掉的項目每輪重試而沒人知道」,被這樣填滿就等於用
# 一個修不動的數字把真的壞掉蓋掉。
# 修在這裡而不是修在讀的那一邊:狀態與成敗是兩種語意,混在同一個通道上才是根因。這個
# 旗標把成敗那一種單獨拉出來,`status` 保持原樣給人看。
VERDICT_ONLY=0
while [ "$#" -gt 0 ]; do
case "$1" in
--verdict) VERDICT_ONLY=1; shift ;;
*) usage ;;
esac
done
case "$action:$VERDICT_ONLY" in
status:1|*:0) ;;
*) printf '[jsc] --verdict 只有 status 用得到,%s 不收這個旗標。\n' "$action" >&2; exit 2 ;;
esac
# 唯讀契約在程式層把關,不靠呼叫端記得只打 status。子命令解析完就判:預設動作是接線,
# 所以少打一個子命令就會直接改環境,這個判定要擋的正是那一次手滑。
@@ -247,6 +282,19 @@ codex_derive_hooks() { # $1=來源 hooks.json
# 條目形態照這台機器上既有的那一筆第三方設定(type、bash、timeoutSec),不套 Claude 的形狀。
# 指令前綴帶 JSC_CLI=copilot:閘門靠 cli_name() 認代號才取得到技能名與阻擋形態,沒設就一律
# 安靜放行,matcher 對、位置對、卻一次都擋不下來。別名那條路只在走別名啟動時才成立,不能靠它。
# 列出一份檔案或一段設定裡引用到的 hook 腳本名,一行一個、去重。
#
# 這一支存在的理由是「接線寫了哪幾支」與「盤點驗了哪幾支」本來是兩份手寫清單。
# 2026-09-07 實測踩到:新加第十支 hook 之後,claude 與 kiro 的盤點點名得出它,
# codex、copilot、antigravity 三段的盤點卻各自只驗自己寫死的那兩支——那三支的接線
# 盤點看不出第十支在不在,而 smoke 那一行還自稱「十支 hook」。兩句話都是真的,
# 範圍不同,讀的人分不出來。
# 改成從接線那一邊實際會寫出去的內容抽名字,兩邊就只有一份清單:往後加一支 hook,
# 盤點自動跟著驗,不必記得回來改第二個地方。
hook_scripts_of() { # 讀標準輸入,印出 {名稱}.sh
grep -oE '[a-z][a-z0-9-]*\.sh' 2>/dev/null | sort -u
}
copilot_hook_entries() {
cat <<JSCEOF
{
@@ -509,7 +557,8 @@ kiro_agent_json() {
],
"hooks": {
"agentSpawn": [
{ "command": "JSC_CLI=kiro sh \\"$WIRE_HOOKS/session-timer.sh\\" restart </dev/null", "timeout_ms": 10000 }
{ "command": "JSC_CLI=kiro sh \\"$WIRE_HOOKS/session-timer.sh\\" restart </dev/null", "timeout_ms": 10000 },
{ "command": "JSC_CLI=kiro sh \\"$WIRE_HOOKS/session-reminder.sh\\" </dev/null", "timeout_ms": 10000 }
],
"userPromptSubmit": [
{ "command": "JSC_CLI=kiro sh \\"$WIRE_HOOKS/restart-gate.sh\\"", "timeout_ms": 10000 },
@@ -1352,7 +1401,7 @@ if [ "$action" = smoke ]; then
smoke_n_sn=0; smoke_n_dn=0; smoke_n_cx=0; smoke_n_sh=0
# 預期條數(改動判定路徑時一起改):每一類都要有自己的計數器,印得出結果行卻沒人計數的
# 那一類會讓總數永遠對不上,斷言也就形同虛設。
# hook 模式 九支 hook 的每個接線模式各一條。sdlc-gate.sh、comment-scope.sh、
# hook 模式 十支 hook 的每個接線模式各一條。sdlc-gate.sh、comment-scope.sh、
# lang-guard.sh 與 write-guard.sh 各有多個模式,所以比 hook 支數多
# 模型與階段鎖 sdlc-gate.sh 取模型代號的四條來源判定路徑,加上「不知道能力就擋下」的
# 三種情形、check 的 fail-closed,以及階段鎖狀態檔的 CLI 區隔與舊格式相容
@@ -1364,10 +1413,10 @@ if [ "$action" = smoke ]; then
# 阻擋形態 deny.sh 四種形態(stderr 加 2、stdout deny JSON、kiro 注入、未知代號的保守預設)
# 跨 CLI 貫通 restart-gate.sh 吃五支 CLI 的真實負載,驗判定與輸出形態串得起來
# 接線形狀 每支 CLI 要寫出去的內容真的產出來一次,驗結構本身(正反案例各一組)
SMOKE_EXPECT_HOOK=17
SMOKE_EXPECT_HOOK=18
SMOKE_EXPECT_MODEL=12
SMOKE_EXPECT_WP=6
SMOKE_EXPECT_RS=16
SMOKE_EXPECT_RS=21
SMOKE_EXPECT_WG=21
SMOKE_EXPECT_VG=13
SMOKE_EXPECT_SN=10
@@ -1415,6 +1464,7 @@ if [ "$action" = smoke ]; then
}
smoke_one session-timer.sh mark
smoke_one session-reminder.sh
smoke_one sdlc-gate.sh check
smoke_one version-guard.sh
smoke_one restart-gate.sh
@@ -1670,6 +1720,19 @@ if [ "$action" = smoke ]; then
"$(printf '%s' "$_out" | tr '\n' ' ' | cut -c1-200)" >> "$smoke_out"
fi
}
# 狀態檔裡有沒有記到那個欄位也要比。清除的判準整個掛在 session 與 pid 兩個欄位上:require
# 少寫了它們,清除那一邊會退回「認不出來就清」的相容路徑,也就是改版前那個會被子行程清掉的
# 行為——而每一條行為斷言照樣全綠,因為那條相容路徑本來就該清。
smoke_rs_key() { # $1=情境 $2=狀態檔 $3=鍵名
smoke_n_rs=$((smoke_n_rs + 1))
if [ -n "$(sed -n "s/^$3=//p" "$2" 2>/dev/null | head -n1)" ]; then
printf '[jsc] restart-gate.sh(%s):%s 有值,與預期相同。\n' "$1" "$3" >> "$smoke_out"
else
smoke_fails=$((smoke_fails + 1))
printf '[jsc] restart-gate.sh(%s):%s 沒有值,清除判定會退回相容路徑而被子行程清掉:%s\n' \
"$1" "$3" "$2" >> "$smoke_out"
fi
}
# 狀態檔在不在也要比:一支 CLI 一份的重點就在「該留的留、該刪的刪」,只看結束碼看不出來。
smoke_rs_file() { # $1=情境 $2=狀態檔 $3=exist 或 absent
smoke_n_rs=$((smoke_n_rs + 1))
@@ -1692,6 +1755,7 @@ if [ "$action" = smoke ]; then
JSC_HOME="$rs_home" JSC_CLI="$cli" \
sh "$HOOKS/restart-gate.sh" require update hooks cli </dev/null 2>/dev/null
smoke_rs_file "require 寫出當前 CLI 那一份" "$rs_dir/$cli" exist
smoke_rs_key "require 記下掛上閘門時的工作階段" "$rs_dir/$cli" session
smoke_rs_case "當前 CLI 那份存在,技能 jsc-sdlc:implement" jsc-sdlc:implement deny
smoke_rs_case "當前 CLI 那份存在,豁免技能 jsc-cli:deploy" jsc-cli:deploy 0
smoke_rs_case "當前 CLI 那份存在,豁免技能 jsc-gitea:wiki" jsc-gitea:wiki 0
@@ -1699,13 +1763,39 @@ if [ "$action" = smoke ]; then
smoke_rs_case "當前 CLI 那份存在,豁免技能 jsc-meta:skill-check" jsc-meta:skill-check 0
smoke_rs_case "逃生門 JSC_RESTART_GATE=off" jsc-sdlc:implement 0 off
smoke_rs_case "取不到技能名" "" 0
# 清除機制:session-timer.sh 判定為新工作階段時會呼叫 restart-gate.sh clear。
# 這裡走的就是那條路徑(暫時 $JSC_HOME 底下沒有起始檔,等同行程新起的第一次)。
# 清除機制:session-timer.sh 在工作階段開始時呼叫 restart-gate.sh clear,清不清由那一邊
# 看行程存活決定。三條路徑各驗一次,理由見 hooks/restart-gate.sh 的「清除時機」。
#
# 一、還沒重啟的工作階段生出來的子行程:工作階段代號沒見過,但 require 記到的那個行程還
# 活著(就是現在跑冒煙的這一個)。閘門必須留著。這一條是實測抓到的洞:改版前它會被清掉,
# 於是部署收尾那句「請重新啟動」沒人再說得出口,而人一次都沒重啟。
JSC_HOME="$rs_home" JSC_CLI="$cli" JSC_SESSION_ID=smoke-child \
sh "$HOOKS/session-timer.sh" start </dev/null 2>/dev/null
smoke_rs_file "還沒重啟,子行程清不掉自己那一份" "$rs_dir/$cli" exist
smoke_rs_case "子行程清不掉,照樣擋下" jsc-sdlc:implement deny
# 二、記到的行程真的走了。拿一個剛結束並回收過的行程代號來寫,比寫死一個「應該不存在」的
# 號碼可靠:那種號碼哪天被別的行程佔走,這條斷言就會反過來變成偽陽性。
( exit 0 ) & rs_dead=$!
wait "$rs_dead" 2>/dev/null || true
printf 'at=%s\nmode=update\ndomains=hooks cli\ncli=%s\nsession=smoke-gone\npid=%s\n' \
"$(now_iso)" "$cli" "$rs_dead" > "$rs_dir/$cli" 2>/dev/null
JSC_HOME="$rs_home" JSC_CLI="$cli" JSC_SESSION_ID=smoke-restart \
sh "$HOOKS/session-timer.sh" start </dev/null 2>/dev/null
smoke_rs_file "新工作階段開始後清掉自己那一份" "$rs_dir/$cli" absent
smoke_rs_file "記到的行程走了就清掉自己那一份" "$rs_dir/$cli" absent
smoke_rs_file "清除不動別支 CLI 那一份" "$rs_dir/$rs_other" exist
smoke_rs_case "清除後放行" jsc-sdlc:implement 0
# 三、追不到行程代號時退回結束記號(CLI 的命令名對不上代號的那幾支走這條)。要求「代號換了」
# 而且「舊的那個工作階段寫出過結束記號」兩件事同時成立,所以先驗少了結束記號會留著。
mkdir -p "$rs_home/sessions" 2>/dev/null || true
printf 'at=%s\nmode=update\ndomains=hooks cli\ncli=%s\nsession=smoke-noend\npid=\n' \
"$(now_iso)" "$cli" > "$rs_dir/$cli" 2>/dev/null
JSC_HOME="$rs_home" JSC_CLI="$cli" JSC_SESSION_ID=smoke-noend-child \
sh "$HOOKS/session-timer.sh" start </dev/null 2>/dev/null
smoke_rs_file "追不到行程代號又沒有結束記號,留著" "$rs_dir/$cli" exist
now_epoch > "$rs_home/sessions/smoke-noend.end" 2>/dev/null
JSC_HOME="$rs_home" JSC_CLI="$cli" JSC_SESSION_ID=smoke-noend-next \
sh "$HOOKS/session-timer.sh" start </dev/null 2>/dev/null
smoke_rs_file "追不到行程代號但有結束記號,清掉" "$rs_dir/$cli" absent
# 舊格式的單一狀態檔(過渡相容):沒有 per-CLI 資訊,所以一律擋,clear 一併刪掉。
printf 'at=%s\nmode=update\ndomains=hooks\ncli=%s\n' "$(now_iso)" "$rs_other" \
> "$rs_home/restart-required" 2>/dev/null
@@ -2205,7 +2295,7 @@ if [ "$action" = smoke ]; then
fi
if [ "$smoke_fails" -eq 0 ]; then
printf 'status=ok reason=%s\n' "九支 hook 的每個接線模式都跑得完,模型與階段鎖、工作包歸屬、部署後重啟閘門、寫入提交閘門、相依版本檢查、五支 CLI 的技能名解析、四種阻擋形態、跨 CLI 貫通與各 CLI 的接線形狀的每條路徑也各走過一次($smoke_breakdown),沒有執行期錯誤"
printf 'status=ok reason=%s\n' "十支 hook 的每個接線模式都跑得完,模型與階段鎖、工作包歸屬、部署後重啟閘門、寫入提交閘門、相依版本檢查、五支 CLI 的技能名解析、四種阻擋形態、跨 CLI 貫通與各 CLI 的接線形狀的每條路徑也各走過一次($smoke_breakdown),沒有執行期錯誤"
printf 'lines\t%s\n' "$smoke_lines"
cat "$smoke_out"; rm -f "$smoke_out"; exit 0
fi
@@ -2225,8 +2315,8 @@ if [ "$action" = status ]; then
st_missing=0
st_degrade=""
# 接上時的 reason 也要一支 CLI 一句:五支的覆蓋範圍不一樣,共用一句話就會把「codex 接了兩道閘門」
# 講成「codex 九支全接」。預設值給 claude,其餘各自在下面覆寫。
st_wired="hooks.json 自動接線全部九支 hook"
# 講成「codex 十支全接」。預設值給 claude,其餘各自在下面覆寫。
st_wired="hooks.json 自動接線全部十支 hook"
# 記一個檢查點。$1=項目名 $2=路徑 $3=present|missing|unverified
#
@@ -2301,10 +2391,13 @@ if [ "$action" = status ]; then
claude_hooks="$claude_root/hooks/hooks.json"
if [ -n "$claude_root" ] && [ -f "$claude_hooks" ]; then st_item hooks.json "$claude_hooks" present
else st_item hooks.json "${claude_hooks:-$HOME/.claude/plugins/installed_plugins.json}" missing; fi
# 九支 hook 全靠這一個檔宣告,只看檔案在不在會漏掉「檔在、某支沒接進去」。
# 每一支都列一項,一支都不省。reason 那行講的是「全部九支」,列舉卻只挑幾支的話,
# 十支 hook 全靠這一個檔宣告,只看檔案在不在會漏掉「檔在、某支沒接進去」。
# 每一支都列一項,一支都不省。reason 那行講的是「全部十支」,列舉卻只挑幾支的話,
# 拿這份輸出驗收接線的人會把沒列到的當成沒接——模型能力鎖就是這樣被誤判成沒接線的。
for _h in comment-scope lang-guard restart-gate skill-usage version-guard; do
# 別支 CLI 的那幾段同樣逐支點名,但名單各自取自「那一支的接線實際會寫出去的內容」,
# 因為每一支能接到的範圍不同:codex 拿的是從這一份推導出來的複本(十支全帶),
# copilot 與 antigravity 只接得上兩道閘門,其餘幾支在那兩支 CLI 上是沒有事件可掛。
for _h in comment-scope lang-guard restart-gate skill-usage session-reminder version-guard; do
if [ -f "$claude_hooks" ] && grep -qF "$_h.sh" "$claude_hooks" 2>/dev/null
then st_item "$_h" "$claude_hooks" present
else st_item "$_h" "$claude_hooks" missing; fi
@@ -2381,7 +2474,9 @@ if [ "$action" = status ]; then
if [ -f "$cx_hooks" ] && grep -qF '"matcher": "Skill"' "$cx_hooks" 2>/dev/null
then st_item codex-no-skill-matcher "$cx_hooks" missing
else st_item codex-no-skill-matcher "$cx_hooks" present; fi
for _g in restart-gate.sh version-guard.sh; do
# 逐支對照來源那一份。codex 讀的是從 hooks/hooks.json 推導出來的複本,所以「該有哪幾支」
# 的答案在來源檔裡,不在這裡寫死——推導漏掉一支的時候,這一項才說得出是漏了哪一支。
for _g in $(hook_scripts_of <"$HOOKS/hooks.json"); do
if [ -f "$cx_hooks" ] && grep -qF "$_g" "$cx_hooks" 2>/dev/null
then st_item "codex-${_g%.sh}" "$cx_hooks" present
else st_item "codex-${_g%.sh}" "$cx_hooks" missing; fi
@@ -2409,6 +2504,13 @@ if [ "$action" = status ]; then
if copilot_single_case_ok "$cp_settings"
then st_item single-event-case "$cp_settings" present
else st_item single-event-case "$cp_settings" missing; fi
# 逐支對照接線那一段自己會寫出去的條目。原本這一段一支 hook 都沒點名,只驗「hooks 鍵在」
# ——鍵在而某一支條目掉了,照樣回 present,那正是這一整支腳本一直在防的形態。
for _g in $(copilot_hook_entries | hook_scripts_of); do
if [ -f "$cp_settings" ] && grep -qF "$_g" "$cp_settings" 2>/dev/null
then st_item "${_g%.sh}" "$cp_settings" present
else st_item "${_g%.sh}" "$cp_settings" missing; fi
done
# CLI 代號也是接線的一部分,理由同 codex 那一項:取不到代號的閘門一律安靜放行。
if [ -f "$cp_settings" ] && grep -qF 'JSC_CLI=copilot' "$cp_settings" 2>/dev/null
then st_item cli-code "$cp_settings" present
@@ -2446,7 +2548,8 @@ if [ "$action" = status ]; then
if antigravity_flat_ok "$ag_hooks"
then st_item preinvocation-flat "$ag_hooks" present
else st_item preinvocation-flat "$ag_hooks" missing; fi
for _g in restart-gate.sh version-guard.sh; do
# 逐支對照接線那一段自己會寫出去的內容,不在這裡另寫一份清單。
for _g in $(antigravity_hooks_block | hook_scripts_of); do
if [ -f "$ag_hooks" ] && grep -qF "$_g" "$ag_hooks" 2>/dev/null
then st_item "${_g%.sh}" "$ag_hooks" present
else st_item "${_g%.sh}" "$ag_hooks" missing; fi
@@ -2494,7 +2597,7 @@ if [ "$action" = status ]; then
then st_item "event-$_e" "$kr_agent" present
else st_item "event-$_e" "$kr_agent" missing; fi
done
for _g in session-timer.sh restart-gate.sh version-guard.sh ste100-guard.sh comment-scope.sh lang-guard.sh; do
for _g in session-timer.sh session-reminder.sh restart-gate.sh version-guard.sh ste100-guard.sh comment-scope.sh lang-guard.sh; do
if [ -f "$kr_agent" ] && grep -qF "$_g" "$kr_agent" 2>/dev/null
then st_item "${_g%.sh}" "$kr_agent" present
else st_item "${_g%.sh}" "$kr_agent" missing; fi
@@ -2513,7 +2616,10 @@ if [ "$action" = status ]; then
fi
if [ -n "$st_degrade" ]; then
printf 'status=degraded reason=%s\n' "$st_degrade"
cat "$st_items"; rm -f "$st_items"; exit 1
cat "$st_items"; rm -f "$st_items"
# --verdict 之下先天限制不算失敗:輸出照印,讓人看得到,但結束碼說「該接的都接了」。
[ "$VERDICT_ONLY" -eq 1 ] && exit 0
exit 1
fi
printf 'status=wired reason=%s\n' "$st_wired"
cat "$st_items"; rm -f "$st_items"; exit 0
@@ -2539,7 +2645,7 @@ case "$cli" in
done
printf 'status=wired reason=%s\n' "hooks.json 自動接線"
echo "$WIRE_PATH_NOTE"
echo "[jsc] claude:由 hooks/hooks.json 自動接線全部九支 hook,無需寫入設定。"
echo "[jsc] claude:由 hooks/hooks.json 自動接線全部十支 hook,無需寫入設定。"
echo "[jsc] claude:只有 claude 有 pre-tool hook,版本前置檢查、部署後重啟閘門與 write-guard.sh 的三種模式只在這裡擋得下來;其他四個 CLI 這幾道閘門都接不上。"
echo "[jsc] claude:只有 claude 有 post-tool hook,comment-scope.sh 與 lang-guard.sh 的逐檔即時掃描只在這裡接得上;其他四個 CLI 改用 sweep 掃整個工作區,時機晚一輪或晚到工作階段結束。"
exit 0 ;;
@@ -2594,9 +2700,11 @@ case "$cli" in
|| fail "$cx_hooks 沒有 Bash matcher,codex 沒有 Skill 工具,擋不到技能載入那一次"
grep -qF '"matcher": "Skill"' "$cx_hooks" 2>/dev/null \
&& fail "$cx_hooks 還留著 Skill matcher,codex 沒有那個工具,那一組永遠不會被叫用"
for _g in restart-gate.sh version-guard.sh; do
# 逐支對照來源那一份,不只驗那兩道閘門:推導是整份複製再改 matcher,漏掉任何一支都算
# 推導壞了,而漏掉的那一支在 codex 上就是永遠不會被叫用。
for _g in $(hook_scripts_of <"$HOOKS/hooks.json"); do
grep -qF "$_g" "$cx_hooks" 2>/dev/null \
|| fail "$cx_hooks 沒有接上 $_g,那道閘門在 codex 上不會生效"
|| fail "$cx_hooks 沒有接上 $_g,那一支在 codex 上不會生效(來源是 $HOOKS/hooks.json,推導漏了它)"
done
# 代號單獨驗一項:少了它,兩道閘門認不出現在跑的是哪一支 CLI,技能名解不出來就整批安靜放行。
grep -qF 'JSC_CLI=codex' "$cx_hooks" 2>/dev/null \
@@ -2762,7 +2870,7 @@ case "$cli" in
# 兩層 glob 是技能看不看得到的關鍵:預設只掃一層,jsc 的技能在第二層。
grep -qF '/skills/*/*/SKILL.md' "$kr_agent" 2>/dev/null \
|| fail "$kr_agent 的 resources 少了兩層 glob,jsc 技能一支都載不到"
for _s in session-timer.sh restart-gate.sh version-guard.sh ste100-guard.sh comment-scope.sh lang-guard.sh; do
for _s in session-timer.sh session-reminder.sh restart-gate.sh version-guard.sh ste100-guard.sh comment-scope.sh lang-guard.sh; do
grep -qF "$_s" "$kr_agent" 2>/dev/null || fail "$kr_agent 的 hooks 沒有接到 $_s"
done
# 這個 agent 要被選用才算數,所以還要設 chat.defaultAgent。內建 agent 改不了,