Files
workbuddy_skills/session-mechanism/scripts/hooks/prompt-guards.py
T
admin 64dd82073b session-mechanism: 修复钩子静默失效 + 3 处判据缺陷;禁「变相征询」
1) stop-dialog-guard: session_budget() 早退路径返回 2 值、末尾返回 3 值,调用方按 3 值解包
   ⇒ transcript > 64 MiB 时每轮 ValueError。因 fail-open(异常仍 exit 0),
   宿主零报错、install.py --verify 只判 rc=0 ⇒ 假绿;实测 86 条 EXCEPTION,
   死掉的是整条(水位/收口、接续机制起点、预算告警、门禁自检、路径自检)。

2) session-rules-check 三处判据:
   · hook_reg  按旧文件名找 ⇒ 合并成 prompt-guards.py 后每轮假红 ⇒ 改为一组可接受名
   · snap_sync 拿 mtime 当内容判据 ⇒ 连续 4 天假红 ⇒ 改为复用抽取器本体比对内容
               (变异对照:截断快照能报 fail,非恒绿)
   · mem_ptr   只查全局技能根 ⇒ 工作区自带技能被判悬空 ⇒ 改查「全局 ∪ 工作区」

3) pitfalls 新增 P0-95(改判据必须重跑变异对照;fail-open + 只看 rc=0 = 假绿温床)

4) 回复排版核心块新增「变相征询同样禁止」(先只报不动/等你发话/我倾向X你看呢
   这类不带选项的待定清单,一律按待拍板项写:问题+说明+各候选优缺点+倾向)
2026-10-06 22:27:03 +08:00

