298 lines
14 KiB
Python
298 lines
14 KiB
Python
# -*- 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)
|