Files
workbuddy_skills/session-mechanism/scripts/hooks/reply-style-guard.py
T
admin 5004909267 提问规则审计 + 撤出本机生成的两份契约 + 「一律用肯定表述」落进注入面
用户 10-09 三条令:
①「那两份 不说具体 我怎么知道,看看会话提问的规则是否有缺陷」
②「A 方案」(撤出 session-mechanism/roots.env 与 references/manifest.md)
③「要用确定XXXX 这样的描述,避免 不要XXXXX 会导致上下文干扰的描述,除了经验沉淀和红线避坑外」
   +「不只是修改 创建生成时也需要遵守」

一、提问规则审计(三条缺陷,全部修掉)
1、判据用错了维度:旧措辞「⛔ 提问正文不许出现的:包名·环境变量·文件路径·commit/sha·表名/字段名·
   类名/函数名」是按**词性**一刀切,而同一段末尾又写「正文只留能决定下一步的内容」⇒ 两句自相矛盾。
   我按前半句执行、把要撤的文件名删成了「那两份」⇒ 待拍板项不可答。
   ✅ 改成按功能判:**少了这个标识,用户还能不能决定** —— 不能 ⇒ 写进正文;能 ⇒ 删、下沉技术附录。
2、同一判据散在 6 处、措辞各不同(违反本项目自己的「禁重复判定标准」):已收敛到同一句判据,
   并互相标注「改这里必须同批改」。
3、🔴 最隐蔽:**每轮注入的那份 ≠ 包内那份**。钩子 `_core()` 是三级回退,① **本工作区
   CODEBUDDY.md 的 `REPLY-CORE` 段**才是每轮注入的(包内那份只是换机器的兜底)。
   我先只改包内 ⇒ 注入的仍是旧条文,而"文件都改了、自测全绿",看不出来。
   ⇒ 两份都改 + 新增防漂移自测 `t_reply_core_same_source`(两份实质内容必须逐字一致)。

二、撤出两份「本机生成」的契约(用户选 A)
- `git rm --cached session-mechanism/roots.env` 与 `session-mechanism/references/manifest.md`
  —— **本地文件一律保留**(实测 624 B / 18 568 B 仍在)。
- 为什么必须撤:别的电脑取仓时「同名覆盖」会把它们换成**我们这台机器**的路径 ⇒ 那台机器的
  机制脚本去找不存在的目录。
- 新增入库样例 `session-mechanism/roots.env.example`(占位符,无本机路径)。
- `.gitignore`:加这两份的忽略行(⛔ 防 `git add -A` 捎回),并改掉原「刻意入库」那段注释。

三、「一律用肯定表述」写进注入面(原来只在 rules.md,注入面看不到)
- 口径:**写或改技能与规则文件时一律写"要什么、怎么做";新建、生成时同样适用**;
  例外=**以「经验沉淀」或「红线/避坑」为目的**的技能与规则(写法=正向目标 + 括号里的踩坑依据)。
- 落点:injected `REPLY-CORE` 段(工作区 + 包内,逐字一致)· `rules.md §9` ·
  `改包纪律.md` 新增第 5 条硬纪律(「二、四条」→「五条」)· `01-文档索引.md` 指针同步 ·
  把我在注入块里加的那段负向表述改成**正向主导**。

四、🔴 顺带抓到并修掉一个我自己造成的净变差
- 注入块有 **1400 字上限、且从开头截** ⇒ 我加条文把它顶过上限(1274 → 1414+)⇒
  尾部三条(变相征询禁止 / 不用征询句收尾 / 本工作区另有定稿)**静默消失**、不再注入。
- 修:上限 1400 → **2000**;并在 `t_reply_core_same_source` 里加断言 **块长 ≤ 上限** ——
  以后谁再顶破上限,自测直接报红,逼他"要么精简、要么显式抬上限"。
- 验证:`被截断=False`,尾部三条都回来了。

五、验证
- 全套自测 **PASS 109 / FAIL 0**;py 语法全绿;`install.py --manifest` 重算(78 份,语法失败 0)。
- 注入块实测:来源=工作区 CODEBUDDY.md,不截断,含「一律用肯定表述」「决定对象要点名到具体」
  「新建、生成时同样适用」。
2026-10-09 10:45:00 +08:00

