21971ea83e
14 個 open milestone 同時亮著,其中「Mira 現代化」同名活在 5 個 repo,
所以 SOP 說的「那個 active milestone」在現場沒有指涉對象。
- hooks/lib/mainline.py 主線的唯一存放處(一個檔放得下一條),從不打網路
- scripts/mainline show/list/set/clear/refresh/adopt/has
- hooks/mainline-focus-guard.sh 派了不在主線上的票 ⇒ 攔一次,問補收還是跳線
- hooks/lib/countdown.py ⏱ 那一行的主線改讀「被標定的」,蓋過「期限最近」的猜測
- hooks/countdown-guard.sh 同一個注入點加第二行 🎯(ISEP#63 那半不動)
- 測試 24 條(離線)+ countdown 原有 20 條仍全綠
177 lines
8.2 KiB
Bash
Executable File
177 lines
8.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 標題,等不等於被標定的主線標題(字串相等,不是模糊比對)
|
||
# 措辭怎麼寫都不影響判決;改的只有「這張票掛在哪」這個 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
|