沒人會叫的事會自己叫+讀不完的必讀檔要被整理(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
+416
View File
@@ -0,0 +1,416 @@
#!/usr/bin/env python3
"""isep-notify — 發一則 Telegram 給 leo,而且**發不出去的時候不會安靜**
inkstone/ISEP#93
━━ 這支存在的理由(2026-08-28 實測出來的限制)━━━━━━━━━━━━━━━━━━━━━━━━
通道本身早就有(`notify_leo`,一條 curl、不需金鑰,見頂層 wiki `agent-memory.md`)。
問題不在通道,在**閘**
· `prod-write-guard.sh` 擋「打到線上實例的寫入型請求」,而發一則 Telegram
在它眼裡跟「部署一個工作流上線」長得一模一樣(同一個 named webhook 家族路徑)
· v0.10.0 已經修好——只放行 `notify_leo` 這**一個名字**
· **但 hook 的註冊路徑是 session 啟動當下寫死的** ⇒ 更新 plugin 之後,
要開一個**新** session 才會載到新那份
⇒ 所以「能不能發得出去」是**每個 session 各自不同的事實**,不是全域設定。
這支的工作就是:**先去問這個 session 的閘,再決定怎麼辦**,而不是先射再說。
🔴 **發不出去而靜默,等於沒做。** 三條出路,一條都不能省:
① 先問閘(`--gate`):把「這個 session 載到的是哪一版、它會不會擋」變成查得到的事實
② 閘說會擋 ⇒ **不送**(不繞路,見下),改走退路:貼回票上 + 大聲印在眼前
③ 送出去了也要驗**內層** `data.data.ok`——外層 200 不算送到(wiki 記過這個坑)
━━ 為什麼閘說會擋就不送,而不是「反正 python 打得出去」━━━━━━━━━━━━━━━
這支用 urllib 打 HTTP`prod-write-guard` 掛在 `Bash` 上——**它看不到這支**。
也就是說:不問閘就送,一定送得出去。**而那正是不能做的事。**
leo 2026-08-11:「hook 要不讓它對正式環境做任何推送,不是某種推送」
leo(同期):「人就會學會繞過它,那它就等於不存在」
一支「繞過閘也照送」的工具,會讓那道閘對整條 python 路徑失效。
⇒ 本支**自己去問那道閘**,閘說不行就不行。這比 shell 那條路更嚴,不是更鬆。
━━ 用法 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
isep-notify --gate 只印閘的判定(不送、不碰網路)
isep-notify --text "…" 送一則
isep-notify --text-file <檔> 內文放檔案(長訊息用)
isep-notify --text "…" --fallback-ticket inkstone/ISEP#93
送不出去時貼回那張票
isep-notify --text "…" --dry-run 全流程走一遍,只是不真的送
離開碼:0 leo 拿得到了(Telegram 送達,或退路留在票上)
1 = 兩條路都沒走通(這時 stderr 一定有一段紅字,不會安靜)
環境變數(都有預設,測試才需要動):
ISEP_NOTIFY_URL notify_leo 的 trigger 網址
ISEP_NOTIFY_IDENTITY 貼票時的【身份】欄
ISEP_NOTIFY_STATE_DIR 閘判定快取的位置(預設 ~/.claude/isep-nag
ISEP_NOTIFY_GUARD 指定要問哪一支 prod-write-guard(測試用)
ISEP_NOTIFY_OFFLINE=1 不打任何網路(測試用)
"""
import hashlib
import json
import os
import re
import subprocess
import sys
import time
import urllib.error
import urllib.request
NOTIFY_URL = os.environ.get(
"ISEP_NOTIFY_URL",
"https://arcrun-cypher-executor.leo21c.workers.dev"
"/webhooks/named/leo/notify_leo/trigger",
)
IDENTITY = os.environ.get("ISEP_NOTIFY_IDENTITY", "【身份】總管/inkstone/ISEP-")
STATE_DIR = os.environ.get("ISEP_NOTIFY_STATE_DIR") or os.path.join(
os.path.expanduser("~"), ".claude", "isep-nag")
GITEA_HOST = os.environ.get("TICKET_HOST") or "https://git.uncle6.me"
PROD_STAMP = "/tmp/.prod-write-ok"
GATE_FACT = "gate-verdict.json"
# ══ 一、這個 session 的閘是哪一份、哪一版 ═══════════════════════════════
#
# 🔴 這一段就是工單說的「**把『這個 session 的閘是哪一版』變成它自己查得到的事實**」。
# 三個來源,可信度由高到低——**而且一定把來源講出來**,
# 因為「我問到的那份」跟「這個 session 真的註冊的那份」可能不是同一個檔。
def find_guard():
"""回 (guard 路徑, plugin 根目錄, 來源說明, 可信度)。找不到回 (None, ...)。"""
cands = []
forced = os.environ.get("ISEP_NOTIFY_GUARD")
if forced:
# 測試用:直接指定要問哪一支(測「舊版會擋」時把歷史版本擺進來)
return forced, os.path.dirname(os.path.dirname(forced)), "ISEP_NOTIFY_GUARD 指定", "high"
root = os.environ.get("CLAUDE_PLUGIN_ROOT")
if root:
# hook 裡跑:這就是**這個 session 真的註冊的那一份**,沒有第二種可能
cands.append((root, "CLAUDE_PLUGIN_ROOT(這個 session 註冊的那一份)", "high"))
remembered = _read_fact()
if remembered and remembered.get("plugin_root"):
cands.append((remembered["plugin_root"],
"SessionStart 當時記下來的那一份", "high"))
here = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
cands.append((here, "本腳本自己所在的那一份(不一定是 session 註冊的)", "low"))
for r, why, conf in cands:
g = os.path.join(r, "hooks", "prod-write-guard.sh")
if os.path.isfile(g):
return g, r, why, conf
return None, None, "找不到任何 prod-write-guard.sh", "none"
def plugin_version(root):
try:
with open(os.path.join(root, ".claude-plugin", "plugin.json")) as f:
return json.load(f).get("version") or "未知"
except Exception:
return "未知"
def probe_gate(url=None):
"""拿**這一支真的要送的請求**去問閘:你會不會擋?
回一個 dict,永遠有 verdict
pass 閘看過了,放行
block 閘會擋(=這個 session 載到的是舊版,或白名單被改窄了)
unknown 問不出來(沒有閘、戳記在場、腳本壞了)——**不會謊稱 pass**
"""
url = url or NOTIFY_URL
guard, root, why, conf = find_guard()
fact = {
"at": int(time.time()),
"url": url,
"guard": guard,
"plugin_root": root,
"source": why,
"confidence": conf,
"version": plugin_version(root) if root else "未知",
}
if not guard:
fact.update(verdict="unknown", reason="這個環境找不到 ISEP 的 prod-write-guard"
"(沒有閘 ⇒ 沒有東西被繞過)")
return fact
# 🔴 戳記在場就**不探針**`prod-write-guard` 的戳記是**單次、用完即丟**的,
# 探一次就把總管剛按下去的那一次授權燒掉了。
# (而戳記在場本來就代表「現在允許寫」⇒ 送得出去,不必問。)
if os.path.exists(PROD_STAMP):
fact.update(verdict="unknown",
reason="/tmp/.prod-write-ok 在場:探針會把它燒掉(單次用完即丟),"
"所以不問。戳記在場=現在允許寫入")
return fact
payload = json.dumps({
"tool_name": "Bash",
"tool_input": {
"command": ("curl -s -X POST %s -H 'Content-Type: application/json' "
"-d '{\"text\":\"[總管] gate probe\"}'" % url),
},
})
try:
p = subprocess.run(["bash", guard], input=payload, capture_output=True,
text=True, timeout=30)
except Exception as e:
fact.update(verdict="unknown", reason="問不動那支閘:%s" % e)
return fact
if p.returncode == 0:
fact.update(verdict="pass", reason="閘看過這一則通知,放行")
elif p.returncode == 2:
fact.update(verdict="block",
reason="閘會擋。這個 session 載到的 ISEP 是 v%s——"
"它的 prod-write-guard 還認不出 notify_leo 不是部署"
% fact["version"],
gate_says=(p.stderr or "").strip()[:400])
else:
fact.update(verdict="unknown",
reason="那支閘回了離開碼 %d(既不是放行也不是擋)" % p.returncode)
return fact
def _fact_path():
return os.path.join(STATE_DIR, GATE_FACT)
def _read_fact():
try:
with open(_fact_path()) as f:
return json.load(f)
except Exception:
return None
def remember_fact(fact):
"""把判定寫成檔:之後任何一支腳本(含手動跑的)都查得到,不必自己再探一次。"""
try:
os.makedirs(STATE_DIR, exist_ok=True)
with open(_fact_path(), "w") as f:
json.dump(fact, f, ensure_ascii=False, indent=2)
except Exception:
pass
def gate_report(fact):
icon = {"pass": "🟢", "block": "🔴", "unknown": "🟡"}.get(fact["verdict"], "🟡")
lines = [
"%s 這個 session 的通知閘:**%s**" % (icon, fact["verdict"]),
" ISEP 版本:v%s" % fact.get("version", "未知"),
" 問的是哪一份:%s" % (fact.get("guard") or "(找不到)"),
" 來源可信度:%s%s" % (fact.get("confidence"), fact.get("source")),
" 理由:%s" % fact.get("reason", ""),
]
if fact["verdict"] == "block":
lines += [
"",
" ⇒ **這個 session 發不出 Telegram**,不是網路問題,是閘的版本問題。",
" ⇒ 解法(兩步,缺一不可):",
" claude plugin update isep@inkstone # 拿到 v0.10.0 以上",
" **開一個新的 session** # 註冊路徑是啟動當下寫死的",
" ⇒ 在那之前,本支一律走退路(貼回票上+印在眼前),不會安靜。",
]
return "\n".join(lines)
# ══ 二、真的送 ═══════════════════════════════════════════════════════════
def send_telegram(text):
"""回 (ok, 說明)。**內層 data.data.ok 才算送到**——外層 200 不算。"""
if os.environ.get("ISEP_NOTIFY_OFFLINE") == "1":
return False, "ISEP_NOTIFY_OFFLINE=1:刻意不打網路"
body = json.dumps({"text": text}).encode()
# 🔴 一定要帶 User-Agent2026-08-28 實撞):不帶的話 urllib 送出的是
# `Python-urllib/3.x`**Cloudflare 直接回 403 error code 1010**bad user agent)。
# 症狀非常會騙人——wiki 說「一條 curl、不需任何金鑰」是對的,
# curl 自己帶 UA,所以同一個網址用 curl 通、用 python 不通
# ⇒ 會被誤讀成「通道壞了」或「這個 session 沒權限」,而兩個都不是。
req = urllib.request.Request(
NOTIFY_URL, data=body, method="POST",
headers={"Content-Type": "application/json",
"User-Agent": "isep-notify/1.0 (+inkstone/ISEP#93)"})
try:
raw = urllib.request.urlopen(req, timeout=30).read().decode()
except urllib.error.HTTPError as e:
return False, "HTTP %s%s" % (e.code, e.read().decode()[:200])
except Exception as e:
return False, "連不上:%s" % e
try:
d = json.loads(raw)
except Exception:
return False, "回應不是 JSON%s" % raw[:200]
# 🔴 wiki 記過這個坑:外層 success=true、內層 404 也發生過
inner = ((d.get("data") or {}).get("data") or {})
if inner.get("ok") is True:
return True, "送到了(message_id=%s" % inner.get("message_id")
if "找不到 workflow" in raw or "not found" in raw.lower():
# 🔴 這一種要分開講:**閘沒擋、網路也通,是那台實例上根本沒有這支工作流**
# wiki `agent-memory.md` 記過一次:2026-08-10 KV 整批換新時定義消失)。
# 講成「發不出去」會讓人去修錯的地方——修閘、修 token,而斷點不在那裡。
return False, ("通道本身斷了:實例上找不到 `notify_leo` 工作流。"
"**這不是閘擋的、也不是網路問題**——要有人把它重新 push 上去"
"`mira` repo `workflows/notify-leo.yaml`)。原文:%s" % raw[:200])
return False, "外層通了但**內層沒 ok**%s" % raw[:300]
# ══ 三、退路:貼回票上 ═══════════════════════════════════════════════════
def gitea_token():
for env in ("GITEA_TOKEN_CLAUDE_CODE", "GITEA_TOKEN"):
v = os.environ.get(env)
if v:
return v
root = os.environ.get("CLAUDE_PROJECT_DIR") or os.getcwd()
host = GITEA_HOST.split("//")[-1].rstrip("/")
try:
out = subprocess.run(["git", "-C", root, "remote", "-v"],
capture_output=True, text=True, timeout=20).stdout
except Exception:
out = ""
for line in out.splitlines():
if host in line:
m = re.search(r"//[^:/]+:([^@]+)@", line)
if m:
return m.group(1)
return None
def comment_on_ticket(ref, text, gate_fact):
"""把那則發不出去的訊息貼回票上。回 (ok, 說明)。
🔴 **同一則不重複貼**:內容雜湊存在狀態目錄,24 小時內一樣就跳過。
票被洗版的下場跟發不出去一樣——leo 會學會不看它。
"""
m = re.match(r"^([\w.-]+)/([\w.-]+)#(\d+)$", (ref or "").strip())
if not m:
return False, "票的寫法是 owner/repo#N,你給的是:%s" % ref
owner, repo, num = m.group(1), m.group(2), m.group(3)
key = hashlib.sha256(("%s|%s" % (ref, text)).encode()).hexdigest()[:16]
seen = os.path.join(STATE_DIR, "commented-%s" % key)
if os.path.exists(seen) and time.time() - os.path.getmtime(seen) < 24 * 3600:
return True, "24 小時內已經貼過一模一樣的內容,跳過(不洗版)"
if os.environ.get("ISEP_NOTIFY_OFFLINE") == "1":
return False, "ISEP_NOTIFY_OFFLINE=1:刻意不打網路"
tok = gitea_token()
if not tok:
return False, "拿不到 Gitea tokenGITEA_TOKEN_CLAUDE_CODE 沒設,remote 也沒帶憑證)"
body = "\n".join([
IDENTITY,
"",
"🔴 **這則本來要用 Telegram 發給 leo,但這個 session 發不出去**——所以貼在這裡。",
"",
gate_report(gate_fact),
"",
"---",
"",
text,
])
url = "%s/api/v1/repos/%s/%s/issues/%s/comments" % (GITEA_HOST, owner, repo, num)
req = urllib.request.Request(
url, data=json.dumps({"body": body}).encode(), method="POST",
headers={"Authorization": "token %s" % tok,
"Content-Type": "application/json"})
try:
r = json.load(urllib.request.urlopen(req, timeout=40))
except urllib.error.HTTPError as e:
return False, "Gitea %s%s" % (e.code, e.read().decode()[:200])
except Exception as e:
return False, "連不上 Gitea%s" % e
try:
os.makedirs(STATE_DIR, exist_ok=True)
open(seen, "w").close()
except Exception:
pass
return True, "已貼到 %s%s" % (ref, r.get("html_url") or "")
# ══ 四、CLI ══════════════════════════════════════════════════════════════
def notify(text, fallback_ticket=None, dry_run=False):
"""回 (離開碼, 給人看的報告)。**任何一條路都會留下一段話,不會安靜。**"""
fact = probe_gate()
remember_fact(fact)
out = [gate_report(fact), ""]
if dry_run:
out.append("🧪 --dry-run:到此為止,什麼都沒送。要送的內容是:")
out.append("")
out.append(text)
return 0, "\n".join(out)
if fact["verdict"] == "block":
out.append("⛔ 閘說會擋 ⇒ **本支不繞路**,直接走退路。")
sent, why = False, "沒有嘗試(閘會擋,繞過去等於拆了那道閘)"
else:
sent, why = send_telegram(text)
out.append(("✅ Telegram%s" if sent else "❌ Telegram%s") % why)
if sent:
return 0, "\n".join(out)
# ── 退路 ──────────────────────────────────────────────────────────
if fallback_ticket:
ok, detail = comment_on_ticket(fallback_ticket, text, fact)
out.append(("✅ 退路(貼回票上):%s" if ok else "❌ 退路(貼回票上):%s") % detail)
else:
ok, detail = False, "沒有給 --fallback-ticket,沒有票可以貼"
out.append("⚠️ 退路(貼回票上):%s" % detail)
out += [
"",
"🔴 **leo 的手機上沒有出現這則。** 原文照抄在下面,"
"看到這段的人有義務把它講出去:",
"",
text,
]
return (0 if ok else 1), "\n".join(out)
def main(argv):
text, ticket, dry, want_json = None, None, False, False
mode = "notify"
i = 0
while i < len(argv):
a = argv[i]
if a == "--gate":
mode = "gate"
elif a == "--text":
i += 1; text = argv[i]
elif a == "--text-file":
i += 1
with open(argv[i]) as f:
text = f.read()
elif a == "--fallback-ticket":
i += 1; ticket = argv[i]
elif a == "--dry-run":
dry = True
elif a == "--json":
want_json = True
elif a in ("-h", "--help"):
print(__doc__)
return 0
else:
print("不認得的參數:%s--help 看用法)" % a, file=sys.stderr)
return 2
i += 1
if mode == "gate":
fact = probe_gate()
remember_fact(fact)
print(json.dumps(fact, ensure_ascii=False, indent=2) if want_json
else gate_report(fact))
return 0
if not text or not text.strip():
print("🔴 沒有內容可以發(--text 或 --text-file", file=sys.stderr)
return 2
if not text.lstrip().startswith("["):
text = "[總管] " + text.lstrip() # 署名鐵律:text 開頭必署名
code, report = notify(text, ticket, dry)
print(report, file=(sys.stdout if code == 0 else sys.stderr))
return code
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))