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你看呢
这类不带选项的待定清单,一律按待拍板项写:问题+说明+各候选优缺点+倾向)
706 lines
36 KiB
Python
706 lines
36 KiB
Python
#!/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())
|