每一則回覆都自己說出拖了多久(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>
This commit is contained in:
2026-08-28 00:03:38 +00:00
parent e0ac81b2e3
commit 17a74d7d3a
10 changed files with 842 additions and 5 deletions
+182
View File
@@ -0,0 +1,182 @@
#!/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