diff --git a/README.md b/README.md index e1975d3..4bf4bd4 100644 --- a/README.md +++ b/README.md @@ -171,6 +171,7 @@ Claude 由 `hooks/hooks.json` 自動接線九支 hook;其他 CLI 用 `hooks-in | `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`)標記整段重寫,重跑等同先移除再重裝;寫完每個檔案會重讀驗證位置正確才回報成功(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{數量}`,那是其後 `[jsc]` 結果行的實際條數,與腳本內逐類宣告的預期條數比對,不符就回非零。判定路徑增減時只改腳本裡的預期值,散文一律引用這一行,不另外抄一份數字。`status {cli}` 是唯讀盤點:只讀設定檔判斷段落與 matcher 對不對,不寫檔也不執行 hook,claude、codex、copilot、antigravity 回 `wired`,kiro 回 `degraded` 並在 `reason` 講明那是 CLI 限制;每個接線點印一行 `item{項目}{路徑}{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` 回報 | ## 失敗回報範本 diff --git a/hooks/assistant-gate.sh b/hooks/assistant-gate.sh index eddb6b1..d6cec18 100755 --- a/hooks/assistant-gate.sh +++ b/hooks/assistant-gate.sh @@ -120,6 +120,7 @@ if [ "${JSC_ASSISTANT_GATE:-}" = "off" ]; then fi HERE=$(dirname "$0"); . "$HERE/lib.sh" +hook_trace "assistant-gate ${1:-}" read_stdin @@ -200,5 +201,8 @@ esac printf '%s\n' "$second" printf '仍可使用:/jsc-assist:*、/jsc-hooks:repair、/jsc-hooks:hooks-install、/jsc-cli:doctor、/jsc-cli:setup、/jsc-cli:deploy、/jsc-cli:models、/jsc-gitea:wiki、/jsc-ask:ask、/jsc-git:commit、/jsc-git:pr(啟動助理與修環境這兩條路徑要永遠走得通,包括它們轉呼叫的下一層)\n' printf '確定要略過閘門:JSC_ASSISTANT_GATE=off\n' +# 這條路徑是「已經擋下」,但輸出形態依 CLI 而定:antigravity 走 stdout 的 deny JSON、 +# kiro 只印警告,兩者的結束碼都是 0。不覆寫狀態的話,事件流會把擋下記成放行。 +JSC_EVENT_STATUS=blocked } | sh "$HERE/deny.sh" "$(cli_name)" exit $? diff --git a/hooks/comment-scope.sh b/hooks/comment-scope.sh index e67f49d..6963885 100755 --- a/hooks/comment-scope.sh +++ b/hooks/comment-scope.sh @@ -25,6 +25,7 @@ set -u . "$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)/lib.sh" 2>/dev/null || true +command -v hook_trace >/dev/null 2>&1 && hook_trace "comment-scope ${1:-}" [ "${JSC_COMMENT_SCOPE:-on}" = "off" ] && exit 0 diff --git a/hooks/heartbeat.sh b/hooks/heartbeat.sh index beac7a2..8dea014 100755 --- a/hooks/heartbeat.sh +++ b/hooks/heartbeat.sh @@ -75,6 +75,7 @@ # 2。這一點的後果與 restart-gate.sh 不同:那支接在 PreToolUse 上,回 2 等於無聲擋下每一次 # 技能呼叫;這支沒接任何 hook,回 2 只會讓呼叫端收到「心跳判不出來」,擋不到任何人。 HERE=$(dirname "$0"); . "$HERE/lib.sh" +hook_trace "heartbeat ${1:-}" # 這支永遠不讀標準輸入,但 session_id() 會去看 STDIN_JSON。先設成空字串,讓它直接走環境 # 變數那條路,不會因為變數沒定義而拿到不確定的值。 diff --git a/hooks/lang-guard.sh b/hooks/lang-guard.sh index e40e0e8..67c45c9 100755 --- a/hooks/lang-guard.sh +++ b/hooks/lang-guard.sh @@ -32,6 +32,7 @@ set -u . "$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)/lib.sh" 2>/dev/null || true +command -v hook_trace >/dev/null 2>&1 && hook_trace "lang-guard ${1:-}" [ "${JSC_LANG_GUARD:-on}" = "off" ] && exit 0 diff --git a/hooks/lib.sh b/hooks/lib.sh index a18b50d..9eebf2f 100755 --- a/hooks/lib.sh +++ b/hooks/lib.sh @@ -263,3 +263,59 @@ cli_bin() { # $1=CLI 代號 now_epoch() { date +%s; } now_iso() { date -u +%Y-%m-%dT%H:%M:%SZ; } + +# --- 執行狀態事件流 --- +# +# 每支 hook 與每支技能的執行結果都寫進 $JSC_HOME/usage/events.jsonl,助理巡檢時排空。 +# 為什麼不直接寫 wiki:hook 每次提示都跑,網路寫入會拖垮宿主 CLI;而且失敗的 hook +# 自我回報會疊出迴圈,report-error.sh 因此刻意不接在失敗的 hook 上,這裡沿用同一條線。 +# +# 兩條硬規則,違反哪一條這套機制都會反過來害到被它記錄的東西: +# 一、一行一次 printf,且長度壓在 4096 位元組內。五支 CLI 併發時,單次 O_APPEND +# 寫入才不會互相插隊;拆成多次 printf 就會交錯成無法解析的行。detail 因此要截斷。 +# 二、寫入失敗一律吞掉,不得改變呼叫端的結束碼。回報機制自己壞掉,不可以讓被回報的 +# 東西跟著壞——hook 的結束碼是閘門的判準,被記錄動到就等於閘門行為被記錄改寫。 + +# JSON 字串值跳脫:只處理反斜線、雙引號與會拆行的字元。這三類不處理就會寫出解析不了的行。 +json_escape() { + printf '%s' "$1" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' | tr -d '\n\r\t' +} + +# emit_event [ms] [detail] +emit_event() { + _ek="$1"; _en="$2"; _ep="$3"; _es="$4"; _ex="$5"; _em="${6:-}"; _ed="${7:-}" + # detail 截到 200 字元:長內容是硬規則一的主要威脅,來源不可信就先砍再寫。 + [ -n "$_ed" ] && _ed=$(printf '%s' "$_ed" | cut -c1-200) + _ems="" + [ -n "$_em" ] && _ems=$(printf ',"ms":%s' "$_em") + _eds="" + [ -n "$_ed" ] && _eds=$(printf ',"detail":"%s"' "$(json_escape "$_ed")") + printf '{"ts":"%s","cli":"%s","session":"%s","kind":"%s","name":"%s","phase":"%s","status":"%s","exit":%s%s%s}\n' \ + "$(now_iso)" "$(cli_name)" "$(session_id)" "$_ek" "$(json_escape "$_en")" \ + "$_ep" "$_es" "$_ex" "$_ems" "$_eds" \ + >> "$JSC_HOME/usage/events.jsonl" 2>/dev/null || true +} + +# 結束碼推 status。各 hook 的 2 一律是「擋下」的設計行為,不是壞掉。 +hook_status_of() { + case "$1" in + 0) printf ok ;; + 2) printf blocked ;; + *) printf failed ;; + esac +} + +# hook_trace <名稱> — 裝一個 EXIT trap,腳本不論從哪一個 exit 離開都記一筆。 +# +# 為什麼用 trap 而不是逐點改:這幾支 hook 的 exit 點很多,sdlc-gate.sh 一支就有五十幾個。 +# 逐點換成「記錄再離開」要改動每一條判定路徑,而那些路徑正是閘門的判準;為了加一行紀錄 +# 去動閘門,風險遠大於收益。trap 只加一行,且涵蓋每一條離開路徑,含 set -e 的中途失敗。 +# +# 狀態預設由結束碼推。推不出來的由 hook 自己在離開前設 JSC_EVENT_STATUS 覆寫—— +# version-guard.sh 就有這種情形:antigravity 走 stdout 的 deny JSON、kiro 只印警告, +# 兩者擋下時結束碼都是 0,單看結束碼會把「已經擋下」記成「放行」。 +hook_trace() { + JSC_EVENT_NAME="$1" + JSC_EVENT_STATUS="" + trap '_rc=$?; emit_event hook "$JSC_EVENT_NAME" end "${JSC_EVENT_STATUS:-$(hook_status_of "$_rc")}" "$_rc"' EXIT +} diff --git a/hooks/restart-gate.sh b/hooks/restart-gate.sh index 4264ed5..21f1277 100755 --- a/hooks/restart-gate.sh +++ b/hooks/restart-gate.sh @@ -120,6 +120,7 @@ # # 逃生門:JSC_RESTART_GATE=off 完全略過這道閘門。 HERE=$(dirname "$0"); . "$HERE/lib.sh" +hook_trace "restart-gate ${1:-}" STATE_DIR="$JSC_HOME/restart-required.d" # 舊格式的單一狀態檔。只為過渡而讀,可移除的時機見檔頭「舊檔相容」。 @@ -249,5 +250,8 @@ info="" printf '重新啟動:結束 %s 再重新開啟一次,狀態檔 %s 會在新工作階段開始時自動清除。\n' \ "$bin" "$state" printf '仍可使用:/jsc-cli:deploy、/jsc-hooks:hooks-install、/jsc-hooks:repair、/jsc-gitea:wiki、/jsc-log:worklog、/jsc-log:learn、/jsc-meta:*、/jsc-ask:ask、/jsc-git:pr、/jsc-git:commit(部署後的異動報告與工作日誌要寫得完,hook 壞掉也要修得回來) | 確定要略過閘門:JSC_RESTART_GATE=off\n' +# 這條路徑是「已經擋下」,但輸出形態依 CLI 而定:antigravity 走 stdout 的 deny JSON、 +# kiro 只印警告,兩者的結束碼都是 0。不覆寫狀態的話,事件流會把擋下記成放行。 +JSC_EVENT_STATUS=blocked } | sh "$HERE/deny.sh" "$(cli_name)" exit $? diff --git a/hooks/sdlc-gate.sh b/hooks/sdlc-gate.sh index d8db948..8a14c50 100755 --- a/hooks/sdlc-gate.sh +++ b/hooks/sdlc-gate.sh @@ -123,6 +123,7 @@ # 刻意的例外——鎖存在且不合規時 exit 2 擋下。只用提示注入的話模型可以無視,閘門形同虛設。 # 無鎖、或資料不足無法判定時,仍照舊 exit 0 安靜降級。 HERE=$(dirname "$0"); . "$HERE/lib.sh" +hook_trace "sdlc-gate ${1:-}" # 只有需要 stdin JSON 的子命令才讀它:模型判定要 transcript_path,session 判定要 session_id。 # wp-lock、wp-unlock、wp-claim、wp-unclaim、wp-report 兩者都不需要,而 read_stdin 在標準輸入 # 是管線又沒人關閉時會一直等——工具腳本(jsc-sdlc 的 wp-gate.sh)轉呼叫這些子命令時就這樣整支 diff --git a/hooks/session-timer.sh b/hooks/session-timer.sh index 3dcf048..273ffb4 100755 --- a/hooks/session-timer.sh +++ b/hooks/session-timer.sh @@ -25,6 +25,7 @@ # 清除的範圍是「跑到這一支腳本的那個 CLI 自己那一份狀態檔」,由 restart-gate.sh clear 認定, # 這裡不必也不能過問:這個工作階段開始的只有一支 CLI,別支沒重啟,閘門要留著。 HERE=$(dirname "$0"); . "$HERE/lib.sh" +hook_trace "session-timer ${1:-}" read_stdin sid=$(session_id) diff --git a/hooks/skill-usage.sh b/hooks/skill-usage.sh index 91cc26d..5344873 100755 --- a/hooks/skill-usage.sh +++ b/hooks/skill-usage.sh @@ -12,6 +12,7 @@ # 唯一的非零來源同 session-timer.sh:本檔以 `. "$HERE/lib.sh"` 載入,沒有接 `|| true`, # lib.sh 讀不到時 sh 會就地結束並回 2。 HERE=$(dirname "$0"); . "$HERE/lib.sh" +hook_trace "skill-usage ${1:-}" read_stdin skill="${JSC_SKILL:-$(json_str skill)}" [ -n "$skill" ] || exit 0 @@ -25,4 +26,10 @@ if [ -n "$last" ]; then "$ts" "$cli" "$sid" "$last" "$skill" >> "$JSC_HOME/usage/chains.jsonl" fi printf '%s' "$skill" > "$last_f" + +# 技能的 start 事件在這裡發,不必改任何 SKILL.md:這支接在技能指示載入之後,那一刻 +# 就是「技能開始跑」。end 只能由技能自己在收尾步驟寫——本 hook 觸發時,技能的實際工作 +# 還在後面的模型輪次,看不到成敗。有 start 沒有配對的 end,就是那一輪中止了。 +emit_event skill "$skill" start ok 0 + exit 0 diff --git a/hooks/version-guard.sh b/hooks/version-guard.sh index 54896d4..23a68bd 100755 --- a/hooks/version-guard.sh +++ b/hooks/version-guard.sh @@ -101,6 +101,7 @@ # 再呼叫一次 report,這裡刻意不混印,免得 cut 取值被表格內容打亂。 # recommend 只讀不擋,永遠 exit 0:判定結果只看那一行的第二欄。 HERE=$(dirname "$0"); . "$HERE/lib.sh" +hook_trace "version-guard ${1:-}" REG="$HOME/.claude/plugins/installed_plugins.json" MK="$HOME/.claude/plugins/known_marketplaces.json" @@ -354,6 +355,9 @@ deny() { # $1=訊息 { printf '[jsc][版本檢查][ERR]:%s\n' "$1" printf '更新指令:%s\n' "$(update_cmd "$domain")" printf '更新整組:/jsc-cli:deploy | 確定要略過檢查:JSC_VERSION_GUARD=off\n' +# 這條路徑是「已經擋下」,但輸出形態依 CLI 而定:antigravity 走 stdout 的 deny JSON、 +# kiro 只印警告,兩者的結束碼都是 0。不覆寫狀態的話,事件流會把擋下記成放行。 +JSC_EVENT_STATUS=blocked } | sh "$HERE/deny.sh" "$(cli_name)" exit $? } diff --git a/hooks/write-guard.sh b/hooks/write-guard.sh index 8615090..b6345a0 100755 --- a/hooks/write-guard.sh +++ b/hooks/write-guard.sh @@ -75,6 +75,7 @@ HERE=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) # 的 exit 2——而 PreToolUse 的 exit 2 正是「擋下」,等於每一次寫檔與提交都被無聲擋死。 [ -r "$HERE/lib.sh" ] || exit 0 . "$HERE/lib.sh" +hook_trace "write-guard ${1:-}" # lib.sh 沒載到時這個變數就沒人設,下面兩個模式都要用它組狀態檔路徑,補一份同樣的預設值。 JSC_HOME="${JSC_HOME:-$HOME/.jsc}" diff --git a/references/behaviors.md b/references/behaviors.md index 2b64c64..47a3abc 100644 --- a/references/behaviors.md +++ b/references/behaviors.md @@ -10,7 +10,7 @@ | 關鍵步驟 | 取得 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 | +| 可驗證跡象 | 各 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 的結束碼 | ## repair diff --git a/tools/report-status.sh b/tools/report-status.sh new file mode 100755 index 0000000..9c412e4 --- /dev/null +++ b/tools/report-status.sh @@ -0,0 +1,130 @@ +#!/usr/bin/env sh +# report-status.sh — 技能與 hook 的執行狀態事件流。 +# +# 為什麼要有這支:現行 usage/skills.jsonl 只記「被叫用」,欄位是 {ts,cli,session,skill}, +# 沒有成敗、沒有結束碼。跑完整輪的技能與開場就中止的技能,在紀錄裡長得一模一樣。 +# hook 成功時更是完全不留紀錄,只有錯誤路徑會寫 wiki,而那條路徑刻意不自動觸發。 +# +# 為什麼不直接寫 wiki:hook 每次提示都跑,網路寫入會拖垮宿主 CLI;失敗的 hook 自我回報 +# 還會疊出迴圈。所以一律先寫本機事件流,助理巡檢時排空、彙整、寫 MONITOR 頁。 +# +# 用法: +# report-status.sh skill-start <名稱> +# report-status.sh skill-end <名稱> [結束碼] [detail] +# report-status.sh hook-end <名稱> <結束碼> [detail] +# report-status.sh drain # 印出上次排空之後的新事件 +# report-status.sh rotate # 超過上限就輪替,只留一份舊的 +# +# <名稱>: 技能寫 {domain}:{skill},hook 寫 {腳本檔名} 加子命令,例如 sdlc-gate check。 +# : ok、blocked、failed、degraded、aborted 五選一。 +# ok 完成條件全部達成 +# blocked 被閘門或前置條件擋下,沒有做事 +# failed 做到一半失敗 +# degraded 做完了但有部分沒達成 +# aborted 使用者中止,或前提不成立而主動停止 +# +# 規則: +# - 三個記錄子命令一律回 0,寫檔失敗也是 0。回報機制自己壞掉,不可以讓被回報的東西 +# 跟著壞——hook 的結束碼是閘門的判準,被記錄動到就等於閘門行為被記錄改寫。 +# 參數檢查是例外:那是呼叫端的程式錯誤,寫進去只會汙染事件流,所以先擋下來。 +# - 本檔不讀 stdin。技能由 Bash 呼叫它,stdin 可能是還沒關閉的管線,讀下去會卡住宿主。 +# 所有資訊一律走參數。 +# - 輪替不放在每次寫入。每次提示都寫事件,順手 stat 一次檔案就是每次提示多一次系統呼叫; +# 改由巡檢排空之後呼叫 rotate,成本落在本來就週期性執行的地方。 +# +# 結束碼: 0=成功(記錄子命令一律 0) 2=用法錯誤 3=drain 沒有新事件 +set -eu + +HERE=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +# lib.sh 在同一個存取庫,不是跨 plugin 依賴。hook 必須自足,事件寫入的函式因此留在 lib.sh, +# 這支只是把它包成命令列介面給技能用。 +STDIN_JSON="" +. "$HERE/../hooks/lib.sh" + +EVENTS="$JSC_HOME/usage/events.jsonl" +OFFSET="$JSC_HOME/usage/scan-state/events.offset" +MAX_BYTES=5242880 + +usage() { + cat >&2 <<'EOF' +用法: + report-status.sh skill-start <名稱> + report-status.sh skill-end <名稱> [結束碼] [detail] + report-status.sh hook-end <名稱> <結束碼> [detail] + report-status.sh drain + report-status.sh rotate + +status: ok、blocked、failed、degraded、aborted +結束碼: 0=成功 2=用法錯誤 3=drain 沒有新事件 +EOF + exit 2 +} + +valid_status() { + case "$1" in + ok|blocked|failed|degraded|aborted) ;; + *) echo "[jsc][狀態回報][ERR]:status 須為 ok、blocked、failed、degraded、aborted 五選一,收到「$1」。" >&2; exit 2 ;; + esac +} + +valid_exit() { + case "$1" in + ''|*[!0-9]*) echo "[jsc][狀態回報][ERR]:結束碼須為非負整數,收到「$1」。" >&2; exit 2 ;; + esac +} + +cmd="${1:-}"; [ -n "$cmd" ] || usage +shift || true + +case "$cmd" in + skill-start) + name="${1:-}"; [ -n "$name" ] || usage + # start 沒有成敗可言,狀態欄固定 ok、結束碼固定 0。判讀靠的是「有沒有配對的 end」: + # 有 start 沒 end 就是中止,那正是現行紀錄分不出來的那一種。 + emit_event skill "$name" start ok 0 + ;; + skill-end) + name="${1:-}"; status="${2:-}" + [ -n "$name" ] && [ -n "$status" ] || usage + valid_status "$status" + code="${3:-0}"; valid_exit "$code" + emit_event skill "$name" end "$status" "$code" "" "${4:-}" + ;; + hook-end) + name="${1:-}"; status="${2:-}"; code="${3:-}" + [ -n "$name" ] && [ -n "$status" ] && [ -n "$code" ] || usage + valid_status "$status"; valid_exit "$code" + emit_event hook "$name" end "$status" "$code" "" "${4:-}" + ;; + drain) + [ -f "$EVENTS" ] || exit 3 + size=$(wc -c < "$EVENTS" 2>/dev/null || echo 0) + old=0 + [ -f "$OFFSET" ] && old=$(cat "$OFFSET" 2>/dev/null || echo 0) + case "$old" in ''|*[!0-9]*) old=0 ;; esac + # 檔案比已存位移還小就是輪替過,從頭讀。不比對 inode:五支 CLI 與容器裡的行程 + # 看到的 inode 不保證一致,用大小判斷才在每個環境都成立。 + [ "$size" -lt "$old" ] && old=0 + [ "$size" -eq "$old" ] && exit 3 + mkdir -p "$(dirname "$OFFSET")" 2>/dev/null || true + # tail -c +N 從第 N 個位元組起(1 起算),所以位移要加一。 + # 不用 dd bs=1 skip=:那是一個位元組一次系統呼叫,位移到了幾 MB 就是幾百萬次, + # 每輪巡檢都排空一次的話會慢到不能用。 + tail -c "+$((old + 1))" "$EVENTS" 2>/dev/null || true + printf '%s' "$size" > "$OFFSET" 2>/dev/null || true + ;; + rotate) + [ -f "$EVENTS" ] || exit 0 + size=$(wc -c < "$EVENTS" 2>/dev/null || echo 0) + if [ "$size" -gt "$MAX_BYTES" ]; then + mv "$EVENTS" "$EVENTS.1" 2>/dev/null || true + : > "$EVENTS" 2>/dev/null || true + # 位移歸零:新檔從頭算起,不歸零的話下一次 drain 會跳過開頭那一段。 + printf '0' > "$OFFSET" 2>/dev/null || true + printf '已輪替:%s -> %s.1(原大小 %s 位元組)\n' "$EVENTS" "$EVENTS" "$size" + fi + ;; + *) usage ;; +esac + +exit 0