#!/usr/bin/env python3 """mainline — 「現在的主線是哪一個」這個問題的**唯一指令**(inkstone/ISEP#82)。 leo 2026-08-27:「**14 個里程碑同時亮著卻不知道該看哪個**」。 當天實查 `GET /repos/inkstone/*/milestones?state=open` 回 14 個,其中「Mira 現代化」 這個名字同時活在 5 個 repo,另有 5 個已經逾期。 ⇒ SOP 說的「**那個** active milestone」在現場根本沒有指涉對象—— 要注入不知道注哪一個,要擋跳線也不知道拿哪一個當基準。 mainline 現在的主線是哪一個(= show,唯一答案,離線) mainline list 有哪些 open milestone 可以選(打網路) mainline set [--hub ] 把某一個 milestone 標成主線(打網路,寫進 state); --hub 指定這條線的載體票(別 repo 的票用相依指到它) mainline clear 現在沒有主線(例:一條線收掉了,下一條還沒開始) mainline refresh [--members] 更新進度/期限(SessionStart 跑一次,不輪詢);--members 順便重收成員 mainline adopt [--via ] 「補收」:把一張票掛進主線(打網路)。同 repo ⇒ 掛進 hub 里程碑; 別 repo ⇒ 成為 hub 票的相依。**不建同名里程碑。** mainline has 這張票在不在主線上(exit 0 =在,1 =不在) 🔴 **`` 的 id 是 milestone 的 id,不是 issue 的號碼。** 兩者在 Gitea 是兩套獨立編號,`mainline list` 印出來的就是可以直接貼的那一個。 ── 答案為什麼唯一 ───────────────────────────────────────────────── 主線住在一個檔裡(`~/.claude/isep-countdown/mainline.json`),那個檔**放得下一條**。 (inkstone/ISEP#130 起它有一份隨 repo 走的鏡像:`InkStoneCo/system-dev/mainline.json`—— 雲端沒有家目錄那份,讀的就是它;`set`/`adopt`/`clear` 兩份一起寫,`refresh` 只寫家目錄。 inkstone/ISEP#140 起:兩份都不在時(雲端薄殼 bootstrap 之前的形狀),`refresh` 會去 Gitea 讀 `inkstone/InkStoneCo` main 上那份一次、寫進家目錄——優先序寫在 `hooks/lib/mainline.py` 檔頭。) 沒有清單、沒有優先序、沒有「前三名」——`set` 覆蓋掉的那條就不再是主線。 ⇒ 問一次只會拿到 0 或 1 個答案,不會拿到 14 個。 ── 一條線只有一個里程碑;別的 repo 用相依指過來(inkstone/ISEP#133)────── 🔴 這支的第一版檔頭寫「跨 repo 同名是既有做法,不動它」,成員也是照標題跨 repo 收。 那句話被總管 09-07 當成規則抄進 InkStoneCo 的 ops-facts,還在 arcrun-rag 多建了一個同名的。 leo 當天:「拆,誰說『同名跨 repo 是 mainline 機制的一部分』,早就定了只有一個 milestone, 不同 repo 用指針」(規則本身在 inkstone/ISEP#30、inkstone/InkStoneCo#44)。 現在的形狀(判準全部是 Gitea 的欄位,沒有標題比對): · **錨** = `owner/repo#`:整個 Gitea 只有這一個 milestone 物件, 期限/進度/目標宣告都讀它。 · **hub 票**(`set --hub`)= 這條線的載體票。別 repo 的票要進主線 ⇒ 成為它的 dependency。 · **成員** = hub 里程碑裡的票 + hub 票,沿 `/dependencies` 邊(跨 repo)收下去 (`hooks/lib/mainline.py::collect_members`)。同名里程碑關掉之後成員一張都不會掉—— 因為成員從來不是靠名字算的。 · `adopt` 別 repo 的票 ⇒ 在 hub 票上加一條 dependency,**不會在那個 repo 建同名里程碑**。 """ import importlib.machinery import importlib.util import os import sys from datetime import datetime, timezone HERE = os.path.dirname(os.path.abspath(__file__)) def _load(name, path): """把一個檔載進來當模組。`scripts/ticket` 沒有 `.py` 副檔名,所以要明著給 loader (`spec_from_file_location` 只靠副檔名認不出它,會回 None)。""" loader = importlib.machinery.SourceFileLoader(name, path) spec = importlib.util.spec_from_file_location(name, path, loader=loader) mod = importlib.util.module_from_spec(spec) loader.exec_module(mod) return mod # 憑證怎麼拿、API 怎麼打、票號怎麼解析——**全部沿用 `scripts/ticket` 那一份**。 # 🔴 刻意不自己再寫一份 token():金鑰的取得只准有一條路(頂層 CLAUDE.md 金鑰鐵律 # + 2026-07-29 t145 的實害——同一把金鑰兩種寫法並存,必然漂移)。 ML = _load("isep_mainline", os.path.join(HERE, "..", "hooks", "lib", "mainline.py")) try: T = _load("isep_ticket", os.path.join(HERE, "ticket")) except Exception: # 🔴 載不到就只是「打不了網路的那幾個動作不能用」,不是整支壞掉。 # ISEP#82 驗收第 4 條要的是「沒有主線也不能整組壞掉」,同一個道理: # `show` 是離線的,它不該因為別的零件壞了而回答不出來。 T = None def die(msg, code=2): print(msg, file=sys.stderr) sys.exit(code) def need_net(): if T is None: die("🔴 載不到 `scripts/ticket`(憑證與 API 都沿用它),這個動作打不了網路。\n" " `scripts/mainline show` 仍然可以用——它是離線的。") def parse_ms_ref(s): """`owner/repo#`。與票號同形,所以錯手貼票號會拿到 404,訊息要講清楚。""" import re m = re.match(r"^([\w.-]+)/([\w.-]+)#(\d+)$", (s or "").strip()) if not m: die("🔴 寫法是 owner/repo#,你給的是:%s\n" "(先跑 `scripts/mainline list` 看有哪些,那裡印的就是可以直接貼的)" % s) return m.group(1), m.group(2), int(m.group(3)) def org_repos(owner): rows = T.api_soft("/orgs/%s/repos?limit=100" % owner) or [] return [r["name"] for r in rows if isinstance(r, dict) and r.get("name")] def collect_members(ms): """成員=hub 里程碑裡的票 + hub 票 + 它們的相依(跨 repo)。判準在 `hooks/lib/mainline.py`。""" return ML.collect_members(T.api_soft, ms) def members_line(refs, info): byrepo = {} for r in refs: byrepo[r.split("#")[0]] = byrepo.get(r.split("#")[0], 0) + 1 dist = "、".join("%s %d" % (k, v) for k, v in sorted(byrepo.items())) return (" 成員 %d 張(里程碑裡 %d + 相依收到 %d)%s" % ( len(refs), len(info.get("milestone") or []), len(info.get("via_deps") or []), (":" + dist) if dist else "")) def fetch_issue(ref): parts = ML.split_ref(ref) if not parts: die("🔴 票的寫法是 owner/repo#N,你給的是:%s" % ref) it = T.api_soft("/repos/%s/%s/issues/%d" % parts) return parts, (it if isinstance(it, dict) and it.get("number") else None) def fetch_blocks(parts): """誰把這張票當相依(`/blocks`)。讀不到 ⇒ None(讀不到 ≠ 沒有人)。""" rows = T.api_soft("/repos/%s/%s/issues/%d/blocks?limit=100" % parts) if not isinstance(rows, list): return None return [r for r in (ML.issue_ref(x) for x in rows) if r] def fetch_milestone(owner, repo, mid): d = T.api_soft("/repos/%s/%s/milestones/%d" % (owner, repo, mid)) if not isinstance(d, dict) or not d.get("title"): die("🔴 讀不到 %s/%s#%d 這個 milestone。\n" " · id 是 milestone 的 id,不是 issue 的號碼(`scripts/mainline list` 會印對的)\n" " · 或是這台機器現在拿不到 Gitea(那就先別標,不要標一個猜的)" % (owner, repo, mid)) return d # ── show ───────────────────────────────────────────────────────────────── def cmd_show(argv): """離線。**永遠回一個答案**,包括「現在沒有主線」(ISEP#82 驗收第 4 條)。""" ms = ML.load() if not ms: print("🎯 現在沒有主線。") print() print(" 這不是故障——沒有人標過,或上一條收掉了。") print(" 要標一條:`scripts/mainline list` 看有哪些 → `scripts/mainline set `") print() print("📌 在標之前,每回合的 🎯 那一行也會照樣講「現在沒有主線」,不會編一條出來。") print(" 雲端:SessionStart 的 `mainline refresh` 會先去 Gitea 讀 inkstone/InkStoneCo main 的") print(" system-dev/mainline.json;還是沒有 ⇒ 那份不存在,或這台拿不到 Gitea(inkstone/ISEP#140)。") return c, total, pct = ML.progress(ms) print("🎯 現在的主線:%s「%s」" % (ML.ref_of(ms), ms.get("title"))) desc = (ms.get("description") or "").strip() if desc: first = desc.splitlines()[0].strip() # 只印第一行、最多 150 字:這是**一眼掃**的答案,不是 milestone 的全文。 # leo 一天看兩眼(白話鐵律),整段貼過來他就不會看。 print(" 目標:" + (first[:150] + "…" if len(first) > 150 else first)) print(" 期限:%s" % ML.due_phrase(ms)) print(" 進度:%d/%d 張已關(%d%%)" % (c, total, pct)) if ML.hub_ticket(ms): print(" hub 票:%s(別 repo 的票用相依指到它)" % ML.hub_ticket(ms)) mem = sorted(ML.members(ms)) byrepo = {} for r in mem: byrepo[r.split("#")[0]] = byrepo.get(r.split("#")[0], 0) + 1 print(" 屬於主線的票:%d 張%s" % ( len(mem), ("(" + "、".join("%s %d" % (k, v) for k, v in sorted(byrepo.items())) + ")") if byrepo else "")) if mem: print(" " + "、".join(mem)) if ms.get("set_at"): print(" 標定於:%s" % ms["set_at"]) if ms.get("refreshed_at"): print(" 最後更新:%s" % ms["refreshed_at"]) print() print("📌 這是唯一答案——主線住在一個檔裡,那個檔放得下一條:%s" % ML.path()) # ── list ───────────────────────────────────────────────────────────────── def cmd_list(argv): need_net() owner = argv[0] if argv else T.ORG rows = [] for r in org_repos(owner): for m in (T.api_soft("/repos/%s/%s/milestones?state=open" % (owner, r)) or []): if not isinstance(m, dict): continue rows.append((owner, r, m)) if not rows: die("🔴 一個 open milestone 都沒讀到——多半是拿不到 Gitea,不是真的沒有。\n" "(讀不到 ≠ 不存在。先修連線,不要因此標一個猜的主線。)", 1) now = datetime.now(timezone.utc) cur = ML.load() print("open milestone 共 %d 個(%s):\n" % (len(rows), owner)) for o, r, m in sorted(rows, key=lambda x: (ML.due_of(x[2]) or datetime.max.replace(tzinfo=timezone.utc))): c, total, pct = ML.progress(m) mark = " ← 現在的主線" if cur and ML.ref_of(cur) == "%s/%s#%s" % (o, r, m.get("id")) else "" print(" %-28s %-24s %3d%% 期限 %s%s" % ( "%s/%s#%s" % (o, r, m.get("id")), (m.get("title") or "")[:24], pct, ML.due_phrase(m, now), mark)) print("\n📌 貼左邊那一欄:`scripts/mainline set `") # ── set ────────────────────────────────────────────────────────────────── def opt_of(argv, name): if name in argv and argv.index(name) + 1 < len(argv): return argv[argv.index(name) + 1] return None def positional(argv, *flags_with_value): return [a for i, a in enumerate(argv) if not a.startswith("--") and (i == 0 or argv[i - 1] not in flags_with_value)] def cmd_set(argv): need_net() pos = positional(argv, "--hub") if not pos: die("用法:mainline set [--hub ]") owner, repo, mid = parse_ms_ref(pos[0]) m = fetch_milestone(owner, repo, mid) title = m.get("title") hub = opt_of(argv, "--hub") if hub: parts, it = fetch_issue(hub) if not it: die("🔴 讀不到 hub 票 %s(票號寫法 owner/repo#N;讀不到就先別標,不要標一張猜的)" % hub) hub = "%s/%s#%d" % parts else: prev = ML.load() or {} # 重標同一條線而沒帶 --hub ⇒ 沿用原本的 hub 票;換一條線 ⇒ 不沿用(那是別條線的載體) hub = ML.hub_ticket(prev) if prev.get("ref") == "%s/%s#%d" % (owner, repo, mid) else None ms = { "ref": "%s/%s#%d" % (owner, repo, mid), "owner": owner, "repo": repo, "id": mid, "title": title, "description": m.get("description") or "", "due_on": m.get("due_on") or "", "open_issues": m.get("open_issues") or 0, "closed_issues": m.get("closed_issues") or 0, "hub_ticket": hub, "members": [], } refs, info = collect_members(ms) ms["members"] = refs now = datetime.now(timezone.utc).astimezone().strftime("%Y-%m-%d %H:%M") ms["set_at"] = ms["refreshed_at"] = now if not ML.save(ms, mirror=True): die("🔴 寫不進 %s——主線沒有被標上。" % ML.path()) print("✅ 主線已標定:%s/%s#%d「%s」" % (owner, repo, mid, title)) if hub: print(" hub 票:%s" % hub) print(members_line(refs, info) if info.get("reached") else " ⚠️ 讀不到 hub 里程碑裡的票,成員先留空(讀不到 ≠ 沒有)") if info.get("capped"): print(" ⚠️ 相依走到 %d 張就停了(上限),剩下的沒收" % ML.MEMBER_WALK_CAP) print(mirror_line()) print() cmd_show([]) def mirror_line(): """repo 那份有沒有跟著寫(inkstone/ISEP#130):寫了要人去 commit,沒寫要講為什麼。""" rp = ML.repo_path(for_write=True) if rp and os.path.isfile(rp): return (" 已同步寫進 repo 那份:%s\n" " 📌 這份要 commit/開 PR 併進 inkstone/InkStoneCo,雲端 clone 才拿得到" % rp) return (" ⚠️ 這台找不到 InkStoneCo 的 system-dev/(CLAUDE_PROJECT_DIR 或 cwd 底下都沒有)," "repo 那份沒更新——雲端仍會讀到舊主線") def cmd_clear(argv): had = ML.load() rp = ML.repo_path() ML.clear(mirror=True) print("✅ 已清掉主線%s。現在沒有主線——每回合的 🎯 那一行會照實說。" % ("(原本是 %s「%s」)" % (ML.ref_of(had), had.get("title")) if had else "")) if rp: print(" repo 那份也刪了:%s(要 commit 才會到雲端)" % rp) # ── refresh ────────────────────────────────────────────────────────────── def soft_token(): """拿得到就給,拿不到回空字串。`refresh` 跑在 SessionStart,**不准因為沒 token 就整支炸** ——雲端薄殼的 remote 是 GitHub、家目錄是空的,那正是它最需要活著的時候。""" if T is None: return "" import contextlib import io try: with contextlib.redirect_stderr(io.StringIO()): # token() 拿不到會先抱怨再 exit:兩個都吞掉 return T.token() or "" except (SystemExit, Exception): return "" def cmd_refresh(argv): """SessionStart 跑一次。**拿不到就原封不動**——寧可資料舊,不要把主線弄丟。 inkstone/ISEP#140:家目錄與 repo 那份都不在(雲端薄殼跑 SessionStart 時 `InkStoneCo/` 還沒 clone)⇒ 去 Gitea 讀 `inkstone/InkStoneCo` main 上那份**一次**,寫進家目錄。 之後每一則 UserPromptSubmit 讀的是家目錄,路徑上照舊沒有網路。 讀不到 ⇒ 什麼都不寫,退回現行行為(`countdown.py` 的第二基準)。 """ ms = ML.load() if not ms: got = ML.fetch_gitea(token=soft_token) # 傳函式:匿名讀得到就不去碰 token if not got: return # 兩份都沒有、Gitea 也問不到 ⇒ 真的沒有主線,安靜結束 ML.save(got) # 只寫家目錄:雲端沒有 InkStoneCo/ 可寫,本機不弄髒工作樹 ms = ML.load() if not ms: return if T is None: return try: d = T.api_soft("/repos/%s/%s/milestones/%s" % (ms["owner"], ms["repo"], ms["id"])) except SystemExit: return # 拿不到 token:主線已經在了,進度晚點再更新,不炸 if not isinstance(d, dict) or not d.get("title"): return # 讀不到 ≠ 主線不存在 ms.update({ "title": d.get("title") or ms["title"], "description": d.get("description") or ms.get("description") or "", "due_on": d.get("due_on") or ms.get("due_on") or "", "open_issues": d.get("open_issues") or 0, "closed_issues": d.get("closed_issues") or 0, "refreshed_at": datetime.now(timezone.utc).astimezone().strftime("%Y-%m-%d %H:%M"), }) if "--members" in argv: # 成員靠相依邊算(inkstone/ISEP#133):hub 里程碑一頁 + 每張成員一次 /dependencies。 # 真 Gitea 實測 25 張成員要 19 秒 ⇒ SessionStart 那條不帶 --members(成本不變); # `set`/`adopt` 會重收,`pick`/`has`/focus guard 本來就每次現問 Gitea,快取只餵 `show`。 # 讀不到(reached=False)就保留舊快取——寧可資料舊,不要把成員弄丟。 try: refs, info = collect_members(ms) except SystemExit: refs, info = [], {"reached": False} if info.get("reached"): ms["members"] = refs ML.save(ms) # ── adopt(補收)────────────────────────────────────────────────────────── def cmd_adopt(argv): """補收。同 repo ⇒ 掛進 hub 里程碑(原生欄位);別 repo ⇒ 成為 hub 票的相依。 🔴 不會在別的 repo 建同名里程碑——那是被 leo 推翻的做法(本檔檔頭)。""" need_net() pos = positional(argv, "--via") if not pos: die("用法:mainline adopt [--via ](把一張票掛進主線=「補收」)") ms = ML.load() if not ms: die("🔴 現在沒有主線,沒有東西可以補收進去。先 `scripts/mainline set …`。") owner, repo, num = T.parse_ref(pos[0]) ref = "%s/%s#%d" % (owner, repo, num) if owner == ms["owner"] and repo == ms["repo"]: T.api("/repos/%s/%s/issues/%d" % (owner, repo, num), {"milestone": ms["id"]}, method="PATCH") how = "掛進 hub 里程碑 %s" % ML.ref_of(ms) else: via = opt_of(argv, "--via") or ML.hub_ticket(ms) if not via: _, info = collect_members(ms) cands = info.get("milestone") or [] die("""🔴 %s 在別的 repo,要進主線得成為 hub 票的相依——而這條線還沒有 hub 票。 兩條路(擇一,都不會建同名里程碑): · 指定這條線的載體票,之後每次 adopt 都用它: %s set %s --hub · 或這一次點名要掛在哪一張底下: %s adopt %s --via hub 里程碑裡現在有:%s (在別的 repo 開一個同名里程碑**不是**出路——leo 2026-09-07:「早就定了只有一個 milestone,不同 repo 用指針」)""" % ( ref, os.path.abspath(sys.argv[0]), ML.ref_of(ms), os.path.abspath(sys.argv[0]), ref, "、".join(cands) if cands else "(讀不到/沒有)")) vparts = ML.split_ref(via) if not vparts: die("🔴 --via 的寫法是 owner/repo#N,你給的是:%s" % via) via = "%s/%s#%d" % vparts T.add_dependency(vparts, (owner, repo, num)) how = "成為 hub 票 %s 的相依(%s/%s 沒有多出任何里程碑)" % (via, owner, repo) refs, info = collect_members(ms) if info.get("reached"): ms["members"] = sorted(set(refs) | {ref}) else: ms["members"] = sorted(set(ms.get("members") or []) | {ref}) ML.save(ms, mirror=True) print("✅ 補收:%s → %s" % (ref, how)) print(members_line(ms["members"], info) if info.get("reached") else " (成員清單沿用舊快取+這一張)") print(mirror_line()) # ── has ────────────────────────────────────────────────────────────────── def cmd_has(argv): if not argv: die("用法:mainline has ") ms = ML.load() ref = argv[0].strip() if not ms: print("🎯 現在沒有主線 ⇒ 沒有「不屬於主線」這回事。") return v = ML.belongs(ref, ms) if v is None: need_net() parts, it = fetch_issue(ref) if not it: print("⚪ 讀不到 %s,判不出來(讀不到 ≠ 不屬於)。" % ref) sys.exit(0) blocks = fetch_blocks(parts) v = ML.belongs(ref, ms, ticket_milestone=it.get("milestone") or {}, blocks=blocks) if v is None: print("⚪ %s:讀得到票,但讀不到「誰把它當相依」,判不出來(讀不到 ≠ 不屬於)。" % ref) sys.exit(0) if v: print("✅ %s 在主線「%s」上。" % (ref, ms["title"])) sys.exit(0) print("🚧 %s **不在**主線「%s」上。\n" " 補收 ⇒ `scripts/mainline adopt %s`(別 repo 的票會成為 hub 票的相依);" "真的是插件事 ⇒ 那是跳線,說清楚再做。" % (ref, ms["title"], ref)) sys.exit(1) CMDS = {"show": cmd_show, "list": cmd_list, "set": cmd_set, "clear": cmd_clear, "refresh": cmd_refresh, "adopt": cmd_adopt, "has": cmd_has} if __name__ == "__main__": # `mainline | head` 是很自然的用法,別讓它吐一段 BrokenPipe 的 traceback # ——那會讓人以為這支壞了。 try: import signal signal.signal(signal.SIGPIPE, signal.SIG_DFL) except Exception: pass if len(sys.argv) < 2: cmd_show([]) sys.exit(0) if sys.argv[1] in ("-h", "--help", "help"): print(__doc__) sys.exit(0) if sys.argv[1] not in CMDS: print(__doc__) sys.exit(2) CMDS[sys.argv[1]](sys.argv[2:])