init: workbuddy_skills 重建,仅收录 session-mechanism

- 按用户指示清空原有 25 技能内容,只提交 session-mechanism(57 文件)
- 附 .gitignore(产物 + 本机凭据)
- 令牌明文已脱敏(历史 .neodata_token 与 pitfalls 引用均不入库)
- 本提交为孤儿提交(父提交为空),历史自此重新开始
This commit is contained in:
admin committed 2026-10-05 14:13:24 +08:00
commit 19101acd65
58 files changed
+36134

No files matched your search

+356
View File
@@ -0,0 +1,356 @@
# -*- 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 生成)
# 🔴🔴 2026-10-04 单包自包含改造:识别特征改成**多个候选**,⛔ 不再只认
# `agent-operating-rules`(那是**另一个**技能 ⇒ 只装本包时会把真技能库判成"不存在")。
# 🔴 旧写法的病:`skills_root()` / `env_check()` 三处都拿`(root)/_SNIFF).is_dir()` 当
# "技能库在不在"的判据 ⇒ **单包环境下永远判否**(实测:隔离库只搬本包时全部降级)。
# ✅ 正解:任一候选目录存在即认;⛔ 全都没有才算定位失败(硬规矩②「找不到就报错不静默」不变)。
# 判据数组**第一个元素 = 本包自己** ⇒ 只拷本包也能定位。
_SNIFFS = ("session-mechanism", "agent-operating-rules")
def _has_skill(root) -> bool:
"""技能库根的判据 =⛔ 至少存在一个识别特征目录(⛔ 只认目录真存在,不猜)。"""
if not root:
return False
try:
return any((Path(_norm(root)) / s).is_dir() for s in _SNIFFS)
except Exception:
return False
# ⛔ 保留旧名给外部引用(⛔ 外部脚本若 import 它做文案,仍能拿到第一个候选)
_SNIFF = _SNIFFS[0]
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 _norm_ws(p) -> str:
"""工作区路径**归一化**(判归属专用)。
🔴 为什么不能直接比字符串:`sessions.cwd` / `automations.cwds` 里
**同一区出现过反斜杠与正斜杠两种写法**(`E:\\ProgramData\\…` vs `E:/ProgramData/…`),
末尾斜杠有时在有时不在 ⇒ **逐字比会把同一个区判成两个** ⇒ 归属误判、裂组。
⇒ 统一:转正斜杠、去尾斜杠、转小写。
"""
s = str(p or "").strip().replace("\\", "/")
while s.endswith("/") and len(s) > 3:
s = s[:-1]
return s.lower()
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 _has_skill(v):
return _norm(v)
d = Path(__file__).resolve().parent
for _ in range(6): # 沿 __file__ 上溯(换机可用,⛔ 不写死盘符)
cand = d.parent
if _has_skill(cand):
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 _has_skill(cand):
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:
"""🔍 **只读体检**:返回逐项读数,⛔ **绝不写标记**(硬规矩③)。
🔴🔴 2026-10-04 补上**工作区部署**那一半(用户:「使用技能时就要检查环境、做相关配置,
⚠️ **说了无数遍但没实现**」)。**为什么必须补**:
原来的 `env_check` 只查两件事 —— 技能根能不能定位、钩子有没有注册
⇒ **「技能被复制进某个工作区、但那个工作区根本没部署」这类状态它永远查不出来**
⇒ 没人提醒、没人负责 ⇒ 机制在该工作区**跑不起来却看不出为什么**。
🔴 实测坐实:`vibe-product` 复制了完整副本(47 个文件),但**无 `goal.json`、
无工作区级 `collabd.py`、配置是旧格式** ⇒ 旧版 `env_check` 读数全绿。
"""
rep = {"scope": scope, "checked_at": time.strftime("%Y-%m-%dT%H:%M:%S"),
"config_dir": config_dir(), "skills_root": skills_root(), "bad": [],
"warn": []}
if not rep["skills_root"]:
rep["bad"].append("skills_root 定位失败(⛔ 已不回落~)")
elif not _has_skill(rep["skills_root"]):
rep["bad"].append("skills_root 存在但缺识别特征目录 %s:%s"
% ("/".join(_SNIFFS), 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
# ── 🔴🔴 补:**工作区部署**体检(原来完全没有这一段)──────────────────
# ⚠️ `ws` 传空就**回落成技能根的父目录**(⛔ 不猜:副本场景下技能根在
# `<工作区>/.workbuddy/skills/<包>/`,往上一级才是工作区)。
_ws = _norm(ws or "")
if not _ws and rep["skills_root"]:
_ws = _norm(str(Path(rep["skills_root"]).parent.parent))
dep = {"workspace": _ws, "deployed": None, "missing": [], "stale": []}
if not _ws:
dep["missing"].append("**工作区根定位不到**(⛔ 传 `ws=` 或让技能根可定位)")
else:
w = Path(_ws)
cfg_p = w / ".workbuddy" / "collab" / "collabd.config.json"
goal_p = w / "tmp" / "supervise-inbox" / "goal.json"
# ① 部署配置:⛔ 不存在 = 没部署(`collabd.py` 不认这个工作区)
dep["config"] = {"path": _norm(str(cfg_p)), "exists": cfg_p.exists()}
if not cfg_p.exists():
dep["missing"].append("**部署配置不存在**(`collabd.py` 不认这个工作区)"
" ⇒ 跑 `init_workspace.py <工作区>`")
# ② 目标文件:⛔ 没有它,机制没有目标可推
dep["goal"] = {"path": _norm(str(goal_p)), "exists": goal_p.exists()}
if not goal_p.exists():
dep["missing"].append("**目标文件不存在**(机制没有目标可推)"
" ⇒ 建 `tmp/supervise-inbox/goal.json`")
# ③ inbox 骨架
if not (w / "tmp" / "supervise-inbox").is_dir():
dep["missing"].append("**inbox 骨架缺失**(`tmp/supervise-inbox/`)")
# ④ 配置**格式是否过期**(有配置却缺现行键 = 旧格式 = 行为不可预期)
if cfg_p.exists():
try:
_c = json.loads(cfg_p.read_text(encoding="utf-8"))
need = ("log", "supervise_interval", "wake_enable")
gone = [k for k in need if k not in _c]
if gone:
dep["stale"].append("配置缺现行键 %s ⇒ **旧格式**(升级技能后不会自动补)"
% "/".join(gone))
except Exception as e:
dep["missing"].append("部署配置**读不出/解析失败**:%r" % (e,))
dep["deployed"] = not dep["missing"]
rep["deploy"] = dep
# ⚠️ 归类:**没部署进 `warn` 而不塞 `bad`** ——
# 「这个工作区还没启用机制」是**合法状态**(技能可只作资产存放),
# ⛔ 不该让只装了技能副本的工作区一律体检红。查 `deploy.deployed` 即可。
rep["warn"].extend(dep["missing"] + dep["stale"])
# 🔴🔴🔴 ⑤ **工作区归属核对**(S 红线 · 2026-10-04 用户当场纠正后补)
# 病根:用户在本工作区下达「监控 vibe-product」的目標,我把任务会话建到了
# `vibe-product` ⇒ 那条会话在本工作区看板/台账里**直接不可见**,
# 而本工作区恰恰是问题最集中、最需要它的地方。⛔ 没有任何机制提醒过。
# ⇒ 这里做成**体检项**:技能一被用起来就核对一次归属,⛔ 不靠人记住。
att = {"this_ws": _ws, "declared": [], "mismatch": [], "peer_missing": []}
try:
_db = Path(config_dir()) / "workbuddy.db"
if _db.exists():
import sqlite3
_con = sqlite3.connect("file:%s?mode=ro" % _db.as_posix(), uri=True, timeout=8)
try:
_con.execute("PRAGMA busy_timeout=8000")
# 读法:⛔ **不按 `sessions.cwd` 分组**(后台会话可带独立 cwd),
# 而按 `automations.cwds` 的字面正斜杠形态 —— 那才是「这条线归谁」。
_rows = _con.execute(
"SELECT name, cwds, status FROM automations").fetchall()
finally:
_con.close()
_this = _norm_ws(_ws)
for _nm, _cw, _stt in _rows:
try:
_c = json.loads(_cw or "[]")
except Exception:
_c = []
for _one in _c:
att["declared"].append({"name": _nm, "cwd": _one,
"status": _stt, "is_self": _norm_ws(_one) == _this})
# 🔴 判「有没有本工作区自己的线」:⛔ **只数有名字的**(按库实测:
# 机制里存在多条**无 `name` 的排期** ⇒ 拿来凑数会恒绿)
att["self_lines"] = [d for d in att["declared"] if d["is_self"]]
else:
att["peer_missing"].append("宿主库 `%s` 读不到(⛔ 归属无法核对)" % _db)
except Exception as e:
att["peer_missing"].append("归属核对失败:%r" % (e,))
if not att.get("self_lines") and not att["peer_missing"]:
att["peer_missing"].append(
"**本工作区名下没有任何协作线**(`automations.cwds` 里找不到它)"
" ⇒ 目标/会话会落到别的工作区去,本区看板与台账看不到")
rep["attribution"] = att
rep["warn"].extend(att["peer_missing"])
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 "", "/".join(_SNIFFS)))
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,223 @@
#!/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…(超长已截断,全文见本包 `references/03-回复排版-核心块.md`)'
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):
"""三级回退:① 本环境规则文件里的落地副本 ② **本包内联件** ③ 外部技能里的权威件。
🔴🔴 **2026-10-04 单包自包含改造**(用户定案:复制**一个**技能到别的机器,这些功能都要能用):
原来第② 级直接指向**另一个技能** `agent-operating-rules/references/回复排版-核心块.md`
⇒ 只装本包时它找不到(实测降级成`core=0 字符`,日志里看不出是"机制坏"还是"没配规则")
⇒ 现在**把那份核心块内联进本包** `references/03-回复排版-核心块.md`(**内容守恒,逐字搬**)
⇒ 优先读**包内**;包内没有才回退外部那份(保留与 `agent-operating-rules` 的一致性)。
⚠️ 顺序⛔ 不许换:包内优先 ⇒ 只拷本包也能用;外部优先 ⇒ 又变成"看别人脸色"。
"""
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
# ② 本包内联件(⛔ 单包自包含的主力路径)
# ⚠️⚠️ 上溯**两级**到包根:`__file__` = `<包>/scripts/hooks/reply-style-guard.py`
# ⇒ `dirname` ①= hooks/,②= scripts/,③= **包根**。
# 🔴 2026-10-04 首次改造时只上溯了两级(落到 `scripts/`)⇒ 找不到本档 ⇒ 静默零输出
# (实测:干净工作区里 hook 输出空 JSON,而日志里看不出是"路径算错")。
# ⇒ 判据:**必须 `isfile()` 验到文件**,⛔ 不许"算出来就信"。
_hooks = os.path.dirname(os.path.abspath(__file__))
_pkg = os.path.dirname(os.path.dirname(_hooks)) # scripts/hooks → scripts → 包根
p = os.path.join(_pkg, 'references', '03-回复排版-核心块.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)
@@ -0,0 +1,298 @@
# -*- coding: utf-8 -*-
"""supervise-ensure-hook.py —— UserPromptSubmit 钩子:**开工那一刻自动把常驻补上**。
🔴🔴 为什么必须有这个脚本(2026-10-04 用户两次纠正 + 一次「我要说几遍」)
用户原话:「**调用执行任务完成目标时,判断是否已经开启,如果没开启就要开启**」
⇒ 这句话是**技能必须自己做的动作**,⛔ 不是「写进文档等我照着做」。
🔴 实测的病根(`vibe-product` 区)
· 该区常驻**停 16.4 小时**,而我在那边「一步一步按文档走」——第一步就没人做
· `ensure_supervise()` **早就存在**、能自己起常驻,
⛔ 但它**只被文档和自测引用**,**没有任何地方在开工时调它**
⇒ 能力齐了,**缺一根接线**。本脚本就是那根接线。
⚖️ 三条边界(⚠️ 越界就是又一次「凭空造需求」)
1. ⛔ **不碰「没启用」的工作区** —— 用户 2026-10-04 口径:
「**没有目标就说明 当前用户是用的是基础会话方式**」⇒ 那是**正常态**,
**不是故障**,**不需要"开启"**。⇒ 判据=`goal.json` 有**且** `lifecycle` 有效。
2. ⛔ **不代用户开目标** —— 想让它有 ⇒ **用户开**(`goal_life_set` 是用户动作)。
3. ⛔ **只做「常驻在不在」这一件事** —— ⛔ 不建任务会话、⛔ 不建检查会话
(那两件是**主会话**的判断,见 SKILL.md T 表)。
⇒ 越权等级:`本脚本 ≤ 第 3 条`;`第 2 条` 用户专属;`建会话` 主会话专属。
输出:`hookSpecificOutput.additionalContext` 注入一句**短**提醒(⛔ 不长篇大论)。
退出码恒 0(⛔ 钩子失败 ⇔ 用户的话被吞 ⇒ 最坏的结果)。
"""
from __future__ import annotations
import io
import json
import os
import subprocess
import sys
import time
from pathlib import Path
HERE = Path(__file__).resolve().parent
SKILL = HERE.parent.parent
try:
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
except Exception:
pass
# 🔴🔴 2026-10-04 用户问「为什么 vibe 会话说技能没有调整」时挖出的真缺陷:
# **本钩子在两个工作区全是空转,一次都没生效过。**
# 病根=我写它时只读 `DSH_WS_ROOT`,而 `settings.json` 的 hook 条目
# **没有 env 字段** ⇒ 宿主 env 里 `DSH_WS_ROOT` 恒空
# (实测 Process/User/Machine 三级全空)⇒ 第①步就 `_noop()` 返回。
# ⇒ 「能力齐了、缺一根接线」我以为接上了,**其实那根线接在墙上没插进插头**。
# ✅ 正确取法=**照抄 `wb-result-hook.py` / `lock-guard-hook.py`**:
# ① stdin 的 `payload.cwd`(宿主一定给)
# ② `roots.env` 的 `DSH_WS_ROOT`(副本专属,⛔ 不能只靠宿主 env)
def _sm_load_roots() -> None:
"""把 `roots.env` 以 setdefault 注入 env(宿主 env 优先,⛔ 不覆盖)。"""
p = SKILL / "roots.env"
try:
for line in p.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
k, v = line.split("=", 1)
os.environ.setdefault(k.strip(), v.strip())
except Exception:
pass
_sm_load_roots()
COLLABD = SKILL / "scripts" / "collabd.py"
PY = sys.executable
def _norm(p: str) -> str:
"""`E:\\x\\y` / `E:/x/y` ⇒ 统一正斜杠 + 去尾斜杠(⛔ 与 `tasks.json` 去重键同形)。"""
return (p or "").replace("\\", "/").rstrip("/")
def _stdin_payload() -> dict:
"""读 hook stdin(⛔ 最多 1 MiB,⛔ 失败当没有)。
🔴🔴 **必须缓存**(2026-10-04 实测栽到):
`sys.stdin.buffer.read()` 是**一次性**的 —— 读第一次之后再调
`_ws_root()` 只会拿到空串 ⇒ 表现为「注入零输出」,
**和「没触发」一模一样** ⇒ 又一次假绿。
⇒ 只读一次、存进 `_PAYLOAD`,后续一律走缓存。
"""
global _PAYLOAD
if _PAYLOAD is None:
try:
raw = sys.stdin.buffer.read(1024 * 1024)
_PAYLOAD = json.loads(raw.decode("utf-8", "replace")) if raw else {}
if not isinstance(_PAYLOAD, dict):
_PAYLOAD = {}
except Exception:
_PAYLOAD = {}
return _PAYLOAD
_PAYLOAD = None
def _ws_root() -> str:
"""🔴 工作区真源=**stdin 的 `payload.cwd`**(与 `wb-result-hook` 同一口径)。
⚠️ 为什么不能只信 env:`settings.json` 的 hook 条目**没有 env 字段**,
宿主 env 里 `DSH_WS_ROOT` 实测恒空(10-04 三级全空)⇒ 只读 env 的钩子
**一次都不会触发**(这正是本脚本上一版的病)。
⚠️ 也不直接用 `Path.cwd()`:钩子的 cwd 是**宿主进程目录**,⛔ 不是会话工作区。
"""
return _norm(str(_stdin_payload().get("cwd") or ""))
def _out(obj) -> None:
data = json.dumps(obj, ensure_ascii=False).encode("utf-8")
try:
sys.stdout.buffer.write(data)
sys.stdout.buffer.flush()
except Exception:
sys.stdout.write(data.decode("utf-8", "replace"))
sys.stdout.flush()
def _noop() -> None:
"""⛔ 什么都不注入 —— 绝大多数情况(⛔ 别每次提交都吵用户)。"""
_out({"hookSpecificOutput": {"hookEventName": "UserPromptSubmit",
"additionalContext": ""}})
def _cfg(ws: Path):
"""读该工作区的部署配置(⛔ 读不到 ⇒ 视为**未启用**,⛔ 不碰)。"""
p = ws / ".workbuddy" / "collab" / "collabd.config.json"
try:
return json.loads(p.read_text(encoding="utf-8"))
except Exception:
return None
def _goal(ws: Path):
"""读 `goal.json`(⛔ 没有 ⇒ **基础会话方式**,⛔ 不碰)。"""
for rel in (("tmp", "supervise-inbox", "goal.json"),):
p = ws.joinpath(*rel)
try:
if p.is_file():
return json.loads(p.read_text(encoding="utf-8"))
except Exception:
return None
return None
def main() -> int:
# ① 拿工作区(🔴 真源=stdin 的 `payload.cwd`;env / `roots.env` 只兜底)
ws = _ws_root() or (os.environ.get("DSH_WS_ROOT") or "").strip()
if not ws:
_noop()
return 0
ws = Path(ws)
if not ws.is_dir():
_noop()
return 0
# ② ⚖️ 边界①:**没启用 ⇒ 完全不碰**(不是故障,是用户在用基础会话方式)
cfg = _cfg(ws)
if not cfg:
_noop()
return 0
goal = _goal(ws)
if not goal:
# 📌 无 `goal.json` ⇒ 基础会话方式 ⇒ ⛔ 不起常驻、⛔ 不注入任何东西
_noop()
return 0
# 🔴🔴 2026-10-04 用户拍板(原话:「**当然需要判断如果没有起就要起**」):
# ⇒ 判据=「**有 `goal.json` 就归我管**」,⛔ **不看 `lifecycle` 的值**。
# ⚠️ 为什么**故意不看** lifecycle(这是口径,⛔ 不是漏写):
# 用户说「继续完成目标」这个动作本身**就是"有活"的证据** ——
# 目标处于「已完成/等待」时用户仍然开口 ⇒ 说明还有事要办。
# 若这里加一道「lifecycle 必须进行中」的闸,就会出现
# **用户说了继续、却什么都没发生** ⇒ 又回到"感觉没改到位"。
# ⚠️ 已知副作用(**接受**,⛔ 不是缺陷):`已完成` 的区里常驻会被拉起,
# 但 `maybe_spawn_check_agent()` 五道闸① 仍要求「进行中」⇒ 暂时不建检查会话。
# ⇒ 那份「只起常驻、不建检查会话」的中间态是**刻意的**:
# 先保证后台在,检查会话等目标状态被改回「进行中」再说。
# ⛔ 变异对照见 `selftest.py::t_ensure_hook_fires_on_any_lifecycle()`。
# ③ 问程序自己「常驻在不在」(⛔ 别手拼路径、⛔ 别读快照)
# 🔴🔴 2026-10-04 实测栽到:`env = dict(os.environ)` 之后再往 `env` 里塞
# `COLLABD_CONFIG` 是**无效**的 —— `exec_module()` 期间 `collabd.load_cfg()`
# 读的是 **`os.environ`**(真进程环境),不是那个局部字典。
# ⇒ 实测 `CFG_USED` 仍指向 **别的区**,把「A 区常驻在位」当成本区结论
# ⇒ 又一次**假绿**。正解=**直接改 `os.environ`**(并记住原值,出栈还原)。
cfg_env_key = "COLLABD_CONFIG"
_cfg_path = str(ws / ".workbuddy" / "collab" / "collabd.config.json").replace("\\", "/")
_saved_env = os.environ.get(cfg_env_key)
os.environ[cfg_env_key] = _cfg_path
try:
import importlib.util
_sp = importlib.util.spec_from_file_location("_cd_hook", COLLABD)
m = importlib.util.module_from_spec(_sp)
_sp.loader.exec_module(m)
# 📌 自检:加载完必须确认它**真的在看本区**(⛔ 不确认=又一次假绿)
_used = str(getattr(m, "CFG_USED", "") or "").replace("\\", "/")
if Path(_used).resolve() != Path(_cfg_path).resolve():
_noop()
return 0
alive, det = m.supervise_alive()
except Exception:
_noop()
return 0
finally:
if _saved_env is None:
os.environ.pop(cfg_env_key, None)
else:
os.environ[cfg_env_key] = _saved_env
# ④ 在位 ⇒ 零输出(⛔ 别每次提交都吵)
if alive:
_noop()
return 0
# ⑤ 🔴 不在位 ⇒ 自己起来(这就是用户那句「没开启就要开启」)
# 🔴 复查用的 `m` 是**上面已加载的本区模块**;此时 `os.environ` 已还原
# ⇒ `supervise_alive()` 若重新读 env 会验到别的区 ⇒ 必须把 env 再钉回去。
#
# 🔴🔴 2026-10-04 **时间预算(必须守住,⛔ 超时=用户那句话被吞=最坏结果)**:
# 钩子注册里 `timeout=30`(`settings.json` 的 `UserPromptSubmit` 第 6 条)
# ⇒ 本函数总耗时必须**显著小于 30 s**,否则宿主掐掉钩子 ⇒ 输入丢失。
# 实测正常路径 3.5 s,但**起不来**那条路径原实现最坏 =
# `subprocess timeout 40` + 回验循环 `10 × 0.9 ≈ 9` ⇒ **理论 49 s > 30 s**。
# ✅ 收口(2026-10-04 用户批准「可以」):
# · 子进程超时 `40 → 15`(`--ensure` 本身是"起一条即返回",15 s 足够)
# · 回验循环 `10 × 0.9 ≈ 9 → 5 × 0.8 = 4`(心跳节拍实测 ~1 s 内就出来)
# ⇒ 最坏 ≈ 15 + 4 + 1 = **20 s**,留 10 s 余量给解释器启动与 import。
_ENSURE_SUBPROC_TIMEOUT = 15
_VERIFY_TRIES, _VERIFY_GAP = 5, 0.8
try:
_restore_env = os.environ.get(cfg_env_key)
os.environ[cfg_env_key] = _cfg_path
env = dict(os.environ)
# 🔴 参数名是 **`--ensure`**(⛔ **没有** `--ensure-supervise`)
# 🔴🔴 **⛔ 不看它的返回码**(2026-10-04 实测坐实):它会报
# `{"ok": false, "spawned": true}` ⇒ **`spawned` 看着像成功、其实秒退**。
# 那个区因此「起了三次、活零次」,而我照着返回码报了"起好了"。
# ⇒ 唯一可信判据=**等它自己活下来,再问 `supervise_alive()`**。
subprocess.run([PY, str(COLLABD), "--ensure"],
env=env, cwd=str(ws), capture_output=True,
timeout=_ENSURE_SUBPROC_TIMEOUT)
ok = False
for _ in range(_VERIFY_TRIES):
time.sleep(_VERIFY_GAP)
try:
if m.supervise_alive()[0]:
ok = True
break
except Exception:
pass
except Exception:
ok = False
finally:
if _restore_env is None:
os.environ.pop(cfg_env_key, None)
else:
os.environ[cfg_env_key] = _restore_env
if not ok:
# ⛔ 起不来 ⇒ **只报事实**,⛔ 不许编原因(今天栽过 5 次)
# 🔴 但「起不来」本身就是用户要的那条红线(10-04 原话「起不来就先报错」)
# ⇒ 这里必须**报出来**,⛔ 不许再静默零输出。
_out({"hookSpecificOutput": {"hookEventName": "UserPromptSubmit",
"additionalContext": (
"🔴 **会话机制 · 常驻没起来**(本工作区有目标在册,但常驻不在位,"
"自动补起失败)——⛔ 先修好这个再往下做,别当它已经好了。")}})
return 0
# ⑥ 起好了 ⇒ 验一次,⛔ 打印「已启动」不算(照 supervise-persistence 的验收口径)
# 🔴 同样要把 env 钉回本区,否则复查读的是别的区的心跳(⛔ 验错对象=假绿)
_r2 = os.environ.get(cfg_env_key)
os.environ[cfg_env_key] = _cfg_path
try:
time.sleep(1.0)
alive2, det2 = m.supervise_alive()
except Exception:
alive2, det2 = False, "复查失败"
finally:
if _r2 is None:
os.environ.pop(cfg_env_key, None)
else:
os.environ[cfg_env_key] = _r2
msg = ("🔧 **会话机制 · 常驻已自动补起**(本工作区 goal 在册但常驻不在位)"
if alive2 else
"🔴 **会话机制 · 常驻起后仍未活**(%s)——⛔ 这是事实,先修好再往下做。" % det2)
_out({"hookSpecificOutput": {"hookEventName": "UserPromptSubmit",
"additionalContext": msg}})
return 0
if __name__ == "__main__":
try:
sys.exit(main())
except Exception:
# 🔴 铁律:钩子**绝不能**让用户的话被吞 ⇒ 任何异常都静默退出 0
sys.exit(0)
@@ -0,0 +1,712 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
监管机制 · 结果钩子(SessionEnd)—— **实现「hook 把执行结果交给后台任务」**
================================================================================
用户在 2026-09-28 提出的设计:
「可以通过 hook 把执行结果给到 你的后台任务,根据后台任务接收到的情况,决定如何执行下一步」
本脚本 = 这条链上**「交付结果」那一半**。它做的事极少、极稳:
某个会话结束
↓ SessionEnd 钩子(本项目**项目级**配置 → 只作用于本工作区,⛔ 不污染全局)
↓ 读宿主给的 transcript_path → 取「最后一条 assistant 正文」+ 判「是不是半路断了」
↓ 追加**一行** JSON 到本地台账 tmp/supervise-inbox/ledger.jsonl
↓ 退出码 0(无论发生什么)
后台任务(= 派活监管巡检棒)
→ 读台账 → 结合登记表 → 决定下一步(重排 / 报告 / 等待)
🔴 六条安全性质(**改本文件前先逐条读**,都是"多重检查"的产物)
1. **绝不写 stdout** —— stdout 是钩子协议通道,乱写可能干扰宿主。本脚本连一个字节都不输出。
2. **永远 exit 0** —— 任何异常都被吞掉并**只记录到自己的日志**(fail-open)。
宿主侧该钩子挂了 timeout=10s:万一死循环也会被掐掉,不会卡住会话退出。
3. **⛔ 不读令牌、⛔ 不联网、⛔ 不起子进程** —— 本脚本继承到的环境里有
`CODEBUDDY_GATEWAY_PASSWORD`(网关令牌),但那意味着"能创建定时任务"这种**强能力**。
本脚本**刻意不碰它**:只做本地文件读写 ⇒ 不构成任何权限扩大(R5 干净)。
4. **只写自己的目录** —— 唯一写点是 `<本工作区>/tmp/supervise-inbox/`。⛔ 不写别线、⛔ 不改配置。
5. **有界** —— 读 stdin ≤1 MiB、读转录尾部 ≤128 KiB、台账 >8 MiB 就**停写并留痕**(⛔ 不删旧数据)。
6. **留痕优先** —— "钩子没被调用"与"被调用了但静默失败"必须可分 ⇒ 每次调用都写日志。
7. 🔴 **路径靠"上溯找标志",⛔ 不靠脚本在第几层**(2026-09-28 实测 bug:脚本一移动,
`dirname(dirname(__file__))` 就指错 ⇒ 台账**静默写到别处**而测试全绿)。
自证命令:`python wb-result-hook.py --where`(**只能手工跑**,真实钩子调用⛔不带此参数)。
对照实现:本机既有的全局钩子
`E:/ProgramData/AIProject/ai1net-decision-laya/bridge/decision_bridge.py`
(它的 docstring 同样强调 fail-open 与"留痕先于一切";本脚本只取这两个约定,⛔ 不复用它任何逻辑)
装在哪里(**项目级 = 只影响本工作区**):
`E:/ProgramData/AIProject/ai1net-dsh-server/.codebuddy/settings.json`
{"hooks": {"SessionEnd": [{"hooks": [{"type":"command","command":"…python.exe …结果钩子-20260928.py","timeout":10}]}]}}
⛔ 不要装到 `~/.workbuddy/settings.json`(那是全局,会跑在**所有**会话上)。
自测(同一份代码路径,⛔ 不是另写一份测试逻辑):
python 结果钩子-20260928.py --selftest <payload.json> [transcript.jsonl]
"""
from __future__ import annotations
import json
import os
import subprocess
import sys
import tempfile
import time
from datetime import datetime
MAX_STDIN = 1 << 20 # 1 MiB
MAX_TAIL = 128 * 1024 # 128 KiB
LEDGER_MAX = 8 * 1024 * 1024 # 8 MiB 就停写(留痕,不删)
# 🔴 2026-09-30 修(用户:"整的后台又开始一闪一闪的"):
# `python.exe` 是**控制台程序** —— 钩子本身没有控制台,子进程不带 CREATE_NO_WINDOW
# 就会被 Windows **新分配一个控制台窗口** ⇒ 每跑一次闪一下黑窗。
# ⇒ 本脚本 spawn 的一切子进程**一律隐窗**(非 Windows 上该标志无意义 ⇒ 取 0)。
HIDE = 0x08000000 if os.name == "nt" else 0 # CREATE_NO_WINDOW
# ---------------------------------------------------------------------------
# 🔴 工作区根**靠上溯发现**,⛔ 不靠"脚本在第几层"(2026-09-28 实测 bug)
# 事故:脚本原在 `交付物/`,用 `WS = dirname(dirname(__file__))` 正好对;
# 一移到 `.workbuddy/tools/` ⇒ 同一行变成了 `.workbuddy/` ⇒
# **台账静默写到 `.workbuddy/tmp/`**,而"测试全绿、退出码 0"。
# ⇒ 教训:路径**不能依赖脚本自身的相对层数**;用**内容标志**锚定。
# ---------------------------------------------------------------------------
WS_MARKER = "state.py" # 本工作区的唯一标志(配合 .workbuddy/ 目录)
def resolve_ws():
"""环境变量优先;否则从脚本所在目录逐级上溯,找同时含 state.py 与 .workbuddy/ 的目录。"""
env = os.environ.get("WB_RESULT_HOOK_WS")
if env and os.path.isfile(os.path.join(env, WS_MARKER)):
return os.path.abspath(env)
cur = os.path.dirname(os.path.abspath(__file__))
for _ in range(8):
if os.path.isfile(os.path.join(cur, WS_MARKER)) and os.path.isdir(os.path.join(cur, ".workbuddy")):
return cur
parent = os.path.dirname(cur)
if parent == cur:
break
cur = parent
return ""
WS = resolve_ws()
INBOX = os.path.join(WS, "tmp", "supervise-inbox") if WS else ""
LEDGER = os.path.join(INBOX, "ledger.jsonl") if INBOX else ""
HOOKLOG = os.path.join(INBOX, "hook.log") if INBOX else ""
# 兜底日志:连工作区都认不出来时,写系统临时目录(保证"失败也可被发现",⛔ 不静默)
LOG_FALLBACK = os.path.join(tempfile.gettempdir(), "wb-result-hook.log")
# ---------------------------------------------------------------------------
# 🔴 作用域(2026-09-28 23:2x 重做 —— 用户指出「钩子会不会把相关会话排除掉」)
# 旧版只有一条 `CARE_PREFIX`,把「被监管的三条线」和「**本工作区**(监管自己的家)」
# 混在同一个前缀里,且**域外一律静默 return**(只写一行 log)⇒ 两个后果:
# ① 本工作区的会话被当成被监管对象 ⇒ **监管自己看自己**;
# ② 被挡下的会话从外面看**与"没触发"完全一样**(同一个坑,本文件 §98 注释已批评过)。
# ⇒ 现在三改:① 被监管线 = **显式白名单**(与 supervisor 的 WATCH 一处对齐)
# ② 本工作区标 `scope=home` ⇒ 记账但⛔ 不当被监管线
# ③ **凡被丢弃的,写 `skipped.jsonl` + 打一行 stderr** ⇒ 排除本身可观测
# ---------------------------------------------------------------------------
HOME_WS = "ai1net-dsh-server" # 监管本家(本工作区)——⛔ 不是被监管线
LINE_WS = { # ⚠️ 改这里**必须同步** wb-supervisor-watch.py 的 WATCH
"ai1net-dsh-desktop": "客户端线",
"ai1net-dsh-anywhere": "手机接入线",
"ai1net_ui": "ai1net_ui",
}
CARE_PREFIX = "e:/programdata/aiproject/" # 大项目根:域外一律不读转录
SKIPPED = os.path.join(INBOX, "skipped.jsonl") if INBOX else ""
# ---------------------------------------------------------------------------
# 🔴 hook → 监管(**零 token** 的那一半)
# 用户口径(2026-09-28):「可以通过 hook 把执行结果给到**你的后台任务**,
# 根据后台任务接收到的情况,决定如何执行下一步」。
# ⇒ 正确落地 = **钩子里限流地跑一轮监管**,因为:
# · 钩子是**宿主**起的子进程 ⇒ ⛔ 不占任何会话(不会把会话拖死,见 §7.7 事故)
# · 本地脚本 ⇒ **零 token**(⛔ 不必为此开自动化会话烧钱)
# ⛔ 三条护栏:① 域外不触发 ② 限流 SUP_THROTTLE 秒 ③ 硬超时 6s + 错误全吞
# ---------------------------------------------------------------------------
SUPERVISE = os.path.join(WS, ".workbuddy", "tools", "wb-supervisor-watch.py") if WS else ""
# ---------------------------------------------------------------------------
# 🔴 会话日志上限兜底(2026-09-29 实测定型)
# `<配置根>/logs/<日期>/sdk/conversations/<sid>.log` 涨到 ~10 MiB 上限后轮转失败,
# 宿主每批写入抛 EPERM ⇒ **整批丢弃** ⇒ 该会话"确实在执行,但对话窗口不显示内容"。
# 修法 = 把卡死的 `<sid>.log` **改名挪开**(⛔ 不删),宿主数十秒内重建并恢复写入。
# 钩子是**宿主起的短命子进程** ⇒ 挂这里兜底最合适:零 token、不占会话、跑完即退。
# ⛔ 任何异常都不许冒出去;⛔ 只动 `sdk/conversations/*.log`,不动别的。
# 判据/修法全文 ⇒ 技能 workbuddy-session-forensics §2h-1
# ---------------------------------------------------------------------------
SWEEP = os.path.join(WS, ".workbuddy", "tools", "wb-logcap-sweep.py") if WS else ""
SWEEP_THROTTLE = 120 # 秒:最快多久扫一次
SWEEP_TIMEOUT = 8 # 秒:硬超时(留足余量给钩子注册的 timeout)
SWEEP_STAMP = os.path.join(INBOX, "_logcap.stamp") if INBOX else ""
def maybe_sweep_logcap() -> None:
"""解除"会话日志撞 10 MiB 上限 ⇒ 写入被拒 ⇒ 输出被丢"。失败一律吞掉。"""
if not SWEEP or not os.path.isfile(SWEEP):
return
try:
if SWEEP_STAMP and os.path.isfile(SWEEP_STAMP):
if (time.time() - os.path.getmtime(SWEEP_STAMP)) < SWEEP_THROTTLE:
return
except Exception:
pass
try:
p = subprocess.run(
[sys.executable, "-u", SWEEP, "--quiet"],
cwd=WS or None,
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
timeout=SWEEP_TIMEOUT, creationflags=HIDE,
)
try:
with open(SWEEP_STAMP, "w", encoding="utf-8") as f:
f.write(str(int(time.time())))
except Exception:
pass
out = (p.stdout or b"").decode("utf-8", "replace").strip()
if out:
log("logcap-sweep: " + out.replace("\n", " | ")[:300])
except Exception as e:
try:
log("logcap-sweep 失败(已忽略): %r" % (e,))
except Exception:
pass
SUP_THROTTLE = 600 # 秒:最快多久跑一次(⛔ 别每次 SessionEnd 都跑)
SUP_TIMEOUT = 6 # 秒:硬超时(钩子注册的 timeout 是 10s,留足余量)
SUP_STAMP = os.path.join(INBOX, "_supervise.stamp") if INBOX else ""
SUP_LOG = os.path.join(INBOX, "_supervise.log") if INBOX else ""
def maybe_run_supervisor(rec) -> None:
"""受控地跑一轮监管。⛔ 任何异常都不许冒出去(钩子绝不能因为这个失败)。"""
if not SUPERVISE or not os.path.isfile(SUPERVISE):
return
if rec.get("scope") == "outside":
return
try:
if SUP_STAMP and os.path.isfile(SUP_STAMP):
if (time.time() - os.path.getmtime(SUP_STAMP)) < SUP_THROTTLE:
return
except Exception:
pass
try:
p = subprocess.run(
[sys.executable, "-u", SUPERVISE, "--once"],
cwd=WS or None,
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
timeout=SUP_TIMEOUT, creationflags=HIDE,
)
try:
with open(SUP_STAMP, "w", encoding="utf-8") as f:
f.write(str(int(time.time())))
except Exception:
pass
if SUP_LOG:
try:
with open(SUP_LOG, "a", encoding="utf-8", newline="\n") as f:
f.write("\n--- %s 由钩子触发(session=%s line=%s)---\n"
% (datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
str(rec.get("session", ""))[:8], rec.get("line")))
f.write((p.stdout or b"").decode("utf-8", "replace"))
except Exception:
pass
except Exception:
pass
# ---------------------------------------------------------------------------
# 🔴 2026-09-29 13:4x 加(**取证驱动**:`SessionEnd` 这条路实测不可用)
# 事实(宿主日志 + 台账,⛔ 非推断):
# · 宿主自 09-28 13:55 起**未重启**;带本钩子的 `SessionEnd` 注册**从未被投递过**
# (两日全部 `HookExecutor spawn` 里,`SessionEnd` 那条注册零次出现);
# · `ledger.jsonl` / `hook.log` 末次真实写入 = 07:28,且全是测试夹具;
# `gate-done.stamp` **不存在** ⇒ 真实会话收尾从未触发过本钩子;
# · ⇒「会话收尾 ⇒ 跑一轮协作程序」这条接线**空转**,协作回路自 13:11 起休眠。
# 修法(**最小**:只换「谁触发 `--once`」,⛔ 不改常驻程序本身、⛔ 不扩范围):
# 把同一个 `--once` 挂到**已被证明会被投递**的事件 `UserPromptSubmit` 上
# (两日 26 次 spawn 全落本钩子)⇒ ⛔ 不依赖常驻、⛔ 不依赖重启宿主。
# 护栏:静默 · fail-open · 硬超时 12 s(注册 20 s,留足余量)· 节流 180 s。
# ---------------------------------------------------------------------------
CB_GAP = 180 # 秒:最快多久跑一次(⛔ 别每次提示都跑)
CB_TIMEOUT = 12 # 秒:硬超时
CB_STAMP = os.path.join(INBOX, "collabd-once.stamp") if INBOX else ""
# 🔴 协作机制的**部署配置属于使用方**(用户 2026-09-30:「技能就是技能 程序就是程序,
# 谁用产生的文件 放在他自己那里」)⇒ 钩子每次唤起 collabd 时**显式**指过去:
# · COLLABD_CONFIG = <本工作区>/.workbuddy/collab/collabd.config.json
# · COLLABD_WORKSPACE = <本工作区>
# ⛔ 已删掉原来的"回落同目录旧副本"兜底 —— 那是**静默换版本**的经典来源
# (技能 §9 警告过「指向旧副本 ⇒ 唤醒回路等于没接」);找不到就**停手**,⛔ 不猜。
_WS_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
def _collabd_ctx():
"""返回 (脚本路径, 子进程 env)。⛔ 找不到脚本 ⇒ ("", {}),调用方直接 return。"""
_p = os.environ.get("COLLABD_PATH") or ""
if not _p or not os.path.isfile(_p):
# 🔴 2026-10-01:原 `multi-session-collab` 已并入 `session-mechanism`(本包即主技能)
# ⇒ **新落点优先**;旧落点只作**过渡兜底**(⛔ 别删:包未装/被移走时不至于静默停摆)。
# ⛔ 不再写死盘符 —— 一律按 `<配置目录>/skills/...` 推(换机器也成立)。
_base = os.environ.get("CODEBUDDY_CONFIG_DIR") or os.path.join(
os.path.expanduser("~"), ".workbuddy")
for _cand in (
os.path.join(_base, "skills", "session-mechanism", "scripts", "collabd.py"),
os.path.join(_base, "skills", "multi-session-collab", "scripts", "collabd.py"),
):
if os.path.isfile(_cand):
_p = _cand
break
if not _p or not os.path.isfile(_p):
return "", {}
_env = dict(os.environ)
_env["COLLABD_WORKSPACE"] = _WS_ROOT
_cfg = os.path.join(_WS_ROOT, ".workbuddy", "collab", "collabd.config.json")
if os.path.isfile(_cfg):
_env["COLLABD_CONFIG"] = _cfg
return _p, _env
def _supervise_switch_on() -> bool:
"""🔴🔴 **协作程序总开关**(2026-10-05 用户明令「提供一个启动的开关给我来控制」)。
开关文件= `<工作区>/.workbuddy/collab/supervise.switch`,内容为 `on` ⇒ 开。
🔴 **判据是 fail-safe**:文件**不存在**/读不到/内容不是 `on` ⇒ 一律判**关**
(⛔ 绝不许"读不到就当开" —— 那正是"用户没让它跑它却一直在弹窗"的成因)。
用户手动控制:
开 ⇒ 写 `on` 到该文件;关 ⇒ 改成 `off` 或直接删掉文件。
"""
try:
_cd, _env = _collabd_ctx()
if not _cd:
return False
_sw = os.path.join(os.path.dirname(_cd), "supervise.switch")
if not os.path.isfile(_sw):
return False
with open(_sw, encoding="utf-8", errors="replace") as f:
return f.read().strip().lower() == "on"
except Exception:
return False
def maybe_run_collabd_once() -> None:
"""受控地跑一轮常驻程序。⛔ 任何异常都不许冒出去。"""
if not INBOX:
return
if not _supervise_switch_on():
return # 🔴 总开关关着 ⇒ ⛔ 不起任何进程
_cd, _collabd_env = _collabd_ctx()
if not _cd:
return
try:
if os.path.isfile(CB_STAMP) and (time.time() - os.path.getmtime(CB_STAMP)) < CB_GAP:
return
except Exception:
pass
try:
with open(CB_STAMP, "w", encoding="utf-8", newline="\n") as f:
f.write(datetime.now().strftime("%Y-%m-%d %H:%M:%S"))
except Exception:
pass
try:
subprocess.run([sys.executable, "-u", _cd, "--once"],
env=_collabd_env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
timeout=CB_TIMEOUT, check=False, creationflags=HIDE)
except Exception:
pass
# ---------------------------------------------------------------------------
# 🔴 2026-09-30 立 · **投递(监督程序)由宿主钩子唤起** —— 用户口径:「又给我整到自动任务去了」
# ⇒ **投递 ⛔ 不靠自动化排期、⛔ 不靠常驻进程**。
# 事实(源码 + 本机实测):
# · 钩子是**宿主起的子进程** ⇒ ① 继承网关口令 `CODEBUDDY_GATEWAY_PASSWORD`
# (2026-09-30 实测本机会话内进程 `len=43`)② ⛔ 不占任何会话 ③ **零 token**;
# · `UserPromptSubmit` 两日 26 次 spawn 实测**会被宿主投递**(`SessionEnd` 反而 0 次)。
# · 🔴🔴 **2026-10-05 `--tick` 已删**(用户:「检查程序 常驻 自己不会判断吗,非要什么 tick
# once 去触发?」)⇒ 上面这套"钩子唤起投递轮"的**前提整段作废**:
# · 投递 **2026-10-03 整体退役**(⛔ 已无"投给主会话"这回事);
# · 建检查会话排期 + park 指纹探针 ⇒ **搬进 `--supervise` 常驻主循环**(常驻自己判);
# · 钩子在协作链上**只剩一件事**:`UserPromptSubmit` 时 `maybe_ensure_supervise()`
# 顺手续命常驻(⛔ 不判定、⛔ 不建排期、⛔ 不投递)。
# ⇒ **判定的权威在常驻**(一直运行、自己拨),钩子⛔ 不再是任何判定的触发源。
# ---------------------------------------------------------------------------
ENSURE_GAP_UNUSED = 120 # 🔴 2026-10-05:`--tick` 删除后本层不再节流;`--ensure` 自带幂等与节流
TICK_TIMEOUT = 20 # 秒:硬超时(⛔ 钩子注册的 timeout 必须比它大)
# 🔴 2026-10-05 删:`TICK_STAMP`(`_tick.stamp`)不再写 —— `--tick` 已删,
# ⛔ 留着会让看板/工具把它当"常驻程序还在跑"的证据(戳会一直变旧 ⇒ 读数说谎)。
# ── A 方案(2026-09-30 用户拍板):把网关口令**投递**给设备接入垫片 ──────────────
# 垫片原设计「口令只在 env」⇒ **脱离会话**拉起时读不到口令 ⇒ 一路 503、链路差最后一跳。
# 本钩子跑在**宿主进程树内** ⇒ 天然读得到口令 ⇒ 由它负责「送」(实现见那个脚本)。
# ⛔ 静默 · ⛔ 有节流 · ⛔ 异常一律吞(它是**旁路**,⛔ 不得影响钩子、更不得影响 WorkBuddy)。
_TOKEN_DELIVER = None
_TOKEN_STAMP = ""
_TOKEN_GAP = 60.0
def maybe_deliver_gateway_token() -> None:
"""🔴 A 方案的口令**投递**(送的一方)。说明见 `.workbuddy/collab/deliver-gateway-token.py`。
⚠️ 调用方是 `PreToolUse` —— 该事件**必须零 stdout** ⇒ 子进程输出一律丢弃。
"""
global _TOKEN_DELIVER, _TOKEN_STAMP
if not WS:
return
if _TOKEN_DELIVER is None:
_TOKEN_DELIVER = os.path.join(WS, ".workbuddy", "collab", "deliver-gateway-token.py")
_TOKEN_STAMP = os.path.join(WS, "tmp", "supervise-inbox", "_token-deliver.stamp")
if not os.path.isfile(_TOKEN_DELIVER):
return
try:
if os.path.isfile(_TOKEN_STAMP) and (time.time() - os.path.getmtime(_TOKEN_STAMP)) < _TOKEN_GAP:
return
except Exception:
pass
try:
subprocess.run([sys.executable, _TOKEN_DELIVER],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=12,
env=dict(os.environ),
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0))
except Exception:
pass
def maybe_ensure_supervise(rec) -> None:
"""🔴 宿主钩子**顺手续命常驻**(幂等):常驻不在 ⇒ 就地补起。
🔴🔴 2026-10-05 **`--tick` 已删**(用户:「检查程序 常驻 自己不会判断吗,非要什么 tick once
去触发?」)⇒ 建检查会话排期、park 指纹探针两件事**全部搬进 `--supervise` 主循环**。
⛔ 本函数**只留"续命"这一件**(⛔ 不再唤起任何投递/判定轮)——
常驻总会被打断(换会话/宿主回收),没有这一下就没人把它带回来。
⛔ 任何异常都不许冒出去(钩子绝不能因为这个失败)。
⚠️ `--ensure` 内部自带节流与幂等(已在跑 ⇒ 直接返回),⛔ 这里不再加一层戳。
"""
if not INBOX or (rec or {}).get("scope") == "outside":
return # 域外不碰(⛔ 不扩大影响面)
if not _supervise_switch_on():
return # 🔴 总开关关着 ⇒ ⛔ 不补起常驻(用户要的停止)
_cd, _collabd_env = _collabd_ctx()
if not _cd:
return
try:
subprocess.run([sys.executable, "-u", _cd, "--ensure"],
env=_collabd_env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
timeout=TICK_TIMEOUT, check=False, creationflags=HIDE)
except Exception:
pass
def classify(cwd: str):
"""会话 cwd ⇒ (scope, line)。
scope: `line` 被监管线 | `home` 本工作区(监管自己) | `other` AIProject 下其他工作区 | `outside` 域外。
🔴 必须按**相对 AIProject 的第一段**取工作区名 —— 旧版用 `basename(cwd)`,
会话 cwd 落在**子目录**时(如 `…/ai1net_ui/docs`)会得到 `docs` ⇒ **线名错位**、
下游按线名找不到它。"""
if not cwd:
return "outside", "?"
c = cwd if cwd.endswith("/") else cwd + "/"
if not c.startswith(CARE_PREFIX):
return "outside", (cwd.rstrip("/").split("/")[-1] or "?")
rest = c[len(CARE_PREFIX):].strip("/")
seg = rest.split("/", 1)[0] if rest else "?"
if seg in LINE_WS:
return "line", LINE_WS[seg]
if seg == HOME_WS:
return "home", HOME_WS
return "other", seg
def norm_path(p: str) -> str:
"""路径归一化(比对前必须做)。
🔴 2026-09-28 实测缺陷:作用域闸门原用**单斜杠字面**比对 cwd,遇到
`E://ProgramData//AIProject//…`(重复斜杠)就判成"域外" ⇒ **静默跳过、什么都不写**,
而钩子 rc=0、stdout 空 ⇒ 从外面看**与"没触发"完全一样**。
⇒ 先反斜杠转正斜杠、再折叠连续斜杠、再小写,然后才比。"""
import re as _re
return _re.sub(r"/+", "/", str(p).replace("\\", "/")).lower()
# 判「半路断了」:Host 注入的错误重试标记(2026-09-28 实测到的成因)
ABRUPT_MARKERS = ("error-recovery", "Model error retry", "Tool Not Found")
def log(msg: str) -> None:
"""留痕。⛔ 这条日志本身失败也绝不能影响会话 —— 全部吞掉。
工作区认不出来时退到系统临时目录,保证「失败可被发现」(⛔ 不静默)。"""
for path in (HOOKLOG, LOG_FALLBACK):
if not path:
continue
try:
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "a", encoding="utf-8", newline="\n") as f:
f.write("%s %s\n" % (datetime.now().strftime("%Y-%m-%d %H:%M:%S"), msg))
return
except Exception:
continue
def note_skip(payload, cwd, reason):
"""🔴 被作用域挡下的会话 **必须留痕**:写 `skipped.jsonl` + 一行 stderr。
⛔ 不许像旧版那样只 `return 0` —— 那样从外面看**与"没触发"完全一样**(假阴性)。"""
rec = {
"ts": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"reason": reason,
"session": str(payload.get("session_id") or ""),
"cwd": cwd,
"event": str(payload.get("hook_event_name") or ""),
}
log("skip: %s (cwd=%s session=%s)" % (reason, cwd or "-", rec["session"][:8]))
if SKIPPED:
try:
os.makedirs(INBOX, exist_ok=True)
with open(SKIPPED, "a", encoding="utf-8", newline="\n") as f:
f.write(json.dumps(rec, ensure_ascii=False) + "\n")
except Exception:
pass
try: # stderr 也出一行(宿主会把 hook 的 stderr 记进日志)
sys.stderr.write("[wb-result-hook] skipped %s cwd=%s\n" % (reason, cwd or "-"))
except Exception:
pass
def read_tail(path: str) -> str:
"""读文件尾部(有界)。读不到就返回空串。"""
try:
size = os.path.getsize(path)
with open(path, "rb") as f:
if size > MAX_TAIL:
f.seek(size - MAX_TAIL)
return f.read(MAX_TAIL).decode("utf-8", "replace")
except Exception as e:
log("read_tail failed: %s: %s" % (type(e).__name__, e))
return ""
def last_assistant_text(tail: str) -> str:
"""从转录尾部取「最后一条 assistant 正文」。逐行宽松解析(坏行跳过)。"""
out = ""
for ln in tail.splitlines():
ln = ln.strip()
if not ln or '"message"' not in ln:
continue
try:
rec = json.loads(ln)
except Exception:
continue
if rec.get("type") != "message" or rec.get("role") != "assistant":
continue
c = rec.get("content")
if isinstance(c, str):
out = c
elif isinstance(c, list):
parts = []
for b in c:
if isinstance(b, dict) and isinstance(b.get("text"), str):
parts.append(b["text"])
elif isinstance(b, str):
parts.append(b)
if parts:
out = "\n".join(parts)
return out
def build_record(payload, transcript_path, mode="run"):
"""从 payload 抽出要记的一行。所有字段都做类型兜底。"""
cwd = norm_path(payload.get("cwd") or "")
scope, line = classify(cwd)
tail = read_tail(transcript_path) if transcript_path else ""
final = last_assistant_text(tail)
abrupt = any(m in tail[-20000:] for m in ABRUPT_MARKERS) if tail else False
# 判定:太短的收尾 / 命中断裂标记 / 压根没读到正文 ⇒ 可疑
if not tail:
verdict = "inbox-missing-transcript"
elif abrupt:
verdict = "abrupt"
elif not final.strip():
verdict = "no-assistant-text"
elif len(final) < 40:
verdict = "short"
else:
verdict = "ok"
return {
"ts": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"mode": mode,
"event": str(payload.get("hook_event_name") or ""),
"session": str(payload.get("session_id") or ""),
"line": line,
"scope": scope,
"cwd": cwd,
"verdict": verdict,
"final_head": final.strip().replace("\n", " ")[:300],
"tail_bytes": len(tail),
}
def append_ledger(rec: dict) -> str:
"""追加一行。返回一个简短结果码(用于留痕,⛔ 不输出到 stdout)。"""
if not LEDGER:
return "err-no-workspace" # 认不出工作区 ⇒ ⛔ 不瞎猜路径
try:
os.makedirs(INBOX, exist_ok=True)
try:
if os.path.getsize(LEDGER) > LEDGER_MAX:
return "skip-ledger-full" # ⛔ 不删旧数据,只停写并留痕
except OSError:
pass
with open(LEDGER, "a", encoding="utf-8", newline="\n") as f:
f.write(json.dumps(rec, ensure_ascii=False) + "\n")
return "ok"
except Exception as e:
log("append_ledger failed: %s: %s" % (type(e).__name__, e))
return "err-ledger"
def main(argv) -> int:
t0 = time.time()
mode = "run"
payload = {}
transcript_path = ""
# ---- 诊断模式:打印解析出来的路径(**只能手工跑**;真实钩子调用绝不带此参数)----
if "--where" in argv:
sys.stdout.buffer.write(("WS=%s\nINBOX=%s\nLEDGER=%s\nHOOKLOG=%s\nFALLBACK=%s\n"
% (WS or "(未识别)", INBOX or "-", LEDGER or "-", HOOKLOG or "-", LOG_FALLBACK)
).encode("utf-8", "replace"))
return 0
# ---- 自测模式:同一份代码路径,喂文件而不是 stdin ----
if "--selftest" in argv:
i = argv.index("--selftest")
mode = "selftest"
try:
with open(argv[i + 1], "rb") as f:
payload = json.loads(f.read(MAX_STDIN).decode("utf-8", "replace") or "{}")
except Exception as e:
log("selftest payload read failed: %s: %s" % (type(e).__name__, e))
return 0
if len(argv) > i + 2:
transcript_path = argv[i + 2]
transcript_path = transcript_path or str(payload.get("transcript_path") or "")
if not transcript_path and payload.get("_transcript_from_cwd"):
sid = str(payload.get("session_id") or "")
enc = str(payload.get("cwd") or "").replace("\\", "-").replace("/", "-").replace(":", "-")
transcript_path = os.path.join(
os.path.expanduser("~"), ".workbuddy", "projects", enc,
sid + ".jsonl") if sid else ""
else:
try:
raw = sys.stdin.buffer.read(MAX_STDIN)
payload = json.loads(raw.decode("utf-8", "replace") or "{}")
except Exception as e:
log("stdin parse failed: %s: %s" % (type(e).__name__, e))
return 0
transcript_path = str(payload.get("transcript_path") or "")
# ---- 🔴 2026-09-29 新增:UserPromptSubmit ⇒ 把**常驻程序的机械摘要注入会话上下文**
# 这就是"**程序 → 主会话**"的同步通道(用户要的:"他同步给你"):
# ⛔ 不需要任何自动化、⛔ 不需要"监管棒"角色 —— 用户每次发话时顺手把最新状态喂进来。
# ⚠️ 只有本事件**允许写 stdout**(stdout 正是钩子协议通道;其他事件仍保持零输出)。
event = str(payload.get("hook_event_name") or "")
if event == "UserPromptSubmit":
try:
ctx = "(常驻程序暂无摘要)"
if WS:
dg = os.path.join(WS, "tmp", "supervise-inbox", "digest.md")
if os.path.exists(dg):
with open(dg, "r", encoding="utf-8", errors="replace") as f:
ctx = f.read().strip()[:600] # 🔴 上限 600 字符:注入物**每轮**都进上下文,太长会让会话变慢
inbox = os.path.join(WS, "tmp", "supervise-inbox")
marks = [m for m in ("VACUUM.md", "READY.md", "STALL.md") if os.path.exists(os.path.join(inbox, m))]
if marks:
ctx += "\n⚠️ 有信号文件待处理:" + "、".join(marks)
sys.stdout.buffer.write(json.dumps(
{"hookSpecificOutput": {"hookEventName": "UserPromptSubmit",
"additionalContext": "【常驻程序同步 · 自动注入】\n" + ctx}},
ensure_ascii=False).encode("utf-8", "replace"))
except Exception:
pass
# 🔴 2026-09-29 加:摘要写完后(⛔ 不阻塞注入)顺手驱动常驻程序一轮。
# `SessionEnd` 那条注册实测从未被投递 ⇒ 回路只能挂在这个**确实会被投递**的事件上。
maybe_run_collabd_once()
try: # ⛔ 异常一律吞掉(绝不许因为投递让钩子失败)
_s, _l = classify(norm_path(payload.get("cwd") or ""))
maybe_ensure_supervise({"scope": _s, "line": _l}) # 顺手续命常驻(⛔ 不建排期、不投递)
except Exception:
pass
return 0
# ---- 🔴 2026-09-30 加:`PreToolUse` ⇒ **宿主侧的投递时机** = 主通道 --------------------
# 用户口径(「又给我整到自动任务去了」):**投递 ⛔ 不靠自动化排期、⛔ 不靠常驻进程**。
# 机制:任何会话跑一次 Bash ⇒ 宿主唤起本钩子(它是**宿主的子进程**,自带网关口令、
# ⛔ 不占会话、**零 token**)⇒ 顺手唤起**一轮监督程序**,把队列变化投给主会话。
# ⚠️ 本事件**必须零 stdout**(⚠️ 只有 UserPromptSubmit 允许写协议通道)。
# ⚠️ 作用域:`outside` 一律不碰(⛔ 不扩大影响面)。
if event == "PreToolUse":
# 🔴🔴 2026-10-05 `--tick` 删除:本事件原先唤起"投递轮"⇒ **整块删除**(投递早已退役,
# 且建排期/park 探针已搬进常驻,钩子这里⛔ 不再做任何协作判定)。
# ⛔ 不补 `--ensure`:本事件**极高频**(每次 Bash 都触发),续命交给 `UserPromptSubmit`。
# 🔴 A 方案:顺手把网关口令投给设备接入垫片(有 60s 节流;⛔ 零 stdout)
try:
maybe_deliver_gateway_token()
except Exception:
pass
return 0
# ---- 闸门 1:只处理 SessionEnd ----
if event != "SessionEnd":
log("skip: event=%r (only SessionEnd / UserPromptSubmit / PreToolUse)" % event)
return 0
# ---- 闸门 2:作用域(域外不读转录;但**被丢弃也要留痕**,⛔ 不静默)----
cwd = norm_path(payload.get("cwd") or "")
scope, line = classify(cwd)
if scope == "outside":
note_skip(payload, cwd, "outside-scope")
return 0
rec = build_record(payload, transcript_path, mode)
code = append_ledger(rec)
# 🔴 2026-09-29 加(用户定案架构):**本会话收尾 ⇒ 通知协作程序「这条处理完了」**
# 这就是用户说的「**处理完成后再通过 hook 让监控后台继续读下一条**」——
# 用**事件**驱动"继续",⛔ 不靠定时轮询。程序读 `gate-done.stamp` 的 mtime 放行。
try:
if WS:
_gd = os.path.join(WS, "tmp", "supervise-inbox", "gate-done.stamp")
with open(_gd, "w", encoding="utf-8") as f:
f.write("%s %s %s\n" % (time.strftime("%Y-%m-%d %H:%M:%S"),
str(payload.get("session_id") or "")[:8],
str(rec.get("line") or "")))
except Exception:
pass
log("done %s line=%s verdict=%s session=%s took=%.0fms tail=%dB"
% (code, rec["line"], rec["verdict"], rec["session"][:8], (time.time() - t0) * 1000, rec["tail_bytes"]))
maybe_sweep_logcap() # 🔴 hook → 解除"会话日志撞上限 ⇒ 输出被丢"(零 token)
maybe_run_supervisor(rec) # 🔴 hook → 监管(零 token、不占会话;见上方说明)
# 🔴 2026-09-29 改:合并为**单一协作守护程序** `collabd.py`(原 `advance-watch.py` 已被它吸收)
# `--once` = 跑一轮即退(零 token、不占会话);常驻版由主会话/检查点用宿主后台机制启动。
try:
_cd, _collabd_env = _collabd_ctx()
if _cd:
subprocess.run([sys.executable, "-u", _cd, "--once"],
env=_collabd_env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
timeout=25, check=False, creationflags=HIDE)
except Exception:
pass
return 0
if __name__ == "__main__":
try:
sys.exit(main(sys.argv))
except SystemExit:
raise
except BaseException as e: # 连 KeyboardInterrupt 也不许冒出去
try:
log("fatal: %s: %s" % (type(e).__name__, e))
except Exception:
pass
sys.exit(0)