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

713 lines
36 KiB
Python
Raw Permalink 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
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)