一、起因(用户令逐字) 「重点问题是 会话接收到 调用执行会话完成目标 没有去调用对应技能去执行」。 实测会话 b232218f(vibe-product):用户 01:32:00 / 01:33:46 两次说触发句, 到 01:36:27 才建出排期 —— 中间那一轮 AI 只在回复里解释口径,没拉起执行会话。 根因:机制里**只有 "注入提醒"**,⛔ 没有任何机器抓手检查"到底做没做"。 二、改法(发现 → 拦住,两步) 1. scripts/hooks/skill-load-guard.py:命中**派活族**触发句时写一条待办 <工作区>/.workbuddy/.dispatch-pending(会话 id / 时间戳 / 命中词)。 ⚠️ 写在**冷却判定之前** —— 实测 01:33:46 那条最明确的触发句正被 300 s 冷却吞掉, 写在冷却之后就等于"最该拦的那一条反而没有标记"。 ⚠️ 比对前**去掉空白**:用户写的是「应该使用 执行会话的技能去完成工作」(中间有空格)。 ⛔ 不把裸词「执行会话」算进派活族(太宽,讨论机制时也出现)⇒ 误报面保持最小。 2. scripts/hooks/stop-dialog-guard.py:新增收工前闸 —— 同会话 + 超 90 s 宽限 + 宿主库该区此后**没有**新建排期 ⇒ 返回 {"continue": false} 拦一次,逼它先建排期。 ⚠️ 必须放在**自作用域判定之前**(那条作用域只认本工作区,而栽的会话在 vibe-product)。 ⛔ 三条安全底线:① 全程 fail-open(读不到库/解析失败/任何异常 ⇒ 一律放行); ② 只拦一次(复用本文件 10 分钟限流);③ 待办 30 分钟过期,⛔ 不长期纠缠。 三、判据(新增自测用例,5 项) selftest.py::t_dispatch_gate —— 派活句留待办(含带空格写法)/非派活句不留/ 超宽限且未建排期则拦/宽限内放行。三条断言都确定性,⛔ 不依赖现网存量。 验收:selftest.py rc=0 PASS 100 / FAIL 0(改前 99); 两组外部变异对照(发现侧 3 项、收工闸 4 项)全过; 另实测"10 分钟限流"有效(同一会话重复构造不再重复拦)。
777 lines
45 KiB
Python
777 lines
45 KiB
Python
#!/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}})
|
||
|
||
|
||
# ── 🔴🔴 2026-10-07 「派活没收工」闸 ──────────────────────────────────────────
|
||
# 起因(用户令逐字):「**重点问题是 会话接收到 调用执行会话完成目标 没有去调用对应技能去执行**」。
|
||
# 实测(会话 `b232218f`,vibe-product):用户 01:32:00 / 01:33:46 两次说了触发句,
|
||
# 直到 01:36:27 才建出排期 —— 中间那一轮 AI 只在回复里解释口径,**没拉起执行会话**。
|
||
# 形态:`skill-load-guard.py` 命中**派活族**时写 `<ws>/.workbuddy/.dispatch-pending`
|
||
# (会话 id / 时间戳 / 命中词),本闸在**收工前**核对一次 ——
|
||
# **同会话 + 未过期 + 宿主库里没有该区在标记之后新建的排期** ⇒ 拦一次,逼它把会话拉起来。
|
||
# ⛔ 三条安全底线(一条都不许省):
|
||
# ① **全程 fail-open** —— 读不到库 / 解析失败 / 任何异常 ⇒ 一律放行(⛔ 钩子自身故障不许挡住用户);
|
||
# ② **只拦一次** —— 复用本文件的 10 分钟限流(`_rate_limited`);
|
||
# ③ **待办 30 分钟过期** ⇒ ⛔ 不长期纠缠。
|
||
DISPATCH_REL = os.path.join('.workbuddy', '.dispatch-pending')
|
||
DISPATCH_GRACE = 90 # 命中后给 90 秒去建排期(⛔ 不拦"刚说完、还没轮到")
|
||
DISPATCH_EXPIRE = 1800 # 待办最长存活 30 分钟
|
||
DISPATCH_REASON = (
|
||
'⛔ **收工前自检:你收到的是「派活」触发句,但这一轮没有建任何排期。**\n'
|
||
'用户说的是「使用执行会话完成目标 / 调用执行会话完成目标」—— **这句话本身就是授权**,\n'
|
||
'要的动作是**把执行会话拉起来**(建排期),⛔ 不是在线程里自己代做、也不是回一句解释。\n'
|
||
'请先把排期建出来(`once` 或 `recurring`,prompt 自包含、运行时不问用户),再收工;'
|
||
'确实建不了,就在回复里写明卡在哪一步。'
|
||
)
|
||
|
||
|
||
def _dispatch_gate(payload):
|
||
"""见上面那段注释。返回 True = 已发出阻拦 ⇒ 调用方**直接 return**。"""
|
||
try:
|
||
ws = str(payload.get('cwd') or '') or \
|
||
(os.environ.get('CODEBUDDY_PROJECT_DIR') or os.environ.get('DSH_WORKSPACE') or '')
|
||
if not ws:
|
||
return False
|
||
p = os.path.join(_norm_path(ws), DISPATCH_REL)
|
||
if not os.path.isfile(p):
|
||
return False
|
||
parts = (io.open(p, encoding='utf-8').read().strip().split('\t') + ['', '', ''])[:3]
|
||
sid_m, ts_s, hits = parts
|
||
sid_now = str(payload.get('session_id') or '')
|
||
if not sid_now or sid_m != sid_now: # ⛔ 不是本会话的待办 ⇒ 不碰
|
||
return False
|
||
ts = float(ts_s or 0)
|
||
age = time.time() - ts
|
||
if age > DISPATCH_EXPIRE: # 过期 ⇒ 作废,⛔ 不纠缠
|
||
os.remove(p)
|
||
return False
|
||
if age < DISPATCH_GRACE: # 刚说完、还没轮到 ⇒ 放行
|
||
return False
|
||
import sqlite3
|
||
cfg = (os.environ.get('CODEBUDDY_CONFIG_DIR')
|
||
or os.path.join(os.path.expanduser('~'), '.workbuddy'))
|
||
con = sqlite3.connect('file:%s?mode=ro' % os.path.join(cfg, 'workbuddy.db').replace('\\', '/'),
|
||
uri=True, timeout=5)
|
||
n = con.execute('select count(*) from automations where created_at > ? and cwds like ?',
|
||
(int(ts * 1000), '%' + _norm_path(ws).replace('\\', '/') + '%')).fetchone()[0]
|
||
con.close()
|
||
if n: # 已经建过排期 ⇒ 待办销账
|
||
os.remove(p)
|
||
return False
|
||
except Exception:
|
||
return False # ⛔ fail-open
|
||
root = _norm_path(ws)
|
||
if _rate_limited(root, sid_now): # 同一会话 10 分钟最多拦 1 次
|
||
return False
|
||
log(root, 'dispatch-gate 命中:触发句已说但未建排期(hits=%s|待办 %.0fs)' % (hits, age))
|
||
_emit({'continue': False, 'reason': DISPATCH_REASON})
|
||
return True
|
||
|
||
|
||
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 '')
|
||
# 🔴🔴 2026-10-07 「派活没收工」闸 —— **必须放在自作用域判定之前**:
|
||
# 下面那条作用域只认本工作区,而实测栽的那条会话在 `vibe-product` ⇒ 放在后面就永远拦不到。
|
||
# ⛔ 它只在"本工作区确实有本会话的派活待办"时才可能生效,其余情形一次都不触发。
|
||
if _dispatch_gate(payload):
|
||
return
|
||
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)
|