Files
workbuddy_skills/session-mechanism/scripts/hooks/skill-load-guard.py
T

402 lines
24 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.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""skill-load-guard.py —— 「用户点名了方法 ⇒ 强制加载技能」的 UserPromptSubmit 钩子
为什么需要它
────────────
2026-09-16 复盘会话 `ddea70b7`(「确认guest用户数据迁移到106服务器」):
用户 U6 明确说「D1–D6 先做哪些 **按照你的规划执行**,中间有问题**参考决策方法**」,
但该会话 265 次工具调用里 **`Skill` 计数 = 0** —— 那份方法论**从未进入上下文**。
AI 仍按常驻判据把两个**非门禁**问题("106 旧控制面要不要停" / "D1–D6 先做哪些")提报用户给用户,
而用户随后 45 秒 / 58 秒内自己给出了答案(Q1 的答案还超出了 AI 给的选项空间)。
⇒ 与 `stop-dialog-guard.py` 同族问题:**规则写了,但没有任何机制保证它在正确的时机被取用**。
(那个钩子处理"回复收尾时的征询句",本钩子处理"用户点名时的方法加载",两者互补。)
本钩子 = 覆盖"用户点名"这条面:**每次用户提交时**扫输入,命中点名关键词 ⇒
通过 `additionalContext` 注入一条**紧邻用户消息**的强制指令
(位置比静态规则文件显著得多 —— 长会话里 CODEBUDDY.md 的硬要求容易被"稀释")。
安全设计(都不许省)
────────────────────
1. **自作用域**:只在 cwd 落在本工作区(`ai1net-dsh-server`)时生效,其他项目一律放行。
2. **绝不添乱**:任何异常 → 静默放行(exit 0);本钩子**从不拦用户**,只在命中时**追加一段提醒**。
3. **防跑飞**:同一会话 300 秒内最多注入一次。
4. **急停双闸**(无需卸载/重启):env `DSH_SKILL_GUARD_OFF=1`,或 `<工作区>/.workbuddy/skill-guard.disabled`。
5. **低频自证日志**:命中才写一行到 `<工作区>/.workbuddy/skill-load-guard.log` —— 用来回答"到底有没有触发"。
6. **显式 UTF-8**:读写都走 `buffer`,不依赖 `PYTHONUTF8`(`-E` 会屏蔽它 ⇒ cp936 ⇒ 含中文静默炸)。
退出码:始终 0;决策通过 stdout 的 JSON 表达。
安装(`settings.json` 的 hooks 段 · 与既有钩子同族,可并列挂在同一个 UserPromptSubmit 的 hooks 数组里,
也可各自独立成块 —— 2026-09-30 实测:**多个 UserPromptSubmit 块都会投递 `additionalContext`**):
"UserPromptSubmit": [{ "hooks": [
{ "type": "command", "command": "\"<python>\" \"<此脚本>\"", "timeout": 10 },
{ "type": "command", "command": "\"<python>\" \"<stop-dialog-guard.py>\"", "timeout": 10 }
]}]
⛔ **绝不要给本族脚本加 `-E`**:`-E` 屏蔽 `PYTHONUTF8`/`PYTHONIOENCODING` ⇒ stdin 回退 cp936 ⇒
含中文的 payload 解析失败且**静默 fail-open**(`bash-output-guard.py` 与 `stop-dialog-guard.py`
头部都有同样的警告;本项目已因此"白排查一天")。
✅ **`-S` 可选**(省 site 初始化),但**非必需** —— 2026-09-30 实测不带 `-S` 也正常工作。
⚠️ 旧注记"hooks 是应用启动时快照 ⇒ 装完必须完全重启"**已不成立**(2026-09-30 实测:18:32 改配置、
18:38 起本钩子与 `stop-dialog-guard` 就在真实用户发言上**热生效**,未重启宿主;`PreToolUse` 同类)。
⇒ 改完仍应**喂一次模拟载荷端到端自证**,⛔ 别只改配置就宣布"修好了"。
"""
# [session-mechanism] roots.env 外置(由 install.py 生成;缺失则回落到按位置推导)
def _sm_load_roots():
import os as _os
_here = _os.path.dirname(_os.path.abspath(__file__))
for _up in range(4):
_p = _os.path.join(_here, *([".."] * _up), "roots.env")
_p = _os.path.normpath(_p)
if _os.path.isfile(_p):
try:
with open(_p, encoding="utf-8") as _f:
for _ln in _f:
_ln = _ln.strip()
if _ln and not _ln.startswith("#") and "=" in _ln:
_k, _v = _ln.split("=", 1)
_os.environ.setdefault(_k.strip(), _v.strip())
except Exception:
pass
return
_sm_load_roots()
import io
import json
import os
import re
import sys
import time
# 🔴 2026-10-02:环境定位/体检收敛到 `_env`(同目录优先)——⛔ 不再用 `~` 直拼
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
try:
import _env
except Exception:
sys.stderr.write('[env] 无法导入 _env.py ⇒ 环境定位不可信\n')
raise
# ── 作用域(2026-09-22 由单值扩为**多值 + env 可覆盖**)─────────────────────────
# 起因:用户 2026-09-22 拍板「B」—— 让本钩子覆盖本机全部会话区,而不是只管 ai1net-dsh-server。
# 形态刻意与 `dsh-ai1net-desktop/.workbuddy/guard/sync-scoped-guards.py` 生成的副本**同形**
# (`_scopes()` + `_in_scope()`),这样两侧可互换、副本的机械变换规则不再需要改写本文件。
# ⛔ 不写死绝对路径:只比工作区**目录名**,换机器 / 改盘符都不受影响。
# ⚠️ env `DSH_GUARD_SCOPES`(逗号分隔)可覆盖;设为空串 ⇒ 只走默认表。
# 🔴 2026-09-24 改:原为**两个目录名的白名单** ⇒ 每新建一个工作区都要手工加名字,
# 漏加即**静默失效**(实测 dsh-decision-laya:日志全 `in_scope=False`,技能闸门从未生效)。
# 用户 2026-09-22 拍板原话是「让本钩子覆盖本机**全部会话区**」⇒ 此处补齐为**默认全机**。
# ⛔ 不要再改回目录名清单;要收窄 ⇒ env `DSH_GUARD_SCOPES`(逗号分隔目录名)。
_SCOPES_DEFAULT = ('*',) # '*' = 本机全部工作区
_log_rel = os.path.join('.workbuddy', 'skill-load-guard.log')
def _workdirs():
"""作用域标记元组(env `DSH_GUARD_SCOPES` 优先,逗号分隔)。"""
raw = os.environ.get('DSH_GUARD_SCOPES')
if raw is None:
return _SCOPES_DEFAULT
return tuple(s.strip() for s in raw.split(',') if s.strip())
def _in_scope(s):
"""`'*'` ⇒ 全机命中;否则按目录名匹配(比绝对路径稳,不受盘符/用户名影响)。"""
names = _workdirs()
if '*' in names:
return True
return any(x in str(s or '') for x in names)
LOG_REL = _log_rel
STATE_REL = os.path.join('.workbuddy', '.skill-load-guard.state')
DISABLE_REL = os.path.join('.workbuddy', 'skill-guard.disabled')
COOLDOWN = 300 # 同会话注入冷却(秒)
# 用户「点名方法 / 点名规则」的词表 —— 命中任一即注入。
# ── 分组 1:点名「决策方法」(2026-09-16 立,最稳的一条)─────────────────────
# ── 分组 2:点名「通用作业规则」(2026-09-22 用户拍板 B 后新增)─────────────────
# 目的:`agent-operating-rules`(跨工作区通用规则技能)过去**完全靠 description 匹配**,
# 是所有技能里最容易被漏的一个 —— 加三条窄词给它一条机制通道。
TRIGGERS = (
# 分组 1 —— 决策方法族(→ 加载 `session-mechanism`;`dsh-decision` 已于 2026-10-06 删除)
'决策方法',
'自行决策', '自主决策', '自己决策', '自己拿主意',
'别问我', '不要问我', '不用问我', '按你的规划', '按你的判断',
'参考决策', '决策方法论',
# 分组 2 —— 作业规则族(→ 加载 `session-mechanism`;规则已整包搬入)
# 🔴 2026-10-02 扩面:**「回复形态 / 排版」这一族原先一个词都没有**。
# 用户看的就是"你这条回复长什么样"(原话「我发现你又忘记如何回复 执行结果了,
# 是不是技能规则失效了」),而旧词表只有「按规则来」等 5 个窄词 ⇒ 这类点名
# **从未命中过**:`skill-load-guard.log` 全量 1177 行里只有 12 次 HIT,
# 且 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 加:从"只会推决策技能"扩为按命中词分流)
# 🔴 2026-10-02 修:本行原写 `dsh-decision-method`,而它**已于 2026-09-28 合并退役**
# ⇒ 注入文本让人去加载一个**不存在的技能**(找得到才怪 ⇒ 规则仍不进上下文;属静默失效族)。
# 现名 `dsh-decision`,两档全文:`references/00-决策方法论.md`(原 decision-method)
# / `references/01-功能优先协作协议.md`(原 feature-first,含 §5.1 结论骨架 + §5.4 排版硬约束)。
_LOAD_RULES = ('按规则来', '按规则做', '按作业规则', '遵守规则', '按规矩来',
'回复排版', '执行结果排版', '回复格式', '怎么回复', '排版')
# ── 分组 3 的词 → 加载 `session-mechanism`(2026-10-05 加)─────────────────────
# 与 `_LOAD_RULES`(→ agent-operating-rules)并列的第三条路由。
# 🔴 为什么单列一族:这三族**目标技能不同**,⛔ 不能混在一个 if 里 ——
# 命中"使用任务会话完成目标"的人,要的是**会话机制本体**(怎么建排期/怎么派活/
# 自决策白名单在 `references/02-功能优先协作协议.md`),⛔ 不是决策方法论。
_LOAD_SESSION = ('使用任务会话', '使用执行会话', '使用协作会话', '任务会话完成', '执行会话完成',
'继续完成目标', '继续目标', '继续完成之前', '继续之前的',
'会话技能', '会话机制', '创建任务会话', '建任务会话',
'派任务会话', '任务会话', '执行会话', '协作会话', '多会话')
def _read_stdin():
try:
raw = sys.stdin.buffer.read()
except Exception:
return {}
if not raw:
return {}
for enc in ('utf-8', 'utf-8-sig', 'gbk'):
try:
return json.loads(raw.decode(enc))
except Exception:
continue
return {}
def _emit(obj):
try:
sys.stdout.buffer.write(json.dumps(obj, ensure_ascii=False).encode('utf-8'))
sys.stdout.buffer.flush()
except Exception:
pass
def _workdir(obj):
for k in ('cwd', 'project_dir', 'workspace', 'projectDir'):
v = obj.get(k)
if isinstance(v, str) and v:
return v
for k in ('CODEBUDDY_PROJECT_DIR', 'WORKSPACE_FOLDER', 'PWD'):
v = os.environ.get(k)
if v:
return v
# 兜底:本脚本位于 <工作区>/dsh-server-docs/07-scripts/
try:
return (os.environ.get('DSH_WS_ROOT')
or os.path.abspath(os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', '..')))
except Exception:
return ''
def _norm_path(p):
"""把 MSYS / Git-Bash 风格路径规范成 Windows 风格(`/e/foo` → `E:/foo`)。
动机(2026-09-22 实测,**正在持续发生**):**Windows 原生 python 会把
`/e/ProgramData/x` 解释成「当前盘根 + 相对路径」= `e\\ProgramData\\x`**
⇒ 若当前盘是 E,就落到 `E:\\e\\ProgramData\\x`。
实证:E 盘根长出影子目录 `E:\\e\\ProgramData\\AIProject\\<ws>\\.workbuddy\\`(355 文件 / 29 MB,仍在增长)。
⛔ 同源铁律:本工作区已有「**Python exe 不认 `/e/…` ⇒ 传 `E:/…`**」。
"""
try:
s = str(p or '')
m = re.match(r'^/([A-Za-z])(/.*)?$', s)
if m:
return m.group(1).upper() + ':' + (m.group(2) or '/')
except Exception:
pass
return p
def _log(workdir, line):
try:
p = os.path.join(_norm_path(workdir), LOG_REL)
with io.open(p, 'a', encoding='utf-8', newline='\n') as f:
f.write('%s %s\n' % (time.strftime('%Y-%m-%d %H:%M:%S'), line))
except Exception:
pass
def _cooling(workdir, sid):
"""同一会话 COOLDOWN 秒内不重复注入。"""
try:
p = os.path.join(workdir, STATE_REL)
now = time.time()
last_sid, last_ts = '', 0.0
if os.path.exists(p):
try:
with io.open(p, 'r', encoding='utf-8') as f:
parts = f.read().strip().split('\t')
last_sid, last_ts = parts[0], float(parts[1] or 0)
except Exception:
pass
if last_sid == sid and (now - last_ts) < COOLDOWN:
return True
with io.open(p, 'w', encoding='utf-8', newline='\n') as f:
f.write('%s\t%.3f\n' % (sid, now))
except Exception:
pass
return False
def main():
if os.environ.get('DSH_SKILL_GUARD_OFF') == '1':
return
obj = _read_stdin()
if not obj:
return
workdir = _norm_path(_workdir(obj))
if not workdir or not _in_scope(workdir):
return # 自作用域:表内工作区之外的会话一律放行
if os.path.exists(os.path.join(workdir, DISABLE_REL)):
return
prompt = ''
for k in ('prompt', 'user_prompt', 'userPrompt', 'message', 'text'):
v = obj.get(k)
if isinstance(v, str) and v.strip():
prompt = v
break
sid = str(obj.get('session_id') or obj.get('sessionId') or 'unknown')
# ★ 入口即留痕(2026-09-16 加)—— 否则"没命中就没日志",永远无法回答
# "它到底有没有被宿主调用"。与 stop-dialog-guard.py 同款设计;每次用户提交一行。
_log(workdir, 'entry|event=UserPromptSubmit|in_scope=True|sid=%s|prompt_len=%d'
% (sid[:8], len(prompt)))
if not prompt:
return
hits = [w for w in TRIGGERS if w in prompt]
if not hits:
return
if _cooling(workdir, sid):
return
seg = re.sub(r'\s+', ' ', prompt)
pos = min(seg.find(w) for w in hits)
excerpt = seg[max(0, pos - 40): pos + 80].strip()
# ── 按命中词分流(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_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'
' **先调用 Skill 工具加载 `session-mechanism`**(总入口 · 作业规矩已整包搬入本包 `references/作业规矩/`:自决策 / 提报用户 / 回复排版 / 说话方式 / 不越界 / 多棒接力),\n'
' 然后才开始作答。若本轮涉及功能需求判类型 / 该不该提报用户,**并同读本包 `references/02-功能优先协作协议.md`**。\n\n'
'为什么强制:该技能是**跨工作区通用**的(不绑定任何具体项目),'
'但它与其他技能一样**靠 description 匹配按需加载 —— 没有机制保证它在任何工作区都被取用**'
'(它的 description 里已写「当你在任何一个工作区开始任务…」,仍可能漏)。'
'本条注入即补上这个缺口。\n'
'(若判定本轮确实与作业规则无关,可在作答中一句话说明后继续 —— 但**不要静默跳过加载**。)'
)
else:
head = '检测到用户本轮**点名了方法**(命中:%s)。' % '、'.join(hits)
body = (
'⛔ **不要凭记忆代替、也不要以"我已经知道判据"为由跳过**:\n'
' **先调用 Skill 工具加载 `session-mechanism`**(总入口 · 判据实体已全部收进本包:`references/04-决策方法论.md` + `references/02-功能优先协作协议.md`),\n'
' 然后才开始作答(原 `dsh-decision-method` + `dsh-feature-first` **已于 2026-09-28 合并进它**,⛔ 不要再按旧名找)。\n\n'
'为什么强制:2026-09-16 实测(会话 ddea70b7)—— 用户点名「参考决策方法」后,'
'AI 全程 `Skill` 调用 **0 次**,仍按旧判据把两个**非门禁**问题提报用户给用户,'
'用户 45 / 58 秒后自己给出了答案。**规则写在文件里 ≠ 会在正确的时机被取用。**\n'
'(若判定本轮确实与决策无关,可在作答中一句话说明后继续 —— 但**不要静默跳过加载**。)'
)
ctx = '【技能加载闸门 · 机制层强制】%s\n' '原文片段:「…%s…」\n\n' '%s' % (head, excerpt, body)
# 🔴🔴 2026-10-02 用户定案:「重点是**技能的使用时要检查环境配置是否已配置,
# 如果没有配置就要先配置**」⇒ 技能被加载的这一轮,**把环境体检结论一并注入**。
# 为什么放这里:这是「技能即将被用」的唯一公共入口 ⇒ ⛔ 不靠各会话自觉去查。
try:
if not _env.skills_root():
ctx += (
'\n\n🔴🔴 **【环境未配好 · 先配再用】**技能库根**定位失败**'
'(⛔ 已不回落 `~`,因 Windows 上 `~` 不是真配置目录)'
'⇒ **本技能的钩子会静默零输出**(日志只有一行 `core=0 字符`)。\n'
'✅ 先跑:`python "<包>/scripts/hooks/_env.py" --ws "<本工作区绝对路径>"`\n'
)
else:
# 🔴🔴 2026-10-02 用户拍板**A 案:装全局,一处配好所有工作区共用**
# (原话:「装全局,一处配好所有工作区共用」;优点=省事、换项目不用重配;
# 缺点=改动影响面大,别的项目出问题也会连带 —— 用户知悉后仍选全局)。
# ⇒ **判据只认全局那份**(scope=global)。⛔ 不再退回「工作区或全局任一即可」
# —— 那是本轮之前的状态:两份标记**互相兜底** ⇒ 只挪走一份不报警
# ⇒ 看着有配置、实际是残的(且换工作区要重新配一遍,正是A 案要消掉的毛病)。
# 保留 `workspace` 作为**只读兼容**:万一历史工作区级标记还在,
# 会在这里明确提示"这是旧口径、应迁到全局",⛔ 而不是默默当它有效。
_st = _env.read_env_stamp("", "global")
_st_old = _env.read_env_stamp(workdir, "workspace")
if not _st:
ctx += (
'\n\n🔴 **【环境标记缺失 · 全局口径】**全局环境标记(`scope=global`)不存在'
' ⇒ ⛔ 别假定"配好了"(用户 2026-10-02 拍板:**装全局,一处配好所有工作区共用**)。\n'
'✅ 先跑体检再写标记:'
'`python "<包>/scripts/hooks/_env.py" --ws "<本工作区绝对路径>"` 看结论,'
'通过后加 `--stamp --scope global` 写**全局**标记(⛔ 不加 `--scope` 默认就是 global)。\n'
)
elif _st_old:
ctx += (
'\n\n⚠️ **【口径迁移提醒】**本工作区还留着一份旧的 `scope=workspace` 标记,'
'而现行口径是**全局**(用户 2026-10-02 拍板 A 案)⇒ 读数以全局那份为准。\n'
'✅ 一次性清理:`rm "<工作区>/.workbuddy/env-stamp.json"`'
'(⛔ 留着不影响生效,只是会让人误以为工作区要单独配)。\n'
)
except Exception:
pass # 🔴 环境检查本身异常 ⛔ 不许把主注入带崩(fail-open)
_emit({'hookSpecificOutput': {
'hookEventName': 'UserPromptSubmit',
'additionalContext': ctx,
}})
_log(workdir, 'HIT sid=%s hits=%s' % (sid[:8], ','.join(hits)))
if __name__ == '__main__':
try:
main()
except Exception:
pass
sys.exit(0)