Files
workbuddy_skills/session-mechanism/install.py
T
admin 64dd82073b session-mechanism: 修复钩子静默失效 + 3 处判据缺陷;禁「变相征询」
1) stop-dialog-guard: session_budget() 早退路径返回 2 值、末尾返回 3 值,调用方按 3 值解包
   ⇒ transcript > 64 MiB 时每轮 ValueError。因 fail-open(异常仍 exit 0),
   宿主零报错、install.py --verify 只判 rc=0 ⇒ 假绿;实测 86 条 EXCEPTION,
   死掉的是整条(水位/收口、接续机制起点、预算告警、门禁自检、路径自检)。

2) session-rules-check 三处判据:
   · hook_reg  按旧文件名找 ⇒ 合并成 prompt-guards.py 后每轮假红 ⇒ 改为一组可接受名
   · snap_sync 拿 mtime 当内容判据 ⇒ 连续 4 天假红 ⇒ 改为复用抽取器本体比对内容
               (变异对照:截断快照能报 fail,非恒绿)
   · mem_ptr   只查全局技能根 ⇒ 工作区自带技能被判悬空 ⇒ 改查「全局 ∪ 工作区」

3) pitfalls 新增 P0-95(改判据必须重跑变异对照;fail-open + 只看 rc=0 = 假绿温床)

4) 回复排版核心块新增「变相征询同样禁止」(先只报不动/等你发话/我倾向X你看呢
   这类不带选项的待定清单,一律按待拍板项写:问题+说明+各候选优缺点+倾向)
2026-10-06 22:27:03 +08:00

