#!/usr/bin/env python3 # -*- coding: utf-8 -*- """reply-style-guard.py —— 「回复排版与格式」的 UserPromptSubmit **全机**强制注入钩子 为什么需要它 ──────────── 2026-10-02 用户令(逐字): · 「所有会话中回复排版和格式要求和规则,也要整合到会话技能中,使用时配置到对应环境文件中」 · 前一轮:「这个规则改为强遵循 固化在必循加入会话的地方」 起因:同一件事 **连点两次**(「我发现你又忘记如何回复执行结果了」「我确认你是又把执行后的 内容回复排版和格式给忘记了」)—— 根因**不是规则丢了**,是**规则只在会话开始时被读一次**: 长会话里会被稀释,而"回复长什么样"这件事**每一轮都要判一次**。 机制对照(同族,已存在) ──────────────────────── · `stop-dialog-guard.py` —— 治「收尾时的征询句」 · `skill-load-guard.py` —— 治「用户点名了方法却没加载技能」 本钩子治第三条面:**「每轮回复的形态」**。三者同挂 `UserPromptSubmit`,互补。 🔴 三条设计判据(本需求的关键,⛔ 都别改) ────────────────────────────────────────── 1. **真相源在技能里**(不在本文件、也不在某个工作区): `<技能库>/agent-operating-rules/references/回复排版-核心块.md` ⇒ 技能是**跨工作区**的 ⇒ 一次改动,**所有会话**都跟着变。 ⛔ **绝不在本文件里写死规则文本** —— 写死就变成第二真相源,两边必然漂。 2. **每轮都注入,⛔ 不设冷却**:这条规矩的价值就在"紧贴用户消息、每轮重述"。 设冷却(如 300s)= 让它按会话衰减回原样,那就白做了。 3. **落环境文件 = 可选项不是必需**:某环境若已用 `apply-reply-rules.py` 把核心块落到自己的 规则文件(`CODEBUDDY.md` / `AGENTS.md`)里,**优先用那份**(可能被本地化过); 没有 ⇒ 回落到技能里那份。两级回退,⛔ 哪一级都读不到就**静默零输出**(不影响任何人)。 安装(`settings.json` 的 `hooks.UserPromptSubmit`,`timeout` 建议 10) ⛔ 绝不要给本族脚本加 `-E`(会屏蔽 PYTHONUTF8 ⇒ cp936 ⇒ 含 `⛔` 的 payload 静默炸)。 急停:env `DSH_REPLY_GUARD_OFF=1` 或 `<工作区>/.workbuddy/reply-guard.disabled`。 退出码:始终 0;决策通过 stdout 的 JSON 表达。 """ import io import json import os import sys import time # 🔴 2026-10-02:环境定位/体检收敛到 `_env`(**同目录优先,其次技能包 scripts/**)—— # ⛔ 必须在 `_norm`/`_skills_root` 定义**之前**导入(它被下面的函数体引用)。 sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) try: import _env except Exception: # 🔴 找不到就报错(⛔ 不静默,见 _env 硬规矩②) sys.stderr.write('[env] 无法导入 _env.py(%s)⇒ 技能库定位不可信\n' % os.path.dirname(os.path.abspath(__file__))) raise CORE_REL = os.path.join('agent-operating-rules', 'references', '回复排版-核心块.md') BEGIN = '' MAX_CHARS = 1400 LOG_REL = os.path.join('.workbuddy', 'reply-style-guard.log') DISABLE_REL = os.path.join('.workbuddy', 'reply-guard.disabled') 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 _norm(p): """MSYS 风格 `/e/foo` → `E:/foo`(Windows 原生 python 不认前者)。""" s = str(p or '') if len(s) > 2 and s[0] == '/' and s[2:3] == '/': return s[1].upper() + ':' + s[2:] return s def _skills_root(): """技能库根 —— 🔴 **2026-10-02 改为共用 `_env`**(原实现是本起事故的根)。 🔴 原实现的病:`沿 __file__ 上溯失败 ⇒ return os.path.expanduser('~/.workbuddy/skills')` 而 **Windows 上 `~` 不是真配置目录**(本机真值在 E 盘 `CODEBUDDY_CONFIG_DIR`) ⇒ 本文件一旦被注册到「够不到 skills/ 的位置」(实测:文档库 D 盘副本), 就定位到C 盘一个不存在的目录 ⇒ **静默零输出**(日志只有 `core=0 字符`) ⇒ **「机制坏了」与「没配规则」表现完全一样**(真实代价:另一工作区为此绕了两轮)。 ✅ 新判据见 `_env` 模块三条硬规矩:顺序固定、**找不到就报错不静默**、体检与标记分离。 """ return _env.skills_root() def _extract(text): i = text.find(BEGIN) if i < 0: return '' i = text.find('\n', i) j = text.find(END, i + 1) if i < 0 or j < 0: return '' body = text[i:j].strip() if len(body) > MAX_CHARS: body = body[:MAX_CHARS].rstrip() + '\n…(超长已截断,全文见本包 `references/03-回复排版-核心块.md`)' return body def _read(p): try: with io.open(p, encoding='utf-8', newline='') as f: return f.read() except Exception: return '' def _core(workdir): """三级回退:① 本环境规则文件里的落地副本 ② **本包内联件** ③ 外部技能里的权威件。 🔴🔴 **2026-10-04 单包自包含改造**(用户定案:复制**一个**技能到别的机器,这些功能都要能用): 原来第② 级直接指向**另一个技能** `agent-operating-rules/references/回复排版-核心块.md` ⇒ 只装本包时它找不到(实测降级成`core=0 字符`,日志里看不出是"机制坏"还是"没配规则") ⇒ 现在**把那份核心块内联进本包** `references/03-回复排版-核心块.md`(**内容守恒,逐字搬**) ⇒ 优先读**包内**;包内没有才回退外部那份(保留与 `agent-operating-rules` 的一致性)。 ⚠️ 顺序⛔ 不许换:包内优先 ⇒ 只拷本包也能用;外部优先 ⇒ 又变成"看别人脸色"。 """ for p in (os.path.join(workdir, 'CODEBUDDY.md'), os.path.join(workdir, '.codebuddy', 'CODEBUDDY.md'), os.path.join(workdir, 'AGENTS.md')): c = _extract(_read(p)) if c: return c, p # ② 本包内联件(⛔ 单包自包含的主力路径) # ⚠️⚠️ 上溯**两级**到包根:`__file__` = `<包>/scripts/hooks/reply-style-guard.py` # ⇒ `dirname` ①= hooks/,②= scripts/,③= **包根**。 # 🔴 2026-10-04 首次改造时只上溯了两级(落到 `scripts/`)⇒ 找不到本档 ⇒ 静默零输出 # (实测:干净工作区里 hook 输出空 JSON,而日志里看不出是"路径算错")。 # ⇒ 判据:**必须 `isfile()` 验到文件**,⛔ 不许"算出来就信"。 _hooks = os.path.dirname(os.path.abspath(__file__)) _pkg = os.path.dirname(os.path.dirname(_hooks)) # scripts/hooks → scripts → 包根 p = os.path.join(_pkg, 'references', '03-回复排版-核心块.md') c = _extract(_read(p)) if c: return c, p # ③ 外部技能那份(⛔ 最后兜底;也兼容"规则块单独放在技能库里"的旧布局) p = os.environ.get('DSH_REPLY_CORE') or os.path.join(_skills_root(), CORE_REL) return _extract(_read(p)), p def _log(workdir, line): try: with io.open(os.path.join(workdir, LOG_REL), '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 main(): if os.environ.get('DSH_REPLY_GUARD_OFF') == '1': return obj = _read_stdin() if not obj: return workdir = '' for k in ('cwd', 'project_dir', 'workspace', 'projectDir'): if isinstance(obj.get(k), str) and obj[k]: workdir = _norm(obj[k]) break if not workdir or not os.path.isdir(workdir): return if os.path.exists(os.path.join(workdir, DISABLE_REL)): return sid = str(obj.get('session_id') or obj.get('sessionId') or 'unknown') # 🔴 2026-10-02 **先查环境**(用户定案「技能使用时先检查环境配置」)—— # ⛔ 定位失败时**必须报错 + 非零退出**,⛔ 不许再「静默零输出」。 # 为什么:静默兜底把「机制坏了」和「没配规则」压成同一表象,日志只留一行 core=0。 if not _env.skills_root(): msg = ('[env] 技能库根定位失败(sid=%s)⇒ 本钩子无法定位权威规则块。\n' ' ✅ 修法:设 DSH_SKILLS_ROOT,或跑 `python scripts/hooks/_env.py --stamp` 体检。' % sid[:8]) _log(workdir, 'ENV-FAIL sid=%s|技能库根定位失败' % sid[:8]) try: sys.stderr.write(msg) sys.stderr.flush() except Exception: pass raise SystemExit(3) # 🔴 非零 ⇒ 宿主侧可见(⛔ 不再静默) core, src = _core(workdir) # ★ 入口即留痕:命中与否都要能回答"它到底有没有被宿主调用" _log(workdir, 'entry|sid=%s|core=%d 字符|源=%s' % (sid[:8], len(core), src)) if not core: # 🔴 区分两种「空」:环境坏了(该报)vs 规则块真的没配(正常,静默) _log(workdir, 'EMPTY sid=%s|技能库根=%s|⚠️环境正常但规则块读不到' % (sid[:8], _env.skills_root())) return ctx = ('【回复排版闸门 · 强遵循】本机已把「回复形态」定为**强遵循**规则(源:%s)\n' % os.path.basename(src).replace('.md', '') + core + '\n(以上每条都是硬约束;发出前过一遍。⛔ 表格/长散文/碎标签堆叠三者一律不许出现。)') _emit({'hookSpecificOutput': { 'hookEventName': 'UserPromptSubmit', 'additionalContext': ctx, }}) _log(workdir, 'HIT sid=%s|%d 字节' % (sid[:8], len(ctx.encode('utf-8')))) if __name__ == '__main__': try: main() except Exception: pass # fail-open:钩子绝不因自己出错而挡人 sys.exit(0)