da9bd53cae
兩張票放同一條分支:都是「該發生卻不會自己發生的事」,觸發點都在 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),待總管定版。
110 lines
6.5 KiB
Bash
Executable File
110 lines
6.5 KiB
Bash
Executable File
#!/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
|