用户 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,不截断,含「一律用肯定表述」「决定对象要点名到具体」 「新建、生成时同样适用」。
275 lines
14 KiB
Python
275 lines
14 KiB
Python
#!/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)
|