#!/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