#!/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 當場退化成它要避免的「文字層自咬」。 # # 正解:子代理的交件是以 `` 的形式 # 進到主對話的 **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 "" in txt: blob.append(txt) if blob: m = re.search(r"(.*?)", 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] stamp = hashlib.sha1((tp + sid + aid).encode()).hexdigest()[:8] path = os.path.join(os.environ["DIR"], "claims-%s-%s.md" % (sid, stamp)) 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 <