Files
ISEP/hooks/mainline-focus-guard.sh
T
Claude Code 21971ea83e 現在的主線是哪一個,變成一個查得到的事實(inkstone/ISEP#82)
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 條仍全綠
2026-08-28 00:39:50 +00:00

177 lines
8.2 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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-firstKBDB-firststage-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.exitSystemExit),
# 那不是 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