275 lines
14 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 -*-
"""reply-style-guard.py —— 「回复排版与格式」的 UserPromptSubmit **全机**强制注入钩子
为什么需要它
────────────
2026-10-02 用户令(逐字):
· 「所有会话中回复排版和格式要求和规则,也要整合到会话技能中,使用时配置到对应环境文件中」
· 前一轮:「这个规则改为强遵循 固化在必循加入会话的地方」
起因:同一件事 **连点两次**(「我发现你又忘记如何回复执行结果了」「我确认你是又把执行后的
内容回复排版和格式给忘记了」)—— 根因**不是规则丢了**,是**规则只在会话开始时被读一次**:
长会话里会被稀释,而"回复长什么样"这件事**每一轮都要判一次**。
机制对照(同族,已存在)
────────────────────────
· `stop-dialog-guard.py` —— 治「收尾时的征询句」
· `skill-load-guard.py` —— 治「用户点名了方法却没加载技能」
本钩子治第三条面:**「每轮回复的形态」**。三者同挂 `UserPromptSubmit`,互补。
🔴 三条设计判据(本需求的关键,⛔ 都别改)
──────────────────────────────────────────
1. **真相源在技能里**(不在本文件、也不在某个工作区):
`<技能库>/session-mechanism/references/03-回复排版-核心块.md`
⚠️ 2026-10-07 订正:原写 `<技能库>/agent-operating-rules/references/回复排版-核心块.md` ——
该技能**已于 2026-10-06 整包并入 `session-mechanism`**,旧路径已不存在(属「指针陈旧」族)。
⇒ 技能是**跨工作区**的 ⇒ 一次改动,**所有会话**都跟着变。
⛔ **绝不在本文件里写死规则文本** —— 写死就变成第二真相源,两边必然漂。
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
# 🔴 2026-10-06:第③级兜底原指向**另一个技能**(agent-operating-rules)⇒ 与「本包自包含」矛盾,
# 且权威已定为包内 03 号 ⇒ 这里指回包内;包内路径由第②级处理,本行只是 env 覆盖时的兜底。
CORE_REL = os.path.join('session-mechanism', 'references', '03-回复排版-核心块.md')
BEGIN = '<!-- REPLY-CORE:BEGIN'
END = '<!-- REPLY-CORE:END -->'
MAX_CHARS = 2000
# 🔴🔴 2026-10-09 由 1400 抬到 2000 —— **必须 ≥ 标记块实际长度**,否则是"净变差":
# 本块**从开头截**,超出的部分(`变相征询禁止` / `不用征询句收尾` / `本工作区另有定稿` 三条)
# 会**静默消失**,而模型那边看不出少了什么(只看到一句"超长已截断")。
# 本轮实测:块 1274 字 ⇒ 全文注入;我加了约 300 字条文 ⇒ 1414+ ⇒ 尾部三条**当场失效**。
# ✅ 配套判据:`selftest.py::t_reply_core_same_source` 断言 **块长 ≤ 本常量** ——
# 以后谁再加条文顶破上限,自测直接报红,逼他"要么精简、要么显式抬上限"。
# ⚖️ 取舍:抬高上限=每轮多带这点上下文;⛔ 但比「规则静默失效」便宜得多,所以选抬。
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
# ── 🔴 2026-10-07 「说人话」块(用户令逐字:「重点就是做到说人话就行了」)──────────────
# 为什么要它:语气那一档原先只有**指针**(必读表里一行)⇒ 实测没被读到(用户报障
# 「content_marketing_agent 的会话生成的文档并没有说人话,还是我手动要求的」)。
# ⇒ 照 `03`(排版)那条**每轮注入**的现成路子,把语气也送进每轮上下文。
# ⚠️ 与 `03` 分工:`03` 管「能不能被扫」(结构),本块管「像不像人话」(语气)。
# ⚠️ **只从包内取**;包内没有 ⇒ **静默不注入**(它是增强项,⛔ 不是结构契约,缺了不影响排版那条)。
VOICE_BEGIN = '<!-- VOICE-CORE:BEGIN'
VOICE_END = '<!-- VOICE-CORE:END -->'
VOICE_MAX_CHARS = 1100
def _voice_core():
"""取包内 `references/作业规矩/04-去AI味与说话方式.md` 的 VOICE-CORE 块。"""
_hooks = os.path.dirname(os.path.abspath(__file__))
_pkg = os.path.dirname(os.path.dirname(_hooks))
t = _read(os.path.join(_pkg, 'references', '作业规矩', '04-去AI味与说话方式.md'))
i = t.find(VOICE_BEGIN)
j = t.find(VOICE_END)
if i < 0 or j < 0 or j <= i:
return ''
body = t[t.find('\n', i) + 1:j].strip()
if len(body) > VOICE_MAX_CHARS:
body = body[:VOICE_MAX_CHARS].rstrip() + \
'\n…(超长已截断,全文见本包 `references/作业规矩/04-去AI味与说话方式.md`)'
return body
# 🔴 原实现改名留档;`_core` **重新定义**为「原实现 + 追加说人话块」
# ⇒ `main()` 一行都不用改(它只认 `_core`),注入正文自动多一段。
_core_reply = _core
def _core(workdir):
c, p = _core_reply(workdir)
v = _voice_core()
if c and v:
return c + '\n\n' + v, p
return (c or v), (p if c else '')
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)