沒人會叫的事會自己叫+讀不完的必讀檔要被整理(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
+109
View File
@@ -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