沒人會叫的事會自己叫+讀不完的必讀檔要被整理(inkstone/ISEP#93、inkstone/ISEP#89)

兩張票放同一條分支:都是「該發生卻不會自己發生的事」,觸發點都在 session 邊界。

## inkstone/ISEP#93 —— 逾期和掛著沒人接的事會主動叫

- scripts/isep-nag        撈三種沒人會叫的事(逾期 milestone/等 leo 的票/掉在地上的棒子)
- scripts/isep-notify     發 Telegram,而且**發不出去的時候不會安靜**
- hooks/overdue-nag-guard.sh  SessionStart 跑一次(不輪詢、不 fan-out、不掛 Actions)
- hooks/tests/overdue-nag.test.sh  35 條,全程離線

實跑撈得出票上點名的那五個逾期 milestone(08-24 三個、08-26 兩個)。
沒東西可報時會說「查過了,沒有」——安靜跟壞掉長得一模一樣。

🔴 工單補的那個限制(今天實測出來的)已經處理:
「能不能發得出去」取決於這個 session 載到的 ISEP 是哪一版
(prod-write-guard v0.10.0 才認得出 notify_leo 不是部署,而 hook 註冊路徑
在 session 啟動當下就寫死了)。所以 isep-notify 會**先拿真的要送的那一則
去問這個 session 註冊的那支閘**,把判定寫成檔(誰都查得到),然後:
  · 放行 ⇒ 送,並驗內層 data.data.ok(外層 200 不算送到)
  · 會擋 ⇒ **不繞路**,改貼回票上並把原文印在眼前
用 git 歷史裡的真跡(c263866 那一版閘)測過「會擋」那條路。

 實測發現通道本身現在是斷的:實例上找不到 notify_leo 工作流(404)。
   不是閘、不是網路、不是金鑰。詳情與修法寫在 docs/TESTING.md 最後一段。
   退路兩次都走通了(inkstone/ISEP#93 comment 5182、5184)。

## inkstone/ISEP#89 —— wiki 太長時有人整理

- scripts/wiki-compress   audit/plan/apply/verify/bench 五個動詞
- hooks/wiki-size-guard.sh  SessionStart 點名太長的檔;寫檔時擋「沒走流程的壓縮」
- hooks/tests/wiki-compress.test.sh  25 條,全程離線、不碰真的 wiki

設計上最重要的一條:**只搬不改**,正文一個字都不動。
「合併同類、濃縮成一行」要重寫正文,而重寫的當下沒有人會發現弄丟了什麼。
所以機器只做「搬 + 目錄 + 標 ×N/↻」,合併留給人。

拿現在的 mistakes.md 複本實壓過(不動真的 wiki):
  7,681 行 → 1,199 行
  260 條一條都沒少(verify 用內文雜湊逐條對帳)
  80 個查詢命中率 80/80,定位成本 2,956 → 131 行,**快 22.5 倍**(bench)
verify 反向測過:真的弄丟一條時它抓得到(抓不到的對帳表比沒有更糟)。

## 盤點數字

在這棵樹上當場數的,不是拿上一版加減推的:
  ls hooks/*.sh | wc -l              → 55(was 53)
  grep -c '"command":' hooks.json    → 71(was 68)
plugin.json/README/hooks-inventory 三處同步改。
**版本號沒動**(0.11.0),待總管定版。
This commit is contained in:
Claude Code
2026-08-28 01:04:16 +00:00
parent bebbbd2c11
commit da9bd53cae
12 changed files with 2190 additions and 7 deletions
+186
View File
@@ -0,0 +1,186 @@
#!/usr/bin/env bash
# wiki 壓縮的迴歸測試(inkstone/ISEP#89
#
# 對應票上那三條驗收:
# 1 壓完之後同一件事查得到、而且查得比壓之前快 → C 群(bench)
# 2 壓縮前後不能弄丟任何一條教訓(逐條對帳) → B 群(verify
# 3 壓縮這件事本身要有票、有交付紀錄 → D 群(--ticket .compress-log.md
#
# 另外兩群是這套的主要失效模式:
# A 群 切條要切對 —— 切錯的話後面三件全部失準(尤其 code fence 裡的 `#`
# E 群 閘:大幅刪減要擋、正常編輯不准擋 —— **誤攔比漏擋更該修**
#
# 🔴 全程離線、只在 mktemp 目錄裡動檔案,不碰任何真的 wiki。
set -u
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT="$(cd "$HERE/../.." && pwd)"
WC="$ROOT/scripts/wiki-compress"
HOOK="$ROOT/hooks/wiki-size-guard.sh"
TMP=$(mktemp -d)
trap 'rm -rf "$TMP" /tmp/.wiki-size-guard-wctest*' EXIT
# 🔴 目錄結構要跟真的一樣(`<專案>/system-dev/wiki`),不然 F 群測到的是
# 「hook 找不到 wiki 目錄所以閉嘴」,那是假綠——它跟「都在門檻內」長得一模一樣。
W="$TMP/system-dev/wiki"; mkdir -p "$W"
PASS=0; FAIL=0
ok(){ printf ' ✅ %s\n' "$1"; PASS=$((PASS+1)); }
no(){ printf ' ❌ %s —— %s\n' "$1" "$2"; FAIL=$((FAIL+1)); }
has(){ printf '%s' "$1" | grep -q -- "$2"; }
# ── 測資:一份小而完整的假 wiki(含所有會出事的形狀)──────────────────
{
echo "# 假的 mistakes"
echo
echo "> 前言,壓縮後要留在最上面。"
echo
for i in $(seq 1 40); do
d=$(printf '2026-0%d-%02d' $(( (i % 3) + 6 )) $(( (i % 27) + 1 )))
echo
echo "## 第 $i 條:獨特關鍵詞 KEY$i 的教訓($d"
echo
echo "**症狀**:某某東西壞了 $i。票:inkstone/ISEP#$((100 + i))"
echo
echo '```sh'
echo "# 這一行是程式碼註解,不是標題 $i"
echo "## 這一行也是,切條的時候不准把它當成一條"
echo '```'
echo
for _ in $(seq 1 12); do echo "正文填充 $i:這一段是為了讓每條有足夠的行數。"; done
done
echo
echo "## 自承重複的那一條:這是同款第三次(2026-08-20"
echo
echo "同一個病又犯一次。票:inkstone/ISEP#999"
} > "$W/mistakes.md"
cp "$W/mistakes.md" "$TMP/orig.md"
echo "── A 群:切條要切對(切錯的話後面三件全部失準)──────────────────"
A=$(python3 "$WC" audit "$W" 2>&1)
has "$A" "41 條" && ok "① 切出 41 條(40 自承重複那條)" \
|| no "① 切出 41 條" "實得:$(printf '%s' "$A" | grep mistakes)"
has "$A" "這一行也是" && no "② code fence 裡的 ## 不算一條" "算進去了" \
|| ok "② code fence 裡的 ## 不算一條(mistakes.md 真的有這種行)"
has "$A" "自承是同款" && ok "③ 認得出「自承是同款第 N 次」(人下的判斷比機器猜的準)" \
|| no "③ 自承重複" "沒偵測到"
echo "── B 群:不能弄丟任何一條(票上驗收 2)──────────────────────────"
AP=$(WIKI_COMPRESS_THRESHOLD=400 python3 "$WC" apply "$W/mistakes.md" \
--ticket inkstone/ISEP#89 2>&1)
has "$AP" "✅ 壓完了" && ok "④ 壓得動" || no "④ 壓得動" "$AP"
V=$(python3 "$WC" verify "$W/mistakes.md.before-compress" "$W/mistakes.md" \
"$W"/mistakes-archive-*.md 2>&1)
has "$V" "一條都沒弄丟" && ok "⑤ 逐條對帳:一條都沒弄丟" || no "⑤ 逐條對帳" "$V"
diff -q "$TMP/orig.md" "$W/mistakes.md.before-compress" >/dev/null \
&& ok "⑥ 壓縮前的原檔完整留著(.before-compress,錯了還原得回去)" \
|| no "⑥ 原檔留底" "跟壓縮前不一樣"
# 真的弄丟一條 ⇒ verify 一定要抓到。**抓不到的對帳表比沒有更糟。**
cp "$W/mistakes.md" "$TMP/tampered.md"
(python3 - "$W" "$TMP/tampered.md" <<'PY'
import glob, os, re, sys
w, tam = sys.argv[1], sys.argv[2]
# 從某個 archive 裡整條刪掉一條,模擬「壓縮的時候弄丟了」
for f in sorted(glob.glob(os.path.join(w, "mistakes-archive-*.md"))):
t = open(f, encoding="utf-8").read()
parts = re.split(r"(?m)^## ", t)
if len(parts) > 2:
open(f + ".broken", "w", encoding="utf-8").write(
parts[0] + "## " + "## ".join(parts[2:]))
print(f + ".broken")
break
PY
) >/dev/null
BROKEN=$(ls "$W"/mistakes-archive-*.md.broken 2>/dev/null | head -1)
if [ -n "$BROKEN" ]; then
KEEP=$(ls "$W"/mistakes-archive-*.md 2>/dev/null | grep -v '\.broken$' \
| grep -v "$(basename "${BROKEN%.broken}")" || true)
# shellcheck disable=SC2086
V2=$(python3 "$WC" verify "$W/mistakes.md.before-compress" "$W/mistakes.md" \
"$BROKEN" $KEEP 2>&1); RC2=$?
has "$V2" "弄丟了" && [ "$RC2" = 1 ] \
&& ok "⑦ **真的弄丟一條時 verify 抓得到**(反向測過,離開碼 1)" \
|| no "⑦ 反向測" "沒抓到(rc=$RC2):$(printf '%s' "$V2" | tr '\n' ' ')"
else
printf ' ⏭️ SKIP ⑦:這次沒有 archive 可以拿來破壞\n'
fi
echo "── C 群:查得到,而且查得更快(票上驗收 1)──────────────────────"
B=$(python3 "$WC" bench "$W/mistakes.md.before-compress" "$W/mistakes.md" \
"$W"/mistakes-archive-*.md 2>&1)
has "$B" "查得更快" && ok "⑧ 命中率沒掉,而且更快" || no "⑧ 更快" "$B"
BEF=$(printf '%s' "$B" | grep -o '壓縮前.*平均 [0-9]* 行' | grep -o '[0-9]*' | tail -1)
AFT=$(printf '%s' "$B" | grep -o '壓縮後.*平均 [0-9]* 行' | grep -o '[0-9]*' | tail -1)
[ -n "$BEF" ] && [ -n "$AFT" ] && [ "$AFT" -lt "$BEF" ] \
&& ok "⑨ 定位成本真的變小($BEF$AFT 行)" \
|| no "⑨ 定位成本" "前=$BEF 後=$AFT"
has "$(cat "$W/mistakes.md")" "KEY7" && ok "⑩ 壓完的原檔裡查得到舊條目(目錄留了指標)" \
|| no "⑩ 目錄留指標" "KEY7 在壓縮後的原檔裡不見了"
echo "── D 群:壓縮這件事本身要有票、有交付紀錄(票上驗收 3)────────────"
NT=$(python3 "$WC" apply "$W/mistakes.md" 2>&1); RCN=$?
[ "$RCN" != 0 ] && ok "⑪ 沒給 --ticket 就不准壓" || no "⑪ 沒票不准壓" "居然壓了"
BAD=$(python3 "$WC" apply "$W/mistakes.md" --ticket 89 2>&1); RCB=$?
[ "$RCB" != 0 ] && ok "⑫ 裸號不算票號(跨 repo 會撞號)" || no "⑫ 裸號" "收了"
[ -f "$W/.compress-log.md" ] && ok "⑬ 留下交付紀錄 .compress-log.md" \
|| no "⑬ 交付紀錄" "沒有這個檔"
L=$(cat "$W/.compress-log.md" 2>/dev/null)
has "$L" "inkstone/ISEP#89" && ok "⑭ 紀錄裡寫著票號(誰批准壓的查得到)" || no "⑭ 票號" "$L"
has "$L" "對帳指令" && ok "⑮ 紀錄裡寫著對帳指令(下一個人跑得起來)" || no "⑮ 對帳指令" "$L"
echo "── E 群:閘——大幅刪減要擋,正常編輯不准擋 ──────────────────────"
mk(){ printf '{"hook_event_name":"PreToolUse","session_id":"wctest%s","tool_name":"%s","tool_input":%s}' "$1" "$2" "$3"; }
run(){ rm -f /tmp/.wiki-size-guard-wctest*; out=$(printf '%s' "$1" | bash "$HOOK" 2>&1); printf '%s|%s' "$?" "$(printf '%s' "$out" | tr '\n' ' ')"; }
BIG=$(python3 -c 'print("這是一段很長的舊內容。" * 200)')
SMALL="剩這一句。"
R=$(run "$(mk 1 Edit "$(python3 -c "
import json,sys
print(json.dumps({'file_path':'$W/mistakes.md','old_string':'''$BIG''','new_string':'$SMALL'}, ensure_ascii=False))")")")
case "$R" in 2\|*) ok "⑯ 砍掉一大段 wiki ⇒ 擋一次" ;; *) no "⑯ 該擋" "實得 $R" ;; esac
has "$R" "只搬不改" && ok "⑰ 擋的訊息說得出出路(三步指令)" || no "⑰ 出路" "$R"
R=$(run "$(mk 2 Edit "$(python3 -c "
import json
print(json.dumps({'file_path':'$W/mistakes.md','old_string':'這是一句舊的話,長度普通。','new_string':'這是一句新的話,長度也普通,改了幾個字。'}, ensure_ascii=False))")")")
case "$R" in 0\|*) ok "⑱ 一般編輯不擋(誤攔比漏擋更該修)" ;; *) no "⑱ 不該擋" "實得 $R" ;; esac
R=$(run "$(mk 3 Edit "$(python3 -c "
import json
print(json.dumps({'file_path':'/somewhere/src/app.ts','old_string':'x'*3000,'new_string':'y'}, ensure_ascii=False))")")")
case "$R" in 0\|*) ok "⑲ 不是 wiki 的檔不管(別的檔縮水是它們自己的事)" ;; *) no "⑲ 非 wiki" "實得 $R" ;; esac
R=$(run "$(mk 4 Edit "$(python3 -c "
import json,sys
print(json.dumps({'file_path':'$W/mistakes.md','old_string':'''$BIG''','new_string':'剩這一句。 wiki-compress-ok'}, ensure_ascii=False))")")")
case "$R" in 0\|*) ok "⑳ wiki-compress-ok 留痕式豁免走得通" ;; *) no "⑳ 豁免" "實得 $R" ;; esac
R=$(run "$(mk 5 Write "$(python3 -c "
import json
print(json.dumps({'file_path':'$W/從來沒有過的新檔.md','content':'新檔案的一點內容'}, ensure_ascii=False))")")")
case "$R" in 0\|*) ok "㉑ 新建檔案不算壓縮" ;; *) no "㉑ 新檔" "實得 $R" ;; esac
# 至多擋一次:同一個 session 連兩次,第二次要放行(不然會卡死)
PAY=$(mk 6 Edit "$(python3 -c "
import json
print(json.dumps({'file_path':'$W/mistakes.md','old_string':'''$BIG''','new_string':'剩這一句。'}, ensure_ascii=False))")")
rm -f /tmp/.wiki-size-guard-wctest6
printf '%s' "$PAY" | bash "$HOOK" >/dev/null 2>&1; first=$?
printf '%s' "$PAY" | bash "$HOOK" >/dev/null 2>&1; second=$?
[ "$first" = 2 ] && [ "$second" = 0 ] \
&& ok "㉒ 至多擋一次(被擋兩次的閘會被想辦法繞過)" \
|| no "㉒ 至多擋一次" "第一次 $first、第二次 $second"
echo "── F 群:SessionStart 講出來哪幾個檔沒人讀得完 ──────────────────"
S=$(printf '{"hook_event_name":"SessionStart","source":"startup"}' \
| CLAUDE_PROJECT_DIR="$TMP" WIKI_COMPRESS_THRESHOLD=50 bash "$HOOK" 2>/dev/null)
printf '%s' "$S" | python3 -c 'import json,sys;json.load(sys.stdin)' 2>/dev/null \
&& ok "㉓ 吐得出合法 JSON" || no "㉓ 合法 JSON" "實得:$(printf '%s' "$S" | head -c 200)"
has "$S" "沒人讀得完" && ok "㉔ 真的點名超過門檻的檔" || no "㉔ 點名" "$S"
S2=$(printf '{"hook_event_name":"SessionStart","source":"startup"}' \
| CLAUDE_PROJECT_DIR="$TMP" WIKI_COMPRESS_THRESHOLD=99999 bash "$HOOK" 2>/dev/null)
[ -z "$S2" ] && ok "㉕ 都在門檻內就閉嘴(沒事還講話=下次沒人看)" || no "㉕ 沒事閉嘴" "$S2"
echo
printf '通過 %d 條,失敗 %d 條\n' "$PASS" "$FAIL"
[ "$FAIL" = 0 ]