d4bf172990
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
193 lines
9.2 KiB
Bash
Executable File
193 lines
9.2 KiB
Bash
Executable File
#!/usr/bin/env bash
|
||
# mainline-focus-guard.sh — 派了一張不在主線上的票,就當場問清楚(inkstone/ISEP#82)
|
||
#
|
||
# ── 補的是哪一格 ────────────────────────────────────────────────
|
||
# leo 2026-08-27:「**14 個里程碑同時亮著卻不知道該看哪個**」「今天……分心」。
|
||
# SOP 說「有 active milestone 時只做該 milestone 的事」——但現場 14 個 open milestone,
|
||
# 「那個」沒有指涉對象 ⇒ **那條規則在機器上等於不存在**(同款第 N 次:
|
||
# history-first/KBDB-first/stage-first 全是「規則被讀到了,卻沒有機制驗證有沒有照做」)。
|
||
#
|
||
# ISEP#82 把「現在在做哪一條」變成一個查得到的事實(`scripts/mainline`),
|
||
# 本閘是那個事實的**用處**:派工的票號不在主線上,就當場說是補收還是跳線,
|
||
# 不要默默做完——分心不是「做了壞事」,是「岔開了而沒有人注意到」。
|
||
#
|
||
# ── 🔴 這不是關鍵字黑名單(leo 2026-08-17 已證偽那條路)────────────────
|
||
# 「自然語言的變體是無限的,blacklist 永遠追不完……封路哲學之所以有效,
|
||
# 是因為它封的是**動作**——動作有限且可枚舉,文字不是。」
|
||
# 當日實測:文字層的閘 8 次誤攔、0 次正確攔截。
|
||
#
|
||
# 本閘從頭到尾**不讀派工單的任何一個字義**。它只做兩個查表:
|
||
# ① 派工單的【工單】欄位是哪一張票(`hooks/lib/dispatch_parse.py` 解結構,不判語意)
|
||
# ② 那張票在不在主線上:掛在錨那一個 milestone(同 repo、同 id),或有任何一張主線成員
|
||
# 把它當相依(`/blocks`)。判準是 milestone id 與相依邊——**不是標題**:
|
||
# 別 repo 裡同名的里程碑不算(leo 2026-09-07,inkstone/ISEP#133)。
|
||
# 措辭怎麼寫都不影響判決;改的只有「這張票掛在哪、誰指著它」這兩個 Gitea 上的事實。
|
||
#
|
||
# ── 誤攔的出口全部先關掉(誤攔比漏擋更該修)──────────────────────────
|
||
# · **沒有主線 ⇒ 一律放行**(ISEP#82 驗收第 4 條:沒有主線不能整組壞掉)
|
||
# · 派工單裡沒有票號 ⇒ 放行(那是 no-ticket-no-dispatch.sh 的地盤,
|
||
# 兩支閘同時開口,收工方會拿到兩份打架的教學)
|
||
# · 多張票只要**有一張**在主線上 ⇒ 放行(那次派工有在推主線)
|
||
# · 快取裡查不到,就**打一次網路去問那張票**;問不到 ⇒ 放行
|
||
# (🔴「讀不到」不等於「不屬於」——把這兩件事講成同一句是最貴的錯)
|
||
# · 子 session 不查:主線是**派工者**手上的判準(CLAUDE.md:「critical path 是
|
||
# 你用來盯 subagent 的判準」),不是收工方要背的東西
|
||
# · **同一張票只擋一次**:擋完就落一個戳記,重送即放行 ⇒ 不會鬼打牆
|
||
# · 內部出錯一律放行——這是節拍器不是安全閘,它壞掉不該讓派工停擺
|
||
set -uo pipefail
|
||
|
||
INPUT="$(cat)"
|
||
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||
|
||
# 子 session 不查(見上)
|
||
[ "${CLAUDE_CODE_CHILD_SESSION:-}" = "1" ] && exit 0
|
||
|
||
VERDICT=$(printf '%s' "$INPUT" | ISEP_HOOKS_DIR="$HERE" python3 -c '
|
||
import importlib.machinery, importlib.util, json, os, sys, urllib.request
|
||
|
||
HERE = os.environ["ISEP_HOOKS_DIR"]
|
||
|
||
def load(name, path):
|
||
ldr = importlib.machinery.SourceFileLoader(name, path)
|
||
spec = importlib.util.spec_from_file_location(name, path, loader=ldr)
|
||
mod = importlib.util.module_from_spec(spec)
|
||
ldr.exec_module(mod)
|
||
return mod
|
||
|
||
def bail(why):
|
||
print("SKIP:" + why); raise SystemExit
|
||
|
||
try:
|
||
d = json.load(sys.stdin)
|
||
except Exception:
|
||
bail("bad-json")
|
||
|
||
prompt = (d.get("tool_input") or {}).get("prompt") or ""
|
||
if not prompt.strip():
|
||
bail("no-prompt")
|
||
|
||
try:
|
||
dp = load("isep_dispatch_parse", os.path.join(HERE, "lib", "dispatch_parse.py"))
|
||
ML = load("isep_mainline", os.path.join(HERE, "lib", "mainline.py"))
|
||
except Exception:
|
||
bail("lib-unavailable")
|
||
|
||
try:
|
||
refs = dp.parse_dispatch(prompt).get("refs") or []
|
||
except Exception:
|
||
bail("parse-failed")
|
||
if not refs:
|
||
bail("no-ticket") # no-ticket-no-dispatch 的地盤
|
||
|
||
ms = ML.load()
|
||
if not ms:
|
||
bail("no-mainline") # 沒有主線 ⇒ 沒有「不屬於主線」這回事
|
||
|
||
wanted = ["%s/%s#%d" % (r["owner"], r["repo"], r["num"]) for r in refs]
|
||
|
||
# ① 離線先問快取:有一張在主線上就放行
|
||
unknown = []
|
||
for ref in wanted:
|
||
v = ML.belongs(ref, ms)
|
||
if v is True:
|
||
print("SKIP:on-mainline"); raise SystemExit
|
||
if v is None:
|
||
unknown.append(ref)
|
||
|
||
# ② 快取查不到的,去問兩件事實:那張票掛在哪個 milestone、誰把它當相依(inkstone/ISEP#133)。
|
||
# 兩件都到手才敢說「不在」;問不到任何一件 ⇒ 放行。
|
||
if unknown:
|
||
fx = ML.fixture_titles()
|
||
if fx is not None: # 測試把外界定住(見 mainline.fixture_lookup)
|
||
verdicts = {}
|
||
for r in unknown:
|
||
got = ML.fixture_lookup(fx, r)
|
||
if got is not None:
|
||
verdicts[r] = ML.belongs(r, ms, ticket_milestone=got[0], blocks=got[1])
|
||
if any(v is True for v in verdicts.values()):
|
||
print("SKIP:on-mainline-fixture"); raise SystemExit
|
||
if not verdicts or any(v is None for v in verdicts.values()):
|
||
bail("unreachable") # fixture 沒收錄/只知一半 ⇒ 等同問不到
|
||
else:
|
||
try:
|
||
T = load("isep_ticket", os.path.join(HERE, "..", "scripts", "ticket"))
|
||
tok = T.token()
|
||
host = T.HOST
|
||
# 🔴 BaseException 不是筆誤:`scripts/ticket` 拿不到憑證時是 sys.exit(SystemExit),
|
||
# 那不是 Exception 的子類。只接 Exception 的話,這支會整個死掉而不是放行。
|
||
except BaseException:
|
||
bail("no-token") # 讀不到 ≠ 不屬於
|
||
def get(path):
|
||
req = urllib.request.Request("%s/api/v1%s" % (host, path),
|
||
headers={"Authorization": "token %s" % tok})
|
||
return json.load(urllib.request.urlopen(req, timeout=6))
|
||
reached = False
|
||
learned = []
|
||
undecided = False
|
||
for ref in unknown:
|
||
parts = ML.split_ref(ref)
|
||
if not parts:
|
||
continue
|
||
try:
|
||
it = get("/repos/%s/%s/issues/%d" % parts)
|
||
reached = True
|
||
except Exception:
|
||
continue
|
||
try:
|
||
blocks = [x for x in (ML.issue_ref(b) for b in get("/repos/%s/%s/issues/%d/blocks?limit=100" % parts)) if x]
|
||
except Exception:
|
||
blocks = None # 讀不到「誰把它當相依」⇒ 這一張判不出來
|
||
v = ML.belongs(ref, ms, ticket_milestone=(it or {}).get("milestone") or {}, blocks=blocks)
|
||
if v is True:
|
||
learned.append(ref)
|
||
elif v is None:
|
||
undecided = True
|
||
if learned:
|
||
ms["members"] = sorted(set(ms.get("members") or []) | set(learned))
|
||
ML.save(ms)
|
||
print("SKIP:on-mainline-fresh"); raise SystemExit
|
||
if not reached or undecided:
|
||
bail("unreachable") # 問不到/只知一半 ⇒ 放行
|
||
|
||
# ③ 確定都不在主線上 —— 同一組票只擋一次
|
||
import hashlib
|
||
key = hashlib.sha1(("|".join(sorted(wanted)) + "@" + ML.ref_of(ms)).encode()).hexdigest()[:16]
|
||
stamp = os.path.join(ML.state_dir(), "jump-ok-" + key)
|
||
if os.path.exists(stamp):
|
||
print("SKIP:already-asked"); raise SystemExit
|
||
try:
|
||
open(stamp, "w").close()
|
||
except Exception:
|
||
pass
|
||
ML.record_jump(",".join(wanted), ms)
|
||
print("BLOCK:%s\t%s\t%s" % (",".join(wanted), ML.ref_of(ms), ms.get("title") or "?"))
|
||
' 2>/dev/null || echo "SKIP:crash")
|
||
|
||
case "$VERDICT" in
|
||
BLOCK:*)
|
||
PAYLOAD="${VERDICT#BLOCK:}"
|
||
TICKETS=$(printf '%s' "$PAYLOAD" | cut -f1)
|
||
MREF=$(printf '%s' "$PAYLOAD" | cut -f2)
|
||
MTITLE=$(printf '%s' "$PAYLOAD" | cut -f3)
|
||
FIRST=$(printf '%s' "$TICKETS" | cut -d, -f1)
|
||
{
|
||
printf '🎯 這張票不在主線上(inkstone/ISEP#82)\n\n'
|
||
printf ' 現在的主線:%s「%s」\n' "$MREF" "$MTITLE"
|
||
printf ' 這次要派的:%s\n\n' "$TICKETS"
|
||
printf '**先說是哪一種,再繼續**——兩種都可能是對的,錯的是默默做完:\n\n'
|
||
printf ' ① 補收 —— 它本來就該在主線上,只是沒掛上去\n'
|
||
printf ' `scripts/mainline adopt %s` 然後重送\n' "$FIRST"
|
||
printf ' (別 repo 的票會成為 hub 票的相依;**不要**去那個 repo 開同名里程碑)\n\n'
|
||
printf ' ② 跳線 —— 真的是插件事(leo 臨時交辦、擋路的地雷…)\n'
|
||
printf ' 直接重送即可(**同一張票只擋一次**),並在回覆講一句為什麼現在要岔開主線\n\n'
|
||
printf '為什麼要問:leo 2026-08-27「今天……分心」。分心的前提是有一條主線,\n'
|
||
printf '而分心本身不是做了壞事,是**岔開了而沒有人注意到**。這一問就是那個「注意到」。\n\n'
|
||
printf '想確認主線是誰:`scripts/mainline`(唯一答案)。\n'
|
||
} >&2
|
||
exit 2
|
||
;;
|
||
*)
|
||
exit 0
|
||
;;
|
||
esac
|