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
This commit is contained in:
admin committed 2026-10-15 22:44:59 +08:00
1 parent 22799fc9c2
commit 9b839e7a02
3 files changed
+72 -8

No files matched your search

@@ -1,5 +1,5 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
"""decision-rules-hook.py —— `SessionStart` 钩子:**把决策判据常驻注入每一次对话**。 """decision-rules-hook.py —— 决策判据**双通路注入**:`SessionStart` 全量 + `UserPromptSubmit` 极短。
## 🔴 为什么要有这个脚本(2026-10-06 用户明令) ## 🔴 为什么要有这个脚本(2026-10-06 用户明令)
@@ -8,6 +8,19 @@
> 「**决策方法 必须想办法 加载到每次对话中**」 > 「**决策方法 必须想办法 加载到每次对话中**」
> 「要把这个动作 加到 会话技能配置环境的时候一并处理」 > 「要把这个动作 加到 会话技能配置环境的时候一并处理」
## 🔴 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/` 里; · 决策判据(`U1–U28` / `A1–A25`)原本只住在 `skills/dsh-decision/references/` 里;
@@ -28,11 +41,14 @@
⇒ 本次逐字搬入 `references/04-决策方法论.md`(+ `dsh-decision-method/` 三份素材库), ⇒ 本次逐字搬入 `references/04-决策方法论.md`(+ `dsh-decision-method/` 三份素材库),
本脚本的指针**指向包内**,⛔ 不再指向 `dsh-decision`。 本脚本的指针**指向包内**,⛔ 不再指向 `dsh-decision`。
## 为什么挂 `SessionStart` 而不是 `UserPromptSubmit` ## 两个通路各管什么(2026-10-15 定,⛔ 别再按"只挂 SessionStart"读)
· `SessionStart` **每开一个会话注入一次** ⇒ 规矩"从会话开头就在", · **`SessionStart`**:注入**全量** `RULES`(U27/A6 那一整套)。会话开头一次,
且**每轮不重复注入**(不白烧 token); **每轮不重复**(不白烧 token)。
· `UserPromptSubmit` 每轮都跑 ⇒ 同一段文字被灌 N 遍,开销大且会稀释注意力。 · **`UserPromptSubmit`**:注入**极短** `RULES_BRIEF`(四行,见文件下方)。
存在的唯一理由是上面那条实测 —— SessionStart 那份**在长会话压缩后会丢**。
成本实测 **0.002 s**,且**不新增进程**(由 `prompt-guards.py` 合并调用)。
⚠️ 两者**不重复也不冲突**:全量那份讲全,极短那份只放"最常被违反的四条"。
· ⚠️ 与既有 `decision_bridge.py`(也挂 SessionStart)**职责不重叠**: · ⚠️ 与既有 `decision_bridge.py`(也挂 SessionStart)**职责不重叠**:
那个注入的是**《提问规范》骨架**("要问的时候怎么写"); 那个注入的是**《提问规范》骨架**("要问的时候怎么写");
本脚本注入的是**决策判据**("怎么想、怎么定、什么不许")。 本脚本注入的是**决策判据**("怎么想、怎么定、什么不许")。
@@ -166,6 +182,25 @@ RULES = (
) )
# ── 🔴 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: def main() -> int:
raw = _read_stdin_text() raw = _read_stdin_text()
try: try:
@@ -178,10 +213,20 @@ def main() -> int:
event = str(payload.get("hook_event_name") or payload.get("hookEventName") or "") event = str(payload.get("hook_event_name") or payload.get("hookEventName") or "")
session_id = str(payload.get("session_id") or "") session_id = str(payload.get("session_id") or "")
# 🔴 只认 SessionStart。别的档位走到这里 ⇒ 直接合法地什么都不做 # 🔴 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` 的声明表决定;⛔ 不在这里猜)。 # (本脚本会被挂到哪个档由 `install.py` 的声明表决定;⛔ 不在这里猜)。
if event != "SessionStart": if event != "SessionStart":
_log("skip event=%s(本钩子只处理 SessionStart)" % (event or "-")) _log("skip event=%s(本钩子只处理 SessionStart + UserPromptSubmit)" % (event or "-"))
return 0 return 0
_log("inject session_id=%s len=%d" % (session_id or "-", len(RULES))) _log("inject session_id=%s len=%d" % (session_id or "-", len(RULES)))
@@ -58,11 +58,17 @@ HERE = os.path.dirname(os.path.abspath(__file__))
# 🔴 合并的守卫清单(⛔ 顺序=执行顺序;都是 fail-open 的"补充信息/提醒"类) # 🔴 合并的守卫清单(⛔ 顺序=执行顺序;都是 fail-open 的"补充信息/提醒"类)
# ⚠️ **不含** `supervise-ensure-hook` / `wb-result-hook` / `decision_bridge` —— # ⚠️ **不含** `supervise-ensure-hook` / `wb-result-hook` / `decision_bridge` ——
# 它们有副作用(投递/常驻确保)或属别处接线,**合并会改变语义**,一律不碰。 # 它们有副作用(投递/常驻确保)或属别处接线,**合并会改变语义**,一律不碰。
# 🔴 2026-10-15 增第 5 条 `decision-rules-hook.py`(**决策判据的每轮通路**):
# 它**零副作用**(纯本地字符串,不读文件/不起进程/不写任何东西)⇒ 符合本入口的合并条件。
# 为什么必须走每轮:该钩子原先只挂 `SessionStart`(会话开头注入一次),
# 而**长会话被压缩后那段注入会一起丢掉** ⇒ 实测出现「不点名就不加载决策方法」的循环。
# 本文件承担它的**极短版**(`RULES_BRIEF`);全文仍由 `SessionStart` 那份注入。
GUARDS = ( GUARDS = (
"reply-style-guard.py", "reply-style-guard.py",
"stop-dialog-guard.py", "stop-dialog-guard.py",
"skill-load-guard.py", "skill-load-guard.py",
"session-log-guard.py", "session-log-guard.py",
"decision-rules-hook.py",
) )
@@ -27,7 +27,20 @@ _SM_HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
_SM_PKG="$(cd "$_SM_HERE/../.." && pwd)" _SM_PKG="$(cd "$_SM_HERE/../.." && pwd)"
if [ -f "$_SM_PKG/env/roots.env" ]; then . "$_SM_PKG/env/roots.env" if [ -f "$_SM_PKG/env/roots.env" ]; then . "$_SM_PKG/env/roots.env"
elif [ -f "$_SM_PKG/roots.env" ]; then . "$_SM_PKG/roots.env"; fi elif [ -f "$_SM_PKG/roots.env" ]; then . "$_SM_PKG/roots.env"; fi
DOCS_ROOT="${DSH_DOCS_ROOT:-$(cd "$_SM_HERE/.." && pwd)}" # 🔴 2026-10-15 修(第 11 条禁令 + D 盘实测事故):原写
# `DOCS_ROOT="${DSH_DOCS_ROOT:-$(cd "$_SM_HERE/.." && pwd)}"` —— 回落值是
# `$_SM_HERE/..` = **本技能包自己的 `scripts/`** ⇒ 锁根落进技能包。
# ✅ 现行:取不到 ⇒ **如实报错 + 留痕 + 退出码 2**,⛔ 不回落到包内、⛔ 不猜路径。
if [ -z "${DSH_DOCS_ROOT:-}" ]; then
_SM_ERR="${_SM_PKG}/logs/preflight-lock.err.log"
mkdir -p "$(dirname "$_SM_ERR")" 2>/dev/null || true
printf '%s ⛔ 取不到 DSH_DOCS_ROOT(本包 env/roots.env 缺该键)⇒ 退出码 2\n' \
"$(date '+%Y-%m-%d %H:%M:%S')" >> "$_SM_ERR" 2>/dev/null || true
printf '⛔ 取不到文档库根(DSH_DOCS_ROOT)—— 锁根无法定位 ⇒ 停手(退出码 2)。\n' >&2
printf ' ✅ 修法:把本工作区根写进 <技能包>/env/roots.env 的 DSH_DOCS_ROOT,或跑 install.py --apply。\n' >&2
exit 2
fi
DOCS_ROOT="$DSH_DOCS_ROOT"
CODE_ROOT="${DSH_CODE_REPO:-${DOCS_ROOT%/*}}" # ⛔ 不留盘符:代码库=文档库的上一级(纯 bash 展开,⛔ 不调 dirname) CODE_ROOT="${DSH_CODE_REPO:-${DOCS_ROOT%/*}}" # ⛔ 不留盘符:代码库=文档库的上一级(纯 bash 展开,⛔ 不调 dirname)
WS_ROOT="${DSH_WS_ROOT:-$(pwd)}" # ⛔ 不留盘符:默认=当前工作目录(本文件内未被使用) WS_ROOT="${DSH_WS_ROOT:-$(pwd)}" # ⛔ 不留盘符:默认=当前工作目录(本文件内未被使用)
LOCKROOT="$DOCS_ROOT/05-交接单/.locks" LOCKROOT="$DOCS_ROOT/05-交接单/.locks"