沒人會叫的事會自己叫+讀不完的必讀檔要被整理(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:
@@ -110,6 +110,10 @@
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/pending-changes-retired.sh"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/wiki-size-guard.sh"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -227,6 +231,14 @@
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/scripts/countdown-milestone-refresh.sh"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/overdue-nag-guard.sh"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/wiki-size-guard.sh"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Executable
+109
@@ -0,0 +1,109 @@
|
||||
#!/usr/bin/env bash
|
||||
# overdue-nag-guard.sh — SessionStart:**開工的時候,先把沒人會叫的事叫出來**
|
||||
# (inkstone/ISEP#93)
|
||||
#
|
||||
# ── 為什麼是 SessionStart,不是排程 ────────────────────────────────────
|
||||
# 票上寫死:「第一版做成手動可跑的腳本 + 開 session 時順手跑一次,
|
||||
# **不依賴 Gitea 的自動通知機制**」。理由兩個,都不是偷懶:
|
||||
# ① 機器帳號 `claude-code` 在 `bots` 團隊,`/orgs/inkstone/hooks` 回 403 ——**裝不了**
|
||||
# ② ISEP v0.6.0 §8.2:「壞掉的形式是『以為有人在跑』,比『沒人跑』貴得多」
|
||||
# 而頂層 `CLAUDE.md` 的 flag 紅線直接禁掉排程輪詢與事件 fan-out。
|
||||
# ⇒ 這支的觸發點是**人開工的那一刻**,不是時鐘。
|
||||
#
|
||||
# ── 這支不是閘,是**鬧鐘** ────────────────────────────────────────────
|
||||
# 它不擋任何東西(永遠 exit 0)。它做三件事:
|
||||
# ① 撈:`scripts/isep-nag` 去問 Gitea(逾期 milestone/等 leo 的票/掉在地上的棒子)
|
||||
# ② 講:把白話報告注入眼前——連「查過了,沒有」也要講出來
|
||||
# ③ 叫:發 Telegram 給 leo。**發不出去的時候不會安靜**(見下)
|
||||
#
|
||||
# ── 🔴 「發不出去」是每個 session 各自不同的事實 ──────────────────────
|
||||
# 2026-08-28 實測:能不能發 Telegram 取決於**這個 session 載到的 ISEP 是哪一版**——
|
||||
# `prod-write-guard` v0.10.0 才認得出「發通知」不是「部署工作流」,
|
||||
# 而 **hook 的註冊路徑是 session 啟動當下寫死的** ⇒ 更新 plugin 之後要開新 session。
|
||||
#
|
||||
# ⇒ 所以本支**不假設發得出去**:`isep-notify` 會先拿真的要送的那一則去問
|
||||
# `$CLAUDE_PLUGIN_ROOT/hooks/prod-write-guard.sh`(=這個 session 註冊的那一份,
|
||||
# 沒有第二種可能),把判定寫成檔,然後
|
||||
# · 放行 ⇒ 送,並驗**內層** `data.data.ok`(外層 200 不算送到)
|
||||
# · 會擋 ⇒ **不繞路**,改貼回 `inkstone/ISEP#93`,並把整段印在眼前
|
||||
# **發不出去而靜默,等於沒做**——所以判定與退路都會出現在注入的文字裡。
|
||||
set -uo pipefail
|
||||
|
||||
ROOT="${CLAUDE_PLUGIN_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}"
|
||||
NAG="$ROOT/scripts/isep-nag"
|
||||
NOTIFY="$ROOT/scripts/isep-notify"
|
||||
[ -x "$NAG" ] || [ -f "$NAG" ] || exit 0
|
||||
|
||||
STATE="${ISEP_NAG_STATE_DIR:-$HOME/.claude/isep-nag}"
|
||||
export ISEP_NOTIFY_STATE_DIR="${ISEP_NOTIFY_STATE_DIR:-$STATE}"
|
||||
COOLDOWN="${ISEP_NAG_NOTIFY_COOLDOWN:-21600}" # 預設 6 小時叫一次,不洗版
|
||||
TICKET="${ISEP_NAG_TICKET:-inkstone/ISEP#93}"
|
||||
BUDGET="${ISEP_NAG_TIMEOUT:-25}"
|
||||
mkdir -p "$STATE" 2>/dev/null || true
|
||||
|
||||
# 子 session 不叫:subagent 開工不是 leo 開工,鬧鐘響給沒有手的人聽是噪音。
|
||||
[ "${CLAUDE_CODE_CHILD_SESSION:-}" = "1" ] && exit 0
|
||||
|
||||
# ── ① 撈 ──────────────────────────────────────────────────────────────
|
||||
# 逾時/沒網路/沒 token 都不准讓開場卡住——這支是鬧鐘,不是安全閘。
|
||||
# ISEP_NAG_FIXTURE 只給測試用:讓整條路徑(撈→講→叫)離線跑得完,不打 Gitea。
|
||||
ARGS=()
|
||||
[ -n "${ISEP_NAG_FIXTURE:-}" ] && ARGS=(--fixture "$ISEP_NAG_FIXTURE")
|
||||
# 🔴 一次算完長版+短版(--both)。分兩次呼叫=打兩輪 Gitea=開場多等一輪,
|
||||
# 而這裡是 leo 每次開工都會經過的關鍵路徑。
|
||||
BOTH=$(timeout "$BUDGET" python3 "$NAG" "${ARGS[@]+"${ARGS[@]}"}" --both 2>/dev/null) || BOTH=""
|
||||
SEP='␟--isep-nag-short--␟'
|
||||
REPORT=$(printf '%s' "$BOTH" | sed -n "1,/$SEP/p" | sed '$d')
|
||||
SHORT=$(printf '%s' "$BOTH" | sed -n "/$SEP/,\$p" | sed '1d')
|
||||
[ -n "$REPORT" ] || REPORT="🔔 催辦員這次沒跑完(逾時 ${BUDGET}s 或拿不到 Gitea)。
|
||||
⇒ **這不等於「沒有事情逾期」**,這是「我沒查到」。手動補一次:python3 scripts/isep-nag"
|
||||
|
||||
# ── ② 叫(有東西才叫,而且有冷卻)────────────────────────────────────
|
||||
NOTE=""
|
||||
STAMP="$STATE/last-notify"
|
||||
now=$(date +%s 2>/dev/null || echo 0)
|
||||
last=0
|
||||
[ -f "$STAMP" ] && last=$(stat -c %Y "$STAMP" 2>/dev/null || stat -f %m "$STAMP" 2>/dev/null || echo 0)
|
||||
case "$now$last" in *[!0-9]*) now=0; last=0 ;; esac
|
||||
|
||||
if [ "${ISEP_NAG_NO_NOTIFY:-}" = "1" ]; then
|
||||
NOTE="(ISEP_NAG_NO_NOTIFY=1,這次不發 Telegram)"
|
||||
elif [ -z "$SHORT" ]; then
|
||||
NOTE="(撈不到東西,沒有可發的內容——這不是「沒事」,是「沒查到」)"
|
||||
elif printf '%s' "$SHORT" | grep -q "查過了,沒有"; then
|
||||
# 🔴 「沒事」不發 Telegram,但**眼前照樣講**。
|
||||
# 每隔幾小時發一則「一切正常」,leo 會學會不看它——那時真的有事也叫不動他。
|
||||
NOTE="(三類都乾淨,不打擾 leo;但上面那段一定講出來)"
|
||||
elif [ "$now" -gt 0 ] && [ $((now - last)) -lt "$COOLDOWN" ]; then
|
||||
NOTE="($(( (COOLDOWN - (now - last)) / 60 )) 分鐘前才叫過,這次不重複發)"
|
||||
else
|
||||
NOTE=$(timeout 60 python3 "$NOTIFY" --text "$SHORT" --fallback-ticket "$TICKET" 2>&1) \
|
||||
&& touch "$STAMP" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# ── ③ 講(不管上面走哪條路,都要有話)────────────────────────────────
|
||||
python3 - "$REPORT" "$NOTE" <<'PY'
|
||||
import json, sys
|
||||
report, note = sys.argv[1], sys.argv[2]
|
||||
head = report.splitlines()[0] if report else "🔔 催辦員"
|
||||
n = sum(report.count(k) for k in ("⏰ **逾期", "🙋 **在等你", "🧤 **棒子"))
|
||||
sysmsg = "🔔 催辦員查過了(%s 類有事)" % n if n else "🔔 催辦員查過了:三類都乾淨"
|
||||
|
||||
ctx = "\n".join([
|
||||
report,
|
||||
"",
|
||||
"── 通知這一段 ──",
|
||||
note or "(沒有通知動作)",
|
||||
"",
|
||||
"🔴 這段是 ISEP 的催辦員(inkstone/ISEP#93)自己發的,不是你想起來要查的。",
|
||||
"它只在**你開 session 的那一刻**跑一次——**CC 沒在跑的那段時間補不到**,",
|
||||
"那一段要 leo 決定要不要開 Gitea 的通知權限(機器帳號現在是 403)。",
|
||||
"",
|
||||
"要重跑:`python3 scripts/isep-nag`(`--json` 看全部、`--notify` 再發一次)。",
|
||||
])
|
||||
print(json.dumps({
|
||||
"systemMessage": sysmsg,
|
||||
"hookSpecificOutput": {"hookEventName": "SessionStart", "additionalContext": ctx},
|
||||
}, ensure_ascii=False))
|
||||
PY
|
||||
exit 0
|
||||
Executable
+190
@@ -0,0 +1,190 @@
|
||||
#!/usr/bin/env bash
|
||||
# 催辦員的迴歸測試(inkstone/ISEP#93)
|
||||
#
|
||||
# 對應票上那四條驗收:
|
||||
# 1 撈得出逾期的 milestone,而且訊息是白話的 → A 群
|
||||
# 2 沒東西可報時要說「查過了,沒有」 → B 群
|
||||
# 3 開一個新 session 要自己跑過一次 → E 群(hook 的注入)
|
||||
# 4 Telegram 真的收得到 → D 群只驗得了「發不出去不會安靜」
|
||||
# (「leo 手機上真的出現」機器驗不了,那一格由人驗,見 docs/TESTING.md)
|
||||
#
|
||||
# 另外兩群是這支的**主要失效模式**:
|
||||
# C 群 不該叫的不要叫 —— 誤報比漏報更該修:每次開場都被念一串不相干的東西,
|
||||
# 人就學會跳過它,那時真的有事也叫不動(本 repo 心法第 2 條)
|
||||
# D 群 發不出去不會安靜 —— 「送出成功」跟「送到了」是兩件事
|
||||
#
|
||||
# 🔴 全程離線:資料走 --fixture、時鐘走 --now、網路用 ISEP_NOTIFY_OFFLINE=1 關掉。
|
||||
# 不打 Gitea、不打實例、不留任何測試票。
|
||||
set -u
|
||||
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ROOT="$(cd "$HERE/../.." && pwd)"
|
||||
NAG="$ROOT/scripts/isep-nag"
|
||||
NOTIFY="$ROOT/scripts/isep-notify"
|
||||
HOOK="$ROOT/hooks/overdue-nag-guard.sh"
|
||||
|
||||
TMP=$(mktemp -d)
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
export ISEP_NOTIFY_STATE_DIR="$TMP/state"
|
||||
export ISEP_NAG_STATE_DIR="$TMP/state"
|
||||
export ISEP_NOTIFY_OFFLINE=1
|
||||
mkdir -p "$ISEP_NOTIFY_STATE_DIR"
|
||||
|
||||
# 🔴 明著清掉:測試常常跑在一條 subagent 裡,而 hook 刻意放行子 session
|
||||
# ⇒ 不清掉的話 E 群會全綠,而且是**假綠**(countdown-guard.test.sh 撞過同一個坑)。
|
||||
export CLAUDE_CODE_CHILD_SESSION=
|
||||
|
||||
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"; }
|
||||
|
||||
# 定住的時鐘:2026-08-28 00:00 UTC(=台北 08:00),跟票上那天同一天
|
||||
NOW=$(python3 -c 'import datetime as d;print(int(d.datetime(2026,8,28,0,0,tzinfo=d.timezone.utc).timestamp()))')
|
||||
|
||||
# ── 測資:票上點名的那五個逾期 milestone,加上一整組「不該叫」的對照 ──────
|
||||
cat > "$TMP/full.json" <<'JSON'
|
||||
{"milestones": [
|
||||
{"repo":"inkstone/arcrun-rag","title":"不必開瀏覽器,也不怕反悔","due_on":"2026-08-24T23:59:59Z","open_issues":2,"closed_issues":0},
|
||||
{"repo":"inkstone/Arcrun","title":"App 打開來就是 Arcrun,而且回得去","due_on":"2026-08-24T23:59:59Z","open_issues":3,"closed_issues":1},
|
||||
{"repo":"inkstone/ISEP","title":"把管理這條線做對","due_on":"2026-08-24T23:59:59Z","open_issues":14,"closed_issues":1},
|
||||
{"repo":"inkstone/Arcrun","title":"AI 問得到內文,不是只查到檔名","due_on":"2026-08-26T23:59:59Z","open_issues":1,"closed_issues":1},
|
||||
{"repo":"inkstone/Arcrun","title":"寫進去,就查得到","due_on":"2026-08-26T23:59:59Z","open_issues":1,"closed_issues":6},
|
||||
{"repo":"inkstone/ISEP","title":"還沒到期的","due_on":"2026-09-30T23:59:59Z","open_issues":9,"closed_issues":0},
|
||||
{"repo":"inkstone/ISEP","title":"逾期但票都關完了","due_on":"2026-08-01T23:59:59Z","open_issues":0,"closed_issues":7},
|
||||
{"repo":"inkstone/ISEP","title":"沒設期限的","due_on":null,"open_issues":5,"closed_issues":0}
|
||||
],
|
||||
"issues": [
|
||||
{"number":138,"title":"App 沒辦法說「我要接哪些外部事件」","updated_at":"2026-08-17T00:00:00Z",
|
||||
"repository":{"full_name":"inkstone/Arcrun"},"labels":[{"name":"Human"}],"assignees":[]},
|
||||
{"number":51,"title":"leo21c 上有一批舊資源還在","updated_at":"2026-08-20T00:00:00Z",
|
||||
"repository":{"full_name":"inkstone/InkStoneCo"},"labels":[],"assignees":[{"login":"leo"}]},
|
||||
{"number":38,"title":"辨識碼信在 stage 寄不出去","updated_at":"2026-08-12T00:00:00Z",
|
||||
"repository":{"full_name":"inkstone/arcrun-rag"},"labels":[{"name":"s/doing"},{"name":"p/high"}],"assignees":[]},
|
||||
{"number":999,"title":"昨天才動過的 s/doing——還在做,不要叫","updated_at":"2026-08-27T00:00:00Z",
|
||||
"repository":{"full_name":"inkstone/ISEP"},"labels":[{"name":"s/doing"}],"assignees":[]},
|
||||
{"number":998,"title":"這是一個 PR,pr-verdict-guard 在管","updated_at":"2026-08-01T00:00:00Z",
|
||||
"repository":{"full_name":"inkstone/ISEP"},"labels":[{"name":"s/review"}],"assignees":[],
|
||||
"pull_request":{"merged":false}},
|
||||
{"number":997,"title":"舊 org 的同一張票(重複,不是兩件事)","updated_at":"2026-08-01T00:00:00Z",
|
||||
"repository":{"full_name":"Leo/arcrun-rag"},"labels":[{"name":"s/doing"}],"assignees":[]},
|
||||
{"number":996,"title":"沒有狀態標籤,也沒人等——不是誰的事","updated_at":"2026-07-01T00:00:00Z",
|
||||
"repository":{"full_name":"inkstone/ISEP"},"labels":[],"assignees":[]}
|
||||
]}
|
||||
JSON
|
||||
echo '{"milestones": [], "issues": []}' > "$TMP/empty.json"
|
||||
|
||||
R=$(python3 "$NAG" --fixture "$TMP/full.json" --now "$NOW" 2>&1)
|
||||
S=$(python3 "$NAG" --fixture "$TMP/full.json" --now "$NOW" --short 2>&1)
|
||||
J=$(python3 "$NAG" --fixture "$TMP/full.json" --now "$NOW" --json 2>&1)
|
||||
|
||||
echo "── A 群:撈得出票上那五個逾期 milestone,而且是白話 ──────────────"
|
||||
has "$R" "逾期的 milestone(5 個)" && ok "① 五個都撈到了(票上驗收 1)" \
|
||||
|| no "① 五個都撈到了" "實得:$(printf '%s' "$R" | head -3 | tr '\n' ' ')"
|
||||
n=0
|
||||
for t in "不必開瀏覽器" "App 打開來就是 Arcrun" "把管理這條線做對" "AI 問得到內文" "寫進去,就查得到"; do
|
||||
has "$R" "$t" && n=$((n+1))
|
||||
done
|
||||
[ "$n" = 5 ] && ok "② 五個名字一個不漏" || no "② 五個名字一個不漏" "只對上 $n 個"
|
||||
has "$R" "逾期 3 天" && has "$R" "逾期 1 天" \
|
||||
&& ok "③ 逾期幾天算得對(08-24→3 天、08-26→1 天)" \
|
||||
|| no "③ 逾期幾天算得對" "實得:$(printf '%s' "$R" | grep -o '逾期 [0-9]* 天' | tr '\n' ' ')"
|
||||
has "$R" "還有 14 張票沒關" && ok "④ 講得出「還有幾張票沒關」(這才是他要判斷的)" \
|
||||
|| no "④ 還有幾張票沒關" "沒出現"
|
||||
has "$R" "對你意味著" && ok "⑤ 每一段都講「這對你意味什麼」(白話鐵律)" \
|
||||
|| no "⑤ 對你意味著" "整份報告沒有一句在講後果"
|
||||
has "$R" "等了 11 天" && ok "⑥ 等 leo 的票標出等了幾天(規則五:不標天數=不會被催)" \
|
||||
|| no "⑥ 等了幾天" "實得:$(printf '%s' "$R" | grep -o '等了 [0-9]* 天' | tr '\n' ' ')"
|
||||
has "$S" "回「看」" && ok "⑦ 短版給得出一個可以回的詞(leo 回一個詞就能處理)" \
|
||||
|| no "⑦ 可回的詞" "短版:$S"
|
||||
[ "$(printf '%s' "$S" | wc -l)" -le 6 ] && ok "⑧ 短版夠短(手機一眼掃完)" \
|
||||
|| no "⑧ 短版夠短" "$(printf '%s' "$S" | wc -l) 行"
|
||||
|
||||
echo "── B 群:沒東西可報時,要說「查過了,沒有」────────────────────────"
|
||||
E=$(python3 "$NAG" --fixture "$TMP/empty.json" --now "$NOW" 2>&1)
|
||||
ES=$(python3 "$NAG" --fixture "$TMP/empty.json" --now "$NOW" --short 2>&1)
|
||||
has "$E" "查過了,沒有" && ok "⑨ 長版說「查過了,沒有」(票上驗收 2)" || no "⑨ 長版" "實得:$E"
|
||||
has "$ES" "查過了,沒有" && ok "⑩ 短版也說(安靜跟壞掉長得一模一樣)" || no "⑩ 短版" "實得:$ES"
|
||||
BAD=$(TICKET_HOST=http://127.0.0.1:9 GITEA_TOKEN_CLAUDE_CODE= GITEA_TOKEN= \
|
||||
CLAUDE_PROJECT_DIR="$TMP" python3 "$NAG" --now "$NOW" 2>&1)
|
||||
has "$BAD" "這不等於" && ok "⑪ 撈不到時講「我沒查到」,不講成「沒有事情逾期」" \
|
||||
|| no "⑪ 撈不到≠沒事" "實得:$(printf '%s' "$BAD" | tr '\n' ' ')"
|
||||
|
||||
echo "── C 群:不該叫的不要叫(誤報比漏報更該修)──────────────────────"
|
||||
has "$R" "還沒到期的" && no "⑫ 還沒到期的不叫" "叫了" || ok "⑫ 還沒到期的不叫"
|
||||
has "$R" "逾期但票都關完了" && no "⑬ 票關完的不叫" "叫了" || ok "⑬ 逾期但票都關完了不叫"
|
||||
has "$R" "沒設期限的" && no "⑭ 沒期限的不叫" "叫了" || ok "⑭ 沒設期限的不是節拍器"
|
||||
has "$R" "昨天才動過" && no "⑮ 才動過的不叫" "叫了" || ok "⑮ 3 天內動過的 s/doing 不算掉棒"
|
||||
has "$R" "pr-verdict-guard 在管" && no "⑯ PR 不重複點名" "叫了" || ok "⑯ PR 交給 pr-verdict-guard,不重複"
|
||||
has "$R" "舊 org 的同一張票" && no "⑰ 舊 org 的重複票不叫" "叫了" || ok "⑰ 只看 inkstone,不重複計舊 org"
|
||||
has "$R" "不是誰的事" && no "⑱ 沒標籤沒人等的不叫" "叫了" || ok "⑱ 沒標籤又沒人等的不叫"
|
||||
printf '%s' "$J" | python3 -c 'import json,sys;json.load(sys.stdin)' 2>/dev/null \
|
||||
&& ok "⑲ --json 是合法 JSON(全清單在這裡,長版才封頂)" || no "⑲ --json" "不是合法 JSON"
|
||||
|
||||
echo "── D 群:發不出去不會安靜(票上驗收 4 的機器可驗那一半)──────────"
|
||||
OUT=$(python3 "$NOTIFY" --text "測試訊息 ABC" 2>&1); RC=$?
|
||||
[ "$RC" = 1 ] && ok "⑳ 兩條路都沒走通 ⇒ 離開碼 1(不是靜靜地成功)" \
|
||||
|| no "⑳ 離開碼" "實得 $RC"
|
||||
has "$OUT" "測試訊息 ABC" && ok "㉑ 原文照抄在輸出裡(看到的人才有辦法代發)" \
|
||||
|| no "㉑ 原文照抄" "實得:$OUT"
|
||||
has "$OUT" "leo 的手機上沒有出現這則" && ok "㉒ 明著說 leo 沒收到(不是「送出成功」)" \
|
||||
|| no "㉒ 明著說沒收到" "實得:$OUT"
|
||||
|
||||
echo "── E 群:閘的版本是查得到的事實,不是假設 ────────────────────────"
|
||||
G=$(python3 "$NOTIFY" --gate 2>&1)
|
||||
has "$G" "這個 session 的通知閘" && ok "㉓ 問得出這個 session 的閘怎麼判" || no "㉓ --gate" "$G"
|
||||
has "$G" "ISEP 版本:v" && ok "㉔ 講得出版本(「哪一版」就是這件事的全部)" || no "㉔ 版本" "$G"
|
||||
has "$G" "問的是哪一份" && ok "㉕ 講得出問的是哪一個檔(同機可能有兩份)" || no "㉕ 路徑" "$G"
|
||||
|
||||
# 舊版的閘(v0.10.0 之前):拿真跡來測——git 歷史裡第一版 prod-write-guard
|
||||
OLD="$TMP/oldplug"
|
||||
mkdir -p "$OLD/hooks/lib" "$OLD/.claude-plugin"
|
||||
if git -C "$ROOT" show c263866:hooks/prod-write-guard.sh > "$OLD/hooks/prod-write-guard.sh" 2>/dev/null; then
|
||||
git -C "$ROOT" show c263866:.claude-plugin/plugin.json > "$OLD/.claude-plugin/plugin.json" 2>/dev/null
|
||||
OG=$(ISEP_NOTIFY_GUARD="$OLD/hooks/prod-write-guard.sh" python3 "$NOTIFY" --gate 2>&1)
|
||||
has "$OG" '\*\*block\*\*' && ok "㉖ 舊版的閘會擋——**用真跡測的**(c263866 那一版)" \
|
||||
|| no "㉖ 舊版會擋" "實得:$(printf '%s' "$OG" | tr '\n' ' ')"
|
||||
has "$OG" "開一個新的 session" && ok "㉗ 擋的時候說得出解法(更新 plugin + 開新 session)" \
|
||||
|| no "㉗ 說得出解法" "$OG"
|
||||
OB=$(ISEP_NOTIFY_GUARD="$OLD/hooks/prod-write-guard.sh" python3 "$NOTIFY" --text "被擋的訊息" 2>&1)
|
||||
has "$OB" "不繞路" && ok "㉘ 閘說會擋就不送——**不從 python 這條路繞過去**" \
|
||||
|| no "㉘ 不繞路" "$OB"
|
||||
has "$OB" "被擋的訊息" && ok "㉙ 被擋也照樣把原文吐出來(擋≠靜默)" || no "㉙ 原文" "$OB"
|
||||
else
|
||||
printf ' ⏭️ SKIP ㉖㉗㉘㉙:拿不到 c263866 的舊版閘(淺 clone?)\n'
|
||||
fi
|
||||
|
||||
NG=$(ISEP_NOTIFY_GUARD="$TMP/沒有這支.sh" python3 "$NOTIFY" --gate 2>&1)
|
||||
has "$NG" '\*\*unknown\*\*' && ok "㉚ 找不到閘 ⇒ 誠實回 unknown(不謊稱 pass)" \
|
||||
|| no "㉚ unknown" "$NG"
|
||||
|
||||
touch /dev/null
|
||||
STAMP_BAK=""
|
||||
if [ -f /tmp/.prod-write-ok ]; then STAMP_BAK=1; fi
|
||||
if [ -z "$STAMP_BAK" ]; then
|
||||
touch /tmp/.prod-write-ok
|
||||
SG=$(python3 "$NOTIFY" --gate 2>&1)
|
||||
[ -f /tmp/.prod-write-ok ] \
|
||||
&& ok "㉛ 戳記在場時不探針——**探一次就把總管那次授權燒掉了**" \
|
||||
|| no "㉛ 不燒戳記" "探針把 /tmp/.prod-write-ok 吃掉了"
|
||||
rm -f /tmp/.prod-write-ok
|
||||
else
|
||||
printf ' ⏭️ SKIP ㉛:這台機器上本來就有 /tmp/.prod-write-ok,不動它\n'
|
||||
fi
|
||||
|
||||
echo "── F 群:開 session 自己跑過一次(票上驗收 3)────────────────────"
|
||||
IN='{"hook_event_name":"SessionStart","session_id":"t"}'
|
||||
H=$(printf '%s' "$IN" | ISEP_NAG_NO_NOTIFY=1 ISEP_NAG_FIXTURE="$TMP/full.json" \
|
||||
ISEP_NAG_TIMEOUT=40 bash "$HOOK" 2>/dev/null)
|
||||
printf '%s' "$H" | python3 -c 'import json,sys;json.load(sys.stdin)' 2>/dev/null \
|
||||
&& ok "㉜ SessionStart 吐得出合法 JSON(吐壞的會被 harness 丟掉)" \
|
||||
|| no "㉜ 合法 JSON" "實得:$(printf '%s' "$H" | head -c 200)"
|
||||
has "$H" "additionalContext" && ok "㉝ 真的把報告注入眼前(不是印在誰都看不到的地方)" \
|
||||
|| no "㉝ 注入" "$H"
|
||||
has "$H" "catch" && no "㉞ 不該有例外堆疊" "有" || ok "㉞ 沒有噴例外"
|
||||
CH=$(printf '%s' "$IN" | CLAUDE_CODE_CHILD_SESSION=1 bash "$HOOK" 2>/dev/null)
|
||||
[ -z "$CH" ] && ok "㉟ 子 session 不叫(subagent 開工不是 leo 開工)" \
|
||||
|| no "㉟ 子 session 不叫" "實得:$CH"
|
||||
|
||||
echo
|
||||
printf '通過 %d 條,失敗 %d 條\n' "$PASS" "$FAIL"
|
||||
[ "$FAIL" = 0 ]
|
||||
Executable
+186
@@ -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 ]
|
||||
Executable
+183
@@ -0,0 +1,183 @@
|
||||
#!/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
|
||||
Reference in New Issue
Block a user