Files
workbuddy_skills/session-mechanism/scripts/hooks/decision-rules-hook.py
T

205 lines
12 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` 钩子:**把决策判据常驻注入每一次对话**。
## 🔴 为什么要有这个脚本(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);✅ 只注入**能被违反、且违反就是事故**的硬判据,
按"**一句话能判**"重写。完整判据以**本包** `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"
)
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)