fef35ed03a
- 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
377 lines
19 KiB
Python
377 lines
19 KiB
Python
# ── 以下五格是「這一份是不是還有效」自檢 ─────────────────────────────
|
||
# ①②③ 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#90,2026-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.0/v0.24.0 的 tag 都推了,Gitea Releases 頁卻停在 v0.22.0——
|
||
# 交棒給 leo 的「測試位置 releases/tag/v0.24.0」打開是一頁空 tag。
|
||
# 建 release 的工具(scripts/release-ship)在 09-05 就寫好了,但**沒有任何東西在
|
||
# 下一個 session 開場說「你上一版少了一站」**——於是斷點靠人記得,而人沒記得。
|
||
# 判準(與 release-ship/release-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)看不到這一版,對他等於不存在。"
|
||
"補:%s(inkstone/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 會**自動載入**的東西(skill/command/agent)兩邊各有一份而內容不同 ══
|
||
#
|
||
# 🔴 為什麼上面那一格抓不到(inkstone/ISEP#122,2026-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 的 commit/sha256、升版" % (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.tsv(inkstone/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 的 commit/sha256 ⇒ 補上那兩欄。"
|
||
% "、".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))
|