1b5551274a
stamp 原本雜湊「交件路徑」⇒ 同樣的宣稱每回合生一個新檔名,
而且不知道總管已經驗過了。
實際發作(2026-08-21 一個 session 內):同兩條 sdd-guard 宣稱連生四張單
1c97d461/fcb285dc/256de849/394b97ae——驗掉一張下一回合又冒一張,
Stop 閘於是變成永遠過不去。閘在懲罰有照做的人。
改成雜湊宣稱內容本身,並在寫檔前檢查 verified/ 底下有沒有同名。
雙向實測:
兩次不同 transcript、同樣宣稱 → 只生 1 個檔
移進 verified/ 後再跑 → SKIP:already-verified,沒再冒出來
過程中兩個自己的錯,記下來免得下次重犯:
① 先猜了變數名 blocked/ok/nogo,實際是 ok_hits/ng_hits
——猜錯的話 _claims 永遠是空的、悄悄退回舊行為,不會報錯
② 測試資料先寫成 role:assistant,再寫成 role:user 都不觸發
——它要的是 <task-notification> 裡的 <result>
前兩次「0 個檔」我差點當成去重成功
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
197 lines
9.5 KiB
Bash
Executable File
197 lines
9.5 KiB
Bash
Executable File
#!/usr/bin/env bash
|
||
# subagent-claim-worksheet.sh — subagent 交回時,把它的宣稱抽成一張待驗工作單
|
||
# (SubagentStop;leo 2026-08-18 立)
|
||
#
|
||
# ── leo 的原話(本閘的規格)─────────────────────────────────────────
|
||
# 「你如何確認說的都是真的?subagent 做完事,你應該在 SubagentStop 掛動作,
|
||
# **它說可以,你去驗證;它說不行,你想辦法,不行再報告**,
|
||
# 這解決一半的未查證。你常常直接把 subagent 說的不經查證就回報,
|
||
# **它視野小,它說的你有更多資訊去檢驗**。」
|
||
#
|
||
# ── 為什麼是「工作單檔案」而不是提醒文字 ─────────────────────────────
|
||
# leo 2026-08-17 診斷過:文字層封路是打不贏的軍火競賽,要封**動作**。
|
||
# 所以這支不勸、也不偵測我的措辭——它**在磁碟上放一個必須被處理掉的東西**。
|
||
# 配套的 Stop 閘(claim-verify-police.sh)看的是「那個檔還在不在」,
|
||
# 而檔案只能被動作消滅,不能被文案消滅。
|
||
#
|
||
# ── 兩類宣稱,處理方式不同(leo 的規格就是這兩條)──────────────────
|
||
# ✅ 它說可以 → 我去驗證(我有跨 repo 視野、有 token、有瀏覽器,它沒有)
|
||
# ⛔ 它說不行 → 我想辦法(換路徑/查憑證地圖/自己撞一次),真的不行才報告 leo
|
||
#
|
||
# ⚠️ 這裡的關鍵詞比對只用來**列出候選**,不用來判定對錯——
|
||
# 列多了我自己劃掉(要寫理由),列少了不影響我另外查。
|
||
# ⇒ 它的失敗模式是「多列幾條」,不是「擋錯事」。
|
||
#
|
||
# ── 留痕(InkStoneCo#48:36 支閘只有 2 支記錄自己做了什麼)─────────
|
||
INPUT=$(cat)
|
||
DIR="$CLAUDE_PROJECT_DIR/.claude/pending-verification"
|
||
LOG="$CLAUDE_PROJECT_DIR/.claude/hooks/subagent-claim-worksheet.log"
|
||
mkdir -p "$DIR" 2>/dev/null
|
||
|
||
OUT=$(printf '%s' "$INPUT" | DIR="$DIR" python3 -c '
|
||
import json, os, re, sys, hashlib
|
||
|
||
try:
|
||
d = json.load(sys.stdin)
|
||
except Exception:
|
||
print("SKIP:bad-json"); raise SystemExit
|
||
|
||
tp = d.get("transcript_path") or ""
|
||
if not tp or not os.path.exists(tp):
|
||
print("SKIP:no-transcript"); raise SystemExit
|
||
|
||
# ── 取 subagent 的交件 ────────────────────────────────────────────
|
||
# 🔴 2026-08-18 首次實跑就抓到自己的缺陷:`SubagentStop` 給的 `transcript_path`
|
||
# 指向**主對話**,不是子代理的紀錄 ⇒ 原本「取最後一則 assistant 訊息」
|
||
# 抓到的是**總管自己寫的句子**,這支 hook 當場退化成它要避免的「文字層自咬」。
|
||
#
|
||
# 正解:子代理的交件是以 `<task-notification>…<result>…</result>` 的形式
|
||
# 進到主對話的 **user 訊息**裡。只認那個區塊,其餘一律不看。
|
||
# ⇒ 判準回到「這段話是誰說的」,而不是「這段話長什麼樣」。
|
||
last = ""
|
||
try:
|
||
blob = []
|
||
with open(tp, "r", encoding="utf-8", errors="replace") as f:
|
||
for line in f:
|
||
line = line.strip()
|
||
if not line:
|
||
continue
|
||
try:
|
||
ev = json.loads(line)
|
||
except Exception:
|
||
continue
|
||
msg = ev.get("message") or {}
|
||
if msg.get("role") != "user":
|
||
continue
|
||
parts = msg.get("content")
|
||
if isinstance(parts, str):
|
||
txt = parts
|
||
elif isinstance(parts, list):
|
||
txt = "".join(p.get("text", "") for p in parts
|
||
if isinstance(p, dict) and p.get("type") == "text")
|
||
else:
|
||
txt = ""
|
||
if "<task-notification>" in txt:
|
||
blob.append(txt)
|
||
if blob:
|
||
m = re.search(r"<result>(.*?)</result>", blob[-1], re.S)
|
||
last = m.group(1) if m else ""
|
||
except Exception:
|
||
print("SKIP:read-fail"); raise SystemExit
|
||
|
||
if not last.strip():
|
||
print("SKIP:no-subagent-report"); raise SystemExit
|
||
|
||
if not last.strip():
|
||
print("SKIP:empty-report"); raise SystemExit
|
||
|
||
# ── 抽候選宣稱 ──────────────────────────────────────────────────
|
||
# 成功類:它說某件事成立/做好了 ⇒ 我去驗
|
||
OK = ("驗過", "測過", "通過", "全綠", "已部署", "部署完成", "可以用", "成立",
|
||
"完成", "修好", "已推", "已併", "沒問題", "正常", "一致", "對上",
|
||
"✅", "green", "passed", "verified", "deployed")
|
||
# 阻擋類:它說做不到 ⇒ 我想辦法,不行才報告 leo
|
||
NG = ("無法", "不行", "做不到", "被擋", "擋下", "失敗", "不存在", "找不到",
|
||
"沒有權限", "權限不足", "需要 leo", "要 leo", "請總管", "交給你",
|
||
"❌", "blocked", "denied", "cannot", "failed", "not found")
|
||
|
||
def lines_of(text):
|
||
out = []
|
||
for raw in text.split("\n"):
|
||
s = raw.strip().lstrip("-*#>| ").strip()
|
||
if len(s) < 8 or len(s) > 220:
|
||
continue
|
||
out.append(s)
|
||
return out
|
||
|
||
ls = lines_of(last)
|
||
ok_hits, ng_hits = [], []
|
||
for s in ls:
|
||
low = s.lower()
|
||
if any(k in s or k.lower() in low for k in NG):
|
||
ng_hits.append(s)
|
||
elif any(k in s or k.lower() in low for k in OK):
|
||
ok_hits.append(s)
|
||
|
||
# 兩類都沒有 ⇒ 它交的是純資料/純提問,沒有事實宣稱要驗
|
||
if not ok_hits and not ng_hits:
|
||
print("SKIP:no-claims"); raise SystemExit
|
||
|
||
sid = (d.get("session_id") or "nosid")[:8]
|
||
aid = (d.get("agent_id") or d.get("subagent_id") or "")[:10]
|
||
# 🔴 2026-08-21:stamp 原本雜湊「交件路徑」⇒ 同樣的宣稱每回合生一個新檔名,
|
||
# 而且不知道總管已經驗過了。實際發作:同兩條 sdd-guard 宣稱連生四張單
|
||
# (1c97d461/fcb285dc/256de849/394b97ae),驗掉一張下一回合又冒一張。
|
||
# ⇒ 改成雜湊**宣稱內容本身**:同樣的宣稱=同一個檔名 ⇒ 驗過就不再冒出來。
|
||
_claims = sorted(set(str(x) for x in (ok_hits + ng_hits)))
|
||
# 🔴 空清單就退回舊行為 —— 但那等於這支閘沒抓到任何宣稱,本來就會 SKIP,
|
||
# 所以這個 fallback 實際上不會被用到;留著只是不讓 stamp 變成空字串的雜湊。
|
||
_basis = "\n".join(_claims) if _claims else (tp + sid + aid)
|
||
stamp = hashlib.sha1(_basis.encode()).hexdigest()[:8]
|
||
_name = "claims-%s-%s.md" % (sid, stamp)
|
||
path = os.path.join(os.environ["DIR"], _name)
|
||
|
||
# 已經驗過並移進 verified/ 的,不要再生一次。
|
||
if os.path.exists(os.path.join(os.environ["DIR"], "verified", _name)):
|
||
print("SKIP:already-verified"); raise SystemExit
|
||
|
||
def block(title, items, howto, cap):
|
||
if not items:
|
||
return ""
|
||
body = "\n".join("- [ ] %s" % x for x in items[:cap])
|
||
more = ""
|
||
if len(items) > cap:
|
||
more = "\n- [ ] (還有 %d 條同類,讀原始交件)" % (len(items) - cap)
|
||
return "## %s\n\n%s\n%s%s\n\n" % (title, howto, body, more)
|
||
|
||
doc = []
|
||
doc.append("# 待驗工作單(subagent 交回的宣稱)\n")
|
||
doc.append("> leo 2026-08-18:「**它視野小,它說的你有更多資訊去檢驗。**」\n")
|
||
doc.append("> 交件全文在:`%s`\n" % tp)
|
||
doc.append("\n🔴 **這張單子只能用動作消滅,不能用文字消滅。**")
|
||
doc.append("逐條處理完之後,把本檔**刪掉或移走**;在那之前 Stop 閘會擋。\n\n")
|
||
doc.append(block(
|
||
"✅ 它說「可以」——我要自己驗一次",
|
||
ok_hits,
|
||
"判準:**我不採信,我自己跑一次**。它只看得到自己那個 repo 與自己那次呼叫;\n"
|
||
"我有跨 repo 視野、有 token、有瀏覽器。驗完把證據貼進回覆或票裡。\n",
|
||
12))
|
||
doc.append(block(
|
||
"⛔ 它說「不行」——我先想辦法,真的不行才報告 leo",
|
||
ng_hits,
|
||
"判準:**它的「不存在/被擋」往往只是「我這裡看不到」**。\n"
|
||
"先做三件:① 查憑證地圖/wiki ② 換一條路徑 ③ **自己真的撞一次並貼出拒絕原文**。\n"
|
||
"🔴 沒撞過就不准說「我被 X 擋住」——那是虛構的閘(2026-08-18 已第五次)。\n"
|
||
"真的是人閘 ⇒ 走三件機械動作:`Human` 標籤 + 指派給 Leo + **一張它自己的票**,\n"
|
||
"不是在別張票的留言裡寫一段話(leo 08-18:「不是默默塞進錯的地方」)。\n",
|
||
12))
|
||
|
||
with open(path, "w", encoding="utf-8") as f:
|
||
f.write("".join(doc))
|
||
|
||
print("WROTE:%s:ok=%d:ng=%d" % (os.path.basename(path), len(ok_hits), len(ng_hits)))
|
||
' 2>/dev/null)
|
||
|
||
printf '%s\t%s\n' "$(date +%FT%T)" "${OUT:-SKIP:no-output}" >> "$LOG" 2>/dev/null
|
||
|
||
case "$OUT" in
|
||
WROTE:*)
|
||
F=$(printf '%s' "$OUT" | cut -d: -f2)
|
||
N=$(printf '%s' "$OUT" | sed 's/.*ok=\([0-9]*\):ng=\([0-9]*\)/\1 成功宣稱、\2 阻擋宣稱/')
|
||
cat <<EOF
|
||
🔍 交件查證:subagent 交回了 $N,已列成待驗工作單。
|
||
|
||
【leo 2026-08-18 的規格】
|
||
「它說可以,你去驗證;它說不行,你想辦法,不行再報告。
|
||
**它視野小,它說的你有更多資訊去檢驗。**」
|
||
|
||
工作單:.claude/pending-verification/$F
|
||
|
||
🔴 **不要直接把它說的轉給 leo。** 逐條處理完,把那個檔刪掉;在那之前 Stop 閘會擋一次。
|
||
· 它說「可以」→ 你自己跑一次(測試/curl/瀏覽器/git,看那條宣稱是什麼)
|
||
· 它說「不行」→ 先換路徑、查憑證地圖;**要說「被擋」就得自己撞一次並貼原文**
|
||
EOF
|
||
;;
|
||
esac
|
||
exit 0
|