助理算得出哪幾筆到期、哪幾筆逾期,但那些結果只寫在監控頁上——人要自己
去翻,或自己跑一次狀態查詢。這一支把它帶到前景。
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>
198 lines
8.7 KiB
Bash
Executable File
198 lines
8.7 KiB
Bash
Executable File
#!/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
|