402 lines
24 KiB
Python
402 lines
24 KiB
Python
#!/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 落在本工作区(`ai1net-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 数组里,
|
||
也可各自独立成块 —— 2026-09-30 实测:**多个 UserPromptSubmit 块都会投递 `additionalContext`**):
|
||
"UserPromptSubmit": [{ "hooks": [
|
||
{ "type": "command", "command": "\"<python>\" \"<此脚本>\"", "timeout": 10 },
|
||
{ "type": "command", "command": "\"<python>\" \"<stop-dialog-guard.py>\"", "timeout": 10 }
|
||
]}]
|
||
⛔ **绝不要给本族脚本加 `-E`**:`-E` 屏蔽 `PYTHONUTF8`/`PYTHONIOENCODING` ⇒ stdin 回退 cp936 ⇒
|
||
含中文的 payload 解析失败且**静默 fail-open**(`bash-output-guard.py` 与 `stop-dialog-guard.py`
|
||
头部都有同样的警告;本项目已因此"白排查一天")。
|
||
✅ **`-S` 可选**(省 site 初始化),但**非必需** —— 2026-09-30 实测不带 `-S` 也正常工作。
|
||
⚠️ 旧注记"hooks 是应用启动时快照 ⇒ 装完必须完全重启"**已不成立**(2026-09-30 实测:18:32 改配置、
|
||
18:38 起本钩子与 `stop-dialog-guard` 就在真实用户发言上**热生效**,未重启宿主;`PreToolUse` 同类)。
|
||
⇒ 改完仍应**喂一次模拟载荷端到端自证**,⛔ 别只改配置就宣布"修好了"。
|
||
"""
|
||
# [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 = ('*',) # '*' = 本机全部工作区
|
||
_log_rel = os.path.join('.workbuddy', 'skill-load-guard.log')
|
||
|
||
|
||
def _workdirs():
|
||
"""作用域标记元组(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 = _workdirs()
|
||
if '*' in names:
|
||
return True
|
||
return any(x in str(s or '') for x in names)
|
||
|
||
|
||
LOG_REL = _log_rel
|
||
STATE_REL = os.path.join('.workbuddy', '.skill-load-guard.state')
|
||
DISABLE_REL = os.path.join('.workbuddy', 'skill-guard.disabled')
|
||
COOLDOWN = 300 # 同会话注入冷却(秒)
|
||
|
||
# 用户「点名方法 / 点名规则」的词表 —— 命中任一即注入。
|
||
# ── 分组 1:点名「决策方法」(2026-09-16 立,最稳的一条)─────────────────────
|
||
# ── 分组 2:点名「通用作业规则」(2026-09-22 用户拍板 B 后新增)─────────────────
|
||
# 目的:`agent-operating-rules`(跨工作区通用规则技能)过去**完全靠 description 匹配**,
|
||
# 是所有技能里最容易被漏的一个 —— 加三条窄词给它一条机制通道。
|
||
TRIGGERS = (
|
||
# 分组 1 —— 决策方法族(→ 加载 `session-mechanism`;`dsh-decision` 已于 2026-10-06 删除)
|
||
'决策方法',
|
||
'自行决策', '自主决策', '自己决策', '自己拿主意',
|
||
'别问我', '不要问我', '不用问我', '按你的规划', '按你的判断',
|
||
'参考决策', '决策方法论',
|
||
# 分组 2 —— 作业规则族(→ 加载 `session-mechanism`;规则已整包搬入)
|
||
# 🔴 2026-10-02 扩面:**「回复形态 / 排版」这一族原先一个词都没有**。
|
||
# 用户看的就是"你这条回复长什么样"(原话「我发现你又忘记如何回复 执行结果了,
|
||
# 是不是技能规则失效了」),而旧词表只有「按规则来」等 5 个窄词 ⇒ 这类点名
|
||
# **从未命中过**:`skill-load-guard.log` 全量 1177 行里只有 12 次 HIT,
|
||
# 且 10-01 / 10-02 **零命中**(最后一次真实 HIT 停在 2026-09-30 18:41)。
|
||
'按规则来', '按规则做', '按作业规则', '遵守规则', '按规矩来',
|
||
'回复排版', '执行结果排版', '回复格式', '怎么回复', '排版',
|
||
# ── 分组 3:点名「会话机制 / 派活」(2026-10-05 立 · 🔴 补的正是"漏了主触发句"这个洞)──
|
||
# 🔴🔴 起因(10-05 实测取证):会话 `b232218f`(vibe-product 主会话)里,
|
||
# 用户原话「**2、使用任务会话完成目标:补抓…**」+ 连续三次质问
|
||
# 「任务会话的目的不就是建立任务会话执行嘛」「我想知道你哪来的这么多问题啊」,
|
||
# 而**本钩子全程 0 命中** —— 旧词表 22 个词里**一个都匹配不上"使用任务会话完成目标"**:
|
||
# · 「决策方法」族:用户没说"决策",他说的是"使用任务会话"
|
||
# · 「按规则来」族:用户没说"规则",他说的是"会话技能里没告诉你自决策的规则嘛"(**反问**,含"规则"但那是质问不是点名)
|
||
# ⇒ 结果:技能从未被强制加载 ⇒ 助手靠记忆干活 ⇒ 把"要不要建任务会话"当待拍板项反复问。
|
||
# 🔴 教训:**词表只覆盖"点名方法论",漏了"点名派活"** —— 而后者才是本技能的主入口。
|
||
'使用任务会话', '使用执行会话', '使用协作会话', '任务会话完成', '执行会话完成',
|
||
'继续完成目标', '继续目标', '继续完成之前', '继续之前的',
|
||
'会话技能', '会话机制', '创建任务会话', '建任务会话',
|
||
'派任务会话', '任务会话', '执行会话', '协作会话', '多会话',
|
||
)
|
||
|
||
# 命中词 → 该加载哪个技能(2026-09-22 加:从"只会推决策技能"扩为按命中词分流)
|
||
# 🔴 2026-10-02 修:本行原写 `dsh-decision-method`,而它**已于 2026-09-28 合并退役**
|
||
# ⇒ 注入文本让人去加载一个**不存在的技能**(找得到才怪 ⇒ 规则仍不进上下文;属静默失效族)。
|
||
# 现名 `dsh-decision`,两档全文:`references/00-决策方法论.md`(原 decision-method)
|
||
# / `references/01-功能优先协作协议.md`(原 feature-first,含 §5.1 结论骨架 + §5.4 排版硬约束)。
|
||
_LOAD_RULES = ('按规则来', '按规则做', '按作业规则', '遵守规则', '按规矩来',
|
||
'回复排版', '执行结果排版', '回复格式', '怎么回复', '排版')
|
||
|
||
# ── 分组 3 的词 → 加载 `session-mechanism`(2026-10-05 加)─────────────────────
|
||
# 与 `_LOAD_RULES`(→ agent-operating-rules)并列的第三条路由。
|
||
# 🔴 为什么单列一族:这三族**目标技能不同**,⛔ 不能混在一个 if 里 ——
|
||
# 命中"使用任务会话完成目标"的人,要的是**会话机制本体**(怎么建排期/怎么派活/
|
||
# 自决策白名单在 `references/02-功能优先协作协议.md`),⛔ 不是决策方法论。
|
||
_LOAD_SESSION = ('使用任务会话', '使用执行会话', '使用协作会话', '任务会话完成', '执行会话完成',
|
||
'继续完成目标', '继续目标', '继续完成之前', '继续之前的',
|
||
'会话技能', '会话机制', '创建任务会话', '建任务会话',
|
||
'派任务会话', '任务会话', '执行会话', '协作会话', '多会话')
|
||
|
||
|
||
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/07-scripts/
|
||
try:
|
||
return (os.environ.get('DSH_WS_ROOT')
|
||
or os.path.abspath(os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', '..')))
|
||
except Exception:
|
||
return ''
|
||
|
||
|
||
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\\`(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(workdir, line):
|
||
try:
|
||
p = os.path.join(_norm_path(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 = _norm_path(_workdir(obj))
|
||
if not workdir or not _in_scope(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()
|
||
|
||
# ── 按命中词分流(2026-09-22 起;2026-10-05 加第三族「会话机制」)───────────
|
||
# 优先级:会话族 > 规则族 > 决策族。
|
||
# 🔴 为什么会话族最高:命中"使用任务会话完成目标"时,用户要的是**派活**,
|
||
# 而派活的前置恰恰是"自决策白名单"(否则就会回头问"要不要建任务会话"——
|
||
# 10-05 就是这么栽的)。⛔ 此时若只推 `dsh-decision`,人还是可能漏掉会话机制本体。
|
||
hit_session = [w for w in hits if w in _LOAD_SESSION]
|
||
hit_rules = [w for w in hits if w in _LOAD_RULES]
|
||
if hit_session:
|
||
head = '检测到用户本轮**点名了会话机制 / 派活**(命中:%s)。' % '、'.join(hit_session)
|
||
body = (
|
||
'⛔ **不要凭记忆代替、也不要以"我已经知道这套机制"为由跳过**:\n'
|
||
' **先调用 Skill 工具加载 `session-mechanism`**(会话机制 + 多会话执行总入口 · 含**首屏第 0 步加载门槛**'
|
||
'/三类会话/派活线/自决策白名单指向),然后才开始作答。\n'
|
||
' ⛔ 若上一轮已在本会话加载过,可跳过加载,但**必须确认用的是现行口径**'
|
||
'(机制 10-03/10-04/10-05 连续改过三轮,记忆里的版本大概率是旧的)。\n\n'
|
||
'🔴🔴 **本条最关键的作用 —— 拦住"把已授权的事重新要签字"**:\n'
|
||
' 用户说「**使用任务会话完成 X 目标**」**本身就是授权** ⇒ 直接建排期、拉起任务会话,\n'
|
||
' ⛔ **不许再问「要不要建任务会话」**。\n'
|
||
' 📌 2026-10-05 实测栽过(会话 `b232218f`):用户下发「2、使用任务会话完成目标:…」,\n'
|
||
' AI 却回「你要我直接派任务会话,还是先自己执行?」⇒ 用户连问三次\n'
|
||
' 「任务会话的目的不就是建立任务会话执行嘛,不然我调用任务会话技能干什么」\n'
|
||
' 「我想知道你哪来的这么多问题啊,你去执行不行啊」。\n'
|
||
' ⇒ 根因:**本钩子当时词表里没有一个词能命中"使用任务会话完成目标"**(22 词全是"决策方法"族),\n'
|
||
' 技能从未被强制加载 ⇒ AI 靠记忆干活 ⇒ 白名单没进上下文。本轮已补上触发词。\n\n'
|
||
'⚠️ 冲突裁决:**用户原话 > 注入通报**(通报是机制层旁路信号,⛔ 不能拿它反驳用户当轮明确指令)。'
|
||
)
|
||
elif hit_rules:
|
||
head = '检测到用户本轮**点名了作业规则**(命中:%s)。' % '、'.join(hit_rules)
|
||
body = (
|
||
'⛔ **不要凭记忆代替、也不要以"我已经知道规则"为由跳过**:\n'
|
||
' **先调用 Skill 工具加载 `session-mechanism`**(总入口 · 作业规矩已整包搬入本包 `references/作业规矩/`:自决策 / 提报用户 / 回复排版 / 说话方式 / 不越界 / 多棒接力),\n'
|
||
' 然后才开始作答。若本轮涉及功能需求判类型 / 该不该提报用户,**并同读本包 `references/02-功能优先协作协议.md`**。\n\n'
|
||
'为什么强制:该技能是**跨工作区通用**的(不绑定任何具体项目),'
|
||
'但它与其他技能一样**靠 description 匹配按需加载 —— 没有机制保证它在任何工作区都被取用**'
|
||
'(它的 description 里已写「当你在任何一个工作区开始任务…」,仍可能漏)。'
|
||
'本条注入即补上这个缺口。\n'
|
||
'(若判定本轮确实与作业规则无关,可在作答中一句话说明后继续 —— 但**不要静默跳过加载**。)'
|
||
)
|
||
else:
|
||
head = '检测到用户本轮**点名了方法**(命中:%s)。' % '、'.join(hits)
|
||
body = (
|
||
'⛔ **不要凭记忆代替、也不要以"我已经知道判据"为由跳过**:\n'
|
||
' **先调用 Skill 工具加载 `session-mechanism`**(总入口 · 判据实体已全部收进本包:`references/04-决策方法论.md` + `references/02-功能优先协作协议.md`),\n'
|
||
' 然后才开始作答(原 `dsh-decision-method` + `dsh-feature-first` **已于 2026-09-28 合并进它**,⛔ 不要再按旧名找)。\n\n'
|
||
'为什么强制:2026-09-16 实测(会话 ddea70b7)—— 用户点名「参考决策方法」后,'
|
||
'AI 全程 `Skill` 调用 **0 次**,仍按旧判据把两个**非门禁**问题提报用户给用户,'
|
||
'用户 45 / 58 秒后自己给出了答案。**规则写在文件里 ≠ 会在正确的时机被取用。**\n'
|
||
'(若判定本轮确实与决策无关,可在作答中一句话说明后继续 —— 但**不要静默跳过加载**。)'
|
||
)
|
||
|
||
ctx = '【技能加载闸门 · 机制层强制】%s\n' '原文片段:「…%s…」\n\n' '%s' % (head, excerpt, body)
|
||
|
||
# 🔴🔴 2026-10-02 用户定案:「重点是**技能的使用时要检查环境配置是否已配置,
|
||
# 如果没有配置就要先配置**」⇒ 技能被加载的这一轮,**把环境体检结论一并注入**。
|
||
# 为什么放这里:这是「技能即将被用」的唯一公共入口 ⇒ ⛔ 不靠各会话自觉去查。
|
||
try:
|
||
if not _env.skills_root():
|
||
ctx += (
|
||
'\n\n🔴🔴 **【环境未配好 · 先配再用】**技能库根**定位失败**'
|
||
'(⛔ 已不回落 `~`,因 Windows 上 `~` 不是真配置目录)'
|
||
'⇒ **本技能的钩子会静默零输出**(日志只有一行 `core=0 字符`)。\n'
|
||
'✅ 先跑:`python "<包>/scripts/hooks/_env.py" --ws "<本工作区绝对路径>"`\n'
|
||
)
|
||
else:
|
||
# 🔴🔴 2026-10-02 用户拍板**A 案:装全局,一处配好所有工作区共用**
|
||
# (原话:「装全局,一处配好所有工作区共用」;优点=省事、换项目不用重配;
|
||
# 缺点=改动影响面大,别的项目出问题也会连带 —— 用户知悉后仍选全局)。
|
||
# ⇒ **判据只认全局那份**(scope=global)。⛔ 不再退回「工作区或全局任一即可」
|
||
# —— 那是本轮之前的状态:两份标记**互相兜底** ⇒ 只挪走一份不报警
|
||
# ⇒ 看着有配置、实际是残的(且换工作区要重新配一遍,正是A 案要消掉的毛病)。
|
||
# 保留 `workspace` 作为**只读兼容**:万一历史工作区级标记还在,
|
||
# 会在这里明确提示"这是旧口径、应迁到全局",⛔ 而不是默默当它有效。
|
||
_st = _env.read_env_stamp("", "global")
|
||
_st_old = _env.read_env_stamp(workdir, "workspace")
|
||
if not _st:
|
||
ctx += (
|
||
'\n\n🔴 **【环境标记缺失 · 全局口径】**全局环境标记(`scope=global`)不存在'
|
||
' ⇒ ⛔ 别假定"配好了"(用户 2026-10-02 拍板:**装全局,一处配好所有工作区共用**)。\n'
|
||
'✅ 先跑体检再写标记:'
|
||
'`python "<包>/scripts/hooks/_env.py" --ws "<本工作区绝对路径>"` 看结论,'
|
||
'通过后加 `--stamp --scope global` 写**全局**标记(⛔ 不加 `--scope` 默认就是 global)。\n'
|
||
)
|
||
elif _st_old:
|
||
ctx += (
|
||
'\n\n⚠️ **【口径迁移提醒】**本工作区还留着一份旧的 `scope=workspace` 标记,'
|
||
'而现行口径是**全局**(用户 2026-10-02 拍板 A 案)⇒ 读数以全局那份为准。\n'
|
||
'✅ 一次性清理:`rm "<工作区>/.workbuddy/env-stamp.json"`'
|
||
'(⛔ 留着不影响生效,只是会让人误以为工作区要单独配)。\n'
|
||
)
|
||
except Exception:
|
||
pass # 🔴 环境检查本身异常 ⛔ 不许把主注入带崩(fail-open)
|
||
|
||
_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)
|