Files
workbuddy_skills/session-mechanism/scripts/hooks/decision-rules-hook.py
T
admin 9b839e7a02 session-mechanism:决策判据补每轮通路 + preflight-lock 补回落修复
用户问:「为什么决策方法 每次都要说一遍才会加载,是否需要把它纳入 hook」

一、根因(实测,⛔ 不是猜)
`decision-rules-hook.py` 早就存在、也早就接线(SessionStart,install.py 声明表第 11 条),
但它**只挂 SessionStart** ⇒ 会话开头注入一次,而**长会话被压缩后那段会一起丢掉**。
本机实测:一个由压缩接续而来的会话,上下文里**没有**那段注入。
用户的会话基本都很长 ⇒ 每次都丢 ⇒ 外在就是「不点名就不加载」,
而 `skill-load-guard.py` 只在**点名时**才提醒 ⇒ 形成「必须说一遍」的循环。

二、修法(零新增进程)
`decision-rules-hook.py` 增加 `UserPromptSubmit` 分支,注入极短版 `RULES_BRIEF`(四行);
由 `prompt-guards.py`(UserPromptSubmit 的多守卫合并入口)把本文件作为**第 5 个 guard**
合进同一进程调用 —— 实测 `decision=0.002 s`,⛔ 不新增 Python 冷启动
(该入口存在的理由正是"别再往 UserPromptSubmit 上串新进程")。
全量仍在 SessionStart 那份;每轮只补"最常被违反的四条"。

三、preflight-lock.sh 补回落修复(22799fc 漏了它)
`DOCS_ROOT` 的回落原与 `handoff-guard.sh` 同款(`$_SM_HERE/..` = 回落进技能包 `scripts/`)
⇒ 现改为「取不到就如实报错 + 留痕 + 退出码 2」。
⚠️ `handoff-guard.sh` / `lock-guard-hook.py` / `collabd.py` / `install.py` 四处已由 22799fc 落地。

验收:
· 语法:两个 .py 走 ast.parse、两个 .sh 走 bash -n(PortableGit 1.3.0)—— 全过
· 实跑 `prompt-guards.py`(UserPromptSubmit 载荷):注入 2387 字符,含「决策判据 / 技术实现自己定 /
  只提报用户三类 / 回复排版闸门」四项;各 guard 耗时 0.002–0.013 s
· 实跑 `handoff-guard.sh --status` ⇒ `root=D:/github/dsh_shenxian/dsh-server-docs`(正确落文档库,⛔ 不是技能包)
· 实跑 `collabd.py --domain-status` ⇒ 正常列出在册域锁(读侧已能读到锁)
· 全量自测 PASS 110 / FAIL 0
2026-10-15 22:44:59 +08:00

