merge main(v0.14.0,五批)into 派工紀律三件

hooks.json 兩處衝突:roster-guard 與 mainline-focus-guard 在同一個陣列位置,
兩支都是真的閘,展開成兩個項目保留(不是二選一)。
盤點在合併後重數:58 支 .sh/79 條註冊/49 支腳本/7 位具名工人。版本 0.15.0。
This commit is contained in:
Claude
2026-08-28 01:20:00 +00:00
36 changed files with 5345 additions and 41 deletions
+167
View File
@@ -0,0 +1,167 @@
# ── 以下三格是 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#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))
# ══ ③ 工作區有沒有「已退役機制」留下的產物 ══════════════════════════════
#
# 判準是機械的、而且會自己長大:**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))
+46 -6
View File
@@ -27,6 +27,13 @@ leo 2026-08-28 07:40:「今天我整天上課……**下午四點左右**希
從快取檔讀,**這支從不打網路**UserPromptSubmit 走在每一則訊息的關鍵路徑上,
在那裡打網路 = 每一句話都先等一次 HTTP。快取由 SessionStart 那支負責更新
`scripts/countdown-milestone-refresh.sh`),拿不到就整段不顯示,不編數字。
🔴 **來源有兩個,優先序寫死(inkstone/ISEP#82**
① **被標定的主線**`hooks/lib/mainline.py``scripts/mainline set` 寫的)—— 優先
② 期限最近的那一個(`milestone-due` 快取,`countdown-milestone-refresh.sh` 寫的)
② 是**猜**出來的(「最近到期的大概就是在做的那個」),而 ISEP#82 的整件事就是
「那個 active milestone」在 14 個裡沒有指涉對象。⇒ 一旦有人真的標了主線,
就不准再用猜的那個蓋過它。兩個都沒有 ⇒ 整段不顯示。
"""
import json
import os
@@ -137,10 +144,40 @@ def deadline(now: datetime) -> datetime:
return d
def milestone() -> "tuple[str, datetime] | None":
"""主線 milestone 的期限。快取檔一行:`YYYY-MM-DD|<名稱>`。沒有就沒有。"""
def _mainline():
"""借 `hooks/lib/mainline.py` 讀「被標定的主線」。拿不到就當沒有(fail-open)。
刻意用檔案路徑載入而不是 `import mainline`:本檔自己也是被
`spec_from_file_location` 載進去的,`sys.path` 裡沒有 `hooks/lib`。
"""
try:
import importlib.util
p = os.path.join(os.path.dirname(os.path.abspath(__file__)), "mainline.py")
spec = importlib.util.spec_from_file_location("isep_mainline", p)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
except Exception:
return None
def milestone() -> "tuple[str, datetime | None] | None":
"""主線 milestone 的期限。優先讀被標定的主線,其次讀「期限最近」的快取。
快取檔一行:`YYYY-MM-DD|<名稱>`。兩個都沒有就沒有(回 None,整段不顯示)。
期限可能是 None(被標定的主線沒設期限)——那時只說名字,不編一個日期。
"""
raw = os.environ.get("ISEP_MILESTONE_DUE", "").strip()
name = os.environ.get("ISEP_MILESTONE_NAME", "").strip()
if not raw:
mod = _mainline()
if mod is not None:
try:
seg = mod.countdown_segment()
except Exception:
seg = None
if seg:
return seg
if not raw:
p = os.path.join(state_dir(), "milestone-due")
try:
@@ -184,11 +221,14 @@ def line(session_id: str, transcript_path: str) -> str:
ms = milestone()
if ms:
name, due = ms
gap = due - now
if gap.total_seconds() >= 0:
parts.append("主線 %s%s" % (name, human(gap)))
if due is None:
parts.append("主線 %s(沒設期限)" % name)
else:
parts.append("🔴 主線 %s 已逾期 %s" % (name, human(gap)))
gap = due - now
if gap.total_seconds() >= 0:
parts.append("主線 %s%s" % (name, human(gap)))
else:
parts.append("🔴 主線 %s 已逾期 %s" % (name, human(gap)))
return "".join(parts)
+248
View File
@@ -0,0 +1,248 @@
#!/usr/bin/env python3
"""hooks/lib/mainline.py —— 「現在的主線是哪一個」這件事的**唯一存放處**。
這是 helper,不是閘(inkstone/ISEP#40 S7`lib/` 底下的東西不算一支手寫的閘)。
三個呼叫者共用它,所以「主線是誰」的答案只有一份:
· `scripts/mainline` —— 人問「現在的主線是哪一個」
· `hooks/lib/countdown.py` —— 每一則回覆眼前那一行的第二段
· `hooks/mainline-focus-guard.sh` —— 派了一張不屬於主線的票就攔一次
── 為什麼要有這個檔(inkstone/ISEP#82)──────────────────────────────
2026-08-27 實查:`GET /repos/inkstone/*/milestones?state=open` 回 **14 個**
其中「Mira 現代化」這個名字同時活在 5 個 repo,還有 5 個已經逾期。
SOP 說「有 active milestone 時只做該 milestone 的事」——但**「那個」在現場沒有指涉對象**。
leo 的原話是「**14 個里程碑同時亮著卻不知道該看哪個**」。
⇒ 要解的不是「milestone 太多」(跨 repo 同名是既有做法,v0.6.0 §4SOP S7-4),
要解的是「**這一刻我在做哪一條**」沒有答案。
⇒ 所以這個檔的形狀就是答案的形狀:**一個檔,放得下一條**。
沒有清單、沒有優先序、沒有「前三名」——被覆蓋掉的那條就不再是主線。
問「現在的主線是哪一個」永遠只會拿到 0 或 1 個答案,不會拿到 14 個。
── 主線是一個「名字」,錨在一個具體的 milestone 上 ──────────────────
Gitea 的 milestone 不能跨 repo,而同一條線常常同時開在 5 個 repo(票上實查)。
所以:
· **錨**anchor)= `owner/repo#<milestone id>`:期限、進度、目標宣告都讀它
· **成員判定** 票所屬 milestone 的**標題**與主線標題相同(不分 repo)
⇒ 「同名跨 repo」照舊能用,而「現在在做哪一條」仍然只有一個答案。
── 這支從不打網路 ────────────────────────────────────────────────
它被 `UserPromptSubmit` 用(每一則訊息的關鍵路徑)。在那裡打 HTTP = 每講一句話
都先等一次網路。網路那一半全部在 `scripts/mainline`setrefreshadopt),
本支只讀寫 state 檔。讀不到 ⇒ 回「沒有主線」,**不編一條出來**。
"""
import json
import os
from datetime import datetime, timedelta, timezone
TAIPEI = timezone(timedelta(hours=8))
MARKER = "🎯"
STATE_NAME = "mainline.json"
JUMP_LOG = "mainline-jumps.jsonl"
def state_dir() -> str:
"""與倒數共用同一個狀態目錄——兩行字是同一個注入點的兩半,狀態不該分家。"""
d = os.environ.get("ISEP_COUNTDOWN_STATE_DIR", "").strip()
if not d:
d = os.path.join(os.path.expanduser("~"), ".claude", "isep-countdown")
try:
os.makedirs(d, exist_ok=True)
except Exception:
pass
return d
def path() -> str:
return os.path.join(state_dir(), STATE_NAME)
def load():
"""讀主線。沒有/壞掉 ⇒ None(=現在沒有主線),**不是例外**。
「沒有主線」是一個正常狀態,不是故障:ISEP#82 驗收第 4 條寫死了
「沒有任何主線時不能整組壞掉」。所以這支從不 raise。
"""
try:
with open(path()) as f:
d = json.load(f)
except Exception:
return None
if not isinstance(d, dict) or not d.get("title"):
return None
return d
def save(d) -> bool:
try:
with open(path(), "w") as f:
json.dump(d, f, ensure_ascii=False, indent=2)
return True
except Exception:
return False
def clear() -> bool:
try:
os.remove(path())
return True
except FileNotFoundError:
return True
except Exception:
return False
def parse_ts(raw):
if not isinstance(raw, str) or not raw.strip():
return None
s = raw.strip().replace("Z", "+00:00")
try:
dt = datetime.fromisoformat(s)
except Exception:
try:
dt = datetime.fromisoformat(s + "T23:59:59+08:00")
except Exception:
return None
return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc)
def due_of(ms):
return parse_ts((ms or {}).get("due_on") or "")
def norm(s) -> str:
"""比對標題用。只做「看不見的差異」的正規化——大小寫與空白。
🔴 刻意不做同義詞、不做模糊比對:主線是**被指定的**,不是被猜出來的。
猜錯的方向是「把不屬於主線的票放行」,那正是這張票要擋的事。
"""
return "".join((s or "").split()).lower()
def ref_of(ms) -> str:
if not ms:
return ""
return "%s/%s#%s" % (ms.get("owner") or "?", ms.get("repo") or "?", ms.get("id") or "?")
def members(ms):
return set(ms.get("members") or []) if ms else set()
def belongs(ticket_ref, ms, ticket_milestone_title=None):
"""這張票屬不屬於主線?回 True/FalseNone。
None = **不知道**(快取裡沒有這張票,而呼叫端也沒查到它的 milestone)。
🔴 「不知道」不等於「不屬於」——這條分界就是誤攔與否的分水嶺。
呼叫的閘拿到 None 時該放行(本 repo 心法第 2 條:誤攔比漏擋更該修)。
"""
if not ms:
return True # 沒有主線 ⇒ 沒有「不屬於主線」這回事
ref = (ticket_ref or "").strip()
if ref and ref in members(ms):
return True
if ticket_milestone_title is not None:
return norm(ticket_milestone_title) == norm(ms.get("title"))
return None
def fixture_titles():
"""測試用:把「去 Gitea 問這張票掛在哪」換成一份現成的答案。
`ISEP_MAINLINE_FIXTURE` 指向一個 JSON`{"owner/repo#N": "milestone 標題"}`。
設了它,閘就不打網路,直接拿這份當事實。
🔴 這與 `ISEP_COUNTDOWN_NOW`(把時鐘定住)是同一個性質的東西:
**把外界定住,讓閘能離線、可重複地被測**。正式環境不會設它;
沒設 ⇒ 這支回 None,判決完全走真實的 Gitea。
"""
p = os.environ.get("ISEP_MAINLINE_FIXTURE", "").strip()
if not p:
return None
try:
with open(p) as f:
d = json.load(f)
return d if isinstance(d, dict) else None
except Exception:
return None
def record_jump(ticket_ref, ms):
"""把「派了一張不在主線上的票」記一筆。**只留痕,不判罪。**
跳線本身常常是對的(補收、真的插件事)。留痕是為了讓「這條線今天被岔開幾次」
事後數得出來——沒有紀錄的話,跳線與專注在機器上長得一模一樣。
"""
try:
with open(os.path.join(state_dir(), JUMP_LOG), "a") as f:
f.write(json.dumps({
"at": datetime.now(timezone.utc).isoformat(),
"ticket": ticket_ref,
"mainline": ref_of(ms),
"mainline_title": (ms or {}).get("title"),
}, ensure_ascii=False) + "\n")
except Exception:
pass
def _human(delta: timedelta) -> str:
days = int(abs(delta).total_seconds()) // 86400
if days >= 1:
return "%d" % days
hours = int(abs(delta).total_seconds()) // 3600
return "%d 小時" % hours
def due_phrase(ms, now=None) -> str:
"""期限那一段。沒設期限就說沒設——不准編一個日期出來。"""
dt = due_of(ms)
if not dt:
return "沒設期限"
now = now or datetime.now(timezone.utc)
day = dt.astimezone(TAIPEI).strftime("%Y-%m-%d")
gap = dt - now
if gap.total_seconds() >= 0:
return "%s(剩 %s" % (day, _human(gap))
return "%s(🔴 已逾期 %s" % (day, _human(gap))
def progress(ms):
o = int((ms or {}).get("open_issues") or 0)
c = int((ms or {}).get("closed_issues") or 0)
total = o + c
pct = int(round(c * 100.0 / total)) if total else 0
return c, total, pct
def line(now=None) -> str:
"""注入用的那一行(倒數那一行的第二行)。**沒有主線時也要回一句話。**
ISEP#82 驗收第 4 條:沒有主線不能整組壞掉,要講得出「現在沒有主線」。
⇒ 這支永遠回一個非空字串,永遠不 raise。
"""
ms = load()
if not ms:
return ("%s 現在沒有主線——沒有任何 milestone 被標成「現在在做的那一條」。"
"要標:`scripts/mainline set <owner/repo#milestone_id>`"
"(先看有哪些:`scripts/mainline list`" % MARKER)
c, total, pct = progress(ms)
head = "%s 主線:%s%s」|%s%d%d 張已關(%d%%" % (
MARKER, ref_of(ms), ms.get("title") or "?", due_phrase(ms, now), c, total, pct)
goal = (ms.get("description") or "").strip().splitlines()
if goal:
head += "\n 目標:" + goal[0].strip()[:120]
return head
def countdown_segment(now=None):
"""給 `countdown.py` 用的 (名稱, 期限) ——讓 ⏱ 那一行說得出主線是誰。
為什麼要塞進 ⏱ 那一行:⏱ 那一行**已經有一支 Stop 閘在查它有沒有被戴上**
inkstone/ISEP#63)。主線的名字掛在那一行上,就跟著那道已經驗過的閘一起
到 leo 眼前——不必為了同一件事再立第二道會擋人的閘。
"""
ms = load()
if not ms:
return None
return (ms.get("title") or "主線", due_of(ms))
+57
View File
@@ -0,0 +1,57 @@
# hooks/lib/mtime.sh — 「這個檔幾點被動的」,跨 macOS/Linux 都問得出來。
# 不是獨立掛的閘(沒進 hooks.json),給那幾支用 /tmp 戳記的閘 `source` 用。
#
# ══ 為什麼要有這支(inkstone/ISEP#90 ④,2026-08-28 實查)═══════════════
#
# 三支閘(prod-write-guard、main-and-prod-push-guard、stage-before-prod-guard
# 的戳記檢查都寫成這一行:
#
# MT=$(stat -f %m "$STAMP" 2>/dev/null || stat -c %Y "$STAMP" 2>/dev/null || echo 0)
#
# 在 macOSBSD stat)上它是對的:`-f %m` 就是 mtime。
# 在 **LinuxGNU coreutils)上 `-f` 是「顯示檔案系統資訊」**,而且它
# **一邊回非零、一邊往 stdout 吐一整段檔案系統的區塊**:
#
# $ stat -f %m /tmp/.probe
# stat: cannot read file system information for '%m': No such file or directory ← stderr
# File: "/tmp/.probe" ← stdout
# ID: 0 Namelen: 255 Type: ext2/ext3
# …
#
# ⇒ `2>/dev/null` 把錯誤訊息吃掉、`||` 接著跑 `stat -c %Y` 把正確的秒數**接在那堆垃圾後面**
# ⇒ `MT` 變成「一段多行文字+一個數字」
# ⇒ 閘下一行的 `case "$NOW$MT" in *[!0-9]*) return 1` 一定命中
# ⇒ **在 Linux 上,那三支閘的戳記永遠不會被接受。**
#
# 🔴 這件事的後果不是「少一個便利功能」:
# 那三支閘都是「擋下來,但**總管看過就可以放行**」的設計。
# 放行的那道門在雲端(Linux)打不開 ⇒ **它們在雲端等於純擋**,
# 總管照著閘自己印的指示做,做幾次都打不開,而閘不會告訴他門是壞的。
# inkstone/InkStoneCo#99 記的「連續四次蓋不出戳記」就是這個形狀。
#
# ── 再往下一層:`-f` 根本不吃格式參數,所以連離開碼都不可靠 ────────────
#
# 2026-08-28 兩個人量這一格,一個量到 `exit=0`、一個量到 `exit=1`。**兩個都是真的**
# 而分歧本身就是最後一塊拼圖:**GNU 的 `-f` 是 `--file-system`,布林旗標、不接格式字串**
# ⇒ `%m` 被當成**另一個檔名運算元** ⇒ 離開碼取決於「cwd 裡有沒有一個叫 `%m` 的檔」:
#
# A. 沒有(一般情況) → exit 1 ⇒ `||` **會**跑 ⇒ 正確的秒數接在垃圾後面
# B. 剛好有 → exit 0 ⇒ `||` **不會**跑 ⇒ 整包連一個數字都沒有
#
# 兩種情況下閘的結果一樣:`MT` 都不是純數字,戳記都作廢。(實測 GNU coreutils 9.4
#
# 🔴 **這才是該記住的教訓**:舊寫法的 `||` fallback 救不了,
# **不是因為它沒跑,而是因為「跑不跑」根本不由這支腳本決定**
# ——它由「cwd 裡有沒有某個檔名」決定。
# 一個**行為取決於 cwd 裡有沒有某個檔**的判斷式,不管跑不跑都是壞的。
#
# 🔴 所以修法不是「把順序反過來」而已:改成先 `-c %Y`(GNU)再 `-f %m`BSD),
# 而且**每一步都驗它是不是純數字**——因為這個 bug 的成因正是
# 「命令失敗了卻還是印了東西出來」,只看離開碼會再被騙一次。
file_mtime() {
_fm=$(stat -c %Y "$1" 2>/dev/null || true)
case "${_fm:-}" in ''|*[!0-9]*) _fm=$(stat -f %m "$1" 2>/dev/null || true) ;; esac
case "${_fm:-}" in ''|*[!0-9]*) _fm=$(python3 -c 'import os,sys; print(int(os.path.getmtime(sys.argv[1])))' "$1" 2>/dev/null || true) ;; esac
case "${_fm:-}" in ''|*[!0-9]*) _fm=0 ;; esac
printf '%s' "$_fm"
}