Files
ISEP/hooks/tests/countdown-guard.test.sh
T
Leo 17a74d7d3a 每一則回覆都自己說出拖了多久(inkstone/ISEP#63 → comment 5106)
leo 2026-08-27:「前面說過每個回覆要戴上已經花了總時長,這為什麼沒出現?」
              「這應該寫在 ISEP,隨時看自己拖了多久」
重點在後面那句:不是要總管記得戴,是要它長在機器上。
總管當時答「我沒做,現在開始戴」——而那正是這條規則第一次失效的方式。

一支閘掛兩個事件,是同一件事的兩半(票上點名的失效模式就在這裡):
  UserPromptSubmit → 注入算好的那一行(模型不必自己算,也算不準)
  Stop            → 查核這一回合的回覆裡到底有沒有那一行,沒有就擋一次
只做前半=又一個會被忽略的提醒;只做後半=罰它做一件拿不到資料的事。

判準不是關鍵字黑名單,是「那個被要求的輸出元素在不在」——
whitelist-of-one:要求一個機器產生的標記在場,不是猜哪些字不該在場。
換講法照樣要帶標記,多寫什麼都不會觸發。

時長從這段對話的第一則訊息算起,理由寫在 hooks/lib/countdown.py 檔頭:
「任務」在機器上沒有起點,而 CLAUDE.md 規則三點七「一段對話=一個 release」
剛好讓對話起點就是這個交付的起點——這個數字沒有人要維護,也不會說謊。
resume 取較早的那個:接關不是重新開始。

連帶修好票上的兩個卡點(prod-write-guard):
  卡點一 發一則 Telegram 跟部署工作流在閘眼裡一模一樣 ⇒ 判準改看打的是哪一個
        named webhook(路徑形狀+名字),放行範圍只有 notify_leo 一個名字
  卡點二 連「把卡點寫進票裡」都被同一支閘擋(同款第八次)⇒ 剝掉內文再判,
        起始行保留,所以真的在部署的寫法照樣擋
        (修這支的過程又撞了一次同款——那就是這一格最好的證據)

測試:countdown 20/20、prod-write-guard 29→37/37,全程離線
     (時鐘定住、狀態走環境變數、不打網路)。
實測:起點 06:28 台北、現在 08:03 ⇒ 期望「已過 1 小時 35 分」,實得同一字串。

plugin.json 0.9.0 → 0.10.0(版本沒動=沒有人吃得到)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 00:03:38 +00:00

225 lines
11 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
# 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 "⑥ resumetranscript 起點更早)⇒ 昨天燒掉的時間仍然算" ;;
*) 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:沒有快取就整段不出現(不准編一個日期出來)
case "$CTX" in
*"主線"*) no "⑨ 沒有 milestone 快取 ⇒ 那一段不出現" "實得:$CTX" ;;
*) ok "⑨ 沒有 milestone 快取 ⇒ 那一段不出現(不編數字)" ;;
esac
printf '2026-08-30|Sprint 8-28\n' > "$ISEP_COUNTDOWN_STATE_DIR/milestone-due"
CTX=$(inject s-ms "$TMP/a5.jsonl" "$T0")
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 "⑱ 子 sessionsubagent)⇒ 放行,它的回覆不是給 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