#!/usr/bin/env sh # scan-hook-errors.sh — 掃 CLI 的原生紀錄,找出 hook 的「執行期」錯誤:接線寫對了、 # hook 也真的被觸發了,但跑起來出錯(缺執行檔、路徑錯、權限不足)。 # 用法: scan-hook-errors.sh --cli {claude|codex|copilot|antigravity|kiro} # # 覆蓋範圍要據實回報,不得暗示每個 CLI 都掃得到: # claude 有 hook 結果紀錄,掃 ~/.claude/projects/**/*.jsonl 兩種紀錄—— # `"type":"hook_non_blocking_error"` 的 attachment # (hookName、hookEvent、exitCode、stderr、command、timestamp) # 與非空的 `"hookErrors":[...]` # codex、copilot、antigravity、kiro 原生紀錄只留工作階段與提示內容,沒有記下 hook 的退出碼與 # stderr,一律回報 unavailable,改用 wire-cli.sh smoke {cli} # 主動跑一輪驗執行期 # # 去重: 以 $JSC_HOME/errors/scan-state/ 記住每個日誌檔已掃描的位元組數(同 tools/scan-logs.sh), # 重掃只讀新增段落。兩種紀錄各自成筆,不互相配對:同一次失敗在不同 transcript 條目 # 裡各留一筆,靠位置猜配對只會把真錯誤併掉。 # # 產出: 每筆錯誤附加一行 JSON 到 $JSC_HOME/errors/hooks.jsonl(格式比照 hooks/skill-usage.sh): # {ts,cli,hook,event,exit,detail,jsc} # jsc 欄位:command 或 stderr 命中九支 hook 腳本任一支,或命中 jsc-hooks 路徑,就是 true, # 否則 false。分得出來才用得上——非 jsc 的 hook 錯誤不是 jsc 該修的,hooks-install 只回報、 # 不轉 jsc-hooks:repair。 # # 輸出: 第一行 `status={clean|errors|unavailable} reason=...`(可供程式判讀), # errors 時其後每筆一行人類可讀的繁中摘要(hook 名、退出碼、是否屬 jsc)。 # 結束碼: 0=clean 或 unavailable、1=errors、2=用法錯誤 set -u HERE=$(cd "$(dirname "$0")" && pwd) . "$HERE/../hooks/lib.sh" cli="" while [ $# -gt 0 ]; do case "$1" in --cli) cli="${2:-}"; shift 2 ;; *) shift ;; esac done case "$cli" in claude|codex|copilot|antigravity|kiro) ;; *) echo "用法:scan-hook-errors.sh --cli {claude|codex|copilot|antigravity|kiro}" >&2 exit 2 ;; esac ERRDIR="$JSC_HOME/errors" STATE="$ERRDIR/scan-state" OUT="$ERRDIR/hooks.jsonl" mkdir -p "$STATE" 2>/dev/null || true case "$cli" in codex|copilot|antigravity|kiro) printf 'status=unavailable reason=%s\n' "$cli 沒有 hook 結果紀錄,執行期錯誤掃不到" echo "[jsc] $cli:原生紀錄只留工作階段與提示內容,沒有記下 hook 的退出碼與 stderr。" echo "[jsc] $cli:改跑 tools/wire-cli.sh smoke $cli,主動執行九支 hook 驗執行期。" exit 0 ;; esac # 印出日誌檔自上次掃描後的新增內容,並更新位移(位移即去重機制) new_content() { # $1=file key=$(printf '%s' "$1" | cksum | tr ' \t' '--') off_f="$STATE/$cli-$key.offset" off=$(cat "$off_f" 2>/dev/null || echo 0) size=$(wc -c < "$1" 2>/dev/null || echo 0) [ "$size" -gt "$off" ] 2>/dev/null || return 0 tail -c +"$((off + 1))" "$1" 2>/dev/null echo "$size" > "$off_f" } # 從新增內容萃取錯誤,每筆一行 TSV:hook、event、exit、jsc、ts、detail。 # 欄位用手寫掃描取,不靠正規式一次抓完:stderr 裡有轉義引號,正規式會抓過頭。 extract_errors() { awk ' # 取 JSON 字串或純量欄位;字串保留原本的轉義序列,寫回 jsonl 時才不必重新轉義。 function jstr(s, name, p, i, c, out) { p = index(s, "\"" name "\":") if (p == 0) return "" i = p + length(name) + 3 while (substr(s, i, 1) == " ") i++ if (substr(s, i, 1) != "\"") { out = "" while (i <= length(s) && substr(s, i, 1) !~ /[,}\]]/) { out = out substr(s, i, 1); i++ } return out } i++ out = "" while (i <= length(s)) { c = substr(s, i, 1) if (c == "\\") { out = out substr(s, i, 2); i += 2; continue } if (c == "\"") break out = out c; i++ } return out } # 判定這筆錯誤是不是 jsc 自己的 hook。腳本名逐支列,再補一條 jsc-hooks 路徑判定: # 接線寫進設定的命令一律走 $JSC_HOME/current/jsc-hooks,路徑本身就是證據,新增 hook 時 # 就算忘了補進下面這張清單也還認得出來。認錯邊的代價不對稱——漏認會把 jsc 的錯誤當成 # 第三方的,只回報不修正。 function is_jsc(t) { if (index(t, "ste100-guard.sh") || index(t, "session-timer.sh") \ || index(t, "skill-usage.sh") || index(t, "sdlc-gate.sh") \ || index(t, "version-guard.sh") || index(t, "restart-gate.sh") \ || index(t, "comment-scope.sh") || index(t, "lang-guard.sh") \ || index(t, "write-guard.sh") || index(t, "jsc-hooks")) return "true" return "false" } # 摘要收斂成單行短字串:TSV 欄位不能有 tab,jsonl 欄位不能有裸換行; # 截斷可能切到半個轉義序列,尾端的反斜線要清掉才是合法 JSON 字串。 function clean(t, x) { x = t gsub(/\t/, " ", x) gsub(/\\n/, " ", x) x = substr(x, 1, 300) sub(/\\+$/, "", x) if (x == "") x = "(無錯誤輸出)" return x } # hookErrors 的內容是 JSON 陣列原文,元素外面那對引號是結構、不是文字。 # 直接寫進 jsonl 會多出一對裸引號把字串切斷,所以先拆成純文字,多筆用分號串起來。 function unarray(a) { gsub(/","/, "; ", a) sub(/^"/, "", a) sub(/"$/, "", a) return a } function emit(hook, ev, ec, own, ts, detail) { if (hook == "") hook = "unknown" if (ev == "") ev = "-" if (ec == "") ec = "-" printf "%s\t%s\t%s\t%s\t%s\t%s\n", hook, ev, ec, own, ts, clean(detail) } /"type":"hook_non_blocking_error"/ { err = jstr($0, "stderr"); cmd = jstr($0, "command") detail = (err != "" ? err : cmd) emit(jstr($0, "hookName"), jstr($0, "hookEvent"), jstr($0, "exitCode"), \ is_jsc(cmd " " err), jstr($0, "timestamp"), detail) next } # 非空的 hookErrors:只有訊息陣列,沒有 hook 名與退出碼,欄位據實留空。 /"hookErrors":\[[^]]/ { p = index($0, "\"hookErrors\":[") rest = substr($0, p + length("\"hookErrors\":[")) q = index(rest, "]") arr = (q > 1 ? substr(rest, 1, q - 1) : rest) emit("", "", "", is_jsc(arr), jstr($0, "timestamp"), unarray(arr)) } ' } d="$HOME/.claude/projects" if [ ! -d "$d" ]; then printf 'status=clean reason=%s\n' "找不到 $d,沒有 claude 紀錄可掃" echo "[jsc] claude:這台機器沒有 transcript 目錄,掃不到紀錄跟沒有錯誤是兩件事,請改跑 tools/wire-cli.sh smoke claude。" exit 0 fi recs=$(mktemp) || { printf 'status=clean reason=%s\n' "無法建立暫存檔"; exit 0; } files=$(mktemp) || { rm -f "$recs"; printf 'status=clean reason=%s\n' "無法建立暫存檔"; exit 0; } trap 'rm -f "$recs" "$files"' EXIT find "$d" -type f -name '*.jsonl' 2>/dev/null | sort > "$files" # 迴圈從檔案讀,不放在管線右邊:管線會開子 shell,計數與旗標傳不回本 shell。 while IFS= read -r f; do [ -n "$f" ] || continue new_content "$f" | extract_errors >> "$recs" done < "$files" total=0; jsc_n=0 human=$(mktemp) || { printf 'status=clean reason=%s\n' "無法建立暫存檔"; exit 0; } TAB=$(printf '\t') while IFS="$TAB" read -r hook ev ec isjsc ts detail; do [ -n "$hook" ] || continue [ -n "$ts" ] || ts=$(now_iso) total=$((total + 1)) printf '{"ts":"%s","cli":"%s","hook":"%s","event":"%s","exit":"%s","detail":"%s","jsc":%s}\n' \ "$ts" "$cli" "$hook" "$ev" "$ec" "$detail" "$isjsc" >> "$OUT" if [ "$isjsc" = true ]; then jsc_n=$((jsc_n + 1)); own="屬 jsc" else own="非 jsc 的第三方 hook" fi printf '[jsc] %s(%s 事件)exit %s,%s:%s\n' "$hook" "$ev" "$ec" "$own" "$detail" >> "$human" done < "$recs" if [ "$total" -eq 0 ]; then rm -f "$human" printf 'status=clean reason=%s\n' "claude 紀錄裡沒有新的 hook 執行期錯誤" exit 0 fi printf 'status=errors reason=%s\n' "掃到 $total 筆 hook 執行期錯誤,其中 $jsc_n 筆屬 jsc" cat "$human" rm -f "$human" echo "[jsc] 屬 jsc 的錯誤請先以 tools/report-error.sh 回報,再交給 /jsc-hooks:repair 自動修正並開 develop PR。" echo "[jsc] 非 jsc 的第三方 hook 錯誤只回報,不由 jsc 修正。" echo "[jsc] 全部紀錄已附加到 $OUT。" exit 1