#!/usr/bin/env bash # 主線那一條線的迴歸測試(inkstone/ISEP#82) # # 票上四條驗收,這裡一條一群: # A 「現在的主線是哪一個」→ 一個指令答得出來,而且答案唯一 # B 標了主線之後開新回合 → 目標宣告自己出現(不用人去翻) # C 派一張不屬於主線的票 → 攔一次,並問是補收還是跳線 # D 沒有任何主線 → 不能整組壞掉,要講得出「現在沒有主線」 # E 不該擋的(誤攔比漏擋更該修) # # 用法:hooks/tests/mainline-focus-guard.test.sh # 🔴 全程離線:狀態走 ISEP_COUNTDOWN_STATE_DIR、時鐘走 ISEP_COUNTDOWN_NOW、 # 「那張票掛在哪」走 ISEP_MAINLINE_FIXTURE,並把 TICKET_HOST 指到一個 # 連不上的位址——**任何一條真的走到網路,就會在那裡當場失敗,不會靜靜地變成假綠**。 set -u ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" GUARD="$ROOT/mainline-focus-guard.sh" COUNTDOWN="$ROOT/countdown-guard.sh" MAINLINE="$ROOT/../scripts/mainline" TMP=$(mktemp -d) trap 'rm -rf "$TMP"' EXIT export ISEP_COUNTDOWN_STATE_DIR="$TMP/state" export TICKET_HOST="http://127.0.0.1:9" # 連不上:走到網路就會馬上失敗 mkdir -p "$ISEP_COUNTDOWN_STATE_DIR" unset ISEP_MAINLINE_FIXTURE PASS=0; FAIL=0 ok(){ printf ' ✅ %s\n' "$1"; PASS=$((PASS+1)); } no(){ printf ' ❌ %s —— %s\n' "$1" "$2"; FAIL=$((FAIL+1)); } T0=$(python3 -c 'import datetime as d;print(int(d.datetime(2026,8,28,0,0,tzinfo=d.timezone.utc).timestamp()))') # set_mainline <標題> <成員 ref...> —— 直接寫 state(離線;`mainline set` 那半要網路) set_mainline(){ python3 - "$ISEP_COUNTDOWN_STATE_DIR/mainline.json" "$@" <<'PY' import json, sys path, title = sys.argv[1], sys.argv[2] json.dump({"ref": "inkstone/ISEP#57", "owner": "inkstone", "repo": "ISEP", "id": 57, "title": title, "description": "把 SOP 變成閘:規則要長在機器上,不是長在誰的記性上。", "due_on": "2026-08-31T15:59:59+08:00", "open_issues": 12, "closed_issues": 1, "members": list(sys.argv[3:]), "set_at": "2026-08-28 08:00"}, open(path, "w"), ensure_ascii=False) PY } clear_mainline(){ rm -f "$ISEP_COUNTDOWN_STATE_DIR/mainline.json"; } # dispatch <派工單內文> → "exit|訊息" dispatch(){ out=$(python3 -c ' import json,sys; print(json.dumps({"tool_input":{"prompt":sys.argv[1]},"session_id":"t"}))' "$1" \ | CLAUDE_CODE_CHILD_SESSION= bash "$GUARD" 2>&1); rc=$? printf '%s|%s' "$rc" "$(printf '%s' "$out" | tr '\n' ' ')" } # inject → UserPromptSubmit 注入的內容 inject(){ printf '{"hook_event_name":"UserPromptSubmit","session_id":"s","transcript_path":"%s"}' "$TMP/none.jsonl" \ | ISEP_COUNTDOWN_NOW="$1" bash "$COUNTDOWN" 2>/dev/null \ | python3 -c 'import json,sys try: print(json.load(sys.stdin)["hookSpecificOutput"]["additionalContext"]) except Exception: print("")' } echo "── A 群:「現在的主線是哪一個」一個指令答得出來,答案唯一(驗收 1)──" clear_mainline OUT=$(python3 "$MAINLINE" 2>&1); RC=$? [ "$RC" = 0 ] && ok "① 沒標過主線 ⇒ 指令仍然回答得出來(exit 0,不是壞掉)" \ || no "① 沒標過主線 ⇒ exit 0" "實得 exit=$RC" case "$OUT" in *"現在沒有主線"*) ok "② 沒標過主線 ⇒ 答案是「現在沒有主線」(驗收 4)" ;; *) no "② 沒標過 ⇒ 說「現在沒有主線」" "實得:$OUT" ;; esac set_mainline "SOP 變成閘" "inkstone/ISEP#82" "inkstone/ISEP#57" OUT=$(python3 "$MAINLINE" 2>&1) N=$(printf '%s\n' "$OUT" | grep -c "現在的主線") [ "$N" = 1 ] && ok "③ 標了之後 ⇒ 只有一個答案(「現在的主線」出現 1 次,不是 14 個)" \ || no "③ 答案唯一" "「現在的主線」出現 $N 次" case "$OUT" in *"SOP 變成閘"*) ok "④ 答案指名道姓(標題印出來了)" ;; *) no "④ 答案指名道姓" "實得:$OUT" ;; esac set_mainline "換一條線" "inkstone/ISEP#82" # 🔴 只看標題那一行:描述欄裡本來就會提到舊標題(它是同一份測資), # 拿整份輸出去比對會**因為描述而誤判**——那不是「舊的還是主線」。 OUT=$(python3 "$MAINLINE" 2>&1 | head -1) case "$OUT" in *"SOP 變成閘"*) no "⑤ 換標一條 ⇒ 舊的不再是主線" "舊標題還在:$OUT" ;; *"換一條線"*) ok "⑤ 換標一條 ⇒ 舊的當場不再是主線(一個檔放得下一條)" ;; *) no "⑤ 換標一條" "實得:$OUT" ;; esac echo "── B 群:標了之後開新回合,目標宣告自己出現(驗收 2)──────────" set_mainline "SOP 變成閘" "inkstone/ISEP#82" CTX=$(inject "$T0") case "$CTX" in *"🎯 主線:"*) ok "⑥ 什麼都沒交代 ⇒ 注入裡有 🎯 主線那一行" ;; *) no "⑥ 注入裡有 🎯 主線那一行" "實得:$CTX" ;; esac case "$CTX" in *"SOP 變成閘"*) ok "⑦ 注入裡有主線的名字" ;; *) no "⑦ 注入裡有主線的名字" "實得:$CTX" ;; esac case "$CTX" in *"目標:把 SOP 變成閘"*) ok "⑧ 注入裡有**目標宣告**(milestone 的描述),不用人去翻" ;; *) no "⑧ 注入裡有目標宣告" "實得:$CTX" ;; esac # ⏱ 那一行是**已經有 Stop 閘在查**的那一行(ISEP#63)。主線的名字掛在它上面, # 就跟著那道驗過的閘一起到 leo 眼前——不必為了同一件事再立第二道會擋人的閘。 FIRST=$(printf '%s\n' "$CTX" | head -1) case "$FIRST" in *"⏱"*"主線 SOP 變成閘"*) ok "⑨ 被查核的那一行(⏱)也帶著主線名字 ⇒ 主線會到 leo 眼前" ;; *) no "⑨ ⏱ 那一行帶主線名字" "實得:$FIRST" ;; esac # 被標定的主線要蓋過「期限最近的那個」快取——後者是猜的,前者是被指定的 printf '2026-08-29|某個期限更近的\n' > "$ISEP_COUNTDOWN_STATE_DIR/milestone-due" FIRST=$(inject "$T0" | head -1) case "$FIRST" in *"主線 SOP 變成閘"*) ok "⑩ 標定的主線蓋過「期限最近」的猜測(不准用猜的蓋過指定的)" ;; *) no "⑩ 標定的主線優先" "實得:$FIRST" ;; esac rm -f "$ISEP_COUNTDOWN_STATE_DIR/milestone-due" clear_mainline CTX=$(inject "$T0") case "$CTX" in *"現在沒有主線"*) ok "⑪ 沒有主線 ⇒ 注入照樣講得出「現在沒有主線」(驗收 4,不編一條)" ;; *) no "⑪ 沒有主線 ⇒ 注入講「現在沒有主線」" "實得:$CTX" ;; esac echo "── C 群:派了不在主線上的票 ⇒ 攔一次並問清楚(驗收 3)──────────" set_mainline "SOP 變成閘" "inkstone/ISEP#82" "inkstone/ISEP#57" printf '{"inkstone/Arcrun#50":"AI 問得到內文"}\n' > "$TMP/fx.json" export ISEP_MAINLINE_FIXTURE="$TMP/fx.json" R=$(dispatch '【工單】inkstone/ISEP#82') [ "${R%%|*}" = 0 ] && ok "⑫ 派主線上的票 ⇒ 放行" || no "⑫ 派主線上的票 ⇒ 放行" "實得 exit=${R%%|*}" R=$(dispatch '【工單】inkstone/Arcrun#50') [ "${R%%|*}" = 2 ] && ok "⑬ 派不在主線上的票 ⇒ 擋" || no "⑬ 派不在主線上的票 ⇒ 擋" "實得 exit=${R%%|*}" case "${R#*|}" in *"補收"*"跳線"*) ok "⑭ 擋下時問的是「補收還是跳線」,兩條路都給了指令" ;; *) no "⑭ 訊息問補收/跳線" "實得:${R#*|}" ;; esac case "${R#*|}" in *"SOP 變成閘"*) ok "⑮ 擋下時說得出現在的主線是誰(不是只說「你錯了」)" ;; *) no "⑮ 訊息說出主線是誰" "實得:${R#*|}" ;; esac R=$(dispatch '【工單】inkstone/Arcrun#50') [ "${R%%|*}" = 0 ] && ok "⑯ 同一張票重送 ⇒ 放行(至多擋一次,不鬼打牆)" \ || no "⑯ 重送 ⇒ 放行" "實得 exit=${R%%|*}" J=$(wc -l < "$ISEP_COUNTDOWN_STATE_DIR/mainline-jumps.jsonl" 2>/dev/null || echo 0) [ "$J" -ge 1 ] && ok "⑰ 跳線留痕(mainline-jumps.jsonl 記了一筆)——不留痕的話跳線與專注長得一樣" \ || no "⑰ 跳線留痕" "jumps 檔有 $J 行" R=$(dispatch '【工單】inkstone/Arcrun#50 【工單】inkstone/ISEP#82') [ "${R%%|*}" = 0 ] && ok "⑱ 多張票只要有一張在主線上 ⇒ 放行(那次派工有在推主線)" \ || no "⑱ 多張票有一張在主線 ⇒ 放行" "實得 exit=${R%%|*}" echo "── D/E 群:不該擋(誤攔比漏擋更該修)──────────────────────────" clear_mainline R=$(dispatch '【工單】inkstone/Arcrun#50') [ "${R%%|*}" = 0 ] && ok "⑲ 沒有主線 ⇒ 一律放行(驗收 4:不能整組壞掉)" \ || no "⑲ 沒有主線 ⇒ 放行" "實得 exit=${R%%|*}" set_mainline "SOP 變成閘" "inkstone/ISEP#82" R=$(dispatch '幫我看一下這個 repo 的狀況') [ "${R%%|*}" = 0 ] && ok "⑳ 派工單裡沒有票號 ⇒ 放行(那是 no-ticket-no-dispatch 的地盤)" \ || no "⑳ 沒有票號 ⇒ 放行" "實得 exit=${R%%|*}" out=$(python3 -c ' import json;print(json.dumps({"tool_input":{"prompt":"【工單】inkstone/Arcrun#50"},"session_id":"t"}))' \ | CLAUDE_CODE_CHILD_SESSION=1 bash "$GUARD" 2>&1); rc=$? [ "$rc" = 0 ] && ok "㉑ 子 session ⇒ 放行(主線是派工者手上的判準,不是收工方要背的)" \ || no "㉑ 子 session ⇒ 放行" "實得 exit=$rc" unset ISEP_MAINLINE_FIXTURE R=$(dispatch '【工單】inkstone/mira#999') [ "${R%%|*}" = 0 ] && ok "㉒ 問不到那張票掛在哪(沒 fixture、網路也不通)⇒ 放行:讀不到 ≠ 不屬於" \ || no "㉒ 問不到 ⇒ 放行" "實得 exit=${R%%|*}" printf 'not json {{{\n' > "$ISEP_COUNTDOWN_STATE_DIR/mainline.json" R=$(dispatch '【工單】inkstone/Arcrun#50') [ "${R%%|*}" = 0 ] && ok "㉓ state 檔壞掉 ⇒ 放行(節拍器壞掉不該讓派工停擺)" \ || no "㉓ state 壞掉 ⇒ 放行" "實得 exit=${R%%|*}" OUT=$(python3 "$MAINLINE" 2>&1); RC=$? [ "$RC" = 0 ] && ok "㉔ state 檔壞掉 ⇒ 指令仍然回答得出來(當成「現在沒有主線」)" \ || no "㉔ state 壞掉 ⇒ 指令不崩" "實得 exit=$RC:$OUT" echo echo "結果:通過 $PASS 條,失敗 $FAIL 條" [ "$FAIL" -eq 0 ] || exit 1