Files
hooks/tools/scan-hook-errors.sh
jiantw83andClaude Opus 5 cd4cd8b936 feat(hooks): 工作階段開始時把助理的未讀提醒帶到前景
助理算得出哪幾筆到期、哪幾筆逾期,但那些結果只寫在監控頁上——人要自己
去翻,或自己跑一次狀態查詢。這一支把它帶到前景。

session-reminder.sh 只讀助理那一輪寫好的提醒佇列,一個判定都不做。
自己拿 due 欄與 next_run 去跟現在比就是第二套到期判定,跟助理那一套遲早
對不上,而對不上的那一天兩邊都說自己是對的。它也要快:這一支跑在每一個
工作階段的開頭。

只印佇列換來一個新的失效模式,正面處理:助理停了,佇列就不再更新,而一份
舊佇列讀起來跟新的一模一樣。所以佇列檔頭帶那一輪的時間戳與 epoch,這裡
算出它多舊;超過心跳門檻或心跳不新鮮,就明說這批提醒是多久以前算的、
助理現在的心跳是什麼狀態。門檻與狀態都取 heartbeat.sh 印的那一行。

佇列空又過期的那一種分兩路:待辦簿有東西才說話,零筆就安靜——人自己按停
也算零筆那一種,對著一個刻意的決定每個工作階段催一次是噪音不是提醒。
助理狀態目錄根本不存在時整支安靜退出,那台機器從沒啟動過助理。

一個工作階段只提一次,記號是 sessions/{代號}.reminded。接不到 session id
的 CLI 全部共用 default,所以 session-timer.sh 的 restart 分支順手清掉那個
記號——不清的話那支 CLI 從第二個工作階段起再也收不到提醒。清除掛在那裡
不掛在這裡:「這是不是新的工作階段」的判準只有那一支知道。

接線與檢核一起改,不留一支沒人驗的 hook:hooks.json 與推導出來的
codex-hooks.json 各加一條、kiro 的 agentSpawn 加一條、claude 的 status
逐支列舉加一項、冒煙測試加一條並把預期條數從 17 改成 18、執行期錯誤掃描
的 jsc 判定加一支腳本名,還有散在文件與回報字串裡的「九支 hook」十處
全部改成十支。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 11:14:41 +08:00

198 lines
8.7 KiB
Bash
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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, "session-reminder.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