#!/usr/bin/env python3 """hooks/lib/mainline.py —— 「現在的主線是哪一個」這件事的**唯一存放處**。 這是 helper,不是閘(inkstone/ISEP#40 S7:`lib/` 底下的東西不算一支手寫的閘)。 四個呼叫者共用它,所以「主線是誰」「誰在主線上」的答案只有一份: · `scripts/mainline` —— 人問「現在的主線是哪一個」 · `scripts/ticket pick/claim` —— 雲端工人抓票:抓的是主線的成員 · `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 個里程碑同時亮著卻不知道該看哪個**」。 ⇒ 所以這個檔的形狀就是答案的形狀:**一個檔,放得下一條**。 沒有清單、沒有優先序、沒有「前三名」——被覆蓋掉的那條就不再是主線。 問「現在的主線是哪一個」永遠只會拿到 0 或 1 個答案,不會拿到 14 個。 ── 一條線在整個 Gitea 只有一個里程碑;別的 repo 用相依指過來(inkstone/ISEP#133)── 🔴 本檔第一版寫的是「同名跨 repo 是既有做法,成員=票所屬 milestone 的標題與主線相同」。 那不是規則,是當時現場的樣子——而 leo 早在 2026-08-20(inkstone/ISEP#30)與 08-27 (inkstone/InkStoneCo#44)就定了相反的規則:**一條線一個里程碑,別 repo 的票用相依指到 hub 票**。 工具把現場當規則寫進檔頭,總管 09-07 照它做,還在 arcrun-rag 補建了一個同名的; leo 當天:「拆,誰說『同名跨 repo 是 mainline 機制的一部分』,早就定了只有一個 milestone, 不同 repo 用指針」。⇒ 檔頭寫的「既有做法」會被當成規則抄走,所以這裡改成規則本身: · **錨**(anchor)= `owner/repo#`:整個 Gitea 只有這一個 milestone 物件。 期限、進度、目標宣告都讀它。 · **hub 票**(`hub_ticket`,可選)= 這條線的載體票(例:inkstone/InkStoneCo#44「這張票是那條線本身」)。 別 repo 的票要進主線,就成為它(或 hub 里程碑裡任一張票)的 **dependency**。 · **成員判定**= `collect_members()`:hub 里程碑裡的票 + hub 票,再沿著 Gitea 的 `/dependencies` 邊(跨 repo)一路收下去。**判準是 milestone id 與相依邊,不是標題。** 同名 milestone 在別的 repo 裡開著也**不算**成員——那正是被推翻的那條。 · `belongs()` 因此需要「這張票掛在哪個 milestone」+「誰把它當相依」兩件事實才敢說「不屬於」; 只知道一半 ⇒ 回 None(不知道),閘拿到 None 一律放行。 ── `load()`/`line()` 從不打網路 ───────────────────────────────── 它們被 `UserPromptSubmit` 用(每一則訊息的關鍵路徑)。在那裡打 HTTP = 每講一句話 都先等一次網路。網路那一半全部在 `scripts/mainline`(set/refresh/adopt), 本支的讀寫只碰 state 檔。讀不到 ⇒ 回「沒有主線」,**不編一條出來**。 兩個例外都不在關鍵路徑上:`fetch_gitea()`(inkstone/ISEP#140,只有 SessionStart 的 `scripts/mainline refresh` 叫)與 `collect_members()`(要呼叫端把 api 函式傳進來, 本檔自己不帶憑證、不認主機;`load()`/`line()` 永遠不叫它們)。 ── 三個來源,優先序寫死:家目錄 > repo 那份 > Gitea main 那份 ─────── ① 家目錄 `~/.claude/isep-countdown/mainline.json`(這台機器上 refresh 過、有進度) ② repo 那份 `InkStoneCo/system-dev/mainline.json`(隨 repo 走,inkstone/ISEP#130) ③ Gitea 上 `inkstone/InkStoneCo` main 的同一個檔(inkstone/ISEP#140) ①② 之間誰算數看 `set_at`(見 `load()`);③ **只在 ①② 都不存在時**被問一次, 抓到就寫進 ①,之後跟 ① 一樣對待。③ 讀的就是 ② 那個檔,只是從 Gitea 拿而不是從 工作樹拿——它不是第二套主線來源。 """ 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) # ── repo 裡的那一份(inkstone/ISEP#130)────────────────────────────── # # 主線原本只住在 `~/.claude/isep-countdown/mainline.json`——**這台機器的家目錄**。 # 雲端 session 是另一台機器:家目錄是空的,clone 下來的 repo 才是它唯一拿得到的東西。 # 2026-09-05 leo:「routine 每天早上開啟後抓不到任務」——不是 Gitea 沒票, # 是雲端根本不知道主線是哪一條。 # ⇒ `inkstone/InkStoneCo` 的 `system-dev/mainline.json` 是**隨 repo 走的那一份** # (PR inkstone/InkStoneCo#118 放進去的)。這裡的規矩: # · 讀:家目錄那份沒有 ⇒ 讀 repo 那份(雲端就是這個形狀)。 # 兩份都在 ⇒ 用 `set_at` 較新的(誰後標的誰算數,不分本機雲端)。 # · 寫:`set`/`adopt`/`clear` 這種「主線是誰」的改動 ⇒ 兩份一起寫(`mirror=True`)。 # `refresh`/閘順手學到成員 ⇒ 只寫家目錄那份——否則每開一個 session # InkStoneCo 的工作樹就髒一次,而髒的 diff 沒有人會去 commit。 # · repo 那份要 commit/開 PR 才到得了雲端;這支不碰 git。 REPO_REL = os.path.join("system-dev", STATE_NAME) def project_roots(): """「這個 session 的專案在哪」的候選根,依序:`$CLAUDE_PROJECT_DIR`、它底下的 `InkStoneCo/` (雲端薄殼的形狀:專案根是薄殼、真身在 InkStoneCo/)、cwd、cwd 底下的 `InkStoneCo/`。 主線檔(本檔)與 `scripts/milestone-account` 的帳本(inkstone/ISEP#133)都靠它找 `system-dev/`——兩份「隨 repo 走的狀態」用同一把尺找家,不各寫一套。""" roots = [] for r in (os.environ.get("CLAUDE_PROJECT_DIR", "").strip(), os.getcwd()): if r: for c in (r, os.path.join(r, "InkStoneCo")): if c not in roots: roots.append(c) return roots def repo_candidates(): """repo 那一份可能在哪:明指 > 專案根 > 專案根底下的 InkStoneCo/(雲端薄殼的形狀)> cwd 同兩層。""" out = [] forced = os.environ.get("ISEP_MAINLINE_REPO_FILE", "").strip() if forced == "none": return [] # 測試用:把「repo 那份」整個拿掉,外界定住 if forced: out.append(forced) for r in project_roots(): p = os.path.join(r, REPO_REL) if p not in out: out.append(p) return out def repo_path(for_write=False): """讀:第一個**存在**的檔。寫:第一個 `system-dev/` 目錄存在的位置(檔可以還沒有)。 找不到 ⇒ None(雲端沒 clone InkStoneCo、或 cwd 在別的 repo 時都是正常狀態)。""" for p in repo_candidates(): if for_write: if os.path.isdir(os.path.dirname(p)): return p elif os.path.isfile(p): return p return None def _valid(d): """一份主線檔長什麼樣才算數:dict、而且有 title。三個來源用同一把尺。""" return d if (isinstance(d, dict) and d.get("title")) else None def _read(p): try: with open(p) as f: d = json.load(f) except Exception: return None return _valid(d) # ── Gitea main 上的那一份(inkstone/ISEP#140)──────────────────────── # # 雲端薄殼 `youlinhsieh/inkstoneco` 的 SessionStart 跑在 `bootstrap.sh` 之前:那時 # `InkStoneCo/` 還沒 clone,家目錄又是空的 ⇒ 上面兩份都不存在 ⇒ `countdown.py` 退到 # 第二基準「期限最近的 milestone」⇒ 第一則 prompt 印出一條兩週前的舊線 # (票上實測:`🔴 主線 把管理這條線做對 已逾期 341 小時`+`🎯 現在沒有主線`)。 # 機制沒壞,是順序。而「哪一條是主線」這個指標本來就在 Gitea 上讀得到。 # # 規矩(跟上面「repo 裡的那一份」那段接起來,不另立一套): # · 只在家目錄與 repo 那份**都不存在**時問 Gitea,問一次;任一份在場就不問。 # · 只有 SessionStart 的 `scripts/mainline refresh` 會叫 `fetch_gitea()`;抓到就 # `save()` 進家目錄(不 mirror:雲端沒有 InkStoneCo/ 可寫,本機也不弄髒工作樹)。 # bootstrap 之後 repo 那份出現時,兩份 `set_at` 相同 ⇒ 家目錄那份算數(它有 refresh 過的進度)。 # · 匿名優先。匿名被拒(401/403/404)且環境拿得到 Gitea token 才帶 token 再問一次 # ——`inkstone/InkStoneCo` 在 Gitea 是 **private**(2026-09-07 實測:匿名打 raw/ # contents/repo API 都 404),沒 token 就真的讀不到。 # · 讀不到(斷網/5xx/不是 JSON/沒 title/逾時)⇒ 回 None,退回現行行為,不報錯。 # 逾時有上限(`ISEP_MAINLINE_GITEA_TIMEOUT`,預設 4 秒,最多問兩次)。 # · 測試用:`ISEP_MAINLINE_GITEA_URL=none` 把這個來源整個拿掉;`TICKET_HOST` 換主機 # (既有測試都把它指到連不上的位址,所以它們照舊離線)。 GITEA_HOST_DEFAULT = "https://git.uncle6.me" GITEA_REPO = "inkstone/InkStoneCo" GITEA_REF = "main" def gitea_url() -> str: forced = os.environ.get("ISEP_MAINLINE_GITEA_URL", "").strip() if forced == "none": return "" if forced: return forced host = (os.environ.get("TICKET_HOST", "").strip() or GITEA_HOST_DEFAULT).rstrip("/") return "%s/api/v1/repos/%s/raw/%s?ref=%s" % ( host, GITEA_REPO, REPO_REL.replace(os.sep, "/"), GITEA_REF) def fetch_gitea(token="", timeout=None): """去 Gitea 讀 `inkstone/InkStoneCo` main 上的 `system-dev/mainline.json`。 回一份跟 `_read()` 同樣通過 `_valid()` 的 dict,或 None。**永遠不 raise。** `token`:字串,或一個回字串的函式(只在匿名被拒時才會被叫)。 🔴 這是本檔唯一會打網路的函式,只准 SessionStart 的 `scripts/mainline refresh` 叫; `load()`/`line()` 走的是每一則訊息的關鍵路徑,不准碰它。 """ url = gitea_url() if not url: return None if timeout is None: try: timeout = float(os.environ.get("ISEP_MAINLINE_GITEA_TIMEOUT", "") or 4) except Exception: timeout = 4.0 import urllib.error import urllib.request def get(headers): req = urllib.request.Request(url, headers=headers) with urllib.request.urlopen(req, timeout=timeout) as r: return r.read().decode("utf-8", "replace") try: text = get({}) # 匿名優先(D20 的形狀:讀不帶憑證) except urllib.error.HTTPError as e: if e.code not in (401, 403, 404): return None # `token` 可以是字串,也可以是「被拒了才去拿」的函式——拿 token 可能要掃 git remote、 # 可能拿不到而想開口抱怨,匿名讀得到的時候根本不該走到那裡。 tok = token() if callable(token) else token if not tok: return None try: text = get({"Authorization": "token " + tok}) # private repo:帶 token 再問一次 except Exception: return None except Exception: return None try: return _valid(json.loads(text)) except Exception: return None def load(): """讀主線。沒有/壞掉 ⇒ None(=現在沒有主線),**不是例外**。 「沒有主線」是一個正常狀態,不是故障:ISEP#82 驗收第 4 條寫死了 「沒有任何主線時不能整組壞掉」。所以這支從不 raise。 家目錄那份與 repo 那份都在時,`set_at` 較新的算數(格式固定 `YYYY-MM-DD HH:MM`, 字串比大小就是時間比大小);一樣新 ⇒ 家目錄那份(它才有 refresh 過的進度)。 """ local = _read(path()) rp = repo_path() remote = _read(rp) if rp else None if local and remote: return remote if (remote.get("set_at") or "") > (local.get("set_at") or "") else local return local or remote def save(d, mirror=False) -> bool: """寫家目錄那份;`mirror=True` 時同時寫 repo 那份(只有「主線是誰」變了才這樣叫)。 回 True/False 只看家目錄那份——repo 那份寫不寫得進去用 `repo_path(for_write=True)` 另外問。""" try: with open(path(), "w") as f: json.dump(d, f, ensure_ascii=False, indent=2) except Exception: return False if mirror: rp = repo_path(for_write=True) if rp: try: with open(rp, "w") as f: json.dump(d, f, ensure_ascii=False, indent=2) f.write("\n") except Exception: pass return True def clear(mirror=False) -> bool: ok = True try: os.remove(path()) except FileNotFoundError: pass except Exception: ok = False if mirror: rp = repo_path() if rp: try: os.remove(rp) except Exception: ok = False return ok 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: """比對標題用。只做「看不見的差異」的正規化——大小寫與空白。 🔴 刻意不做同義詞、不做模糊比對:主線是**被指定的**,不是被猜出來的。 猜錯的方向是「把不屬於主線的票放行」,那正是這張票要擋的事。 inkstone/ISEP#133 起標題只用來**查**(Gitea 的 `milestones=` 只吃名字), 成員判定看的是 milestone id 與相依邊,不再拿標題當判準。 """ 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 split_ref(ref): """`owner/repo#N` → (owner, repo, N);不是這個形狀 ⇒ None。""" import re m = re.match(r"^([\w.-]+)/([\w.-]+)#(\d+)$", (ref or "").strip()) return (m.group(1), m.group(2), int(m.group(3))) if m else None def issue_ref(issue, owner=None, repo=None): """Gitea 的 issue dict → `owner/repo#N`。相依端點回的 issue 帶 `repository.full_name`, 列 milestone 的端點有時不帶 ⇒ 呼叫端補 owner/repo。""" if not isinstance(issue, dict) or not issue.get("number"): return None full = ((issue.get("repository") or {}).get("full_name")) or ( "%s/%s" % (owner, repo) if owner and repo else None) return "%s#%s" % (full, issue["number"]) if full else None def hub_ticket(ms): return ((ms or {}).get("hub_ticket") or "").strip() or None def members(ms): out = set(ms.get("members") or []) if ms else set() h = hub_ticket(ms) if h: out.add(h) return out MEMBER_WALK_CAP = 300 def collect_members(api, ms, cap=MEMBER_WALK_CAP): """主線的成員(inkstone/ISEP#133):**hub 里程碑裡的票 + hub 票,沿 `/dependencies` 收下去**。 `api(path)` 由呼叫端提供(`scripts/ticket` 的 `api`/`api_soft`):回解析好的 JSON 或 None。 本檔不帶憑證、不認主機——這樣 `scripts/ticket` 的離線測試用假池子就測得到整條路。 回 (members, info): members 排好序的 `owner/repo#N` 清單(含已關的——進度與 `has` 都要看得到它們) info {"milestone": [...], "via_deps": [...], "hub_ticket": ref|None, "reached": bool, "capped": bool} 🔴 `reached=False` = 連 hub 里程碑那一頁都讀不到 ⇒ 呼叫端**不要拿空清單蓋掉舊快取** (讀不到 ≠ 沒有成員)。 判準只有兩個 Gitea 事實:票的 `milestone.id` 等於錨的 id、以及相依邊。標題只拿來查詢。 """ import urllib.parse owner, repo, mid = ms.get("owner"), ms.get("repo"), ms.get("id") title = ms.get("title") or "" info = {"milestone": [], "via_deps": [], "hub_ticket": hub_ticket(ms), "reached": False, "capped": False} q = urllib.parse.urlencode({"state": "all", "type": "issues", "milestones": title, "limit": 100}) rows = api("/repos/%s/%s/issues?%s" % (owner, repo, q)) if not isinstance(rows, list): return [], info info["reached"] = True roots = [] for it in rows: if not isinstance(it, dict) or it.get("pull_request"): continue im = it.get("milestone") or {} # 🔴 `milestones=` 在不同 Gitea 版本對「名稱 vs id」的解讀不一致,拿回來要自己用 id 確認 if str(im.get("id")) != str(mid) and norm(im.get("title")) != norm(title): continue r = issue_ref(it, owner, repo) if r and r not in roots: roots.append(r) info["milestone"] = list(roots) h = hub_ticket(ms) if h and h not in roots: roots.append(h) seen, queue, out = set(roots), list(roots), list(roots) while queue: cur = queue.pop(0) parts = split_ref(cur) if not parts: continue deps = api("/repos/%s/%s/issues/%d/dependencies?limit=100" % parts) if not isinstance(deps, list): continue # 這一張讀不到:跳過它,不當成「沒有相依」以外的事 for d in deps: r = issue_ref(d) if not r or r in seen: continue if len(out) >= cap: info["capped"] = True break seen.add(r); out.append(r); queue.append(r) info["via_deps"].append(r) return sorted(set(out)), info def _ms_matches(ticket_milestone, ms): """票所掛的 milestone 是不是錨那一個。dict 看 id(沒 id 才退回標題);str 只有標題。""" if isinstance(ticket_milestone, dict): if ticket_milestone.get("id") is not None and ms.get("id") is not None: return str(ticket_milestone.get("id")) == str(ms.get("id")) return norm(ticket_milestone.get("title")) == norm(ms.get("title")) return norm(ticket_milestone) == norm(ms.get("title")) def belongs(ticket_ref, ms, ticket_milestone=None, blocks=None): """這張票屬不屬於主線?回 True/False/None。 None = **不知道**。🔴 「不知道」不等於「不屬於」——這條分界就是誤攔與否的分水嶺。 呼叫的閘拿到 None 時該放行(本 repo 心法第 2 條:誤攔比漏擋更該修)。 inkstone/ISEP#133 起要說「不屬於」需要兩件事實都在手上: ticket_milestone 票掛在哪個 milestone(dict 或標題字串;None=沒查) blocks 誰把這張票當相依(`/issues/N/blocks` 回的 ref 清單;None=沒查) · 在快取成員裡/就是 hub 票 ⇒ True · 掛在錨那一個 milestone(同 repo、同 id) ⇒ True · blocks 裡有任何一張是成員或 hub 票 ⇒ True · milestone 與 blocks 都查過、都不中 ⇒ False · 只查了其中一半而那一半不中 ⇒ None(別 repo 的票只看 milestone 判不出來) 別 repo 裡同名的 milestone **不算**——那是被 leo 推翻的判準(本檔檔頭)。 """ if not ms: return True # 沒有主線 ⇒ 沒有「不屬於主線」這回事 ref = (ticket_ref or "").strip() if ref and ref in members(ms): return True parts = split_ref(ref) same_repo = bool(parts) and parts[0] == ms.get("owner") and parts[1] == ms.get("repo") if ticket_milestone is not None and ticket_milestone != "" and same_repo and _ms_matches(ticket_milestone, ms): return True if blocks is not None: mem = members(ms) if any((b or "").strip() in mem for b in blocks): return True if ticket_milestone is not None and blocks is not None: return False return None def fixture_lookup(fx, ref): """把 `ISEP_MAINLINE_FIXTURE` 裡一筆整理成 (ticket_milestone, blocks)。 值是字串 ⇒ 那是它掛的 milestone 標題、而且**沒有人把它當相依**(blocks=[])—— 舊測資的形狀,兩件事實都給齊了,判得出 True/False。 值是 dict ⇒ `{"milestone": {...}|"標題", "blocks": ["owner/repo#N", …]}`。 沒收錄 ⇒ None(等同問不到)。""" if not isinstance(fx, dict) or ref not in fx: return None v = fx[ref] if isinstance(v, dict): return v.get("milestone"), list(v.get("blocks") or []) return v, [] 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))