沒人會叫的事會自己叫+讀不完的必讀檔要被整理(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:]))
+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:]))
+598
View File
@@ -0,0 +1,598 @@
#!/usr/bin/env python3
"""wiki-compress — 讀不完的必讀檔,等於沒有(inkstone/ISEP#89
━━ 這支解的問題 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
wiki 一直長大,沒有人會回頭整理。`mistakes.md` 是「必讀」等級的檔案,
實測 **7,680 行、168 條**——**沒有人真的每次都從頭讀。**
leo 2026-08-27 說那天「失去記憶」。東西還在,只是**沒人讀得完**。
已經有的三支管的是別的事:`wiki-secret-scan`(寫進去安不安全)、
`wiki-first-search``wiki-first-police`(有沒有先查)。**沒有任何一支管「太大了」。**
━━ 🔴 最重要的設計決定:**只搬不改** ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
票上第 2 條驗收是「**不能弄丟任何一條教訓**(要能逐條對帳)」。
⇒ 所以本支**不改寫任何一條的正文**,一個字都不動。它只做三件事:
① 產生**目錄**(每條一行:標題+日期+票號+去哪了)放回原檔開頭
② 把舊的整條**原封不動搬**到 `<檔名>-archive-YYYY-MM.md`
③ 把「同一件事的第 N 次」在目錄裡標成 `×N`,**而不是把它們合併掉**
「合併同類、濃縮成一行」聽起來更漂亮,但那要重寫正文——
**而重寫的當下沒有人會發現弄丟了什麼**(票上原話)。
只搬不改的好處是:`verify` 可以用**內文雜湊**逐條對帳,
「沒弄丟」不是宣稱,是算出來的。
━━ 五個動詞 ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
wiki-compress audit [<wiki 目錄或檔>...] 哪幾個檔太大了、大在哪
wiki-compress plan <檔> 要怎麼壓:同類幾群、可搬幾條
wiki-compress apply <檔> --ticket owner/repo#N
真的壓(只搬不改),並留交付紀錄
wiki-compress verify <壓縮前的檔> <壓縮後的檔> [搬去的檔...]
逐條對帳:有沒有弄丟(票上驗收 2)
wiki-compress bench <壓縮前的檔> <壓縮後的檔> [搬去的檔...]
量「查得比壓之前快」(票上驗收 1)
🔴 `apply` **一定要 --ticket**(票上驗收 3:壓縮這件事本身要有票、有交付紀錄,
不准順手做完沒人知道)。它會把「哪一版壓掉了什麼」寫進 `.compress-log.md`。
"""
import argparse
import datetime as dt
import hashlib
import os
import re
import sys
THRESHOLD = int(os.environ.get("WIKI_COMPRESS_THRESHOLD") or 1200) # 行
LOG_NAME = ".compress-log.md"
FENCE = re.compile(r"^\s*(```|~~~)")
H2 = re.compile(r"^##\s+(?!#)(.+?)\s*$")
DATE = re.compile(r"(20\d\d)[-/](\d\d)[-/]?(\d\d)?")
TICKET = re.compile(r"\b([\w.-]+/[\w.-]+#\d+)\b")
# ══ 一、把一個 wiki 檔切成「條」═══════════════════════════════════════════
#
# 🔴 一定要跳過 code fence。`mistakes.md` 第 534 行是 ```` # 兩邊各跑一次 ````
# ——那是**程式碼註解**,不是標題。照抄 grep '^#' 會把它當成一條,
# 然後 verify 會拿一個不存在的東西去對帳,全盤失準。
def split_entries(text):
"""回 (前言, [條...])。每條 = {"title","body","line","date","tickets","hash"}。"""
lines = text.split("\n")
fenced = False
marks = []
for i, ln in enumerate(lines):
if FENCE.match(ln):
fenced = not fenced
continue
if fenced:
continue
m = H2.match(ln)
if m:
marks.append((i, m.group(1).strip()))
pre = "\n".join(lines[:marks[0][0]]) if marks else text
out = []
for n, (i, title) in enumerate(marks):
end = marks[n + 1][0] if n + 1 < len(marks) else len(lines)
body = "\n".join(lines[i:end]).rstrip()
out.append({
"title": title,
"body": body,
"line": i + 1,
"lines": end - i,
"date": entry_date(title, body),
"tickets": sorted(set(TICKET.findall(body))),
"hash": body_hash(body),
})
return pre, out
def body_hash(body):
"""對帳用的指紋:把空白正規化之後雜湊。
正規化空白(而不是逐字元比對)是為了讓「搬檔時尾端多/少一個換行」
這種**無意義的差異**不要被報成「弄丟了」——誤報會讓對帳表沒人看。
真的改了字,雜湊一定不同。
"""
return hashlib.sha256(re.sub(r"\s+", " ", body).strip().encode()).hexdigest()[:16]
def entry_date(title, body):
"""條的日期:標題裡的優先(那是人寫的),沒有才往內文找第一個。"""
for src in (title, body[:1500]):
m = DATE.search(src or "")
if m:
y, mo, d = m.group(1), m.group(2), m.group(3) or "01"
try:
return dt.date(int(y), int(mo), int(d)).isoformat()
except ValueError:
return "%s-%s-01" % (y, mo)
return ""
# ══ 二之〇、自承重複:**這一條自己說它是同一件事的第 N 次** ══════════════
#
# 為什麼另外做這一格(實測數字,不是設計偏好):
# `mistakes.md` 260 條,用**標題相似度**分群只找得到 1 對——
# 因為同一個病每次都被寫成完全不同的句子(那正是它一犯再犯的原因)。
# 但其中 **68 條的內文自己寫著「同款第三次」「又犯一次」「同一個病」**。
# ⇒ 人下的判斷比機器算的相似度準得多。**照抄人已經寫下的結論,不要重新猜一遍。**
#
# 這 68 條就是「合併成一條、記發生過幾次」最該下手的地方,
# 但**合併要人來做**(要重寫正文,機器一動就會弄丟東西)⇒ 本支只把它們點名出來。
SELF_DUP = re.compile(r"同款|第[一二三四五六七八九十百0-9]+次|又犯|再犯|又一次|"
r"同一個病|同一形狀|同樣的錯|重蹈")
def self_declared_repeat(e):
"""回它自己講的那句話(沒有就回 None)。**引它的原話,不要自己下結論。**"""
for src in (e["title"], e["body"][:900]):
m = SELF_DUP.search(src or "")
if m:
i = max(0, m.start() - 12)
return re.sub(r"\s+", " ", (src[i:m.end() + 10]).strip())
return None
# ══ 二、同類分群(標 ×N,不合併)═════════════════════════════════════════
STOP = set("的了是在有和與及對於把被就都也還很更最一個這那我你他它們不沒要會能"
"MISTAKE mistake 方法 為什麼 怎麼".split())
def tokens(title):
t = re.sub(r"[(].*?[)]", " ", title)
t = re.sub(r"[^\w一-鿿]+", " ", t)
out = set()
for w in t.split():
if re.fullmatch(r"[A-Za-z0-9_.-]+", w):
if len(w) > 2 and w not in STOP:
out.add(w.lower())
else:
for i in range(len(w) - 1): # 中文用 bigram
bg = w[i:i + 2]
if bg not in STOP:
out.add(bg)
return out
def cluster(entries, thresh=0.42):
"""同一件事的第 N 次 ⇒ 分到同一群。回 [[索引...]],單獨一條的也算一群。"""
toks = [tokens(e["title"]) for e in entries]
parent = list(range(len(entries)))
def find(x):
while parent[x] != x:
parent[x] = parent[parent[x]]
x = parent[x]
return x
for a in range(len(entries)):
for b in range(a + 1, len(entries)):
ta, tb = toks[a], toks[b]
if not ta or not tb:
continue
j = len(ta & tb) / float(len(ta | tb))
# 共用票號=同一條線,門檻放寬(票號是人下的判斷,比字面相似可靠)
same_ticket = bool(set(entries[a]["tickets"]) & set(entries[b]["tickets"]))
if j >= thresh or (same_ticket and j >= thresh * 0.6):
parent[find(a)] = find(b)
groups = {}
for i in range(len(entries)):
groups.setdefault(find(i), []).append(i)
return sorted(groups.values(), key=lambda g: (-len(g), g[0]))
# ══ 三、audit ════════════════════════════════════════════════════════════
def iter_wiki_files(paths):
for p in paths:
if os.path.isdir(p):
for name in sorted(os.listdir(p)):
if name.endswith(".md") and not name.startswith("."):
yield os.path.join(p, name)
elif os.path.isfile(p):
yield p
def cmd_audit(args):
rows = []
for f in iter_wiki_files(args.paths):
text = open(f, encoding="utf-8", errors="replace").read()
_, entries = split_entries(text)
groups = [g for g in cluster(entries) if len(g) > 1] if entries else []
rows.append({
"file": f,
"lines": text.count("\n") + 1,
"entries": len(entries),
"dup_groups": len(groups),
"dup_entries": sum(len(g) for g in groups),
"self_dup": sum(1 for e in entries if self_declared_repeat(e)),
})
rows.sort(key=lambda r: -r["lines"])
over = [r for r in rows if r["lines"] > THRESHOLD]
print("📚 wiki 體檢(門檻 %d 行——超過就沒有人會從頭讀)" % THRESHOLD)
print()
for r in rows:
flag = "🔴 太長" if r["lines"] > THRESHOLD else " "
print("%s %-46s %6d 行 / %4d 條%s"
% (flag, os.path.basename(r["file"]), r["lines"], r["entries"],
"%d 條自承是同款的第 N 次" % r["self_dup"] if r["self_dup"] else ""))
print()
if not over:
print("✅ 查過了,沒有超過門檻的檔。")
return 0
print("⇒ %d 個檔超過門檻。**這不是風格問題**——一個沒有人讀得完的必讀檔,"
"跟沒有那個檔的差別只在於它讓人以為有。" % len(over))
print("⇒ 下一步:`wiki-compress plan <檔>` 看怎麼壓;壓之前先開票(票上驗收 3)。")
return 1
# ══ 四、plan ═════════════════════════════════════════════════════════════
def plan(path, before=None, keep=None, threshold=None):
"""算出「哪幾條留下、哪幾條搬走」。
🔴 預設政策=**搬到原檔掉到門檻以下為止**(最新的留下、最舊的先搬)。
為什麼不是「早於某個日期就搬」(第一版是那樣寫的,實測就被打臉):
`mistakes.md` 260 條裡有 205 條是最近一個月的 ⇒ 用 30 天當線只搬得動 40 條,
壓完還是 6,000 行 ⇒ **一樣沒有人讀得完,等於白壓。**
而這件事的目的是「讓必讀檔可讀」,那就該讓**可讀**本身當判準。
"""
text = open(path, encoding="utf-8", errors="replace").read()
pre, entries = split_entries(text)
groups = cluster(entries)
limit = threshold or THRESHOLD
# 新舊排序:有日期的照日期,沒日期的照它在檔裡的位置(這批 wiki 都是新的在上面)
order = sorted(range(len(entries)),
key=lambda i: (entries[i]["date"] or "0000-00-00", -i),
reverse=True)
if before:
stay = {i for i in range(len(entries))
if not entries[i]["date"] or entries[i]["date"] >= before}
else:
n_keep = keep if keep is not None else None
if n_keep is None:
# 目錄一條一行 + 檔頭固定幾行;一直加到再加就超過門檻為止
budget = limit - (len(entries) + 14)
n_keep, used = 0, 0
for i in order:
if used + entries[i]["lines"] > budget:
break
used += entries[i]["lines"]
n_keep += 1
stay = set(order[:max(0, n_keep)])
movable = [entries[i] for i in range(len(entries)) if i not in stay]
return {"text": text, "pre": pre, "entries": entries, "groups": groups,
"stay": stay, "movable": movable, "limit": limit,
"cutoff": before or "(門檻政策:壓到 %d 行以下)" % limit}
def cmd_plan(args):
p = plan(args.file, args.before, args.keep)
entries, groups = p["entries"], p["groups"]
dups = [g for g in groups if len(g) > 1]
selfs = [e for e in entries if self_declared_repeat(e)]
stay_lines = sum(entries[i]["lines"] for i in p["stay"])
print("📐 壓縮計畫:%s" % args.file)
print(" 現況:%d 行 / %d 條" % (p["text"].count("\n") + 1, len(entries)))
print(" 政策:%s" % p["cutoff"])
print(" 搬走:%d 條 → <檔名>-archive-YYYY-MM.md**原封不動,一個字都不改**"
% len(p["movable"]))
print(" 留下:%d 條、%d 行 目錄 %d 行 約 %d 行"
% (len(p["stay"]), stay_lines, len(entries) + 14,
stay_lines + len(entries) + 14))
print()
print(" ↻ **自承是同款第 N 次的有 %d 條**(占 %.0f%%)——"
% (len(selfs), 100.0 * len(selfs) / max(1, len(entries))))
print(" 這批是「合併成一條、記發生過幾次」最該下手的地方,")
print(" 但**合併要人來做**:機器一改正文就會弄丟東西,所以本支只點名。")
for e in selfs[:6]:
print(" · %s" % e["title"][:54])
print(" 它自己說:…%s…" % (self_declared_repeat(e) or "")[:52])
if len(selfs) > 6:
print(" · …另外還有 %d 條" % (len(selfs) - 6))
if dups:
print()
print(" ×N 標題就長得像的(%d 群):" % len(dups))
for g in dups[:5]:
print(" ×%d %s" % (len(g), entries[g[0]]["title"][:52]))
print()
print("⇒ 真的要壓:wiki-compress apply %s --ticket owner/repo#N" % args.file)
return 0
# ══ 五、apply(只搬不改)═════════════════════════════════════════════════
def cmd_apply(args):
p = plan(args.file, args.before, args.keep)
entries, groups = p["entries"], p["groups"]
if not entries:
print("🔴 這個檔切不出任何一條(沒有 `## ` 標題),不動它。", file=sys.stderr)
return 2
where = {} # 條的索引 → 它搬去哪個檔(留下的是 None)
buckets = {}
stem, _ = os.path.splitext(args.file)
for idx, e in enumerate(entries):
if idx not in p["stay"]:
month = (e["date"] or "")[:7] or "undated"
target = "%s-archive-%s.md" % (stem, month)
buckets.setdefault(target, []).append(idx)
where[idx] = target
else:
where[idx] = None
gid = {}
for n, g in enumerate(groups):
for i in g:
gid[i] = n
# ── 目錄:每條一行。這一行就是票上說的「原位置留一份目錄」──────────
idx_lines = []
for n, g in enumerate(groups):
head = entries[g[0]]
mark = "×%d " % len(g) if len(g) > 1 else ""
for k, i in enumerate(g):
e = entries[i]
dest = where[i]
loc = "本檔" if dest is None else "→ `%s`" % os.path.basename(dest)
tick = (" " + "".join(e["tickets"][:2])) if e["tickets"] else ""
rep = "↻ " if self_declared_repeat(e) else ""
pre = mark if k == 0 else "  ↳ "
idx_lines.append("- %s%s`%s` %s%s%d 行)%s%s"
% (pre, rep, e["hash"], e["title"], e["date"] or "沒寫日期",
e["lines"], loc, tick))
del head
stamp = dt.datetime.now().strftime("%Y-%m-%d")
header = [
p["pre"].rstrip(),
"",
"---",
"",
"## 📇 目錄(%s 壓縮,票:%s" % (stamp, args.ticket),
"",
"> 這份目錄是機器產生的(`scripts/wiki-compress`)。**每一條的正文一個字都沒改**——",
"> 舊的整條原封不動搬到 archive,留下的還在本檔。要對帳跑:",
"> `wiki-compress verify <壓縮前> <壓縮後> <archive...>`",
">",
"> `×N` = 標題就長得像的同一群。`↻` = **這一條自己寫著它是同款第 N 次**",
"> ——那是它自己下的判斷,不是機器猜的。要合併就從 `↻` 這批開始,**由人合併**。",
"",
] + idx_lines + ["", "---", ""]
kept = [entries[i]["body"] for i in range(len(entries)) if where[i] is None]
new_text = "\n".join(header) + "\n\n".join(kept) + "\n"
if args.dry_run:
print("🧪 --dry-run,什麼都沒寫。壓完會是:")
print(" %s%d 行 → %d 行(目錄 %d 條)"
% (args.file, p["text"].count("\n") + 1, new_text.count("\n") + 1,
len(idx_lines)))
for t, ids in sorted(buckets.items()):
print(" %s+%d 條" % (os.path.basename(t), len(ids)))
return 0
# ── 先把搬走的寫出去,再改原檔(順序不能反:中途死掉也不會弄丟)──────
for target, ids in sorted(buckets.items()):
chunk = ["# %s(自 %s 搬出,%s" % (os.path.basename(target),
os.path.basename(args.file), stamp),
"",
"> 原封不動搬過來的,**一個字都沒改**。票:%s" % args.ticket,
"> 回原檔看目錄:`%s`" % os.path.basename(args.file),
""]
chunk += [entries[i]["body"] for i in ids]
old = ""
if os.path.exists(target):
old = open(target, encoding="utf-8").read().rstrip() + "\n\n"
with open(target, "w", encoding="utf-8") as f:
f.write(old + "\n\n".join(chunk) + "\n")
backup = args.file + ".before-compress"
with open(backup, "w", encoding="utf-8") as f:
f.write(p["text"])
with open(args.file, "w", encoding="utf-8") as f:
f.write(new_text)
# ── 交付紀錄(票上驗收 3:不准順手做完沒人知道)────────────────────
log = os.path.join(os.path.dirname(os.path.abspath(args.file)) or ".", LOG_NAME)
with open(log, "a", encoding="utf-8") as f:
f.write("\n## %s — %s(票:%s\n\n" % (stamp, os.path.basename(args.file),
args.ticket))
f.write("- 壓縮前:%d 行 / %d 條\n" % (p["text"].count("\n") + 1, len(entries)))
f.write("- 壓縮後:%d 行(目錄 %d 條 留下 %d 條)\n"
% (new_text.count("\n") + 1, len(idx_lines), len(kept)))
for t, ids in sorted(buckets.items()):
f.write("- 搬出 %d 條 → `%s`\n" % (len(ids), os.path.basename(t)))
f.write("- 對帳指令:`wiki-compress verify %s %s %s`\n"
% (os.path.basename(backup), os.path.basename(args.file),
" ".join(os.path.basename(t) for t in sorted(buckets))))
f.write("- 🔴 正文未改動(只搬不改);逐條對帳結果見上面那條指令\n")
print("✅ 壓完了。%d 行 → %d 行"
% (p["text"].count("\n") + 1, new_text.count("\n") + 1))
for t, ids in sorted(buckets.items()):
print(" 搬出 %d 條 → %s" % (len(ids), t))
print(" 壓縮前的原檔留在:%s" % backup)
print(" 交付紀錄:%s" % log)
print()
print("⇒ 現在跑對帳(**沒跑過就不算壓完**):")
print(" wiki-compress verify %s %s %s"
% (backup, args.file, " ".join(sorted(buckets))))
return 0
# ══ 六、verify(票上驗收 2:不能弄丟任何一條)════════════════════════════
def cmd_verify(args):
before = open(args.before, encoding="utf-8", errors="replace").read()
_, b_entries = split_entries(before)
after_files = [args.after] + list(args.also)
found = {}
for f in after_files:
if not os.path.exists(f):
print("🔴 找不到 %s" % f, file=sys.stderr)
return 2
_, es = split_entries(open(f, encoding="utf-8", errors="replace").read())
for e in es:
found.setdefault(e["hash"], []).append((f, e["title"]))
missing = [e for e in b_entries if e["hash"] not in found]
dupes = {h: v for h, v in found.items() if len(v) > 1}
print("🧾 逐條對帳:%s → %s" % (args.before, "".join(after_files)))
print(" 壓縮前 %d 條 / 壓縮後(含搬出去的)%d 條"
% (len(b_entries), sum(len(v) for v in found.values())))
print()
if missing:
print("🔴 **弄丟了 %d 條**(下面每一條在壓縮後找不到內文一模一樣的):" % len(missing))
for e in missing[:40]:
print(" · 第 %d 行 `%s` %s" % (e["line"], e["hash"], e["title"][:60]))
if len(missing) > 40:
print(" · …另外還有 %d 條" % (len(missing) - 40))
print()
print("⇒ **這次壓縮不合格。** 票上第 2 條驗收就是這件事。")
print("⇒ 原檔還在 `%s.before-compress`,把它還原回去再查。" % args.after)
return 1
print("✅ **一條都沒弄丟。** %d 條全部在壓縮後的檔裡找得到內文一模一樣的。"
% len(b_entries))
if dupes:
print(" ⚠️ 有 %d 條同時出現在兩個檔(搬了但原檔沒清):" % len(dupes))
for h, v in list(dupes.items())[:8]:
print(" · %s%s" % (v[0][1][:40], "、".join(f for f, _ in v)))
return 0
# ══ 七、bench(票上驗收 1:查得比壓之前快)═══════════════════════════════
#
# 「快」要能量,不然它只是感覺。這裡量的是**定位成本**:
# 從檔頭讀到「確定答案在哪一條」為止,你要掃過幾行。
# 壓縮前:沒有目錄 ⇒ 只能一路掃到命中的那一行 ⇒ 成本 = 命中行號
# 壓縮後:檔頭有目錄 ⇒ 在目錄裡命中就停 ⇒ 成本 = 目錄裡的行號
# 同時量**命中率**——「查得到」是「查得快」的前提,快而查不到是更糟的結果。
def probes(entries, cap=80):
"""從每條的標題挑一個**在全檔獨一無二**的詞當查詢。挑不到就跳過那條。
🔴 **要在整份檔上均勻取樣**,不能只取前 cap 條。
第一版就是取前 80 條——而那批正好是最新的、壓縮後留在原檔最上面的,
**等於只考它最擅長的題目**,量出來的倍數是假的。
"""
titles = [e["title"] for e in entries]
step = max(1, len(entries) // cap)
sample = entries[::step]
out = []
for e in sample:
best = None
cands = re.findall(r"[一-鿿]{4,10}|[A-Za-z][\w.-]{4,}", e["title"])
for c in sorted(cands, key=len, reverse=True):
if sum(1 for t in titles if c in t) == 1:
best = c
break
if best:
out.append((best, e["hash"]))
if len(out) >= cap:
break
return out
def first_hit_line(path, needle):
with open(path, encoding="utf-8", errors="replace") as f:
for n, ln in enumerate(f, 1):
if needle in ln:
return n
return None
def cmd_bench(args):
before = open(args.before, encoding="utf-8", errors="replace").read()
_, b_entries = split_entries(before)
qs = probes(b_entries)
if not qs:
print("🔴 這個檔挑不出可以拿來查的詞(標題太相似)。", file=sys.stderr)
return 2
files = [args.after] + list(args.also)
hit_b = hit_a = 0
cost_b = cost_a = 0
worse = []
for q, _h in qs:
cb = first_hit_line(args.before, q)
ca = None
for f in files:
c = first_hit_line(f, q)
if c is not None and (ca is None or c < ca):
ca = c
if cb:
hit_b += 1; cost_b += cb
if ca:
hit_a += 1; cost_a += ca
if cb and ca and ca > cb:
worse.append((q, cb, ca))
n = len(qs)
print("⏱ 查得比壓之前快嗎?(%d 個查詢,每條各出一題)" % n)
print()
print(" 命中率 壓縮前 %d/%d → 壓縮後 %d/%d" % (hit_b, n, hit_a, n))
print(" 定位成本(要從檔頭掃過幾行才確定答案在哪):")
print(" 壓縮前 平均 %.0f 行" % (cost_b / max(1, hit_b)))
print(" 壓縮後 平均 %.0f 行" % (cost_a / max(1, hit_a)))
if hit_a and hit_b:
ratio = (cost_b / hit_b) / max(1e-9, cost_a / hit_a)
print(" ⇒ **快 %.1f 倍**" % ratio)
print()
ok = True
if hit_a < hit_b:
print("🔴 **有 %d 個查詢壓縮後查不到了。** 查得快但查不到=更糟。" % (hit_b - hit_a))
ok = False
if worse:
print(" ⚠️ 有 %d 個查詢變慢了(前 5 個):" % len(worse))
for q, cb, ca in worse[:5]:
print(" · 「%s」 %d → %d 行" % (q, cb, ca))
if ok and hit_a == hit_b:
print("✅ **同一件事查得到,而且查得更快**(票上驗收 1)。")
return 0 if ok else 1
def main(argv):
ap = argparse.ArgumentParser(prog="wiki-compress", description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
sub = ap.add_subparsers(dest="cmd", required=True)
a = sub.add_parser("audit"); a.add_argument("paths", nargs="*", default=["."])
a.set_defaults(fn=cmd_audit)
p = sub.add_parser("plan"); p.add_argument("file")
p.add_argument("--before", help="早於這個日期的搬走(預設:壓到門檻以下為止)")
p.add_argument("--keep", type=int, help="最新的幾條留在原檔")
p.set_defaults(fn=cmd_plan)
ap2 = sub.add_parser("apply"); ap2.add_argument("file")
ap2.add_argument("--ticket", required=True,
help="owner/repo#N —— 壓縮這件事本身要有票(票上驗收 3)")
ap2.add_argument("--before")
ap2.add_argument("--keep", type=int)
ap2.add_argument("--dry-run", action="store_true")
ap2.set_defaults(fn=cmd_apply)
v = sub.add_parser("verify"); v.add_argument("before"); v.add_argument("after")
v.add_argument("also", nargs="*")
v.set_defaults(fn=cmd_verify)
b = sub.add_parser("bench"); b.add_argument("before"); b.add_argument("after")
b.add_argument("also", nargs="*")
b.set_defaults(fn=cmd_bench)
args = ap.parse_args(argv)
if args.cmd == "audit" and not args.paths:
args.paths = ["."]
if args.cmd == "apply" and not re.match(r"^[\w.-]+/[\w.-]+#\d+$", args.ticket):
print("🔴 --ticket 的寫法是 owner/repo#N(裸號跨 repo 會撞號)", file=sys.stderr)
return 2
return args.fn(args)
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))