602500a4c7
- hooks.json mainline-focus-guard 掛 Agent+Task(派工的兩個入口都要守);
SessionStart 多一條 `scripts/mainline refresh`(0.8s,不輪詢)
- hooks-inventory 54 支/71 條(當場數的)+補人話一列;順手修掉上一版重複的那一行,
並把「指向空氣」那格的假警報寫清楚(countdown-milestone-refresh 在 scripts/)
- TESTING A16(24 條離線)+ A17(要網路的那半怎麼驗)
- plugin.json 描述數字對齊;版本不動,待總管定版
208 lines
10 KiB
Bash
Executable File
208 lines
10 KiB
Bash
Executable File
#!/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
|