#!/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 [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/.log` 涨到 ~10 MiB 上限后轮转失败, # 宿主每批写入抛 EPERM ⇒ **整批丢弃** ⇒ 该会话"确实在执行,但对话窗口不显示内容"。 # 修法 = 把卡死的 `.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)