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:
@@ -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#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))
|
||||
+46
-6
@@ -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)
|
||||
|
||||
|
||||
@@ -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 §4/SOP S7-4),
|
||||
要解的是「**這一刻我在做哪一條**」沒有答案。
|
||||
|
||||
⇒ 所以這個檔的形狀就是答案的形狀:**一個檔,放得下一條**。
|
||||
沒有清單、沒有優先序、沒有「前三名」——被覆蓋掉的那條就不再是主線。
|
||||
問「現在的主線是哪一個」永遠只會拿到 0 或 1 個答案,不會拿到 14 個。
|
||||
|
||||
── 主線是一個「名字」,錨在一個具體的 milestone 上 ──────────────────
|
||||
Gitea 的 milestone 不能跨 repo,而同一條線常常同時開在 5 個 repo(票上實查)。
|
||||
所以:
|
||||
· **錨**(anchor)= `owner/repo#<milestone id>`:期限、進度、目標宣告都讀它
|
||||
· **成員判定**= 票所屬 milestone 的**標題**與主線標題相同(不分 repo)
|
||||
⇒ 「同名跨 repo」照舊能用,而「現在在做哪一條」仍然只有一個答案。
|
||||
|
||||
── 這支從不打網路 ────────────────────────────────────────────────
|
||||
它被 `UserPromptSubmit` 用(每一則訊息的關鍵路徑)。在那裡打 HTTP = 每講一句話
|
||||
都先等一次網路。網路那一半全部在 `scripts/mainline`(set/refresh/adopt),
|
||||
本支只讀寫 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/False/None。
|
||||
|
||||
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))
|
||||
@@ -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)
|
||||
#
|
||||
# 在 macOS(BSD stat)上它是對的:`-f %m` 就是 mtime。
|
||||
# 在 **Linux(GNU 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"
|
||||
}
|
||||
Reference in New Issue
Block a user