feat(hooks-install): 安裝改為先清空所有 hook 再接線並驗執行期

This commit is contained in:
2026-08-25 18:18:10 +08:00
parent ad2f944a66
commit b45a98af87
3 changed files with 652 additions and 18 deletions
+190
View File
@@ -0,0 +1,190 @@
#!/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 命中 ste100-guard.sh、session-timer.sh、skill-usage.sh、
# sdlc-gate.sh、version-guard.sh 任一支就是 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
}
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")) 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