Files
ISEP/hooks/wiki-size-guard.sh
T
Claude Code da9bd53cae 沒人會叫的事會自己叫+讀不完的必讀檔要被整理(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),待總管定版。
2026-08-28 01:04:16 +00:00

184 lines
8.4 KiB
Bash
Executable File
Raw 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 bash
# wiki-size-guard.sh — 讀不完的必讀檔要被講出來;壓它的時候不准沒人知道
# inkstone/ISEP#89
#
# ── 為什麼要有這一支 ──────────────────────────────────────────────────
# leo 2026-08-27 說那天「**失去記憶**」。而記憶失效的其中一個原因是:
# **東西還在,只是沒人讀得完。** `mistakes.md` 實測 7,681 行、260 條,
# 而它是「做新功能前讀一遍」等級的必讀檔。**讀不完的必讀檔,等於沒有。**
#
# 既有三支管的是別的事,一支都接不到這裡:
# · `wiki-secret-scan.sh` 寫進去的內容安不安全
# · `wiki-first-search.sh` 有沒有先查 wiki
# · `wiki-first-police.sh` 收工前有沒有查過
# **沒有任何一支管「太大了」。**
#
# ── 兩個事件,是同一件事的兩半 ────────────────────────────────────────
# ① SessionStart ── 講:哪幾個檔已經沒有人讀得完了(📝 記錄,不擋)
# ② Write|Edit|MultiEdit ── 擋:**大幅刪減 wiki 檔**卻沒走正式流程 ⇒ 擋一次
#
# 只有 ① = 又一個會被忽略的提醒。
# 只有 ② = 罰它做一件沒人告訴它該做的事。
#
# ── 🔴 ② 擋的判準是「動作」不是「措辭」(leo 2026-08-17)────────────────
# 「自然語言的變體是無限的,blacklist 永遠追不完……封路哲學之所以有效,
# 是因為它封的是**動作**——動作有限且可枚舉。」
# 本支比的是**字元數**:wiki 檔的內容淨縮水超過門檻 ⇒ 那就是一次壓縮,
# 不管它在 commit 訊息裡怎麼講。加字、改字、小修一律不碰。
#
# 為什麼壓縮要擋:**壓縮會弄丟東西,而弄丟的當下沒有人會發現**(票上原話)。
# 走 `scripts/wiki-compress` 有兩件手寫做不到的事——
# · `--ticket` 逼票號,並把「這一版壓掉了什麼」寫進 `.compress-log.md`(留痕)
# · `verify` 用內文雜湊**逐條對帳**,「沒弄丟」是算出來的,不是宣稱的
#
# 出口(留痕式,同 `wiki-secret-ok` 的慣例):真的要手改,
# 在寫入的內容裡放一個 `wiki-compress-ok`,並在 commit 說明理由。
set -uo pipefail
ROOT="${CLAUDE_PLUGIN_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}"
TOOL_PY="$ROOT/scripts/wiki-compress"
INPUT="$(cat)"
EVENT=$(printf '%s' "$INPUT" | python3 -c '
import sys, json
try: print(json.load(sys.stdin).get("hook_event_name", "") or "")
except Exception: print("")
' 2>/dev/null || printf '')
# ══ ① SessionStart:講出來哪幾個檔已經沒人讀得完 ═══════════════════════
if [ "$EVENT" = "SessionStart" ]; then
[ -f "$TOOL_PY" ] || exit 0
PROJ="${CLAUDE_PROJECT_DIR:-$PWD}"
DIRS=""
for d in "$PROJ/system-dev/wiki" "$PROJ/.claude/wiki" \
"$PROJ/InkStoneCo/system-dev/wiki"; do
[ -d "$d" ] && DIRS="$DIRS $d"
done
[ -n "$DIRS" ] || exit 0
# shellcheck disable=SC2086
# 🔴 不要寫成 `OUT=$(…) || OUT=""``audit` 有東西可報時**刻意回離開碼 1**,
# 加上 pipefail,那個 `||` 會把剛拿到的結果整個清成空的
# ⇒ **有 7 個檔爆表,而這一段一聲不吭**。(2026-08-28 寫的時候當場踩到。)
OUT=$(python3 "$TOOL_PY" audit $DIRS 2>/dev/null | grep '🔴 太長' | head -6 || true)
[ -n "$OUT" ] || exit 0
python3 - "$OUT" <<'PY'
import json, sys
rows = [r for r in sys.argv[1].split("\n") if r.strip()]
msg = "📚 wiki 有 %d 個檔沒人讀得完了" % len(rows)
ctx = "\n".join([
"📚 **這幾個 wiki 檔已經超過『讀得完』的門檻**inkstone/ISEP#89):",
"",
] + rows + [
"",
"🔴 讀不完的必讀檔,跟沒有那個檔的差別只在於**它讓人以為有**。",
"leo 2026-08-27 說那天「失去記憶」——東西都還在,只是沒人讀得完。",
"",
"要整理:`python3 scripts/wiki-compress plan <檔>` 看怎麼壓,",
"然後 `apply <檔> --ticket owner/repo#N`**只搬不改**,正文一個字都不動),",
"壓完一定要 `verify` 逐條對帳。**壓縮這件事本身要有票**,不准順手做完沒人知道。",
])
print(json.dumps({"systemMessage": msg,
"hookSpecificOutput": {"hookEventName": "SessionStart",
"additionalContext": ctx}},
ensure_ascii=False))
PY
exit 0
fi
# ══ ② Write|Edit|MultiEdit:大幅刪減 wiki 檔 ⇒ 擋一次 ═══════════════════
VERDICT=$(printf '%s' "$INPUT" | python3 -c '
import json, os, re, sys
try:
d = json.load(sys.stdin)
except Exception:
print("SKIP:bad-json"); raise SystemExit
ti = d.get("tool_input") or {}
path = ti.get("file_path") or ""
# 只管 wiki 底下的 .md。別的檔案縮水是它們自己的事。
if not path.endswith(".md") or not re.search(r"(^|/)(wiki)/", path):
print("SKIP:not-wiki"); raise SystemExit
def size(s):
return len(re.sub(r"\s+", "", s or ""))
# 留痕式出口:內容裡放了 wiki-compress-ok ⇒ 放行(同 wiki-secret-ok 的慣例)
blob = json.dumps(ti, ensure_ascii=False)
if "wiki-compress-ok" in blob:
print("SKIP:opt-out"); raise SystemExit
before = after = 0
if "content" in ti: # Write:跟磁碟上現有的比
try:
with open(path, encoding="utf-8", errors="replace") as f:
before = size(f.read())
except Exception:
print("SKIP:new-file"); raise SystemExit # 新檔不是壓縮
after = size(ti.get("content"))
else: # Edit / MultiEdit
edits = ti.get("edits") or [ti]
for e in edits:
if not isinstance(e, dict):
continue
before += size(e.get("old_string"))
after += size(e.get("new_string"))
# 判準:**淨縮水的量**要夠大,比例也要夠大。兩個都要,才不會抓到小修。
# · 至少砍掉 800 個非空白字元 —— 改錯字、刪一段話都到不了
# · 而且砍掉的超過原本的 45% —— 「重寫一段」通常增減相抵
if before < 800:
print("SKIP:too-small"); raise SystemExit
cut = before - after
if cut < 800 or cut < before * 0.45:
print("SKIP:not-a-compression"); raise SystemExit
print("BLOCK:%s:%d:%d" % (os.path.basename(path), before, cut))
' 2>/dev/null || echo "SKIP:crash")
case "$VERDICT" in
BLOCK:*) ;;
*) exit 0 ;;
esac
# 至多擋一次:被擋兩次的閘會被想辦法繞過,而繞過的閘等於不存在。
SID=$(printf '%s' "$INPUT" | python3 -c '
import sys, json
try: print(json.load(sys.stdin).get("session_id", "") or "x")
except Exception: print("x")
' 2>/dev/null || printf 'x')
STAMP="/tmp/.wiki-size-guard-$SID"
[ -f "$STAMP" ] && exit 0
touch "$STAMP" 2>/dev/null || true
FILE=$(printf '%s' "$VERDICT" | cut -d: -f2)
BEFORE=$(printf '%s' "$VERDICT" | cut -d: -f3)
CUT=$(printf '%s' "$VERDICT" | cut -d: -f4)
cat >&2 <<MSG
📚 這是一次 wiki 壓縮,不是一次編輯(inkstone/ISEP#89
檔案:$FILE
這一筆會砍掉 $CUT 個字(原本 $BEFORE 個)——超過一半。
🔴 **壓縮會弄丟東西,而弄丟的當下沒有人會發現。**(票上原話)
所以壓縮不能順手做,它要留得下痕跡、而且要能逐條對帳。
改走這條(三步,機器會幫你證明沒弄丟):
python3 scripts/wiki-compress plan <檔>
python3 scripts/wiki-compress apply <檔> --ticket owner/repo#N
python3 scripts/wiki-compress verify <檔>.before-compress <檔> <搬去的檔...>
它跟手改的差別只有兩件,但這兩件正是手改做不到的:
· **--ticket 逼票號**,並把「這一版壓掉了什麼」寫進 \`.compress-log.md\`
· **verify 用內文雜湊逐條對帳**——「沒弄丟」是算出來的,不是你說的
它的做法是**只搬不改**:舊的整條原封不動搬去 archive,原檔留一份目錄。
實測 \`mistakes.md\` 7,681 行 → 1,199 行,**260 條一條都沒少,查詢快 22.5 倍**。
真的要手改(例如只是刪掉一段過期的說明):
在寫入的內容裡放一個 \`wiki-compress-ok\`,並在 commit 說明理由。
本閘**至多擋一次**,不會卡死。
MSG
exit 2