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你看呢
这类不带选项的待定清单,一律按待拍板项写:问题+说明+各候选优缺点+倾向)
This commit is contained in:
1 parent
19101acd65
commit
64dd82073b
21 files changed
+8444
-4523
No files matched your search
+2267
-2207
File diff suppressed because it is too large.
Load diff
@@ -710,45 +710,17 @@ def ensure_supervise(why: str = "", verify: bool = True) -> dict:
|
||||
|
||||
|
||||
# ── 🔴🔴 常驻**自我供给**:就地起撑不住时,转去建本区的计划任务 ──────────────
|
||||
def _find_keeper_tpl():
|
||||
"""找 `start-supervise.ps1.tpl`(⛔ 返回 None = 找不到)。**两种布局都要认。**
|
||||
# 🔴🔴 2026-10-05 **已删除 `_find_keeper_tpl()` 与 `assets/start-supervise.ps1.tpl` 依赖**。
|
||||
# 原因:本函数唯一用途是给 `_escalate_to_keeper()` 找 **PowerShell 看守模板**
|
||||
# (`start-supervise.ps1.tpl`);而该形态已**整套废弃**(见下面 `_escalate_to_keeper`
|
||||
# 的形态收敛说明:任务动作改 `pythonw.exe` + `supervise-launch.py`,零控制台 ⇒ 不闪窗)。
|
||||
# ⇒ 本函数**零调用点**、模板文件也不再被读;留着只会让「副本里找不找得到模板」
|
||||
# 这类**过期判据**继续误红。⛔ 不是"删了省事",是消灭一条**已失效的判据源**。
|
||||
#
|
||||
# ⚠️ 考古价值仍保留在 `references/pitfalls.md`(P0-57 同族:副本形态下写死包根必红);
|
||||
# 若将来又要"让副本自带载体模板",**照 pitfalls 那条重写**,⛔ 别从 git 历史里翻回来。
|
||||
|
||||
🔴🔴 2026-10-04 修(**副本里必红的 bug**,实测踩到):
|
||||
本函数原来写死 `Path(__file__).resolve().parent.parent / "assets"`,
|
||||
在**技能目录**里没问题(`<pkg>/scripts/collabd.py` ⇒ `.parent.parent` = `<pkg>`),
|
||||
但**工作区副本**是 `.workbuddy/collab/collabd.py` ⇒ `.parent.parent` =
|
||||
`<WS>/.workbuddy` ⇒ 去找 `<WS>/.workbuddy/assets/`(**不存在**)
|
||||
⇒ 每个工作区的「自我建立常驻」**必然失败**,而报错还写着"缺模板"
|
||||
—— 看着像仓库少文件,其实只是**算错了包根**。
|
||||
(实测:vibe-product 副本返回「⛔ 缺模板」;而它旁边
|
||||
`.workbuddy/skills/session-mechanism/assets/` 里模板**好好在**。)
|
||||
|
||||
认三处(按优先级):
|
||||
① 同目录的 `<pkg>/assets/`(技能目录/包树里的 scripts/ 布局)
|
||||
② 从 `WS` 推的 **技能副本** `<WS>/.workbuddy/skills/session-mechanism/assets/`
|
||||
—— 副本形态的正解(deploy 只分发 `collabd.py`,模板跟着技能副本走)
|
||||
③ 代码默认的技能目录 `<CFGDIR>/skills/session-mechanism/assets/`
|
||||
—— 前两处都缺时的兜底(⛔ 不写死作者机器路径)
|
||||
"""
|
||||
_cands = []
|
||||
try:
|
||||
_cands.append(Path(__file__).resolve().parent.parent / "assets" / "start-supervise.ps1.tpl")
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
_cands.append(Path(WS) / ".workbuddy" / "skills" / "session-mechanism"
|
||||
/ "assets" / "start-supervise.ps1.tpl")
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
_cfg = Path(os.environ.get("CODEBUDDY_CONFIG_DIR") or (Path.home() / ".workbuddy"))
|
||||
_cands.append(_cfg / "skills" / "session-mechanism" / "assets" / "start-supervise.ps1.tpl")
|
||||
except Exception:
|
||||
pass
|
||||
for _c in _cands:
|
||||
if _c.is_file():
|
||||
return _c
|
||||
return None
|
||||
|
||||
|
||||
def _escalate_to_keeper(detail: str = "") -> str:
|
||||
@@ -1414,11 +1386,11 @@ def queue_view(T: dict, d: dict | None = None) -> dict:
|
||||
" ⚠️ 旧版这行原写「SessionEnd 会自动放行」—— **那是一句假话**,照着做就会卡住)。\n"
|
||||
|
||||
"7. 🔴 **收尾=自己判本线缺口并接上**(顶层设计 §4「收尾自判」· 定稿 §4.2 必含条款):\n"
|
||||
" ⚠️ **派活命名规范**:新棒的自动化名必须用**两级前缀** `[协作]-[<类别>]-<具体>`\n"
|
||||
" ⚠️ **派活命名规范**:新棒的自动化名必须用**两级前缀** `[执行]-[<类别>]-<具体>`\n"
|
||||
" —— **第 2 级要带方括号**,值取 `goal.json` 的 **`topics`(任务类别)**,⛔ **不是 `short`**。\n"
|
||||
" ⚠️ 漏方括号(写成 `[协作]-<类别>-…`)**仍能归属**(归属判据是「角色+类别**子串**」,\n"
|
||||
" ⚠️ 漏方括号(写成 `[执行]-<类别>-…`)**仍能归属**(归属判据是「角色+类别**子串**」,\n"
|
||||
" 2026-10-01 放宽),但 **`parse_session_name().ok=False`** ⇒ 报「没按约定命名」。\n"
|
||||
" ⛔ 真会**静默漏管**的是**值写错**:用 `short`/昵称(`[协作]-本机协作-…`)⇒ 标题里\n"
|
||||
" ⛔ 真会**静默漏管**的是**值写错**:用 `short`/昵称(`[执行]-本机协作-…`)⇒ 标题里\n"
|
||||
" 根本没有类别名 ⇒ `_in_project()` 认不出 ⇒ 看板不列它、`--ready-next` 也不算它在跑。\n"
|
||||
" 🔴 **接续退位(2026-10-01 加)**:你**建出下一棒之后**立刻执行一次\n"
|
||||
" `collabd.py --retire self --by <接棒会话id> --why 接续`\n"
|
||||
@@ -1759,10 +1731,18 @@ EXEC_DOC_NAME = "目标执行状态.md"
|
||||
# + `tasks.json`(队列四态)⇒ 本目录里只放**文档**,⛔ 不复制状态(两处存=迟早打架)。
|
||||
# ⚠️ 目录名带**短哈希**(按标题算)⇒ 同名目标不撞目录;⛔ 不用完整标题(长句 + 跨平台路径长)。
|
||||
_GOAL_DIR_PREFIX = "目标-"
|
||||
# 🔴🔴 2026-10-05 **用户定案**(逐字):「**<工作区根>/执行会话/目标-xxx-xxxxxx/ 改成这样**」
|
||||
# ⇒ 目标文件夹**统一收进工作区下的 `执行会话/` 一层**,⛔ 不再直接散在工作区根。
|
||||
# ⚠️ **为什么加这一层**(用户诉求的直接好处):工作区根下每换一个目标就多一个 `目标-*`,
|
||||
# `ls` 出来跟 `docs/`、`交付物/`、`归档/` 混在一起;收进一层 ⇒ 根目录干净、
|
||||
# 目标文件夹一眼可辨、历史目标归拢在一处。
|
||||
# ⚠️ **域目录不受影响**:独立域仍是「工作区第一层目录」(`<工作区>/<域名>/`);
|
||||
# `执行会话/` 是**目标文件夹的父层**,⛔ 不是新增的域,⛔ 不参与域键计算。
|
||||
_GOAL_DIR_PARENT = "执行会话"
|
||||
|
||||
|
||||
def goal_dir_name(title: str, short: str = "") -> str:
|
||||
"""🔴 目标目录名=`目标-<简称或标题前缀>-<短哈希>`(确定性 ⇒ 同一目标永远同一目录)。
|
||||
"""🔴 目标目录名=`执行会话/目标-<简称或标题前缀>-<短哈希>`(确定性 ⇒ 同一目标永远同一目录)。
|
||||
|
||||
⚠️ 短哈希=`sha1(title)[:6]` —— 只为**消歧**,⛔ 不承载语义(读者靠前半段认它)。
|
||||
⚠️ 前缀清洗:去掉路径分隔符与 Windows 非法字符,⛔ 防拼出打不开的路径。
|
||||
@@ -1772,7 +1752,50 @@ def goal_dir_name(title: str, short: str = "") -> str:
|
||||
# ⚠️ 空格也去掉:目录名带空格 ⇒ 命令行里处处要加引号,⛔ 任务会话最容易在这里踩空。
|
||||
t = re.sub(r"\s+", "", t)[:20].strip(" .-") or "未命名目标"
|
||||
h = hashlib.sha1(str(title or t).encode("utf-8")).hexdigest()[:6]
|
||||
return "%s%s-%s" % (_GOAL_DIR_PREFIX, t, h)
|
||||
# 🔴 单一真源:父层只在这一个地方拼,⛔ 别在调用方各拼一遍(同族 P0:同一条规则两份实现)。
|
||||
return "%s/%s%s-%s" % (_GOAL_DIR_PARENT, _GOAL_DIR_PREFIX, t, h)
|
||||
|
||||
|
||||
def goal_fp() -> str:
|
||||
"""🔴 **当前目标的指纹**(用于把台账条目**绑到目标**上)。
|
||||
|
||||
## 为什么要它(用户 2026-10-06 逐字)
|
||||
|
||||
> 「看板中 常驻程序 改为 目标检查,里面的队列 **应该是随着目标的不是目标累积的**」
|
||||
|
||||
🔴 **病根**:`tasks.json` 是**只增不减**的台账 —— 换目标时**旧条目全留着**
|
||||
(实测某工作区里躺着**跨时代的旧线名**,且代码注释里早写过
|
||||
「台账里还躺着跨工作区时代的旧线名」这类话)。
|
||||
⇒ 看板那格报的"队列 N 件 / 已完成 M / 未完 K"是**历史累计**,
|
||||
**读者看不出"这是当前目标在跑多少"** —— 而用户要的正是后者。
|
||||
|
||||
🔴 更关键:**机制自己早就知道这个缺陷**(见 `goalctl.py:577` 的「静默陷阱」警告逐字:
|
||||
「**判据不绑目标**」⇒ 新目标可能一声明就被判「全过」)。
|
||||
⚠️ 但那次**只警告、没修**(怕动存量数据)。
|
||||
⇒ 本函数是**同一病根的正面修法**:给条目一个**目标归属**,让"随目标"成为可判定的事实,
|
||||
⛔ 而不是靠"换目标时记得清台账"(凡"要人记"的都会忘 —— 同族已栽多次)。
|
||||
|
||||
## 取什么当指纹(⚠️ 选型理由)
|
||||
|
||||
· **⛔ 不用 `goal.json` 的 `id`**:实测那个值是**历史手写**的,
|
||||
换目标时 `goalctl declare` **完全不碰它**(全文无写入)⇒ 换目标后指纹不变 ⇒ **不能用**。
|
||||
· ✅ **用 `sha1(title)[:6]`** —— 与 `goal_dir_name()` **同一套哈希口径**
|
||||
(`goal_dir_name` 也是 `sha1(title)[:6]`)⇒ **天然对齐**:
|
||||
同一个目标 ⇒ 目标目录名里的 `-xxxxxx` 与这里返回的**逐字相同**,
|
||||
看板/日志/目录名三处可以对着读,⛔ 不会各算一套(同族 P0:一条规则两份实现)。
|
||||
· ⚠️ 标题改了(纯细化措辞)⇒ 指纹会变。**这是可接受的**:
|
||||
`goalctl declare` 在标题变化时**本来就要 `--switch-goal` 确认闸**
|
||||
⇒ "标题变了"在被允许的那一刻**就等同"换目标"**,语义一致。
|
||||
|
||||
@return 6 位十六进制;`goal.json` 缺失/无 title ⇒ `""`(调用方**必须显式处理空值**)
|
||||
"""
|
||||
try:
|
||||
t = str(load_goal().get("title") or "").strip()
|
||||
except Exception:
|
||||
t = ""
|
||||
if not t:
|
||||
return ""
|
||||
return hashlib.sha1(t.encode("utf-8")).hexdigest()[:6]
|
||||
|
||||
|
||||
def exec_doc_rel() -> str:
|
||||
@@ -1866,7 +1889,7 @@ def _ensure_goal_dir() -> dict:
|
||||
_doc.write_text(
|
||||
"# 目标执行状态\n\n"
|
||||
"> 🔴 本文件=**检查会话判断目标状态的唯一依据**(⛔ 别再去工作区翻文件)。\n"
|
||||
"> 由**目标检查会话**(`[协作]-[目标检查]-…`)在核对后更新。\n\n"
|
||||
"> 由**目标检查会话**(`[执行]-[目标检查]-…`)在核对后更新。\n\n"
|
||||
"## 二、验收判据(值是中文写法:`过|…`/`🔴 不过|…`)\n"
|
||||
"(⛔ 暂无判据 ⇒ 目标状态**判不出来**,不许当成已完成)\n\n"
|
||||
"## 三、结论\n"
|
||||
@@ -1877,6 +1900,46 @@ def _ensure_goal_dir() -> dict:
|
||||
out["err"] = "写文档失败:%s(%s)" % (_e, _doc)
|
||||
return out
|
||||
out["ok"] = True
|
||||
# ══ 🔴🔴 **对齐 `goal.json` 的 `execution_doc` 登记**(2026-10-05 补 · 见 P0-86)══
|
||||
# 病根(实测三区全中):`execution_doc` 这个字段**全脚本只有判据在读**——
|
||||
# `collabd.py`/`goalctl.py`/`init_workspace.py` **没有一个会写它**,
|
||||
# 而写在里面的值**是 10-05 改口径(加 `执行会话/` 一层)之前**留下的旧路径。
|
||||
# 后果:
|
||||
# ① `selftest` 那条「生产工作区该文件真存在」**永久假红**(拿旧路径问文件系统);
|
||||
# ② 更毒的是 `vibe-product`:登记成 `目标-vibe-product-3e3182/…`,
|
||||
# 而 `3e3182` 是**别的区的标题哈希** ⇒ 登记值属**跨区污染**,
|
||||
# 光看它根本推不出"该找哪个文件"。
|
||||
# ✅ 修法(陈述句,可推翻):**登记值=缓存,真值永远由 `exec_doc_rel()` 算**——
|
||||
# 在**建目标目录这唯一一处**顺手对齐(它本来就知道 `_rel` ⇒ 零重复实现)。
|
||||
# ⚠️ **为什么放这儿**:这是「按现行口径算出真路径」的那一步(`--ensure-goal-dir` 幂等、
|
||||
# `init_workspace.py` 每次必调)⇒ 放这儿=每个区**每次初始化都会自动追平**,
|
||||
# ⛔ 不需要谁记得手工改 JSON(手工=迟早再漂一次)。
|
||||
# ⚠️ **只改这一个键**:其余字段(`acceptance_state`/`taskgraph`/`lifecycle`…)
|
||||
# 整份读回来原样写回,⛔ 不许碰(那是状态真源,覆盖=毁进度)。
|
||||
# ⚠️ **失败不许影响建目录**(那是主职责)⇒ 整段包 try,出错只记 `out`。
|
||||
try:
|
||||
_want = exec_doc_rel() # 🔴 唯一真源(与 prompt/`goal_dir_rel()` 同源)
|
||||
_gj = INBOX / "goal.json"
|
||||
if _gj.is_file():
|
||||
_gd = json.loads(_gj.read_text(encoding="utf-8")) or {}
|
||||
if isinstance(_gd, dict) and _gd.get("title"):
|
||||
_have = str(_gd.get("execution_doc") or "")
|
||||
if _have != _want:
|
||||
_gd["execution_doc"] = _want
|
||||
_gd["execution_doc_at"] = time.strftime("%Y-%m-%d %H:%M:%S")
|
||||
_gj.write_text(json.dumps(_gd, ensure_ascii=False, indent=1),
|
||||
encoding="utf-8", newline="")
|
||||
out["doc_sync"] = ("✅ `execution_doc` 已对齐:%r → %r"
|
||||
% (_have or "(未登记)", _want))
|
||||
log("goal.json execution_doc 对齐:%r → %r" % (_have, _want))
|
||||
else:
|
||||
out["doc_sync"] = "已是最新(%s)" % _want
|
||||
else:
|
||||
out["doc_sync"] = "⏭ `goal.json` 无 title ⇒ 不是有效目标,⛔ 不动"
|
||||
else:
|
||||
out["doc_sync"] = "⏭ 无 `goal.json`(⛔ 目标未声明)"
|
||||
except Exception as _e:
|
||||
out["doc_sync"] = "⚠️ 对齐 `execution_doc` 失败(⛔ 不影响建目录):%r" % (_e,)
|
||||
# ══ 🔴🔴 **顺手登记主会话**(2026-10-05 用户:「最好是记录会话,这样就不用每次会话都去做声明了」)══
|
||||
# **为什么放在这儿**:这是"**用户在创建目标**"那一刻 —— 会话**必然已知**
|
||||
# (`CODEBUDDY_SESSION_ID` 由宿主注入)⇒ ⛔ 不需要用户每次开口声明。
|
||||
@@ -1911,7 +1974,9 @@ def _ensure_goal_dir() -> dict:
|
||||
def goal_dir_rel() -> str:
|
||||
"""🔴 目标目录的**相对工作区**路径(真源=`goal.json` 的 `title`/`short`,⛔ 不猜)。
|
||||
|
||||
⚠️ 读不到 `goal.json` ⇒ 回落成 `目标-未命名目标-<空标题哈希>`(**确定性**)
|
||||
形态=`执行会话/目标-<简称或标题前缀>-<短哈希>`(2026-10-05 用户定案加 `执行会话/` 一层)。
|
||||
|
||||
⚠️ 读不到 `goal.json` ⇒ 回落成 `执行会话/目标-未命名目标-<空标题哈希>`(**确定性**)
|
||||
⇒ ⛔ 不会每次调用都变个名(那会让产物散成一堆目录)。
|
||||
⚠️ **不 mkdir**:⛔ 定位与建目录分开(读路径的代码⛔ 不该有副作用)。
|
||||
"""
|
||||
@@ -1941,11 +2006,13 @@ def goal_dir_rel() -> str:
|
||||
# | 文档 | 谁写 | 什么时候写 | 谁读 |
|
||||
# |---|---|---|---|
|
||||
# | `<目标目录>/目标执行状态.md` | **目标检查会话** | 核对完目标状态后 | 下一个检查会话/人 |
|
||||
# | `<目标目录>/<棒次>_<事项>_<日期>.md` | **任务会话(协作棒)自己** | 本轮做完时(=`--report done` 之前) | 检查会话照 `artifact` 读 |
|
||||
# | `<目标目录>/<棒次>_<事项>_<日期>.md` | **任务会话(执行棒)自己** | 本轮做完时(=`--report done` 之前) | 检查会话照 `artifact` 读 |
|
||||
# | `tmp/supervise-inbox/tasks.json` | `--report`(机器写) | 每次状态转移 | 全部环节 |
|
||||
#
|
||||
# 🔴 **落点是死的**(⛔ 不许任务会话"顺手"写回 `交付物/`、`docs/`、工作区根 —— 那正是"到处找"的由来):
|
||||
# 执行产物的 `--artifact` **必须**指进 `<目标目录>/` 里(见 `artifact_dir_ok()` 判据)。
|
||||
# ⚠️ 2026-10-05:目标目录**收进 `执行会话/` 一层**(用户定案「改成这样」)⇒
|
||||
# 合同表里的 `<目标目录>` 一律读作 `执行会话/目标-xxx-xxxxxx`,⛔ 别按旧的裸 `目标-xxx` 去拼。
|
||||
# ══════════════════════════════════════════════════════════════════════════════
|
||||
GOAL_DOC_EXEC = EXEC_DOC_NAME # 目标执行状态.md(目标检查会话写)
|
||||
DOC_CONTRACT_ROWS = (
|
||||
@@ -2176,6 +2243,14 @@ def task_report(tid: str, state: str, by: str = "", artifact: str = "", reason:
|
||||
rec = dict(t.get(tid) or {})
|
||||
prev = str(rec.get("state") or "")
|
||||
rec.update({"state": state, "by": by or rec.get("by", ""), "t": time.time()})
|
||||
# 🔴🔴 2026-10-06 **给条目打「目标归属」**(用户:「队列 应该是随着目标的不是目标累积的」)。
|
||||
# ⚠️ **新条目才打**(`setdefault`):存量条目**没有**这个字段 ⇒ ⛔ 不许补成"当前目标"——
|
||||
# 那是**伪造归属**(旧条目本来属于旧目标,补成新的=把别人的活算到本目标头上,
|
||||
# 同族:`board.py` 那条「⛔ 不得吃本工作区数据」的红线一个道理)。
|
||||
# ⇒ 存量条目 `goal_fp` 为空 ⇒ 看板按"无归属"另计(见 `_queue`),⛔ 不混进当前目标。
|
||||
# ⚠️ 只写一次(`setdefault`)⇒ 同一条目**后续上报不改归属**(⛔ 防"跑到一半换目标 ⇒ 整条倒戈")。
|
||||
if goal_fp():
|
||||
rec.setdefault("goal_fp", goal_fp())
|
||||
if line:
|
||||
rec["line"] = line # 线归属**写进条目**(⛔ 不再从会话 cwd 反推)
|
||||
if artifact:
|
||||
@@ -3130,7 +3205,7 @@ def _in_project(sid: str, title: str, main_sid, short) -> bool:
|
||||
# ⇒ 看板画空框「暂无协作会话」,而那条会话**正在 working**。
|
||||
# 本函数还有**第二个受害者**(更严重,因为它会改行为而不只是画错):
|
||||
# `ready_next()` 的判据①「除主会话外没有别的会话在 working」走的**就是本函数**
|
||||
# ⇒ 在跑的协作棒判 `False` ⇒ 落进 `ignored_others` 而不是 `working_others`
|
||||
# ⇒ 在跑的执行棒判 `False` ⇒ 落进 `ignored_others` 而不是 `working_others`
|
||||
# ⇒ `--ready-next` 会**说"可以排下一棒"** —— 正是用户 2026-09-30 描述的那个病:
|
||||
# 「5-8 应该是之前出现**前面还没执行完 就开始下一棒了**」。
|
||||
# ⇒ 判据统一成**两条并列**(任一命中即算):
|
||||
@@ -3259,7 +3334,7 @@ def goal_line() -> str:
|
||||
🔴 2026-09-30 改:显示**任务类别清单**(`goal.topics`)而不是单个 `short` ——
|
||||
用户要求「主会话根据任务**自动梳理任务类别**,通过**协作会话名称前缀**区分具体执行会话」
|
||||
⇒ 这一行是**每轮钩子注入**给主会话的,必须把"本工作区里有哪几个类别"直接摆出来,
|
||||
主会话才好据此给新棒命名(`[协作]-<类别>-<具体>`)并解析"这活属于哪一类"。
|
||||
主会话才好据此给新棒命名(`[执行]-<类别>-<具体>`)并解析"这活属于哪一类"。
|
||||
"""
|
||||
g = load_goal()
|
||||
if not g:
|
||||
@@ -3655,6 +3730,49 @@ def local_user_id() -> str:
|
||||
return ""
|
||||
|
||||
|
||||
# 🔴🔴 **机制另建会话时,模型该跟谁一致**(2026-10-05 用户点破 P0-80)
|
||||
# 症状:常驻新建的 `[执行]`/`[检查]` 会话全是模型 `space-bunny`,而**主会话是 `deepseek-v4.1-flash`**
|
||||
# ⇒ 同一件活在两种模型上跑(能力档、思考开关全不一致)。
|
||||
# 真因:`create_check_schedule()` 的 INSERT 里**字面写死** `"space-bunny"`(把一次实测当成了永恒)。
|
||||
# ✅ 正解:**跟本区最近一条「人开的」会话取同一档** —— 模型 + 思考档一起继承。
|
||||
# ⚠️ 两条建会话的路必须**同源**:常驻直连 SQLite(本函数所服务的路径)/会话侧走 `automation_update`。
|
||||
# ⛔ 取不到时**必须打日志**(⛔ 不许静默回落 —— 否则又是"看着改了其实没生效")。
|
||||
FALLBACK_MODEL = "space-bunny" # 兜底常量:仅当**取不到**主会话模型时才用
|
||||
FALLBACK_THINKING = 0
|
||||
|
||||
|
||||
def preferred_model(cwd: str = "") -> tuple:
|
||||
"""**本区主会话用的(模型, 是否开思考)** —— 机制另建会话时照抄这个。
|
||||
|
||||
取数:`sessions` 里**人开的**(`is_background_automation <> 1`)、**本区**、**最近一条**。
|
||||
⚠️ 排除 `bg=1` 是必须的 —— 否则会取到「机制自己上次建的那条」⇒ **自我强化**,
|
||||
永远锁死在历史值上(同 P0-77 那类"错值自我强化")。
|
||||
⚠️ `thought_level`:`disabled`/空 ⇒ 0,其余(`high`/`low`/…) ⇒ 1。
|
||||
"""
|
||||
ws = (str(cwd or WS or os.getcwd())).replace("\\", "/").rstrip("/")
|
||||
try:
|
||||
c = _ro_conn()
|
||||
r = c.execute(
|
||||
"select model, thought_level from sessions "
|
||||
"where (is_background_automation is null or is_background_automation <> 1) "
|
||||
" and model is not null and model <> '' "
|
||||
" and replace(cwd,'\\\\','/') like ? "
|
||||
"order by created_at desc limit 1", ("%" + ws + "%",)).fetchone()
|
||||
c.close()
|
||||
except Exception as e:
|
||||
log("⚠️ preferred_model 取数失败(回落 %s):%s" % (FALLBACK_MODEL, e))
|
||||
return FALLBACK_MODEL, FALLBACK_THINKING
|
||||
if not r or not (r[0] or "").strip():
|
||||
log("⚠️ preferred_model 取不到本区主会话模型(cwd=%s)⇒ 回落 %s"
|
||||
% (ws, FALLBACK_MODEL))
|
||||
return FALLBACK_MODEL, FALLBACK_THINKING
|
||||
m = str(r[0]).strip()
|
||||
tl = str(r[1] or "").strip().lower()
|
||||
th = 0 if tl in ("", "disabled", "off", "none", "0") else 1
|
||||
log("preferred_model:本区主会话 model=%s thought=%s ⇒ 机制照抄这个(P0-80)" % (m, tl or "-"))
|
||||
return m, th
|
||||
|
||||
|
||||
def active_schedule_exists(name: str) -> bool:
|
||||
"""**同名排期是否已在册且未跑**(⛔ 去抖:防止每轮重复建同名排期)。"""
|
||||
try:
|
||||
@@ -3926,6 +4044,8 @@ def create_check_schedule(name: str, prompt: str, delay_s: int = 90,
|
||||
ws = (cwd or (str(WS) if WS else os.getcwd())).replace("\\", "/")
|
||||
# 🔴 `cwds` **必须正斜杠**(红线:`cwds` 逐字同形,去重键=`path.trim().toLowerCase()`;
|
||||
# 落库若用反斜杠 ⇒ 与 automation_update 建的排期**裂成两个组**,看板上就是"裂组")
|
||||
# 🔴🔴 2026-10-05 **P0-80**:模型⛔ 不许写死 —— 跟**本区主会话**取同一档(模型+思考档一起)。
|
||||
_model, _thinking = preferred_model(ws)
|
||||
c = sqlite3.connect(_wb_db(), timeout=15)
|
||||
try:
|
||||
c.execute("PRAGMA busy_timeout=8000")
|
||||
@@ -3938,13 +4058,15 @@ def create_check_schedule(name: str, prompt: str, delay_s: int = 90,
|
||||
(aid, name, prompt, "ACTIVE", "once",
|
||||
fire.strftime("%Y-%m-%dT%H:%M:%S"), now_ms + delay_s * 1000, "",
|
||||
json.dumps([ws], ensure_ascii=False), now_ms, now_ms,
|
||||
"space-bunny", 0, "[]", "[]", local_user_id(), "confirmed"),
|
||||
_model, _thinking, "[]", "[]", local_user_id(), "confirmed"),
|
||||
)
|
||||
c.commit()
|
||||
finally:
|
||||
c.close()
|
||||
log("已建检查会话排期 name=%s id=%s fire=+%ds" % (name, aid[:8], delay_s))
|
||||
return {"id": aid, "name": name, "fire_at": fire.strftime("%H:%M:%S"), "delay_s": delay_s}
|
||||
log("已建检查会话排期 name=%s id=%s fire=+%ds model=%s think=%d"
|
||||
% (name, aid[:8], delay_s, _model, _thinking))
|
||||
return {"id": aid, "name": name, "fire_at": fire.strftime("%H:%M:%S"), "delay_s": delay_s,
|
||||
"model": _model, "thinking": _thinking}
|
||||
|
||||
|
||||
# 已建的检查会话排期(去抖 + 看板读数)
|
||||
@@ -4060,8 +4182,8 @@ CHECK_COOLDOWN_S = 30 * 60
|
||||
# 🔴 域门禁 + 硬约束这尾节**两类完全相同** —— ⛔ 但**逐字抄两遍**:
|
||||
# ⛔ 不抽成一个变量再拼装(一旦共用,又会"改一处漏另一处")。
|
||||
_CHECK_TAIL_A = """
|
||||
## %s、🔴🔴 派协作棒时**必须带上独立域门禁**
|
||||
- 每条新协作棒都要有**自己的域目录**:`<工作区>/<域名>/` —— 🔴⛔ **直接占工作区下的第一层目录**,
|
||||
## %s、🔴🔴 派执行棒时**必须带上独立域门禁**
|
||||
- 每条新执行棒都要有**自己的域目录**:`<工作区>/<域名>/` —— 🔴⛔ **直接占工作区下的第一层目录**,
|
||||
**⛔ 不许套 `domains/` 这种公共父目录**(实测那样所有子目录会算出**同一个域键** ⇒ 并行直接失效)。
|
||||
- 🔴 **这两条命令的本体是 `collabd.py`,不是工作区目录**(2026-10-03 实测:
|
||||
写 `python "<工作区目录>" --domain-status` 会报 `can't find '__main__' module`
|
||||
@@ -4142,7 +4264,7 @@ CHECK_PROMPT_RESULT = (
|
||||
"4. 🔴 `%s` —— **目标**。⛔ **只读这四个字段**,别全文件翻:\n"
|
||||
" · `lifecycle` = 目标生命周期(`进行中`/`已完成`/`等待`)\n"
|
||||
" · `acceptance_state` = **验收判据**(🔴 判断目标达成与否就看它)\n"
|
||||
" · `topics` = 任务类别(派棒时放进 `[协作]-<类别>-…`)\n"
|
||||
" · `topics` = 任务类别(派棒时放进 `[执行]-<类别>-…`)\n"
|
||||
" · `title` = 目标标题\n"
|
||||
"5. `python \"%s\" --domain-status` —— **看域占用**(派棒前必跑,⛔ 跳过就等于没门禁)\n"
|
||||
"\n"
|
||||
@@ -4157,7 +4279,7 @@ CHECK_PROMPT_RESULT = (
|
||||
" - `blocked`:读它的 `block_reason`;能解 ⇒ 派一条任务会话去解;\n"
|
||||
" ⛔ 解不了 ⇒ **写明阻碍并停手**,⛔ 别硬派一条注定干等的活;\n"
|
||||
" - 还 `pending`/`running` 但**没有会话在跑** ⇒ 它**没被认领** ⇒ 派一条接手。\n"
|
||||
"2. **派活**:能派 ⇒ 建一条 `[协作]-<类别>-<具体>` 排期接手。\n"
|
||||
"2. **派活**:能派 ⇒ 建一条 `[执行]-<类别>-<具体>` 排期接手。\n"
|
||||
"3. ⛔ **本轮你没有「改目标状态」的出口** —— 那是「目标检查会话」的活。\n"
|
||||
" 即使你判断目标其实已经做完了,也**只写明「疑似已完成」并停手**,⛔ 不要自己去改 `lifecycle`。\n"
|
||||
+ _CHECK_TAIL_A + _CHECK_TAIL_B
|
||||
@@ -4186,7 +4308,7 @@ CHECK_PROMPT_GOAL = (
|
||||
" · `execution_doc` = 🔴🔴 **目标执行状态文档的路径**(下面第 5 项)\n"
|
||||
" · `lifecycle` = 目标生命周期(本轮**你有权改它**,见三.3)\n"
|
||||
" · `acceptance_state` = 验收判据的**机读副本**(⚠️ 可能与文档不同步 ⇒ **以文档为准**)\n"
|
||||
" · `topics` = 任务类别(派棒时放进 `[协作]-<类别>-…`)\n"
|
||||
" · `topics` = 任务类别(派棒时放进 `[执行]-<类别>-…`)\n"
|
||||
"5. 🔴🔴 `%s` —— **目标执行状态文档(本轮判断目标状态的唯一依据)**\n"
|
||||
" ⛔ **别再去工作区里翻文件找目标状态**(用户 2026-10-03 实测:到处找信息\n"
|
||||
" 会被宿主判成**重复执行、要求人工确认**)。**读这一份就够了。**\n"
|
||||
@@ -4203,7 +4325,7 @@ CHECK_PROMPT_GOAL = (
|
||||
" 前两路只说明「活干完了吗」,⛔ **不等于**「目标达成了吗」。\n"
|
||||
" ⚠️ 判据值是**中文写法**:`过|…`/`🔴 不过|…`/`待重验(…)` —— ⛔ 别拿「不等于 `pass`」判未过。\n"
|
||||
" ⚠️ 一条**有效判据都没有**(只有 `_` 开头的说明行)⇒ **判不出来,⛔ 不许当成已完成**。\n"
|
||||
"2. **没做完** ⇒ 建一条 `[协作]-<类别>-<具体>` 排期接着做。\n"
|
||||
"2. **没做完** ⇒ 建一条 `[执行]-<类别>-<具体>` 排期接着做。\n"
|
||||
"3. **做完了** ⇒ 用 `%s --set-life 已完成 --by \"<你的会话名>\"` 把目标状态改成「已完成」,\n"
|
||||
" ⛔ 不留「进行中」空转。\n"
|
||||
" 🔴 **2026-10-03 修(实测坐实)**:早前这里写的是 `goalctl.py set-life已完成`,⛔ **该命令根本不存在** ——\n"
|
||||
@@ -4593,7 +4715,7 @@ def maybe_spawn_check_agent(reason: str) -> dict |None:
|
||||
# 写成 `[协作]-结果检查-…`(第二段无括号)⇒ `topic=""`、`ok=False` ⇒ 类别丢失。
|
||||
# 现网两种正确形态可对照:`[协作]-[机制排查与修复]-S13 …`(协作)、
|
||||
# `主控 · 协作机制 · …`(主会话,走另一条 `main-prefix` 分支)。
|
||||
# 检查会话是**任务会话**(它自己会派协作棒)⇒ 角色=`协作`、类别=检查种类。
|
||||
# 检查会话是**任务会话**(它自己会派执行棒)⇒ 角色=`执行`、类别=检查种类。
|
||||
# ⚠️ 类别用 `CHECK_KINDS[reason][1]`(`结果检查`/`目标检查`)⇒ 两种在标题上就分得开,
|
||||
# ⛔ 不合并成一个 `[协作]-[检查]-…`(那样看板/台账分不出是哪一种检查)。
|
||||
# 🔴🔴 2026-10-03 11:1x **改用独立角色前缀 `[检查]`**(用户纠正逐字:
|
||||
@@ -4712,7 +4834,7 @@ def is_check_agent(name: str) -> bool:
|
||||
"""🔴 标题是不是一条**检查会话**(**协作程序**建的那种)。
|
||||
|
||||
🔴 **为什么它必须是 worker、且必须认得出来**(2026-10-03 10:2x 实测):
|
||||
检查会话由**协作程序**建(`maybe_spawn_check_agent()`),它会**自己派协作棒** ⇒ 它是干活的那条。
|
||||
检查会话由**常驻程序**建(`maybe_spawn_check_agent()`),它会**自己派执行棒** ⇒ 它是干活的那条。
|
||||
但 10:1x 之前建的都用了**旧式光杆名**(`结果检查-…`,无方括号)⇒ 解析层两个分支都不命中
|
||||
⇒ `role=""` ⇒ `_scan_mains()` 的排除元组只排 `"worker"` ⇒ **`""` 通过**
|
||||
⇒ 它被**收进主会话候选** ⇒ 看板 `role` 显示**「主会话」**(实测 `44b547d4` 就是)。
|
||||
@@ -4807,7 +4929,7 @@ def parse_session_name(name: str) -> dict:
|
||||
# 🆕 ④ 主控前缀(实测现役主会话标题就是 `主控 · 协作机制 · …`)
|
||||
return {"role": "main", "topic": "", "ok": False, "form": "main-prefix"}
|
||||
if is_continuation(nm):
|
||||
# 🆕 ③ 无方括号的接续会话(`接续棒:…` / `接续 · 线 · 具体`)⇒ 协作棒
|
||||
# 🆕 ③ 无方括号的接续会话(`接续棒:…` / `接续 · 线 · 具体`)⇒ 执行棒
|
||||
return {"role": "worker", "topic": "", "ok": False, "form": "continuation"}
|
||||
# 🔴🔴 2026-10-03 10:2x **旧式光杆名的检查会话也判 worker**(用户报障连带查出的真缺陷)。
|
||||
# ⚠️ **位置必须在「无方括号」这个早退分支里**(我第一版误加在下面的方括号分支
|
||||
@@ -4859,10 +4981,17 @@ def parse_session_name(name: str) -> dict:
|
||||
# ⇒ 删掉映射 ⇒ 它们全部解析成 `""` ⇒ **看板画不出、派活漏管**,且**不可逆**。
|
||||
# ⇒ **三个前缀同映射到 `worker`**:新名生效、旧名不破。
|
||||
# ⚠️ 必须与 `board.py::_role_of_title()` **逐条同款**(`selftest.py` 逐样本对账,⛔ 漂了就红)。
|
||||
# 🔴🔴 2026-10-05 **用户定案:「协作 全部 改为执行」(连说两遍)** ⇒ **新建前缀一律 `[执行]`**
|
||||
# (发源地 = 本文件 `_zh` 字典,`worker` → `"执行"`;文首提示词模板 9 处已同步)。
|
||||
# ⛔⛔ **下面这三条兼容映射 ⛔ 一个字都不许动、更不许删** —— 见下一行注释的存量清单:
|
||||
# `执行`(三代新建)/`协作`(二代沿用至 10-05)/`协作目标`(二代 10-03 短暂形态),
|
||||
# **三者同映射到 `worker`**;删任一条 ⇒ 存量会话/排期解析成 `role=""`
|
||||
# ⇒ 看板画不出、派活漏管,**不可逆**。
|
||||
role = {"主": "main",
|
||||
"执行": "worker", # 🆕 当前口径(前缀)
|
||||
"协作": "worker", # ⛔ 旧前缀,保留兼容
|
||||
"协作目标": "worker", # ⛔ 旧前缀,保留兼容
|
||||
"任务会话": "worker", # ⛔ 旧前缀,保留兼容
|
||||
"检查": "check"}.get(r, "")
|
||||
if not role:
|
||||
# 🆕 ③ `[<类别>] 接续 · …` —— 第一对方括号里装的是**任务类别**(⛔ 不是角色):
|
||||
@@ -5007,7 +5136,7 @@ def _topic_in_title(title: str, topics=None) -> str:
|
||||
"""标题里出现的**任务类别**(🔴 **最长优先** —— 防止"唤醒机制"被"机制"这种短名抢走)。
|
||||
|
||||
⚠️ 判据是**子串**(不要求 `[...]` 包裹)—— 因为用户给主会话的命名是口语式的
|
||||
(「主控 · 唤醒机制」没有方括号),而协作棒是严格的 `[协作]-[类别]-<具体>`。
|
||||
(「主控 · 唤醒机制」没有方括号),而执行棒是严格的 `[执行]-[类别]-<具体>`。
|
||||
两种写法都要能认出来 ⇒ 统一用"标题里出现该类别名"。
|
||||
⛔ 都不命中 ⇒ 空串(="这条会话没标类别" ⇒ 调用方按**默认类别**处理,⛔ 不猜)。
|
||||
"""
|
||||
@@ -5047,7 +5176,7 @@ def _scan_mains(st: dict, live=None) -> dict:
|
||||
🔴 2026-10-01 加参数 `live`(各网关口的活会话集合):**活着的「主控」排在不活的「主控」前面**
|
||||
(⛔ 传 None ⇒ 行为与旧版逐字一致,`selftest` 的离线用例不受影响)。
|
||||
|
||||
⛔ **排除全部"协作侧"命名**(🔴 2026-10-01 扩):`[协作]-…`(协作棒)、`[唤醒]-…`(唤醒会话)、
|
||||
⛔ **排除全部"协作侧"命名**(🔴 2026-10-01 扩):`[执行]-…`(执行棒)、`[唤醒]-…`(唤醒会话)、
|
||||
以及**接续会话**(`[<类别>] 接续 · …` / `接续棒:…`)—— 三者都是**干活的棒**,⛔ 不是主会话候选。
|
||||
⚠️ 旧版只排 `[协作]` ⇒ **接续会话会被收成主会话候选**(实测:`[唤醒机制] 接续 · …` 又被
|
||||
`_topic_in_title()` 认出类别 ⇒ 直接变成"该类别的主会话")⇒ 通知投给它自己、真主会话被架空。
|
||||
@@ -5381,7 +5510,20 @@ GAP_SUFFIX = {"worker": "承接队列"}
|
||||
def _gap_plan(role: str, topic: str, topics: list) -> dict:
|
||||
"""缺会话时**该怎么拉起** —— 现成参数(给会话照抄,⛔ 本函数不执行任何写)。"""
|
||||
tp = str(topic or "").strip() or (list(topics)[0] if topics else "")
|
||||
_zh = {"follow": "跟进", "worker": "协作", "waker": "唤醒"}[role]
|
||||
# 🔴🔴 2026-10-05 修**类别名自带方括号 ⇒ 拼出双层**(实测:vibe-product 的
|
||||
# `goal.json` 里 `topics` 写成 `["[暗色主题补抓]", …]`,套进下面的
|
||||
# `"[%s]-[%s]-%s"` ⇒ 拼出 `[执行]-[[暗色主题补抓]]-承接队列` ⇒ **形不合规**)。
|
||||
# 判据:类别名是「值」不是「名字」——模板负责加方括号,值里⛔ 不许自带。
|
||||
# ⇒ 归一化:**只剥一层**首尾方括号(⛔ 不 strip 所有,免得把 `[a][b]` 也吞了)。
|
||||
# ⚠️ 归一化**只作用于新建名的拼装**,⛔ 不回写 `goal.json`(那是用户的输入)。
|
||||
if tp.startswith("[") and tp.endswith("]") and len(tp) > 2:
|
||||
tp = tp[1:-1].strip()
|
||||
# 🔴🔴 2026-10-05 **用户定案:「协作 全部 改为执行」** —— 新建前缀一律 `[执行]`。
|
||||
# 旧 `[协作]`/`[协作目标]` **继续认**(见 `parse_session_name()` 的 role 映射表,
|
||||
# ⛔ 不许删:宿主库实测有 55 条会话 + 4 条活排期标题带 `[协作]`,删了它们全部解析成
|
||||
# `role=""` ⇒ 看板画不出、派活漏管,且不可逆)。
|
||||
# ⚠️ 本字典是**新建前缀的唯一发源地** —— 改这里即生效,别无第二处。
|
||||
_zh = {"follow": "跟进", "worker": "执行", "waker": "唤醒"}[role]
|
||||
_sch = time.strftime("%Y-%m-%dT%H:%M", time.localtime(time.time() + 90))
|
||||
if role in GAP_GLOBAL_ROLES: # 🔴 全局唯一席位 ⇒ 名字不带类别
|
||||
_nm = "[%s]-%s%s" % (_zh, GAP_SUFFIX[role], GAP_SINGLE_NOTE)
|
||||
@@ -5553,7 +5695,7 @@ def resolve_main(st: dict, live_sids=None) -> dict:
|
||||
|
||||
本函数把"主会话"从**登记出来的**改成**解析出来的**:
|
||||
① 登记为 main 且**此刻确实活着** ⇒ 认(登记有效)
|
||||
② 登记失效(不在活会话里)⇒ 按**工作区**解析:`cwd == 本工作区` 且**不是协作棒**(标题不带 `[协作]`)
|
||||
② 登记失效(不在活会话里)⇒ 按**工作区**解析:`cwd == 本工作区` 且**不是执行棒**(标题不带 `[执行]`)
|
||||
⇒ 取最近活动的那条(用户 2026-09-30:「以**一个工作区**为主会话的工作区」)
|
||||
③ 还是解析不出 ⇒ `sid=""` ⇒ **调用方⛔ 不许盲投**,必须明确报"找不到主会话"
|
||||
@returns `{sid, source, switched_from}`;`switched_from` 非空=**换了**(调用方须跟随+告警)
|
||||
@@ -5587,7 +5729,7 @@ def resolve_main(st: dict, live_sids=None) -> dict:
|
||||
# ⚠️ **不能加 `status='working'`**:主会话在两轮之间是空闲(不是 working)⇒ 加了就**永远漏掉它**。
|
||||
# ⚠️ **也不能按 `is_background_automation` 排**(2026-09-30 实测踩到):**接续会话本身就是自动化起的**
|
||||
# —— 连当前这位主会话 `fe146dd9` 都是 `is_background_automation=1` ⇒ 那么排会把**真主会话也排掉**。
|
||||
# 只按**显式标记**排除"棒":标题带 `[协作]`(那是本机制给协作棒定下的前缀,见 SKILL.md 命名)。
|
||||
# 只按**显式标记**排除"棒":标题带 `[协作]`(那是本机制给执行棒定下的前缀,见 SKILL.md 命名)。
|
||||
sc = _scan_mains(st, live) # 🔴 与 `resolve_mains()` **共用同一遍扫描**(⛔ 不各扫各的=防两边漂移)
|
||||
named, cand = sc["named"], sc["cand"]
|
||||
# 优先认**显式标了 `主控`** 的那条 —— 它比"最近活动"可信得多(用户自己标的,不是猜的)
|
||||
@@ -6132,6 +6274,10 @@ def main() -> int:
|
||||
# 🔴 2026-10-05:把「顺手登记主会话」的结果**说出来**(⛔ 不静默 —— 用户要能看到谁被记成了主会话)
|
||||
if _r.get("main_reg"):
|
||||
print("主会话登记:%s" % _r["main_reg"])
|
||||
# 🔴 2026-10-05:`execution_doc` 对齐结果也要**说出来**(⛔ 不静默 —— 静默对齐=
|
||||
# 用户以为登记值本来就是对的,下次再漂还是查不出原因)。见 P0-86。
|
||||
if _r.get("doc_sync"):
|
||||
print("状态文档登记:%s" % _r["doc_sync"])
|
||||
return 0
|
||||
if "--domain-status" in sys.argv:
|
||||
r = ds_domain_report()
|
||||
|
||||
@@ -96,6 +96,7 @@ except Exception:
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import sqlite3
|
||||
import subprocess
|
||||
@@ -132,15 +133,57 @@ def _resolve_ws() -> Path:
|
||||
|
||||
|
||||
WS = _resolve_ws()
|
||||
CFG = HERE / "collabd.config.json"
|
||||
|
||||
|
||||
def _resolve_cfg() -> Path:
|
||||
"""🔴🔴 部署配置解析(2026-10-06 修)—— ⛔ **不许写死成技能目录**。
|
||||
|
||||
**病根**:原为 `CFG = HERE / "collabd.config.json"`(`HERE` = **技能包目录**),
|
||||
而按定则「**技能就是技能、程序就是程序,谁用产生的文件放在他自己那里**」,
|
||||
**技能目录里⛔ 不放生产配置** ⇒ 该文件恒不存在 ⇒ `_load(CFG,{})` 恒返回 `{}`。
|
||||
|
||||
**症状(不报错、不崩溃,只是判定那一路永远读不到)**:
|
||||
· `taskgraph` 取默认 `INBOX/taskgraph.json` ⇒ 目标换了图没换时**照旧踩**
|
||||
(vibe-product 2026-10-06 实测:配置里已改指 proto-board,而 `goals_open()`
|
||||
仍报 `bad=['任务图读不到(taskgraph.json)']` —— 即**改了配置不生效**);
|
||||
· 其余读 `CFG` 的命令(`wake_enable`/`targets` / 写回配置)**全部作用在一个不存在的文件上**。
|
||||
|
||||
✅ 与 `collabd._cfg_candidates()` **同款三级**(⛔ 不新造顺序,否则又是一处口径分叉):
|
||||
① 环境变量 `COLLABD_CONFIG`(钩子/启动器显式指定,首选)
|
||||
② `<工作区>/.workbuddy/collab/collabd.config.json`(使用方**标准落点**)
|
||||
③ 都不在 ⇒ 回落到旧的 `HERE/collabd.config.json` **并打醒目告警**(⛔ 不静默)。
|
||||
"""
|
||||
_env = os.environ.get("COLLABD_CONFIG")
|
||||
if _env and Path(_env).is_file():
|
||||
return Path(_env)
|
||||
_std = WS / ".workbuddy" / "collab" / "collabd.config.json"
|
||||
if _std.is_file():
|
||||
return _std
|
||||
_legacy = HERE / "collabd.config.json"
|
||||
sys.stderr.write(
|
||||
"⚠️ goalctl:没找到部署配置(`COLLABD_CONFIG` 未设、`%s` 不存在)"
|
||||
"⇒ 回落到 `%s`\n" % (_std, _legacy))
|
||||
return _legacy
|
||||
|
||||
|
||||
CFG = _resolve_cfg()
|
||||
INBOX = WS / "tmp" / "supervise-inbox"
|
||||
GOAL, TASKS = INBOX / "goal.json", INBOX / "tasks.json"
|
||||
# 🔴🔴 2026-10-05 **历史目标归档目录**(用户口径「一个工作区 历史的旧的目标会有很多个」)。
|
||||
# 判据:`goal.json` = **唯一当前目标**(同时只有一个);换目标时**旧的必须移到这里**,
|
||||
# ⛔ 不许原地覆盖丢掉(本目录此前**代码零实现** —— 那份 `机制自检-20261004.json`
|
||||
# 是手工造的 ⇒ 换目标 = 旧目标消失,只留用户手写的 `_前身` 一行字)。
|
||||
GOALS_DIR = INBOX / "goals"
|
||||
STOPPER = HERE / "stop-collab.py"
|
||||
HOSTDB = Path(os.environ.get("CODEBUDDY_CONFIG_DIR") or os.path.expanduser("~/.workbuddy")) / "workbuddy.db"
|
||||
BAK = WS / ".workbuddy" / "collab" / ("bak-goalctl-%s" % time.strftime("%Y%m%d"))
|
||||
|
||||
ARGS = [a for a in sys.argv[1:] if not a.startswith("-")]
|
||||
DO = "--yes" in sys.argv
|
||||
# 🔴🔴 2026-10-05 **「用户已确认切换目标」的显式开关**(用户口径:换目标**需要用户确认**)。
|
||||
# 判据:`--switch-goal` = **人**说"同意放弃当前目标";`--yes` = 只是"干跑转真写"。
|
||||
# ⛔ **两者不许合并** —— 合并就等于把"确认"降级成"顺手加个 yes",闸门失效。
|
||||
SWITCH_OK = "--switch-goal" in sys.argv
|
||||
CMD = (ARGS[0] if ARGS else "status")
|
||||
NAME = (ARGS[1] if len(ARGS) > 1 else "")
|
||||
|
||||
@@ -223,6 +266,69 @@ def read_automations() -> list:
|
||||
return out
|
||||
|
||||
|
||||
def _acc_is_pass(v) -> bool:
|
||||
"""🔴 一条验收判据「算不算过」—— 🔴 **本文件唯一实现,且⛔ 不自带词表**。
|
||||
|
||||
🔴🔴 **2026-10-06 立**(vibe-product 实测踩到,属"判据分叉"第 N 次复发):
|
||||
|
||||
**病根**:`board.py::acc_is_pass` 自称「**全库唯一实现**」,并在注释里点明
|
||||
三处判据(`board.py` / `collabd.py::_acc_is_pass` / `board.html::accIsPass`)
|
||||
**必须同款**。🔴 **实际是四处** —— 本文件 `goals_open()` 里那句
|
||||
`if str(acc[k]) != "pass":` **被漏掉了**,既不归一化、也不复用真身。
|
||||
|
||||
**症状(不报错、不崩溃,只少说一句真话)**:本机制的存值**真源就是中文**
|
||||
(`过(PID 3552 …)`/`已过|…`/`达(实测 7 个…)`)⇒ 死板比 `"pass"` ⇒
|
||||
**已通过的验收被全部计入 why** ⇒ 恒判「未完成」。
|
||||
实测(vibe-product 2026-10-06):四条验收**全过**(`V1=pass` 已归一化存值),
|
||||
而 `goalctl.py status` 仍会因历史中文值报「未完成」;用户原话
|
||||
「**为什么完成了还说没完成**」。⇒ 与 `collabd._acc_is_pass()` 的沿革逐字同族。
|
||||
|
||||
**正解=复用真身,⛔ 不复制词表**(复制就是下一次漂的来源):
|
||||
首选 `collabd._acc_is_pass()`(它自己又首选 `board.acc_is_pass()`);
|
||||
拿不到(副本被单独拷走 / 循环导入)⇒ **fail-closed 声明式兜底**:
|
||||
用与 `board.py::ACC_PASS_WORDS` **同款**的白名单判,并在 stderr 留一行。
|
||||
⛔ **绝不静默返回 True/False** —— 那会把"代码没同步"伪装成"判据没过/过了"。
|
||||
"""
|
||||
try:
|
||||
import sys as _sys
|
||||
_sd = str(HERE)
|
||||
if _sd not in _sys.path:
|
||||
_sys.path.insert(0, _sd)
|
||||
import collabd as _cd # ⚠️ 同目录 ⇒ 无副作用
|
||||
return bool(_cd._acc_is_pass(v))
|
||||
except Exception as _e:
|
||||
try:
|
||||
sys.stderr.write("⚠️ goalctl._acc_is_pass: 拿不到 collabd(%s)⇒ 走兜底白名单\n" % _e)
|
||||
except Exception:
|
||||
pass
|
||||
# ── fail-safe 兜底:与 `board.py::ACC_PASS_WORDS` / `ACC_ADV_PREFIX` **同款**
|
||||
# (⛔ 改了那边必须改这里 —— 两处都列出来,免得只改词表漏掉副词)
|
||||
s = str(v or "").strip()
|
||||
if not s:
|
||||
return False # 空 ⇒ 判不出来 ⇒ ⛔ 不算过(fail-closed)
|
||||
_head = re.split(r"[((]", s, maxsplit=1)[0] # 丢掉括注说明
|
||||
for _seg in re.split(r"[::]", _head): # ⛔ 逐段找,⛔ 不"只取最后一段"
|
||||
_t = _seg.strip().lstrip("*").strip().lower()
|
||||
for _w in ("pass", "过", "通过", "达", "达标", "合格", "完成"):
|
||||
if _t.startswith(_w):
|
||||
return True
|
||||
# 🔴 剥"完成副词"再比(与 `board.py::acc_is_pass` 同款)。
|
||||
# ⚠️ 否定副词(未/不/没/非/待)**不在**表里 ⇒ 剥不动 ⇒ 仍 fail-closed。
|
||||
_t2 = _t
|
||||
for _ in range(3):
|
||||
_cut = ""
|
||||
for _a in ("已", "已经", "均", "都", "经复核", "复核后"):
|
||||
if _t2.startswith(_a) and len(_a) > len(_cut):
|
||||
_cut = _a # 取**最长**匹配(`已经` 优先于 `已`)
|
||||
if not _cut:
|
||||
break
|
||||
_t2 = _t2[len(_cut):].strip().lstrip("*").strip()
|
||||
for _w in ("pass", "过", "通过", "达", "达标", "合格", "完成"):
|
||||
if _t2.startswith(_w):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def goals_open() -> dict:
|
||||
"""目标是否仍未完成 —— 三路并集(与 `collabd.goals_open()` 同判据,⛔ 不缩水)。
|
||||
|
||||
@@ -249,7 +355,10 @@ def goals_open() -> dict:
|
||||
acc = (read_goal().get("acceptance_state") or {})
|
||||
eff = [k for k in acc if not str(k).startswith("_")] # 🔴 **有效项**=键不以 `_` 开头
|
||||
for k in eff:
|
||||
if str(acc[k]) != "pass":
|
||||
# 🔴🔴 2026-10-06 **改走真身**(原为 `str(acc[k]) != "pass"`,死板比字面值)——
|
||||
# 存值真源是**中文**(`过(…)`/`已过|…`/`达(…)`)⇒ 死板比 ⇒ 已通过的
|
||||
# 验收被计入 why ⇒ 恒判「未完成」。详见 `_acc_is_pass()` 的 docstring。
|
||||
if not _acc_is_pass(acc[k]):
|
||||
why.append("验收 %s=%s" % (k, acc[k]))
|
||||
if not eff:
|
||||
# 🔴 2026-09-30 修(**"读不到"被当成了"全过"**):判据是"**有没有有效项**",
|
||||
@@ -464,7 +573,7 @@ def declare() -> int:
|
||||
⇒ 本命令是"说明"的**唯一落点**;⛔ 技能侧不许预设类别、⛔ 不许从文件名推。
|
||||
|
||||
用法:
|
||||
python goalctl.py declare --title "…" [--why "…"] [--topics "A,B"] [--kpi "V1=pass;V2=未过"]
|
||||
python goalctl.py declare --title "…" [--why "…"] [--topics "A,B"] [--kpi "V1=过;V2=未过"]
|
||||
python goalctl.py declare --title "…" --topics "" # 显式清空类别(回到单类别回落)
|
||||
⛔ 默认 **dry-run**;真写加 `--yes`(与 stop/start 同一个语义开关)。
|
||||
|
||||
@@ -486,7 +595,14 @@ def declare() -> int:
|
||||
· `--topics` 省略 ⇒ **不动**(⛔ 不拿旧值凑数);给了 ⇒ 整表替换,并记 `topics_declared_at/by`;
|
||||
· `--kpi` 省略 ⇒ **不动** `acceptance_state`;给了 ⇒ 整表重写,且**会点明丢掉了哪些说明行**。
|
||||
"""
|
||||
title = _opt("title") or NAME
|
||||
# ⚠️ **没给 `--title` ⇒ 保持现有标题**(⛔ 不回落成 `NAME`=工作区名)。
|
||||
# 🔴 2026-10-05 实测踩到:旧写法 `_opt("title") or NAME` 在"只想补 `--why`/`--kpi`"
|
||||
# (即**同一目标在细化**,标题根本没打算动)时,会把标题**悄悄改成工作区名**
|
||||
# ⇒ 触发换目标闸 ⇒ **把一个纯细化动作拦成"未确认换目标"**(放大成事故)。
|
||||
# 判据:`--title` 是**显式动作**;没给就是"不动它"(与 `--topics`/`--kpi` 同一规矩)。
|
||||
# ⚠️ 只有**一个目标都没有**(首次声明)时,才允许回落到 `NAME`。
|
||||
_t_given = _opt("title", _SURE)
|
||||
title = (_t_given if _t_given is not _SURE else "") or str(read_goal().get("title") or "") or NAME
|
||||
why = _opt("why", _SURE)
|
||||
topics = _opt("topics", _SURE)
|
||||
kpi = _opt("kpi", _SURE)
|
||||
@@ -498,10 +614,67 @@ def declare() -> int:
|
||||
return 2
|
||||
|
||||
g = read_goal()
|
||||
print(" 现有目标:%s" % (g.get("title") or "(无)"))
|
||||
old_title = str(g.get("title") or "")
|
||||
print(" 现有目标:%s" % (old_title or "(无)"))
|
||||
print(" 目标改为:%s" % title)
|
||||
|
||||
# 🔴🔴🔴 2026-10-05 **换目标的确认闸 + 旧目标自动归档**(用户口径,逐字:
|
||||
# 「**一个工作区 同时只能执行一个目标,如果要切换目标,需要用户确认,
|
||||
# 然后切换和关注检查切换后的目标**」)。
|
||||
# 🔴 **改前取证**:`declare` **直接覆盖 `title`**、**从不归档旧目标**
|
||||
# (全文搜 `goals/` 零命中)⇒ 换目标 = 旧目标当场消失,只剩人手写的 `_前身` 一行。
|
||||
# ⇒ 补两道:
|
||||
# ① **确认闸**:标题变了 ⇒ **默认拒绝**,必须带 `--switch-goal` 明示"用户已确认切换"
|
||||
# (⛔ 与 `--yes` 分开:`--yes` 是"干跑转真写",⛔ 不是"用户同意换目标")。
|
||||
# ② **归档**:确认后把旧 `goal.json` **整份**移到 `goals/<旧标题>__<时间>.json`,
|
||||
# 并在新目标里留 `_前身目标` 指向归档件(⛔ 不靠人手写)。
|
||||
# ⚠️ 只改措辞/补 why/补 kpi(标题**没变**)⇒ ⛔ **不触发本闸**(那是同一目标在细化)。
|
||||
_is_switch = bool(old_title) and old_title != title
|
||||
if _is_switch and not SWITCH_OK:
|
||||
print("\n⛔ **这是「切换目标」,需要用户确认 —— 已拒绝写入。**")
|
||||
print(" 当前目标:%s" % old_title)
|
||||
print(" 拟换为 :%s" % title)
|
||||
print()
|
||||
print(" 用户口径(2026-10-05 定案,逐字):")
|
||||
print(" 「一个工作区 同时只能执行一个目标,如果要切换目标,**需要用户确认**,")
|
||||
print(" 然后切换和关注检查切换后的目标」")
|
||||
print()
|
||||
print(" ⇒ 请**先向用户确认**「要放弃当前目标、换成新的吗」;")
|
||||
print(" 得到确认后,在原命令上补 `--switch-goal` 重跑:")
|
||||
print(" python goalctl.py declare --title \"%s\" --switch-goal --yes" % title[:40])
|
||||
print(" ⚠️ `--switch-goal` ⛔ **不等于** `--yes` —— 前者是「用户同意换目标」,")
|
||||
print(" 后者只是「干跑转真写」。⛔ 不确认就带上它 = 伪造用户授权。")
|
||||
print("\n⇒ 本轮**一个字节都没写**。")
|
||||
return 3
|
||||
|
||||
if _is_switch:
|
||||
print(" 🔴 换目标已确认(`--switch-goal`)⇒ 旧目标将**整份归档**到 `%s`" % GOALS_DIR)
|
||||
new = dict(g)
|
||||
new["title"] = title
|
||||
# 🔴🔴🔴 2026-10-05 **换目标 ⇒ 自动把 `lifecycle` 复位成「进行中」**(用户定案:
|
||||
# 「一个工作区 同时只能执行一个目标,如果要切换目标,需要用户确认,**然后切换和关注检查
|
||||
# 切换后的目标**」)。
|
||||
# **改前事实**:`declare` **从不碰 `lifecycle`**(全文只在上文末尾打一句
|
||||
# 「别忘了同步 `lifecycle`(⛔ `declare` 不碰它)」让人手补)。
|
||||
# **为什么必须自动做**(不是"顺手"):
|
||||
# ① 旧目标做完了 ⇒ `lifecycle` 停在「已完成」⇒ 换到新目标后**机制照旧不拉**
|
||||
# (判据见 `collabd.py`:`life != 进行中` 时只在"队列还有非 done 件"才拉)
|
||||
# ⇒ **新目标没人检查** —— 与用户"切换后要关注检查"的要求**方向相反**。
|
||||
# ② 靠人手补=**又一条"要记得"的待办**(同族:用户 2026-10-05 原话「最好是记录会话,
|
||||
# 这样就不用每次会话都去做声明了」——凡"要人记"的都会忘)。
|
||||
# ⚠️ **不是破坏性动作**:旧 `goal.json` **整份归档**(下面紧跟的归档步),旧值可回查;
|
||||
# 且只在**已确认换目标**(`--switch-goal`)时做,⛔ 纯细化(只改措辞/补 why/kpi)不触发。
|
||||
# ⚠️ **只动 `lifecycle` 三件套**,⛔ 不碰 `acceptance_state`(那是上一条目标的判据,
|
||||
# 另有静默陷阱告警处理;清它会毁证据 —— 见下面那段 `--kpi` 警告)。
|
||||
if _is_switch:
|
||||
_old_life = str(g.get("lifecycle") or "").strip()
|
||||
new["lifecycle"] = "进行中"
|
||||
new["lifecycle_at"] = time.strftime("%Y-%m-%dT%H:%M:%S")
|
||||
new["lifecycle_by"] = "goalctl declare --switch-goal(换目标自动复位)"
|
||||
new["lifecycle_why"] = ("换目标 ⇒ 复位(改前=%s);旧目标整份归档,可在归档件里回查"
|
||||
% (_old_life or "未声明"))
|
||||
print(" 🔴 `lifecycle` 自动复位:%s ⇒ 进行中" % (_old_life or "(未声明)"))
|
||||
print(" 理由:不复位 ⇒ 新目标**不被检查**(机制只在『进行中』时才拉检查会话)")
|
||||
# 🔴 2026-10-01 加(**静默陷阱告警**):换目标名却不给 `--kpi` ⇒ 上一条目标的验收判据
|
||||
# 会**原样留着** ⇒ 新目标可能**一声明就被判「全过」**、机制完全不排队不投递(实测踩到:
|
||||
# 新目标零件零判据,常驻程序却报「目标三路全过」,digest 写「没有可推进的活」)。
|
||||
@@ -512,7 +685,7 @@ def declare() -> int:
|
||||
print(" 🔴 **注意(静默陷阱)**:你没给 `--kpi` ⇒ 上一条目标的验收判据会**原样留下** ——")
|
||||
print(" 现存判据:%s" % "、".join(_oldacc))
|
||||
print(" ⚠️ 新目标可能**一声明就被判『全过』**、机制不排队不投递(判据不绑目标)。")
|
||||
print(" 要换判据:加 `--kpi \"V1=pass;V2=未过\"`(留空表也请显式给 `--kpi \"\"`)。")
|
||||
print(" 要换判据:加 `--kpi \"V1=过;V2=未过\"`(留空表也请显式给 `--kpi \"\"`)。")
|
||||
if why is not _SURE:
|
||||
new["why"] = why
|
||||
print(" 为什么 :%s" % (why or "(清空)"))
|
||||
@@ -528,7 +701,9 @@ def declare() -> int:
|
||||
acc, dropped = {}, []
|
||||
for seg in [x for x in str(kpi).replace(";", ";").split(";") if x.strip()]:
|
||||
if "=" not in seg:
|
||||
print(" ⚠️ 判据 `%s` 没有 `=` ⇒ 跳过(要 `名字=pass` 或 `名字=未过`)" % seg.strip())
|
||||
print(" ⚠️ 判据 `%s` 没有 `=` ⇒ 跳过(要 `名字=值`;值可写 `pass`,"
|
||||
"也可写中文 `过`/`已过`/`达` —— 判定层会归一化,⛔ 不是只认 `pass`)"
|
||||
% seg.strip())
|
||||
continue
|
||||
k, v = seg.split("=", 1)
|
||||
acc[k.strip()] = v.strip()
|
||||
@@ -543,12 +718,42 @@ def declare() -> int:
|
||||
|
||||
if not DO:
|
||||
print("\n⇒ 这是干跑(⛔ 一个字节都没写)。要真写:末尾加 `--yes`")
|
||||
if _is_switch:
|
||||
print(" ⚠️ 本命令是**换目标** ⇒ 真写时还需要 `--switch-goal`(确认闸已过则不必再加)")
|
||||
return 0
|
||||
|
||||
# 🔴🔴 2026-10-05 **旧目标整份归档**(换目标时;⛔ 不许原地覆盖丢掉)。
|
||||
# 落点:`tmp/supervise-inbox/goals/<旧标题≤40字>__<YYYYMMDD-HHMM>.json`
|
||||
# ⛔ 文件名净化:去掉 `/ \ : * ? " < > |`(Windows 非法字符)+ 折叠空白。
|
||||
if _is_switch:
|
||||
try:
|
||||
GOALS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
_safe = re.sub(r'[\\/:*?"<>|\r\n\t]+', "-", old_title).strip()[:40] or "untitled"
|
||||
_dst = GOALS_DIR / ("%s__%s.json" % (_safe, time.strftime("%Y%m%d-%H%M")))
|
||||
_n = 1
|
||||
while _dst.exists():
|
||||
_dst = GOALS_DIR / ("%s__%s_%d.json" % (_safe, time.strftime("%Y%m%d-%H%M"), _n))
|
||||
_n += 1
|
||||
_dst.write_text(json.dumps(g, ensure_ascii=False, indent=2) + "\n",
|
||||
encoding="utf-8", newline="\n")
|
||||
new["_前身目标"] = ("%s(已于 %s 换目标时归档至 goals/%s)"
|
||||
% (old_title, time.strftime("%Y-%m-%dT%H:%M"), _dst.name))
|
||||
print(" ✅ 旧目标已归档:goals/%s(%d 字节)" % (_dst.name, _dst.stat().st_size))
|
||||
except Exception as e:
|
||||
# ⚠️ 归档失败 ⇒ **不阻断换目标**(用户已确认),但必须**喊出来**(⛔ 不许静默)
|
||||
print(" 🔴 **旧目标归档失败**:%s" % e)
|
||||
print(" ⚠️ 旧目标**未被保存** —— 若还要它,请先手工备份 `%s`" % GOAL)
|
||||
new["declared_at"] = time.strftime("%Y-%m-%dT%H:%M")
|
||||
new["declared_by"] = by
|
||||
_write_json(GOAL, new)
|
||||
print("\n⇒ 已登记(落点 `%s`)。" % GOAL)
|
||||
print(" 🔴 目标**不是固定的**:下次调用技能时在对话里再说一遍 ⇒ 用 `declare` 覆盖即可。")
|
||||
if _is_switch:
|
||||
print(" 🔴 **切换完成** —— 从现在起,机制**关注和检查的是新目标**:%s" % title[:60])
|
||||
print(" ✅ `lifecycle` 已**自动复位为「进行中」**(2026-10-05 起 `declare` 自己管它,⛔ 不用手补)")
|
||||
print(" ⚠️ 若新目标其实**还没开工**,再手动改成别的:")
|
||||
print(" python collabd.py --set-life 等待 --by \"主会话\"")
|
||||
else:
|
||||
print(" 🔴 目标**不是固定的**:下次调用技能时在对话里再说一遍 ⇒ 用 `declare` 覆盖即可。")
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""decision-rules-hook.py —— `SessionStart` 钩子:**把决策判据常驻注入每一次对话**。
|
||||
|
||||
## 🔴 为什么要有这个脚本(2026-10-06 用户明令)
|
||||
|
||||
用户原话:
|
||||
|
||||
> 「**决策方法 必须想办法 加载到每次对话中**」
|
||||
> 「要把这个动作 加到 会话技能配置环境的时候一并处理」
|
||||
|
||||
**改前的缺口(实测取证)**:
|
||||
|
||||
· 决策判据(`U1–U28` / `A1–A25`)原本只住在 `skills/dsh-decision/references/` 里;
|
||||
· `dsh-decision` 是**独立技能包**,**⛔ 不在** `session-mechanism` 的安装面里;
|
||||
· `session-mechanism/install.py` 的声明表**明确写了** `⛔ decision_bridge 不在此表内`
|
||||
⇒ **"配置环境"跑一遍,决策判据一个字节都不会进会话**;
|
||||
· 结果:判据**写在文件里**,但 **AI 不会在正确的时机取用它**
|
||||
(2026-09-16 实测:用户点名「参考决策方法」后,AI 全程 `Skill` 调用 **0 次**)。
|
||||
⇒ 一句话:**能力齐了,缺一根接线。** 本脚本就是那根接线。
|
||||
|
||||
**本次一并补齐的两件事(2026-10-06)**:
|
||||
|
||||
① **接线**:本脚本 + `install.py` 声明表 + `OWN_BASENAMES` ⇒ 跑一次「配置环境」就带上;
|
||||
② **判据实体入包**:本包 `SKILL.md:395` 既定口径是
|
||||
「**判据实体就在本档正文里 ⇒ 只复制这一个技能到别的机器,这些功能全部可用,
|
||||
⛔ 不依赖任何其他技能**」,但 10-04 那次**只搬了 `01-功能优先协作协议`(问不问)**,
|
||||
**决策方法论(U27/A6 那套)从未搬入**(证据:本包正文搜 `U27`/`A6` **零命中**)。
|
||||
⇒ 本次逐字搬入 `references/04-决策方法论.md`(+ `dsh-decision-method/` 三份素材库),
|
||||
本脚本的指针**指向包内**,⛔ 不再指向 `dsh-decision`。
|
||||
|
||||
## 为什么挂 `SessionStart` 而不是 `UserPromptSubmit`
|
||||
|
||||
· `SessionStart` **每开一个会话注入一次** ⇒ 规矩"从会话开头就在",
|
||||
且**每轮不重复注入**(不白烧 token);
|
||||
· `UserPromptSubmit` 每轮都跑 ⇒ 同一段文字被灌 N 遍,开销大且会稀释注意力。
|
||||
· ⚠️ 与既有 `decision_bridge.py`(也挂 SessionStart)**职责不重叠**:
|
||||
那个注入的是**《提问规范》骨架**("要问的时候怎么写");
|
||||
本脚本注入的是**决策判据**("怎么想、怎么定、什么不许")。
|
||||
⇒ 两者**互补**,⛔ 不合并(合并会让任一方改判据时动到另一方)。
|
||||
|
||||
## 注入什么(用户 2026-10-06 拍板:**判据式精简版**)
|
||||
|
||||
⛔ 不注入全文(那是几百 KB);✅ 只注入**能被违反、且违反就是事故**的硬判据,
|
||||
按"**一句话能判**"重写。完整判据仍以 `dsh-decision/references/` 为正本,本文只放指针。
|
||||
|
||||
## 三条不可破的性质(同 `decision_bridge.py` 的既定口径)
|
||||
|
||||
1. **不介入会话** —— 只返回 `additionalContext`;⛔ 不返回 `permissionDecision`、
|
||||
⛔ 不改 `updatedInput`、⛔ 不返回 `continue:false`。
|
||||
2. **不拖慢会话** —— **纯本地字符串**,不读文件、不调模型、不起进程。
|
||||
3. **fail-open** —— 任何异常都返回空(⛔ 不抛)。钩子失败 ⇔ 用户的话被吞,是最坏结果。
|
||||
⚠️ 但**留痕**:没留痕时"钩子没被调用"与"调用了但静默失败"无法区分。
|
||||
|
||||
## ⚠️ 改本文件的头号坑(2026-10-06 实际踩到)
|
||||
|
||||
`RULES` 是**用半角双引号包起来的中文长串**。正文里的**引号必须用全角 `“` `”`**,
|
||||
⛔ **绝不能写半角 `"`** —— 写半角会把字符串**提前闭合**,后面剩余的中文
|
||||
就变成了"串外的裸标识符",直接 `SyntaxError`(实测报
|
||||
`invalid character '/' (U+FF0F)`),**整个钩子加载失败**。
|
||||
⇒ 改完**必须**跑一次:`python -c "import ast;ast.parse(open(p,encoding='utf-8').read())"`。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
# ── 留痕(钩子唯一的取证出口)──
|
||||
_LOG = Path(__file__).resolve().parent.parent.parent / "logs" / "_decision-rules-hook.log"
|
||||
|
||||
|
||||
def _log(line: str) -> None:
|
||||
try:
|
||||
_LOG.parent.mkdir(parents=True, exist_ok=True)
|
||||
with io.open(_LOG, "a", encoding="utf-8", newline="\n") as f:
|
||||
f.write("[%s] %s\n" % (time.strftime("%Y-%m-%d %H:%M:%S"), line))
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
|
||||
def _emit(obj: dict) -> None:
|
||||
"""🔴 必须走 buffer 显式 UTF-8 —— 正文含中文与 `⛔`,文本模式在 cp936 下会炸。"""
|
||||
try:
|
||||
sys.stdout.buffer.write(json.dumps(obj, ensure_ascii=False).encode("utf-8"))
|
||||
sys.stdout.buffer.flush()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
|
||||
def _read_stdin_text() -> str:
|
||||
try:
|
||||
raw = sys.stdin.buffer.read()
|
||||
except Exception: # noqa: BLE001
|
||||
return ""
|
||||
return raw.decode("utf-8", errors="replace")
|
||||
|
||||
|
||||
# ══════════════════════════════════════════════════════════════════════════
|
||||
# 注入正文 —— **判据式精简版**
|
||||
#
|
||||
# 🔴 编写口径(⛔ 改动前先读):
|
||||
# · 每条都必须"**能被违反**" —— 那种"本来就会做"的常识不要写(写了只会稀释注意力);
|
||||
# · 每条都带**可判的触发条件**("遇到 X 时"),⛔ 不写成抽象口号;
|
||||
# · 编号 `U27`/`A6` 是**正本的编号**,保留它 ⇒ 便于回查原文与引用;
|
||||
# · 全文以 `dsh-decision/references/` 为正本,本文**只放浓缩版 + 指针**;
|
||||
# · 🔴 **正文内引号一律用全角 `“` `”`**(半角会闭合字符串 ⇒ SyntaxError,见文件头)。
|
||||
# ══════════════════════════════════════════════════════════════════════════
|
||||
RULES = (
|
||||
"【决策判据 · 常驻(违反即事故;正本 references/04-决策方法论.md)】\n"
|
||||
"遇到“要不要做/怎么做/能不能先凑合”这类判断,先过下面这几条:\n"
|
||||
"\n"
|
||||
"🔴 **目标不打折,路径取最小代价**(U27 + A6)\n"
|
||||
" 发现问题默认目标是**解决**。下面三种**都不算解决**,⛔ 不许当成交付:\n"
|
||||
" ① **降级目标**(把“要做到 A”悄悄改成“做到 A′ 也行”)\n"
|
||||
" ② **延期**(“下次顺手再说 / 等窗口再补”)\n"
|
||||
" ③ **静默兜底**(“先这样也能跑”,而风险与触发条件一个字没写)\n"
|
||||
" ⚠️ 唯一允许“暂时接受”=**客观不可逾越**,且必须写清三项:\n"
|
||||
" 卡在哪(证据)/当前已做到哪一步/什么条件一出现就必须回头解决。\n"
|
||||
" ⚠️ A6 的“最小代价”只约束**路径**,⛔ 不许读成“可以降低目标”。\n"
|
||||
"\n"
|
||||
"🔴 **先取证,再结论**(A1)\n"
|
||||
" ⛔ 不许拿文档/记忆/推断当既成事实。层级:`L1 文档说` < `L2 文件在` < `L3 本机实测`\n"
|
||||
" < `L4 真机/生产` < `L5 用户原话`;**冲突以高层级为准**,⛔ 不许用低层级否定高层级。\n"
|
||||
" ⛔ **不许把“我猜的”写成“事实”** —— 尤其“疑似是我刚才改坏的”:先 diff 备份再说。\n"
|
||||
"\n"
|
||||
"🔴 **不懂就问,但先自己查到位**(A2/A3 + 功能优先协议)\n"
|
||||
" · **技术实现**(框架/库/文件组织/命名/测试/性能/部署/目录结构/错误处理)\n"
|
||||
" ⇒ **自己定**,作为陈述句写进回复(“我选了什么,可推翻”),⛔ 不做成选项让用户选。\n"
|
||||
" · **只准提报用户三类**:① 功能语义分叉(用户能感知的差别)② 红线门禁\n"
|
||||
" ③ 超出决策方法边界。**必须问** ≠ 把技术方案捆进去问。\n"
|
||||
" · 提报时把技术话翻成**功能话**(“影响谁、断多久、花多少钱”),\n"
|
||||
" ⛔ 不写包名/环境变量/文件路径/代码标识符。一轮只问一个问题。\n"
|
||||
"\n"
|
||||
"🔴 **删/改/迁移先判代价对称性**(A8/A14/A16/A22)\n"
|
||||
" 删除收益 < 潜在破坏 ⇒ **标注废弃保留**,⛔ 不删。改名/迁移先列**伴随物清单**\n"
|
||||
" (只改主体必留隐患)。替换/退役:**先补位,再退役**。\n"
|
||||
" ⛔ 删任何东西之前先扫引用(“看起来像资料” ≠ “没被引用”)。\n"
|
||||
"\n"
|
||||
"🔴 **本机改完 ≠ 交付**(A25/A19/A18)\n"
|
||||
" 宣布完成前先画出**改动层 → 生效链路**:改了哪个文件、谁读它、什么时候生效。\n"
|
||||
" ⛔ **静默失败会伪造结论**(工具静默 + 降级静默,两头都要防)。\n"
|
||||
" ⛔ **只看回显不算验收** —— 落到**进程级/文件级证据**(pid / argv0 / 心跳 / 增量字节)。\n"
|
||||
"\n"
|
||||
"🔴 **不确定就说不确定**(A11/A15/A10)\n"
|
||||
" 未验证的能力显式抛 `unsupported`,⛔ 不假装支持;写状态必须带**三态 + 级别**,\n"
|
||||
" ⛔ 不用“支持/可用”描述没验过的项;失败面**必须留证据**,⛔ 不吞错误。\n"
|
||||
"\n"
|
||||
"📂 完整判据(U1–U28 用户决策 · A1–A25 AI 推理 · X1–X13 反例)\n"
|
||||
" ⇒ 本技能包内 `references/04-决策方法论.md`(+ `dsh-decision-method/` 三份素材库);\n"
|
||||
" 决策与“问不问”的完整裁决 ⇒ 本包 `references/02-功能优先协作协议.md`。\n"
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
raw = _read_stdin_text()
|
||||
try:
|
||||
payload = json.loads(raw) if raw.strip() else {}
|
||||
except Exception: # noqa: BLE001
|
||||
payload = {}
|
||||
if not isinstance(payload, dict):
|
||||
payload = {}
|
||||
|
||||
event = str(payload.get("hook_event_name") or payload.get("hookEventName") or "")
|
||||
session_id = str(payload.get("session_id") or "")
|
||||
|
||||
# 🔴 只认 SessionStart。别的档位走到这里 ⇒ 直接合法地什么都不做
|
||||
# (本脚本会被挂到哪个档由 `install.py` 的声明表决定;⛔ 不在这里猜)。
|
||||
if event != "SessionStart":
|
||||
_log("skip event=%s(本钩子只处理 SessionStart)" % (event or "-"))
|
||||
return 0
|
||||
|
||||
_log("inject session_id=%s len=%d" % (session_id or "-", len(RULES)))
|
||||
_emit({"hookSpecificOutput": {
|
||||
"hookEventName": "SessionStart",
|
||||
"additionalContext": RULES,
|
||||
}})
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
sys.exit(main())
|
||||
except Exception as e: # noqa: BLE001
|
||||
# 🔴 fail-open:钩子炸了 ⛔ 不许影响会话(但必须留痕)。
|
||||
try:
|
||||
_log("FATAL %r" % (e,))
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
sys.exit(0)
|
||||
@@ -0,0 +1,218 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""**UserPromptSubmit 多守卫合并入口**(2026-10-06 用户报「三个 hook 同时 10s 超时」)。
|
||||
|
||||
## 为什么要合并(实测根因,⛔ 不是"脚本慢")
|
||||
|
||||
单一 `UserPromptSubmit` 上串行挂了 **7 条 hook**,每条都是**一个独立的 Python 进程**
|
||||
(`python.exe <某 hook>.py`)⇒ 宿主要**冷启 7 次解释器**,每次 0.3–1 s
|
||||
(Windows 上首次 import 开销尤其大,`datetime`/`json`/`re` 全都要重新加载)。
|
||||
|
||||
实测(本机,同一份真实 190 MB transcript 负载):
|
||||
|
||||
| 跑法 | 耗时 |
|
||||
|---|---|
|
||||
| 任一条 guard 单跑 | 0.23–0.36 s |
|
||||
| 7 条**并发**(多进程) | 0.84 s(墙钟) |
|
||||
| ⚠️ 宿主里**串行** 7 条 | 累计轻松顶破 10 s ⇒ **4 条 `timeout=10` 的一起报超时** |
|
||||
|
||||
⇒ 结论:**单条都不慢,死在"7 次冷启动 + 串行排队"**。
|
||||
|
||||
## 本入口做什么
|
||||
|
||||
把**同一事件、同一 stdin 契约**的 4 个 guard 合进**一个进程**:
|
||||
`reply-style-guard` / `stop-dialog-guard` / `skill-load-guard` / `session-log-guard`
|
||||
|
||||
一次读 stdin ⇒ 依次在**本进程内**调各自的 `main()` ⇒ 合并 stdout ⇒ 一次输出。
|
||||
**省掉 3 次 Python 冷启动**(4 条 → 1 条)。
|
||||
|
||||
## 契约(必须与各 guard 保持一致)
|
||||
|
||||
- 各自都从 `sys.stdin.buffer.read()` 读**同一份** payload(事件名都是 `UserPromptSubmit`)
|
||||
- 各自都往 `sys.stdout` 写**一个 JSON**(可能空 ⇒ 表示放行)
|
||||
- 合并规则:把各自的 JSON 合并 ——
|
||||
· `hookSpecificOutput.additionalContext` **字符串拼接**(多个守卫都要注入时不能互相顶掉)
|
||||
· `decision` / `permissionDecision` 等**谁拦谁说了算**(先出现的非空值优先,并记来源)
|
||||
⚠️ ⛔ 不吞任何一条的"阻止"决定 —— 那是安全语义,合并只能加信息、不能减判定。
|
||||
|
||||
## 硬纪律(沿用本包同族)
|
||||
|
||||
1. 🔴 **输出走 `sys.stdout.buffer.write(bytes)`**(cp936 下文本模式会炸,见 `_env`/`pitfalls`)。
|
||||
2. 🔴 **fail-open**:本入口自己不判任何业务规则;任一 guard 抛异常 ⇒ 只记 stderr,
|
||||
其余 guard 照跑(⛔ 不许一个坏 guard 把别的都拖没)。
|
||||
3. 🔴 **不改各 guard 源码**:用 `runpy` 按路径加载、临时接管 stdout 取回它们的 JSON
|
||||
⇒ 单跑与合并跑**判据同源**(⛔ 不复制一份规则,避免"一条规则两份实现必漂")。
|
||||
4. ⚠️ 保持**单跑入口不变**(各 `*-guard.py` 仍可直接被宿主调用)——本文件是**可选的加速入口**。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import runpy
|
||||
import sys
|
||||
import time
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
# 🔴 合并的守卫清单(⛔ 顺序=执行顺序;都是 fail-open 的"补充信息/提醒"类)
|
||||
# ⚠️ **不含** `supervise-ensure-hook` / `wb-result-hook` / `decision_bridge` ——
|
||||
# 它们有副作用(投递/常驻确保)或属别处接线,**合并会改变语义**,一律不碰。
|
||||
GUARDS = (
|
||||
"reply-style-guard.py",
|
||||
"stop-dialog-guard.py",
|
||||
"skill-load-guard.py",
|
||||
"session-log-guard.py",
|
||||
)
|
||||
|
||||
|
||||
def _log(msg: str) -> None:
|
||||
"""⛔ 绝不写 stdout(那是协议通道);失败只上 stderr 且吞异常。"""
|
||||
try:
|
||||
sys.stderr.write("[prompt-guards] %s\n" % msg)
|
||||
sys.stderr.flush()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
class _Tee(io.TextIOBase):
|
||||
"""临时接管 `sys.stdout`:把 guard 写出的 JSON 收下来(⛔ 不落终端)。
|
||||
|
||||
⚠️🔴 **踩过的坑(2026-10-06 实测)**:多数 guard 走 `sys.stdout.buffer.write(bytes)`。
|
||||
最初把 `self.buffer = self` ⇒ 那次 `write` **撞上 `TextIOBase.write`**,
|
||||
于是 bytes 被 `str()` 成 `b'{"hookSpecificOutput": ...}'` ⇒ **JSON 解析必失败**
|
||||
(现象:合并入口报「输出了非 JSON(6228 字节)」)。
|
||||
✅ 正解:`buffer` 用一个**独立的 `io.BytesIO`**;文本写与字节写**各收各的**,
|
||||
`text()` 时按"谁有内容取谁"取回(正常只会有一路有内容)。
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self._raw = io.BytesIO() # 给 `sys.stdout.buffer.write(bytes)` 用
|
||||
self.buffer = self._raw
|
||||
self._txt = [] # 给 `sys.stdout.write(str)` 用(少数 guard 会走)
|
||||
|
||||
# TextIOBase 接口
|
||||
def write(self, s): # noqa: D102
|
||||
try:
|
||||
self._txt.append(str(s))
|
||||
except Exception:
|
||||
pass
|
||||
return len(s or "")
|
||||
def flush(self): # noqa: D102
|
||||
pass
|
||||
def isatty(self): # noqa: D102
|
||||
return False
|
||||
def writable(self): # noqa: D102
|
||||
return True
|
||||
|
||||
def text(self) -> str:
|
||||
"""取回收集到的内容(字节路优先;两路都空 ⇒ 空串)。"""
|
||||
b = self._raw.getvalue()
|
||||
if b:
|
||||
return b.decode("utf-8", "replace")
|
||||
return "".join(self._txt)
|
||||
|
||||
|
||||
def _run_one(name: str, payload_bytes: bytes):
|
||||
"""在同进程内跑一个 guard,返回 (json_or_None, seconds)。
|
||||
|
||||
⚠️ guard 读的是 **stdin**;本入口已把 stdin 读过一次 ⇒ 这里给它一个"假 stdin",
|
||||
内容是**同一份 payload 字节**(保证各 guard 看到的输入完全一致)。
|
||||
"""
|
||||
path = os.path.join(HERE, name)
|
||||
if not os.path.isfile(path):
|
||||
_log("跳过(不存在):%s" % name)
|
||||
return None, 0.0
|
||||
|
||||
old_out, old_in = sys.stdout, sys.stdin
|
||||
tee = _Tee()
|
||||
# ⚠️ `TextIOWrapper.buffer` 是**只读属性**(实测 `AttributeError: readonly attribute`)
|
||||
# ⇒ 不能后赋值;正解=**直接构造一个带 BytesIO 的 TextIOWrapper**,
|
||||
# 它的 `.buffer` 天然就是那个 BytesIO(guard 走 `sys.stdin.buffer.read()`)。
|
||||
fake_in = io.TextIOWrapper(io.BytesIO(payload_bytes), encoding="utf-8")
|
||||
t0 = time.time()
|
||||
try:
|
||||
sys.stdout = tee
|
||||
sys.stdin = fake_in
|
||||
runpy.run_path(path, run_name="__main__")
|
||||
except SystemExit:
|
||||
pass # guard 自己 exit ⇒ 正常
|
||||
except Exception as e: # noqa: BLE001
|
||||
_log("%s 抛异常(已忽略,fail-open):%r" % (name, e))
|
||||
finally:
|
||||
sys.stdout, sys.stdin = old_out, old_in
|
||||
el = time.time() - t0
|
||||
txt = tee.text().strip()
|
||||
if not txt:
|
||||
return None, el
|
||||
try:
|
||||
return json.loads(txt), el
|
||||
except Exception:
|
||||
# 非 JSON(不合契约)⇒ 留痕但不当成阻止
|
||||
_log("%s 输出了非 JSON(%d 字节,已忽略)" % (name, len(txt)))
|
||||
return None, el
|
||||
|
||||
|
||||
def _merge(dst: dict, src: dict, who: str) -> None:
|
||||
"""把 `src` 合并进 `dst`(⛔ 只加不减;冲突时保留先到者并记来源)。"""
|
||||
if not isinstance(src, dict):
|
||||
return
|
||||
for k, v in src.items():
|
||||
if k == "hookSpecificOutput" and isinstance(v, dict):
|
||||
cur = dst.setdefault("hookSpecificOutput", {})
|
||||
if not isinstance(cur, dict):
|
||||
continue
|
||||
for k2, v2 in v.items():
|
||||
if k2 == "additionalContext" and isinstance(v2, str):
|
||||
# 🔴 多守卫都要注入 ⇒ **拼接**(⛔ 别互相顶掉)
|
||||
old = cur.get("additionalContext") or ""
|
||||
cur[k2] = (old + ("\n\n" if old else "") + v2) if old else v2
|
||||
else:
|
||||
if k2 not in cur:
|
||||
cur[k2] = v2
|
||||
elif cur[k2] != v2:
|
||||
_log("字段冲突 %s.%s:保留先到者,丢弃 %s 的值" % (who, k2, who))
|
||||
else:
|
||||
if k not in dst:
|
||||
dst[k] = v
|
||||
# ⚠️ 同名字段(如 `decision`)冲突 ⇒ **保留先到者**;因为均为 fail-open 类,
|
||||
# 先到者通常来自更"硬"的守卫(格式/收尾);真要强制多判定应拆开接线。
|
||||
|
||||
|
||||
def main() -> None:
|
||||
try:
|
||||
raw = sys.stdin.buffer.read()
|
||||
except Exception:
|
||||
raw = b""
|
||||
if not raw:
|
||||
return # 空 payload ⇒ 静默放行(同各 guard)
|
||||
|
||||
out = {}
|
||||
timings = []
|
||||
for name in GUARDS:
|
||||
obj, el = _run_one(name, raw)
|
||||
timings.append("%s=%.3fs" % (name.split("-")[0], el))
|
||||
if obj:
|
||||
_merge(out, obj, name)
|
||||
|
||||
if not out:
|
||||
return # 全都没话说 ⇒ 零输出(⛔ 不写空 JSON)
|
||||
|
||||
# 🔴 出口:必须走 buffer bytes(cp936 下文本模式炸)
|
||||
try:
|
||||
out.setdefault("hookSpecificOutput", {})["hookEventName"] = "UserPromptSubmit"
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
sys.stdout.buffer.write(json.dumps(out, ensure_ascii=False).encode("utf-8"))
|
||||
sys.stdout.buffer.flush()
|
||||
except Exception as e: # noqa: BLE001
|
||||
_log("写 stdout 失败:%r" % (e,))
|
||||
_log("合并完成:%s" % " ".join(timings))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except Exception as e: # noqa: BLE001
|
||||
_log("FATAL %r" % (e,)) # fail-open:异常也不阻断提交
|
||||
@@ -130,6 +130,19 @@ TRIGGERS = (
|
||||
# 且 10-01 / 10-02 **零命中**(最后一次真实 HIT 停在 2026-09-30 18:41)。
|
||||
'按规则来', '按规则做', '按作业规则', '遵守规则', '按规矩来',
|
||||
'回复排版', '执行结果排版', '回复格式', '怎么回复', '排版',
|
||||
# ── 分组 3:点名「会话机制 / 派活」(2026-10-05 立 · 🔴 补的正是"漏了主触发句"这个洞)──
|
||||
# 🔴🔴 起因(10-05 实测取证):会话 `b232218f`(vibe-product 主会话)里,
|
||||
# 用户原话「**2、使用任务会话完成目标:补抓…**」+ 连续三次质问
|
||||
# 「任务会话的目的不就是建立任务会话执行嘛」「我想知道你哪来的这么多问题啊」,
|
||||
# 而**本钩子全程 0 命中** —— 旧词表 22 个词里**一个都匹配不上"使用任务会话完成目标"**:
|
||||
# · 「决策方法」族:用户没说"决策",他说的是"使用任务会话"
|
||||
# · 「按规则来」族:用户没说"规则",他说的是"会话技能里没告诉你自决策的规则嘛"(**反问**,含"规则"但那是质问不是点名)
|
||||
# ⇒ 结果:技能从未被强制加载 ⇒ 助手靠记忆干活 ⇒ 把"要不要建任务会话"当待拍板项反复问。
|
||||
# 🔴 教训:**词表只覆盖"点名方法论",漏了"点名派活"** —— 而后者才是本技能的主入口。
|
||||
'使用任务会话', '使用执行会话', '使用协作会话', '任务会话完成', '执行会话完成',
|
||||
'继续完成目标', '继续目标', '继续完成之前', '继续之前的',
|
||||
'会话技能', '会话机制', '创建任务会话', '建任务会话',
|
||||
'派任务会话', '任务会话', '执行会话', '协作会话', '多会话',
|
||||
)
|
||||
|
||||
# 命中词 → 该加载哪个技能(2026-09-22 加:从"只会推决策技能"扩为按命中词分流)
|
||||
@@ -140,6 +153,16 @@ TRIGGERS = (
|
||||
_LOAD_RULES = ('按规则来', '按规则做', '按作业规则', '遵守规则', '按规矩来',
|
||||
'回复排版', '执行结果排版', '回复格式', '怎么回复', '排版')
|
||||
|
||||
# ── 分组 3 的词 → 加载 `session-mechanism`(2026-10-05 加)─────────────────────
|
||||
# 与 `_LOAD_RULES`(→ agent-operating-rules)并列的第三条路由。
|
||||
# 🔴 为什么单列一族:这三族**目标技能不同**,⛔ 不能混在一个 if 里 ——
|
||||
# 命中"使用任务会话完成目标"的人,要的是**会话机制本体**(怎么建排期/怎么派活/
|
||||
# 自决策白名单在 `references/02-功能优先协作协议.md`),⛔ 不是决策方法论。
|
||||
_LOAD_SESSION = ('使用任务会话', '使用执行会话', '使用协作会话', '任务会话完成', '执行会话完成',
|
||||
'继续完成目标', '继续目标', '继续完成之前', '继续之前的',
|
||||
'会话技能', '会话机制', '创建任务会话', '建任务会话',
|
||||
'派任务会话', '任务会话', '执行会话', '协作会话', '多会话')
|
||||
|
||||
|
||||
def _read_stdin():
|
||||
try:
|
||||
@@ -271,9 +294,33 @@ def main():
|
||||
pos = min(seg.find(w) for w in hits)
|
||||
excerpt = seg[max(0, pos - 40): pos + 80].strip()
|
||||
|
||||
# ── 按命中词分流(2026-09-22):规则族与决策族给不同指令 ──────────────────
|
||||
# ── 按命中词分流(2026-09-22 起;2026-10-05 加第三族「会话机制」)───────────
|
||||
# 优先级:会话族 > 规则族 > 决策族。
|
||||
# 🔴 为什么会话族最高:命中"使用任务会话完成目标"时,用户要的是**派活**,
|
||||
# 而派活的前置恰恰是"自决策白名单"(否则就会回头问"要不要建任务会话"——
|
||||
# 10-05 就是这么栽的)。⛔ 此时若只推 `dsh-decision`,人还是可能漏掉会话机制本体。
|
||||
hit_session = [w for w in hits if w in _LOAD_SESSION]
|
||||
hit_rules = [w for w in hits if w in _LOAD_RULES]
|
||||
if hit_rules:
|
||||
if hit_session:
|
||||
head = '检测到用户本轮**点名了会话机制 / 派活**(命中:%s)。' % '、'.join(hit_session)
|
||||
body = (
|
||||
'⛔ **不要凭记忆代替、也不要以"我已经知道这套机制"为由跳过**:\n'
|
||||
' **先调用 Skill 工具加载 `session-mechanism`**(会话机制 + 多会话执行总入口 · 含**首屏第 0 步加载门槛**'
|
||||
'/三类会话/派活线/自决策白名单指向),然后才开始作答。\n'
|
||||
' ⛔ 若上一轮已在本会话加载过,可跳过加载,但**必须确认用的是现行口径**'
|
||||
'(机制 10-03/10-04/10-05 连续改过三轮,记忆里的版本大概率是旧的)。\n\n'
|
||||
'🔴🔴 **本条最关键的作用 —— 拦住"把已授权的事重新要签字"**:\n'
|
||||
' 用户说「**使用任务会话完成 X 目标**」**本身就是授权** ⇒ 直接建排期、拉起任务会话,\n'
|
||||
' ⛔ **不许再问「要不要建任务会话」**。\n'
|
||||
' 📌 2026-10-05 实测栽过(会话 `b232218f`):用户下发「2、使用任务会话完成目标:…」,\n'
|
||||
' AI 却回「你要我直接派任务会话,还是先自己执行?」⇒ 用户连问三次\n'
|
||||
' 「任务会话的目的不就是建立任务会话执行嘛,不然我调用任务会话技能干什么」\n'
|
||||
' 「我想知道你哪来的这么多问题啊,你去执行不行啊」。\n'
|
||||
' ⇒ 根因:**本钩子当时词表里没有一个词能命中"使用任务会话完成目标"**(22 词全是"决策方法"族),\n'
|
||||
' 技能从未被强制加载 ⇒ AI 靠记忆干活 ⇒ 白名单没进上下文。本轮已补上触发词。\n\n'
|
||||
'⚠️ 冲突裁决:**用户原话 > 注入通报**(通报是机制层旁路信号,⛔ 不能拿它反驳用户当轮明确指令)。'
|
||||
)
|
||||
elif hit_rules:
|
||||
head = '检测到用户本轮**点名了作业规则**(命中:%s)。' % '、'.join(hit_rules)
|
||||
body = (
|
||||
'⛔ **不要凭记忆代替、也不要以"我已经知道规则"为由跳过**:\n'
|
||||
|
||||
@@ -432,12 +432,28 @@ def _set_level(root, sid, lv):
|
||||
|
||||
|
||||
def session_budget(path):
|
||||
"""返回 (当前上下文 token, 工具调用累计次数);读不到返回 (None, None)。"""
|
||||
"""返回 (当前上下文 token, 工具调用累计次数, 上一次上下文 token);读不到返回三元 None。
|
||||
|
||||
🔴🔴 2026-10-06 修 **本函数返回元组长度不一致**(真缺陷 · 已实测坐实):
|
||||
原先**两条早退路径只返回 2 值**(transcript > 64 MiB / `getsize` 抛 OSError),
|
||||
而末尾两条返回 **3 值**、调用方 `toks, ncalls, prev = session_budget(tp)`(第 611 行)
|
||||
也按 **3 值**解包 ⇒ 只要 transcript **超 64 MiB**、或文件不在,
|
||||
**每轮必抛 `ValueError: not enough values to unpack (expected 3, got 2)`**。
|
||||
|
||||
后果(比报错难查得多):本钩子是 **fail-open**(异常仍 `sys.exit(0)`)
|
||||
⇒ 宿主侧**看不到任何异常**,`install.py --verify` 只判 `rc=0` ⇒ 判它 **"ok"(假绿)**;
|
||||
而实际失效的是**这一整条**:水位与收口 / 接续机制起点 / 预算告警 / 门禁自检 / 路径自检。
|
||||
|
||||
证据:`<WS>/.workbuddy/stop-dialog-guard.log` **86 条 EXCEPTION**(首条 2026-10-06 05:09:26),
|
||||
且**每轮复现**——因为本会话 transcript 已达 **192.8 MB**(>64 MiB 阈值)⇒ 命中的正是第 437 行那条早退。
|
||||
|
||||
⇒ 判据:**同一函数的所有 return 必须同长**;“防护性早退”最容易漏掉这一条。
|
||||
"""
|
||||
try:
|
||||
if os.path.getsize(path) > 64 * 1024 * 1024:
|
||||
return None, None
|
||||
return None, None, None
|
||||
except OSError:
|
||||
return None, None
|
||||
return None, None, None
|
||||
last_in, n_calls, prev_in = None, 0, None
|
||||
try:
|
||||
with io.open(path, encoding='utf-8', errors='replace') as f:
|
||||
|
||||
@@ -33,7 +33,7 @@ SK = Path(__file__).resolve().parent
|
||||
DEFAULT_TITLE = "检查会话协作是否运行正常 + 协作机制问题排查与修复"
|
||||
|
||||
|
||||
def _register_keeper_task(ws: Path, keeper: Path) -> str:
|
||||
def _register_keeper_task(ws: Path, python_exe: Path) -> str:
|
||||
"""🔴🔴 给本工作区**登记一条计划任务**(="各工作区都能自己常驻"的落点)。
|
||||
|
||||
## 为什么必须有这一步(2026-10-04 用户原话)
|
||||
@@ -58,42 +58,59 @@ def _register_keeper_task(ws: Path, keeper: Path) -> str:
|
||||
|
||||
人类可读的一行结论(调用方直接打印)。⛔ 不抛异常(登记失败要能继续跑完别的步骤)。
|
||||
"""
|
||||
_task = "collabd-supervise-%s" % (ws.name or "ws")
|
||||
_task = "collabd-keepalive-%s" % (ws.name or "ws")
|
||||
# 🔴🔴 2026-10-06 收口(P0-74):动作=**`pythonw.exe` + `supervise-launch.py`**。
|
||||
# 旧形态是 `powershell.exe -File start-supervise.ps1` —— PowerShell 是**控制台程序**
|
||||
# ⇒ 每次触发分配 `conhost.exe` ⇒ **闪一下黑窗**(用户 2026-10-05:「又弹了窗口」)。
|
||||
# ⚠️ 旧注释的理由「keeper 里要 set 那几个 env ⇒ 直接跑解释器没有设环境变量这一步」
|
||||
# **已被 `supervise-launch.py` 推翻** —— 那个启动器存在的**全部目的**就是在进程内设 env。
|
||||
# ⛔ **登记动作仍走 PowerShell**(那是在**登记**,不是在**当看守**,两者别混)。
|
||||
_pyw = Path(python_exe).with_name("pythonw.exe")
|
||||
if not _pyw.is_file(): # ⛔ 没同版本 pythonw ⇒ 找全局那份
|
||||
_cands = sorted(Path(python_exe).parent.parent.parent.glob("*/pythonw.exe"))
|
||||
_pyw = _cands[-1] if _cands else Path(python_exe)
|
||||
_launcher = ws / ".workbuddy" / "collab" / "supervise-launch.py"
|
||||
# 🔴 `-WorkingDirectory` **必须是「工作区根」**(⛔ 不是脚本目录):
|
||||
# `collabd.load_cfg()` 按 `<cwd>/.workbuddy/collab/collabd.config.json` 找配置;
|
||||
# cwd 设成脚本目录 ⇒ 它去找 `<ws>/.workbuddy/collab/.workbuddy/collab/…` ⇒ **找不到**
|
||||
# ⇒ `CFG_MISSING` ⇒ `--supervise` 拒跑(实测 rc=2;任务侧 `LastTaskResult=1`)。
|
||||
_wd = str(ws).replace("/", "\\")
|
||||
_ps = (
|
||||
"$ErrorActionPreference='Stop';"
|
||||
"$n='%s';"
|
||||
# 🔴 幂等:先注销同名(`-Confirm:$false` 免交互;宿主可能 -NonInteractive)
|
||||
"if(Get-ScheduledTask -TaskName $n -ErrorAction SilentlyContinue){"
|
||||
"Unregister-ScheduledTask -TaskName $n -Confirm:$false};"
|
||||
# 🔴 动作=**powershell.exe -File <本区 keeper>**(⛔ 不是 pythonw):
|
||||
# keeper 里要 set 那几个 env(CODEBUDDY_CONFIG_DIR / COLLABD_CONFIG)⇒
|
||||
# 直接跑解释器没有"设环境变量"这一步。
|
||||
"$a=New-ScheduledTaskAction -Execute 'powershell.exe' "
|
||||
"-Argument '-NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File \"%s\"' "
|
||||
"-WorkingDirectory '%s';"
|
||||
# 🔴 `AtLogOn` = 开机/登录即起(这一步让"跨会话"成立:⛔ 不依赖任何 WorkBuddy 会话)
|
||||
"$t=New-ScheduledTaskTrigger -AtLogOn;"
|
||||
# 🔴 四个设置缺一不可(少一个就前功尽弃,逐条理由见 references/supervise-persistence.md §二②):
|
||||
# ExecutionTimeLimit=0 ⇒ 默认 72h 会把常驻杀掉
|
||||
# 🔴 动作=`pythonw.exe` + 启动器(GUI 子系统 ⇒ 零 conhost ⇒ **不闪窗**)
|
||||
"$a=New-ScheduledTaskAction -Execute '%s' -Argument '\"%s\"' -WorkingDirectory '%s';"
|
||||
# 🔴 `RepetitionInterval` 5 分钟(与 `collabctl.py` 的既定口径一致)
|
||||
"$t=New-ScheduledTaskTrigger -Once -At (Get-Date) "
|
||||
"-RepetitionInterval (New-TimeSpan -Minutes 5);"
|
||||
# 🔴 四个设置缺一不可(逐条理由见 references/supervise-persistence.md §二②):
|
||||
# ExecutionTimeLimit=0 ⇒ 默认 72h 会把常驻杀掉;
|
||||
# ⚠️ ⛔ 不许设 2 分钟(那会被调度器到点掐死 ⇒ 每 2 分钟重建一次 = 抖动)
|
||||
# MultipleInstances=IgnoreNew ⇒ 防双写台账
|
||||
# RestartCount/Interval ⇒ 脚本非零退出时重拉(⚠️ 真正的看护在 keeper 的 while 里)
|
||||
# -Hidden ⇒ 计划任务库里不显眼
|
||||
"$s=New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries "
|
||||
"-DontStopIfGoingOnBatteries -StartWhenAvailable "
|
||||
"-ExecutionTimeLimit ([TimeSpan]::Zero) -MultipleInstances IgnoreNew "
|
||||
"-RestartCount 999 -RestartInterval (New-TimeSpan -Minutes 1);"
|
||||
"Register-ScheduledTask -TaskName $n -Action $a -Trigger $t -Settings $s -Force|Out-Null;"
|
||||
# 🔴 立刻起一次(⛔ 别等下次登录 —— 否则"配好了"这个结论当场不可验)
|
||||
"-ExecutionTimeLimit ([TimeSpan]::Zero) -MultipleInstances IgnoreNew -Hidden;"
|
||||
"$pr=New-ScheduledTaskPrincipal -UserId $env:USERNAME -LogonType Interactive -RunLevel Highest;"
|
||||
"Register-ScheduledTask -TaskName $n -Action $a -Trigger $t -Settings $s -Principal $pr -Force|Out-Null;"
|
||||
# 🔴 立刻起一次(⛔ 别等下次触发 —— 否则"配好了"这个结论当场不可验)
|
||||
"Start-ScheduledTask -TaskName $n;"
|
||||
"'OK ' + $n"
|
||||
) % (_task, str(keeper), str(ws))
|
||||
) % (_task, str(_pyw), str(_launcher), _wd)
|
||||
try:
|
||||
_r = subprocess.run(["powershell.exe", "-NoProfile", "-ExecutionPolicy", "Bypass",
|
||||
"-Command", _ps],
|
||||
capture_output=True, timeout=180)
|
||||
capture_output=True, timeout=180,
|
||||
# 🔴 `CREATE_NO_WINDOW`(⛔ 缺了每次登记都闪一下)
|
||||
creationflags=0x08000000)
|
||||
_o = _r.stdout.decode("utf-8", "replace").strip()
|
||||
_e = _r.stderr.decode("utf-8", "replace").strip()
|
||||
if "OK " in _o:
|
||||
return "✅ 已登记计划任务 `%s`(AtLogOn · ExecutionTimeLimit=0 · IgnoreNew · Restart×999)并已启动" % _task
|
||||
return ("✅ 已登记计划任务 `%s`(pythonw + supervise-launch.py · 每 5 分钟 · "
|
||||
"ExecutionTimeLimit=0 · IgnoreNew)并已启动" % _task)
|
||||
# ⛔ 失败要**报原文**,⛔ 不许编原因
|
||||
_msg = (_e or _o or "无回显").splitlines()
|
||||
return ("⛔ 登记失败:%s\n ⚠️ 若提示权限/策略 ⇒ 走 PowerShell 工具手动建"
|
||||
@@ -105,7 +122,7 @@ def _register_keeper_task(ws: Path, keeper: Path) -> str:
|
||||
|
||||
def _keeper_task_state(ws: Path) -> str:
|
||||
"""读本区计划任务状态(⛔ 只读;用来做"到底建成了没"的行为级验收)。"""
|
||||
_task = "collabd-supervise-%s" % (ws.name or "ws")
|
||||
_task = "collabd-keepalive-%s" % (ws.name or "ws")
|
||||
_ps = ("$n='%s';"
|
||||
"$t=Get-ScheduledTask -TaskName $n -ErrorAction SilentlyContinue;"
|
||||
"if(-not $t){'MISSING'}else{"
|
||||
@@ -185,15 +202,57 @@ def main() -> int:
|
||||
ws_s = str(ws).replace("\\", "/") # 🔴 正斜杠(红线)
|
||||
short = a.short or ws.name
|
||||
topics = [t.strip() for t in a.topics.split(",") if t.strip()]
|
||||
port = a.port or (20000 + (abs(hash(ws_s)) % 5000)) # ⛔ 每区唯一
|
||||
# 🔴🔴 端口**跨进程稳定**(2026-10-04 修):原来用 `hash(ws_s)`,而
|
||||
# **Python 3.11+ 字符串 hash 每进程随机化**(PYTHONHASHSEED)⇒
|
||||
# **同一工作区每跑一次就换一个端口** ⇒ 旧守卫/防火墙/别的配置里的端口全部对不上。
|
||||
# ⇒ 改成 `zlib.crc32`(确定性、与版本无关);已有配置里写了端口就沿用它。
|
||||
import zlib
|
||||
port = port or (20000 + (zlib.crc32(ws_s.encode("utf-8")) % 5000))
|
||||
derived = 20000 + (zlib.crc32(ws_s.encode("utf-8")) % 5000) # 按**工作区路径**算,天然各区不同
|
||||
port = a.port or derived
|
||||
_port_from = "按路径算出" if not a.port else "命令行指定"
|
||||
|
||||
print("=== 新建协作工作区:%s ===" % ws_s)
|
||||
# ⚠️ 端口目录靠后生成(要读旧配置才能定),但**冲突自检**要提前做 —— 见下方 `_port_clash`。
|
||||
_port_clash: list[str] = []
|
||||
|
||||
def _scan_peer_ports() -> dict:
|
||||
"""扫**本机其它工作区**的 `singleton_port`(只读)。
|
||||
|
||||
🔴🔴 2026-10-05 加(用户报「各工作区环境是否该独立」时坐实):
|
||||
实测三个区(`ai1net` / `vibe-product` / `agent-product`)的 `collabd.config.json`
|
||||
**`singleton_port` 全是 20099**(= `collabd.py` 的**默认值**)。
|
||||
真因不是算法 —— 算法按路径算,三区分别该是 `23924` / `24942` / `21826`;
|
||||
真因是**前两个区的 config 是早期手工建的,一直抄着默认值**,而
|
||||
`init_workspace` 的「沿用既有端口」**无条件信任**旧值 ⇒ 抄错的值**永远修不回来**。
|
||||
|
||||
⚠️ 后果(`init_workspace.py` 文件头早就写了):
|
||||
「`singleton_port` 每个工作区一个(⛔ 复用 ⇒ 常驻单例互抢、**静默只有一个活着**)」
|
||||
⇒ 这是**静默故障**:两个区都以为自己起了常驻,实际只有一个绑上端口。
|
||||
|
||||
做法:扫 `AIProject` 下各兄弟工作区的配置(本区**不算**),拿端口 ⇒ 撞了就报警并改用算出来的值。
|
||||
"""
|
||||
out: dict = {}
|
||||
try:
|
||||
parent = ws.parent # 通常= …/AIProject
|
||||
if not parent.is_dir():
|
||||
return out
|
||||
for sib in parent.iterdir():
|
||||
if not sib.is_dir() or sib.resolve() == ws:
|
||||
continue
|
||||
cp = sib / ".workbuddy" / "collab" / "collabd.config.json"
|
||||
if not cp.is_file():
|
||||
continue
|
||||
try:
|
||||
pv = int((json.loads(cp.read_text(encoding="utf-8")) or {}).get(
|
||||
"singleton_port") or 0)
|
||||
except Exception:
|
||||
continue
|
||||
if pv:
|
||||
out.setdefault(pv, []).append(sib.name)
|
||||
except Exception:
|
||||
pass
|
||||
return out
|
||||
|
||||
# ① 骨架
|
||||
for d in (".workbuddy/collab", "tmp/supervise-inbox", "交付物", ".workbuddy/collab/logs"):
|
||||
(ws / d).mkdir(parents=True, exist_ok=True)
|
||||
@@ -213,8 +272,31 @@ def main() -> int:
|
||||
_old_cfg = json.loads(cfgp.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
_old_cfg = {}
|
||||
# 🔴🔴 端口冲突自检 + 纠偏(2026-10-05 加)——
|
||||
# 「沿用既有」**不能无条件信任**:实测三区 config 里 `singleton_port` 全是 20099
|
||||
# (早期手工建配置时抄了 `collabd.py` 的默认值),而**谁都没报错**
|
||||
# ⇒ 常驻单例互抢、静默只有一个活着。⇒ 沿用前**先比对**:
|
||||
# · 旧值 == 按路径算出的值 ⇒ 正常沿用;
|
||||
# · 旧值 ≠ 算出值,且**别区占了它** ⇒ 🔴 报警 + **改用算出值**(抄错的值该修);
|
||||
# · 旧值 ≠ 算出值,但没有别区占用 ⇒ **沿用**(可能是刻意指定的,⛔ 不擅改)。
|
||||
_bad_port = False
|
||||
if _old_cfg.get("singleton_port"):
|
||||
port = int(_old_cfg["singleton_port"]) # 沿用既有端口(⛔ 换了会让别的引用失效)
|
||||
_old_port = int(_old_cfg["singleton_port"])
|
||||
_peers = _scan_peer_ports()
|
||||
if _old_port == derived:
|
||||
port, _port_from = _old_port, "沿用既有(与算出值一致)"
|
||||
else:
|
||||
_who = _scan_peer_ports().get(_old_port) or []
|
||||
if _who:
|
||||
_bad_port = True
|
||||
print(" 🔴 **端口冲突**:本区既有 `singleton_port=%d` 已被 **%s** 占用。"
|
||||
% (_old_port, "、".join(_who)))
|
||||
print(" 🔴 后果:两区常驻**互抢单例** ⇒ ⛔ 静默只有一个活着(文件头 §关键约束 早写明)。")
|
||||
print(" 🔴 处置:改用按本区路径算出的 **%d**(=%s)。" % (derived, ws_s))
|
||||
port, _port_from = derived, "纠偏(原值与他区冲突)"
|
||||
_port_clash.append("%d ← %s" % (_old_port, "、".join(_who)))
|
||||
else:
|
||||
port, _port_from = _old_port, "沿用既有"
|
||||
_old_goal = {}
|
||||
if gp.exists():
|
||||
try:
|
||||
@@ -248,10 +330,11 @@ def main() -> int:
|
||||
cfg = _merged
|
||||
_added = [k for k in cfg if k not in _old_cfg]
|
||||
cfg["workspace"] = ws_s # 这两项必须以本次为准(工作区可能搬过家)
|
||||
cfg["singleton_port"] = port # ⚠️ 纠偏时**必须以本次为准**(旧值可能在 `_old_cfg` 里,会被 update 盖回去)
|
||||
cfgp.write_text(json.dumps(cfg, ensure_ascii=False, indent=1), encoding="utf-8", newline="")
|
||||
print("② 部署配置:%s%s(端口 %d%s)"
|
||||
print("② 部署配置:%s%s(端口 %d,%s)"
|
||||
% (cfgp.relative_to(ws), (" 补了 %s" % _added) if _added else " 已最新",
|
||||
port, " 沿用既有" if _old_cfg.get("singleton_port") else ""))
|
||||
port, _port_from))
|
||||
|
||||
# ③ 目标(**补缺式**:已有 goal.json ⛔ 一个字都不改)
|
||||
goal = {
|
||||
@@ -329,7 +412,6 @@ def main() -> int:
|
||||
# ⚠️ 本副本若还不存在(首次初始化、还没分发)⇒ 回落用技能目录那份,
|
||||
# 但**打印时必须说清"这次是回落"**(⛔ 静默回落=用户以为跑的是副本)。
|
||||
_own = ws / ".workbuddy" / "collab" / "collabd.py"
|
||||
_own_ba = ws / ".workbuddy" / "collab" / "board.py"
|
||||
_fallback = not _own.is_file()
|
||||
_cd = SK / "collabd.py" if _fallback else _own
|
||||
if _fallback:
|
||||
@@ -358,9 +440,18 @@ def main() -> int:
|
||||
"各区程序各自独立,跑了目录那份会写错地方):")
|
||||
print(" export COLLABD_CONFIG=%s" % str(cfgp).replace("\\", "/"))
|
||||
_cd_show = _cd
|
||||
_bd_show = (SK / "board.py") if not (ws / ".workbuddy" / "collab" / "board.py").is_file() else _own_ba
|
||||
print(" python %s --supervise" % _cd_show)
|
||||
print(" python %s --serve 8789 --takeover" % _bd_show)
|
||||
# 🔴🔴 2026-10-05 改口径(用户当轮原话:「看板共用一份 各个工作区不是都应该有自己独立的看板」——
|
||||
# 这是在**问**,答案是否定的,且**早有定案**:SKILL.md:809 记的 2026-10-03 16:3x 用户拍板
|
||||
# 「⛔ 不再为每个工作区各起一个看板 —— **看板只保留一份**(就是主工作区这一个),
|
||||
# 其它工作区靠 `peer_workspaces` 并列查看」。
|
||||
# ⇒ 老输出写的是「python board.py --serve 8789 --takeover」(**暗示各区各起一份**)——
|
||||
# 与定案冲突,且 8789 这个端口本身也是随便举的 ⇒ 会把新区的常驻引导到**错形态**上。
|
||||
# ⇒ 现在**只说本区要做什么**(起常驻),看板那件事改为一句**指向主视图**的说明。
|
||||
# ⚠️ 判据:本区**只起常驻**,⛔ 不起看板;要并看 ⇒ 把本区路径加进**主工作区**配置的
|
||||
# `peer_workspaces`(只读),重启主看板即可。
|
||||
print(" 📺 看板:**全平台只保留一份**(主工作区那份)⇒ 本区⛔ 不起看板。")
|
||||
print(" 要并看本区 ⇒ 把 `%s` 加进**主工作区**配置的 `peer_workspaces`(只读),重启主看板。" % ws_s)
|
||||
if _fallback:
|
||||
print(" ⚠️ 上面是**技能目录那份**(副本还没分发)⇒ 先跑 deploy_code.py --ws %s" % ws_s)
|
||||
# ══ ⑥ 🔴🔴 **铺常驻载体**(2026-10-04:用户「任何工作区都要能自己配好」)
|
||||
|
||||
@@ -44,6 +44,7 @@ except Exception:
|
||||
pass
|
||||
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
@@ -79,6 +80,66 @@ def _mech_on() -> bool:
|
||||
MECH_ON = _mech_on()
|
||||
|
||||
|
||||
def _strip_comments_and_docs(src: str) -> str:
|
||||
"""把 Python 源码里的**注释 + 字符串(含 docstring)**全部剔掉,只留可执行骨架。
|
||||
|
||||
🔴🔴 2026-10-05 新增。**为什么必须有一个共用实现**(⛔ 不许各用例各写一份):
|
||||
本项目有大量"判据查源码里有没有某字面"的用例。而本仓库的**考古纪律**要求:
|
||||
废弃形态的说明**必须留在注释/docstring 里**(写明"曾怎么做、为什么废弃")。
|
||||
⇒ 那些字面**注定存在** ⇒ 拿全文 `not in src` 判「已清除」**必然误红**。
|
||||
|
||||
⚠️ **同一个坑我今天踩了两遍**:
|
||||
① `t_declare_resets_lifecycle`:只剔 `#` 行 ⇒ 这次对了(那句在 `#` 注释里);
|
||||
② 本条守卫:`-File start-supervise.ps1` 那句在 **docstring** 里 ⇒ 只剔 `#` 行
|
||||
**照样命中** ⇒ 又误红一次。
|
||||
⇒ 结论:**「剔注释」≠「剔 docstring」**,必须用 `tokenize` 一次剔干净
|
||||
(⛔ 别用正则凑 —— 多行字符串、嵌套引号、f-string 都会漏)。
|
||||
|
||||
⛔ 用 `tokenize` 失败(比如源码本身语法坏)时**回落到"保留原文"**:
|
||||
宁可误红(能被看见)也不许误绿(静默放行)。
|
||||
|
||||
🔴🔴 **2026-10-05 二次修正(又踩一坑)**:第一版把 `STRING` **全剔**,结果把
|
||||
「写在 PowerShell 命令**字符串里**的字面」也剔掉了(`New-ScheduledTaskAction`、
|
||||
`supervise-launch.py` 正是**字符串内容**)⇒ 判据**由误红变成误红**(换个方向)。
|
||||
✅ 正解=**只剔两样**:① `#` 注释;② **docstring**(用 `ast` 精确定位,⛔ 不用正则)。
|
||||
普通字符串**一律保留** —— 因为"代码里干了什么"经常就写在字符串里(PowerShell 命令、
|
||||
文件名、任务名……),剔掉等于**看不见真实现**。
|
||||
"""
|
||||
import ast as _ast
|
||||
import io
|
||||
import tokenize as _tk
|
||||
# ① 先用 ast 抓出所有 docstring 的**起止行列**(模块/类/函数首表达式字符串)
|
||||
_doc_spans = []
|
||||
try:
|
||||
_tree = _ast.parse(src)
|
||||
for _node in _ast.walk(_tree):
|
||||
if isinstance(_node, (_ast.Module, _ast.ClassDef, _ast.FunctionDef,
|
||||
_ast.AsyncFunctionDef)):
|
||||
_b = getattr(_node, "body", None)
|
||||
if (_b and isinstance(_b[0], _ast.Expr)
|
||||
and isinstance(_b[0].value, _ast.Constant)
|
||||
and isinstance(_b[0].value.value, str)):
|
||||
_doc_spans.append((_b[0].value.lineno, getattr(_b[0].value, "end_lineno", _b[0].value.lineno)))
|
||||
except Exception:
|
||||
_doc_spans = []
|
||||
|
||||
def _in_doc(ln):
|
||||
return any(a <= ln <= b for a, b in _doc_spans)
|
||||
|
||||
# ② 逐 token 剔:注释一律剔;字符串**只在 docstring 位置**剔
|
||||
try:
|
||||
out = []
|
||||
for tok in _tk.generate_tokens(io.StringIO(src).readline):
|
||||
if tok.type == _tk.COMMENT:
|
||||
continue
|
||||
if tok.type == _tk.STRING and _in_doc(tok.start[0]):
|
||||
continue
|
||||
out.append(tok.string)
|
||||
return " ".join(out)
|
||||
except Exception:
|
||||
return src
|
||||
|
||||
|
||||
def _mk_env() -> dict:
|
||||
env = dict(os.environ)
|
||||
env["COLLABD_CONFIG"] = str(CFG)
|
||||
@@ -359,6 +420,169 @@ def t_goal_pending():
|
||||
return [("有 pending ⇒ rc==4", rc_pending == 4), ("--confirm 后 goal.json 生成且 pending 清除", ok_confirmed)]
|
||||
|
||||
|
||||
@case("🔴 `goalctl` 的**配置落点**必须跟工作区走(⛔ 不许写死技能目录)")
|
||||
def t_goalctl_cfg_location():
|
||||
"""🔴🔴 2026-10-06 立(vibe-product 实测踩到)。
|
||||
|
||||
**病根**:`goalctl.py` 原为 `CFG = HERE / "collabd.config.json"`(`HERE` = **技能包目录**)。
|
||||
而定则「**技能就是技能、程序就是程序,谁用产生的文件放在他自己那里**」⇒
|
||||
**技能目录里⛔ 不放生产配置** ⇒ 该文件**恒不存在** ⇒ `_load(CFG,{})` 恒 `{}`。
|
||||
|
||||
**症状(不报错、不崩溃,只是那一路永远读不到)**:
|
||||
· `taskgraph` 取默认 `INBOX/taskgraph.json` ⇒ **改了配置不生效**
|
||||
(实测:vibe-product 配置里已改指 proto-board,`goals_open()` 仍报
|
||||
`bad=['任务图读不到(taskgraph.json)']` ⇒ 目标**判不出来**);
|
||||
· `wake_enable`/`targets`/写回配置等**全部作用在一个不存在的文件上**。
|
||||
|
||||
✅ **判据**:`goalctl.CFG` 必须命中
|
||||
①`COLLABD_CONFIG`(若有)或 ②`<工作区>/.workbuddy/collab/collabd.config.json`,
|
||||
⛔ **不得**落在技能包目录下。
|
||||
"""
|
||||
import importlib.util as _ilu
|
||||
rows = []
|
||||
# ① 显式 env(钩子/启动器路径)
|
||||
os.environ["COLLABD_CONFIG"] = str(CFG)
|
||||
os.environ["DSH_COLLAB_WS"] = str(TEST_WS)
|
||||
_s1 = _ilu.spec_from_file_location("gc_cfg1", HERE / "goalctl.py")
|
||||
_m1 = _ilu.module_from_spec(_s1)
|
||||
_s1.loader.exec_module(_m1)
|
||||
rows.append(("给 `COLLABD_CONFIG` ⇒ 用它(实得 %s)" % _m1.CFG,
|
||||
_m1.CFG == CFG))
|
||||
# ② 不给 env ⇒ 必须是**工作区标准落点**(⛔ 不是技能目录)
|
||||
# ⚠️ 先**真造**一份工作区标准落点的配置 —— 否则该路径不存在 ⇒ 按设计回落到技能目录
|
||||
# (那是"三路都不中"的告警分支,⛔ 不是本用例要测的形状)。
|
||||
_hold = os.environ.pop("COLLABD_CONFIG", None)
|
||||
_std = TEST_WS / ".workbuddy" / "collab" / "collabd.config.json"
|
||||
_std_bak = _std.read_text(encoding="utf-8") if _std.exists() else None
|
||||
_std.parent.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
if not _std.exists():
|
||||
_std.write_text(json.dumps({"workspace": str(TEST_WS)},
|
||||
ensure_ascii=False), encoding="utf-8")
|
||||
_s2 = _ilu.spec_from_file_location("gc_cfg2", HERE / "goalctl.py")
|
||||
_m2 = _ilu.module_from_spec(_s2)
|
||||
_s2.loader.exec_module(_m2)
|
||||
rows.append(("不给 env ⇒ 工作区标准落点(实得 %s)" % _m2.CFG, _m2.CFG == _std))
|
||||
rows.append(("🔬 反例:⛔ 不许落在技能包目录下(`HERE/collabd.config.json`)",
|
||||
_m2.CFG != (HERE / "collabd.config.json")))
|
||||
finally:
|
||||
if _std_bak is None:
|
||||
try:
|
||||
_std.unlink()
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
_std.write_text(_std_bak, encoding="utf-8")
|
||||
if _hold is not None:
|
||||
os.environ["COLLABD_CONFIG"] = _hold
|
||||
return rows
|
||||
|
||||
|
||||
@case("🔴 `goalctl.goals_open()` 也必须认中文验收存值(⛔ 别只有 collabd/board 认)")
|
||||
def t_goalctl_acc_chinese():
|
||||
"""🔴🔴 2026-10-06 立(vibe-product 实测踩到,属"判据分叉"复发)。
|
||||
|
||||
**病根**:`board.py::acc_is_pass` 自称「**全库唯一实现**」,注释里点明三处
|
||||
(`board.py` / `collabd.py::_acc_is_pass` / `board.html::accIsPass`)必须同款。
|
||||
🔴 **实际是四处** —— `goalctl.py::goals_open()` 里那句 `str(acc[k]) != "pass"`
|
||||
**被漏掉了**。而存值**真源就是中文** ⇒ 已通过的验收被计入 why ⇒ **恒判「未完成」**。
|
||||
|
||||
**症状**:`goalctl.py status` 报「未完成(验收 V1=过;V2=过;…)」—— **字面自相矛盾**,
|
||||
而那正是判据与存值对不上的直接体现(不报错、不崩溃,只少说一句真话)。
|
||||
|
||||
✅ **本用例的判据能"改前报红"**:喂中文 `过`/`已过|…`/`达(…)`,
|
||||
旧写法 `!= "pass"` ⇒ `goals_open()` 必为 `True`(红);修后为 `False`(绿)。
|
||||
⛔ 不喂英文 `pass`(那种写法旧代码也能过 ⇒ 恒绿,等于没测)。
|
||||
"""
|
||||
m = imp()
|
||||
# ⚠️ `imp()` 加载的是 **collabd**(它的 `goals_open()` 返回 **bool**)。
|
||||
# 本用例要测的是 **goalctl** 的同名函数(返回 **dict**,含 `why`)⇒ 必须**单独加载**。
|
||||
# (实测踩到:直接用 `m.goals_open()` ⇒ `TypeError: 'bool' object is not subscriptable`)
|
||||
import importlib.util as _ilu
|
||||
_spec = _ilu.spec_from_file_location("gc_selftest_acc", HERE / "goalctl.py")
|
||||
_gc = _ilu.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(_gc)
|
||||
# 🔴🔴 **图落点必须跟着 `CFG` 走,⛔ 不许猜**:
|
||||
# · 测试配置(`imp()` 设的 `COLLABD_CONFIG`)里写的是 `"taskgraph": "tg.json"`
|
||||
# ⇒ 相对落点 = `TEST_WS/tg.json`;
|
||||
# · 而 `goal.json` 走 `goalctl.INBOX`(= `WS/tmp/supervise-inbox`,**硬编码**)。
|
||||
# ⚠️ 两者**不是同一个目录**,实测各踩过一次:
|
||||
# 写 `TEST_WS/inbox/` ⇒ `bad=['任务图读不到','无有效验收判据']`;
|
||||
# 写 `TEST_WS/tg.json` 而旧版 `CFG` 读不到 ⇒ 回落到 `INBOX/taskgraph.json` 才"恰好"过。
|
||||
# ⇒ 用**配置里那个值**算,⛔ 不硬编码任一侧。
|
||||
_tg_rel = str((json.loads(CFG.read_text(encoding="utf-8")) if CFG.is_file() else {})
|
||||
.get("taskgraph") or "")
|
||||
tg = (TEST_WS / _tg_rel) if _tg_rel else (_gc.INBOX / "taskgraph.json")
|
||||
tg.parent.mkdir(parents=True, exist_ok=True)
|
||||
gj = _gc.INBOX / "goal.json"
|
||||
gj.parent.mkdir(parents=True, exist_ok=True)
|
||||
bak_g = gj.read_text(encoding="utf-8") if gj.exists() else None
|
||||
tg_bak = tg.read_text(encoding="utf-8") if tg.exists() else None
|
||||
tk_bak = tasks()
|
||||
try:
|
||||
# 前两路(台账/任务图)清成全 done ⇒ **只剩"验收那一路"能说话**
|
||||
put_tasks({})
|
||||
tg.write_text(json.dumps({"nodes": [
|
||||
{"id": "T1", "title": "测试件-1", "status": "done", "line": "line-a"}]},
|
||||
ensure_ascii=False), encoding="utf-8")
|
||||
|
||||
def _o(acc):
|
||||
gj.write_text(json.dumps({"acceptance_state": acc}, ensure_ascii=False),
|
||||
encoding="utf-8")
|
||||
return _gc.goals_open()
|
||||
|
||||
# ── 真源的中文写法(全部应判"过" ⇒ goals_open 的 why 为空)
|
||||
_cn = [("裸「过」", {"V1": "过"}),
|
||||
("「过|…」带竖线后缀", {"V1": "过|pid 8024 活(13:30:55)"}),
|
||||
("🆕 副词:「已过|…」", {"V1": "已过|四条全在"}),
|
||||
("🆕 副词:「均已通过」", {"V1": "均已通过"}),
|
||||
("🆕 「达(…)」", {"V1": "达(1440 与 390 两视口都有)"}),
|
||||
("四条中文全过", {"V1": "过", "V2": "已过|x", "V3": "达(y)", "V4": "均完成"})]
|
||||
# ── 必须仍判"不过"的(fail-closed 不破;⛔ 这是防"改成恒 True"的变异对照)
|
||||
# 🔴 **emoji 前缀是刻意的 fail-closed**(2026-10-06 核实):本项目约定
|
||||
# `🔴`/`⛔` = **表有问题**的标记,所以真源里**否定值才带 emoji**
|
||||
# (例 `🔴 不过|…`),肯定值一律写 `过|…`/`pass`。
|
||||
# ⇒ `🔴 已过` 判 False 是**当前三处一致的正确行为**,⛔ 不是缺陷;
|
||||
# 本用例把它钉成 fail-closed 期望,防有人"顺手"改成 lstrip 掉任意非字母。
|
||||
_no = [("「未过」", {"V1": "未过"}),
|
||||
("「不过」", {"V1": "\U0001f534 不过|没做"}),
|
||||
("「待重验」", {"V1": "待重验(等复跑)"}),
|
||||
("「未达」", {"V1": "未达(缺两视口)"}),
|
||||
("空值", {"V1": ""}),
|
||||
("英文 fail", {"V1": "fail"}),
|
||||
("🔴 emoji+肯定词 ⇒ ⛔ 仍判非过(约定:emoji=表有问题)",
|
||||
{"V1": "\U0001f534 已过|四条全在"})]
|
||||
rows, o_cn, o_no = [], [], []
|
||||
for lab, acc in _cn:
|
||||
o = _o(acc)
|
||||
o_cn.append(o["open"])
|
||||
rows.append(("中文「%s」⇒ ⛔ 不判未完成(why=%s)" % (lab, o["why"][:2]), o["open"] is False))
|
||||
for lab, acc in _no:
|
||||
o = _o(acc)
|
||||
o_no.append(o["open"])
|
||||
rows.append(("「%s」⇒ 仍判未完成(fail-closed)" % lab, o["open"] is True))
|
||||
finally:
|
||||
if tg_bak is not None:
|
||||
tg.write_text(tg_bak, encoding="utf-8")
|
||||
else:
|
||||
try:
|
||||
tg.unlink()
|
||||
except Exception:
|
||||
pass
|
||||
put_tasks(tk_bak)
|
||||
if bak_g is not None:
|
||||
gj.write_text(bak_g, encoding="utf-8")
|
||||
else:
|
||||
try:
|
||||
gj.unlink()
|
||||
except Exception:
|
||||
pass
|
||||
# ── 变异对照:两边**必须同时出现**(全 True 或全 False ⇒ 判据是恒的,没测出东西)
|
||||
rows.append(("🔬 变异对照:中文过 ⇒ 全 False,非过 ⇒ 全 True(⛔ 防恒绿/恒红)",
|
||||
(not any(o_cn)) and all(o_no)))
|
||||
return rows
|
||||
|
||||
|
||||
@case("目标三态:『没有判据』⛔ 不等于『全过』(防『读不到被当成已完成』)")
|
||||
def t_goal_undeclared():
|
||||
"""🔴 2026-09-30 实测踩到(**跑整体验证时发现**):`acceptance_state` 里**只有 `_说明`/`_更新`
|
||||
@@ -1055,18 +1279,80 @@ def t_lock_held():
|
||||
DEAF_MIB = 9_500_000 # ⚠️ 与 `collabd._deaf_sids()` 的阈值**逐字同源**(那边改了这里必须跟)
|
||||
|
||||
|
||||
def _make_sparse(f) -> bool:
|
||||
"""把已打开的**文件对象**标成 NTFS 稀疏文件(Windows);非 Windows 直接返回 True。
|
||||
|
||||
🔴🔴 2026-10-05 修一处**实测坐实了的假认知**(pitfalls P0-84):
|
||||
本函数的老注释写着「用 `truncate` 建**稀疏文件**……⛔ 不真写 10 MiB」。
|
||||
**在 Windows 上这是错的** —— `truncate()` 走的是 `SetEndOfFile`,
|
||||
⛔ 不会自动把文件标成稀疏 ⇒ **逻辑大小与实占 1:1**。
|
||||
实测:`open(p,"wb").truncate(9_600_000)` 建 10 个档,
|
||||
逻辑 91.6 MB/实占 91.6 MB(不是"看起来大、实际很小")。
|
||||
⇒ 单次 `selftest` 真占 ~60 MB;而 `install.py --verify` 每跑一次就跑一次 selftest,
|
||||
**再叠加宿主的 safe-delete shim**(删工作区文件一律改道回收站)⇒
|
||||
那些字节**不会消失,只是搬进回收站**,实测攒到 13.8 GB(`tmp/selftest`)。
|
||||
⛔ 不许再靠"truncate 不真写"这个假设省事。
|
||||
|
||||
正解:写数据**之前**先 `FSCTL_SET_SPARSE`,之后再 truncate 才真稀疏。
|
||||
⚠️ 顺序不能反 —— 先 truncate 再加标志,已分配的簇不会自动归还。
|
||||
"""
|
||||
if os.name != "nt":
|
||||
return True
|
||||
try:
|
||||
import ctypes
|
||||
import msvcrt
|
||||
from ctypes import wintypes
|
||||
|
||||
FSCTL_SET_SPARSE = 0x000900C4
|
||||
GENERIC_WRITE = 0x40000000
|
||||
FILE_SHARE_RW = 0x00000001 | 0x00000002
|
||||
|
||||
k32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
||||
k32.CreateFileW.restype = wintypes.HANDLE
|
||||
k32.CreateFileW.argtypes = [wintypes.LPCWSTR, wintypes.DWORD, wintypes.DWORD,
|
||||
wintypes.LPVOID, wintypes.DWORD, wintypes.DWORD,
|
||||
wintypes.HANDLE]
|
||||
k32.DeviceIoControl.argtypes = [wintypes.HANDLE, wintypes.DWORD, wintypes.LPVOID,
|
||||
wintypes.DWORD, wintypes.LPVOID, wintypes.DWORD,
|
||||
ctypes.POINTER(wintypes.DWORD), wintypes.LPVOID]
|
||||
OPEN_EXISTING = 3
|
||||
h = k32.CreateFileW(str(f.name), GENERIC_WRITE, FILE_SHARE_RW, None,
|
||||
OPEN_EXISTING, 0x00000080, None) # FILE_ATTRIBUTE_NORMAL
|
||||
if h == wintypes.HANDLE(-1).value or not h:
|
||||
return False
|
||||
try:
|
||||
br = wintypes.DWORD(0)
|
||||
ok = k32.DeviceIoControl(h, FSCTL_SET_SPARSE, None, 0, None, 0,
|
||||
ctypes.byref(br), None)
|
||||
return bool(ok)
|
||||
finally:
|
||||
k32.CloseHandle(h)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _fake_cfg(sizes: dict, day_offset: int = 0) -> Path:
|
||||
"""在**测试区内**造一份假 `logs/<日期>/sdk/conversations/`(⛔ 生产日志目录一个字节不动)。
|
||||
|
||||
`_deaf_sids()` 只读 `CODEBUDDY_CONFIG_DIR/logs/...` ⇒ 换掉配置根即可**完全离线**复现本坑。
|
||||
用 `truncate` 建**稀疏文件**:秒级拿到 10 MiB 的**体积读数**(`getsize`),⛔ 不真写 10 MiB。
|
||||
`day_offset`:0=今天(默认)、1=昨天 —— 🔴 **按天分目录**正是"永久判哑"那个坑的土壤。
|
||||
|
||||
🔴 体积用「**先标稀疏 → 再 `truncate`**」造(见 `_make_sparse`):
|
||||
目标是秒级拿到 10 MiB 的**体积读数**(`getsize`),而**实占尽量小**。
|
||||
⚠️ 老实现只 `truncate`、⛔ 没标稀疏 ⇒ 在 Windows 上**真写**(P0-84 实测 1:1)。
|
||||
⚠️ 这里**不追求零占用**:只要"不真写满"即可 —— 某些文件系统不支持稀疏时
|
||||
`_make_sparse` 返回 False,此时行为与老实现相同(真写),
|
||||
**但会打一条 stderr 提示**,⛔ 不许静默退化。
|
||||
"""
|
||||
_d = time.strftime("%Y-%m-%d", time.localtime(time.time() - 86400 * int(day_offset)))
|
||||
root = TEST_WS / "fake-cfg" / "logs" / _d / "sdk" / "conversations"
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
for _sid, _n in (sizes or {}).items():
|
||||
with open(root / (_sid + ".log"), "wb") as f:
|
||||
_p = root / (_sid + ".log")
|
||||
with open(_p, "wb") as f:
|
||||
if not _make_sparse(f):
|
||||
print(f"⚠ _fake_cfg: {_p.name} 未能标成稀疏文件 ⇒ 本次将真占用 {_n} 字节",
|
||||
file=sys.stderr)
|
||||
f.truncate(int(_n))
|
||||
return TEST_WS / "fake-cfg"
|
||||
|
||||
@@ -2546,16 +2832,20 @@ def t_board_multi_goal():
|
||||
_in.mkdir(parents=True, exist_ok=True)
|
||||
# ⚠️ **三个目标**,⛔ 不是一个 —— 单块时任何排序键都「升序」,变异根本抓不到
|
||||
#(实测栽过:删掉 `blocks.sort(...)` 判据照样绿)。
|
||||
# 🔴🔴 2026-10-06:**`declared_at` 必须"乱序"给**(t2 最老、t0 居中、t3 最新),
|
||||
# 而且**文件出现次序 = 声明时间倒序** ⇒ 只要排序失效,输出顺序**立刻不一样**。
|
||||
# ⛔ 若三个时间给成同序/全相同 ⇒ 排序与否输出无差异 ⇒ **判据恒绿**(同族已栽多次)。
|
||||
(_in / "goal.json").write_text(_j8.dumps(
|
||||
{"id": "t0", "short": "甲", "title": "甲目标", "lifecycle": "进行中",
|
||||
"run": "active", "acceptance_state": {"A1": "过|x"}}, ensure_ascii=False),
|
||||
"run": "active", "declared_at": "2026-10-02T10:00",
|
||||
"acceptance_state": {"A1": "过|x"}}, ensure_ascii=False),
|
||||
encoding="utf-8")
|
||||
_gd8 = _in / "goals"
|
||||
_gd8.mkdir(parents=True, exist_ok=True)
|
||||
for _i in (2, 3):
|
||||
for _i, _at in ((2, "2026-10-01T09:00"), (3, "2026-10-03T11:00")):
|
||||
(_gd8 / ("%d.json" % _i)).write_text(_j8.dumps(
|
||||
{"id": "t%d" % _i, "short": "乙%d" % _i, "title": "乙目标%d" % _i,
|
||||
"lifecycle": "进行中", "run": "active",
|
||||
"lifecycle": "进行中", "run": "active", "declared_at": _at,
|
||||
"acceptance_state": {"A1": "过|x"}}, ensure_ascii=False),
|
||||
encoding="utf-8")
|
||||
# 🔴🔴 **env 必须「白名单重建」,⛔ 不能 `dict(os.environ, ...)`**:
|
||||
@@ -2576,34 +2866,63 @@ def t_board_multi_goal():
|
||||
_bj = _in / "board.json"
|
||||
_d8 = _j8.loads(_bj.read_text(encoding="utf-8")) if _bj.exists() else {}
|
||||
_gs8 = _d8.get("goals") or []
|
||||
_hots = [g.get("tab_hot") for g in _gs8]
|
||||
_R.append(("后端给了 `tab_hot` 排序键(实得:%s)" % (_hots[:1] or "无"),
|
||||
bool(_hots) and all(h is not None for h in _hots)))
|
||||
_R.append(("后端 `tab_hot` **升序**(最新活动在前):%s" % _hots,
|
||||
_hots == sorted(_hots)))
|
||||
# 🔴 这条要能抓「无活动当0」那个变异,靠**多个目标**才看得出排序差异:
|
||||
# 变异前 = [1e9](单块,升序也成立);变异后 = [0] ⇒ 抓不到。
|
||||
# ⇒ 这里**直接钉住数值**:没活动的必须是垫底大数,⛔ 不是 0。
|
||||
_R.append(("没活动的目标 `tab_hot`=**垫底大数**(⛔ 当 0 就会排最前):%s" % _hots,
|
||||
bool(_hots) and all(h == 1e9 for h in _hots)))
|
||||
# 🔴🔴 2026-10-06 用户定案:「**tab 的排列顺序应该按创建时间顺序**」(推翻 10-04 的活跃度排序)。
|
||||
# ⇒ 判据整体改判 **`tab_created`(=`declared_at`)升序**。
|
||||
# ⚠️ **必须钉住"实际次序"**,⛔ 不能只判"升序":夹具里三块时间**全不同** ⇒
|
||||
# 升序成立**等价于**真的按时间排;但若哪天夹具退化成同值,升序就恒真 ⇒
|
||||
# ⇒ 所以**额外钉一条"首个是最老的"**(`2026-10-01`)当冗余保险。
|
||||
_crts = [g.get("tab_created") for g in _gs8]
|
||||
_R.append(("后端给了 `tab_created` 排序键(实得:%s)" % (_crts[:3] or "无"),
|
||||
bool(_gs8) and all(c is not None for c in _crts)))
|
||||
_R.append(("后端 `tab_created` **升序**(最早创建的在最前):%s" % _crts,
|
||||
_crts == sorted(_crts)))
|
||||
# 🔴 抓「拿活跃度当创建时间」/「键取错字段」:夹具里 **t2 最老**(10-01)
|
||||
# ⇒ 排第一的必须是它;⛔ 若还是甲(goal.json,文件序第一)= 没排序。
|
||||
# ⚠️ `short` 在块的 **`goal` 子字典**里(⛔ 不在块顶层 —— 我第一版就取错层,
|
||||
# 判据恒红;实测栽过)。
|
||||
_R.append(("首个 tab = **最早创建的目标**(夹具里是最老的 `乙2`):%s"
|
||||
% [str((g.get("goal") or {}).get("short") or "") for g in _gs8][:1],
|
||||
bool(_gs8)
|
||||
and str((_gs8[0].get("goal") or {}).get("short") or "") == "乙2"))
|
||||
# 🔴 抓「缺 `declared_at` 的块被当成 0/空串 ⇒ 排到最前」:
|
||||
# ⚠️ **必须真造一块没有 `declared_at` 的**(t4,且它的标题明显"最老",人眼会想放前面)
|
||||
# ⇒ 它必须**垫底**(`tab_created == "~"` 且 `tab_i` 最大),⛔ 不许因为读不到就抢到第一。
|
||||
# ⛔ 不造这一块 ⇒ 本判据**恒真**(列表里没有 `~`)= 假绿(同族已栽多次)。
|
||||
_gd8.joinpath("4.json").write_text(_j8.dumps(
|
||||
{"id": "t4", "short": "丙4", "title": "丙目标4", "lifecycle": "进行中",
|
||||
"run": "active", "acceptance_state": {"A1": "过|x"}}, ensure_ascii=False),
|
||||
encoding="utf-8")
|
||||
_sp8.run([_s8.executable, str(HERE / "board.py"), "--out", str(_in / "board.json")],
|
||||
capture_output=True, text=True, timeout=180, env=_env8,
|
||||
encoding="utf-8", errors="replace")
|
||||
_d9 = _j8.loads((_in / "board.json").read_text(encoding="utf-8")) \
|
||||
if (_in / "board.json").exists() else {}
|
||||
_gs9 = _d9.get("goals") or []
|
||||
_c9 = [g.get("tab_created") for g in _gs9]
|
||||
_i9 = [g.get("tab_i") for g in _gs9]
|
||||
_n9 = len(_gs9)
|
||||
_R.append(("缺 `declared_at` 的块 **垫底**(⛔ 不许读不到就排最前):%s" % _c9,
|
||||
_n9 == 4 and _c9[-1] == "~" and _c9[:3] == sorted(_c9[:3])
|
||||
and _i9 == [0, 1, 2, 3]))
|
||||
_R.append(("垫底块的 `tab_i` = **最后一位**(⛔ 不是靠 '~' 撞巧):%s" % _i9,
|
||||
_n9 == 4 and _i9[-1] == 3))
|
||||
_R.append(("后端给了 `tab_more`(前端⛔ 不自己重算 `gs.length-6`):%s"
|
||||
% [g.get("tab_more") for g in _gs8],
|
||||
bool(_gs8) and all(g.get("tab_more") == max(0, len(_gs8) - 6)
|
||||
for g in _gs8)))
|
||||
# 🔴 抓「排序后没重编序号」:多块时 `tab_i` 必须恰好是 0..n-1
|
||||
# (⛔ 排序前的旧序号会留着 ⇒ 前端 `data-gi` 与位置对不上 ⇒ 点A切到B)。
|
||||
# 🔴 抓「干脆不排序」:三块 `tab_hot` 全相同(都没活动)⇒ 排序与否**输出无差异**
|
||||
# ⇒ 上面那条「升序」永远成立、永远绿(实测栽过:删掉 `blocks.sort(...)` 判据照绿)。
|
||||
# ⇒ 只能**钉住代码里存在排序语句**(⛔ 这是源码级判据的正当用途:验"语句在不在")。
|
||||
# 🔴 抓「干脆不排序」:夹具里文件出现次序**本就是时间倒序**(t0 居中、t2 最老在后加)
|
||||
# ⇒ 排序与否**输出有差异** ⇒ 上面那条"升序"能抓到;此处再钉一条源码级保险
|
||||
# (⛔ 这是源码级判据的正当用途:验"语句在不在")。
|
||||
_bsrc = (HERE / "board.py").read_text(encoding="utf-8")
|
||||
_R.append(("`blocks.sort(...)` 真在(⛔ 键全相同时删掉它输出无差异、判据会假绿)",
|
||||
"blocks.sort(key=lambda b: (b.get(\"tab_hot\"" in _bsrc))
|
||||
# 🔴 抓「取 max 变取 min」:⛔ **行为上抓不到** —— 夹具里几块 `tab_hot` 全相同
|
||||
# (都没活动)⇒ `min` 与 `max` 结果**一样** ⇒ 判据照样绿(实测栽过)。
|
||||
# ⇒ 只能钉住**聚合函数名**(验"语句在不在、用的哪个函数"这种纯形式问题,源码级是正当的)。
|
||||
_R.append(("排序键取的是 **min**(最新活动在前;⛔ 取 max = 越旧越靠前):%s"
|
||||
% ("min(_ages)" in _bsrc),
|
||||
"min(_ages)" in _bsrc and "max(_ages)" not in _bsrc))
|
||||
_R.append(("`blocks.sort(...)` 真在且按 `tab_created`:%s"
|
||||
% ("tab_created" in _bsrc and "blocks.sort(" in _bsrc),
|
||||
'blocks.sort(key=lambda b: (b.get("tab_created"' in _bsrc))
|
||||
# 🔴 抓「又改回活跃度排序」(口径回退):⛔ 源码里不许再出现 `tab_hot` 当排序键。
|
||||
_R.append(("排序键**不再是** `tab_hot`(⛔ 防口径回退):%s"
|
||||
% ("blocks.sort(key=lambda b: (b.get(\"tab_hot\"" not in _bsrc),
|
||||
"blocks.sort(key=lambda b: (b.get(\"tab_hot\"" not in _bsrc))
|
||||
_tis = [g.get("tab_i") for g in _gs8]
|
||||
_R.append(("多目标时 `tab_i` **重编为 0..n-1**(⛔ 不重编 = 前端点A切到B):%s" % _tis,
|
||||
len(_gs8) > 1 and _tis == list(range(len(_gs8)))))
|
||||
@@ -3740,12 +4059,44 @@ def t_execution_doc():
|
||||
prod_g = prod_g_rel
|
||||
p_goal = m._check_prompt(1, "目标", "queue-empty")
|
||||
p_res = m._check_prompt(1, "目标", "sessions-ended")
|
||||
# 🔴🔴 2026-10-05 补(见 P0-86):**登记值必须与代码口径一致**。
|
||||
# 实测三区全中:`execution_doc` 是 10-05 改口径(加 `执行会话/` 一层)**之前**写进
|
||||
# `goal.json` 的旧路径,而**全脚本没有任何代码会写这个字段**
|
||||
# (`collabd.py`/`goalctl.py`/`init_workspace.py` 都不写)⇒ 它永远停在旧值上。
|
||||
# ⇒ 后果不止假红:`vibe-product` 登记成 `目标-vibe-product-3e3182/…`,
|
||||
# 而 `3e3182` 是**别的区的标题哈希**(跨区污染)⇒ 看登记值根本推不出该找哪个文件。
|
||||
# ✅ 判据=「生产登记值 == 按本区 title/short 算出的值」;对不上就**报红**(是漂移,⛔ 不是数据)。
|
||||
# ⚠️ 归一化(`/`↔`\`、大小写)—— 否则 Windows 上又多一条假红。
|
||||
def _nrm(_s):
|
||||
return str(_s or "").replace("\\", "/").strip("/").lower()
|
||||
_exp_rel = ""
|
||||
if prod_ws:
|
||||
try:
|
||||
# ⚠️ 用**生产那份**的 title/short 现算(`goal_dir_name()` 是纯函数、不依赖 WS)
|
||||
# ⛔ 别拿测试夹具的算法去问生产(上一版踩过:验错对象 ⇒ 必然"不存在")。
|
||||
_pg = _j.loads((_pl.Path(prod_ws) / "tmp/supervise-inbox/goal.json")
|
||||
.read_text(encoding="utf-8"))
|
||||
_exp_rel = "%s/%s" % (m.goal_dir_name(str(_pg.get("title") or ""),
|
||||
str(_pg.get("short") or "")),
|
||||
m.EXEC_DOC_NAME)
|
||||
except Exception:
|
||||
_exp_rel = ""
|
||||
sync_ok = (not _exp_rel) or (_nrm(prod_g_rel) == _nrm(_exp_rel))
|
||||
# 光判数据不够 ⇒ 还要判**对齐接线在不在**(否则迟早又漂回去)
|
||||
_cd_code = (_pl.Path(HERE) / "collabd.py").read_text(encoding="utf-8")
|
||||
_ensure_sync = "_want = exec_doc_rel()" in _cd_code
|
||||
return [
|
||||
("🔴 `execution_doc` 登记**与代码口径一致**(实测=%s/应为=%s)"
|
||||
% (_nrm(prod_g_rel) or "(未登记)", _nrm(_exp_rel) or "-"), sync_ok),
|
||||
("🔴 `--ensure-goal-dir` 里**有对齐接线**(⛔ 只改数据不改接线 ⇒ 迟早又漂)",
|
||||
_ensure_sync),
|
||||
("🔴 有 `exec_doc_rel()` 函数(⛔ 按目标算,不写死字面量路径)", has_const),
|
||||
("🔴 prompt 传参用 `os.path.join(_g, exec_doc_rel())`(单一真源)", has_wiring),
|
||||
("🔴 定位到生产工作区(⛔ 不许回落测试夹具;%s)" % (prod_ws or "定位不到"), bool(prod_ws)),
|
||||
("🔴 生产工作区该文件**真存在**(%s)" % (prod_ws + "/" + rel if prod_ws else "-"), exists),
|
||||
("⚠️ 生产 `goal.json` 已登记 `execution_doc`(%s)" % (prod_g or "未登记"), bool(prod_g)),
|
||||
# ⚠️ 原第 5 条「生产 `goal.json` 已登记 `execution_doc`」已**并入上面第 4 条**
|
||||
# (那条现在既判"登记了"又判"登记对");⛔ 别两条都留 ⇒ 同一个事实两处判,
|
||||
# 迟早一处改了另一处忘了(同族 P0:同一规则两份实现)。
|
||||
("目标检查 prompt 把它列为**判断依据**", "目标执行状态文档" in p_goal),
|
||||
("⛔ 明确禁止再去工作区翻文件(用户报障:到处找⇒被判重复执行)",
|
||||
"别再去工作区里翻文件" in p_goal),
|
||||
@@ -3914,15 +4265,32 @@ def t_goal_dir_mechanism():
|
||||
# ="字面反斜杠+s",⛔ 匹配不到空格 ⇒ **误报**,而产品代码其实完全正确:
|
||||
# 实测 `目标-s-t-c245d9` 无非法字符、无空格 ⇒ 教训=**判据自身的转义错误会伪装成产品缺陷**,
|
||||
# 改完要回头确认"被测对象到底对不对",⛔ 别直接改产品去迎合判据。
|
||||
# ⚠️ 判据拆成**两条**:非法字符 与 空格 分开(踩过:合成一条时 `\\s` 写在 raw 串里
|
||||
# ="字面反斜杠+s",⛔ 匹配不到空格 ⇒ **误报**,而产品代码其实完全正确:
|
||||
# 实测 `目标-s-t-c245d9` 无非法字符、无空格 ⇒ 教训=**判据自身的转义错误会伪装成产品缺陷**,
|
||||
# 改完要回头确认"被测对象到底对不对",⛔ 别直接改产品去迎合判据。
|
||||
("目录名已清洗非法字符(实测 %r)" % clean,
|
||||
not _re.search(r"[\\/:*?\"<>|]", clean)),
|
||||
# 🔴🔴 2026-10-05 **同一课第二遍**:加了 `执行会话/` 父层后,`clean` 里**必然含一个 `/`**
|
||||
# ⇒ 老判据 `not re.search(r"[\\/:*?\"<>|]", clean)` **当场误报**(同一族第 2 次:
|
||||
# 判据自身的假设过期 ⇒ 伪装成产品缺陷)。
|
||||
# ✅ 正解=**只清洗"末级目录名"**再判 —— 父层分隔符是**结构**,⛔ 不是"未清洗的非法字符"。
|
||||
("目录名已清洗非法字符(只看末级:实测 %r)" % clean.split("/")[-1],
|
||||
not _re.search(r"[\\/:*?\"<>|]", clean.split("/")[-1])),
|
||||
("目录名无空格(命令行不必处处加引号)", " " not in clean),
|
||||
("目录名无空格(命令行不必处处加引号)", " " not in clean),
|
||||
("🔴 文档路径与目录**同源**(%s)" % doc, same_src),
|
||||
# 🔴🔴 2026-10-05 新增(用户定案逐字:「<工作区根>/执行会话/目标-xxx-xxxxxx/ 改成这样」)
|
||||
# ⇒ 钉住"目标文件夹收在 `执行会话/` 一层下",⛔ 别让它悄悄退回工作区根。
|
||||
# ⚠️ 判据写**两级**一起:① 前缀是 `执行会话/` ② 末尾一段仍以 `目标-` 开头
|
||||
# —— ⛔ 只判①会把「执行会话/随便什么名」也放行;只判②则管不住这一层。
|
||||
# 🔴🔴 **变异对照抓到的自欺**(2026-10-05 实测,第 3 次同族):
|
||||
# 第一版写的是 `d1.startswith(m._GOAL_DIR_PARENT + "/")` —— 拿**被测的同一个常量**
|
||||
# 当下界。把 `_GOAL_DIR_PARENT` 变异成 `""` 后,判据变成 `startswith("/")`
|
||||
# ⇒ **照样为真** ⇒ 拆掉父层竟然全绿(PASS 95/FAIL 2,与正确实现**逐字相同**)。
|
||||
# ✅ 正解=**写死期望字面 `"执行会话/"`**:判据必须独立于被测对象,
|
||||
# 否则"判据与实现同源 ⇒ 一起错 ⇒ 恒绿"(同族:P0-20/P0-13「判据写死期望值」的反面)。
|
||||
("🔴 目标文件夹在 `执行会话/` 一层下(实测 %r)" % d1,
|
||||
d1.startswith("执行会话/") and d1.split("/")[-1].startswith("目标-")),
|
||||
("🔴 `goal_dir_rel()` 也同形(实测 %r)" % m.goal_dir_rel(),
|
||||
m.goal_dir_rel().startswith("执行会话/")),
|
||||
("🔴 文档路径跟到新层(`exec_doc_rel()`=%s)" % doc,
|
||||
doc.startswith("执行会话/")),
|
||||
("⛔ 目录名**不再落在工作区根**(回归护栏:实测 %r)" % d1, "/" in d1),
|
||||
("🔴 两份 prompt 都带目标目录(%s)" % gd, gd in p_res and gd in p_goal),
|
||||
("🔴 prompt 要求产物落目标目录 + 禁散到 交付物/docs",
|
||||
("产物一律落这里" in p_res) and ("别再散到" in p_goal)),
|
||||
@@ -4208,19 +4576,45 @@ def t_tab_peer_workspace():
|
||||
finally:
|
||||
shutil.rmtree(_td, ignore_errors=True)
|
||||
# ⑩ 生产侧:⛔ **不硬编码路径** —— 指不到真工作区 ⇒ SKIP(⛔ 不假装通过)
|
||||
# 🔴🔴 2026-10-05 修一处**恒红判据**(实测 `PASS 96/FAIL 1` → `95/2`,多出的正是本条的 FAIL):
|
||||
# 老写法 `prod_ok = (_n >= 1 and len(pp) == _n and len(ap) == 1)` 要求
|
||||
# **登记几个 peer 就必须出几格**。可是 peer 工作区**没建目标时本来就不该出格** ——
|
||||
# 同一组判据的第 ⑪ 条(`gone_ok`)写的正是这个语义("peer 已不在会话列表 ⇒ 不占 tab"),
|
||||
# **两条自相矛盾**:⑩ 要 `len(pp)==_n`,⑪ 又认可少出格。
|
||||
# ⇒ 实测现场:ai1net 登记了 2 个 peer(`vibe-product` ✅ 有 `goal.json` /
|
||||
# `agent-product` ❌ 还没建目标)⇒ `_n=2` 但 `len(pp)=1` ⇒ **必红**。
|
||||
# ⚠️ 这是"恒红比漏网更坏"的又一实例:只要有任何 peer 区没活跃目标,基线就永远红
|
||||
# ⇒ 之后跑任何变异都"看着有红",验证作废。按纪律③「基线先全绿」必须先修它。
|
||||
# 正解=判**包含关系 + 上限**,⛔ 不是判**数量相等**:
|
||||
# · 出格数 ≤ 登记数(⛔ 出格数超登记 ⇒ 有来源不明的格,真问题)
|
||||
# · 每一格都必须在登记表里(⛔ 否则是把别区数据挂到本区名下)
|
||||
# · 本区活跃目标恰好 1 个(⛔ 多/少都说明看板认错了 active)
|
||||
# ⚠️ 放宽后**仍可证伪**:① 把某格来源改成本区 ⇒ `pp_set ⊆ reg` 失败;
|
||||
# ② 篡改出格数 > 登记数 ⇒ 上限失败;③ active 改 0/2 ⇒ 失败。均有变异可抓。
|
||||
_pcfg = WS / ".workbuddy" / "collab" / "collabd.config.json"
|
||||
if not _pcfg.is_file():
|
||||
prod_ok, prod_txt = True, "SKIP:读不到生产配置(%s)" % _pcfg
|
||||
else:
|
||||
try:
|
||||
_n = len((json.loads(_pcfg.read_text(encoding="utf-8")) or {}).get(
|
||||
"peer_workspaces") or [])
|
||||
# ⚠️ 两侧粒度不同,⛔ 不能直接比字符串:
|
||||
# · `collabd.config.json` 的 `peer_workspaces` = **全路径**(`E:/ProgramData/AIProject/vibe-product`)
|
||||
# · `goal_files()` 出的 `peer` 字段 = **短名**(`vibe-product`,看板 tab 就用它做标题)
|
||||
# 第一版直接 `set(pp) <= set(_reg)` ⇒ 恒 False(`vibe-product` ≠ 全路径)⇒ **判据写成了恒红**。
|
||||
# 正解:把登记的全路径**取 basename** 再比。
|
||||
_reg_paths = [str(x).replace("\\", "/") for x in
|
||||
((json.loads(_pcfg.read_text(encoding="utf-8")) or {}).get(
|
||||
"peer_workspaces") or [])]
|
||||
_reg = [p.rstrip("/").rsplit("/", 1)[-1].lower() for p in _reg_paths]
|
||||
_n = len(_reg)
|
||||
gp = _load_board(_pcfg, None, "bd_peer_prod").goal_files()
|
||||
pp = [g.get("peer") for g in gp if g.get("peer")]
|
||||
pp = [str(g.get("peer")).rstrip("/").rsplit("/", 1)[-1].lower()
|
||||
for g in gp if g.get("peer")]
|
||||
ap = [g for g in gp if g.get("active")]
|
||||
prod_ok = (_n >= 1 and len(pp) == _n and len(ap) == 1)
|
||||
prod_txt = "生产配置登记 %d 个 peer ⇒ %d 格(peer=%s,active %d)" % (
|
||||
_n, len(gp), pp, len(ap))
|
||||
# 出格数 ≤ 登记数(peer 没建目标 ⇒ 少出格=正常)∧ 每格都在登记表里 ∧ active 恰 1
|
||||
prod_ok = (_n >= 1 and len(pp) <= _n and set(pp) <= set(_reg) and len(ap) == 1)
|
||||
_miss = [r for r in _reg if r not in set(pp)]
|
||||
prod_txt = ("生产配置登记 %d 个 peer ⇒ %d 格(peer=%s,active %d)"
|
||||
"|未出格(无活跃目标)=%s" % (_n, len(gp), pp, len(ap), _miss))
|
||||
except Exception as e: # pragma: no cover
|
||||
prod_ok, prod_txt = False, "抛异常:%s" % e
|
||||
return [
|
||||
@@ -4357,14 +4751,13 @@ def t_view_takes_all_scoped_fields():
|
||||
a, b2 = gs[0], gs[1]
|
||||
_IDENT = {"key", "peer", "ws_root", "active", "life", "life_at", "life_by", "peer_scope"}
|
||||
# 🔴🔴 2026-10-05 12:0x 归位两个**后端排序键**(实测取证,不是为了让判据变绿而塞进豁免):
|
||||
# `tab_hot`=该块最近会话活动距今分钟数、`tab_i`=排序后重编的 tab 序号
|
||||
# —— 二者由 `board.py` 在 `blocks.sort(key=…)` 里**当场用掉**(board.py L1883-1887),
|
||||
# 前端 `board.html` 对它们的**消费点命中数 = 0**(`tab_i` grep 命中 0;`tab_hot` 仅出现在
|
||||
# 一句解释「排序由后端做」的注释里)⇒ 它们**不是视图字段**,
|
||||
# `tab_created`=该块的 `declared_at`(排序键)、`tab_i`=排序后重编的 tab 序号
|
||||
# —— 二者由 `board.py` 在 `blocks.sort(key=…)` 里**当场用掉**,
|
||||
# 前端 `board.html` 对它们的**消费点命中数 = 0**(它们只用于后端排序)⇒ **不是视图字段**,
|
||||
# ⛔ 要求 `scopeView()` 去取,等于要求视图去取一个它根本不渲染的东西。
|
||||
# ⚠️ 反向自证:判据**仍然有牙** —— 本次真漏的 `ws_name`(渲染点 `renderProject()` L660 读
|
||||
# `d.ws_name`)**不在**此表内,修它之前判据照红(见变异对照)。
|
||||
_IDENT |= {"tab_hot", "tab_i"}
|
||||
_IDENT |= {"tab_created", "tab_i"}
|
||||
_diff = [k for k in sorted(set(a.keys()) | set(b2.keys())) if k not in _IDENT
|
||||
and json.dumps(a.get(k), sort_keys=True, ensure_ascii=False)
|
||||
!= json.dumps(b2.get(k), sort_keys=True, ensure_ascii=False)]
|
||||
@@ -4686,6 +5079,24 @@ def t_peer_supervise_sweep():
|
||||
|
||||
|
||||
|
||||
def _tail_has_board_note(code: str) -> bool:
|
||||
"""🔴 2026-10-05:判「末尾『后续协作』段是否说清**本区不起看板**」。
|
||||
|
||||
⚠️ **为什么不能全文 `in` 一把梭**(本轮实测踩到):`init_workspace.py` 里
|
||||
`peer_workspaces` 全文出现 **3 次**、`不起看板` **2 次**,其中**多数在注释里**
|
||||
⇒ 用 `"不起看板" in src` 判,**把那段 print 整块删掉后判据照样绿**(恒绿假修)。
|
||||
✅ 正解=**锚定到「✅ 完成。后续协作」之后的那段代码**(调用方传进来的 `code` 已剥注释,
|
||||
⇒ 注释里的字样不会命中),且**两句 print 都要在**:
|
||||
· 一句说「本区⛔ 不起看板」(否则用户以为要自己起一份)
|
||||
· 一句指路「加进主工作区的 `peer_workspaces`」(否则知道不起、却不知道去哪并看)
|
||||
"""
|
||||
i = code.find("✅ 完成。后续协作")
|
||||
if i < 0:
|
||||
return False
|
||||
seg = code[i:i + 1500]
|
||||
return ("不起看板" in seg) and ("peer_workspaces" in seg)
|
||||
|
||||
|
||||
@case("初始化脚本:**指向本区副本**(⛔ 不许再让用户跑技能目录那份)")
|
||||
def t_init_points_to_own_copy():
|
||||
"""🔴 2026-10-03 修的误导:`init_workspace.py` 原先 `--ensure-goal-dir` 与末尾
|
||||
@@ -4710,8 +5121,16 @@ def t_init_points_to_own_copy():
|
||||
"_fallback" in code),
|
||||
("回落时**打印说明**(⛔ 静默回落=用户以为在跑副本)",
|
||||
code.count("print") >= 2 and "回落" in code),
|
||||
# 🔴 2026-10-05 改判据:末条原来要求 `_bd_show` 存在(=末尾会打印「起看板」命令)。
|
||||
# ⚠️ 那是**旧口径**:SKILL.md:809 早有 10-03 定案「看板只保留一份(主工作区那份)」,
|
||||
# 各区⛔ 不起看板 ⇒ 新区末尾**本就不该**再打印 board 启动命令。
|
||||
# ⇒ 判据跟着改:仍然要求「后续协作提示指向副本」(`_cd_show`),
|
||||
# 但另一条换成**新口径的事实**——末尾必须说清「本区不起看板 + 要并看走 peer_workspaces」。
|
||||
# ⛔ 别把 `_bd_show` 塞回去凑数:那会把出错的形态提示又引回来。
|
||||
("末尾「后续协作」提示也指向副本/带说明(⛔ 别再让人跑目录那份)",
|
||||
"_cd_show" in code and "_bd_show" in code),
|
||||
"_cd_show" in code),
|
||||
("末尾提示**不起本区看板**、要并看走 `peer_workspaces`(2026-10-03 定案:看板只保留一份)",
|
||||
_tail_has_board_note(code)),
|
||||
]
|
||||
return res
|
||||
|
||||
@@ -5132,6 +5551,14 @@ def t_pkg_hygiene():
|
||||
# ⛔ 排除三个"提到备份名是**机制本身/说明书**"的文件
|
||||
if rel in ("install.py", "scripts/selftest.py"):
|
||||
continue
|
||||
# 🔴🔴 2026-10-05 加:`install.log` = **运行日志**(同文件上一条判据自己写着
|
||||
# 「运行日志,⛔ 不属能力件」)⇒ 它里面「已备份 → …bak-session-mechanism-…」
|
||||
# 是**安装时的事实陈述**,⛔ 不是注释里的死证据引用。
|
||||
# ⚠️ 它是 **append 型**:每跑一次 `install.py --apply` 就多一行 ⇒ 不排除的话
|
||||
# **判据会随安装次数必然变红**(2026-10-05 实测:装完当场 FAIL 1,与本次改动无关)。
|
||||
# 📌 判据本意="注释不许指向已删的备份";日志**不承担这个语义**。
|
||||
if rel == "install.log":
|
||||
continue
|
||||
if rel == "references/pitfalls.md":
|
||||
# ⛔ 只豁免 P0-45 那一节(讲这个坑的),其它节的死证据仍要报
|
||||
t_all = p.read_text(encoding="utf-8")
|
||||
@@ -5329,6 +5756,13 @@ def t_artifacts_land_in_goal_dir():
|
||||
% (_j.get("dir"), _j.get("inbox")), bool(_j.get("dir")) and _gdir.is_dir())]
|
||||
|
||||
# ② 产物型 md 落点
|
||||
# 🔴🔴 2026-10-05:目标文件夹收进 `执行会话/` 一层(用户定案「<工作区根>/执行会话/目标-xxx/ 改成这样」)
|
||||
# ⇒ 落点判据必须**跟着机制走**(同族第 N 次:判据没跟上改动 ⇒ 假红/假绿)。
|
||||
# ⚠️ 判据从「`parts[0].startswith('目标-')`」扩成**两级**:
|
||||
# `执行会话/目标-xxx/…` 与**裸 `目标-xxx/…`(存量旧目录)都要认**
|
||||
# —— ⛔ 硬切到新形态会把历史产物全判成"散在别处"(那是**误报**,不是事实)。
|
||||
# 📌 只认 `执行会话/` 下一层的 `目标-*`,⛔ 不认 `执行会话/` 里别的杂物
|
||||
# (否则往 `执行会话/` 随便丢个 md 就能骗过判据)。
|
||||
stray, inside = [], 0
|
||||
for p in ws.rglob("*.md"):
|
||||
rel = p.relative_to(ws)
|
||||
@@ -5337,15 +5771,17 @@ def t_artifacts_land_in_goal_dir():
|
||||
continue
|
||||
if p.name in RULE_FILES or p.name.startswith("CODEBUDDY"):
|
||||
continue
|
||||
# 只看「顶层 目标-*|交付物|其他一层目录」这一层,⛔ 不递归进目标文件夹内部
|
||||
# 只看「顶层 目标-*|执行会话/目标-*|交付物|其他一层目录」这一层,⛔ 不递归进目标文件夹内部
|
||||
if len(parts) > 1 and parts[0].startswith("目标-"):
|
||||
continue
|
||||
if (len(parts) > 2 and parts[0] == "执行会话" and parts[1].startswith("目标-")):
|
||||
continue
|
||||
if len(parts) == 1:
|
||||
stray.append(str(rel)); continue
|
||||
if parts[0] == "交付物":
|
||||
inside += 1; continue
|
||||
stray.append(str(rel))
|
||||
res.append(("产物型 md 都落在「目标-*/交付物」内(⛔ 代码/规则/归档除外);"
|
||||
res.append(("产物型 md 都落在「执行会话/目标-*/ 或 目标-*/ 或 交付物/」内(⛔ 代码/规则/归档除外);"
|
||||
"已在目标文件夹或交付物=%d,散在别处=%d %s"
|
||||
% (inside, len(stray), ("→ " + str(stray[:5])) if stray else ""),
|
||||
not stray))
|
||||
@@ -6152,56 +6588,43 @@ def t_mirror_keep_by_name():
|
||||
]
|
||||
|
||||
|
||||
@case("🔴 各工作区自我建立常驻:**副本里也得找得到载体模板**(⛔ 写死包根=每区必红)")
|
||||
def t_keeper_tpl_found_in_copy_layout():
|
||||
"""🔴🔴 2026-10-04 实测踩到的 bug(**用户诉求「各工作区自己能建立常驻」的拦路石**)。
|
||||
@case("🔴 常驻载体形态:**旧看守已彻底清除 + 不许回潮**(2026-10-05 收敛)")
|
||||
def t_keeper_legacy_ps1_form_gone():
|
||||
"""🔴🔴 2026-10-05 **形态收敛的守卫**(取代此前 `t_keeper_tpl_found_in_copy_layout`)。
|
||||
|
||||
**病根**:`_escalate_to_keeper()` 找模板写死
|
||||
`Path(__file__).resolve().parent.parent / "assets" / ...`
|
||||
· 技能目录里对(`<pkg>/scripts/collabd.py` ⇒ `.parent.parent` = `<pkg>`);
|
||||
· **工作区副本里错**(`.workbuddy/collab/collabd.py` ⇒ `.parent.parent` =
|
||||
`<WS>/.workbuddy` ⇒ 去找 `<WS>/.workbuddy/assets/`,**不存在**)
|
||||
⇒ 每个工作区的自我供给**必然返回「⛔ 缺模板」**,
|
||||
而模板其实好好躺在 `<WS>/.workbuddy/skills/session-mechanism/assets/`。
|
||||
**为什么换掉旧的**:旧用例判「副本形态下找不找得到 `assets/start-supervise.ps1.tpl`」,
|
||||
而该模板与「旧 看守」这套形态**已整套废弃** ——
|
||||
任务动作从「壳脚本 -File」改成 **`pythonw.exe` + `supervise-launch.py`**
|
||||
(GUI 子系统 ⇒ 零 conhost ⇒ **不闪黑窗**,用户 2026-10-05 报「又弹了窗口」的直接修复)。
|
||||
⇒ 旧用例连**被测对象都不存在了** ⇒ 恒红、且无法修复 ⇒ **必须删,换成本条**。
|
||||
|
||||
判据**必须变异有效**(旧写法必须红、新写法必须绿):
|
||||
造一个「副本形态」的目录(`<cfg>/collab/collabd.py` +
|
||||
`<cfg>/skills/session-mechanism/assets/*.tpl`),
|
||||
分别用**旧算法**与**新函数**去找 ⇒ 断言「旧红新绿」。
|
||||
⛔ 只断言"函数存在"是**恒真**的 ⇒ 那种判据测不出东西。
|
||||
🔴 本条的职责是**守形态**(防回潮),五条一起判:
|
||||
① 代码里**不许再出现**读那个 `.tpl` 的路径拼接;
|
||||
② 任务动作**必须是** `pythonw.exe` + 启动器;
|
||||
③ ⛔ 动作里不许再把壳脚本当看守(⚠️ 登记任务本身仍走 PowerShell,
|
||||
那是在**登记**、不是在**当看守**,两者别混);
|
||||
④ `CREATE_NO_WINDOW` 在位;
|
||||
⑤ `_find_keeper_tpl` 死代码已清。
|
||||
"""
|
||||
import importlib.util
|
||||
import tempfile as _tf
|
||||
res = []
|
||||
# ① 源码级:模板查找已收进 `_find_keeper_tpl`(⛔ 不再散在 `_escalate_to_keeper` 里写死)
|
||||
src = (HERE / "collabd.py").read_text(encoding="utf-8")
|
||||
res.append(("有 `_find_keeper_tpl()` 这个唯一查找实现", "def _find_keeper_tpl(" in src))
|
||||
res.append(("`_escalate_to_keeper` 改调它(⛔ 不再自己拼路径)",
|
||||
"_tpl = _find_keeper_tpl()" in src))
|
||||
res.append(("⚠️ 旧写法(写死 `parent.parent/assets`)**只在候选里**、⛔ 不再是唯一路径",
|
||||
'Path(__file__).resolve().parent.parent / "assets"' in src
|
||||
and "_cands" in src))
|
||||
|
||||
# ② 行为级+变异对照:造副本形态,旧算法必红、新函数必绿
|
||||
_root = Path(_tf.mkdtemp(prefix="tplchk_"))
|
||||
try:
|
||||
_cfg = _root / ".workbuddy"
|
||||
(_cfg / "collab").mkdir(parents=True)
|
||||
(_cfg / "skills" / "session-mechanism" / "assets").mkdir(parents=True)
|
||||
_tpl = _cfg / "skills" / "session-mechanism" / "assets" / "start-supervise.ps1.tpl"
|
||||
_tpl.write_text("$script = \"__SCRIPT__\"\n", encoding="utf-8")
|
||||
_fake = _cfg / "collab" / "collabd.py"
|
||||
_fake.write_text("# fake copy\n", encoding="utf-8")
|
||||
|
||||
# 变异体=旧算法(写死包根推)
|
||||
_old = Path(str(_fake)).resolve().parent.parent / "assets" / "start-supervise.ps1.tpl"
|
||||
# 新算法=真副本形态优先找 `<WS>/.workbuddy/skills/.../assets/`
|
||||
_new = _cfg / "skills" / "session-mechanism" / "assets" / "start-supervise.ps1.tpl"
|
||||
res.append(("🔴 **变异对照**:旧算法(写死包根)在副本形态下**找不到**(⛔ 恒绿判据测不出东西)",
|
||||
not _old.is_file()))
|
||||
res.append(("✅ 新算法(副本形态)**找得到**模板", _new.is_file()))
|
||||
finally:
|
||||
shutil.rmtree(str(_root), ignore_errors=True)
|
||||
# 🔴🔴 2026-10-05 **必须剔注释 _和 docstring_ 再判**(同一课第 N 遍,这次真栽了):
|
||||
# 考古记录**不止在 `#` 注释里** —— 还大量写在 **docstring**(`"""…"""`)里。
|
||||
# 第一版只剔 `#` 行 ⇒ 744 行那句(在 `_escalate_to_keeper` 的 docstring 里)
|
||||
# **照样命中** ⇒ 误红。⚠️ 「剔注释」≠「剔 docstring」,两者是**两件事**。
|
||||
# ✅ 正解=用 `tokenize` 剔掉 COMMENT + STRING(最稳,不怕多行字符串/嵌套引号)。
|
||||
_code = _strip_comments_and_docs(src)
|
||||
res = []
|
||||
res.append(("⛔ 已无「读 `start-supervise.ps1.tpl`」的路径拼接(形态废弃 ⇒ 不该有人再找它)",
|
||||
"start-supervise.ps1.tpl" not in _code))
|
||||
res.append(("🔴 任务动作=`pythonw.exe` + 启动器 `supervise-launch.py`",
|
||||
"New-ScheduledTaskAction" in _code and "supervise-launch.py" in _code
|
||||
and "with_name" in _code and "pythonw.exe" in src))
|
||||
res.append(("🔴 动作里**不再有** `-File start-supervise.ps1`(⛔ 壳脚本当看守=闪黑窗)",
|
||||
"-File start-supervise.ps1" not in _code))
|
||||
res.append(("🔴 `CREATE_NO_WINDOW`(0x08000000)在位(⛔ 缺了每次登记都闪一下)",
|
||||
"0x08000000" in _code))
|
||||
res.append(("`_find_keeper_tpl` 死代码已清(⛔ 留着会再招一批过期判据)",
|
||||
"def _find_keeper_tpl(" not in _code))
|
||||
return res
|
||||
|
||||
|
||||
@@ -6492,36 +6915,13 @@ def t_main_registered_on_goal_create():
|
||||
return res
|
||||
|
||||
|
||||
@case("🔴🔴 keeper 生成 ps1 时 `--script` 必须填**本区发布物**(⛔ 不许照着当前这份抄)")
|
||||
def t_keeper_script_is_published_copy():
|
||||
"""🔴🔴 2026-10-05 实测事故(**自我污染**,两区常驻一起被打歪)。
|
||||
|
||||
**现象**:`ai1net-dsh-server` 的 `.workbuddy/collab/start-supervise.ps1` 被重铺成
|
||||
`$script = "…\\.workbuddy\\skills\\session-mechanism\\scripts\\collabd.py"`
|
||||
⇒ keeper 拉起的全是**技能目录那份**,本区副本成**孤儿**(P0-57 判据②必不过)。
|
||||
**真因**:`_escalate_to_keeper()` 里 `_own = Path(__file__).resolve()` ——
|
||||
它填的是**"当前正在跑的那一份"**;而当前跑的那份**本身就可能是错的**
|
||||
(技能目录那份)⇒ 重铺时把**正确的本区路径覆盖成技能目录**
|
||||
⇒ **自我强化**:错一次就再也回不来。
|
||||
|
||||
✅ 正解=**发布物优先**:本区 `.workbuddy/collab/collabd.py` 在位 ⇒ `__SCRIPT__` 一律填它。
|
||||
🔴 判据必须**变异有效**:撤掉"发布物优先"这层 ⇒ 本条必红。
|
||||
"""
|
||||
src = (HERE / "collabd.py").read_text(encoding="utf-8")
|
||||
res = []
|
||||
# 🔴 2026-10-05 二次收敛:判据抽进 `_own_path()` 唯一实现
|
||||
# (原先三处各写一遍"以谁为准"= 同一事实三套答案,正是本 bug 的温床)。
|
||||
res.append(("有唯一基准函数 `_own_path()`(⛔ 不再各处各写一份判定)",
|
||||
"def _own_path() -> Path:" in src))
|
||||
res.append(("🔴 `_own_path()` 里「发布物在位 ⇒ 用它」的分支",
|
||||
'_pub = _cdir / "collabd.py"' in src or 'pub = Path(WS) / ".workbuddy" / "collab" / "collabd.py"' in src))
|
||||
res.append(("🔴 `__SCRIPT__` 走 `_own_path()`(⛔ 不再直接填 `Path(__file__).resolve()`)",
|
||||
"_own = _own_path()" in src))
|
||||
res.append(("⚠️ 回落时**必须打日志告警**(⛔ 静默回落=用户以为跑的是副本)",
|
||||
"keeper 的 --script 回落到当前这份" in src))
|
||||
res.append(("替换表仍用 `_own` 填 `__SCRIPT__`(⛔ 没被改成别处算出的值)",
|
||||
'"__SCRIPT__": str(_own)' in src))
|
||||
return res
|
||||
# 🔴🔴 2026-10-05 **已删除 `t_keeper_script_is_published_copy`**。
|
||||
# 它判的是旧 ps1 看守形态的三件事:`_own_path()` 的存在、`__SCRIPT__` 走 `_own_path()`、
|
||||
# 「回落必打日志」。而 ps1 看守**整套废弃** ⇒ `__SCRIPT__` 在 `collabd.py` 里只剩注释一处
|
||||
# ⇒ 该用例**恒红且无法修复**(被测对象不存在了)。
|
||||
#
|
||||
# ⚠️ 它真正有价值的那部分(**续命目标 / 身份基准必须走 `_own_path()` = 本区发布物**)
|
||||
# 已被 `t_ensure_spawns_published_copy` **完整接管**(6 项,含变异对照),没有覆盖缺口。
|
||||
|
||||
|
||||
@case("🔴🔴 常驻**自我繁殖**必须断:续命只许起**本区发布物**(用户「越创建越多」的真因)")
|
||||
@@ -6610,6 +7010,155 @@ def t_supervise_singleton_identity():
|
||||
return res
|
||||
|
||||
|
||||
@case("🔴🔴 换目标(`declare --switch-goal`)⇒ `lifecycle` **自动复位「进行中」**(⛔ 不靠人手补)")
|
||||
def t_declare_resets_lifecycle():
|
||||
"""🔴🔴 2026-10-05(用户定案逐字):
|
||||
|
||||
> 「一个工作区 同时只能执行一个目标,如果要切换目标,需要用户确认,
|
||||
> **然后切换和关注检查切换后的目标**」
|
||||
|
||||
🔴 **改前事实**:`declare` **从不碰 `lifecycle`** —— 只在末尾打一句
|
||||
「别忘了同步 `lifecycle`(⛔ `declare` 不碰它)」**让人手补**。
|
||||
|
||||
🔴 **为什么必须自动做**:旧目标做完 ⇒ `lifecycle` 停在「已完成」⇒ 换到新目标后
|
||||
**机制照旧不拉检查会话**(`collabd.py` 的判据:`life != 进行中` 时只在
|
||||
"队列还有非 done 件"才拉)⇒ **新目标没人检查** = 与用户"切换后要关注检查"**方向相反**。
|
||||
⚠️ 且靠人手补 = 又一条"要记得"的待办(同族:用户「最好是记录会话,这样就不用每次
|
||||
会话都去做声明了」—— **凡"要人记"的都会忘**)。
|
||||
|
||||
⚠️ **不是破坏性动作**:旧 `goal.json` **整份归档**(同一步做)⇒ 旧值可回查;
|
||||
且只在**已确认换目标**时做,⛔ 纯细化(只改措辞/补 why/kpi)不触发。
|
||||
|
||||
🔴 **判据必须行为级**(⛔ 不许只查源码里有没有那行):
|
||||
真造一份 `goal.json`(`lifecycle=已完成`)⇒ 真跑 `goalctl.py declare --switch-goal --yes`
|
||||
⇒ 读回落盘的 `lifecycle` 必须是「进行中」;**对照组**:不带 `--switch-goal` 的
|
||||
纯细化(标题不变)⇒ `lifecycle` **必须原样不动**(⛔ 证明复位绑的是"换目标"这个事件,
|
||||
不是"跑了一次 declare")。
|
||||
⚠️ 隔离一律用**临时 `COLLABD_CONFIG`**(同 `t_acc_selfref_excluded`:
|
||||
只设 env 不给 config 会回落读到真工作区,曾覆盖真 `goal.json`)。
|
||||
"""
|
||||
import json as _j
|
||||
import tempfile as _tf
|
||||
import subprocess as _sb
|
||||
import pathlib as _pl
|
||||
res = []
|
||||
gctl = HERE / "goalctl.py"
|
||||
# 源码级:确认 `_is_switch` 分支里真写了 lifecycle(⛔ 只作辅助,主判在行为级)
|
||||
src = gctl.read_text(encoding="utf-8")
|
||||
res.append(("`declare` 的换目标分支里**真写** `new[\"lifecycle\"]`(⛔ 不再只打提示)",
|
||||
'new["lifecycle"] = "进行中"' in src))
|
||||
# 🔴🔴 2026-10-05 **误红修正**:第一版直接查 `"别忘了同步" not in src` ⇒ 误红。
|
||||
# 真因:`goalctl.py` 里那句字面**仍合法保留在注释**中 —— 它是「**改前事实**」的
|
||||
# 取证记录(写明"改前 `declare` 从不碰 lifecycle"),是有价值的考古证据,
|
||||
# ⛔ 不该为了过一条断言而删掉。
|
||||
# ✅ 正解=**剔掉注释行再查**:只判「有没有这句提示出现在**可执行语句**里」。
|
||||
_code_lines = [ln for ln in src.splitlines()
|
||||
if not ln.lstrip().startswith("#")]
|
||||
_code = "\n".join(_code_lines)
|
||||
res.append(("⛔ **可执行语句**里已无那句「别忘了同步 `lifecycle`」旧提示(注释里的改前取证不算)",
|
||||
"别忘了同步" not in _code))
|
||||
res.append(("⛔ 也确认旧的「让人手补」`print` 真被换成自动复位提示",
|
||||
"自动复位为「进行中」" in src or "自动复位" in _code))
|
||||
# 🔴🔴 2026-10-05 **护栏自身要被判**(⛔ 别让护栏变成死代码):
|
||||
# 本用例出过两次"隔离失效 ⇒ 静默写真工作区"的事故 ⇒ 夹具必须**自查落点**。
|
||||
# ⚠️ 注意查的是**本文件**(`selftest.py`),⛔ 不是 `goalctl.py` —— 第一版查错文件 ⇒ 误红。
|
||||
_self_src = Path(__file__).read_text(encoding="utf-8")
|
||||
res.append(("🔴 夹具**自查子进程自报落点**(越界即记红,⛔ 不许静默写到别处)",
|
||||
"夹具越界" in _self_src and "落点 `" in _self_src))
|
||||
res.append(("🔴 夹具用对变量:`DSH_COLLAB_WS`(`_resolve_ws()` 的第一顺位)",
|
||||
'"DSH_COLLAB_WS": tmp' in _self_src))
|
||||
|
||||
def _run(tmp, args, goal):
|
||||
# 🔴🔴 2026-10-05 **隔离夹具的真修(本用例的第 2 个坑)**:
|
||||
# `goalctl.py` 的 `INBOX` 是**硬编码** `WS/"tmp"/"supervise-inbox"`
|
||||
# —— ⛔ **不读配置里的 `inbox` 字段**(实测:`{"inbox":"inbox"}` 被无视)。
|
||||
# 第一版夹具把 `goal.json` 写在 `<tmp>/inbox/` ⇒ 脚本读到「(无)」,
|
||||
# 写又落到 `<tmp>/tmp/supervise-inbox/` ⇒ **读回当然还是旧值**(看着像"没生效")。
|
||||
# ✅ 正解=**落点跟着脚本走**:夹具与脚本用**同一个** `WS/tmp/supervise-inbox`。
|
||||
cfg = _pl.Path(tmp) / "collabd.config.json"
|
||||
cfg.write_text(_j.dumps({"workspace": tmp.replace("\\", "/"),
|
||||
"inbox": "inbox", "taskgraph": "inbox/taskgraph.json",
|
||||
"live": "inbox/realtime.md"}, ensure_ascii=False),
|
||||
encoding="utf-8")
|
||||
inbox = _pl.Path(tmp) / "tmp" / "supervise-inbox"
|
||||
inbox.mkdir(parents=True, exist_ok=True)
|
||||
(inbox / "goal.json").write_text(_j.dumps(goal, ensure_ascii=False), encoding="utf-8")
|
||||
# 🔴🔴 2026-10-05 **隔离夹具的真修**(同一课**第四遍**):
|
||||
# `goalctl.py:_resolve_ws()` 的候选顺序是
|
||||
# `DSH_COLLAB_WS` → `DSH_WS_ROOT` → cwd(含 `.workbuddy/collab/`)
|
||||
# —— **⛔ 完全不看 `COLLABD_CONFIG`,⛔ 也不认 `COLLABD_WORKSPACE`**!
|
||||
# 第一版夹具只设了 `COLLABD_CONFIG` + `COLLABD_WORKSPACE` ⇒ `_resolve_ws()` 两个都不认,
|
||||
# cwd 又是临时目录(没有 `.workbuddy/collab/`)⇒ **回落到技能包上级目录**,
|
||||
# 而若那里恰好叠了真工作区 ⇒ **静默写进真 `goal.json`**(上一轮就是这么把真 title 改掉的)。
|
||||
# ✅ 正解=**设 `DSH_COLLAB_WS`**(第一顺位,显式压倒一切)。
|
||||
env = {**os.environ, "COLLABD_CONFIG": str(cfg).replace("\\", "/"),
|
||||
"DSH_COLLAB_WS": tmp.replace("\\", "/")}
|
||||
r = _sb.run([sys.executable, str(gctl)] + args, env=env, cwd=tmp,
|
||||
capture_output=True, timeout=90)
|
||||
# 🔴🔴🔴 2026-10-05 **兜底断言(防再犯)**:子进程**自报**的落点必须在本 tmp 内。
|
||||
# 为什么要"自报"而不是"我猜":`goalctl.py` 的 `INBOX` 是**硬编码**的,夹具
|
||||
# 一旦把目录猜错(本用例真犯过两次:`COLLABD_CONFIG` 不认、`inbox` 字段不读),
|
||||
# 就会**静默写到别处** —— 甚至写到真工作区(上一轮真出过事故)。
|
||||
# ⇒ 从子进程 stdout 里抠出它自己打印的落点,**核对前缀**;不在 tmp 内 ⇒ 直接记红。
|
||||
_so = (r.stdout or b"").decode("utf-8", "replace")
|
||||
_claimed = ""
|
||||
for _ln in _so.splitlines():
|
||||
if "落点 `" in _ln and "`" in _ln:
|
||||
_claimed = _ln.split("落点 `", 1)[1].split("`", 1)[0]
|
||||
_normp = lambda s: str(s or "").replace("\\", "/").strip().lower().rstrip("/")
|
||||
_claimed_ok = (not _claimed) or _normp(_claimed).startswith(_normp(tmp))
|
||||
try:
|
||||
back = _j.loads((inbox / "goal.json").read_text(encoding="utf-8"))
|
||||
except Exception as e:
|
||||
back = {"__err": repr(e)}
|
||||
if not _claimed_ok:
|
||||
back = {"__err": "🔴 夹具越界:子进程自报落点 %r 不在 tmp %r 内 ⇒ 已污染别处!"
|
||||
% (_claimed, tmp)}
|
||||
return (r.returncode, _so,
|
||||
(r.stderr or b"").decode("utf-8", "replace"), back)
|
||||
|
||||
base = {"title": "旧目标甲", "short": "甲", "lifecycle": "已完成",
|
||||
"lifecycle_at": "2026-10-01T00:00:00", "acceptance_state": {"a1": "过|x"}}
|
||||
# 🔴🔴 2026-10-05 **防再犯硬护栏**(上一轮真出过事故:夹具隔离失效 ⇒ 静默改真 `goal.json`):
|
||||
# 跑任何写动作**前后**都量一遍**真工作区 `goal.json` 的 md5**,必须逐字不动。
|
||||
# ⛔ 这不是"重复劳动" —— 它把「隔离失效」从**静默事故**变成**当场报红**。
|
||||
_real_goal = Path(TEST_WS).parent / "supervise-inbox" / "goal.json"
|
||||
_digest = lambda p: (hashlib.sha1(p.read_bytes()).hexdigest() if p.is_file() else "(无)")
|
||||
_real_before = _digest(_real_goal)
|
||||
# ① 换目标(带 --switch-goal)⇒ 必须复位
|
||||
try:
|
||||
d1 = _tf.mkdtemp(prefix="gl1-")
|
||||
rc1, o1, e1, b1 = _run(d1, ["declare", "--title", "新目标乙", "--switch-goal", "--yes"], dict(base))
|
||||
res.append(("① 换目标:rc=0(实测 rc=%s;err=%s)" % (rc1, (e1 or "")[-120:]), rc1 == 0))
|
||||
res.append(("① 换目标 ⇒ 标题真换成「新目标乙」(实测 %r)" % b1.get("title"), b1.get("title") == "新目标乙"))
|
||||
res.append(("🔴 换目标 ⇒ `lifecycle` 复位成「进行中」(实测 %r,改前会是『已完成』)"
|
||||
% b1.get("lifecycle"), b1.get("lifecycle") == "进行中"))
|
||||
res.append(("① 复位留痕(`lifecycle_by` 写明自动复位,实测 %r)"
|
||||
% str(b1.get("lifecycle_by"))[:40], "自动复位" in str(b1.get("lifecycle_by") or "")))
|
||||
res.append(("① 旧值存进 `lifecycle_why` 可回查(实测 %r)"
|
||||
% str(b1.get("lifecycle_why"))[:50], "已完成" in str(b1.get("lifecycle_why") or "")))
|
||||
res.append(("⛔ `acceptance_state` **不许被顺手清掉**(那是上一条目标的判据=证据)",
|
||||
bool(b1.get("acceptance_state"))))
|
||||
except Exception as e:
|
||||
res.append(("① 换目标用例跑不起来:%r" % (e,), False))
|
||||
# ② 对照:**纯细化**(标题不变,只补 why)⇒ lifecycle 必须原样不动
|
||||
try:
|
||||
d2 = _tf.mkdtemp(prefix="gl2-")
|
||||
g2 = dict(base); g2["title"] = "同名目标"
|
||||
rc2, o2, e2, b2 = _run(d2, ["declare", "--title", "同名目标", "--why", "补充说明", "--yes"], g2)
|
||||
res.append(("② 对照·纯细化:rc=0(实测 rc=%s)" % rc2, rc2 == 0))
|
||||
res.append(("🔴 对照·纯细化 ⇒ `lifecycle` **原样不动**(实测 %r;⛔ 证复位绑的是『换目标』不是『跑过 declare』)"
|
||||
% b2.get("lifecycle"), b2.get("lifecycle") == "已完成"))
|
||||
except Exception as e:
|
||||
res.append(("② 对照用例跑不起来:%r" % (e,), False))
|
||||
# 🔴🔴 硬护栏收口:真工作区 `goal.json` 必须**逐字未动**
|
||||
_real_after = _digest(_real_goal)
|
||||
res.append(("🔴🔴 护栏:**真工作区 `goal.json` 逐字未动**(%s ⇒ %s)"
|
||||
% (_real_before[:12], _real_after[:12]),
|
||||
_real_before == _real_after))
|
||||
return res
|
||||
|
||||
|
||||
def main() -> int:
|
||||
only = ""
|
||||
if "-k" in sys.argv:
|
||||
|
||||
@@ -76,15 +76,24 @@ AS_JSON = "--json" in ARGV
|
||||
SHOW_ALL = "--all" in ARGV
|
||||
|
||||
# 「会话机制三件套」的**关键钩子**(缺一 ⇒ 该机制不会运行)
|
||||
# 🔴🔴 2026-10-06 修:第 2 元由**单个文件名**改成**一组可接受的文件名**(命中任一即算在册)。
|
||||
# 起因(实测坐实):`UserPromptSubmit` 上原有 4 条独立守卫(`stop-dialog-guard` /
|
||||
# `skill-load-guard` / `reply-style-guard` / `session-log-guard`),每轮**各起一个 Python 进程**
|
||||
# ⇒ 宿主冷启 7 次解释器 ⇒ 预算被排队挤破 ⇒ 报 `Hook timed out after 10000ms`(用户报障)。
|
||||
# ⇒ 已**合并**为一个入口 `prompt-guards.py`(本进程内 `runpy` 依次跑那 4 个,合并输出)。
|
||||
# ⚠️ 本条判据若仍按**旧文件名**找 ⇒ 合并后**每轮必报「关键钩子不在册」(假红)**,
|
||||
# 而它惩罚的正是"按要求做过的合并"。
|
||||
# 🔴 判据要问的是「**这项能力有没有接线**」,⛔ 不是「某个文件名在不在」——
|
||||
# 所以两种形态**都必须认**:独立接线 | 合并入口。
|
||||
KEY_HOOKS = [
|
||||
("SessionStart", "lock-guard-hook.py", "无锁不许改代码库 / 文档库"),
|
||||
("PreToolUse", "lock-guard-hook.py", "同上(Write|Edit 面)"),
|
||||
("PreToolUse", "bash-output-guard.py", "拦下会灌爆上下文的读命令"),
|
||||
("UserPromptSubmit", "wb-result-hook.py", "收结果 / 结果回流"),
|
||||
("UserPromptSubmit", "stop-dialog-guard.py", "水位与收口(接续机制起点)"),
|
||||
("UserPromptSubmit", "skill-load-guard.py", "用户点名方法 ⇒ 强制加载技能"),
|
||||
("UserPromptSubmit", "session-log-guard.py", "会话日志闸(防把界面顶死)"),
|
||||
("PostToolUse", "session-log-guard.py", "同上(工具后)"),
|
||||
("SessionStart", ("lock-guard-hook.py",), "无锁不许改代码库 / 文档库"),
|
||||
("PreToolUse", ("lock-guard-hook.py",), "同上(Write|Edit 面)"),
|
||||
("PreToolUse", ("bash-output-guard.py",), "拦下会灌爆上下文的读命令"),
|
||||
("UserPromptSubmit", ("wb-result-hook.py",), "收结果 / 结果回流"),
|
||||
("UserPromptSubmit", ("stop-dialog-guard.py", "prompt-guards.py"), "水位与收口(接续机制起点)"),
|
||||
("UserPromptSubmit", ("skill-load-guard.py", "prompt-guards.py"), "用户点名方法 ⇒ 强制加载技能"),
|
||||
("UserPromptSubmit", ("session-log-guard.py", "prompt-guards.py"), "会话日志闸(防把界面顶死)"),
|
||||
("PostToolUse", ("session-log-guard.py",), "同上(工具后)"),
|
||||
]
|
||||
GATE_LOGS = [("收口", WS + "/.workbuddy/stop-dialog-guard.log"),
|
||||
("技能", WS + "/.workbuddy/skill-load-guard.log"),
|
||||
@@ -94,7 +103,33 @@ GATE_LOGS = [("收口", WS + "/.workbuddy/stop-dialog-guard.log"),
|
||||
("锁", (os.path.join(os.path.dirname(os.environ.get("DSH_DOCS_ROOT", "")),
|
||||
".workbuddy", "lock-hook.log")
|
||||
if os.environ.get("DSH_DOCS_ROOT") else ""))]
|
||||
ROLES_LIVE = [("唤醒", "[唤醒]"), ("协作", "[协作]"), ("跟进", "[跟进]")]
|
||||
# 🔴🔴 2026-10-05 **用户定案:「协作 全部 改为 执行」**(连说两遍,第二轮逐字:
|
||||
# 「**兼容个毛线,今天兼容一个明天兼容一个 过不了一周就成大杂烩了**」)。
|
||||
# ⛔⛔ 本文件里 `协作` 二字**不准再出现在任何"活"的语境里**。
|
||||
# 「兼容」这个词**本身就是病** —— 后人读到"兼容"会以为它迟早能删,于是一拖再拖。
|
||||
# ⇒ 凡看到旧前缀,必须能一句话答出**"删了会坏在哪"**;答不出 ⇒ 它就是该删的。
|
||||
# 🔴 分清**两件性质完全不同的事**(本文件唯一的例外,且**不许写"兼容"**):
|
||||
# ① **活类别表 `ROLES_LIVE`** = "必须存在活会话"的类别 ⇒ **只许有当前在用的前缀**。
|
||||
# 多一个过时前缀 ⇒ 每轮必报"类别缺失"(假红)+ 给旧名续命(就是用户说的"大杂烩")。
|
||||
# ② **扫描面 `SESS_PREFIXES`** = "要捞进视野"的前缀 ⇒ 过时前缀**留在这里**。
|
||||
# 性质是**历史行解析正确性**(⛔ 不是"兼容"):宿主库 `sessions` 表里有旧标题的行,
|
||||
# 扫不到 ⇒ 本闸门对它们**静默失明**("读到了却不说"是该机制的既有红线)。
|
||||
# 🔴 **实测判据(2026-10-05 当场跑 `board.py::_role_of_title()` 验的,⛔ 非推理)**:
|
||||
# 删 `_PFX` 里 `协作`/`协作目标`/`任务会话` 三条 ⇒
|
||||
# `[协作]-[手机接入]-N9 复测` / `[协作]N9 派活 · …` / `[协作目标]-xxx` / `[任务会话]-…`
|
||||
# **全部解析成 `''`** ⇒ 看板画不出、派活漏管,**不可逆**。
|
||||
# ⇒ 这就是"删了会坏在哪"的答案。**留着有据,不是人情。**
|
||||
# ⚠️ 与 `collabd.py::parse_session_name()` / `board.py::_role_of_title()` 的映射口径对齐:
|
||||
# worker 侧新建=`执行`(唯一在用)。
|
||||
ROLES_LIVE = [("执行", "[执行]")]
|
||||
# 🔴 扫描面 = 当前活类别 + **历史前缀**(⛔ 语义是「历史行解析正确性」,**不是"兼容"**)。
|
||||
# ⛔⛔ **这三个历史前缀不许删**:删任一条 ⇒ 存量旧标题行**扫不到** ⇒ 本闸门静默失明。
|
||||
# ⚠️ 它们**不参与"活类别"判定**(不判"必须有活的")—— 判了就是给旧名续命。
|
||||
SESS_PREFIXES = [p for _, p in ROLES_LIVE] + ["[协作]", "[协作目标]", "[任务会话]"]
|
||||
# 🔴🔴 2026-10-03 口径后**已退役**的角色前缀 ⇒ **只扫、不判活**(⛔ 不是"兼容",是"还在库里"):
|
||||
# ⛔ 不许放回 `ROLES_LIVE` —— 唤醒/跟进**整套退役**(见 SKILL.md 文首口径块),
|
||||
# 放回去 ⇒ 每轮必报"唤醒/跟进 类别缺失"(惩罚的正是"按要求删掉的东西")。
|
||||
SESS_PREFIXES += ["[唤醒]", "[跟进]"]
|
||||
# 🔴 2026-10-03 口径:唤醒/跟进**整套退役**(见 SKILL.md 文首口径块)
|
||||
# ⇒ ⑦ 「周期钟必须存在」这条旧判据与现口径**互斥**:按旧判据每轮必报 fail,而它惩罚的
|
||||
# 正是"已经按用户要求删掉的东西"。现行「处理」这条腿的载体=常驻 `--supervise`(⑩ 判它)。
|
||||
@@ -181,11 +216,32 @@ def _dangling_skills_in_text(txts):
|
||||
out.setdefault(nm, t[max(0, m.start() - 20):m.end() + 20].replace("\n", " ").strip())
|
||||
return out
|
||||
|
||||
EXISTING_SKILLS = set()
|
||||
try:
|
||||
EXISTING_SKILLS = {n for n in os.listdir(SKILLS) if os.path.isdir(os.path.join(SKILLS, n))}
|
||||
except Exception:
|
||||
pass
|
||||
def _skill_names():
|
||||
"""**本机可见的全部技能名** = 全局技能根 ∪ 工作区技能根。
|
||||
|
||||
🔴🔴 2026-10-06 修 **只查全局根 ⇒ 工作区自带技能被判"悬空"(假红,且每轮刷)**。
|
||||
实测(`vibe-product`):其 `MEMORY.md` 里写「`product-planning/`(工作区根)」,
|
||||
而 `product-planning` **只在** `<WS>/.workbuddy/skills/` 里 —— 全局库
|
||||
`E:/ProgramData/.workbuddy/skills/` 下**没有它**(实测 `ls` = No such file)。
|
||||
⇒ 旧判据(只 `listdir(SKILLS)`)每轮报「工作区记忆里的技能指针悬空」,
|
||||
而它惩罚的正是「**工作区自己装的技能**」这种**完全合法**的形态。
|
||||
|
||||
⛔ 判据要问的是「**这个名字在本机取不取得到**」,⛔ **不是**「全局库目录里有没有」。
|
||||
本机 `DSH_SKILLS_ROOT` 未设时 `SKILLS` 落在 `CODEBUDDY_CONFIG_DIR/skills`(全局),
|
||||
本身就**看不见**工作区那一层 ⇒ 漏判是必然而非偶然。
|
||||
"""
|
||||
out = set()
|
||||
for root in (SKILLS, os.path.join(WS, ".workbuddy", "skills")):
|
||||
try:
|
||||
if root and os.path.isdir(root):
|
||||
out |= {n for n in os.listdir(root)
|
||||
if os.path.isdir(os.path.join(root, n))}
|
||||
except Exception:
|
||||
pass
|
||||
return out
|
||||
|
||||
|
||||
EXISTING_SKILLS = _skill_names()
|
||||
|
||||
|
||||
# ── A. 机制装没装好 ─────────────────────────────────────────────────────
|
||||
@@ -200,10 +256,12 @@ def check_hooks():
|
||||
return [], None
|
||||
cmds = [(ev, str(h.get("command") or ""))
|
||||
for ev, arr in hk.items() for blk in (arr or []) for h in ((blk or {}).get("hooks") or [])]
|
||||
miss = [k for k in KEY_HOOKS if not any(k[0] == ev and k[1] in c for ev, c in cmds)]
|
||||
# ⚠️ `k[1]` 是**一组**可接受的文件名 ⇒ 命中任一即算在册(独立接线 或 合并入口)。
|
||||
miss = [k for k in KEY_HOOKS
|
||||
if not any(k[0] == ev and any(n in c for n in k[1]) for ev, c in cmds)]
|
||||
if miss:
|
||||
rec("hook_reg", "A", "fail", "关键钩子不在册(该机制现在不会运行)",
|
||||
"、".join("%s(%s)" % (k[1], k[0]) for k in miss))
|
||||
"、".join("%s(%s)" % ("/".join(k[1]), k[0]) for k in miss))
|
||||
else:
|
||||
rec("hook_reg", "A", "ok", "关键钩子在册", "共 %d 条钩子注册" % len(cmds))
|
||||
|
||||
@@ -304,26 +362,89 @@ def check_memory_pointers():
|
||||
|
||||
|
||||
def check_snapshot_stale():
|
||||
"""常驻规则快照 vs 权威 `CODEBUDDY.md`:**比权威旧 ⇒ 内容过期**(快照是副本,权威单向)。"""
|
||||
"""常驻规则快照 vs 权威 `CODEBUDDY.md`:**内容**一不一致(快照是副本,权威单向)。
|
||||
|
||||
🔴🔴 2026-10-06 修 **判据从「比 mtime」改成「比内容」**(原判据是**假红**,已实测坐实):
|
||||
原写法比 `getmtime(auth) - getmtime(snap) > 0.01 天` ⇒ 只要权威 CODEBUDDY.md
|
||||
**被 touch 过**(哪怕只是重排、挪归档、加一行注释)就报
|
||||
「快照比权威旧 ⇒ 新规则没进快照」—— 而**快照内容可能一个字都没差**。
|
||||
⚠️ 实测(2026-10-06,ai1net):新旧快照 **md5 逐字节相同**(`f54e48d4…`)、`diff` **0 行**、
|
||||
`resident-rules.py --check` 报「✅ 关键规则齐备」,而本条已**连续 4 天每轮报 fail**。
|
||||
⇒ 病根:**拿文件时间当内容判据**(时间只说明"谁最后被写过",不说明"内容差没差")。
|
||||
|
||||
⇒ 改为**内容口径**,并**复用唯一事实源**:直接调 `dsh-local-env` 的
|
||||
`resident-rules.py --check --goal <CODEBUDDY.md>`(它本来就是干这个的)
|
||||
—— ⛔ **不在这里再抄一份"抽章节再比对"的逻辑**(抄一份 ⇒ 两处漂移 ⇒ 同族事故)。
|
||||
rc=0 ⇒ ok | rc=1 ⇒ **真不一致**(fail)| 工具缺失/跑不起来 ⇒ warn(⛔ 不假装通过)。
|
||||
"""
|
||||
snap = os.path.join(SKILLS, "dsh-local-env", "references", "dsh-env-bootstrap",
|
||||
"常驻规则-快照.md")
|
||||
auth = os.path.join(WS, "CODEBUDDY.md")
|
||||
if not os.path.isfile(auth):
|
||||
rec("snap_sync", "B", "warn", "找不到权威规则文件", auth)
|
||||
# ⚠️ 本区**本来就没有** CODEBUDDY.md(如 vibe-product)⇒ 这是**合法状态**,不是故障。
|
||||
rec("snap_sync", "B", "warn", "本区无权威规则文件(`CODEBUDDY.md` 不存在 ⇒ 本项不适用)", auth)
|
||||
return
|
||||
if not os.path.isfile(snap):
|
||||
rec("snap_sync", "B", "warn", "常驻规则快照不存在", snap)
|
||||
return
|
||||
try:
|
||||
d = (os.path.getmtime(auth) - os.path.getmtime(snap)) / 86400.0
|
||||
except Exception:
|
||||
tool = os.path.join(SKILLS, "dsh-local-env", "references", "dsh-env-bootstrap",
|
||||
"resident-rules.py")
|
||||
if not os.path.isfile(tool):
|
||||
rec("snap_sync", "B", "warn",
|
||||
"**抽取器不在** ⇒ 判不了快照同不同步(⛔ 不算通过)", tool)
|
||||
return
|
||||
if d > 0.01:
|
||||
rec("snap_sync", "B", "fail", "常驻规则快照**比权威文件旧** ⇒ 新规则没进快照",
|
||||
"权威 %s 比快照新 %.1f 天;重生成 ⇒ `python scripts/resident-rules.py --snapshot`"
|
||||
% (os.path.basename(auth), d))
|
||||
else:
|
||||
rec("snap_sync", "B", "ok", "常驻规则快照不比权威旧", "")
|
||||
# 🔴🔴 真·内容口径:**让抽取器自己算一遍"快照应该长什么样"**,再与磁盘上的真快照比对。
|
||||
# ⛔ 不能用 `resident-rules.py --check` —— 实测(2026-10-06 变异对照):
|
||||
# 它校验的是**目标 CODEBUDDY.md 里关键规则齐不齐**,**根本不含"与快照比对"**。
|
||||
# 我中途误用它 ⇒ 把快照**截断到 400 字节**,它照样报 `✅ 关键规则齐备`(rc=0)
|
||||
# ⇒ 判据变 **恒绿**(比原来的 mtime 假红更坏)。
|
||||
# ⚠️ 教训:**换判据必须重跑变异对照**;"换了个看起来更对的调用" ≠ 判据变强了。
|
||||
# ⛔ 也不在这里自己抄一份"抽章节"逻辑(两处 ⇒ 漂移)。做法=**导入抽取器本体**,
|
||||
# 把它的 `SNAP` 常量**临时改指到临时文件**,调它自己的 `snapshot()` 取"应有内容",
|
||||
# 读完还原常量并删临时文件(⛔ 全程不碰真快照 —— 体检⛔ 不改资产)。
|
||||
try:
|
||||
import importlib.util
|
||||
import tempfile
|
||||
_spec = importlib.util.spec_from_file_location("_rr_probe", tool)
|
||||
_mod = importlib.util.module_from_spec(_spec)
|
||||
_spec.loader.exec_module(_mod) # `__name__` ≠ `'__main__'` ⇒ 不会跑 main()
|
||||
_fd, _tmp = tempfile.mkstemp(suffix=".md")
|
||||
os.close(_fd)
|
||||
_orig = _mod.SNAP
|
||||
try:
|
||||
_mod.SNAP = _tmp
|
||||
_mod.snapshot(auth) # 用**它自己**的逻辑生成"应有内容"
|
||||
expected = io.open(_tmp, encoding="utf-8", errors="replace").read()
|
||||
finally:
|
||||
_mod.SNAP = _orig
|
||||
try:
|
||||
os.remove(_tmp)
|
||||
except OSError:
|
||||
pass
|
||||
except Exception as e:
|
||||
rec("snap_sync", "B", "warn",
|
||||
"**内容比对跑不起来** ⇒ 判不了快照同不同步(⛔ 不算通过)", str(e)[:130])
|
||||
return
|
||||
try:
|
||||
actual = io.open(snap, encoding="utf-8", errors="replace").read()
|
||||
except Exception as e:
|
||||
rec("snap_sync", "B", "warn", "读快照失败", str(e)[:120])
|
||||
return
|
||||
if actual == expected:
|
||||
rec("snap_sync", "B", "ok", "常驻规则快照与权威**逐字节一致**", "")
|
||||
return
|
||||
# 定位**第一处**差异,便于一眼看懂差在哪(⛔ 不倒全文)
|
||||
_a, _b = actual.split("\n"), expected.split("\n")
|
||||
_i = 0
|
||||
while _i < min(len(_a), len(_b)) and _a[_i] == _b[_i]:
|
||||
_i += 1
|
||||
_exp_line = (_b[_i][:60].strip() if _i < len(_b) else "(应有多出的行)")
|
||||
_act_line = (_a[_i][:60].strip() if _i < len(_a) else "(快照到此为止 ⇒ 缺内容)")
|
||||
rec("snap_sync", "B", "fail",
|
||||
"常驻规则快照与权威**内容不一致** ⇒ 新规则没进快照(或快照被改坏)",
|
||||
"第 %d 行起不同;快照实际=「%s」/应为=「%s」(快照 %d 行 / 应为 %d 行);"
|
||||
"重生成 ⇒ `python <dsh-local-env>/references/dsh-env-bootstrap/resident-rules.py --snapshot`"
|
||||
% (_i + 1, _act_line, _exp_line, len(_a), len(_b)))
|
||||
|
||||
|
||||
# ── C. 编排在不在跑 ─────────────────────────────────────────────────────
|
||||
@@ -420,8 +541,9 @@ def check_orchestration():
|
||||
"select id,name,schedule_type,next_run_at,model_id,model_is_thinking,cwds,"
|
||||
" scheduled_at,status "
|
||||
"from automations where deleted_at is null")]
|
||||
sess = list(c.execute("select id,title,status from sessions "
|
||||
"where title like '[唤醒]%' or title like '[协作]%' or title like '[跟进]%'"))
|
||||
sess = list(c.execute(
|
||||
"select id,title,status from sessions where "
|
||||
+ " or ".join(["title like '%s%%'" % p for p in SESS_PREFIXES])))
|
||||
except Exception as e:
|
||||
rec("clock", "C", "fail", "读宿主库失败", str(e)[:140])
|
||||
return
|
||||
@@ -442,19 +564,54 @@ def check_orchestration():
|
||||
rec("clock", "C", "ok", "已退役角色的周期钟已全部 PAUSED(或本就无在册)",
|
||||
"在册 %d 条,全部非 ACTIVE" % len(retire))
|
||||
|
||||
# ⑧ 模型可用性(thinking=0 + flash ⇒ 服务端必拒)
|
||||
# ⚠️ 2026-10-03 后**不再只扫"周期钟"** —— 唤醒/跟进已退役,本工作区的 recurring 排期
|
||||
# 可能一条都没有 ⇒ 旧写法(只扫 clocks)会恒判 ok(**恒绿假通过**)。
|
||||
# ⇒ 改为扫**本工作区全部 recurring 排期**(含检查类),覆盖面只增不减。
|
||||
# ⑧ 模型一致性(🔴🔴 2026-10-05 P0-80 重写:**扫描面从 `recurring` 扩到全部排期**)
|
||||
# ⚠️ 旧写法只扫 `recurring` ⇒ 而**机制新建的排期 `schedule_type` 恒为 `once`**(见 P0-65)
|
||||
# ⇒ 一条都不进扫描面 ⇒ 该判据**恒判 ok**(同 P0-76 ①/P0-77 ③ 的"闸门看不见新东西")。
|
||||
# ⚠️ 且旧判据只问"flash + thinking=0 会不会被服务端拒",**⛔ 没问"跟主会话是不是同一个"**
|
||||
# —— 这才是用户真正遇到的问题(P0-80:机制写死 `space-bunny`,主会话是 `deepseek-v4.1-flash`)。
|
||||
# ✅ 现判据两条腿:
|
||||
# ① **可用性**(旧):flash 类 + thinking=0 ⇒ 服务端拒;
|
||||
# ② **一致性**(新):本区排期的 `model_id` 必须与本区主会话 `model` 同值(否则报 fail)。
|
||||
_recur = [a for a in mine if a["schedule_type"] == "recurring"]
|
||||
deaf = ["%s(%s)" % ((a["name"] or "")[:26], a["model_id"]) for a in _recur
|
||||
try:
|
||||
_c2 = sqlite3.connect("file:%s?mode=ro" % DB, uri=True, timeout=10)
|
||||
_c2.execute("PRAGMA busy_timeout=5000")
|
||||
_mr = _c2.execute(
|
||||
"select model from sessions "
|
||||
"where (is_background_automation is null or is_background_automation <> 1) "
|
||||
" and model is not null and model <> '' "
|
||||
" and replace(cwd,'\\\\','/') like ? order by created_at desc limit 1",
|
||||
("%" + ws_norm + "%",)).fetchone()
|
||||
_c2.close()
|
||||
_main_model = (_mr[0] if _mr else "") or ""
|
||||
except Exception:
|
||||
_main_model = ""
|
||||
# 可用性:本区**全部**排期(含 once)
|
||||
_allmine = [a for a in mine]
|
||||
deaf = ["%s(%s)" % ((a["name"] or "")[:26], a["model_id"]) for a in _allmine
|
||||
if not a["model_is_thinking"]
|
||||
and any(h in (a["model_id"] or "").lower() for h in FLASH_HINT)]
|
||||
and any(h in (a["model_id"] or "").lower() for h in FLASH_HINT)
|
||||
and a["schedule_type"] == "recurring"] # ⚠️ 只有 recurring 会被服务端审模型
|
||||
if deaf:
|
||||
rec("model", "C", "fail", "周期排期会被服务端拒(静默失效)",
|
||||
"模型不支持关思考却传 thinking=0:%s" % "、".join(deaf))
|
||||
else:
|
||||
rec("model", "C", "ok", "周期排期模型可用", "本区 recurring %d 条" % len(_recur))
|
||||
# 一致性:机制建的排期(once)模型须与本区主会话同值
|
||||
if not _main_model:
|
||||
rec("model_same", "C", "warn", "取不到本区主会话模型 ⇒ 无法核对一致性(P0-80)",
|
||||
"sessions 里没有本区的人开会话")
|
||||
else:
|
||||
_mech = [a for a in _allmine if (a.get("name") or "").startswith(("[执行]", "[检查]"))]
|
||||
_diff = ["%s(%s)" % ((a["name"] or "")[:26], a["model_id"]) for a in _mech
|
||||
if (a["model_id"] or "") != _main_model]
|
||||
if _diff:
|
||||
rec("model_same", "C", "fail",
|
||||
"机制建的排期模型 ≠ 本区主会话模型(P0-80)",
|
||||
"主会话=%s;不一致 %d 条:%s" % (_main_model, len(_diff), "、".join(_diff[:3])))
|
||||
else:
|
||||
rec("model_same", "C", "ok", "机制排期模型与本区主会话一致(P0-80)",
|
||||
"主会话=%s,机制排期 %d 条全同" % (_main_model, len(_mech)))
|
||||
|
||||
# ⑨ cwds 归属同形(错一字面 ⇒ 裂组且自我强化)
|
||||
off = _cwds_nearmiss(autos, ws_norm)
|
||||
@@ -481,13 +638,14 @@ def check_orchestration():
|
||||
rec("deliver", "C", "ok", "投递心跳新鲜", "%.0f 秒前" % age)
|
||||
|
||||
# ⑪ 活会话
|
||||
# ⑪ 活会话(🔴 只判 `ROLES_LIVE`=**当前在用的前缀**;历史前缀⛔ 不判"必须有活的")
|
||||
live = {lb: [s for s in sess if (s[1] or "").startswith(pre) and (s[2] or "") == "working"]
|
||||
for lb, pre in ROLES_LIVE}
|
||||
dead = [lb for lb, _ in ROLES_LIVE if not live.get(lb)]
|
||||
if dead:
|
||||
rec("live", "C", "warn", "此刻无活会话(按需创建属正常;主会话开工阶段须建齐)", "、".join(dead))
|
||||
else:
|
||||
rec("live", "C", "ok", "三类会话均有活的",
|
||||
rec("live", "C", "ok", "在用类别的会话均有活的",
|
||||
";".join("%s×%d" % (lb, len(live[lb])) for lb, _ in ROLES_LIVE))
|
||||
|
||||
# ⑫ 死排期:`once` 已无下次触发、却**从未运行过** ⇒ 活静默消失
|
||||
@@ -508,6 +666,42 @@ def check_orchestration():
|
||||
"本线 %d 条已跑完的一次性排期(正常痕迹,不计问题)%s"
|
||||
% (consumed, (";%d 条刚到点、仍在补跑窗口内(⛔ 不是哑火)" % in_grace) if in_grace else ""))
|
||||
|
||||
# ⑬ 🔴🔴 **旧前缀不许在「活的语境」里出现**(2026-10-05 用户定案)
|
||||
# 用户原话:「**兼容个毛线,今天兼容一个明天兼容一个 过不了一周就成大杂烩了**」。
|
||||
# ⇒ 本条把「不许兼容」从**口头要求**变成**机制判据**:任何人在活件里再写旧前缀
|
||||
# ⇒ 本闸门当场报红(⛔ 不靠"下次记得")。
|
||||
# 🔴 判据只扫**能产生/承载新名字的两处**(⛔ 不全文扫 —— 注释与 `references/*.md`
|
||||
# 里的考古记录是**合法保留**的,扫它们就是误红):
|
||||
# ① 活排期名(`automations.name`,未软删)—— 是旧前缀 ⇒ "今天又长出来一条"
|
||||
# ② 活会话标题(`sessions.title`)—— 同上
|
||||
# ⚠️ **不扫**:注释 / docstring / 考古段 / 软删的历史行
|
||||
# —— 那些是"记录过去发生过什么",⛔ 不是"现在还在用"(混为一谈即是误红)。
|
||||
# ✅ **变异对照**(2026-10-05 实跑,证明非恒绿非恒红):
|
||||
# 全 `[执行]` ⇒ ok;混 1 条 `[协作]` ⇒ fail;混 1 条 `[任务会话]` ⇒ fail;
|
||||
# 历史行但**不以旧前缀开头**(如 `接续:…`)⇒ ok。
|
||||
_OLD_WORKER = ("[协作]", "[协作目标]", "[任务会话]")
|
||||
_bad_sched, _bad_sess = [], []
|
||||
for _r in (mine or []):
|
||||
_nm2 = str((_r or {}).get("name") or "")
|
||||
if any(_nm2.startswith(_p) for _p in _OLD_WORKER):
|
||||
_bad_sched.append(_nm2[:44])
|
||||
for _s in (sess or []):
|
||||
_t2 = str(_s[1] or "")
|
||||
if any(_t2.startswith(_p) for _p in _OLD_WORKER):
|
||||
_bad_sess.append(_t2[:44])
|
||||
if _bad_sched or _bad_sess:
|
||||
_det = []
|
||||
if _bad_sched:
|
||||
_det.append("活排期 %d 条:%s" % (len(_bad_sched), "、".join(_bad_sched[:3])))
|
||||
if _bad_sess:
|
||||
_det.append("活会话 %d 条:%s" % (len(_bad_sess), "、".join(_bad_sess[:3])))
|
||||
rec("old_prefix", "C", "fail",
|
||||
"旧前缀又长出来了(新建一律 `[执行]`)", ";".join(_det))
|
||||
else:
|
||||
rec("old_prefix", "C", "ok",
|
||||
"活排期/活会话里无旧前缀(新建一律 `[执行]`)",
|
||||
"⛔ 历史行不在此列:它们是**解析正确性**,删了看板画不出(⛔ 不叫「兼容」)")
|
||||
|
||||
# ── 收口 ───────────────────────────────────────────────────────────────
|
||||
def finish():
|
||||
fails = [c for c in CHECKS if c["level"] == "fail"]
|
||||
|
||||
Reference in new issue
Block a user