17a74d7d3a
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>
183 lines
8.4 KiB
Bash
Executable File
183 lines
8.4 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 ── 查核:這一回合的回覆裡到底有沒有那一行;沒有就擋一次
|
||
#
|
||
# 只有 ① = 又一個會被忽略的提醒(本 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) # 算不出來就不出聲,不要讓它變成噪音
|
||
|
||
ctx = (
|
||
stamp + "\n\n"
|
||
"🔴 這一行是 ISEP 的倒數(inkstone/ISEP#63)。**把它原樣放在你這則回覆的最前面**,"
|
||
"數字不要自己重算、也不要改寫措辭——它是機器算的,你算的會漂。\n"
|
||
"leo 整天上課、一天只看兩眼:沒有這行,他就不知道『這件事還來得及嗎』。\n"
|
||
"收工前這一回合若沒有戴上它,Stop 那一半會擋一次要你補。"
|
||
)
|
||
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
|