feat(cost): 路径自检 —— hooks 指向失效 / 配置被清空时自动上报(用户选 A)

**来历(2026-09-15 实测事故)**:文档库被整目录搬到 `_中间产物_待清理/` ⇒ 宿主 hooks 仍指向旧位置
⇒ **锁闸门静默失效**;当时唯一线索是"`lock-hook.log` 今天 0 条 PreToolUse",而**没人会主动去数**。

`stop-dialog-guard.py` 新增 `path_health()`(每轮随 `UserPromptSubmit` 跑):
1. **从 `settings.json` 现读** hooks 的 command ⇒ 抽出其中的 `.py` 逐个 `os.path.exists`
   (配置里是权威指向 ⇒ 能发现"指向了不存在的文件",正是今晚那种形态)
2. **hooks 里没有任何 command 条目** ⇒ 报"可能被清空 / 被整段覆盖"(用户记忆里的真实风险)
命中即经 `UserPromptSubmit` 注入「🚨【路径自检】…请立刻上报用户」。
**fail-open**:读不到配置就直接跳过,绝不因体检本身误报。

⚠️ 设计取舍(实测发现):**没有**去做"文档库是否在预期位置"的检查 —— 新位置在**另一个盘**
(`D:/github/dsh_shenxian`),硬编码候选路径会**误报**;而检查 ① 已能覆盖同一类事故。
实测三种情形:真实环境**不报** ✓ | 指向不存在脚本**报** ✓ | hooks 被清空**报** ✓
This commit is contained in:
admin committed 2026-09-15 22:06:36 +08:00
1 parent fff2519ad3
commit c25609c4c1
1 file changed
+47 -4
+47 -4
View File
@@ -339,6 +339,48 @@ def guard_health(root):
'(规则该不该收窄是人的决定)。' % (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 = []
cfg = os.path.join(os.environ.get('WORKBUDDY_CONFIG_DIR')
or os.path.join(os.path.expanduser('~'), '.workbuddy'), '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()
@@ -364,12 +406,13 @@ def user_prompt_mode(payload):
toks, ncalls, prev = session_budget(tp)
note = budget_note(toks, ncalls, prev)
gh = guard_health(root0 or '.')
log(root0 or '.', 'invoked(user-prompt)|mode=%s|上轮收尾=征询句:%s|上下文=%s tok(+%s)|工具=%s 次|预算告警=%s|门禁自检=%s|%s'
% (mode, hit, toks, (toks - prev) if (toks and prev) else '-', ncalls, bool(note), bool(gh),
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 '(取不到上一轮文本)')))
if mode == 'inject' and (hit or note or gh):
if mode == 'inject' and (hit or note or gh or ph):
ctx = ''
for seg in (CONTEXT if hit else '', note, gh):
for seg in (CONTEXT if hit else '', note, gh, ph):
if seg:
ctx += ('\n\n' + seg) if ctx else seg
_emit({'hookSpecificOutput': {'hookEventName': 'UserPromptSubmit',