Files
workbuddy_skills/session-mechanism/scripts/hooks/stop-dialog-guard.py
T
admin 64dd82073b session-mechanism: 修复钩子静默失效 + 3 处判据缺陷;禁「变相征询」
1) stop-dialog-guard: session_budget() 早退路径返回 2 值、末尾返回 3 值,调用方按 3 值解包
   ⇒ transcript > 64 MiB 时每轮 ValueError。因 fail-open(异常仍 exit 0),
   宿主零报错、install.py --verify 只判 rc=0 ⇒ 假绿;实测 86 条 EXCEPTION,
   死掉的是整条(水位/收口、接续机制起点、预算告警、门禁自检、路径自检)。

2) session-rules-check 三处判据:
   · hook_reg  按旧文件名找 ⇒ 合并成 prompt-guards.py 后每轮假红 ⇒ 改为一组可接受名
   · snap_sync 拿 mtime 当内容判据 ⇒ 连续 4 天假红 ⇒ 改为复用抽取器本体比对内容
               (变异对照:截断快照能报 fail,非恒绿)
   · mem_ptr   只查全局技能根 ⇒ 工作区自带技能被判悬空 ⇒ 改查「全局 ∪ 工作区」

3) pitfalls 新增 P0-95(改判据必须重跑变异对照;fail-open + 只看 rc=0 = 假绿温床)

4) 回复排版核心块新增「变相征询同样禁止」(先只报不动/等你发话/我倾向X你看呢
   这类不带选项的待定清单,一律按待拍板项写:问题+说明+各候选优缺点+倾向)
2026-10-06 22:27:03 +08:00

