Files
ISEP/hooks/overdue-nag-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

110 lines
6.5 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
# 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