沒人會叫的事會自己叫+讀不完的必讀檔要被整理(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
+393
View File
@@ -0,0 +1,393 @@
#!/usr/bin/env python3
"""isep-nag — 把「沒有人會叫的事」叫出來(inkstone/ISEP#93
━━ 這支解的問題 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
有些事情**沒有人會叫**:一張票掛著等 leo 三天了、一個 milestone 逾期了、
一根棒子交回來躺著沒人接。它們不會壞掉、不會噴錯,**只是靜靜地過期**。
leo 整天上課,一天看兩眼。**不叫,就等於這件事不會發生。**
━━ 🔴 刻意不做什麼(這不是簡化,是判斷)━━━━━━━━━━━━━━━━━━━━━━━━━━
**不做排程輪詢、不做 webhook fan-out、不掛 Gitea Actions。**
· 頂層 `CLAUDE.md`「避免再被 GitHub flag 的硬規則」:禁一事件 fan-out 到多 repo
· `issue-handle` skill:「**有事才讀**」,換成 Gitea 也不放寬
· ISEP v0.6.0 §8.2:第一版不依賴 runner——「壞掉的形式是『以為有人在跑』,
比『沒人跑』貴得多」
⇒ 本支只在**兩個時機**跑:開 session 時順手一次、人要看的時候手動一次。
**已知限制,誠實寫在這裡**:它只在 CC 醒著的時候會叫。
「CC 整個沒在跑」的那一段補不到——那要 leo 決定要不要開 Gitea 的通知權限
(機器帳號 `claude-code` 在 `bots` 團隊,`/orgs/…/hooks` 回 403,裝不了)。
**但腳本版本身是完整可用的,不是半成品。**
━━ 撈三種東西 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
① 逾期的 milestone 期限過了、票還沒關 ⇒ 那個版本交不出來了
② 掛著等 leo 的票 `Human` 或指派給 leo**標等了幾天**
③ 棒子掉在地上的票 `s/doing``s/review` 卻很多天沒動 ⇒ 沒有人在做
每一條都用白話寫(`CLAUDE.md` 規則五):一眼看懂、代號附一句人話、
講「這對你意味什麼」而不是系統內部狀態。
🔴 **沒東西可報時會說「查過了,沒有」**,不會安靜結束——
安靜跟壞掉長得一模一樣,而分不出來的東西沒有人敢信。
━━ 用法 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
isep-nag 撈一次,白話印出來
isep-nag --json 同一份資料的機器格式
isep-nag --notify 撈完順便發 Telegram(走 isep-notify,發不出去有退路)
isep-nag --short 只印 Telegram 版(很短,leo 手機上看的那則)
isep-nag --both 長版+分隔線+短版,一次算完(開場的 hook 用這個)
isep-nag --fixture <檔> 用假資料跑(離線測試用,完全不碰網路)
isep-nag --now <epoch> 把時鐘定住(測試用)
環境變數:
ISEP_NAG_REPOS 只看這幾個 repo(空白分隔)。預設:整個 org 撈一次
ISEP_NAG_ORG 預設 inkstone
ISEP_NAG_STALE_DAYS 「幾天沒動算掉棒」,預設 3
TICKET_HOST Gitea 位址,預設 https://git.uncle6.me
"""
import concurrent.futures as cf
import json
import os
import re
import subprocess
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
from datetime import datetime, timedelta, timezone
# leo 看的是台北時間。容器多半跑在 UTC ⇒ 不指定就會印出一個他要自己換算的數字。
try:
from zoneinfo import ZoneInfo
TAIPEI = ZoneInfo("Asia/Taipei")
except Exception:
TAIPEI = timezone(timedelta(hours=8))
HOST = os.environ.get("TICKET_HOST") or "https://git.uncle6.me"
ORG = os.environ.get("ISEP_NAG_ORG") or "inkstone"
STALE_DAYS = int(os.environ.get("ISEP_NAG_STALE_DAYS") or 3)
HERE = os.path.dirname(os.path.abspath(__file__))
# 「等 leo」的兩個訊號:掛 Human 標籤,或指派給他本人。
HUMAN_LABEL = "Human"
LEO_LOGINS = {"leo", "leo21c", "Leo"}
# 「有人領了」的狀態標籤——掛著這個卻很久沒動,就是棒子掉在地上。
BATON_LABELS = {"s/doing", "s/review"}
def 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 = 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 api(path, tok):
url = "%s/api/v1%s" % (HOST, path)
req = urllib.request.Request(url, headers={"Authorization": "token %s" % tok})
try:
return json.load(urllib.request.urlopen(req, timeout=25))
except Exception:
return None
def ts(s):
"""Gitea 的時間字串 → epoch。看不懂就回 None(不猜)。"""
if not s:
return None
try:
return int(datetime.fromisoformat(s.replace("Z", "+00:00"))
.astimezone(timezone.utc).timestamp())
except Exception:
return None
def days(a, b):
return max(0, int((a - b) // 86400))
# ══ 一、把資料撈回來(或從 fixture 讀)═══════════════════════════════════
def collect(fixture=None):
"""回 {"milestones": [...], "issues": [...]}。撈不到就回空的,不編。"""
if fixture:
with open(fixture) as f:
return json.load(f)
tok = token()
if not tok:
return {"milestones": [], "issues": [], "error":
"拿不到 Gitea tokenGITEA_TOKEN_CLAUDE_CODE 沒設,remote 也沒帶憑證)"}
repos = os.environ.get("ISEP_NAG_REPOS", "").split()
if not repos:
rows = api("/orgs/%s/repos?limit=50" % ORG, tok) or []
repos = [r.get("full_name") for r in rows if r.get("full_name")]
# 🔴 平行撈,因為這支掛在 SessionStart 上——那是 leo 每次開工的關鍵路徑。
# 循序版實測 16 秒(16 個 repo + 5 頁票),**開場等 16 秒沒有人會忍受它,
# 而被忍不了的東西下一步就是被關掉**。平行之後落在 3 秒上下。
milestones = []
with cf.ThreadPoolExecutor(max_workers=8) as pool:
jobs = {pool.submit(api, "/repos/%s/milestones?state=open" % f, tok): f
for f in repos}
for job in cf.as_completed(jobs):
full = jobs[job]
try:
rows = job.result() or []
except Exception:
continue
for m in rows:
m = dict(m or {})
m["repo"] = full
milestones.append(m)
# 票用**一支跨 repo 的搜尋**撈,不是每個 repo 打一次:
# 16 個 repo × 一次 = 16 通 API,那個形狀就開始像輪詢了。
# 而且只撈**這三種標籤**Gitea 的 labels= 是 OR)——不是撈回全部再自己篩:
# 全撈是 5 頁,篩過是 1–2 頁。
# ⚠️ 已知取捨:**沒掛 Human 標籤、只是被指派給 leo 的票,這條路撈不到。**
# 真相源是標籤(`labels.yaml`),指派是輔助訊號 ⇒ 寧可漏掉那個邊角,
# 也不要為它把開場多花 13 秒。要撈全部就設 ISEP_NAG_ALL_ISSUES=1。
want = ",".join(sorted({HUMAN_LABEL} | BATON_LABELS))
qs = "" if os.environ.get("ISEP_NAG_ALL_ISSUES") == "1" else \
"&labels=%s" % urllib.parse.quote(want)
issues = []
for page in range(1, 6):
rows = api("/repos/issues/search?state=open&type=issues&limit=50&page=%d%s"
% (page, qs), tok) or []
if not rows:
break
issues.extend(rows)
if len(rows) < 50:
break
return {"milestones": milestones, "issues": issues}
# ══ 二、找出「該叫的事」═══════════════════════════════════════════════════
def findings(data, now):
over, waiting, dropped = [], [], []
for m in data.get("milestones") or []:
due = ts(m.get("due_on"))
if not due or due >= now:
continue # 沒設期限的不是節拍器;還沒到期的不叫
open_n = int(m.get("open_issues") or 0)
if open_n == 0:
continue # 票都關完了,只是沒人按關閉鈕——不吵
over.append({
"repo": m.get("repo") or "?",
"title": m.get("title") or "(沒有名字)",
"due": (m.get("due_on") or "")[:10],
"late_days": days(now, due),
"open": open_n,
"closed": int(m.get("closed_issues") or 0),
})
over.sort(key=lambda x: -x["late_days"])
for i in data.get("issues") or []:
if i.get("pull_request"):
continue # PR 有 pr-verdict-guard 在管,不重複點名
full = (i.get("repository") or {}).get("full_name") or ""
# 🔴 只看設定的那個 org。跨 repo 搜尋端點會把**舊 org 的同名票**一起回來
# (實測:`Leo/Arcrun#85` 與 `inkstone/Arcrun#85` 是同一件事的兩份),
# 照單全收會讓「26 張掉棒」裡有一半是重複——**灌水的清單等於沒有清單**。
if ORG and not full.startswith(ORG + "/"):
continue
labels = {(l or {}).get("name") for l in (i.get("labels") or [])}
who = {(a or {}).get("login") for a in (i.get("assignees") or [])}
upd = ts(i.get("updated_at")) or now
row = {
"ref": "%s#%s" % ((i.get("repository") or {}).get("full_name") or "?",
i.get("number")),
"title": (i.get("title") or "").strip(),
"quiet_days": days(now, upd),
"labels": sorted(labels),
"url": i.get("html_url") or "",
}
if HUMAN_LABEL in labels or (who & LEO_LOGINS):
waiting.append(row)
elif (labels & BATON_LABELS) and row["quiet_days"] >= STALE_DAYS:
dropped.append(row)
waiting.sort(key=lambda x: -x["quiet_days"])
dropped.sort(key=lambda x: -x["quiet_days"])
return {"overdue_milestones": over, "waiting_on_leo": waiting,
"dropped_batons": dropped}
# ══ 三、講出來(白話)═════════════════════════════════════════════════════
MAX_ROWS = int(os.environ.get("ISEP_NAG_MAX_ROWS") or 8)
BOTH_SEP = "␟--isep-nag-short--␟" # --both 的分隔線(不會出現在正文裡)
def _tail(rows, shown):
"""超過封頂的部分收成一行。**不是藏起來,是講出來還有幾張。**"""
rest = len(rows) - shown
return [" · …另外還有 %d 張(`isep-nag --json` 看全部)" % rest] if rest > 0 else []
def _waited(d):
"""0 天講「今天剛掛上」——「等了 0 天」讀起來像壞掉的計數器。"""
return "今天剛掛上" if d == 0 else "等了 %d 天" % d
def _clip(s, n):
s = (s or "").replace("\n", " ").strip()
return s if len(s) <= n else s[:n - 1] + "…"
def full_report(f, now, err=None):
"""給眼前看的長版。**沒東西也一定有話講。**"""
when = datetime.fromtimestamp(now, timezone.utc).astimezone(TAIPEI).strftime("%Y-%m-%d %H:%M")
out = ["🔔 催辦員查了一次(%s)——這些事沒有人會叫,所以它叫(inkstone/ISEP#93" % when]
if err:
out += ["", "🔴 撈不到資料:%s" % err,
" ⇒ **這不等於「沒有事情逾期」**,這是「我沒查到」。兩件事不要講成同一句。"]
return "\n".join(out)
n = sum(len(v) for v in f.values())
if n == 0:
out += ["", "✅ **查過了,沒有。** 三類都乾淨:",
" · 沒有逾期還開著票的 milestone",
" · 沒有掛著等你的票",
" · 沒有超過 %d 天沒動的 s/doings/review" % STALE_DAYS]
return "\n".join(out)
if f["overdue_milestones"]:
out += ["", "⏰ **逾期的 milestone(%d 個)**——期限過了,票還開著,"
"代表那個版本交不出來了:" % len(f["overdue_milestones"])]
for m in f["overdue_milestones"][:MAX_ROWS]:
out.append(" · %s 「%s」——%s 到期,**逾期 %d 天**,還有 %d 張票沒關"
% (m["repo"], m["title"], m["due"], m["late_days"], m["open"]))
out += _tail(f["overdue_milestones"], MAX_ROWS)
out.append(" ⇒ 對你意味著:不是改期限,就是把票搬走。**放著它只會繼續爛。**")
if f["waiting_on_leo"]:
out += ["", "🙋 **在等你的票(%d 張)**——只有你按得下去,"
"沒人替得了:" % len(f["waiting_on_leo"])]
for i in f["waiting_on_leo"][:MAX_ROWS]:
out.append(" · %s **%s**%s"
% (i["ref"], _waited(i["quiet_days"]), _clip(i["title"], 42)))
out += _tail(f["waiting_on_leo"], MAX_ROWS)
out.append(" ⇒ 對你意味著:回一個詞就能推動。不回,這幾件永遠不會發生。")
if f["dropped_batons"]:
out += ["", "🧤 **棒子掉在地上(%d 張)**——標著「有人在做」,"
"但超過 %d 天沒動:" % (len(f["dropped_batons"]), STALE_DAYS)]
for i in f["dropped_batons"][:MAX_ROWS]:
out.append(" · %s **靜了 %d 天**%s):%s"
% (i["ref"], i["quiet_days"], "".join(i["labels"]),
_clip(i["title"], 38)))
out += _tail(f["dropped_batons"], MAX_ROWS)
out.append(" ⇒ 對你意味著:這些不是在做,是沒人接。要嘛派人,要嘛改回 s/todo。")
return "\n".join(out)
def short_report(f, now, err=None):
"""Telegram 版:leo 在手機上一眼掃完、回一個詞。**寧短勿長。**"""
if err:
return "[總管] 🔴 催辦員查不到資料(%s)。這不是「沒事」,是「沒查到」。" % _clip(err, 60)
a, b, c = (len(f["overdue_milestones"]), len(f["waiting_on_leo"]),
len(f["dropped_batons"]))
if a + b + c == 0:
return "[總管] ✅ 查過了,沒有:沒有逾期的 milestone、沒有在等你的票、沒有掉在地上的棒子。"
lines = ["[總管] 🔔 有 %d 件沒人叫的事:" % (a + b + c)]
if a:
top = f["overdue_milestones"][0]
lines.append("⏰ %d 個 milestone 逾期,最久的是「%s」(%s,逾期 %d 天,%d 張票沒關)"
% (a, _clip(top["title"], 20), top["repo"].split("/")[-1],
top["late_days"], top["open"]))
if b:
top = f["waiting_on_leo"][0]
lines.append("🙋 %d 張票在等你,最久的 %s %s%s"
% (b, top["ref"], _waited(top["quiet_days"]),
_clip(top["title"], 26)))
if c:
top = f["dropped_batons"][0]
lines.append("🧤 %d 張標著「有人在做」卻沒人動,最久 %d 天:%s"
% (c, top["quiet_days"], top["ref"]))
lines.append("回「看」我就把完整清單貼出來。")
return "\n".join(lines)
# ══ 四、CLI ══════════════════════════════════════════════════════════════
def main(argv):
fixture = now = None
want_json = short = do_notify = both = False
ticket = "inkstone/ISEP#93"
i = 0
while i < len(argv):
a = argv[i]
if a == "--json":
want_json = True
elif a == "--short":
short = True
elif a == "--both":
# 開場的 hook 用這個:長版+短版**一次算完**。
# 分兩次呼叫=打兩輪 Gitea=開場多等 5 秒,而那是 leo 每次開工的關鍵路徑。
both = True
elif a == "--notify":
do_notify = True
elif a == "--fixture":
i += 1; fixture = argv[i]
elif a == "--now":
i += 1; now = int(argv[i])
elif a == "--fallback-ticket":
i += 1; ticket = argv[i]
elif a in ("-h", "--help"):
print(__doc__); return 0
else:
print("不認得的參數:%s--help 看用法)" % a, file=sys.stderr)
return 2
i += 1
now = now if now is not None else int(time.time())
try:
data = collect(fixture)
except Exception as e:
data = {"milestones": [], "issues": [], "error": "撈的時候炸了:%s" % e}
err = data.get("error")
f = findings(data, now)
if both:
print(full_report(f, now, err))
print(BOTH_SEP)
print(short_report(f, now, err))
elif want_json:
print(json.dumps({"now": now, "error": err, "findings": f},
ensure_ascii=False, indent=2))
elif short:
print(short_report(f, now, err))
else:
print(full_report(f, now, err))
if do_notify:
# 🔴 走 isep-notify,不自己打 HTTP:那支才會先問閘、發不出去才有退路。
# 在這裡自己 urlopen 一次,就等於多開一條沒有退路的路。
p = subprocess.run(
[sys.executable, os.path.join(HERE, "isep-notify"),
"--text", short_report(f, now, err), "--fallback-ticket", ticket],
capture_output=True, text=True)
sys.stderr.write(p.stdout + p.stderr)
return p.returncode
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))