706 lines
36 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.
#!/usr/bin/env python3
"""
session-mechanism · 一键配置(换机器只需跑这一个)
用法
python install.py --dry-run # 只打印将改什么(settings.json 的 diff),⛔ 不写盘
python install.py --apply # 真装:写 roots.env → 按声明表接线全局钩子 → 初始化工作区
python install.py --verify # 装完自检:每个钩子空载荷 rc=0 + collabd --where + selftest
python install.py --uninstall # 还原 settings.json(与装前备份逐字节相同)
python install.py --manifest # 重算 references/manifest.md 的逐文件表(改完包**必跑**)
设计要点(都由实测倒逼,⛔ 不要"优化"掉)
1. **自解析**:解释器一律 `sys.executable`;配置目录按 `CODEBUDDY_CONFIG_DIR` 推导。
⛔ 不硬编码 python 路径、⛔ 不硬编码盘符 —— 这正是「换机器必碎」的根因。
2. **声明表驱动**:本包 **11 条**接线收敛成一张 HOOKS 表(`--verify` 逐条空载荷复测)。
🔴 **2026-10-06 由 10 条增至 11 条**:新增 `SessionStart / decision-rules-hook.py`
(**决策判据常驻注入** —— 用户明令「决策方法必须想办法加载到每次对话中」+
「要把这个动作加到会话技能配置环境的时候一并处理」)。
⛔ 表里**不含** `decision_bridge.py`(属 `ai1net-decision-laya` 另一条线)—— 它在 settings.json 里
另有 4 条、与本包同处一个 `hooks` 段 ⇒ 极易误读成"本包 15 条"(`--dry-run` 会打印被排除条数供目视核对)。
⚠️ 别把新钩子与 `decision_bridge.py` 合并:前者注入**【决策判据】**(怎么想、什么不许),
后者注入**《提问规范》骨架**(要问时怎么写)—— 职责互补,合并会让任一方改判据时动到另一方。
3. **幂等**:先删「本包自己的旧条目」再插;装两遍结果相同。
4. **可逆**:首次安装先留**原状**备份 `settings.json.bak-session-mechanism-orig`(⛔ 已存在不覆盖),
其后每次 `--apply` 另写**带微秒**的时间戳备份(防同秒同名互相覆盖);
`--uninstall` **优先**用 `-orig`,还原后做**逐字节**比对,不符即报错。
5. **外置根目录**:写 `<包根>/roots.env` —— 因为 `settings.json` 的 hook 条目**没有 env 字段**,
包内脚本搬一次就会静默指错(历史事故:台账写到别处、测试却全绿)。
6. **清单自算**:`--manifest` 只重写 `references/manifest.md` 的**表 + 计数行 + 重算时间**,
⛔ 不碰上面的「最近改动」散文 —— 那是人写的结论,脚本代笔会把它冲成流水账。
包一改就得跑一次,否则表里的字节/md5 立刻过期(本项目已因此误判过两次)。
"""
from __future__ import annotations
import argparse
import difflib
import hashlib
import json
import os
import re
import shutil
import subprocess
import sys
import tempfile
import time
from pathlib import Path
PKG = Path(__file__).resolve().parent
ROOTS_ENV = PKG / "roots.env"
LOG = PKG / "install.log"
# 🔴 原状备份的**固定名**(首次安装、且当前 settings.json 无本包痕迹时才写;⛔ 已存在不覆盖)。
# 有了它,`--uninstall` 不需要"从一堆时间戳备份里猜哪份是原状"。
ORIG_BAK_NAME = "settings.json.bak-session-mechanism-orig"
BAK_PREFIX = "settings.json.bak-session-mechanism-"
# 🔴 判定"是不是本包的"一律**按脚本名**,⛔ 不用路径片段(`session-mechanism`)——
# 路径片段在"包被改名 / 拷到别的目录"时**认不出自己**(实测教训:§2 #4)。
# ⚠️ 这些脚本名**必须连旧落点一起认**(文档库 `07-scripts/` + 工作区 `.workbuddy/tools/`),
# 否则旧接线删不掉 ⇒ 同一件事挂两条钩子、同一秒各跑一次(`agent-operating-rules §10.2` 实测故障)。
# ⛔ `decision_bridge.py` 不在此列。
OWN_BASENAMES = (
"session-log-guard.py",
"lock-guard-hook.py",
"bash-output-guard.py",
"stop-dialog-guard.py",
"skill-load-guard.py",
"wb-result-hook.py",
# 🔴 2026-10-06 新增:决策判据常驻注入(用户明令「决策方法必须加载到每次对话中」
# +「要把这个动作加到会话技能配置环境的时候一并处理」)。
# ⚠️ 不加进这里的后果:`--uninstall` 认不出它 ⇒ 旧接线删不掉 ⇒ 换包/重装后
# **同一件事挂两条钩子**(agent-operating-rules §10.2 实测故障)。
"decision-rules-hook.py",
# 🔴🔴 2026-10-06 新增:**UserPromptSubmit 多守卫合并入口**(用户报「三个 hook 同时 10s 超时」)。
# ⚠️ 必须在此登记 —— 否则 `--apply` 认不出它 ⇒ 重装后又多挂一条 ⇒ 合并+单跑**双跑**。
# 📌 它替掉 4 条单跑接线(见 `HOOKS` 注释),但**那 4 个脚本文件本身保留**
# (仍可单跑;`OWN_BASENAMES` 里那 4 个名字**不能删** —— 用于删旧接线)。
"prompt-guards.py",
# 🔴 2026-10-06 补登记:`reply-style-guard.py`(回复排版闸门)**此前是手工接线**
# (⛔ 一直不在本声明表里 ⇒ `--uninstall` 认不出 ⇒ 摘不干净)。
# 改由 `prompt-guards.py` 合并调用后,把它补进白名单,让包能认全自己的钩子。
"reply-style-guard.py",
)
# ── 声明表:本包负责的钩子(⛔ decision_bridge 不在此表内)───────────────────
# (事件, matcher, 包内脚本相对路径, 额外参数, 超时秒)
HOOKS: list[tuple[str, str | None, str, list[str], int]] = [
("PostToolUse", None, "scripts/hooks/session-log-guard.py", ["-S"], 10),
("PreToolUse", "^Bash$", "scripts/hooks/wb-result-hook.py", [], 30),
("PreToolUse", "Write|Edit", "scripts/hooks/lock-guard-hook.py", [], 10),
("PreToolUse", "Bash|Read", "scripts/hooks/bash-output-guard.py", [], 10),
("SessionEnd", None, "scripts/hooks/wb-result-hook.py", [], 10),
("SessionStart", "startup|resume", "scripts/hooks/lock-guard-hook.py", [], 10),
# 🔴 2026-10-06 新增:**决策判据常驻注入**(用户明令)。
# 挂 `SessionStart` ⇒ 每开一个会话注入一次,⛔ 不每轮重复(不白烧 token)。
# ⚠️ 与 `decision_bridge.py`(也挂 SessionStart)**互补、⛔ 不合并**:
# 那个注入《提问规范》骨架("要问时怎么写");本钩子注入【决策判据】("怎么想、什么不许")。
# ⚠️ 超时给 5 s:它是**纯本地字符串**(不读文件/不调模型/不起进程)⇒ 正常 0.05 s 内返回。
("SessionStart", "startup|resume", "scripts/hooks/decision-rules-hook.py", [], 5),
("UserPromptSubmit", None, "scripts/hooks/wb-result-hook.py", [], 20),
# 🔴🔴🔴 2026-10-06:**UserPromptSubmit 的 4 条守卫合并成 1 条**(用户报超时后改)。
#
# 事实(本机实测):`UserPromptSubmit` 上串行挂了 **7 条 hook**,每条都是**一个
# Python 进程** ⇒ 宿主每轮要**冷启 7 次解释器**。单条都不慢(0.23–0.36 s;
# 7 条并发也才 0.84 s),但**串行 + 冷启动**累加 ⇒ 4 条 `timeout=10` 的一起报超时。
#
# ✅ 改法:把下面这 4 条(都是"同一事件、同一 stdin 契约、fail-open、无副作用"的守卫)
# 合进 `prompt-guards.py`,**一个进程跑完 4 个**。实测 1.247 s → **0.357 s(3.5×)**,
# 注入内容**逐字一致**(654 字符 additionalContext 完整保留)。
#
# ⛔ **不许合并**的三条(有副作用/属别处接线,合并会改语义):
# `supervise-ensure-hook.py`(常驻确保)、`wb-result-hook.py`(结果投递)、
# `decision_bridge.py`(不在本包声明表内)。
("UserPromptSubmit", None, "scripts/hooks/prompt-guards.py", [], 30),
# ⚠️ 原 4 条单跑接线(timeout=10 ⇒ 被前面挤破而超时)**已由上面这条替代**:
# stop-dialog-guard.py / skill-load-guard.py / session-log-guard.py / reply-style-guard.py
# ⛔ 别再加回来 —— 会变成"合并跑一次 + 单跑一次"(同一判据跑两遍)。
]
def config_dir() -> Path:
v = os.environ.get("CODEBUDDY_CONFIG_DIR")
if v:
return Path(v)
return Path.home() / ".workbuddy"
def settings_path() -> Path:
return config_dir() / "settings.json"
def log(msg: str) -> None:
line = f"{time.strftime('%Y-%m-%d %H:%M:%S')} {msg}"
print(line)
try:
with open(LOG, "a", encoding="utf-8") as f:
f.write(line + "\n")
except Exception:
pass
def read_roots() -> dict[str, str]:
d: dict[str, str] = {}
if ROOTS_ENV.is_file():
for ln in ROOTS_ENV.read_text(encoding="utf-8").splitlines():
ln = ln.strip()
if ln and not ln.startswith("#") and "=" in ln:
k, v = ln.split("=", 1)
d[k.strip()] = v.strip()
return d
def _ws_marker(cand: Path) -> bool:
"""**候选目录是不是一个工作区**。
🔴 2026-10-05 修两处误判(原判据=`.workbuddy` + `state.py`):
**病一:硬要 `state.py` ⇒ 非 ai1net 线全判死。** `state.py` 只是 ai1net 那条线的
现状快照脚本(`collabd.py:4163` 引它作「跑这一条看现状」),`vibe-product` /
`agent-product` **本来就没有** ⇒ 老判据在那些工作区里**永远返回 None** ⇒ 被下游
`roots.env` 残留顶替(见 `detect_workspace` 的说明)。
**病二:⛔ 没有"这是本目录自己的还是从祖先继承的"之分。**
`E:/ProgramData/AIProject/.workbuddy` 也存在(早期遗留:`automations/` + `memory/2026-08-30.md`)
⇒ 若只看"存在性",按 `cwd.parents` 向上找会在**父目录**上命中。
⇒ 现判据:**必须同时具备「有 `.workbuddy`」+「`.workbuddy` 里有本工作区级的真物」**。
真物取三选一(都是**工作区**语义、且宿主会往里写,⛔ 不是"某条线的产物"):
· `memory/` —— 工作区记忆(宿主写入;`AIProject/.workbuddy` 也有,故单靠它不够)
· `collab/` —— 会话机制的协作落点(`collabd.py` 的家)
· `state.py` —— ai1net 线的现状快照(保留,兼容老工作区)
⚠️ `agent-product` 现只落钩子日志(`bash-guard.log` 等)⇒ 三条都不满足 ⇒ 仍判 False,
由 `detect_workspace` 的**显式参数**兜住(用户跑 `install.py` 时本就该点名工作区)。
"""
wb = cand / ".workbuddy"
if not wb.is_dir():
return False
if (wb / "collab").is_dir():
return True
if (wb / "memory").is_dir() and (cand / "state.py").is_file():
return True
return (cand / "state.py").is_file()
def _cwd_workspace() -> Path | None:
"""从 cwd 向上找最近的**带工作区标记**的目录。"""
cwd = Path.cwd().resolve()
for cand in (cwd, *cwd.parents):
if _ws_marker(cand):
return cand
return None
def detect_workspace(explicit: str | None) -> Path | None:
"""定「装到哪个工作区」。**优先级 = 显式 > 宿主 env > cwd 就近 > roots.env 残留。**
🔴🔴 2026-10-05 修一处**静默错归属**(实测:在 `agent-product` 里跑 `--dry-run`,
解析结果 = `ai1net-dsh-server`,踩技能 S 红线)。原顺序把 **`roots.env` 残留**
排在 **cwd** 之前 ⇒ `roots.env` 里只要留着上次装的值,**换任何目录跑都会继承它**。
⛔ 那是两件事被混为一谈:`roots.env` 是**本包全局单例**(一个包只有一份),
`roots.env` 里那个 `DSH_WS_ROOT` 记的是**上次装到哪**,⛔ 不是"当前工作区"。
三个非 ai1net 工作区(`vibe-product` / `agent-product` / …)**全部**会被它顶替。
⚠️ 兜底(沿用 `roots.env`)仍保留,但**必须开口说话** —— 见 `cmd_apply` 的冲突告警:
只要 cwd 与 `roots.env` 指向不同,就打 🔴 并**要求显式 `--workspace`**,⛔ 不许默默装。
"""
if explicit:
return Path(explicit).resolve()
for k in ("DSH_WS_ROOT", "COLLABD_WORKSPACE"):
if os.environ.get(k):
return Path(os.environ[k]).resolve()
here = _cwd_workspace()
if here is not None:
return here
prev = read_roots().get("DSH_WS_ROOT")
if prev and Path(prev).is_dir():
return Path(prev)
return None
def detect_docs_root(explicit: str | None) -> Path | None:
if explicit:
return Path(explicit).resolve()
if os.environ.get("DSH_DOCS_ROOT"):
return Path(os.environ["DSH_DOCS_ROOT"]).resolve()
prev = read_roots().get("DSH_DOCS_ROOT")
if prev and Path(prev).is_dir():
return Path(prev)
# 启发式:文档库必须同时具备「05-交接单」与「07-scripts」两个标志目录
# ⚠️ 只看同名会误选到工作区里的 `dsh-server-docs/` 副本 ⇒ 两个条件都要满足。
seeds = [Path.cwd(), *Path.cwd().parents]
for k in ("DSH_CODE_REPO",):
if os.environ.get(k):
seeds.insert(0, Path(os.environ[k]))
for s in seeds[:6]:
for cand in (s / "dsh-server-docs", s.parent / "dsh-server-docs"):
if (cand / "05-交接单").is_dir() and (cand / "07-scripts").is_dir():
return cand.resolve()
return None
def py() -> str:
return sys.executable
def build_command(rel: str, extra: list[str]) -> str:
script = (PKG / rel).resolve()
parts = [f'"{py()}"', *[f'"{a}"' for a in extra], f'"{script}"']
return " ".join(parts)
def has_our_trace(text: str) -> bool:
"""**这条 hook 归不归本包管** —— 按**脚本名**判。
⚠️ 必须连旧落点一起认(文档库 `07-scripts/` + 工作区 `.workbuddy/tools/`),否则旧接线删不掉。
⚠️ ⛔ 不可拿它当"这份文件是否已装过本包"的判据 —— 真机 settings.json **本来就有**同名脚本
挂在 07-scripts 上(实测:6 个脚本名全 True)⇒ 会**永远写不出 `-orig`**、`--uninstall` **永远拒做**。
那个判据用 `points_into_pkg()`。"""
return any(b in text for b in OWN_BASENAMES)
def points_into_pkg(text: str) -> bool:
"""**这份文件是否已被本包接管** —— 有没有钩子**指向本包目录**。
🔴 路径**运行时从 `__file__` 推导**(`PKG`)⇒ 包被改名 / 拷到别的目录都认得出自己。
这正是 §3.1b 要修的病:⛔ 旧写法硬编码路径片段 `session-mechanism`。
旧落点(07-scripts / tools)**不算**"已装本包"。"""
return PKG.as_posix().lower() in text.replace("\\", "/").lower()
def is_ours(entry: dict) -> bool:
for h in entry.get("hooks", []):
if has_our_trace(str(h.get("command", ""))):
return True
return False
def unique_backup_path(sp: Path) -> Path:
"""备份落点:**带微秒**的时间戳 ⇒ 连装两遍不会同秒同名互相覆盖(§6 第 4 条实测踩到)。
极端情况下同名仍存在 ⇒ 追加序号,⛔ 绝不覆盖既有备份。"""
ts = time.strftime("%Y%m%d-%H%M%S") + f"-{time.time_ns() // 1000 % 1_000_000:06d}"
cand = sp.with_name(f"{BAK_PREFIX}{ts}")
n = 1
while cand.exists():
cand = sp.with_name(f"{BAK_PREFIX}{ts}-{n}")
n += 1
return cand
def make_backup(sp: Path, before_text: str) -> tuple[Path, Path | None]:
"""写备份。返回 (本次时间戳备份, 本次新写的原状备份或 None)。
· 当前 settings.json **尚未被本包接管**(无钩子指向本包)⇒ 另写固定名 `-orig`,已存在则⛔不覆盖。
· 每次 `--apply` 都另写一份带微秒的时间戳备份(逐次可回滚)。
"""
orig = sp.with_name(ORIG_BAK_NAME)
wrote_orig: Path | None = None
if not points_into_pkg(before_text):
if orig.exists():
log(f"⚠ 原状备份已存在,⛔ 不覆盖(若怀疑原件已换,请人工核对):{orig}")
else:
shutil.copy2(sp, orig)
wrote_orig = orig
log(f"已写**原状**备份(本包接管前)→ {orig}")
else:
log(f"ℹ 当前 settings.json 已被本包接管 ⇒ 跳过 `-orig`(原状备份只在首次安装时留)")
bak = unique_backup_path(sp)
shutil.copy2(sp, bak)
log(f"已备份 → {bak}")
return bak, wrote_orig
def desired_hooks() -> dict[str, list[dict]]:
out: dict[str, list[dict]] = {}
for event, matcher, rel, extra, timeout in HOOKS:
item: dict = {"hooks": [{"type": "command", "command": build_command(rel, extra), "timeout": timeout}]}
if matcher is not None:
item["matcher"] = matcher
out.setdefault(event, []).append(item)
return out
def merge(settings: dict) -> dict:
"""先删本包旧条目 → 再插本包新条目;⛔ 其他条目(含 decision_bridge)原样保留。"""
new = json.loads(json.dumps(settings))
hooks = new.setdefault("hooks", {})
want = desired_hooks()
for event in set(list(hooks.keys()) + list(want.keys())):
keep = [e for e in hooks.get(event, []) if not is_ours(e)]
keep.extend(want.get(event, []))
if keep:
hooks[event] = keep
elif event in hooks:
del hooks[event]
return new
def write_roots(ws: Path, docs: Path | None, code: str | None, dry: bool) -> None:
lines = [
"# session-mechanism · 根目录(由 install.py 生成,⛔ 请勿手改)",
"# 说明:settings.json 的 hook 条目没有 env 字段 ⇒ 包内脚本靠本文件定位根目录。",
"# 读取:各脚本头部 `_sm_load_roots()` 以 setdefault 注入 ⇒ **宿主 env 优先,本文件兜底**。",
f"DSH_WS_ROOT={ws.as_posix()}",
f"WB_RESULT_HOOK_WS={ws.as_posix()}",
# 🔴 2026-10-04 换机演练实测后加:`selftest.py` 的「生产工作区」判据要定位
# **使用方的部署配置**(它验的是生产数据)。⛔ 不可拿 `DSH_WS_ROOT` 拼 ——
# 那条是**自测自己的测试工作区根**(`WS = ... or DSH_WS_ROOT`)⇒ 会把夹具当生产。
f"COLLABD_PROD_CONFIG={(ws / '.workbuddy' / 'collab' / 'collabd.config.json').as_posix()}",
"CODEBUDDY_CONFIG_DIR=%s" % (os.environ.get("CODEBUDDY_CONFIG_DIR")
or os.path.expanduser("~/.workbuddy")).replace(os.sep, "/"),
]
# 可选键:仅当宿主 env 里已有值才落盘(⛔ 不猜、⛔ 不写死盘符)
for _k in ("DSH_OVERLAY_LOG_GLOB", "DSH_OVERLAY_DEV_ROOT"):
if os.environ.get(_k):
lines.append("%s=%s" % (_k, os.environ[_k].replace(os.sep, "/")))
if docs:
lines.append(f"DSH_DOCS_ROOT={docs.as_posix()}")
if code:
lines.append(f"DSH_CODE_REPO={code.as_posix()}")
text = "\n".join(lines) + "\n"
if dry:
log(f"[dry-run] 将写 {ROOTS_ENV}:\n{text}")
return
ROOTS_ENV.write_text(text, encoding="utf-8")
log(f"已写 {ROOTS_ENV}")
def init_workspace(ws: Path, dry: bool) -> None:
"""由 example 生成 collabd.config.json(已存在则不覆盖 ⇒ 保护现场活配置)。"""
src = PKG / "scripts" / "collabd.config.example.json"
dst = ws / ".workbuddy" / "collab" / "collabd.config.json"
if dst.is_file():
log(f"工作区配置已存在,⛔ 不覆盖:{dst}")
return
if not src.is_file():
log(f"⚠ 未找到模板 {src} ⇒ 跳过工作区初始化")
return
try:
cfg = json.loads(src.read_text(encoding="utf-8"))
except Exception as e:
log(f"⚠ 模板解析失败({e})⇒ 跳过")
return
cfg["workspace"] = ws.as_posix()
if dry:
log(f"[dry-run] 将写 {dst}")
return
dst.parent.mkdir(parents=True, exist_ok=True)
dst.write_text(json.dumps(cfg, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
log(f"已写 {dst}")
def cmd_apply(args) -> int:
sp = settings_path()
if not sp.is_file():
log(f"🔴 找不到 {sp} ⇒ 停手")
return 2
ws = detect_workspace(args.workspace)
if ws is None:
log("🔴 未能推断工作区 ⇒ 请显式 `--workspace <路径>`(⛔ 不猜)")
return 2
# 🔴🔴 归属自检(2026-10-05 加):**换工作区装的时候,最坏的结果是"装错地方还不出声"。**
# 实测踩到:在 `agent-product` 里跑,解析成 `ai1net-dsh-server`(S 红线)。
# 根因修在 `detect_workspace` 的优先级;这里再加一道**开口说话**的闸:
# ① cwd 就近判据与最终结果不一致 ⇒ 打字说明,二者都打印,让人当场看见。
# ② 已在别的工作区装过(`roots.env` 有旧值且 ≠ 现在这个)⇒ 打 🔴 警示,
# 因为 `roots.env` 是**包级单例** ⇒ 装了新的,**旧工作区的钩子会一起改指向**。
# ⛔ 不在这里自动改写 / 自动停手:显式 `--workspace` 时用户已表态,别多管;
# 非显式时由优先级保证"就近优先",本段只负责把事实摆在眼前。
here = _cwd_workspace()
prev_ws = read_roots().get("DSH_WS_ROOT")
log(f"归属自检:cwd 就近判据={here} | roots.env 上次装的={prev_ws}")
if here is None and not args.workspace:
# 🔴🔴 最该报警的一档,⛔ 不是「无信号 ⇒ 跳过」。
# cwd 认不出工作区 ⇒ 现在的 ws 只可能来自「宿主 env」或「roots.env 残留」。
# 残留那条正是 2026-10-05 踩到的病(在 agent-product 里解析成 ai1net-dsh-server)。
# ⚠️ 老写法只在 `here != ws` 时报警 ⇒ **恰好漏掉这一档**(`here` 是 None,比较为真但语义不对)。
log("🔴 cwd(及各级祖先)里**没有任何工作区标记**(`.workbuddy/collab/` 或 `state.py`)。")
log(f"🔴 当前工作区 **{ws}** 是**从 `roots.env` 残留推出来的**,⛔ 不是从你脚下的目录认出来的。")
log("🔴 若你确实要装到当前目录 ⇒ 加 `--workspace <当前目录绝对路径>`,⛔ 别让它继承。")
log("🔴 若当前目录**本就该是个工作区** ⇒ 先让它长出标记(至少建 `.workbuddy/collab/`)。")
elif here is not None and here != ws:
log(f"⚠ cwd 判据({here})与最终选用({ws})**不一致** —— 若非故意,请核查 `--workspace`")
if prev_ws and Path(prev_ws).is_dir() and Path(prev_ws) != ws:
# 🔴🔴 2026-10-05 改口径 —— 老文案制造了**不必要的恐慌**("一个包同时只服务一个工作区")。
# 实情(有读数+SKILL.md 支撑):**多工作区并存本来就是既定形态**,分工是两层:
# · **全局层**(本脚本)= 接线钩子 + `roots.env` 兜底 ⇒ 全机器一份;
# · **每区层**(`init_workspace.py`)= 各区自己的 `collabd.py` 副本 + 自己的
# `collabd.config.json` + 自己的计划任务 ⇒ **各用各的**。
# `roots.env` 的 `DSH_WS_ROOT` 只是**整机默认工作区**(钩子在拿不到会话 cwd 时的退化),
# ⛔ 不是"每装一次就迁一次家"。SKILL.md:886 逐字:
# 「技能目录 scripts/collabd.py = 源(唯一真身);每个工作区 .workbuddy/collab/collabd.py
# = 它自己的一份副本(另含 goalctl.py)。⛔ 各区不再跑技能目录那份。」
# 钩子侧也是按 cwd 分流的(`wb-result-hook.py::classify` ⇒ line/home/other/outside)⇒
# 新增一个工作区**不需要**动钩子。
log(f"ℹ `roots.env` 原指向 **{prev_ws}**,本次将改指 **{ws}**。")
log("ℹ `roots.env` 只是**整机默认工作区**(钩子在拿不到会话 cwd 时的退化),")
log('ℹ ⛔ 它不决定「哪个工作区能跑机制」—— 各区跑的是**自己那份** `collabd.py`,互不干扰。')
log(f'ℹ 若本意是「新开一个工作区 {ws}」⇒ ⛔ 别用本脚本改 roots.env;')
log(f'ℹ 正解=在 {ws} 里跑 `init_workspace.py`(铺本区副本 + 本区配置 + 本区计划任务)。')
log(f'ℹ 只有「整机默认工作区要换家」时,才该由本脚本改这一行。')
docs = detect_docs_root(args.docs_root)
log(f"包根={PKG} 工作区={ws} 文档库根={docs} 配置目录={config_dir()}")
raw = sp.read_text(encoding="utf-8")
before = json.loads(raw)
after = merge(before)
diff = "\n".join(
difflib.unified_diff(
json.dumps(before, ensure_ascii=False, indent=2).splitlines(),
json.dumps(after, ensure_ascii=False, indent=2).splitlines(),
fromfile="settings.json (before)", tofile="settings.json (after)", lineterm="",
)
)
print(diff)
excluded = [
str(h.get("command", ""))
for arr in before.get("hooks", {}).values()
for it in arr
for h in it.get("hooks", [])
if "decision_bridge" in str(h.get("command", ""))
]
log(f"(目视核对)⛔ 被排除、未被本包触碰的接线条数:{len(excluded)}(应 >0,且都在 decision_bridge 线上)")
write_roots(ws, docs, args.code_repo, args.dry_run)
init_workspace(ws, args.dry_run)
if args.dry_run:
log("[dry-run] ⛔ 未写 settings.json")
return 0
bak, wrote_orig = make_backup(sp, raw)
sp.write_text(json.dumps(after, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
log(f"已写 {sp}")
with open(LOG, "a", encoding="utf-8") as f:
f.write(f"BACKUP={bak}\n")
if wrote_orig is not None:
f.write(f"ORIG_BACKUP={wrote_orig}\n")
return 0
def cmd_verify(args) -> int:
fails: list[str] = []
# 🔴 ①②③ 一律在**临时目录**里跑:钩子与 collabd 都会按 cwd 推导"工作区"。
# 若直接用调用者的 cwd(常见=包根),它们会把**技能包本身**当成工作区,
# 于是在 `<包>/tmp/supervise-inbox/` 里写出运行态日志 ⇒ **污染技能包**(实测踩到)。
scratch = tempfile.mkdtemp(prefix="sm-verify-")
try:
print("== ① 包内钩子空载荷自检(期望 rc=0)==")
for event, _m, rel, extra, _t in HOOKS:
p = PKG / rel
if not p.is_file():
fails.append(f"{rel} 不存在")
print(f" 🔴 {event:16} {rel} ← 文件不存在")
continue
r = subprocess.run([py(), *extra, str(p)], input=b"{}", capture_output=True, cwd=scratch)
tag = "ok" if r.returncode == 0 else f"rc={r.returncode}"
if r.returncode != 0:
fails.append(f"{rel} rc={r.returncode}")
print(f" {tag:6} {event:16} {rel}")
print("== ② collabd --where ==")
collabd = PKG / "scripts" / "collabd.py"
r = subprocess.run([py(), str(collabd), "--where"], capture_output=True,
cwd=scratch, env={**os.environ, **read_roots()})
out = (r.stdout or b"").decode("utf-8", "replace").strip()
print(f" rc={r.returncode}\n {out[:300]}")
if r.returncode != 0:
fails.append("collabd --where 非 0")
print("== ③ selftest ==")
st = PKG / "scripts" / "selftest.py"
if st.is_file():
r = subprocess.run([py(), str(st)], capture_output=True,
cwd=scratch, env={**os.environ, **read_roots()})
tail = (r.stdout or b"").decode("utf-8", "replace").strip().splitlines()[-6:]
for ln in tail:
print(" " + ln)
if r.returncode != 0 or "FAIL 0" not in " ".join(tail):
fails.append("selftest 未通过(未见 FAIL 0)")
else:
print(" ⚠ 无 selftest.py")
finally:
shutil.rmtree(scratch, ignore_errors=True)
print("== 结论 ==")
if fails:
for f in fails:
print(" 🔴 " + f)
log(f"--verify 失败:{fails}")
return 1
print(" ✅ 全绿")
log("--verify 全绿")
return 0
def cmd_uninstall(args) -> int:
sp = settings_path()
orig = sp.with_name(ORIG_BAK_NAME)
ts_baks = sorted(p for p in sp.parent.glob(f"{BAK_PREFIX}*") if p.name != ORIG_BAK_NAME)
# 🔴 优先级:① 固定名 `-orig`(本包接管前的原状,最可信)→ ② 最早那份「未被本包接管」的时间戳备份。
# ⛔ 不能取 `sorted(...)[-1]`:`--apply` 每次都备份 ⇒ 最新那份其实是**已装状态**,
# 拿它还原 = 还原了个寂寞,而且脚本自比自还会报"逐字节相同"(**假绿**,上一棒实测撞到)。
pristine = orig if orig.is_file() else None
if pristine is not None:
log(f"采用固定名原状备份:{pristine.name}")
else:
for b in ts_baks:
try:
if not points_into_pkg(b.read_text(encoding="utf-8")):
pristine = b
break
except Exception:
continue
if pristine is not None:
log(f"⚠ 无 `-orig` 备份 ⇒ 回落用最早的原状时间戳备份:{pristine.name}")
if pristine is None:
log(f"🔴 {len(ts_baks)} 份时间戳备份里没有一份是「未被本包接管」的原状,且无 `-orig` ⇒ 停手(⛔ 不拿已装状态糊弄)")
return 2
# 还原前先把"当前状态"留一份 ⇒ `--uninstall` 本身也可回退。
stamp = unique_backup_path(sp).name[len(BAK_PREFIX):]
pre = sp.with_name(f"{BAK_PREFIX}preuninstall-{stamp}")
n = 1
while pre.exists():
pre = sp.with_name(f"{BAK_PREFIX}preuninstall-{stamp}-{n}")
n += 1
shutil.copy2(sp, pre)
log(f"(还原前存照)→ {pre}")
shutil.copy2(pristine, sp)
same = hashlib.md5(pristine.read_bytes()).hexdigest() == hashlib.md5(sp.read_bytes()).hexdigest()
clean = not points_into_pkg(sp.read_text(encoding="utf-8"))
log(f"已用 {pristine.name} 还原 {sp}")
log(f" · 与备份逐字节相同:{'✅' if same else '🔴'}")
log(f" · 还原后已无指向本包的钩子:{'✅' if clean else '🔴'}")
return 0 if (same and clean) else 1
# ── --manifest:重算 references/manifest.md 的逐文件表 ──────────────────────
# 🔴 为什么固化进 install.py:包一改,表里的字节/md5 立刻过期 ⇒ **每轮都得重算**。
# 以前每轮临时手搓一个脚本、跑完即删 —— 既浪费又会漏(本项目已因此误判过两次)。
MANIFEST = PKG / "references" / "manifest.md"
# ⛔ 不入表:运行日志 + 表自身(写完即失真)
MANIFEST_SKIP = {"install.log", "references/manifest.md"}
def _manifest_syntax(p: Path) -> str:
if p.suffix == ".py":
try:
compile(p.read_text(encoding="utf-8"), str(p), "exec")
return "ok"
except Exception:
return "🔴 py"
if p.suffix == ".json":
try:
json.loads(p.read_text(encoding="utf-8"))
return "ok"
except Exception:
return "🔴 json"
return "—"
def manifest_files() -> list[Path]:
keep: list[Path] = []
for p in PKG.rglob("*"):
if not p.is_file():
continue
rel = p.relative_to(PKG).as_posix()
if rel in MANIFEST_SKIP or "__pycache__" in p.parts:
continue
if p.suffix in (".pyc", ".pyo") or rel.startswith("tmp/") or "/tmp/" in rel:
continue
keep.append(p)
return sorted(keep, key=lambda q: q.relative_to(PKG).as_posix())
def cmd_manifest(args) -> int:
if not MANIFEST.is_file():
log(f"🔴 找不到 {MANIFEST}")
return 2
files = manifest_files()
rows: list[str] = []
bad: list[str] = []
for p in files:
rel = p.relative_to(PKG).as_posix()
b = p.read_bytes()
st = _manifest_syntax(p)
if st.startswith("🔴"):
bad.append(rel)
rows.append(f"| `{rel}` | {len(b)} | `{hashlib.md5(b).hexdigest()}` | {st} |")
# 🔴 行尾按**原文件**来:本表历史上是 CRLF,写完变 LF 会成为一次无声的全文件 diff。
raw = MANIFEST.read_bytes()
crlf = b"\r\n" in raw
lines = raw.decode("utf-8").replace("\r\n", "\n").split("\n")
# ① 计数行
for i, ln in enumerate(lines):
if ln.startswith("文件总数:"):
lines[i] = f"文件总数:**{len(files)}** | 语法 / 结构检查失败:**{len(bad)}**"
break
# ② 重算时间(`--note` 给括注;不给则保留原括注,⛔ 不抹掉人写的结论)
stamp = time.strftime("%Y-%m-%d %H:%M")
note = getattr(args, "note", None)
# 🔴🔴 2026-10-01 修一处**逐轮累积的写坏**(实测把这一行套成了 4 层):
# 原写法 `re.sub(r"最近一次全量重算:.*?)", …)` —— `.*?` **非贪婪**,只吃到**第一个 `)`**。
# `--note` 里一旦出现全角括号(例如「…行(会说出假话)+ …」),第一个 `)` 就是
# **括注内部**的那个 ⇒ 只替换了前半截,**旧括注的尾巴原样留在原地**,
# 下一轮再套一层 ⇒ 越写越长,新结论和旧结论粘成一句(再看分不清哪句是本次的)。
# ⇒ 改成**用 `**` 收尾界定整段**(模板里 `**最近一次全量重算:…**` 的那对 `**` 是唯一的),
# `--note` 含什么括号都不受影响。
# ⚠️ 不给 `--note` 时**仍保留原括注**(那是人写的结论,⛔ 不抹),但顺手把已经累积出来的
# 多余 `)` 收敛成一个 —— 否则坏行会一直坏下去。
for i, ln in enumerate(lines):
if "最近一次全量重算:" in ln:
_k = ln.find("最近一次全量重算:")
_end = ln.rfind("**")
_tail = ln[_end:] if _end > _k else "" # 正常=`**`;模板被改过则不吃行尾
_body = ln[_k + len("最近一次全量重算:"):(_end if _end > _k else len(ln))]
if note:
_body = f"{stamp} ({note})"
else:
_body = re.sub(r"){2,}", ")", re.sub(r"^\S+ \S+", stamp, _body))
lines[i] = ln[:_k] + "最近一次全量重算:" + _body + _tail
break
# ③ 表:自 `| 包内路径 |` 表头起整段替换(⛔ 上方散文一字不动)
head = next((i for i, ln in enumerate(lines) if ln.startswith("| 包内路径 |")), None)
if head is None:
log("🔴 manifest.md 里找不到 `| 包内路径 |` 表头 ⇒ 停手(⛔ 不猜、不追加)")
return 2
new = lines[:head] + ["| 包内路径 | 字节 | md5 | 语法检查 |", "|---|---|---|---|", *rows]
out = "\n".join(new) + "\n"
if crlf:
out = out.replace("\n", "\r\n")
MANIFEST.write_bytes(out.encode("utf-8"))
log(f"--manifest 已重算:{len(files)} 份文件,语法失败 {len(bad)}")
for r in bad:
log(f" 🔴 {r}")
return 0 if not bad else 1
def main() -> int:
ap = argparse.ArgumentParser(description="session-mechanism 一键配置")
g = ap.add_mutually_exclusive_group(required=True)
g.add_argument("--apply", action="store_true", help="真装")
g.add_argument("--dry-run", action="store_true", help="只打印 diff")
g.add_argument("--verify", action="store_true", help="装完自检")
g.add_argument("--uninstall", action="store_true", help="还原 settings.json")
g.add_argument("--manifest", action="store_true", help="重算 references/manifest.md 的逐文件表")
ap.add_argument("--note", help="写进「最近一次全量重算」括注(仅 --manifest 用)")
ap.add_argument("--workspace", help="工作区根(不传则自动推断)")
ap.add_argument("--docs-root", help="文档库根(不传则自动推断/沿用 roots.env)")
ap.add_argument("--code-repo", help="代码仓根(可选)")
args = ap.parse_args()
if args.apply or args.dry_run:
return cmd_apply(args)
if args.verify:
return cmd_verify(args)
if args.manifest:
return cmd_manifest(args)
return cmd_uninstall(args)
if __name__ == "__main__":
raise SystemExit(main())