Files
ISEP/hooks/lib/beacon_report.py
T
claude-code fef35ed03a 出貨鏈三站上 main:release-ship 前置①改看 tag 的樹、release-check 可指定版本、信標開場報「打了 tag 沒 release」(inkstone/ISEP#67)
- hooks/lib/release_chain.py:問 Gitea 的判準一份三處用(匿名讀,D20)
- scripts/release-ship:前置①比「要建的那個 tag 的樹」裡的 plugin.json,不比工作樹/最新 tag
  (總管 09-07 在 v0.23.0 被擋的情境現在放行);note 從 Gitea compare 生,從 plugin 快取跑也行;
  覆核改跑 release-check <那個 tag>
- scripts/release-check [vX.Y.Z]:指定版本;v0.19/0.20/0.21 依裁決標「不補」不再黃字;補法印正本絕對路徑
- hooks/lib/beacon_report.py ④:最新 tag 沒 release/main 定版沒打 tag ⇒ 一行缺站+補法;都在安靜;
  只快取「都在」6 小時
- 測試:test-release-ship 14、test-release-check 14、信標 26→33;docs:TESTING A37/A38、盤點表、README 60 支腳本、
  白名單加 release-ship/release-check 形狀、mistakes 一條
- 沒改 plugin.json 版本號(待總管定版)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0178ef1fGw3XeZtpN7LaZrm4
2026-09-07 05:52:41 +00:00

377 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# ── 以下五格是「這一份是不是還有效」自檢 ─────────────────────────────
# ①②③ inkstone/ISEP#90 立;②b②c inkstone/ISEP#122 補(會自動載入的東西也有分身)
# 都**只是報告,不擋任何事**(SessionStart 本來就不該擋),而且每一格拿不到答案就閉嘴。
import hashlib, json, os, re, subprocess, sys, time, urllib.request
ROOT = os.environ.get("CLAUDE_PLUGIN_ROOT", "")
PROJ = os.environ.get("CLAUDE_PROJECT_DIR") or os.getcwd()
VER = sys.argv[1] if len(sys.argv) > 1 else "未知"
GATES = sys.argv[2] if len(sys.argv) > 2 else "?"
SRC = sys.argv[3] if len(sys.argv) > 3 else "來源不明"
MSG = "🟢 ISEP v%s 已載入(%s 支閘|來源:%s%s" % (VER, GATES, SRC, ROOT)
notes = []
# ══ ① 這一份跟 ISEP main 是不是同一版 ═══════════════════════════════════
#
# 🔴 為什麼要自己查(inkstone/ISEP#902026-08-27 實查):
# ISEP main 的 plugin.json → 0.9.0
# 雲端實際載入 → 0.3.9 ← 中間差 7 個 release
# 而信標**照樣是綠的**——它只證明「有一份 plugin 載入了」,不證明「載入的是哪一份」。
# 後果不是抽象的:0.3.9 裡還活著兩支已經在 v0.9.0 整支刪掉的 hook
# 於是 `.claude/pending-verification/` 在雲端**被清掉之後又長回來**。
# ⇒ 一個看不見的落差,會讓「已經刪掉的機制」在別人的工作區裡復活。
#
# 匿名讀(不帶任何憑證)⇒ D20 判準下屬於「讀」,不需要開閘、不計次。
# 快取 6 小時、逾時 6 秒、任何失敗一律閉嘴——信標不能因為網路而變吵或變慢。
def main_version():
cache = os.path.join(os.environ.get("ISEP_BEACON_CACHE_DIR", "/tmp"), ".isep-main-version")
try:
if time.time() - os.path.getmtime(cache) < 6 * 3600:
v = open(cache, encoding="utf-8").read().strip()
return v or None
except Exception:
pass
url = os.environ.get("ISEP_MAIN_MANIFEST_URL",
"https://git.uncle6.me/inkstone/ISEP/raw/branch/main/.claude-plugin/plugin.json")
try:
with urllib.request.urlopen(url, timeout=6) as r:
v = (json.loads(r.read().decode("utf-8")) or {}).get("version") or ""
except Exception:
v = ""
try:
open(cache, "w", encoding="utf-8").write(v)
except Exception:
pass
return v or None
def vtuple(v):
return tuple(int(x) for x in re.findall(r"\d+", v)[:3]) or (0,)
MAIN = main_version() if os.environ.get("ISEP_BEACON_SKIP_NET") != "1" else os.environ.get("ISEP_FAKE_MAIN_VERSION")
if MAIN and VER != "未知" and MAIN != VER:
if vtuple(MAIN) > vtuple(VER):
notes.append(
"🔴 **這一份落後 ISEP main**(載入 %s main %s)——你現在跑的不是最新那組閘,"
"而且**已經刪掉的機制可能還活著**(0.3.9 就是這樣讓 .claude/pending-verification/ 復活的)。"
"修:本機 `claude plugin update isep@inkstone`;雲端要去動一下 Environment 的 setup script "
"內容逼它重拍快照(快取約 7 天)。追蹤票 inkstone/ISEP#67。" % (VER, MAIN))
else:
notes.append("️ 這一份比 ISEP main 新(載入 %s main %s)——沒發版的改動只在這台機器上。" % (VER, MAIN))
# ══ ④ 打了 tag 卻沒 release(運送鏈斷在 leo 看得到的那一站之前)═══════════
#
# 🔴 為什麼要在開場報(inkstone/ISEP#67 → comment 6574,總管 2026-09-07 裁定):
# v0.23.0v0.24.0 的 tag 都推了,Gitea Releases 頁卻停在 v0.22.0——
# 交棒給 leo 的「測試位置 releases/tag/v0.24.0」打開是一頁空 tag。
# 建 release 的工具(scripts/release-ship)在 09-05 就寫好了,但**沒有任何東西在
# 下一個 session 開場說「你上一版少了一站」**——於是斷點靠人記得,而人沒記得。
# 判準(與 release-shiprelease-check 同一份:hooks/lib/release_chain.py):
# · Gitea 上最新的 tag,它的 release 在不在 → 不在=「打了 tag 沒 release」
# · main 宣稱的版本(①已經拿到的 MAIN)比最新 tag 新 → 「定版了沒打 tag」
# 兩者都在 ⇒ 一個字都不說(天天念的東西會被學會忽略)。
# 匿名讀(D20)、逾時 6 秒、問不到就閉嘴;只快取「都在」的結果 6 小時,
# 缺件不快取——補完的下一個 session 就該安靜,不該再念 6 小時。
def chain_gap():
"""回 (版本, [(站, 補法)]) 或 None(問不到/沒有 tag/都在)。"""
fx = None
if os.environ.get("ISEP_BEACON_SKIP_NET") == "1":
fxp = os.environ.get("ISEP_BEACON_CHAIN_FIXTURE")
if not fxp:
return None
try:
fx = json.load(open(fxp, encoding="utf-8"))
except Exception:
return None
try:
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import release_chain as rc
except Exception:
return None
ship = os.path.join(ROOT, "scripts", "release-ship")
ship_cmd = ("python3 %s" % ship) if os.path.isfile(ship) else \
"先 `claude plugin update isep@inkstone`(這一份 v%s 還沒有 scripts/release-ship)再跑 `python3 \"$CLAUDE_PLUGIN_ROOT\"/scripts/release-ship`" % VER
chain = rc.Chain(fixture=fx, timeout=6)
try:
tags = chain.tags()
if not tags:
return None
latest = max(tags, key=rc.vtuple)
cache = os.path.join(os.environ.get("ISEP_BEACON_CACHE_DIR", "/tmp"), ".isep-chain-ok-" + latest)
# main 定版了、tag 還沒打 ⇒ 缺的是 tag 那一站(MAIN 來自 ①,可能是 6 小時內的快取)
if MAIN and rc.vtuple(MAIN) > rc.vtuple(latest):
g = [(label, fix.format(v=MAIN, ship=ship_cmd)) for k, label, fix in rc.STATIONS if k == "tag"]
return MAIN, g
try:
if fx is None and time.time() - os.path.getmtime(cache) < 6 * 3600:
return None
except Exception:
pass
rels = chain.releases()
g = [(label, fix.format(v=latest.lstrip("v"), ship=ship_cmd))
for k, label, fix in rc.gaps(latest, tags, rels) if k == "release"]
if not g:
try:
open(cache, "w").write(latest)
except Exception:
pass
return None
return latest.lstrip("v"), g
except Exception:
return None
cg = chain_gap()
if cg:
ver_gap, stations = cg
notes.append(
"🔴 **v%s 送到一半:%s**——leo 那頁(Gitea Releases)看不到這一版,對他等於不存在。"
"補:%sinkstone/ISEP#67"
% (ver_gap, "、".join("缺 " + label for label, _f in stations),
"".join(fix for _l, fix in stations)))
# ══ ② 專案裡有沒有 ISEP 腳本的舊複本在遮蔽正門 ══════════════════════════
#
# 🔴 實例(inkstone/ISEP#90 ②):`InkStoneCo/scripts/ticket` 是 ISEP `scripts/ticket`
# 的**舊複本**,它的取 token 邏輯還停在「只認名叫 gitea 的 remote」,
# 而 bootstrap.sh 在雲端把 Gitea 設成 `origin`
# ⇒ 在雲端跑 `scripts/ticket` 一律死在「拿不到 gitea token」
# ⇒ 人只好繞過正門直接打 API——而那正是 ticket-api-bypass-guard.sh 在防的事。
# **一道閘把人逼去走它自己禁止的那條路,那道閘就是在製造違規。**
#
# 判準不是「檔名一樣」,是「檔名一樣**而內容不同**」——同步過的複本不吵。
def shadow_copies():
out = []
src = os.path.join(ROOT, "scripts")
if not os.path.isdir(src):
return out
roots = [PROJ, os.path.join(PROJ, "InkStoneCo")]
for name in sorted(os.listdir(src)):
a = os.path.join(src, name)
if not os.path.isfile(a):
continue
try:
ab = open(a, "rb").read()
except Exception:
continue
for base in roots:
b = os.path.join(base, "scripts", name)
if os.path.realpath(b) == os.path.realpath(a):
continue
if not os.path.isfile(b):
continue
try:
if open(b, "rb").read() != ab:
out.append(os.path.relpath(b, PROJ))
except Exception:
pass
return out
sh = shadow_copies()
if sh:
notes.append(
"🟡 **專案裡有 ISEP 腳本的舊複本**,而它們排在 plugin 前面被叫到:%s。"
"兩份必然漂移,漂移的那份會安靜地騙人——`InkStoneCo/scripts/ticket` 就是這樣"
"在雲端一律死在「拿不到 gitea token」。要嘛刪掉複本改叫 "
"`\"$CLAUDE_PLUGIN_ROOT\"/scripts/<名字>`,要嘛把複本同步回 ISEP。" % "、".join(sh))
# ══ ②b 會**自動載入**的東西(skillcommand/agent)兩邊各有一份而內容不同 ══
#
# 🔴 為什麼上面那一格抓不到(inkstone/ISEP#1222026-09-02 實查):
# ② 只掃 `scripts/`。而真正會**自動載入**的東西住在別的目錄,同樣兩邊各有一份:
# plugin `skills/<名>/SKILL.md` ↔ 專案 `.claude/skills/<名>/SKILL.md`
# plugin `commands/<名>.md` ↔ 專案 `.claude/commands/<名>.md`
# plugin `agents/<名>.md` ↔ 專案 `.claude/agents/<名>.md`
#
# 實況:這 9 個檔案在 ISEP `0.1.0`c263866)從 InkStoneCo 複製過來**一次**
# 之後再也沒有同步過。到 2026-09-02 已經分家兩個,而且**方向相反**:
# `skills/ship-check/SKILL.md` InkStoneCo 651 行 ISEP 595 行
# `commands/sdd-check.md` ISEP 81 行 InkStoneCo 65 行
# InkStoneCo 那份還在教 ISEP#91 已退役的「唯一 active SDD」)
# ⇒ 「ISEP 一定比較新」與「InkStoneCo 一定比較新」**兩句都是錯的**。
# 兩份都會被就地編輯 ⇒ 歸屬只能寫下來(`docs/file-ownership.tsv`)並且要有東西去比。
#
# 🔴 這一格比 ② 嚴重:腳本要有人叫它才會跑,**skill/command 是自動載入的**——
# 載到舊的那份不會報錯、不會變慢、不會有任何症狀,只會安靜地教錯的東西。
# ship-check 就是這樣:舊描述在「我要發一篇部落格文章」時根本不會被觸發。)
#
# 判準與 ② 同一條:**檔名一樣而內容不同**才出聲,同步過的不吵。
LOADABLE_PAIRS = [("skills", ".claude/skills", True),
("commands", ".claude/commands", False),
("agents", ".claude/agents", False)]
def ownership():
"""讀 docs/file-ownership.tsv → {plugin 內路徑: (真相源, 真相源路徑, commit, sha256)}"""
out = {}
try:
with open(os.path.join(ROOT, "docs", "file-ownership.tsv"), encoding="utf-8") as f:
for line in f:
if not line.strip() or line.lstrip().startswith("#"):
continue
c = line.rstrip("\n").split("\t")
if len(c) >= 2 and c[0].strip():
out[c[0].strip()] = tuple((c[1:5] + ["-", "-", "-", "-"])[:4])
except Exception:
pass
return out
OWN = ownership()
def loadable_pairs():
"""產生 (plugin 內相對路徑, 專案內相對路徑)——只列 plugin 真的有的那些"""
for pdir, jdir, nested in LOADABLE_PAIRS:
src = os.path.join(ROOT, pdir)
if not os.path.isdir(src):
continue
try:
names = sorted(os.listdir(src))
except Exception:
continue
for name in names:
if nested:
if os.path.isfile(os.path.join(src, name, "SKILL.md")):
yield "%s/%s/SKILL.md" % (pdir, name), "%s/%s/SKILL.md" % (jdir, name)
elif name.endswith(".md") and os.path.isfile(os.path.join(src, name)):
yield "%s/%s" % (pdir, name), "%s/%s" % (jdir, name)
def whose(rel):
"""真相源是誰 → 一句照著做就會走到的話。表上沒有就誠實說未定,不要猜。"""
row = OWN.get(rel)
if not row:
return "歸屬未定 ⇒ 兩份都看一眼,決定之後補一列進 docs/file-ownership.tsv"
src, spath, _commit, _sha = row
if src == "inkstone/ISEP":
return "真相源=ISEP 這一份 ⇒ 專案那份是舊複本,同步過去或刪掉它"
return ("真相源=%s:%s ⇒ 內容改在那裡,改完原樣搬進 ISEP、"
"更新 docs/file-ownership.tsv 的 commitsha256、升版" % (src, spath or "?"))
def shadow_loadables():
out = []
roots = [PROJ, os.path.join(PROJ, "InkStoneCo")]
for prel, jrel in loadable_pairs():
a = os.path.join(ROOT, prel)
try:
ab = open(a, "rb").read()
except Exception:
continue
for base in roots:
b = os.path.join(base, jrel)
try:
if os.path.realpath(b) == os.path.realpath(a) or not os.path.isfile(b):
continue
if open(b, "rb").read() != ab:
out.append("%s%s%s"
% (prel, os.path.relpath(b, PROJ), whose(prel)))
except Exception:
pass
return out
ld = shadow_loadables()
if ld:
notes.append(
"🟡 **會自動載入的東西兩邊各有一份,而且內容不同**:\n - %s\n"
" 自動載入的東西載到舊的那份**不會有任何症狀**——不報錯、不變慢,"
"只會安靜地教錯的東西(`ship-check` 的舊描述在「我要發一篇部落格文章」時"
"根本不會被觸發)。歸屬表:docs/file-ownership.tsvinkstone/ISEP#122)。"
% "\n - ".join(ld))
# ══ ②c ISEP 自己這一份,跟歸屬表記的那顆對不對得上 ══════════════════════
#
# 🔴 為什麼要有這一格:②b 要「專案那一份」在磁碟上才比得出來,
# 而**雲端的 project dir 是薄殼,根本沒有那一份**(ISEP#90 記過同一件事)。
# 這一格只比「檔案 vs 表上寫的 sha256」——離線、單邊、不依賴任何別的 repo,
# 是雲端唯一還作數的那個檢查。
def off_manifest():
out = []
for rel, (src, spath, commit, sha) in OWN.items():
if not sha or sha == "-":
continue
p = os.path.join(ROOT, rel)
if not os.path.isfile(p):
continue
try:
h = hashlib.sha256(open(p, "rb").read()).hexdigest()
except Exception:
continue
if h != sha:
out.append("%s(表記 %s%s…,實際 %s…;真相源 %s:%s"
% (rel, (commit or "?")[:7], sha[:12], h[:12], src, spath or "?"))
return out
om = off_manifest()
if om:
notes.append(
"🟡 **ISEP 這一份跟歸屬表對不上**:%s。兩種可能,兩種都要動手:"
"① 它被就地改過 ⇒ 內容要改在真相源那邊,這裡只放原樣搬過來的複本;"
"② 它是同步過的新內容、只是沒更新 docs/file-ownership.tsv 的 commitsha256 ⇒ 補上那兩欄。"
% "、".join(om))
# ══ ③ 工作區有沒有「已退役機制」留下的產物 ══════════════════════════════
#
# 判準是機械的、而且會自己長大:**plugin 自己的原始碼裡有沒有任何一個字提到這個目錄**。
# 提到了 ⇒ 它是現行機制的產物,正常。
# 一個字都沒提到 ⇒ 產生它的東西已經不在這一份 ISEP 裡了 ⇒ 它是殘骸。
# 刻意**不用關鍵字黑名單**leo 2026-08-17 已證明那條路 8 次誤攔、0 次正確攔截):
# 這裡問的是「plugin 現在還認不認得它」,不是「這個名字看起來像不像壞東西」。
NATIVE = {"hooks", "commands", "skills", "agents", "plugins", "wiki", "cloud-shell",
"projects", "statsig", "shell-snapshots", "todos", "ide", "local", "isep"}
def orphan_artifacts():
out = []
for base in [PROJ, os.path.join(PROJ, "InkStoneCo")]:
d = os.path.join(base, ".claude")
if not os.path.isdir(d):
continue
for name in sorted(os.listdir(d)):
p = os.path.join(d, name)
if not os.path.isdir(p) or name in NATIVE or name.startswith("."):
continue
# 🔴 只搜「會產生東西的那些檔」(hooks/scripts),不搜 docs
# docs 提到一個名字**不會讓那個目錄長出來**,但會讓這一格閉嘴。
# 🔴 也要把本檔排除掉:本檔的註解裡就寫著 `pending-verification` 當例子,
# 第一次跑就因此漏報了真正存在的那一個——**自己提到自己=這格靜音**。
try:
hit = False
for sub in ("hooks", "scripts"):
# 🔴 變數名不要跟外層的 `d`(.claude 那個目錄)撞——撞了會把
# 外層迴圈的基準目錄換掉,第二個名字之後全部被靜靜跳過。
# 第一版就是這樣寫的,實測結果:真的存在的 `verified-claims`
# 一聲不吭地消失了。**假綠不是漏寫檢查,是檢查跑在錯的對象上。**
sd = os.path.join(ROOT, sub)
if not os.path.isdir(sd):
continue
if subprocess.run(["grep", "-rqlF", "--exclude", os.path.basename(__file__),
"--", name, sd],
capture_output=True, timeout=20).returncode == 0:
hit = True
break
except Exception:
hit = True # 問不出來就當它有效,不亂報
if not hit:
out.append(os.path.relpath(p, PROJ))
return out
orph = orphan_artifacts()
if orph:
notes.append(
"🟡 **工作區有已退役機制的產物**:%s。這一份 ISEP 裡沒有任何東西提到它們"
"v0.9.0 已整支刪除產生它的 hook),所以它們是殘骸——"
"**它們還在長,就表示這台機器跑的是舊版**(見上面那格)。確認之後刪掉。" % "、".join(orph))
CONTEXT = ("%s。這行是 ISEP plugin 自己發的——看得到它就表示閘真的生效了。"
"若某個 session 從頭到尾沒有這行,那個 session 是零閘狀態,"
"先修 plugin 再做事,不要用『跑得動』當證據。" % MSG)
if notes:
MSG = MSG + "\n" + "\n".join(notes)
CONTEXT = CONTEXT + "\n\n" + "\n".join(notes)
print(json.dumps({"systemMessage": MSG,
"hookSpecificOutput": {"hookEventName": "SessionStart",
"additionalContext": CONTEXT}},
ensure_ascii=False))