#!/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": "\"\" \"<此脚本>\"", "timeout": 10 }, { "type": "command", "command": "\"\" \"\"", "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 —— 决策方法族(→ 加载 `dsh-decision`) '决策方法', '自行决策', '自主决策', '自己决策', '自己拿主意', '别问我', '不要问我', '不用问我', '按你的规划', '按你的判断', '参考决策', '决策方法论', # 分组 2 —— 作业规则族(→ 加载 `agent-operating-rules`) # 🔴 2026-10-02 扩面:**「回复形态 / 排版」这一族原先一个词都没有**。 # 用户看的就是"你这条回复长什么样"(原话「我发现你又忘记如何回复 执行结果了, # 是不是技能规则失效了」),而旧词表只有「按规则来」等 5 个窄词 ⇒ 这类点名 # **从未命中过**:`skill-load-guard.log` 全量 1177 行里只有 12 次 HIT, # 且 10-01 / 10-02 **零命中**(最后一次真实 HIT 停在 2026-09-30 18:41)。 '按规则来', '按规则做', '按作业规则', '遵守规则', '按规矩来', '回复排版', '执行结果排版', '回复格式', '怎么回复', '排版', ) # 命中词 → 该加载哪个技能(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 = ('按规则来', '按规则做', '按作业规则', '遵守规则', '按规矩来', '回复排版', '执行结果排版', '回复格式', '怎么回复', '排版') 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\\\\.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):规则族与决策族给不同指令 ────────────────── hit_rules = [w for w in hits if w in _LOAD_RULES] if hit_rules: head = '检测到用户本轮**点名了作业规则**(命中:%s)。' % '、'.join(hit_rules) body = ( '⛔ **不要凭记忆代替、也不要以"我已经知道规则"为由跳过**:\n' ' **先调用 Skill 工具加载 `agent-operating-rules`**(通用作业规则 · 含"什么时候自决策 / 什么时候提报用户 / 回复排版 / 说话方式 / 不越界 / 多棒接力"),\n' ' 然后才开始作答。若本轮涉及功能需求判类型 / 该不该提报用户,**同时加载 `dsh-decision`**。\n\n' '为什么强制:该技能是**跨工作区通用**的(不绑定任何具体项目),' '但它与其他技能一样**靠 description 匹配按需加载 —— 没有机制保证它在任何工作区都被取用**' '(它的 description 里已写「当你在任何一个工作区开始任务…」,仍可能漏)。' '本条注入即补上这个缺口。\n' '(若判定本轮确实与作业规则无关,可在作答中一句话说明后继续 —— 但**不要静默跳过加载**。)' ) else: head = '检测到用户本轮**点名了方法**(命中:%s)。' % '、'.join(hits) body = ( '⛔ **不要凭记忆代替、也不要以"我已经知道判据"为由跳过**:\n' ' **先调用 Skill 工具加载 `dsh-decision`**(决策总入口 · 决策方法论 + 功能优先协作协议 + §4.5 规则冲突裁决顺序 + 提报给用户前三问),\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)