Files
dsh_ai1net_server/dsh-server-docs/scripts/skill-load-guard.py
T

194 lines
8.0 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 -*-
"""skill-load-guard.py —— 「用户点名了方法 ⇒ 强制加载技能」的 UserPromptSubmit 钩子
为什么需要它
────────────
2026-09-16 复盘会话 `ddea70b7`(「确认guest用户数据迁移到106服务器」):
用户 U6 明确说「D1–D6 先做哪些 **按照你的规划执行**,中间有问题**参考决策方法**」,
但该会话 265 次工具调用里 **`Skill` 计数 = 0** —— 那份方法论**从未进入上下文**。
AI 仍按常驻判据把两个**非门禁**问题("106 旧控制面要不要停" / "D1–D6 先做哪些")上抛给用户,
而用户随后 45 秒 / 58 秒内自己给出了答案(Q1 的答案还超出了 AI 给的选项空间)。
⇒ 与 `stop-dialog-guard.py` 同族问题:**规则写了,但没有任何机制保证它在正确的时机被取用**。
(那个钩子处理"回复收尾时的征询句",本钩子处理"用户点名时的方法加载",两者互补。)
本钩子 = 覆盖"用户点名"这条面:**每次用户提交时**扫输入,命中点名关键词 ⇒
通过 `additionalContext` 注入一条**紧邻用户消息**的强制指令
(位置比静态规则文件显著得多 —— 长会话里 CODEBUDDY.md 的硬要求容易被"稀释")。
安全设计(都不许省)
────────────────────
1. **自作用域**:只在 cwd 落在本工作区(`aliyun-dsh-server`)时生效,其他项目一律放行。
2. **绝不添乱**:任何异常 → 静默放行(exit 0);本钩子**从不拦用户**,只在命中时**追加一段提醒**。
3. **防跑飞**:同一会话 300 秒内最多注入一次。
4. **急停双闸**(无需卸载/重启):env `DSH_SKILL_GUARD_OFF=1`,或 `<工作区>/.workbuddy/skill-guard.disabled`。
5. **低频自证日志**:命中才写一行到 `<工作区>/.workbuddy/skill-load-guard.log` —— 用来回答"到底有没有触发"。
6. **显式 UTF-8**:读写都走 `buffer`,不依赖 `PYTHONUTF8`(`-E` 会屏蔽它 ⇒ cp936 ⇒ 含中文静默炸)。
退出码:始终 0;决策通过 stdout 的 JSON 表达。
安装(`settings.json` 的 hooks 段 · 与既有钩子同族,**并列挂在同一个 UserPromptSubmit 的 hooks 数组里**):
"UserPromptSubmit": [{ "hooks": [
{ "type": "command", "command": "\"<python>\" \"<此脚本>\"", "timeout": 10 },
{ "type": "command", "command": "\"<python>\" -S -E \"<stop-dialog-guard.py>\"", "timeout": 10 }
]}]
⚠️ hooks 是**应用启动时快照** ⇒ 装完必须**完全重启 WorkBuddy**(关窗 ≠ 退出)才加载。
"""
import io
import json
import os
import re
import sys
import time
SCOPE = 'aliyun-dsh-server' # 只对本工作区生效
LOG_REL = os.path.join('.workbuddy', 'skill-load-guard.log')
STATE_REL = os.path.join('.workbuddy', '.skill-load-guard.state')
DISABLE_REL = os.path.join('.workbuddy', 'skill-guard.disabled')
COOLDOWN = 300 # 同会话注入冷却(秒)
# 用户「点名方法」的词表 —— 命中任一即注入。
# 「决策方法」是最稳的一条(覆盖"参考/按/用决策方法")。
TRIGGERS = (
'决策方法',
'自行决策', '自主决策', '自己决策', '自己拿主意',
'别问我', '不要问我', '不用问我', '按你的规划', '按你的判断',
'参考决策', '决策方法论',
)
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 _workdir(obj):
for k in ('cwd', 'project_dir', 'workspace', 'projectDir'):
v = obj.get(k)
if isinstance(v, str) and v:
return v
for k in ('CODEBUDDY_PROJECT_DIR', 'WORKSPACE_FOLDER', 'PWD'):
v = os.environ.get(k)
if v:
return v
# 兜底:本脚本位于 <工作区>/dsh-server-docs/scripts/
try:
return os.path.abspath(os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', '..'))
except Exception:
return ''
def _log(workdir, line):
try:
p = os.path.join(workdir, LOG_REL)
with io.open(p, '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 _cooling(workdir, sid):
"""同一会话 COOLDOWN 秒内不重复注入。"""
try:
p = os.path.join(workdir, STATE_REL)
now = time.time()
last_sid, last_ts = '', 0.0
if os.path.exists(p):
try:
with io.open(p, 'r', encoding='utf-8') as f:
parts = f.read().strip().split('\t')
last_sid, last_ts = parts[0], float(parts[1] or 0)
except Exception:
pass
if last_sid == sid and (now - last_ts) < COOLDOWN:
return True
with io.open(p, 'w', encoding='utf-8', newline='\n') as f:
f.write('%s\t%.3f\n' % (sid, now))
except Exception:
pass
return False
def main():
if os.environ.get('DSH_SKILL_GUARD_OFF') == '1':
return
obj = _read_stdin()
if not obj:
return
workdir = _workdir(obj)
if not workdir or SCOPE not in workdir:
return # 自作用域:别的工作区一律放行
if os.path.exists(os.path.join(workdir, DISABLE_REL)):
return
prompt = ''
for k in ('prompt', 'user_prompt', 'userPrompt', 'message', 'text'):
v = obj.get(k)
if isinstance(v, str) and v.strip():
prompt = v
break
sid = str(obj.get('session_id') or obj.get('sessionId') or 'unknown')
# ★ 入口即留痕(2026-09-16 加)—— 否则"没命中就没日志",永远无法回答
# "它到底有没有被宿主调用"。与 stop-dialog-guard.py 同款设计;每次用户提交一行。
_log(workdir, 'entry|event=UserPromptSubmit|in_scope=True|sid=%s|prompt_len=%d'
% (sid[:8], len(prompt)))
if not prompt:
return
hits = [w for w in TRIGGERS if w in prompt]
if not hits:
return
if _cooling(workdir, sid):
return
seg = re.sub(r'\s+', ' ', prompt)
pos = min(seg.find(w) for w in hits)
excerpt = seg[max(0, pos - 40): pos + 80].strip()
ctx = (
'【技能加载闸门 · 机制层强制】检测到用户本轮**点名了方法**(命中:%s)。\n'
'原文片段:「…%s…」\n\n'
'⛔ **不要凭记忆代替、也不要以"我已经知道判据"为由跳过**:\n'
' **先调用 Skill 工具加载 `dsh-decision-method`**(决策方法论 · 含 §4.5 规则冲突裁决顺序 + 上抛前三问),\n'
' 然后才开始作答。若本轮涉及功能需求判类型 / 该不该上抛,**同时加载 `dsh-feature-first`**。\n\n'
'为什么强制:2026-09-16 实测(会话 ddea70b7)—— 用户点名「参考决策方法」后,'
'AI 全程 `Skill` 调用 **0 次**,仍按旧判据把两个**非门禁**问题上抛给用户,'
'用户 45 / 58 秒后自己给出了答案。**规则写在文件里 ≠ 会在正确的时机被取用。**\n'
'(若判定本轮确实与决策无关,可在作答中一句话说明后继续 —— 但**不要静默跳过加载**。)'
) % ('、'.join(hits), excerpt)
_emit({'hookSpecificOutput': {
'hookEventName': 'UserPromptSubmit',
'additionalContext': ctx,
}})
_log(workdir, 'HIT sid=%s hits=%s' % (sid[:8], ','.join(hits)))
if __name__ == '__main__':
try:
main()
except Exception:
pass
sys.exit(0)