Files
ISEP/hooks/lib/beacon_report.py
T
Leo 044ef289fe ship-check 送到得了任何 session:內容原樣搬過來,歸屬寫下來讓機器去比(inkstone/ISEP#122 → comment 6096)
## 這一份是原樣搬過來的,一個字都沒改

skills/ship-check/SKILL.md ← inkstone/InkStoneCo `bd52fa3`(PR #114,**還沒併**)
  搬完 md5 f565e4df1b18cc8aaf057f5fa64a9e15,跟來源逐位元組相同(cmp 通過)
  595 → 651 行;描述裡多了部落格/GitHub 鏡像/n8n/pages deploy 這些觸發詞
  ——舊描述那幾個詞**一個都沒有**,所以「我要發一篇部落格文章」那個情境
    根本觸發不到它,而那正是 leo 這次要解的問題。

## 為什麼不是「改成指針」也不是「ISEP 不再自帶」

雲端的 project dir 是**薄殼根**,InkStoneCo 只是它底下的一個目錄
(docs/governance/cloud-wiring.md 記著這件事)
⇒ `InkStoneCo/.claude/skills/` 在雲端**不會被載入**,只有 plugin 這一份會
⇒ ISEP 不帶全文 = 雲端拿不到 = 又變回「薄殼是真身的子集」,
   而那正是這個 repo 成立時要殺掉的病(README 開頭)。
所以 ISEP 必須帶全文,但它是**搬運工不是作者**:內容改在真相源,這裡只放複本。

## 歸屬不能靠人記得——實查證明兩個方向都會發生

skills/ 與 commands/ 那 9 個檔案在 0.1.0(c263866)從 InkStoneCo 複製過來一次,
之後**再也沒有同步過**(git log 只有那一顆)。到今天已經分家兩個,方向相反:

  skills/ship-check/SKILL.md   InkStoneCo 651 行 / ISEP 595 行   ← 那邊新
  commands/sdd-check.md        ISEP 81 行 / InkStoneCo 65 行     ← 這邊新
                               (InkStoneCo 那份還在教 ISEP#91 已退役的「唯一 active SDD」)

⇒「ISEP 一定比較新」與「InkStoneCo 一定比較新」兩句都是錯的。
  這是票上第 4 題「為什麼會有兩份」的答案:不是誰忘了同步,
  是**兩份都會被就地編輯**,而沒有任何東西會喊一聲。

## 所以機制是「寫下來 + 讓機器去比」,而且不新開一支閘

- docs/file-ownership.tsv —— 哪一份是真相源、取自哪顆 commit、當時的 sha256
- 比對長在**既有的信標**上(isep-presence-beacon.sh → hooks/lib/beacon_report.py):
  它的 ② 已經在做「同名而內容不同」這件事,只是**只掃 scripts/**。
  這次把 skills/commands/agents 一起納進去(②b),
  再加一格 ②c 用 sha256 單邊驗——**雲端沒有 InkStoneCo 可以比,那是唯一還作數的檢查**。
  🔴 刻意不開新閘:ISEP 最常見的錯是重造一支平行的閘
  (docs/governance/dispatch-and-reply-format.md §1.6 記著同一課)。

判準是「檔名一樣**而內容不同**」,同步過的不吵——誤攔比漏擋嚴重。
全部只講不擋(SessionStart 本來就不該擋人)。

## 實跑(這棵樹,真的 InkStoneCo)

  🟡 會自動載入的東西兩邊各有一份,而且內容不同:
    - skills/ship-check/SKILL.md ↔ .claude/skills/ship-check/SKILL.md
      (真相源=inkstone/InkStoneCo:… ⇒ 內容改在那裡,改完原樣搬進 ISEP、更新 commit/sha256、升版)
    - commands/sdd-check.md ↔ .claude/commands/sdd-check.md
      (真相源=ISEP 這一份 ⇒ 專案那份是舊複本,同步過去或刪掉它)

兩個方向各講對了自己的出路。

測試:hooks/tests/isep-presence-beacon.test.sh 14 → 26 條,全綠、全離線。
README/docs/hooks-inventory.md/docs/TESTING.md(A18 改 26 條、新增 A29)都跟著改了。
六個數字在這棵樹上實數:61 支閘/84 條註冊/7 位工人/7 支命令/2 支 skill/49 支腳本
——這次沒有增減,但仍然是數出來的,不是沿用上一版。

(本 commit 也帶著上一顆「逃生門」那件事的兩列文件:hooks-inventory 第 255 列與 TESTING A29。)

🔴 待總管定版:改了會被載入的東西就要升版,否則 plugin update 是 no-op。
🔴 InkStoneCo 那半不是我做的(不同 repo):那邊的 .claude/skills/ 與 .claude/commands/
   還留著兩份舊複本,該同步或刪掉;在那之前信標會每次開場點名它們。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 11:12:22 +08:00

307 lines
16 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))
# ══ ② 專案裡有沒有 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))