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你看呢
这类不带选项的待定清单,一律按待拍板项写:问题+说明+各候选优缺点+倾向)
This commit is contained in:
1 parent
19101acd65
commit
64dd82073b
21 files changed
+8444
-4523
No files matched your search
@@ -0,0 +1,191 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""decision-rules-hook.py —— `SessionStart` 钩子:**把决策判据常驻注入每一次对话**。
|
||||
|
||||
## 🔴 为什么要有这个脚本(2026-10-06 用户明令)
|
||||
|
||||
用户原话:
|
||||
|
||||
> 「**决策方法 必须想办法 加载到每次对话中**」
|
||||
> 「要把这个动作 加到 会话技能配置环境的时候一并处理」
|
||||
|
||||
**改前的缺口(实测取证)**:
|
||||
|
||||
· 决策判据(`U1–U28` / `A1–A25`)原本只住在 `skills/dsh-decision/references/` 里;
|
||||
· `dsh-decision` 是**独立技能包**,**⛔ 不在** `session-mechanism` 的安装面里;
|
||||
· `session-mechanism/install.py` 的声明表**明确写了** `⛔ decision_bridge 不在此表内`
|
||||
⇒ **"配置环境"跑一遍,决策判据一个字节都不会进会话**;
|
||||
· 结果:判据**写在文件里**,但 **AI 不会在正确的时机取用它**
|
||||
(2026-09-16 实测:用户点名「参考决策方法」后,AI 全程 `Skill` 调用 **0 次**)。
|
||||
⇒ 一句话:**能力齐了,缺一根接线。** 本脚本就是那根接线。
|
||||
|
||||
**本次一并补齐的两件事(2026-10-06)**:
|
||||
|
||||
① **接线**:本脚本 + `install.py` 声明表 + `OWN_BASENAMES` ⇒ 跑一次「配置环境」就带上;
|
||||
② **判据实体入包**:本包 `SKILL.md:395` 既定口径是
|
||||
「**判据实体就在本档正文里 ⇒ 只复制这一个技能到别的机器,这些功能全部可用,
|
||||
⛔ 不依赖任何其他技能**」,但 10-04 那次**只搬了 `01-功能优先协作协议`(问不问)**,
|
||||
**决策方法论(U27/A6 那套)从未搬入**(证据:本包正文搜 `U27`/`A6` **零命中**)。
|
||||
⇒ 本次逐字搬入 `references/04-决策方法论.md`(+ `dsh-decision-method/` 三份素材库),
|
||||
本脚本的指针**指向包内**,⛔ 不再指向 `dsh-decision`。
|
||||
|
||||
## 为什么挂 `SessionStart` 而不是 `UserPromptSubmit`
|
||||
|
||||
· `SessionStart` **每开一个会话注入一次** ⇒ 规矩"从会话开头就在",
|
||||
且**每轮不重复注入**(不白烧 token);
|
||||
· `UserPromptSubmit` 每轮都跑 ⇒ 同一段文字被灌 N 遍,开销大且会稀释注意力。
|
||||
· ⚠️ 与既有 `decision_bridge.py`(也挂 SessionStart)**职责不重叠**:
|
||||
那个注入的是**《提问规范》骨架**("要问的时候怎么写");
|
||||
本脚本注入的是**决策判据**("怎么想、怎么定、什么不许")。
|
||||
⇒ 两者**互补**,⛔ 不合并(合并会让任一方改判据时动到另一方)。
|
||||
|
||||
## 注入什么(用户 2026-10-06 拍板:**判据式精简版**)
|
||||
|
||||
⛔ 不注入全文(那是几百 KB);✅ 只注入**能被违反、且违反就是事故**的硬判据,
|
||||
按"**一句话能判**"重写。完整判据仍以 `dsh-decision/references/` 为正本,本文只放指针。
|
||||
|
||||
## 三条不可破的性质(同 `decision_bridge.py` 的既定口径)
|
||||
|
||||
1. **不介入会话** —— 只返回 `additionalContext`;⛔ 不返回 `permissionDecision`、
|
||||
⛔ 不改 `updatedInput`、⛔ 不返回 `continue:false`。
|
||||
2. **不拖慢会话** —— **纯本地字符串**,不读文件、不调模型、不起进程。
|
||||
3. **fail-open** —— 任何异常都返回空(⛔ 不抛)。钩子失败 ⇔ 用户的话被吞,是最坏结果。
|
||||
⚠️ 但**留痕**:没留痕时"钩子没被调用"与"调用了但静默失败"无法区分。
|
||||
|
||||
## ⚠️ 改本文件的头号坑(2026-10-06 实际踩到)
|
||||
|
||||
`RULES` 是**用半角双引号包起来的中文长串**。正文里的**引号必须用全角 `“` `”`**,
|
||||
⛔ **绝不能写半角 `"`** —— 写半角会把字符串**提前闭合**,后面剩余的中文
|
||||
就变成了"串外的裸标识符",直接 `SyntaxError`(实测报
|
||||
`invalid character '/' (U+FF0F)`),**整个钩子加载失败**。
|
||||
⇒ 改完**必须**跑一次:`python -c "import ast;ast.parse(open(p,encoding='utf-8').read())"`。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
# ── 留痕(钩子唯一的取证出口)──
|
||||
_LOG = Path(__file__).resolve().parent.parent.parent / "logs" / "_decision-rules-hook.log"
|
||||
|
||||
|
||||
def _log(line: str) -> None:
|
||||
try:
|
||||
_LOG.parent.mkdir(parents=True, exist_ok=True)
|
||||
with io.open(_LOG, "a", encoding="utf-8", newline="\n") as f:
|
||||
f.write("[%s] %s\n" % (time.strftime("%Y-%m-%d %H:%M:%S"), line))
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
|
||||
def _emit(obj: dict) -> None:
|
||||
"""🔴 必须走 buffer 显式 UTF-8 —— 正文含中文与 `⛔`,文本模式在 cp936 下会炸。"""
|
||||
try:
|
||||
sys.stdout.buffer.write(json.dumps(obj, ensure_ascii=False).encode("utf-8"))
|
||||
sys.stdout.buffer.flush()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
|
||||
def _read_stdin_text() -> str:
|
||||
try:
|
||||
raw = sys.stdin.buffer.read()
|
||||
except Exception: # noqa: BLE001
|
||||
return ""
|
||||
return raw.decode("utf-8", errors="replace")
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════════════
|
||||
# 注入正文 —— **判据式精简版**
|
||||
#
|
||||
# 🔴 编写口径(⛔ 改动前先读):
|
||||
# · 每条都必须"**能被违反**" —— 那种"本来就会做"的常识不要写(写了只会稀释注意力);
|
||||
# · 每条都带**可判的触发条件**("遇到 X 时"),⛔ 不写成抽象口号;
|
||||
# · 编号 `U27`/`A6` 是**正本的编号**,保留它 ⇒ 便于回查原文与引用;
|
||||
# · 全文以 `dsh-decision/references/` 为正本,本文**只放浓缩版 + 指针**;
|
||||
# · 🔴 **正文内引号一律用全角 `“` `”`**(半角会闭合字符串 ⇒ SyntaxError,见文件头)。
|
||||
# ══════════════════════════════════════════════════════════════════════════
|
||||
RULES = (
|
||||
"【决策判据 · 常驻(违反即事故;正本 references/04-决策方法论.md)】\n"
|
||||
"遇到“要不要做/怎么做/能不能先凑合”这类判断,先过下面这几条:\n"
|
||||
"\n"
|
||||
"🔴 **目标不打折,路径取最小代价**(U27 + A6)\n"
|
||||
" 发现问题默认目标是**解决**。下面三种**都不算解决**,⛔ 不许当成交付:\n"
|
||||
" ① **降级目标**(把“要做到 A”悄悄改成“做到 A′ 也行”)\n"
|
||||
" ② **延期**(“下次顺手再说 / 等窗口再补”)\n"
|
||||
" ③ **静默兜底**(“先这样也能跑”,而风险与触发条件一个字没写)\n"
|
||||
" ⚠️ 唯一允许“暂时接受”=**客观不可逾越**,且必须写清三项:\n"
|
||||
" 卡在哪(证据)/当前已做到哪一步/什么条件一出现就必须回头解决。\n"
|
||||
" ⚠️ A6 的“最小代价”只约束**路径**,⛔ 不许读成“可以降低目标”。\n"
|
||||
"\n"
|
||||
"🔴 **先取证,再结论**(A1)\n"
|
||||
" ⛔ 不许拿文档/记忆/推断当既成事实。层级:`L1 文档说` < `L2 文件在` < `L3 本机实测`\n"
|
||||
" < `L4 真机/生产` < `L5 用户原话`;**冲突以高层级为准**,⛔ 不许用低层级否定高层级。\n"
|
||||
" ⛔ **不许把“我猜的”写成“事实”** —— 尤其“疑似是我刚才改坏的”:先 diff 备份再说。\n"
|
||||
"\n"
|
||||
"🔴 **不懂就问,但先自己查到位**(A2/A3 + 功能优先协议)\n"
|
||||
" · **技术实现**(框架/库/文件组织/命名/测试/性能/部署/目录结构/错误处理)\n"
|
||||
" ⇒ **自己定**,作为陈述句写进回复(“我选了什么,可推翻”),⛔ 不做成选项让用户选。\n"
|
||||
" · **只准提报用户三类**:① 功能语义分叉(用户能感知的差别)② 红线门禁\n"
|
||||
" ③ 超出决策方法边界。**必须问** ≠ 把技术方案捆进去问。\n"
|
||||
" · 提报时把技术话翻成**功能话**(“影响谁、断多久、花多少钱”),\n"
|
||||
" ⛔ 不写包名/环境变量/文件路径/代码标识符。一轮只问一个问题。\n"
|
||||
"\n"
|
||||
"🔴 **删/改/迁移先判代价对称性**(A8/A14/A16/A22)\n"
|
||||
" 删除收益 < 潜在破坏 ⇒ **标注废弃保留**,⛔ 不删。改名/迁移先列**伴随物清单**\n"
|
||||
" (只改主体必留隐患)。替换/退役:**先补位,再退役**。\n"
|
||||
" ⛔ 删任何东西之前先扫引用(“看起来像资料” ≠ “没被引用”)。\n"
|
||||
"\n"
|
||||
"🔴 **本机改完 ≠ 交付**(A25/A19/A18)\n"
|
||||
" 宣布完成前先画出**改动层 → 生效链路**:改了哪个文件、谁读它、什么时候生效。\n"
|
||||
" ⛔ **静默失败会伪造结论**(工具静默 + 降级静默,两头都要防)。\n"
|
||||
" ⛔ **只看回显不算验收** —— 落到**进程级/文件级证据**(pid / argv0 / 心跳 / 增量字节)。\n"
|
||||
"\n"
|
||||
"🔴 **不确定就说不确定**(A11/A15/A10)\n"
|
||||
" 未验证的能力显式抛 `unsupported`,⛔ 不假装支持;写状态必须带**三态 + 级别**,\n"
|
||||
" ⛔ 不用“支持/可用”描述没验过的项;失败面**必须留证据**,⛔ 不吞错误。\n"
|
||||
"\n"
|
||||
"📂 完整判据(U1–U28 用户决策 · A1–A25 AI 推理 · X1–X13 反例)\n"
|
||||
" ⇒ 本技能包内 `references/04-决策方法论.md`(+ `dsh-decision-method/` 三份素材库);\n"
|
||||
" 决策与“问不问”的完整裁决 ⇒ 本包 `references/02-功能优先协作协议.md`。\n"
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
raw = _read_stdin_text()
|
||||
try:
|
||||
payload = json.loads(raw) if raw.strip() else {}
|
||||
except Exception: # noqa: BLE001
|
||||
payload = {}
|
||||
if not isinstance(payload, dict):
|
||||
payload = {}
|
||||
|
||||
event = str(payload.get("hook_event_name") or payload.get("hookEventName") or "")
|
||||
session_id = str(payload.get("session_id") or "")
|
||||
|
||||
# 🔴 只认 SessionStart。别的档位走到这里 ⇒ 直接合法地什么都不做
|
||||
# (本脚本会被挂到哪个档由 `install.py` 的声明表决定;⛔ 不在这里猜)。
|
||||
if event != "SessionStart":
|
||||
_log("skip event=%s(本钩子只处理 SessionStart)" % (event or "-"))
|
||||
return 0
|
||||
|
||||
_log("inject session_id=%s len=%d" % (session_id or "-", len(RULES)))
|
||||
_emit({"hookSpecificOutput": {
|
||||
"hookEventName": "SessionStart",
|
||||
"additionalContext": RULES,
|
||||
}})
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
sys.exit(main())
|
||||
except Exception as e: # noqa: BLE001
|
||||
# 🔴 fail-open:钩子炸了 ⛔ 不许影响会话(但必须留痕)。
|
||||
try:
|
||||
_log("FATAL %r" % (e,))
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
sys.exit(0)
|
||||
@@ -0,0 +1,218 @@
|
||||
# -*- 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:异常也不阻断提交
|
||||
@@ -130,6 +130,19 @@ TRIGGERS = (
|
||||
# 且 10-01 / 10-02 **零命中**(最后一次真实 HIT 停在 2026-09-30 18:41)。
|
||||
'按规则来', '按规则做', '按作业规则', '遵守规则', '按规矩来',
|
||||
'回复排版', '执行结果排版', '回复格式', '怎么回复', '排版',
|
||||
# ── 分组 3:点名「会话机制 / 派活」(2026-10-05 立 · 🔴 补的正是"漏了主触发句"这个洞)──
|
||||
# 🔴🔴 起因(10-05 实测取证):会话 `b232218f`(vibe-product 主会话)里,
|
||||
# 用户原话「**2、使用任务会话完成目标:补抓…**」+ 连续三次质问
|
||||
# 「任务会话的目的不就是建立任务会话执行嘛」「我想知道你哪来的这么多问题啊」,
|
||||
# 而**本钩子全程 0 命中** —— 旧词表 22 个词里**一个都匹配不上"使用任务会话完成目标"**:
|
||||
# · 「决策方法」族:用户没说"决策",他说的是"使用任务会话"
|
||||
# · 「按规则来」族:用户没说"规则",他说的是"会话技能里没告诉你自决策的规则嘛"(**反问**,含"规则"但那是质问不是点名)
|
||||
# ⇒ 结果:技能从未被强制加载 ⇒ 助手靠记忆干活 ⇒ 把"要不要建任务会话"当待拍板项反复问。
|
||||
# 🔴 教训:**词表只覆盖"点名方法论",漏了"点名派活"** —— 而后者才是本技能的主入口。
|
||||
'使用任务会话', '使用执行会话', '使用协作会话', '任务会话完成', '执行会话完成',
|
||||
'继续完成目标', '继续目标', '继续完成之前', '继续之前的',
|
||||
'会话技能', '会话机制', '创建任务会话', '建任务会话',
|
||||
'派任务会话', '任务会话', '执行会话', '协作会话', '多会话',
|
||||
)
|
||||
|
||||
# 命中词 → 该加载哪个技能(2026-09-22 加:从"只会推决策技能"扩为按命中词分流)
|
||||
@@ -140,6 +153,16 @@ TRIGGERS = (
|
||||
_LOAD_RULES = ('按规则来', '按规则做', '按作业规则', '遵守规则', '按规矩来',
|
||||
'回复排版', '执行结果排版', '回复格式', '怎么回复', '排版')
|
||||
|
||||
# ── 分组 3 的词 → 加载 `session-mechanism`(2026-10-05 加)─────────────────────
|
||||
# 与 `_LOAD_RULES`(→ agent-operating-rules)并列的第三条路由。
|
||||
# 🔴 为什么单列一族:这三族**目标技能不同**,⛔ 不能混在一个 if 里 ——
|
||||
# 命中"使用任务会话完成目标"的人,要的是**会话机制本体**(怎么建排期/怎么派活/
|
||||
# 自决策白名单在 `references/02-功能优先协作协议.md`),⛔ 不是决策方法论。
|
||||
_LOAD_SESSION = ('使用任务会话', '使用执行会话', '使用协作会话', '任务会话完成', '执行会话完成',
|
||||
'继续完成目标', '继续目标', '继续完成之前', '继续之前的',
|
||||
'会话技能', '会话机制', '创建任务会话', '建任务会话',
|
||||
'派任务会话', '任务会话', '执行会话', '协作会话', '多会话')
|
||||
|
||||
|
||||
def _read_stdin():
|
||||
try:
|
||||
@@ -271,9 +294,33 @@ def main():
|
||||
pos = min(seg.find(w) for w in hits)
|
||||
excerpt = seg[max(0, pos - 40): pos + 80].strip()
|
||||
|
||||
# ── 按命中词分流(2026-09-22):规则族与决策族给不同指令 ──────────────────
|
||||
# ── 按命中词分流(2026-09-22 起;2026-10-05 加第三族「会话机制」)───────────
|
||||
# 优先级:会话族 > 规则族 > 决策族。
|
||||
# 🔴 为什么会话族最高:命中"使用任务会话完成目标"时,用户要的是**派活**,
|
||||
# 而派活的前置恰恰是"自决策白名单"(否则就会回头问"要不要建任务会话"——
|
||||
# 10-05 就是这么栽的)。⛔ 此时若只推 `dsh-decision`,人还是可能漏掉会话机制本体。
|
||||
hit_session = [w for w in hits if w in _LOAD_SESSION]
|
||||
hit_rules = [w for w in hits if w in _LOAD_RULES]
|
||||
if hit_rules:
|
||||
if hit_session:
|
||||
head = '检测到用户本轮**点名了会话机制 / 派活**(命中:%s)。' % '、'.join(hit_session)
|
||||
body = (
|
||||
'⛔ **不要凭记忆代替、也不要以"我已经知道这套机制"为由跳过**:\n'
|
||||
' **先调用 Skill 工具加载 `session-mechanism`**(会话机制 + 多会话执行总入口 · 含**首屏第 0 步加载门槛**'
|
||||
'/三类会话/派活线/自决策白名单指向),然后才开始作答。\n'
|
||||
' ⛔ 若上一轮已在本会话加载过,可跳过加载,但**必须确认用的是现行口径**'
|
||||
'(机制 10-03/10-04/10-05 连续改过三轮,记忆里的版本大概率是旧的)。\n\n'
|
||||
'🔴🔴 **本条最关键的作用 —— 拦住"把已授权的事重新要签字"**:\n'
|
||||
' 用户说「**使用任务会话完成 X 目标**」**本身就是授权** ⇒ 直接建排期、拉起任务会话,\n'
|
||||
' ⛔ **不许再问「要不要建任务会话」**。\n'
|
||||
' 📌 2026-10-05 实测栽过(会话 `b232218f`):用户下发「2、使用任务会话完成目标:…」,\n'
|
||||
' AI 却回「你要我直接派任务会话,还是先自己执行?」⇒ 用户连问三次\n'
|
||||
' 「任务会话的目的不就是建立任务会话执行嘛,不然我调用任务会话技能干什么」\n'
|
||||
' 「我想知道你哪来的这么多问题啊,你去执行不行啊」。\n'
|
||||
' ⇒ 根因:**本钩子当时词表里没有一个词能命中"使用任务会话完成目标"**(22 词全是"决策方法"族),\n'
|
||||
' 技能从未被强制加载 ⇒ AI 靠记忆干活 ⇒ 白名单没进上下文。本轮已补上触发词。\n\n'
|
||||
'⚠️ 冲突裁决:**用户原话 > 注入通报**(通报是机制层旁路信号,⛔ 不能拿它反驳用户当轮明确指令)。'
|
||||
)
|
||||
elif hit_rules:
|
||||
head = '检测到用户本轮**点名了作业规则**(命中:%s)。' % '、'.join(hit_rules)
|
||||
body = (
|
||||
'⛔ **不要凭记忆代替、也不要以"我已经知道规则"为由跳过**:\n'
|
||||
|
||||
@@ -432,12 +432,28 @@ def _set_level(root, sid, lv):
|
||||
|
||||
|
||||
def session_budget(path):
|
||||
"""返回 (当前上下文 token, 工具调用累计次数);读不到返回 (None, None)。"""
|
||||
"""返回 (当前上下文 token, 工具调用累计次数, 上一次上下文 token);读不到返回三元 None。
|
||||
|
||||
🔴🔴 2026-10-06 修 **本函数返回元组长度不一致**(真缺陷 · 已实测坐实):
|
||||
原先**两条早退路径只返回 2 值**(transcript > 64 MiB / `getsize` 抛 OSError),
|
||||
而末尾两条返回 **3 值**、调用方 `toks, ncalls, prev = session_budget(tp)`(第 611 行)
|
||||
也按 **3 值**解包 ⇒ 只要 transcript **超 64 MiB**、或文件不在,
|
||||
**每轮必抛 `ValueError: not enough values to unpack (expected 3, got 2)`**。
|
||||
|
||||
后果(比报错难查得多):本钩子是 **fail-open**(异常仍 `sys.exit(0)`)
|
||||
⇒ 宿主侧**看不到任何异常**,`install.py --verify` 只判 `rc=0` ⇒ 判它 **"ok"(假绿)**;
|
||||
而实际失效的是**这一整条**:水位与收口 / 接续机制起点 / 预算告警 / 门禁自检 / 路径自检。
|
||||
|
||||
证据:`<WS>/.workbuddy/stop-dialog-guard.log` **86 条 EXCEPTION**(首条 2026-10-06 05:09:26),
|
||||
且**每轮复现**——因为本会话 transcript 已达 **192.8 MB**(>64 MiB 阈值)⇒ 命中的正是第 437 行那条早退。
|
||||
|
||||
⇒ 判据:**同一函数的所有 return 必须同长**;“防护性早退”最容易漏掉这一条。
|
||||
"""
|
||||
try:
|
||||
if os.path.getsize(path) > 64 * 1024 * 1024:
|
||||
return None, None
|
||||
return None, None, None
|
||||
except OSError:
|
||||
return None, None
|
||||
return None, None, None
|
||||
last_in, n_calls, prev_in = None, 0, None
|
||||
try:
|
||||
with io.open(path, encoding='utf-8', errors='replace') as f:
|
||||
|
||||
Reference in new issue
Block a user