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

299 lines
14 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.
# -*- coding: utf-8 -*-
"""supervise-ensure-hook.py —— UserPromptSubmit 钩子:**开工那一刻自动把常驻补上**。
🔴🔴 为什么必须有这个脚本(2026-10-04 用户两次纠正 + 一次「我要说几遍」)
用户原话:「**调用执行任务完成目标时,判断是否已经开启,如果没开启就要开启**」
⇒ 这句话是**技能必须自己做的动作**,⛔ 不是「写进文档等我照着做」。
🔴 实测的病根(`vibe-product` 区)
· 该区常驻**停 16.4 小时**,而我在那边「一步一步按文档走」——第一步就没人做
· `ensure_supervise()` **早就存在**、能自己起常驻,
⛔ 但它**只被文档和自测引用**,**没有任何地方在开工时调它**
⇒ 能力齐了,**缺一根接线**。本脚本就是那根接线。
⚖️ 三条边界(⚠️ 越界就是又一次「凭空造需求」)
1. ⛔ **不碰「没启用」的工作区** —— 用户 2026-10-04 口径:
「**没有目标就说明 当前用户是用的是基础会话方式**」⇒ 那是**正常态**,
**不是故障**,**不需要"开启"**。⇒ 判据=`goal.json` 有**且** `lifecycle` 有效。
2. ⛔ **不代用户开目标** —— 想让它有 ⇒ **用户开**(`goal_life_set` 是用户动作)。
3. ⛔ **只做「常驻在不在」这一件事** —— ⛔ 不建任务会话、⛔ 不建检查会话
(那两件是**主会话**的判断,见 SKILL.md T 表)。
⇒ 越权等级:`本脚本 ≤ 第 3 条`;`第 2 条` 用户专属;`建会话` 主会话专属。
输出:`hookSpecificOutput.additionalContext` 注入一句**短**提醒(⛔ 不长篇大论)。
退出码恒 0(⛔ 钩子失败 ⇔ 用户的话被吞 ⇒ 最坏的结果)。
"""
from __future__ import annotations
import io
import json
import os
import subprocess
import sys
import time
from pathlib import Path
HERE = Path(__file__).resolve().parent
SKILL = HERE.parent.parent
try:
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
except Exception:
pass
# 🔴🔴 2026-10-04 用户问「为什么 vibe 会话说技能没有调整」时挖出的真缺陷:
# **本钩子在两个工作区全是空转,一次都没生效过。**
# 病根=我写它时只读 `DSH_WS_ROOT`,而 `settings.json` 的 hook 条目
# **没有 env 字段** ⇒ 宿主 env 里 `DSH_WS_ROOT` 恒空
# (实测 Process/User/Machine 三级全空)⇒ 第①步就 `_noop()` 返回。
# ⇒ 「能力齐了、缺一根接线」我以为接上了,**其实那根线接在墙上没插进插头**。
# ✅ 正确取法=**照抄 `wb-result-hook.py` / `lock-guard-hook.py`**:
# ① stdin 的 `payload.cwd`(宿主一定给)
# ② `roots.env` 的 `DSH_WS_ROOT`(副本专属,⛔ 不能只靠宿主 env)
def _sm_load_roots() -> None:
"""把 `roots.env` 以 setdefault 注入 env(宿主 env 优先,⛔ 不覆盖)。"""
p = SKILL / "roots.env"
try:
for line in p.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
k, v = line.split("=", 1)
os.environ.setdefault(k.strip(), v.strip())
except Exception:
pass
_sm_load_roots()
COLLABD = SKILL / "scripts" / "collabd.py"
PY = sys.executable
def _norm(p: str) -> str:
"""`E:\\x\\y` / `E:/x/y` ⇒ 统一正斜杠 + 去尾斜杠(⛔ 与 `tasks.json` 去重键同形)。"""
return (p or "").replace("\\", "/").rstrip("/")
def _stdin_payload() -> dict:
"""读 hook stdin(⛔ 最多 1 MiB,⛔ 失败当没有)。
🔴🔴 **必须缓存**(2026-10-04 实测栽到):
`sys.stdin.buffer.read()` 是**一次性**的 —— 读第一次之后再调
`_ws_root()` 只会拿到空串 ⇒ 表现为「注入零输出」,
**和「没触发」一模一样** ⇒ 又一次假绿。
⇒ 只读一次、存进 `_PAYLOAD`,后续一律走缓存。
"""
global _PAYLOAD
if _PAYLOAD is None:
try:
raw = sys.stdin.buffer.read(1024 * 1024)
_PAYLOAD = json.loads(raw.decode("utf-8", "replace")) if raw else {}
if not isinstance(_PAYLOAD, dict):
_PAYLOAD = {}
except Exception:
_PAYLOAD = {}
return _PAYLOAD
_PAYLOAD = None
def _ws_root() -> str:
"""🔴 工作区真源=**stdin 的 `payload.cwd`**(与 `wb-result-hook` 同一口径)。
⚠️ 为什么不能只信 env:`settings.json` 的 hook 条目**没有 env 字段**,
宿主 env 里 `DSH_WS_ROOT` 实测恒空(10-04 三级全空)⇒ 只读 env 的钩子
**一次都不会触发**(这正是本脚本上一版的病)。
⚠️ 也不直接用 `Path.cwd()`:钩子的 cwd 是**宿主进程目录**,⛔ 不是会话工作区。
"""
return _norm(str(_stdin_payload().get("cwd") or ""))
def _out(obj) -> None:
data = json.dumps(obj, ensure_ascii=False).encode("utf-8")
try:
sys.stdout.buffer.write(data)
sys.stdout.buffer.flush()
except Exception:
sys.stdout.write(data.decode("utf-8", "replace"))
sys.stdout.flush()
def _noop() -> None:
"""⛔ 什么都不注入 —— 绝大多数情况(⛔ 别每次提交都吵用户)。"""
_out({"hookSpecificOutput": {"hookEventName": "UserPromptSubmit",
"additionalContext": ""}})
def _cfg(ws: Path):
"""读该工作区的部署配置(⛔ 读不到 ⇒ 视为**未启用**,⛔ 不碰)。"""
p = ws / ".workbuddy" / "collab" / "collabd.config.json"
try:
return json.loads(p.read_text(encoding="utf-8"))
except Exception:
return None
def _goal(ws: Path):
"""读 `goal.json`(⛔ 没有 ⇒ **基础会话方式**,⛔ 不碰)。"""
for rel in (("tmp", "supervise-inbox", "goal.json"),):
p = ws.joinpath(*rel)
try:
if p.is_file():
return json.loads(p.read_text(encoding="utf-8"))
except Exception:
return None
return None
def main() -> int:
# ① 拿工作区(🔴 真源=stdin 的 `payload.cwd`;env / `roots.env` 只兜底)
ws = _ws_root() or (os.environ.get("DSH_WS_ROOT") or "").strip()
if not ws:
_noop()
return 0
ws = Path(ws)
if not ws.is_dir():
_noop()
return 0
# ② ⚖️ 边界①:**没启用 ⇒ 完全不碰**(不是故障,是用户在用基础会话方式)
cfg = _cfg(ws)
if not cfg:
_noop()
return 0
goal = _goal(ws)
if not goal:
# 📌 无 `goal.json` ⇒ 基础会话方式 ⇒ ⛔ 不起常驻、⛔ 不注入任何东西
_noop()
return 0
# 🔴🔴 2026-10-04 用户拍板(原话:「**当然需要判断如果没有起就要起**」):
# ⇒ 判据=「**有 `goal.json` 就归我管**」,⛔ **不看 `lifecycle` 的值**。
# ⚠️ 为什么**故意不看** lifecycle(这是口径,⛔ 不是漏写):
# 用户说「继续完成目标」这个动作本身**就是"有活"的证据** ——
# 目标处于「已完成/等待」时用户仍然开口 ⇒ 说明还有事要办。
# 若这里加一道「lifecycle 必须进行中」的闸,就会出现
# **用户说了继续、却什么都没发生** ⇒ 又回到"感觉没改到位"。
# ⚠️ 已知副作用(**接受**,⛔ 不是缺陷):`已完成` 的区里常驻会被拉起,
# 但 `maybe_spawn_check_agent()` 五道闸① 仍要求「进行中」⇒ 暂时不建检查会话。
# ⇒ 那份「只起常驻、不建检查会话」的中间态是**刻意的**:
# 先保证后台在,检查会话等目标状态被改回「进行中」再说。
# ⛔ 变异对照见 `selftest.py::t_ensure_hook_fires_on_any_lifecycle()`。
# ③ 问程序自己「常驻在不在」(⛔ 别手拼路径、⛔ 别读快照)
# 🔴🔴 2026-10-04 实测栽到:`env = dict(os.environ)` 之后再往 `env` 里塞
# `COLLABD_CONFIG` 是**无效**的 —— `exec_module()` 期间 `collabd.load_cfg()`
# 读的是 **`os.environ`**(真进程环境),不是那个局部字典。
# ⇒ 实测 `CFG_USED` 仍指向 **别的区**,把「A 区常驻在位」当成本区结论
# ⇒ 又一次**假绿**。正解=**直接改 `os.environ`**(并记住原值,出栈还原)。
cfg_env_key = "COLLABD_CONFIG"
_cfg_path = str(ws / ".workbuddy" / "collab" / "collabd.config.json").replace("\\", "/")
_saved_env = os.environ.get(cfg_env_key)
os.environ[cfg_env_key] = _cfg_path
try:
import importlib.util
_sp = importlib.util.spec_from_file_location("_cd_hook", COLLABD)
m = importlib.util.module_from_spec(_sp)
_sp.loader.exec_module(m)
# 📌 自检:加载完必须确认它**真的在看本区**(⛔ 不确认=又一次假绿)
_used = str(getattr(m, "CFG_USED", "") or "").replace("\\", "/")
if Path(_used).resolve() != Path(_cfg_path).resolve():
_noop()
return 0
alive, det = m.supervise_alive()
except Exception:
_noop()
return 0
finally:
if _saved_env is None:
os.environ.pop(cfg_env_key, None)
else:
os.environ[cfg_env_key] = _saved_env
# ④ 在位 ⇒ 零输出(⛔ 别每次提交都吵)
if alive:
_noop()
return 0
# ⑤ 🔴 不在位 ⇒ 自己起来(这就是用户那句「没开启就要开启」)
# 🔴 复查用的 `m` 是**上面已加载的本区模块**;此时 `os.environ` 已还原
# ⇒ `supervise_alive()` 若重新读 env 会验到别的区 ⇒ 必须把 env 再钉回去。
#
# 🔴🔴 2026-10-04 **时间预算(必须守住,⛔ 超时=用户那句话被吞=最坏结果)**:
# 钩子注册里 `timeout=30`(`settings.json` 的 `UserPromptSubmit` 第 6 条)
# ⇒ 本函数总耗时必须**显著小于 30 s**,否则宿主掐掉钩子 ⇒ 输入丢失。
# 实测正常路径 3.5 s,但**起不来**那条路径原实现最坏 =
# `subprocess timeout 40` + 回验循环 `10 × 0.9 ≈ 9` ⇒ **理论 49 s > 30 s**。
# ✅ 收口(2026-10-04 用户批准「可以」):
# · 子进程超时 `40 → 15`(`--ensure` 本身是"起一条即返回",15 s 足够)
# · 回验循环 `10 × 0.9 ≈ 9 → 5 × 0.8 = 4`(心跳节拍实测 ~1 s 内就出来)
# ⇒ 最坏 ≈ 15 + 4 + 1 = **20 s**,留 10 s 余量给解释器启动与 import。
_ENSURE_SUBPROC_TIMEOUT = 15
_VERIFY_TRIES, _VERIFY_GAP = 5, 0.8
try:
_restore_env = os.environ.get(cfg_env_key)
os.environ[cfg_env_key] = _cfg_path
env = dict(os.environ)
# 🔴 参数名是 **`--ensure`**(⛔ **没有** `--ensure-supervise`)
# 🔴🔴 **⛔ 不看它的返回码**(2026-10-04 实测坐实):它会报
# `{"ok": false, "spawned": true}` ⇒ **`spawned` 看着像成功、其实秒退**。
# 那个区因此「起了三次、活零次」,而我照着返回码报了"起好了"。
# ⇒ 唯一可信判据=**等它自己活下来,再问 `supervise_alive()`**。
subprocess.run([PY, str(COLLABD), "--ensure"],
env=env, cwd=str(ws), capture_output=True,
timeout=_ENSURE_SUBPROC_TIMEOUT)
ok = False
for _ in range(_VERIFY_TRIES):
time.sleep(_VERIFY_GAP)
try:
if m.supervise_alive()[0]:
ok = True
break
except Exception:
pass
except Exception:
ok = False
finally:
if _restore_env is None:
os.environ.pop(cfg_env_key, None)
else:
os.environ[cfg_env_key] = _restore_env
if not ok:
# ⛔ 起不来 ⇒ **只报事实**,⛔ 不许编原因(今天栽过 5 次)
# 🔴 但「起不来」本身就是用户要的那条红线(10-04 原话「起不来就先报错」)
# ⇒ 这里必须**报出来**,⛔ 不许再静默零输出。
_out({"hookSpecificOutput": {"hookEventName": "UserPromptSubmit",
"additionalContext": (
"🔴 **会话机制 · 常驻没起来**(本工作区有目标在册,但常驻不在位,"
"自动补起失败)——⛔ 先修好这个再往下做,别当它已经好了。")}})
return 0
# ⑥ 起好了 ⇒ 验一次,⛔ 打印「已启动」不算(照 supervise-persistence 的验收口径)
# 🔴 同样要把 env 钉回本区,否则复查读的是别的区的心跳(⛔ 验错对象=假绿)
_r2 = os.environ.get(cfg_env_key)
os.environ[cfg_env_key] = _cfg_path
try:
time.sleep(1.0)
alive2, det2 = m.supervise_alive()
except Exception:
alive2, det2 = False, "复查失败"
finally:
if _r2 is None:
os.environ.pop(cfg_env_key, None)
else:
os.environ[cfg_env_key] = _r2
msg = ("🔧 **会话机制 · 常驻已自动补起**(本工作区 goal 在册但常驻不在位)"
if alive2 else
"🔴 **会话机制 · 常驻起后仍未活**(%s)——⛔ 这是事实,先修好再往下做。" % det2)
_out({"hookSpecificOutput": {"hookEventName": "UserPromptSubmit",
"additionalContext": msg}})
return 0
if __name__ == "__main__":
try:
sys.exit(main())
except Exception:
# 🔴 铁律:钩子**绝不能**让用户的话被吞 ⇒ 任何异常都静默退出 0
sys.exit(0)