#!/usr/bin/env bash # countdown-guard.sh — 每一則回覆都自己說出「已經花了多久/還剩多久」 # (inkstone/ISEP#63,leo 2026-08-27+08-28 兩次下令) # # ── 為什麼是一支閘,不是一句叮嚀 ────────────────────────────────────── # leo 2026-08-27:「前面說過每個回覆要戴上已經花了總時長,**這為什麼沒出現?**」 # 「這應該寫在 ISEP,隨時看自己拖了多久」 # 總管當時答「我沒做,現在開始戴」——**而那正是這條規則第一次失效的方式**: # 靠記性的規則,下一個 session 就沒有了(history-first/KBDB-first/stage-first # 全是同一個形狀)。⇒ 判準:**一個新開的 session,什麼都沒被交代,回覆裡照樣有時長。** # # ── 兩段,缺一不可(票上點名的失效模式就在這裡)─────────────────────── # 票上寫得很清楚:「**注入了但模型沒照做**是這類機制的主要失效模式」。 # 所以這支同時掛兩個事件,是**同一件事的兩半**: # # ① UserPromptSubmit ── 注入:把算好的那一行送到眼前(模型不必自己算、也算不準) # ② Stop ── 查核:這一回合的回覆裡到底有沒有那一行;沒有就擋一次 # # ── 注入點的第二行:🎯 現在的主線是哪一個(inkstone/ISEP#82)──────────── # 同一個注入點,兩行,來源不同:⏱ 算時間,🎯 讀 `hooks/lib/mainline.py` 裡被標定的主線。 # 🔴 **Stop 那一半只查 ⏱,沒有跟著擴大成兩個標記**,兩個理由: # ① ⏱ 那一行本身**已經帶著主線的名字**(`countdown.py` 的第三段), # 所以主線是跟著一道**已經驗過的閘**到 leo 眼前的,不是靠新增一道會擋人的閘。 # ② 一個回合最多該被擋一次。多要求一個標記 = 多一種被擋的方式, # 而本 repo 心法第 2 條寫死了:永遠在響的警報等於訓練人忽略警報。 # 主線這條線真正會擋人的地方在派工那一端(`hooks/mainline-focus-guard.sh`), # 那裡擋的是「派了一張不在主線上的票」——**一個動作**,不是一段文字。 # # 只有 ① = 又一個會被忽略的提醒(本 repo 心法第 2 條明令不要做這種東西)。 # 只有 ② = 罰它做一件它拿不到資料的事。兩段合起來才是一條走得通的路。 # # ── 🔴 這不是關鍵字黑名單(leo 2026-08-17 的診斷)──────────────────── # 「自然語言的變體是無限的,blacklist 永遠追不完……封路哲學之所以有效, # 是因為它封的是**動作**——動作有限且可枚舉,文字不是。」 # 當日實測:文字層的閘 **8 次誤攔、0 次正確攔截**,且方向穩定。 # # 本支查的**不是「有沒有講某些不該講的話」,而是「那個被要求的輸出元素在不在」**: # 一個由機器產生、只有一個字元的標記(`⏱`),值是機器算的,模型抄過去。 # ⇒ 沒有「換個講法就閃過」的空間(換講法照樣要帶標記), # 也沒有「講到某個詞就被咬」的誤攔(多寫什麼都不會觸發)。 # ⇒ 這是 whitelist-of-one:**要求一個東西在場**,不是**猜哪些東西不該在場**。 # # ── 誤攔的出口都先關掉(誤攔比漏擋更該修)──────────────────────────── # · 子 session(subagent)不查——它的回覆不是給 leo 看的 # · 這一回合沒有任何文字輸出(純工具回合)不查——沒有東西可以戴 # · `stop_hook_active` 為真=已經提醒過一次 ⇒ 放行,**至多擋一次,不會卡死** # · 讀不到 transcript/內部出錯 ⇒ 放行(這支是節拍器,不是安全閘, # 它壞掉不該讓整個 session 停下來) set -uo pipefail INPUT="$(cat)" HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" LIB="$HERE/lib/countdown.py" [ -f "$LIB" ] || exit 0 EVENT=$(printf '%s' "$INPUT" | python3 -c ' import sys, json try: print(json.load(sys.stdin).get("hook_event_name", "") or "") except Exception: print("") ' 2>/dev/null || printf '') # ══ ① 注入模式(UserPromptSubmit)═════════════════════════════════════ if [ "$EVENT" = "UserPromptSubmit" ]; then printf '%s' "$INPUT" | ISEP_COUNTDOWN_LIB="$LIB" python3 -c ' import json, os, sys, importlib.util spec = importlib.util.spec_from_file_location("countdown", os.environ["ISEP_COUNTDOWN_LIB"]) cd = importlib.util.module_from_spec(spec); spec.loader.exec_module(cd) try: d = json.load(sys.stdin) except Exception: d = {} try: stamp = cd.line(d.get("session_id") or "", d.get("transcript_path") or "") except Exception: raise SystemExit(0) # 算不出來就不出聲,不要讓它變成噪音 # 第二行:現在的主線是哪一個(inkstone/ISEP#82)。 # 同一個注入點的兩行,來源不同:⏱ 算時間,🎯 讀被標定的主線。 # 算不出來也一定回一句話(「現在沒有主線」),所以這裡不需要 fallback; # 真的整支壞掉(import 失敗)才留白——一行注入不該讓整個 session 停下來。 try: ml = cd._mainline().line() except Exception: ml = "" ctx = ( stamp + "\n" + (ml + "\n" if ml else "") + "\n" "🔴 第一行是 ISEP 的倒數(inkstone/ISEP#63)。**把它原樣放在你這則回覆的最前面**," "數字不要自己重算、也不要改寫措辭——它是機器算的,你算的會漂。\n" "leo 整天上課、一天只看兩眼:沒有這行,他就不知道『這件事還來得及嗎』。\n" "收工前這一回合若沒有戴上它,Stop 那一半會擋一次要你補。\n\n" "🎯 第二行是**現在的主線**(inkstone/ISEP#82)——leo:「14 個里程碑同時亮著" "卻不知道該看哪個」。它是你這回合的判準:**手上這件事在不在那條線上**。\n" "不在,就當場說清楚是『補收』(它本來就該在主線上)還是『跳線』(真的插件事)," "不要默默做完。主線的名字已經在第一行的倒數裡,戴上倒數=主線也一起到了 leo 眼前。" ) print(json.dumps({ "hookSpecificOutput": { "hookEventName": "UserPromptSubmit", "additionalContext": ctx, } }, ensure_ascii=False)) ' 2>/dev/null || true exit 0 fi # ══ ② 查核模式(Stop)═════════════════════════════════════════════════ # 子 session 不查:subagent 的回覆是交給總管的,不是給 leo 看的那一則。 [ "${CLAUDE_CODE_CHILD_SESSION:-}" = "1" ] && exit 0 LOG="${CLAUDE_PROJECT_DIR:-$PWD}/.claude/hooks/countdown-guard.log" VERDICT=$(printf '%s' "$INPUT" | ISEP_COUNTDOWN_LIB="$LIB" python3 -c ' import json, os, sys, importlib.util spec = importlib.util.spec_from_file_location("countdown", os.environ["ISEP_COUNTDOWN_LIB"]) cd = importlib.util.module_from_spec(spec); spec.loader.exec_module(cd) try: d = json.load(sys.stdin) except Exception: print("SKIP:bad-json"); raise SystemExit if d.get("stop_hook_active"): print("SKIP:already-nudged"); raise SystemExit # 至多擋一次 tp = d.get("transcript_path") or "" if not tp or not os.path.exists(tp): print("SKIP:no-transcript"); raise SystemExit rows = [] try: with open(tp) as f: for ln in f: ln = ln.strip() if ln: try: rows.append(json.loads(ln)) except Exception: pass except Exception: print("SKIP:unreadable"); raise SystemExit # 這一回合=最後一則「真的來自使用者」的訊息之後(工具結果不算使用者說話) start = 0 for i, r in enumerate(rows): if r.get("type") == "user": c = (r.get("message") or {}).get("content") blocks = c if isinstance(c, list) else [{"type": "text"}] if not any(isinstance(b, dict) and b.get("type") == "tool_result" for b in blocks): start = i turn = rows[start:] texts = [] for r in turn: if r.get("type") != "assistant": continue c = (r.get("message") or {}).get("content") if isinstance(c, str): texts.append(c); continue if not isinstance(c, list): continue for b in c: if isinstance(b, dict) and b.get("type") == "text": texts.append(b.get("text") or "") body = "\n".join(t for t in texts if t and t.strip()) if not body: print("SKIP:no-text"); raise SystemExit # 純工具回合,沒有東西可以戴 if cd.MARKER in body: print("OK"); raise SystemExit try: stamp = cd.line(d.get("session_id") or "", tp) except Exception: print("SKIP:no-stamp"); raise SystemExit print("MISSING:" + stamp) ' 2>/dev/null || echo "SKIP:crash") STAMP_LINE="${VERDICT#MISSING:}" NOW=$(date "+%Y-%m-%d %H:%M:%S" 2>/dev/null || echo "?") mkdir -p "$(dirname "$LOG")" 2>/dev/null || true case "$VERDICT" in MISSING:*) printf '| %s | ⛔ 擋下 | 回覆沒有倒數 |\n' "$NOW" >> "$LOG" 2>/dev/null || true { printf '⏱ 倒數警察:**這則回覆沒有戴上倒數**(inkstone/ISEP#63)\n\n' printf 'leo 2026-08-27:「前面說過每個回覆要戴上已經花了總時長,**這為什麼沒出現?**」\n' printf ' 08-28:「先完成倒數計時器,**每一次回覆都發倒數**」\n\n' printf '把這一行原樣放在回覆最前面,再送一次(數字已經幫你算好,不要自己重算):\n\n' printf ' %s\n\n' "$STAMP_LINE" printf '為什麼非戴不可:leo 整天上課、一天只看兩眼。沒有這行,他看不出\n' printf '「這件事花了不該花的時間」——而那正是他要這個東西的全部理由。\n\n' printf '本閘**至多擋一次**(stop_hook_active 之後放行),不會卡死。\n' } >&2 exit 2 ;; OK) printf '| %s | ✅ 放行 | 有倒數 |\n' "$NOW" >> "$LOG" 2>/dev/null || true exit 0 ;; *) printf '| %s | ⚪ 略過 | %s |\n' "$NOW" "$VERDICT" >> "$LOG" 2>/dev/null || true exit 0 ;; esac