chore(工作区): 全量入库 + 补齐 .gitignore(以工作区为准)
- 变更规模:新增 514 / 修改 62 / 重命名 155 / 删除 4(归档重组与文档轮次) - .gitignore 修:`归档/**/db-cwd归一-备份-*/` —— 原规则写绝对层级(归档/db-cwd归一-…), 目录搬进 归档/配置与备份/ 后**静默失效**,43 MB 的 DB 备份又变成未跟踪 - .gitignore 补:嵌套 git 内部数据(归档/内嵌git-20261008/、归档/skills-git-旧线-20261007/dotgit-原样移出/) - .gitignore 补:运行态与部署副本(.workbuddy/collab/、.workbuddy/tools/、.workbuddy/.load-pending、.workbuddy/tmp-*) - .gitignore 补:备份件(*.bak-*) - 未跟踪文件从 2190 降到 890(其余为 归档/ 归档件与 .workbuddy/memory/ 知识文件,按口径入库)
This commit is contained in:
1 parent
30b46dbd0c
commit
c1b5e4d966
735 files changed
+153192
-2415
No files matched your search
@@ -0,0 +1,224 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""**环境定位与环境自检**(2026-10-02 · 用户定案「技能使用时必须先检查环境配置」)。
|
||||
|
||||
一句话:把「技能库根 / 配置目录」从**各脚本各自推导**改成**一处判据+一处体检**。
|
||||
|
||||
## 为什么要有这个模块(2026-10-02 实测事故)
|
||||
|
||||
`reply-style-guard.py` 曾被注册成文档库 `07-scripts/reply-style-guard.py`(D 盘旧副本)。
|
||||
它沿 `__file__` 上溯 4 级找 `skills/` ⇒ 从 D 盘够不到 ⇒ 落进最后一行
|
||||
`os.path.expanduser('~/.workbuddy/skills')`。
|
||||
⚠️🔴 **Windows 上 `~` 不等于真配置目录**(本机真值在 E 盘 `CODEBUDDY_CONFIG_DIR`)⇒ 那一行是**错的**。
|
||||
|
||||
后果比报错更坏:该钩子的设计判据是「哪一级都读不到就**静默零输出**、不影响任何人」
|
||||
⇒ **「机制坏了」和「没配规则」表现完全一样**,日志里只有一行 `core=0 字符`。
|
||||
(真实代价:另一个工作区为这件事绕了两轮,在「规则文件在不在」上打转。)
|
||||
|
||||
## 三条硬规矩(本模块的判据)
|
||||
|
||||
1. 🔴 **顺序固定**:`DSH_SKILLS_ROOT` env → `CODEBUDDY_CONFIG_DIR` env(+ `roots.env` 兜底)
|
||||
→ 沿 `__file__` 上溯找含 `agent-operating-rules` 的那一层 → ⛔ **不再回落到 `~`**。
|
||||
2. 🔴 **找不到就报错,不静默**:写 **stderr** + 返回 `None`;调用方据此**非零退出**。
|
||||
⛔ 绝不「静默零输出」—— 那正是把两类事故压成一个表象的元凶。
|
||||
3. 🔴 **体检与环境标记分离**:`env_check()` 只**读**(体检);`write_env_stamp()` 才**写**标记。
|
||||
⇒ 体检永远不该有副作用(否则「查一下」就把标记刷新了,标记就永不报警)。
|
||||
|
||||
## 标记文件(用户 2026-10-02 要求:「在对应文件夹创建状态变量和时间作为标记」)
|
||||
|
||||
落在**配置作用域对应的文件夹**里,文件名 `env-stamp.json`:
|
||||
|
||||
- 全局作用域 ⇒ `<配置目录>/env-stamp.json`(本机 `E:/ProgramData/.workbuddy/env-stamp.json`)
|
||||
- 工作区作用域 ⇒ `<工作区>/.workbuddy/env-stamp.json`
|
||||
|
||||
字段:`scope`(global/workspace)|`checked_at`(ISO 到秒)|`ok`(bool)|
|
||||
`config_dir` / `skills_root`(**判据读数**,便于事后核)|`hooks`(逐条注册的存在性)|
|
||||
`fixed`(本轮修过的问题清单)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
_STAMP_NAME = "env-stamp.json"
|
||||
_ROOTS_ENV = "roots.env" # 技能根的权威落点(install.py 生成)
|
||||
_SNIFF = "agent-operating-rules" # 技能库的识别特征目录
|
||||
|
||||
|
||||
def _norm(p) -> str:
|
||||
s = str(p or "").strip()
|
||||
return os.path.normpath(s).replace("\\", "/") if s else ""
|
||||
|
||||
|
||||
def _load_roots_env() -> dict:
|
||||
"""读技能包里的 `roots.env`(⛔ 各脚本的 `_sm_load_roots()` 注入 env 前的原始来源)。"""
|
||||
out = {}
|
||||
d = Path(__file__).resolve().parent
|
||||
for _ in range(5):
|
||||
f = d / _ROOTS_ENV
|
||||
if f.is_file():
|
||||
try:
|
||||
for ln in f.read_text(encoding="utf-8").splitlines():
|
||||
ln = ln.strip()
|
||||
if ln and not ln.startswith("#") and "=" in ln:
|
||||
k, v = ln.split("=", 1)
|
||||
out[k.strip()] = v.strip()
|
||||
return out
|
||||
except Exception:
|
||||
return {}
|
||||
n = d.parent
|
||||
if n == d:
|
||||
break
|
||||
d = n
|
||||
return out
|
||||
|
||||
|
||||
def config_dir() -> str:
|
||||
"""**真配置目录** —— 顺序见硬规矩①的前三档。"""
|
||||
v = os.environ.get("CODEBUDDY_CONFIG_DIR")
|
||||
if v:
|
||||
return _norm(v)
|
||||
v = _load_roots_env().get("CODEBUDDY_CONFIG_DIR")
|
||||
if v:
|
||||
return _norm(v)
|
||||
#⛔ 最后一档仍不给 `~`:Windows 上它通常不是真配置目录(实测会指到 C:\Users\...)
|
||||
return _norm(Path.home() / ".workbuddy")
|
||||
|
||||
|
||||
def skills_root() -> str:
|
||||
"""**技能库根** —— 顺序见硬规矩①。⛔ 不再回落到 `~`(硬规矩①末档已删)。
|
||||
|
||||
🔴 2026-10-02 补:**每一档都要验"真的存在"**,⛔ 不信任 env 值本身。
|
||||
实测踩过:`DSH_SKILLS_ROOT` 指向一个不存在的目录时,旧写法直接返回它
|
||||
⇒ 下游 `os.path.isfile(核心块)` 失败 ⇒ **静默零输出**(同款静默失效)。
|
||||
⇒ env 给错 ⇒ 继续往下找;全都找不到 ⇒ 返回 `""`(=报错,⛔ 不猜)。
|
||||
"""
|
||||
v = os.environ.get("DSH_SKILLS_ROOT")
|
||||
if v and (Path(_norm(v)) / _SNIFF).is_dir():
|
||||
return _norm(v)
|
||||
d = Path(__file__).resolve().parent
|
||||
for _ in range(6): # 沿 __file__ 上溯(换机可用,⛔ 不写死盘符)
|
||||
cand = d.parent
|
||||
if (cand / _SNIFF).is_dir():
|
||||
return _norm(cand)
|
||||
if d.parent == d:
|
||||
break
|
||||
d = d.parent
|
||||
r = _load_roots_env().get("CODEBUDDY_CONFIG_DIR")
|
||||
if r:
|
||||
cand = Path(_norm(r)) / "skills"
|
||||
if (cand / _SNIFF).is_dir():
|
||||
return _norm(cand)
|
||||
return "" # 🔴 空串=找不到(⛔ 不是"猜一个")
|
||||
|
||||
|
||||
def _stamp_path(ws: str = "", scope: str = "global") -> str:
|
||||
if scope == "workspace" and ws:
|
||||
return _norm(Path(ws) / ".workbuddy" / _STAMP_NAME)
|
||||
return _norm(Path(config_dir()) / _STAMP_NAME)
|
||||
|
||||
|
||||
def registered_hooks() -> list:
|
||||
"""读全局 `settings.json` 里已注册的钩子 ⇒ `[(事件, 命令, timeout, 脚本路径)]`。"""
|
||||
sp = Path(config_dir()) / "settings.json"
|
||||
out = []
|
||||
try:
|
||||
d = json.loads(sp.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return out
|
||||
for ev, groups in (d.get("hooks") or {}).items():
|
||||
for g in (groups or []):
|
||||
for e in (g or {}).get("hooks", []):
|
||||
c = (e or {}).get("command", "") or ""
|
||||
# 取最后一个 .py 片段(命令形如`<python>"<脚本>" <参数>`)
|
||||
parts = [p for p in c.split('"') if p.strip().endswith(".py")]
|
||||
out.append((ev, c, (e or {}).get("timeout"),
|
||||
_norm(parts[-1]) if parts else ""))
|
||||
return out
|
||||
|
||||
|
||||
def env_check(ws: str = "", scope: str = "global") -> dict:
|
||||
"""🔍 **只读体检**:返回逐项读数,⛔ **绝不写标记**(硬规矩③)。"""
|
||||
rep = {"scope": scope, "checked_at": time.strftime("%Y-%m-%dT%H:%M:%S"),
|
||||
"config_dir": config_dir(), "skills_root": skills_root(), "bad": []}
|
||||
if not rep["skills_root"]:
|
||||
rep["bad"].append("skills_root 定位失败(⛔ 已不回落~)")
|
||||
elif not (Path(rep["skills_root"]) / _SNIFF).is_dir():
|
||||
rep["bad"].append("skills_root 存在但缺 %s:%s" % (_SNIFF, rep["skills_root"]))
|
||||
hooks = []
|
||||
for ev, cmd, t, path in registered_hooks():
|
||||
ok = bool(path) and os.path.isfile(path)
|
||||
row = {"event": ev, "timeout": t, "path": path, "exists": ok}
|
||||
hooks.append(row)
|
||||
if not ok:
|
||||
rep["bad"].append("钩子目标不存在:%s %s" % (ev, path or cmd[:60]))
|
||||
rep["hooks"] = hooks
|
||||
rep["ok"] = not rep["bad"]
|
||||
return rep
|
||||
|
||||
|
||||
def write_env_stamp(ws: str = "", scope: str = "global", fixed=None) -> str:
|
||||
"""✍️ **写环境标记**(含状态变量+时间)。`scope` 决定落在哪个文件夹。"""
|
||||
rep = env_check(ws, scope)
|
||||
rep["fixed"] = list(fixed or [])
|
||||
p = Path(_stamp_path(ws, scope))
|
||||
try:
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
p.write_text(json.dumps(rep, ensure_ascii=False, indent=2) + "\n",
|
||||
encoding="utf-8", newline="\n")
|
||||
except Exception as e:
|
||||
sys.stderr.write("env-stamp 写失败:%r\n" % (e,))
|
||||
return ""
|
||||
return _norm(p)
|
||||
|
||||
|
||||
def read_env_stamp(ws: str = "", scope: str = "global") -> dict:
|
||||
try:
|
||||
return json.loads(Path(_stamp_path(ws, scope)).read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def require_env(ws: str = "", what: str = "") -> str:
|
||||
"""🔴 **调用方统一入口**:定位不到就**报错并返回空串**(硬规矩②)。
|
||||
|
||||
⛔ 绝不在这里 `sys.exit` —— 钩子进程退出码由调用方决定(各钩子预算不同)。
|
||||
"""
|
||||
root = skills_root()
|
||||
if root:
|
||||
return root
|
||||
sys.stderr.write(
|
||||
"[env] 技能库根定位失败%s:DSH_SKILLS_ROOT / CODEBUDDY_CONFIG_DIR 均未给,"
|
||||
"沿 __file__ 上溯也没找到含 %s 的目录。\n"
|
||||
" ⛔ 已⛔静默跳过(不再回落 ~,因Windows 上 ~ 不是真配置目录)\n"
|
||||
" ✅ 修法:设环境变量 DSH_SKILLS_ROOT,或跑 "
|
||||
"`python scripts/hooks/_env.py --stamp` 重建标记并体检\n"
|
||||
% (("(%s)" % what) if what else "", _SNIFF))
|
||||
return ""
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import argparse
|
||||
_ap = argparse.ArgumentParser(description="环境自检/环境标记")
|
||||
_ap.add_argument("--ws", default="", help="工作区绝对路径")
|
||||
_ap.add_argument("--scope", default="global", choices=["global", "workspace"])
|
||||
_ap.add_argument("--stamp", action="store_true", help="写环境标记")
|
||||
_ap.add_argument("--fixed", default="", help="--stamp 时记进 fixed 的说明")
|
||||
_a = _ap.parse_args()
|
||||
if _a.stamp:
|
||||
_p = write_env_stamp(_a.ws, _a.scope,
|
||||
[_a.fixed] if _a.fixed else None)
|
||||
print("已写标记:%s" % (_p or "⛔ 写失败"))
|
||||
raise SystemExit(0 if _p else 1)
|
||||
_r = env_check(_a.ws, _a.scope)
|
||||
print("配置目录 = %s" % _r["config_dir"])
|
||||
print("技能库根 = %s" % (_r["skills_root"] or "⛔ 定位失败"))
|
||||
print("已注册钩子 = %d 条|不存在的 = %d"
|
||||
% (len(_r["hooks"]), sum(1 for h in _r["hooks"] if not h["exists"])))
|
||||
for _b in _r["bad"]:
|
||||
print(" ⛔ %s" % _b)
|
||||
print("结论 = %s" % ("✅ 通过" if _r["ok"] else "❌ 不通过"))
|
||||
raise SystemExit(0 if _r["ok"] else 1)
|
||||
@@ -0,0 +1,356 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""bash-output-guard —— `PreToolUse(Bash)`:在**命令执行前**拦下"会灌爆上下文"的读命令。
|
||||
|
||||
为什么需要(2026-09-15 实测)
|
||||
────────────────────────────
|
||||
对话历史是 **append-only**:一次工具调用在转录里落两条记录 —— `function_call`(命令)
|
||||
+ `function_call_result`(**输出正文**);**输出一旦生成就永久留在 messages 里,且每轮全量重发**。
|
||||
实测某会话 **553 轮 × 平均 42 万 token = 2.33 亿 input**,而 output 仅占 **0.35%**。
|
||||
⇒ 想真正"把大输出从上下文里去掉",**唯一的时机就是它被生成之前**(模型无权删自己的历史)。
|
||||
|
||||
边界(重要)
|
||||
──────────
|
||||
* **只拦"读"类高风险命令**,且**必须给出等价限流写法**(教而非堵)。
|
||||
* **判不准就放行**(fail-open)—— 本机不是沙箱,但这类命令本身不破坏数据,误拦的代价只是麻烦。
|
||||
* 急停:env `DSH_OUTPUT_GUARD_OFF=1`,或新建 `<工作区>/.workbuddy/bash-guard.disabled`。
|
||||
* 命中才写一行日志 `<工作区>/.workbuddy/bash-guard.log`(用于调误报,上限 300 行)。
|
||||
|
||||
安装(`settings.json` 的 hooks 段 · **新增一条**)
|
||||
────────────────────────────────────────────────────────────────────
|
||||
"PreToolUse": [ …,
|
||||
{ "matcher": "Bash|Read",
|
||||
"hooks": [{ "type": "command",
|
||||
"command": "\"<python>\" \"<此脚本>\"", "timeout": 10 }] } ]
|
||||
⚠️ matcher 必须含 **`Read`**:本脚本有两条规则 —— `cat` 大文件(>200 KB)走 **Bash**,
|
||||
`Read` 大文件(>400 KB,见 `READ_BIG`)走 **Read 工具**;只挂 `Bash` ⇒ 后者整条规则静默失效。
|
||||
⚠️ 旧注记"需完全重启才生效"**已不成立**(2026-09-30 实测:改完配置后下一次 Bash 调用即被本钩子
|
||||
记录 `entry` 行,未重启宿主)。⇒ 但改完仍应**喂一次模拟载荷端到端自证**。
|
||||
⛔ **别给本脚本加 `-E`**:`-E` 会屏蔽 `PYTHONUTF8`/`PYTHONIOENCODING` ⇒ stdin 回退 cp936 ⇒
|
||||
含中文的 payload 解析失败且**静默 fail-open**(同目录 `stop-dialog-guard.py` 已因此"白排查一天")。
|
||||
"""
|
||||
# [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
|
||||
|
||||
LOG_REL = os.path.join('.workbuddy', 'bash-guard.log')
|
||||
# 🔴 2026-09-24:本钩子**没有作用域门禁**,一向全机生效。
|
||||
# 原 `SCOPE = 'ai1net-dsh-server'` 只喂给日志字段,却会被误读成"作用域外 ⇒ 没生效"(实测误判过一次)⇒ 删除。
|
||||
# 兜底工作区:脚本位于 <工作区>/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__))))
|
||||
|
||||
# (是否 core, 正则, 名称, 更省的写法)
|
||||
# ⚠️ 设计原则:**不能全拦** —— 误拦挡住正事 ⇒ 我会来回试/绕路 ⇒ 反而更贵;漏拦只是多花点 token。
|
||||
# core=True = 几乎必然巨大、误拦率≈0 ⇒ **默认就拦**
|
||||
# core=False = 经常确实需要全量 ⇒ **仅 hard 模式拦**(默认放行)
|
||||
RULES = [
|
||||
# cat:**按目标文件大小判定**(小文件不拦)—— 这是防日常误伤的关键,见 cat_bigfile()
|
||||
(True, '__CAT_BIGFILE__',
|
||||
'`cat` 大文件', '改用 `head -30 文件` / `sed -n \'1,30p\' 文件` / `wc -l 文件`'),
|
||||
# ⚠️ 2026-09-15 **回放今日 12 个会话的 4049 条真实命令**:本规则命中 62 次,抽样 **29/29 全是窄范围**
|
||||
# (单文件 / 具体目录),**0 条从根**;连 `grep -c` 这种本来只有几行的也被误伤。
|
||||
# 旧正则 `\bgrep\b[^|;]*-[a-zA-Z]*[rR]` 还有 bug:`[^|;]*` 会匹配到**路径里的 `-server`**
|
||||
# (`-s`+`erve`+`r`)⇒ 凡路径含 `ai1net-dsh-server` 就必命中。
|
||||
# ⇒ **误伤 >> 收益** ⇒ 降为 loose(仅 `hard` 模式拦),并把正则收紧到「选项紧跟 grep」。
|
||||
(False, r'\bgrep\s+-[a-zA-Z]*[rR]\b',
|
||||
'递归 grep', '改用 `grep -rn … | head -30`,或 `grep -rc …`(只要计数)'),
|
||||
# ⚠️ 只拦 `-R`(递归全树);**不拦 `ls -la`**(含 l 会误伤日常);选项必须紧跟 ls(同样防路径误匹配)
|
||||
(True, r'\bls\s+-[a-zA-Z]*R\b',
|
||||
'`ls -R` 递归全树', '改用 `ls -la 目录 | head -20`,或 `ls 目录 | wc -l`'),
|
||||
(True, r'\bfind\s+(/[A-Za-z]|[A-Za-z]:)',
|
||||
'`find` 从盘符/根起全树扫', '改用 `find 具体目录 -maxdepth 3 … | head -20`'),
|
||||
(True, r'\bjournalctl\b(?!.*(-n\s*\d|head|--since))',
|
||||
'`journalctl` 无行数/时间限制', '改用 `journalctl -u X -n 50 --no-pager`(`--since` 也算限流,放行)'),
|
||||
(True, r'\bdmesg\b(?!.*head)',
|
||||
'`dmesg` 无行数限制', '改用 `dmesg | tail -30`'),
|
||||
(False, r'(^|[|;&]\s*)rg\b(?!.*\|)',
|
||||
'递归 rg 无管道限流', '改用 `rg … | head -30`,或 `rg -c …`'),
|
||||
(False, r'\bgit\s+(log|diff)\b(?!.*(-n\s*\d|--max-count|head))',
|
||||
'`git log/diff` 无行数限制', '改用 `git log --oneline -5` / `git diff --stat`'),
|
||||
(False, r'\b(du|tree)\b[^|;]*\s(/|[A-Za-z]:)',
|
||||
'`du/tree` 从根起', '改用 `du -sh 具体目录` / `tree -L 2 目录 | head -40`'),
|
||||
]
|
||||
# 命令里已有限流 ⇒ 放行(只按"最外层"有就好了)
|
||||
SAFE = re.compile(r'\|\s*(head|tail|wc|grep\s+-c|cut|sed\s+-n|awk|uniq|sort\s+-u)\b')
|
||||
|
||||
|
||||
def _read_stdin():
|
||||
"""⚠️ 必须走 buffer 显式 UTF-8(`-E` 下 sys.stdin 是 cp936)。"""
|
||||
try:
|
||||
return sys.stdin.buffer.read().decode('utf-8', 'replace')
|
||||
except Exception:
|
||||
try:
|
||||
return sys.stdin.read()
|
||||
except Exception:
|
||||
return ''
|
||||
|
||||
|
||||
def _emit(obj):
|
||||
data = json.dumps(obj, ensure_ascii=False).encode('utf-8')
|
||||
try:
|
||||
sys.stdout.buffer.write(data)
|
||||
sys.stdout.buffer.flush()
|
||||
except Exception:
|
||||
try:
|
||||
sys.stdout.write(data.decode('utf-8', 'replace'))
|
||||
sys.stdout.flush()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def log(root, detail):
|
||||
try:
|
||||
p = os.path.join(root, LOG_REL)
|
||||
os.makedirs(os.path.dirname(p), exist_ok=True)
|
||||
with io.open(p, 'a', encoding='utf-8', newline='\n') 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')
|
||||
if len(lines) > 300:
|
||||
io.open(p, 'w', encoding='utf-8', newline='\n').write('\n'.join(lines[-150:]))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
CAT_BIG = 200 * 1024 # `cat` 目标文件 > 200 KB 才值得拦(小文件放行 ⇒ 防日常误伤)
|
||||
READ_BIG = 400 * 1024 # `Read` 目标文件 > 400 KB ⇒ 至少几万 token ⇒ 拦(2026-09-15 实测补)
|
||||
# ↑ 为什么补 Read:本钩子最初只管 Bash,但实测 `Read` 是**第二大输出源** ——
|
||||
# 某会话 44 次 / 累计 238 K 字符、单条最大 33 K 字符(≈13 K token);另一会话 136 次 / 217 K 字符,
|
||||
# 且**完全没有被任何机制覆盖**。⇒ "想把大输出从上下文里去掉",就必须覆盖所有会灌大输出的工具。
|
||||
|
||||
|
||||
def read_too_big(payload):
|
||||
"""`Read` 一个大文件 ⇒ True。取不到大小(文件不存在/相对路径)⇒ False(放行)。"""
|
||||
ti = payload.get('tool_input') or {}
|
||||
p = ti.get('file_path') or ti.get('path') or ''
|
||||
if not isinstance(p, str) or not p:
|
||||
return False
|
||||
try:
|
||||
return os.path.getsize(p) > READ_BIG
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def cat_bigfile(cmd):
|
||||
"""`cat <文件>` 且目标文件 > CAT_BIG ⇒ True。取不到大小(相对路径/不存在/管道源)⇒ False(放行)。"""
|
||||
m = re.search(r'(?:^|[|;&]\s*)cat\s+(?:-[A-Za-z]+\s+)*([^\s|>;&]+)', cmd)
|
||||
if not m:
|
||||
return False
|
||||
p = m.group(1).strip('"\'')
|
||||
if p in ('-', '/dev/null', '/dev/stdin'):
|
||||
return False
|
||||
try:
|
||||
return os.path.getsize(p) > CAT_BIG
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def segments(cmd):
|
||||
"""按 shell 分隔符切段(`;` `&&` `||` `|` 换行)—— 只对**每段的开头**做匹配。
|
||||
|
||||
⚠️ 为什么不匹配整条命令文本(2026-09-15 实测教训):那样**引号里的字符串也会被拦** ——
|
||||
例如 `printf '...ls -laR...'`、测试脚本、把命令写进文档,全都会误拦(阻塞面过大)。
|
||||
按段匹配既保住真拦(`cd x && ls -laR` 的第二段以 `ls` 开头 ⇒ 照样拦),又不误伤"只是提到"。
|
||||
"""
|
||||
return [s.strip() for s in re.split(r'(?:&&|\|\||;|\n|\|)', cmd) if s.strip()]
|
||||
|
||||
|
||||
# ── 批量活检测(2026-09-15 用户选 A)─────────────────────────────
|
||||
# 要打断的是:**"一句话需求 → 上百次工具调用"** —— 每次调用都把「命令 + 结果」永久留在上下文里。
|
||||
# 实测(会话 7057685c):逐处改注释 ⇒ Edit 142 次、痕迹 13.4 万字符;若改用一个脚本 ⇒ 约 8 千字符(省 94%)。
|
||||
# 实现:`PreToolUse` 里给 Edit/Write/Bash 记账;到档位且**同质** ⇒ deny 一刀并给出替代做法(我改路子)。
|
||||
BATCH_N = 20
|
||||
BATCH_TOOLS = ('Edit', 'Write', 'Bash')
|
||||
|
||||
|
||||
def _batch_state(root):
|
||||
p = os.path.join(root, '.workbuddy', 'batch-guard.json')
|
||||
try:
|
||||
d = json.load(io.open(p, encoding='utf-8'))
|
||||
except Exception:
|
||||
d = {}
|
||||
return p, d
|
||||
|
||||
|
||||
def _homogeneous(tool, st):
|
||||
"""同质性:Edit ⇒ 最近 20 次里同一文件出现 ≥3 次(= 同一文件反复改);Bash ⇒ 命令都很短。"""
|
||||
if tool == 'Edit':
|
||||
paths = st.get('paths') or []
|
||||
if not paths:
|
||||
return False
|
||||
c = {}
|
||||
for x in paths[-20:]:
|
||||
c[x] = c.get(x, 0) + 1
|
||||
return max(c.values()) >= 3
|
||||
if tool == 'Bash':
|
||||
lens = st.get('lens') or []
|
||||
return bool(lens) and (sum(lens[-20:]) / max(len(lens[-20:]), 1)) < 300
|
||||
return True
|
||||
|
||||
|
||||
def batch_check(root, payload, log_fn):
|
||||
"""到档位 + 同质 ⇒ 返回提示串(并记账,每档只提示一次);否则 ''。"""
|
||||
sid = str(payload.get('session_id') or '')
|
||||
tool = payload.get('tool_name') or ''
|
||||
if not sid or tool not in BATCH_TOOLS:
|
||||
return ''
|
||||
p, d = _batch_state(root)
|
||||
st = d.setdefault(sid, {})
|
||||
st[tool] = int(st.get(tool, 0)) + 1
|
||||
n = st[tool]
|
||||
ti = payload.get('tool_input') or {}
|
||||
if tool == 'Edit':
|
||||
fp = str(ti.get('file_path') or '')
|
||||
if fp:
|
||||
st['paths'] = (st.get('paths') or [])[-40:] + [fp]
|
||||
elif tool == 'Bash':
|
||||
st['lens'] = (st.get('lens') or [])[-40:] + [len(str(ti.get('command') or ''))]
|
||||
fired = st.setdefault('fired', [])
|
||||
hit = ''
|
||||
if n in (BATCH_N, BATCH_N * 2, BATCH_N * 4) and ('%s@%d' % (tool, n)) not in fired and _homogeneous(tool, st):
|
||||
fired.append('%s@%d' % (tool, n))
|
||||
files = len(set(st.get('paths') or []))
|
||||
hit = (
|
||||
'🛠【批量活提示】本会话 `%s` 已调用 **%d 次**%s ⇒ 这更像**批量活**。\n'
|
||||
'✅ 改用**一个脚本一次做完**,只回「改了 N 处 / 失败 M 处」—— **别逐处调用**:'
|
||||
'逐次调用会把 N 份「命令 + 结果」**永久留在会话上下文里、之后每轮全量重发**。\n'
|
||||
'ℹ️ 实测参照:某会话逐处改 142 次 ⇒ 痕迹 **13.4 万字符**;改用一个脚本 ⇒ 约 **8 千字符(省 94%%)**。'
|
||||
'改完**统一验一次**即可,不必改一处验一处。'
|
||||
% (tool, n, ('(改到的文件只有 %d 个)' % files) if (tool == 'Edit' and files) else ''))
|
||||
log_fn(str(root), 'DENY|批量活提示 %s@%d' % (tool, n))
|
||||
try:
|
||||
if len(d) > 30:
|
||||
for k in list(d)[:-30]:
|
||||
d.pop(k, None)
|
||||
io.open(p, 'w', encoding='utf-8', newline='\n').write(json.dumps(d, ensure_ascii=False))
|
||||
except Exception:
|
||||
pass
|
||||
return hit
|
||||
|
||||
|
||||
def reason_for(cmd, hard=False):
|
||||
segs = segments(cmd)
|
||||
for core, pat, why, fix in RULES:
|
||||
if not core and not hard: # loose 条只在 hard 模式生效
|
||||
continue
|
||||
for s in segs:
|
||||
if pat == '__CAT_BIGFILE__':
|
||||
if cat_bigfile(s):
|
||||
return why, fix
|
||||
continue
|
||||
if re.match(pat, s): # ← match(段首),不是 search(全串)
|
||||
return why, fix
|
||||
return None, None
|
||||
|
||||
|
||||
def main():
|
||||
raw = _read_stdin()
|
||||
payload = None
|
||||
if raw.strip():
|
||||
try:
|
||||
payload = json.loads(raw)
|
||||
except ValueError:
|
||||
payload = None
|
||||
root = (os.environ.get('CODEBUDDY_PROJECT_DIR') or os.environ.get('DSH_WORKSPACE')
|
||||
or (isinstance(payload, dict) and payload.get('cwd')) or WS_FALLBACK)
|
||||
tp = str((payload or {}).get('transcript_path') or '') if isinstance(payload, dict) else ''
|
||||
if isinstance(payload, dict):
|
||||
# 入口即留痕(只记 event,低频;便于判"有没有被调用")
|
||||
log(str(root), 'entry|event=%s|in_scope=all(无门禁)' % (payload.get('hook_event_name') or '(parse-fail)'))
|
||||
if not isinstance(payload, dict):
|
||||
return
|
||||
if (payload.get('hook_event_name') or '') != 'PreToolUse':
|
||||
return
|
||||
tool = payload.get('tool_name') or ''
|
||||
if tool not in ('Bash', 'Read', 'Edit', 'Write'): # Bash/Read=拦大输出;Edit/Write=批量活记账
|
||||
return
|
||||
if os.environ.get('DSH_OUTPUT_GUARD_OFF'):
|
||||
return
|
||||
try:
|
||||
if os.path.exists(os.path.join(root, '.workbuddy', 'bash-guard.disabled')):
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
if tool in ('Edit', 'Write'): # 批量活检测:deny 一刀,换掉"上百次逐处调用"
|
||||
chk = batch_check(str(root), payload, log)
|
||||
if chk:
|
||||
_emit({'hookSpecificOutput': {'hookEventName': 'PreToolUse',
|
||||
'permissionDecision': 'deny',
|
||||
'permissionDecisionReason': chk}})
|
||||
return
|
||||
if tool == 'Read':
|
||||
if read_too_big(payload):
|
||||
fp = str(((payload.get('tool_input') or {}).get('file_path')) or '')
|
||||
log(str(root), 'DENY|Read 大文件|%s' % fp[:110])
|
||||
_emit({'hookSpecificOutput': {
|
||||
'hookEventName': 'PreToolUse', 'permissionDecision': 'deny',
|
||||
'permissionDecisionReason': (
|
||||
'💰 拦下:**Read 大文件**(`%s`)—— 它的**全文会写进会话历史、之后每轮全量重发**。\n'
|
||||
'✅ 分段读:`Read` 加 `offset` / `limit`(例 `limit: 120`)只取你要的那段;'
|
||||
'或先 `grep -n "关键词" 文件 | head -20` 定位行号,再读那几行。\n'
|
||||
'ℹ️ 确实要通读 ⇒ 用 Bash 分批 `sed -n \'1,200p\' 文件`;急停 env `DSH_OUTPUT_GUARD_OFF=1` '
|
||||
'或 `.workbuddy/bash-guard.disabled`。' % fp[:80])}})
|
||||
return
|
||||
cmd = ((payload.get('tool_input') or {}).get('command')) or ''
|
||||
if not cmd or SAFE.search(cmd):
|
||||
return
|
||||
try:
|
||||
md = io.open(os.path.join(root, '.workbuddy', 'bash-guard-mode'),
|
||||
encoding='utf-8').read().lower()
|
||||
except OSError:
|
||||
md = ''
|
||||
if 'off' in md:
|
||||
return
|
||||
why, fix = reason_for(cmd, hard=('hard' in md))
|
||||
if not why:
|
||||
return
|
||||
log(str(root), 'DENY|%s|%s' % (why, cmd.replace('\n', ' ')[:110]))
|
||||
_emit({'hookSpecificOutput': {
|
||||
'hookEventName': 'PreToolUse',
|
||||
'permissionDecision': 'deny',
|
||||
'permissionDecisionReason': (
|
||||
'💰 拦下:**%s** —— 这类命令的输出会**永久留在会话上下文里、每轮全量重发**'
|
||||
'(实测某会话 553 轮 × 平均 42 万 token = 2.33 亿 input,'
|
||||
'output 仅占 0.35%%)。\n'
|
||||
'✅ 换成限流写法再发:%s\n'
|
||||
'ℹ️ 若确实需要全量:**先落盘再只读关键行**(`… > /tmp/x.txt 2>&1` 然后 `sed -n \'1,40p\' /tmp/x.txt`);'
|
||||
'急停用 env `DSH_OUTPUT_GUARD_OFF=1` 或新建 `<工作区>/.workbuddy/bash-guard.disabled`。'
|
||||
% (why, fix))}})
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
try:
|
||||
main()
|
||||
except Exception:
|
||||
pass # fail-open:本钩子只为省积分,绝不因自身异常阻断正常工作
|
||||
sys.exit(0)
|
||||
@@ -0,0 +1,419 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
lock-guard-hook.py — 「无锁不许改库」的强制钩子(WorkBuddy PreToolUse / SessionStart)
|
||||
|
||||
为什么需要它
|
||||
────────────
|
||||
2026-09-12 实证:`handoff-guard.sh` 输出「无全局锁」被会话读成「环境干净,可以开工」
|
||||
(正确读法是「你快去抢锁」),结果**两个会话同时改了本库**。措辞已在 guard 与
|
||||
`05-交接单/README.md` 里补正,但**约定拦不住不看的人** —— 本脚本是强制层。
|
||||
|
||||
作用域(**刻意收窄:对其他项目零影响**)
|
||||
────────────
|
||||
仅当 `Write` / `Edit` 的目标路径落在下列根之内,才做锁判定;其余一律放行:
|
||||
· 文档库 <DSH_DOCS_ROOT>(默认 E:\\ProgramData\\AIProject\\ai1net-dsh-server\\dsh-server-docs)
|
||||
· 代码库 <DSH_CODE_REPO>(默认 D:\\github\\dsh_shenxian)
|
||||
⚠️ 例外:路径中含 `.workbuddy` 目录段的一律放行 —— 会话记忆 / 自动化工作数据属“运行态数据”,不是“仓库内容”。(2026-09-13 实测:「代码仓三方同步(DSH)」自动化写自己的 memory/*.md 时被本钩子 deny,属误伤)
|
||||
|
||||
判据
|
||||
────
|
||||
`<文档库根>/05-交接单/.exec-lock` 存在 = 有人持锁 → 放行(归属由台账 OWNER 承担);
|
||||
不存在 = **deny**,并把可直接粘贴的抢锁命令回给 Agent。
|
||||
|
||||
为什么**不拦 Bash**
|
||||
──────────────────
|
||||
① 抢锁命令本身必须能跑(否则把自己锁死 —— 拿不到锁就永远开不了工);
|
||||
② Bash 写文件是少数派、且破坏面可见(git status);
|
||||
③ 宁可留一个显式的安全阀,也不要一个可能导致死锁的强制层。
|
||||
|
||||
用法(settings.json 的 hooks 段,见同目录 README 或档案 73)
|
||||
"PreToolUse": [{ "matcher": "Write|Edit", "hooks": [{ "type": "command", "command": "<python> <此脚本>", "timeout": 10 }] }]
|
||||
"SessionStart":[{ "matcher": "startup", "hooks": [{ "type": "command", "command": "<python> <此脚本>", "timeout": 10 }] }]
|
||||
|
||||
退出码:始终 0(判定通过 JSON 输出表达);脚本自身异常也放行,绝不误伤。
|
||||
"""
|
||||
# [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 json
|
||||
import os
|
||||
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
|
||||
|
||||
# ⚠️ 文档库根:env/roots.env 优先;末位兜底**故意保留字面量** —— 锁守卫一旦静默解析不到,
|
||||
# 就会"看起来在守、其实没守"(比指错更危险)。换机器请让 install.py 写 roots.env。
|
||||
DOCS_ROOT = os.environ.get("DSH_DOCS_ROOT") or r"D:\github\dsh_shenxian\dsh-server-docs"
|
||||
# ⛔ 不留盘符:代码库=文档库的上一级(`<repo>/dsh-server-docs` 的约定)
|
||||
CODE_REPO = os.environ.get("DSH_CODE_REPO") or (os.path.dirname(DOCS_ROOT) if DOCS_ROOT else "")
|
||||
LOCK_DIR = os.path.join(DOCS_ROOT, "05-交接单", ".exec-lock")
|
||||
OWNER_FILE = os.path.join(LOCK_DIR, "OWNER")
|
||||
LOCKS_ROOT = os.path.join(DOCS_ROOT, "05-交接单", ".locks")
|
||||
|
||||
GUARD_CMD = 'bash 07-scripts/handoff-guard.sh --claim-exec "<你的会话名>"'
|
||||
GUARD_DOMAIN_CMD = 'bash 07-scripts/handoff-guard.sh --claim-exec "<你的会话名>" --domains <域...>'
|
||||
|
||||
# ⚠️ 顺序有意义:**先长后短**,与 handoff-guard.sh 的 _ANCHOR_SEGS 逐字一致。
|
||||
# 两侧只要顺序或集合不同 ⇒ 同一文件算出不同域键 ⇒ 域锁静默失效(假绿)。
|
||||
# 🔴🔴 2026-10-03 10:0x **修一处真缺陷(不是文档漂移)**:本行原为
|
||||
# `… "docs", "07-scripts", "08-skills", "05-交接单"`,
|
||||
# 而真源 `handoff-guard.sh:66 _ANCHOR_SEGS` 是 `… "docs", "scripts", "skills", "交接单"`。
|
||||
# ⚠️ 后果不是"看起来不一致":`domain_key()` **拿本表参与实际计算** ⇒ 同一个文件
|
||||
# 在 hook 侧与抢锁侧会算出**不同域键** ⇒ 两边各抢各的 ⇒ **域锁等于没有(假绿)**。
|
||||
# ✅ 已与 shell 侧逐字对齐(`scripts`/`skills`/`交接单`=**仓内实际目录名**)。
|
||||
# ⚠️ 改这里**必须**同时改 `handoff-guard.sh` 的 `_ANCHOR_SEGS` 与 `collabd.py` 的 `_DS_ANCHORS`。
|
||||
# 📌 判据:`collabd.py` 的 `_DS_ANCHORS_OTHER` 保留旧值作对照,自检会报两侧差异。
|
||||
_DOMAIN_SEGS = ("dsh-server-docs", "ai1net-dsh-server", "aliyun-dsh-server", "src", "poc", "web", "test", "docs", "scripts", "skills", "交接单")
|
||||
|
||||
|
||||
def _owner_text(d: str) -> str:
|
||||
try:
|
||||
with open(os.path.join(d, "OWNER"), encoding="utf-8") as f:
|
||||
return f.read()
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def _my_name(payload: dict) -> str:
|
||||
"""本会话名 —— hook 进程拿不到 `ME`(那是 bash 侧变量),依次尝试:
|
||||
① payload/env 的 `DSH_SESSION_NAME`(handoff-guard 抢锁时会写进 OWNER)
|
||||
② env `ME`(本地手动调用时)
|
||||
③ OWNER 里**唯一**写着本 session_id 的那把锁(真多会话下 id 唯一,可安全用它反查)
|
||||
"""
|
||||
for k in ("DSH_SESSION_NAME",):
|
||||
v = os.environ.get(k, "")
|
||||
if v:
|
||||
return v
|
||||
v = os.environ.get("ME", "")
|
||||
if v:
|
||||
return v
|
||||
return ""
|
||||
|
||||
|
||||
def _name_owner_of_id(ids: set, exclude: str = "") -> str:
|
||||
"""用 session_id 反查"我的那把锁"的会话名(仅当它唯一时才返回,避免多锁歧义)。"""
|
||||
if not ids or not os.path.isdir(LOCKS_ROOT):
|
||||
return ""
|
||||
hits = []
|
||||
for name in os.listdir(LOCKS_ROOT):
|
||||
if name.startswith("."):
|
||||
continue
|
||||
d = os.path.join(LOCKS_ROOT, name)
|
||||
if not os.path.isdir(d):
|
||||
continue
|
||||
txt = _owner_text(d)
|
||||
if any(i and i in txt for i in ids):
|
||||
first = (txt.splitlines() or [""])[0].strip()
|
||||
if first and first != exclude:
|
||||
hits.append(first)
|
||||
hits = list(dict.fromkeys(hits))
|
||||
return hits[0] if len(hits) == 1 else ""
|
||||
|
||||
|
||||
def _is_mine(owner_txt: str, ids: set, cwd: str, my_name: str = "") -> bool:
|
||||
"""归属判据 —— ⚠️ 会话名与 session_id **同时**吻合才算我的。
|
||||
|
||||
2026-09-22 实测踩到:同一进程内用 `ME=会话A/会话B` 模拟两会话时,`CODEBUDDY_SESSION_ID`
|
||||
是**真实环境变量**、两份 OWNER 都写着同一个 id ⇒ 只按 id 判会把两会话都认作"自己"
|
||||
⇒ 域隔离**全部放行**(假绿,最危险的失效形态)。
|
||||
⇒ 判据收紧为:**会话名相等** 且(id 命中 或 未取到 id)。
|
||||
"""
|
||||
first = (owner_txt.splitlines() or [""])[0].strip()
|
||||
id_hit = bool(ids) and any(i and i in owner_txt for i in ids)
|
||||
if my_name:
|
||||
return first == my_name and id_hit
|
||||
if id_hit:
|
||||
return True
|
||||
if cwd:
|
||||
nc = norm(cwd)
|
||||
if nc and nc in norm(owner_txt):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _iter_domain_locks():
|
||||
"""遍历所有域锁目录(跳过 .gate / .migrations / .publish)。"""
|
||||
if not os.path.isdir(LOCKS_ROOT):
|
||||
return
|
||||
for name in os.listdir(LOCKS_ROOT):
|
||||
if name.startswith("."):
|
||||
continue
|
||||
d = os.path.join(LOCKS_ROOT, name)
|
||||
if not os.path.isdir(d):
|
||||
continue
|
||||
try:
|
||||
with open(os.path.join(d, "DOMAINS"), encoding="utf-8") as f:
|
||||
doms = [ln.strip() for ln in f if ln.strip()]
|
||||
except Exception:
|
||||
continue
|
||||
yield d, _owner_text(d), doms
|
||||
|
||||
|
||||
def domain_key(path: str) -> str:
|
||||
"""路径 → 域键(前两段,与 handoff-guard.sh norm_domain 同规则 ⇒ 粒度到目录,宁可保守)。"""
|
||||
n = norm(path).replace("\\", "/")
|
||||
parts = [p for p in n.split("/") if p]
|
||||
for seg in _DOMAIN_SEGS:
|
||||
if seg in parts:
|
||||
i = parts.index(seg)
|
||||
return "/".join(parts[i:i + 2]).lower()
|
||||
return "/".join(parts[-2:]).lower() if len(parts) >= 2 else n.lower()
|
||||
|
||||
|
||||
def has_my_domain_lock(ids: set, cwd: str, my_name: str = "") -> bool:
|
||||
mn = my_name or _name_owner_of_id(ids)
|
||||
for d, ot, _ in _iter_domain_locks():
|
||||
if _is_mine(ot, ids, cwd, mn):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def blocked_by_domain(path: str, ids: set, cwd: str, my_name: str = "") -> tuple:
|
||||
"""目标是否落在**别人**的域内 ⇒ (占用者, 冲突域)。自己的域锁一律放行。"""
|
||||
key = domain_key(path)
|
||||
mn = my_name or _name_owner_of_id(ids)
|
||||
for d, ot, doms in _iter_domain_locks():
|
||||
if _is_mine(ot, ids, cwd, mn):
|
||||
continue
|
||||
if key and key in doms:
|
||||
return ((ot.splitlines()[0] if ot else "?"), key)
|
||||
return ("", "")
|
||||
|
||||
|
||||
def norm(p: str) -> str:
|
||||
try:
|
||||
return os.path.normcase(os.path.normpath(p))
|
||||
except Exception:
|
||||
return p
|
||||
|
||||
|
||||
PROTECTED = [norm(DOCS_ROOT), norm(CODE_REPO)]
|
||||
|
||||
|
||||
def is_protected(path: str) -> bool:
|
||||
if not path:
|
||||
return False
|
||||
n = norm(path)
|
||||
# 会话记忆 / 自动化工作数据不是「仓库内容」:写它不该被本钩子拦(2026-09-13 新增)
|
||||
if ".workbuddy" in n.split(os.sep):
|
||||
return False
|
||||
for root in PROTECTED:
|
||||
if n == root or n.startswith(root + os.sep):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def lock_owner() -> str:
|
||||
try:
|
||||
with open(OWNER_FILE, encoding="utf-8") as f:
|
||||
return (f.readline() or "").strip() or "(未写 OWNER)"
|
||||
except Exception:
|
||||
return "(未写 OWNER)"
|
||||
|
||||
|
||||
def out(obj: dict) -> None:
|
||||
# ⚠️ 2026-09-16 修复(**根因**):必须走 **`sys.stdout.buffer` 写 bytes**,⛔ 不能走文本层
|
||||
# (`sys.stdout.write(str)`)。
|
||||
# 实证:宿主 spawn 本脚本时 stdout 编码**不保证是 UTF-8**(本机 `PYTHONUTF8=1` 在 hook 环境下
|
||||
# 不保证生效),而 deny 文案里含 `⛔` / `🔐` 等字符 ⇒ 文本层写抛 `UnicodeEncodeError` ⇒
|
||||
# 异常冒泡、**stdout 为空** ⇒ 宿主**收不到 deny** ⇒ **静默放行**。
|
||||
# 而 `hook_log()` 已经把 `PreToolUse-deny` 写进日志 ⇒ 表现成 **「日志里有 DENY,但拦不住」**
|
||||
# (2026-09-16 受控自测:无锁时 Write 文档库 ⇒ 文件真被创建)。
|
||||
# 对照:`bash-output-guard.py` 用 `sys.stdout.buffer.write(bytes)` ⇒ **实测拦得住**。
|
||||
# 📌 教训:本脚本的 `stdin` 早前已按 buffer 加固(见 main 顶部注释),**stdout 漏了** ——
|
||||
# 「读写要同时加固」,只修一半等于没修。
|
||||
data = json.dumps(obj, ensure_ascii=False).encode('utf-8')
|
||||
try:
|
||||
sys.stdout.buffer.write(data)
|
||||
sys.stdout.buffer.flush()
|
||||
except Exception:
|
||||
try:
|
||||
sys.stdout.write(data.decode('utf-8', 'replace'))
|
||||
sys.stdout.flush()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
LOG_FILE = os.environ.get(
|
||||
"DSH_LOCK_HOOK_LOG", os.path.join(os.path.dirname(DOCS_ROOT), ".workbuddy", "lock-hook.log")
|
||||
)
|
||||
|
||||
|
||||
def hook_log(event: str, detail: str) -> None:
|
||||
"""低频自证日志:只在 SessionStart 与 deny 时写一行 —— 用来回答「hook 到底有没有被触发」。
|
||||
|
||||
为什么需要:hook 配置是**启动时缓存**的,改完必须完全重启才加载;没有日志就只能靠猜。
|
||||
写入失败一律静默(hook 绝不能因为自己出问题而干扰工作)。
|
||||
"""
|
||||
try:
|
||||
os.makedirs(os.path.dirname(LOG_FILE), exist_ok=True)
|
||||
with open(LOG_FILE, "a", encoding="utf-8") as f:
|
||||
f.write(f"{time.strftime('%Y-%m-%d %H:%M:%S')}\t{event}\t{detail}\n")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def main() -> None:
|
||||
try:
|
||||
try:
|
||||
# ⚠️ 必须走 buffer 显式 UTF-8:本机环境设了 PYTHONUTF8=1,但只要有人给脚本加 `-E`
|
||||
# 就会被屏蔽 ⇒ sys.stdin 回退 cp936 ⇒ 含中文路径(`E:\ProgramData\AIProject\…`)的
|
||||
# payload 解析即炸。而本函数是 **fail-open**(读不懂就放行)⇒ 会**静默失效**:
|
||||
# 锁守卫不再拦人,却没有任何痕迹。2026-09-15 实测(同款坑已在 stop-dialog-guard.py 踩过)。
|
||||
raw = sys.stdin.buffer.read().decode("utf-8", "replace")
|
||||
except Exception:
|
||||
raw = sys.stdin.read()
|
||||
payload = json.loads(raw) if raw.strip() else {}
|
||||
except Exception as e:
|
||||
# 不留痕 = 失效无声(判不出"没被调用"与"被静默放行")⇒ 必须记一行
|
||||
hook_log("payload-unparsable", "%s: %s" % (type(e).__name__, str(e)[:80]))
|
||||
return # 读不懂 payload → 放行(fail-open 方向正确,但不能无声)
|
||||
|
||||
tool = payload.get("tool_name")
|
||||
event = payload.get("hook_event_name") or payload.get("hook_event") or ""
|
||||
|
||||
# ★ 入口即留痕(2026-09-16 加)—— 与 stop-dialog-guard.py / skill-load-guard.py 同款。
|
||||
# 为什么不只记 SessionStart 与 deny:2026-09-16 实测 —— 重启后本钩子**一行都没写**,
|
||||
# 当时无法区分"没被宿主调用"与"被静默放行",只能靠猜(最后查明:SessionStart 的
|
||||
# `matcher: "startup"` 不匹配"恢复会话",钩子压根没被执行)。
|
||||
# 留痕里带上**实际收到的字段名**(keys):下次宿主字段一变,日志里立刻能看出来,
|
||||
# 而不是等某天发现守卫早已失效。低频:每次调用一行。
|
||||
hook_log("entry", "event=%s|tool=%s|keys=%s" % (
|
||||
event or "?", tool or "-", ",".join(sorted(payload.keys()))[:220]))
|
||||
|
||||
# ── SessionStart:只提示(该事件的输出只给用户看,不会进 Agent 上下文)──
|
||||
if event == "SessionStart" or (tool is None and "source" in payload):
|
||||
src = payload.get("source", "?")
|
||||
_sid = payload.get("session_id", "")
|
||||
_ids = {_sid} if _sid else {x for x in (os.environ.get("CODEBUDDY_SESSION_ID", ""),) if x}
|
||||
_cwd = payload.get("cwd") or ""
|
||||
if has_my_domain_lock(_ids, _cwd):
|
||||
msg = "🔐 你持有域锁 —— 只有落进**别人**域的写入会被拦;锁总览:`handoff-guard.sh --locks`"
|
||||
hook_log("SessionStart", f"本会话持域锁 source={src}")
|
||||
elif os.path.isdir(LOCK_DIR):
|
||||
who = lock_owner()
|
||||
msg = f"🔐 全局执行锁【已被占用】:{who} —— 但**域锁可绕开它**:不重叠的域仍能并行。"
|
||||
hook_log("SessionStart", f"旧全局锁被占 owner={who} source={src}")
|
||||
elif os.path.isdir(DOCKS := os.path.join(DOCS_ROOT, "05-交接单", ".locks")) and os.listdir(DOCKS):
|
||||
msg = "🔐 已有域锁在跑 —— 开工前先抢你要的域(不重叠即可并行):" + GUARD_DOMAIN_CMD
|
||||
hook_log("SessionStart", f"有域锁无全局锁 source={src}")
|
||||
elif os.path.isdir(DOCS_ROOT):
|
||||
msg = "🔐 无锁【空闲】—— 「空闲」≠「可以开工」:先判定可锁定范围再抢锁 →\n " + GUARD_DOMAIN_CMD
|
||||
hook_log("SessionStart", f"空闲 source={src}")
|
||||
else:
|
||||
msg = ""
|
||||
if msg:
|
||||
out({"systemMessage": msg, "suppressOutput": True})
|
||||
return
|
||||
|
||||
# ── PreToolUse:真正的强制点 ──
|
||||
if tool not in ("Write", "Edit", "MultiEdit", "NotebookEdit"):
|
||||
return
|
||||
ti = payload.get("tool_input") or {}
|
||||
fp = ti.get("file_path") or ti.get("path") or ti.get("notebook_path") or ""
|
||||
if not is_protected(fp):
|
||||
return
|
||||
if not os.path.isdir(DOCS_ROOT):
|
||||
return # 库不在这台机器上 → 与本约定无关,放行
|
||||
|
||||
sid = payload.get("session_id") or ""
|
||||
cwd = payload.get("cwd") or ""
|
||||
# ⚠️ **payload 有 session_id ⇒ 只用它**,⛔ 不要与 env 取并集!
|
||||
# 并集会让"payload 里的伪造/过期 id"叠加进程 env 里的真实 id ⇒ 假会话被判成"我" ⇒ 放行(实测踩过)。
|
||||
# env 仅作**缺省兜底**(payload 未带 session_id 时)。
|
||||
ids = {sid} if sid else {x for x in (os.environ.get("CODEBUDDY_SESSION_ID", ""),) if x}
|
||||
my_name = payload.get("session_name") or _my_name(payload)
|
||||
|
||||
# ① 旧全局锁存在 ⇒ 兼容:只对**未持域锁**的会话放行(旧行为),
|
||||
# 已持域锁的会话仍走 ② 做域隔离(2026-09-22 修正)
|
||||
# ⚠️ 修正原因:首版让「旧锁存在 ⇒ 一律放行」⇒ 域隔离**完全失效**(实测命中)。
|
||||
legacy_open = os.path.isdir(LOCK_DIR)
|
||||
if legacy_open and not has_my_domain_lock(ids, cwd, my_name):
|
||||
return
|
||||
|
||||
# ② 域锁路径(2026-09-22 加):自己持域锁 ⇒ 只看是否落进**别人**的域
|
||||
if has_my_domain_lock(ids, cwd, my_name):
|
||||
who, dom = blocked_by_domain(fp, ids, cwd, my_name)
|
||||
if not who:
|
||||
return # 在我自己的域内(或无人占该域)⇒ 放行
|
||||
hook_log("PreToolUse-deny-domain", f"{tool} {fp} ← 域 {dom} 属 {who}")
|
||||
reason = (
|
||||
"⛔ 被「域锁」拦下:目标文件落进了**别的会话**的冲突域。\n\n"
|
||||
f"冲突域:{dom}\n占用会话:{who}\n\n"
|
||||
"域锁的设计意图 = **不重叠的域可以并行**,重叠的域必须串行。\n"
|
||||
"你有两个合规选择(⛔ 都不得删对方锁,R9):\n"
|
||||
" ① 等对方释放该域;\n"
|
||||
" ② **改做不重叠的域**(这才是域锁的价值所在)。\n\n"
|
||||
f"查全部锁:bash 07-scripts/handoff-guard.sh --locks"
|
||||
)
|
||||
out({
|
||||
"hookSpecificOutput": {
|
||||
"hookEventName": "PreToolUse",
|
||||
"permissionDecision": "deny",
|
||||
"permissionDecisionReason": reason,
|
||||
},
|
||||
"systemMessage": f"⛔ 已拦下跨域写入(域 {dom} 属 {who})",
|
||||
"suppressOutput": True,
|
||||
})
|
||||
return
|
||||
|
||||
# ③ 既无全局锁、也无自己的域锁 ⇒ 拒写(与旧行为一致)
|
||||
hook_log("PreToolUse-deny", f"{tool} {fp}")
|
||||
|
||||
reason = (
|
||||
"⛔ 被「锁机制」拦下:本仓库(文档库 / 代码库)当前**你没有持任何锁**,"
|
||||
"不允许直接修改。\n\n"
|
||||
"「无锁」的正确读法 = **「你快去抢」**,不是「可以开工」"
|
||||
"(2026-09-12 实证:两个会话把「无锁」读成「环境干净」→ 同时改了本库)。\n\n"
|
||||
"请先执行(Bash,不受本钩子限制)——**两种锁选一种**:\n\n"
|
||||
" 【域锁】只改局部的模块/文件(推荐,可与他人并行)\n"
|
||||
f" cd \"{DOCS_ROOT}\"\n {GUARD_DOMAIN_CMD}\n\n"
|
||||
" 【全局锁】无域可声明,或不确定(旧行为,独占)\n"
|
||||
f" cd \"{DOCS_ROOT}\"\n {GUARD_CMD}\n\n"
|
||||
"抢到 = 开工许可;**抢不到 = 冲突域被占 → 停手或改做不重叠的域**。\n"
|
||||
"开工前先判定可锁定范围:`bash 07-scripts/preflight-lock.sh \"<会话名>\" <目标文件...>`。"
|
||||
)
|
||||
out(
|
||||
{
|
||||
"hookSpecificOutput": {
|
||||
"hookEventName": "PreToolUse",
|
||||
"permissionDecision": "deny",
|
||||
"permissionDecisionReason": reason,
|
||||
},
|
||||
"systemMessage": "⛔ 已拦下一次无锁写入(本仓库要求先抢锁)",
|
||||
"suppressOutput": True,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except Exception:
|
||||
pass # 任何异常都不阻断工作
|
||||
sys.exit(0)
|
||||
@@ -0,0 +1,202 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""reply-style-guard.py —— 「回复排版与格式」的 UserPromptSubmit **全机**强制注入钩子
|
||||
|
||||
为什么需要它
|
||||
────────────
|
||||
2026-10-02 用户令(逐字):
|
||||
· 「所有会话中回复排版和格式要求和规则,也要整合到会话技能中,使用时配置到对应环境文件中」
|
||||
· 前一轮:「这个规则改为强遵循 固化在必循加入会话的地方」
|
||||
起因:同一件事 **连点两次**(「我发现你又忘记如何回复执行结果了」「我确认你是又把执行后的
|
||||
内容回复排版和格式给忘记了」)—— 根因**不是规则丢了**,是**规则只在会话开始时被读一次**:
|
||||
长会话里会被稀释,而"回复长什么样"这件事**每一轮都要判一次**。
|
||||
|
||||
机制对照(同族,已存在)
|
||||
────────────────────────
|
||||
· `stop-dialog-guard.py` —— 治「收尾时的征询句」
|
||||
· `skill-load-guard.py` —— 治「用户点名了方法却没加载技能」
|
||||
本钩子治第三条面:**「每轮回复的形态」**。三者同挂 `UserPromptSubmit`,互补。
|
||||
|
||||
🔴 三条设计判据(本需求的关键,⛔ 都别改)
|
||||
──────────────────────────────────────────
|
||||
1. **真相源在技能里**(不在本文件、也不在某个工作区):
|
||||
`<技能库>/agent-operating-rules/references/回复排版-核心块.md`
|
||||
⇒ 技能是**跨工作区**的 ⇒ 一次改动,**所有会话**都跟着变。
|
||||
⛔ **绝不在本文件里写死规则文本** —— 写死就变成第二真相源,两边必然漂。
|
||||
2. **每轮都注入,⛔ 不设冷却**:这条规矩的价值就在"紧贴用户消息、每轮重述"。
|
||||
设冷却(如 300s)= 让它按会话衰减回原样,那就白做了。
|
||||
3. **落环境文件 = 可选项不是必需**:某环境若已用 `apply-reply-rules.py` 把核心块落到自己的
|
||||
规则文件(`CODEBUDDY.md` / `AGENTS.md`)里,**优先用那份**(可能被本地化过);
|
||||
没有 ⇒ 回落到技能里那份。两级回退,⛔ 哪一级都读不到就**静默零输出**(不影响任何人)。
|
||||
|
||||
安装(`settings.json` 的 `hooks.UserPromptSubmit`,`timeout` 建议 10)
|
||||
⛔ 绝不要给本族脚本加 `-E`(会屏蔽 PYTHONUTF8 ⇒ cp936 ⇒ 含 `⛔` 的 payload 静默炸)。
|
||||
急停:env `DSH_REPLY_GUARD_OFF=1` 或 `<工作区>/.workbuddy/reply-guard.disabled`。
|
||||
退出码:始终 0;决策通过 stdout 的 JSON 表达。
|
||||
"""
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
# 🔴 2026-10-02:环境定位/体检收敛到 `_env`(**同目录优先,其次技能包 scripts/**)——
|
||||
# ⛔ 必须在 `_norm`/`_skills_root` 定义**之前**导入(它被下面的函数体引用)。
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
try:
|
||||
import _env
|
||||
except Exception: # 🔴 找不到就报错(⛔ 不静默,见 _env 硬规矩②)
|
||||
sys.stderr.write('[env] 无法导入 _env.py(%s)⇒ 技能库定位不可信\n'
|
||||
% os.path.dirname(os.path.abspath(__file__)))
|
||||
raise
|
||||
|
||||
CORE_REL = os.path.join('agent-operating-rules', 'references', '回复排版-核心块.md')
|
||||
BEGIN = '<!-- REPLY-CORE:BEGIN'
|
||||
END = '<!-- REPLY-CORE:END -->'
|
||||
MAX_CHARS = 1400
|
||||
LOG_REL = os.path.join('.workbuddy', 'reply-style-guard.log')
|
||||
DISABLE_REL = os.path.join('.workbuddy', 'reply-guard.disabled')
|
||||
|
||||
|
||||
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 _norm(p):
|
||||
"""MSYS 风格 `/e/foo` → `E:/foo`(Windows 原生 python 不认前者)。"""
|
||||
s = str(p or '')
|
||||
if len(s) > 2 and s[0] == '/' and s[2:3] == '/':
|
||||
return s[1].upper() + ':' + s[2:]
|
||||
return s
|
||||
|
||||
|
||||
def _skills_root():
|
||||
"""技能库根 —— 🔴 **2026-10-02 改为共用 `_env`**(原实现是本起事故的根)。
|
||||
|
||||
🔴 原实现的病:`沿 __file__ 上溯失败 ⇒ return os.path.expanduser('~/.workbuddy/skills')`
|
||||
而 **Windows 上 `~` 不是真配置目录**(本机真值在 E 盘 `CODEBUDDY_CONFIG_DIR`)
|
||||
⇒ 本文件一旦被注册到「够不到 skills/ 的位置」(实测:文档库 D 盘副本),
|
||||
就定位到C 盘一个不存在的目录 ⇒ **静默零输出**(日志只有 `core=0 字符`)
|
||||
⇒ **「机制坏了」与「没配规则」表现完全一样**(真实代价:另一工作区为此绕了两轮)。
|
||||
✅ 新判据见 `_env` 模块三条硬规矩:顺序固定、**找不到就报错不静默**、体检与标记分离。
|
||||
"""
|
||||
return _env.skills_root()
|
||||
|
||||
|
||||
def _extract(text):
|
||||
i = text.find(BEGIN)
|
||||
if i < 0:
|
||||
return ''
|
||||
i = text.find('\n', i)
|
||||
j = text.find(END, i + 1)
|
||||
if i < 0 or j < 0:
|
||||
return ''
|
||||
body = text[i:j].strip()
|
||||
if len(body) > MAX_CHARS:
|
||||
body = body[:MAX_CHARS].rstrip() + '\n…(超长已截断,全文见技能 `agent-operating-rules` §2)'
|
||||
return body
|
||||
|
||||
|
||||
def _read(p):
|
||||
try:
|
||||
with io.open(p, encoding='utf-8', newline='') as f:
|
||||
return f.read()
|
||||
except Exception:
|
||||
return ''
|
||||
|
||||
|
||||
def _core(workdir):
|
||||
"""两级回退:① 本环境规则文件里的落地副本 ② 技能里的权威件。"""
|
||||
for p in (os.path.join(workdir, 'CODEBUDDY.md'),
|
||||
os.path.join(workdir, '.codebuddy', 'CODEBUDDY.md'),
|
||||
os.path.join(workdir, 'AGENTS.md')):
|
||||
c = _extract(_read(p))
|
||||
if c:
|
||||
return c, p
|
||||
p = os.environ.get('DSH_REPLY_CORE') or os.path.join(_skills_root(), CORE_REL)
|
||||
return _extract(_read(p)), p
|
||||
|
||||
|
||||
def _log(workdir, line):
|
||||
try:
|
||||
with io.open(os.path.join(workdir, LOG_REL), '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 main():
|
||||
if os.environ.get('DSH_REPLY_GUARD_OFF') == '1':
|
||||
return
|
||||
obj = _read_stdin()
|
||||
if not obj:
|
||||
return
|
||||
workdir = ''
|
||||
for k in ('cwd', 'project_dir', 'workspace', 'projectDir'):
|
||||
if isinstance(obj.get(k), str) and obj[k]:
|
||||
workdir = _norm(obj[k])
|
||||
break
|
||||
if not workdir or not os.path.isdir(workdir):
|
||||
return
|
||||
if os.path.exists(os.path.join(workdir, DISABLE_REL)):
|
||||
return
|
||||
|
||||
sid = str(obj.get('session_id') or obj.get('sessionId') or 'unknown')
|
||||
# 🔴 2026-10-02 **先查环境**(用户定案「技能使用时先检查环境配置」)——
|
||||
# ⛔ 定位失败时**必须报错 + 非零退出**,⛔ 不许再「静默零输出」。
|
||||
# 为什么:静默兜底把「机制坏了」和「没配规则」压成同一表象,日志只留一行 core=0。
|
||||
if not _env.skills_root():
|
||||
msg = ('[env] 技能库根定位失败(sid=%s)⇒ 本钩子无法定位权威规则块。\n'
|
||||
' ✅ 修法:设 DSH_SKILLS_ROOT,或跑 `python scripts/hooks/_env.py --stamp` 体检。'
|
||||
% sid[:8])
|
||||
_log(workdir, 'ENV-FAIL sid=%s|技能库根定位失败' % sid[:8])
|
||||
try:
|
||||
sys.stderr.write(msg)
|
||||
sys.stderr.flush()
|
||||
except Exception:
|
||||
pass
|
||||
raise SystemExit(3) # 🔴 非零 ⇒ 宿主侧可见(⛔ 不再静默)
|
||||
core, src = _core(workdir)
|
||||
# ★ 入口即留痕:命中与否都要能回答"它到底有没有被宿主调用"
|
||||
_log(workdir, 'entry|sid=%s|core=%d 字符|源=%s' % (sid[:8], len(core), src))
|
||||
if not core:
|
||||
# 🔴 区分两种「空」:环境坏了(该报)vs 规则块真的没配(正常,静默)
|
||||
_log(workdir, 'EMPTY sid=%s|技能库根=%s|⚠️环境正常但规则块读不到'
|
||||
% (sid[:8], _env.skills_root()))
|
||||
return
|
||||
|
||||
ctx = ('【回复排版闸门 · 强遵循】本机已把「回复形态」定为**强遵循**规则(源:%s)\n'
|
||||
% os.path.basename(src).replace('.md', '') + core +
|
||||
'\n(以上每条都是硬约束;发出前过一遍。⛔ 表格/长散文/碎标签堆叠三者一律不许出现。)')
|
||||
_emit({'hookSpecificOutput': {
|
||||
'hookEventName': 'UserPromptSubmit',
|
||||
'additionalContext': ctx,
|
||||
}})
|
||||
_log(workdir, 'HIT sid=%s|%d 字节' % (sid[:8], len(ctx.encode('utf-8'))))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
try:
|
||||
main()
|
||||
except Exception:
|
||||
pass # fail-open:钩子绝不因自己出错而挡人
|
||||
sys.exit(0)
|
||||
@@ -0,0 +1,453 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""session-log-guard.py —— 「会话诊断日志 · 事前叫停」钩子(WorkBuddy / CodeBuddy)
|
||||
|
||||
═══ 为什么需要它(2026-10-01 取证 · 结论见 接续包_日志事前叫停_20261001.md)═══
|
||||
宿主把每次工具调用写成 ≈145 帧 = ≈37 KB 诊断日志(零去重零节流,**其中 97.8% 是零信息唤醒行**)⇒
|
||||
`10 MiB ÷ 37 KB ≈ 280 次调用` 就是一个会话的**物理寿命**;撞上 10 MiB 上限即 **拒写 ⇒
|
||||
界面静默哑掉、用户零感知**。而全平台**只做了事后拦截**(`collabd.py::_deaf_sids()` 管"哑了不许再投"),
|
||||
**没有任何机制在"快哑了"时叫停** —— 本钩子=补这道**事前**闸。
|
||||
🔴 但请注意**病根不在"每次调用贵",而在"调用次数多"**:2026-10-01 会话 `ee3c2d82` 自证 ——
|
||||
8 分钟内跑了 **166 次调用(≈20 次/分,是参考会话 10.5 次/分的 2 倍)** ⇒ 日志 8 分钟就 5.9 MB;
|
||||
而其后**空闲 34 分钟写 0 行**(坐实"增长只与工具在跑成正比、与挂钟时间无关")。
|
||||
⚠️ 本钩子**只叫停、不降速**:真正降速要**压调用次数** + 上报宿主(那 97.8% 本就不该写)。
|
||||
|
||||
═══ 它只做一件事 ═══
|
||||
只读**本会话诊断日志的字节数**(`os.path.getsize`,⛔ **绝不 grep 日志内容** —— 否则判定本身拖慢会话)。
|
||||
达档 ⇒ 通过 `hookSpecificOutput.additionalContext` 注入一段提醒;未达档 ⇒ **零输出**。
|
||||
|
||||
🟡 软档 ≥ 5 MiB ⇒ 开始写交接材料
|
||||
🔴 硬档 ≥ 8 MiB ⇒ **先试一次「就地回收」**;成功 ⇒ **不必停手**(会话继续跑)
|
||||
|失败 ⇒ 退回原口径:立刻停手 + 按 `CODEBUDDY.md §G` 建接续会话
|
||||
|
||||
🔴 **「就地回收」是什么、依据什么**(2026-10-01 实测,推翻了当天更早的相反结论):
|
||||
把**本会话自己的** `<sid>.log` 改名为 `<sid>.log.recycled-<时间戳>`(⛔ 改名、**不删**)⇒
|
||||
宿主立刻**重建**同名新文件并**继续写入**(实测:改名后紧邻的下一次读数里原文件名已回来,
|
||||
文件冻结在旧尺寸)⇒ 会话因此**不必再因撞 10 MiB 而停手**。
|
||||
⚠️ 对照:当天更早的 scratch 复现(子进程持续 append、父进程在写入进行中改名 ⇒ `WinError 32`)
|
||||
只证明了**"正在被写的那一瞬"**不可改名;**本次实测在真实会话里一次成功** ⇒ 宿主并非"句柄常开",
|
||||
而是**每次日志批写开-写-关** ⇒ 工具调用的间隙里句柄是**关着的**。
|
||||
⇒ 判语:**能回收就回收(会话不被截断),回收不了才叫停(fail-safe 方向=退回旧行为)**。
|
||||
⛔ 只试**一次**,失败立即退回叫停口径 —— ⛔ **不做重试式硬撞**。
|
||||
⛔ 只动**本会话自己那一份**(按 `session_id` 精确拼名),⛔ 绝不碰别人的日志。
|
||||
开关:`DSH_SLG_NO_RECYCLE=1` ⇒ 只关回收、**保留**软/硬档叫停。
|
||||
|
||||
⛔ **不重复造轮子**:上下文 token / 工具调用次数的分级+去重+注入**已由 `stop-dialog-guard.py` 覆盖**
|
||||
(其 `session_budget()` / `budget_note()`)。本脚本的**真实增量只有"日志字节"这一档**,⛔ 不要再抄一份。
|
||||
|
||||
═══ 挂点 ═══
|
||||
`PostToolUse`(每次工具调用后)+ `UserPromptSubmit`(每次用户消息到达时)。
|
||||
⚠️ 为什么要两个:日志既随工具调用涨,也随每次模型输出涨 —— 会话闲下来只剩后者,单挂 PostToolUse 会漏。
|
||||
|
||||
═══ 硬纪律(都不许省)═══
|
||||
1. 🔴 **输出必须走 `sys.stdout.buffer.write(bytes)`**。本机 python 在 hook 环境下 stdin/stdout 可能是
|
||||
**cp936**(`-E` 会屏蔽 `PYTHONUTF8`)⇒ 文案里的 `⛔` / `🟡` 会 `UnicodeEncodeError`
|
||||
⇒ **stdout 为空 ⇒ 宿主当"无输出"静默放行**(=钩子白装,且看不出坏在哪里)。
|
||||
2. 🔴 **去抖**:同一会话**同一档位只报一次**(升级才再报)。
|
||||
3. 🔴 **状态落盘用「临时文件 + `os.replace`」原子替换,⛔ 不用文件锁** ——
|
||||
2026-09-30 本机实测:文件锁在隔离目录里全绿,接进生产后 `--once` 与 6 个并发 `--report`
|
||||
**全部 rc=124 卡死**(`session-mechanism/scripts/collabd.py` 注释里有完整登记)。
|
||||
本脚本用**乐观写法**:读→改→写临时文件→`os.replace`;并发下最坏后果=多报一条,**不会卡死**。
|
||||
4. 🔴 **性能 < 50 ms**:只做 `getsize` + 一个小 JSON 的读改写;⛔ 不读转录、⛔ 不读 settings.json、
|
||||
⛔ 不 grep 任何大文件。本脚本被**每次工具调用**触发 ⇒ 任何 O(日志大小) 的动作都不可接受。
|
||||
5. **fail-open**:任何异常 ⇒ 静默 exit 0(钩子绝不能因自身故障干扰会话)。
|
||||
6. **急停**:env `DSH_SLG_OFF=1`,或 `<工作区>/.workbuddy/session-log-guard.disabled` 存在。
|
||||
7. **自证**:命中才写一行 `<工作区>/.workbuddy/session-log-guard.log`;排查期可设
|
||||
`DSH_SLG_LOG_ALL=1` 让**每次调用**都留痕(用来回答"宿主到底调没调我")。
|
||||
|
||||
退出码始终 0;决策只通过 stdout 的 JSON 表达。
|
||||
|
||||
═══ 安装(`<CODEBUDDY_CONFIG_DIR>/settings.json` 的 `hooks` 段)═══
|
||||
"PostToolUse": [{ "hooks": [{ "type": "command",
|
||||
"command": "\"<python>\" -S \"<此脚本>\"", "timeout": 10 }] }],
|
||||
"UserPromptSubmit": [ …既有条目… , { "hooks": [{ "type":"command", "command": "\"<python>\" -S \"<此脚本>\"", "timeout": 10 }] }]
|
||||
🔴 **可以加 `-S`**(跳过 site 初始化 ⇒ 单次调用实测 84 ms → 52 ms;本脚本**只用标准库** ⇒ 安全)。
|
||||
🔴 **⛔ 绝不可加 `-E` / `-I`** —— 它们会忽略 `PYTHONUTF8` / `PYTHONIOENCODING` ⇒ stdin/stdout 回退 **cp936**
|
||||
(`stop-dialog-guard.py` docstring 里有完整事故记录:`-S -E` 曾让钩子"看起来从未被调用"整整一天)。
|
||||
⚠️ 本机实测:`settings.json` 的 hooks **下一次调用即生效**(⛔ 无需重启宿主,2026-10-01 复核)。
|
||||
⚠️ `07-scripts/` 改名 / 改名后必须**同批**改宿主 hooks 入口 —— 见库内 `CODEBUDDY.md`
|
||||
(原写「5 条 hook 入口」,2026-10-01 起为 **7 条**:lock-guard ×2 / bash-output-guard / skill-load-guard /
|
||||
stop-dialog-guard / **session-log-guard ×2**)。
|
||||
"""
|
||||
# [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
|
||||
|
||||
# ── 阈值(口径来源:`CODEBUDDY.md §G` 档 2 · 2026-10-01 用户拍板「B方案」)──────────
|
||||
SOFT_BYTES = 5 * 1024 * 1024 # 5 MiB ⇒ 开始写交接材料
|
||||
HARD_BYTES = 8 * 1024 * 1024 # 8 MiB ⇒ 就地回收;回收不成才停手 + 建接续会话
|
||||
# 🔴 为什么不是 10 MiB:宿主上限即 10 MiB,且实测**一次交接本身烧 ≈1.9 MiB**
|
||||
# ⇒ 8 MiB 已是**下限**,⛔ 不得再往上调(调到 9 以上 = 交接材料写不完就撞顶)。详见 §G。
|
||||
# ♻️ 回收阈值=硬档(**同一个点**):到这一点"要么回收、要么叫停",⛔ 不另设第三个阈值
|
||||
# (多一个阈值就多一条要维护的口径;且 8 MiB 已实测是"还来得及"的下限)。
|
||||
RECYCLE_MIN_BYTES = HARD_BYTES
|
||||
|
||||
# 🔴 **验收通道**(⛔ 日常**没有这个文件** ⇒ 行为与上面两个常量完全一致)
|
||||
# 为什么必须有:本钩子的真实触发条件是"日志 ≥5 MiB",而正常会话远达不到 ⇒
|
||||
# 在 **⛔ 不动生产日志一字节** 的前提下,若不开口子就**无法在真实会话里验收**
|
||||
# (只能本地跑 ⇒ 按交付门禁**不算交付**:宿主到底调没调我、注入通道通不通,都还是未知)。
|
||||
# 形态:`<工作区>/tmp/.session-log-guard.verify.json`,例
|
||||
# {"sid": "ee3c2d82", "soft_mib": 0.3, "hard_mib": 0.6}
|
||||
# · `sid` ⇒ **把口子收窄到单个会话**(前缀匹配)⇒ 验收期间其它会话**零影响**,不会误伤别人。
|
||||
# · 用完**立刻删**(本机 `tmp/` 收口即清);只要文件在,每次触发都会往日志写 `VERIFY-ON` 自证。
|
||||
# ⚠️ 取不到 / 解析失败 / 无此文件 ⇒ 一律回落到真阈值(fail-safe 方向=按真阈值办事)。
|
||||
VERIFY_REL = os.path.join('tmp', '.session-log-guard.verify.json')
|
||||
|
||||
|
||||
def _verify(root):
|
||||
try:
|
||||
with io.open(os.path.join(root, VERIFY_REL), encoding='utf-8') as f:
|
||||
o = json.loads(f.read() or '{}')
|
||||
if isinstance(o, dict):
|
||||
return o
|
||||
except Exception:
|
||||
pass
|
||||
return {}
|
||||
|
||||
|
||||
def _mib_env(o, key, default):
|
||||
try:
|
||||
v = float(o.get(key) or 0)
|
||||
return int(v * 1024 * 1024) if v > 0 else default
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
STATE_REL = os.path.join('tmp', '.session-log-guard.level.json')
|
||||
LOG_REL = os.path.join('.workbuddy', 'session-log-guard.log')
|
||||
MAX_STATE_KEYS = 40 # 状态文件只留最近 N 个会话(防无限膨胀)
|
||||
|
||||
# 本脚本位于 <工作区>/dsh-server-docs/07-scripts/ ⇒ 往上三级即工作区(宿主没给 cwd 时的兜底)
|
||||
WS_FALLBACK = os.environ.get("DSH_WS_ROOT") or os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
|
||||
def _norm_path(p):
|
||||
"""MSYS / Git-Bash 风格路径 → Windows 风格(`/e/foo` → `E:/foo`)。
|
||||
|
||||
⛔ 同源铁律:**Windows 原生 python 会把 `/e/...` 解释成「当前盘根 + 相对路径」**
|
||||
⇒ 落到 `E:\\e\\...`。本工作区已有实证(E 盘根长出 `E:\\e\\` 影子目录)。
|
||||
"""
|
||||
try:
|
||||
m = re.match(r'^/([A-Za-z])(/.*)?$', str(p or ''))
|
||||
if m:
|
||||
return m.group(1).upper() + ':' + (m.group(2) or '/')
|
||||
except Exception:
|
||||
pass
|
||||
return p
|
||||
|
||||
|
||||
def _read_stdin_text():
|
||||
"""显式按 UTF-8 读 stdin(⛔ 不要用 `sys.stdin.read()`:cp936 下中文 payload 直接抛错)。"""
|
||||
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(见模块 docstring 硬纪律 1)。"""
|
||||
data = json.dumps(obj, ensure_ascii=False).encode('utf-8')
|
||||
try:
|
||||
sys.stdout.buffer.write(data)
|
||||
sys.stdout.buffer.flush()
|
||||
except Exception:
|
||||
try:
|
||||
sys.stdout.write(data.decode('utf-8', 'replace'))
|
||||
sys.stdout.flush()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _root(payload):
|
||||
return (_norm_path(os.environ.get('CODEBUDDY_PROJECT_DIR')
|
||||
or os.environ.get('DSH_WORKSPACE')
|
||||
or (payload or {}).get('cwd')
|
||||
or WS_FALLBACK))
|
||||
|
||||
|
||||
def _config_dir():
|
||||
"""🔴 2026-10-02:共用 `_env.config_dir()` —— ⛔ 不再直接拼 `~`
|
||||
(Windows 上 `~` 通常不是真配置目录;真值顺序=env → roots.env)。"""
|
||||
return _norm_path(_env.config_dir())
|
||||
|
||||
|
||||
def _log_path(sid):
|
||||
"""本会话诊断日志的绝对路径(返回 [] 表示还没落盘 / 取不到)。
|
||||
|
||||
真实形态(2026-10-01 实测):
|
||||
<configDir>/logs/<YYYY-MM-DD>/sdk/conversations/<session_id>.log
|
||||
⚠️ 跨零点:会话可能昨天开的 ⇒ 今天那份不存在时**回看昨天**(只多一次 `getsize`,可忽略)。
|
||||
"""
|
||||
base = os.path.join(_config_dir(), 'logs')
|
||||
out = []
|
||||
now = time.time()
|
||||
for back in (0, 1):
|
||||
d = time.strftime('%Y-%m-%d', time.localtime(now - back * 86400))
|
||||
p = os.path.join(base, d, 'sdk', 'conversations', '%s.log' % sid)
|
||||
try:
|
||||
os.path.getsize(p) # 🔴 只取**字节数**;⛔ 绝不打开/搜内容
|
||||
except OSError:
|
||||
continue
|
||||
out.append(p)
|
||||
return out
|
||||
|
||||
|
||||
def _level_of(size, soft=SOFT_BYTES, hard=HARD_BYTES):
|
||||
if size >= hard:
|
||||
return 2
|
||||
if size >= soft:
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
def _read_state(root):
|
||||
try:
|
||||
with io.open(os.path.join(root, STATE_REL), encoding='utf-8') as f:
|
||||
d = json.loads(f.read() or '{}')
|
||||
return d if isinstance(d, dict) else {}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def _write_state(root, st):
|
||||
"""临时文件 + `os.replace` 原子替换(⛔ 不用文件锁 ⇒ 见 docstring 硬纪律 3)。"""
|
||||
p = os.path.join(root, STATE_REL)
|
||||
d = os.path.dirname(p)
|
||||
try:
|
||||
os.makedirs(d, exist_ok=True)
|
||||
tmp = '%s.tmp.%d' % (p, os.getpid())
|
||||
with io.open(tmp, 'w', encoding='utf-8', newline='\n') as f:
|
||||
f.write(json.dumps(st, ensure_ascii=False))
|
||||
os.replace(tmp, p)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
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 _mib(n):
|
||||
return '%.2f MiB' % (n / 1048576.0)
|
||||
|
||||
|
||||
def _recycle(paths, min_bytes=RECYCLE_MIN_BYTES):
|
||||
"""♻️ 就地回收**本会话自己**的日志:改名挪开,让宿主重建。
|
||||
|
||||
返回 `(源路径, 目标路径, 错误串)`;未达到回收条件或失败 ⇒ 源路径为 `None`。
|
||||
🔴 只试**一次**:`os.rename` 抛 `OSError`(典型 `WinError 32`=文件被占用)就**立刻返回**,
|
||||
⛔ 不 sleep、⛔ 不重试、⛔ 不降级成"删/截断"(那会毁掉唯一的取证载体)。
|
||||
⚠️ 后缀**刻意不以 `.log` 结尾** ⇒ 与 `wb-logcap-sweep.py` 的 `*.log` 匹配**天然不重叠**
|
||||
(清扫器不会二次搬运本文件)。
|
||||
"""
|
||||
best, bestsz = None, -1
|
||||
for p in paths: # `_log_path` 已按 sid 精确拼名 ⇒ 这里只会是本会话自己的
|
||||
try:
|
||||
s = os.path.getsize(p)
|
||||
except OSError:
|
||||
continue
|
||||
if s > bestsz:
|
||||
best, bestsz = p, s
|
||||
if not best or bestsz < int(min_bytes):
|
||||
return None, None, 'below-threshold'
|
||||
dst = '%s.recycled-%s' % (best, time.strftime('%Y%m%d-%H%M%S'))
|
||||
try:
|
||||
os.rename(best, dst)
|
||||
except OSError as e:
|
||||
return None, None, '%s(errno=%s, winerror=%s)' % (type(e).__name__, e.errno,
|
||||
getattr(e, 'winerror', None))
|
||||
return best, dst, ''
|
||||
|
||||
|
||||
def _recycle_note(size, src, dst, hard=HARD_BYTES):
|
||||
"""回收**成功**时注入的提醒(⛔ 与"叫停"是两套口径:这套说的是"可以继续")。
|
||||
|
||||
⚠️ `hard` 取**实际生效值**(⛔ 不写死常量)⇒ 验收通道把阈值调小时,提示里的数字跟着对。
|
||||
"""
|
||||
return ('♻️【日志就地回收 · 已复位】本会话诊断日志已达 **%s**(硬档 %s)⇒ 已**当场回收**:'
|
||||
'把 `%s` 改名为 `%s`(⛔ 只改名、**没删**,取证内容仍在同目录),'
|
||||
'宿主会**立刻重建**同名文件并**继续写入** ⇒ **本会话可继续,⛔ 不必停手、'
|
||||
'⛔ 不必建接续会话**(2026-10-01 本机实测:改名后紧邻读数里原文件名已回来、旧件冻结)。'
|
||||
# ⚠️ 下面有**字面量百分号** ⇒ 必须写成 `%%`(本串走 `%` 格式化;漏了会抛
|
||||
# `TypeError: not enough arguments for format string` ⇒ 提示发不出去)
|
||||
'\n⚠️ 但**病根没除**:日志增长只与"工具在跑"成正比(≈37 KB/次调用,其中 97.8%% 是'
|
||||
'零信息唤醒行)⇒ 仍请压住频率:① 合并命令、一次取多个读数;② 大输出先落盘再只读关键行'
|
||||
'(⛔ 不 `cat` 大文件);③ 让脚本内部聚合、只 print 摘要。'
|
||||
'\n(下一次再涨到硬档会再回收一次;⛔ 若某次回收失败,本钩子会退回"停手+建接续"口径。)'
|
||||
% (_mib(size), _mib(hard), os.path.basename(src), os.path.basename(dst)))
|
||||
|
||||
|
||||
def _note(level, size, soft=SOFT_BYTES, hard=HARD_BYTES):
|
||||
pct = int(size * 100 / (10 * 1024 * 1024)) # 占宿主 10 MiB 上限的比例
|
||||
soft_s, hard_s = _mib(soft), _mib(hard)
|
||||
head = ('🟡【日志事前叫停 · 软档】本会话诊断日志已达 **%s**(10 MiB 上限的 %d%%,软档 %s)。'
|
||||
% (_mib(size), pct, soft_s))
|
||||
if level == 2:
|
||||
head = ('🔴【日志事前叫停 · 硬档】本会话诊断日志已达 **%s**(10 MiB 上限的 %d%%,硬档 %s)。'
|
||||
% (_mib(size), pct, hard_s))
|
||||
why = ('为什么必须现在处理:宿主把**每次工具调用**写成 ≈145 帧 = **≈37 KB** 诊断日志'
|
||||
'(零去重零节流,⛔ 其中约 **97.8% 是零信息唤醒行** —— 每行 249 B,只有 `requestId` 与四个'
|
||||
'恒为 `false` 的布尔位在变)⇒ `10 MiB ÷ 37 KB ≈ 280 次调用` 就是本会话的**物理寿命**'
|
||||
'(2026-10-01 实测:某会话 8 分钟跑了 166 次调用 ⇒ 日志 5.9 MB,≈20 次/分);'
|
||||
'撞顶即**拒写 ⇒ 界面静默哑掉、用户零感知**,而协作程序的投递仍会记 `ok:true`(假绿)。'
|
||||
'🔴 **病根是"调用次数多",不是"每次调用贵"**;且**增长只与"工具在跑"成正比、与挂钟时间无关**'
|
||||
'(实测:活跃 630 KB/分 ↔ 空闲 34 分钟 0 行)。')
|
||||
if level == 1:
|
||||
act = ('处置(软档):**现在就开始写交接材料**(接续包落工作区根),并立刻压住增长 —— '
|
||||
'① 合并命令,一次取多个读数;② 大输出先落盘再只读关键行(⛔ 不 `cat` 大文件);'
|
||||
'③ 让脚本内部聚合、只 print 摘要。⛔ 不要再开新任务。')
|
||||
else:
|
||||
act = ('处置(硬档 · 顺序固定):① **立刻停手**,本轮不再开新任务;② 写接续包(含「不要重做」清单);'
|
||||
'③ **建接续会话**(走一次性 automation —— 自动化是**唯一**能开新会话的通道);'
|
||||
'④ 在**给用户的最后一条回复里用陈述句**告知「已登记自动接续,约 N 分钟后自动开新会话,不需要你操作」。'
|
||||
'⚠️ **钩子开不了新会话** ⇒ 这四步必须由**你自己**做完;本钩子只负责"叫"。')
|
||||
return head + '\n' + why + '\n' + act
|
||||
|
||||
|
||||
def main():
|
||||
raw = _read_stdin_text()
|
||||
payload = None
|
||||
if raw.strip():
|
||||
try:
|
||||
payload = json.loads(raw)
|
||||
except ValueError:
|
||||
payload = None
|
||||
if not isinstance(payload, dict):
|
||||
return
|
||||
root = _root(payload)
|
||||
ev = str(payload.get('hook_event_name') or '')
|
||||
sid = str(payload.get('session_id') or '')
|
||||
force_log = bool(os.environ.get('DSH_SLG_LOG_ALL'))
|
||||
if force_log:
|
||||
_log(root, 'invoked|event=%s|sid=%s' % (ev or '(none)', sid[-8:] or '-'))
|
||||
if not sid:
|
||||
return
|
||||
vf = _verify(root)
|
||||
if vf:
|
||||
_log(root, 'VERIFY-ON|sid_gate=%s|soft=%s|hard=%s' % (vf.get('sid') or '-',
|
||||
vf.get('soft_mib'), vf.get('hard_mib')))
|
||||
gate = str(vf.get('sid') or '')
|
||||
if gate and not sid.startswith(gate):
|
||||
return # 验收通道:只认指定会话
|
||||
soft = _mib_env(vf, 'soft_mib', SOFT_BYTES)
|
||||
hard = _mib_env(vf, 'hard_mib', HARD_BYTES)
|
||||
if os.environ.get('DSH_SLG_OFF'): # 急停(env)
|
||||
return
|
||||
try:
|
||||
if os.path.exists(os.path.join(root, '.workbuddy', 'session-log-guard.disabled')):
|
||||
return # 急停(闸刀文件)
|
||||
except Exception:
|
||||
pass
|
||||
paths = _log_path(sid)
|
||||
if not paths:
|
||||
return # 还没落盘 ⇒ 静默
|
||||
size = 0
|
||||
for p in paths:
|
||||
try:
|
||||
size = max(size, os.path.getsize(p))
|
||||
except OSError:
|
||||
pass
|
||||
lv = _level_of(size, soft, hard)
|
||||
if not lv:
|
||||
return # 🔴 未达阈值 ⇒ **零输出**
|
||||
st = _read_state(root)
|
||||
try:
|
||||
prev = int((st.get(sid) or [0])[0] or 0)
|
||||
except Exception:
|
||||
prev = 0
|
||||
if lv <= prev: # 同档只报一次(升级才再报)
|
||||
return
|
||||
|
||||
# ♻️ 硬档 ⇒ **先试一次**「就地回收」(依据见模块 docstring;2026-10-01 本机实测可行)
|
||||
# · 放在去重之后 ⇒ **每个档位只试一次** ⇒ ⛔ 天然不会"每次调用都硬撞"
|
||||
# · 成功 ⇒ 复位本会话档位(不复位的话后半程再也报不出来)+ 注入"可以继续"的提醒,**直接结束**
|
||||
# · 失败 ⇒ 一个字都不改,继续往下走原来的叫停口径(fail-safe 方向)
|
||||
if lv >= 2 and not os.environ.get('DSH_SLG_NO_RECYCLE'):
|
||||
# ⚠️ 传 `hard`(而不是模块常量)⇒ 验收通道把阈值调小时,**回收分支也跟着走**
|
||||
# (否则验收通道只能验"叫停"、验不到"回收" ⇒ 按交付门禁=没验)
|
||||
_src, _dst, _err = _recycle(paths, hard)
|
||||
if _src:
|
||||
st[sid] = [0, time.time()]
|
||||
if len(st) > MAX_STATE_KEYS:
|
||||
for k in sorted(st, key=lambda k: (st[k] or [0, 0])[-1])[:-MAX_STATE_KEYS]:
|
||||
st.pop(k, None)
|
||||
_write_state(root, st)
|
||||
_log(root, 'RECYCLE|size=%d B(%s)|%s -> %s'
|
||||
% (size, _mib(size), os.path.basename(_src), os.path.basename(_dst)))
|
||||
_emit({'hookSpecificOutput': {'hookEventName': ev or 'PostToolUse',
|
||||
'additionalContext': _recycle_note(size, _src, _dst, hard)}})
|
||||
return
|
||||
_log(root, 'RECYCLE-FAIL|size=%d B|%s ⇒ 退回叫停口径' % (size, _err))
|
||||
|
||||
now = time.time()
|
||||
st[sid] = [lv, now]
|
||||
if len(st) > MAX_STATE_KEYS: # 只留最近 N 个会话
|
||||
for k in sorted(st, key=lambda k: (st[k] or [0, 0])[-1])[:-MAX_STATE_KEYS]:
|
||||
st.pop(k, None)
|
||||
_write_state(root, st)
|
||||
_log(root, 'EMIT|level=%d|size=%d B(%s)|event=%s' % (lv, size, _mib(size), ev or '-'))
|
||||
_emit({'hookSpecificOutput': {'hookEventName': ev or 'PostToolUse',
|
||||
'additionalContext': _note(lv, size, soft, hard)}})
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
t0 = time.time()
|
||||
try:
|
||||
main()
|
||||
except Exception:
|
||||
try:
|
||||
import traceback
|
||||
_r = os.environ.get('CODEBUDDY_PROJECT_DIR') or os.environ.get('DSH_WORKSPACE') or WS_FALLBACK
|
||||
_log(_r, 'EXCEPTION|%s' % traceback.format_exc().strip().split('\n')[-1][:120])
|
||||
except Exception:
|
||||
pass
|
||||
# 性能自证(仅排查期):DSH_SLG_LOG_ALL=1 时把耗时也落一行
|
||||
if os.environ.get('DSH_SLG_LOG_ALL'):
|
||||
try:
|
||||
_log(os.environ.get('CODEBUDDY_PROJECT_DIR') or os.environ.get('DSH_WORKSPACE') or WS_FALLBACK,
|
||||
'elapsed=%.1f ms' % ((time.time() - t0) * 1000))
|
||||
except Exception:
|
||||
pass
|
||||
sys.exit(0)
|
||||
@@ -0,0 +1,354 @@
|
||||
#!/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 —— 决策方法族(→ 加载 `dsh-decision`)
|
||||
'决策方法',
|
||||
'自行决策', '自主决策', '自己决策', '自己拿主意',
|
||||
'别问我', '不要问我', '不用问我', '按你的规划', '按你的判断',
|
||||
'参考决策', '决策方法论',
|
||||
# 分组 2 —— 作业规则族(→ 加载 `agent-operating-rules`)
|
||||
# 🔴 2026-10-02 扩面:**「回复形态 / 排版」这一族原先一个词都没有**。
|
||||
# 用户看的就是"你这条回复长什么样"(原话「我发现你又忘记如何回复 执行结果了,
|
||||
# 是不是技能规则失效了」),而旧词表只有「按规则来」等 5 个窄词 ⇒ 这类点名
|
||||
# **从未命中过**:`skill-load-guard.log` 全量 1177 行里只有 12 次 HIT,
|
||||
# 且 10-01 / 10-02 **零命中**(最后一次真实 HIT 停在 2026-09-30 18:41)。
|
||||
'按规则来', '按规则做', '按作业规则', '遵守规则', '按规矩来',
|
||||
'回复排版', '执行结果排版', '回复格式', '怎么回复', '排版',
|
||||
)
|
||||
|
||||
# 命中词 → 该加载哪个技能(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 = ('按规则来', '按规则做', '按作业规则', '遵守规则', '按规矩来',
|
||||
'回复排版', '执行结果排版', '回复格式', '怎么回复', '排版')
|
||||
|
||||
|
||||
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):规则族与决策族给不同指令 ──────────────────
|
||||
hit_rules = [w for w in hits if w in _LOAD_RULES]
|
||||
if hit_rules:
|
||||
head = '检测到用户本轮**点名了作业规则**(命中:%s)。' % '、'.join(hit_rules)
|
||||
body = (
|
||||
'⛔ **不要凭记忆代替、也不要以"我已经知道规则"为由跳过**:\n'
|
||||
' **先调用 Skill 工具加载 `agent-operating-rules`**(通用作业规则 · 含"什么时候自决 / 什么时候上抛 / 回复排版 / 说话方式 / 不越界 / 多棒接力"),\n'
|
||||
' 然后才开始作答。若本轮涉及功能需求判类型 / 该不该上抛,**同时加载 `dsh-decision`**。\n\n'
|
||||
'为什么强制:该技能是**跨工作区通用**的(不绑定任何具体项目),'
|
||||
'但它与其他技能一样**靠 description 匹配按需加载 —— 没有机制保证它在任何工作区都被取用**'
|
||||
'(它的 description 里已写「当你在任何一个工作区开始任务…」,仍可能漏)。'
|
||||
'本条注入即补上这个缺口。\n'
|
||||
'(若判定本轮确实与作业规则无关,可在作答中一句话说明后继续 —— 但**不要静默跳过加载**。)'
|
||||
)
|
||||
else:
|
||||
head = '检测到用户本轮**点名了方法**(命中:%s)。' % '、'.join(hits)
|
||||
body = (
|
||||
'⛔ **不要凭记忆代替、也不要以"我已经知道判据"为由跳过**:\n'
|
||||
' **先调用 Skill 工具加载 `dsh-decision`**(决策总入口 · 决策方法论 + 功能优先协作协议 + §4.5 规则冲突裁决顺序 + 上抛前三问),\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)
|
||||
@@ -0,0 +1,689 @@
|
||||
#!/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, 工具调用累计次数);读不到返回 (None, None)。"""
|
||||
try:
|
||||
if os.path.getsize(path) > 64 * 1024 * 1024:
|
||||
return None, None
|
||||
except OSError:
|
||||
return 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)
|
||||
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user