706 lines
41 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 -*-
"""stop-dialog-guard.py —— 「禁止用征询句收尾」的 Stop 钩子(WorkBuddy / CodeBuddy)
为什么需要它
────────────
2026-09-15 实测:本工作区日志里 `tool=AskUserQuestion` 调用数 = 09-12: 43 / 09-13: 3 / **09-14: 0 / 09-15: 0**
⇒ 既有「提问闸门」(PreToolUse + matcher ^AskUserQuestion$)**拦的是几乎不走的工具面**,
而真实的提报用户("要我接着做吗 / 请确认 / 说一声即可")发生在**正文里** —— 没有任何机制覆盖。
本钩子 = 覆盖那条面:**每次回复结束时**读 transcript 的**最后一条 assistant 文本**,
只扫**收尾段**(最后两行有效内容)里的征询句式;命中 → 返回 `{"continue": false, "reason": …}`
让 Agent **继续一轮并自我纠正**(把该自己做的事做掉,或改写成「需要你拍板」一节)。
安全设计(都不许省)
────────────────────
1. **自作用域**:只在 `transcript_path` 落在本工作区(`ai1net-dsh-server`)时生效,其他项目一律放行。
2. **防死循环**:输入里的 `stop_hook_active == true` 时**不再阻拦**(官方语义:本次停止已由 stop hook 触发过)。
3. **绝不添乱**:任何异常 → 静默放行(exit 0)。判定只在**收尾段**做,避免正文引用规则时误伤。
4. **性能**:只读转录**末尾 256 KB**(实测整库最大转录 31.9 MB、全文读 14 MB ≈ 832 ms ⇒ 不可接受),只看 stdin + 该文件。
5. **防跑飞**:同一会话 600 秒内最多拦**一次**。
6. **急停双闸**(无需卸载/重启):env `DSH_STOP_GUARD_OFF=1`,或新建 `<工作区>/.workbuddy/stop-guard.disabled`。
7. **低频自证日志**:命中才写一行(`<工作区>/.workbuddy/stop-dialog-guard.log`),用来回答"到底有没有触发"。
退出码:始终 0;决策通过 stdout 的 JSON 表达。
安装(settings.json 的 hooks 段 · 见档案 73 / 99):
"Stop": [{ "hooks": [{ "type": "command",
"command": "\"<python>\" \"<此脚本>\"", "timeout": 10 }] }]
⚠️ hooks 是**应用启动时快照** ⇒ 装完必须**完全重启 WorkBuddy**;桌面版无 /hooks 面板,等效。
⛔ **安装命令不要给本脚本加 `-E`(或任何会屏蔽 PYTHONUTF8 的 flag)**:本机环境本就设了
`PYTHONUTF8=1` / `PYTHONIOENCODING=utf-8`,而 `-E` 会把它们**全部忽略** ⇒ stdin 回退 **cp936** ⇒
含中文的 payload 解码即炸。本脚本现已改为走 `buffer` 显式 UTF-8(读写都加固),但**不要靠加固兜底**,
装的时候也别再引入新雷。(2026-09-15 实测:`-S -E` 曾让本钩子"看起来从未被调用"整整一天。)
"""
# [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 = ('*',) # '*' = 本机全部工作区
def _scopes():
"""作用域标记元组(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 = _scopes()
if '*' in names:
return True
return any(x in str(s or '') for x in names)
LOG_REL = os.path.join('.workbuddy', 'stop-dialog-guard.log')
# 兜底工作区:用于"每次调用必留痕"(万一宿主没给 cwd、也没设 CODEBUDDY_PROJECT_DIR)
# 本脚本位于 <工作区>/dsh-server-docs/07-scripts/ ⇒ 往上三级即工作区
WS_FALLBACK = os.environ.get("DSH_WS_ROOT") or os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
# 只扫「收尾段」:出现这些就是"把该自己做的事甩回给用户"
PATTERNS = [
r'要我(再|接着|继续|现在)?[^。!?\n]{0,20}吗',
r'要不要我[^。!?\n]{0,20}',
r'是否要我[^。!?\n]{0,20}',
r'需要我[^。!?\n]{0,20}吗',
r'请确认[^。!?\n]{0,16}',
r'要不要(继续|现在做|我来)[^。!?\n]{0,20}',
r'是否(继续|需要我)[^。!?\n]{0,20}',
r'说一声即可',
r'你看(怎么办|怎么弄|要不要)',
r'你(决定|拍板)一下',
]
RE_BAN = re.compile('|'.join(PATTERNS))
REASON = (
'⛔ 收尾句是**征询句**,但按本平台规则(`CODEBUDDY.md §1`「回话前自检」+ `dsh-feature-first §5.3` 铁律 3)'
'先重判三问:① 命中**真门禁**吗(不可逆破坏性操作 / 边界外六类)?没命中 → **删掉这句,自己做完,改成陈述句**("我接着做 X");'
'② 是不是在把已经定下来的事再问一遍?是 → 删;③ 这件事用户有客观可判的优劣吗?没有 → 才允许问,且**一轮只问这一句**,'
'并写进 `dsh-feature-first §5.1` 结论骨架的「**需要你拍板**」一节 —— 该节必须是**整条回复的最后一节**、'
'且**逐条编号**(有序段落)(2026-09-15 用户明令:「放在最后,别隐藏在回复内容中间」「按照有序段落展示」),'
'用**陈述句**列"各候选的**优点 / 缺点** + 我的倾向",不要用征询句。'
'⚠️ 提报给用户前先过**取舍筛** —— 某个候选**只有优点 / 只有缺点** ⇒ **自己拍掉、不要问**;'
'且候选**竖排成段**(A / B / C 各占一行),⛔ 不横排、不做成表格的列(2026-09-15 用户明令)。'
)
TAIL_BYTES = 262144
MAX_BYTES = 4194304 # 扩窗上限 4 MB(防"巨行"时无限读) # 只读末尾 256 KB(实测:整库最大转录 31.9 MB;全文读 14 MB = 832 ms/轮,不可接受)
def transcribe_last_assistant(path):
"""返回最后一条 assistant 文本(**从尾部向后分块读**;读不到返回 '')。
⚠️ 为什么不是"一次读末尾 256 KB":一条 assistant 记录可能本身就 > 256 KB
(长回复 / 被回显的工具输出),此时尾窗会切在 JSON 行中间 ⇒ `json.loads` 失败 ⇒ **静默漏判**。
做法:从尾部按 TAIL_BYTES 递增扩窗(上限 MAX_BYTES),**直到至少解析出一条 assistant 记录**。
常见情形(小消息)只花一次 256 KB 读,成本可忽略。
"""
try:
size = os.path.getsize(path)
except OSError:
return ''
with io.open(path, 'rb') as f:
window = TAIL_BYTES
while True:
start = max(0, size - window)
f.seek(start)
raw = f.read().decode('utf-8', 'replace')
lines = raw.split('\n')
if start > 0:
lines = lines[1:] # 丢弃被截断的首行
for line in reversed(lines):
line = line.strip()
if not line:
continue
try:
rec = json.loads(line)
except ValueError:
continue
if rec.get('type') != 'message' or rec.get('role') != 'assistant':
continue
chunks = [c['text'] for c in (rec.get('content') or [])
if isinstance(c, dict) and isinstance(c.get('text'), str)]
chunks += [c for c in (rec.get('content') or []) if isinstance(c, str)]
if chunks:
return '\n'.join(chunks)
if start == 0 or window >= MAX_BYTES:
# 放行,但**留痕**(A18:静默失败是负债)——可能是一条 >MAX_BYTES 的巨型记录
try:
os.environ.setdefault('_DSH_SG_MISS', '1')
r0 = os.environ.get('CODEBUDDY_PROJECT_DIR') or os.environ.get('DSH_WORKSPACE') or ''
if r0:
log(r0, '未能解析(窗口 %d 字节仍无 assistant 记录)' % window)
except Exception:
pass
return ''
window = min(window * 4, MAX_BYTES)
def tail_lines(text, n=2):
out = [l.strip() for l in text.strip().split('\n') if l.strip()]
return '\n'.join(out[-n:])
# 转述/引用豁免:收尾行里带引号或"引用/规则/写着/禁"等词 ⇒ 是在复述规则,不是在问用户
RE_QUOTE = re.compile(r'[「」“”"\']|引用|规则|写着|禁')
RATE_WINDOW = 600 # 秒;同一会话两次「阻止停止」的最小间隔
def _rate_limited(root, sid, peek=False):
"""同一会话 RATE_WINDOW 秒内已拦过 ⇒ 本次直接放行(防连续多轮被拦)。"""
if not root or not sid:
return False
p = os.path.join(_norm_path(root), '.workbuddy', 'cache', 'stop-guard-fires.json')
try:
d = json.loads(io.open(p, encoding='utf-8').read()) if os.path.exists(p) else {}
except Exception:
d = {}
now = time.time()
if now - float(d.get(sid, 0) or 0) < RATE_WINDOW:
return True
d = {k: v for k, v in d.items() if now - float(v or 0) < 86400} # 只留 1 天
d[sid] = now
try:
os.makedirs(os.path.dirname(p), exist_ok=True)
io.open(p, 'w', encoding='utf-8', newline='\n').write(json.dumps(d))
except Exception:
pass
return False
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\\`,
内含本 hook 的 `stop-dialog-guard.log`(2496 B,最后写入 09-22 06:17)与
浏览器 `_devlogs/pud-f1/`(Chromium user-data-dir)—— 共 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(root, detail):
try:
p = os.path.join(_norm_path(root), LOG_REL)
os.makedirs(os.path.dirname(p), exist_ok=True)
with io.open(p, 'a', encoding='utf-8') as f:
f.write('%s\t%s\n' % (time.strftime('%Y-%m-%d %H:%M:%S'), detail))
lines = io.open(p, encoding='utf-8').read().split('\n') # 上限 300 行,超出截半(防膨胀)
if len(lines) > 300:
io.open(p, 'w', encoding='utf-8', newline='\n').write('\n'.join(lines[-150:]))
except Exception:
pass
def _entry_log(payload, raw_len):
"""⚠️ **每次被调用必留痕**(含"payload 解析失败 / 未进作用域 / 被急停"三种静默情形)。
2026-09-15 教训:原实现只在**通过全部守卫之后**才写日志 ⇒ 日志缺失时**无法区分**
「宿主根本没调用」与「调用了但被静默 return」—— 而这两者的处置**完全相反**
(前者要卸载、后者要放宽作用域判据)。凡"要判有没有被调用"的探针,必须**入口即留痕**。
"""
try:
p = payload if isinstance(payload, dict) else {}
tp = str(p.get('transcript_path') or '')
root = (os.environ.get('CODEBUDDY_PROJECT_DIR') or os.environ.get('DSH_WORKSPACE')
or p.get('cwd') or WS_FALLBACK)
log(str(root), 'entry|event=%s|cwd=%s|in_scope=%s|tp=%s|keys=%s|stdin_len=%s'
% (p.get('hook_event_name') or '(parse-fail)', p.get('cwd') or '-',
_in_scope(tp), (tp[-80:] if tp else '-'),
(','.join(sorted(p.keys()))[:120] or '-'), raw_len))
except Exception:
pass
def _read_stdin_text():
"""**显式按 UTF-8 读 stdin** —— 不要用 `sys.stdin.read()`。
⚠️ 2026-09-15 实测定位:本脚本的安装形态是 `python -S -E <脚本>`,而 **`-E` 会忽略
`PYTHONUTF8=1` / `PYTHONIOENCODING=utf-8`** ⇒ `sys.stdin.encoding` 回退成 **cp936**;
钩子 payload 里**必然含中文**(用户的提示词)⇒ 文本模式读取抛
`UnicodeDecodeError: 'gbk' codec can't decode byte 0x80` ⇒ **钩子静默不生效、日志为空**,
表象却是"宿主好像没调用钩子"(实为本地炸在解码上,白排查一轮)。
读 `buffer` 即与 flag / locale 完全无关。
"""
try:
return sys.stdin.buffer.read().decode('utf-8', 'replace')
except Exception:
try:
return sys.stdin.read()
except Exception:
return ''
def _emit(obj):
"""**显式按 UTF-8 写 stdout**(同理:cp936 下 `ensure_ascii=False` 的中文 / `⛔` 会 UnicodeEncodeError)。"""
data = json.dumps(obj, ensure_ascii=False).encode('utf-8')
try:
sys.stdout.buffer.write(data)
sys.stdout.buffer.flush()
except Exception: # 极端兜底:退回文本写(可能丢非 GBK 字符,但不至于静默不输出)
try:
sys.stdout.write(data.decode('utf-8', 'replace'))
sys.stdout.flush()
except Exception:
pass
# ─────────────────────────────────────────────────────────────
# 第二方案:`UserPromptSubmit`(2026-09-15 加)
# 背景:本版 WorkBuddy **不调用 `Stop` 钩子**(实测:留痕已开、探针句已验证会命中、日志仍空)⇒ 改用
# `UserPromptSubmit`(输入同样带 `transcript_path`,且能通过 `additionalContext` 注入上下文)。
# 两级模式(**改一个文本文件即可切换,无需重启** —— 脚本内容每次调用现读):
# probe :只写日志(零风险、可判定"有没有被调用")
# inject :若**上一轮回复的收尾是征询句** ⇒ 注入一段上下文,让下一轮自我纠正
# 模式文件:<工作区>/.workbuddy/stop-guard-mode (内容含 "inject" 即切到 inject,否则 probe)
CONTEXT = (
'⛔ 【上一轮收尾自检】你上一条回复的**最后一行是征询句**("要我…吗 / 要不要我 / 请确认 / 说一声即可"类),'
'这属于本平台**被禁的形态**(`CODEBUDDY.md §1`「回话前自检」)。本轮的处置:'
'① 若那件事本来就该你自己拍 —— **直接做完**,用陈述句交代;'
'② 若确实命中真门禁(不可逆破坏性操作 / 边界外六类)—— 写进 `dsh-feature-first §5.1` 结论骨架的'
'「**需要你拍板**」一节,该节必须是**整条回复的最后一节**、**逐条编号**,且**每个候选写明优点 / 缺点**、**候选竖排成段**(A / B / C 各占一行,⛔ 不横排、不做成表格的列)(陈述句,不要用征询句);'
'⚠️ 若某候选**只有优点或只有缺点** ⇒ **那不该问**,自己拍掉;'
'③ 顺带按红线 **R11** 复核:这个改动有没有让项目某一维度**净变差**。'
)
# ─────────────────────────────────────────────────────────────
# 会话预算(2026-09-15 用户选 A 案 · 提交 03c8363):到 **12 万 token / 200 次工具调用** ⇒ 提醒开新会话
# ⚠️ 2026-10-01 改值:**80 → 200 / 250**(§G 档 3)、**20 万 → 22 万**(§G 档 1),见下方常量块;本行已同步。
# ⚠️ 2026-09-16 更正:本行原写「~15 万 token / ~120 次工具调用」,与代码值(`BUDGET_TOKENS=120000` /
# `BUDGET_TOOLS=80`)**不符** —— 提交 `03c8363` 的 message 里就写的是 `120000/80`,代码从未用过 15 万/120
# ⇒ 判定为注释笔误,已按代码更正(避免后人据注释误判告警点)。
# 数值本身由 AI 在实现层自定(属 §1「性能与资源调参」= 边界内);**方案(A 案四招)才是用户 09-15 拍板的**。
# 为什么加进本脚本、而不新装一个 hook:`settings.json` 的 hooks 是**应用启动时快照**(新增条目要重启),
# 而**脚本内容每次调用现读** ⇒ 改这里即刻生效。数据源 = 转录 `type=function_call` → `message.usage.input_tokens`
# (最近一条 usage 即"当前上下文体量",精确,不靠估算)。
# 依据(实测某会话):上下文 5.2 万 → 59.2 万;累计 input 1.93 亿 / output 51.9 万(**371:1**);
# 其中 33 次缓存失效,每次都把 ~50 万 token **按全价**重算 ⇒ 会话越长,单次失效越贵。
# ★ 2026-09-16 改为**分级 + 去重**(原为单一阈值 120000/80,超了之后每轮都报 ⇒ 变成"狼来了")
# 设计要点:**同一级别只报一次** ⇒ 跨级才再提醒,既不麻木也不失警。
# ★ 2026-10-01 🔴 **与 `CODEBUDDY.md §G` 对齐**(本棒任务 C)—— 原 `BUDGET_TOOLS=80` / token `12万/20万/30万`
# 是 2026-09-15 的旧口径,与用户 2026-10-01 定则(§G 三条阈值)**不一致** ⇒ 本棒按 §G 改值。映射如下:
# §G 档 1(上下文):**120K** = 宿主告警点 ⇒ 本脚本**一级**
# (§G 原话「该点低于 220K ⇒ 只当**中途提示**,⛔ 不必交接」)
# **220K** = §G 的**交接触发点** ⇒ 本脚本**二级**(≥22 万即按 §G「触发后四步」办)
# §G 档 3(调用次数):**软 200 次** ⇒ 本脚本**一级**(§G 原话「开始收尾」)
# **硬 250 次** ⇒ 本脚本**三级**(§G 原话「停手建接续会话」)
# §G 档 2(诊断日志 软 5 / 硬 8 MiB)**不归本脚本** —— 同宿主同 env,由 `session-log-guard.py` 管。
# ⚠️ `BUDGET_FORCE=300000` **§G 未定义**,仅作"更晚的兜底"保留 ⇒ ⛔ 不代表门槛已变、⛔ 别拿它当 §G 档位。
BUDGET_TOKENS = 120000 # 一级:轻提示 —— §G「中途提示」点(宿主 120K 告警;⛔ 不必交接)
BUDGET_STRONG = 220000 # 二级:建议收口 —— 🔴 §G 档 1 触发点(2026-10-01 由 200000 改 220000)
BUDGET_FORCE = 300000 # 三级:**强制收口**(先落盘、出接续包,再开新会话)· §G 未定义,兜底保留
BUDGET_TOOLS = 200 # 一级:🔴 §G 档 3 **软档**(2026-10-01 由 80 改 200)
BUDGET_TOOLS_HARD = 250 # 三级:🔴 §G 档 3 **硬档**(停手建接续会话)
ALERT_LEVEL_REL = os.path.join('.workbuddy', '.budget-alert-level')
# 为什么三级不是"自动开新会话"(2026-09-16 与用户讨论后定):
# ① **技术上做不到** —— hook 只有 `additionalContext`(注入)与 `permissionDecision`(拦工具)两种输出,
# 事件只有 SessionStart / PreToolUse / UserPromptSubmit / Stop,**没有"创建/切换会话"的能力**;
# AI 自身也只能在会话内行动。⇒ "自动开"这一半无法实现。
# ② **设计上不该做** —— 新会话 = 上下文清零 ⇒ **在途状态全丢**;而"该带走什么"只有 AI 判断得了
# ⇒ 顺序必须是 **先收口(落盘 + 接续包)→ 再由用户开新会话**,反了就是"突然失忆"。
# ③ 因此本级**不做"自动开"**,做**"强制收口"**:把状态固化成文档,让下一个会话能无损接上。
LV_PREFIX = {
1: '',
2: '⚠️ 上下文已过 **22 万** —— 🔴 这正是 `CODEBUDDY.md §G` 档 1 的**交接触发点** ⇒ **按 §G「触发后四步」办**'
'(先落盘交接材料 → 开接续会话 → 在回复里告知用户)。\n'
' ⚠️ 本轮若你**登记了自动接续(automation)/ 要开新会话** ⇒ **必须在给用户的回复里用陈述句说明**(别悄悄做掉)。\n',
3: ('🔴 **已到硬档 ⇒ 进入强制收口模式**(`CODEBUDDY.md §G`:工具调用 ≥250 次 / 上下文 ≥30 万)'
'——先落盘、再接续:\n'
' ① 把在途状态写进 `.workbuddy/memory/`(今日日志 + 必要的 MEMORY.md 条目);\n'
' ② 产出**接续包**:目标 / 已完成 / 在途 / 未完成 / 下一步 / 关键决定 / 回滚点'
'(模板见工作区根 `会话接续规范_20260916.md §3.1.1`);\n'
' ③ **把接续登记成一次性 automation**(调 `automation_update`,+2 分钟触发)'
'—— 这是"自动接续"唯一可用的通道(钩子不能建会话、不能建自动化);\n'
' prompt **照 `会话接续规范 §3.2.1` 模板**:⓪ 先跑 `state.py` + 接续包路径 + 开机四步 + 工具调用上限;\n'
' ⛔ prompt 里**不许复制任务细节**("细节的唯一来源是交接单/接续包";实测:抄细节的 prompt 会挤掉"开机四步",'
'那轮跑了 40 次调用 / 9.37 分);\n'
' 🔴 **prompt 必须带「接续包 md5」**(`md5sum <接续包路径>`)—— 新会话开工前会重算校验,'
'不符即停、报告「口径已更新,需重新接续」(2026-09-16 实测:登记 17:01 → 触发 17:03,'
'而原会话一路工作到 18:21、17:2x–17:29 还在改判断 ⇒ 新旧两张皮);\n'
' 🔴 **登记后若你还要改接续包 / 改关键判断 ⇒ 回来撤销或重登记**这条 automation ——'
'⛔ 不重登记 = 下一棒按旧口径开工且无从知道;\n'
' ④ ★**必做 · 告知用户**:在**给用户的最后一条回复里**(用陈述句,不是征询句)写明这次自动接续 —— '
'例如「已登记自动接续:约 N 分钟后自动开新会话继续(**不需要你操作**);接续点 = `<文件>`;'
'若想自己开,口令 = `<state.py 口令>`」。\n'
' ⛔ **这不是可选项**:新建会话 / 新建自动化是**用户可感知的状态变更**'
'(提问闸门 A 类原话:「AI 会不会悄悄改他的设置」)—— 悄悄做掉不告知,'
'用户会在会话列表里凭空看见多出一个会话而不知何来。\n'
' 📌 2026-09-16 用户实测反馈:「**他在最后一个回复结尾没说这个事,导致我不知道**」⇒ 本条即由此而来。\n'
' ⑤ ③ 若做不成(工具不可用等)⇒ 兜底同样要告知:「请开新会话,接续点在 X」,**由用户开**。\n'
' ⛔ 不要在本会话继续开新任务 —— 每多跑一轮,成本按当前水位线性放大。\n'),
}
def _alert_level(tokens, ncalls):
"""0=未超 1=轻 2=建议收口 3=强制收口(🔴 2026-10-01 与 `CODEBUDDY.md §G` 对齐,映射见文件头常量注释)
§G 的"硬"侧(调用 ≥250 / 兜底 token ≥30 万)⇒ **三级**;§G 档 1 交接触发点(≥22 万)⇒ **二级**;
§G 的"软"侧(调用 ≥200)与 120K 中途提示 ⇒ **一级**。
⚠️ 两级分别来自**两把不同的尺**(token / 调用次数)⇒ 取**较高**者,⛔ 不是相加。
"""
t = tokens or 0
if t >= BUDGET_FORCE or (ncalls is not None and ncalls >= BUDGET_TOOLS_HARD):
return 3
if t >= BUDGET_STRONG:
return 2
if t >= BUDGET_TOKENS or (ncalls is not None and ncalls >= BUDGET_TOOLS):
return 1
return 0
def _last_level(root, sid):
"""读「本会话」已报到的级别。**按 sid 区分** —— 否则新会话会被上一会话的状态挡住 ⇒ **漏报**。"""
try:
with io.open(os.path.join(_norm_path(root), ALERT_LEVEL_REL), encoding='utf-8') as f:
s = (f.read() or '').strip()
ps = s.split('\t')
if len(ps) == 2 and ps[0] == sid:
return int(ps[1] or 0)
except Exception:
pass
return 0
def _set_level(root, sid, lv):
try:
p = os.path.join(_norm_path(root), ALERT_LEVEL_REL)
os.makedirs(os.path.dirname(p), exist_ok=True)
with io.open(p, 'w', encoding='utf-8', newline='\n') as f:
f.write('%s\t%d' % (sid, lv))
except Exception:
pass
def session_budget(path):
"""返回 (当前上下文 token, 工具调用累计次数, 上一次上下文 token);读不到返回三元 None。
🔴🔴 2026-10-06 修 **本函数返回元组长度不一致**(真缺陷 · 已实测坐实):
原先**两条早退路径只返回 2 值**(transcript > 64 MiB / `getsize` 抛 OSError),
而末尾两条返回 **3 值**、调用方 `toks, ncalls, prev = session_budget(tp)`(第 611 行)
也按 **3 值**解包 ⇒ 只要 transcript **超 64 MiB**、或文件不在,
**每轮必抛 `ValueError: not enough values to unpack (expected 3, got 2)`**。
后果(比报错难查得多):本钩子是 **fail-open**(异常仍 `sys.exit(0)`)
⇒ 宿主侧**看不到任何异常**,`install.py --verify` 只判 `rc=0` ⇒ 判它 **"ok"(假绿)**;
而实际失效的是**这一整条**:水位与收口 / 接续机制起点 / 预算告警 / 门禁自检 / 路径自检。
证据:`<WS>/.workbuddy/stop-dialog-guard.log` **86 条 EXCEPTION**(首条 2026-10-06 05:09:26),
且**每轮复现**——因为本会话 transcript 已达 **192.8 MB**(>64 MiB 阈值)⇒ 命中的正是第 437 行那条早退。
⇒ 判据:**同一函数的所有 return 必须同长**;“防护性早退”最容易漏掉这一条。
"""
try:
if os.path.getsize(path) > 64 * 1024 * 1024:
return None, None, None
except OSError:
return None, None, None
last_in, n_calls, prev_in = None, 0, None
try:
with io.open(path, encoding='utf-8', errors='replace') as f:
for ln in f:
if '"function_call"' not in ln:
continue
n_calls += 1
if '"usage"' not in ln:
continue
try:
o = json.loads(ln)
except ValueError:
continue
if o.get('type') != 'function_call':
continue
u = ((o.get('message') or {}).get('usage')) or {}
v = u.get('input_tokens')
if v:
prev_in, last_in = last_in, int(v)
except OSError:
return None, None, None
return last_in, n_calls, prev_in
def budget_note(tokens, ncalls, prev=None, root='', sid=''):
"""分级提醒;**同一级别只注入一次**(跨级才再报)—— 治"超阈值后每轮都报"导致的麻木。
未超阈值时,单轮增量异常(≥4 万 token)仍提醒。"""
# ★ 分级 + 去重(2026-09-16):本级已报过 ⇒ 静默(**按会话 id 区分**,新会话重新计数)
lv = _alert_level(tokens, ncalls)
if lv and lv <= _last_level(root, sid):
return ''
if lv:
_set_level(root, sid, lv)
over_t = tokens is not None and tokens >= BUDGET_TOKENS
over_n = ncalls is not None and ncalls >= BUDGET_TOOLS
grew = (tokens is not None and prev is not None and tokens - prev >= 40000) if not (over_t or over_n) else False
if not (over_t or over_n or grew):
return ''
which = ('上下文 %s token' % tokens) if over_t else (
'工具调用 %s 次' % ncalls) if over_n else ('**上一轮新增 %s token**' % (tokens - prev))
return (
LV_PREFIX.get(lv, '') +
'💰 【会话预算告警】本会话已到 **%s**(一级阈值 %s token / %s 次工具调用)。'
'代价机制(2026-09-15 实测):对话历史是**追加式**的 —— 一次工具调用的输出(`function_call_result`)'
'**永久留在历史里、每轮全量重发**(某会话 553 轮 × 平均 42 万 = **2.33 亿 input**,output 仅 0.35%%)。'
'**处置:本轮收口后开新会话**(新会话起点约 5 万 ⇒ 每轮降到 1/12);'
'并把该记的状态写进记忆 + 按 `dsh-workflow` 的交付门禁落交接'
'(`references/00-平台改造六阶段.md` 阶段 5 §0 —— 原技能名 `dsh-change-workflow` 已于 2026-09-28 并入 `dsh-workflow`)。'
'⚠️ 压增长的三条硬纪律:**❶ 大输出先落盘、只读关键行**(`> /tmp/x.txt` 后 `sed -n`);'
'**❷ 命令层限流**(`| head -30` / `| cut -c1-120` / `grep -c` 代替 `grep`);'
'**❸ 让脚本内部聚合、只 print 摘要** —— ⛔ 禁 `cat` 大文件、无 `head` 的 `grep -r`、`ls -laR`。'
% (which, BUDGET_TOKENS, BUDGET_TOOLS)
)
_FRESH_SEC = 24 * 3600
def _fresh(line, sec=_FRESH_SEC):
"""行首时间戳在 sec 秒内 ⇒ True —— **陈旧日志不许当"最近"**。
2026-09-30 实测:`guard_health()` 原按"最后 40 条 DENY"统计、**不看时间** ⇒
拿 4 天前(09-26,且指向已改名的旧工作区 `aliyun-dsh-server`)的 4 条 DENY,
每轮都报「最近 4 次 Bash 里有 4 次被拦」—— 把「闸门已停用」伪装成「闸门在乱拦」。
取不到时间 ⇒ False(宁可不报,也不吓人)。
"""
try:
t = time.mktime(time.strptime(line[:19], '%Y-%m-%d %H:%M:%S'))
except Exception:
return False
return (time.time() - t) <= sec
def guard_health(root):
"""**自动发现并上报**:读 `bash-guard.log`,同一规则重复命中 ≥3 次 ⇒ 提示"可能误伤"。
为什么要有:门禁自己不会喊疼 —— 只有"写日志"没人看。让每轮都跑的脚本顺带体检,
机制问题才能在**下一次用户发言时**浮出来(而不是等人发现"AI 怎么老做不成事")。
"""
try:
p = os.path.join(_norm_path(root), '.workbuddy', 'bash-guard.log')
rows = [l for l in io.open(p, encoding='utf-8').read().split('\n') if 'DENY|' in l][-40:]
rows = [l for l in rows if _fresh(l)] # 🔴 只认近 24h(陈旧日志 ⇒ 假警报)
except OSError:
return ''
if len(rows) < 3:
return ''
cnt = {}
for l in rows:
try:
k = l.split('DENY|')[1].split('|')[0].strip()
except IndexError:
continue
cnt[k] = cnt.get(k, 0) + 1
if not cnt:
return ''
why, n = max(cnt.items(), key=lambda kv: kv[1])
if n < 3:
return ''
return ('⚠️【门禁自检】最近 %d 次 Bash 里有 **%d 次**因「%s」被拦(同一规则重复命中)⇒ 先怀疑**误伤**,'
'不是谁的操作有问题。处置:① 换等价限流写法;② 若确认误伤 ⇒ 把 `off` 写进 '
'`.workbuddy/bash-guard-mode`(或 env `DSH_OUTPUT_GUARD_OFF=1`),并**主动上报用户**'
'(规则该不该收窄是人的决定)。' % (len(rows), n, why))
def path_health(root):
"""**自动发现并上报**:hooks 指向的脚本、文档库位置,是否还在。
来历(2026-09-15 实测事故):文档库被整目录搬到 `_中间产物_待清理/` ⇒ 宿主 hooks 仍指向旧位置 ⇒
**锁闸门静默失效**;当时唯一线索是"`lock-hook.log` 今天 0 条 PreToolUse",而**没人会主动去数**。
⇒ 让每轮都跑的脚本顺带体检,机制失效能在**下一次用户发言时**自己浮出来。
判据(任一命中即报):① **从 `settings.json` 现读** hooks 的 command,抽出其中的 `.py` 逐个 `os.path.exists`
(配置里是权威指向 ⇒ 能发现"指向了不存在的文件");② 文档库在两处候选位置**都不存在**。
取不到配置 ⇒ 跳过该项(**fail-open**,绝不因体检本身误报)。
"""
bad = []
# 🔴 2026-10-02:共用 `_env.config_dir()` ——⛔ 不再用 `~` 直接拼
# (Windows 上 `~` 通常不是真配置目录;真值走 env → roots.env)。
cfg = os.path.join(_env.config_dir(), 'settings.json')
seen = 0
try:
d = json.load(io.open(cfg, encoding='utf-8'))
for ev, arr in (d.get('hooks') or {}).items():
for blk in (arr or []):
for h in ((blk or {}).get('hooks') or []):
c = h.get('command') or ''
if c:
seen += 1
for m in re.finditer(r'([A-Za-z]:[\\/][^"\']*?\.py)', c):
p = m.group(1)
if not os.path.exists(p):
bad.append('hooks「%s」指向的 `%s` 不存在' % (ev, os.path.basename(p)))
except Exception:
return '' # 读不到配置 ⇒ fail-open,绝不因体检本身误报
if seen == 0:
bad.append('hooks 配置里**没有任何 command 条目** ⇒ 钩子可能被清空 / 被整段覆盖')
if not bad:
return ''
uniq = []
for x in bad:
if x not in uniq:
uniq.append(x)
return ('🚨【路径自检】%s ⇒ **机制可能已静默失效**。处置:**立刻上报用户**,并核对 `settings.json` 的 '
'hooks 路径与文档库当前位置(2026-09-15 同类事故:文档库被整目录搬走,锁闸门失效一整天无人察觉)。'
% ';'.join(uniq[:3]))
def mode_of(root):
try:
m = io.open(os.path.join(root or '.', '.workbuddy', 'stop-guard-mode'), encoding='utf-8').read()
except OSError:
m = ''
return 'inject' if 'inject' in m else 'probe'
def user_prompt_mode(payload):
"""UserPromptSubmit:probe=只记日志;inject=命中则注入上下文(不阻断提示词)。"""
tp = str(payload.get('transcript_path') or '')
if not _in_scope(tp):
return
if os.environ.get('DSH_STOP_GUARD_OFF'):
return
root0 = os.environ.get('CODEBUDDY_PROJECT_DIR') or os.environ.get('DSH_WORKSPACE') or ''
if root0 and os.path.exists(os.path.join(_norm_path(root0 or '.'), '.workbuddy', 'stop-guard.disabled')):
return
mode = mode_of(root0)
text = transcribe_last_assistant(tp)
tail = tail_lines(text, 1) if text else ''
hit = bool(tail) and not RE_QUOTE.search(tail) and bool(RE_BAN.search(tail))
toks, ncalls, prev = session_budget(tp)
note = budget_note(toks, ncalls, prev, root0 or '.', str(payload.get('session_id') or ''))
gh = guard_health(root0 or '.')
ph = path_health(root0 or '.')
log(root0 or '.', 'invoked(user-prompt)|mode=%s|上轮收尾=征询句:%s|上下文=%s tok(+%s)|工具=%s 次|预算告警=%s|门禁自检=%s|路径自检=%s|%s'
% (mode, hit, toks, (toks - prev) if (toks and prev) else '-', ncalls, bool(note), bool(gh), bool(ph),
(tail.replace('\n', ' ')[:60] if tail else '(取不到上一轮文本)')))
# ⚠️ 2026-09-15 用户选 C:**取消**「会话预算」注入 —— 当时的症结是"每轮都报、太吵"。
# ★ 2026-09-16 恢复**分级注入**:`budget_note` 已改为「分级 + 同会话跨级才报一次」(见其 docstring),
# 噪音症结已消解;且用户当日提出「上下文超 30 万要不要自动收口」⇒
# **AI 必须先知道水位才谈得上收口** ⇒ 恢复注入(一级静默无关、二级建议、三级强制收口)。
if mode == 'inject' and (hit or gh or ph or note):
ctx = ''
for seg in (CONTEXT if hit else '', note, gh, ph):
if seg:
ctx += ('\n\n' + seg) if ctx else seg
_emit({'hookSpecificOutput': {'hookEventName': 'UserPromptSubmit',
'additionalContext': ctx}})
def main():
raw = _read_stdin_text() # ⚠️ 必须走 buffer:`-E` 下 sys.stdin 是 cp936(见 _read_stdin_text 注释)
payload = None
if raw.strip():
try:
payload = json.loads(raw)
except ValueError:
payload = None
_entry_log(payload, len(raw)) # ⚠️ 先留痕,再判作用域(否则"没被调用"与"静默失配"分不开)
if payload is None:
return
if (payload.get('hook_event_name') or '') == 'UserPromptSubmit': # 第二方案分派
return user_prompt_mode(payload)
tp = str(payload.get('transcript_path') or '')
if not _in_scope(tp): # 作用域外 → 放行
return
if os.environ.get('DSH_STOP_GUARD_OFF'): # 急停(环境变量)→ 放行
return
root0 = os.environ.get('CODEBUDDY_PROJECT_DIR') or os.environ.get('DSH_WORKSPACE') or ''
if root0 and os.path.exists(os.path.join(_norm_path(root0 or '.'), '.workbuddy', 'stop-guard.disabled')):
return # 急停(闸刀文件)→ 放行
if payload.get('stop_hook_active'): # 防死循环 → 放行
return
text = transcribe_last_assistant(tp)
if not text:
return
sid = str(payload.get('session_id') or '')
if os.environ.get('DSH_SG_DEBUG'): # 调试:每次调用都留痕(用于验证宿主是否真的调用本钩子)
log(root0 or '.', 'invoked|scope=%s|tail_active=%s' % (_in_scope(tp), bool(payload.get('stop_hook_active'))))
if _rate_limited(root0, sid, peek=True): # 只查不记账
return
if os.environ.get('DSH_SG_LOG_ALL', '1') != '0': # ★本工作区内**每次调用都留痕**(可判定"有没有被调用")
log(root0 or '.', 'invoked|tail_active=%s' % bool(payload.get('stop_hook_active')))
tail = tail_lines(text, 1) # 只看**最后一行**:命中面越窄,误报越少
if RE_QUOTE.search(tail): # 复述/引用规则 → 不是收尾提问
return
m = RE_BAN.search(tail)
if not m:
return
root = (os.environ.get('CODEBUDDY_PROJECT_DIR') or os.environ.get('DSH_WORKSPACE') or '')
_rate_limited(root0, sid) # 命中才记账(同一会话 10 分钟最多拦 1 次)
log(root if os.path.isdir(root) else '.', 'stop-dialog-guard 命中:%s | 收尾:%s'
% (m.group(0), tail.replace('\n', ' ')[:80]))
_emit({'continue': False, 'reason': REASON})
if __name__ == '__main__':
try:
main()
except Exception:
# ⚠️ 钩子绝不能因自身故障干扰会话 ⇒ 仍放行,但**必须留痕**(A18:静默失败是负债;
# 2026-09-15 实证:本文件的 `except: pass` 曾把 `NameError: out is not defined` 藏住半小时)
try:
import traceback
_r = os.environ.get('CODEBUDDY_PROJECT_DIR') or os.environ.get('DSH_WORKSPACE') or '.'
log(_r, 'EXCEPTION|%s' % traceback.format_exc().strip().split('\n')[-1][:120])
except Exception:
pass
sys.exit(0)