044ef289fe
## 這一份是原樣搬過來的,一個字都沒改
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>
307 lines
16 KiB
Python
307 lines
16 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))
|
||
|
||
# ══ ② 專案裡有沒有 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))
|