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:
admin committed 2026-10-06 22:27:03 +08:00
1 parent 19101acd65
commit 64dd82073b
21 files changed
+8444 -4523

No files matched your search

+110 -6
View File
@@ -23,6 +23,19 @@ last_change: 2026-10-05 10:5x · 🔴🔴 **`lifecycle` 判定收敛为「单一
| 三 | **10-05** | **主会话/任务会话/检查会话** | **常驻程序** | ✅ **现行** |
🔴 **任务会话 = 旧称「执行会话」,同一个角色**(⛔ 不是第四类)。
🔴🔴 **2026-10-05 用户定案**:**「协作 全部 改为执行」**(连说两遍)⇒ **新建前缀一律 `[执行]`**,包内现行文案一律用「**执行**」(旧「协作」只作历史沿革保留)。
🔴🔴🔴 **2026-10-05 追加定案(同一话题,用户逐字)**:「**兼容个毛线,今天兼容一个明天兼容一个 过不了一周就成大杂烩了**」
⇒ **本包禁用「兼容」二字描述任何前缀/字段**。凡看见旧前缀,**必须一句话答出"删了会坏在哪"**;
答不出 ⇒ **它就是该删的**(不许以"兼容"为由留)。
· ✅ **留得住的唯一理由 = 历史行解析正确性**:`[协作]`/`[协作目标]`/`[任务会话]` 三条**不许删** ——
实测(2026-10-05 跑 `board.py::_role_of_title()`)删任一条 ⇒ 存量旧标题行解析成 `role=""`
⇒ **看板画不出、派活漏管,且不可逆**。⇒ 这是"**删了会坏**",⛔ **不是"兼容"**。
· ⛔ **不许进「活类别表」**:`session-rules-check.py::ROLES_LIVE` **只有 `[执行]`**。
旧前缀只出现在**扫描面** `SESS_PREFIXES`(捞历史行用)+ `_PFX` 映射表(解析用)。
· 🔴 **机制层闸门(已落地,防"明天又长一条")**:`session-rules-check.py` **第 ⑬ 项自检**
—— 扫**活排期名**与**活会话标题**,出现旧前缀 ⇒ **当场 fail**(判据只扫"活件",⛔ 不扫注释/考古段)。
变异对照已跑:全 `[执行]`⇒ok|混 1 条 `[协作]`⇒fail|混 1 条 `[任务会话]`⇒fail|历史行不以旧前缀开头⇒ok。
🔴 **改名落地位置**:发源地 `collabd.py::_gap_plan()` 的 `_zh = {"worker": "执行"}`(**唯一出处**)+ 文首提示词模板 9 处 + 本文件现行规范句。
🔴 **待办(未做)**:一/二代旧词在包内仍有 ~174 处(`collabd.py` 37、`board.py` 27、`board.html` 28、`selftest.py` 22、`goalctl.py` 15…)。
⛔ **改名不能盲替换**,先分三类:① 注释/文档 ⇒ 可直接改;② `selftest.py` 的 `@case` 标题 ⇒ 改了会断 `-k` 用例引用;③ 看板显示文案 ⇒ 改了用户可见,且可能有字面判据(`board.py:191` 那条就是防两处漂移的)。→ 待办清单 `references/manifest.md`。
· **本包自身**:`install.py`(干跑/安装/校验)+`references/manifest.md`(清单);⛔ 包外**不应**有第二套会话机制代码。
@@ -43,6 +56,30 @@ agent_created: true
⚠️ 那篇里还有**文档四条规则**(分类索引/结论在最前/历史倒排·新的在前/单条 ≤6 KB)——
**⛔ 只写进工作区日志的规矩 = 没立**(10-04 实证:一条红线立在对的地方,换会话照样读不到)。
## 🔴🔴 第 0 步:加载门槛(2026-10-05 立 · ⛔ 这是**第一条**,在十条禁令之前)
> **判据(一句话)**:**用户说了触发句 ⇒ 第 0 步是先加载本技能,然后才动手。⛔ 没有"我大概知道"这条捷径。**
**触发句(命中任一条即须加载)**:`使用任务会话完成 X 目标` / `使用执行会话完成 X 目标` / `继续 X 目标` /
`用执行会话完成 X` / `派任务会话` / `建任务会话` / 任何"让我去把某个目标做完/接着做"的表述。
**⛔ 禁止的三种"跳过理由"**(都真实发生过):
| 跳过理由 | 为什么不算数 |
|---|---|
| 「我已经很熟这套机制了」 | 熟悉 ≠ 现行。机制 10-03/10-04/10-05 连续改过三轮(会话类别、常驻形态、术语),**记忆里的版本大概率是旧的**。 |
| 「上下文里已经有规则片段了」 | 片段 ≠ 全文。**只读片段最容易漏「自决策白名单」这一节**(见下)。 |
| 「注入通报里说本轮不需要」 | 🔴🔴 **用户原话 > 注入通报**。通报是机制层的**旁路信号**,⛔ 不能拿它反驳用户当轮的明确指令。 |
🔴🔴 **最关键的一条(2026-10-05 实测栽过)**:**用户明确说了"使用任务会话完成目标",就不许再回头问"要不要建任务会话"。**
- 用户原话:「**任务会话的目的不就是 建立任务会话执行嘛,不然我调用任务会话技能干什么**」
- 用户原话:「**我想知道你哪来的这么多问题啊,你去执行不行啊,会话技能里面没有告诉你自决策的规则和机制嘛**」
- ⇒ 说这句话**本身就是授权**:直接建排期、拉起任务会话。把"怎么执行"当待拍板项 = 把已授权的事重新要一遍签字。
**自决策白名单(九类,命中即永不问)** —— 全文在 `references/02-功能优先协作协议.md` §2:
技术选型/实现路径/命名/调参/部署/排查/版本/兼容降级/文档技术内容。
**只准提报三类**:① 功能语义分叉(做 A 还是做 B,影响用户看到什么)② 红线门禁 ③ 超出决策边界(花钱、对外承诺、要凭据)。
## 🚨 十条禁令(2026-10-05 立 · ⛔ 每条都栽过,栽一次就重来一遍)
> 用户 2026-10-05 原话:「**你不要给我知道了,你给我记下来,每次都是知道了,知道了,过一会儿又忘了**」。
@@ -71,10 +108,66 @@ agent_created: true
**三条线各归各的(⛔ 别混谈)**
1. **派活线**:主会话 → 任务会话。排期名 `[协作]-[<类别>]-<具体>`。🔴 **不受总开关影响**(开关只挡常驻)。
1. **派活线**:主会话 → 任务会话。排期名 `[执行]-[<类别>]-<具体>`。🔴 **不受总开关影响**(开关只挡常驻)。
2. **检查线**:常驻 → 检查会话。排期名 `[检查]-[结果检查/目标检查]-…-第N棒`。🔴 **受总开关管**。
3. **常驻线**:一工作区一条 `collabd.py --supervise` + 看板**全局只一条**(端口 20099)。
**🔴🔴 目标口径(2026-10-05 用户定案 · ⛔ 这是"关注哪个目标"的唯一判据)**
> 用户原话逐字:**「一个工作区 历史的旧的目标会有很多个,当前要求完成什么目标 就因该关注和检查那个目标」**
> **「一个工作区 同时只能执行一个目标,如果要切换目标,需要用户确认,然后切换和关注检查切换后的目标」**
拆成**两条硬规矩**:
1、**同时只有一个"当前目标"** —— `tmp/supervise-inbox/goal.json` = **唯一当前目标的载体**(⛔ 不存在"多目标并行")。
历史旧目标**移到 `tmp/supervise-inbox/goals/` 归档**(⛔ 不许原地堆在 `goal.json` 里)。
2、**切目标 = 用户确认的动作** —— 「声明新目标」**不是**随便覆盖;**必须先问用户**,得到确认才切,切完**关注和检查的是切换后的那个**。
⛔ **机制不许自作主张换目标**(同 `lifecycle` 那条铁律:**宁可等,不可动**)。
✅ **三条落点现状(2026-10-05 已全补,⛔ 别再照抄旧缺口表)**:
| 项 | 现状 |
|---|---|
| 当前目标载体 | `goal.json`(单文件,✅ 天然只有一个) |
| 切目标需确认 | ✅ `declare` 加**确认闸** —— 标题变了 ⇒ **默认拒绝**(rc=3),必须带 `--switch-goal` |
| 旧目标归档 | ✅ 换目标时**整份**存 `goals/<旧标题≤40字>__<时间>.json` + 新目标留 `_前身目标` 指针(⛔ 不再靠人手写) |
🔴 **改前取证结论**(P0-78 留档):① 载体**本就满足**;② 归档**零实现**(全文搜 `goals/` 零命中 ⇒ 那份归档件是**手工造的**);③ 确认闸**零实现**(`declare` 直接覆盖 `title`)。
⚠️ **加闸带出的连带缺陷**:`declare` 没给 `--title` 时**回落成工作区名** ⇒ 把"纯细化"误判成"换目标" ⇒ 已修成「没给 ⇒ 保持现有标题」。
📂 全文 ⇒ `references/pitfalls.md` **P0-78**;⚠️ `lifecycle` 那条独立铁律 ⇒ **P0-79**(从 P0-77 拆出)。
**🔴🔴 换目标的正确动作(⛔ 照这个做,别自己发明)**
1、**先问用户** —— 「当前目标是 X,要放弃它换成 Y 吗?」(⛔ 机制不许自作主张换目标)。
2、用户**确认后**才执行:
```
python .workbuddy/collab/goalctl.py declare --title "<新目标>" --switch-goal --yes
```
3、**紧接着同步 `lifecycle`**(🔴 `declare` **不碰它**,不同步 ⇒ 检查机制会空转):
```
python .workbuddy/collab/collabd.py --set-life 进行中 --by "主会话"
```
4、切完**关注和检查的是切换后的新目标** —— 旧目标已归档,⛔ 不再建它的检查会话。
⚠️ **只细化同一目标**(补 `--why`/`--kpi`/改错别字)⇒ **不带 `--switch-goal`**,正常放行(⛔ 不该被拦)。
⚠️ `--switch-goal` ⛔ **不等于** `--yes`:前者=「用户已同意换目标」,后者只是「干跑转真写」。**混用 = 伪造用户授权**。
**🔴🔴 机制新建会话「用哪个模型」(2026-10-05 用户点破 · P0-80)**
> 用户原话:**「为什么 vibe-product 检查进程 创建的 执行和检查会话没有使用 主会话相同的模型」**
一句话判据:**机制另建会话时,模型与思考档一律「跟本区主会话」—— ⛔ 不许在代码里写死。**
- **改前事实**:`collabd.py::create_check_schedule()` 的 INSERT 里**字面写死** `"space-bunny"` + `model_is_thinking=0`
⇒ 机制建的 49 条会话/9 条排期全是 `space-bunny`,而**主会话是 `deepseek-v4.1-flash`(thought=high)**。
- **两条建会话的路,模型来源不同(⛔ 修的时候别只修一条)**:
· **常驻直连 SQLite**(`create_check_schedule()`)⇒ 这次已改成**取本区主会话**;
· **会话侧走 `automation_update`** ⇒ 天然跟主会话同档(宿主默认)。
- ✅ **现在的实现**:`collabd.py::preferred_model(cwd)` —— 取 `sessions` 里**人开的**(`is_background_automation <> 1`)、
**本区**、**最近一条**的 `model` + `thought_level`;取不到才回落常量,且**必打日志**(⛔ 不许静默)。
⚠️ **必须排除 `bg=1`** —— 否则会取到"机制自己上次建的那条" ⇒ **自我强化**,永远锁死在历史值上。
- 🔴 **配套判据**(`session-rules-check.py` ⑧):报「**机制建的排期模型 ≠ 本区主会话模型**」。
⚠️ 扫描面**必须含 `once`** —— 机制建的排期全是 `once`,旧判据只扫 `recurring` ⇒ **一条都看不见**(恒绿)。
**常驻的唯一管理入口**:`python collabctl.py <status|on|off|ensure>`(加 `--out <文件>` 取结果)
**用户可见入口(双击)**:`会话机制-一键开关.bat`(工作区根 + 桌面各一份)= 启动/全部停止/看状态
@@ -255,7 +348,7 @@ agent_created: true
> **(现在不需要上报机制了、之前已经去掉 唤醒会话和跟进会话机制)**」—— 本轮是**把代码与文档对齐到已有口径**。)
>
> **已落地(现算)**:
> · 角色表 `collabd.py::parse_session_name()` 与 `board.py::_role_of_title()` **两处同款**收到 `主/协作`
> · 角色表 `collabd.py::parse_session_name()` 与 `board.py::_role_of_title()` **两处同款**收到 `主/执行`
> ⇒ `[唤醒]`/`[跟进]` **判空**(旧 `sessions` 行不删,但不再是活类别,也**不会被当主会话候选**)。
> · 主会话候选排除元组**两处**收到 `("worker",)`。
> · `follow_for_topic()` **短路退役**(恒返回 `why="follow-retired"`、`sid=""`)⇒
@@ -343,7 +436,7 @@ agent_created: true
└─ 🔴 实测依据:`collabd.py` 文首「处理 = 建检查会话排期 …… 由常驻 --supervise
每 2 轮判一次,是这条腿的**唯一载体**」⇒ **没有常驻 ⇒ 检查会话永远建不出来**
第 1 层:检查会话([结果检查]/[目标检查]) ← 常驻建的,⛔ 你自己建不了
第 2 层:任务会话([协作]-…) ← ⛔ **只有主会话在第①步建一轮**,之后不再建
第 2 层:任务会话([执行]-…) ← ⛔ **只有主会话在第①步建一轮**,之后不再建
```
🔴 **因此开工第 0 步的真顺序是**:
@@ -432,9 +525,20 @@ agent_created: true
| 文档 | 谁写 | 什么时候写 | 谁读 |
|---|---|---|---|
| `<目标目录>/目标执行状态.md` | **目标检查会话** | 核对完目标状态后 | 下一个检查会话/用户 |
| `<目标目录>/<棒次>_<事项>_<日期>.md` | **任务会话(协作棒)自己** | 本轮做完时(=`--report done` **之前**) | 检查会话照 `artifact` 读 |
| `<目标目录>/<棒次>_<事项>_<日期>.md` | **任务会话(执行棒)自己** | 本轮做完时(=`--report done` **之前**) | 检查会话照 `artifact` 读 |
| `tasks.json` | `--report`(机器) | 每次状态转移 | 全部环节 |
🔴🔴 **目标目录的完整形态(2026-10-05 用户定案,逐字:「`<工作区根>/执行会话/目标-xxx-xxxxxx/` 改成这样」)**:
```
<工作区根>/执行会话/目标-<简称或标题前缀≤20字>-<sha1(title)前6位>/
```
- **加了一层 `执行会话/`** —— ⛔ 目标文件夹**不再直接散在工作区根**(用户诉求:根目录干净、历史目标归拢一处)。
- 单一真源:常量 `_GOAL_DIR_PARENT = "执行会话"` + `goal_dir_name()`(⛔ 别在调用方各拼一遍)。
- ⚠️ 短哈希算的是 **`title`(全量标题)**,⛔ 不是 `short` ⇒ **改标题 = 换目录名**(存量里已有漂移案例,见 `pitfalls.md`)。
- ⚠️ **域目录不受影响**:独立域仍是「工作区第一层目录」;`执行会话/` 是**目标文件夹的父层**,⛔ 不参与域键计算。
🔴 **落点是死的**:执行产物的 `--artifact` **必须**指进 `<目标目录>/` 里
(⛔ 不许写回 `交付物/`、`docs/`、工作区根 —— **那正是「到处找」的由来**)。
⚠️ **落点=软判据**(`artifact_dir_ok()` 只**提示**):存量里有落在别处但有效的产物,
@@ -653,12 +757,12 @@ agent_created: true
· ① 原话逐字:「**这个会话和协作会话的技能包 运行的第一件事 ,就应该是检查清楚 所有会话规划是否配置完整且生效,然后标记一个状态**」
· ② 原话逐字:「**就应该是检查清楚 所有会话规则机制 是否配置完整且生效, 不是规划 是 规则**」⇒ 对象 = **规则机制**(钩子 / 闸门 / 技能指针 / 常驻 / 编排…),⛔ 不是"排期规划"。🔴 首版按①的字面做成「会话规划体检」、**只查排期那一面** ⇒ 当天实测出的三类失效(钩子注入指向**已退役技能名** / 快照**写进幽灵目录** / **每轮注入的记忆**里指针悬空)**一条都查不到** ⇒ 旧脚本已退役到 `<WS>/归档/技能包-旧件-20261002/`,**同包内只剩一个入口**(两个入口 = 「在册 ≠ 生效」本身)。
⇒ **怎么跑**:`"$PY" "<本包>/scripts/session-rules-check.py" [--ws <工作区>]` —— ✅ **已接进工作区 `state.py`**(跑状态快照就自带这一段,⛔ 不必另记一条命令)。
⇒ **查三类、十二项**:**A 机制装没装好** ① 关键钩子在册 ② 钩子脚本路径存在 ③ 钩子注入里引用的技能名**是否还存在** ④ 钩子**真在被调用**没(闸门日志新鲜度)|**B 规则载体同没同步** ⑤ **每轮注入的记忆**里引用的技能名存在 ⑥ 常驻规则快照**不比权威旧**|**C 编排在不在跑** ⑦ 唤醒 / 跟进两台**周期钟**(缺 = 没人推 / 没人收)(⚠️ 其中「唤醒」这台是**代偿形态** —— 定案的唤醒时钟=**常驻投递**(⑩ 那一项查的才是它);「唤醒排期在册」⛔ **不等于唤醒时钟已就位**,两件事要分开读)⑧ 排期绑的模型**会不会被服务端拒**(`model_is_thinking=0` + flash 系 ⇒ 每触发必拒,2026-10-02 实测)⑨ `cwds` **归属同形**(错一字面 ⇒ 裂组且自我强化)⑩ **投递(常驻)**心跳 ⑪ 三类会话**当前有没有活的** ⑫ 有没有「**从未运行**就失效」的一次性排期。
⇒ **查三类、十二项**:**A 机制装没装好** ① 关键钩子在册 ② 钩子脚本路径存在 ③ 钩子注入里引用的技能名**是否还存在** ④ 钩子**真在被调用**没(闸门日志新鲜度)|**B 规则载体同没同步** ⑤ **每轮注入的记忆**里引用的技能名存在 ⑥ 常驻规则快照**不比权威旧**|**C 编排在不在跑** ⑦ 唤醒 / 跟进两台**周期钟**(缺 = 没人推 / 没人收)(⚠️ 其中「唤醒」这台是**代偿形态** —— 定案的唤醒时钟=**常驻投递**(⑩ 那一项查的才是它);「唤醒排期在册」⛔ **不等于唤醒时钟已就位**,两件事要分开读)⑧ 排期绑的模型**会不会被服务端拒**(`model_is_thinking=0` + flash 系 ⇒ 每触发必拒,2026-10-02 实测)+ 🔴🔴 **机制建的排期模型是否与「本区主会话」同值**(2026-10-05 加,P0-80 —— 此前 `collabd.py` 把模型**写死**成 `space-bunny`,而主会话是 `deepseek-v4.1-flash` ⇒ 「同一件活在两种模型上跑」;⚠️ 该判据**扫描面必须含 `once`**,机制建的排期全是 `once`,只扫 `recurring` 就恒绿)⑨ `cwds` **归属同形**(错一字面 ⇒ 裂组且自我强化)⑩ **投递(常驻)**心跳 ⑪ 三类会话**当前有没有活的** ⑫ 有没有「**从未运行**就失效」的一次性排期。
⇒ **标记**:结论写成 `<WS>/.workbuddy/collab/session-rules.json`(`verdict` = `ok`/`warn`/`fail` + 逐项 `detail`)—— 后续会话与看板**读它**,⛔ 不靠人复述。
🔴 **为什么必须是第一件事**:2026-10-02 实测——排期**都在册**、模型**都可用**、cwds **都同形**,**却三类会话一条活的都没有**(=配置在、机制没在跑);同一天还查出钩子注入文本指着**已合并退役的技能名**、常驻快照脚本**写到没人读的幽灵目录** ⇒ 全是「**看着有配置、其实没生效**」。这类状态**不问就不会知道**,等它表现成"卡住"时已经晚了。
🔴 **判据本身也要能报出问题**:⑨ / ⑫ 这两项(以及 `cwds` 判据的边界)用**合成样本 + 四个变异体**做过红绿对照(夹具 `<WS>/tmp/rules-check-mutate.py`,跑完即弃)—— 变异体=判据恒空 / 判据放宽成"同父目录即报" / 把"从未运行"当"跑完了" / 不排除"还有下次触发"的排期,**逐一按预期报红**。⛔ **别拿"实跑一次没报错"当验收** —— 判据恒空时那次实跑**同样是绿的**。
⛔ **只标记、不设卡**:体检 `rc≠0` 也照常开工 —— 它的职责是**把状态问清楚**,不是拦人。
- 🔴🔴 **开工清单:主会话开工的第 0 步不是"派活",是"把三类会话摆好"**(用户 2026-10-01 明令:「**开始会话完成需求的时候,主会话需要创建 唤醒会话 以及根据分工类别 创建 协作会话 和 跟进会话呢 不然整个机制跑不起来**」)⇒ 建 **唤醒会话** `[唤醒]-<类别>-<具体>`(少建=**没人推**,需求原地静着)+ **每个分工类别一条任务会话** `[协作]-<类别>-<具体>`(少建=**没人干**)+ **跟进会话** `[跟进]-<具体>`(少建=**没人收**)。🔴 **跟进会话是全局唯一席位、⛔ 不按类别各建一条**(2026-10-02 用户订正);它是**收口者**:任务会话干完活把**待核对状态**写进执行队列,上报给**这固定的一个**跟进会话处理。⚠️ **只有自动化能开新会话** ⇒ "建会话"=登记一条自动化,⛔ 不是自己 spawn;标题**第 2 级必须带方括号、值取 `goal.json` 的 `topics`**(⛔ 用 `short` ⇒ 静默漏管)。细则 ⇒ `architecture.md §2.3.0d`。
- 🔴🔴 **开工清单:主会话开工的第 0 步不是"派活",是"把三类会话摆好"**(用户 2026-10-01 明令:「**开始会话完成需求的时候,主会话需要创建 唤醒会话 以及根据分工类别 创建 协作会话 和 跟进会话呢 不然整个机制跑不起来**」)⇒ 建 **唤醒会话** `[唤醒]-<类别>-<具体>`(少建=**没人推**,需求原地静着)+ **每个分工类别一条任务会话** `[执行]-<类别>-<具体>`(少建=**没人干**)+ **跟进会话** `[跟进]-<具体>`(少建=**没人收**)。🔴 **跟进会话是全局唯一席位、⛔ 不按类别各建一条**(2026-10-02 用户订正);它是**收口者**:任务会话干完活把**待核对状态**写进执行队列,上报给**这固定的一个**跟进会话处理。⚠️ **只有自动化能开新会话** ⇒ "建会话"=登记一条自动化,⛔ 不是自己 spawn;标题**第 2 级必须带方括号、值取 `goal.json` 的 `topics`**(⛔ 用 `short` ⇒ 静默漏管)。细则 ⇒ `architecture.md §2.3.0d`。
- 🔴🔴 **缺会话 ⇒ 自动拉起**(用户 **2026-10-02** 口径,逐字:「**是用户说 使用协作会话方式 完成目标 或 继续完成目标**」)
⇒ 用户说这两句(或队列堵住)时:**先查三类会话齐不齐、活不活**;**缺 ⇒ 机制自己补建排期把它拉起来**,
⛔ **不许把"你去开一条会话"甩给用户**(旧行为=写 `NEED-USER.md` 喊人开会话,本条**取代**它)。
File diff suppressed because it is too large. Load diff
+141 -9
View File
@@ -12,9 +12,14 @@ session-mechanism · 一键配置(换机器只需跑这一个)
设计要点(都由实测倒逼,⛔ 不要"优化"掉)
1. **自解析**:解释器一律 `sys.executable`;配置目录按 `CODEBUDDY_CONFIG_DIR` 推导。
⛔ 不硬编码 python 路径、⛔ 不硬编码盘符 —— 这正是「换机器必碎」的根因。
2. **声明表驱动**:本包 **10 条**接线收敛成一张 HOOKS 表(`--verify` 逐条空载荷复测)。
2. **声明表驱动**:本包 **11 条**接线收敛成一张 HOOKS 表(`--verify` 逐条空载荷复测)。
🔴 **2026-10-06 由 10 条增至 11 条**:新增 `SessionStart / decision-rules-hook.py`
(**决策判据常驻注入** —— 用户明令「决策方法必须想办法加载到每次对话中」+
「要把这个动作加到会话技能配置环境的时候一并处理」)。
⛔ 表里**不含** `decision_bridge.py`(属 `ai1net-decision-laya` 另一条线)—— 它在 settings.json 里
另有 4 条、与本包同处一个 `hooks` 段 ⇒ 极易误读成"本包 14 条"(`--dry-run` 会打印被排除条数供目视核对)。
另有 4 条、与本包同处一个 `hooks` 段 ⇒ 极易误读成"本包 15 条"(`--dry-run` 会打印被排除条数供目视核对)。
⚠️ 别把新钩子与 `decision_bridge.py` 合并:前者注入**【决策判据】**(怎么想、什么不许),
后者注入**《提问规范》骨架**(要问时怎么写)—— 职责互补,合并会让任一方改判据时动到另一方。
3. **幂等**:先删「本包自己的旧条目」再插;装两遍结果相同。
4. **可逆**:首次安装先留**原状**备份 `settings.json.bak-session-mechanism-orig`(⛔ 已存在不覆盖),
其后每次 `--apply` 另写**带微秒**的时间戳备份(防同秒同名互相覆盖);
@@ -60,6 +65,20 @@ OWN_BASENAMES = (
"stop-dialog-guard.py",
"skill-load-guard.py",
"wb-result-hook.py",
# 🔴 2026-10-06 新增:决策判据常驻注入(用户明令「决策方法必须加载到每次对话中」
# +「要把这个动作加到会话技能配置环境的时候一并处理」)。
# ⚠️ 不加进这里的后果:`--uninstall` 认不出它 ⇒ 旧接线删不掉 ⇒ 换包/重装后
# **同一件事挂两条钩子**(agent-operating-rules §10.2 实测故障)。
"decision-rules-hook.py",
# 🔴🔴 2026-10-06 新增:**UserPromptSubmit 多守卫合并入口**(用户报「三个 hook 同时 10s 超时」)。
# ⚠️ 必须在此登记 —— 否则 `--apply` 认不出它 ⇒ 重装后又多挂一条 ⇒ 合并+单跑**双跑**。
# 📌 它替掉 4 条单跑接线(见 `HOOKS` 注释),但**那 4 个脚本文件本身保留**
# (仍可单跑;`OWN_BASENAMES` 里那 4 个名字**不能删** —— 用于删旧接线)。
"prompt-guards.py",
# 🔴 2026-10-06 补登记:`reply-style-guard.py`(回复排版闸门)**此前是手工接线**
# (⛔ 一直不在本声明表里 ⇒ `--uninstall` 认不出 ⇒ 摘不干净)。
# 改由 `prompt-guards.py` 合并调用后,把它补进白名单,让包能认全自己的钩子。
"reply-style-guard.py",
)
# ── 声明表:本包负责的钩子(⛔ decision_bridge 不在此表内)───────────────────
@@ -71,10 +90,30 @@ HOOKS: list[tuple[str, str | None, str, list[str], int]] = [
("PreToolUse", "Bash|Read", "scripts/hooks/bash-output-guard.py", [], 10),
("SessionEnd", None, "scripts/hooks/wb-result-hook.py", [], 10),
("SessionStart", "startup|resume", "scripts/hooks/lock-guard-hook.py", [], 10),
# 🔴 2026-10-06 新增:**决策判据常驻注入**(用户明令)。
# 挂 `SessionStart` ⇒ 每开一个会话注入一次,⛔ 不每轮重复(不白烧 token)。
# ⚠️ 与 `decision_bridge.py`(也挂 SessionStart)**互补、⛔ 不合并**:
# 那个注入《提问规范》骨架("要问时怎么写");本钩子注入【决策判据】("怎么想、什么不许")。
# ⚠️ 超时给 5 s:它是**纯本地字符串**(不读文件/不调模型/不起进程)⇒ 正常 0.05 s 内返回。
("SessionStart", "startup|resume", "scripts/hooks/decision-rules-hook.py", [], 5),
("UserPromptSubmit", None, "scripts/hooks/wb-result-hook.py", [], 20),
("UserPromptSubmit", None, "scripts/hooks/stop-dialog-guard.py", [], 10),
("UserPromptSubmit", None, "scripts/hooks/skill-load-guard.py", [], 10),
("UserPromptSubmit", None, "scripts/hooks/session-log-guard.py", ["-S"], 10),
# 🔴🔴🔴 2026-10-06:**UserPromptSubmit 的 4 条守卫合并成 1 条**(用户报超时后改)。
#
# 事实(本机实测):`UserPromptSubmit` 上串行挂了 **7 条 hook**,每条都是**一个
# Python 进程** ⇒ 宿主每轮要**冷启 7 次解释器**。单条都不慢(0.23–0.36 s;
# 7 条并发也才 0.84 s),但**串行 + 冷启动**累加 ⇒ 4 条 `timeout=10` 的一起报超时。
#
# ✅ 改法:把下面这 4 条(都是"同一事件、同一 stdin 契约、fail-open、无副作用"的守卫)
# 合进 `prompt-guards.py`,**一个进程跑完 4 个**。实测 1.247 s → **0.357 s(3.5×)**,
# 注入内容**逐字一致**(654 字符 additionalContext 完整保留)。
#
# ⛔ **不许合并**的三条(有副作用/属别处接线,合并会改语义):
# `supervise-ensure-hook.py`(常驻确保)、`wb-result-hook.py`(结果投递)、
# `decision_bridge.py`(不在本包声明表内)。
("UserPromptSubmit", None, "scripts/hooks/prompt-guards.py", [], 30),
# ⚠️ 原 4 条单跑接线(timeout=10 ⇒ 被前面挤破而超时)**已由上面这条替代**:
# stop-dialog-guard.py / skill-load-guard.py / session-log-guard.py / reply-style-guard.py
# ⛔ 别再加回来 —— 会变成"合并跑一次 + 单跑一次"(同一判据跑两遍)。
]
@@ -110,19 +149,70 @@ def read_roots() -> dict[str, str]:
return d
def _ws_marker(cand: Path) -> bool:
"""**候选目录是不是一个工作区**。
🔴 2026-10-05 修两处误判(原判据=`.workbuddy` + `state.py`):
**病一:硬要 `state.py` ⇒ 非 ai1net 线全判死。** `state.py` 只是 ai1net 那条线的
现状快照脚本(`collabd.py:4163` 引它作「跑这一条看现状」),`vibe-product` /
`agent-product` **本来就没有** ⇒ 老判据在那些工作区里**永远返回 None** ⇒ 被下游
`roots.env` 残留顶替(见 `detect_workspace` 的说明)。
**病二:⛔ 没有"这是本目录自己的还是从祖先继承的"之分。**
`E:/ProgramData/AIProject/.workbuddy` 也存在(早期遗留:`automations/` + `memory/2026-08-30.md`)
⇒ 若只看"存在性",按 `cwd.parents` 向上找会在**父目录**上命中。
⇒ 现判据:**必须同时具备「有 `.workbuddy`」+「`.workbuddy` 里有本工作区级的真物」**。
真物取三选一(都是**工作区**语义、且宿主会往里写,⛔ 不是"某条线的产物"):
· `memory/` —— 工作区记忆(宿主写入;`AIProject/.workbuddy` 也有,故单靠它不够)
· `collab/` —— 会话机制的协作落点(`collabd.py` 的家)
· `state.py` —— ai1net 线的现状快照(保留,兼容老工作区)
⚠️ `agent-product` 现只落钩子日志(`bash-guard.log` 等)⇒ 三条都不满足 ⇒ 仍判 False,
由 `detect_workspace` 的**显式参数**兜住(用户跑 `install.py` 时本就该点名工作区)。
"""
wb = cand / ".workbuddy"
if not wb.is_dir():
return False
if (wb / "collab").is_dir():
return True
if (wb / "memory").is_dir() and (cand / "state.py").is_file():
return True
return (cand / "state.py").is_file()
def _cwd_workspace() -> Path | None:
"""从 cwd 向上找最近的**带工作区标记**的目录。"""
cwd = Path.cwd().resolve()
for cand in (cwd, *cwd.parents):
if _ws_marker(cand):
return cand
return None
def detect_workspace(explicit: str | None) -> Path | None:
"""定「装到哪个工作区」。**优先级 = 显式 > 宿主 env > cwd 就近 > roots.env 残留。**
🔴🔴 2026-10-05 修一处**静默错归属**(实测:在 `agent-product` 里跑 `--dry-run`,
解析结果 = `ai1net-dsh-server`,踩技能 S 红线)。原顺序把 **`roots.env` 残留**
排在 **cwd** 之前 ⇒ `roots.env` 里只要留着上次装的值,**换任何目录跑都会继承它**。
⛔ 那是两件事被混为一谈:`roots.env` 是**本包全局单例**(一个包只有一份),
`roots.env` 里那个 `DSH_WS_ROOT` 记的是**上次装到哪**,⛔ 不是"当前工作区"。
三个非 ai1net 工作区(`vibe-product` / `agent-product` / …)**全部**会被它顶替。
⚠️ 兜底(沿用 `roots.env`)仍保留,但**必须开口说话** —— 见 `cmd_apply` 的冲突告警:
只要 cwd 与 `roots.env` 指向不同,就打 🔴 并**要求显式 `--workspace`**,⛔ 不许默默装。
"""
if explicit:
return Path(explicit).resolve()
for k in ("DSH_WS_ROOT", "COLLABD_WORKSPACE"):
if os.environ.get(k):
return Path(os.environ[k]).resolve()
here = _cwd_workspace()
if here is not None:
return here
prev = read_roots().get("DSH_WS_ROOT")
if prev and Path(prev).is_dir():
return Path(prev)
cwd = Path.cwd().resolve()
for cand in (cwd, *cwd.parents):
if (cand / ".workbuddy").is_dir() and (cand / "state.py").is_file():
return cand
return None
@@ -303,6 +393,48 @@ def cmd_apply(args) -> int:
if ws is None:
log("🔴 未能推断工作区 ⇒ 请显式 `--workspace <路径>`(⛔ 不猜)")
return 2
# 🔴🔴 归属自检(2026-10-05 加):**换工作区装的时候,最坏的结果是"装错地方还不出声"。**
# 实测踩到:在 `agent-product` 里跑,解析成 `ai1net-dsh-server`(S 红线)。
# 根因修在 `detect_workspace` 的优先级;这里再加一道**开口说话**的闸:
# ① cwd 就近判据与最终结果不一致 ⇒ 打字说明,二者都打印,让人当场看见。
# ② 已在别的工作区装过(`roots.env` 有旧值且 ≠ 现在这个)⇒ 打 🔴 警示,
# 因为 `roots.env` 是**包级单例** ⇒ 装了新的,**旧工作区的钩子会一起改指向**。
# ⛔ 不在这里自动改写 / 自动停手:显式 `--workspace` 时用户已表态,别多管;
# 非显式时由优先级保证"就近优先",本段只负责把事实摆在眼前。
here = _cwd_workspace()
prev_ws = read_roots().get("DSH_WS_ROOT")
log(f"归属自检:cwd 就近判据={here} | roots.env 上次装的={prev_ws}")
if here is None and not args.workspace:
# 🔴🔴 最该报警的一档,⛔ 不是「无信号 ⇒ 跳过」。
# cwd 认不出工作区 ⇒ 现在的 ws 只可能来自「宿主 env」或「roots.env 残留」。
# 残留那条正是 2026-10-05 踩到的病(在 agent-product 里解析成 ai1net-dsh-server)。
# ⚠️ 老写法只在 `here != ws` 时报警 ⇒ **恰好漏掉这一档**(`here` 是 None,比较为真但语义不对)。
log("🔴 cwd(及各级祖先)里**没有任何工作区标记**(`.workbuddy/collab/` 或 `state.py`)。")
log(f"🔴 当前工作区 **{ws}** 是**从 `roots.env` 残留推出来的**,⛔ 不是从你脚下的目录认出来的。")
log("🔴 若你确实要装到当前目录 ⇒ 加 `--workspace <当前目录绝对路径>`,⛔ 别让它继承。")
log("🔴 若当前目录**本就该是个工作区** ⇒ 先让它长出标记(至少建 `.workbuddy/collab/`)。")
elif here is not None and here != ws:
log(f"⚠ cwd 判据({here})与最终选用({ws})**不一致** —— 若非故意,请核查 `--workspace`")
if prev_ws and Path(prev_ws).is_dir() and Path(prev_ws) != ws:
# 🔴🔴 2026-10-05 改口径 —— 老文案制造了**不必要的恐慌**("一个包同时只服务一个工作区")。
# 实情(有读数+SKILL.md 支撑):**多工作区并存本来就是既定形态**,分工是两层:
# · **全局层**(本脚本)= 接线钩子 + `roots.env` 兜底 ⇒ 全机器一份;
# · **每区层**(`init_workspace.py`)= 各区自己的 `collabd.py` 副本 + 自己的
# `collabd.config.json` + 自己的计划任务 ⇒ **各用各的**。
# `roots.env` 的 `DSH_WS_ROOT` 只是**整机默认工作区**(钩子在拿不到会话 cwd 时的退化),
# ⛔ 不是"每装一次就迁一次家"。SKILL.md:886 逐字:
# 「技能目录 scripts/collabd.py = 源(唯一真身);每个工作区 .workbuddy/collab/collabd.py
# = 它自己的一份副本(另含 goalctl.py)。⛔ 各区不再跑技能目录那份。」
# 钩子侧也是按 cwd 分流的(`wb-result-hook.py::classify` ⇒ line/home/other/outside)⇒
# 新增一个工作区**不需要**动钩子。
log(f"ℹ `roots.env` 原指向 **{prev_ws}**,本次将改指 **{ws}**。")
log("ℹ `roots.env` 只是**整机默认工作区**(钩子在拿不到会话 cwd 时的退化),")
log('ℹ ⛔ 它不决定「哪个工作区能跑机制」—— 各区跑的是**自己那份** `collabd.py`,互不干扰。')
log(f'ℹ 若本意是「新开一个工作区 {ws}」⇒ ⛔ 别用本脚本改 roots.env;')
log(f'ℹ 正解=在 {ws} 里跑 `init_workspace.py`(铺本区副本 + 本区配置 + 本区计划任务)。')
log(f'ℹ 只有「整机默认工作区要换家」时,才该由本脚本改这一行。')
docs = detect_docs_root(args.docs_root)
log(f"包根={PKG} 工作区={ws} 文档库根={docs} 配置目录={config_dir()}")
+1
View File
@@ -0,0 +1 @@
{"hookSpecificOutput": {"hookEventName": "SessionStart", "additionalContext": "【决策判据 · 常驻(违反即事故;正本 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"}}
@@ -13,6 +13,11 @@
- **三禁**(⛔ 任一命中 = 该条回复**作废、重写后再发**):**表格** / **长散文** / **碎标签堆叠**。
- **并列内容竖排**:多个候选、多项并列各占一段、逐条编号;⛔ 不横排、⛔ 不挤进一段、⛔ 不塞成表格的列。
- **待拍板项**:放回复**最后一节**,逐条编号;每条写清「问题 + 说明(影响谁/断多久/花多少钱/有无不可逆)+ 各候选的优点与缺点 + 倾向」。
- 🔴🔴 **「变相征询」同样禁止**(2026-10-06 用户据实况追认):凡是**要用户拿主意**的事 ——
包括「**先只报不动**」「**等你发话**」「我倾向 X,你看呢」这类**不带选项的待定清单** ——
**一律按上一条写成「待拍板项」**(问题 + 说明 + **各候选的优点与缺点** + 倾向)。
⛔ 不许用一句"倾向"或"等你发话"**顶替**选项;⛔ 也不许把「没写选项的清单」当成"只是汇报"蒙过去。
反过来:**已自决**的事写成陈述句(「我选了什么,可推翻」),⛔ 不要混进待拍板清单里充数。
- ⛔ **不用征询句收尾**(「要我…吗/请确认/你看怎么办」);能自决策的直接做完,只留一句"我选了什么(可推翻)"。
- ⚠️ 若**本工作区另有更新的定稿**(环境文件里有更细的排版节)⇒ **以那份为准**,本块是通用形态。
<!-- REPLY-CORE:END -->
@@ -0,0 +1,400 @@
# 决策方法论(怎么想、怎么定)
<!-- ══════════════════════════════════════════════════════════════════════════
🔴 搬运登记块(2026-10-06 · 技能 `session-mechanism` 侧维护,⛔ 不是原文内容)
──────────────────────────────────────────────────────────────────────
来源:`skills/dsh-decision/references/00-决策方法论.md`
bytes=33197 | md5=ab06e86031a50df01ae16b58a533b2c9 | 376 行
附件(同批搬入,逐字一致):
`dsh-decision-method/素材库-U-用户决策.md` 24597 B md5=94c9e860148f4265f1e21e5322346f94
`dsh-decision-method/素材库-A-AI推理.md` 19914 B md5=89fc178e671c989d9079c1def20591da
`dsh-decision-method/素材库-反例-X.md` 5712 B md5=8bce93e8c1ce71d17e6d33aa9a2d7724
🔴 **为什么搬进来**:本包 `SKILL.md:395` 既定口径(2026-10-04)=
「**判据实体就在本档正文里** ⇒ 只复制这一个技能到别的机器,这些功能全部可用,
⛔ 不依赖任何其他技能」。而实测(2026-10-06):10-04 那次**只搬了
`01-功能优先协作协议`(问不问)**,**决策方法论(U27/A6 那套)从未搬入**
⇒ 本包正文里搜 `U27`/`A6` **零命中** ⇒ 口径落空。本次补齐。
用户口径(2026-10-06):**「决策方法不是早就合并到 会话技能了吗」**
⚠️ **搬运纪律**:本档**逐字未改**(含原文里已过时的措辞),**唯一一处例外**见下。
改判据请改**本包这一份**;`dsh-decision` 那份视为副本。
🔧 **本包相对源件的唯一修正(1 处)**:第 54 行附件链接
`素材库-U-提报用户.md` → `素材库-U-用户决策.md`
(源件文件名实为「用户决策」;此为**源件既有断链**,一并修掉,⛔ 不留断链)
══════════════════════════════════════════════════════════════════════════ -->
> **归属**:技能 `dsh-decision` · 详情档(主干 `../SKILL.md`)
> **本档覆盖**:原技能 `dsh-decision-method` **全文**(正文 263 行 + frontmatter 变更历史)
> **provenance**:本机版(WorkBuddy 实况)
> **搬运方式**:**逐行未改**;内部附件链接已改写为 `dsh-decision-method/<附件名>`(附件在本目录下)
> ⚠️ 原技能 `dsh-decision-method` **已合并退役** ⇒ 见到该名按本档读。
---
# dsh-decision-method — 平台改造的思考与决策方法
> **一句话**:把「这个需求该怎么定」从**直觉**变成**可复用的判定**。
> 材料来源 = 本工作区 `dsh-server-docs/04-调整方案/` 62 份档案 + `.workbuddy/memory/` 五天日志里的**真实决策痕迹**(含被驳回的)。
> 每条模式都带**实例出处**,可以回溯核验,不是抽象原则。
---
## 0. 定位:和 `dsh-change-workflow` 的分工
| | 本技能 | `dsh-change-workflow` |
|---|---|---|
| 管什么 | **需求和方案怎么定**(选型 / 收敛 / 判方向 / 拍板) | **定了之后怎么落地**(六阶段 / 红线 / 档案模板) |
| 什么时候用 | 用户刚提需求、问"最优方案"、要在选项间选、要判"该不该做" | 决策已定,开始改码 / 验证 / 归档 |
| 产出 | **选项表 + 决策点 + 判定依据** | 代码改动 + 验证记录 + 档案 |
> 顺序:**先本技能定案 → 再 dsh-change-workflow 执行**。规划会话只做前者,执行会话只做后者(交接载体 = `dsh-server-docs/05-交接单/`)。
### 0.1 素材来源与复跑方式(**honest provenance,勿含糊**)
| 素材 | 位置 | 说明 |
|---|---|---|
| 改造档案 | `dsh-server-docs/04-调整方案/NN-*.md` | 每份含「触发 / 用户裁定 / 方案对比 / 事故踩坑」——**会话的结论层** |
| 工作日志 | `.workbuddy/memory/YYYY-MM-DD.md` | 五天过程日志,含用户原话引用——**会话的过程层** |
| 现行事实层 | `BRIEF.md` / `INDEX.md` / `README.md` / `01-规范/06-工作台UI规范.md` / `05-交接单/README.md` | 约定与红线 |
| **原始会话转录** | `~/.workbuddy/projects/<工作区目录名>/*.jsonl` | **用户真实发言的原始记录**(含被否决、被纠正的内容) |
**复跑命令(只读)**:
```bash
# 抽出本工作区全部历史会话里的「用户真实发言」(2026-09-12 实测 = 180 条,跨 09-08→09-12)
python3 dsh-server-docs/07-scripts/extract-user-voice.py
# 查某个决策的来龙去脉
python3 dsh-server-docs/07-scripts/extract-user-voice.py --needle 复用价值
```
> ⚠️ **本方法 v1.0 的素材边界(如实记录)**:首版只从**档案 + 日志**提炼(当时会话检索接口返回 0 命中)。
> 2026-09-12 用上面的提取器回补核对 —— **U1–U12 里 11 条能在原始发言中找到对应原话**(覆盖良好),
> 据此**补上了 U13–U19** 这 7 条只在原始对话里才看得见的模式。**以后迭代本技能,先跑这个脚本。**
---
## 1. 决策素材库 · 用户的有效决策(U1–U28) → **`dsh-decision-method/素材库-U-用户决策.md`**
> **已被拆出本文件**(原占全文 55%)。本文件只留「**判决核心 + 指针**」:素材库是**查阅型**(看了更准、不看也不违规),判定核心才是每次都要用的。
> **触发词 → 直接查哪条**(**必须去读**,别凭印象答用户口径):
| 你正在判断什么 | 去查 |
|---|---|
| 「这件事该不该问用户 / 我是不是又在提报用户了」 | **U20 · U21 · U22** + 本文件 §4.1 两条总闸 |
| 「要不要自己开发 / 有没有现成的」 | **U23**(官方库优先 + 体检三关) |
| 「多期方案 / 兼容性怎么定」 | **U24** |
| 「要给用户看什么 / UI 怎么排」 | U5 · U7 · U11 + 本文件 §6 |
| 「这个改动会不会让项目变差」 | **U28**(十维净变差即命中 ⇒ **停下复盘**;无正向做法 ⇒ **立即停止**)+ 红线 R11 |
| 「要不要降级/延期/先这样跑」 | **U27**(要的是解决问题,不是将就妥协)+ A6 边界(目标不打折、路径取最小代价)|
| 「方案有风险/问题,能不能先干着看」 | **U26**(先优化到「当下最优解」+ 残余风险写清,再走下一步)|
| 「抢了锁之后怎么收口 / 能不能先放着」 | **U25**(锁的生命周期 = 任务的生命周期;带锁结束不算完成)+ 本文件 §4 判据 |
| 「删 / 留 / 清理 / 收尾怎么定」 | U3 · U6 · U14 |
| 「用户那句话到底是什么意思」 | 读全表(每条 = 原话 + 落地 + 判据) |
---
## 2. 决策素材库 · AI 的有效决策(A1–A22) → **`dsh-decision-method/素材库-A-AI推理.md`**
> **已被拆出本文件**。**触发词 → 直接查**:
| 你正在判断什么 | 去查 |
|---|---|
| 「我的证据够不够 / 结论能不能下」 | A1 · A4 · A5 · **A18**(静默失败会伪造结论)· **A19**(验收要含第二环境) |
| 「我凭什么说这是限制 / 做不到」 | **A17**(配置项 / 已抽象层 / 真硬编码,三层取证) |
| 「写状态 / 写支持度 / 交付回执」 | **A15**(三态词表:✅已验证 / ⚠️待开发验证 / ❌已知不支持) |
| 「删除 / 迁移 / 回滚 / 替换」 | A8 · **A16**(伴随物清单)· **A20**(存量烂账选只增不改)· **A22**(先补位再退役) |
| 「我做完了吗 / 能不能宣布完成」 | **A25**(本机改完 ≠ 交付:先画「改动层 → 生效链路」;四条"自我安慰"不算交付)|
| 「我装的门禁/钩子到底管用吗」 | **A24**(拦截面必须覆盖真实行为面 —— 先核账再装)|
| 「判据 / 分类 / 索引怎么设计」 | **A21**(必须报分布、要有区分度) |
| 「失败面 / 报错 / 能力不确定」 | A10 · A11 |
| 「流程走到哪一步了 / 我是不是跳步了」 | **A23**(流程类失效 = 触发词没命中;规则要进常驻层、触发用动作词)+ `dsh-change-workflow` 六阶段 |
| 「拍板前还要问自己什么」 | A2(必含"不做")· A3(决策点)· A6 · A7 · A9 |
---
## 3. 反例库:被驳回 / 被纠正的决策(X1–X13) → **`dsh-decision-method/素材库-反例-X.md`**
> **已被拆出本文件**。**每条反例 = 一条避免规则**。**触发词 → 直接查**:
| 你正在判断什么 | 去查 |
|---|---|
| 「我刚被纠正了,同类还有哪些坑」 | X1(批量)· X4(归因)· X9(过度提报用户)· X10(答非所问)· X11(越界)· **X12**(把用户材料当权威)· **X13**(把自己的解析失败当成版本差异)· **X14**(一批改造拆成多次中断动作)|
| **「这个库 / 方案成熟、star 高,所以选它」** | **§4.6**(选型判据轴:⛔ 热度≠安全/性能;先立轴再排序;必查默认值 + CVE 历史)|
| 动手前的"别踩"清单 | 读全表 13 条 |
---
## 4. 如何确认最优解(本技能的核心)
### 4.1 判定矩阵:先给方案贴标签
> **⭐ 两条总闸(顺序在前:先问「是不是我的 lane」,再问「要不要停手」)**
> **闸 1 · lane**:这件事**落在谁的 lane**?—— ① **我自己负责的**(我的插件源码/产物、该 profile 的依赖、我自己的临时脚本、我方案内的执行细节)⇒ **自己拍**;② **平台级 / 全局 / 别人 lane 的**(`/var/lib/**`、全局符号链接、别人的 profile、别人的产物)⇒ **只报告、不动手**,**哪怕改它能让自己流程跑通**(2026-09-13 用户原话:「谁让你去改这个的」「不是自己负责的任务相关文件不要去改」→ **X11**)。
> **闸 2 · 门禁**:命中**真门禁**才停等确认 —— 现行只剩两条:① **不可逆破坏性操作**(删数据 / 迁 DB / 清目录)② **边界外**(业务目标与优先级 / 花钱与资源承诺 / 对外承诺与合规 / 需用户提供的凭据 / 无客观优劣的偏好 / 影响面超出本平台)。**其余一律自决策**(含部署上线、重启、改配置),事后一句「我选了什么(可推翻)」(**U20 / X9**)。
> ⚠️ 两条闸**对称**:闸 1 治「**越界动手**」,闸 2 治「**过度提报用户**」—— 2026-09-13 两类各犯过一次。
| 判定问题 | 若答案是 | 处置 |
|---|---|---|
| 这次是**扩大**还是**收窄**可见面/权限?(R5) | 扩大 | **停手**,出「权限影响评估」四问 + 等确认 |
| | 收窄 | 可直接做,但**遮蔽类必须真启动一次实例验证** |
| 有没有**不扩大也能实现**的方案? | 有 | **必须先提**;提不出才说明为什么没有 |
| 改动**影响多少文件**?(R7) | >10 或有"所有/整个/全库" | 先出**受影响清单** + 等确认;先 1 个对象单点验证 |
| 会不会**中断在线用户**?(R8) | 会 | 先说明影响面 + 取得确认;能避开活跃时段就避开 |
| 会不会**中断在线用户**?(R8)——**按实际影响面判,不按动作名字判** | **不会**(只换包 / 传产物 / 改静态页 / 投放候选池) | **属边界内的「部署与同步」⇒ 做完即上线,不要问**(U20 / X9);先做完,再用一句「我选了什么」交代 |
| 这个改动**失败**会怎样? | 实例起不来 / 全站不可达 / 不可逆 | 必须有回滚点(备份路径 + 恢复命令)**先就位** |
| **删除/迁移**的收益 vs 潜在破坏? | 收益小、破坏大 | **不删**——标注废弃 + 禁再加功能 |
| **这个改动会让项目某一维度净变差吗?**(**R11 · 十维**:目标/方向/架构/功能/性能/安全/交互/UI/便利性/扩展性) | **会** | **立即停下复盘** → 找保住正向收益的做法;**拿不出 ⇒ 立即停止、只报告**(不许"先做着看",见 **U28**) |
| 会不会造出**第二个漂移源**? | 会 | 改成**指针**(单一来源)——清单/待办/部署事实各只有一个权威文件 |
### 4.2 「确认最优解」十问(拍板前逐条答,答不出就是还没想清)
1. **一句话目标**是什么?做完了**没有**(可判定)?
2. 这条结论我**用什么命令/证据**证明?(说不出 = 还在推断,见 A1)
3. 有哪 **≥2 个选项**、以及**"不做"**这一条?(A2)
4. 这个改动是**扩大**还是**收窄**?(U8 / R5)
5. 有没有**更小**的改动达到同样目的?(A6)
6. 会影响**谁**(全部租户 / 单租户 / 仅 admin)?会**断多久**?(R8)
7. **回滚**怎么做?备份在哪?(写得出可执行命令才算数)
8. 造出**第二个真相源**了吗?(单一来源原则)
9. 用户**能不能感知到**(新入口 / 新反馈 / 新文案),还是只有后端变了?(U5)
10. 谁来**验收**?我能不能给一个**第三方可复现**的命令 + 期望输出 + 退出码?
### 4.3 验收口径分级(越靠后越权威)
| 级别 | 手段 | 能证明什么 |
|---|---|---|
| L1 推断 | 读代码 / 读文档 | **什么都不能证明**(只用来生成假设) |
| L2 命令 | `curl` 状态码 / `--dump-config` / 日志 grep | 机制是否被触达(**注意 `--dump-config` 不反映 bundle/patch 层**) |
| L3 对账 | md5 双端 / `docs-sync-check.sh` / 可见面清单 diff / 工具清单前后对比 | 一致性、无回归 |
| L4 端到端 | 铸造临时 session 实跑 → 越界用例(`../../etc/passwd` 应 400) | 安全与功能闭环 |
| L5 用户实测 | 用户浏览器硬刷新 + 体感确认 | **最终验收**(前端渲染、动画、位置类只能到此为止) |
> **做不到就明说**:「未做浏览器渲染验证(本机无 Chromium,装它成本不成比例)→ 待用户硬刷新确认」是**合格交付**;把 L1 说成 L5 才是事故。
### 4.4 技术实现的默认裁决顺序(**AI 自主用,不问用户**)
> 这是「技术实现找最优解」的可执行算法:**按序自答,第一个"是"就是答案**。全部答"否"才说明确实需要新造东西。
> 配套 `dsh-feature-first`:用户在技术层没有判断依据 → 这 8 条**不构成决策点**,不要提报用户。
| 序 | 自问 | 若"是" → 选它 |
|---|---|---|
| **1** | 有没有**既有机制 / 扩展点**能复用? | 扩展,**不新建**(U2:不做两套) |
| **2** | 有没有**更小改动**达到同一效果? | 取**最小改动半径**(A6) |
| **3** | 能不能**用配置解决**而不是改代码?(cordis patch / env / bundle 声明) | **配置优先** —— 不改码 = 免 build、免重启 |
| **4** | 能不能**只改一层**?(门户静态页 / 编排器 / profile / bwrap / nginx) | **单层**;绝不"跨层顺手改" |
| **5** | 失败代价**是否对称**? | 不对称 → 选**可回滚、可保留**的那条(删除/迁移类尤其,A8);**删除类先走 A14「删除前三问」** |
| **6** | 结果**能不能被验证**?(命令 / 日志标记 / 端点) | 不可验证 → **顺手加可 grep 的标记或端点**(A5) |
| **7** | 这个改动是**收窄**还是**扩大**可见面 / 权限? | 收窄可直接做;**扩大 → 回 R5 门禁,先取得确认** |
| **8** | 与**官方契约的耦合面**有多大? | 耦合越小越好;**每一处耦合都要写进升级回归清单**(档案 26 六类) |
**三条硬约束**:
1. **第 3 条优先于第 2 条** —— "能配置就不改码":改码要走 build + 重启,而重启**会中断在线用户**(触及 R8);配置改动(尤其静态页)常常即时生效。
2. ⚠️ **2026-09-16 修正**:原表述「任一条与红线冲突 → 红线赢」**过宽**,是会话 `ddea70b7` 提报用户的诱因 —— 把"平台级 / 全局 ⇒ 只报告不动手"(R7-边界②)当红线读,于是"红线赢"⇒ **该自己做的事被推给用户**。正确表述:**与「真门禁」冲突 → 门禁赢**(真门禁只有两类 ✓ 见 §4.5);**与表述重叠/打架的规则冲突 → 按 §4.5 裁决顺序**。
3. 🔀 **规则冲突时按 `R8 → §1 边界内自决策清单 → 其余红线` 取首个命中项**,⛔ **不许"自行取保守侧"**(详见 §4.5)。
### 4.5 规则冲突裁决顺序 + 提报给用户前三问(**2026-09-16 加,源自会话 `ddea70b7` 复盘**)
> **案例**:用户要求"完成 guest 迁移",AI 在结尾用「## 四、待你拍板」提了两问(① 106 旧控制面停/留 ② D1–D6 先做哪些)。用户 U6 回:「**D1–D6 先做哪些 按照你的规划执行,中间有问题参考决策方法**」,再两次(U5 / U7)自行给出"删除"的答案。
> **代价**:AI 那两问**都不是真门禁** —— 用户不但没被"省事",还多花一轮把答案喂回来;且 AI 全程 `Skill` 调用 **0 次**(用户点名的方法没被取用)。
**A. 冲突裁决顺序(同一对象被多条规则给出相反结论时)**
| 序 | 取谁 | 命中即停 |
|---|---|---|
| 1 | **R8** | 开发环境服务器 ⇒ **该动就动**,只需动手前一句话说明;⛔ 不必等确认 |
| 2 | **§1 边界内自决策清单** | 部署 / 重启 / 改配置 / nginx·nft / drain / 技术选型 / 命名 / 调参 ⇒ **自决策** |
| 3 | **其余红线** | R5 权限扩大 / R7 批量写入 / R9 锁 / R10 uid —— **永远是硬约束**,不参与裁决 |
**B. "平台级" ≠ "别人的"**(本次误判根源)
`dshs*`·`dsh-*` 单元、`/var/lib/dshs/**`、nginx·nft、端口,只要在**我们自己的 47 / 106 / 本工作区**上,就是**本平台自己的资源** ⇒ 按 §1 + R8 **直接做**。
R7-边界② 的"只报告不动手"**只针对「别人的 / 归属不明」的对象**。**判据看"归属",不看"是不是平台组件"。**
**C. 提报给用户前三问 —— 任一条为"是"即自决策(不必三问全过)**
| # | 自问 | 结论 |
|---|---|---|
| 1 | 对象是**我们自己的**平台资源吗? | 是 ⇒ **自决策** |
| 2 | 我**查证过**关键不确定点吗?(如"还有谁在用 / 有没有访问记录") | 没查 ⇒ **先查**,⛔ 不许把"我不确定"当提报用户理由 |
| 3 | 候选按优劣排序后,**第一名是否明显更优**? | 是 ⇒ **自决策**(只有"各有优有劣、客观分不出高下"才算真取舍) |
⛔ **同时禁止**:把"我有倾向"降级成"建议 + 待你拍板" —— 有倾向 ⇒ 直接做完,写一句「我选了什么(可推翻)」。
⛔ **冲突 ≠ 门禁**:两条规则打架**不构成**提报用户理由。
**产出**:把 1–8 的答案写进档案的「技术选择」段(一行一条,**含被否决的选项与否决理由**)。
→ 这样用户**事后可推翻**、但**事前不被打扰**(对应 `dsh-feature-first` §5 的"默认自主 + 事后可推翻")。
---
### 4.6 技术选型:**先立判据轴,再排序**;⛔ 不许用 star / 年龄 / 生态当选型依据(**2026-09-16 加,源自覆盖网络 relay 选型**)
**反例(我当场犯的)**:把 `frp` 排「首选」,排查依据只有 **~10.6 万 star + 约一月一版 + 生态最好** —— 那是**「省心度」轴**,不是**「安全 / 性能」轴**。用户一句「**项目时间比较久,性能和安全性还真不一定是最好**」直接击穿,复查后**撤销排序**。
复查出的**真实安全面**:`CVE-2026-40910`(认证绕过 + 未授权 DoS,**影响 ≥0.53.0**)|dashboard **默认 `admin:admin` 且口令明文存配置**|**`proxyBindAddr` 默认跟随 `bindAddr`**(官方自己写「**大多数指南遗漏的配置**」—— 不设则代理监听器**绑到公网**)|服务端**默认不强制 TLS**|`auth.token` 是**单一静态共享令牌**、frpc 明文存、`allowPorts` 白名单**默认关**(⇒ 一台客户端失陷可申请**任意**端口)。
⇒ **"项目久"是双刃**:稳定 + 文档好 **vs 漏洞历史长 + 默认值停在历史约定(安全靠运维纪律补)**。
**四条硬规矩**
1. **⛔ 不用「star / fork / 项目年龄 / 发布频率 / 生态」做首轮排序** —— 它们度量的是**热度与省心度**,推不出"在本案里更安全 / 更快"。**只能当同分时的决胜项**,不能当**排序主依据**。
2. **判据轴必须从"本案的真实约束"反推**,并且**每条都要能回答"这条轴上的差异会不会改变本项目的结论?"** —— 不会 ⇒ **该轴不参与选型**。
· 本例立出六条:**① 认证模型(身份 > 每服务密钥 > 共享 token)② 默认拒绝还是默认放行 ③ 能否零新增入站 ④ 单节点失陷的爆炸半径 ⑤ 可观测 ⑥ 生态**。
· 结果:**frp 在 ①②④ 三条里都最差一档**(共享 token / 默认放行 / 可申请任意端口),只在 ⑤⑥ 领先 ⇒ 排序翻转为 **证书身份型(OpenZiti / Nebula)> rathole(Noise_NK 双向认证 + 每服务 token 必填)> frp**。
3. **性能轴先自证"它是不是本案瓶颈"**:本例**第一瓶颈是 presence 不是带宽**、量级是「每 worker 几十个 HTTP 会话」⇒ **吞吐 benchmark 不参与选型**(且多为厂商/二手自测)。**真要测就测链路本身**(RTT / jitter / 带宽)—— **任何 relay 的上限由链路决定,不由实现决定**。
4. **老 / 流行项目必查两张单子**:① **默认值清单**(逐项问"**不设它会怎样**"—— 本例 5 项里任何一项漏设都会**静默**破掉既定安全目标)② **CVE / 安全公告历史**(编号 + 影响版本区间)。**"成熟" ≠ "默认安全"。**
**配套两条(本例同时验证)**
- **"可选性"优先于"选对"**:先把接口抽出来(`Reachability.via` + `Rendezvous` 注册表)⇒ **换实现是 env 级切换** ⇒ 选型**可以推迟**,选错也不致命。**能在不选的情况下保留选择权,就别为"一次选对"付引入成本。**
- **替换 ≠ 无条件升级**:换第三方 = 用**我们不掌控的攻击面**替换**已收窄、且有系统补丁渠道的面**(例:sshd + `restrict,port-forwarding`)。⇒ **没有明确痛点之前,"维持现状"也是合法候选**,别把"换掉旧的"默认当成正向。
**判定触发器**:只要出现「成熟 / 久经考验 / 用得最多 / star 高 / 大家都在用」这类**热度型论据**给排序 ⇒ **立即反问三句**:① 这条论据落在**哪个轴**上?② 这个轴是**本案的瓶颈轴**吗?③ 它的**默认值**与 **CVE 历史**查过没有?
---
## 5. 决策流程十步(新需求到手照这个走)
| 步 | 动作 | 产出 |
|---|---|---|
| 1 | **判类型**:这是「方案请求」还是「任务明确直接执行」? | 方案请求 → 只输出方案,**未经明确授权不改文件** |
| 2 | **开工前置检查**:先看已有技能/资产/记忆,"本机已有的能否满足?" | 决定"扩展还是新建"(→ U2) |
| 3 | **取证**:源码级 / 命令级实证,先拿真实失败请求 | 事实清单(每条带命令 + 期望输出) |
| 4 | **判方向**:扩大 / 收窄 / 中性 | 扩大 → 立即停手出四问 |
| 5 | **出选项表**:≥2 选项 + "不做" + 影响/风险 + 推荐项 | 方案对比表 |
| 6 | **选路径**:优先"最小代价合法路径""能一行代码就不要改平台""删除类判代价对称性" | 推荐方案 + 被否决方案的否决理由 |
| 7 | **列决策点**:已定的标"已定(谁定的/依据)",未定的标"开跑前问用户" | 3–5 个决策点 |
| 8 | **用户拍板** | 决策语言落成文字(见 §7) |
| 9 | **小步落地 + 回滚点就位**(单点验证 → 再推广) | 备份路径 + 恢复命令 |
| 10 | **可复现验收 + 沉淀** | 档案(需求→改动→验证→红线→回滚)+ 红线/技能/记忆三层沉淀 |
> **第 1 步和第 4 步是"停止点"** —— 这两步没结论就不要往下走。
---
## 6. 交互 / UI 改造专项决策清单
> 用户对本项目的界面要求有明确取向,以下是**已验证有效的取向**(源自档案 16/31/36/45/56/57/59/60/61/62 + `01-规范/06-工作台UI规范`)。
### 6.1 反馈与状态(最高频痛点)
| 场景 | 必须做到 | 实例 |
|---|---|---|
| 有任何等待(实例启动 / 拉目录 / 上传) | **可见进度**,且**导航路径与 XHR 路径都要有** | 档案 59:只有导航有 `wake.html` 动画,XHR 静默 20 秒 → 用户"以为死了" |
| 长耗时 | **3 秒后才亮**覆盖层(短请求不打扰) | 同上:`SLOW_MS = 3000` |
| 状态可缓存 | 页面明示「缓存更新于 X 前(6 小时内直接复用,不联网)」 | 档案 62:功能早已存在**但完全看不出来** |
| 不可编辑 / 只读 | 明示原因,不要"点了没反应" | 档案 36 |
| 失败 | 行内提示(`.toolbar-note` / `.empty`),给出**下一步** | 06 规范 §4.8 |
### 6.2 信息层级与术语
- **首列放用户读得懂的那一列**(说明 > 名称 > 技术标识);技术标识降副行(U7)
- **内部术语不上页面**:「候选池」→「导入到平台」;并显式解释易误解的关系(「**投放 ≠ 生效**」)
- **数值列右对齐 + `tabular-nums`**(否则滚动时数字跳动);超宽字段移出共享网格
- **文件名/标题默认同正文色,hover 才变蓝**(避免整列花掉)
- **图表类改动先看 `01-规范/06-工作台UI规范`**,冲突时以 `06` 的**实测 Token** 为准
### 6.3 布局与滚动(踩过的坑)
- `main#view` 高度 = `calc(100vh - 55px)`(**55 是实测值**,写 54 会多出 1px 第二层滚动条)
- **"视口自适应高度"必须上下都算** —— 档案 61 第一次修正只算了列表上方 372px,漏了下方 116px(按钮行 50 + card padding 18 + `#view` padding-bottom 48)→ 仍然溢出。正解 `calc(100vh - 520px)`
- `.modal-mask` 是 `display:flex` → **必须显式写** `[hidden] { display: none }`,否则弹窗关不掉
- 同名类二次定义会互相覆盖(`.tab` 有胶囊式与下划线式两套)→ 新页面**另起类名**(如 `.pg-tab`)
### 6.4 危险与不可逆操作
- 确认弹窗 **必须写明后果**(「将重启实例,会话可能中断」)
- 危险按钮用 `--danger` 系;保存/查看类靠**底色深浅**区分(保存=浅灰底深字 `.btn-import`,查看=白底蓝字 `.btn-view`)
- **不静默提升权限**:档位过时只**提示**,不自动改(安全语义变更必须用户知情)→ 档案 45/56
### 6.5 UI 决策的验收口径
- 静态文件(`web/*.html`)改完**立即生效、无需重启**;只有 `src/**` 才 build + restart
- 改完 `node --check` 内联 JS;用**独立 headless Chrome**(`--headless=new` + 独立 profile + CDP)验证,**绝不碰用户日常浏览器**
- **`section` 名是运行时注册的,`curl` 抓不到** → 这类改动只能靠用户硬刷新确认,**如实标注"未做浏览器渲染验证"**
---
## 7. 决策语言对照表(用户怎么说 → 怎么落)
| 用户的话 | 真实含义 | 该怎么落 |
|---|---|---|
| 「按建议处理」 | **只授权那条建议本身** | 不做顺带优化;范围外的发现**先报告**(R7) |
| 「是不是最优方案 / 还有更好的方式吗」 | 要**选项对比 + 依据 + 风险** | 出对比表 + 十问,不要直接开干 |
| 「一次性优化到位」 | 同主题**做透**,别留尾巴 | 一次列全所有子项,附完成清单 |
| 「不需要 / 是不需要的」 | 先**扫引用与依赖**再定范围 | 出"引用实测表",只剔真 0 引用的(U3) |
| 「暂缓 / 还没准备好」 | **parked** | 保留侦察结论 + 列出"需你先办的事",**不再推进,等用户主动提起** |
| 「确认开始执行」 | 决策已定,**可以动刀** | 立即执行;但 R7/R8 门禁仍生效 |
| 「为什么要等我确认才部署呢」 | **部署 / 上线属执行细节** —— 不打断用户的动作不该提报用户 | 直接做完上线,让用户**看线上效果**判断需求是否被满足(U20 / X9) |
| 「需要告诉我的是 是否已实现,如果未实现:为什么不能,需要我拍板可以问我」 | 要**结论三件套**,不要过程与自省 | 用 **U21** 四节骨架:判定 → 为什么不行(分层)→ 需你拍板(真需要才写)→ 我接着做(陈述句)|
| 「谁让你去改这个的」 | **越界了** —— 动到了不是我 lane 的东西 | 立即停手 → 能撤就撤(并自证不依赖它)→ 报告;此后动手前先过 **U22 闸 1** |
| 「推送 / 提交」 | 明确授权 git 操作 | 此前一律**不 commit 不 push**;提交也只用定向 `git add` |
| 一行指令(无上下文) | 期望**自主拆解 + 排查到底** | 自己建任务链、自己做根因定位,别逐步问 |
| 「为什么…?」(问现象) | 要**根因链**,不是复述现象 | 先取证(真实请求/日志/实测数),再给"现象→根因→修法"三段 |
| 「有没有越过红线」 | 要**逐条对照全量红线**的结论 | 分"当时成文口径 / 新立口径"两种口径答(A13)|
---
## 8. 决策记录模板(写进档案的固定段落)
```markdown
# <NN>-<标题>(<日期> 落地 / 调研)
- 日期: / - 状态:✅已完成|🔄进行中|⏸暂缓|❌关闭 / - 触发:<用户原话或原始报障>
> **TL;DR**|结论 / 关键 / 状态(紧贴头部,3 行内)
## 背景与动机 # 需求来源、真实失败请求、触发场景
## 提报用户 # 关键分叉 + 选择 + 理由 + 日期(原文引用优先)
## 方案对比 # 表格:方案 / 内容 / 判定 / 理由(含"不做")
## 实现 # 改动文件清单 + commit + 关键片段
## 验证记录 # 命令 + 输出 + 结论;**含失败尝试与假阴性**
## 事故 / 踩坑记录 # 现象 → 根因 → 规避
## 回滚 / 注意 # 回滚命令、副作用、后续待办
```
**三层沉淀(每次决策收口都要做)**:
1. **治本层**:机制与流程固化进 skill,或写进 `04-调整方案/NN-*.md` 改造档案(可复用的留 skill,一次性的归档)
2. **失误层**:教训 append 到 `.workbuddy/memory/YYYY-MM-DD.md`(**append-only**,不改写历史)
3. **记忆层**:长期约定/红线写 `MEMORY.md`(工作区级 + 用户级)
---
## 9. 与其他约束的关系
- **红线优先级高于本技能**:R1 不自动升级 dsh|R2 不改官方主程序与缓存|R3 client bundle 禁 `exports.default`|R4 不用真实账号做登录测试|**R5 权限只准收窄**|**R7 禁未经确认的批量/全仓写入**|**R8 中断在线用户须先知会** —— 任一条命中,**先停手**,本技能的效率论证不构成豁免。
- **执行层**:决策定了之后走 `dsh-change-workflow`(六阶段 + 档案模板 + 并行调度协议)。
- **规划/执行分离**:本技能属**规划侧**;产出交接单(`dsh-server-docs/05-交接单/`,8 段必填),规划会话**不 ssh、不改码、不重启、不 scp**。
- **单一来源**:清单与状态 = `INDEX.md §二`;待办 = `01-规范/03-路线图与待办.md`;部署事实 = `DEPLOY-本部署.md`;UI 基线 = `01-规范/06-工作台UI规范.md`;现行事实 = `BRIEF.md`(首读)。
- **可复跑判定工具**:`docs-audit.py`(歧义/编号/悬空引用,退出码非 0 即需处理)、`docs-sync-check.sh`(双端对账)、`docs-manifest.py`(机读清单)、`handoff-guard.sh`(并发预检,推送前 `PUSH=1`)。
---
## 附:本技能的自检(用完之后问自己)
1. 我有没有**先取证再下结论**?(A1)
2. 我给的方案里有没有**"不做"**这一条?(A2)
3. 未定的决策点,我是**问用户**了还是自己替他定了?(A3)
4. 我的"最优解"能通过 §4.2 十问吗?
5. 我的验收口径是 L1 还是 L5?**有没有把推断说成实测**?
6. 这次操作**会不会命中红线 R5/R7/R8**?
7. 收口时**三层沉淀**做了吗?有没有造出第二个漂移源?
8. 技术实现我是**按 §4.4 的裁决顺序**自己定的吗?还是又把技术选项拿去问用户了?(若问了 → 违反 `dsh-feature-first`)
9. 我这次「停手等确认」,是按**实际影响面**判的吗?还是只看到「部署 / 上线 / 生产」这类**词**就触发了门禁?(X9)
10. 我的答复**第一句**是在答用户问的那件事吗?有没有把「我的失误 / 进度 / 计划」写在前两节?(X10 / U21)
11. 动手前我过「**两条总闸**」了吗?—— ① **这落在谁的 lane**(不是我的 → 只报告,**X11**)② **命中真门禁了吗**(没命中 → 自己拍,别问,**U20 / X9**)
12. 这条回复**能被扫吗**?—— 排版按 `dsh-feature-first §5.4`(首屏 3 行给判定 · 每节 ≤7 行 · 加粗只留关键词 · 表格 ≤5 列 · 一条信息只说一次)
---
## 变更历史(原 frontmatter · 逐字保留)
```text
name: dsh-decision-method
description: dsh 多租户平台(ai1net.com)「改造 / 优化功能交互 / UI 界面」的**决策方法论**。当用户提出一个新需求、问「这是不是最优方案 / 还有没有更好的做法」、要在多个方案里选型、要判断某个决策是否该做 / 该不该扩大范围、要**自主给技术实现选取最优解**、或者要复盘「为什么这么定」时使用。⛔ **用户点名「决策方法」/「参考决策方法」/「按你的规划」/「别问我」/「自行决策」/「自主决策」⇒ 必须立即加载本技能,不得凭记忆代替**(机制层由钩子 `dsh-server-docs/07-scripts/skill-load-guard.py` 强制注入加载提醒;2026-09-16 实证:用户点名后 AI 全程 `Skill` 调用 **0 次**)。核心 = 决策素材库(用户有效决策 **U1-U28** / AI 有效决策 A1-A25 / 反例 X1-X14,**素材源含 180 条用户真实发言**)+ 「如何确认最优解」的十问与判定矩阵 + **技术实现的默认裁决顺序(8 条,AI 自主用、不问用户)** + 决策流程十步 + 交互 UI 改造专项清单 + 决策语言对照表 + **复跑脚本 `extract-user-voice.py`**。**v2.0.0(2026-09-14)结构变更:素材库(U/A/X)已拆到 `references/`,按需读;本文件只留判定核心 + 触发词索引**(索引绑定可识别动作)。**与 dsh-change-workflow 分工:本技能管「怎么想、怎么定」,那个管「怎么落地」。** 与 dsh-feature-first 分工:那个管「谁定什么」,本技能管「怎么定得对」。
version: 1.0.0
updated_at: 2026-09-16
created_from: 本工作区 62 份改造档案 + 5 天工作日志(2026-09-08 ~ 09-12)全量提炼
last_change: 【2026-09-22 按要求统一版本号】frontmatter `version` → `1.0.0`(原 v2.8.0);正文与历史中的版本号为当时记录,未改动。
agent_created: true
```
@@ -0,0 +1,165 @@
# 素材库 · AI 的有效决策(A1–A22)
> **归属**:技能 `dsh-decision-method` 的素材库(**按需读**,不是每次都要读)。
> **主文件 / 索引 / 判定核心** = `../SKILL.md`(§4 判定核心 · §5 流程 · §7 语言表 · 附 自检)。
> **用法**:只在「要判某条是否属于既有口径」或「要引用用户原话」时读本文件;**别整段抄进答复**。
> **维护**:条目**只增不改**(编号进位到末尾);用户原话**逐字**引用;每条必须带「实例出处 + 判据」。
---
## 2. 决策素材库 · AI 的有效决策(A1–A16)
> 这些是**被实践证明有效的推理方式**,不是结论。新任务遇到同类岔路时直接套用。
### A1|取证优先于推断:源码级 / 命令级,禁止只靠文档
- **实例**:`--dump-config` 被证伪(它**不反映** bundle/profile patch 层,连已生效的行都不显示)→ 判定口径改为「加载标记 + 浏览器实测」;「手放 node_modules ≠ 安装」(lockfile 才是账本);「空闲回收」**从未生效**(全历史 `idle-reap` 仅 1 次)
- **做法** → 结论前先问"这条我能用什么命令证明",写成**命令 + 期望输出**;文档只当线索
### A2|方案对比表:≥2 选项 + 影响/风险 + 建议,**必须含"不做"**
- **实例**:档案 20 方案 A(加固,推荐)/ B(启 enablePatch,不推荐);档案 45 方案 A(会话迁移,不做)/ B(提示,采用);档案 16 §四 folder_plugins(查明后**建议废弃**)
- **做法** → 表格四列:方案 / 内容 / 判定 / 理由;**推荐项置首并标注 "(Recommended)"**;给不出"不做"这条路说明分析还没做完
### A3|决策点显式列出,等用户拍板(不替用户决定)
- **实例**:交接单 8 段里第 4 段就是「决策点」——**已定的写"已定(谁定的/依据)",未定的写"开跑前问用户"**
- **做法** → 一单最多留 3–5 个决策点,每个给推荐项 + 代价;**未定的决策点不允许执行会话自行拍板**
### A4|能用 A/B 实测就 A/B,别写"应该会更快"
- **实例**:`NODE_COMPILE_CACHE` 冷启动 **5.0s → 4.0s**(实测);反向教训也记:`--max-old-space-size` **不降稳态内存**(优化前后 cgroup 都 ~98 MiB)→ 它买的是**可诊断性**,不是省内存
- **做法** → 性能/资源类结论必须给**前后两个数**;说不出第二个数就明说"未实测"
### A5|把"不可验证"改造成"可验证"
- **实例**:给插件加**加载标记** `[workspace-scoped-picker] loaded root=…` → 把升级回归(C4)从"看 UI"变成"看日志";`GET /api/capabilities` 人机同源
- **做法** → 一个改动如果只能靠"看起来生效了",就**顺手加一行可 grep 的日志/标记/端点** —— 这是最便宜的可验证性投资
### A6|选「最小代价的合法路径」,不选「最彻底的」
- **实例**:档案 45 —— 根治方案是**改写存量会话的种子事件**(属 R5「扩大」+ 多帧 zstd append-only 日志重写,风险收益不成比例)→ 改选**一句提示文案**;档案 42 —— 想禁 `python3.6` 的"遮蔽"会让实例起不来 → 改选**文档引导**(成本 0、风险 0)
- **判据** → 问三句:① 有没有更小的改动达到同样目的?② 这个改动**扩大**了吗?③ 失败时的后果对称吗?
- ⚠️ **边界(U27)**:A6 只约束**路径**(实现取最小代价),**不约束目标** —— 不许把"选最小代价"读成"降低目标 / 延期 / 静默兜底"。**目标不打折,路径取最小代价。**
### A7|能一行代码解决,就不要改平台配置
- **实例**:实例内 Python 抓 HTTPS 报 CA 错 → 正解是脚本里 `SSL_CERT_FILE=/etc/pki/tls/certs/ca-bundle.crt`,**不走 R5 注入 env**(因为注入 env = 扩大,而一行代码就够了)
- **判据** → 平台级改动(env / 挂载 / nft / 配额)是**最后手段**,不是第一手段
### A8|遇到"要删/要迁移"先判代价对称性;不对称 → 保留
- **实例**:`folder_plugins` / `workspaces` 表**加废弃注释保留**(跨 10 文件 + 含 k8s/PG 未验证路径 → 删除风险不对称);4 份 cold 档案不迁 archive(收益小);技能包裁剪则相反 —— 真 0 引用的才剔
- **判据** → 删除的**收益 < 潜在破坏**时,选择"标注废弃 + 禁止再加功能",而不是删
### A9|明确「不做」并写明「打开条件」
- **实例**:白名单源码安装 **不做**(与"平台不执行第三方构建脚本"红线冲突)→ 同时留「打开条件清单」(沙箱构建 / `--ignore-scripts` / 仅 admin / 审计 / 全量扫描);熔断实测、管理类插件化也都明确关闭
- **做法** → 「关闭」必须写两句:**为什么不做** + **什么条件下可以重开**;只写"不做"会在下次被重新提起
### A10|失败面要留证据,不要吞错误
- **实例**:插件探活失败**回传 dsh 真实错误**(`duplicate loader entry id` / `ERR_MODULE_NOT_FOUND`),不要泛化成"插件不兼容";坏包隔离并改名标注 `-BAD-missing-index.tgz`
- **判据** → 报错信息是 admin 判断根因的**唯一线索**,泛化等于毁掉线索
### A11|不确定的能力显式抛 `unsupported`,不假装支持
- **实例**:`K8sUserFs.readFile` **显式抛 `unsupported`**(sidecar 尚无端点)→ 「属未验证路径,**不假装支持**」
- **判据** → 本地验证过的能力才写"支持";没验证的路径要么标注"未验证",要么直接拒绝
### A12|只读核对也要留痕(不改也要出结论)
- **实例**:所有"核查 / 取证 / 评估"类任务都产出档案(编号 + 状态 + 触发 + 结论表),即使**一行代码都没改**
- **判据** → 「核查完成」本身就是一个交付物;不落档的结论等于没发生(下次会重复发现)
### A13|自查要分「当时成文的口径」与「新立的口径」
- **实例**:越界自查结论 = 按当时的 R1-R6 **未违反**;按当天新立的 R7/R8 则**5 次实质越界** —— 两种口径都给出,不粉饰
- **判据** → 复盘/自查时**说明用的是哪一版标准**,否则结论不可信
### A14|删除 / 移除 / 下线前,先查清楚再动手(用户 2026-09-12 明确)
- **实例(我的失手)**:我把服务器上两个文件报成"多余、待用户定是否删除",却**没先看它们是什么** —— 一查才发现三件事:
① 本机 `scripts/` 里**都有**(我只 `ls` 了根目录就说"本机没有")
② 服务器那两份**早已被并行会话清掉**(我把一个**已消失**的问题抛给了用户)
③ 顺着 `grep -rn` 找到档案 73,一句话就看清用途 —— `lock-guard-hook.py` 是 **PreToolUse 强制钩子**
- **删除前三问(没答完就不动)**:
1. **它是什么** —— 读内容 / 读关联档案的 TL;DR,**不要凭文件名猜**。
2. **被谁引用** —— `grep -rn <名> --include="*.md" --include="*.ts" --include="*.cjs"`;**全库 `find`,不要只看当前目录**。
3. **删了影响谁** —— 是否有别的会话 / 服务 / cron 在用;**是不是唯一副本**。
- **判据** → **未查明就不动**;查明后确认是"**放错位置的多余副本**"(正本在别处且 md5 一致)才可直接清理。
- 与 **A8** 配合:删除是**风险不对称**动作(删错 = 丢失,留着 = 只占空间)→ **默认保留**,除非已查清。
### A15|写状态必须带「三态 + 级别」,禁止用「支持 / 可用」描述未验证项
- **实例**:开源文档把未验证的 Kubernetes 模式写成**方案 B** → 用户纠正:「模式 B · Kubernetes **去掉这个,根本没验证**,应该是**待开发验证**」。
- **三态词表(强制)**:
| 写法 | 什么时候能用 | 必须附 |
|---|---|---|
| ✅ **已验证** | 有**可复跑**的命令 / 日志 / 截图,且**取数时间**明确 | 命令 + 期望输出(§4.3 L2–L5) |
| ⚠️ **待开发验证** | 代码 / 文档存在,但没跑过端到端 | 「未验证」三个字必须出现在**结论句**里 |
| ❌ **已知不支持** | 实测证伪 | 失败证据 + 复现条件 |
⛔ **禁用词**:「支持 / 可用 / 已实现 / 已落地」**不得**用于 ⚠️ 段;上一轮(档案 76 §10.7)就是**用文件名代替读包**,把「没这能力」说成了结论。
- 落地:开源 README / 档案状态 / 能力清单 / 交付回执 **四处统一用这套词**。
---
### A16|回滚 / 改名 / 迁移类动作:先列「伴随物清单」,只改主体必留隐患
- **实例(同日两起)**:① **回滚只回滚了部署、没回滚源码** → 下次重建又把补丁带回来(0.2.19 误带堆限 ⇒ 0.2.20 才真修);② **改 SQLite 库名漏了 `-wal` / `-shm`** → 358 KB WAL 未被 replay、**丢了 2 条会话记录**(归位后 4→6 恢复)。
- **伴随物清单(动手前逐项打勾)**:
| 动作 | 主体 | **必须一起处理的伴随物** |
|---|---|---|
| 回滚 | 制品 / 部署 | **源码** + 构建缓存 + lockfile + 实例内已加载的 bundle |
| 改名 / 迁移 | 主文件 | **旁路文件**(SQLite `-wal` `-shm`)· 目录 · 符号链接 · 所有引用点(`grep -rn`)· 运行中的进程 / 服务 |
| 批量替换 | 命中文件 | 自引用 / 自赋值(`replace_all` 会命中刚插入的定义行)· 行尾风格 · 语法校验(用 build 当校验器) |
- **判据** → 凡「一个名字 / 一份数据 / 一段制品」被改或退回,**先写出它的伴随物清单**再动手;**改完必须读回复核**(不靠「应该没问题」)。
### A17|判「这是限制」之前先分层取证:配置项 / 已抽象层 / 真硬编码
- **实例(用户当场纠正我)**:我把「DB 是单文件 SQLite」列为集群化的限制 → 用户口径「**可以改为连接数据库集群,数据库不是限制**」;实测 `DSHS_DB_URL` 非空即切 PG(`db/index.ts:19`),`db/adapter.ts` 头注释已声明「routes depend only on this abstraction」⇒ **早有抽象,是配置项不是天花板**。
- **判据** → 任何「做不到 / 是限制 / 是天花板」的结论,先把它归到三类之一并给出**代码行号或命令**:① 配置项(改 env/参数即可)② 已抽象层(换实现后端)③ 真硬编码(必须改码)。
- 反面同时成立:**别把「能配置」当成「已经能用」** —— 同一次实测才发现 `dsh_instances` 归属表**只有 k8s 路径在写**(`LocalSpawner` 根本不收 db)⇒「换库单独做 = 零收益」。
### A18|静默失败会伪造结论:工具静默 + 降级静默,两头都要防
- **实例(同日两起,都差点骗了我)**:① `grep -v node_modules` 把**要查的行本身**也滤掉 ⇒ 得出"源仓 0 处"的**假结论**(改用 ripgrep 才看到真相);`rg` 在本机 PATH 不存在 + `2>/dev/null` ⇒ **静默返回空**,看起来像"扫干净了"。② `listCatalogProviders()` 的 readdir 抛错被 catch 吞掉返回 `[]`,注释还把降级写成特性 ⇒ **"没生效"和"没做"不可区分**,缺陷潜伏三轮。
- **判据** → **断言"扫干净了 / 没有 / 全绿"之前,先自证工具真的跑了**:跑一次已知命中的探测、看退出码、别用 `2>/dev/null` 掩盖失败;排依赖目录用 `--exclude-dir`/`.gitignore`,**不要用行过滤**。
- - ⚠️ **姊妹坑:把失败误读成成功**(2026-09-15 实证)—— 用管道截尾看命令输出时(`| grep` / `| tail`),**失败提示里也含成功关键词** ⇒ 假命中 ⇒ "以为抢到锁了"却在无锁状态改库。
✅ **判据**:**先看退出码**(不要接管道),再**复读关键状态文件/OWNER 并断言**;"输出了像成功的话"**不等于**操作成功。
**降级必须留痕**:静默 catch 至少 `console.warn` 一次,或把「不可读 / 目录为空」**透出到状态端点**(本次已落地:`/api/me/model-providers` 的 `catalog{dir,readable,count}`)。
### A19|验收判据必须包含「第二环境」——同环境反复通过会掩盖跨环境从未验证
- **实例**:写死 `/usr/local/lib/node_modules` 在本机(npm 默认 prefix)**恰好是对的** ⇒ 端到端验收"38 家全绿"只证明了"**在这一台机器上**对";换 `/usr/lib` 布局(`install.sh` 部署的机器)则厂家目录读空、兼容性预检**整体失效**、目录选择器 import 即抛 —— 且**全都静默**。同一个未验证假设被**复制了三轮**(picker → plugin-compat → model-catalog)。
- **判据** → ① 凡「读**别人**安装位置 / 版本 / 布局」的代码,路径**必须走解析层**,且**至少一条单测用假根**(已落地 `scripts/verify-dsh-install.mjs`,15 项断言);② 验收判据**显式包含"非本机布局"**(一句 env 注入即可);③ 前端验收**别用 fixture 桩**掩盖后端不可读(本次就是断言全绿而端点其实是空的)。
- **🔑 信号识别**:注释里出现「**本部署事实 / 目前是 / 暂时**」⇒ **当场转成待验证项**(那是作者自己知道这是假设的痕迹)。
### A20|存量烂账(巨量重复 / 历史遗留):选「只增不改 + 库外视图」,不就地重写
- **实例**:档案 82 = 2024 行 / 89.1 KB,切 **224 块**后 **重复标题 22 个、冗余块 190 个(≈85%)**;但「八、口径提醒」有**两个内容不同的变体** ⇒ **不是纯复制,盲目去重会丢信息**。
- **做法(零改写)**:原文**一字未改** → 文末追加「修正(日期)」小节(实测数据 + 视图路径 + 阅读建议)→ 生成**去重视图**(保留每组信息最全的一份,其余留占位注释)**只写库外** `.workbuddy/cache/dedupe-view/` → manifest 加 `dedupeView` 字段、检索命中时提示"优先读视图"。89.1 KB → 59.0 KB,**零风险**。
- **判据** → ① 先做**变体检测(同名 ≠ 同内容)**再决定能不能去重;② **写入权留在原文**(历史冻结),派生物放库外;③ 根治(拆分)与止血(视图)**分开立项**,别用一次大改解决两件事。
### A21|任何自动判据都要报「分布」;失效就改,别让它假装有区分度
- **实例**:`tier` 判据原为"被引用次数 ≥8" ⇒ **hot 44/89 ≈ 一半**(等于没筛);改为「**谁在引**」四档(hot = 被 L1/L2 现行层引 ≥3 次)⇒ **hot 7/89(8%)** ✓。⚠️ 我第一版把 **L4 台账类**也算现行层 ⇒ hot 反升到 60,**更糟**(台账会顺带列出几乎所有档案号,**索引式提及 ≠ 要读**)。
- **判据** → ① 判据上线时必须报**分布**(各档占比),一眼看出有没有区分度;② **区分"顺带列举"与"真的依赖"**(索引/台账/清单类文件的提及**不算**引用);③ 判据本身要定期体检(老判据会随规模失效)。
### A22|替换 / 退役类动作的顺序:先补位,再退役
- **实例**:univer 承担 ① AI 生成 docx/xlsx ② Office 导入导出 ③ `.univer` 协作预览(**独有**)⇒ 要弃用必须**先把 ① 换成 `dsh-office` 并验证**,再停 univer;「**顺序不能倒**」。同理 `@softspark/dsh-file-preview` 的退役**必须等平台升级(阶段 4)之后** —— 现在退 = 生产立刻失去预览。
- **判据** → 动「下线 / 替换 / 摘除」之前先写三行:**它现在承担什么**(逐项列)→ **每一项换成什么 + 验证过没有** → **换完之前不许停**。
- 与 **A16(伴随物清单)** 互补:A16 管"要一起改什么",本条管"**先做哪一步**"。
### A23|流程类失效多半不是「忘了」,而是「触发词没命中」
- **实例(2026-09-14 事故复盘,档案 95)**:改平台代码 / 铺插件 / 重启 `dshs` 时,AI **没把自己这次动作分类成"落地一次改造"** ⇒ 六阶段流程**整条不存在**(阶段 0 前置检查、阶段 2 方案确认、阶段 5 归档全缺)。自审原话:「**本技能就在本机,我一开始没加载;直到用户追问才加载**」。
- **根因是结构性的**:流程只写在**按需加载的技能**里,而常驻层 `CODEBUDDY.md §2` 自己就写着「**技能的加载由模型判断相关性,不能保证**」 ⇒ **没加载 = 没有流程**。
- **判据** → ① 凡「**动作前必须生效**」的规则,**必须写进常驻层**(项目根 `CODEBUDDY.md` / `MEMORY.md`),技能只放"需要时去拿的方法论";② 常驻层的**触发条件要用「动作词」**("要改平台代码 / 要铺插件 / 要重启服务"),**不要依赖模型自我分类**("我要做一次改造"这种判断本身就会失效);③ 自审时区分**"当时成文口径"与"新立口径"**(A13)—— 本次 95 的自审引的是**过期 R8**(要求"取得确认"),而 R8 已于 09-13 由用户改为"开发环境服务器不必等确认,只需动手前一句话说明"⇒ 两栏都要给,否则违规被高估。
### A24|拦截面必须覆盖「真实行为面」——装在工具上的闸门,拦不住正文里的行为
- **实例(2026-09-15 实测)**:昨天给「提问闸门」装了 `PreToolUse` + matcher `^AskUserQuestion$` 的 hook,想治"AI 老让用户确认简单问题"。今天核账:本工作区日志 `tool=AskUserQuestion` 调用数 = **09-12: 43 / 09-13: 3 / 09-14: 0 / 09-15: 0**(`UserQuestion`/`elicitation` 的命中全是 `--tools` 参数与 host capability 字符串,不是调用)⇒ **真实提报用户几乎全在正文里,hook 从装好那天起就 0 命中**。
- **判据** → 设计任何"拦截 / 校验 / 门禁"之前,先**用日志或计数证明行为发生在哪一面**:
① 统计**该面的真实发生率**(不是"应该有");② 若闸门装面上限远低于行为面,**闸门等于装饰**(还制造"已经治好了"的假安全感);
③ 正文类行为(无法被工具 hook 拦)只能靠**常驻层的可执行自检动作**(详见 `CODEBUDDY.md §1` "回话前自检")或 **Stop hook 扫最后一条回复**。
- **可复跑的核账命令**:`grep -c "tool=AskUserQuestion" <工作区宿主日志>`(宿主日志在 `~/.workbuddy/logs/<日期>/<工作区名>__*.log`,记录 `[ToolManager] execute | tool=X`)。
### A25|本机改完 ≠ 交付 —— 先画出「改动层 → 生效链路」再宣布完成
- **实例(同类 3 次,2026-09-13/14/15)**:① 只做到**本地打包、没部署** ⇒ 用户「点开看还是和之前一样」;② 平台登录页/运行时**只改本机、从没部署到服务器** ⇒ 用户看的是旧页面(原话:「**那你看的当然还是旧页面**」)。
- **判据** → 宣布完成前逐项答三句:**① 改的是哪一层?② 这一层的生效链路是什么?③ 最后一步走了吗、在「用户可见面」验了吗?**
链路清单(缺一步都不算交付)见 `dsh-change-workflow` **阶段 5 §0「交付门禁」**表格:静态页 = scp(含 CDN 缓存坑)|平台 TS = build + restart|插件 = tgz → 候选池 → 实例启用 → **重启实例**|文档库/技能 = scp + 对账|配置 = 改 + reload/restart。
- **四条"自我安慰",一条都不算交付**:**本机改完了** · **build 通过了** · **本地打包完成** · **已 commit 了**。
- 与 **A19**(验收要含第二环境)互补:**A19 管"在哪个环境验",本条管"链路走没走完"**;与 **U20 / X9** 互补:部署本身**不必问用户**,但**必须做**。
@@ -0,0 +1,194 @@
# 素材库 · 用户的有效决策(U1–U24)
> **归属**:技能 `dsh-decision-method` 的素材库(**按需读**,不是每次都要读)。
> **主文件 / 索引 / 判定核心** = `../SKILL.md`(§4 判定核心 · §5 流程 · §7 语言表 · 附 自检)。
> **用法**:只在「要判某条是否属于既有口径」或「要引用用户原话」时读本文件;**别整段抄进答复**。
> **维护**:条目**只增不改**(编号进位到末尾);用户原话**逐字**引用;每条必须带「实例出处 + 判据」。
---
## 1. 决策素材库 · 用户的有效决策(U1–U22)
> 「有效」= 事后被证明正确、且已被落地验证。**这些是用户的稳定偏好,不是一次性指令** —— 新需求来时可以按此预判方向。
### U1|面向用户的东西只保留「用户视角」,不暴露平台内部
- **原话**:「只允许在自己的目录下创建工作区」「**不要给用户看完整路径**」;「用户就只能访问(含读取)dsh 服务用户 id 对应的那个文件夹,**连读都不要读取**」
- **落地**:picker 根固定 `<userRoot>/ws`、面包屑显示「我的工作区」、下载走平台代理不暴露宿主路径(档案 17/18/39/56)
- **判据** → 任何 UI/接口会暴露**绝对路径、uuid、内部术语、宿主目录名**的,一律收敛;收敛不需确认,扩大才需要(见 R5)
### U2|不做两套实现,能扩展就不新建
- **原话**:T01「实例内我的技能 → **扩展 business-plugins 不用做两套**」(否决了"新建 `@dsh-local/my-skills`")
- **判据** → 已有扩展点能承载(哪怕要加一个 section / 一个路由)→ **优先扩展**;新建只在"语义完全不同 + 复用会耦合"时才提
### U3|删任何东西之前先扫引用("看起来像资料"≠"没被引用")
- **原话**:「参考资料是不需要」(针对 MCN 技能包裁剪)
- **AI 的正确处置**:扫描后发现三类"像资料"的其实是被引用的**功能件** —— `nuwa-skill-main/` 主体被引为"主方法论"、`references/样例/` 被 `06_生成账号设定卡片.md:67` 写"动手前必须先读" → **保留**;只剔真 0 引用的块;且**剔 `browser-harness/` 目录必须同步改文档**,否则留死引用(档案 T03 §4.1 裁剪表)
- **判据** → 删除类需求的**第一步永远是引用扫描**(`grep -rIn` + 排除说明行),产出"引用实测表"再定取舍;**剔除目录 = 必须同改引用它的文档**
### U4|交付面越少越好(推翻架构洁癖)
- **原话**:「**业务技能要打包进插件里一起安装使用,不要分开管理**」(推翻 AI 既有的"技能走平台共享技能层"结论)
- **判据** → 用户的心智模型("我装一个插件就全有了")**优先于**架构上的"分层更干净";一个包能解决就别拆成两条投放链路
### U5|状态变化必须让用户感知到(无反馈 = 缺陷)
- **原话**:「点重连成功,但**过程中没有任何加载动画**」;「**AI 生成的文件看不到、下不了**,本机地址浏览器打不开」
- **落地**:XHR 挂起 3 秒上覆盖层(档案 59)、右下角「📁 我的文件 / 🧭 能力」、档位提示条(档案 56)
- **判据** → 后端正确但用户"看不见 / 点不动 / 不知道在等什么" = **同等优先级的功能缺陷**;凡有等待、有状态、有产出的地方都要有可见反馈
### U6|同一主题一次性做透
- **原话**:「**一次性优化到位**」;「AI 对话记录(sessions)保留时间**可以长些**」
- **判据** → 用户讨厌"打补丁式小改";同主题一次列全(如清理策略一次落 5 项);用户给方向性偏好("长些")时**自己定量再回填**,不要反复问
### U7|信息层级要站在"看得懂"的角度,而不是开发者视角
- **原话**:「**导入到候选池是什么意思**」「官方插件优先展示应该是**插件说明或中文名称**,需要**一眼知道这个插件是干什么的**」
- **落地**:白名单首列由插件名改**中文说明**(2 行 clamp),技术标识降副行;「候选池」→「导入到平台」;加一句「**投放 ≠ 生效**」的说明(档案 31)
- **判据** → 表格/列表首列放**用户能读懂的那一列**;内部术语不得直接上页面
### U8|权限方向只准收窄,扩大必须先确认
- **原话**:(R5 由来)用户明确划定:收窄可直接做,**扩大一律先出「权限影响评估」四问**
- **判据** → 任何改动先答「这次是**扩大**还是**收窄**」;命中扩大(新增挂载 / 放开遮蔽 / 暴露平台目录或 env / 放宽 ALLOWED_ENV / 放松 nft / 提档位 / 新增可读写路径 / 让 root 执行链路的对象变用户可控)→ **停手等确认**
### U9|明确「暂缓」也是决策
- **实例**:档案 21 后续(禁 HMR / catch-all vhost)「用户已决定暂缓」;域名切换侦察后用户说"还没准备好" → **parked,不再推进,等用户主动提起**
- **判据** → 前置条件在用户侧(备案 / 账号 / 供应商)时,**给出"需你先办的三件事"然后 parked**,不要反复追问或硬推
### U10|术语统一只改用户可见的那一层
- **原话**:「将所有**业务插件**字段改为**功能插件**」→ 后来「将**功能插件**改为**功能管理**」
- **AI 的正确收窄**:只改**显示文案/label**(zh+en 同步),**代码标识符、表名、包名、API 路径、文件名一律不动**;同一 locale 里描述"插件这个事物"的句子也**刻意不改**(分区叫「功能管理」,管的对象仍叫「功能插件」)
- **判据** → 术语需求 = **label 级改动**;一旦要动标识符就是大范围重命名 → 回到 R7 先出清单
### U11|用户给的数值即使偏离规范也是硬约束
- **原话**:「插件管理的官方插件管理 列表页高度增加 和页面底部间隔 **200PX** 即可」
- **落地**:`#view.page-plugins { padding-bottom: 200px }`,**代码注释注明**「`01-规范/06-工作台UI规范` 常规区间是 14~24px,**200px 是用户明确指定的例外**」(档案 61)
- **判据** → 用户点名数值 → **照做 + 注释固化来源**,防止日后被"按规范修正"回去;与规范冲突时在注释里写清是例外
### U12|危险操作要确认弹窗 + 明说后果
- **原话/定稿**(档案 16 §2.2):批量启用禁用 → **必须点确认弹窗**,弹窗内写明「**将重启实例,会话可能中断**」
- **判据** → 任何会打断用户当前状态的操作(重启实例 / 停会话 / 清数据)→ 前端确认 + 明写后果;后端配合「先备份再执行」
---
### U13–U19|2026-09-12 用原始会话转录回补核对后新增
> 前 12 条来自**档案 + 工作日志**(会话的结构化沉淀)。这 7 条是拿 `07-scripts/extract-user-voice.py` 抽出
> **180 条用户真实发言**(跨 09-08→09-12、5 个会话)逐条比对后**补上的漏项**。
### U13|我报给用户的数字,会被他当作事实基础
- **实例**:09-12 00:06 用户直接引用我的读数再追问 ——「当前实况:2 个实例在跑,占用 **212 MiB** 和 **329 MiB**:为什么一个用户要占用这么多内存」
- **判据** → **报数必须准确、并带取数时间与口径**;分母/单位错一次就会被当成事实传下去(档案 58 曾把 `systemd` 的**字节**当 KB,读数放大 1024 倍)
### U14|判断「该不该留」用的判据是「**有没有复用价值**」
- **原话**:「是不是搞错了,**AI 生成的代码和脚本等才是需要重点定期清理的**」「这些脚本基本都是根据某个对话任务产生,**没有复用价值**」
- **判据** → 取舍类需求**先问"这东西有没有复用价值"**,而不是问"它看起来像什么"(对照 U3:先扫引用)
### U15|用户会盯**产品级容量约束**(能撑多少人)
- **原话**:「这个应该属于**控制用户上限**,不能让 10 个用户注册每个用户体验都差」
- **判据** → 用户提需求时**隐含容量期望**;凡涉及常驻资源 / 并发 / 注册量,**主动把容量影响算出来讲清**(引用档案 58 的实测:2 vCPU / 1870 MiB,并发上限 ≈ 3 实例)
### U16|用户给的「实现建议」是**意图表达**,不是硬要求
- **实例**:09-12 09:39 用户提「检测鼠标移动/焦点自动拉起实例」→ 被论证「同时动鼠标会 OOM」后**他接受了否决**,并把话题转到容量上限(U15)
- **判据** → 可以(也应该)用更优方案替代,**但必须说明为什么否决**;不要为了"照做"而实现一个坏方案
### U17|用户会充当**执行手** —— 必须给可直接粘贴的完整命令
- **原话**:「还需要建立 ssh 链接 **端口号 32022**」「我的意思有些操作需要 ssh 执行」「**给我个命令去服务器上执行就行**」
- **判据** → 凡需要用户动手的(服务器命令 / 浏览器验证 / 关应用再执行),**给一条完整可粘贴的命令 + 期望输出**,不要让用户拼命令;需要他关掉某程序时,要说清"为什么要关"
### U18|用户会明确「关闭」一条线 —— 关闭后不再推进
- **原话**:「**忘记这个项目把**」(否决 my-deepseek-harness 二开路线);「**还没准备好 后面再说**」(域名切换 parked);「**档案 21(卡顿/流式)可以展缓**」
- **判据** → 看到这类措辞**立即 parked**:保留已得结论、不再追问、不列入待办;**等用户主动提起**(同 U9,但这条是"用户主动关",U9 是"AI 建议暂缓")
### U19|用户大量时间花在**平台能力认知**类提问上
- **原话**:「dsh 插件开发 不同的插件 代码都是独立的吗」「一个插件的功能都在一个文件夹中吗,是否可以像 SKILL 管理一样做一个插件管理页面」「插件可以热更新 热加载吗」
- **判据** → 这类"**是什么 / 能不能**"的往返,**应由「能力清单」类交付物一次性消除**(档案 56 的 `platform-capabilities` 共享技能 + 实例页「能力」面板就是为此而生);**每做一次能力变更,同步更新能力清单**
### U20|「不打断用户的上线」不要问 —— 部署 / 上线属执行细节(2026-09-13 用户纠正)
- **原话**:「**为什么要等我确认才部署呢,我看线上效果才知道是否满足需求**」
- **背景(真实失手)**:v0.2.8 已打好包,`ensure-biz-plugins.cjs --all` **只换 profile 里的包**(不重启、不停实例、**零中断**),AI 却按 R7「只做被明确要求的事」**停下等确认** ⇒ 用户看不到线上效果,**无法判断需求是否被满足**。
- **判据** → **红线的触发按「实际影响面」判,不按「动作名字」判**:
- R8 的**唯一**触发条件 = **会中断在线用户**(重启 `dshs` / 停实例 scope / drain / 改实例配额·env / 改 nginx·nft);
- **不中断**的上线(传产物到 `/opt/dsh/artifacts`、换 profile 包、改静态页、候选池投放)= 边界内已列的「**部署与同步**」⇒ **做完即上线**,事后一句「我选了什么(可推翻)」交代即可。
- **与 R7 的边界**:R7 管的是「**未经确认的批量 / 全仓写入**」和「**范围外的额外改动**」,**不管「该不该上线」**;把 R7 的精神套到上线动作上 = **过度套用**(见 X9)。
### U21|答复要「结论三件套」:是否已实现 → 为什么不行 → 要拍板什么(2026-09-13 用户纠正)
- **原话**:「**需要告诉我的是 是否已实现,如果未实现:为什么不能,需要我拍板可以问我**」
- **背景(真实失手)**:AI 用「我自己的失误(一并交代)+ 探针怎么被污染 + 版本流水(0.2.19/0.2.20/0.2.23)+ 下一步三步计划」回答了「是否已实现」——用户要的结论**在第 5 段之后**才出现;收尾还问「要我现在接着做,说一声即可」。
- **判据** → 回答「是否 / 能不能 / 为什么不行」类提问,**固定四节、顺序不变、没有的节整节删掉**:
① **判定**(✅ 已实现 / ⚠️ 部分可用 / ❌ 未实现 + 一句话;用户列了多项就逐项给)
② **为什么不行**(按层:已排除的原因 → 当前唯一卡点,最多 3 层)
③ **我接着做**(**陈述句**,不是征询句)
④ **需要你拍板**(**必须是整条回复的最后一节** —— 它后面不许再有任何节;**真需要才写**,不需要就删掉整节)
- **四条铁律**:主位必须是**用户问的那件事**(AI 的进度 / 失误 / 计划不得占前两节)· 结论层**零技术标识** · **禁征询式收尾**(已定的下一步直接做)· **待拍板项置末**。
- **修正(2026-09-15 用户明令 · 完整原话)**:「**能根据决策方法 自行决策的就自决策继续处理,不能决策的问题和需确认内容放在回复的最后,按照有序段落展示**」⇒ ③④ **对调**(原为 ③ 拍板 → ④ 接着做),且该节要**逐条编号**(有序段落,不写成散文)。⚠️ 分清三件事:**自决策**(能判的别问,直接做完并陈述)· **位置**(不能自决策的收到最后一节)· **形态**(陈述句列「选项 + 优缺点 + 我的倾向」,⛔ 不是征询句"要不要我…" / "说一声即可")。
- **修正 2(2026-09-15 用户明令)**:「**需要我确认的方案需要说明优点和缺点,现在没法判断,假如只有优点或只有缺点那不需要我判断**」⇒ **提报给用户前先过「取舍筛」**:候选各写**优点 + 缺点** —— ① 某个**只有优点**(明显更优)或**只有缺点** ⇒ **不需要用户判断**,自己拍掉再陈述;② 只有**各有优有劣、客观标准分不出高下**(真取舍)才提报用户;③ 提报给用户时**必须逐项列出优点与缺点**(只写"可感知差别"**不算** —— 用户原话「现在没法判断」就是这么来的)。落地载体:`dsh-feature-first §5.3 铁律 5 / §5.4 硬约束 8 / 反模式 12`。
- **修正 3(2026-09-15 用户明令)**:「**每个需要我决策的问题的潜在解决方案 A B C 也按照段落式排版,别横着排列**」⇒ 候选**竖排成段**(A / B / C **各占一行**),⛔ 不许写成 `A:… · B:…` 一行横排,⛔ 也不许把候选做成**表格的列**(表格是横向对比,与"段落式"正相反)。段内「优点…;缺点…」连写即可,不必每个字段再拆行(否则撞 §5.4 硬约束 3「每节 ≤7 行」)。落地载体:`dsh-feature-first §5.3 铁律 5 ④ / §5.4 硬约束 9 / 反模式 13`。⚠️ 触发它的正是**我自己的实际输出**(把三个候选写成 `A 维持现状 · B 按 50 KB 切分 · C 改成按月分片` 一行并列)。
- 落地载体:`dsh-feature-first §5.1–§5.3`(含**可复制骨架**);与 **X10**、**U20 / X9** 同族(都是「别把该自己拍的推回去」)。
### U22|只碰自己 lane 的东西;平台级 / 全局 / 别人 lane 一律只报告(2026-09-13 用户明令)
- **原话**:「**谁让你去改这个的**」+「**不是自己负责的任务相关文件不要去改**」
- **背景(真实越界)**:为打通自己任务的插件投放(撞 `ERR_PNPM_UNEXPECTED_STORE`),AI **自行创建了 `/var/lib/dshs` 全局符号链接** —— 无人授权,且属**平台级路径**。用户当场追问「谁让你去改这个的」;AI 随即自行撤销并确认实例不依赖它。
- **判据** → **动手前先问「这在谁的 lane」**:
- **我的 lane(可自己拍)**:① 我负责的插件源码 / 产物 ② 该 profile 的依赖安装 ③ 我自己的临时脚本 ④ 我方案内的执行细节(部署 / 上线 / 重启 / 改配置)
- **不是我的 lane(只报告、不动手)**:平台级路径与全局文件(`/var/lib/**`、符号链接、systemd 单元、nginx·nft)· 别人的 profile / 产物 / lane · 与本次任务无关的文件
- **与 R7 的关系**:R7 适用于「不是我的 lane」,**不适用**于「我 lane 内的执行细节」—— 两个方向都别套错(对称面见 **U20 / X9**)。
- ⚠️ **动机不构成豁免**:「**改它能让我流程跑通**」正是越界的高发动机 ⇒ 越方便越要先问「这是谁的 lane」。
---
### U23|不要重新开发:先查官方推荐库有没有现成的(2026-09-14 用户口径)
- **原话**:「**需要查官方推荐库是否有类似插件,是重新开发还是改造**」(背景:univer 太重,要"点对话里的文件在旁边窗口打开")
- **结果**:官方推荐库里拿到 **`@softspark/dsh-file-preview` v2.0.0(65 KB tgz)** ⇒ 体积约 univer 的 **1/650**,且未动 univer 一处。
- **判据** → 要加任何能力,**顺序固定**:① 本机/项目已有 ② **官方推荐库**(`Awesome-DeepSeek-Harness-Plugins`,源 = cordis.run 索引 331 个)③ npm `keywords:dsh-plugin` ④ 才考虑自研/改造。
- **候选体检三关(缺一即否)**:① inject 依赖的**官方包存在吗** ② 有没有**被角色补丁禁**的包(⇒ 静默挂死)③ `peerDependencies` 是否**覆盖我们的 dsh 版本**。反例:`dsh-file-viewer` inject 含本平台不存在的 `dsh-client-runtime` ⇒ 装上即静默挂死。
### U24|分期方案必须互相兼容(2026-09-14 用户口径)
- **原话**:「**一/二期方案必须互相兼容**」(集群化改造 Manager/Worker)
- **落地原则**:**分期只分「自动化程度与规模」,不分「机制 / 数据结构 / 协议」** —— 机制与结构**一期定死**,二期只加机器 / 加开关 / 加运维。
- **可执行判据(7 维度兼容矩阵)**:Manager 数(同代码 1..N,**禁止"必须 2 台"的硬假设**)|Worker 数(一期就走 RemoteSpawner+agent,哪怕 Worker 在本机)|存储(一期就写能力探测 + 按目录分层)|DB(**一期必须 PG**,不能先用 SQLite 顶——租约依赖 PG 原子 `UPDATE…WHERE`)|自动接管(开关默认关,但 **lease+fencing+self-fencing 一期全实现**)|备份(一期就用同一套工具/格式,只调频率)|代理路由(零改动)。
- **配套**:新增「**状态三分类**」表(用户数据 / 平台状态 / 机器基线)—— 机器基线(原生运行时、镜像、bwrap 白名单)**不能跟着用户迁移**。
### U25|锁是独占资源:抢到就必须还 ——「带锁结束」不算完成(2026-09-14 用户明令)
- **原话**:「抢到的锁一定要**执行完成后解锁**才算任务完成,**禁止抢锁执行一半不解锁就结束任务**」
- **为什么是硬规则**:锁的意义就是**同一时刻只允许一个执行会话**(本库多会话并行是常态)⇒ **带锁结束 = 把所有其他会话挡在门外**;而本库**无心跳机制**、别人**没有任何判据**能确认你已停 ⇒ 只能空等,或被人误判"已死"而违规接管(**R9** 禁止)。这正是 R9 存在的原因。
- **判据(三条配套)**:
① **抢锁前先把收口步骤列出来**(落地 → 校验 → 推送/对账 → 收尾)—— 别做到一半才发现收不完;
② **中途必须停**(等用户拍板 / 等外部窗口)⇒ **先释放锁再停**(锁是"正在动手"的凭证,不是"占位符");
③ **结束语必须对锁状态负责**:要么写明「已释放」,要么**显式点名**「锁仍在 `<OWNER>`、未释放、原因、下一步」—— 后者**仅限"释放通道不可用"这类极端情形**;⛔ 「忘了 / 做不完就走」一律不允许。
- 与 **U22(lane)** 互补:U22 管"**该不该动手**",本条管"**动手后必须收口**"。
### U26|决策中发现风险/问题 ⇒ **不许带着问题往下走**,先优化到「当前情境下的最优解」(2026-09-14/15 用户明令)
- **原话**:「**决策中发现方案有风险和问题,需要分析并优化到当前情况和状态下的最优解,然后进行下一步处理**,这个也要加入决策方法」
- **为什么是硬规则**:带着已知风险进入下一步 = 把风险**转移给未来**(届时修更贵);而"最优"不是理想方案,是**当下条件(现有资源 / 时间 / 风险面 / 能否验证)下最好的那条**。
- **判据(四步,缺一不可)**:
① **列出来**:把发现的风险/问题**逐条写成清单**(不许只在脑子里);
② **逐条处置**:每条给出**当前情境下可用**的处置 —— 能当场消掉的当场消(改设计/加缓解/降范围),**消不掉的写"残余风险 + 触发条件"**;
③ **说清残余**:哪些是"已知但接受"、为什么不接受不行、什么信号出现就必须回头看;
④ **然后**才进入下一步 —— 且在交付里把 ①②③ **一并写出来**(这就是 `dsh-feature-first §5.1` 骨架里"为什么不行 / 需要你拍板"两节的原料)。
- **配套工具**:能不能消掉要靠 **A24**(先核账真实行为面)· **A18**(静默失败会伪造结论)· **A19**(第二环境)· **A25**(本机改完≠交付)去判;**"改小范围先落地"永远是合法候选**(§4.4 第 2/3 条)。
- ⚠️ **反面**:把"有风险"当成"要不要问用户"(**过度提报用户**)或者"先干着看"(**风险转移**)—— 两者都不对:**先自己优化到当下最优,再带着残余风险请用户拍板是否接受**。
### U27|要的是**解决问题**,不是**得过且过、将就妥协**(2026-09-15 用户明令)
- **原话**:「**项目推进要的是解决问题 不是得过且过,将就妥协**」
- **判据** → 面对发现的风险 / 缺陷,**默认目标是"解决"**;下面三种**都不算解决**,一律不许当成交付:
① **降级目标**(把"要做到 A"悄悄改成"做到 A′ 也行");
② **延期**("下次顺手再说 / 等窗口再补" —— 除非**客观不可逾越**且有证据);
③ **静默兜底**("先这样也能跑",而风险与触发条件一个字没写)。
- **唯一允许"暂时接受"的情形 = 客观不可逾越**(技术不可行 / 上游未支持 / 需要用户侧凭据或窗口)⇒ 必须写明**三项**:
① **卡在哪**(证据)② **当前已做到哪一步** ③ **什么条件一出现就必须回头解决**。
- **与 A6 的边界(别读成互相矛盾)**:**A6 管"路径"**(实现取最小代价、不追求最彻底);**U27 管"目标"**(目标不许打折)⇒ 一句话:**目标不打折,路径取最小代价**。
- **与 U26 的关系**:U26 要求「发现问题先优化到当下最优解再走下一步」;**U27 补的是"最优解"里不许包含'降低目标'这个选项**。
### U28|🟢 红线:只做正向迭代 —— 命中「劣化风险」立即停下复盘,无正向做法则立即停止(2026-09-15 用户明令)
- **原话**:「**要确保所有决策是让项目正向迭代和提升**,如果遇到**纯在项目劣化风险**(目标,方向,架构,功能,性能,安全,交互,UI,便利性,扩展性等)需要**立即停下复盘**,如确实**无正向迭代方法**立即**停止**,**禁止继续执行**」
- **判据(每次决策前过一遍十维)**:**目标 / 方向 / 架构 / 功能 / 性能 / 安全 / 交互 / UI / 便利性 / 扩展性** —— 任一维度**净变差**即命中。
- **命中后的三步(一步都不许跳)**:① **立即停下**(不许"做完再看")② **复盘**:写清劣化在**哪一维**、**代价量级**(数字 / 证据)③ **找正向做法**(改小范围 / 换实现 / 分阶段)—— **拿不出来 ⇒ 立即停止、只报告,禁止继续执行**。
- ⛔ **三种伪装**:把劣化说成"必要代价"/用"后续再优化"掩盖已知劣化/把劣化项藏进交付里不写。
- 与 **R5** 互补:R5 只管**权限**(只准收窄);**R11 管全维度净收益**。与 **U27**(不将就妥协)· **A20**(代价不对称就保留)同族。
@@ -0,0 +1,34 @@
# 反例库 · 被驳回 / 被纠正的决策(X1–X13)
> **归属**:技能 `dsh-decision-method` 的素材库(**按需读**,不是每次都要读)。
> **主文件 / 索引 / 判定核心** = `../SKILL.md`(§4 判定核心 · §5 流程 · §7 语言表 · 附 自检)。
> **用法**:只在「要判某条是否属于既有口径」或「要引用用户原话」时读本文件;**别整段抄进答复**。
> **维护**:条目**只增不改**(编号进位到末尾);用户原话**逐字**引用;每条必须带「实例出处 + 判据」。
---
## 3. 反例库:被驳回 / 被纠正的决策(X1–X11)
> 每条**反例 = 一条避免规则**。这些是本工作区里真实发生过的失手。
| # | 反例 | 根因 | 转为规则 |
|---|---|---|---|
| **X1** | 为"让 scp 文件行尾干净",用脚本把 **147 个文件** CRLF→LF;当期只被要求改一句 UI 字符串 | 把"顺手修"当效率;**没做单点验证就全库推广**;忘了本机镜像不是沙箱 | → **R7**:只做被明确要求的事;>10 文件先出清单;先单点验证;传播前 `git status` 比对待传清单 |
| **X2** | 档案 58/59 连续两次重启 `dshs`,**把在线用户踢下线**并引发报障 | 把"改完即验"当完整闭环,漏了"改前先知会" | → **R8**:重启/drain/铺插件/改配额 env → 先说「影响谁、断多久、为何必须现在做」 |
| **X3** | 把 mtime 当并发冲突判据 | mtime **分不清是谁改的**;本库长期不提交 → 无冲突检测 | → 冲突判定只认两个硬信号:**别人的占用锁** + **待推送清单里的未声明文件**;mtime 只作提示 |
| **X4** | 由"空闲回收没生效"外推出"实例不会中断"(**被用户当面纠正**) | 把"某机制失效"误推成"该类现象不存在" | → 现象归因要**枚举全部可能来源再逐一实测**(中断真凶是服务重启 + 崩溃重启,与回收无关) |
| **X5** | 按字面执行"参考资料不需要"去删 | "看起来像资料"与"实际被引用"是两件事 | → 见 **U3**:删除前先扫引用 |
| **X6** | 用截断到 200 字符的 `grep` 输出当 `old_string` 去 Edit 长行 → **误删对方记录行首** | 拿不完整内容当精确锚点 | → **长行 Edit 前必须先用 Read 取全文**;共享文件只用 Edit 精确替换(失败即冲突信号),禁整文件 Write |
| **X7** | 把用户设备上的"旧会话不好用"当成感受问题,未深挖 | 没做会话级取证 | → 报障第一步**先拿真实失败请求/真实状态**(`journalctl` 精确 URL+method+status),再归因 |
| **X8** | "只注入 env 就以为配好了"(bundled 技能层实际未挂载) | 漏了"插件在实例内 `resolve()` + 读盘"这一层 | → 通用判据:**插件在实例内读盘的东西,必须真的出现在命名空间里**;env 只决定"去哪儿找" |
| **X9** | **把 R7 按「动作名字」套到部署上** —— v0.2.8 只换 profile 里的包、**零中断**,却停下等确认;用户回「为什么要等我确认才部署呢」 | 红线被当成**关键词匹配**(见「部署 / 上线 / 生产」就触发门禁),没按**实际影响面**判 | → **按影响面判,不按动作名字判**(U20):R8 只认「会中断在线用户」;**不中断的上线 = 执行细节,做完即上线** |
| **X10** | **答非所问:拿「我的失误 + 探针踩坑 + 版本流水 + 下一步计划」去答「是否已实现」** | 主位错位 —— 写的是**AI 的进度**,用户问的是**功能的可用性**;收尾「要我接着做,说一声即可」= 又把已定的执行细节提报用户 | → 用 **U21 结论三件套**(判定 → 为什么不行 → 要拍板什么 / 我接着做);失误只在 ①改变结论 ②用户问根因 时写(`dsh-feature-first §5.1–5.3`) |
| **X11** | **为了让自己的流程走通,自行改了平台级路径**(建 `/var/lib/dshs` 全局符号链接,无人授权)→ 用户追问「谁让你去改这个的」 | **动机取代了边界判断** —— 「改它能让我流程跑通」被当成理由,没问「这落在谁的 lane」 | → **U22 闸 1**:不是我 lane 的(平台级 / 全局 / 别人 lane)**只报告不动手**;越方便越要先问 |
---
| **X12** | **把用户给的材料当权威照抄**(他们扒的是新版 `0.1.5+` 源码,我们跑的是 `0.1.2-rc.1`) | 没先确认「**这份材料描述的是哪个版本**」 | → 用户给的素材要用**我们的实际版本**核对;本次 4 处纠正:包不存在 / 扩展点不存在 / 装上也静默挂死 / 投放通道不符。⚠️ 与 **U11**(用户给的**数值**是硬约束)区分:**数值口径是约束,事实陈述要核对** |
| **X13** | **把自己的解析失败当成"版本差异"**(报「`__DSH_BOOT__` 结构变了」,其实是我的解析正则过时) | 差异归因时**先怀疑版本、没先怀疑自己** | → 报"两版不一样"之前,**必须在两边用同一解析都跑通一次**(或做 A/B 对照);档案 90 已追加更正防误导 |
| **X14** | **把「一批改造」的中断动作拆成多次执行**(一天连做 5 项改造 ⇒ 铺插件 4 次 + 重启 `dshs` 3 次,每次都让已打开页面手里的 `rev` 过期) | 只有"改一处→验一处"的单点思维,**没有"批处理窗口"概念**(R8 只说"先说明/取得确认",没说"攒批") | → **同一批改动的所有中断动作攒到一个窗口执行**;**窗口内 >1 次重启 = 违规信号**,停下来问"能不能并到一次"。⚠️ 这正是「Failed to load plugins」的直接成因(档案 95) |
+172 -134
View File
@@ -1,134 +1,172 @@
# manifest · 包内文件清单
> 生成方式:逐文件 `compile()` / `json.loads` + md5 | **最近一次全量重算:2026-10-04 17:4x (2026-10-04 单包权威:02 档改为「本档即权威」(⛔ 不再指向外部技能当权威),保留来路说明;SKILL.md 撤回『必需外部依赖』声明改为自包含)**
> ⛔ 本表**不含** `install.log`(运行日志)与 `references/manifest.md`(自引用,写完即失真)。
> ⚠️ **provenance 列里的 `skills/multi-session-collab/…`、`skills/workbuddy-session-forensics/…` 已是历史路径**
> —— 那两个目录 2026-10-01 已移到 `<工作区>/归档/技能-退役-20261001/`(⛔ 不在技能根了)。
文件总数:**49** | 语法 / 结构检查失败:**0**
## ✅ 已完成 · 2026-10-05 三代术语收敛(本节替代原「待办」)
- **已执行**:包内 + 两个工作区副本三方同步,md5 一致(`collabd.py`/`goalctl.py`/`board.py`/`board_ext.py`/
`selftest.py`/`collabctl.py`/`guard.py`/`init_workspace.py`/`session-rules-check.py`/`board.html` 全部 OK)。
`selftest.py` 基线 **PASS 97 / FAIL 0**(报告型 1 条不计入);改名的 5 条 `@case` 均能按新名字 `-k` 找到。
- **⛔ 三类不动,是判据不是遗漏**(下一次改名前照抄):
① **「」引述内容逐字不动**(含跨行引述块)—— 引用用户口径只写原话;
② **历史段整段不动** —— `SKILL.md` 19–27 行代际表、含 `旧称/旧词/一代/二代/第三代` 的行、`<hN> 历史…` 段落
(要讲清三代分别叫什么,就必须留着旧名);
③ **`.py` 里 `目标检查` 一律不动** —— 它是**检查会话类别名**(`_CHECK_TAGS`/`_CHECK_TOPICS`、
`[检查]-[结果检查/目标检查]`),并散在字面断言里(`CHECK_KINDS["queue-empty"][1] == "目标检查"`、
`rows.append(...)`、`【目标检查】` prompt 头)⇒ 改了直接断判据。
- ⚠️ **一处同改对**:`board.py::_ROLE_LABEL["worker"]` 与 `board.html` 的 `role==='…'` 是同一处判据的两侧。
- ⚠️ **本条暴露的存量缺陷(已一并修)**:`selftest.py` 的期望串原写 `才建立一轮任务会话`,而 `SKILL.md`
引述里逐字是 `才建立一轮执行会话` ⇒ 该子项**改前就是红的**(被基线里的 97/0 掩盖)。
- 工具:`tmp/term-apply-v3-20261005.py`(dry-run/`--apply`,写盘前自动备份)+ `tmp/term-sync-20261005.py`。
## 2026-10-04 新增文件(⛔ 由 manifest 重算登记)
- `references/01-文档索引.md` — 3604 B |md5 `825999a411e2a4f6b740063f81c01b03`
- `scripts/judge_audit.py` — 13562 B |md5 `8f14a973fa5709a3b9a60b065dc22d7d`
- `scripts/workspace_mirror.py` — 17263 B |md5 `60f104b37df3133785abb13f686eea4f`
- `assets/start-supervise.ps1.tpl` — 5360 B |md5 `3805f1a82a1747269212287e9dfde369`
- `assets/design-tokens.css` — 6364 B |md5 `0668feb3a4729f1022e579ac0c5d2569`
## 最近改动(⛔ 只记「什么时候改了什么」,不写流水账)
- **2026-10-02 07:0x · 开工第 0 步改名:会话「规划」→ 会话「规则机制」(本轮)**:
① **用户纠正原话**:「**就应该是检查清楚 所有会话规则机制 是否配置完整且生效, 不是规划 是 规则**」
⇒ 首版体检脚本**只查排期那一面**,当天实测出的三类失效(钩子注入指向已退役技能名 / 常驻快照写进幽灵目录 /
每轮注入的记忆指针悬空)**一条都没覆盖** ⇒ 名字还叫「规划」=第二次「在册 ≠ 生效」。
② **收敛成一个入口**:新增 `scripts/session-rules-check.py`(查 **三类十二项**:A 钩子在册·路径存在·
注入技能名存在·闸门日志新鲜 | B 每轮注入记忆的技能指针·常驻快照不比权威旧 | C 周期钟·模型可用性·
`cwds` 同形·投递心跳·活会话·`once` 从未运行的死排期);旧 `scripts/session-plan-check.py` **退役**
到 `<WS>/归档/技能包-旧件-20261002/`,**能力不回退**。
③ **状态标记改名**:`session-plan.json` → **`session-rules.json`**(旧的属过期件,⛔ 别拿来对照)。
④ **判据收紧**:`cwds` 近失配原写成「同父目录 + 字面不同」⇒ 在 `AIProject/` 这种**多业务线平级目录**下
把**别的线**报成失配(实跑当场 2 条假红)⇒ 改为「同父目录 + **名字去 `-`/`_` 后仍相同**」/「同名不同父目录」。
⑤ **判据自验**:⑨⑫ 抽成**纯函数** + 合成样本夹具(`<WS>/tmp/rules-check-mutate.py`)—— 原版 10/0 绿、
**四个变异体**逐一按预期报红。⚠️ 夹具**自己先红两次**(期望值写死:漏算样本 + 有序列表比中文名)。
- **2026-10-01 18:5x · 外置根贯通 + 两处真缺陷(本轮)**:
① `roots.env` 引导块从 9 份扩到 **16 份**(补齐 7 个非钩子脚本:`board` / `board_ext` / `collabd` /
`deliver-gateway-token` / `goalctl` / `selftest` / `stop-collab`);
② **盘符字面量清零**:配置目录兜底改 `~/.workbuddy`,工作区兜底改 `DSH_WS_ROOT`/cwd,
覆盖网日志 glob 改 `DSH_OVERLAY_LOG_GLOB`(原为死路径 —— 本机 `E:/dsh-worker-dev` 不存在);
③ 7 份加**输出编码兜底**;④ `roots.env` 增写 `CODEBUDDY_CONFIG_DIR`;
⑤ 🔴 **修「钩子把技能包当工作区」**:`wb-result-hook.py` 的 `_WS_ROOT` 原按 `3×dirname(__file__)` 推,
包内这份推出来=技能包自己 ⇒ 包里长出 `tmp/supervise-inbox/`;改为复用 `resolve_ws()`;
⑥ 🔴 **修「重定向 + GBK ⇒ print 抛异常 ⇒ 顶层记 fatal、整轮失败」**(本机实测 4 次;
这正是**常驻**跑不起来的拦路石 —— 常驻必须重定向 stdout);
⑦ `install.py` 工作区模板路径纠错(原指 `scripts/collab/`,实际在 `scripts/`);
⑧ `collabd.py` docstring + 两处活注释改**常驻定案**(上一轮漏传导);
⑨ `lock/preflight-lock.sh` + `hooks/lock-guard-hook.py` 的盘符字面量清零;
⑩ 新增 `install.py --manifest`(见下条);
⑪ 🔴 **`scripts/selftest.py` 去掉最后一处项目绝对路径**:`t_tick_wired` 原先写死
`<某工作区>/.workbuddy/tools/wb-result-hook.py` ⇒ 两处都不对:既违反「技能里只用相对路径」,
又因宿主接线已改指**包内**那份而**恒走"文件不在,跳过"** ⇒ 看着绿、其实什么都没验(静默假绿)。
现改按包内相对路径取 `hooks/wb-result-hook.py`,该用例从"跳过"变**真检**(4 项实跑通过)。
- **2026-10-01 19:0x · `install.py --manifest`**:把「重算本表」固化成子命令
(包一改表就过期 ⇒ 以前每轮临时手搓脚本、跑完即删,已因此误判两次)。
只重写**表 + 计数行 + 重算时间**,⛔ 不碰上方散文;**行尾随原文件**(本表是 CRLF,写成 LF 会造成一次无声全文件 diff)。
- **🔴 两份同源脚本「有意不一致」(⛔ 别当 bug 去修平)**:`preflight-lock.sh` 与 `lock-guard-hook.py` 在
**技能包**与**文档库 `07-scripts/`** 各有一份。包内那份住在技能包里、**推不出文档库根** ⇒ 只能走
`roots.env` +(`lock-guard-hook.py` 的)末位字面量兜底;库内那份住在 `<文档库>/07-scripts/` ⇒
**按 `__file__` 往上两级即文档库根**。⇒ 包内有引导块、库内没有,是**位置决定的**,不是漏改。
- **2026-10-01 18:2x · 瘦身(本轮)**:`references/collab-detail.md` 删去**原件 YAML 变更流水**(约 9.4 KB)+ 加**读法索引**+ 压缩 §12;
`references/{manifest,taskgraph,pitfalls}.md` 去掉过期条目与旧抬头;包内 `__pycache__/`、`hooks/bak-*/`、`tmp/` 已清。
**⛔ 本轮无机制代码改动。**
- **2026-10-01 16:5x**:`scripts/hooks/session-log-guard.py` 新增「**硬档就地回收**」—— 到 8 MiB 先试**一次**把本会话
`logs/<日期>/sdk/conversations/<sid>.log` 改名(后缀 `.recycled-<时间戳>`,⛔ 不删、⛔ 不以 `.log` 结尾故与清扫器不重叠)
⇒ 宿主立刻重建并继续写 ⇒ **会话不必再因撞 10 MiB 而停手**;失败则**逐字退回**原「停手+建接续」口径。
开关 `DSH_SLG_NO_RECYCLE=1`(只关回收、保留叫停)。⚠️ 文档库原件 `07-scripts/session-log-guard.py` **已同语义同步**。
- **2026-10-01 16:0x**:原 `multi-session-collab` 的 `SKILL.md` 全文并入 `references/collab-detail.md`(治 8 处悬空引用:
`§0.05` `§0.5.2` `§0.5.4` `§0.5.5` `§1.3` `§1.4a` `§3` `§6`);清掉 9 处旧技能路径/名的活文本;
两个原技能目录移入 `<工作区>/归档/技能-退役-20261001/`。
## 逐文件清单
| 包内路径 | 字节 | md5 | 语法检查 |
|---|---|---|---|
| `SKILL.md` | 52451 | `c304346227e3d2b217e442a471b6c10d` | ok |
| `assets/board.html` | 136715 | `bf0e560a0420c833e94f329958e64a3c` | ok |
| `assets/design-tokens.css` | 6364 | `0668feb3a4729f1022e579ac0c5d2569` | ok |
| `install.py` | 26132 | `9522ec34773385a638efdeb3df0090d2` | ok |
| `references/00-动手前必过.md` | 3923 | `33bc86e442ccd4fcbb930c79c4ad0641` | ok |
| `references/02-功能优先协作协议.md` | 30502 | `eeeab3dcad47ad01380c8b05fee6fb3f` | ok |
| `references/03-回复排版-核心块.md` | 2187 | `201e7f112ee432b89e38f82f49301966` | ok |
| `references/99-速查清单.md` | 9832 | `17cc8717649164fa3b9e26da3be9405b` | ok |
| `references/architecture.md` | 119291 | `c78f5cba060efc79db5d160f7df0cca6` | ok |
| `references/collab-detail.md` | 98760 | `6bd9cc7e28596789d55dcd3da22548fe` | ok |
| `references/collab.md` | 16560 | `e7024a823d14c5aa808c7be3a7b00eda` | ok |
| `references/deploy.md` | 16754 | `66342ae0e32ecb499b4c72e4ccfabb9b` | ok |
| `references/forensics.md` | 6067 | `1fb6deca9bb05bebe950d4f4e3adf043` | ok |
| `references/pitfalls.md` | 132523 | `9bd6509df320833a41313848009aeb9a` | ok |
| `references/rules.md` | 6931 | `d654e9dac68a6f44b93898ba98e49221` | ok |
| `references/supervise-persistence.md` | 19245 | `1d4bbb08f02a661124886578cb700af5` | ok |
| `references/taskgraph.md` | 3521 | `be6c6540be86475bb3430688c2987afc` | ok |
| `roots.env` | 624 | `ec38f15f9a4101d69c815aa2cdd7b477` | ok |
| `scripts/board.py` | 123843 | `339d38fd579c602c20ce59f4aa106408` | ok |
| `scripts/board_ext.py` | 45655 | `f138d944145b276e65f6aa86bd8baa07` | ok |
| `scripts/collabd.config.example.json` | 1158 | `6655c15c411a84e3ca12fd136dbe2c60` | ok |
| `scripts/collabd.py` | 355568 | `58ffc3e52a52445e5b0e782fc99d9323` | ok |
| `scripts/deliver-gateway-token.py` | 5685 | `cbba9648d404ecd2683061e19dbdb24a` | ok |
| `scripts/deploy_code.py` | 4391 | `c22969124dd937ddaa66db09738df4a4` | ok |
| `scripts/forensics/proc-parent.py` | 2347 | `8cdfff2dbb88303298752c3777da7b20` | ok |
| `scripts/goalctl.py` | 39314 | `b3c4428b3aa7e4e3093e96105d1bc4dd` | ok |
| `scripts/guard.py` | 13653 | `378e2a388aa14bc55f83e17b5ea61053` | ok |
| `scripts/hooks/_env.py` | 11082 | `d3a557f22286bffb62ff29ee85c71799` | ok |
| `scripts/hooks/bash-output-guard.py` | 17741 | `3f2fdfa17f66f7fcac42e06853433a26` | ok |
| `scripts/hooks/lock-guard-hook.py` | 20363 | `641457de0297ee7a812ccde57f67dc2b` | ok |
| `scripts/hooks/reply-style-guard.py` | 10779 | `a05ca0d6ebef2be55a3d6f96a4c712da` | ok |
| `scripts/hooks/session-log-guard.py` | 24636 | `0bd6865e1a3ab97315c64631f37963a3` | ok |
| `scripts/hooks/skill-load-guard.py` | 19380 | `682019c18af80991b14c3f3505ecab26` | ok |
| `scripts/hooks/stop-dialog-guard.py` | 40247 | `4e5f9889717d4ed37a6384c643b4d05e` | ok |
| `scripts/hooks/wb-result-hook.py` | 65810 | `94bc3c781b43be6c2a4da645fe008639` | ok |
| `scripts/init_workspace.py` | 8840 | `0581b3864d35461f2fd8f76ea4c66d4d` | ok |
| `scripts/lock/handoff-guard.sh` | 34616 | `f38b2ec92fdc1dab9abdaca0e0bb001a` | ok |
| `scripts/lock/handoff-status.py` | 3923 | `3edb20f9c4021ebe2323478860bf8f6f` | ok |
| `scripts/lock/op-lock.sh` | 4690 | `1a31eda3642e23693a65e1519860c744` | ok |
| `scripts/lock/preflight-lock.sh` | 8673 | `e985ec1cb853fae4c351cd03b171355d` | ok |
| `scripts/selftest.py` | 281959 | `f68dcb48173f2321527d54bc30703bc5` | ok |
| `scripts/session-rules-check.py` | 28650 | `484b4e7370ff7f8377e725471bdfd88a` | ok |
| `scripts/stop-collab.py` | 10462 | `d8043bedd23b52b6642aaf7ec8b8c980` | ok |
| `scripts/wake-session.py` | 11011 | `32c6038b541249730f322f933ee287c1` | ok |
# manifest · 包内文件清单
> 生成方式:逐文件 `compile()` / `json.loads` + md5 | **最近一次全量重算:2026-10-06 05:10 (补 P0-85(工作区解析静默错归属)+P0-84 已修尾注)**
> ⚠️ **2026-10-05 局部增量**:`references/pitfalls.md`(P0-77 拆条 + P0-73/P0-77 压缩)与
> `scripts/goalctl.py`(`--switch-goal` 确认闸 + 旧目标归档)两行的 md5/大小已按当天实测值更新;**其余行仍是 10-04 基线**。
> ⛔ 本表**不含** `install.log`(运行日志)与 `references/manifest.md`(自引用,写完即失真)。
> ⚠️ **provenance 列里的 `skills/multi-session-collab/…`、`skills/workbuddy-session-forensics/…` 已是历史路径**
> —— 那两个目录 2026-10-01 已移到 `<工作区>/归档/技能-退役-20261001/`(⛔ 不在技能根了)。
文件总数:**56** | 语法 / 结构检查失败:**0**
## ✅ 已完成 · 2026-10-05 「目标唯一性 + 换目标需确认」(用户口径落地)
> 用户原话逐字:**「一个工作区 同时只能执行一个目标,如果要切换目标,需要用户确认,然后切换和关注检查切换后的目标」**
- **改前取证(三条,全实测)**:① 载体 `goal.json` **本就是单文件** ⇒ 天然满足"同时一个";② **归档零实现**
(`goalctl.py`/`collabd.py` 全文搜 `goals/` **零命中** ⇒ 那份归档件是**手工造的**);③ **确认闸零实现**
(`declare` **直接覆盖** `title`,无任何准入判断)。
- **修法(两道闸,均在 `goalctl.py::declare()`)**:① 标题变了 ⇒ **默认拒绝**(`rc=3`、**零写入**),
必须带专用开关 `--switch-goal`(⛔ **不等于** `--yes`:前者=「用户已同意换目标」,后者只是「干跑转真写」,
**混用 = 伪造用户授权**);② 确认后旧目标**整份**归档到 `goals/<旧标题≤40字>__<时间>.json` + 新目标留 `_前身目标`。
- **验收(7 项全过)**:拒绝 rc=3 /同目标细化 rc=0 **不被拦** /归档 **32 字段 / 6186 B** 且与旧目标**同源** /
**变异对照**(闸短路 ⇒ rc=0,还原 ⇒ rc=3,证判据非恒绿)/真区未污染 /副本 md5 全同。
- ⚠️ **加闸带出的连带缺陷(已一并修)**:`declare` 没给 `--title` 时**回落成工作区名** ⇒ 把"纯细化"误判成
"换目标" ⇒ 修成「**没给 ⇒ 保持现有标题**」(与 `--topics`/`--kpi` 同规矩)。
- 📂 全文 ⇒ `references/pitfalls.md` **P0-78**;`lifecycle` 那条独立铁律 ⇒ **P0-79**(**从 P0-77 拆出**)。
### 📌 同批完成:`pitfalls.md` 沉淀纪律转绿(改前既有红,顺手修掉)
- 判据=「**单条 > 6 KB 且证据密度 < 4.0 ⇒ 判流水账**」。改前基线读数:`P0-73` 7590 B/2.8。
- 修法(**两条腿**,⛔ 不靠删内容):① **拆条** —— P0-77 里"前缀改造"与"`lifecycle` 空转"是**两个主题**,
拆出 **P0-79**(P0-77 7765→5107 B/P0-79 2141 B);② **删冗余展开**(P0-73 验收段的重复读数)7795→约 5.9 KB。
- 结果:**沉淀纪律 ✓ 转绿**(5 项);自测 **`PASS 94/FAIL 3` → `PASS 95/FAIL 2`**(另 1 条报告型不计入)。
- ⚠️ ⛔ 别为压体量**删判据要点** —— 判据自带关键词对账(`MUST` 表),删了就换一种方式报红。❗原编号 77 → 79,**不许再当 77 引用**
## ✅ 已完成 · 2026-10-05 三代术语收敛(本节替代原「待办」)
- **已执行**:包内 + 两个工作区副本三方同步,md5 一致(`collabd.py`/`goalctl.py`/`board.py`/`board_ext.py`/
`selftest.py`/`collabctl.py`/`guard.py`/`init_workspace.py`/`session-rules-check.py`/`board.html` 全部 OK)。
`selftest.py` 基线 **PASS 97 / FAIL 0**(报告型 1 条不计入);改名的 5 条 `@case` 均能按新名字 `-k` 找到。
- **⛔ 三类不动,是判据不是遗漏**(下一次改名前照抄):
① **「」引述内容逐字不动**(含跨行引述块)—— 引用用户口径只写原话;
② **历史段整段不动** —— `SKILL.md` 19–27 行代际表、含 `旧称/旧词/一代/二代/第三代` 的行、`<hN> 历史…` 段落
(要讲清三代分别叫什么,就必须留着旧名);
③ **`.py` 里 `目标检查` 一律不动** —— 它是**检查会话类别名**(`_CHECK_TAGS`/`_CHECK_TOPICS`、
`[检查]-[结果检查/目标检查]`),并散在字面断言里(`CHECK_KINDS["queue-empty"][1] == "目标检查"`、
`rows.append(...)`、`【目标检查】` prompt 头)⇒ 改了直接断判据。
- ⚠️ **一处同改对**:`board.py::_ROLE_LABEL["worker"]` 与 `board.html` 的 `role==='…'` 是同一处判据的两侧。
- ⚠️ **本条暴露的存量缺陷(已一并修)**:`selftest.py` 的期望串原写 `才建立一轮任务会话`,而 `SKILL.md`
引述里逐字是 `才建立一轮执行会话` ⇒ 该子项**改前就是红的**(被基线里的 97/0 掩盖)。
- 工具:`tmp/term-apply-v3-20261005.py`(dry-run/`--apply`,写盘前自动备份)+ `tmp/term-sync-20261005.py`。
## 2026-10-04 新增文件(⛔ 由 manifest 重算登记)
- `references/01-文档索引.md` — 3604 B |md5 `825999a411e2a4f6b740063f81c01b03`
- `scripts/judge_audit.py` — 13562 B |md5 `8f14a973fa5709a3b9a60b065dc22d7d`
- `scripts/workspace_mirror.py` — 17263 B |md5 `60f104b37df3133785abb13f686eea4f`
- `assets/start-supervise.ps1.tpl` — 5360 B |md5 `3805f1a82a1747269212287e9dfde369`
- `assets/design-tokens.css` — 6364 B |md5 `0668feb3a4729f1022e579ac0c5d2569`
## 最近改动(⛔ 只记「什么时候改了什么」,不写流水账)
- **2026-10-02 07:0x · 开工第 0 步改名:会话「规划」→ 会话「规则机制」(本轮)**:
① **用户纠正原话**:「**就应该是检查清楚 所有会话规则机制 是否配置完整且生效, 不是规划 是 规则**」
⇒ 首版体检脚本**只查排期那一面**,当天实测出的三类失效(钩子注入指向已退役技能名 / 常驻快照写进幽灵目录 /
每轮注入的记忆指针悬空)**一条都没覆盖** ⇒ 名字还叫「规划」=第二次「在册 ≠ 生效」。
② **收敛成一个入口**:新增 `scripts/session-rules-check.py`(查 **三类十二项**:A 钩子在册·路径存在·
注入技能名存在·闸门日志新鲜 | B 每轮注入记忆的技能指针·常驻快照不比权威旧 | C 周期钟·模型可用性·
`cwds` 同形·投递心跳·活会话·`once` 从未运行的死排期);旧 `scripts/session-plan-check.py` **退役**
到 `<WS>/归档/技能包-旧件-20261002/`,**能力不回退**。
③ **状态标记改名**:`session-plan.json` → **`session-rules.json`**(旧的属过期件,⛔ 别拿来对照)。
④ **判据收紧**:`cwds` 近失配原写成「同父目录 + 字面不同」⇒ 在 `AIProject/` 这种**多业务线平级目录**下
把**别的线**报成失配(实跑当场 2 条假红)⇒ 改为「同父目录 + **名字去 `-`/`_` 后仍相同**」/「同名不同父目录」。
⑤ **判据自验**:⑨⑫ 抽成**纯函数** + 合成样本夹具(`<WS>/tmp/rules-check-mutate.py`)—— 原版 10/0 绿、
**四个变异体**逐一按预期报红。⚠️ 夹具**自己先红两次**(期望值写死:漏算样本 + 有序列表比中文名)。
- **2026-10-01 18:5x · 外置根贯通 + 两处真缺陷(本轮)**:
① `roots.env` 引导块从 9 份扩到 **16 份**(补齐 7 个非钩子脚本:`board` / `board_ext` / `collabd` /
`deliver-gateway-token` / `goalctl` / `selftest` / `stop-collab`);
② **盘符字面量清零**:配置目录兜底改 `~/.workbuddy`,工作区兜底改 `DSH_WS_ROOT`/cwd,
覆盖网日志 glob 改 `DSH_OVERLAY_LOG_GLOB`(原为死路径 —— 本机 `E:/dsh-worker-dev` 不存在);
③ 7 份加**输出编码兜底**;④ `roots.env` 增写 `CODEBUDDY_CONFIG_DIR`;
⑤ 🔴 **修「钩子把技能包当工作区」**:`wb-result-hook.py` 的 `_WS_ROOT` 原按 `3×dirname(__file__)` 推,
包内这份推出来=技能包自己 ⇒ 包里长出 `tmp/supervise-inbox/`;改为复用 `resolve_ws()`;
⑥ 🔴 **修「重定向 + GBK ⇒ print 抛异常 ⇒ 顶层记 fatal、整轮失败」**(本机实测 4 次;
这正是**常驻**跑不起来的拦路石 —— 常驻必须重定向 stdout);
⑦ `install.py` 工作区模板路径纠错(原指 `scripts/collab/`,实际在 `scripts/`);
⑧ `collabd.py` docstring + 两处活注释改**常驻定案**(上一轮漏传导);
⑨ `lock/preflight-lock.sh` + `hooks/lock-guard-hook.py` 的盘符字面量清零;
⑩ 新增 `install.py --manifest`(见下条);
⑪ 🔴 **`scripts/selftest.py` 去掉最后一处项目绝对路径**:`t_tick_wired` 原先写死
`<某工作区>/.workbuddy/tools/wb-result-hook.py` ⇒ 两处都不对:既违反「技能里只用相对路径」,
又因宿主接线已改指**包内**那份而**恒走"文件不在,跳过"** ⇒ 看着绿、其实什么都没验(静默假绿)。
现改按包内相对路径取 `hooks/wb-result-hook.py`,该用例从"跳过"变**真检**(4 项实跑通过)。
- **2026-10-01 19:0x · `install.py --manifest`**:把「重算本表」固化成子命令
(包一改表就过期 ⇒ 以前每轮临时手搓脚本、跑完即删,已因此误判两次)。
只重写**表 + 计数行 + 重算时间**,⛔ 不碰上方散文;**行尾随原文件**(本表是 CRLF,写成 LF 会造成一次无声全文件 diff)。
- **🔴 两份同源脚本「有意不一致」(⛔ 别当 bug 去修平)**:`preflight-lock.sh` 与 `lock-guard-hook.py` 在
**技能包**与**文档库 `07-scripts/`** 各有一份。包内那份住在技能包里、**推不出文档库根** ⇒ 只能走
`roots.env` +(`lock-guard-hook.py` 的)末位字面量兜底;库内那份住在 `<文档库>/07-scripts/` ⇒
**按 `__file__` 往上两级即文档库根**。⇒ 包内有引导块、库内没有,是**位置决定的**,不是漏改。
- **2026-10-01 18:2x · 瘦身(本轮)**:`references/collab-detail.md` 删去**原件 YAML 变更流水**(约 9.4 KB)+ 加**读法索引**+ 压缩 §12;
`references/{manifest,taskgraph,pitfalls}.md` 去掉过期条目与旧抬头;包内 `__pycache__/`、`hooks/bak-*/`、`tmp/` 已清。
**⛔ 本轮无机制代码改动。**
- **2026-10-01 16:5x**:`scripts/hooks/session-log-guard.py` 新增「**硬档就地回收**」—— 到 8 MiB 先试**一次**把本会话
`logs/<日期>/sdk/conversations/<sid>.log` 改名(后缀 `.recycled-<时间戳>`,⛔ 不删、⛔ 不以 `.log` 结尾故与清扫器不重叠)
⇒ 宿主立刻重建并继续写 ⇒ **会话不必再因撞 10 MiB 而停手**;失败则**逐字退回**原「停手+建接续」口径。
开关 `DSH_SLG_NO_RECYCLE=1`(只关回收、保留叫停)。⚠️ 文档库原件 `07-scripts/session-log-guard.py` **已同语义同步**。
- **2026-10-01 16:0x**:原 `multi-session-collab` 的 `SKILL.md` 全文并入 `references/collab-detail.md`(治 8 处悬空引用:
`§0.05` `§0.5.2` `§0.5.4` `§0.5.5` `§1.3` `§1.4a` `§3` `§6`);清掉 9 处旧技能路径/名的活文本;
两个原技能目录移入 `<工作区>/归档/技能-退役-20261001/`。
## 逐文件清单
| 包内路径 | 字节 | md5 | 语法检查 |
|---|---|---|---|
| `SKILL.md` | 110832 | `25aefce4ad7402c216ad7784ebed7b92` | — |
| `assets/board-launch.py.tpl` | 4355 | `7e9dfe1c4845f20dbb89f599d80e167b` | — |
| `assets/board-render-probe.js` | 14398 | `a21bd1905b34537436ce778c6bfbc288` | — |
| `assets/board.html` | 151811 | `c597cd7f21543542e9cdbe486c5ccd79` | — |
| `assets/design-tokens.css` | 6364 | `0668feb3a4729f1022e579ac0c5d2569` | — |
| `assets/start-supervise.ps1.tpl` | 5778 | `4eae189c7c850e4748a0a62024f48127` | — |
| `install.py` | 33311 | `ee11283cbcdbb4f821f021e86cff4498` | ok |
| `references/00-动手前必过.md` | 7181 | `33bc86e442ccd4fcbb930c79c4ad0641` | — |
| `references/01-文档索引.md` | 3604 | `825999a411e2a4f6b740063f81c01b03` | — |
| `references/02-功能优先协作协议.md` | 30502 | `e241742b41cb25ff7d1b6447b9ee623e` | — |
| `references/03-回复排版-核心块.md` | 2187 | `201e7f112ee432b89e38f82f49301966` | — |
| `references/99-速查清单.md` | 9832 | `5c97728af7cb67e9be9a3e491bdc5fd9` | — |
| `references/architecture.md` | 119622 | `d7f0990daeb4cd7d2d262646f67b8ffd` | — |
| `references/collab-detail.md` | 98760 | `7b843b0072d8da8abe0a162a44a96eb7` | — |
| `references/collab.md` | 16560 | `97aa751f86d9b4ba8db401b5ec592695` | — |
| `references/deploy.md` | 16754 | `4e382f7876f31754ec9629925340c2d8` | — |
| `references/forensics.md` | 6067 | `1fb6deca9bb05bebe950d4f4e3adf043` | — |
| `references/pitfalls.md` | 281757 | `8e3c654a8ea824da2a066726305accde` | — |
| `references/rules.md` | 8106 | `95af3566066d27c3c224ce681a82efc1` | — |
| `references/supervise-persistence.md` | 27698 | `2e7ba3d89146881eb9a7ec5d2ada891b` | — |
| `references/taskgraph.md` | 3521 | `be6c6540be86475bb3430688c2987afc` | — |
| `roots.env` | 624 | `ec38f15f9a4101d69c815aa2cdd7b477` | — |
| `scripts/board-launch.py` | 2794 | `998a66b9d335ab1863b5b871a527bf4f` | ok |
| `scripts/board.py` | 146111 | `d90395d1f1a7c3f94419a33cdf444384` | ok |
| `scripts/board_ext.py` | 45655 | `2319fb9d2d21bf961e03a42da9a1ad4b` | ok |
| `scripts/collabctl.py` | 28124 | `827fe07da25b86b773a3f701e86db351` | ok |
| `scripts/collabd.config.example.json` | 1158 | `6655c15c411a84e3ca12fd136dbe2c60` | ok |
| `scripts/collabd.py` | 431355 | `955bd92009d7cd0cccc53d867f591b49` | ok |
| `scripts/deliver-gateway-token.py` | 5685 | `cbba9648d404ecd2683061e19dbdb24a` | ok |
| `scripts/deploy_code.py` | 5325 | `a79cf5e5ffb3e8c60a31b4d0d8845f40` | ok |
| `scripts/forensics/proc-parent.py` | 2347 | `8cdfff2dbb88303298752c3777da7b20` | ok |
| `scripts/goalctl.py` | 47839 | `0e8b1d05003342a9ab474cfbfde5d704` | ok |
| `scripts/guard.py` | 13653 | `43f4e279896090c6c6c3fcbdb4fa95d6` | ok |
| `scripts/hooks/_env.py` | 17767 | `7177d04805c607fa155831340267c49b` | ok |
| `scripts/hooks/bash-output-guard.py` | 17741 | `3f2fdfa17f66f7fcac42e06853433a26` | ok |
| `scripts/hooks/lock-guard-hook.py` | 20363 | `641457de0297ee7a812ccde57f67dc2b` | ok |
| `scripts/hooks/reply-style-guard.py` | 10779 | `a05ca0d6ebef2be55a3d6f96a4c712da` | ok |
| `scripts/hooks/session-log-guard.py` | 24636 | `4dd13b24d541d1afc7b4874dba6bb1eb` | ok |
| `scripts/hooks/skill-load-guard.py` | 24261 | `21c3a3f8732698ef3a6f1a5c61aae343` | ok |
| `scripts/hooks/stop-dialog-guard.py` | 40247 | `4e5f9889717d4ed37a6384c643b4d05e` | ok |
| `scripts/hooks/supervise-ensure-hook.py` | 14308 | `dc6f62584a8384baba0a1f2460828013` | ok |
| `scripts/hooks/wb-result-hook.py` | 37199 | `2d4f694acd031222bf03b98a859b6e90` | ok |
| `scripts/init_workspace.py` | 35817 | `c8b6bbbae01f21939ea2fd8d294d2726` | ok |
| `scripts/judge_audit.py` | 13562 | `8f14a973fa5709a3b9a60b065dc22d7d` | ok |
| `scripts/keepalive.py` | 12265 | `b84c753917e0a34cf40800bda953e7a4` | ok |
| `scripts/lock/handoff-guard.sh` | 34616 | `f38b2ec92fdc1dab9abdaca0e0bb001a` | — |
| `scripts/lock/handoff-status.py` | 3923 | `3edb20f9c4021ebe2323478860bf8f6f` | ok |
| `scripts/lock/op-lock.sh` | 4690 | `1a31eda3642e23693a65e1519860c744` | — |
| `scripts/lock/preflight-lock.sh` | 8673 | `e985ec1cb853fae4c351cd03b171355d` | — |
| `scripts/mut_run.py` | 13323 | `3bffc12e1e57d5efc743bac55e584b62` | ok |
| `scripts/selftest.py` | 469482 | `dc0f4801b2e4fa76e71f649b03f2ed50` | ok |
| `scripts/session-rules-check.py` | 37640 | `552c90b93a051d775299170d5ff49b56` | ok |
| `scripts/stop-collab.py` | 10462 | `d8043bedd23b52b6642aaf7ec8b8c980` | ok |
| `scripts/supervise-launch.py` | 2503 | `f416a2f197e5524a8859c1d227ba1d05` | ok |
| `scripts/wake-session.py` | 11011 | `32c6038b541249730f322f933ee287c1` | ok |
| `scripts/workspace_mirror.py` | 17263 | `60f104b37df3133785abb13f686eea4f` | ok |
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+210 -64
View File
@@ -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()
+213 -8
View File
@@ -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:
+120 -29
View File
@@ -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:用户「任何工作区都要能自己配好」)
+672 -123
View File
@@ -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:
+230 -36
View File
@@ -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"]