feat(狀態回報): 技能與 hook 的執行結果寫進本機事件流

現行紀錄只記「被叫用」,欄位是 ts、cli、session、skill,沒有成敗也沒有
結束碼。跑完整輪的技能與開場就中止的技能,在紀錄裡長得一模一樣。hook
成功時更是完全不留紀錄,只有錯誤路徑會寫 wiki,而那條路徑刻意不自動觸發。

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

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

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

技能的 start 由既有的技能用量 hook 順手發,不必改任何技能文件。end 只能由
技能自己在收尾步驟寫——hook 觸發時技能的實際工作還在後面的模型輪次,看不到
成敗。有 start 沒有配對的 end,就是那一輪中止了。
This commit is contained in:
2026-09-02 15:40:17 +08:00
parent 97c56212df
commit a3ef205489
14 changed files with 213 additions and 1 deletions
+4
View File
@@ -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 $?
+1
View File
@@ -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
+1
View File
@@ -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。先設成空字串,讓它直接走環境
# 變數那條路,不會因為變數沒定義而拿到不確定的值。
+1
View File
@@ -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
+56
View File
@@ -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 <kind> <name> <phase> <status> <exit> [ms] [detail]
emit_event() {
_ek="$1"; _en="$2"; _ep="$3"; _es="$4"; _ex="$5"; _em="${6:-}"; _ed="${7:-}"
# detail 截到 200 字元:長內容是硬規則一的主要威脅,來源不可信就先砍再寫。
[ -n "$_ed" ] && _ed=$(printf '%s' "$_ed" | cut -c1-200)
_ems=""
[ -n "$_em" ] && _ems=$(printf ',"ms":%s' "$_em")
_eds=""
[ -n "$_ed" ] && _eds=$(printf ',"detail":"%s"' "$(json_escape "$_ed")")
printf '{"ts":"%s","cli":"%s","session":"%s","kind":"%s","name":"%s","phase":"%s","status":"%s","exit":%s%s%s}\n' \
"$(now_iso)" "$(cli_name)" "$(session_id)" "$_ek" "$(json_escape "$_en")" \
"$_ep" "$_es" "$_ex" "$_ems" "$_eds" \
>> "$JSC_HOME/usage/events.jsonl" 2>/dev/null || true
}
# 結束碼推 status。各 hook 的 2 一律是「擋下」的設計行為,不是壞掉。
hook_status_of() {
case "$1" in
0) printf ok ;;
2) printf blocked ;;
*) printf failed ;;
esac
}
# hook_trace <名稱> — 裝一個 EXIT trap,腳本不論從哪一個 exit 離開都記一筆。
#
# 為什麼用 trap 而不是逐點改:這幾支 hook 的 exit 點很多,sdlc-gate.sh 一支就有五十幾個。
# 逐點換成「記錄再離開」要改動每一條判定路徑,而那些路徑正是閘門的判準;為了加一行紀錄
# 去動閘門,風險遠大於收益。trap 只加一行,且涵蓋每一條離開路徑,含 set -e 的中途失敗。
#
# 狀態預設由結束碼推。推不出來的由 hook 自己在離開前設 JSC_EVENT_STATUS 覆寫——
# version-guard.sh 就有這種情形:antigravity 走 stdout 的 deny JSON、kiro 只印警告,
# 兩者擋下時結束碼都是 0,單看結束碼會把「已經擋下」記成「放行」。
hook_trace() {
JSC_EVENT_NAME="$1"
JSC_EVENT_STATUS=""
trap '_rc=$?; emit_event hook "$JSC_EVENT_NAME" end "${JSC_EVENT_STATUS:-$(hook_status_of "$_rc")}" "$_rc"' EXIT
}
+4
View File
@@ -120,6 +120,7 @@
#
# 逃生門:JSC_RESTART_GATE=off 完全略過這道閘門。
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 $?
+1
View File
@@ -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)轉呼叫這些子命令時就這樣整支
+1
View File
@@ -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)
+7
View File
@@ -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
+4
View File
@@ -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 $?
}
+1
View File
@@ -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}"