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:
admin committed 2026-10-06 22:27:03 +08:00
1 parent 19101acd65
commit 64dd82073b
21 files changed
+8444 -4523

No files matched your search

+141 -9
View File
@@ -12,9 +12,14 @@ session-mechanism · 一键配置(换机器只需跑这一个)
设计要点(都由实测倒逼,⛔ 不要"优化"掉)
1. **自解析**:解释器一律 `sys.executable`;配置目录按 `CODEBUDDY_CONFIG_DIR` 推导。
⛔ 不硬编码 python 路径、⛔ 不硬编码盘符 —— 这正是「换机器必碎」的根因。
2. **声明表驱动**:本包 **10 条**接线收敛成一张 HOOKS 表(`--verify` 逐条空载荷复测)。
2. **声明表驱动**:本包 **11 条**接线收敛成一张 HOOKS 表(`--verify` 逐条空载荷复测)。
🔴 **2026-10-06 由 10 条增至 11 条**:新增 `SessionStart / decision-rules-hook.py`
(**决策判据常驻注入** —— 用户明令「决策方法必须想办法加载到每次对话中」+
「要把这个动作加到会话技能配置环境的时候一并处理」)。
⛔ 表里**不含** `decision_bridge.py`(属 `ai1net-decision-laya` 另一条线)—— 它在 settings.json 里
另有 4 条、与本包同处一个 `hooks` 段 ⇒ 极易误读成"本包 14 条"(`--dry-run` 会打印被排除条数供目视核对)。
另有 4 条、与本包同处一个 `hooks` 段 ⇒ 极易误读成"本包 15 条"(`--dry-run` 会打印被排除条数供目视核对)。
⚠️ 别把新钩子与 `decision_bridge.py` 合并:前者注入**【决策判据】**(怎么想、什么不许),
后者注入**《提问规范》骨架**(要问时怎么写)—— 职责互补,合并会让任一方改判据时动到另一方。
3. **幂等**:先删「本包自己的旧条目」再插;装两遍结果相同。
4. **可逆**:首次安装先留**原状**备份 `settings.json.bak-session-mechanism-orig`(⛔ 已存在不覆盖),
其后每次 `--apply` 另写**带微秒**的时间戳备份(防同秒同名互相覆盖);
@@ -60,6 +65,20 @@ OWN_BASENAMES = (
"stop-dialog-guard.py",
"skill-load-guard.py",
"wb-result-hook.py",
# 🔴 2026-10-06 新增:决策判据常驻注入(用户明令「决策方法必须加载到每次对话中」
# +「要把这个动作加到会话技能配置环境的时候一并处理」)。
# ⚠️ 不加进这里的后果:`--uninstall` 认不出它 ⇒ 旧接线删不掉 ⇒ 换包/重装后
# **同一件事挂两条钩子**(agent-operating-rules §10.2 实测故障)。
"decision-rules-hook.py",
# 🔴🔴 2026-10-06 新增:**UserPromptSubmit 多守卫合并入口**(用户报「三个 hook 同时 10s 超时」)。
# ⚠️ 必须在此登记 —— 否则 `--apply` 认不出它 ⇒ 重装后又多挂一条 ⇒ 合并+单跑**双跑**。
# 📌 它替掉 4 条单跑接线(见 `HOOKS` 注释),但**那 4 个脚本文件本身保留**
# (仍可单跑;`OWN_BASENAMES` 里那 4 个名字**不能删** —— 用于删旧接线)。
"prompt-guards.py",
# 🔴 2026-10-06 补登记:`reply-style-guard.py`(回复排版闸门)**此前是手工接线**
# (⛔ 一直不在本声明表里 ⇒ `--uninstall` 认不出 ⇒ 摘不干净)。
# 改由 `prompt-guards.py` 合并调用后,把它补进白名单,让包能认全自己的钩子。
"reply-style-guard.py",
)
# ── 声明表:本包负责的钩子(⛔ decision_bridge 不在此表内)───────────────────
@@ -71,10 +90,30 @@ HOOKS: list[tuple[str, str | None, str, list[str], int]] = [
("PreToolUse", "Bash|Read", "scripts/hooks/bash-output-guard.py", [], 10),
("SessionEnd", None, "scripts/hooks/wb-result-hook.py", [], 10),
("SessionStart", "startup|resume", "scripts/hooks/lock-guard-hook.py", [], 10),
# 🔴 2026-10-06 新增:**决策判据常驻注入**(用户明令)。
# 挂 `SessionStart` ⇒ 每开一个会话注入一次,⛔ 不每轮重复(不白烧 token)。
# ⚠️ 与 `decision_bridge.py`(也挂 SessionStart)**互补、⛔ 不合并**:
# 那个注入《提问规范》骨架("要问时怎么写");本钩子注入【决策判据】("怎么想、什么不许")。
# ⚠️ 超时给 5 s:它是**纯本地字符串**(不读文件/不调模型/不起进程)⇒ 正常 0.05 s 内返回。
("SessionStart", "startup|resume", "scripts/hooks/decision-rules-hook.py", [], 5),
("UserPromptSubmit", None, "scripts/hooks/wb-result-hook.py", [], 20),
("UserPromptSubmit", None, "scripts/hooks/stop-dialog-guard.py", [], 10),
("UserPromptSubmit", None, "scripts/hooks/skill-load-guard.py", [], 10),
("UserPromptSubmit", None, "scripts/hooks/session-log-guard.py", ["-S"], 10),
# 🔴🔴🔴 2026-10-06:**UserPromptSubmit 的 4 条守卫合并成 1 条**(用户报超时后改)。
#
# 事实(本机实测):`UserPromptSubmit` 上串行挂了 **7 条 hook**,每条都是**一个
# Python 进程** ⇒ 宿主每轮要**冷启 7 次解释器**。单条都不慢(0.23–0.36 s;
# 7 条并发也才 0.84 s),但**串行 + 冷启动**累加 ⇒ 4 条 `timeout=10` 的一起报超时。
#
# ✅ 改法:把下面这 4 条(都是"同一事件、同一 stdin 契约、fail-open、无副作用"的守卫)
# 合进 `prompt-guards.py`,**一个进程跑完 4 个**。实测 1.247 s → **0.357 s(3.5×)**,
# 注入内容**逐字一致**(654 字符 additionalContext 完整保留)。
#
# ⛔ **不许合并**的三条(有副作用/属别处接线,合并会改语义):
# `supervise-ensure-hook.py`(常驻确保)、`wb-result-hook.py`(结果投递)、
# `decision_bridge.py`(不在本包声明表内)。
("UserPromptSubmit", None, "scripts/hooks/prompt-guards.py", [], 30),
# ⚠️ 原 4 条单跑接线(timeout=10 ⇒ 被前面挤破而超时)**已由上面这条替代**:
# stop-dialog-guard.py / skill-load-guard.py / session-log-guard.py / reply-style-guard.py
# ⛔ 别再加回来 —— 会变成"合并跑一次 + 单跑一次"(同一判据跑两遍)。
]
@@ -110,19 +149,70 @@ def read_roots() -> dict[str, str]:
return d
def _ws_marker(cand: Path) -> bool:
"""**候选目录是不是一个工作区**。
🔴 2026-10-05 修两处误判(原判据=`.workbuddy` + `state.py`):
**病一:硬要 `state.py` ⇒ 非 ai1net 线全判死。** `state.py` 只是 ai1net 那条线的
现状快照脚本(`collabd.py:4163` 引它作「跑这一条看现状」),`vibe-product` /
`agent-product` **本来就没有** ⇒ 老判据在那些工作区里**永远返回 None** ⇒ 被下游
`roots.env` 残留顶替(见 `detect_workspace` 的说明)。
**病二:⛔ 没有"这是本目录自己的还是从祖先继承的"之分。**
`E:/ProgramData/AIProject/.workbuddy` 也存在(早期遗留:`automations/` + `memory/2026-08-30.md`)
⇒ 若只看"存在性",按 `cwd.parents` 向上找会在**父目录**上命中。
⇒ 现判据:**必须同时具备「有 `.workbuddy`」+「`.workbuddy` 里有本工作区级的真物」**。
真物取三选一(都是**工作区**语义、且宿主会往里写,⛔ 不是"某条线的产物"):
· `memory/` —— 工作区记忆(宿主写入;`AIProject/.workbuddy` 也有,故单靠它不够)
· `collab/` —— 会话机制的协作落点(`collabd.py` 的家)
· `state.py` —— ai1net 线的现状快照(保留,兼容老工作区)
⚠️ `agent-product` 现只落钩子日志(`bash-guard.log` 等)⇒ 三条都不满足 ⇒ 仍判 False,
由 `detect_workspace` 的**显式参数**兜住(用户跑 `install.py` 时本就该点名工作区)。
"""
wb = cand / ".workbuddy"
if not wb.is_dir():
return False
if (wb / "collab").is_dir():
return True
if (wb / "memory").is_dir() and (cand / "state.py").is_file():
return True
return (cand / "state.py").is_file()
def _cwd_workspace() -> Path | None:
"""从 cwd 向上找最近的**带工作区标记**的目录。"""
cwd = Path.cwd().resolve()
for cand in (cwd, *cwd.parents):
if _ws_marker(cand):
return cand
return None
def detect_workspace(explicit: str | None) -> Path | None:
"""定「装到哪个工作区」。**优先级 = 显式 > 宿主 env > cwd 就近 > roots.env 残留。**
🔴🔴 2026-10-05 修一处**静默错归属**(实测:在 `agent-product` 里跑 `--dry-run`,
解析结果 = `ai1net-dsh-server`,踩技能 S 红线)。原顺序把 **`roots.env` 残留**
排在 **cwd** 之前 ⇒ `roots.env` 里只要留着上次装的值,**换任何目录跑都会继承它**。
⛔ 那是两件事被混为一谈:`roots.env` 是**本包全局单例**(一个包只有一份),
`roots.env` 里那个 `DSH_WS_ROOT` 记的是**上次装到哪**,⛔ 不是"当前工作区"。
三个非 ai1net 工作区(`vibe-product` / `agent-product` / …)**全部**会被它顶替。
⚠️ 兜底(沿用 `roots.env`)仍保留,但**必须开口说话** —— 见 `cmd_apply` 的冲突告警:
只要 cwd 与 `roots.env` 指向不同,就打 🔴 并**要求显式 `--workspace`**,⛔ 不许默默装。
"""
if explicit:
return Path(explicit).resolve()
for k in ("DSH_WS_ROOT", "COLLABD_WORKSPACE"):
if os.environ.get(k):
return Path(os.environ[k]).resolve()
here = _cwd_workspace()
if here is not None:
return here
prev = read_roots().get("DSH_WS_ROOT")
if prev and Path(prev).is_dir():
return Path(prev)
cwd = Path.cwd().resolve()
for cand in (cwd, *cwd.parents):
if (cand / ".workbuddy").is_dir() and (cand / "state.py").is_file():
return cand
return None
@@ -303,6 +393,48 @@ def cmd_apply(args) -> int:
if ws is None:
log("🔴 未能推断工作区 ⇒ 请显式 `--workspace <路径>`(⛔ 不猜)")
return 2
# 🔴🔴 归属自检(2026-10-05 加):**换工作区装的时候,最坏的结果是"装错地方还不出声"。**
# 实测踩到:在 `agent-product` 里跑,解析成 `ai1net-dsh-server`(S 红线)。
# 根因修在 `detect_workspace` 的优先级;这里再加一道**开口说话**的闸:
# ① cwd 就近判据与最终结果不一致 ⇒ 打字说明,二者都打印,让人当场看见。
# ② 已在别的工作区装过(`roots.env` 有旧值且 ≠ 现在这个)⇒ 打 🔴 警示,
# 因为 `roots.env` 是**包级单例** ⇒ 装了新的,**旧工作区的钩子会一起改指向**。
# ⛔ 不在这里自动改写 / 自动停手:显式 `--workspace` 时用户已表态,别多管;
# 非显式时由优先级保证"就近优先",本段只负责把事实摆在眼前。
here = _cwd_workspace()
prev_ws = read_roots().get("DSH_WS_ROOT")
log(f"归属自检:cwd 就近判据={here} | roots.env 上次装的={prev_ws}")
if here is None and not args.workspace:
# 🔴🔴 最该报警的一档,⛔ 不是「无信号 ⇒ 跳过」。
# cwd 认不出工作区 ⇒ 现在的 ws 只可能来自「宿主 env」或「roots.env 残留」。
# 残留那条正是 2026-10-05 踩到的病(在 agent-product 里解析成 ai1net-dsh-server)。
# ⚠️ 老写法只在 `here != ws` 时报警 ⇒ **恰好漏掉这一档**(`here` 是 None,比较为真但语义不对)。
log("🔴 cwd(及各级祖先)里**没有任何工作区标记**(`.workbuddy/collab/` 或 `state.py`)。")
log(f"🔴 当前工作区 **{ws}** 是**从 `roots.env` 残留推出来的**,⛔ 不是从你脚下的目录认出来的。")
log("🔴 若你确实要装到当前目录 ⇒ 加 `--workspace <当前目录绝对路径>`,⛔ 别让它继承。")
log("🔴 若当前目录**本就该是个工作区** ⇒ 先让它长出标记(至少建 `.workbuddy/collab/`)。")
elif here is not None and here != ws:
log(f"⚠ cwd 判据({here})与最终选用({ws})**不一致** —— 若非故意,请核查 `--workspace`")
if prev_ws and Path(prev_ws).is_dir() and Path(prev_ws) != ws:
# 🔴🔴 2026-10-05 改口径 —— 老文案制造了**不必要的恐慌**("一个包同时只服务一个工作区")。
# 实情(有读数+SKILL.md 支撑):**多工作区并存本来就是既定形态**,分工是两层:
# · **全局层**(本脚本)= 接线钩子 + `roots.env` 兜底 ⇒ 全机器一份;
# · **每区层**(`init_workspace.py`)= 各区自己的 `collabd.py` 副本 + 自己的
# `collabd.config.json` + 自己的计划任务 ⇒ **各用各的**。
# `roots.env` 的 `DSH_WS_ROOT` 只是**整机默认工作区**(钩子在拿不到会话 cwd 时的退化),
# ⛔ 不是"每装一次就迁一次家"。SKILL.md:886 逐字:
# 「技能目录 scripts/collabd.py = 源(唯一真身);每个工作区 .workbuddy/collab/collabd.py
# = 它自己的一份副本(另含 goalctl.py)。⛔ 各区不再跑技能目录那份。」
# 钩子侧也是按 cwd 分流的(`wb-result-hook.py::classify` ⇒ line/home/other/outside)⇒
# 新增一个工作区**不需要**动钩子。
log(f"ℹ `roots.env` 原指向 **{prev_ws}**,本次将改指 **{ws}**。")
log("ℹ `roots.env` 只是**整机默认工作区**(钩子在拿不到会话 cwd 时的退化),")
log('ℹ ⛔ 它不决定「哪个工作区能跑机制」—— 各区跑的是**自己那份** `collabd.py`,互不干扰。')
log(f'ℹ 若本意是「新开一个工作区 {ws}」⇒ ⛔ 别用本脚本改 roots.env;')
log(f'ℹ 正解=在 {ws} 里跑 `init_workspace.py`(铺本区副本 + 本区配置 + 本区计划任务)。')
log(f'ℹ 只有「整机默认工作区要换家」时,才该由本脚本改这一行。')
docs = detect_docs_root(args.docs_root)
log(f"包根={PKG} 工作区={ws} 文档库根={docs} 配置目录={config_dir()}")