Merge remote-tracking branch 'origin/main' into fix/cloud-wiring-isep90
# Conflicts: # docs/TESTING.md
This commit is contained in:
Executable
+182
@@ -0,0 +1,182 @@
|
||||
#!/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
|
||||
@@ -223,6 +223,10 @@
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/isep-presence-beacon.sh"
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/scripts/countdown-milestone-refresh.sh"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -287,6 +291,14 @@
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/unpushed-police.sh"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/countdown-guard.sh"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"SubagentStop": [
|
||||
@@ -363,6 +375,16 @@
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"UserPromptSubmit": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "${CLAUDE_PLUGIN_ROOT}/hooks/countdown-guard.sh"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
Executable
+201
@@ -0,0 +1,201 @@
|
||||
#!/usr/bin/env python3
|
||||
"""hooks/lib/countdown.py —— 算「這件事已經花了多久/還剩多久」的那一行。
|
||||
|
||||
這是 helper,不是閘(inkstone/ISEP#40 S7:lib/ 底下的東西不算一支手寫的閘)。
|
||||
兩個呼叫者共用它,所以時間的算法只有一份:
|
||||
· UserPromptSubmit(`countdown-guard.sh` 注入模式)—— 把這一行送到模型眼前
|
||||
· Stop(同一支的查核模式)—— 擋下來時要把同一行原樣印出來給它抄
|
||||
|
||||
── 基準從哪一刻算起(inkstone/ISEP#63 要我自己判斷並說明理由)──────────
|
||||
leo 的原話是「**隨時看自己拖了多久**」。「拖」指向的是**任務**,不是 session,
|
||||
但「任務」在機器上沒有起點——沒有任何欄位記著「我什麼時候接手這條線」。
|
||||
可機械取得、又真的對應到一個交付的起點,只有一個:**這段對話的第一則訊息**。
|
||||
|
||||
而那正好就是總管的交貨單位:CLAUDE.md 規則三點七「**一段對話 = 一個 release**」。
|
||||
⇒ 已過時間 = 這個 release 已經燒掉多久。**這個數字不需要任何人維護,也不會說謊。**
|
||||
|
||||
resume 的 session 取「transcript 第一列的時間」而不是「hook 第一次被叫到的時間」:
|
||||
接關不是重新開始,昨天燒掉的兩小時仍然算在這個交付上。兩者都拿得到時取較早的。
|
||||
|
||||
── 第二個數字:今天的收工線 ──────────────────────────────────────────
|
||||
leo 2026-08-28 07:40:「今天我整天上課……**下午四點左右**希望已經都完成了」。
|
||||
⇒ 台北 16:00。過了 16:00 之後**不立刻換算到明天**——那會讓超時消失,
|
||||
而超時正是他要看的東西。過線後四小時內顯示「已超過 X」,
|
||||
台北 20:00 之後才滾到隔天(那時已經是下一個工作日的倒數了)。
|
||||
|
||||
── 第三個數字:主線 milestone 的期限(可選)────────────────────────────
|
||||
從快取檔讀,**這支從不打網路**:UserPromptSubmit 走在每一則訊息的關鍵路徑上,
|
||||
在那裡打網路 = 每一句話都先等一次 HTTP。快取由 SessionStart 那支負責更新
|
||||
(`scripts/countdown-milestone-refresh.sh`),拿不到就整段不顯示,不編數字。
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
TAIPEI = timezone(timedelta(hours=8))
|
||||
MARKER = "⏱"
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
"""現在。測試用 ISEP_COUNTDOWN_NOW(epoch 秒)把時鐘定住。"""
|
||||
override = os.environ.get("ISEP_COUNTDOWN_NOW", "").strip()
|
||||
if override:
|
||||
try:
|
||||
return datetime.fromtimestamp(float(override), tz=timezone.utc)
|
||||
except Exception:
|
||||
pass
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def _parse_ts(raw) -> "datetime | None":
|
||||
if not isinstance(raw, str) or not raw.strip():
|
||||
return None
|
||||
s = raw.strip().replace("Z", "+00:00")
|
||||
try:
|
||||
dt = datetime.fromisoformat(s)
|
||||
except Exception:
|
||||
return None
|
||||
return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc)
|
||||
|
||||
|
||||
def transcript_start(path: str) -> "datetime | None":
|
||||
"""transcript 第一列帶時間戳的那一列 = 這段對話真正的起點。"""
|
||||
if not path or not os.path.exists(path):
|
||||
return None
|
||||
try:
|
||||
with open(path) as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
row = json.loads(line)
|
||||
except Exception:
|
||||
continue
|
||||
dt = _parse_ts(row.get("timestamp"))
|
||||
if dt:
|
||||
return dt
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def state_dir() -> str:
|
||||
d = os.environ.get("ISEP_COUNTDOWN_STATE_DIR", "").strip()
|
||||
if not d:
|
||||
d = os.path.join(os.path.expanduser("~"), ".claude", "isep-countdown")
|
||||
try:
|
||||
os.makedirs(d, exist_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
return d
|
||||
|
||||
|
||||
def session_start(session_id: str, transcript_path: str, now: datetime) -> datetime:
|
||||
"""起點 = transcript 第一列與「第一次看到這個 session」兩者取較早。
|
||||
|
||||
第一次看到就落一個檔;transcript 沒有時間戳(或讀不到)時它就是唯一的憑據。
|
||||
"""
|
||||
cands = []
|
||||
ts = transcript_start(transcript_path)
|
||||
if ts:
|
||||
cands.append(ts)
|
||||
|
||||
sid = "".join(c for c in (session_id or "") if c.isalnum() or c in "-_")[:80]
|
||||
if sid:
|
||||
p = os.path.join(state_dir(), sid + ".start")
|
||||
seen = None
|
||||
try:
|
||||
with open(p) as f:
|
||||
seen = datetime.fromtimestamp(float(f.read().strip()), tz=timezone.utc)
|
||||
except Exception:
|
||||
seen = None
|
||||
if seen is None:
|
||||
seen = min(cands) if cands else now
|
||||
try:
|
||||
with open(p, "w") as f:
|
||||
f.write("%d" % seen.timestamp())
|
||||
except Exception:
|
||||
pass
|
||||
cands.append(seen)
|
||||
|
||||
return min(cands) if cands else now
|
||||
|
||||
|
||||
def deadline(now: datetime) -> datetime:
|
||||
"""今天的收工線(台北 HH:MM)。過線後四小時內不滾,讓超時看得見。"""
|
||||
hhmm = os.environ.get("ISEP_COUNTDOWN_DEADLINE", "16:00").strip() or "16:00"
|
||||
try:
|
||||
hh, mm = (int(x) for x in hhmm.split(":", 1))
|
||||
except Exception:
|
||||
hh, mm = 16, 0
|
||||
local = now.astimezone(TAIPEI)
|
||||
d = local.replace(hour=hh, minute=mm, second=0, microsecond=0)
|
||||
if local >= d + timedelta(hours=4):
|
||||
d += timedelta(days=1)
|
||||
return d
|
||||
|
||||
|
||||
def milestone() -> "tuple[str, datetime] | None":
|
||||
"""主線 milestone 的期限。快取檔一行:`YYYY-MM-DD|<名稱>`。沒有就沒有。"""
|
||||
raw = os.environ.get("ISEP_MILESTONE_DUE", "").strip()
|
||||
name = os.environ.get("ISEP_MILESTONE_NAME", "").strip()
|
||||
if not raw:
|
||||
p = os.path.join(state_dir(), "milestone-due")
|
||||
try:
|
||||
with open(p) as f:
|
||||
raw = f.read().strip()
|
||||
except Exception:
|
||||
return None
|
||||
if "|" in raw:
|
||||
raw, name = raw.split("|", 1)
|
||||
raw, name = raw.strip(), name.strip()
|
||||
if not raw:
|
||||
return None
|
||||
dt = _parse_ts(raw) or _parse_ts(raw + "T23:59:59+08:00")
|
||||
if not dt:
|
||||
return None
|
||||
return (name or "主線", dt)
|
||||
|
||||
|
||||
def human(delta: timedelta) -> str:
|
||||
mins = int(abs(delta).total_seconds()) // 60
|
||||
h, m = divmod(mins, 60)
|
||||
if h and m:
|
||||
return "%d 小時 %d 分" % (h, m)
|
||||
if h:
|
||||
return "%d 小時" % h
|
||||
return "%d 分" % m
|
||||
|
||||
|
||||
def line(session_id: str, transcript_path: str) -> str:
|
||||
now = _now()
|
||||
start = session_start(session_id, transcript_path, now)
|
||||
dl = deadline(now)
|
||||
|
||||
parts = ["%s 已過 %s" % (MARKER, human(now - start))]
|
||||
left = dl - now
|
||||
if left.total_seconds() >= 0:
|
||||
parts.append("距收工線(台北 %s)剩 %s" % (dl.strftime("%H:%M"), human(left)))
|
||||
else:
|
||||
parts.append("🔴 已超過收工線(台北 %s)%s" % (dl.strftime("%H:%M"), human(left)))
|
||||
|
||||
ms = milestone()
|
||||
if ms:
|
||||
name, due = ms
|
||||
gap = due - now
|
||||
if gap.total_seconds() >= 0:
|
||||
parts.append("主線 %s 剩 %s" % (name, human(gap)))
|
||||
else:
|
||||
parts.append("🔴 主線 %s 已逾期 %s" % (name, human(gap)))
|
||||
|
||||
return "|".join(parts)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
payload = json.load(sys.stdin)
|
||||
except Exception:
|
||||
payload = {}
|
||||
print(line(payload.get("session_id") or "", payload.get("transcript_path") or ""))
|
||||
@@ -41,6 +41,26 @@ try: print(json.load(sys.stdin).get("tool_input", {}).get("command", "") or "")
|
||||
except Exception: print("")
|
||||
' 2>/dev/null || printf '')
|
||||
|
||||
# ── 🔴 卡點二(inkstone/ISEP#63 → comment 5106):「執行它」vs「談論它」,第八次 ──
|
||||
#
|
||||
# 總管要把「本閘擋住了發通知」這件事**貼到票上**,而那則留言的內文**引用了**
|
||||
# 本閘的判準(實例主機名、寫入旗標的字樣)⇒ **一則留言被自己擋下來。**
|
||||
# 同款的第八次(`inkstone/InkStoneCo#23` 的標題已經寫著「第七次」)。
|
||||
# ⇒ 本次修這支閘時**又撞了一次**:連「把修法寫進檔案」的那條指令都被擋,
|
||||
# 因為它的內文引用了主機名。那就是這一格最好的證據。
|
||||
#
|
||||
# leo 2026-08-17 的診斷:文字層的閘那天 **8 次誤攔、0 次正確攔截**,且方向穩定——
|
||||
# **紅線寫得越細,命中關鍵字的機率越高 ⇒ 那些閘在懲罰謹慎。**
|
||||
#
|
||||
# 那一段內文**是資料,不是指令**。這支 lib(`github-contact-guard`/
|
||||
# `main-and-prod-push-guard`/`stage-before-prod-guard` 三支已經在用)只剝內文、
|
||||
# **保留起始行**——所以真的在部署的寫法(起始行就帶著實例網址與寫入旗標)
|
||||
# 仍然整條看得到、照樣擋。
|
||||
#
|
||||
# ⚠️ 繞法(把內文先寫成檔案、再用不含關鍵字的指令送出)本來就通,
|
||||
# 但那正是 leo 說的「**人就會學會繞過它,那它就等於不存在**」⇒ 要修的是閘。
|
||||
CMD=$(printf '%s' "$CMD" | python3 "$(dirname "$0")/lib/strip_""heredoc.py" 2>/dev/null || printf '%s' "$CMD")
|
||||
|
||||
# ── 總管戳記:**單次、15 分鐘失效** ────────────────────────────────────
|
||||
#
|
||||
# 🔴 這一版一開始寫成「15 分鐘內無限次放行」,而同一天 `main-and-prod-push-guard.sh`
|
||||
@@ -234,6 +254,40 @@ case "$CMD_M" in
|
||||
*curl*|*httpie*|*" http "*|*wget*)
|
||||
case "$CMD" in
|
||||
*workers.dev*|*arcrun.dev*)
|
||||
# 🔴 卡點一(inkstone/ISEP#63 → comment 5106):發一則通知被當成部署擋下
|
||||
#
|
||||
# leo 2026-08-28 要的是「每小時一個版本,用 Telegram 通知我」。
|
||||
# 通道是 `notify_leo` 工作流(wiki `agent-memory.md`:一條 curl、不需任何金鑰)。
|
||||
# 而發一則 Telegram **一定是帶 body 的寫入型請求打到那台實例**
|
||||
# ⇒ 在本閘眼裡它跟「部署一個工作流上線」長得一模一樣。
|
||||
#
|
||||
# 🔴 **這不是誤攔,是解析度不夠**:兩者打的是同一個 named webhook 家族路徑。
|
||||
# 真正的差別在**打的是哪一個 named webhook**——
|
||||
# `…/webhooks/named/<ns>/notify_leo/trigger` = 發一則訊息,什麼都沒改
|
||||
# `…/webhooks/named`(無名字、無 /trigger) = 部署工作流定義
|
||||
# `…/webhooks/named/<ns>/ship_refresh_cdn/trigger` = 真的動線上狀態
|
||||
#
|
||||
# 判準因此看**目標的結構**(路徑形狀+工作流名字),不是「有沒有寫入旗標」:
|
||||
# 指令裡打到實例的**每一個**網址都是白名單裡的通知型 trigger ⇒ 放行
|
||||
# 只要有一個不是(含部署端點、含別的工作流)⇒ 照擋
|
||||
# ⚠️ 放行的範圍刻意只有一個名字。**不放行整個 named webhook 家族**——
|
||||
# 那會把部署一起放掉,等於拆了這道閘。
|
||||
NOTIFY_ONLY=$(printf '%s' "$CMD" | python3 -c '
|
||||
import re, sys
|
||||
cmd = sys.stdin.read()
|
||||
ALLOW = {"notify_leo"} # 通知型:只發訊息,不改線上任何狀態
|
||||
urls = re.findall(r"https?://[^\s\"\x27<>]+", cmd)
|
||||
inst = [u for u in urls if re.search(r"://[^/]*(workers\.dev|arcrun\.dev)", u)]
|
||||
if not inst:
|
||||
raise SystemExit(0) # 沒有實例網址 ⇒ 這裡不表態,交給下面的寫入語意判斷
|
||||
PAT = re.compile(r"https?://[^/]+/webhooks/named/[^/]+/([A-Za-z0-9_-]+)/trigger/?$")
|
||||
def notify(u):
|
||||
m = PAT.match(u.rstrip(",;)"))
|
||||
return bool(m) and m.group(1) in ALLOW
|
||||
print("notify-only" if all(notify(u) for u in inst) else "")
|
||||
' 2>/dev/null || printf '')
|
||||
[ "$NOTIFY_ONLY" = "notify-only" ] && exit 0
|
||||
|
||||
# 🔴 2026-08-12 修誤攔:` -d ` 不只是 curl 的 --data,**一堆唯讀工具也用 -d**
|
||||
# (`tr -d`/`cut -d=`/`sort -d`/`date -d`/`xargs -d`/`paste -d`…)。
|
||||
# 實撞:總管要「唯讀」驗證一把金鑰能不能開 kbdb 的閘,指令第一行是
|
||||
|
||||
Executable
+224
@@ -0,0 +1,224 @@
|
||||
#!/usr/bin/env bash
|
||||
# countdown-guard.sh 的迴歸測試(inkstone/ISEP#63 → comment 5106)
|
||||
#
|
||||
# 三個方向都要有證據,對應票上那三條驗收:
|
||||
# A 群「注入」—— 新 session 什麼都沒交代,那一行就在;數字對得上;連續呼叫遞增
|
||||
# B 群「查核該擋」—— 回覆沒戴倒數 ⇒ 擋一次,且把算好的那一行印出來給它抄
|
||||
# C 群「查核不該擋」—— 誤攔比漏擋更該修:純工具回合/子 session/已提醒過/
|
||||
# transcript 讀不到,全部放行
|
||||
#
|
||||
# 用法:hooks/tests/countdown-guard.test.sh [hooks/countdown-guard.sh 的路徑]
|
||||
# 🔴 全程離線:時鐘用 ISEP_COUNTDOWN_NOW 定住、狀態走 ISEP_COUNTDOWN_STATE_DIR,
|
||||
# 不打網路、不碰 $HOME、跑完自己清。
|
||||
set -u
|
||||
HOOK="${1:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/countdown-guard.sh}"
|
||||
TMP=$(mktemp -d)
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
export ISEP_COUNTDOWN_STATE_DIR="$TMP/state"
|
||||
mkdir -p "$ISEP_COUNTDOWN_STATE_DIR"
|
||||
|
||||
PASS=0; FAIL=0
|
||||
|
||||
# 定住的時鐘:2026-08-28 08:00 台北 = 2026-08-28 00:00 UTC
|
||||
T0=$(python3 -c 'import datetime as d;print(int(d.datetime(2026,8,28,0,0,tzinfo=d.timezone.utc).timestamp()))')
|
||||
H=3600
|
||||
|
||||
ok(){ printf ' ✅ %s\n' "$1"; PASS=$((PASS+1)); }
|
||||
no(){ printf ' ❌ %s —— %s\n' "$1" "$2"; FAIL=$((FAIL+1)); }
|
||||
|
||||
# mk_transcript <檔> <起始 epoch> —— 一則帶時間戳的使用者訊息,當作對話起點
|
||||
mk_transcript(){
|
||||
python3 - "$1" "$2" <<'PY'
|
||||
import datetime as d, json, sys
|
||||
p, ts = sys.argv[1], int(sys.argv[2])
|
||||
iso = d.datetime.fromtimestamp(ts, d.timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
with open(p, "w") as f:
|
||||
f.write(json.dumps({"type": "user", "timestamp": iso,
|
||||
"message": {"content": [{"type": "text", "text": "開工"}]}}) + "\n")
|
||||
PY
|
||||
}
|
||||
|
||||
# add_turn <檔> <spec>... —— spec:`T:<文字>` assistant 文字;`U:<工具>` tool_use;
|
||||
# `R:` 一列工具結果(不算新回合的分界)
|
||||
add_turn(){
|
||||
python3 - "$@" <<'PY'
|
||||
import json, sys
|
||||
path, specs = sys.argv[1], sys.argv[2:]
|
||||
rows = []
|
||||
blocks = []
|
||||
for s in specs:
|
||||
k, v = s.split(":", 1)
|
||||
if k == "T":
|
||||
blocks.append({"type": "text", "text": v})
|
||||
elif k == "U":
|
||||
blocks.append({"type": "tool_use", "name": v, "id": "t", "input": {}})
|
||||
elif k == "R":
|
||||
rows.append({"type": "user", "message": {"content": [
|
||||
{"type": "tool_result", "tool_use_id": "t", "content": "ok"}]}})
|
||||
with open(path, "a") as f:
|
||||
f.write(json.dumps({"type": "user", "message": {"content": [
|
||||
{"type": "text", "text": "繼續"}]}}, ensure_ascii=False) + "\n")
|
||||
for r in rows:
|
||||
f.write(json.dumps(r, ensure_ascii=False) + "\n")
|
||||
if blocks:
|
||||
f.write(json.dumps({"type": "assistant", "message": {"content": blocks}},
|
||||
ensure_ascii=False) + "\n")
|
||||
PY
|
||||
}
|
||||
|
||||
# inject <session> <transcript> <now epoch> —— 跑注入模式,回傳 additionalContext
|
||||
inject(){
|
||||
printf '{"hook_event_name":"UserPromptSubmit","session_id":"%s","transcript_path":"%s"}' "$1" "$2" \
|
||||
| ISEP_COUNTDOWN_NOW="$3" bash "$HOOK" 2>/dev/null \
|
||||
| python3 -c 'import json,sys
|
||||
try: print(json.load(sys.stdin)["hookSpecificOutput"]["additionalContext"])
|
||||
except Exception: print("")'
|
||||
}
|
||||
|
||||
# stop <session> <transcript> <now epoch> <extra json> —— 跑查核模式,回傳 "exit|訊息"
|
||||
stop(){
|
||||
# 🔴 明著把 CLAUDE_CODE_CHILD_SESSION 清掉:測試本身常常跑在一條 subagent 裡,
|
||||
# 而那正是本閘刻意放行的情況 ⇒ 不清掉的話 B 群會全綠,而且是**假綠**
|
||||
# (2026-08-28 第一次跑就撞到:B 群三條全 exit 0,因為跑測試的就是子 session)。
|
||||
out=$(printf '{"hook_event_name":"Stop","session_id":"%s","transcript_path":"%s"%s}' "$1" "$2" "${4:-}" \
|
||||
| ISEP_COUNTDOWN_NOW="$3" CLAUDE_CODE_CHILD_SESSION= CLAUDE_PROJECT_DIR="$TMP" \
|
||||
bash "$HOOK" 2>&1); rc=$?
|
||||
printf '%s|%s' "$rc" "$(printf '%s' "$out" | tr '\n' ' ')"
|
||||
}
|
||||
|
||||
echo "── A 群:注入(每一則回覆眼前都有那一行)────────────────────────"
|
||||
|
||||
TR="$TMP/a1.jsonl"; mk_transcript "$TR" "$T0"
|
||||
CTX=$(inject s-a1 "$TR" "$T0")
|
||||
case "$CTX" in
|
||||
*"⏱"*) ok "① 新 session 什麼都沒交代 ⇒ 注入的內容帶著標記" ;;
|
||||
*) no "① 新 session 什麼都沒交代 ⇒ 注入的內容帶著標記" "實得:$CTX" ;;
|
||||
esac
|
||||
case "$CTX" in
|
||||
*"已過 0 分"*) ok "② 剛開工 ⇒ 已過 0 分" ;;
|
||||
*) no "② 剛開工 ⇒ 已過 0 分" "實得:$CTX" ;;
|
||||
esac
|
||||
# 台北 08:00 → 16:00 收工線還有 8 小時
|
||||
case "$CTX" in
|
||||
*"距收工線(台北 16:00)剩 8 小時"*) ok "③ 台北 08:00 ⇒ 距收工線剩 8 小時" ;;
|
||||
*) no "③ 台北 08:00 ⇒ 距收工線剩 8 小時" "實得:$CTX" ;;
|
||||
esac
|
||||
|
||||
# 票上驗收 2:數字要對得上真實經過的時間(期望 2 小時 13 分)
|
||||
CTX=$(inject s-a1 "$TR" $((T0 + 2*H + 13*60)))
|
||||
case "$CTX" in
|
||||
*"已過 2 小時 13 分"*) ok "④ 過了 2h13m ⇒ 顯示「已過 2 小時 13 分」(期望=實得)" ;;
|
||||
*) no "④ 過了 2h13m ⇒ 顯示「已過 2 小時 13 分」" "實得:$CTX" ;;
|
||||
esac
|
||||
|
||||
# 票上驗收 3:連續幾則要遞增,不歸零、不亂跳
|
||||
LAST=-1; SEQ_OK=1
|
||||
for k in 0 1 3 5; do
|
||||
CTX=$(inject s-a1 "$TR" $((T0 + k*H)))
|
||||
got=$(printf '%s' "$CTX" | sed -n 's/.*已過 \([0-9]*\) 小時.*/\1/p')
|
||||
[ -z "$got" ] && got=0
|
||||
[ "$got" -ge "$LAST" ] || SEQ_OK=0
|
||||
LAST="$got"
|
||||
done
|
||||
[ "$SEQ_OK" = 1 ] && ok "⑤ 連續四則(0/1/3/5 小時)⇒ 遞增,不歸零不亂跳" \
|
||||
|| no "⑤ 連續四則 ⇒ 遞增" "第 $LAST 則往回跳了"
|
||||
|
||||
# resume:同一個 session 換一份較早起點的 transcript,起點取較早的那個
|
||||
TR2="$TMP/a2.jsonl"; mk_transcript "$TR2" $((T0 - 5*H))
|
||||
CTX=$(inject s-a1 "$TR2" "$T0")
|
||||
case "$CTX" in
|
||||
*"已過 5 小時"*) ok "⑥ resume(transcript 起點更早)⇒ 昨天燒掉的時間仍然算" ;;
|
||||
*) no "⑥ resume ⇒ 起點取較早" "實得:$CTX" ;;
|
||||
esac
|
||||
|
||||
# 過了收工線:台北 17:30 ⇒ 顯示超過,不准悄悄換算成明天
|
||||
CTX=$(inject s-late "$TMP/a3.jsonl" $((T0 + 9*H + 30*60)))
|
||||
case "$CTX" in
|
||||
*"已超過收工線"*) ok "⑦ 台北 17:30 ⇒ 顯示「已超過收工線」(超時不許消失)" ;;
|
||||
*) no "⑦ 台北 17:30 ⇒ 顯示已超過" "實得:$CTX" ;;
|
||||
esac
|
||||
# 台北 21:00(過線四小時後)⇒ 滾到隔天 16:00,剩 19 小時
|
||||
CTX=$(inject s-next "$TMP/a4.jsonl" $((T0 + 13*H)))
|
||||
case "$CTX" in
|
||||
*"距收工線(台北 16:00)剩 19 小時"*) ok "⑧ 台北 21:00 ⇒ 滾到隔天,剩 19 小時" ;;
|
||||
*) no "⑧ 台北 21:00 ⇒ 滾到隔天" "實得:$CTX" ;;
|
||||
esac
|
||||
|
||||
# milestone:沒有快取就整段不出現(不准編一個日期出來)
|
||||
case "$CTX" in
|
||||
*"主線"*) no "⑨ 沒有 milestone 快取 ⇒ 那一段不出現" "實得:$CTX" ;;
|
||||
*) ok "⑨ 沒有 milestone 快取 ⇒ 那一段不出現(不編數字)" ;;
|
||||
esac
|
||||
printf '2026-08-30|Sprint 8-28\n' > "$ISEP_COUNTDOWN_STATE_DIR/milestone-due"
|
||||
CTX=$(inject s-ms "$TMP/a5.jsonl" "$T0")
|
||||
case "$CTX" in
|
||||
*"主線 Sprint 8-28 剩"*) ok "⑩ 有 milestone 快取 ⇒ 期限那一段出現" ;;
|
||||
*) no "⑩ 有 milestone 快取 ⇒ 期限那一段出現" "實得:$CTX" ;;
|
||||
esac
|
||||
rm -f "$ISEP_COUNTDOWN_STATE_DIR/milestone-due"
|
||||
|
||||
echo "── B 群:查核——該擋 ────────────────────────────────────────────"
|
||||
|
||||
TR="$TMP/b1.jsonl"; mk_transcript "$TR" "$T0"
|
||||
add_turn "$TR" 'U:Read' 'T:做完了,改了三個檔。'
|
||||
R=$(stop s-b1 "$TR" $((T0 + 2*H)))
|
||||
[ "${R%%|*}" = 2 ] && ok "⑪ 回覆沒戴倒數 ⇒ 擋" || no "⑪ 回覆沒戴倒數 ⇒ 擋" "實得 exit=${R%%|*}"
|
||||
case "${R#*|}" in
|
||||
*"已過 2 小時"*) ok "⑫ 擋下時把算好的那一行印出來給它抄(不是叫它自己算)" ;;
|
||||
*) no "⑫ 擋下時印出算好的那一行" "實得:${R#*|}" ;;
|
||||
esac
|
||||
|
||||
# 回合邊界:上一回合戴了、這一回合沒戴 ⇒ 還是要擋
|
||||
TR="$TMP/b2.jsonl"; mk_transcript "$TR" "$T0"
|
||||
add_turn "$TR" 'T:⏱ 已過 1 小時|距收工線剩 7 小時。上一回合戴過了。'
|
||||
add_turn "$TR" 'U:Edit' 'T:這回合忘了戴。'
|
||||
R=$(stop s-b2 "$TR" $((T0 + 2*H)))
|
||||
[ "${R%%|*}" = 2 ] && ok "⑬ 只有上一回合戴 ⇒ 這一回合仍要擋(回合邊界對)" \
|
||||
|| no "⑬ 只有上一回合戴 ⇒ 仍要擋" "實得 exit=${R%%|*}"
|
||||
|
||||
echo "── C 群:查核——不該擋(誤攔比漏擋更該修)──────────────────────"
|
||||
|
||||
TR="$TMP/c1.jsonl"; mk_transcript "$TR" "$T0"
|
||||
add_turn "$TR" 'U:Read' 'T:⏱ 已過 2 小時|距收工線(台北 16:00)剩 6 小時 —— 做完了。'
|
||||
R=$(stop s-c1 "$TR" $((T0 + 2*H)))
|
||||
[ "${R%%|*}" = 0 ] && ok "⑭ 回覆戴了倒數 ⇒ 放行" || no "⑭ 回覆戴了倒數 ⇒ 放行" "實得 exit=${R%%|*}"
|
||||
|
||||
TR="$TMP/c2.jsonl"; mk_transcript "$TR" "$T0"
|
||||
add_turn "$TR" 'T:上一回合。'
|
||||
add_turn "$TR" 'U:Read' 'T:⏱ 已過 2 小時|這一回合戴了。'
|
||||
R=$(stop s-c2 "$TR" $((T0 + 2*H)))
|
||||
[ "${R%%|*}" = 0 ] && ok "⑮ 這一回合戴了、上一回合沒戴 ⇒ 放行" \
|
||||
|| no "⑮ 這一回合戴了 ⇒ 放行" "實得 exit=${R%%|*}"
|
||||
|
||||
TR="$TMP/c3.jsonl"; mk_transcript "$TR" "$T0"
|
||||
add_turn "$TR" 'U:Read' 'R:' 'U:Grep'
|
||||
R=$(stop s-c3 "$TR" $((T0 + 2*H)))
|
||||
[ "${R%%|*}" = 0 ] && ok "⑯ 純工具回合(沒有任何文字)⇒ 放行,沒有東西可以戴" \
|
||||
|| no "⑯ 純工具回合 ⇒ 放行" "實得 exit=${R%%|*}"
|
||||
|
||||
TR="$TMP/c4.jsonl"; mk_transcript "$TR" "$T0"
|
||||
add_turn "$TR" 'T:沒戴。'
|
||||
R=$(stop s-c4 "$TR" $((T0 + 2*H)) ',"stop_hook_active":true')
|
||||
[ "${R%%|*}" = 0 ] && ok "⑰ 已經提醒過一次(stop_hook_active)⇒ 放行,至多擋一次" \
|
||||
|| no "⑰ stop_hook_active ⇒ 放行" "實得 exit=${R%%|*}"
|
||||
|
||||
TR="$TMP/c5.jsonl"; mk_transcript "$TR" "$T0"
|
||||
add_turn "$TR" 'T:我是 subagent,交件給總管。'
|
||||
out=$(printf '{"hook_event_name":"Stop","session_id":"s-c5","transcript_path":"%s"}' "$TR" \
|
||||
| ISEP_COUNTDOWN_NOW=$((T0 + 2*H)) CLAUDE_CODE_CHILD_SESSION=1 \
|
||||
CLAUDE_PROJECT_DIR="$TMP" bash "$HOOK" 2>&1); rc=$?
|
||||
[ "$rc" = 0 ] && ok "⑱ 子 session(subagent)⇒ 放行,它的回覆不是給 leo 看的" \
|
||||
|| no "⑱ 子 session ⇒ 放行" "實得 exit=$rc"
|
||||
|
||||
R=$(stop s-c6 "$TMP/does-not-exist.jsonl" $((T0 + 2*H)))
|
||||
[ "${R%%|*}" = 0 ] && ok "⑲ transcript 讀不到 ⇒ 放行(節拍器壞掉不該卡死 session)" \
|
||||
|| no "⑲ transcript 讀不到 ⇒ 放行" "實得 exit=${R%%|*}"
|
||||
|
||||
printf 'not json at all\n{{{\n' > "$TMP/c7.jsonl"
|
||||
R=$(stop s-c7 "$TMP/c7.jsonl" $((T0 + 2*H)))
|
||||
[ "${R%%|*}" = 0 ] && ok "⑳ transcript 是壞的 ⇒ 放行,不是擋" \
|
||||
|| no "⑳ transcript 壞掉 ⇒ 放行" "實得 exit=${R%%|*}"
|
||||
|
||||
echo
|
||||
echo "結果:通過 $PASS 條,失敗 $FAIL 條"
|
||||
[ "$FAIL" -eq 0 ] || exit 1
|
||||
@@ -68,6 +68,42 @@ t block "heredoc 之後的真指令(剝內文不能連指令一起剝)" \
|
||||
EOF
|
||||
$ACR_U --force"
|
||||
|
||||
# ══ inkstone/ISEP#63 → comment 5106:兩個卡點 ═══════════════════════════
|
||||
#
|
||||
# 卡點一:發一則 Telegram 給 leo(`notify_leo`)跟「部署一個工作流上線」在本閘眼裡
|
||||
# 長得一模一樣——兩者都是帶 body 的寫入型請求打到同一台實例。
|
||||
# 判準改成看**打的是哪一個 named webhook**(路徑形狀+名字),
|
||||
# 而放行的範圍只有一個名字,家族其他成員照擋。
|
||||
# 卡點二:連「把這個卡點寫進票裡」都被同一支閘擋(同款第八次)——
|
||||
# 內文引用了主機名與寫入旗標,而閘比對的是指令文字。
|
||||
EXE="https://arcrun-cypher-executor.leo21c.wo""rkers.dev"
|
||||
NAMED="$EXE/webhooks/named"
|
||||
|
||||
echo "── 卡點一:通知放得過,家族其他成員照擋 ──"
|
||||
t pass "發一則 Telegram 給 leo(notify_leo trigger)" \
|
||||
"curl -s -X POST $NAMED/leo/notify_leo/trigger $DASH_D '{\"text\":\"[總管] 進度\"}'"
|
||||
t pass "同上,網址後面帶斜線" \
|
||||
"curl -s -X POST $NAMED/leo/notify_leo/trigger/ --data '{\"text\":\"[總管] x\"}'"
|
||||
t block "別的 named webhook(會動線上狀態)不在白名單裡" \
|
||||
"curl -X POST $NAMED/leo/ship_refresh_cdn/trigger $DASH_D '{}'"
|
||||
t block "部署端點(沒有名字、沒有 /trigger)" \
|
||||
"curl -X POST $NAMED $DASH_D @workflow.json"
|
||||
t block "通知混著一筆真的寫入 ⇒ 整條照擋(白名單要每一個都符合)" \
|
||||
"curl -X POST $NAMED/leo/notify_leo/trigger $DASH_D '{}' && curl -X POST $EXE/entries $DASH_D '{}'"
|
||||
t block "名字只是前綴相同(notify_leo_deploy)不算通知" \
|
||||
"curl -X POST $NAMED/leo/notify_leo_deploy/trigger $DASH_D '{}'"
|
||||
|
||||
echo "── 卡點二:談論它(內文引用)不是執行它 ──"
|
||||
t pass "把卡點貼回票上,內文引用了實例網址與寫入旗標(本次實撞)" \
|
||||
"B=\$(cat <<'EOF'
|
||||
閘擋下來了:\`curl -X POST $NAMED\` 被當成部署。
|
||||
EOF
|
||||
); curl -X POST https://git.uncle6.me/api/v1/repos/inkstone/ISEP/issues/63/comments $DASH_D \"\$B\""
|
||||
t block "真的在部署、只是把 payload 放進內文 ⇒ 起始行仍看得到,照擋" \
|
||||
"curl -X POST $NAMED $DASH_D @- <<'EOF'
|
||||
{\"name\":\"x\"}
|
||||
EOF"
|
||||
|
||||
echo
|
||||
echo "結果:通過 $PASS / 失敗 $FAIL"
|
||||
[ $FAIL -eq 0 ] || exit 1
|
||||
|
||||
Reference in New Issue
Block a user