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
+130
View File
@@ -0,0 +1,130 @@
#!/usr/bin/env sh
# report-status.sh — 技能與 hook 的執行狀態事件流。
#
# 為什麼要有這支:現行 usage/skills.jsonl 只記「被叫用」,欄位是 {ts,cli,session,skill},
# 沒有成敗、沒有結束碼。跑完整輪的技能與開場就中止的技能,在紀錄裡長得一模一樣。
# hook 成功時更是完全不留紀錄,只有錯誤路徑會寫 wiki,而那條路徑刻意不自動觸發。
#
# 為什麼不直接寫 wiki:hook 每次提示都跑,網路寫入會拖垮宿主 CLI;失敗的 hook 自我回報
# 還會疊出迴圈。所以一律先寫本機事件流,助理巡檢時排空、彙整、寫 MONITOR 頁。
#
# 用法:
# report-status.sh skill-start <名稱>
# report-status.sh skill-end <名稱> <status> [結束碼] [detail]
# report-status.sh hook-end <名稱> <status> <結束碼> [detail]
# report-status.sh drain # 印出上次排空之後的新事件
# report-status.sh rotate # 超過上限就輪替,只留一份舊的
#
# <名稱>: 技能寫 {domain}:{skill},hook 寫 {腳本檔名} 加子命令,例如 sdlc-gate check。
# <status>: ok、blocked、failed、degraded、aborted 五選一。
# ok 完成條件全部達成
# blocked 被閘門或前置條件擋下,沒有做事
# failed 做到一半失敗
# degraded 做完了但有部分沒達成
# aborted 使用者中止,或前提不成立而主動停止
#
# 規則:
# - 三個記錄子命令一律回 0,寫檔失敗也是 0。回報機制自己壞掉,不可以讓被回報的東西
# 跟著壞——hook 的結束碼是閘門的判準,被記錄動到就等於閘門行為被記錄改寫。
# 參數檢查是例外:那是呼叫端的程式錯誤,寫進去只會汙染事件流,所以先擋下來。
# - 本檔不讀 stdin。技能由 Bash 呼叫它,stdin 可能是還沒關閉的管線,讀下去會卡住宿主。
# 所有資訊一律走參數。
# - 輪替不放在每次寫入。每次提示都寫事件,順手 stat 一次檔案就是每次提示多一次系統呼叫;
# 改由巡檢排空之後呼叫 rotate,成本落在本來就週期性執行的地方。
#
# 結束碼: 0=成功(記錄子命令一律 0) 2=用法錯誤 3=drain 沒有新事件
set -eu
HERE=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
# lib.sh 在同一個存取庫,不是跨 plugin 依賴。hook 必須自足,事件寫入的函式因此留在 lib.sh,
# 這支只是把它包成命令列介面給技能用。
STDIN_JSON=""
. "$HERE/../hooks/lib.sh"
EVENTS="$JSC_HOME/usage/events.jsonl"
OFFSET="$JSC_HOME/usage/scan-state/events.offset"
MAX_BYTES=5242880
usage() {
cat >&2 <<'EOF'
用法:
report-status.sh skill-start <名稱>
report-status.sh skill-end <名稱> <status> [結束碼] [detail]
report-status.sh hook-end <名稱> <status> <結束碼> [detail]
report-status.sh drain
report-status.sh rotate
status: ok、blocked、failed、degraded、aborted
結束碼: 0=成功 2=用法錯誤 3=drain 沒有新事件
EOF
exit 2
}
valid_status() {
case "$1" in
ok|blocked|failed|degraded|aborted) ;;
*) echo "[jsc][狀態回報][ERR]:status 須為 ok、blocked、failed、degraded、aborted 五選一,收到「$1」。" >&2; exit 2 ;;
esac
}
valid_exit() {
case "$1" in
''|*[!0-9]*) echo "[jsc][狀態回報][ERR]:結束碼須為非負整數,收到「$1」。" >&2; exit 2 ;;
esac
}
cmd="${1:-}"; [ -n "$cmd" ] || usage
shift || true
case "$cmd" in
skill-start)
name="${1:-}"; [ -n "$name" ] || usage
# start 沒有成敗可言,狀態欄固定 ok、結束碼固定 0。判讀靠的是「有沒有配對的 end」:
# 有 start 沒 end 就是中止,那正是現行紀錄分不出來的那一種。
emit_event skill "$name" start ok 0
;;
skill-end)
name="${1:-}"; status="${2:-}"
[ -n "$name" ] && [ -n "$status" ] || usage
valid_status "$status"
code="${3:-0}"; valid_exit "$code"
emit_event skill "$name" end "$status" "$code" "" "${4:-}"
;;
hook-end)
name="${1:-}"; status="${2:-}"; code="${3:-}"
[ -n "$name" ] && [ -n "$status" ] && [ -n "$code" ] || usage
valid_status "$status"; valid_exit "$code"
emit_event hook "$name" end "$status" "$code" "" "${4:-}"
;;
drain)
[ -f "$EVENTS" ] || exit 3
size=$(wc -c < "$EVENTS" 2>/dev/null || echo 0)
old=0
[ -f "$OFFSET" ] && old=$(cat "$OFFSET" 2>/dev/null || echo 0)
case "$old" in ''|*[!0-9]*) old=0 ;; esac
# 檔案比已存位移還小就是輪替過,從頭讀。不比對 inode:五支 CLI 與容器裡的行程
# 看到的 inode 不保證一致,用大小判斷才在每個環境都成立。
[ "$size" -lt "$old" ] && old=0
[ "$size" -eq "$old" ] && exit 3
mkdir -p "$(dirname "$OFFSET")" 2>/dev/null || true
# tail -c +N 從第 N 個位元組起(1 起算),所以位移要加一。
# 不用 dd bs=1 skip=:那是一個位元組一次系統呼叫,位移到了幾 MB 就是幾百萬次,
# 每輪巡檢都排空一次的話會慢到不能用。
tail -c "+$((old + 1))" "$EVENTS" 2>/dev/null || true
printf '%s' "$size" > "$OFFSET" 2>/dev/null || true
;;
rotate)
[ -f "$EVENTS" ] || exit 0
size=$(wc -c < "$EVENTS" 2>/dev/null || echo 0)
if [ "$size" -gt "$MAX_BYTES" ]; then
mv "$EVENTS" "$EVENTS.1" 2>/dev/null || true
: > "$EVENTS" 2>/dev/null || true
# 位移歸零:新檔從頭算起,不歸零的話下一次 drain 會跳過開頭那一段。
printf '0' > "$OFFSET" 2>/dev/null || true
printf '已輪替:%s -> %s.1(原大小 %s 位元組)\n' "$EVENTS" "$EVENTS" "$size"
fi
;;
*) usage ;;
esac
exit 0