219 lines
9.1 KiB
Python
Raw 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.
# -*- coding: utf-8 -*-
"""**UserPromptSubmit 多守卫合并入口**(2026-10-06 用户报「三个 hook 同时 10s 超时」)。
## 为什么要合并(实测根因,⛔ 不是"脚本慢")
单一 `UserPromptSubmit` 上串行挂了 **7 条 hook**,每条都是**一个独立的 Python 进程**
(`python.exe <某 hook>.py`)⇒ 宿主要**冷启 7 次解释器**,每次 0.3–1 s
(Windows 上首次 import 开销尤其大,`datetime`/`json`/`re` 全都要重新加载)。
实测(本机,同一份真实 190 MB transcript 负载):
| 跑法 | 耗时 |
|---|---|
| 任一条 guard 单跑 | 0.23–0.36 s |
| 7 条**并发**(多进程) | 0.84 s(墙钟) |
| ⚠️ 宿主里**串行** 7 条 | 累计轻松顶破 10 s ⇒ **4 条 `timeout=10` 的一起报超时** |
⇒ 结论:**单条都不慢,死在"7 次冷启动 + 串行排队"**。
## 本入口做什么
把**同一事件、同一 stdin 契约**的 4 个 guard 合进**一个进程**:
`reply-style-guard` / `stop-dialog-guard` / `skill-load-guard` / `session-log-guard`
一次读 stdin ⇒ 依次在**本进程内**调各自的 `main()` ⇒ 合并 stdout ⇒ 一次输出。
**省掉 3 次 Python 冷启动**(4 条 → 1 条)。
## 契约(必须与各 guard 保持一致)
- 各自都从 `sys.stdin.buffer.read()` 读**同一份** payload(事件名都是 `UserPromptSubmit`)
- 各自都往 `sys.stdout` 写**一个 JSON**(可能空 ⇒ 表示放行)
- 合并规则:把各自的 JSON 合并 ——
· `hookSpecificOutput.additionalContext` **字符串拼接**(多个守卫都要注入时不能互相顶掉)
· `decision` / `permissionDecision` 等**谁拦谁说了算**(先出现的非空值优先,并记来源)
⚠️ ⛔ 不吞任何一条的"阻止"决定 —— 那是安全语义,合并只能加信息、不能减判定。
## 硬纪律(沿用本包同族)
1. 🔴 **输出走 `sys.stdout.buffer.write(bytes)`**(cp936 下文本模式会炸,见 `_env`/`pitfalls`)。
2. 🔴 **fail-open**:本入口自己不判任何业务规则;任一 guard 抛异常 ⇒ 只记 stderr,
其余 guard 照跑(⛔ 不许一个坏 guard 把别的都拖没)。
3. 🔴 **不改各 guard 源码**:用 `runpy` 按路径加载、临时接管 stdout 取回它们的 JSON
⇒ 单跑与合并跑**判据同源**(⛔ 不复制一份规则,避免"一条规则两份实现必漂")。
4. ⚠️ 保持**单跑入口不变**(各 `*-guard.py` 仍可直接被宿主调用)——本文件是**可选的加速入口**。
"""
from __future__ import annotations
import io
import json
import os
import runpy
import sys
import time
HERE = os.path.dirname(os.path.abspath(__file__))
# 🔴 合并的守卫清单(⛔ 顺序=执行顺序;都是 fail-open 的"补充信息/提醒"类)
# ⚠️ **不含** `supervise-ensure-hook` / `wb-result-hook` / `decision_bridge` ——
# 它们有副作用(投递/常驻确保)或属别处接线,**合并会改变语义**,一律不碰。
GUARDS = (
"reply-style-guard.py",
"stop-dialog-guard.py",
"skill-load-guard.py",
"session-log-guard.py",
)
def _log(msg: str) -> None:
"""⛔ 绝不写 stdout(那是协议通道);失败只上 stderr 且吞异常。"""
try:
sys.stderr.write("[prompt-guards] %s\n" % msg)
sys.stderr.flush()
except Exception:
pass
class _Tee(io.TextIOBase):
"""临时接管 `sys.stdout`:把 guard 写出的 JSON 收下来(⛔ 不落终端)。
⚠️🔴 **踩过的坑(2026-10-06 实测)**:多数 guard 走 `sys.stdout.buffer.write(bytes)`。
最初把 `self.buffer = self` ⇒ 那次 `write` **撞上 `TextIOBase.write`**,
于是 bytes 被 `str()` 成 `b'{"hookSpecificOutput": ...}'` ⇒ **JSON 解析必失败**
(现象:合并入口报「输出了非 JSON(6228 字节)」)。
✅ 正解:`buffer` 用一个**独立的 `io.BytesIO`**;文本写与字节写**各收各的**,
`text()` 时按"谁有内容取谁"取回(正常只会有一路有内容)。
"""
def __init__(self):
self._raw = io.BytesIO() # 给 `sys.stdout.buffer.write(bytes)` 用
self.buffer = self._raw
self._txt = [] # 给 `sys.stdout.write(str)` 用(少数 guard 会走)
# TextIOBase 接口
def write(self, s): # noqa: D102
try:
self._txt.append(str(s))
except Exception:
pass
return len(s or "")
def flush(self): # noqa: D102
pass
def isatty(self): # noqa: D102
return False
def writable(self): # noqa: D102
return True
def text(self) -> str:
"""取回收集到的内容(字节路优先;两路都空 ⇒ 空串)。"""
b = self._raw.getvalue()
if b:
return b.decode("utf-8", "replace")
return "".join(self._txt)
def _run_one(name: str, payload_bytes: bytes):
"""在同进程内跑一个 guard,返回 (json_or_None, seconds)。
⚠️ guard 读的是 **stdin**;本入口已把 stdin 读过一次 ⇒ 这里给它一个"假 stdin",
内容是**同一份 payload 字节**(保证各 guard 看到的输入完全一致)。
"""
path = os.path.join(HERE, name)
if not os.path.isfile(path):
_log("跳过(不存在):%s" % name)
return None, 0.0
old_out, old_in = sys.stdout, sys.stdin
tee = _Tee()
# ⚠️ `TextIOWrapper.buffer` 是**只读属性**(实测 `AttributeError: readonly attribute`)
# ⇒ 不能后赋值;正解=**直接构造一个带 BytesIO 的 TextIOWrapper**,
# 它的 `.buffer` 天然就是那个 BytesIO(guard 走 `sys.stdin.buffer.read()`)。
fake_in = io.TextIOWrapper(io.BytesIO(payload_bytes), encoding="utf-8")
t0 = time.time()
try:
sys.stdout = tee
sys.stdin = fake_in
runpy.run_path(path, run_name="__main__")
except SystemExit:
pass # guard 自己 exit ⇒ 正常
except Exception as e: # noqa: BLE001
_log("%s 抛异常(已忽略,fail-open):%r" % (name, e))
finally:
sys.stdout, sys.stdin = old_out, old_in
el = time.time() - t0
txt = tee.text().strip()
if not txt:
return None, el
try:
return json.loads(txt), el
except Exception:
# 非 JSON(不合契约)⇒ 留痕但不当成阻止
_log("%s 输出了非 JSON(%d 字节,已忽略)" % (name, len(txt)))
return None, el
def _merge(dst: dict, src: dict, who: str) -> None:
"""把 `src` 合并进 `dst`(⛔ 只加不减;冲突时保留先到者并记来源)。"""
if not isinstance(src, dict):
return
for k, v in src.items():
if k == "hookSpecificOutput" and isinstance(v, dict):
cur = dst.setdefault("hookSpecificOutput", {})
if not isinstance(cur, dict):
continue
for k2, v2 in v.items():
if k2 == "additionalContext" and isinstance(v2, str):
# 🔴 多守卫都要注入 ⇒ **拼接**(⛔ 别互相顶掉)
old = cur.get("additionalContext") or ""
cur[k2] = (old + ("\n\n" if old else "") + v2) if old else v2
else:
if k2 not in cur:
cur[k2] = v2
elif cur[k2] != v2:
_log("字段冲突 %s.%s:保留先到者,丢弃 %s 的值" % (who, k2, who))
else:
if k not in dst:
dst[k] = v
# ⚠️ 同名字段(如 `decision`)冲突 ⇒ **保留先到者**;因为均为 fail-open 类,
# 先到者通常来自更"硬"的守卫(格式/收尾);真要强制多判定应拆开接线。
def main() -> None:
try:
raw = sys.stdin.buffer.read()
except Exception:
raw = b""
if not raw:
return # 空 payload ⇒ 静默放行(同各 guard)
out = {}
timings = []
for name in GUARDS:
obj, el = _run_one(name, raw)
timings.append("%s=%.3fs" % (name.split("-")[0], el))
if obj:
_merge(out, obj, name)
if not out:
return # 全都没话说 ⇒ 零输出(⛔ 不写空 JSON)
# 🔴 出口:必须走 buffer bytes(cp936 下文本模式炸)
try:
out.setdefault("hookSpecificOutput", {})["hookEventName"] = "UserPromptSubmit"
except Exception:
pass
try:
sys.stdout.buffer.write(json.dumps(out, ensure_ascii=False).encode("utf-8"))
sys.stdout.buffer.flush()
except Exception as e: # noqa: BLE001
_log("写 stdout 失败:%r" % (e,))
_log("合并完成:%s" % " ".join(timings))
if __name__ == "__main__":
try:
main()
except Exception as e: # noqa: BLE001
_log("FATAL %r" % (e,)) # fail-open:异常也不阻断提交