#!/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 < 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