Files
dsh_ai1net_server/归档/技能包快照/session-mechanism-20261004/scripts/hooks/wb-result-hook.py
T
admin c1b5e4d966 chore(工作区): 全量入库 + 补齐 .gitignore(以工作区为准)
- 变更规模:新增 514 / 修改 62 / 重命名 155 / 删除 4(归档重组与文档轮次)
- .gitignore 修:`归档/**/db-cwd归一-备份-*/` —— 原规则写绝对层级(归档/db-cwd归一-…),
  目录搬进 归档/配置与备份/ 后**静默失效**,43 MB 的 DB 备份又变成未跟踪
- .gitignore 补:嵌套 git 内部数据(归档/内嵌git-20261008/、归档/skills-git-旧线-20261007/dotgit-原样移出/)
- .gitignore 补:运行态与部署副本(.workbuddy/collab/、.workbuddy/tools/、.workbuddy/.load-pending、.workbuddy/tmp-*)
- .gitignore 补:备份件(*.bak-*)
- 未跟踪文件从 2190 降到 890(其余为 归档/ 归档件与 .workbuddy/memory/ 知识文件,按口径入库)
2026-10-10 23:13:22 +08:00

1188 lines
64 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env 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
# [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 subprocess
import sys
import tempfile
import time
from datetime import datetime
# 🔴 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
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
def _win_pythonw():
"""常驻/后台子进程优先用 `pythonw.exe`(GUI 子系统)—— Windows 永不为它分配控制台窗口,
比单靠 CREATE_NO_WINDOW 更稳(2026-10-02 闪屏根治)。⛔ 不存在时回退本解释器。
🔴🔴 2026-10-03 **修一处让它每次调用都崩的真缺陷**(取证:`hook.log` 停在 10-02 21:24、
手工喂 `UserPromptSubmit` 立刻 `NameError: name 'PYW' is not defined`):
原写法 `PYW = _win_pythonw()` —— 而函数体内部引用 `PYW` ⇒ **调用时 `PYW` 尚未赋值**
⇒ **模块导入即 NameError** ⇒ 本 hook 自 10-02 起**一次都没真跑过**(三个事件全废)。
✅ 判据(不得靠"读代码觉得对"):`_win_pythonw()` 的函数体⛔ 不许引用任何尚未赋值的全局。
✅ 兜底用 `sys.executable`(**当前解释器自身**)—— 天然已定义、且必定可执行。"""
_me = sys.executable or "python"
if os.name != "nt":
return _me
_d = os.path.dirname(os.path.abspath(_me))
_pw = os.path.join(_d, "pythonw.exe")
return _pw if os.path.isfile(_pw) else _me
PYW = _win_pythonw()
# ---------------------------------------------------------------------------
# 🔴 工作区根**靠上溯发现**,⛔ 不靠"脚本在第几层"(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") or os.environ.get("DSH_WS_ROOT")
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`,不动别的。
# 判据/修法全文 ⇒ 技能 session-mechanism(references/forensics.md §4)
# ---------------------------------------------------------------------------
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(
[PYW, "-u", SWEEP, "--quiet"],
cwd=WS or None,
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
timeout=max(1.0, min(float(SWEEP_TIMEOUT), _left() - 1.0)), 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(
[PYW, "-u", SUPERVISE, "--once"],
cwd=WS or None,
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
timeout=max(1.0, min(float(SUP_TIMEOUT), _left() - 1.0)), 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 全落本钩子)⇒ ⛔ 不依赖重启宿主(常驻的钟另算,见下方 tick 段)。
# 护栏:静默 · 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 警告过「指向旧副本 ⇒ 唤醒回路等于没接」);找不到就**停手**,⛔ 不猜。
# 🔴 2026-10-01 纠错:本行原按 `3×dirname(__file__)` 推根 —— 包内这份推出来**正好=技能包自己**
# ⇒ 钩子把技能包当工作区交给 collabd ⇒ 在包里长出 `tmp/supervise-inbox/`、且找不到配置 ⇒ 崩。
# ⇒ 改为**复用上面已算好的 `resolve_ws()`**(env 优先 + 内容标志上溯),⛔ 不再有第二套推法。
_WS_ROOT = WS
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 = _env.config_dir() # 🔴 2026-10-02 共用判据(⛔ 不再用 `~` 直拼)
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 "", {}
if not _WS_ROOT:
return "", {} # ⛔ 不知道工作区 ⇒ 停手(⛔ 不拿 cwd/包目录顶替)
_cenv = dict(os.environ)
_cenv["COLLABD_WORKSPACE"] = _WS_ROOT
_cfg = os.path.join(_WS_ROOT, ".workbuddy", "collab", "collabd.config.json")
if os.path.isfile(_cfg):
_cenv["COLLABD_CONFIG"] = _cfg
return _p, _cenv
def maybe_run_collabd_once() -> None:
"""受控地跑一轮协作程序。⛔ 任何异常都不许冒出去。"""
if not INBOX:
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
# 🔴 2026-10-02 二修:`--once` 实测 0.5s,但它的**硬超时是 12s** ⇒ 抖动一次就吃掉大半预算;
# 且本钩子**不需要它的返回值** ⇒ 一律后台(⛔ 不等)。`SessionEnd` 那处保持同步另算
# (那里有 `_left()` 预算兜底,且语义上要确定性),本函数是 `UserPromptSubmit` 专用。
try:
_bg(_cd, ["--once"], env=_collabd_env, tag="once", min_gap=60.0)
except Exception:
pass
# ---------------------------------------------------------------------------
# ---------------------------------------------------------------------------
# 🔴 触发源(2026-10-01 用户定案)· 本钩子只作**补充**,⛔ 不是投递的前置:
# · **主 = 常驻投递**:`collabd.py --supervise` **一直运行**,按周期自己读队列 ⇒ 自己拨。
# · **补 = 本钩子**:`UserPromptSubmit` 等事件到了,顺手补投一版 ⇒ 让投递更及时。
# 用户原话:「协作与投递一直运行(常驻) 因为可能不是所有 队列都是钩子产生的 ,
# 而且定时任务的方案已经废弃了」
# ⛔ 「需要投递的时刻必然伴随会话在动」是**错假设** —— 队列可能由**非钩子**的来源产生,
# 那一刻没有事件、也就没人投递 ⇒ **纯事件驱动必漏**,必须靠常驻的钟补上。
# ⛔ 已废弃「用自动任务当闹钟」;⛔ 也不靠宿主排期。
# 事实(源码 + 本机实测):钩子是宿主起的子进程 ⇒ ① 继承网关口令 `CODEBUDDY_GATEWAY_PASSWORD`
# (2026-09-30 实测本机会话内进程 `len=43`)② ⛔ 不占任何会话 ③ **零 token**;
# · `UserPromptSubmit` 两日 26 次 spawn 实测**会被宿主投递**(`SessionEnd` 反而 0 次)。
# 护栏:① scope 非 outside 才触发 ② 节流 TICK_GAP 秒 ③ 硬超时 ④ 一切异常全吞 ⑤ 隐窗
# ---------------------------------------------------------------------------
TICK_GAP = 120 # 秒:最快多久唤起一轮(⚠️ 必须 < collabd 的 `wake_min_gap`,否则投递被本层饿死)
TICK_TIMEOUT = 20 # 秒:硬超时(⛔ 钩子注册的 timeout 必须比它大)
TICK_STAMP = os.path.join(INBOX, "_tick.stamp") if INBOX else ""
# ── 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
# 🔴 2026-10-02 二修:原实现**同步** `timeout=12` ⇒ 挂在 `PreToolUse ^Bash$` 上
# ⇒ **本机每一条 Bash 命令执行前都要先等它**(最坏 12s)=「会话无法执行」的第二大致命点。
# 它不需要任何返回值 ⇒ 一律后台(投递能力不变:detach 只脱离进程树,⛔ 不丢 env 里的口令)。
try:
_bg(_TOKEN_DELIVER, [], env=dict(os.environ), tag="tok", min_gap=5.0)
except Exception:
pass
# ── 🔴 2026-10-02 加:「**没有相关会话就自动拉起**」(用户口径)────────────────────
# 用户原话(逐字):「应该是 用户说 协作会话 完成目标 和 继续执行 的时候
# 如果没有 相关会话就自动拉起」
# 用户 2026-10-02 13:18 **再订正触发面(逐字)**:「是用户说 **使用协作会话方式 完成目标**
# 或 **继续完成目标**」⇒ 触发词按这两句取(`GAP_TRIGGERS`)。
# · 判据面=`collabd.py --gap --json`(**只读**:判"缺没缺"+给现成排期参数)。
# · 本钩子**只把结论注入当前会话上下文** ⇒ 由**会话**用 `automation_update` 建排期。
# 🔴 为什么必须绕这一手:**只有自动化能开新会话**,而**脚本写 `automations` 表是双红线**
# (§2.3.0e ⚠️ 那段)⇒ "缺会话 ⇒ 自动拉起"在架构上**只剩这一条可行通路**。
# ⛔ 旧行为(写 `NEED-USER.md` 请**用户**去开一条会话)=把机制该干的活推给人 ⇒ 本条取代它。
# · 触发两条:① 用户话里命中触发词 ⇒ **立刻查**(无视节流) ② 否则按 `GAP_GAP` 节流查一次。
# · 一切异常吞掉(钩子绝不能因此失败);⛔ 结果为空 ⇒ 不注入(⛔ 不制造噪音)。
# ⚠️ 触发词=用户原话的两个说法(2026-10-02 13:18 订正):
# 「使用协作会话方式**完成目标**」/「继续**完成目标**」⇒ 取 `协作会话方式` 与 `完成目标` 两个词;
# `继续执行` 保留为**宽松兜底**(同一意图的另一种说法,命中只是"多查一次",无副作用)。
#
# 🔴🔴 2026-10-02 18:0x **收紧授权面**(P0-24 的**同一个洞、第二个方向**):
# 事故:用户说「**继续完成目标,并打开协作看板**」⇒ 命中 `完成目标` ⇒ 钩子判定**已授权** ⇒ 注入派活指令。
# ⛔ 「完成目标」「继续执行」是**通用说法、随处可见**(「继续完成目标」根本不必含"协作"),
# 拿它当授权 = **系统自己把"用户在说别的"读成"用户要用协作机制"**。
# 这与 17:5x 的越权是**同一个判据缺陷的两个方向**:那次是"没授权也派"、这次是"乱授权也派"。
# ⇒ 修法:**授权只认「协作会话」这一个词根**(必须真的在说协作机制),且要求**不是命令式祈使**
# ("打开协作看板"是用户要**看**看板,⛔ 不是要**建**会话)。通用说法一律降级为"仅通报"。
# ⇒ 代价(明确接受):用户说「继续完成目标」而**没提协作**时,⛔ 不自动拉起 —— 那就**不拉**,
# 顶多少一次通报。这是**宁可漏、不可越权**的取舍。
GAP_AUTH_ROOTS = ("协作会话",) # 🔴 唯一授权词根:要协作机制时一定会说到它
# ⛔ 刻意**不含**:完成目标 / 继续执行 / 接着做 —— 通用说法,证明不了"要用协作"。
GAP_TRIGGERS = ("协作会话", "完成目标", "继续执行") # 仅用于「要不要跑 --gap 检查」的节流判断
GAP_GAP = 120.0 # 秒:非触发词时的最小检查间隔(触发词命中 ⇒ 无视)
GAP_TIMEOUT = 25 # 秒:硬超时(⛔ 钩子注册的 timeout 必须比它大)
GAP_STAMP = os.path.join(INBOX, "_gap.stamp") if INBOX else ""
GAP_MAX = 700 # 字符上限:注入物**每轮**都进上下文(太长会让会话变慢)
# 🔴 2026-10-02 加:`--gap` 实测 **13.5s**(见 tmp/hook-timing-probe.py 实测)⇒ ⛔ 不能再同步堵在
# `UserPromptSubmit`(宿主注册 20s)上 ⇒ 结果**落盘缓存**,钩子只读缓存(毫秒级),
# 后台进程负责刷新。缓存内容最多滞后一个刷新周期(无害:它只是"建议",不是判据)。
GAP_CACHE = os.path.join(INBOX, "gap-cache.json") if INBOX else ""
GAP_CACHE_TMP = os.path.join(INBOX, "gap-cache.json.tmp") if INBOX else ""
GAP_CACHE_TTL = 300.0 # 秒:缓存多久算新鲜
# ---------------------------------------------------------------------------
# 🔴 2026-10-02 加:**钩子总预算**(宿主给每个事件注册的 timeout ≠ 本脚本能用的全部)
# 事故:UserPromptSubmit 注册 20s,而本脚本在该路径上串了 `--gap`(13.5s) + `--once`(0.5s)
# + `--tick`(13.5s) ≈ **27.5s** ⇒ 宿主掐断 ⇒
# `UserPromptSubmit operation blocked by hook: Hook timed out after 20000ms`
# ⇒ **用户发的每一句话都被拦下**(不只是慢,是提交失败)。
# ⇒ 三条硬规则:① 每个事件**开局就认领预算**(BUDGET,比注册值少 2s 余量)
# ② 凡是**要等**的子进程,先问 `_left()` 够不够(不够 ⇒ 跳过并留痕)
# ③ 不需要结果的活 ⇒ **后台跑**(`_bg`,⛔ 不 wait)⇒ 钩子毫秒级返回
# ---------------------------------------------------------------------------
BUDGET = {"UserPromptSubmit": 18.0, "PreToolUse": 25.0, "SessionEnd": 8.0}
_T0 = time.time()
_BUDGET = 15.0
# 同步等待一个"贵活"所需的**实测**耗时(2026-10-02 实测:`--gap` 13.5s | `--tick` 13.5s | `--once` 0.5s)
GAP_SYNC = 16.0
TICK_SYNC = 16.0
_HEAVY_DONE = False # 🔴 本轮已经同步干过一个贵活 ⇒ 另一个只能后台(⛔ 一轮一个,别叠到 27s)
def _hard_exit() -> None:
"""硬闸触发:留痕后**立刻**退出(fail-open)。"""
try:
log("⚠️ 硬闸触发(%.1fs 预算耗尽)⇒ 强制退出。UserPromptSubmit 未产出注入,属 fail-open。" % _BUDGET)
except Exception:
pass
os._exit(0)
def _arm_deadline() -> None:
"""🔴 **自身硬闸**(2026-10-02 加 · P0-23 的第 4 条硬规则)
为什么必须有这道闸:上面三处「改后台」只是**改掉了这一次的三个具体活**;
🔴 **任何人(含以后的另一个会话)只要再往这条路上加一个同步子进程,故障就原样复发**,
而复发的第一现场是「用户说不了话」——**不可能等到事后排查**。
⇒ 由脚本自己对削 patient 底线:**到点强制退出**,绝不让宿主掐。
fail-open 方向是明确的:**宁可本轮少注入一段(═ 什么都没发生),也绝不让会话被拦下**。
⚠️ `daemon=True` 是必须的:非 daemon 线程会让解释器在退出时**卡住不退出**(等同另一种超时)。
⚠️ `os._exit(0)` 而非 `sys.exit`:此时主线程可能正卡在 `subprocess.run` 上,
正常退出路径走不到 ⇒ 只能强杀(留痕已先行写盘)。
"""
try:
import threading
t = threading.Timer(max(1.0, _BUDGET - 0.5), _hard_exit)
t.daemon = True
t.start()
except Exception:
pass
def set_budget(event: str) -> None:
global _BUDGET
_BUDGET = BUDGET.get(event, 10.0)
_arm_deadline() # 🔴 开局即上闸:后面无论怎么改,都不可能越过宿主预算
def _left() -> float:
"""本轮还剩多少秒(⛔ 负数 = 已超预算)。"""
return _BUDGET - (time.time() - _T0)
def _bg(script: str, args, env=None, tag: str = "bg", out_path=None, min_gap: float = 60.0) -> bool:
"""**后台**起一个子进程,⛔ 不等它回来(钩子立刻返回)。
⛔ 只给"不需要结果"的活(`--tick` / `--gap` 刷新):输出一律落盘,⛔ 不用 PIPE
(PIPE 的坑:超时后 `communicate()` 会等孙进程放管道 ⇒ **即使 kill 也回不来**)。
⛔ 同 tag 已有活着的后台 ⇒ 不再起(防止每轮发话堆一个 13s 进程)。
⚠️ 口令照旧在 env 里(detach 只脱离进程树,⛔ 不丢 env)⇒ 投递能力不受影响。
"""
if not script or not os.path.isfile(script):
return False
try:
# ⚠️ 判"上一个还在跑"用**起始时间戳**,⛔ 不用 `os.kill(pid, 0)`:
# Windows 上 `os.kill` 对 `sig=0` 的行为不保证(有版本直接 TerminateProcess)⇒ 宁可不探活。
_pf = os.path.join(INBOX, "_bg-%s.stamp" % tag) if INBOX else ""
if _pf and os.path.isfile(_pf):
try:
if (time.time() - os.path.getmtime(_pf)) < min_gap:
return False # 上次刚起(可能还在跑)⇒ ⛔ 不再堆一个
except Exception:
pass
try:
if _pf:
with open(_pf, "w", encoding="utf-8", newline="\n") as f3:
f3.write(datetime.now().strftime("%Y-%m-%d %H:%M:%S"))
except Exception:
pass
_out = out_path or (os.path.join(INBOX, "_bg-%s.out" % tag) if INBOX else os.path.join(
tempfile.gettempdir(), "wb-hook-bg-%s.out" % tag))
f = open(_out, "ab")
_flags = HIDE
if os.name == "nt":
_flags |= 0x00000008 | 0x00000200 # DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP
subprocess.Popen([PYW, "-u", script] + [str(a) for a in args],
env=env or dict(os.environ), stdout=f, stdin=subprocess.DEVNULL,
stderr=subprocess.DEVNULL, creationflags=_flags, close_fds=True)
return True
except Exception as e:
try:
log("bg spawn(%s) 失败(已忽略): %r" % (tag, e))
except Exception:
pass
return False
def _prompt_text(payload) -> str:
"""取用户这轮说的话(⛔ 猜不到 ⇒ 空串,且**不因此放弃检查** —— 节流那条路照样走)。
⚠️ 字段名按 Claude-Code 式钩子契约是 `prompt`;`user_prompt` / `text` / `message` 只是兜底
(⛔ 别把兜底当契约)。拿不到原文 ⇒ 只影响"触发词加急",不影响"没会话就拉起"这件事本身。
"""
for k in ("prompt", "user_prompt", "text", "message"):
v = payload.get(k)
if isinstance(v, str) and v.strip():
return v
if isinstance(v, dict):
_t = v.get("text") or v.get("content")
if isinstance(_t, str) and _t.strip():
return _t
return ""
def _gap_text(g: dict, authorized: bool) -> str:
"""`--gap` 的 JSON ⇒ 注入文本(无缺口 ⇒ 空串)。⛔ 纯函数 —— 现算与"读缓存"共用同一段。
🔴 2026-10-02 17:5x **结构性修正**(事故:用户没提协作,会话却被注入的指令逼着建了两条排期):
旧版把「⛔ 不要问用户、直接建排期」**写死在文案里**,而且这段文案被**存进缓存**逐轮复用
⇒ 每轮都在下命令,**与用户当轮有没有授权完全无关**。那个会话的判断是对的:它照着注入的指令做了。
⇒ 三处同时改:
① **授权闸**(`authorized`):只有用户当轮**明确说了**协作类诉求,才输出"自动拉起"指令;
否则只输出**中性事实**(有几条缺、叫什么),并**明写「⛔ 不要建排期」**。
② **文案不再命令**:「⛔ 不要问用户」**整句删除** —— 建不建排期是**用户的决定**,
会话最多**报告**、⛔ 不得代替用户拍板(越权 = 事故,不是"勤快")。
③ **只报已存在者**:提示里标出哪些**同名排期已经在册**(去重判据),
⛔ 不让会话把同一条反复排。
"""
hard = (g or {}).get("hard") or []
if not hard:
return ""
_lines = []
for r in hard[:4]:
p = r.get("plan") or {}
_nm = p.get("name") or "?"
_dup = " ⚠️**同名排期已在册** ⇒ ⛔ 别再排一遍" if _nm in _known_plan_names() else ""
_lines.append("· %s(类别「%s」,在册 %s 条、都不活 / 一条都没有)⇒ `%s`(一次性 %s)%s"
% (r.get("role_zh") or r.get("role"), r.get("topic") or "(默认类别)",
r.get("n_total"), _nm, p.get("scheduledAt") or "?", _dup))
_body = ("本工作区有 **%d 条硬缺**(硬缺=跟进/协作会话没有活着的):\n%s\n"
% (len(hard), "\n".join(_lines)))
if authorized:
out = ("⚠️【缺会话 ⇒ 自动拉起】" + _body +
"🔴 你**本轮明确说了**要用协作会话方式完成目标 ⇒ 用 `automation_update` 按上面名字建排期"
"(参数见 `collabd.py --gap`,prompt 现成)把它拉起来。"
"⚠️ 仍要遵守:① **同名已在册的别重排**;② 建完在回复里**说清这是你依据本轮授权做的**。\n"
"('把一条能收指令的会话带回来'只有**排期**做得到;`jobs/resume` 给的是 background worker、"
"进不了 live ⇒ 接不到 reply。⛔ 脚本不许写 `automations` 表。)")
else:
out = ("ℹ️【协作机制 · 现状通报】(⛔ **仅通报,不构成行动指令**)\n" + _body +
"🔴 **本轮用户没有要求使用协作会话** ⇒ ⛔ **不要建任何排期**、⛔ 不要写「请你去开一条会话」、"
"⛔ 不要把它当待办。真的要做时,用户会明确提出。"
"\n(口径来源:`session-mechanism` 技能 · 协作机制。**建不建排期是用户的决定**,"
"会话最多报告,⛔ 不得代用户拍板。)")
return out[:GAP_MAX]
def _known_plan_names():
"""在册排期里已存在的**同名**集合(去重判据)。读不到 ⇒ 空集(宁可少提示,⛔ 不错报)。"""
try:
db = os.path.join(_env.config_dir(), # 🔴 2026-10-02 共用判据
"workbuddy.db")
if not os.path.isfile(db):
return set()
import sqlite3 as _sq
con = _sq.connect("file:%s?mode=ro" % db.replace("\\", "/"), uri=True, timeout=3)
rows = con.execute("select name from automations where status='ACTIVE'").fetchall()
con.close()
return set(r[0] for r in rows if r and r[0])
except Exception:
return set()
def _reap_gap_cache() -> None:
"""后台刷新写完了 ⇒ 把 tmp 收割成正式缓存(**毫秒级**,每轮开头做一次)。
⚠️ 后台进程还握着句柄时 `os.replace`/`remove` 会失败 ⇒ 一律吞掉,**下一轮再收**(无害)。
"""
if not GAP_CACHE or not GAP_CACHE_TMP or not os.path.isfile(GAP_CACHE_TMP):
return
try:
if os.path.getsize(GAP_CACHE_TMP) < 2:
return
with open(GAP_CACHE_TMP, encoding="utf-8", errors="replace") as f:
g = json.loads(f.read() or "{}")
if not (g.get("rows") or g.get("topics") or g.get("hard")):
return # 半截文件/空输出 ⇒ ⛔ 不收(别把缓存写坏)
_t2 = GAP_CACHE + ".tmp2"
with open(_t2, "w", encoding="utf-8", newline="\n") as f:
# 🔴 2026-10-02 17:5x:**存原始 `--gap` JSON**(`_raw`),⛔ 不再存 `_text` 成品文案。
# 理由:文案是**随授权状态变**的(旧版把「⛔ 不要问用户」烤进了缓存 ⇒ 逐轮越权)。
# `_raw` 缺失的旧缓存会被 `_cached_gap_text` 判为无效 ⇒ 自动重刷,不做兼容迁移。
json.dump({"_ts": time.time(), "_raw": g}, f, ensure_ascii=False)
os.replace(_t2, GAP_CACHE)
try:
os.remove(GAP_CACHE_TMP)
except Exception:
pass
except Exception:
pass
def _cached_gap_text():
"""(注入文本, 缓存年龄秒)。读不到 ⇒ ("", 1e9)。
🔴 2026-10-02 17:5x:文本**在此刻现算**(`_gap_text(raw, authorized)`),
⛔ 不再返回缓存里烤好的文案 ⇒ 本轮授权状态与呈现**永远一致**。
🔴 授权只认**用户当轮的明确诉求**(`GAP_TRIGGERS`)⇒ 没提就只通报、不派活。
"""
if not GAP_CACHE or not os.path.isfile(GAP_CACHE):
return "", 1e9
try:
_age = time.time() - os.path.getmtime(GAP_CACHE)
_j = json.loads(open(GAP_CACHE, encoding="utf-8", errors="replace").read() or "{}")
_raw = _j.get("_raw")
if not isinstance(_raw, dict) or not _raw.get("hard"):
return "", 1e9 # 旧版缓存(只有 `_text`)⇒ 判无效,下轮重刷
_auth = _authorized_now()
return _gap_text(_raw, _auth), _age
except Exception:
return "", 1e9
def _authorized_now() -> bool:
"""用户**当轮**是否明确要求用协作会话方式完成目标。
🔴 判据只有一条:**当轮原话里出现「协作会话」这个词根**(`GAP_AUTH_ROOTS`)。
⛔ 刻意**不用**这些当授权(2026-10-02 18:0x 教训,两个方向都栽过):
· 「完成目标」「继续执行」—— **通用说法**,会到处命中(用户说「继续完成目标,并打开协作看板」
就被误判成授权 ⇒ 白派活);说这话的人**根本没提协作**。
· 「上一轮说过」「队列有缺口」「机制建议」—— 那些是**系统自己的诉求,不是用户的**。
⚠️ 也不认命令式祈使(如「打开协作看板」):用户要**看**看板 ≠ 要**建**会话。
"""
_t = _prompt_text(_CUR_PAYLOAD)
if not _t:
return False
if not any(r in _t for r in GAP_AUTH_ROOTS):
return False
# 用户点名的是"协作机制"才授权;若这句明显在**索取展示**(看板/状态/看看),视为非授权
if any(k in _t for k in ("看板", "状态", "看看", "打开看板", "看一下")):
return False
return True
_CUR_PAYLOAD = {}
def maybe_inject_session_gap(payload) -> str:
"""缺会话 ⇒ 返回要注入的文本(无缺口 / 查不动 ⇒ 空串)。⛔ 只读,⛔ 不建排期。
🔴 2026-10-02 改(事故:`--gap` 实测 13.5s,同步堵在 `UserPromptSubmit` 上 ⇒ 钩子 20s 被掐 ⇒
用户**每句话都被拦**):
· **读缓存**为主(毫秒级)——缓存由**后台**进程刷新;
· 缓存缺失/过期 ⇒ **后台**刷新(⛔ 不等,见「二修」段),本轮读不到就**不注入**。
🔴 2026-10-02 17:5x 改(事故:会话被注入的指令**逼着建了用户没授权的排期**):
· **当轮 payload 存进 `_CUR_PAYLOAD`** —— 否则授权判据拿不到用户原话,闸门形同虚设;
· **注入内容随授权状态变**(通报 vs 派活)—— 见 `_gap_text`。
"""
global _HEAVY_DONE, _CUR_PAYLOAD
if not INBOX or not GAP_STAMP:
return ""
_CUR_PAYLOAD = payload or {} # 🔴 先存,后面的授权判据要用
_reap_gap_cache()
_txt = _prompt_text(payload)
_hit = [t for t in GAP_TRIGGERS if t and t in _txt]
_cached, _age = _cached_gap_text()
_need = bool(_hit)
if not _need:
if _age >= GAP_CACHE_TTL:
_need = True
else:
try:
if (not os.path.isfile(GAP_STAMP)
or (time.time() - os.path.getmtime(GAP_STAMP)) >= GAP_GAP):
_need = True
except Exception:
_need = True
if _need:
_cd, _env = _collabd_ctx()
if _cd:
try:
with open(GAP_STAMP, "w", encoding="utf-8", newline="\n") as f:
f.write(datetime.now().strftime("%Y-%m-%d %H:%M:%S") + "\n")
except Exception:
pass
# 🔴 2026-10-02 二修(15:31 那版**没修干净**:仍留了同步兜底 ⇒ 16:12:38 实测 12.8s,
# 叠加解释器启动与并发子进程抢 CPU ⇒ 越过宿主 20s ⇒ **用户每句话都被拦**):
# ⇒ **UserPromptSubmit 上一个子进程都不许同步等**。理由是一条算术题:
# 宿主注册 20s - 解释器启动与 payload 解析(本机实测 0.3~1.0s,波动不可控)
# - `--gap` 实测均值 13.5s(且随负载上飘)= 余量不足 6s,
# 而同一事件还挂着另外 5 个钩子在抢同一颗 CPU ⇒ **任何一次抖动都越线**。
# ⇒ 缓存没有 / 缓存过期 ⇒ **本轮就少注入一段**(⛔ 绝不为此等待)。
# 这段注入是**建议**而非判据:缺一轮不产生任何错误行为,下一轮自然补上。
# ⛔ 判:「是不是真的缺会话」以 `collabd.py --gap` 现跑为准,⛔ 不以本钩子注入与否为准。
_bg(_cd, ["--gap", "--json"], env=_env, tag="gap",
out_path=GAP_CACHE_TMP, min_gap=90.0)
return _cached or ""
def maybe_run_supervisor_tick(rec, wait: bool = False) -> None:
"""宿主钩子唤起**一轮投递**(单条+握手 / 三条件唤醒)⇒ 由它把通知投给主会话。
⛔ 任何异常都不许冒出去(钩子绝不能因为这个失败)。
⚠️ 本函数**依赖宿主进程树里的网关口令**;在会话外手工跑会得到 `no-token`(属预期,⛔ 不是 bug)。
🔴 2026-10-02 加 `wait`:**默认后台跑**(`--tick` 实测 13.5s,同步等会把 `UserPromptSubmit`
顶到 20s 之外 ⇒ 用户发话被拦)。只有调用方明确要结果 **且** 预算充足时才同步等。
"""
if not INBOX or (rec or {}).get("scope") == "outside":
return # 域外不碰(⛔ 不扩大影响面)
_cd, _collabd_env = _collabd_ctx()
if not _cd:
return
try:
if TICK_STAMP and os.path.isfile(TICK_STAMP):
if (time.time() - os.path.getmtime(TICK_STAMP)) < TICK_GAP:
return
except Exception:
pass
try:
with open(TICK_STAMP, "w", encoding="utf-8", newline="\n") as f:
f.write(datetime.now().strftime("%Y-%m-%d %H:%M:%S"))
except Exception:
pass
try:
# 🔴 一轮只允许一个贵活:`_HEAVY_DONE` ⇒ 本轮已同步跑过 `--gap` ⇒ 这个只能后台。
if wait and _left() > (TICK_SYNC + 1.0) and not _HEAVY_DONE:
subprocess.run([PYW, "-u", _cd, "--tick"],
env=_collabd_env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
timeout=max(1.0, min(float(TICK_TIMEOUT), _left() - 1.0)),
check=False, creationflags=HIDE)
_HEAVY_DONE = True
else:
_bg(_cd, ["--tick"], env=_collabd_env, tag="tick", min_gap=90.0) # ⛔ 不 wait ⇒ 钩子毫秒级返回
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 _dump_unknown_event(payload, argv):
"""空事件名 ⇒ **全量留痕**(payload 原文 + 相关 env + argv)。
2026-10-02:光记 payload keys 不够 —— 宿主在 asar 里根本搜不到 `hook_event_name` 字面量,
⇒ 只能等一次真实调用把**完整原文**抓下来。同一 session 只留一份(⛔ 不刷屏、不占盘)。
落到 `tmp/supervise-inbox/_unknown-event-<sid>.json`。
"""
try:
if not INBOX:
return ""
sid = (str(payload.get("session_id") or "")[:8] if isinstance(payload, dict) else "") or "nosid"
p = os.path.join(INBOX, "_unknown-event-%s.json" % sid)
if os.path.exists(p):
return p
keys = [k for k in os.environ
if any(t in k.upper() for t in ("HOOK", "SESSION", "EVENT", "TRANSCRIPT", "STOP"))]
data = {"ts": time.strftime("%Y-%m-%d %H:%M:%S"),
"argv_tail": [str(a)[:200] for a in argv[1:6]],
"env": {k: str(os.environ.get(k, ""))[:200] for k in sorted(set(keys))[:24]},
"payload_keys": sorted(payload.keys()) if isinstance(payload, dict) else [],
"payload": payload}
tmp = p + ".tmp"
with open(tmp, "w", encoding="utf-8", errors="replace") as f:
json.dump(data, f, ensure_ascii=False, indent=1, default=str)
os.replace(tmp, p)
return p
except Exception:
return ""
def _recover_event(payload):
"""**反推**被宿主留空的事件名(⛔ 拿不准就返回 "",保持"丢弃但留痕"的旧行为)。
判据按 payload **形态**分(与真实抓到的三种 payload 对齐):
· 含 `prompt` / `user_prompt` ⇒ UserPromptSubmit
· 含 `tool_name` / `tool_input` ⇒ PreToolUse
· 只有 `session_id` / `transcript_path` ⇒ SessionEnd(收尾型调用)
"""
if not isinstance(payload, dict):
return ""
k = set(payload.keys())
if k & {"prompt", "user_prompt"}:
return "UserPromptSubmit"
if k & {"tool_name", "tool_input"}:
return "PreToolUse"
if payload.get("session_id") or payload.get("transcript_path"):
return "SessionEnd"
return ""
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(
_env.config_dir(), "projects", enc, # 🔴 2026-10-02 共用判据
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 not event:
# 🔴 2026-10-02 取证(**颠覆旧结论**):`hook.log` 15:34:19/20/21 有 3 次 `skip: event=''`
# ⇒ 宿主**确实调用了本钩子**,但 payload 里 `hook_event_name` 是空的 ⇒ 事件被本分支丢掉,
# 从外面看就**像"SessionEnd 从未投递"**(这正是 `SessionEnd 从不投递` 这条旧结论的来源 —— **它是错的**)。
# ⇒ 2026-10-02 **接线完成**(不再等下一棒):
# ① 把 payload **原全文**落盘(同 session 一份)⇒ 下次出事可精确对号;
# ② 按 payload **形态反推**事件名(`_recover_event`)⇒ 让 SessionEnd 那条路真正走通。
# ⛔ 反推不出来才退回旧行为(丢弃并留痕),⛔ 不猜。
_dump_unknown_event(payload, argv)
_ev = _recover_event(payload)
log("probe: 空事件名(宿主已调用)keys=%s ⇒ recover=%r"
% (sorted(payload.keys())[:12] if isinstance(payload, dict) else type(payload).__name__, _ev))
if _ev:
event = _ev
set_budget(event) # 🔴 2026-10-02:**开局就认领预算**(超时=用户发话被拦,⛔ 不是慢而已)
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)
# 🔴 2026-10-02:缺会话 ⇒ **自动拉起**(判据+方案由 `collabd.py --gap` 给,本钩子只注入)
_gp = ""
try:
_gp = maybe_inject_session_gap(payload)
except Exception:
_gp = ""
if _gp:
ctx += "\n" + _gp
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` 那条注册实测从未被投递 ⇒ 回路只能挂在这个**确实会被投递**的事件上。
if _left() > 3.0: # `--once` 实测 0.5s(⛔ 按实测给门槛,别按硬超时 12s)
maybe_run_collabd_once()
try: # ⛔ 异常一律吞掉(绝不许因为投递让钩子失败)
_s, _l = classify(norm_path(payload.get("cwd") or ""))
maybe_run_supervisor_tick({"scope": _s, "line": _l}) # **后台**投一轮(⛔ 不 wait、⛔ 不靠排期)
except Exception:
pass
log("UserPromptSubmit done in %.0fms (budget %.0fs, left %.1fs)"
% ((time.time() - t0) * 1000, _BUDGET, _left()))
return 0
# ---- 🔴 2026-09-30 加:`PreToolUse` ⇒ **宿主侧的投递时机** = 主通道 --------------------
# 触发源(2026-10-01 定案):**主=常驻投递、补=本钩子**;⛔ 不靠自动化排期(已废弃)。
# 机制:任何会话跑一次 Bash ⇒ 宿主唤起本钩子(它是**宿主的子进程**,自带网关口令、
# ⛔ 不占会话、**零 token**)⇒ 顺手唤起**一轮投递**,把队列变化投给主会话。
# ⚠️ 本事件**必须零 stdout**(⚠️ 只有 UserPromptSubmit 允许写协议通道)。
# ⚠️ 作用域:`outside` 一律不碰(⛔ 不扩大影响面)。
if event == "PreToolUse":
try:
_s, _l = classify(norm_path(payload.get("cwd") or ""))
if _s in ("line", "home"):
# ⛔ 不改同步:Bash 前同步等 14s 会让**每条命令都变慢**;投递的**主通道是常驻**,
# 钩子只是补 ⇒ 一律后台(2026-10-02 实测后台 15s 内跑完,投递照样发生)。
maybe_run_supervisor_tick({"scope": _s, "line": _l}, wait=False)
except Exception:
pass
# 🔴 A 方案:顺手把网关口令投给设备接入垫片(有 60s 节流;⛔ 零 stdout)
try:
if _left() > 14.0: # 该子过程硬超时 12s ⇒ 预算不够就别起(⛔ 起了也必被掐)
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"]))
# 🔴 2026-10-02:`SessionEnd` 宿主只给 **10s** ⇒ 每一步都先问预算,超了就跳过并留痕
# (⛔ 旧行为:`--tick` 13.5s + `--once` 25s 硬超时 ⇒ 这条必然被掐,"投递"其实从未跑完)
if _left() > 2.0: # 实测 0.95s(⛔ 别按"硬超时 8s"挡,那样在 8s 预算下永远跑不到)
maybe_sweep_logcap() # 🔴 hook → 解除"会话日志撞上限 ⇒ 输出被丢"(零 token)
if _left() > 2.0: # 实测 0.96s,同上
maybe_run_supervisor(rec) # 🔴 hook → 监管(零 token、不占会话;见上方说明)
maybe_run_supervisor_tick(rec, wait=True) # 🔴 hook → **投递**(预算够才同步等)
# 🔴 2026-09-29 改:合并为**单一协作守护程序** `collabd.py`(原 `advance-watch.py` 已被它吸收)
# `--once` = 跑一轮即退(零 token、不占会话);常驻版由主会话/检查点用宿主后台机制启动。
try:
_cd, _collabd_env = _collabd_ctx()
if _cd and _left() > 5.0:
subprocess.run([PYW, "-u", _cd, "--once"],
env=_collabd_env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
timeout=max(1.0, min(25.0, _left() - 1.0)), 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:
import traceback as _tb
log("fatal: %s: %s\n%s" % (type(e).__name__, e, _tb.format_exc()))
except Exception:
pass
sys.exit(0)