Files

712 lines
36 KiB
Python
Raw Permalink Normal View History

#!/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)