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 條仍全綠
230 lines
11 KiB
Bash
Executable File
230 lines
11 KiB
Bash
Executable File
#!/usr/bin/env bash
|
||
# countdown-guard.sh 的迴歸測試(inkstone/ISEP#63 → comment 5106)
|
||
#
|
||
# 三個方向都要有證據,對應票上那三條驗收:
|
||
# A 群「注入」—— 新 session 什麼都沒交代,那一行就在;數字對得上;連續呼叫遞增
|
||
# B 群「查核該擋」—— 回覆沒戴倒數 ⇒ 擋一次,且把算好的那一行印出來給它抄
|
||
# C 群「查核不該擋」—— 誤攔比漏擋更該修:純工具回合/子 session/已提醒過/
|
||
# transcript 讀不到,全部放行
|
||
#
|
||
# 用法:hooks/tests/countdown-guard.test.sh [hooks/countdown-guard.sh 的路徑]
|
||
# 🔴 全程離線:時鐘用 ISEP_COUNTDOWN_NOW 定住、狀態走 ISEP_COUNTDOWN_STATE_DIR,
|
||
# 不打網路、不碰 $HOME、跑完自己清。
|
||
set -u
|
||
HOOK="${1:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/countdown-guard.sh}"
|
||
TMP=$(mktemp -d)
|
||
trap 'rm -rf "$TMP"' EXIT
|
||
export ISEP_COUNTDOWN_STATE_DIR="$TMP/state"
|
||
mkdir -p "$ISEP_COUNTDOWN_STATE_DIR"
|
||
|
||
PASS=0; FAIL=0
|
||
|
||
# 定住的時鐘:2026-08-28 08:00 台北 = 2026-08-28 00:00 UTC
|
||
T0=$(python3 -c 'import datetime as d;print(int(d.datetime(2026,8,28,0,0,tzinfo=d.timezone.utc).timestamp()))')
|
||
H=3600
|
||
|
||
ok(){ printf ' ✅ %s\n' "$1"; PASS=$((PASS+1)); }
|
||
no(){ printf ' ❌ %s —— %s\n' "$1" "$2"; FAIL=$((FAIL+1)); }
|
||
|
||
# mk_transcript <檔> <起始 epoch> —— 一則帶時間戳的使用者訊息,當作對話起點
|
||
mk_transcript(){
|
||
python3 - "$1" "$2" <<'PY'
|
||
import datetime as d, json, sys
|
||
p, ts = sys.argv[1], int(sys.argv[2])
|
||
iso = d.datetime.fromtimestamp(ts, d.timezone.utc).isoformat().replace("+00:00", "Z")
|
||
with open(p, "w") as f:
|
||
f.write(json.dumps({"type": "user", "timestamp": iso,
|
||
"message": {"content": [{"type": "text", "text": "開工"}]}}) + "\n")
|
||
PY
|
||
}
|
||
|
||
# add_turn <檔> <spec>... —— spec:`T:<文字>` assistant 文字;`U:<工具>` tool_use;
|
||
# `R:` 一列工具結果(不算新回合的分界)
|
||
add_turn(){
|
||
python3 - "$@" <<'PY'
|
||
import json, sys
|
||
path, specs = sys.argv[1], sys.argv[2:]
|
||
rows = []
|
||
blocks = []
|
||
for s in specs:
|
||
k, v = s.split(":", 1)
|
||
if k == "T":
|
||
blocks.append({"type": "text", "text": v})
|
||
elif k == "U":
|
||
blocks.append({"type": "tool_use", "name": v, "id": "t", "input": {}})
|
||
elif k == "R":
|
||
rows.append({"type": "user", "message": {"content": [
|
||
{"type": "tool_result", "tool_use_id": "t", "content": "ok"}]}})
|
||
with open(path, "a") as f:
|
||
f.write(json.dumps({"type": "user", "message": {"content": [
|
||
{"type": "text", "text": "繼續"}]}}, ensure_ascii=False) + "\n")
|
||
for r in rows:
|
||
f.write(json.dumps(r, ensure_ascii=False) + "\n")
|
||
if blocks:
|
||
f.write(json.dumps({"type": "assistant", "message": {"content": blocks}},
|
||
ensure_ascii=False) + "\n")
|
||
PY
|
||
}
|
||
|
||
# inject <session> <transcript> <now epoch> —— 跑注入模式,回傳 additionalContext
|
||
inject(){
|
||
printf '{"hook_event_name":"UserPromptSubmit","session_id":"%s","transcript_path":"%s"}' "$1" "$2" \
|
||
| ISEP_COUNTDOWN_NOW="$3" bash "$HOOK" 2>/dev/null \
|
||
| python3 -c 'import json,sys
|
||
try: print(json.load(sys.stdin)["hookSpecificOutput"]["additionalContext"])
|
||
except Exception: print("")'
|
||
}
|
||
|
||
# stop <session> <transcript> <now epoch> <extra json> —— 跑查核模式,回傳 "exit|訊息"
|
||
stop(){
|
||
# 🔴 明著把 CLAUDE_CODE_CHILD_SESSION 清掉:測試本身常常跑在一條 subagent 裡,
|
||
# 而那正是本閘刻意放行的情況 ⇒ 不清掉的話 B 群會全綠,而且是**假綠**
|
||
# (2026-08-28 第一次跑就撞到:B 群三條全 exit 0,因為跑測試的就是子 session)。
|
||
out=$(printf '{"hook_event_name":"Stop","session_id":"%s","transcript_path":"%s"%s}' "$1" "$2" "${4:-}" \
|
||
| ISEP_COUNTDOWN_NOW="$3" CLAUDE_CODE_CHILD_SESSION= CLAUDE_PROJECT_DIR="$TMP" \
|
||
bash "$HOOK" 2>&1); rc=$?
|
||
printf '%s|%s' "$rc" "$(printf '%s' "$out" | tr '\n' ' ')"
|
||
}
|
||
|
||
echo "── A 群:注入(每一則回覆眼前都有那一行)────────────────────────"
|
||
|
||
TR="$TMP/a1.jsonl"; mk_transcript "$TR" "$T0"
|
||
CTX=$(inject s-a1 "$TR" "$T0")
|
||
case "$CTX" in
|
||
*"⏱"*) ok "① 新 session 什麼都沒交代 ⇒ 注入的內容帶著標記" ;;
|
||
*) no "① 新 session 什麼都沒交代 ⇒ 注入的內容帶著標記" "實得:$CTX" ;;
|
||
esac
|
||
case "$CTX" in
|
||
*"已過 0 分"*) ok "② 剛開工 ⇒ 已過 0 分" ;;
|
||
*) no "② 剛開工 ⇒ 已過 0 分" "實得:$CTX" ;;
|
||
esac
|
||
# 台北 08:00 → 16:00 收工線還有 8 小時
|
||
case "$CTX" in
|
||
*"距收工線(台北 16:00)剩 8 小時"*) ok "③ 台北 08:00 ⇒ 距收工線剩 8 小時" ;;
|
||
*) no "③ 台北 08:00 ⇒ 距收工線剩 8 小時" "實得:$CTX" ;;
|
||
esac
|
||
|
||
# 票上驗收 2:數字要對得上真實經過的時間(期望 2 小時 13 分)
|
||
CTX=$(inject s-a1 "$TR" $((T0 + 2*H + 13*60)))
|
||
case "$CTX" in
|
||
*"已過 2 小時 13 分"*) ok "④ 過了 2h13m ⇒ 顯示「已過 2 小時 13 分」(期望=實得)" ;;
|
||
*) no "④ 過了 2h13m ⇒ 顯示「已過 2 小時 13 分」" "實得:$CTX" ;;
|
||
esac
|
||
|
||
# 票上驗收 3:連續幾則要遞增,不歸零、不亂跳
|
||
LAST=-1; SEQ_OK=1
|
||
for k in 0 1 3 5; do
|
||
CTX=$(inject s-a1 "$TR" $((T0 + k*H)))
|
||
got=$(printf '%s' "$CTX" | sed -n 's/.*已過 \([0-9]*\) 小時.*/\1/p')
|
||
[ -z "$got" ] && got=0
|
||
[ "$got" -ge "$LAST" ] || SEQ_OK=0
|
||
LAST="$got"
|
||
done
|
||
[ "$SEQ_OK" = 1 ] && ok "⑤ 連續四則(0/1/3/5 小時)⇒ 遞增,不歸零不亂跳" \
|
||
|| no "⑤ 連續四則 ⇒ 遞增" "第 $LAST 則往回跳了"
|
||
|
||
# resume:同一個 session 換一份較早起點的 transcript,起點取較早的那個
|
||
TR2="$TMP/a2.jsonl"; mk_transcript "$TR2" $((T0 - 5*H))
|
||
CTX=$(inject s-a1 "$TR2" "$T0")
|
||
case "$CTX" in
|
||
*"已過 5 小時"*) ok "⑥ resume(transcript 起點更早)⇒ 昨天燒掉的時間仍然算" ;;
|
||
*) no "⑥ resume ⇒ 起點取較早" "實得:$CTX" ;;
|
||
esac
|
||
|
||
# 過了收工線:台北 17:30 ⇒ 顯示超過,不准悄悄換算成明天
|
||
CTX=$(inject s-late "$TMP/a3.jsonl" $((T0 + 9*H + 30*60)))
|
||
case "$CTX" in
|
||
*"已超過收工線"*) ok "⑦ 台北 17:30 ⇒ 顯示「已超過收工線」(超時不許消失)" ;;
|
||
*) no "⑦ 台北 17:30 ⇒ 顯示已超過" "實得:$CTX" ;;
|
||
esac
|
||
# 台北 21:00(過線四小時後)⇒ 滾到隔天 16:00,剩 19 小時
|
||
CTX=$(inject s-next "$TMP/a4.jsonl" $((T0 + 13*H)))
|
||
case "$CTX" in
|
||
*"距收工線(台北 16:00)剩 19 小時"*) ok "⑧ 台北 21:00 ⇒ 滾到隔天,剩 19 小時" ;;
|
||
*) no "⑧ 台北 21:00 ⇒ 滾到隔天" "實得:$CTX" ;;
|
||
esac
|
||
|
||
# milestone:沒有快取就整段不出現(不准編一個日期出來)
|
||
# 🔴 只比對**第一行**(那條 ⏱ 倒數本身)。inkstone/ISEP#82 之後,同一個注入點還帶著
|
||
# 第二行「🎯 主線……」與一段說明,那段文字裡本來就會出現「主線」兩個字
|
||
# ——拿整段去比對會把說明當成數字,變成一條**測不到重點的假紅**。
|
||
# 這一格要驗的一直都是「⏱ 那一行有沒有多長出一段編出來的期限」。
|
||
STAMP_LINE_ONLY=$(printf '%s\n' "$CTX" | head -1)
|
||
case "$STAMP_LINE_ONLY" in
|
||
*"主線"*) no "⑨ 沒有 milestone 快取 ⇒ 那一段不出現" "實得:$STAMP_LINE_ONLY" ;;
|
||
*) ok "⑨ 沒有 milestone 快取 ⇒ 那一段不出現(不編數字)" ;;
|
||
esac
|
||
printf '2026-08-30|Sprint 8-28\n' > "$ISEP_COUNTDOWN_STATE_DIR/milestone-due"
|
||
CTX=$(printf '%s\n' "$(inject s-ms "$TMP/a5.jsonl" "$T0")" | head -1)
|
||
case "$CTX" in
|
||
*"主線 Sprint 8-28 剩"*) ok "⑩ 有 milestone 快取 ⇒ 期限那一段出現" ;;
|
||
*) no "⑩ 有 milestone 快取 ⇒ 期限那一段出現" "實得:$CTX" ;;
|
||
esac
|
||
rm -f "$ISEP_COUNTDOWN_STATE_DIR/milestone-due"
|
||
|
||
echo "── B 群:查核——該擋 ────────────────────────────────────────────"
|
||
|
||
TR="$TMP/b1.jsonl"; mk_transcript "$TR" "$T0"
|
||
add_turn "$TR" 'U:Read' 'T:做完了,改了三個檔。'
|
||
R=$(stop s-b1 "$TR" $((T0 + 2*H)))
|
||
[ "${R%%|*}" = 2 ] && ok "⑪ 回覆沒戴倒數 ⇒ 擋" || no "⑪ 回覆沒戴倒數 ⇒ 擋" "實得 exit=${R%%|*}"
|
||
case "${R#*|}" in
|
||
*"已過 2 小時"*) ok "⑫ 擋下時把算好的那一行印出來給它抄(不是叫它自己算)" ;;
|
||
*) no "⑫ 擋下時印出算好的那一行" "實得:${R#*|}" ;;
|
||
esac
|
||
|
||
# 回合邊界:上一回合戴了、這一回合沒戴 ⇒ 還是要擋
|
||
TR="$TMP/b2.jsonl"; mk_transcript "$TR" "$T0"
|
||
add_turn "$TR" 'T:⏱ 已過 1 小時|距收工線剩 7 小時。上一回合戴過了。'
|
||
add_turn "$TR" 'U:Edit' 'T:這回合忘了戴。'
|
||
R=$(stop s-b2 "$TR" $((T0 + 2*H)))
|
||
[ "${R%%|*}" = 2 ] && ok "⑬ 只有上一回合戴 ⇒ 這一回合仍要擋(回合邊界對)" \
|
||
|| no "⑬ 只有上一回合戴 ⇒ 仍要擋" "實得 exit=${R%%|*}"
|
||
|
||
echo "── C 群:查核——不該擋(誤攔比漏擋更該修)──────────────────────"
|
||
|
||
TR="$TMP/c1.jsonl"; mk_transcript "$TR" "$T0"
|
||
add_turn "$TR" 'U:Read' 'T:⏱ 已過 2 小時|距收工線(台北 16:00)剩 6 小時 —— 做完了。'
|
||
R=$(stop s-c1 "$TR" $((T0 + 2*H)))
|
||
[ "${R%%|*}" = 0 ] && ok "⑭ 回覆戴了倒數 ⇒ 放行" || no "⑭ 回覆戴了倒數 ⇒ 放行" "實得 exit=${R%%|*}"
|
||
|
||
TR="$TMP/c2.jsonl"; mk_transcript "$TR" "$T0"
|
||
add_turn "$TR" 'T:上一回合。'
|
||
add_turn "$TR" 'U:Read' 'T:⏱ 已過 2 小時|這一回合戴了。'
|
||
R=$(stop s-c2 "$TR" $((T0 + 2*H)))
|
||
[ "${R%%|*}" = 0 ] && ok "⑮ 這一回合戴了、上一回合沒戴 ⇒ 放行" \
|
||
|| no "⑮ 這一回合戴了 ⇒ 放行" "實得 exit=${R%%|*}"
|
||
|
||
TR="$TMP/c3.jsonl"; mk_transcript "$TR" "$T0"
|
||
add_turn "$TR" 'U:Read' 'R:' 'U:Grep'
|
||
R=$(stop s-c3 "$TR" $((T0 + 2*H)))
|
||
[ "${R%%|*}" = 0 ] && ok "⑯ 純工具回合(沒有任何文字)⇒ 放行,沒有東西可以戴" \
|
||
|| no "⑯ 純工具回合 ⇒ 放行" "實得 exit=${R%%|*}"
|
||
|
||
TR="$TMP/c4.jsonl"; mk_transcript "$TR" "$T0"
|
||
add_turn "$TR" 'T:沒戴。'
|
||
R=$(stop s-c4 "$TR" $((T0 + 2*H)) ',"stop_hook_active":true')
|
||
[ "${R%%|*}" = 0 ] && ok "⑰ 已經提醒過一次(stop_hook_active)⇒ 放行,至多擋一次" \
|
||
|| no "⑰ stop_hook_active ⇒ 放行" "實得 exit=${R%%|*}"
|
||
|
||
TR="$TMP/c5.jsonl"; mk_transcript "$TR" "$T0"
|
||
add_turn "$TR" 'T:我是 subagent,交件給總管。'
|
||
out=$(printf '{"hook_event_name":"Stop","session_id":"s-c5","transcript_path":"%s"}' "$TR" \
|
||
| ISEP_COUNTDOWN_NOW=$((T0 + 2*H)) CLAUDE_CODE_CHILD_SESSION=1 \
|
||
CLAUDE_PROJECT_DIR="$TMP" bash "$HOOK" 2>&1); rc=$?
|
||
[ "$rc" = 0 ] && ok "⑱ 子 session(subagent)⇒ 放行,它的回覆不是給 leo 看的" \
|
||
|| no "⑱ 子 session ⇒ 放行" "實得 exit=$rc"
|
||
|
||
R=$(stop s-c6 "$TMP/does-not-exist.jsonl" $((T0 + 2*H)))
|
||
[ "${R%%|*}" = 0 ] && ok "⑲ transcript 讀不到 ⇒ 放行(節拍器壞掉不該卡死 session)" \
|
||
|| no "⑲ transcript 讀不到 ⇒ 放行" "實得 exit=${R%%|*}"
|
||
|
||
printf 'not json at all\n{{{\n' > "$TMP/c7.jsonl"
|
||
R=$(stop s-c7 "$TMP/c7.jsonl" $((T0 + 2*H)))
|
||
[ "${R%%|*}" = 0 ] && ok "⑳ transcript 是壞的 ⇒ 放行,不是擋" \
|
||
|| no "⑳ transcript 壞掉 ⇒ 放行" "實得 exit=${R%%|*}"
|
||
|
||
echo
|
||
echo "結果:通過 $PASS 條,失敗 $FAIL 條"
|
||
[ "$FAIL" -eq 0 ] || exit 1
|