主線成員改由相依邊決定,不靠同名里程碑;adopt 別 repo 的票=加相依到 hub 票;帳本落在 InkStoneCo/system-dev(inkstone/ISEP#133 → 6743)

leo 2026-09-07:「拆,誰說『同名跨 repo 是 mainline 機制的一部分』,早就定了只有一個
milestone,不同 repo 用指針」。上一版 scripts/mainline 檔頭把現場的樣子(5 個同名)寫成
「既有做法」,成員也照標題跨 repo 收;總管照它做、抄進 ops-facts、還在 arcrun-rag 補建了一個同名的。

改了什麼(判準全部是 Gitea 欄位,沒有標題比對):
- hooks/lib/mainline.py:collect_members()=hub 里程碑裡的票+hub 票,沿 /dependencies 邊
  (跨 repo)收下去;belongs() 要同時拿到「掛哪個 milestone」+「誰把它當相依」才准說「不屬於」,
  只知一半回 None(閘放行);project_roots() 給主線檔與帳本共用
- scripts/mainline:set --hub <票>、adopt 別 repo ⇒ POST 相依到 hub 票(--via 可指定成員),
  沒 hub 票時擋下並給兩條走得通的路,不建同名里程碑;has 查 /blocks;
  refresh --members 才重收(真 Gitea 25 張要 19 秒,SessionStart 成本不變)
- scripts/ticket:add_dependency() 一份(subtask 與 adopt 共用,409 冪等);pick 主線組走成員清單,
  逾期組一個里程碑物件一組;claim 的主線判定改看相依;pickable(None) 給靠邊進來的票
- hooks/mainline-focus-guard.sh:新鮮查詢問 milestone+/blocks,補收出路明講不要開同名里程碑
- scripts/milestone-account:帳本找家用同一把尺,退回原始碼目錄只在它有 .git(plugin 快取沒有)

測試:新 hooks/tests/mainline-members-by-dependency.test.sh 56 條(假 Gitea 照真的回 201/409);
scripts/test-ticket-pick.sh 48→53 條(池子改用相依邊,補「同名沒邊 ⇒ 不抓」);
既有 mainline-focus-guard/repo-mirror/gitea-fallback/idle/milestone-account/debt-worklist/
handoff-writeback 全綠。真 Gitea 唯讀實跑:set --hub inkstone/InkStoneCo#44 收到 25 張、5 個 repo,
mira#6/ISEP#130/ISEP#140/arcrun-rag#104 都在,不必新增任何邊。

版本:待總管定版(改了會被載入的 hook/scripts)。

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0178ef1fGw3XeZtpN7LaZrm4
This commit is contained in:
isep-hand
2026-09-07 10:40:55 +00:00
parent 7a9e30589c
commit d4bf172990
11 changed files with 912 additions and 170 deletions
+174 -24
View File
@@ -2,8 +2,9 @@
"""hooks/lib/mainline.py —— 「現在的主線是哪一個」這件事的**唯一存放處**。
這是 helper,不是閘(inkstone/ISEP#40 S7`lib/` 底下的東西不算一支手寫的閘)。
個呼叫者共用它,所以「主線是誰」的答案只有一份:
個呼叫者共用它,所以「主線是誰」「誰在主線上」的答案只有一份:
· `scripts/mainline` —— 人問「現在的主線是哪一個」
· `scripts/ticket pickclaim` —— 雲端工人抓票:抓的是主線的成員
· `hooks/lib/countdown.py` —— 每一則回覆眼前那一行的第二段
· `hooks/mainline-focus-guard.sh` —— 派了一張不屬於主線的票就攔一次
@@ -13,27 +14,35 @@
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」照舊能用,而「現在在做哪一條」仍然只有一個答案。
── 一條線在整個 Gitea 只有一個里程碑;別的 repo 用相依指過來(inkstone/ISEP#133──
🔴 本檔第一版寫的是「同名跨 repo 是既有做法,成員=票所屬 milestone 的標題與主線相同」
那不是規則,是當時現場的樣子——而 leo 早在 2026-08-20inkstone/ISEP#30)與 08-27
inkstone/InkStoneCo#44)就定了相反的規則:**一條線一個里程碑,別 repo 的票用相依指到 hub 票**。
工具把現場當規則寫進檔頭,總管 09-07 照它做,還在 arcrun-rag 補建了一個同名的;
leo 當天:「拆,誰說『同名跨 repo 是 mainline 機制的一部分』,早就定了只有一個 milestone,
不同 repo 用指針」。⇒ 檔頭寫的「既有做法」會被當成規則抄走,所以這裡改成規則本身:
· **錨**anchor)= `owner/repo#<milestone id>`:整個 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`setrefreshadopt),
本支的讀寫只碰 state 檔。讀不到 ⇒ 回「沒有主線」,**不編一條出來**。
唯一的例外是 `fetch_gitea()`inkstone/ISEP#140見下方第三個來源那段):
它會打一次 Gitea,但**只有 SessionStart 的 `scripts/mainline refresh` 叫它**
`load()``line()` 永遠不叫——所以每一則訊息的路徑上仍然沒有網路
兩個例外都不在關鍵路徑上:`fetch_gitea()`inkstone/ISEP#140只有 SessionStart 的
`scripts/mainline refresh` 叫)與 `collect_members()`(要呼叫端把 api 函式傳進來
本檔自己不帶憑證、不認主機;`load()``line()` 永遠不叫它們)
── 三個來源,優先序寫死:家目錄 > repo 那份 > Gitea main 那份 ───────
① 家目錄 `~/.claude/isep-countdown/mainline.json`(這台機器上 refresh 過、有進度)
@@ -86,6 +95,21 @@ def path() -> str:
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 = []
@@ -94,11 +118,7 @@ def repo_candidates():
return [] # 測試用:把「repo 那份」整個拿掉,外界定住
if forced:
out.append(forced)
roots = []
for r in (os.environ.get("CLAUDE_PROJECT_DIR", "").strip(), os.getcwd()):
if r:
roots += [r, os.path.join(r, "InkStoneCo")]
for r in roots:
for r in project_roots():
p = os.path.join(r, REPO_REL)
if p not in out:
out.append(p)
@@ -291,6 +311,8 @@ def norm(s) -> str:
🔴 刻意不做同義詞、不做模糊比對:主線是**被指定的**,不是被猜出來的。
猜錯的方向是「把不屬於主線的票放行」,那正是這張票要擋的事。
inkstone/ISEP#133 起標題只用來**查**Gitea 的 `milestones=` 只吃名字),
成員判定看的是 milestone id 與相依邊,不再拿標題當判準。
"""
return "".join((s or "").split()).lower()
@@ -301,27 +323,155 @@ def ref_of(ms) -> str:
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):
return set(ms.get("members") or []) if ms else set()
out = set(ms.get("members") or []) if ms else set()
h = hub_ticket(ms)
if h:
out.add(h)
return out
def belongs(ticket_ref, ms, ticket_milestone_title=None):
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/FalseNone。
None **不知道**(快取裡沒有這張票,而呼叫端也沒查到它的 milestone)
🔴 「不知道」不等於「不屬於」——這條分界就是誤攔與否的分水嶺。
None **不知道**。🔴 「不知道」不等於「不屬於」——這條分界就是誤攔與否的分水嶺
呼叫的閘拿到 None 時該放行(本 repo 心法第 2 條:誤攔比漏擋更該修)。
inkstone/ISEP#133 起要說「不屬於」需要兩件事實都在手上:
ticket_milestone 票掛在哪個 milestonedict 或標題字串;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
if ticket_milestone_title is not None:
return norm(ticket_milestone_title) == norm(ms.get("title"))
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 問這張票掛在哪」換成一份現成的答案。