adb7009b67
信標原本只證明「有一份 plugin 載入了」,不證明「載入的是哪一份」—— 2026-08-27 雲端載的是 0.3.9、main 是 0.9.0,中間差 7 個 release,而它照樣是綠的。 那個看不見的落差的實際後果:0.3.9 裡還活著兩支 v0.9.0 已整支刪掉的 hook, 於是 .claude/pending-verification/ 被清掉之後又長回來。 三格(都只報告、不擋、拿不到答案就閉嘴): · 版本落差:匿名讀 ISEP main 的 plugin.json(D20 判準下屬於「讀」),快取 6 小時、 逾時 6 秒。落後就講清楚差幾版與怎麼修,並指向 inkstone/ISEP#67。 · 舊複本遮蔽正門:plugin 的 scripts/* 在專案樹(含雲端的 $TOP/InkStoneCo/)裡 有同名但**內容不同**的複本就點名。實例=InkStoneCo/scripts/ticket 的取 token 邏輯還停在「只認名叫 gitea 的 remote」,而雲端的 Gitea 叫 origin ⇒ 一律死在「拿不到 gitea token」⇒ 人被逼去繞過正門直接打 API, 而那正是 ticket-api-bypass-guard.sh 在防的事。 · 退役機制的殘骸:.claude/ 底下的目錄,若 plugin 的 hooks/scripts 一個字都沒提到 ⇒ 產生它的東西已經不在這一份裡了。判準是「plugin 現在還認不認得它」, **不是關鍵字黑名單**(leo 2026-08-17 已證明那條路 8 次誤攔、0 次正確攔截)。 訊息改由 hooks/lib/beacon_report.py 序列化:三段報告含引號與換行, 用 shell 內插拼 JSON 一個引號就會讓整行信標消失——而那正是「零閘狀態」的長相。 fail-open:python 掛掉、檔案不見、網路不通,一律退回原本那一行乾淨的信標。 測試 hooks/tests/isep-presence-beacon.test.sh:14/14(全離線)。 第 ⑪ 條守的是寫這支時真的犯過的 bug(內層迴圈變數跟外層的 d 撞名, 第二個殘骸之後全部被靜靜跳過)——把那個 bug 放回去,該條立刻轉紅。
168 lines
9.0 KiB
Python
168 lines
9.0 KiB
Python
# ── 以下三格是 inkstone/ISEP#90 加的「這一份是不是還有效」自檢 ──────────
|
||
# 都**只是報告,不擋任何事**(SessionStart 本來就不該擋),而且每一格拿不到答案就閉嘴。
|
||
import 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))
|
||
|
||
# ══ ② 專案裡有沒有 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))
|
||
|
||
# ══ ③ 工作區有沒有「已退役機制」留下的產物 ══════════════════════════════
|
||
#
|
||
# 判準是機械的、而且會自己長大:**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))
|