#!/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 標題,等不等於被標定的主線標題(字串相等,不是模糊比對) # 措辭怎麼寫都不影響判決;改的只有「這張票掛在哪」這個 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) # ② 快取查不到的,去問那張票掛在哪。問不到 ⇒ 放行。 if unknown: fx = ML.fixture_titles() if fx is not None: # 測試把外界定住(見 mainline.fixture_titles) learned = [r for r in unknown if ML.norm(fx.get(r, "")) == ML.norm(ms.get("title"))] if learned: print("SKIP:on-mainline-fixture"); raise SystemExit if not any(r in fx for r in unknown): 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") # 讀不到 ≠ 不屬於 reached = False learned = [] for ref in unknown: try: owner, rest = ref.split("/", 1) repo, num = rest.split("#", 1) req = urllib.request.Request( "%s/api/v1/repos/%s/%s/issues/%s" % (host, owner, repo, num), headers={"Authorization": "token %s" % tok}) it = json.load(urllib.request.urlopen(req, timeout=6)) reached = True except Exception: continue title = ((it or {}).get("milestone") or {}).get("title") or "" if ML.norm(title) == ML.norm(ms.get("title")): learned.append(ref) 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: 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\n' "$FIRST" 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