Files
ISEP/hooks/countdown-guard.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

183 lines
8.4 KiB
Bash
Executable File
Raw Permalink 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#63leo 2026-08-2708-28 兩次下令)
#
# ── 為什麼是一支閘,不是一句叮嚀 ──────────────────────────────────────
# leo 2026-08-27:「前面說過每個回覆要戴上已經花了總時長,**這為什麼沒出現?**」
# 「這應該寫在 ISEP,隨時看自己拖了多久」
# 總管當時答「我沒做,現在開始戴」——**而那正是這條規則第一次失效的方式**:
# 靠記性的規則,下一個 session 就沒有了(history-firstKBDB-firststage-first
# 全是同一個形狀)。⇒ 判準:**一個新開的 session,什麼都沒被交代,回覆裡照樣有時長。**
#
# ── 兩段,缺一不可(票上點名的失效模式就在這裡)───────────────────────
# 票上寫得很清楚:「**注入了但模型沒照做**是這類機制的主要失效模式」。
# 所以這支同時掛兩個事件,是**同一件事的兩半**:
#
# ① UserPromptSubmit ── 注入:把算好的那一行送到眼前(模型不必自己算、也算不準)
# ② Stop ── 查核:這一回合的回覆裡到底有沒有那一行;沒有就擋一次
#
# 只有 ① = 又一個會被忽略的提醒(本 repo 心法第 2 條明令不要做這種東西)。
# 只有 ② = 罰它做一件它拿不到資料的事。兩段合起來才是一條走得通的路。
#
# ── 🔴 這不是關鍵字黑名單(leo 2026-08-17 的診斷)────────────────────
# 「自然語言的變體是無限的,blacklist 永遠追不完……封路哲學之所以有效,
# 是因為它封的是**動作**——動作有限且可枚舉,文字不是。」
# 當日實測:文字層的閘 **8 次誤攔、0 次正確攔截**,且方向穩定。
#
# 本支查的**不是「有沒有講某些不該講的話」,而是「那個被要求的輸出元素在不在」**:
# 一個由機器產生、只有一個字元的標記(`⏱`),值是機器算的,模型抄過去。
# ⇒ 沒有「換個講法就閃過」的空間(換講法照樣要帶標記),
# 也沒有「講到某個詞就被咬」的誤攔(多寫什麼都不會觸發)。
# ⇒ 這是 whitelist-of-one**要求一個東西在場**,不是**猜哪些東西不該在場**。
#
# ── 誤攔的出口都先關掉(誤攔比漏擋更該修)────────────────────────────
# · 子 sessionsubagent)不查——它的回覆不是給 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