#!/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 的**標題**與主線標題相同(不分 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 `" "(先看有哪些:`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))