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