250 lines
16 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 -*-
"""decision-rules-hook.py —— 决策判据**双通路注入**:`SessionStart` 全量 + `UserPromptSubmit` 极短。
## 🔴 为什么要有这个脚本(2026-10-06 用户明令)
用户原话:
> 「**决策方法 必须想办法 加载到每次对话中**」
> 「要把这个动作 加到 会话技能配置环境的时候一并处理」
## 🔴 2026-10-15 补每轮通路(用户报障:「为什么决策方法 每次都要说一遍才会加载」)
**根因(实测)**:原先只挂 `SessionStart` —— 它在**会话开头注入一次**,而
**长会话被压缩后那段注入会一起丢掉**(本机实测:一个由压缩接续而来的会话,
上下文里**没有**那段 SessionStart 注入)。用户的会话基本都很长 ⇒ **每次都丢**
⇒ 外在就是「不点名就不加载」,而 `skill-load-guard.py` 只在**点名时**才提醒,
于是形成"必须说一遍"的循环。
**修法**:本脚本增加 `UserPromptSubmit` 分支,注入 `RULES_BRIEF`(极短版)。
⚠️ 它**不单独占一条进程** —— 由 `prompt-guards.py`(UserPromptSubmit 的多守卫合并入口)
把本文件作为第 5 个 guard 合进同一进程调用(实测 `decision=0.002s`,零新增冷启动)。
⇒ 全量仍在会话开头那份,每轮只补"最常被违反的四条"。
**改前的缺口(实测取证)**:
· 决策判据(`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`。
## 两个通路各管什么(2026-10-15 定,⛔ 别再按"只挂 SessionStart"读)
· **`SessionStart`**:注入**全量** `RULES`(U27/A6 那一整套)。会话开头一次,
**每轮不重复**(不白烧 token)。
· **`UserPromptSubmit`**:注入**极短** `RULES_BRIEF`(四行,见文件下方)。
存在的唯一理由是上面那条实测 —— SessionStart 那份**在长会话压缩后会丢**。
成本实测 **0.002 s**,且**不新增进程**(由 `prompt-guards.py` 合并调用)。
⚠️ 两者**不重复也不冲突**:全量那份讲全,极短那份只放"最常被违反的四条"。
· ⚠️ 与既有 `decision_bridge.py`(也挂 SessionStart)**职责不重叠**:
那个注入的是**《提问规范》骨架**("要问的时候怎么写");
本脚本注入的是**决策判据**("怎么想、怎么定、什么不许")。
⇒ 两者**互补**,⛔ 不合并(合并会让任一方改判据时动到另一方)。
## 注入什么(用户 2026-10-06 拍板:**判据式精简版**)
⛔ 不注入全文(那是几百 KB);✅ 只注入**能被违反、且违反就是事故**的硬判据,
按"**一句话能判**"重写。完整判据以**本包** `references/04-决策方法论.md` 为正本,本文只放指针。
## 三条不可破的性质(同 `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` 是**正本的编号**,保留它 ⇒ 便于回查原文与引用;
# · 全文以**本包** `references/04-决策方法论.md` 为正本,本文**只放浓缩版 + 指针**;
# · 🔴 **正文内引号一律用全角 `“` `”`**(半角会闭合字符串 ⇒ 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"
"🔴 **两个停止点:没结论就不要往下走**(十步里的第 1、4 步)\n"
" ① **判类型**:这是「方案请求」还是「任务明确·直接执行」?\n"
" 方案请求 ⇒ **只出方案**,未经明确授权 ⛔ 不改任何文件。\n"
" ② **判方向**:这次改动是 **扩大 / 收窄 / 中性**?\n"
" 🔴 **只要会「扩大可见面」⇒ 立即停手**,并把「要不要扩大」**单独**作为红线问题问,\n"
" ⛔ **不许**把它和某个技术方案**捆成一条**问(捆包=逼用户在没看到技术选项时先答红线)。\n"
"\n"
"🔴 **技术方案怎么排序:自己排,⛔ 不问用户**(本包 `references/04-决策方法论.md` 的默认裁决顺序)\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"
)
# ── 🔴 2026-10-15 补:`UserPromptSubmit` 用的**极短版**(走每轮通路)──────────────
# 为什么必须补这一条(实测根因,2026-10-15):
# `SessionStart` 的注入**只在会话开头发生一次**,而**长会话被压缩后那段会一起丢掉**
# (本机实测:一个由压缩接续而来的会话,上下文里**没有**那段 SessionStart 注入)。
# 用户的会话基本都很长 ⇒ **每次都丢** ⇒ 外在表现就是「不点名就不加载」,
# 而 `skill-load-guard.py` 只在**点名时**才提醒 ⇒ 形成"必须说一遍"的循环。
# 为什么这里只放**极短版**:`UserPromptSubmit` **每轮都跑**,
# 全文灌 N 遍既烧 token 又稀释注意力(原注释的顾虑是对的,本条是"够用就停"的折中)。
# 全文仍由 `SessionStart` 那份承担;这里只放"最常被违反的三条"。
RULES_BRIEF = (
"【决策判据 · 每轮提醒(正本 references/04-决策方法论.md)】\n"
"· **技术实现自己定**(框架/库/目录结构/命名/参数/部署/排查)⇒ 写成陈述句"
"(「我选了什么,可推翻」),⛔ **不做成选项让用户选**。\n"
"· **只提报用户三类**:① 功能语义分叉(用户能感知)② 红线门禁 ③ 超出决策边界。\n"
"· ⛔ 已定项混进待拍板清单 = 变相征询;⛔ 不用「你看呢/等你发话」收尾。\n"
"· **先取证再结论**:用户原话 > 本机实测 > 文件在 > 文档说 > 推断。\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 "")
# 🔴 2026-10-15 补:**每轮通路**(`UserPromptSubmit`)—— 只注入极短版。
# 理由见 `RULES_BRIEF` 上方注释:SessionStart 那份在长会话被压缩时会丢,必须有一条每轮的兜底。
if event == "UserPromptSubmit":
_log("inject-brief session_id=%s len=%d" % (session_id or "-", len(RULES_BRIEF)))
_emit({"hookSpecificOutput": {
"hookEventName": "UserPromptSubmit",
"additionalContext": RULES_BRIEF,
}})
return 0
# 🔴 全文档:只认 `SessionStart`。别的档位走到这里 ⇒ 直接合法地什么都不做
# (本脚本会被挂到哪个档由 `install.py` 的声明表决定;⛔ 不在这里猜)。
if event != "SessionStart":
_log("skip event=%s(本钩子只处理 SessionStart + UserPromptSubmit)" % (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)