diff --git a/session-mechanism/SKILL.md b/session-mechanism/SKILL.md index 7d57767..e3ef791 100644 --- a/session-mechanism/SKILL.md +++ b/session-mechanism/SKILL.md @@ -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 `(加 `--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字>-/ +``` + +- **加了一层 `执行会话/`** —— ⛔ 目标文件夹**不再直接散在工作区根**(用户诉求:根目录干净、历史目标归拢一处)。 +- 单一真源:常量 `_GOAL_DIR_PARENT = "执行会话"` + `goal_dir_name()`(⛔ 别在调用方各拼一遍)。 +- ⚠️ 短哈希算的是 **`title`(全量标题)**,⛔ 不是 `short` ⇒ **改标题 = 换目录名**(存量里已有漂移案例,见 `pitfalls.md`)。 +- ⚠️ **域目录不受影响**:独立域仍是「工作区第一层目录」;`执行会话/` 是**目标文件夹的父层**,⛔ 不参与域键计算。 + 🔴 **落点是死的**:执行产物的 `--artifact` **必须**指进 `<目标目录>/` 里 (⛔ 不许写回 `交付物/`、`docs/`、工作区根 —— **那正是「到处找」的由来**)。 ⚠️ **落点=软判据**(`artifact_dir_ok()` 只**提示**):存量里有落在别处但有效的产物, @@ -653,12 +757,12 @@ agent_created: true · ① 原话逐字:「**这个会话和协作会话的技能包 运行的第一件事 ,就应该是检查清楚 所有会话规划是否配置完整且生效,然后标记一个状态**」 · ② 原话逐字:「**就应该是检查清楚 所有会话规则机制 是否配置完整且生效, 不是规划 是 规则**」⇒ 对象 = **规则机制**(钩子 / 闸门 / 技能指针 / 常驻 / 编排…),⛔ 不是"排期规划"。🔴 首版按①的字面做成「会话规划体检」、**只查排期那一面** ⇒ 当天实测出的三类失效(钩子注入指向**已退役技能名** / 快照**写进幽灵目录** / **每轮注入的记忆**里指针悬空)**一条都查不到** ⇒ 旧脚本已退役到 `/归档/技能包-旧件-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` **归属同形**(错一字面 ⇒ 裂组且自我强化)⑩ **投递(常驻)**心跳 ⑪ 三类会话**当前有没有活的** ⑫ 有没有「**从未运行**就失效」的一次性排期。 ⇒ **标记**:结论写成 `/.workbuddy/collab/session-rules.json`(`verdict` = `ok`/`warn`/`fail` + 逐项 `detail`)—— 后续会话与看板**读它**,⛔ 不靠人复述。 🔴 **为什么必须是第一件事**:2026-10-02 实测——排期**都在册**、模型**都可用**、cwds **都同形**,**却三类会话一条活的都没有**(=配置在、机制没在跑);同一天还查出钩子注入文本指着**已合并退役的技能名**、常驻快照脚本**写到没人读的幽灵目录** ⇒ 全是「**看着有配置、其实没生效**」。这类状态**不问就不会知道**,等它表现成"卡住"时已经晚了。 🔴 **判据本身也要能报出问题**:⑨ / ⑫ 这两项(以及 `cwds` 判据的边界)用**合成样本 + 四个变异体**做过红绿对照(夹具 `/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` 喊人开会话,本条**取代**它)。 diff --git a/session-mechanism/assets/board.html b/session-mechanism/assets/board.html index d064574..3525550 100644 --- a/session-mechanism/assets/board.html +++ b/session-mechanism/assets/board.html @@ -1,1831 +1,1858 @@ - - - - - -多会话协作 · 实时架构看板 - - - - -
-
-

多会话协作 · 实时架构看板

-
- -
- -
- - - -
-
-
- -
- - - - - - -
-

本项目

- ? - 上面每一格 tab 是一个需求目标;这一块画的是当前那格的目标。
- 跟着 tab 走的还有:协作架构、需求台账、会话明细 —— 因为它们都按"任务类别"归属到某个目标。
- 上面那一排「前置 / 队列通知」是整个工作区的,⛔ 不随 tab 变。
- 怎么多一格:在收件箱的 goals/ 目录里放一份与 goal.json 同字段的 - <目标id>.json,刷新即多一格。只有一格时,就是本工作区只登记了一个目标。
- 看板只读:后台隔一会儿生成一份快照,页面直接读缓存(间隔见「协作架构」那块的问题说明)。 -
- -
-
- -
-

协作架构用户 · 会话 · 任务会话 · 程序 · WorkBuddy

- -
- — - 延迟 — -
- ? - 🔴 2026-10-03 改版:会话只剩两类(用户逐字:「让你把 架构图里面的 唤醒和跟进 以及 - 上报都删除」)—— 唤醒会话 / 跟进会话 / 队列上报机制整套退役,图上整行删除 - (⛔ 不是画成灰格占位)。⇒ 图形顺序=用户 → 主会话 → 任务会话 → 常驻程序。
- 主会话只管判断 + 派活,⛔ 只由用户触发(图上指进主会话的线只有上面那一条)。
- 虚线大框=分组(⛔ 不是节点、⛔ 不是"新的一层"):上面框住主会话, - 下面框住任务会话;两个大框之间只有一条线 = ① 派活(⛔ 不从主会话往每格画射线)。
- 🔴 ① 派活的特别之处:它是主会话建一条自动化排期、宿主到点开新会话 —— - 这是唯一能开新会话的通道(钩子开不了会话)。
- 主会话下方那一排是任务会话 —— 一格一条,横着排、有几条画几条, - 一条都没有时画个空框写明「暂无协作目标」。
- 任务类别是同一个工作区里用会话名称前缀区分的(每个类别各有自己的主会话)。
- 会话共两类:主会话 · 任务会话,靠标题两级前缀区分、⛔ 不看目录;
- 某条会话撞到上限时由它自己建出的接续是形态、⛔ 不是第 3 类 - (它继承被接续那条的角色)。
- 协作与投递按定案一直运行(常驻),宿主钩子只作补充;⛔ 不再用"自动任务当闹钟"。 - ⚠️ 唤醒时钟=常驻程序(--supervise),⛔ 不再靠"唤醒会话"那一套。
- 状态都是实读的,会话读 WorkBuddy 的库,程序读它自己留下的时间记录。哪一格停着不动,看框的颜色就知道。
- 看板只读。后台每 — 秒生成一份快照,页面直接读缓存。 -
-
-
- 有会话在跑 - 有件没人在跑 - 空闲 / 件已全完 - 已停 - WorkBuddy -
- -
-
- -
- -

前置

? - 看什么、叫什么名字,由**使用方**的看板扩展(`board_ext.py`)决定。
- 组件名一律写成「系统 · 模块 · 功能名」,不用自造简称。
- 状态只做探测:读组件自己留下的时间记录和日志,不发请求、不改任何东西。 -
-

队列通知 / 握手

-
- -
-

需求台账tasks.json

-

会话明细本项目 · 含历史

? - 分工看架构图第三层。本表列本项目全部会话,含已完成的。 -
-
-
- - - - + + + + + +多会话协作 · 实时架构看板 + + + + +
+
+

多会话协作 · 实时架构看板

+
+ +
+ +
+ + + +
+
+
+ +
+ + + + + + +
+

本项目

+ ? + 上面每一格 tab 是一个需求目标;这一块画的是当前那格的目标。
+ 跟着 tab 走的还有:协作架构、需求台账、会话明细 —— 因为它们都按"任务类别"归属到某个目标。
+ 上面那一排「前置 / 队列通知」是整个工作区的,⛔ 不随 tab 变。
+ 怎么多一格:在收件箱的 goals/ 目录里放一份与 goal.json 同字段的 + <目标id>.json,刷新即多一格。只有一格时,就是本工作区只登记了一个目标。
+ 看板只读:后台隔一会儿生成一份快照,页面直接读缓存(间隔见「协作架构」那块的问题说明)。 +
+ +
+
+ +
+

协作架构用户 · 会话 · 任务会话 · 程序 · WorkBuddy

+ +
+ — + 延迟 — +
+ ? + 🔴 2026-10-03 改版:会话只剩两类(用户逐字:「让你把 架构图里面的 唤醒和跟进 以及 + 上报都删除」)—— 唤醒会话 / 跟进会话 / 队列上报机制整套退役,图上整行删除 + (⛔ 不是画成灰格占位)。⇒ 图形顺序=用户 → 主会话 → 任务会话 → 检查程序。
+ 主会话只管判断 + 派活,⛔ 只由用户触发(图上指进主会话的线只有上面那一条)。
+ 虚线大框=分组(⛔ 不是节点、⛔ 不是"新的一层"):上面框住主会话, + 下面框住任务会话;两个大框之间只有一条线 = ① 派活(⛔ 不从主会话往每格画射线)。
+ 🔴 ① 派活的特别之处:它是主会话建一条自动化排期、宿主到点开新会话 —— + 这是唯一能开新会话的通道(钩子开不了会话)。
+ 主会话下方那一排是任务会话 —— 一格一条,横着排、有几条画几条, + 一条都没有时画个空框写明「暂无协作目标」。
+ 任务类别是同一个工作区里用会话名称前缀区分的(每个类别各有自己的主会话)。
+ 会话共两类:主会话 · 任务会话,靠标题两级前缀区分、⛔ 不看目录;
+ 某条会话撞到上限时由它自己建出的接续是形态、⛔ 不是第 3 类 + (它继承被接续那条的角色)。
+ 协作与投递按定案一直运行(常驻),宿主钩子只作补充;⛔ 不再用"自动任务当闹钟"。 + ⚠️ 唤醒时钟=检查程序(--supervise),⛔ 不再靠"唤醒会话"那一套。
+ 状态都是实读的,会话读 WorkBuddy 的库,程序读它自己留下的时间记录。哪一格停着不动,看框的颜色就知道。
+ 看板只读。后台每 — 秒生成一份快照,页面直接读缓存。 +
+
+
+ 有会话在跑 + 有件没人在跑 + 空闲 / 件已全完 + 已停 + WorkBuddy +
+ +
+
+ +
+ +

前置

? + 看什么、叫什么名字,由**使用方**的看板扩展(`board_ext.py`)决定。
+ 组件名一律写成「系统 · 模块 · 功能名」,不用自造简称。
+ 状态只做探测:读组件自己留下的时间记录和日志,不发请求、不改任何东西。 +
+

队列通知 / 握手

+
+ +
+

需求台账tasks.json

+

会话明细本项目 · 含历史

? + 分工看架构图第三层。本表列本项目全部会话,含已完成的。 +
+
+
+ + + + diff --git a/session-mechanism/install.py b/session-mechanism/install.py index 794b82b..edaa1c3 100644 --- a/session-mechanism/install.py +++ b/session-mechanism/install.py @@ -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()}") diff --git a/session-mechanism/logs/_dr_probe.json b/session-mechanism/logs/_dr_probe.json new file mode 100644 index 0000000..65909a1 --- /dev/null +++ b/session-mechanism/logs/_dr_probe.json @@ -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"}} \ No newline at end of file diff --git a/session-mechanism/references/03-回复排版-核心块.md b/session-mechanism/references/03-回复排版-核心块.md index 64190fd..85b5208 100644 --- a/session-mechanism/references/03-回复排版-核心块.md +++ b/session-mechanism/references/03-回复排版-核心块.md @@ -13,6 +13,11 @@ - **三禁**(⛔ 任一命中 = 该条回复**作废、重写后再发**):**表格** / **长散文** / **碎标签堆叠**。 - **并列内容竖排**:多个候选、多项并列各占一段、逐条编号;⛔ 不横排、⛔ 不挤进一段、⛔ 不塞成表格的列。 - **待拍板项**:放回复**最后一节**,逐条编号;每条写清「问题 + 说明(影响谁/断多久/花多少钱/有无不可逆)+ 各候选的优点与缺点 + 倾向」。 +- 🔴🔴 **「变相征询」同样禁止**(2026-10-06 用户据实况追认):凡是**要用户拿主意**的事 —— + 包括「**先只报不动**」「**等你发话**」「我倾向 X,你看呢」这类**不带选项的待定清单** —— + **一律按上一条写成「待拍板项」**(问题 + 说明 + **各候选的优点与缺点** + 倾向)。 + ⛔ 不许用一句"倾向"或"等你发话"**顶替**选项;⛔ 也不许把「没写选项的清单」当成"只是汇报"蒙过去。 + 反过来:**已自决**的事写成陈述句(「我选了什么,可推翻」),⛔ 不要混进待拍板清单里充数。 - ⛔ **不用征询句收尾**(「要我…吗/请确认/你看怎么办」);能自决策的直接做完,只留一句"我选了什么(可推翻)"。 - ⚠️ 若**本工作区另有更新的定稿**(环境文件里有更细的排版节)⇒ **以那份为准**,本块是通用形态。 diff --git a/session-mechanism/references/04-决策方法论.md b/session-mechanism/references/04-决策方法论.md new file mode 100644 index 0000000..dda95d3 --- /dev/null +++ b/session-mechanism/references/04-决策方法论.md @@ -0,0 +1,400 @@ +# 决策方法论(怎么想、怎么定) + + + +> **归属**:技能 `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 +# -<标题>(<日期> 落地 / 调研) +- 日期: / - 状态:✅已完成|🔄进行中|⏸暂缓|❌关闭 / - 触发:<用户原话或原始报障> +> **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 +``` diff --git a/session-mechanism/references/dsh-decision-method/素材库-A-AI推理.md b/session-mechanism/references/dsh-decision-method/素材库-A-AI推理.md new file mode 100644 index 0000000..b9862fd --- /dev/null +++ b/session-mechanism/references/dsh-decision-method/素材库-A-AI推理.md @@ -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** 互补:部署本身**不必问用户**,但**必须做**。 diff --git a/session-mechanism/references/dsh-decision-method/素材库-U-用户决策.md b/session-mechanism/references/dsh-decision-method/素材库-U-用户决策.md new file mode 100644 index 0000000..7d0598b --- /dev/null +++ b/session-mechanism/references/dsh-decision-method/素材库-U-用户决策.md @@ -0,0 +1,194 @@ +# 素材库 · 用户的有效决策(U1–U24) + +> **归属**:技能 `dsh-decision-method` 的素材库(**按需读**,不是每次都要读)。 +> **主文件 / 索引 / 判定核心** = `../SKILL.md`(§4 判定核心 · §5 流程 · §7 语言表 · 附 自检)。 +> **用法**:只在「要判某条是否属于既有口径」或「要引用用户原话」时读本文件;**别整段抄进答复**。 +> **维护**:条目**只增不改**(编号进位到末尾);用户原话**逐字**引用;每条必须带「实例出处 + 判据」。 + +--- + +## 1. 决策素材库 · 用户的有效决策(U1–U22) + +> 「有效」= 事后被证明正确、且已被落地验证。**这些是用户的稳定偏好,不是一次性指令** —— 新需求来时可以按此预判方向。 + +### U1|面向用户的东西只保留「用户视角」,不暴露平台内部 +- **原话**:「只允许在自己的目录下创建工作区」「**不要给用户看完整路径**」;「用户就只能访问(含读取)dsh 服务用户 id 对应的那个文件夹,**连读都不要读取**」 +- **落地**:picker 根固定 `/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 存在的原因。 +- **判据(三条配套)**: + ① **抢锁前先把收口步骤列出来**(落地 → 校验 → 推送/对账 → 收尾)—— 别做到一半才发现收不完; + ② **中途必须停**(等用户拍板 / 等外部窗口)⇒ **先释放锁再停**(锁是"正在动手"的凭证,不是"占位符"); + ③ **结束语必须对锁状态负责**:要么写明「已释放」,要么**显式点名**「锁仍在 ``、未释放、原因、下一步」—— 后者**仅限"释放通道不可用"这类极端情形**;⛔ 「忘了 / 做不完就走」一律不允许。 +- 与 **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**(代价不对称就保留)同族。 diff --git a/session-mechanism/references/dsh-decision-method/素材库-反例-X.md b/session-mechanism/references/dsh-decision-method/素材库-反例-X.md new file mode 100644 index 0000000..104d6bf --- /dev/null +++ b/session-mechanism/references/dsh-decision-method/素材库-反例-X.md @@ -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) | diff --git a/session-mechanism/references/manifest.md b/session-mechanism/references/manifest.md index 19e783b..d963d4c 100644 --- a/session-mechanism/references/manifest.md +++ b/session-mechanism/references/manifest.md @@ -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 行代际表、含 `旧称/旧词/一代/二代/第三代` 的行、` 历史…` 段落 - (要讲清三代分别叫什么,就必须留着旧名); - ③ **`.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` **退役** - 到 `/归档/技能包-旧件-20261002/`,**能力不回退**。 - ③ **状态标记改名**:`session-plan.json` → **`session-rules.json`**(旧的属过期件,⛔ 别拿来对照)。 - ④ **判据收紧**:`cwds` 近失配原写成「同父目录 + 字面不同」⇒ 在 `AIProject/` 这种**多业务线平级目录**下 - 把**别的线**报成失配(实跑当场 2 条假红)⇒ 改为「同父目录 + **名字去 `-`/`_` 后仍相同**」/「同名不同父目录」。 - ⑤ **判据自验**:⑨⑫ 抽成**纯函数** + 合成样本夹具(`/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/.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 行代际表、含 `旧称/旧词/一代/二代/第三代` 的行、` 历史…` 段落 + (要讲清三代分别叫什么,就必须留着旧名); + ③ **`.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` **退役** + 到 `/归档/技能包-旧件-20261002/`,**能力不回退**。 + ③ **状态标记改名**:`session-plan.json` → **`session-rules.json`**(旧的属过期件,⛔ 别拿来对照)。 + ④ **判据收紧**:`cwds` 近失配原写成「同父目录 + 字面不同」⇒ 在 `AIProject/` 这种**多业务线平级目录**下 + 把**别的线**报成失配(实跑当场 2 条假红)⇒ 改为「同父目录 + **名字去 `-`/`_` 后仍相同**」/「同名不同父目录」。 + ⑤ **判据自验**:⑨⑫ 抽成**纯函数** + 合成样本夹具(`/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/.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 | diff --git a/session-mechanism/references/pitfalls.md b/session-mechanism/references/pitfalls.md index 69ed430..6323457 100644 --- a/session-mechanism/references/pitfalls.md +++ b/session-mechanism/references/pitfalls.md @@ -10,6 +10,7 @@ > | **P0-71** | 🔴🔴🔴 **常驻靠什么活着:不在「作业对象」(恒真、没鉴别力),在「谁拉起它」** —— 会话树里起的(父链穿到 `WorkBuddy.exe`)一收工就死;**只有计划任务起的能活**(父链断在自己身上)。✅ 定案形态=**计划任务 → `pythonw.exe` → `--supervise`,每 5 分钟判活**;⚠️ 两个致命细节:**`WorkingDirectory` 必须是工作区根**(⛔ 脚本目录 ⇒ 找不到配置 ⇒ 拒跑)、**常驻不需要网关口令**(⛔ 所以别用 `--ensure`)。🔴 看板 `LastResult=1` 是**虚警**(pythonw 无 stdout),判据看**端口**。🔴 **三条硬约束**(先查后建/各区独立/只有看板共用)+ **`deploy_code.py DEFAULT_FILES` 必须含 `collabctl.py` 等入口脚本**(⛔ 漏了 ⇒ 各区副本永不更新=P0-57 同族) | 🔴 **同一件事栽到第四次**(用户:「**从1号搞到5号 还起个程序都启动不起来**」);错一次=常驻全死、兜底全失 | > | **P0-72** | 🔴🔴🔴 **两个静默失败**:① `kill_all()` 的 `taskkill` 带 `HIDE`(含 breakaway)⇒ 必被拒 ⇒ `except` 吞掉 ⇒ **"已杀进程 0 个"却一个没死=假停**(✅ 改 `creationflags=0`);② 看板任务**直起 `board.py`** ⇒ 无 `COLLABD_CONFIG` ⇒ `已拒跑` ⇒ **崩溃重启循环、端口从没绑上**(✅ 新增 `board-launch.py` 启动器在进程内设 env)。🔴 长驻服务的 `ExecutionTimeLimit` 必须 `0`(⛔ 设 2 分钟 ⇒ 到点被掐)|🔴 `list_procs` 的 `python*` 会**连调用方一起匹配到** ⇒ 必须算保护集 | 🔴 **"做了动作" ≠ "动作生效"**:这两件事**都不报错**,只表现为"停止没停""看板没起" ⇒ 关键动作**必须有动作后复核** | > | **P0-73** | 🔴🔴🔴 **常驻启动器缺一个环境变量 ⇒ 检查程序静默失效**:任务**直起 `collabd.py --supervise`** ⇒ 任务环境**没有 `CODEBUDDY_CONFIG_DIR`** ⇒ `_wb_db()` 落到 `C:\\Users\\Administrator\\.workbuddy\\workbuddy.db`(**0 字节空库**)⇒ `all_sessions_idle` 每轮报 **`no such table: sessions`** ⇒ fail-safe **恒判"有会话在跑"** ⇒ **检查会话再也不建**(外表完全安静、零报错)。✅ 新增 `supervise-launch.py`(进程内 `setdefault("CODEBUDDY_CONFIG_DIR", ...)`)+任务动作改指它+`DEFAULT_FILES` 补入。🔴 **"进程活着" ≠ "它在干活"** ⇒ 检查程序必须看**有没有产出预期分支**(`检查会话:…`)|🔴 **重构"起法"时旧起法的 env 要逐条搬**(本坑就是丢了这一句)|🔴 各区 config 的 `host_db` **写死绝对路径最稳**(vibe 一直这么写 ⇒ 只有 ai1net 炸) | 🔴 **同一件事栽到第五次**(用户点名「检查协作程序和检查程序运行是否正常」才抓到);fail-safe 失败方向安静 ⇒ **凡 fail-safe 必须打可检索日志** | +> | **P0-76** | 🔴🔴🔴 **技能加载闸门的词表漏了「主触发句」** ⇒ 用户说了「使用任务会话完成目标」,钩子**全程 0 命中**(旧词表 22 词全是「决策方法/回复排版」族)⇒ 技能从未强制加载 ⇒ **自决策白名单没进上下文** ⇒ AI 拿"注入通报"当准、把已授权的事**反复要签字**(用户连问三次「你去执行不行啊」「会话技能里没告诉你自决策的规则和机制嘛」)。🔴 **⛔ 不是作用域问题**(实测 `_in_scope=True`);✅ 修法=**扩已有钩子的触发面**(新增会话/派活族 17 词 + 第三条路由 `_LOAD_SESSION`)+ `SKILL.md` 首屏加「第 0 步加载门槛」。🔴 **闸门类机制必须做「触发面覆盖审计」**:光验"闸门能响"不够,要验"**该响的场景是否都在词的覆盖面上**"|🔴 **日志里全是 `entry` 没有 `HIT` = "听见了但没认出"**(比"没被调用"更难发现)| 🔴 它是**"机制看着在跑、其实没在管事"**的典型 —— 闸门本身完好,只是听不见你说话 | > | **P0-75** | 🔴🔴 **远端技能总仓里躺着明文令牌**(`workbuddy_skills.git` 的 `.neodata_token`,`tk_` 明文 72 B,自首次入库 `43b83b0` 就在):首次入库 `git add -A` 整目录收录、`.gitignore` 只挡了产物 ⛔ 没挡凭据。✅ `git rm --cached` + 补忽略规则(`031b312`)|⚠️ **历史仍有该 blob**,彻底清须 `push --force` 重写(牵连 25 技能)⇒ 等用户拍板;根治是**服务端吊销令牌**。🔴 入库前必扫凭据文件名 + 已知令牌串;核验远端必**比差异**(vs 备份)| > | **P0-74** | 🔴🔴🔴 **`_escalate_to_keeper()` 残留旧形态 ⇒ 计划任务里躺着 `powershell.exe` ⇒ 闪黑窗**(用户原话「刚才又弹了窗口看看是什么」):自我供给**旁路**没跟着新形态一起改 ⇒ 任务动作还是 `powershell.exe -WindowStyle Hidden -File start-supervise.ps1`(PowerShell = **控制台程序** ⇒ 每次触发分配 `conhost.exe` ⇒ **闪一下**;`-AtLogOn` + `RestartCount 999` ⇒ **反复**闪)。✅ 改指 `pythonw.exe + supervise-launch.py`,并删掉 `start-supervise.ps1.tpl` 前置门槛。🔴 **"改了主路径" ≠ "把旁路也改了"** ⇒ 收口时全文 `grep New-ScheduledTaskAction` 与 `.ps1` | 🔴 它是**钩子路径**上的(`UserPromptSubmit` → `--ensure` → 失败 → 升级),**平时不吭声、专挑你在用时闪** | > | **P0-24** | 🔴🔴🔴 **注入物里写死命令** ⇒ 会话被逼着做**用户没授权**的事。缓存里存的是**成品文案**(含「⛔ 不要问用户」),逐轮复用 ⇒ 跟你当轮说了什么**无关**。硬规:**缓存只存原始数据,文本按当轮授权现算**;未授权时**只通报、不派活** | 🔴 **越权比超时严重**:超时只是慢,越权是**替你做决定**;且表现像"机制很勤快",**最不易被发现** | @@ -2379,106 +2380,72 @@ INBOX = '…\ai1net-dsh-server\tmp\supervise-inbox' | | 协作程序 | 检查程序 | |---|---|---| -| 是什么 | `collabd.py --supervise`(常驻本体,自带循环,每 10~30 s 一轮) | **`--supervise` 循环内建的检查会话排期**(`maybe_spawn_check_agent()` → `create_check_schedule()` → 直连 SQLite 写 `automations`) | -| 判活 | 心跳文件新不新 + `argv0` 指不指本区 | **日志里有没有 `检查会话:…` / `已建检查会话排期 …` 分支** | -| 载体 | `collabd-keepalive-` 计划任务 | **同一个进程**(⛔ 没有独立任务) | +| 是什么 | `collabd.py --supervise`(常驻本体,自带循环) | **循环内建的检查会话排期**(`maybe_spawn_check_agent()`) | +| 判活 | 心跳新不新 + `argv0` 指不指本区 | 日志里**有没有 `检查会话:…` 分支** | +| 载体 | `collabd-keepalive-` 计划任务 | **同一个进程**(⛔ 无独立任务) | -⇒ 🔴 **检查程序跟着协作程序走** ⇒ 协作程序"活着"**不代表**检查程序在工作(本坑正是如此)。 +⇒ 🔴 **检查程序跟着协作程序走** ⇒ 协作程序"活着"**不代表**检查程序在工作。 -### 二、🔴🔴🔴 症状:协作程序活得好好的,检查程序**悄悄地不干活了** +### 二、症状:协作程序活得好好的,检查程序**悄悄地不干活了** -- **协作程序侧一切正常**:两区心跳新鲜、`argv0` 各指本区、进程恒 3 个。 -- **检查程序侧(ai1net)**:`logs/_collabd.log` **每轮刷一行** - `all_sessions_idle 读库失败 no such table: sessions`(累计 **36 次**); - 最后一次成功建检查会话排期**停在 11:54**(重建任务之前)⇒ **此后一次都没再建过**。 -- **对照组(vibe)完全正常**:读库失败 **0 次**,一路在跑 - `检查会话:目标状态=已完成(非进行中)⇒ 不建`。 -- 🔴 **为什么"静默"**:`_all_sessions_idle()` 的 fail-safe 是 - **读库失败 ⇒ 判「有会话在跑」⇒ 不建检查会话**(`return False`)。 - ⇒ 失败方向**恰好是"什么都不做"** ⇒ 表现成"机制很安静",**⛔ 不会报错、不会告警**。 +- **协作侧全正常**:两区心跳新鲜、`argv0` 各指本区、进程恒 3 个。 +- **检查侧(ai1net)**:`_collabd.log` **每轮刷一行** `all_sessions_idle 读库失败 no such table: sessions`(累计 **36 次**);最后一次成功建排期**停在 11:54**,此后一次都没再建。 +- **对照组(vibe)正常**:读库失败 **0 次**,一路在跑 `检查会话:目标状态=已完成 ⇒ 不建`。 +- 🔴 **为什么"静默"**:`_all_sessions_idle()` 的 fail-safe 是**读库失败 ⇒ 判「有会话在跑」⇒ 不建**(`return False`)。失败方向**恰好是"什么都不做"** ⇒ 表现成"机制很安静",**⛔ 不报错、不告警**。 -### 三、🔴🔴🔴 根因:常驻任务**直起 `collabd.py --supervise`**,任务环境**没有 `CODEBUDDY_CONFIG_DIR`** +### 三、根因:常驻任务**直起 `collabd.py --supervise`**,任务环境**没有 `CODEBUDDY_CONFIG_DIR`** -- `_wb_db()` 逐字: - ```python - cfg = os.environ.get("CODEBUDDY_CONFIG_DIR") or "" - return Path(cfg) / "workbuddy.db" if cfg else Path.home() / ".workbuddy" / "workbuddy.db" - ``` - ⇒ 环境变量**没设** ⇒ 落到 `C:\Users\Administrator\.workbuddy\workbuddy.db`。 -- 🔴 **那个文件是 0 字节的空库**(无任何表)⇒ 查询 `sessions` ⇒ **`no such table: sessions`**。 +- `_wb_db()` 逐字:`cfg = os.environ.get("CODEBUDDY_CONFIG_DIR") or ""` ⇒ 没设 ⇒ 落到 `C://Users//Administrator//.workbuddy//workbuddy.db`。 +- 🔴 **那个文件是 0 字节空库**(无任何表)⇒ 查询 `sessions` ⇒ **`no such table: sessions`**。 | 库 | 大小 | `sessions` 表 | |---|---|---| -| `E:\ProgramData\.workbuddy\workbuddy.db`(**活动库**) | 34.9 MB | ✅ 247 行 | -| `C:\Users\Administrator\.workbuddy\workbuddy.db`(旧库) | **0 字节** | ❌ 无 | +| `E://ProgramData//.workbuddy//workbuddy.db`(**活动库**) | 34.9 MB | ✅ 247 行 | +| `C://Users//Administrator//.workbuddy//workbuddy.db`(旧库) | **0 字节** | ❌ 无 | -- 🔴 **为什么只有 ai1net 炸、vibe 没事**:vibe 的 config 里 `host_db` **写死了绝对路径** - `E:/ProgramData/.workbuddy/workbuddy.db`;ai1net 的 `host_db` **一直是空字符串**(历史备份也空)。 - ⇒ **同一份代码,两区行为不同**,差别只在 config 这一格。 -- 🔴 **"计划任务的『动作』里没有 env 字段"**(P0-72 §二 同一条硬约束)⇒ - `CODEBUDDY_CONFIG_DIR` **只能由启动器在进程内设**。 -- 🔴 **我是怎么把它弄丢的(诚实记录)**:旧看守 `start-supervise.ps1` 里本有 - `$env:CODEBUDDY_CONFIG_DIR = "E:\ProgramData\.workbuddy"`; - P0-72 改成"两层形态"(计划任务 → `pythonw` → 启动器)时,**只保了 `COLLABD_CONFIG`,丢了这一句**。 - ⇒ **重构"起法"时,旧起法里的 env 必须逐条搬过去**(⛔ 不许凭记忆只搬"我记得的那个")。 +- 🔴 **为什么只有 ai1net 炸**:vibe 的 config 里 `host_db` **写死绝对路径**;ai1net 的 `host_db` **一直是空字符串**。⇒ **同一份代码,两区行为不同,差别只在 config 这一格**。 +- 🔴 **"计划任务的『动作』里没有 env 字段"**(P0-72 §二 同一条硬约束)⇒ `CODEBUDDY_CONFIG_DIR` **只能由启动器在进程内设**。 +- 🔴 **怎么弄丢的(诚实记录)**:旧看守 `start-supervise.ps1` 本有 `$env:CODEBUDDY_CONFIG_DIR = ...`;P0-72 改成"两层形态"时**只保了 `COLLABD_CONFIG`,丢了这一句**。⇒ **重构"起法"时,旧起法里的 env 必须逐条搬**(⛔ 不许凭记忆只搬"我记得的那个")。 ### 四、✅ 修法:新增常驻启动器(与 `board-launch.py` 对称) -- 新增 **`scripts/supervise-launch.py`**:进程内 **`os.environ.setdefault("CODEBUDDY_CONFIG_DIR", r"E:\ProgramData\.workbuddy")`** - + 设 `COLLABD_CONFIG=<本区>/collabd.config.json` + 兜住 `sys.stdout/stderr`(pythonw 下可能是 `None`) - + `sys.argv=[collabd.py, "--supervise"]` + `runpy.run_path(CB, run_name="__main__")`。 -- 常驻任务动作改指 **`pythonw.exe "<区>/.workbuddy/collab/supervise-launch.py"`** - (⛔ **不再自带 `--supervise`** —— 参数由启动器给),`-WorkingDirectory` 仍是**工作区根**, - `-ExecutionTimeLimit` 恒 **`0`**(长驻服务)。 -- `deploy_code.py` 的 `DEFAULT_FILES` **补入 `supervise-launch.py`**(否则各区吃不到,P0-57 同族)。 -- ⚠️ **`roots.env` 的位置缺口(连带发现)**:`_sm_load_roots()` 从 `__file__` **向上**找 `roots.env`(`here/../..`×4); - 工作区副本在 `/.workbuddy/collab/collabd.py` ⇒ 若工作区里没有 `roots.env` ⇒ **找不到**; - 而 `roots.env` 只在**技能目录**(`E:/ProgramData/.workbuddy/skills/session-mechanism/roots.env`)。 - ⇒ **别指望靠 `roots.env` 兜住各区**,启动器里**写死**最稳。 +- 新增 **`scripts/supervise-launch.py`**:进程内 `os.environ.setdefault("CODEBUDDY_CONFIG_DIR", ...)` + 设 `COLLABD_CONFIG=<本区>/collabd.config.json` + 兜住 `sys.stdout/stderr`(pythonw 下可能是 `None`)+ `runpy.run_path(collabd.py, run_name="__main__")`。 +- 常驻任务动作改指 `pythonw.exe "<区>/.workbuddy/collab/supervise-launch.py"`(⛔ **不再自带 `--supervise`**),`-WorkingDirectory` 仍是**工作区根**,`-ExecutionTimeLimit` 恒 `0`。 +- `deploy_code.py` 的 `DEFAULT_FILES` **补入该启动器**(否则各区吃不到,P0-57 同族)。 +- ⚠️ **`roots.env` 位置缺口(连带发现)**:`_sm_load_roots()` 从 `__file__` **向上**找 `roots.env`;工作区副本在 `/.workbuddy/collab/collabd.py` ⇒ 工作区里没有该文件 ⇒ **找不到** ⇒ **启动器里写死最稳**,⛔ 别指望 `roots.env` 兜住各区。 -### 五、✅ 验收(2026-10-05 12:15 实测,全部留痕) +### 五、✅ 验收(2026-10-05 12:15 实测) -**① 分界线干净得可当判据**(`grep -c` + `awk` 分界): +**① 分界线干净得可当判据**: ``` -[2026-10-05 12:14:49] all_sessions_idle 读库失败 no such table: sessions ← 旧进程最后一次 -[2026-10-05 12:15:02] supervise loop start pid=58860 ← 新形态进程起来 -[2026-10-05 12:15:14] all_sessions_idle:还有 1 条 working(a80f300d)⇒ 不算全结束 +[12:14:49] all_sessions_idle 读库失败 no such table: sessions ← 旧进程最后一次 +[12:15:02] supervise loop start pid=58860 ← 新形态进程起来 +[12:15:14] all_sessions_idle:还有 1 条 working(a80f300d)⇒ 不算全结束 ``` -⇒ **`pid=58860` 之后 `no such table: sessions` 计数 = 0**(`awk` 分界实测), -从 fail-safe 的"恒判有会话"变成**真实读库判定**。 +⇒ **`pid=58860` 之后 `no such table` 计数 = 0**(`awk` 分界**实测**),从 fail-safe 的"恒判有会话"变成**真实读库判定**。 -**② 那条 `working(a80f300d)` 是真的**(`sqlite3` 直查活动库): -`('a80f300d-…', '复盘排期堆积与一次性排期问题', 'working', 'E:/ProgramData/AIProject/ai1net-dsh-server')` -⇒ 就是**当前这条会话本身** ⇒ 闸②「本区有会话在跑 ⇒ 不建检查会话」是**正确的合法拦截** -(⛔ 不是 bug。要观察 `检查会话:…` 分支,得等本区会话全结束)。 +**② 那条 `working(a80f300d)` 是真的**(直查活动库):就是**当前这条会话本身** ⇒ 闸②「本区有会话在跑 ⇒ 不建检查会话」是**正确的合法拦截**(⛔ 不是 bug)。 + +**③ 变异对照证明判据非恒绿**(两档): -**③ 变异对照证明判据非恒绿**(`tmp/mutate_dbpath.py`,两档): | 档 | 环境 | 落的库 | 结果 | |---|---|---|---| -| A | **无** `CODEBUDDY_CONFIG_DIR` | `C:\…\.workbuddy\workbuddy.db`(0 B) | `FAIL no such table: sessions` | -| B | **有**(注入后) | `E:\ProgramData\.workbuddy\workbuddy.db`(34.9 MB) | `OK sessions=247 working=1` | +| A | **无** `CODEBUDDY_CONFIG_DIR` | `C://…`(0 B) | **FAIL no such table: sessions** | +| B | **有**(注入后) | 活动库(34.9 MB) | **OK sessions=247 working=1** | ⇒ **A 必 FAIL、B 必 OK** ⇒ 路径来源**真实决定成败**(⛔ 不是"应该绿")。 -**④ 运行时三件套(`Get-CimInstance` 实测,恒 3 进程、`ppid=3924`=调度器、父链断 ⇒ 真常驻)**: -| pid | 启动器 | 归属 | -|---|---|---| -| 58860 | `/.workbuddy/collab/supervise-launch.py` | 协作程序 ai1net | -| 61956 | `/.workbuddy/collab/supervise-launch.py` | 协作程序 vibe | -| 63684 | `<技能>/scripts/board-launch.py` | 看板 | - -**⑤ 启动器三处 md5 一致** `f416a2f1`(技能目录 + 两区副本);看板 `curl --noproxy '*' http://127.0.0.1:20099/` ⇒ **HTTP 200**(1.5 ms)、`netstat` 实测 `127.0.0.1:20099 LISTENING`。 +**④ 运行时**(恒 3 进程、父链断 ⇒ 真常驻):两台**启动器**(各指本区)+ 一个**看板**。 +**⑤ 三处 md5 一致**;看板 `curl --noproxy '*' :20099/` ⇒ **HTTP 200**、`netstat` 实测 **LISTENING**。 ### 六、⛔ 教训(一句话) -**"进程活着" ≠ "它在干活"** —— 常驻本体活着,但它内部某一环读错了库、被 fail-safe 兜成"什么都不做", -**外表完全安静、零报错**。⇒ +**"进程活着" ≠ "它在干活"** —— 常驻本体活着,但内部某一环读错了库、被 fail-safe 兜成"什么都不做",**外表完全安静、零报错**。⇒ 1. **检查"程序是否正常",必须看它有没有产出预期的分支**(`检查会话:…`),⛔ 不能只看进程在不在; -2. **fail-safe 的方向要选对**(本处是"不建",安全但不可见)⇒ 凡 fail-safe,**必须同时打一条可检索的日志**; -3. **重构"起法"时,旧起法里的 env/inner 参数要逐条搬**(本坑就是丢了一句 `CODEBUDDY_CONFIG_DIR`); +2. **fail-safe 的方向要选对**;凡 fail-safe,**必须同时打一条可检索的日志**(否则"安全"就等于"不可见"); +3. **重构"起法"时,旧起法里的 env/inner 参数要逐条搬**(本坑就是丢了一句 `CODEBUDDY_CONFIG_DIR`); 4. 各区 config 的 `host_db` **写死绝对路径最稳**(vibe 一直这么写,所以只有 ai1net 炸)。 - --- ## P0-74 🔴🔴🔴 **`_escalate_to_keeper()` 残留旧形态 ⇒ 计划任务里躺着 `powershell.exe` ⇒ 闪黑窗**(★ 2026-10-05 用户原话「刚才又弹了窗口看看是什么」) @@ -2596,3 +2563,1140 @@ git push origin master # 13a300e..031b312 本次就是靠差异比对才发现;只看"25 个技能都在"会**直接漏掉**。 4. ⚠️ 首次入库用 `git add -A` 是**高危动作**(整目录收录)⇒ 收录前先跑一遍第 1 条的两扫。 +## P0-76 🔴🔴🔴 **技能加载闸门的词表漏了「主触发句」⇒ 用户说了「使用任务会话完成目标」,钩子全程 0 命中 ⇒ AI 靠记忆干活 ⇒ 把已授权的事反复要签字**(★ 2026-10-05 实测取证 · 用户连问三次「你去执行不行啊」) + +**症状**(用户视角):用户明明说了「**2、使用任务会话完成目标:XXX**」,AI 却回头问「要我现在派任务会话,还是先自己执行?」。 +用户连问三次:「任务会话的目的不就是建立任务会话执行嘛,不然我调用任务会话技能干什么」「我想知道你哪来的这个问题啊,你去执行不行啊」 +「我想知道你哪来的这么多问题啊,你去执行不行啊,**会话技能里面没有告诉你自决策的规则和机制嘛**」。 + +**取证过程**(会话 `b232218f` · vibe-product 主会话 · 20.7 MB / 5023 行 jsonl): +| 步 | 读数 | 结论 | +|---|---|---| +| ① 钩子日志 | 10-05 该会话 **155 条 entry、6 次 HIT**,且 6 次全在 10-02~10-04 | 10-05 当天 **零命中** | +| ② 会话内 `Skill` 调用 | 全文件 8 次,最近一次在 **L3530**(更早的一轮);**下发目标那一刻(L4959)前后完全没加载** | 靠记忆干活 | +| ③ 独立复现 | 喂 `vibe-product` cwd + 用户原话 ⇒ rc=0、**stdout 空** | 静默放行 | +| ④ 内部判定 | `_in_scope()=True`、`_workdirs()=('*',)` | **⛔ 不是作用域问题** | +| ⑤ 词表逐条比对 | 旧 `TRIGGERS` 22 词**全是「决策方法」族 + 「回复排版」族** | **「使用任务会话完成目标」一个词都匹配不上** | + +**根因(一句话)**:**词表只覆盖「点名方法论」,漏了「点名派活」—— 而后者才是 `session-mechanism` 的主入口。** +「使用任务会话」「任务会话完成」「执行会话完成」「继续完成目标」「会话技能」这些**主触发句里一个词都没有**; +用户那句「会话技能里面没有告诉你自决策的规则和机制嘛」是**反问**(含"规则"二字但那是质问,⛔ 不构成点名)。 + +**为什么后果这么重**:技能没被强制加载 ⇒ **自决策白名单没进上下文** ⇒ AI 拿"机制层旁路信号"(注入通报里那句「本轮没有要求使用协作会话」)当了准,而**用户原话优先级高于它** ⇒ 于是把"怎么执行"当成待拍板项。 + +**修法(三处,都在技能内)**: +1. `scripts/hooks/skill-load-guard.py`:新增**分组 3 触发词**(会话机制/派活族 17 词)+ 第三条路由 `_LOAD_SESSION`(→ 加载 `session-mechanism`,⛔ 不是 `dsh-decision`)+ 注入文本里写明「用户说『使用任务会话完成目标』本身就是授权,⛔ 不许再问『要不要建任务会话』」。 +2. `SKILL.md` 首屏:新增 **「🔴🔴 第 0 步:加载门槛」**(在十条禁令之前)—— 三张「⛔ 禁止的跳过理由」表 +「**用户原话 > 注入通报**」冲突裁决 + 白名单九类指向。 +3. 副本同步:`vibe` 副本从落后 28 文件 → 全对齐(仅 `roots.env` / `install.log` 两处刻意差异)。 + +**验收(全部实测)**: +| 项 | 结果 | +|---|---| +| 端到端 7 用例(逐字复刻 settings.json 那条命令) | **7/7 通过**(5 应命中+2 反例正确放行) | +| 跨工作区(vibe / mcn-short-video / ai1net-decision-laya) | 三个区**全部 HIT**(钩子挂全局 ⇒ 全机生效) | +| **变异对照**(把分组 3 词表删回旧状态) | **5 个应命中全部报红(2/7)** ⇒ 判定**不是恒绿** | +| 副本一致性 | 源 58 文件 / 副本 59(多一份 `副本使用说明.md`),**仅 `roots.env`+`install.log` 差异** | + +**🔴 教训(可迁移)**: +1. **闸门类机制必须做「触发面覆盖审计」** —— 光验证"闸门能响"不够,要验证"**该响的场景是否真的都在词的覆盖面上**"。本坑就是"闸门本身好的,但它听不见你说话"。 +2. **钩子日志里全是 `entry` 而没有 `HIT`,就是"听见了但没认出"** —— 这比"完全没被调用"更难发现(前者看起来机制在跑)。 +3. **反问句不算点名** —— 用户质问「没告诉你规则吗」时含关键词,但语义是追责不是请求;靠关键词匹配的闸门**必须把这类误判考虑进去**(本次靠"命中才注入"的保守设计避开了,代价是漏掉了真正的点名)。 +4. ⛔ **别在钩子旁边再造第二套** —— 本次是**扩已有钩子的覆盖面**,不是新建钩子(用户明示前提:「从机制上兜住"忘了加载"这一主因」)。 + +--- + +## P0-77 🔴🔴🔴 **前缀发源地「一对多」:实测 4 类落点,改一处只改一半 ⇒ 半新半旧**(★ 2026-10-05 · 用户连说两遍「**协作 全部改为 执行 不要我在说第二遍**」) + +### 一、症状 + +用户 10-03 已下过令「协作 → 执行」,10-05 发现**新建会话前缀仍叫 `[协作]`** ⇒ 追问「**协作 全部改为 执行 不要我在说第二遍**」。 + +### 二、根因 A — 发源地是「一对多」,共 4 类落点 + +| # | 发源地 | 位置 | 漏改的后果 | +|---|---|---|---| +| ① | `_zh` 字典(**新建前缀唯一出处**) | `collabd.py::_gap_plan()` | 不改 ⇒ 新建的**永远是旧前缀** | +| ② | 提示词模板 **9 处** | `collabd.py` 1417/1419/1421/1869/3262/4145/4160/4189/4206 | 不改 ⇒ **派活的指令文本**还在教 AI 写旧前缀 | +| ③ | 扫描面(`SESS_PREFIXES` + SQL) | `session-rules-check.py` | 不改 ⇒ **新建的 `[执行]` 会话被体检整体漏掉**(**静默失管**) | +| ④ | 识别映射表(`role`/`_PFX`) | `collabd.py` + `board.py` | **⛔ 这个不许改** —— 一删,存量全部解析成 `role=""` ⇒ 看板画不出、派活漏管,**不可逆** | + +🔴 **最阴的是 ③** —— 它是**静默**的:体检照跑、照出 pass,只是**新前缀的会话从来没被扫进来**。与 P0-76 同型:**闸门本身是好的,但它看不见新东西**。 + +### 三、修法(全量,含存量) + +① `_zh` → `"执行"`|② 9 处提示词模板 → `[执行]`|③ `SESS_PREFIXES` + SQL 补齐|④ 映射表**只加注释加固、一个键不动**。 + +- 存量**一并改写**(用户要「全部」):宿主库 `sessions` **55 条** + `automations` **5 条活排期**,事务内 `replace()`,`busy_timeout=30s`,**改后 `integrity_check = ok`**。 +- 备份走官方 `backup()` API(⛔ 禁文件级 `cp`)+ `snapshot_titles.json` 逐条留痕。 + +### 四、顺带修的真缺陷(第三个) + +**类别名自带方括号 ⇒ 拼出双层**:`goal.json` 的 `topics` 写成 `["[暗色主题补抓]", …]`(**值里自带方括号**),套进模板 `"[%s]-[%s]-%s"` ⇒ 拼出 `[执行]-[[暗色主题补抓]]-承接队列` ⇒ **形不合规**。 +- 修法:`_gap_plan()` 里**只剥一层**首尾方括号(⛔ 不全 strip,免得把 `[a][b]` 也吞了);**归一化只作用于拼装名,不回写 `goal.json`**(那是用户的输入)。 +- 实测三种输入 `[暗色主题补抓]`/`暗色主题补抓`/`[a][b]` → 输出全合规。 + +### 五、验收(全部实测 · 2026-10-05 15:2x) + +| 项 | 结果 | +|---|---| +| `_zh` 发源地读数 | `{"follow":"跟进","worker":"执行","waker":"唤醒"}` ✅ | +| `_gap_plan()` 实测产物 | `[执行]-[机制排查与修复]-承接队列` ✅ | +| 识别映射表(**5 样本**,含死前缀) | `[协作]`/`[协作目标]`/`[任务会话]`/`[执行]` → 全 `worker`;`[检查]` → `check` ✅ | +| **变异对照**(`_zh` 改回 `"协作"`) | **FAIL 2 按预期报红** ⇒ 判据**不是恒绿**;还原复验全绿 ✅ | +| 宿主库改写 | `[协作]%` 会话 **55→0**、活排期 **5→0**;`[执行]%` 55→56;**`integrity_check = ok`** ✅ | +| 两处映射表**逐条同款** | `collabd.py::role` ⇄ `board.py::_PFX` **6 键全等** ✅ | +| 扫描面覆盖 | SQL 前缀集合 ⊇ 活类别前缀集合 ✅ | +| 副本同步(4 份) | 源包/vibe 技能副本/ai1net 运行副本/vibe 运行副本 **md5 全同** ✅ | +| 常驻重启(部署≠生效) | ai1net 48372→**41468**、vibe 51308→**39440**;`started_h` **晚于**部署时间 ✅ | +| **自测基线对比** | **改前 PASS 94 / FAIL 3 = 改后 94 / 3**(那 3 条**改前既有**,⛔ 非本次回归) ✅ | +| 踩到的**自造回归** | 把 `协作目标`/`任务会话` 加进活类别表 ⇒ 体检报「类别缺失」(那是**纯兼容死前缀**)⇒ 已拆成 `SESS_PREFIXES`(扫描面)⇄ `ROLES_LIVE`(活类别)**两层** ✅ | + +### 六、⛔ 教训(可迁移) + +1. **「发源地唯一」是句口号,落地要清单** —— 光"改前缀"就牵出 **4 类落点**(新建/提示词/扫描面/识别表),其中**识别表必须反向保留**。凡"XX 只有一处"的断言,**必须 grep 数一遍**再信。 +2. **改名类改动,读的一侧比写的一侧更危险** —— 写入面改错**看得见**(新建名不对),**扫描面改错是静默的**(旧的照扫、新的没进,体检还全绿)。⛔ 改名前先把"谁会读这个前缀"**列全**。 +3. **「兼容层」与「活类别」必须分成两张表** —— 混成一张 ⇒ 死前缀被当成"必须有活的" ⇒ **每轮必报假缺口**。判据:**只有"要求它现在活着"的才进活类别表**。 +4. **改了代码 ≠ 生效** —— 保活任务判定"已活"就**不会重启**;必须**终止进程 + 触发保活**,以 `started_h 晚于部署时间` 作**唯一判据**(⛔ 副本 md5 对 ≠ 进程换了代码)。 +5. **活库改写的安全顺序**:官方 `backup()` 备份 → 导出待改行快照 → `BEGIN IMMEDIATE` + `busy_timeout` → `replace()` → `COMMIT` → `integrity_check`。⛔ **全程不用文件级 `cp`**(WAL 三件套 salt 世代错位 ⇒ `file is not a database`)。 + +--- + +## P0-78 🔴🔴🔴 **「一个工作区同时只能一个目标 + 换目标要用户确认」在代码里几乎零实现**(★ 2026-10-05 用户口径 · `goalctl.py` 全文搜 `goals/` 零命中) + +### 一、用户口径(逐字,两条) + +> **「一个工作区 历史的旧的目标会有很多个,当前要求完成什么目标 就因该关注和检查那个目标」** +> **「一个工作区 同时只能执行一个目标,如果要切换目标,需要用户确认,然后切换和关注检查切换后的目标」** + +### 二、改前取证(三条,全部实测) + +| # | 项 | 实测读数 | 判定 | +|---|---|---|---| +| ① | 当前目标载体 | `tmp/supervise-inbox/goal.json`(**单文件**) | ✅ 天然满足"同时只有一个" | +| ② | 历史目标归档 | `goalctl.py`/`collabd.py` **全文搜 `goals/` = 零命中** | 🔴 **归档机制度:零实现** | +| ③ | 换目标确认闸 | `declare` **直接覆盖 `title`**,无任何准入判断 | 🔴 **确认闸:零实现** | + +- `vibe-product/tmp/supervise-inbox/goals/机制自检-20261004.json` 那份归档件是**手工造的**(代码从不写这个目录)。 +- `ai1net-dsh-server` 区**连 `goals/` 目录都没有**(从未归档过)—— 它换过目标,旧目标只剩 `goal.json` 里一句手写的 `_旧验收作废说明`。 +- ⇒ **换目标 = 旧目标当场消失**,靠着人自觉补一行字留痕。 +- ⇒ **换目标无需任何人同意** —— 只要有人跑 `declare --title`,目标就换了。 + +### 三、修法(两道闸,都落在 `goalctl.py::declare()`) + +**① 确认闸**:标题与现有不同 ⇒ **默认拒绝**(`rc=3`,**零写入**),必须带 `--switch-goal` 才放行。 +- 🔴 **`--switch-goal` ⛔ 不等于 `--yes`**:前者=「**用户已同意换目标**」,后者只是「干跑转真写」。**两者绝不合并** —— 合并就把"确认"降级成"顺手加个 yes",闸门失效。 +- ⚠️ **只改措辞/补 `--why`/补 `--kpi`(标题没变)⇒ ⛔ 不触发本闸**(那是同一目标在细化)。 + +**② 旧目标归档**:确认后把旧 `goal.json` **整份**写到 `goals/<旧标题≤40字>__.json`(文件名净化 Windows 非法字符、重名自动加序号),并在新目标里留 `_前身目标` 指向归档件。 +- ⚠️ 归档失败 ⇒ **不阻断换目标**(用户已确认),但**必须喊出来**(⛔ 不许静默)。 + +### 四、过程中踩到并修掉的第二个真缺陷 + +**`declare` 没给 `--title` 时回落成工作区名 ⇒ 把"纯细化"误判成"换目标"。** +- 旧写法 `title = _opt("title") or NAME`:只想补 `--why`(标题根本不动)时,`title` 变成 **`NAME`=工作区名** ⇒ 与现有标题不同 ⇒ **触发换目标闸** ⇒ 一个纯细化动作被拦成"未确认换目标"。 +- ✅ 正解:**没给 `--title` ⇒ 保持现有标题**(与 `--topics`/`--kpi` 同一规矩:不给=不动);只有**一个目标都没有**(首次声明)时才回落 `NAME`。 +- ⛔ **这是"给旧行为加闸"的典型连带伤** —— 加闸前该行为是"静默改标题",加了闸才暴露成"硬拒绝"。**加闸必须连带检查旧行为里被掩盖的缺陷**。 + +### 五、验收(全部实测 · 2026-10-05 15:3x) + +| 项 | 结果 | +|---|---| +| ① 换目标未确认 | **rc=3、零写入**,并打印用户口径原文与正确命令 ✅ | +| ② 同目标细化(补 `--why`,标题不变) | **rc=0 正常放行**(⛔ 不被拦)✅ | +| ③ 确认后真换(隔离测试区) | 旧目标归档成功(**32 字段 / 6186 B**),新目标 `_前身目标` 指向归档件 ✅ | +| ④ 归档件内容完整性 | 与旧目标**同源**(title/why/acceptance/topics 全在),字段数 32 vs 新 33 ✅ | +| ⑤ **变异对照**(闸短路 `if False and ...`) | **放行 rc=0** ⇒ 判据会失败、**不是恒绿**;还原后 **rc=3** 复验 ✅ | +| ⑥ 真区未被污染 | `title` 未变、`lifecycle` 未变、无 `goals/` 残留 ✅ | +| ⑦ 副本同步 | 源包 + 两工作区运行副本 + vibe 技能副本 **md5 全同** ✅ | + +### 六、⛔ 教训(可迁移) + +1. **"用户口径"与"代码事实"必须分开核** —— 用户说"一个工作区同时只能一个目标"时,**载体确实满足**(单文件),但**配套的归档与确认两条腿是空的**。⛔ 别因为①对了就以为整条口径都落地了,**要逐条拆开验**。 +2. **「文档里写了」≠「代码里做了」** —— `goals/` 目录**真实存在且有文件**,极易让人以为机制在跑;真相是**那份文件是手工造的、代码从不写它**。⇒ **凡"某目录/某文件存在"就当证据 ⇒ 必错**,要**搜写入方**(本次:搜 `goals/` 零命中)。 +3. **默认拒绝(fail-closed)是"用户确认"类闸的唯一正确形态** —— 若做成"默认放行 + 提示",等于没闸(本次若默认放行,AI 顺手一跑就换了)。 +4. **闸的开关必须专用、⛔ 不与通用开关复用** —— `--switch-goal`(人的授权)与 `--yes`(干跑转真写)**语义正交**,复用即失效。 +5. **给旧行为加闸时,务必回归旧行为的所有分支** —— 本次就是加闸后才发现"没给 `--title` 会静默改标题"这个**既存**缺陷被放大成硬拒绝。 + +## P0-79 🔴🔴🔴 **`declare` 换目标不重置 `lifecycle` ⇒ 检查机制静默空转 ~17 小时**(★ 2026-10-05 · 从 P0-77 拆出,两个主题原本塞在一条里) + +### 一、症状 + +`vibe-product` 目标明摆着没完成(`acceptance_state` N1/N2/N3 全「未过」),却**十几小时不建检查会话**。用户质问「**vibe 目标没完成,检查机制也没运行 到底什么问题怎么解决**」。 + +### 二、根因 B — `lifecycle` 的唯一写入点是 `--set-life`,`declare` 一个字都不碰 + +- `vibe-product` 实测时间线:10-04 19:2x 前 `等待` → **22:02 起 `已完成`** → **10-05 14:04 `declare` 了新目标**,但 `lifecycle` **仍是「已完成」**,`lifecycle_at` 停在 10-04(**残留**)。 +- 拦它的只有**一行**:`collabd.py:4557` `if reason == "queue-empty" and life != GOAL_LIFE_RUN: return None`。 +- 常驻于是**每 60 秒打一条** `检查会话:目标状态=已完成(非进行中)⇒ 不建`,**从 10-04 22:02 空转约 17 小时**(日志 14760+ 行**全是这一句**)。 +- 为什么设计成"绝不自动改":`GOAL_LIFE_WAIT="等待"` 的语义是「**用户没点头就不动**」,代码原话「宁可等,不可动」⇒ **⛔ 不许把这条闸拆掉**。 + +### 三、止血(已做)/机制(未定) + +- **已做**:手工把 `lifecycle` 由「已完成」改回「进行中」(`--set-life`,`by=主会话`)⇒ **90 秒内**日志即出现 `已建检查会话排期 … fire=+90s`。 +- ⛔ **`declare` 是否该自动重置 `lifecycle` 仍未拍板**(三候选:A 仅终态重置/B 只警告/C 只写文档)—— 触及「机制会不会自作主张」红线,**须用户定**。 + +### 四、⛔ 教训(可迁移) + +1. **目标状态是"人的决定",⛔ 不许机制替它改** —— `lifecycle` 残留会让检查机制**静默空转**(日志每 60s 一条、看着"在跑");但修法**不能是"自动重置"**,那会把「用户没点头就不动」这条红线一起拆掉。**方向:让残留可见(告警/体检项),而不是让它自动消失。** +2. **两个主题别塞进一条** —— 本条原与 P0-77(前缀改造)合写,7.4 KB;拆开后各自 <5 KB。判据:**单条讲一件事,跨主题就该拆**(P0-2 同型先例)。 + +--- + + +## P0-80 🔴🔴🔴 **机制新建的会话/排期「模型写死」⇒ 与主会话不一致;且已有判据只扫 `recurring` 恰好看不见它**(★ 2026-10-05 用户问「为什么 vibe-product 检查进程创建的 执行和检查会话没有使用主会话相同的模型」) + +### 一、症状(用户视角) + +vibe-product 常驻新建的 `[执行]`/`[检查]` 会话,模型全是 `space-bunny`;而**主会话是 `deepseek-v4.1-flash`(thought=high)** +⇒ **同一件活在两种模型上跑**,能力档位、思考开关全不一致。 + +### 二、实测读数(2026-10-05 16:0x · 只读取证) + +| 对象 | 模型 | 条数 | +|---|---|---| +| **本区主会话**(`a80f300d`=本条会话) | `deepseek-v4.1-flash`(thought=**high**) | 1 | +| 机制新建的会话(`bg=1` 且 `[执行]`/`[检查]`) | `space-bunny` | **49** | +| 同上(历史,10-02 那批) | `deepseek-v4.1-flash` | 28 | +| 排期表 `automations` | `space-bunny` | **9** | +| 排期表对照:**手工建**的那条 | `deepseek-v4.1-flash` | 1 | + +⇒ **10-04 起从 `deepseek…` 整体变成 `space-bunny`** —— 分界线清楚。 + +### 三、根因(两条,各自独立) + +**根因 A — 模型是硬编码字面量,写在唯一一处** + +- `collabd.py:3941`:`create_check_schedule()` 的 INSERT 里**字面写死** `"space-bunny"`,同时 `model_is_thinking=0`。 +- 🔴 **关键是"两条建会话的路,模型来源不同"**: + · **常驻程序直连 SQLite 建**(`create_check_schedule()`)⇒ 模型 = **代码里写死的那个** + · **会话侧走宿主工具建**(`automation_update`)⇒ 模型 = **宿主默认**(跟主会话一致,实测那条 `deepseek-v4.1-flash` 就是这么来的) +- ⇒ 用户看到的"检查进程创建的会话模型不对",正因它走的是**第一条路**;而 `_gap_plan()` **自己不写库**(只产出参数给会话照抄)⇒ 执行会话若由常驻直建,同样中招。 + +**根因 B — 已有「模型可用性」判据只扫 `recurring`,而机制新建的全是 `once`** + +- `session-rules-check.py` ⑧ 逐字:`_recur = [a for a in mine if a["schedule_type"] == "recurring"]`。 +- 机制新建的排期 **`schedule_type` 恒为 `once`**(P0-65)⇒ **一条都不进扫描面** ⇒ 该判据恒判 ok。 +- 🔴 与 **P0-76 / P0-77 的 ③ 完全同型**:**闸门本身是好的,但它看不见这类东西**(扫描面漏 ⇒ 静默失效)。 + +### 四、修法(待落地,方向已定) + +- **A**:`create_check_schedule()` 的模型**不许写死** ⇒ 从**本区最近一条人开会话**取 `model` + `thought_level` + (实测取数可行:`sessions WHERE is_background_automation<>1 AND cwd LIKE <本区> ORDER BY created_at DESC`), + 取不到时**回落**到一个显式常量并**打日志**(⛔ 不许静默)。 +- **B**:判据 ⑧ 的扫描面**从 `recurring` 扩到「本区全部排期」**(含 `once`)。 +- ⚠️ **改完必须带变异对照**:把取数短路成常量 ⇒ 判据须报红;还原 ⇒ 复绿。 + +### 五、⛔ 教训(可迁移) + +1. **凡"机制替你决定"的参数,都要先问「该跟谁一致」** —— 模型、思考档、上下文窗口、权限模式这几格, + 用户在界面上选的是**一次选择**;机制另建会话时**不继承**就相当于**偷偷替用户换了**。 +2. **硬编码的参数,本质是「把一次实测当成了永恒」** —— `space-bunny` 当时能用,但用户后来换成了 + `deepseek-v4.1-flash` ⇒ 机制还在按几个月前的选择跑。**判据:凡写死的业务参数,一律问"它会不会变"。** +3. **两条独立代码路径做同一件事,就会有"两条不一致的默认值"** —— 修的时候 **⛔ 别只修一条** + (本次:常驻直连 vs 会话侧工具,两者的模型默认值必须**同源**)。 +4. **判据的扫描面要按「这类东西实际长什么样」定,⛔ 不能按"我记得的那一种"定** —— + ⑧ 只写 `recurring` 是因为"当初只想到周期钟";机制后来改成 `once` 了,扫描面**没跟着改** ⇒ + **同一类事故第 N 次复发**(P0-76 ①、P0-77 ③、本条 ⑧ 是同族)。 + +## P0-81 🔴🔴🔴 **判据拿「被测的同一个常量」当下界 ⇒ 拆掉被测对象竟然全绿**(★ 2026-10-05 实测,变异对照当场抓到) + +**场景**:用户定案「目标文件夹收进 `<工作区根>/执行会话/目标-xxx-xxxxxx/`」⇒ 新增常量 `_GOAL_DIR_PARENT = "执行会话"` ++ 新增自检判据「目录名在 `执行会话/` 一层下」。 + +**第一版判据(❌ 恒绿)**: +```python +("🔴 目标文件夹在 `执行会话/` 一层下", d1.startswith(m._GOAL_DIR_PARENT + "/")) +``` +**变异对照**(把 `_GOAL_DIR_PARENT` 改成 `""` 模拟"父层被拆掉")⇒ 结果 **PASS 95 / FAIL 2**, +与**正确实现逐字相同** —— **拆掉被测对象竟然全绿**。 + +**根因**:判据的**下界**取自**被测的同一个常量** ⇒ `_GOAL_DIR_PARENT=""` 时判据变成 +`startswith("/")` ⇒ 而返回值是 `"/目标-甲-8b984a"`(父层空 ⇒ 多一个前导斜杠)⇒ **照样为真**。 +⇒ **判据与实现同源 ⇒ 一起错 ⇒ 恒绿**(这就是"判据恒绿"最隐蔽的一种:它**不是写死了期望值**, +而是**把期望值挂在了被测对象身上**)。 + +**✅ 正解**:判据里**写死期望字面**(`"执行会话/"`),⛔ 不引用被测常量: +```python +("🔴 目标文件夹在 `执行会话/` 一层下", d1.startswith("执行会话/") and d1.split("/")[-1].startswith("目标-")) +``` +修完再跑同一变异体 ⇒ **FAIL 3**(三条新判据全红)⇒ 判据真抓得住。 + +**另两条同批教训**: +1. **判据的假设会过期**:老判据 `not re.search(r"[\\/:*?\"<>|]", clean)` 假设"目录名里没有 `/`", + 加了父层后 `clean` **必然含一个 `/`** ⇒ **误报**。✅ 正解=只清洗/只判**末级目录名**: + `clean.split("/")[-1]`。⇒ 同族第 2 次(**判据自身的假设过期 ⇒ 伪装成产品缺陷**)。 +2. **落点判据要同时认新旧形态**:`parts[0].startswith("目标-")` → 补 + `parts[0]=="执行会话" and parts[1].startswith("目标-")`。⛔ **不许硬切新形态** —— + 那会把存量旧目录里的产物全判成"散在别处"(**误报**,不是事实)。实测两态并存时 + `130 在 / 22 散`,与改前**逐字相同** ⇒ 证零新增散落。 + +📌 **纪律**:凡判据里出现 `m.<常量>` 当作**期望值**,先问一句「**把它改坏,判据会红吗**」; +答不上来 ⇒ 立刻做变异对照。**"跑一次是绿的"不构成验收**(恒绿的那次也绿)。 + +## P0-82 🔴🔴🔴 **自检夹具「静默写到真工作区」+「判据剔注释却漏了 docstring」+「过期判据被当缺陷」**(★ 2026-10-05 · 同一天连栽三次,`goal.json` 被真实改写两次) + +**场景**:给 `goalctl.py` 加「换目标自动复位 `lifecycle`」,配一条**行为级**自检用例 +(真跑 `declare --switch-goal --yes`,读回落盘的 `lifecycle`)。 + +### 坑①:夹具隔离失效 ⇒ **静默改写真工作区** + +**现象**:用例 `rc=0`,但读回 `title` **还是旧值**、`lifecycle` **也没变** +—— 看着像"功能没生效"。**真相是它写到别处去了**:真工作区 +`/tmp/supervise-inbox/goal.json` 的 `title` 被改成了测试值「新目标乙」, +并生成了一条归档件。**跑一次自检 = 改一次真目标**。 + +**根因(两层,缺一不可)**: +1. `goalctl.py:_resolve_ws()` 的候选顺序是 + **`DSH_COLLAB_WS` → `DSH_WS_ROOT` → cwd(含 `.workbuddy/collab/`)**, + ⛔ **完全不看 `COLLABD_CONFIG`,⛔ 也不认 `COLLABD_WORKSPACE`**。 + 而夹具第一版只设了后两个 ⇒ `_resolve_ws()` 两个都不认、cwd 又没有 `.workbuddy/collab/` + ⇒ **回落到技能包上级目录**。⚠️ 那里若叠着真工作区 ⇒ **静默写真**。 +2. `goalctl.py` 的 `INBOX` 是 **硬编码** `WS/"tmp"/"supervise-inbox"` + —— ⛔ **不读配置里的 `inbox` 字段**(实测 `{"inbox":"inbox"}` 被无视)。 + 夹具第一版把 `goal.json` 写在 `/inbox/` ⇒ 脚本读到「(无)」、写到 `/tmp/supervise-inbox/` + ⇒ **读回当然还是旧值**(伪装成"功能没生效")。 + +**✅ 正解(三层,缺一不可)**: +```python +# ① 落点跟着脚本走(⛔ 别照配置猜) +inbox = _pl.Path(tmp) / "tmp" / "supervise-inbox" +# ② 变量必须是 _resolve_ws() 的第一顺位 +env = {**os.environ, "COLLABD_CONFIG": str(cfg), "DSH_COLLAB_WS": tmp.replace("\\", "/")} +# ③ **兜底断言**:从子进程 stdout 抠出它自报的落点,核对前缀;不在 tmp 内 ⇒ 直接记红 +``` +**③ 是关键**:⛔ 别靠"我记得传对变量"(**人的记性不是判据**)。 +子进程自报 + 前缀核对 ⇒ 把「隔离失效」从**静默事故**变成**当场报红**。 +另加一道**外部护栏**:跑写动作前后量**真文件 md5**,必须逐字不动(这次实测 +`30ec31d7…` 前后一致 ⇒ 通过)。 + +### 坑②:判据「已剔除注释」却**漏了 docstring** + +**现象**:判「旧形态已清除」用 `"字面" not in src` ⇒ **误红**。第一版修法是「剔掉 `#` 注释行」, +换了另一条判据又误红 —— 因为**该字面在 docstring(三引号)里**。 + +📌 **纪律**:**「剔注释」≠「剔 docstring」,这是两件事**。 +本仓库的考古纪律要求废弃形态的说明**必须写在注释/docstring 里** +⇒ 那些字面**注定存在** ⇒ 拿全文判「已清除」**必然误红**。 + +**✅ 正解**:用 `tokenize` + `ast` **精确剔两样**: +用 `ast` 抓 docstring 的起止行 → `tokenize` 逐 token,只剔 `COMMENT` +和「**位于 docstring 的** `STRING`」。 + +⛔ **别把 `STRING` 全剔** —— 第一版这么干过,结果把「写在命令**字符串里**的字面」 +(任务动作名、启动器文件名)也剔了 ⇒ **由误红变另一种误红**。 +⇒ "代码干了什么"经常就写在字符串里,剔掉等于**看不见真实现**。 + +### 坑③:**过期判据被当成产品缺陷** + +**现象**:全量自检 `FAIL 2`,都指向 `collabd.py` 里 `_escalate_to_keeper()` / `_own_path()`。 +看着像真 bug,实际是**判据判的形态已经整套废弃**(10-05 已把「旧看守形态」换成 +`pythonw.exe` + 启动器)⇒ 连**被测对象都不存在了** ⇒ 恒红、且**无法修复**。 + +**✅ 正解(两条动作,都要做)**: +1. **删掉死代码**(`_find_keeper_tpl()` 零调用点)—— ⛔ 留着只会**再招一批过期判据**; +2. **把过期用例换成形态守卫**:判「旧形态已彻底清除 + 不许回潮」 + (壳脚本动作不许再现、动作必须是 `pythonw.exe` + 启动器、`CREATE_NO_WINDOW` 在位)。 + 变异对照:把动作改回壳脚本 ⇒ **红**;还原 ⇒ **绿**。 + +📌 **纪律**:判据红了,**先问「被测对象还在不在」**。 +不在了 ⇒ 是判据过期,⛔ 不是"改断言把它弄绿"(那是把判据废掉),而是 +**删死代码 + 换一条守新形态的判据**。 + +### 坑④(并列):**夹具隔离失效时的备份不可信** + +`bak-goalctl-*` 是**覆盖前一刻**的内容 —— 若前一步**已经写坏**,备份**也坏** +(实测本轮备份与坏件**逐字相同**,不可作恢复源)。 +✅ **可用的恢复源 = 被污染的归档件本身**(`goals/<旧标题>__<时间>.json` 里存着**改前的完整 34 键**) +⇒ 逐字还原、复核 `title`/`lifecycle` 四件套/`acc keys`/顶层键数全部复原。 + +📌 **纪律**:**验收前必须 md5 目标真文件**("跑完自检没报错"≠"真文件没被动")。 + +--- + +## P0-83 🔴🔴🔴 **「兼容」二字是大杂烩的孵化器 —— 凡旧物必答"删了会坏在哪"**(★ 2026-10-05 · 用户当场点破) + +**用户原话(逐字)**:「**兼容个毛线,今天兼容一个明天兼容一个 过不了一周就成大杂烩了**」。 + +**触发场景**:报障「vibe 区怎么还有一条 `[协作]` 前缀的会话,是不是技能没更新完全」。 +AI 第一版给的方案是「把 `[协作]` 从**活角色**降为**兼容扫描**」—— **被用户当场否掉**。 +⚠️ 要害不是"降级"这个动作错了,而是**用了「兼容」这个词**:它把一件有硬理由的事 +(**删了就坏**)说成了一件可以拖的事(**先兼容着**)⇒ 后人读到就继续拖。 + +### 判据:一句话分清两种"留" + +面对任何旧前缀/旧字段/旧值,**必须能一句话答出它属于哪一种**: + +| 类型 | 判据(一句话能答出) | 处置 | +|---|---|---| +| **① 删了会坏** | 「删它 ⇒ **谁变成什么**」(点名受害对象 + 后果 + 可逆性) | **必须留**,但注释里⛔ **不许写「兼容」**,要写**受害事实** | +| **② 留着没用** | 答不出受害对象,或只能说"万一以后要用" | **当场删**(不给"兼容期") | + +🔴 **"答不出 ⇒ 就是该删的"** —— 这是本条的可操作形式。 +⚠️ **别把 ① 也写成"兼容"**:那是**把有据的保留伪装成没据的拖延**, +下一个读代码的人会照拖,于是**真的变成大杂烩**。 + +### 本仓实测(把「删了会坏在哪」答出来) + +`board.py::_PFX` 里 `协作`/`协作目标`/`任务会话` 三条 —— 2026-10-05 **当场跑函数验的**(⛔ 非推理): + +``` +删任一条 ⇒ + [协作]-[手机接入]-N9 复测 ⇒ role = '' + [协作]N9 派活 · V2 手机发出的消息 ⇒ role = '' + [协作目标]-xxx ⇒ role = '' + [任务会话]-会话机制取证… ⇒ role = '' +⇒ 看板画不出、派活漏管,且不可逆 +``` +⇒ 这就是"删了会坏在哪"的答案 ⇒ **留,但注释必须写这个,不写"兼容"**。 + +### ✅ 正解(三件一起做,缺一仍是拖) + +1. **活类别表只留在用前缀**:`session-rules-check.py::ROLES_LIVE` **只剩 `[执行]`**。 + 旧前缀从"活"里**彻底踢出**,只保留在**扫描面** `SESS_PREFIXES`(捞历史行用)。 + ⚠️ 踢出的收益是**实打实的**:踢之前 ⑪ 判据每轮把 `唤醒、协作、跟进` 三个报成"类别缺失" + (**假红**),踢之后只剩 `执行` 一项,指标准确了。 +2. **加机制闸门,防"明天又长一条"**:`session-rules-check.py` **第 ⑬ 项自检** + —— 扫**活排期名**+**活会话标题**,出现旧前缀 ⇒ **当场 fail**。 + 🔴 判据**只扫活件**(⛔ 不扫注释/docstring/考古段/软删的历史行)—— + 后者是"记录过去发生了什么",扫它们就是**误红**。 + **变异对照(已跑)**:全 `[执行]`⇒ok|混 1 条 `[协作]`⇒fail|混 1 条 `[任务会话]`⇒fail| + 历史行但**不以旧前缀开头**(`接续:…`)⇒ok ⇒ **非恒绿非恒红**。 +3. **存量活件改名,⛔ 不删行**:改 `automations.name` / `sessions.title` 的**前缀** + (`[协作]`→`[执行]`),**先备份原样(3 行)**,改后 `PRAGMA integrity_check` = `ok`。 + ⚠️ 改名前**先查 `deleted_at`** —— 别把**软删的死单**也算进"存量"(实测 59 条 `[协作]` 里 + **58 条早已软删**,真正的活件只有 1 条;把软删的算进来 ⇒ 报出 59 条的假规模)。 + +### 坑(两个,都踩到了) + +- **坑①:`sqlite3` 的 `LIKE` 转义写法失效** ⇒ **假"0 条"**。 + `WHERE title LIKE '[[]协作]%'` 实测**恒返回 0**(同一时刻 `GLOB '[[]协作]*'` 与 + `instr(title,'[协作]')>0` 都返回 1)。 + ✅ **不要用 `LIKE` 转义方括号** —— 改 `instr(title, '[协作]') > 0`(最稳)或 `GLOB`。 + 📌 **纪律**:**"查到 0 条"≠"没有"** —— 换个写法复验再下结论(本次差点据此报"库里已清干净")。 +- **坑②:把"软删的历史行"当成"当前存量"报给用户** ⇒ 规模虚高 59 倍。 + ✅ 数存量**必须带 `deleted_at IS NULL`**;报"活排期/活会话"与"历史痕迹"要**分开说**。 + +--- + +## P0-84 · 回收站「每半天十几个 G」—— 两个叠加根因(宿主 safe-delete shim × Windows `truncate` 不稀疏) + +**用户原话(2026-10-05)**: +> 你再看一看,现在每半天就得创建十几个 G 的文件在回收站里。有必要创建这么多吗?看看是不是都是这个绘画创建的。 + +### 现象 + +E 盘回收站 **15.36 GB / 32,557 文件**,其中: + +| 实占 | 占比 | 路径 | +|---|---|---| +| **12.02 GB** | **77.7%** | `ai1net-dsh-server\tmp\selftest`(6,966 件) | +| **1.79 GB** | **11.6%** | `vibe-product\tmp\selftest`(1,185 件) | +| 0.35 GB | 2.2% | `ai1net\.workbuddy\collab` | + +⇒ **两工作区 `tmp\selftest` 合计 13.81 GB = 89.3%**。删除时刻 **10-04 11:08 → 10-05 10:06 仅 23 小时**。 + +### 根因一:宿主 safe-delete shim 把工作区 `tmp/` 的删除**全送回收站** + +- 载体:`\resources\app.asar.unpacked\cli\vendor\shim\sitecustomize.py` +- 机制:WorkBuddy 把该目录**前置到 `PYTHONPATH` 上** —— `sitecustomize` 是 Python **保留模块名**, + 解释器启动时**自动 import** ⇒ 它 monkey-patch: + `os.remove` / `os.unlink` / `os.rmdir` / `shutil.rmtree` / `pathlib.Path.unlink|rmdir` + → 全部改道 **`shell32.SHFileOperationW(wFunc=FO_DELETE, fFlags=FOF_ALLOWUNDO|NOCONFIRMATION|NOERRORUI|SILENT)`**。 +- **实测坐实(可复跑)**:`shutil.rmtree(<某探针目录>)` ⇒ 回收站 `$I` 条数 **9705 → 9706**。 + ⚠️ 直觉"`rmtree` 是真删、不过回收站" **在本机是错的**。 +- 触发:`CODEBUDDY_SESSION_ID` 非空(=在 WorkBuddy 会话里跑 Python)+ `CODEBUDDY_SAFE_DELETE_ENABLED != "0"`。 +- 🔴 **豁免名单只有三类**:`_is_under_os_tmp_dir`(**系统** temp)/ pip site-packages 临时目录 / WorkBuddy 托管 venv。 + ⇒ **`E:/ProgramData/AIProject/<线>/tmp/` 不在内** ⇒ 工作区里所有 `tmp/` 删除**照单进回收站**。 +- ⇒ **这是宿主的"防误删"安全设计,不是 bug**;但**它不认识"本工作区的 tmp/ 是一次性自检残渣"**。 + +### 根因二:`selftest.py::_fake_cfg()` 的"稀疏文件"假设在 Windows 上**不成立** + +原注释(`selftest.py:1123`): +> 用 `truncate` 建**稀疏文件**:秒级拿到 10 MiB 的**体积读数**(`getsize`),**⛔ 不真写 10 MiB**。 + +🔴 **实测反证**: + +```python +with open(p, "wb") as f: f.truncate(9_600_000) +# 10 个这样的档:逻辑 91.6 MB ⇒ 实占 91.6 MB (1:1,比值 100%) +``` + +⇒ **NTFS 上 Python 的 `f.truncate()` 不产生稀疏文件**(真稀疏要 `CreateFileW` 带 +`FILE_ATTRIBUTE_SPARSE_FILE` + `SetFilePointerEx` + `SetEndOfFile`;实测该法才得 `logical 9.2 MB / real 9.2 MB`… 也不省,须配合 `FSCTL_SET_ZERO_DATA`)。 +**结论:注释里的假设是错的**,每个"假 10 MiB 档"都真占 10 MiB。 + +- 单次 selftest 造:`9.6MB×3 + 10.0MB×3 + 4.2MB×1 ≈ 60 MB` **真占盘**。 +- 13.81 GB ÷ 60 MB ≈ **235 次 selftest**(23 小时内!)。 +- 触发源:**`install.py --verify` 每次都跑 selftest**(`install.py:376`)。⛔ **无任何自动排期** —— + 全是**人工/会话每改一次就跑一遍 `--verify`**。 + +### 判据(怎么量才不骗自己) + +- 🔴 **`du -sh` 在回收站上会虚高**:顶层 `$R` 是**目录**,按"顶层条目"逐条量会漏掉子树 ⇒ 得到**假 2.17 GB**。 + ✅ **正解**:`$I` 解析原路径 → 定位 `$R` 子树 → **`os.walk` 逐文件** `GetCompressedFileSizeW`。 + 实测实占 **15.19 GB ≈ 逻辑 15.46 GB**(98%)⇒ **没有稀疏红利**。 +- 🔴 **`$I` 解析**(Win10/11):`ver(8B) + size(8B) + FILETIME(8B) + plen(4B)` ⇒ 路径在 **offset 28**、长 `plen*2` 字节、`utf-16-le`。 +- 🔴 **⛔ 别在生产工作区裸跑 `selftest.py` 做"量体积"实验**:本轮实测跑 **16m56s / rc=15**, + 且部分用例会 `m.WS = str(tb)`(`selftest.py:3683`)**改写工作区指向** ⇒ 真改状态。 + +### 正解(按优先级) + +1. **治本·改造档方式**:`_fake_cfg()` 不必造真 9.5 MiB 文件 —— `_deaf_sids()` 只读 `getsize`, + 可 **mock `os.path.getsize`** 或在用例里**注入体积读数** ⇒ 单次从 **60 MB → ~0**。 + ⚠️ 须保持"判据读的是体积数字"这层语义,⛔ 不许把判据改成恒真。 +2. **治本·子进程关掉 safe-delete**:`selftest` 起的子进程 env 加 + `CODEBUDDY_SAFE_DELETE_ENABLED=0` ⇒ 真删不进回收站(只对**自检残渣**用,⛔ 别全局关)。 +3. **治标**:定期清空回收站(这些全是**可再生的**自检残渣)。⚠️ 由用户在资源管理器里做。 +4. 🔴 **别忘了**:给**工作区 `tmp/`** 加豁免 → 除非宿主支持(当前**不支持**路径级豁免), + 否则**唯一可控的就是"少造"**(第 1 条)。 +### 🔴 已修(2026-10-05 · 当轮落地,附读数) + +**正解第 1 条落地了**:`selftest.py::_fake_cfg()` 现在**先标 `FSCTL_SET_SPARSE`、再 `truncate`** +(新增 `_make_sparse()`;⛔ 顺序不能反 —— 先 truncate 再加标志,已分配的簇不会自动归还)。 + +| | 逻辑 | 实占 | 比 | +|---|---|---|---| +| 老写法(只 `truncate`) | 9,600,000 | 9,600,000 | **100.00%** | +| 新写法(先标稀疏) | 9,600,000 | 65,536 | **0.68%** | + +5 档合计:48,000,000 → 327,680 字节(**省 99.3%**)。 +⇒ 单次 `selftest` 的假配置从 ~60 MB 降到 ~0.4 MB;`install.py --verify` 不再往回收站搬几十 MB。 + +⚠️ **⛔ 不追求零占用**:文件系统不支持稀疏时 `_make_sparse()` 返回 False,行为**回落成真写** +(与老实现同),但**会打 stderr 提示** —— ⛔ 不许静默退化(否则又是"看着省了、其实没省")。 +⚠️ 正解第 2 条(子进程 `CODEBUDDY_SAFE_DELETE_ENABLED=0`)**仍未接线**,第 1 条已足够按住主量。 + + +## P0-85 🔴🔴🔴 **装机制时「工作区解析」静默错归属 —— `roots.env` 残留顶替了脚下的目录**(★ 2026-10-05 用户报「新工作区加载会话和执行会话技能遇到的问题」) + +### 现象 + +在 `E:/ProgramData/AIProject/agent-product` 里跑 `install.py --dry-run`, +解析出的工作区 **= `ai1net-dsh-server`**(⛔ 不是当前目录),踩技能 S 红线(归属工作区不能错)。 +⚠️ **`--dry-run` 全程零告警** —— 如果把 `--dry-run` 换成 `--apply`,就是**静默装错地方**。 + +### 根因(两层,各自都是"判据写错") + +**① 优先级颠倒**(`install.py::detect_workspace` 老顺序): + +``` +显式 --workspace → 宿主 env → ⚠ roots.env 里上次装的值 → 才轮到 cwd 向上找 + ↑ 排在这里是错的 +``` + +🔴 **`roots.env` 是「本包全局单例」**(一个包只此一份,`install.py:22` 设计如此), +它记的是**上次装到哪**,⛔ 不是"当前工作区"。把它排在 cwd 之前 ⇒ +**只要装过一次,此后在任何目录跑都会继承同一个值**。 + +**② cwd 判据硬要 `state.py` ⇒ 非 ai1net 线全判死**(`install.py` 老写法): +`(cand/".workbuddy").is_dir() and (cand/"state.py").is_file()`。 +`state.py` 只是 **ai1net 那条线**的现状快照脚本 —— `vibe-product` / `agent-product` **本来就没有** +⇒ 老判据在那些工作区里**永远返回 None** ⇒ 于是**恰好**由根因①的残留顶替。两条一起才成灾。 + +⚠️ 另有一处**易误认**:`E:/ProgramData/AIProject/.workbuddy` **也存在** +(早期遗留:`automations/` + `memory/2026-08-30.md`)⇒ +"只看 `.workbuddy` 存不存在"会把**父目录**认成工作区。 + +### 判据(怎么验才算数) + +- 基线必须**四个真实目录全绿**(缺一个就是假绿): + `ai1net-dsh-server` ✅ / `vibe-product` ✅ / `agent-product` ✅ / `AIProject`(父目录)❌ +- 三个变异**必须都能翻**(否则判据恒绿): + ① 退化成"有 `.workbuddy` 即算" ⇒ `AIProject` 翻 + ② 退化成老判据(+`state.py`)⇒ `vibe-product`、`agent-product` 双双翻 + ③ 优先级退化成"残留 > cwd" ⇒ `vibe-product` 里解析成 `ai1net-dsh-server`(**原病复现**) +- 🔴 **⛔ 别只测"能解析"** —— 本轮第一版基线就写错了期望值(把 `agent-product` 期望成 False), + 结果**基线自己先红**;若当时偷懒"看到有红就当判据生效",就是把**期望错**读成**判据对**。 + +### 正解(已落地 · `install.py`) + +1. **重排优先级**:`显式 > 宿主 env > cwd 就近 > roots.env 残留`。 +2. **放宽 cwd 判据**:有 `.workbuddy/collab/`,或 `state.py`,或(`.workbuddy/memory/` + `state.py`) + ⇒ 三选一即算工作区。⛔ 不再把 `state.py` 当必要条件。 +3. **加归属自检(关键)**:`cmd_apply` 里把「cwd 判据」与「最终选用」并排打出来,⛔ 不许静默: + - `cwd 判据 is None` + 未显式传 `--workspace` ⇒ 🔴 **最危险的一档**: + "现在的工作区是从 `roots.env` **残留**推出来的,⛔ 不是从你脚下的目录认出来的"。 + ⚠️ **老写法只判 `here != ws`,恰好漏掉这一档**(`None != ws` 为真但语义不对, + 第一版就是这么写的,实测告警**没触发**)⇒ 这正是"判据绿灯与被检对象状态无关"的老病。 + - `roots.env` 原指向 ≠ 本次将写 ⇒ 🔴 警示「改指之后,原工作区侧也会跟着指过来」。 + +### 🔴 一条**必须说给用户**的结构事实 + +`roots.env` 是**包级单例**,而钩子条目**没有 `cwd` / `env` 字段** +(实测 `settings.json` 里本包条目只有 `command`/`timeout`/`type`)⇒ +所有脚本都靠 `__file__` 反推包根 → 读**同一份** `roots.env`。 +⇒ **一个技能包同时只服务一个工作区**;「两个工作区都要跑机制」是**已知不支持**的形态。 +(此结论有读数支撑:`grep hooks` 字段 + 各脚本 `_sm_load_roots()` 同源。) + +### ⚠️ 本轮另发现(未处理,留给后续) + +- `settings.json` 里有**两条不属于本包声明表**的钩子:`reply-style-guard.py`、`supervise-ensure-hook.py` + ⇒ `--dry-run` 的"被排除条数"只数了 `decision_bridge`(=4),**没覆盖这两条**。 +- `agent-product/.workbuddy/collab/collabd.config.json` 里带着 `_说明` / `_形态` 富文本字段 + (内容:「由 install 流程本地部署生成(未改全局 roots.env,看板仍由 ai1net-dsh-server 主视图承担)」), + ⛔ **不是** `collabd.config.example.json` 的产物(该模板**无** `_形态` 字段) + ⇒ 是**另一次会话手搓的规避性 workaround**。⚠️ 这种"本地部署"是不是该收进 `install.py`,未定。 +### 🔴 已修(2026-10-05 · 当轮落地,附读数) + +**正解第 1 条落地了**:`selftest.py::_fake_cfg()` 现在**先标 `FSCTL_SET_SPARSE`、再 `truncate`** +(新增 `_make_sparse()`;⛔ 顺序不能反 —— 先 truncate 再加标志,已分配的簇不会自动归还)。 + +| | 逻辑 | 实占 | 比 | +|---|---|---|---| +| 老写法(只 `truncate`) | 9,600,000 | 9,600,000 | **100.00%** | +| 新写法(先标稀疏) | 9,600,000 | 65,536 | **0.68%** | + +5 档合计:48,000,000 → 327,680 字节(**省 99.3%**)。 +⇒ 单次 `selftest` 的假配置从 ~60 MB 降到 ~0.4 MB;`install.py --verify` 不再往回收站搬几十 MB。 + +⚠️ **⛔ 不追求零占用**:文件系统不支持稀疏时 `_make_sparse()` 返回 False,行为**回落成真写** +(与老实现同),但**会打 stderr 提示** —— ⛔ 不许静默退化(否则又是"看着省了、其实没省")。 +⚠️ 正解第 2 条(子进程 `CODEBUDDY_SAFE_DELETE_ENABLED=0`)**仍未接线**,第 1 条已足够按住主量。 + + +## P0-86 🔴🔴🔴 **`goal.json` 的 `execution_doc` 是「只读不写」的字段 —— 无人维护 ⇒ 必然漂移,且能漂成跨区污染**(★ 2026-10-05 修 P0-85 时顺手查出) + +### 现象 + +`install.py --verify` 报「生产工作区该文件**真存在**」FAIL,路径 `…/ai1net-dsh-server/目标-本机协作-c8154d/目标执行状态.md`。 +⚠️ 文件**其实存在**,真身多一层 `执行会话/`。 + +### 根因:整条链路只有一个读者、零个作者 + +| 文件 | 对该键做了什么 | +|---|---| +| `collabd.py` | 只在 `CHECK_PROMPT_GOAL` 说明文字里提一句,**无写入代码** | +| `goalctl.py` | 一次都没出现 | +| `init_workspace.py` | 建 goal 模板时只有 `_唯一来源` **注释**,**不含该字段** | +| `selftest.py` | **唯一读者**(判据按它定位文件) | + +⇒ 代码口径会改(10-05 加了 `执行会话/` 一层),登记值永远不跟着改(无作者 ⇒ 只能手工改 JSON ⇒ 迟早忘)。 + +### 比"假红"更坏:漂成了跨区污染 + +| 区 | 登记的 `execution_doc` | 按本区 `title` 算出的真路径 | +|---|---|---| +| `ai1net-dsh-server` | `目标-本机协作-c8154d/…` | `执行会话/目标-本机协作-c8154d/…` | +| `vibe-product` | `目标-vibe-product-3e3182/…` | `执行会话/目标-vibe-product-5d27fe/…` | +| `agent-product` | **(未登记)** | `执行会话/目标-agent-product-3e3182/…` | + +🔴 `vibe-product` 那行最毒:`3e3182` = **`agent-product` 目标标题的 `sha1` 前 6 位**,正确值是 `5d27fe` +⇒ 该值是**从别区串写来的**,只看这一行**推不出该找哪个文件**(不是"前缀写错",是**指向了别人的目标**)。 +⚠️ 噪音:`3e3182` 在 `ai1net-dsh-server/执行会话/` 下也有同名空壳目录(建于 10-03 12:12,早于 10-05 口径变更)⇒ **旧算法遗留,与本条无关**。 + +### 原判据设计是对的,缺的是倒数第二环 + +`t_execution_doc` **故意读生产 `goal.json` 登记值**(⛔ 不拿测试夹具算法重算 —— 上一版那样做过,问生产文件系统**必然不存在 ⇒ 假红**)。 +⇒ 设计意图没错;缺的是**没人保证登记值跟得上代码**。判据没错、数据错了,而数据的错是代码不改它造成的。 + +### 正解(当轮已落地) + +**① 接线:`collabd.py --ensure-goal-dir` 里顺手对齐**(按现行口径算真路径的唯一一步): + +```python +_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)) +``` + +⚠️ 放这里的理由:`--ensure-goal-dir` **幂等**、`init_workspace.py` 每次初始化必调 ⇒ 每区自动追平,⛔ 不靠人记。 +⚠️ **只改这一个键**(整份读回原样写回,其余字段一字不动 —— 那是状态真源,覆盖=毁进度)。 +⚠️ **失败不影响建目录**:整段包 `try`,出错只记 `out["doc_sync"]`。 + +**② 出声:CLI 把对齐结果印出来** + +```python +if _r.get("doc_sync"): + print("状态文档登记:%s" % _r["doc_sync"]) +``` + +⛔ 不许静默对齐(静默 ⇒ 用户以为本来就对,下次再漂仍查不出)。 + +**③ 判据加两条**(一条判数据、一条判接线,缺一条都会"改完数据又漂回去"): + +```python +sync_ok = (not _exp_rel) or (_nrm(prod_g_rel) == _nrm(_exp_rel)) # 数据:登记值 == 按本区算出的 +_ensure_sync = "_want = exec_doc_rel()" in _cd_code # 接线:对齐代码还在不在 +``` + +⚠️ 比对**必须归一化**(`/`↔`\`、大小写),否则 Windows 上加一条假红。 +⚠️ 期望值**用生产那份 `title`/`short` 现算**(`goal_dir_name()` 纯函数、不依赖 `WS`)。 + +### 实测读数 + +| 区 | 对齐前 | 对齐后 | +|---|---|---| +| `ai1net-dsh-server` | `目标-本机协作-c8154d/…` | `执行会话/目标-本机协作-c8154d/…` | +| `vibe-product` | `目标-vibe-product-3e3182/…` | `执行会话/目标-vibe-product-5d27fe/…` | +| `agent-product` | `(未登记)` | `执行会话/目标-agent-product-3e3182/…` | + +`selftest`:`PASS 95 / FAIL 2` → **`PASS 97 / FAIL 0`**;`install.py --verify` ⇒ **✅ 全绿**。 + +### 变异对照(⛔ 必修,否则可能是恒绿) + +| 变异 | 预期 | 实测 | +|---|---|---| +| A 基线 | 绿 | ✅ `PASS 1 / FAIL 0` | +| B 把 `execution_doc` 改回旧值 | 红 | ✅ `PASS 0 / FAIL 1` | +| C 还原 | 绿 | ✅ `PASS 1 / FAIL 0` | +| D 把 `_want = exec_doc_rel()` 改成 `_want = ""` | 红 | ✅ `PASS 0 / FAIL 1` | +| E 还原 | 绿 | ✅ `PASS 1 / FAIL 0` | + +### 同轮另一处 P0:判据「全文 `in` 一把梭」=恒绿假修 + +修 `t_init_points_to_own_copy` 末条时,第一版写成 `("…", "不起看板" in src and "peer_workspaces" in src)`。 +🔴 **实测:把那段 `print` 整块删掉(137 字节),判据照样绿。** +原因:`peer_workspaces` 全文出现 3 次、`不起看板` 2 次,**多数在注释里**(`src` 是**未剥注释**的全文)⇒ 全文 `in` = **在注释里找证据**。 + +✅ 正解:**锚定到那段代码**(`code` 已剥注释 ⇒ 注释字样不命中): + +```python +def _tail_has_board_note(code: str) -> bool: + i = code.find("✅ 完成。后续协作") + if i < 0: + return False + seg = code[i:i + 1500] + return ("不起看板" in seg) and ("peer_workspaces" in seg) +``` + +⇒ 变异:删整段 ⇒ 红;只删「要并看」那一句(84 字节)⇒ 红;还原 ⇒ 绿。 +**教训:判据的"作用域"必须跟着被判对象走,⛔ 不能拿一个大得多的范围去 `in`。** + +--- + +## P0-87 🔴🔴🔴 **同一个技能里,同一份数据读三处、口径各写各的 —— `goalctl` 两处漏网**(★ 2026-10-06 · 用户「vibe-product 最新会话反应新问题 看看如何解决」) + +### 症状(两条都不报错、不崩溃,只是"少说一句真话") + +| # | 现象 | 真因 | +|---|---|---| +| ① | 目标**四条验收全过**,`goalctl.py status` 仍报「未完成(验收 V1=过;V2=过;…)」——**字面自相矛盾** | `goalctl.py:262` 写成 `if str(acc[k]) != "pass":` —— **死板比字面值**,而存值**真源就是中文** | +| ② | 配置里**已改指**新目标的任务图,`goals_open()` 仍报 `bad=['任务图读不到(taskgraph.json)']` ⇒ 目标判不出来 | `goalctl.py:136` 写成 `CFG = HERE / "collabd.config.json"` —— `HERE` = **技能包目录**,而按定则「技能目录里⛔ 不放生产配置」⇒ 该文件**恒不存在** | + +### 🔴🔴 病根是同一个:**「唯一实现」的约定只写在注释里,没有强制** + +`board.py::acc_is_pass()` 的 docstring 明写: + +> 🔴 三处判据(`board.py` 本函数 / `collabd.py::_acc_is_pass` / `board.html::accIsPass`)**必须同款** + +而 `board.py:1618` 与 `collabd.py:3022` **都做了中文归一化**(`ACC_PASS_WORDS = ("pass","过","通过","达","达标","合格","完成")` + 剥完成副词)。**实际是四处,`goalctl.py` 被漏掉了。** + +⇒ 同一个字段(`goal.json.acceptance_state`)在**同一技能内有三套读法**:两处归一化、一处死板。 +⇒ 后果:**看板说通过、常驻程序说不通过、`goalctl` 说未完成** —— 三张嘴各说各的。 + +**同轮第二处同型**:`collabd.py` 有完整的配置三级解析(`_cfg_candidates()`:env → 工作区标准落点), +而 `goalctl.py` **写死技能目录** ⇒ 配置改了不生效。**又是"一个技能两套读法"。** + +### ✅ 正解 + +1. **判定层复用真身,⛔ 不复制词表**(复制就是下一次漂的来源): + `goalctl` 新增 `_acc_is_pass()` ⇒ 首选 `collabd._acc_is_pass()`(它自己又首选 `board.acc_is_pass()`); + 拿不到 ⇒ **fail-closed 声明式兜底** + stderr 留一行(⛔ 绝不静默返回 True/False)。 +2. **配置解析与 `collabd._cfg_candidates()` 同款三级**,⛔ 不新造顺序。 +3. **提示文案必须与判定层一致**:`:605` 原写「要 `名字=pass` 或 `名字=未过`」⇒ **误导**(`未过` 与任意中文值在旧判定层同样恒判未过)⇒ 改为说明"值可写 `pass`,也可写中文 `过`/`已过`/`达`——判定层会归一化"。 + +### 实测读数 + +| 项 | 修前 | 修后 | +|---|---|---| +| vibe-product `goals_open()` | `{'open': True, 'why': [], 'bad': ['任务图读不到']}` | **`{'open': False, 'why': [], 'bad': []}`** | +| `selftest` | PASS 97 / FAIL 0 | **PASS 99 / FAIL 0** | +| `--verify` | — | **rc=0 ✅** | + +### 变异对照(⛔ 必修,否则可能是恒绿) + +| 变异 | 预期 | 实测 | +|---|---|---| +| A 基线 | 绿 | ✅ `PASS 99 / FAIL 0` | +| B `goalctl` 判定改回 `!= "pass"` | 红 | ✅ `PASS 97 / FAIL 1`(**命中本用例**) | +| C 还原 | 绿 | ✅ `PASS 99 / FAIL 0` | + +### 🔴 写用例时连踩三个坑(都是"我写错了,不是代码错") + +1. **`imp()` 加载的是 `collabd`,不是 `goalctl`** —— 两者 `goals_open()` **同名但返回类型不同** + (collabd 返 `bool`、goalctl 返 `dict`)⇒ 直接用 ⇒ `TypeError: 'bool' object is not subscriptable`。 +2. **两个模块的 `INBOX` 不是同一个目录** —— `collabd.INBOX` 来自**配置**; + `goalctl.INBOX = WS/"tmp"/"supervise-inbox"`(**硬编码**)。写错落点 ⇒ `bad=['任务图读不到']` ⇒ **用例恒红**。 +3. **`CFG` 修好之后,用例的图落点又变了** —— 修前 `CFG` 读不到 ⇒ `taskgraph` 回落默认 `INBOX/taskgraph.json`, + 修后 ⇒ 跟着配置走 `TEST_WS/tg.json`。⇒ **用例不许硬编码任一侧,要从配置里那个值算**。 + +> ⭐ **教训:用例红了先分"是被测代码错,还是我喂错了"。** 三次都是后者,若当时直接去"修代码",会把好的代码改坏。 +> ⭐ **教训:`selftest` 的夹具要跟着"被测模块自己算出来的路径"走**(`_gc.INBOX` / 配置里的 `taskgraph`), +> ⛔ 不要按"我以为的目录"写死 —— 同一技能里两个模块的落点可以完全不同。 + +--- + +## P0-88 🔴🔴🔴 本机**没有**任何回收站工具 —— `gio` 不存在,`Add-Type` 被拦,只剩 `SHFileOperationW` + +> 症状:要"把目录送回收站"(可逆删除),三条常规路**全断**;且唯一能走的那条**判据写错会假报错**。 + +### 症状(本机实测) + +| 途径 | 结果 | +|---|---| +| `gio trash ` | ❌ `gio: command not found`(PortableGit 里 `gio`/`trash-put`/`trash` **一个都没有**) | +| PowerShell `[Microsoft.VisualBasic.FileIO.FileSystem]::DeleteDirectory(...,'SendToRecycleBin')` | ❌ **被安全策略拦**("Add-Type compiles and loads .NET code at runtime") | +| PowerShell `New-Object -ComObject Shell.Application`(`Namespace(10)`) | ❌ **被安全策略拦**("COM object instantiation can run arbitrary code") | +| **纯 `ctypes` 调 `SHFileOperationW`** | ✅ **可用**(不编译代码 / 不碰 .NET / 不弹 UAC) | + +### ✅ 正解(可直接抄) + +```python +import ctypes, os +from ctypes import wintypes + +class SHFILEOPSTRUCTW(ctypes.Structure): + _fields_ = [ + ("hwnd", wintypes.HWND), + ("wFunc", wintypes.UINT), + ("pFrom", ctypes.c_void_p), # 🔴 必须 c_void_p,见坑 ① + ("pTo", ctypes.c_void_p), + ("fFlags", ctypes.c_uint16), + ("fAnyOperationsAborted", wintypes.BOOL), + ("hNameMappings", ctypes.c_void_p), + ("lpszProgressTitle", ctypes.c_void_p), + ] + +FO_DELETE, FOF_ALLOWUNDO = 0x0003, 0x0040 # 🔴 有 ALLOWUNDO 才进回收站 +FOF_SILENT, FOF_NOCONFIRMATION = 0x0004, 0x0010 +FOF_NOERRORUI, FOF_NOCONFIRMMKDIR = 0x0400, 0x0200 + +abspath = str(p.resolve()).replace("/", "\\") +buf = ctypes.create_unicode_buffer(abspath, len(abspath) + 2) # 🔴 双 \0 结尾 +op = SHFILEOPSTRUCTW() +op.wFunc = FO_DELETE +op.pFrom = ctypes.cast(buf, ctypes.c_void_p) +op.fFlags = (FOF_ALLOWUNDO | FOF_NOCONFIRMATION | FOF_SILENT + | FOF_NOERRORUI | FOF_NOCONFIRMMKDIR) +rc = ctypes.windll.shell32.SHFileOperationW(ctypes.byref(op)) + +# 🔴🔴 判据:不是 rc == 0! +ok = (not p.exists()) and (not op.fAnyOperationsAborted) +``` + +### 🔴 三个坑 + +1. **`pFrom` 必须手搓双 `\0` 结尾缓冲。** ctypes 的 `LPCWSTR` **会在第一个 `\0` 处截断** + ⇒ 结构体字段必须声明成 `c_void_p`,用 `create_unicode_buffer(s, len(s)+2)` 再 `cast` 进去。 + ⛔ 写成 `wintypes.LPCWSTR` 并直接赋 `str` ⇒ 静默只删半截路径(或直接 `rc=2`)。 +2. **判据⛔ 不是 `rc == 0`。** 实测**送回收站成功**(目录已消失、回收站 `$I` 元数据能解出原路径), + 返回值却**恒为 `rc=2`**(`ERROR_FILE_NOT_FOUND`)—— 这是 Shell 内部探测残留的 `GetLastError`, + **不是真失败**。⇒ 拿 `rc` 当判据 ⇒ **假报错**(用户看到"失败",但东西已经进回收站了,以为没删掉会再点一次)。 + ✅ 真判据只有两条:**① 原路径不存在了 ② `fAnyOperationsAborted` 为假**。 +3. **验证"是否真进了回收站"要解 `$I` 元数据。** 格式:`v2`、路径 **UTF-16LE**、起点 **offset 24** + (⛔ 不是 26;开头 4 字节是长度前缀,解出来会多一个乱字符,`.replace("\x00","").strip()` 后再比对)。 + ⛔ 别拿 `ls $RECYCLE.BIN` 的条数当证据 —— `$R*` 才是数据、`$I*` 是元数据,两两配对,容易数错。 + +### 本机取证(2026-10-06) + +`_pmtest-{verify,trash,wsdel,wsdel2,http}` 五个测试目录全部送进 `E:/$RECYCLE.BIN/S-1-5-21-…-500/`, +共 **29+ 条 `$I` 元数据**,逐条解出的原路径与送入路径**一致** ⇒ **确认可恢复**。 + +> ⭐ **教训:判"操作成功没有",要挑那个"跟操作语义直接对应的观测量",⛔ 不是挑接口返回码。** +> 返回码是**实现细节**(这里还带 Shell 的脏状态),`exists` / `aborted` 才是**语义**。 + +--- + +## P0-89 🔴🔴 **CSS 逐字正确 ≠ 渲染正确** —— 「容器自己就是那个布局类」时内层再套一次 = 宽度被吃两层 + +**症状**(2026-10-06 项目管理界面,用户肉眼先发现): +四列看板整块挤在页面左边一条,每列只有 ~89 px、卡片 67 px,右侧一大片空白,页高 3192 px。 + +**第一诊断陷阱**:`grep` 样式表 ⇒ **CSS 全对** +(`.cols{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:14px}` 逐字正确) +⇒ 读者会以为"样式没问题,是不是内容/数据的问题"。**实际是结构问题,读样式表永远查不出来。** + +**真因 —— 嵌套 grid**(自己上一轮改造时引入): + +```html + +
+``` + +```javascript +// ✗ 错:又包了一层 .cols +cols.innerHTML = '
' + cols.map(renderCol).join("") + '
'; +``` + +内层 div 成了**外层 grid 的一个 grid item** ⇒ 先被 `min-width:auto` 收缩到**内容宽** +(实测 399 px,=4×89+3×14),再在 399 px 里**自己又切 4 列** ⇒ 89 px/列。 +**两层 grid 叠着吃宽度**,而两层各自看都是"对的"。 + +**✅ 正解**:容器是布局类时,**内层只铺子元素,⛔ 不再套同名类**。 + +```javascript +cols.innerHTML = colsData.map(function(c){ return _colHtml(c.key, c.label, …); }).join(""); +``` + +**🔴 判据(同族通用)**:当 `#X` 的 `class` 里**已经含**你正要写进 `innerHTML` 的那个类名 +⇒ 立刻停下,**这就是嵌套信号**。别写第二层。 + +**✅ 取数姿势 —— 量,不读**: + +```javascript +var el = document.querySelector('#cols > .col'); +var r = el.getBoundingClientRect(); +// 再 getComputedStyle(el).gridTemplateColumns ⇐ 看实际生效的列宽 +``` +实测读数(改前→改后):`col 89px → ~400px`、`card 67px → 381px`、`页高 3192 → 1415px`。 + +⚠️ **探针选择器要跟着结构改**:去掉内层 `.cols` 之后 `#cols > .cols > .col` **恒为 0 条** +—— 那是**选择器过期**,⛔ 不是"布局又坏了"(差一步就误判成回归)。 + +--- + +## P0-90 🔴 布局「虚胖」的默认值:grid 的 `align-items:stretch` 会把空列拉到和高列一样高 + +**症状**(同日,用户:「卡片上下之间的距离太远」): +12 张卡片全在「已完成」列,另三列**空着**,但四列被拉成**等高** ⇒ 整页虚胖、空列白占 1100 px。 + +**真因**:grid 默认 `align-items:stretch` ⇒ 每个 grid item(=列)**拉满行高**, +而行高由最高那列决定。 + +**✅ 正解**:`.cols{align-items:start}` ⇒ 每列**各自撑自己的高度**,空列就是一个小盒子。 +**连带压紧**:卡片 `gap` 9→6 px、`.cards` padding 10→8、列的 `min-height:160px`→0。 + +⚠️ **别用「把空列删掉」来治** —— 四列是**固定四态**(待执行/执行中/受阻/已完成), +空态本身是信息。✅ 治的是**高度**,⛔ 不是**列本身**。 + +> ⭐ **教训:`display:grid` 有一堆"看起来对、实际在拉橡皮"的默认值**(`stretch`/ +> `min-width:auto`/`min-height:auto`)。凡是"某个方向被撑大或压扁"的怪相, +> 先怀疑这三条默认值,⛔ 别先怀疑自己的 `gap`/`width` 数值写错。 + +--- + +## P0-91 🔴 **"工作区" 与 "目标" 是两个维度** —— 计数、选择器、文案,混用必错 + +**症状**(同日,用户连问三句才问出来): +选择器写「全部(**3 个目标**)」,用户:「不对吧,本机不是只有一个目标吗」。 +**追问两层**:① 计数用的是 `projects.length` = **工作区条数**(3); +② 用户真实口径是「**下拉框是选工作区的,跟目标没关系**」—— 连"选目标"这个定位都是我加的。 + +**事实**(同一份 `/api/projects`): + +| 维度 | 数 | 说明 | +|---|---|---| +| **工作区**(装了 `.workbuddy/collab` 的目录) | **3** | `ai1net-dsh-server` / `vibe-product` / `agent-product` | +| **目标**(工作区里登记的那件事) | **2** | 前两个才有 `goal.json.title` | + +⇒ 「3」既不是"3 个目标"也不是错的 —— 它只是**工作区数**,被贴了"目标"的标签。 + +**🔴 两条口径**: + +1. **计数与文案必须标清维度**:说"工作区"时数目录,说"目标"时数 `has_goal` 为真的。 + ⛔ 别拿 `projects.length` 去填「N 个目标」(**这是"张冠李戴"型错误,看起来还挺自洽**)。 +2. **没登记目标的工作区 ⛔ 不许藏**(用户原话:「加载机制就可以显示」)。 + ⚠️ 我一度把它判成"空壳"、还提议从选择器里去掉 —— **被否**。 + 实况:它**装了机制只是没登记目标**(有 `collabd-state.json` + `_ensure.stamp`, + **只是没有 `goal.json`**)⇒ 它是**合法状态**,不是垃圾。 + ⇒ 同族「作用域⛔ 不许静默排除」:**列不出来**比**列出一个"未登记"**危险得多。 + +**✅ 呈现**:选项文字=「目录名(目标名)」;没有目标就只印目录名 +(⛔ 别印「(未命名 · xxx)」——用户原话「未命名 看不懂个是什么」; +改印 **「未登记目标 · 」**,直接点出缺什么 + 怎么补)。 + +**⚠️ 命名优先级**:`name = short or title` ⇒ 登记过 `short` 的区在界面上**一律显示短名** +(`vibe-product` 这种目录名)。用户:「显示的是目录名,应该是真正名称」。 +⇒ **`short` 是给文件名/路径用的短标识,⛔ 不是给人读的名字** ⇒ `title or short`。 + +> ⭐ **教训:凡是"张冠李戴"型错误(拿 A 维度的数去填 B 维度的标签),** +> **它不会报错、不会崩、看起来还挺自洽** —— 只有**用户对照现实**才看得出来。 +> ⇒ 报数字时**连维度一起报**("工作区 N 个,其中已登记目标 M 个"),把两个数都摆出来。 + +--- + +## P0-92 🔴 报「疑似是我刚才改坏的」之前,**先 diff 备份** —— 别让怀疑变成向用户上报的事实 + +**经过**(同日):用户说「显示的是目录名,应该是真正名称」。我一看,`vibe-product` 的分支名 +变成 `vibe-product` 了(此前显示的是目标短名 `本机协作` 那一套)⇒ **我立刻怀疑是自己刚才 +执行「收工」操作时改坏的**,并准备这么向用户报告。 + +**✅ 一 diff 就清楚了**:`goal.json` 的 `title` / `short` 在**收工前后逐字一致** +(对比 `goal.json.bak-setlife-20261006-0926`)⇒ 是**老口径**(`short or title`), +⛔ 与收工操作**毫无关系**。 + +**为什么值得单列一条**:差一点我就把"**我猜的**"当"**事实**"报上去了。 +在用户那边的效果是:「你自己改坏的吧」—— **一次未经核实的自责,比不说更糟**: +它会把排查方向**从真因上带偏**,而且**看起来还很坦诚**(所以不会被人拦下)。 + +**🔴 规矩**: +- 动过某个文件/某份状态 **之后**,任何"怎么变成这样了"的疑问 ⇒ **第一步 diff 备份** + (本轮正好有:动手前备了 `goal.json.bak-setlife-<时间戳>`)。 +- ⛔ **⛔ 不许把"疑似副作用"直接写成结论**。措辞必须落到 + 「**我核过了/我没核**」二选一:核过 ⇒ 报事实;没核 ⇒ **先核再说**。 +- ✅ 通则:**做变更前先留一份可比对的快照** —— 它的价值不在回滚,而在**事后能自证清白**。 + + + +## P0-93 「未发现」≠「不存在」—— 报缺失前先证明你的检测器能看见它 + +**2026-10-06 一轮里连撞三次,同一个病。** + +### 现场 + +一天之内,我三次向用户/自己报"东西缺了",三次都是**假红**: + +| # | 我报的 | 真相 | 我的判据错在哪 | +|---|---|---|---| +| 1 | 「`dsh-diagnose` 三个退役名**无独立详情档**,只在正文里被提到」 | 三档**都有**,且每档档头都写了 `本档覆盖:原技能 XXX 全文` + `原行段` + `逐行未改` | 检测判据=**文件名/目录名含退役名** ⇒ ⛔ 认不出「档头声明 provenance」这种落点 | +| 2 | 「`dsh-decision`/`dsh-workflow` 的清单表**没登记子目录档**」 | **都登记了**(`references/dsh-decision-method/` 那一行) | 正则只写 `references/xxx.md` ⇒ ⛔ 认不出目录行 `references/xxx/` | +| 3 | 「界面**四列全空**」(上一轮) | 界面好的,已完成列 **12 张卡** | 探针打了 `/api/board`(**404**)⇒ 拿到空 JSON 就当"数据是空的"。真端点=`/api/projects`,且卡片在 `projects[].cards`,⛔ 不在 `cols[].items` | + +### 为什么这个病特别毒 + +1. **它长得像"发现了问题"** —— 报告读起来是"我查得很细,发现 N 处缺失",**比说"没发现问题"更像在工作**; +2. **下一步动作是破坏性的** —— 你会去"修"那个不存在的缺失:给已经有好档的地方再补一份(**制造重复**)、 + 或者按错判去删/改(**制造事故**); +3. **它自我强化** —— 一旦你按假红动手,改完"验证"还是红的(因为判据本身就错), + 于是你会**继续加码修**,越修越乱。 + +### 根因(一句话) + +**判据写窄了。** 我写检查脚本时,脑子里只有**一种**落点形态(文件名、`.md` 文件、某个端点), +而实际系统的落点形态**不止一种**(档头声明、目录、`projects[].cards`)。 +⇒ **检测器的表达能力 < 系统的真实形态** ⇒ 看不见的就被报成"不存在"。 + +同族已知条:`dsh-local-env` 里记的「**判据写窄=假红**」、`dsh-diagnose` 的「**静默失败当默认假设**」。 + +### 规矩(动手前过一遍) + +1. 🔴🔴 **报"缺失"之前,先给检测器做一次「正例测试」** —— + 拿一个**你已知存在**的东西喂进去,看它**认不认**。 + · 测试 1:拿 `dsh-diagnose/references/00-服务器实例故障.md`(**确定有 provenance 头**) + 喂给"找独立档"的脚本 ⇒ 它报 ❌ ⇒ **判据就是坏的**,⛔ 不许把结果上抛。 + · 测试 2:在界面上**肉眼**看到 12 张卡之后,才允许说"某处为空"。 + **认不出的正例,比认得出的反例更有诊断价值。** +2. 🔴 **"我在 A 处没看到" ≠ "A 处没有"** —— 先问一句:**"它还可能在哪?"** + 同一件事的落点常有多种形态:文件名 / 目录名 / **档头声明** / 表格某列 / 另一个 API 字段。 +3. 🔴 **拿到"空结果"时,先证伪"我的取数姿势"** —— + 空 JSON、404、0 条,**优先怀疑端点/路径/键名写错**,而不是"对方是空的"。 + · 本轮实例:`curl /api/board` 返回空 ⇒ 正解是 **`grep 路由定义`** 找真端点,**⛔ 不是**下结论"没数据"。 +4. ⚠️ **报"我没发现"时,把判据一并写出来** —— 让用户能一眼看出你的判据窄不窄。 + ⛔ 禁止只写「未发现缺失」这种**无法证伪**的表述。 +5. ✅ **措辞三态**(不许含混): + · 「**我核了 X 处,判据是 Y,全部命中**」=有结论 + · 「**我核了 X 处,判据是 Y,有 N 处没命中**」=有发现 + · 「**我只核了 X,没核 Y**」=**边界声明**(⛔ 不许写成"没问题") + +### 一句话 + +> **报"缺了"的举证责任,比报"有"更重** —— 因为你接下来要动的手,是按这个结论去改。 + +--- + +## P0-94 钩子超时:先算「几条 × 冷启动」,别去改脚本 + +**事故形状(2026-10-06 实测)** + +`UserPromptSubmit` 一次报 4 条 hook `timed out after 10000ms`,看着像"这几个脚本卡住了"。 + +**先做的一件事:把每条单独计时。** 实测: + +| 跑法 | 耗时 | +|---|---| +| 任一条单跑 | 0.23–0.36 s | +| 4 条各单跑(累计) | 1.247 s | +| 7 条**并发** | 0.84 s | + +⇒ **单条都不慢,也没有并发争抢** ⇒ **不是脚本的问题**。 + +**真因**:同一事件上**串行挂了 7 条 hook**,每条都是**一个独立进程** +(`python.exe .py`)⇒ 宿主每轮**冷启 7 次解释器**。前 3 条预算 15+30+20 s, +后 4 条 `timeout=10` **被排队挤破**。 + +🔴 **判据**:`单条耗时 × 条数` 远小于超时值、但集群一起超时 ⇒ **问题在"条数 × 冷启动", +不在任何单个脚本**。⛔ 此时去优化某个脚本是白费(它本来只要 0.3 s)。 + +**✅ 正解:同一事件、同一 stdin 契约、fail-open、无副作用的守卫 ⇒ 合并成一个进程。** + +- 本包实现:`scripts/hooks/prompt-guards.py`(一次读 stdin,`runpy` 本进程内依次跑, + 临时接管 stdout 收 JSON 再合并)。实测 1.247 s → **0.357 s(3.5×)**,注入内容**逐字一致**。 +- ⛔ **不许合并**有**副作用**的(常驻确保 / 结果投递 / 决策桥)—— 合并会改语义。 +- 合并时**只能加信息、不能减判定**:`additionalContext` 拼接; + `decision`/`permissionDecision` 等冲突时**保留先到者并留痕**(⛔ 不静默丢阻止)。 + +**⚠️ 顺带两个坑** + +1. 🔴 **别被报错里的路径带偏**:本次有一条写 `E:/ProgramData.workbuddy/...`(**少一个点**), + 实际该目录**不存在** —— 它只是历史 trace 里的残影,⛔ 不是当前接线。 + 判据:**报错里的每个路径都先 `ls` 一下**,别直接顺着它去"修"。 +2. 🔴 **合并入口收 stdout 的坑**:`_Tee` 继承 `TextIOBase` 时若写 `self.buffer = self` + ⇒ guard 的 `sys.stdout.buffer.write(bytes)` **撞上 `TextIOBase.write`** + ⇒ bytes 被 `str()` 成 `b'{"..."}'` ⇒ **JSON 解析必失败**(现象:报"输出了非 JSON(6228 字节)")。 + ✅ `buffer` 必须是**独立的 `io.BytesIO`**,文本/字节两路**各收各的**。 + +**⚠️ 另一个附带教训**:手工接的线(⛔ 不在 `install.py::OWN_BASENAMES` 里的) +`--uninstall` **认不出** ⇒ 换包/重装后**同一件事挂两条钩子**。 +本次实测 `reply-style-guard.py` 就是漏网的 ⇒ 已补登记。 + +### 一句话 + +> **钩子超时,先算「几条 × 冷启动」,再决定动谁** —— +> 单条 0.3 s 却被判超时的,要治的是**条数**,不是那个脚本。 + +--- + +## P0-95 🔴🔴🔴 **「换了个看起来更对的调用」≠「判据变强了」—— 改判据后不重跑变异对照,会把假红换成恒绿**(★ 2026-10-06 当场被自己的变异对照抓住) + +### 现象(本轮三连) + +一天之内,`session-rules-check.py` 里查出**三个判据缺陷**,性质完全一样:**判错了**。 + +| 判据 | 错法 | 表象 | +|---|---|---| +| `hook_reg` | 按**旧文件名**找钩子 | 合并成 `prompt-guards.py` 后,**每轮报「关键钩子不在册」**(假红,惩罚的正是做过的合并) | +| `snap_sync` | 拿 **mtime** 当内容判据 | 权威 `CODEBUDDY.md` 被 touch ⇒ 报「快照比权威旧」;**实测 md5 逐字节相同、diff 0 行** ⇒ 连续 **4 天**假红 | +| `mem_ptr` | 只查**全局**技能根 | 工作区自带的技能(`product-planning`)被判「悬空」——判据问错了问题 | + +### 🔴🔴 本轮最该记住的一幕:**我修 `snap_sync` 时差点交付一个恒绿判据** + +- 第一版修法:把 mtime 比对**换成** `resident-rules.py --check` —— **看起来更对**(内容口径嘛)。 +- 按老规矩跑**变异对照**:把快照文件**截断到前 400 字节** ⇒ 期望报 fail。 +- 实测:**它报 `✅ 关键规则齐备`(rc=0)** ⇒ **判据恒绿**。 + ⚠️ 恒绿**比假红更坏**:假红至少还在喊,恒绿是**彻底哑掉**,且看起来更"先进"。 +- 真因:`--check` 校验的是「**目标 `CODEBUDDY.md` 里关键规则齐不齐**」, + **根本不含「与快照比对」这一步** —— 我没读它的实现就假设了语义。 +- ✅ 正解:**导入抽取器本体**(`importlib` 加载 `resident-rules.py`,把它的 `SNAP` 常量临时改指临时文件, + 调**它自己的** `snapshot()` 拿"应有内容"),再与磁盘真快照比对。 + ⛔ 不抄第二份抽取逻辑(两处 ⇒ 漂移);⛔ 不让它写真快照(体检不改资产)。 +- 修完四段复验:正常→ok|**变异→fail**|还原→ok|前提不成立(无 `CODEBUDDY.md`)→warn「本项不适用」。 + +### 🔴 同族第二个坑:**fail-open + 只看 `rc=0` 的体检 = 假绿温床** + +`stop-dialog-guard.py` 因 `session_budget()` **返回元组长度不一致** +(两条早退 `return None, None`=2 值,末尾 `return a, b, c`=3 值,调用方按 3 值解包) +⇒ transcript **> 64 MiB**(本会话实测 **192.8 MB**)时**每轮 `ValueError`**。 + +- 而它 **fail-open**(异常仍 `sys.exit(0)`)⇒ **宿主零报错**; +- `install.py --verify` 只判 `rc=0` ⇒ **判它 "ok"**; +- 实际死的是**整条钩子**:水位与收口 / 接续机制起点 / 预算告警 / 门禁自检 / 路径自检。 + +⇒ **判「钩子活着」,⛔ 不看 `rc`**,要看**它自己的日志尾部**: +有没有 `EXCEPTION`、有没有写出**完整的 `invoked(...)` 行**(崩在半路的只会写 `entry`)。 + +### 判据(下次改判据前照做) + +1. **改判据 ⇒ 必须重跑变异对照**:**真去破坏一次**(截断 / 改一字 / 删一节 / 造成前提不成立), + 看它**会不会报**。⛔ **"换了个看起来更对的调用" 不算验证。** +2. **先读实现再信语义**:调别人的脚本当判据前,**读它的实现**(或至少读它到底比什么); + ⛔ 别按函数名/文档一句话就假设它做了你想的那件事(本轮 `--check` 就是这么坑的)。 +3. **同一函数的所有 `return` 必须同长**(长**短不一致**是"防护性早退"最常见的漏)。 +4. **判据要问对问题**:`mem_ptr` 该问「**本机取不取得到这个名字**」(全局 ∪ 工作区), + ⛔ 不是「全局库目录里有没有」。 +5. **判据别拿"元数据"当"内容"**:mtime 只说明**谁最后被写过**,不说明**内容差没差**。 +6. **前提不成立时给 `warn` 并写明「本项不适用」**,⛔ 不给 `fail`(那会把合法状态判成故障), + ⛔ 也不给 `ok`(那是假绿)。 + +### 一句话 + +> **改判据的核心动作不是"换个更对的比法",是"再破坏一次看它会不会喊"。** +> 变异对照不做 ⇒ 你的"修复"很可能只是把**假红**换成了**恒绿**。 diff --git a/session-mechanism/scripts/board.py b/session-mechanism/scripts/board.py index 16e9679..b16229a 100644 --- a/session-mechanism/scripts/board.py +++ b/session-mechanism/scripts/board.py @@ -1,2207 +1,2267 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- -"""board.py —— 生成**看板快照** `board.json`(供 `assets/board.html` 实时渲染)。 - -设计原则: - · **只读**:只读台账/状态/投递台账/宿主库(三张只读表)。(组件探测属**使用方**,见 board_ext)⛔ 不写任何账本。 - · **零依赖**:只用标准库。⛔ 不引第三方。 - · **快**:一轮 < 0.1 s(socket 探测 0.3 s 超时上限;宿主库只读一条 SQL)。 -🔴 **总则:看板不能影响程序执行**(用户 2026-09-30 明令)。三条落地: - ① **解耦**:常驻程序/守护程序**一行都不引用本文件**(已核);本文件**从不写任何账本**。 - ② **异步**:`--serve` 由**后台线程**按 `--interval` 秒产快照,**请求线程只吐内存缓存** - ⇒ 请求路径 ⛔ 不碰 DB/⛔ 不读文件 ⇒ 开多少标签页都不增加宿主负载。 - ③ **降级不静默**:任何一块读不到 ⇒ 记进 `warn` 并在界面显示,⛔ 不伪装成"0 个会话"。 - -用法: - python board.py # 产一次 board.json(默认写到 inbox/board.json) - python board.py --out <路径> - python board.py --serve [端口] [--interval 秒] # 起本地只读看板(默认 8788 / 3 秒) - python board.py --serve 8788 --takeover # 显式"接管":先停旧实例再起(换新代码时用) - -🔴🔴 **单实例护栏**(2026-09-30 加,治「多实例打架」): - Windows 的 `SO_REUSEADDR`(`ThreadingHTTPServer` 默认 `allow_reuse_address=1`)允许**同端口重复绑定 - 且不报错** ⇒ 多个 `--serve` 会**静默并存**,同一个 URL 被不同进程随机应答 ⇒ 快照/代码版本互相打架 - (实拍:8788 已被占用时,第二个实例照样打印"看板已起")。 - ⇒ 现规则:**端口上已有本看板 ⇒ 默认拒绝启动**(提示复用现成那个);要换代码用 `--takeover`。 -""" -from __future__ import annotations -# [session-mechanism] roots.env 外置(由 install.py 生成;缺失则回落到按位置推导) -def _sm_load_roots(): - import os as _os - _here = _os.path.dirname(_os.path.abspath(__file__)) - for _up in range(4): - _p = _os.path.join(_here, *([".."] * _up), "roots.env") - _p = _os.path.normpath(_p) - if _os.path.isfile(_p): - try: - with open(_p, encoding="utf-8") as _f: - for _ln in _f: - _ln = _ln.strip() - if _ln and not _ln.startswith("#") and "=" in _ln: - _k, _v = _ln.split("=", 1) - _os.environ.setdefault(_k.strip(), _v.strip()) - except Exception: - pass - return -_sm_load_roots() -# 🔴 2026-10-01 加 · **输出编码兜底**:脚本一旦被重定向(钩子/常驻/后台任务都会这么干), -# Windows 本地编码(GBK)编不出 ⛔/✅/🔴 这类字符 ⇒ `print` 抛 UnicodeEncodeError -# ⇒ 被顶层 handler 记成 `fatal`、**整轮失败**(实测:本包里连续 4 次 `fatal 'gbk' codec ...`)。 -# ⇒ 出口一律 UTF-8 + errors="replace"(⛔ 不让"打不出字"升级成"程序死")。 -try: - import sys as _sys - _sys.stdout.reconfigure(encoding="utf-8", errors="replace") - _sys.stderr.reconfigure(encoding="utf-8", errors="replace") -except Exception: - pass - - -import glob -import json -import os -import re -import socket -import sqlite3 -import sys -import time -from pathlib import Path - -# 🔴🔴 2026-10-03 10:2x **命名判据与 `collabd.py` 共用同一实现**(不是抄一遍)。 -# 为什么:`_role_of_title()` ⇄ `parse_session_name()` 这两侧**已漂过三次** -# (唤醒/跟进/接续各一次,每次都要靠对账用例才发现)⇒ 这次从**结构**上断掉漂移的可能。 -# ⚠️ 走 `importlib` 按路径加载同目录 `collabd.py`:⛔ 不写进 `sys.path` -# (那会污染宿主进程能 import 到的名字空间);⛔ 也不复制判据(那就是第 N 次漂移的种子)。 -# ⚠️ 加载失败 ⇒ `_is_check_agent` 回落成"只认新名"的保守实现(⛔ 宁可少判、不可崩)。 -try: # pragma: no cover - 环境相关 - import importlib.util as _ilu - - _sp = _ilu.spec_from_file_location("_cb_shared", str(Path(__file__).resolve().parent / "collabd.py")) - _cb = _ilu.module_from_spec(_sp) - _sp.loader.exec_module(_cb) # ⛔ collabd 顶层不读配置(配置在函数里懒加载)⇒ 安全 - _is_check_agent = _cb.is_check_agent - # 🔴🔴 2026-10-04 21:5x **「已退役角色」闸也从同一模块取**(用户拍板「候选一」)。 - # 为什么从模块取而不是抄一份字面量:`_RETIRED_PFX` 是**两侧共用的唯一事实源**, - # 抄一份 ⇒ 立刻变成"第 N 次漂移"的种子(这两侧已漂过三次)。 - # ⚠️ 判据本身=**一级方括号是退役角色词**(`[跟进]`/`[唤醒]`),⛔ 不是"角色解析为空" - # —— 空串里还有真主会话(现役 `a80f300d`「复盘失败并避免重犯」就是无前缀的)。 - _is_retired_role_title = _cb.is_retired_role_title - # 🔴 2026-10-03 11:40x:`_pid_alive` 也**从同一模块取**(⛔ 同一模块名 `_cb_shared` - # 在本文件里被重复 spec 两次会拿到**两个独立模块对象** ⇒ ⛔ 判据会漂)。 - # ⇒ 这里统一取模块、再从它上面取函数。 - _cb_shared = _cb -except Exception: # pragma: no cover - def _is_check_agent(name: str) -> bool: - """⚠️ 兜底:只认**新名**(带方括号)—— ⛔ 宁可少判一条,不可让看板崩。""" - return any(t in str(name or "") for t in ("[结果检查]", "[目标检查]")) - - def _is_retired_role_title(name: str) -> bool: - """⚠️ 兜底(与 `collabd.py` 同判据的保守复刻):只认**一级方括号里的退役角色词**。 - ⛔ 只在 `collabd.py` 加载失败时才会用到这里 —— 宁可少判,不可让看板崩。""" - m = re.match(r"^\[([^\]]*)\]\s*", str(name or "").strip()) - return bool(m) and m.group(1).strip() in ("跟进", "唤醒") - -HERE = Path(__file__).resolve().parent -CFG_P = Path(os.environ.get("COLLABD_CONFIG") or (HERE / "collabd.config.json")) -WS = Path(os.environ.get("DSH_COLLAB_WS") or "").resolve() if os.environ.get("DSH_COLLAB_WS") else None - - -def _cfg() -> dict: - try: - return json.loads(CFG_P.read_text(encoding="utf-8")) - except Exception: - return {} - - -C = _cfg() -if WS is None: - WS = Path(str(C.get("workspace") or "")).resolve() -INBOX = WS / str(C.get("inbox") or "tmp/supervise-inbox") - - -def _j(p: Path, dv=None): - try: - return json.loads(p.read_text(encoding="utf-8")) - except Exception: - return dv - - -def _tail_jsonl(p: Path, n: int = 10) -> list: - try: - with open(p, "rb") as f: - f.seek(max(0, os.path.getsize(p) - 65536)) - ls = f.read().decode("utf-8", "replace").strip().splitlines() - out = [] - for ln in ls[-n:]: - try: - out.append(json.loads(ln)) - except Exception: - pass - return out - except Exception: - return [] - - -def _host_db() -> Path | None: - p = str(C.get("host_db") or "") - if p and os.path.isfile(p): - return Path(p) - d = os.environ.get("CODEBUDDY_CONFIG_DIR") or os.path.expanduser("~/.workbuddy") - q = Path(d) / "workbuddy.db" - return q if q.is_file() else None - - -def _main_sid(st: dict) -> str: - """主会话 sid。🔴 **与 `collabd.py::_main_sid()` 必须逐字同款**(判据只此一处权威: - ① 声明为 `main` 的角色 ② 退回最近一次投递到的会话 ③ 都取不到 ⇒ 空串,⛔ 不猜)。 - - 🔴🔴 2026-10-01 修(**两处同批改**):回落**只认"上次投的是主会话"那一次** —— - `wake.target == "follow"` 说明 `wake.sessionId` 记的是**跟进会话**(投递目标已改指跟进会话) - ⇒ 直接**当没有回落**(返回空串)。⛔ 不改的话,投过一次之后**跟进会话会被读成主会话**。 - """ - for _sid, _r in (st.get("roles") or {}).items(): - if str(_r) == "main" and str(_sid).startswith(tuple("0123456789abcdef")): - return str(_sid) - _w = st.get("wake") or {} - if str(_w.get("target") or "") == "follow": - return "" - return str(_w.get("sessionId") or "") - - -def _same_ws(cwd: str, ws_root=None) -> bool: - """`cwd` 是否就是**本工作区**(斜杠/大小写无关)。⛔ 仅用于**主会话解析**,⛔ 不用于"会话归属本项目"。 - - 🔴🔴 2026-10-05 **加 `ws_root` 形参**(peer 格修复,见 `_scan_ws_mains()` docstring): - ⛔ 不传 ⇒ 仍比对本看板的 `WS`(**本区行为逐字不变**); - ✅ 传了 ⇒ 比对**那个工作区**的根 —— peer 格必须用它,否则对方主会话**永远解析不出**。 - """ - a = str(cwd or "").replace("\\", "/").rstrip("/").lower() - b = str(ws_root if ws_root is not None else WS).replace("\\", "/").rstrip("/").lower() - return bool(a) and a == b - - -# 🔴 主会话的**显式前缀**(用户 2026-09-30 定名:「主会话前缀 可以叫 主控」)。 -# ⚠️ 实测现役主会话写成 `主控 · 协作机制 · …` —— **中点分隔、无方括号**。 -MAIN_PREFIX = "主控" - - -def _role_of_title(name: str) -> str: - """标题 ⇒ 会话角色(`main`/`worker`/`waker`/`follow`/`""`)。 - - 🔴 **⛔ 与 `collabd.py::parse_session_name()` 必须逐条同款**(本文件此后是"两处同款"的第 2 处)。 - `selftest.py::看板与常驻程序:命名判据不许漂` 会**真的把两边的函数拉出来对账**,⛔ 不许一边改一边不改。 - - **四种形态**(判据只看标题:⛔ 不看 cwd 归属、⛔ 不看 status) - ① `[角色]-[类别]-<具体>`(合规) ② 一级前缀 `[协作]…` - ③ 🆕 **接续会话**:`[<类别>] 接续 · …` / `接续棒:…` ⇒ **worker** - ④ 🆕 **主控前缀**:`主控 · <类别> · …`(中点分隔、无方括号)⇒ **main** - - 🔴 为什么必须认 ③:接续会话是**会话自己建的新会话**,标题常**没有角色方括号**; - 旧判据只排 `[协作]` ⇒ 它会被当**主会话候选**(且标题里的 `[<类别>]` 又让它变成 - "该类别的主会话")⇒ 通知**投给它自己**(自指死结,2026-10-01 实测)。 - ⛔ 认不出 ⇒ 空串(⛔ 不猜、⛔ 不回落 cwd 推断 —— 架构 §2.3)。 - """ - nm = str(name or "").strip() - if not nm.startswith("["): - head = nm.split("·")[0].split(":")[0].split(":")[0].strip() - if head.startswith(MAIN_PREFIX): - return "main" - # 🔴🔴 2026-10-03 10:2x **接续棒与检查会话都判 worker**(⛔ 顺序有讲究:接续在前)。 - # 检查会话=**常驻程序**建、它自己派协作棒 ⇒ 是干活的那条。 - # ⚠️ 旧式光杆名(`结果检查-…`,10:1x 之前建的)会落到这里 ⇒ 之前返 `""` - # ⇒ 被 `_scan_ws_mains()` 收进**主会话候选** ⇒ 本文件 `_role_label()` 判据①命中 - # ⇒ 看板显示「主会话」(实测 `44b547d4`)⇒ 可能**投错窗口**。 - # ✅ 判据**直接复用 `collabd.py` 的实现**(`import` 同一个函数,不是抄一遍) - # —— 这两侧历史上已漂过三次 ⇒ 这次从结构上断掉漂移的可能。 - # 🔴 2026-10-03 11:1x `worker` ⇒ **`check`**(属常驻程序,⛔ 不是任务会话) - if _is_check_agent(nm): - return "check" - return "worker" if "接续" in nm else "" - _m0 = re.match(r"^\[([^\]]*)\]\s*(.*)$", nm) - r = _m0.group(1).strip() if _m0 else "" - # 🔴🔴 2026-10-02 23:4x **会话类别从四类收敛为两类**(用户:「按照新的逻辑整体修改」/ - # 「唤醒会话 跟进会话 和 上报程序 都去掉才对」/「上报机制也不需要了」)。 - # ⇒ 只认 `主` / `协作` 两键;`唤醒` / `跟进` **不再映射**。 - # ⛔ 历史 `sessions` 旧行(那些标题还在)**照旧显示在 `sessions` 列表里**, - # 但**不再是活类别** ⇒ 解析出 `""` ⇒ 不会被误收成主会话候选。 - # 🔴 **必须与 `collabd.py::parse_session_name()` 逐条同款** —— 见上文那行红线。 - # 🔴 2026-10-03 11:1x 新增 `检查` 键(⛔ 与 `collabd.py::parse_session_name()` 同款) - # 🔴🔴 2026-10-03 19:5x **「白名单」与「映射表」合并成一个 dict** —— - # 原来这里是 `if r in ("主","协作","检查"):` + 下一行一张映射表,**两处都要改**: - # 我只改了映射表 ⇒ 新前缀 `[任务会话]` 被白名单挡在门外 ⇒ 解析出 `""` - # ⇒ 看板认不出这条会话(**本轮实测当场发现**)。 - # 🔴 **判据纪律**:前缀表**只留一处**(dict 本身就是白名单),⛔ 不许再拆成"先判在不在、再查映射"。 - # ⚠️ 必须与 `collabd.py::parse_session_name()` 逐条同款(它用 `.get(r, "")` ⇒ 白名单即 dict 本身)。 - # 🔴🔴 2026-10-03 23:4x **「协作」→「执行」改名(用户定案:「**把协作会话 改为 执行会话,协作 改为 执行**」)** - # —— **沿用 19:2x 那次「显示层改名、机制层不动」的正确范式**,但**这次多留一个新前缀**: - # · 显示层:`worker` 标签 → **「执行会话」**;`main` → 「主会话」不变。 - # · 识别层:**新增 `执行` 前缀**(新排期用它),⛔ **`协作`/`任务会话` 两个旧前缀必须保留** - # (⚠️ 实测宿主库有 **53 条排期 + 45 条会话**标题带 `[协作]` ⇒ 删掉映射 - # ⇒ 它们全部解析成 `""` ⇒ **看板认不出、派活漏管**,且**不可逆**)。 - # 这正是 19:2x 注释里那条「改前缀会让现存历史会话认不出来」的教训。 - # · 机制内部标识 `worker` 一律不动(⛔ 它是代码里的角色 id,不是人看的词)。 - _PFX = {"主": "main", - "执行": "worker", # 🆕 新前缀(当前口径) - "协作": "worker", # ⛔ 旧前缀,保留兼容(库里有 53+45 条在用) - "任务会话": "worker", # ⛔ 旧前缀,保留兼容 - "检查": "check"} - if r in _PFX: - return _PFX[r] - # 🔴🔴 2026-10-01 修**第三处判据漂移**(实测扫出来的,不是推理): - # 原来只查 `_m0.group(2)`(方括号**后面**那段),于是 - # `[接续] 会话机制合并技能包(第 1 棒)` —— 方括号里是「接续」、后面那段里没有 - # ⇒ 本函数返回 **空**,而 `collabd.py::is_continuation()` 判据是 - # `"接续" in name`(**整串**)⇒ 返回 **worker**。 - # 后果(线上正发生):本函数返回空 ⇒ `_scan_ws_mains()` 的排除元组拦不住它 ⇒ - # 它被收进**主会话候选** ⇒ 看板的「主会话」位上坐着一条 **接续棒**(`3aa35bae`,355 分钟没动) - # —— 而它其实是**干活的棒**,正是 `is_continuation()` 注释里那个"自指死结"的同一族。 - # ⇒ 判据改成**整串**判:`"接续" in nm` —— - # 逐字同款 `collabd.py::is_continuation()`(⛔ 两处必须同款,`selftest.py` 有对账用例)。 - if "接续" in nm: - return "worker" - return "" - - -# 🔴 角色标签(**2026-10-02 两类口径**:① 主会话 ② 任务会话 —— 唤醒/跟进/上报整套退役)。 -# ⚠️ 与 `board.html` 的渲染判据**必须同款**(那边按角色挑格子)。 -# ⛔ `waker` / `follow` 两个键**刻意保留在表里**(⛔ 不删,删了旧会话渲成空标签更难读), -# 但**任何新会话都不会再被判成这两个角色**(见 `_role_of_title`)。 -# 🔴🔴 2026-10-03 11:1x **新增 `check`=「检查会话」**(用户纠正逐字: -# 「检查会话**不是协作会话**,**不应该出现在看板协作会话区域中**。 -# 检查会话属于**协作程序的会话**(本来也是协作程序创建),**可以放在协作程序框图中展示」)。 -# ⚠️ 之前 `collabd.py` 把它判成 `worker` ⇒ 混进任务会话那一排 ⇒ **正是用户指出的错误**。 -# ⛔ 不并入 `协作`(并进去前端就会画错地方)⇒ **独立一类**。 -_ROLE_LABEL = {"main": "主会话", - # 🔴🔴 2026-10-03 23:4x **显示层继续改名:「协作目标」→「执行会话」** - # (用户定案:「**把协作会话 改为 执行会话,协作 改为 执行**」; - # 19:2x 那次改的「协作目标」正是这条链上的中间态)。 - # ⚠️⚠️ **这个值同时是前端判据**(`board.html` 里 `role==='任务会话'` 那一排)⇒ - # **改它必须连前端一起改**,⛔ 只改一边 ⇒ 那一排会**整排空掉**(19:2x 已踩过)。 - # ⛔ **机制内部标识一律不动**:`worker` 角色 id、`[协作]`/`[执行]` 前缀的**识别映射** - # 全部保持 —— 前缀映射见上面 `_PFX`(旧名兼容不能删,否则存量失管)。 - "worker": "任务会话", - "check": "检查会话", - "waker": "唤醒会话(已退役)", "follow": "跟进会话(已退役)"} - - -def _role_label(sid: str, title: str, sc: dict) -> str: - """会话角色标签 —— **四类**(⛔ 不是"主会话 / 其它"两档)。 - - 🔴🔴 2026-10-01 修:原来这里是**二分**(`"主会话" if 登记/解析出的主会话 else "任务会话"`) - ⇒ **唤醒会话与第④类「队列上报的跟进会话」一律被标成「协作会话」** ⇒ - `board.html` 第三层(按 `role` 挑格子)会把它们**误画进"任务会话"那一排**: - · 唤醒会话会**画两遍**(一次在主会话左侧的触发源位、一次在这排里); - · 跟进会话则**顶着一个错名字**(读者以为它是任务会话 ⇒ 分不清"谁在干活")。 - ⚠️ 这个缺陷**一直存在但看不见** —— 直到真的建出这两类会话才会显形。 - - 判据优先级(⛔ 只看标题与主会话登记,⛔ 不回落 cwd —— 架构 §2.3): - ① 命中主会话登记/解析结果(`main_sid` / `main_sids`)⇒ **主会话**; - ② 否则按标题解析(`_role_of_title()`)⇒ 唤醒会话/跟进会话/任务会话; - ③ 解析不出(老式命名、接续棒)⇒ 回落**协作会话** —— 它们本来就是干活的棒,旧行为逐字不变。 - """ - if sid == sc.get("main_sid") or sid in (sc.get("main_sids") or []): - return "主会话" - # 🔴 2026-10-03 19:2x **回落值跟着角色表走**(⛔ 原来写死 `"任务会话"`;改名后它会与 - # `_ROLE_LABEL["worker"]` 不一致 ⇒ 同一个人被标成两个词)。 - return _ROLE_LABEL.get(_role_of_title(title), _ROLE_LABEL["worker"]) - - -def _scan_ws_mains(st: dict, topics=None, ws_root=None) -> dict: - """🔴 扫一遍宿主库,挑出**本工作区里所有"可能是主会话"的会话**(按最近活动倒序)。 - - 🔴🔴🔴 2026-10-05 **加 `ws_root` 形参**(用户报障逐字:「**peer 工作区的主会话还是找不到对应的吗**」)。 - - **根因**:本函数(及 `_resolve_main()`)**只认本看板的 `WS`**,而 peer 格(跨工作区 tab) - 渲染的是**别的**工作区 —— 它的会话 `cwd` 指向**对方**目录 - (实测:peer 主会话的 `cwd` = `<对方工作区根>`,而本看板 `WS` = `<本工作区根>`) - ⇒ `_same_ws()` 恒 `False` ⇒ `cand/named/by_topic` **全空** - ⇒ 前端每个类别 chip 都画成「⚠ 无主会话」,而**那条主会话明明活着**。 - - ⚠️ **这是"改一处漏一处"的又一次**(本包已复发多次):`_sessions()` 早就为 peer 修过 - (走 `bypass=True` + `sc["workspace"] = ws_root`,见 `_goal_block()` 注释), - **但同一条数据流上的 `_scan_ws_mains()/_resolve_main()` 没跟上** ⇒ 会话列表对了、主会话仍空。 - - ⛔ 不传 `ws_root` ⇒ 比对本看板 `WS`(**本区行为逐字不变**); - ✅ 传了 ⇒ 只把**"哪个工作区"**换成那个根,**其余判据逐条不动** - (角色排除/接续会话/退休角色/类别取自标题 —— 全部照旧)。 - - 🔴 与 `collabd.py::_scan_mains()` **同款判据**(⛔ **改一处必须改两处** —— 漂了就没人能发现主会话换了): - · 只看 `cwd == 目标工作区`(⛔ 不回落其他推断) - · ⛔ **排除全部"协作侧"命名**:`[协作]-…`/`[唤醒]-…`/🆕 `[跟进]-…`/**接续会话** - (`[<类别>] 接续 · …`)—— 四者都是**干活的棒**,⛔ 不是主会话候选 - (🔴 2026-10-01 两次扩:先只排 `[协作]` ⇒ 接续棒冒充主会话;再到**按角色排** + 补第④类 `follow`) - · 角色判据见 `_role_of_title()`(⛔ 与 `collabd.py::parse_session_name()` **逐条同款**) - · ⚠️ **不按 `status='working'` 筛**(主会话两轮之间是空闲 ⇒ 筛了就永远漏掉它) - · 类别取自**标题里出现的 `goal.topics` 项**(最长优先,见 `_topic_in_title()`) - - @returns `{"cand": [sid…], "named": [sid…], "by_topic": {类别: {"sid","source","explicit"}}}` - ⚠️ `by_topic` 的值是 **dict**(与 `collabd.py::_scan_mains()` **同款**)—— ⛔ 不是"类别→sid"的裸映射, - 否则调用方会把整个 dict 当成 sid 用(**实测踩到过**)。 - """ - out: dict = {"cand": [], "named": [], "by_topic": {}, "titles": {}} - db = _host_db() - if not db: - return out - # 🔴 `topics` 传入 ⇒ **按这个目标的类别认主会话**(多目标 tab 用); - # ⛔ 不传 ⇒ 回落活跃目标(`goal.json`)⇒ **单目标部署行为逐字不变**。 - tps = _as_list(topics) if topics is not None else _goal_topics() - try: - con = sqlite3.connect("file:%s?mode=ro" % str(db).replace("\\", "/"), uri=True, timeout=0.3) - try: - con.execute("pragma busy_timeout=300") - except Exception: - pass - rows = con.execute( - "select id,cwd,coalesce(custom_title,title,'') from sessions " - "where deleted_at is null order by last_activity_at desc limit 50").fetchall() - con.close() - for r in rows: - t = str(r[2] or "") - # 🔴 2026-10-01 改:从"只排 `[协作]`"改成"**排掉全部"协作侧"命名**"(接续会话 + 唤醒会话), - # 同日再补**第④类 `[跟进]`** —— ⛔ 与 `collabd.py::_scan_mains()` **逐条同款**, - # 理由见那边注释(接续棒/跟进棒冒充主会话=自指死结)。 - # 🔴 2026-10-02 两类改造:排除元组收到 `("worker",)` —— 同 `collabd.py` 那行。 - if not (_same_ws(str(r[1] or ""), ws_root) and _role_of_title(t) not in ("worker",) - and not _is_retired_role_title(t)): - continue - sid = str(r[0] or "") - out["titles"][sid] = t # 🔴 2026-10-05:顺手带回标题(供默认类别兜底判"属不属于别类") - out["cand"].append(sid) - ts = t.strip() - _ex = ts.startswith("主控") or ts.startswith("[主控]") - if _ex: - out["named"].append(sid) - _tp = _topic_in_title(t, tps) - if _tp: - _cur = out["by_topic"].get(_tp) - # 同一类别多条候选 ⇒ ① 显式「主控」优先 ② 否则取**最近活动**(首见即最近) - if _cur is None or (_ex and not _cur.get("explicit")): - out["by_topic"][_tp] = { - "sid": sid, "source": ("prefix:主控" if _ex else "topic"), "explicit": _ex} - except Exception: - pass - return out - - -def _resolve_main(st: dict, topics=None, ws_root=None) -> dict: - """🔴 主会话**解析**(⛔ 不只是读登记)—— **与 `collabd.py::resolve_main()` 必须同款**。 - - 2026-09-30 用户实测报障:「**还是没有把这个会话识别为主会话**」。 - 根因:board.py 当时只走**旧的静态判据** `_main_sid()`(① 登记为 main ② **退回"上次投给谁"**), - 而投递侧 `collabd.py` 早已改成 `resolve_main()` 的**动态解析** ⇒ **两边不同源**: - 主会话一换(或旧主会话停了),投递会跟随、**看板却继续指着旧会话**, - 并且因为 `in_project()` 的①号判据就是 `main_sid` ⇒ **正在用的那个会话连列都进不来**。 - ⇒ 本函数把「按**工作区**认」补上,判据与 collabd 逐条对齐: - ① 本工作区里**显式标了 `主控`** 的 ⇒ 认(用户自己标的,比"最近活动"可信) - ② 否则本工作区里**标题不带 `[协作]`** 且**最近活动**的那条 - ③ 都取不到 ⇒ 退回登记 `_main_sid()`;再没有 ⇒ `""`(⛔ 不猜) - ⚠️ 用户口径(2026-09-30):「以**一个工作区**为主会话的工作区」「下面**应该都可能是主会话**」。 - ⛔ 这与 §2.3「不回落 cwd 推断**项目归属**」不冲突 —— 那条管的是"会话属于哪个需求项目", - 本函数管的是"这条工作区里**哪条是主会话**",是**另一件事**。 - - 🔴🔴 2026-10-05 **加 `ws_root` 形参**(peer 格修复):⛔ 不传 ⇒ 走本看板 `WS`(**本区逐字不变**); - ✅ 传了 ⇒ 主会话候选换成**那个工作区**的会话(否则 peer 格永远是 0 条主会话,见 `_scan_ws_mains()`)。 - ⚠️ **注意 `reg`(登记值)的取向**:peer 格传入的 `st` 来自**对方的** - `collabd-state.json`(`_peer_state()`)⇒ 那份登记值本就是对方的 ⇒ 两边一致,⛔ 不会串台。 - """ - reg = _main_sid(st) - sc = _scan_ws_mains(st, topics, ws_root) - named, cand = sc["named"], sc["cand"] - pick = named[0] if named else (cand[0] if cand else "") - # 🔴🔴 2026-10-05 修 **「主会话在,看板却显示无主会话」**(用户报障逐字: - # 「**现在goal创建成功了 还是没有显示主会话**」)。 - # - # 真因=**同一个事实两套实现**(`collabd.py` 有、`board.py` 没有): - # `by_topic` 只在「**该会话标题里含类别名**」时才被填(`_topic_in_title()`)。 - # 而**建目标的那条会话**(=天然的主会话)标题往往是**口语式、不带类别** —— - # 实测本工作区:主会话标题「排查任务不执行的原因」,`topics=[会话协作自检, 机制排查与修复]` - # ⇒ `by_topic = {}` ⇒ 每个类别 chip 都画成「⚠️ 无主会话」, - # 而**同一个快照里 `main_sid8` 明明就是 `a80f300d`** ⇒ 自相矛盾。 - # - # 投递侧早就定过这条规则(`collabd.py::resolve_mains()` docstring ②,用户 2026-09-30): - # > 某类别没扫到 且 它就是**默认类别**(`topics[0]`)⇒ 回落 `default` - # > (这样单类别时的行为与旧版完全一致,向后兼容) - # > 不是默认类别 ⇒ **sid 留空**(⛔ 绝不回落到别类别 —— 那正是"投错窗口") - # ⇒ 看板侧**漏了同一段兜底** ⇒ 两边对同一份数据给出两套答案。本函数补齐。 - # 🔴 判据:**只兜底默认类别 `topics[0]`,且只在"该主会话确实不属于别的类别"时兜** - # —— ⛔ 不许把主会话安到它没标的**非默认**类别上(那会把看板变成"每个类别都有主会话"的假象)。 - _bt = dict(sc["by_topic"]) - _tps = list(_as_list(topics) if topics is not None else _goal_topics()) - if pick and _tps: - _dft_tp = _tps[0] - if not str((_bt.get(_dft_tp) or {}).get("sid") or ""): - # 该主会话标题若**明确标了别的类别** ⇒ 不兜(避免张冠李戴) - _own_tp = _topic_in_title(str(sc.get("titles", {}).get(pick) or ""), _tps) - if _own_tp in ("", _dft_tp): - _bt[_dft_tp] = {"sid": pick, "source": ("prefix:主控" if named else "workspace"), - "explicit": bool(named)} - # 🔴 `all_sids` = **解析出来的那些**(默认 + 登记 + 各任务类别),⛔ **不是**"本工作区所有会话" —— - # 只有解析出来的才算主会话,否则"本工作区随便开个无关会话"会被当成本项目(判据被无声放宽)。 - _all: list = [] - for _s in ([pick, reg] + [str((_v or {}).get("sid") or "") for _v in _bt.values()]): - if _s and _s not in _all: - _all.append(_s) - out: dict = {"all_sids": _all, "by_topic": _bt} - if pick: - out.update({"sid": pick, - "source": ("prefix:主控" if named else "workspace"), - "switched_from": (reg if (reg and reg != pick) else "")}) - return out - out.update({"sid": reg, "source": ("register" if reg else "no-register"), "switched_from": ""}) - return out - - -def project_scope(g: dict | None = None, ws_root=None) -> dict: - """🔴 **本需求目标项目的身份 + 归属判据**(用户 2026-09-30 明示两件事: - ① 「**要明确哪些会话是属于某个需求目标项目的**」 - ② 「**这个看板上应该明确显示是哪个需求目标项目**」)。 - - 🔴 **判据与 `collabd.py::_in_project()` 同款** ⇒ 看板列的"在跑会话" ≡ `--ready-next` 认的"在跑会话" - (⛔ 否则两边各说各话:看板显示没人跑、收尾确认却被拦)。 - ⛔ **刻意不用 cwd 推断** —— 架构 §2.3 明令「绝不回落到 cwd 推断」。 - - 🔴 2026-10-01 多目标:可选传入 `g`(某个目标)⇒ **按这个目标的类别算归属**(tab 用)。 - ⛔ 不传 ⇒ 回落活跃目标 `goal.json` ⇒ **单目标部署行为逐字不变**。 - - 🔴🔴 2026-10-05 加 `ws_root`(peer 格修复): - · `ws_root` 传空 ⇒ `st` 取**本区**收件箱、主会话按**本区** `WS` 解析(**本区逐字不变**); - · `ws_root` 传了 ⇒ `st` 取**对方**的 `collabd-state.json`、主会话按**对方**根解析 - (⛔ 否则 peer 格永远「无主会话」,而那条主会话明明活着 —— 用户报障逐字: - 「**peer 工作区的主会话还是找不到对应的吗**」)。 - ⚠️ **`workspace` 键仍必须回本区 `WS`**:它被 `in_project()` 当"本看板自己的归属依据"用, - 改它会连带把**会话归属**判错(那是另一条判据,⛔ 不许在本函数里顺手改)。 - """ - g = _goal() if g is None else g - if ws_root: - # peer 格:状态取对方的(⛔ 不是本区 —— 否则登记值串台) - st = _peer_state(str(ws_root)) or {} - else: - st = _j(INBOX / "collabd-state.json", {}) or {} - # 🔴 2026-09-30:主会话**解析**(⛔ 不再是"读登记/上次投给谁"那种静态值)—— - # 与 `collabd.py::resolve_main()` 同源,投递侧跟随谁、看板就显示谁。 - _rm = _resolve_main(st, _goal_topics(g), ws_root) - msid = str(_rm.get("sid") or "") - msids = list(_rm.get("all_sids") or []) # 🔴 多任务类别 ⇒ 本工作区可能有多条主会话 - short = str(g.get("short") or "") - topics = _goal_topics(g) # 🔴 任务类别清单(缺省回落 [short]) - gid = str(g.get("id") or "") - crit = [] # 给人读的判据清单(看板上原样展示) - if msid: - crit.append("主会话 %s(%s)" % (msid[:8], _rm.get("source") or "?")) - if len(msids) > 1: - crit.append("本工作区另有主会话 %d 条(共 %d)" % (len(msids) - 1, len(msids))) - for _t in topics: - crit.append("标题含 [%s]" % _t) - if gid: - crit.append("显式声明 --declare --goal %s" % gid) - return {"goal_id": gid, "short": short, "topics": topics, "title": str(g.get("title") or ""), - "topics_source": _goal_topics_source(g), # 🔴 类别来源(declared/fallback/none) - "main_sid": msid, "main_sids": msids, "workspace": str(WS), "criteria": crit, - # 🆕 每个**任务类别**各自的主会话(`{类别: {sid,source,explicit}}`)—— - # 看板"按类别"渲染要用它(用户:「通过协作会话名称前缀区分具体执行会话」) - "main_by_topic": dict(_rm.get("by_topic") or {})} - - -def in_project(sid: str, title: str, sc: dict) -> bool: - """🔴 **一个会话是否属于本项目**。⛔ **与 `collabd.py::_in_project()` 同款 —— 改一处必须改两处**。 - - 🔴 2026-09-30 **泛化**:归属判据的"标题含 `[<主题>]`"从**单个** `goal.short` 扩到 - **`goal.topics` 清单** —— 用户要求「**同一个工作区**多会话协作(主会话根据任务**自动梳理任务类别**: - **通过协作会话名称前缀的方式区分具体执行会话**)」⇒ 同一工作区里**多个任务类别**的会话 - 都必须被认成"本项目"(⛔ 否则除一个之外**静默漏管**:看板不列、收尾确认也不把它们算进来)。 - ⛔ `sc` 里没有 `topics` ⇒ **回落 `short`**(旧调用点/自测用例原样可用)。 - ⛔ `sc` 里没有 `main_sids` ⇒ 只看 `main_sid`。 - - 🔴 2026-10-01 **判据放宽为三条并列**(详见函数体内的长注释): - ① 主会话 sid 命中 - ② **角色可解析 + 标题出现任一类别**(子串,⛔ 不要求方括号)← 🆕 治「看板没显示协作会话」 - ③ 标题含 `[<任一类别>]`(老写法,向后兼容) - """ - if sid and (sid == sc.get("main_sid") or sid in (sc.get("main_sids") or [])): - return True - ti = str(title or "") - _tps = sc.get("topics") or ([sc["short"]] if sc.get("short") else []) - # 🔴🔴 2026-10-01 修(用户报「**看板没有正常显示协作会话**」)。 - # 真因:原来**只认带方括号**的写法(`"[%s]" % 类别 in 标题`),而**实际派活出来的棒名是** - # `[协作]-机制排查与修复-常驻投递容器`(第 2 级**没带方括号**) - # ⇒ 判 `False` ⇒ 它落到 `sessions_unrecognized` ⇒ `_sessions()` 的 `mine` 里没有它 - # ⇒ `board.html` 第三层(按 `role==='任务会话'` 挑格子)**画空框「暂无协作会话」**, - # 而那条会话**正在 working**。同族红线:**读到了却不说**(更糟:说反了)。 - # ⚠️ 而且这和 `_topic_in_title()` **自相矛盾** —— 那个函数注释白纸黑字写着 - # 「判据是**子串**(不要求 `[...]` 包裹)…**两种写法都要认**」, - # 于是**同一个标题,一个函数说命中、另一个说不命中**(判据打架,2026-10-01 实测)。 - # ⇒ 判据统一成**两条并列**(任一命中即算): - # ② **角色可解析**(`_role_of_title()` 非空)**且** 标题里出现任一类别(`_topic_in_title()` 子串) - # —— 这是**放宽**,⛔ 不是放弃精度:加这道"角色前缀闸"就是为了⛔ 不让 - # "正文里恰好提到类别名的无关会话"混进来(那种标题解析不出角色)。 - # ③ 老写法带方括号 `[<类别>]` **原样保留**(向后兼容:跨工作区时代与 `[<类别>] 接续 · …`)。 - # 🔴 **⛔ 与 `collabd.py::_in_project()` 必须逐条同款**(`selftest.py` 会真的把两边拉出来对账)。 - if _topic_in_title(ti, _tps) and _role_of_title(ti): - return True - # 🔴🔴 2026-10-03 10:2x **检查会话无条件归本项目**(用户报障连带查出的第二个缺口)。 - # 现象:`44b547d4`「结果检查-…」修完 role 后**从「主会话」列表移出了**, - # 却落进 `sessions_unrecognized` ⇒ 任务会话那一排**仍然画不出它**。 - # 根因:上面判据②要求「角色可解析 **且** 标题含 `goal.topics` 里的类别」, - # 而 `topics` 现网**未声明**(`None` ⇒ 回落 `short`=「本机协作」), - # `结果检查`/`目标检查` **不在里面** ⇒ 判 False。 - # 🔴 **为什么该无条件**:检查会话是**常驻程序自己建的**(`maybe_spawn_check_agent()`), - # 它**服务于本工作区的目标**,⛔ 不该因为"用户还没声明任务类别"而在看板上消失 - # —— 那正是 2026-10-01「创建了协作会话但看板没展示」同族。 - # ⚠️ 这是**第三条判据**(①②③ 之外),⛔ 不改①②③ ⇒ 老写法全部原样保留。 - # ⚠️ 与 `collabd.py::_in_project()` **必须同款**(改一处漏另一处已吃过三次)⇒ 已在对账用例里。 - if _is_check_agent(ti) and _same_ws_cwd_for_check(sc): - return True - for _t in _tps: - if _t and ("[%s]" % _t) in ti: - return True - return False - - -def _same_ws_cwd_for_check(sc: dict) -> bool: - """🔴 检查会话**是否属于本工作区**(只按 `sc["workspace"]` 这一条真痕迹)。 - - 🔴 为什么单独一个函数:`_in_project()` 的入参里**没有 cwd**(只有 `sid`/`title`/`sc`), - ⛔ 而"别的线也在建检查会话"是可能的(同一台机多工作区)⇒ **不能只凭标题就认领**。 - ⇒ 判据=`sc["workspace"]` 存在(它由 `board.py` 从**部署配置**读出,代表"本看板自己的工作区") - + 调用方已确认这条会话 `cwd` 命中本工作区(见 `_sessions()` 里的 `_ct == _wstail` 那道)。 - """ - return bool(str(sc.get("workspace") or "").strip()) - - -def _goal() -> dict: - return _j(INBOX / "goal.json", {}) or {} - - -# 🔴🔴 2026-10-01 立 · **会话退场**(治「接续棒越堆越多」)—— 看板侧。 -# 病因与设计见 `collabd.py` 那段 `RETIRED` 长注释(权威在那边,本处是**第 2 处消费者**)。 -# ⛔ **本文件只读不写**:写退场由 `collabd.py --retire self` 负责(一个状态只有一个写者)。 -def _retired() -> dict: - """读退场表(`/retired.json`)。读不到/坏了 ⇒ 空表(⛔ 不让它把看板搞挂)。""" - try: - d = _j(INBOX / "retired.json", {}) or {} - return d if isinstance(d, dict) else {} - except Exception: - return {} - - -def _retired_hit(sid: str, table: dict) -> bool: - """这条会话是否已退场。⚠️ 比对**前 8 位**(看板各处只存 8 位短 id)。""" - k = str(sid or "").strip()[:8].lower() - if not k: - return False - for s in table: - if str(s)[:8].lower() == k: - return True - return False - - -# 会话在版面上算不算"活的"(分钟):`completed` 且距上次活动超过它 ⇒ 视同退场。 -# 🔴 与 `collabd.py` 用**同一个配置键** `session_live_min`(⛔ 否则两个渲染器口径又会打架)。 -LIVE_MIN = float(C.get("session_live_min") or 90) - - -GOALS_DIR = INBOX / "goals" # 🔴 多目标:一个文件=一个目标(字段与 goal.json 同款) - - -def _peer_workspaces() -> list: - """🔴 要**并列查看**的其它工作区(只读),来自部署配置 `peer_workspaces`。 - - 🔴 2026-10-03 15:44x 用户报障:「tab 不能切换看另外两个工作区的目标」 - ⇒ 看板原本只认**一个** `INBOX`(=本工作区)⇒ 天生看不见别的目录。 - ⚠️ 配置写法:`"peer_workspaces": ["E:/ProgramData/AIProject/会话协作测试1", …]`(**正斜杠**)。 - 🔴 **严格只读** —— 这条边界必须守住: - · ⛔ 不写对方任何文件;⛔ 不起对方的常驻/看板;⛔ 不改对方的目标状态; - · 只把对方的 `goal.json` **读进来当作一个 tab**。 - ⚠️ 没有这个键/值非法 ⇒ 返回 `[]` ⇒ **行为与旧版逐字一致**(向后兼容)。 - """ - try: - v = (C or {}).get("peer_workspaces") or [] - except Exception: - return [] - if isinstance(v, str): - v = [v] - out = [] - for x in v or []: - s = str(x or "").strip().replace("\\", "/") - if not s or s == str(WS or "").replace("\\", "/"): - continue # ⛔ 本工作区不重复列(它已是 active 那格) - out.append(s) - return out - - -def goal_files() -> list: - """🔴 **所有目标文件**(活跃的排第一):`goal.json` + `goals/*.json`。 - - 用户 2026-10-01:「把协作实时看板改为 **tab 支持多个目标**执行协作状态展示」 - ⇒ 数据模型(我的决定,可推翻): - · `INBOX/goal.json` = **当前活跃目标** —— 唯一权威,`collabd.py` 只认它 ⇒ ⛔ 零改动; - · `INBOX/goals/.json` = **其余目标**(同一套字段:`id`/`title`/`short`/`topics`/ - `acceptance_state`/`run`); - · 看板把两者合成一个**有序列表** ⇒ **一个 tab 一个目标**。 - - ⛔ 没有 `goals/` 目录 ⇒ 列表里只有 1 项 ⇒ **与旧版逐字一致**(向后兼容)。 - ⚠️ 去重键=`id`(缺 `id` ⇒ 用文件名)—— 同一个目标两处都有 ⇒ **以 `goal.json` 为准**。 - - @returns `[{"path","goal","active","key"}]`(⛔ 读不到任何目标 ⇒ `[]`) - """ - out: list = [] - seen: set = set() - - def _add(p: Path, active: bool, ws_name: str = "", ws_root: str = "") -> None: - g = _j(p, {}) or {} - if not g: - return - k = str(g.get("id") or "").strip() or p.stem - if ws_name: - k = "%s/%s" % (ws_name, k) # ⛔ 去重键带工作区前缀 ⇒ 同名目标不互相顶掉 - if k in seen: - return - seen.add(k) - rec = {"path": str(p), "goal": g, "active": active, "key": k} - if ws_name: - # 🔴 标出"来自哪个工作区" ⇒ 前端能显示、也**不会被误当成本区活跃目标** - rec["peer"] = ws_name - rec["ws_root"] = ws_root - out.append(rec) - - _add(INBOX / "goal.json", True) - try: - if GOALS_DIR.is_dir(): - for p in sorted(GOALS_DIR.glob("*.json")): - _add(p, False) - except Exception: - pass - # 🔴🔴 2026-10-03 15:44x **新增:并列查看其它工作区的目标**(用户报障)。 - # ⚠️ **只读**:⛔ 不写对方文件、⛔ 不起对方进程、⛔ 不改对方状态。 - # ⇒ 活跃目标仍然只有本工作区的 `goal.json` ⇒ `collabd.py` 行为**零改动**。 - # 🔴🔴 2026-10-04 21:0x **新增:已经在会话列表里消失的工作区 ⇒ 不再占 tab**。 - # 用户报障逐字:「tab 要把已经不再 会话列表的工作区 目标移除,不然都放不下了」。 - # 🔴 **判据(唯一)**:宿主库 `sessions` 表里该 `cwd` 的**未删会话数**(`deleted_at is null or 0`) - # = 0 ⇒ 这个工作区在会话列表里**已经不存在了** ⇒ ⛔ 不列 tab。 - # 🔴 **为什么用"未删会话数"而不是"心跳新不新鲜"**:心跳只说明那个区的**常驻进程**在不在, - # 与"这个工作区还有没有人在用(会话列表里还有没有它)"**是两件事** —— - # 实测反例:会话协作测试2/3 的常驻挂在别的机器上、心跳早停,但其**未删会话为 0** - # (用户把会话全删了)⇒ 该工作区确实已从列表里退场;而 vibe-product 心跳 1.5 小时 - # 但未删会话 5 条 ⇒ **还在用** ⇒ 必须留。⇒ 判据只能是会话数,⛔ 不是心跳。 - # ⚠️ **严格只读、零删除**:只影响"要不要把这一格画出来", - # ⛔ 不动对方任何文件、⛔ 不从 `peer_workspaces` 里替用户删配置(那是用户的配置)。 - # ⚠️ 读不到库 ⇒ **保留**(fail-open:宁可多一格,⛔ 不因读数失败就把别人的格子吞掉)。 - for _pw in _peer_workspaces(): - try: - _root = Path(_pw).resolve() - if not _root.is_dir(): - continue - _g = _root / "tmp" / "supervise-inbox" / "goal.json" - if not _g.is_file(): - continue - try: - if not _peer_session_rows(str(_root)): - continue # 未删会话 0 条 ⇒ 该工作区已不在会话列表里 ⇒ 跳过(不占 tab) - except Exception: - pass # ⚠️ 读数失败 ⇒ 保留(fail-open) - _add(_g, False, _root.name, str(_root)) - except Exception: - continue - return out - - -# ── 🔴🔴 2026-10-03 16:1x **peer 格的取数**(⛔ 只用对方工作区自己的文件) ──────────── -# 背景(用户报障逐字):「**选择另一个工作区目标 tab 下面没有显示对应工作区目标和执行情况**」。 -# 🔴 实测真因(比"没显示"更糟 —— **在说假话**):`build()` 里的 `tasks`/`srows`/`st` -# **只取一次、所有格共用** ⇒ 切到 peer 格时,`labor`/`sessions`/`progress` 的 md5 -# **三格完全相同** ⇒ 显示的是**本工作区**的执行情况,却挂着对方工作区的名字。 -# ⇒ 修法:**peer 格一律只喂"那个工作区目录下的文件"**;读不到 ⇒ **空 + 如实说明**, -# ⛔ **绝不拿本工作区的数据补位**(同族红线:**读到了就要说**,⛔ 不许说假话)。 - -def _peer_inbox(ws_root: str) -> Path: - """对方工作区的 `INBOX`(同构约定 ⇒ `/tmp/supervise-inbox/`)。⛔ **只读**。""" - return Path(str(ws_root or "")) / "tmp" / "supervise-inbox" - - -def _peer_tasks(ws_root: str) -> dict: - """🔴 对方工作区的**台账**(只读它自己的 `tasks.json`)。读不到 ⇒ `{}`(⛔ 不拿本区的补)。""" - try: - return _j(_peer_inbox(ws_root) / "tasks.json", {}) or {} - except Exception: - return {} - - -def _peer_state(ws_root: str) -> dict: - """🔴 对方工作区常驻写的 `collabd-state.json`(只读)。读不到 ⇒ `{}`(⛔ 不拿本区的补)。""" - try: - return _j(_peer_inbox(ws_root) / "collabd-state.json", {}) or {} - except Exception: - return {} - - -def _peer_session_rows(ws_root: str) -> list: - """🔴 **单独补捞**对方工作区的会话(只读宿主库,⛔ 不写)。 - - ⚠️ 为什么不能只靠 `_session_rows()`:它取的是**全库最近 50 条**(按 `last_activity_at` 倒序) - ⇒ 对方工作区的会话**大概率一条都不在里面** ⇒ peer 格显示「0 条会话」=**假象** - (2026-10-03 实测:两个测试工作区的主会话明明 `working`,peer 格却 0 条)。 - ⛔ 也⛔ 不许为了捞它把全局 limit 放大 —— 那会让**本工作区**的 `others_running` 计数暴涨 - (`_sessions()` 拿全 rows 算"别的项目在跑几条"),等于为一个新功能改坏老行为。 - """ - db = _host_db() - if not db or not ws_root: - return [] - out = [] - try: - con = sqlite3.connect("file:%s?mode=ro" % str(db).replace("\\", "/"), uri=True, timeout=0.3) - con.row_factory = sqlite3.Row - try: - con.execute("pragma busy_timeout=300") - except Exception: - pass - # 🔴 库里 `cwd` 可能是反斜杠形态 ⇒ 用 `replace()` 归一成正斜杠再比(⛔ 别在 Python 侧比 ⇒ 要全表扫) - # 🔴 2026-10-03 17:0x **列必须与 `_session_rows()` 同款(7 列)** —— - # 少选 `status`/`custom_title`/`updated_at` ⇒ `_sessions()` 按键取值时抛 - # `No item with that key` ⇒ 被兜底成「库不可读」+空列表 - # ⇒ **peer 格永远 0 条**,而 `err` 里只有一句看不出来的"库不可读"(本轮实测踩到)。 - out = con.execute( - "select id,title,custom_title,status,cwd,updated_at,last_activity_at " - "from sessions where (deleted_at is null or deleted_at=0) " - "and replace(cwd,'\\','/')=? " - "order by updated_at desc limit 60", - (str(ws_root).replace("\\", "/").rstrip("/"),)).fetchall() - con.close() - except Exception: - return [] - return list(out or []) - - -def _peer_srows(srows, ws_root: str) -> list: - """🔴 对方工作区的会话行 = **已有 srows 里筛出的** + **单独补捞的**(按 `id` 去重)。 - - ⚠️ 用 `cwd` 判只在**这一处**合法:判的是「这条会话属于哪个**工作区**」(≡ `cwd` 的字面含义), - ⛔ **不是**架构 §2.3 禁止的那条「拿 `cwd` 推断会话属于哪个**需求项目**」。 - """ - b = str(ws_root or "").replace("\\", "/").rstrip("/").lower() - if not b: - return [] - seen: set = set() - out: list = [] - - def _push(r) -> None: - try: - _id = str(r["id"] or "") - except Exception: - return - if _id and _id in seen: - return - if _id: - seen.add(_id) - out.append(r) - - for r in srows or []: - try: - c = str(r["cwd"] or "") # `sqlite3.Row`(`_session_rows` 设了 row_factory) - except Exception: - continue - if c.replace("\\", "/").rstrip("/").lower() == b: - _push(r) - for r in _peer_session_rows(ws_root): - _push(r) - return out - - -def _peer_scope_note(ws_root: str) -> dict: - """🔴 peer 那格的**来源说明** —— 看板必须说得出「这格的数字哪来的、多旧」。 - - ⛔ 没有这份说明 ⇒ 读者会把"读不到"当成"对方没在跑",或把别的数字当成它的。 - """ - _ib = _peer_inbox(ws_root) - _hb = Path(str(ws_root or "")) / ".workbuddy" / "collab" / "logs" / "supervise-heartbeat.json" - _age = None - try: - _age = round((time.time() - _hb.stat().st_mtime) / 60.0, 1) - except Exception: - pass - return { - "read_only": True, - "tasks_present": (_ib / "tasks.json").is_file(), - "state_present": (_ib / "collabd-state.json").is_file(), - "heartbeat_age_min": _age, - } - - -def _peer_runtime_min(ws_root: str, n_check: int) -> dict: - """🔴 peer 格的**常驻程序实时状态**(只含架构图真正用到的那几个字段)。 - - ⚠️ **为什么不给 peer 直接复用 `_runtime()`**:那个函数内部**多处直接用本区 `INBOX`** - (`_tick.stamp`/`guard.log`/`guard.stop`…)⇒ 参数化要动一串路径,风险高; - 而这里**只需要"对方常驻在不在跑" + "对方有几次检查"** ⇒ 读**对方心跳**就够。 - ⚠️ 与 `_runtime()` 是**两份实现**(同族风险:同一条规则写两处必然漂)⇒ - **判据口径必须一致**:心跳 `ts` 距今 < 90 s = 在线(1.5 分钟),文案沿用 `_label_when_down()`。 - ⛔ 改 `_runtime()` 的在线判据时**必须**同步改这里。 - """ - hb = Path(str(ws_root or "")) / ".workbuddy" / "collab" / "logs" / "supervise-heartbeat.json" - age = pid = rnd = None - try: - d = _j(hb, {}) or {} - if d: - pid, rnd = d.get("pid"), d.get("round") - age = round((time.time() - float(d.get("ts") or 0)) / 60.0, 2) - except Exception: - pass - up = (age is not None) and (age <= 1.5) # 90 秒,与本区同口径 - return { - "prog": {"up": up, "age_min": age, "heartbeat_age_min": age, "heartbeat_pid": pid, - "by": (("常驻 --supervise(round %s)" % rnd) if rnd is not None else ""), - "label": ("在线" if up else _label_when_down(age, pid, age is None))}, - "check": {"n": n_check, - "label": ("无检查在跑" if not n_check else "%d 条检查在册" % n_check)}, - } - - -def _goal_topics(g: dict | None = None) -> list: - """🔴 **本需求下的"任务类别"清单**(`goal.json.topics`)。 - - 2026-09-30 用户要求:「要能支持**同一个工作区** 多会话协作(主会话根据任务**自动梳理任务类别**: - **通过协作会话名称前缀的方式区分具体执行会话**)」 - ⇒ **任务类别 ≡ 会话标题二级前缀 `[主题]` ≡ 台账条目的 `line` ≡ 分工板的一行**(同一个词,四处同义)。 - - ⛔ `topics` 缺省 ⇒ 回落 `[goal.short]` ⇒ **单类别时的行为与旧版逐字一致**(向后兼容)。 - 🔴 与 `collabd.py::_goal_topics()` **同款**(⛔ 改一处必须改两处)。 - """ - g = _goal() if g is None else g - out: list = [] - for _t in ((g or {}).get("topics") or []): - _t = str(_t or "").strip() - if _t and _t not in out: - out.append(_t) - if not out: - _s = str((g or {}).get("short") or "").strip() - if _s: - out.append(_s) - return out - - -def _goal_topics_source(g: dict | None = None) -> dict: - """🔴 **任务类别清单是"说明来的"还是"回落来的"** —— 看板必须说得出来源。 - - 用户 2026-10-01 订正:「**目标是 通过对话在调用 会话协作skill时说明的,不是固定的**」 - ⇒ `topics` 缺省回落 `short` 是**兼容**(单类别部署照旧),但**看板不许把"没声明过" - 显示成"就是这么定的"** —— 同族红线:**读到了却不说,或者没有却说有**。 - - 返回 `{"kind": "declared"|"fallback"|"none", "at": "...", "by": "...", "candidates": [...]}` - """ - g = _goal() if g is None else g - g = g or {} - raw = [str(x or "").strip() for x in (g.get("topics") or []) if str(x or "").strip()] - if raw: - kind = "declared" - elif str(g.get("short") or "").strip(): - kind = "fallback" - else: - kind = "none" - _cand = ((g.get("_topics候选") or {}) or {}).get("候选") if isinstance(g.get("_topics候选"), dict) else None - return {"kind": kind, - "at": str(g.get("topics_declared_at") or ""), - "by": str(g.get("topics_declared_by") or ""), - "candidates": _as_list(_cand), - # 🔴 目标本身**是不是**"在对话里说明过"(`declared_at` 由 `goalctl declare` 写) - "goal_declared_at": str(g.get("declared_at") or ""), - "goal_declared_by": str(g.get("declared_by") or "")} - - -def _as_list(v) -> list: - """把 `str | list | tuple | set` 归一成 `list[str]`(⛔ 空值 ⇒ `[]`)。""" - if v is None: - return [] - if isinstance(v, str): - return [v] if v.strip() else [] - try: - return [str(x) for x in v if str(x or "").strip()] - except Exception: - return [str(v)] - - -def _topic_in_title(title: str, topics=None) -> str: - """标题里出现的**任务类别**(🔴 **最长优先**);⛔ 都不命中 ⇒ 空串。 - - ⚠️ 判据是**子串**(不要求 `[...]` 包裹):主会话是口语式命名(「主控 · 唤醒机制」), - 协作棒是严格 `[协作]-<类别>-<具体>` —— 两种写法都要认。 - 🔴 与 `collabd.py::_topic_in_title()` **同款**(⛔ 改一处必须改两处)。 - """ - ti = str(title or "") - tps = _as_list(topics) if topics is not None else _goal_topics() - hit = "" - for _t in tps: - if _t and _t in ti and len(_t) > len(hit): - hit = _t - return hit - - -def _labor(tasks: dict, sess: list, goal: dict) -> list: - """🔴 **分工板**(用户 2026-09-30 两条明示: - ① 「协作会话**不是历史记录**,是**展示分工**的板块」 - ② 「每个分工板块可以**展示最近的协作任务**」)。 - - **分工位 = 线**(`goal.topics` ∪ `goal.lines` ∪ 台账里出现过的线)—— 线是本机制里真实存在的分工维度。 - 每格给四样:**最近的协作任务**/**当前承接会话**/**件汇总**/**状态色**。 - - 🔴 **三类"归属"别混(2026-09-30 修 · 用户报「桌面线有会话在跑、看板却说无协作任务」)**: - · **会话 → 本项目**:按 §2.3 的三级判据(主会话 sid / 标题含 `[任务类别]` / `--declare`), - ⛔ **绝不回落到 cwd**。这个判据在 `project_scope()` 里,本函数**收进来的 sess 已经过它筛**。 - · **会话 → 哪条线**(🆕 2026-09-30 多类别):**优先按任务类别归位** —— - 会话标题二级前缀 `[主题]` 命中的类别(`s["topic"]`)等于该线 ⇒ 归这条线。 - 🔴 这是"**同一个工作区**多会话协作"的正确判据:所有会话 cwd 相同,⛔ 按 cwd 分必然全塌成一行。 - · **会话 → 哪条线**(旧/兼容):**线也可能就是工作区名**(跨工作区时代的 `goal.lines`)⇒ - 用 `cwd_tail ∈ lines` 判线归属。⚠️ 两条判据**并列**(`topic == 线 ∨ cwd_tail == 线`), - 因为同一个部署里可能**同时**存在"按类别命名的新会话"和"按工作区命名的旧数据"。 - - 🔴 **修掉的两种误报**(旧版只看台账里的件 ⇒ 会漏): - ① 「(该线暂无协作任务)」:台账没件 **≠** 该线没活 —— 该线正在跑棒时这话是错的。 - ⇒ 台账无件时,**回落到"该线最近的一个会话"**当"最近的协作任务"。 - ② 「当前无会话在跑」:旧版只认"在跑会话标题里出现**台账件 id**" ⇒ - 棒在做台账里还没有的件(如 V6)时**匹配不到** ⇒ 明明在跑却说没人跑。 - ⇒ 承接判据改成:**topic == 线 ∨ cwd_tail == 线** ∨ 标题命中该线的台账件。 - 🔴 **状态色三值**:`busy`=该线有会话在跑|`gap`=**该线还有未完成的件却没人在跑**|`idle`=没活也没人跑。 - """ - lines: list = [] - # 🔴 2026-09-30 改:**任务类别(`goal.topics`)排在最前** —— 用户要求 - # 「**同一个工作区**多会话协作(主会话根据任务**自动梳理任务类别**:**通过协作会话名称前缀 - # 的方式区分具体执行会话**,所有主会话,协作会话,自动唤醒任务,都在**一个工作区**)」 - # ⇒ 分工板的"一 行"从**工作区**改成**任务类别**(⛔ 同工作区多类别时,按工作区分会全塌成一行)。 - # ⚠️ 旧的两路(`goal.lines` + 台账 `line`)**保留** ⇒ 跨工作区时代的既有部署照旧显示。 - for _tp in _goal_topics(goal): - if _tp and _tp not in lines: - lines.append(_tp) - for _ln in (goal.get("lines") or []): - _ln = str(_ln or "") - if _ln and _ln not in lines: - lines.append(_ln) - for _v in (tasks or {}).values(): - _ln = str((_v or {}).get("line") or "") - if _ln and _ln not in lines: - lines.append(_ln) - if not lines: - lines = ["(未标注线)"] - _tps = [t for t in _goal_topics(goal) if t] # 🔴 当前"任务类别"清单(`kind` 判据用它) - - _running = [s for s in (sess or []) if str(s.get("status")) == "working"] - - def _item_in_title(title: str) -> str: - for _tid in sorted((tasks or {}).keys(), key=len, reverse=True): - if _tid and re.search(r"(? _latest[0]: - _latest = (_ts, k, v) - # 承接会话:① 会话就在这条线的工作区里(cwd_tail == 线) ② 或标题命中该线的台账件 - holders = [] - for s in _running: - _ct = str(s.get("cwd_tail") or "") - _tp = str(s.get("topic") or "") - _tid = _item_in_title(s.get("title")) - # 🔴 2026-09-30:`_tp == ln` = **按任务类别归位**(新,同工作区多类别) - # `_ct == ln` = 按工作区归位(旧,向后兼容跨工作区时代的部署) - if _tp == ln or _ct == ln or (_tid and _tid in items): - holders.append({"id8": str(s.get("id8") or ""), - "what": (_tid or str(s.get("title") or ""))[:26]}) - - # 最近的协作任务:台账有件 ⇒ 用最新那件;**台账无件 ⇒ 回落到该线最近的会话** - # (⛔ 别因为"台账没件"就说"暂无协作任务" —— 该线可能正在跑一件还没上报的活) - latest = None - if _latest: - latest = {"item": _latest[1], "state": str(_latest[2].get("state") or "?"), - "by": str(_latest[2].get("by") or ""), - "age_min": round((_now - _latest[0]) / 60.0, 1)} - else: - # 🔴 2026-10-01 修:⛔ **别再排除 `role == "主会话"` 的会话**。 - # 原判据(`… and role != "主会话"`)本意是"最近在做的=承接会话,不含主会话", - # 但**该类只有主会话自己在跑**时会把候选整类判空 ⇒ 同一格里 ③ 说 - # 「(该类还没有记录,也无可归到它的会话)」、④ 却说「承接 <同一个会话>」 - # = **自相矛盾**(用户 2026-10-01 看到的正是这个画面,由此追问 - # 「唤醒机制跟主会话都一个 ID,难道是主会话?」)。 - # ⇒ 候选**不排除任何角色**,只把 `working` 排前 —— - # "正在跑的"就是"最近在做的",③④ 指向同一条会话时是**真相**,⛔ 不是重复。 - _cand = [s for s in (sess or []) - if (str(s.get("topic") or "") == ln or str(s.get("cwd_tail") or "") == ln)] - _cand.sort(key=lambda x: (0 if str(x.get("status")) == "working" else 1, - float(x.get("age_min") or 1e9))) - if _cand: - _s0 = _cand[0] - latest = {"item": str(_s0.get("title") or "")[:28], - "state": ("working" if str(_s0.get("status")) == "working" - else str(_s0.get("status") or "?")), - "by": str(_s0.get("id8") or ""), - "age_min": float(_s0.get("age_min") or 0), "from_session": True} - - out.append({ - "line": ln, - "name": str((C.get("lines") or {}).get(ln) or ln), - "items": {k: str(items[k].get("state") or "?") for k in sorted(items)}, - "done": len(done), "total": len(items), "open": n_open, - "latest": latest, - "running": holders, - "state": ("busy" if holders else ("gap" if n_open else "idle")), - # 🔴 2026-09-30:**这一行到底是不是"任务类别"**。 - # `kind="topic"` ⇒ 命中 `goal.topics`(=当前类别,架构图第三层的正式分工位); - # `kind="legacy"` ⇒ **只在台账/`goal.lines` 里出现过,不在当前类别清单**(跨工作区 - # 时代的旧线名,或已退役的类别)⇒ 它**不是**类别,⛔ 不许让工作区名冒充类别。 - # ⛔ 也**不许把它静默丢掉**(同族红线:读到了却不显示 = 少说一句话)⇒ - # 看板把它**折叠成一格「未归类」**画出来(见 build() 的 `orphan`)。 - "kind": ("topic" if ln in _tps else "legacy"), - }) - return out - - -def _session_rows() -> list: - """🔴 宿主库里的**原始会话行**(只读**一次** ⇒ 多目标共用,⛔ 不 N 个目标读 N 次)。 - - ⛔ 读不到 ⇒ **抛异常**(调用方升级成 `err`,⛔ 不静默伪装成"0 个会话" —— 那是假情报)。 - """ - db = _host_db() - if not db: - return [] - con = sqlite3.connect("file:%s?mode=ro" % str(db).replace("\\", "/"), uri=True, timeout=0.3) - con.row_factory = sqlite3.Row - try: - con.execute("pragma busy_timeout=300") - except Exception: - pass - # 🔴 2026-10-02 用户报障:「**刚才删除历史会话 也被当作有活动 显示在 协作会话中**」 - # ⇒ **真因**:本查询漏了 `deleted_at` 过滤 —— 宿主的"删除会话"是**软删除** - # (只写 `deleted_at` 时间戳,**行还在表里**;实测库里 `deleted_at` 非 0 的有 101 条)。 - # ⚠️ 同文件 `_deliverable` 那条(`:248`)**有**这个过滤,所以**只漏了这里** - # ⇒ 后果:已删会话照样被读进看板、出现在第三层「协作会话」里(用户看到的就是这个)。 - # ⚠️ 判据与 `board_ext.py:396` **统一**:`(deleted_at is null or deleted_at=0)`。 - rows = con.execute( - "select id,title,custom_title,status,cwd,updated_at,last_activity_at " - "from sessions where (deleted_at is null or deleted_at=0) " - "order by updated_at desc limit 60").fetchall() - con.close() - return rows - - -def _sessions(limit: int = 8, sc: dict | None = None, rows=None, bypass: bool = False) -> dict: - """返回 `{"mine": [...], "others_running": n, "unrecognized": [...], "hidden": {...}, "err": ""}` - —— **只把本项目的会话列进看板**。 - `mine` 里**主会话排最前**(看板第一眼要能看到"哪个是主会话")。 - - 🔴 2026-10-01 加 `hidden`:**已退场/超窗口**的会话,形状 - `{"retired": n, "stale": m, "live_min": X, "recent": [{id8,title,age_min,why}]}`。 - ⚠️ `recent` 只留最近 3 条明细(版面精简),**计数是全量**。 - - 🔴 **不许拖慢宿主**(用户 2026-09-30:「看板不能影响程序执行」): - · 只读连接(`mode=ro`)⇒ WAL 下**读者不阻塞写者**,宿主的写事务该多快还是多快 - · `busy_timeout=300` ⇒ 万一撞上写锁,**0.3 秒就放弃**,⛔ 不排队、⛔ 不长时间占着 - · 失败 ⇒ 返回 `err` 让界面显示「宿主库暂不可读」,⛔ **不静默伪装成"0 个会话"**(那是假情报) - - 🔴 2026-10-01 多目标:`sc` 传入 ⇒ 按该目标算归属;`rows` 传入 ⇒ 复用已读的行(⛔ 不重复读库)。 - """ - if sc is None: - sc = project_scope() - try: - if rows is None: - rows = _session_rows() - now = time.time() - # 🔴 2026-10-03 11:1x 加 `checks`(检查会话=常驻程序侧,⛔ 不进 `work`) - main, work, checks, others, unrec = [], [], [], 0, [] - _ret = _retired() - _hid = {"retired": 0, "stale": 0, "live_min": LIVE_MIN, "recent": []} - _wstail = str(sc.get("workspace") or "").replace("\\", "/").rstrip("/").split("/")[-1] - for r in rows: - title = str(r["custom_title"] or "") or str(r["title"] or "") - cwd = str(r["cwd"] or "") - st = str(r["status"] or "") - sid = str(r["id"] or "") - # 🔴 2026-10-03 17:0x **`bypass`**:peer 格专用(见函数头与 `_goal_block()` 调用点)。 - # 本分支恒为 False ⇒ **本工作区的判据逐字不变**(向后兼容)。 - if not (bypass or in_project(sid, title, sc)): - if st == "working": - others += 1 # ⚠️ 别的线在跑 ⇒ **只计数**,⛔ 不混进列表 - # 🔴 2026-10-01 加(用户报「创建了协作会话但看板没展示」):**同工作区、 - # 但按命名判据认不出来**的会话必须**说出口**(同族红线:读到了却不说)。 - # ⚠️⚠️ 这里**只用 cwd 决定"要不要提醒",⛔ 绝不用它决定"归不归本项目"** —— - # 归属判据仍是 §2.3 那三级(主会话 sid / 标题含 `[<类别>]` / `--declare`), - # ⛔ 不回落 cwd。这批会话**不进 `mine`、不进分工板、不算"本项目会话"**。 - # 为什么必须有:棒名写成 `[协作]-<昵称>-…`(漏方括号/用 short)时, - # `parse_session_name()` 与 `in_project()` **双双认不出** ⇒ 它会**静默消失**。 - _ct = cwd.replace("\\", "/").rstrip("/").split("/")[-1] - if _wstail and _ct == _wstail: - # ⚠️ 带 `age_min`:看板**只把"在跑的 / 近 3 小时的"摊在版面上**, - # 陈年历史只计一个数 —— 否则半个版面上全是早已完成的旧棒(用户反复要求"精简")。 - unrec.append({"id8": sid[:8], "title": title[:60], "status": st, - "age_min": round((now - float(r["updated_at"] or 0) / 1000.0) / 60.0, 1)}) - continue - rec = { - "id8": sid[:8], - "title": title[:60], - "status": st, - # 🔴 2026-09-30:多任务类别 ⇒ "主会话"是**一组**(`main_sids`),⛔ 不是"唯一那一条" - # 🔴🔴 2026-10-01 修:原来是**二分**(主会话/任务会话)⇒ 唤醒会话、第④类跟进会话 - # 一律被标成「协作会话」。详见 `_role_label()` 的注释。 - "role": _role_label(sid, title, sc), - # 🔴 任务类别(=标题二级前缀 `[主题]`)—— 分工板按它归位 - # (用户:「通过协作会话名称前缀的方式区分具体执行会话」⇒ 同一个词,四处同义) - "topic": _topic_in_title(title, sc.get("topics") or []), - "cwd_tail": cwd.replace("\\", "/").rstrip("/").split("/")[-1], - "age_min": round((now - float(r["updated_at"] or 0) / 1000.0) / 60.0, 1), - } - # 🔴🔴 2026-10-03 11:1x **三类分拣**(用户纠正:检查会话不进协作会话那一排): - # 主会话 / 任务会话 / **检查会话**(常驻程序侧)。 - # ⚠️ 改前是二分(`== 主会话` ⇒ main,否则 work)⇒ 检查会话被塞进 `work` - # ⇒ 前端 `role==="任务会话"` 那排就把它画成任务会话 ⇒ 用户看到的就是那个错。 - # ⛔ 三类**都显式列**:加第四类时若走 else 会静默混进某一类。 - # 🔴🔴 2026-10-03 19:2x **比较值跟着 `_ROLE_LABEL` 走** —— 原来写死 `"任务会话"` 字面量, - # 而 19:2x 把 worker 的**显示标签**改成了「协作目标」⇒ 两边对不上 - # ⇒ **worker 落进 `else` 分支被当成"检查会话"** ⇒ `work` 为空、`mine` 为空 - # ⇒ 自检「会话退场」四条假红(本轮实测踩到)。 - # 🔴 **判据纪律**:标签是**数据**(`_ROLE_LABEL`),⛔ **不许在比较处再写一遍字面量**。 - if rec["role"] == _ROLE_LABEL["main"]: - main.append(rec) - elif rec["role"] == _ROLE_LABEL["worker"]: - work.append(rec) - else: - checks.append(rec) # 检查会话(常驻程序侧) - # 🔴 2026-10-01:**退场筛**(治"接续棒越堆越多")—— - # ⚠️ **两条闸门都绕开 `working`**:在跑的棒一定要摊在版面上(⛔ 被"太旧了"吃掉最糟)。 - # ⚠️ 也绕开 **主会话**:它是版面上的**锚点**(第一眼要看到"哪个是主会话"), - # ⛔ 不许因为"它 4 小时没动"就把锚点抽掉 —— 那会让版面结构整块塌掉。 - # ⚠️ **收起 ≠ 删除**:宿主库里原样在;这里只影响呈现,并**必须报数**(同族红线)。 - _keep = [] - for rec in work: - if rec["status"] != "working": - if _retired_hit(rec["id8"], _ret): - _hid["retired"] += 1 - _hid["recent"].append({"id8": rec["id8"], "title": rec["title"], - "age_min": rec["age_min"], "why": "显式退场"}) - continue - if rec["status"] == "completed" and float(rec["age_min"] or 0) > LIVE_MIN: - _hid["stale"] += 1 - _hid["recent"].append({"id8": rec["id8"], "title": rec["title"], - "age_min": rec["age_min"], "why": "超 %d 分钟" % int(LIVE_MIN)}) - continue - _keep.append(rec) - work = _keep - # ⚠️ 只留最近 3 条明细(版面精简),但**计数是全量**(⛔ 不许因为"它很旧"就少说一句) - _hid["recent"] = sorted(_hid["recent"], key=lambda x: float(x.get("age_min") or 0))[:3] - # 🔴🔴 2026-10-01:**四类各保底一条**再截断 —— 看板上**每一类都有它自己的位置** - # (主会话独占一行 / 唤醒会话+跟进会话单独一行 / 任务会话那一排), - # ⇒ 哪一类被 `limit` 截掉,版面上就会出现**假话**: - # · 跟进会话被截掉 ⇒ 那一格渲染成「**(未登记跟进会话)**」,而它其实正在跑; - # · 唤醒会话被截掉 ⇒ `front.triggers` 照样画,但 `sessions` 里没有它(两处读数打架)。 - # ⚠️ **不能让"最近 8 条"这种排序决定版面上有没有某一类** —— 那是"读到了却不说"的同族。 - _all = main + work - _pin, _seen = [], set() - for _rec in _all: - if _rec["role"] not in _seen: - _seen.add(_rec["role"]); _pin.append(_rec) - for _rec in _all: - if _rec not in _pin: - _pin.append(_rec) - mine = _pin[:max(limit, len(_pin))] - # 🔴 2026-10-03 11:1x `checks` 一并回传(前端要画进常驻程序框) - return {"mine": mine, "checks": checks, "others_running": others, - "unrecognized": unrec, "hidden": _hid, "err": ""} - except Exception as e: - return {"mine": [], "others_running": 0, "unrecognized": [], "hidden": {}, - "err": "WorkBuddy 库暂不可读:%s" % e} - - -# ══════════════════════════════════════════════════════════════════════════════ -# 🔴 职责边界(用户 2026-09-30 定则): -# 「**技能就是技能 程序就是程序,谁用产生的文件 放在他自己那里**」 -# +「**禁止用抽象词,用 系统-模块-功能名**」 -# -# ⇒ **本文件(技能侧)⛔ 不含任何项目的路径、端口、真名、历史**。 -# 凡是"某个项目要看哪些组件、它们叫什么真名、端到端怎么算通过"这类**项目知识**, -# 一律由**使用方自己的扩展文件**提供,本文件只负责**把它读进来并渲染**: -# -# 配置项 `board_ext`(工作区相对路径,默认 `.workbuddy/collab/board_ext.py`) -# ⇒ 那个文件暴露 `build(ws: str) -> dict`(契约见其文件头) -# -# ⛔ 没配 / 读不到 / 抛异常 ⇒ **降级**(这一块不显示,看板照常)+ 记进 `warn`, -# ⛔ 绝不让使用方的脚本把看板搞挂。 -# ══════════════════════════════════════════════════════════════════════════════ - -EXT_CACHE: dict = {} # ⚠️ 缓存 **(mtime, size, 模块)**;⛔ 绝不缓存 `build()` 的**结果**(见下) - - -def _ext(ws: Path, warn: list) -> dict: - """🔴 读**使用方自己的**看板扩展(⛔ 技能侧不含任何项目知识)。 - - 契约:`build(ws) -> {"title","tag","chips":[{label,up,ok,bad,tip}],"paragraphs":[{text,tone}],"tip", - "triggers":[{name,detail,detail2,label,up,edge,tip}]}`。 - - ⚠️ `triggers` 为**可选**:表示**外部触发源(时间驱动)** ⇒ 画进架构图 R5 行右半。 - (用户 2026-09-30:「把心跳的节点也放到看板协作架构图中」。)空或缺 ⇒ 退回"只有钩子子进程"的老版面。 - 任何异常都吞掉并降级 —— 看板**不能因为使用方的脚本坏了就打不开**。 - - 🔴🔴 **⛔ 不许缓存 `build()` 的结果**(2026-09-30 我自己踩的坑): - 第一版把**结果 dict** 缓存了 ⇒ 扩展里的**探针只跑了一次** ⇒ 前置状态**永久冻结在服务启动那一刻**, - 而且**不报错**(表现为"看板说该端口未监听,而 netstat 明明确有 LISTENING")。 - ⇒ 只缓存**模块对象**(省一次 import),**每次快照都重新调用 `build(ws)`**。 - (⛔ 本文件的注释与 docstring 里也**不写任何项目的端口/路径** —— 有静态用例守着。) - """ - rel = str(C.get("board_ext") or ".workbuddy/collab/board_ext.py") - p = (ws / rel) - out: dict = {"title": "前置", "tag": "", "chips": [], "paragraphs": [], "tip": ""} - if not p.is_file(): - out["_missing"] = "未配看板扩展(board_ext):%s" % p - return out - try: - key = str(p) - try: - sig = (os.path.getmtime(p), os.path.getsize(p)) # 🔴 文件签名 - except OSError: - sig = (0.0, -1) - hit = EXT_CACHE.get(key) - if hit is None or hit[0] != sig: - # 🔴 2026-09-30 改:**按文件签名重载**。旧实现只按路径缓存模块 ⇒ 改了 `board_ext.py` - # 必须**重启服务**才生效,而"没生效"在界面上表现为"我改了但看板没变化" - # (用户当天连问两次)⇒ 改成 mtime+size 变了就重载,⛔ 不再需要重启。 - import importlib.util - spec = importlib.util.spec_from_file_location("_board_ext_%d" % abs(hash(key)), str(p)) - mod = importlib.util.module_from_spec(spec) - spec.loader.exec_module(mod) - EXT_CACHE[key] = (sig, mod) - mod = EXT_CACHE[key][1] - got = mod.build(str(ws)) # 🔴 每次快照都重跑探针(⛔ 结果不缓存) - if isinstance(got, dict): - out.update(got) - except Exception as e: - out["_err"] = str(e) - warn.append("看板扩展加载失败(已降级):%s" % e) - return out - - -def _queue(tasks: dict, st: dict) -> dict: - """🔴 **队列计数**(用户 2026-09-30:「协作程序 也要显示**当前待验收队列数量**」)。 - - 🔴 **队列 = 需求台账 `tasks.json`**(`architecture.md` §迭代记录明载),**四态**: - `pending 待执行` / `running 执行中` / `done 已完成` / `blocked 有阻碍`。 - ⚠️ **机制里没有「待验收」这个态**(⛔ 不臆造一个数字出来)⇒ 这里如实给**四态明细 + 未完结数**: - `open`(未完结)= 待执行 + 执行中 + 有阻碍 + 其它;`done` = 已完成。 - ⚠️ 另附 `notify_n`(= `queue_info.n`,**待主会话反馈**的通知条数)—— 它与台账是两码事,⛔ 别混。 - """ - by = {"pending": 0, "running": 0, "done": 0, "blocked": 0, "other": 0} - for v in (tasks or {}).values(): - s = str((v or {}).get("state") or "").strip().lower() - by[s if s in by else "other"] += 1 - qi = st.get("queue_info") or {} - try: - _nn = int(qi.get("n") or 0) - except Exception: - _nn = 0 - return {"total": len(tasks or {}), "by": by, - "open": by["pending"] + by["running"] + by["blocked"] + by["other"], - "notify_n": _nn} - - -# ⛔ 「监督守护为什么停」是**某个项目的历史事实** ⇒ 归**使用方**(写在其 `board_ext.py` 里, -# 经 `ext["guard_stop_reason"]` 取回);技能侧⛔ 不落任何项目的止损史。 - - -def _age_min(p: Path): - """文件 mtime 距今多少分钟。取不到 ⇒ None(⛔ 不拿 0 冒充"刚更新")。""" - try: - return round((time.time() - p.stat().st_mtime) / 60.0, 1) - except Exception: - return None - - -def _label_when_down(prog_age, hb_pid, degraded: bool) -> str: - """🔴 **常驻不在线时的文案**(四态,⛔ 每态都说清"依据是什么")。 - - 🔴 2026-10-03 11:35x 用户报障后确立(原文:报「已 849.6 分钟没轮」而常驻明明活着): - 那条文案把**两件不同的事**混成一句 ⇒ 读者无法判断该做什么。 - 四态: - · **已停** = pid 不在了(心跳停 ⇒ 进程真死)⇒ ⛔ 别写成"没轮"(那是假绿)。 - · **没轮** = pid 还在但心跳旧(活着却卡住)⇒ 这是"该叫醒它"的场景。 - · **判据降级**= 心跳**读不到**(⛔ 不是"停")⇒ 只报读数,⛔ **不许提进程**(提了就是编)。 - · **状态未知**= 什么都读不到。 - """ - if degraded: - if prog_age is None: - return "心跳读不到 · 状态未知" - return "心跳读不到 · 判据已降级(旧戳 %d 分钟前)" % round(prog_age) - if prog_age is None: - return "心跳读不到 · 状态未知" - if hb_pid and not _pid_alive(hb_pid): - return "已停 · 进程不在(心跳停在 %d 秒前)" % round(prog_age * 60) - return "在线 · 已 %d 秒没轮(进程还在)" % round(prog_age * 60) - - -def _pid_alive(pid) -> bool: - """🔴 pid 是否活着 —— **直接复用 `collabd.py` 的实现**(⛔ 不重复写第二份)。 - - 🔴 2026-10-03 11:39~11:40x 两次踩坑后的决定(**两份同名实现=活例**): - ① 本文件第一版**没声明 `argtypes`/`restype`** ⇒ 64 位 `HANDLE` 被 ctypes 截断 - ⇒ 实测心跳正在更新、却 `OpenProcess` 返回 0 且 `GetLastError=87` - ⇒ **活进程被判死**(方向①假读数)。 - ② 第二版补了签名,但对 `ACCESS_DENIED` 返回"不在" - ⇒ **常驻活着却被说成「已停」**(方向②假读数)。 - ⇒ `collabd.py::_pid_alive` 是踩过坑的成熟版:`WinDLL` + 显式类型 + - **`ACCESS_DENIED` ⇒ 保守判「在」** + 判不出来 ⇒ 保守判「在」。 - ⚠️ 复用方式=`importlib` 按路径加载同目录 `collabd.py`(与 `_is_check_agent` 同款); - ⛔ 不写进 `sys.path`(污染宿主能 import 的名字空间)。 - ⚠️ 加载失败 ⇒ 回落成「保守判在」—— ⛔ 宁可不说"已停"(那会误报故障), - 也**不许**回落成"判不在"(那会误报停机)。 - """ - try: - return bool(_cb_shared._pid_alive(pid)) - except Exception: - return True # ⛔ 判不出来 ⇒ 保守判「在」(宁可漏报,⛔ 不误报停机) - - -def _supervise_heartbeat() -> tuple: - """🔴 读**常驻心跳**(`logs/supervise-heartbeat.json`)⇒ 返回 `(龄_分钟, pid, round)`。 - - 🔴 **这是"常驻程序在不在跑"的唯一真源**(用户 2026-10-03 11:35x 报障后确立): - 旧判据读 `_tick.stamp`/`collabd-once.stamp` —— 那是**已退役投递机制**的遗留戳, - 实测停在 2026-10-02 21:24 ⇒ 报"849.6 分钟没轮",而常驻明明 10 s/轮。 - ⚠️ **只判"心跳新鲜"还不够**:pid 可能已死而文件还在(宿主回收不删文件)⇒ - **pid 活 ∧ 心跳新鲜**才是完整判据(与 `collabd.py::supervise_alive()` 同款)。 - ⚠️ 读不到 ⇒ 返回 `(None, None, None)` ⇒ ⛔ **调用方不许直接判"没在跑"**(那是"读不到")。 - """ - import json as _j - hb = None - for cand in (INBOX.parent.parent / ".workbuddy" / "collab" / "logs" / "supervise-heartbeat.json", - WS / ".workbuddy" / "collab" / "logs" / "supervise-heartbeat.json"): - try: - if cand.exists(): - hb = cand - break - except Exception: - continue - if hb is None: - return (None, None, None) - try: - d = _j.loads(hb.read_text(encoding="utf-8")) - age = max(0.0, (time.time() - float(d.get("ts") or 0)) / 60.0) - return (age, d.get("pid"), d.get("round")) - except Exception: - return (None, None, None) - - -def _runtime() -> dict: - """🔴 **常驻程序 / 投递的实时状态**(用户 2026-09-30 要求架构图「**要能展示实时状态**」)。 - 取的全是**真痕迹**(状态戳 mtime),⛔ 不猜、⛔ 不按"配置里写着要常驻"就当它活着: - · **常驻程序**:`_tick.stamp`(宿主钩子唤起的一次性投递轮)/`collabd-once.stamp`(投影轮)取更新时间 - · **投递**(旧名「监督程序」):**不是一个该常驻的进程** —— 它就是**宿主钩子唤起的一次性 - `--tick`**。所以这里报的是「**这条链通不通**」(看 `--tick` 戳的新鲜度),⛔ 不是"启动没启动"。 - ⚠️ 教训仍在:图上必须能一眼看出**谁其实没在跑** —— 但"没在跑"得先说清**它本来该不该跑**。 - """ - # 🔴🔴 2026-10-03 11:35x **判据改读常驻心跳**(用户报障:「已 849.6 分钟没轮」, - # 而常驻明明 10 秒一轮)。真因:下面这两个戳是**已退役投递机制**的遗留, - # 实测都停在 **2026-10-02 21:24**(无人再写)⇒ 报"849 分钟没轮"=**假读数**。 - # ⇒ 主判据换成**心跳**(⛔ 与 `collabd.py::supervise_alive()` 同一口径)。 - # ⚠️ 旧戳**保留但降级**为附注(`legacy_*`),⛔ **不再参与判定** —— 它们仍记录着 - # "最后一条旧机制痕迹在哪",将来要查历史时用得上。 - tick = _age_min(INBOX / "_tick.stamp") - proj = _age_min(INBOX / "collabd-once.stamp") - glog = _age_min(INBOX / "guard.log") - stopped = (INBOX / "guard.stop").exists() - - # ① 主判据=常驻心跳(pid 活 ∧ ts 新鲜) - hb_age, hb_pid, hb_round = _supervise_heartbeat() - HEARTBEAT_FRESH = 90.0 # 秒。⛔ 与 `collabd.py::SRSM_FRESH` 同量级(那边是 900s, - # 但那是给"长工具调用"留的;⛔ 看板这里要"看起来在线", - # 90s 与常驻 10s/轮匹配 ⇒ 够灵敏又不误报) - if hb_age is not None: - prog_up = hb_age <= (HEARTBEAT_FRESH / 60.0) - prog_age = hb_age - prog_by = "常驻 --supervise(round %s)" % (hb_round if hb_round is not None else "?") - else: - # ② 心跳读不到 ⇒ **回落**旧戳,⛔ **不直接判"没跑"**(那会把"读不到"说成"没在跑")。 - # 🔴 2026-10-03 11:35x 变异 M3 抓到:回落时**不能沿用 pid 相关的文案** - # (实测报出「已 51210 秒没轮(进程还在)」—— pid 根本没读到,那句"进程还在"是**假的**)。 - # ⇒ 回落分支用**独立**的 `_hb_degraded` 标记,让 label 说"判据已降级"⛔ 不说进程。 - _a = [a for a in (tick, proj) if a is not None] - prog_up = bool(_a) and min(_a) < 15.0 - prog_age = min(_a) if _a else None - prog_by = ("旧机制戳(⚠️ 常驻心跳读不到,判据已降级)" if prog_up else "") - _hb_degraded = (hb_age is None) - _hb_note = "" if not _hb_degraded else "(⚠️ 常驻心跳读不到,本行判据已降级为旧机制戳)" - - guard_up = (not stopped) and (glog is not None and glog < 5.0) - return { - # 🔴 2026-09-30 用户:「**协作程序 应该一直运行,改成 在线**」⇒ 正常态就报两个字「在线」, - # ⛔ 不再堆"钩子 --tick · N 分钟前"那种细节长串(细节进 `how`,给界面做 tooltip 用)。 - # ⚠️ **但不放假绿**:真没轮动就说清"在线 · 已 N 分钟没轮"——机制还在,只是没被唤起; - # ⛔ 也不写「已停」——它不是一个"该常驻、可被停"的进程,那个词本身就会误导。 - "prog": {"up": prog_up, "age_min": prog_age, "by": prog_by, - # 🔴 心跳真源读数(前端 tooltip 可显示;⛔ 旧戳另存 `legacy_*`) - "heartbeat_age_min": hb_age, "heartbeat_pid": hb_pid, - "legacy_tick_age_min": tick, "legacy_proj_age_min": proj, - # ⚠️ 文案三态(2026-10-03 改):「在线」/「已 N 秒没轮」/「心跳读不到」 - # ⛔ 原来只有两态,且判据指错文件 ⇒ 报"849.6 分钟没轮"(假读数)。 - # ⚠️🔴 2026-10-03 11:35x **文案必须区分「死了」与「没被唤起」**: - # 实测踩到⇒常驻真死时,旧文案报「已 890 秒没轮」⇒ **把「进程死了」 - # 说成「没被唤起」=假绿**(读者会以为机制正常只是闲着)。 - # ⇒ pid 不在 ⇒ 明说「已停」;pid 在但心跳旧 ⇒ 才是「没轮」。 - # ⛔ 仍不写「已停」给**读不到心跳**的情形(那是读不到,⛔ 不是停)。 - "label": ("在线" if prog_up else - (_label_when_down(prog_age, hb_pid, _hb_degraded))), - "how": ("常驻程序**常驻一直运行**(collabd.py --supervise,10 s/轮);" - "判据=心跳文件 `logs/supervise-heartbeat.json` 的 ts" - "(pid 活 ∧ 心跳 <%d s),⛔ 与 `supervise_alive()` 同一口径。" - % int(HEARTBEAT_FRESH) - + ((";最近一轮=%s" % prog_by) if prog_by else "") - + _hb_note - + (";⚠️ 旧投递机制戳(_tick/--once)已**退役、不再写**," - "仅作历史痕迹保留(%s/%s 分钟前)。" - % (tick if tick is not None else "无", - proj if proj is not None else "无")))}, - # 🔴🔴 **「投递」这个词取代了旧的「监督程序」**(2026-09-30 改口径 · 用户连问四次 - # "投递为什么打不开 / 它一直停着能起什么作用 / 还需要保留吗 / 跟它有关系吗"): - # · 「监督程序」是**投递这条职责的旧名**; - # · 真正该报的是「**投递这条链通不通**」⇒ 判据用 `--tick` 戳的新鲜度(投递轮就是它)。 - # 🔴 2026-10-01 晚订正:常驻定案**回归**(用户「一直运行(常驻)」+「定时任务方案已废弃」) - # ⇒ 投递**应当一直运行** ⇒「长时间未触发」**是一个真读数**(常驻该起没起); - # ⛔ 不再是 09-30 那句「没有东西该在跑、别报已停」。`stopped`(`guard.stop`)随之恢复意义。 - # 🔴 **必须原样保留的**:投递方唯一性(只有 `--tick` 能推进队列)—— 22:53 事故的修法。 - "deliver": {"up": (tick is not None and tick < 15), "stopped": stopped, "age_min": tick, - # ⚠️ 这行窄(`PW-36` 还要给 12px 字号)⇒ 文案要短:机制那句在下面一行已经有了, - # 这里只报**新鲜度**,⛔ 别重复"常驻/钩子"(会溢出成"…前…"被截断)。 - "label": (("就绪 · %s 分钟前跑过" % tick) - if (tick is not None and tick < 15) else "长时间未触发(投递没在跑)"), - "reason": ""}, # ⛔ 不再给"停因"——那个问题已经不存在了 - # 🔴🔴 2026-10-03 10:1x **新增 `check`:检查会话在不在跑**(用户报障逐字: - # 「协作程序这个时候应该显示 **有检查程序在运行**」)。 - # ⚠️ 之前常驻程序那格只有**静态职责文案** + `prog.label`(在线/停), - # **完全看不到"它刚建的检查会话跑到哪一步了"** ⇒ 图上看着像"什么都没发生"。 - # 🔴 判据=**与会话明细同一个口径**(`status='working'` ⇒ 看板那格会亮): - # **⛔ 不另造判据**(同"统一一个标准"那条口径)⇒ 看板亮 ⇔ 这里说"在运行"。 - # ⚠️ 类别从**标题**解析(`[协作]-[结果检查]-…`/`[协作]-[目标检查]-…`) - # ⇒ ⚠️ **依赖检查会话按两级前缀命名**(2026-10-03 10:1x 才把旧名 `结果检查-…` - # 改成 `[协作]-[结果检查]-…`)⇒ 旧名会话 `topic` 为空 ⇒ 只报"检查中"不报种类。 - "check": _check_runtime(), - } - - -def _check_runtime() -> dict: - """🔴 **检查会话在不在跑**(常驻程序那格显示「有检查程序在运行」用)。 - - 🔴 **判据=`sessions.status='working'` + 标题带 `[结果检查]`/`[目标检查]`** —— - **与会话明细/看板亮灭同一个口径**(`board.html` 的 `mOn=!!(main&&main.status==='working')`), - ⛔ 不另造第二套"在执行"判据(那正是 2026-10-03 上午被统一掉的那件事)。 - ⚠️ 读库失败 ⇒ 返回 `{"n": 0, "unknown": True}` ⇒ 前端显示「状态读不到」, - ⛔ **不假装"没有检查在跑"**(那会把机制故障说成正常)。 - """ - out = {"n": 0, "kinds": [], "unknown": False, "label": "无检查在跑"} - try: - db = _host_db() - if not db: - out["unknown"] = True - out["label"] = "检查状态读不到" - return out - con = sqlite3.connect("file:%s?mode=ro" % str(db).replace("\\", "/"), uri=True, timeout=0.3) - try: - con.execute("pragma busy_timeout=300") - except Exception: - pass - # 🔴 `deleted_at` 过滤**必须带**(与 `_sessions()` 同款):宿主「删除会话」是**软删除** - # (行还在表里)⇒ 漏了它 ⇒ 已删的检查会话照样被算成"还在跑"(同 2026-10-02 那个坑)。 - # 🔴🔴 2026-10-03 10:1x **两种标题形态都认**(向后兼容,⛔ 不是妥协): - # · 新名(10:1x 起的合规名)`[协作]-[结果检查]-…` / `[协作]-[目标检查]-…`; - # · 旧名(10:1x 之前建的)`结果检查-…` / `目标检查-…`。 - # ⚠️ 为什么必须都认:改命名只对**之后新建**的会话生效 ⇒ 若只认新名, - # 那些**改之前就在跑**的检查会话在看板上会**凭空消失**(读数假"无检查在跑") - # ⇒ 又是"把机制故障/在跑说成没有"的同族假绿。 - # ⛔ 但**不因此放松命名规范**:新名仍是唯一规范(`collabd.py` 侧已改), - # 这里的双形态只是**读存量**的兼容层。 - rows = con.execute( - "select title from sessions where status='working' " - "and (deleted_at is null or deleted_at=0) " - "and (title like '%[结果检查]%' or title like '%[目标检查]%' " - " or title like '结果检查%' or title like '目标检查%')").fetchall() - con.close() - except Exception: - out["unknown"] = True - out["label"] = "检查状态读不到" - return out - kinds = [] - for (t,) in rows: - s = str(t or "") - kinds.append("目标检查" if "[目标检查]" in s else "结果检查") - out["n"] = len(kinds) - out["kinds"] = kinds - if out["n"]: - uniq = sorted(set(kinds)) - out["label"] = "有检查程序在运行" if len(uniq) > 1 else "有%s在运行" % uniq[0] - return out - - -# ⛔ 上一版这里还有 `_parse_relay_line` / `_overlay_node` / `_end_to_end_verified` 三个函数 —— -# 它们解析的是**某个项目的中继客户端日志**(glob 路径、字段名、`streams>0` 判据)。 -# 按「技能就是技能,谁用产生的文件放在他自己那里」,**已整体搬到使用方的 -# `.workbuddy/collab/board_ext.py`**;技能侧只保留「读扩展并渲染」这一层(见 `_ext()`)。 - - -def _orphan(lb: list) -> dict: - """把 `labor` 里 `kind != "topic"` 的行**汇总成一份可见的"未归类"**(⛔ 不许静默丢)。 - - 🔴 判据同族红线:**"读到了却没显示" = 少说一句话** —— 它不崩溃,所以最难发现。 - 场景:任务类别清单已迁移(唤醒机制/…),台账里仍有**跨工作区时代的旧线名** ⇒ 旧版把它们 - 也当"分工位"塞进 `labor`,而架构图只画前 N 格 ⇒ 那几格(连同里面的件)**整块消失**。 - ⇒ 现在:它们**不是类别**(`kind="legacy"`,⛔ 不占正式分工位),但看板必须**明确说出来**。 - """ - _lg = [x for x in (lb or []) if str(x.get("kind")) != "topic"] - if not _lg: - return {"lines": [], "n": 0, "total": 0, "done": 0, "open": 0} - return {"lines": [{"line": str(x.get("line") or ""), - "name": str(x.get("name") or x.get("line") or ""), - "total": int(x.get("total") or 0), - "done": int(x.get("done") or 0), - "open": int(x.get("open") or 0)} for x in _lg], - "n": len(_lg), - "total": sum(int(x.get("total") or 0) for x in _lg), - "done": sum(int(x.get("done") or 0) for x in _lg), - "open": sum(int(x.get("open") or 0) for x in _lg)} - - -# 🔴🔴🔴 **判定词白名单 —— 全库唯一事实源**(2026-10-04 立,用户报「看板还是 0 通过」)。 -# -# **为什么要立它**:这条规则**已经连修 3 次、每次只补一个词**,第 4 次又栽在同一个形状上: -# · 10-02 只认英文 `pass` ⇒ 真源写中文「过」⇒ 全判非 pass(补「过」) -# · 10-03 只取最后一段 ⇒ 「复核:过」被切掉(改逐段找) -# · 10-04 🔴 **真源写的是「达」**(`达(实测 7 个…)`)⇒ 三处判据**一个都不认** -# ⇒ vibe-product 15 条判据显示 **0 通过**(含 14 条已达标)——用户原话「为什么还是0通过」。 -# ⛔ 每次修完不立清单 ⇒ 下次换个词**必然再栽**。⇒ 白名单**只留这一处**,三处判据都读它。 -# -# ⚠️ **判定词只许在这里增**(改完必须同时改 `assets/board.html` 的 `ACC_PASS_WORDS`)。 -# 🔴 **fail-closed 不变**:`不过`/`未过`/`待重验`/`未达` **都不以这些词开头** ⇒ 自然判非过。 -ACC_PASS_WORDS = ("pass", "过", "通过", "达", "达标", "合格", "完成") - -# 🔴🔴🔴 **判定词前缀「副词」白名单**(2026-10-05 立 —— **第 5 次复发**,这次改成治形状、⛔ 不再补词)。 -# -# **病根**:判定词前会带一个**表已完成/确认的副词**,而 `startswith` 要求**从第一个字符**就命中 -# ⇒ 带了副词就**永远不命中** ⇒ 明明写着"已过"却判"非过": -# · 2026-10-05 实测(vibe-product 页签):9 条判据**全部写着 `已过|…`**,看板显示 **0 / 9 通过** -# —— 因为 `已过` 的第一个字是 `已`、⛔ 不是 `过`。用户原话:「完成情况 0 / 9 通过,但是 -# 我看到都显示已过呀」。 -# ⛔ 前四次(10-02「过」/10-03「逐段找」/10-04「达」)都在**补词**,每次换个措辞必然再栽 -# —— 这一次**换个方向**:**先把副词剥掉再比**,⛔ 不往词表里堆"已过/已通过/已完成"这种组合。 -# -# ✅ 剥离规则:判定词**只在前缀是下列副词时**才剥(⛔ 无脑 lstrip 会把 `未过` 剥成 `过` ⇒ **假绿**)。 -# ⚠️ 只收**表"完成/确认"**的副词;⛔ **绝不许收否定副词**(未/不/没/非/待)—— -# 它们一律**留给下面 fail-closed**(不剥 ⇒ 开头不是判定词 ⇒ 判非过)。 -ACC_ADV_PREFIX = ("已", "已经", "均", "都", "经复核", "复核后") - -# 🔴 与 `ACC_PASS_WORDS` 同理:`assets/board.html` 的 `ACC_ADV_PREFIX` 必须**逐字同款**。 - - - -def acc_is_pass(v) -> bool: - """🔴 一条验收判据「算不算过」—— 🔴 **全库唯一实现**。 - - 🔴🔴 **2026-10-04 立白名单**(见上文 `ACC_PASS_WORDS`):本函数**不再各自写死词表**, - 三处判据(`board.py` 本函数 / `collabd.py::_acc_is_pass` / `board.html::accIsPass`) - **必须同款** —— 漂了就会出现"看板说通过、常驻程序说不通过"(10-04 实测已发生: - 值 `过:1440 与 390 两视口` ⇒ `board.py` 判过、`collabd.py` 判不过)。 - - 🔴🔴 **2026-10-05 第 5 次复发 ⇒ 改治形状**:判定词前常带**表完成的副词**(`已过`/`均已通过`/ - `已完成`),而 `startswith` 要求**从第一个字符**就命中 ⇒ **带副词就永远不命中**。 - 实测(vibe-product 页签):9 条真值全写 `已过|…`,看板却显示 **0 / 9 通过**。 - ✅ **正解=先剥"完成副词"再比**(`ACC_ADV_PREFIX`),⛔ 不再往词表堆 `已过/已通过` 这种组合 - —— 前四次(10-02 `过`/10-03 逐段/10-04 `达`)都在补词,换个措辞必然再栽。 - ⚠️ 剥副词**只对白名单里的副词**;`未/不/没/非/待` **绝不在白名单** ⇒ `未过`仍判非过(fail-closed 不破)。 - - 沿革(⛔ 保留,因为"理由也会过时"): - 2026-10-02 实测修的**假红**:原判据是 `str(v).lower() != "pass"`,而**台账的真实写法是中文** - —— `goal.json::acceptance_state` 的值形如 `过(PID 3552 …)`/`待重验(…)` - ⇒ **6 条已过被判成「非 pass」** ⇒ 看板显示 **7 条全非 pass**,与真源**相反**。 - ⛔ 同族=**判据写死期望值**(`pitfalls.md` P0-13 / P0-20):看着有判据,实际**永远不命中真源**。 - 2026-10-02 残留假红(同族第 2 处):`**复核:过(形态已治本…)**` 判定词**不在开头**。 - 2026-10-03 18:5x 改「逐段找」:值里**有好几个冒号**(说明 + **时间里的**)⇒ 取最后一段切错。 - """ - import re as _re - s = str(v or "").strip() - if not s: - return False # 空 ⇒ 判不出来 ⇒ ⛔ 不算过(fail-closed) - head = _re.split(r"[((]", s, maxsplit=1)[0] # 丢掉括注说明 - # 🔴 **逐段找判定词**(⛔ 不再"只取最后一段")—— 见上沿革 10-03。 - for _seg in _re.split(r"[::]", head): - _t = _seg.strip().lstrip("*").strip().lower() - for _w in ACC_PASS_WORDS: - if _t.startswith(_w): - return True - # 🔴🔴 2026-10-05 第 5 次复发:**剥掉"完成副词"再比一次**(治形状,⛔ 不补词)。 - # ⚠️ 顺序:**先原样比、再剥副词比** —— 剥了更好命中,且⛔ 不影响原判定。 - # ⚠️ **可叠加**:实测真源写 `均已通过`/`都已达标` ⇒ 一个副词不够,要能连剥 - # (`已`+`均`/`都`)。⇒ **循环剥到剥不动为止**,每剥一轮都试一次判定词。 - # ⚠️ 只剥 `ACC_ADV_PREFIX` 里的(已/已经/均/都/…);`未/不/没/非/待` **不在**里面 - # ⇒ `未过`/`不过`/`待重验` **剥不动** ⇒ 仍判非过(fail-closed 不破)。 - # ⚠️ 上限 3 轮:防"怪值死循环"(正常最多两级:`均已`)—— ⛔ 不是省事,是**有界**。 - _t2 = _t - for _ in range(3): - _cut = "" - for _a in ACC_ADV_PREFIX: - 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 ACC_PASS_WORDS: - if _t2.startswith(_w): - return True - return False # 一段都没命中 ⇒ 判不出来 ⇒ ⛔ 不算过 - - -# 🔴 **兼容别名**(2026-10-04):本函数原名 `_acc_is_pass`,改名成公开名 `acc_is_pass` -# 是为了让 `collabd.py` 能 import 复用(`_` 前缀跨模块 import 不便,且语义上它确实是 -# 公开判据)。此处保留旧名别名 ⇒ 本文件内历史调用点(`_acc_summary` 等)与外部 -# 任何仍写 `board._acc_is_pass` 的地方都不会 NameError。⛔ 别名不许各写实现,只指同一对象。 -_acc_is_pass = acc_is_pass - - -def _acc_summary(acc: dict) -> str: - """🔴 验收状态一行话。⛔ 与 goalctl / collabd 同一条红线: - **一条有效判据都没有 ⇒ 判不出来**,⛔ 不许静默显示成「无」(那读起来像"全过")。""" - if not acc: - return "⚠️ 未声明验收判据(只有说明行)⇒ **判不出来**,⛔ 不因此判完成" - bad = [k for k, v in acc.items() if not _acc_is_pass(v)] - if not bad: - return "全部 pass(%d 条)" % len(acc) - n_pass = len(acc) - len(bad) - return "非 pass(%d/%d):%s" % (len(bad), len(acc), bad) if n_pass else "非 pass:%s" % bad - - -def _goal_block(g: dict, active: bool, tasks_all: dict, srows, st: dict, warn: list, - multi: bool = False, all_topics=None, - # 🔴 2026-10-03 15:45x 跨工作区 tab:`peer`=来源工作区名,`ws_root`=其绝对路径。 - # ⚠️ 这两个形参**必须存在**:调用点已按位置传了 10 个实参, - # 少了它们 ⇒ `TypeError` ⇒ 快照生不出 ⇒ 看板一直 `warming` - # (本轮实测就是这样:脚本报了 OK 但这一处没写进文件)。 - peer: str = "", ws_root: str = "") -> dict: - """🔴 **一个目标的完整快照块** —— 看板的一个 tab 就是它(用户 2026-10-01)。 - - 🔴 **台账怎么分到各目标**:**按任务类别**(类别清单 ≡ 台账条目的 `line`,同一个词,四处同义)。 - · **只有一个目标** ⇒ **原样全给**(⛔ 不做任何切分 ⇒ 与旧版逐字一致); - · **多个目标** ⇒ 按 `line ∈ 本目标 topics` 切;`line` 不属**任何**目标的那些件 - (=跨工作区时代留下的旧线名)**归到"活跃目标"名下并原样显示** —— - ⛔ 不静默丢弃(同族红线:**读到了就要说**,⛔ 别让它们整批不见)。 - """ - tps = _goal_topics(g) - tpset = set(tps) - tasks = dict(tasks_all) - if multi: - _at = set(all_topics or []) - tasks = {k: v for k, v in tasks_all.items() - if str((v or {}).get("line") or "") in tpset} - if active: - for k, v in tasks_all.items(): - if str((v or {}).get("line") or "") not in _at: - tasks.setdefault(k, v) - sc = project_scope(g, ws_root) if (peer and ws_root) else project_scope(g) - if peer and ws_root: - # 🔴 peer 格:把「哪个工作区」对准**对方** —— 否则 `_sessions()` 里那道 - # `_ct == _wstail`(`cwd` 末段 == 工作区名,`:971`)会把对方的会话**全部排除** - # ⇒ 界面显示「0 条会话」= **假象**(2026-10-03 实测:对方主会话明明 `working`)。 - # ⚠️ 只覆盖 `workspace` 这一个键:其余判据(主会话登记/任务类别)**仍按对方 goal.json 算**。 - sc = dict(sc or {}) - sc["workspace"] = ws_root - try: - # 🔴🔴 2026-10-03 17:0x **peer 格走 `bypass=True`** —— - # 对方会话的「项目归属」依据(主会话登记 + 任务类别)存在**对方**的收件箱里, - # 而 `in_project()` 读的是**本工作区** ⇒ 用它 ⇒ 对方会话全被判「不属于本项目」 - # ⇒ 格子永远 0 条(2026-10-03 实测:两个新区各 2 条会话,主会话与任务会话都看不见)。 - # ⚠️ 角色判据仍是**同一套**(`_role_of_title()` = 标题两级前缀,两侧共用)⇒ 两边说法一致。 - _ss = _sessions(sc=sc, rows=srows, bypass=bool(peer and ws_root)) - except Exception as e: # ⛔ 兜底:绝不让一个目标把整份快照搞挂 - _ss = {"mine": [], "others_running": 0, "err": "会话读取异常:%s" % e} - if _ss.get("err"): - _nm = str(g.get("short") or g.get("id") or "目标") - warn.append("%s:%s" % (_nm, _ss["err"])) - _lb = _labor(tasks, _ss["mine"], g) # 🔴 只算一次:分工板与"未归类"汇总都用它 - qi = st.get("queue_info") or {} - acc = {k: v for k, v in (g.get("acceptance_state") or {}).items() - if not str(k).startswith("_")} - return { - "key": str(g.get("id") or g.get("short") or ""), - # 🔴🔴 2026-10-03 15:45x **透传 `peer`** —— ⛔ 上游 `goal_files()` 加了、下游这里不带 - # ⇒ 前端永远看不到 ⇒ 三个工作区目标标题相同 ⇒ **tab 等于白加**(读者分不清)。 - # ⚠️ 这是"改一处漏一处"的第 N 次复发 ⇒ 加字段必须**顺着数据流查到消费点**。 - "peer": peer, - "ws_root": ws_root, - # 🔴 2026-10-03 20:31x **每格都带真实工作区名**(用户:「都用真实名称」+「本工作区没用的东西不要显示」)。 - # ⛔ 原来前端对**本区**兜底成"本工作区"三个字 ⇒ 那不是名称、是占位词。 - # ⇒ 本区取**本工作区目录名**、对方区取快照给的 `peer`,两者都是**真实目录名**。 - "ws_name": (peer or Path(str(WS or "")).name or ""), - "active": bool(active), - # 🔴🔴 2026-10-03 18:5x **把「目标生命周期」透出到块里** —— - # 用户报障:「主会话说目标都已完成,⛔ 为什么看板上还是没完成」。 - # 真因:`collabd.py --set-life 已完成` 写的是 **`lifecycle`** 字段, - # 而看板原先**只读 `acceptance_state`/`run`** ⇒ ⛔ 目标早就标完成了,看板却永远看不到。 - # ⚠️ 这是"加字段必须**顺着数据流查到消费点**"的又一次(本包第 N 次):只加字段没人渲染 = 白加。 - "life": str(g.get("lifecycle") or ""), - "life_at": str(g.get("lifecycle_at") or ""), - "life_by": str(g.get("lifecycle_by") or ""), - "run": str(g.get("run") or "active"), - "paused": str(g.get("run") or "active").strip().lower() != "active", - "goal": {"title": g.get("title") or "", "short": g.get("short") or "", "acceptance": acc}, - "acc_summary": _acc_summary(acc), - # 🔴 项目身份:看板顶部「本项目」区用它 ⇒ 一眼看清"这个看板是哪个需求目标项目的" - # 🆕 2026-09-30:`topics` / `main_by_topic` —— 用户要求「**同一个工作区**多会话协作 - # (主会话根据任务**自动梳理任务类别**:**通过协作会话名称前缀**区分具体执行会话)」⇒ - # 看板必须把"**这个工作区有哪几个任务类别、每类的主会话是谁**"直接摆出来 - # (⛔ 否则用户只看到一堆会话,看不出"哪条管哪一类")。 - "project": {"id": sc["goal_id"], "short": sc["short"], "title": sc["title"], - "main_sid8": (sc["main_sid"] or "")[:8], "criteria": sc["criteria"], - "workspace": sc["workspace"], - "topics": list(sc.get("topics") or []), - # 🔴 2026-10-01:类别清单的**来源**(用户:「目标是**通过对话在调用 会话协作技能时说明的**, - # 不是固定的」)⇒ 看板要能回答"这批类别是**在对话里说明的**,还是**回落**来的"。 - "topics_source": dict(sc.get("topics_source") or {}), - "main_sids8": [str(s)[:8] for s in (sc.get("main_sids") or [])], - "main_by_topic": {str(_t): (str(_v.get("sid") or "")[:8] or "") - for _t, _v in ((sc.get("main_by_topic") or {}).items())}}, - "others_running": _ss["others_running"], - # 🔴 2026-10-01:**同工作区、但按命名判据没归入本项目**的会话(用户报「创建了协作会话 - # 但看板没展示」)—— 它们**不是**本项目会话(⛔ 不进 `sessions`、⛔ 不进分工板), - # 但**必须看得见**(同族红线:读到了却不说)⇒ 交给 `assets/board.html` 在图外说明里点名。 - # 典型成因:棒名写成 `[协作]-<昵称>-…`(漏方括号/用 `goal.short` 而非 `topics`)。 - # 🔴🔴 2026-10-03 11:1x **新增 **(用户纠正逐字): - # 「检查会话不是协作会话,不应该出现在看板协作会话区域中; - # 它属于协作程序的会话,**可以放在协作程序框图中展示**」 - # ⇒ 检查会话**从 里单列**(⛔ 不混进任务会话那一排), - # 前端在**常驻程序那格(R4)里**画它们。 - # ⚠️ 仍**保留**在 里(⛔ 不删)—— 会话明细表要看得见有这条会话。 - "sessions_checks": _ss.get("checks") or [], - "sessions_unrecognized": _ss.get("unrecognized") or [], - # 🔴 2026-10-01:**已退场/超窗口**的会话(治「接续棒越堆越多」)—— - # 它们**不进 `sessions`、不进分工板、不进第三层**,但**必须报数** - # (同族红线:读到了却不说 ⇒ 读者以为那些棒凭空消失了)。 - # ⚠️ **退场 ≠ 删除**:宿主库里原样在,⛔ 本文件也不写任何东西。 - "sessions_retired": _ss.get("hidden") or {}, - "tasks": {k: {"state": str((v or {}).get("state") or "?"), - "line": str((v or {}).get("line") or ""), - "by": str((v or {}).get("by") or ""), - "artifact": str((v or {}).get("artifact") or "")} for k, v in tasks.items()}, - "sessions": _ss["mine"], - # 🔴 分工板(架构图「协作会话」层用它渲染)—— ⛔ 不是历史会话列表 - "labor": _lb, - # 🔴🔴 2026-09-30 加:**"未归类"汇总** —— 台账里那些**不在当前任务类别清单**的线。 - # 为什么必须有:类别清单已换成任务类别(唤醒机制/…),而台账里还躺着**跨工作区时代 - # 的旧线名**(`ai1net-dsh-anywhere` / `ai1net-dsh-desktop`,各自 2 件且都已完成)。 - # 旧版把这类行也塞进 `labor` 当"分工位",架构图又只画前 4 格 ⇒ **4 件已完成的活整格不见**, - # 读者只看到 4 个"件 0 · 完成 0"。⇒ 两处修正:① 它们**不是类别**(`kind='legacy'`) - # ② 但**必须看得见**(图上折叠成一格 + 表里标注)。 - "orphan": _orphan(_lb), - "queue": _queue(tasks, st), # 🔴 队列计数(常驻程序节点用) - "progress": {"last_progress_at": st.get("last_progress_at"), - "goals_open": bool(qi.get("probe", {}).get("goal_open")), - "main_busy": bool(qi.get("probe", {}).get("main_busy")), - "prog_age_min": qi.get("probe", {}).get("prog_age_min")}, - } - - -def build() -> dict: - """🔴 **生成一份只读快照**。保证:**永不抛异常**(任何一处读不到 ⇒ 记进 `warn` 并降级), - ⇒ 调用方永远拿得到一份可渲染的数据,⛔ 不会因为看板读不到某个文件而连累别人。 - - 🔴 2026-10-01 多目标(用户:「把协作实时看板改为 **tab 支持多个目标**执行协作状态展示」): - · 多出一个 `goals` 数组 —— **一格一个目标**(活跃的排最前,其余按文件名); - · 顶层的 `goal`/`project`/`tasks`/`sessions`/`labor`/`orphan`/`queue`/`progress` - =**活跃目标那一份** ⇒ ⛔ 老渲染器与老断言**逐字照旧可用**(向后兼容); - · `front`/`runtime`/`notify`/`wakeups`/`meta` **不随目标变**(它们是工作区级的)。 - """ - warn = [] - _gfs = goal_files() - if not _gfs: # ⛔ 连 goal.json 都没有 ⇒ 也要给出一份可渲染的 - _gfs = [{"path": str(INBOX / "goal.json"), "goal": {}, "active": True, "key": ""}] - tasks = _j(INBOX / "tasks.json", {}) or {} - st = _j(INBOX / "collabd-state.json", {}) or {} - if not _gfs[0]["goal"]: - warn.append("读不到 goal.json") - if not st: - warn.append("读不到 collabd-state.json") - try: - srows = _session_rows() # 🔴 只读一次库 ⇒ 所有目标共用 - # 🔴🔴 2026-10-03 17:0x **只留 `cwd` 命中本工作区的行** —— - # `_session_rows()` 取的是**全库**最近 N 条(按活动时间倒序)⇒ 别的工作区只要 - # 任务类别撞上就会混进来(实测:本区格子里出现过 `cwd_tail=会话协作测试2` 的任务会话 - # ⇒ 那是**别区数据混在本区名下**,与 P0-40 同族的假数据)。 - # ⚠️ 这里用 `cwd` 判的是「这条会话属于哪个**工作区**」(≡ `cwd` 的字面含义), - # ⛔ **不是**架构 §2.3 禁止的那条「拿 cwd 推断会话属于哪个**需求项目**」。 - _wtail = str(WS).replace("\\", "/").rstrip("/").split("/")[-1].lower() - _own_rows = [] - for _r in srows or []: - try: - _ct = str(_r["cwd"] or "").replace("\\", "/").rstrip("/").split("/")[-1].lower() - except Exception: - continue - if _ct == _wtail: - _own_rows.append(_r) - srows = _own_rows - except Exception as e: - srows = [] - warn.append("WorkBuddy 库暂不可读:%s" % e) - # 🔴 2026-10-03 16:1x:本工作区的**切分基准只按本工作区自己的目标**算 —— - # ⛔ 把 peer 目标的 topics 混进 `_all_tps` ⇒ 本区台账里 `line` 命中"对方类别"的那些件 - # **两边都不进**(不在本区 tpset、又因为命中 _at 而拿不到"归活跃目标"的兜底) - # ⇒ **静默丢失**(同族红线:读到了就要说,⛔ 不许让它整批不见)。 - _own_gfs = [it for it in _gfs if not it.get("peer")] - _multi = len(_own_gfs) > 1 - _all_tps = [t for it in _own_gfs for t in _goal_topics(it["goal"])] - blocks = [] - for it in _gfs: - _p = it.get("peer") or "" - _wr = it.get("ws_root") or "" - if _p: - # 🔴🔴 peer 格:**只喂那个工作区自己的数据**(⛔ 喂本区的 = 把本区执行情况标到别人名下) - _b = _goal_block(it["goal"], False, _peer_tasks(_wr), _peer_srows(srows, _wr), - _peer_state(_wr), warn, False, _goal_topics(it["goal"]), _p, _wr) - _b["peer_scope"] = _peer_scope_note(_wr) - # 🔴 2026-10-03 20:22x **peer 格补自己的协作程序状态**(用户报障「不同目标的协作程序框内容一样」): - # 框里那两行(常驻程序在线/检查状态)读的是 `runtime` ⇒ ⛔ 块里没有这个键时 - # 前端视图会**沿用顶层**(=本工作区那份)⇒ 三个 tab 显示一模一样。 - _b["runtime"] = _peer_runtime_min(_wr, len(_b.get("sessions_checks") or [])) - blocks.append(_b) - else: - blocks.append(_goal_block(it["goal"], it["active"], tasks, srows, st, warn, - _multi, _all_tps)) - # 🔴🔴 2026-10-04 用户定案:「**最新在运行的执行会话目标要排在前面**」+ tab 最多 6 个、超出走下拉。 - # ✅ 排序键 `tab_hot` = **该块里最近一次会话活动距今的分钟数**(`age_min` 越小 = 越新 = 越靠前)。 - # ⚠️ **为什么用 `age_min` 而不是别的**(实测四个块的真实字段): - # · `sessions[].age_min` = 会话 `updated_at` 距今分钟数,**唯一带"活动新鲜度"的字段**; - # · `labor[].latest` 只有 `state`/`by`(⛔ 多数无 `age_min`)⇒ 排不出; - # · `runtime.up` 四块**全是 None**(peer 块的 runtime 由 `_peer_runtime_min` 补,字段名不同)⇒ 排不出; - # · `others_running` 四块**全 0**(那是"别人家的"计数)⇒ 排不出。 - # ⇒ 所以判据只能是 `sessions` 的 `age_min`;**没有会话的块排在最后**(`tab_hot` = 一个大数)。 - # ⚠️ 同一次活动(同一分钟)的块**保持原相对次序**(Python sort 稳定 + key 里带原序号兜底)。 - for _i, _b in enumerate(blocks): - _ages = [float(s.get("age_min")) for s in (_b.get("sessions") or []) - if s.get("age_min") is not None] - _b["tab_hot"] = round(min(_ages), 1) if _ages else 1e9 # 1e9 = 从没活动过 ⇒ 垫底 - _b["tab_i"] = _i # ⛔ 稳定排序的兜底键 - blocks.sort(key=lambda b: (b.get("tab_hot", 1e9), b.get("tab_i", 0))) - for _i, _b in enumerate(blocks): - _b["tab_i"] = _i # 排序后重编,⛔ 别让前端拿旧序号当"第几个 tab" - _b["tab_more"] = max(0, len(blocks) - 6) # 🔴 超出 6 个的部分 ⇒ 前端收进下拉 - _ext_d = _ext(WS, warn) # 🔴 使用方自己的扩展(⛔ 技能侧不含项目知识) - _rt = _runtime() # ⚠️ 「投递」不再需要"停因"(那个问题已随改名消失)⇒ 不回填 reason - _ext_d.pop("guard_stop_reason", None) # 使用方若还留着这个键 ⇒ 不渲染(避免又把旧问题带回来) - qi = st.get("queue_info") or {} - out = dict(blocks[0]) # ⇒ 顶层=**活跃目标那一份**(向后兼容) - out.update({ - "ts": time.strftime("%Y-%m-%d %H:%M:%S"), - "epoch": round(time.time(), 1), - "goals": blocks, # 🔴 tab 的数据源(一格一个目标) - "wakeups": [{"ts": w.get("ts"), "kind": w.get("kind") or "-", "http": w.get("http"), "ok": w.get("ok")} - for w in _tail_jsonl(INBOX / "wakeups.jsonl", 10)], - # 🔴 「前置」整块由**使用方**提供(见 `board_ext`)—— 技能侧只负责把它渲染出来。 - # ⛔ 技能里不许出现任何项目的端口/路径/真名/历史。 - "front": _ext_d, - "runtime": _rt, # 🔴 实时状态(架构图用) - "notify": {"awaiting": qi.get("awaiting") or "", "phase": qi.get("phase") or "", - "sent": qi.get("sent") or "", "notice": qi.get("notice") or ""}, - "warn": warn, # ⛔ 降级不静默:界面要显示"哪一块没读到" - "meta": {"supervise_interval": C.get("supervise_interval", 30), - "queue_idle_min": 5, "wake_min_gap": C.get("wake_min_gap", 300), - "workspace": str(WS), "inbox": str(INBOX), - # 🔴🔴 2026-10-03 **投递退役标记**(用户口径「没用了就删除」): - # 唤醒会话/跟进会话/队列上报整套退役 ⇒ 队列变化不再自动通知任何人。 - # ⛔ 这是**机读判据**:前端靠它把「队列通知」那张卡画成退役说明,而不是三个空格子 - # (⛔ 别改成"卡片直接删掉"—— 那会让"机制退出了"变成无声无息)。 - "deliver_retired": "2026-10-03", - # 🔴 多目标:看板据此知道"要不要画 tab 条"(1 个目标 ⇒ 不画,与旧版一致) - "goals_dir": str(GOALS_DIR), "goal_count": len(blocks)}, - }) - return out - - - -def _port_owner(port: int): - """监听 `127.0.0.1:` 的 PID(字符串);查不到 ⇒ None。只认 LISTENING。 - - ⚠️ 用 `netstat -ano` 而非 `pkill -f`(后者匹配 argv,会误伤)。返回 `None` 与"确无进程"**不区分** - —— 调用方一律按"没能定位到旧实例"处理(⛔ 不据此下"一定没有别人在跑"的结论)。 - """ - try: - import subprocess - out = subprocess.run(["netstat", "-ano"], capture_output=True, text=True, - timeout=10, errors="replace", - creationflags=0x08000000).stdout or "" # CREATE_NO_WINDOW:⛔ 闪窗 - except Exception: - return None - key = "127.0.0.1:%d " % int(port) - for ln in out.splitlines(): - if "LISTENING" in ln and key in ln: - p = ln.split() - if p: - return p[-1] - return None - - -def _live_board(port: int, timeout: float = 1.2) -> bool: - """同端口上是否已有**本看板**在跑:`/healthz` 能连上 **且** body 是我们的签名。 - - ⛔ 判据必须收紧:① ⛔ 不能只认「端口开着」(别人也可能占这个口) - ② ⛔ 不能只认 HTTP 200(2026-09-30 实测:本机有服务在 `/api/v1/health` 上返 200, - body 却是 `{"ret":1,"version":"3"}`)。✅ 签名 = 同时含 `"ok"` 与 `"snapshots"` - —— 本看板的 `/healthz` 是这两个键,别的服务不会同时给。 - """ - import urllib.request - try: - with urllib.request.urlopen("http://127.0.0.1:%d/healthz" % int(port), timeout=timeout) as x: - body = x.read(400).decode("utf-8", "replace") - except Exception: - return False - return ('"ok"' in body) and ("snapshots" in body) - - -def _stop_pid(pid: str) -> bool: - """按 PID 强停(只给 `--takeover` 用;⛔ 不在其它路径上调用)。返回是否成功。""" - try: - import subprocess - r = subprocess.run(["taskkill", "/F", "/PID", str(pid)], capture_output=True, - text=True, timeout=15, errors="replace", - # 🔴 `taskkill` 是**控制台程序** ⇒ 不加 CREATE_NO_WINDOW 每次必闪黑窗 - #(2026-10-02 实测补上:本文件最后一处漏网的显窗调用, - # AST 全量扫 `subprocess.run` 缺 creationflags 时它被捞出来) - creationflags=0x08000000) - return r.returncode == 0 - except Exception: - return False - - -def _find_other_boards(port: int) -> list: - """🔴🔴 **本机是不是已经有别的看板实例在跑**(⛔ 跨端口也认)—— 2026-10-05 加。 - - ## 为什么必须有它(用户 2026-10-05 逐字) - - > 「**看板全局也只能启动一个**」 - - 🔴 **病根**:原来的护栏 `_live_board(port)` **只按端口判** ⇒ - 8788 上有实例、你再去起 20099 ⇒ **照起不误** ⇒ 两个实例各答各的快照 - (实测撞到:用户书签 8788 拿到的是 09:44 的**陈旧静态快照**、目标显示为空; - 而真正在跑的实例在 20099 上,数据是新的)。 - ⚠️ 更坏的情况:两个实例**不同代码版本** ⇒ 同一个 URL 反复刷新拿到不同结论。 - - ✅ 正解=**全局判据**:扫进程表找**所有** `board.py --serve` 的 pythonw 实例 - (⛔ 不只是本端口)⇒ 已有别的 ⇒ 说清楚"它在哪个端口"并按调用方策略处置。 - - ⚠️ 判据取**进程命令行**(`board.py` + `--serve`)而**不是** glob/文件名 - —— 与「查常驻只许问程序自己」同族:⛔ 不猜、直接读系统给的事实。 - - @return `[{"pid","port"}...]`(⛔ 不含 `port` 自己那条) - """ - out = [] - try: - import subprocess - import json as _json - # 🔴 用 PowerShell 的 CIM(⛔ 不用 wmic —— 新版 Windows 已移除) - # + `-NoProfile`(⛔ 否则加载用户 profile 慢且可能报错) - ps = ("Get-CimInstance Win32_Process -Filter \"Name='pythonw.exe'\" | " - "Where-Object { $_.CommandLine -like '*board.py*' -and " - "$_.CommandLine -like '*--serve*' } | " - "ForEach-Object { \"$($_.ProcessId)`t$($_.CommandLine)\" }") - r = subprocess.run(["powershell", "-NoProfile", "-NonInteractive", "-Command", ps], - capture_output=True, text=True, timeout=15, errors="replace", - creationflags=0x08000000) - for ln in (r.stdout or "").splitlines(): - ln = ln.strip() - if not ln or "\t" not in ln: - continue - _p, _cl = ln.split("\t", 1) - try: - _pid = int(_p) - except Exception: - continue - if _pid == os.getpid(): - continue - _m = re.search(r"--serve\s+(\d+)", _cl) - _pt = int(_m.group(1)) if _m else 0 - if _pt and _pt == int(port): - continue # 同端口那条由 `_live_board` 管,⛔ 这里不重复 - out.append({"pid": _pid, "port": _pt}) - except Exception: - pass - return out - - -def serve(port: int = 8788, interval: float = 3.0, takeover: bool = False) -> int: - """🔴 本地看板服务(**只绑 127.0.0.1**)。⛔ 不引第三方、⛔ 不开对外端口、⛔ 不写任何账本。 - - 🔴🔴 **总则:看板不能影响程序执行**(用户 2026-09-30 明令:「看板不能影响程序执行,可以**异步**、 - 可以**延迟**」)。据此,请求路径与数据生产**彻底解耦**: - · **一个后台线程**每 `interval` 秒生成一次快照 → 存进**内存缓存** - · **请求线程只吐缓存字节** —— ⛔ 不碰 DB、⛔ 不读文件、⛔ 不做任何可能阻塞的事 - ⇒ 开 10 个标签页 = 10 次内存读;宿主库的查询频率**恒定**为 `1/interval`,与页面数无关 - · 刷新失败 ⇒ **保留上一份快照**(界面显示"延迟 N 秒")⇒ ⛔ 绝不 500、⛔ 绝不给空板 - · `--interval` 可取大(实时性换零负担);页面按同一 `interval` 自取,⛔ 不自行加频 - """ - import threading - from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer - - html_p = HERE.parent / "assets" / "board.html" - cache = {"bytes": None, "at": 0.0, "err": "", "n": 0} - - def _refresh_loop(): - """唯一的生产者(后台守护线程)。⛔ 它出任何事都只影响"数据新不新",⛔ 不影响服务存活。""" - while True: - try: - d = build() - try: - _hsig = "%d.%d" % (os.path.getmtime(html_p), os.path.getsize(html_p)) - except OSError: - _hsig = "0" - # 🔴 `html_sig`:架构图的**布局与样式全在 board.html 里**,而页面只轮询本 JSON - # ⇒ 改了 HTML,不刷新页面就看不到(用户 2026-09-30 为此连问两次)。 - # 页面拿这个签名做"变了就自动重载",⛔ 不再需要用户手动 F5。 - d["board"] = {"refresh_interval": interval, "generated_at": round(time.time(), 1), - "readonly": True, "html_sig": _hsig} - cache["bytes"] = json.dumps(d, ensure_ascii=False, indent=1).encode("utf-8") - cache["at"] = time.time() - cache["err"] = "" - cache["n"] += 1 - except Exception as e: # ⛔ 不清旧快照:宁可给"旧的"也不给"空的" - cache["err"] = str(e) - time.sleep(max(0.5, float(interval))) - - threading.Thread(target=_refresh_loop, daemon=True).start() - - class H(BaseHTTPRequestHandler): - protocol_version = "HTTP/1.1" - - def _send(self, code, body: bytes, ctype: str): - try: - self.send_response(code) - self.send_header("Content-Type", ctype) - self.send_header("Content-Length", str(len(body))) - self.send_header("Cache-Control", "no-store") - self.end_headers() - self.wfile.write(body) - except Exception: # 客户端提前断开 ⇒ 静默(⛔ 不刷日志) - pass - - def do_GET(self): # noqa: N802 - path = self.path.split("?")[0] - if path in ("/", "/board.html", "/index.html"): - try: - self._send(200, html_p.read_bytes(), "text/html; charset=utf-8") - except Exception as e: - self._send(500, ("board.html 读不到:%s" % e).encode("utf-8"), - "text/plain; charset=utf-8") - elif path == "/board.json": - body = cache["bytes"] - if body is None: # 冷启动:第一份还没出来 - body = json.dumps({"warming": True, - "board": {"refresh_interval": interval}}, ensure_ascii=False).encode("utf-8") - self._send(200, body, "application/json; charset=utf-8") - elif path == "/healthz": - self._send(200, json.dumps({"ok": True, "snapshots": cache["n"], - "age": round(time.time() - cache["at"], 1), - "err": cache["err"]}, ensure_ascii=False).encode("utf-8"), - "application/json; charset=utf-8") - else: - self._send(404, b"not found", "text/plain; charset=utf-8") - - def log_message(self, *a): # ⛔ 静默(不刷屏、不唤醒宿主) - pass - - # 🔴🔴 单实例护栏(2026-09-30 加,治「多实例打架」)—— ⛔ 别删,原因见文件头。 - # · 默认:端口上已有本看板 ⇒ **拒绝启动**(避免"随机应答不同快照/不同代码版本") - # · `--takeover`:显式接管 —— 先停掉旧实例,再起本实例 - # 🔴🔴 2026-10-05 加强:**全局**判据(用户逐字:「**看板全局也只能启动一个**」)。 - # 病根:原来只按**端口**判 ⇒ 8788 有实例、去起 20099 **照起** ⇒ 两实例各答各的。 - # 实测后果:用户书签 8788 拿到 09:44 的**陈旧快照**(目标显示为空), - # 而真正在跑的实例在 20099(数据是新的)⇒ 用户以为"目标没了"。 - _others = _find_other_boards(port) - if _others: - if takeover: - for _o in _others: - _okx = _stop_pid(str(_o["pid"])) - print("%s 接管:已停**别端口**上的看板 PID %s(端口 %s)(%s)" - % ("✓" if _okx else "⚠️", _o["pid"], _o["port"] or "?", - "成功" if _okx else "失败")) - sys.stdout.flush() - time.sleep(1.0) - else: - _o0 = _others[0] - print("⛔ 本机已有看板在跑(127.0.0.1:%s,PID %s)⇒ **本次不启动**" - % (_o0["port"] or "?", _o0["pid"])) - print(" · 看板**全局只允许一个**(用户 2026-10-05 口径)—— 多实例会各答各的快照。") - print(" · 直接用它:http://127.0.0.1:%s/ ,⛔ 不用再起一个。" % (_o0["port"] or "?",)) - print(" · 要换到本端口/换新代码 ⇒ 加 `--takeover`(先停旧实例再接管)。") - sys.stdout.flush() - return 0 - if takeover: - _own = _port_owner(port) - if _own and _own != str(os.getpid()): - _ok = _stop_pid(_own) - print("%s 接管:已停旧看板 PID %s(%s)" % ("✓" if _ok else "⚠️", _own, - "成功" if _ok else "失败,见 taskkill 输出")) - sys.stdout.flush() - time.sleep(1.0) # 等端口真正释放 - elif _live_board(port): - _own = _port_owner(port) - print("⛔ 已有看板在跑(127.0.0.1:%d%s)⇒ **本次不启动**,避免多实例打架。" % - (int(port), (",PID %s" % _own) if _own else "")) - print(" · 只是想看板子 ⇒ 直接用 http://127.0.0.1:%d/ ,⛔ 不用再起一个。" % int(port)) - print(" · 要换上新代码 ⇒ 加 `--takeover`(先停旧实例再接管),或先停掉它。") - sys.stdout.flush() - return 0 - srv = ThreadingHTTPServer(("127.0.0.1", int(port)), H) - srv.daemon_threads = True - print("看板已起:http://127.0.0.1:%d/ (只绑回环 · 每 %ss 异步快照 · 请求零阻塞)" % (int(port), interval)) - sys.stdout.flush() - try: - srv.serve_forever() - except KeyboardInterrupt: - pass - finally: - srv.server_close() - return 0 - - -def main() -> int: - if "--serve" in sys.argv: - i = sys.argv.index("--serve") - p = 8788 - if i + 1 < len(sys.argv) and sys.argv[i + 1].isdigit(): - p = int(sys.argv[i + 1]) - iv = 3.0 - if "--interval" in sys.argv: # 想更省 ⇒ 调大(实时性换零负担) - j = sys.argv.index("--interval") - if j + 1 < len(sys.argv): - try: - iv = max(0.5, float(sys.argv[j + 1])) - except Exception: - iv = 3.0 - return serve(p, iv, takeover=("--takeover" in sys.argv)) - out = INBOX / "board.json" - if "--out" in sys.argv: - i = sys.argv.index("--out") - if i + 1 < len(sys.argv): - out = Path(sys.argv[i + 1]) - INBOX.mkdir(parents=True, exist_ok=True) - d = build() - out.write_text(json.dumps(d, ensure_ascii=False, indent=1), encoding="utf-8") - print("板快照已写:%s(%d 字节)" % (out, out.stat().st_size)) - print(" 目标:%s | %s" % (d["goal"]["title"][:40], _acc_summary(d["goal"]["acceptance"]))) - _f = d.get("front") or {} - _ch = _f.get("chips") or [] - print(" 前置(%s):%s" % ( - _f.get("title") or "—", - " | ".join("%s=%s" % (c.get("label"), "上线" if c.get("up") else "离线") for c in _ch) - or (_f.get("_missing") or "(使用方未配 board_ext)"))) - print(" 台账:%s" % {k: v["state"] for k, v in d["tasks"].items()}) - _r = d.get("runtime") or {} - print(" 实时:常驻程序=%s | 上报=%s" % ((_r.get("prog") or {}).get("label"), - (_r.get("deliver") or {}).get("label"))) - if d.get("warn"): - print(" ⚠️ 降级(不静默):%s" % ";".join(d["warn"])) - return 0 - - -if __name__ == "__main__": - raise SystemExit(main()) +#!/usr/bin/env python +# -*- coding: utf-8 -*- +"""board.py —— 生成**看板快照** `board.json`(供 `assets/board.html` 实时渲染)。 + +设计原则: + · **只读**:只读台账/状态/投递台账/宿主库(三张只读表)。(组件探测属**使用方**,见 board_ext)⛔ 不写任何账本。 + · **零依赖**:只用标准库。⛔ 不引第三方。 + · **快**:一轮 < 0.1 s(socket 探测 0.3 s 超时上限;宿主库只读一条 SQL)。 +🔴 **总则:看板不能影响程序执行**(用户 2026-09-30 明令)。三条落地: + ① **解耦**:常驻程序/守护程序**一行都不引用本文件**(已核);本文件**从不写任何账本**。 + ② **异步**:`--serve` 由**后台线程**按 `--interval` 秒产快照,**请求线程只吐内存缓存** + ⇒ 请求路径 ⛔ 不碰 DB/⛔ 不读文件 ⇒ 开多少标签页都不增加宿主负载。 + ③ **降级不静默**:任何一块读不到 ⇒ 记进 `warn` 并在界面显示,⛔ 不伪装成"0 个会话"。 + +用法: + python board.py # 产一次 board.json(默认写到 inbox/board.json) + python board.py --out <路径> + python board.py --serve [端口] [--interval 秒] # 起本地只读看板(默认 8788 / 3 秒) + python board.py --serve 8788 --takeover # 显式"接管":先停旧实例再起(换新代码时用) + +🔴🔴 **单实例护栏**(2026-09-30 加,治「多实例打架」): + Windows 的 `SO_REUSEADDR`(`ThreadingHTTPServer` 默认 `allow_reuse_address=1`)允许**同端口重复绑定 + 且不报错** ⇒ 多个 `--serve` 会**静默并存**,同一个 URL 被不同进程随机应答 ⇒ 快照/代码版本互相打架 + (实拍:8788 已被占用时,第二个实例照样打印"看板已起")。 + ⇒ 现规则:**端口上已有本看板 ⇒ 默认拒绝启动**(提示复用现成那个);要换代码用 `--takeover`。 +""" +from __future__ import annotations +# [session-mechanism] roots.env 外置(由 install.py 生成;缺失则回落到按位置推导) +def _sm_load_roots(): + import os as _os + _here = _os.path.dirname(_os.path.abspath(__file__)) + for _up in range(4): + _p = _os.path.join(_here, *([".."] * _up), "roots.env") + _p = _os.path.normpath(_p) + if _os.path.isfile(_p): + try: + with open(_p, encoding="utf-8") as _f: + for _ln in _f: + _ln = _ln.strip() + if _ln and not _ln.startswith("#") and "=" in _ln: + _k, _v = _ln.split("=", 1) + _os.environ.setdefault(_k.strip(), _v.strip()) + except Exception: + pass + return +_sm_load_roots() +# 🔴 2026-10-01 加 · **输出编码兜底**:脚本一旦被重定向(钩子/常驻/后台任务都会这么干), +# Windows 本地编码(GBK)编不出 ⛔/✅/🔴 这类字符 ⇒ `print` 抛 UnicodeEncodeError +# ⇒ 被顶层 handler 记成 `fatal`、**整轮失败**(实测:本包里连续 4 次 `fatal 'gbk' codec ...`)。 +# ⇒ 出口一律 UTF-8 + errors="replace"(⛔ 不让"打不出字"升级成"程序死")。 +try: + import sys as _sys + _sys.stdout.reconfigure(encoding="utf-8", errors="replace") + _sys.stderr.reconfigure(encoding="utf-8", errors="replace") +except Exception: + pass + + +import glob +import hashlib +import json +import os +import re +import socket +import sqlite3 +import sys +import time +from pathlib import Path + +# 🔴🔴 2026-10-03 10:2x **命名判据与 `collabd.py` 共用同一实现**(不是抄一遍)。 +# 为什么:`_role_of_title()` ⇄ `parse_session_name()` 这两侧**已漂过三次** +# (唤醒/跟进/接续各一次,每次都要靠对账用例才发现)⇒ 这次从**结构**上断掉漂移的可能。 +# ⚠️ 走 `importlib` 按路径加载同目录 `collabd.py`:⛔ 不写进 `sys.path` +# (那会污染宿主进程能 import 到的名字空间);⛔ 也不复制判据(那就是第 N 次漂移的种子)。 +# ⚠️ 加载失败 ⇒ `_is_check_agent` 回落成"只认新名"的保守实现(⛔ 宁可少判、不可崩)。 +try: # pragma: no cover - 环境相关 + import importlib.util as _ilu + + _sp = _ilu.spec_from_file_location("_cb_shared", str(Path(__file__).resolve().parent / "collabd.py")) + _cb = _ilu.module_from_spec(_sp) + _sp.loader.exec_module(_cb) # ⛔ collabd 顶层不读配置(配置在函数里懒加载)⇒ 安全 + _is_check_agent = _cb.is_check_agent + # 🔴🔴 2026-10-04 21:5x **「已退役角色」闸也从同一模块取**(用户拍板「候选一」)。 + # 为什么从模块取而不是抄一份字面量:`_RETIRED_PFX` 是**两侧共用的唯一事实源**, + # 抄一份 ⇒ 立刻变成"第 N 次漂移"的种子(这两侧已漂过三次)。 + # ⚠️ 判据本身=**一级方括号是退役角色词**(`[跟进]`/`[唤醒]`),⛔ 不是"角色解析为空" + # —— 空串里还有真主会话(现役 `a80f300d`「复盘失败并避免重犯」就是无前缀的)。 + _is_retired_role_title = _cb.is_retired_role_title + # 🔴 2026-10-03 11:40x:`_pid_alive` 也**从同一模块取**(⛔ 同一模块名 `_cb_shared` + # 在本文件里被重复 spec 两次会拿到**两个独立模块对象** ⇒ ⛔ 判据会漂)。 + # ⇒ 这里统一取模块、再从它上面取函数。 + _cb_shared = _cb +except Exception: # pragma: no cover + def _is_check_agent(name: str) -> bool: + """⚠️ 兜底:只认**新名**(带方括号)—— ⛔ 宁可少判一条,不可让看板崩。""" + return any(t in str(name or "") for t in ("[结果检查]", "[目标检查]")) + + def _is_retired_role_title(name: str) -> bool: + """⚠️ 兜底(与 `collabd.py` 同判据的保守复刻):只认**一级方括号里的退役角色词**。 + ⛔ 只在 `collabd.py` 加载失败时才会用到这里 —— 宁可少判,不可让看板崩。""" + m = re.match(r"^\[([^\]]*)\]\s*", str(name or "").strip()) + return bool(m) and m.group(1).strip() in ("跟进", "唤醒") + +HERE = Path(__file__).resolve().parent +CFG_P = Path(os.environ.get("COLLABD_CONFIG") or (HERE / "collabd.config.json")) +WS = Path(os.environ.get("DSH_COLLAB_WS") or "").resolve() if os.environ.get("DSH_COLLAB_WS") else None + + +def _cfg() -> dict: + try: + return json.loads(CFG_P.read_text(encoding="utf-8")) + except Exception: + return {} + + +C = _cfg() +if WS is None: + WS = Path(str(C.get("workspace") or "")).resolve() +INBOX = WS / str(C.get("inbox") or "tmp/supervise-inbox") + + +def _j(p: Path, dv=None): + try: + return json.loads(p.read_text(encoding="utf-8")) + except Exception: + return dv + + +def _tail_jsonl(p: Path, n: int = 10) -> list: + try: + with open(p, "rb") as f: + f.seek(max(0, os.path.getsize(p) - 65536)) + ls = f.read().decode("utf-8", "replace").strip().splitlines() + out = [] + for ln in ls[-n:]: + try: + out.append(json.loads(ln)) + except Exception: + pass + return out + except Exception: + return [] + + +def _host_db() -> Path | None: + p = str(C.get("host_db") or "") + if p and os.path.isfile(p): + return Path(p) + d = os.environ.get("CODEBUDDY_CONFIG_DIR") or os.path.expanduser("~/.workbuddy") + q = Path(d) / "workbuddy.db" + return q if q.is_file() else None + + +def _main_sid(st: dict) -> str: + """主会话 sid。🔴 **与 `collabd.py::_main_sid()` 必须逐字同款**(判据只此一处权威: + ① 声明为 `main` 的角色 ② 退回最近一次投递到的会话 ③ 都取不到 ⇒ 空串,⛔ 不猜)。 + + 🔴🔴 2026-10-01 修(**两处同批改**):回落**只认"上次投的是主会话"那一次** —— + `wake.target == "follow"` 说明 `wake.sessionId` 记的是**跟进会话**(投递目标已改指跟进会话) + ⇒ 直接**当没有回落**(返回空串)。⛔ 不改的话,投过一次之后**跟进会话会被读成主会话**。 + """ + for _sid, _r in (st.get("roles") or {}).items(): + if str(_r) == "main" and str(_sid).startswith(tuple("0123456789abcdef")): + return str(_sid) + _w = st.get("wake") or {} + if str(_w.get("target") or "") == "follow": + return "" + return str(_w.get("sessionId") or "") + + +def _same_ws(cwd: str, ws_root=None) -> bool: + """`cwd` 是否就是**本工作区**(斜杠/大小写无关)。⛔ 仅用于**主会话解析**,⛔ 不用于"会话归属本项目"。 + + 🔴🔴 2026-10-05 **加 `ws_root` 形参**(peer 格修复,见 `_scan_ws_mains()` docstring): + ⛔ 不传 ⇒ 仍比对本看板的 `WS`(**本区行为逐字不变**); + ✅ 传了 ⇒ 比对**那个工作区**的根 —— peer 格必须用它,否则对方主会话**永远解析不出**。 + """ + a = str(cwd or "").replace("\\", "/").rstrip("/").lower() + b = str(ws_root if ws_root is not None else WS).replace("\\", "/").rstrip("/").lower() + return bool(a) and a == b + + +# 🔴 主会话的**显式前缀**(用户 2026-09-30 定名:「主会话前缀 可以叫 主控」)。 +# ⚠️ 实测现役主会话写成 `主控 · 协作机制 · …` —— **中点分隔、无方括号**。 +MAIN_PREFIX = "主控" + + +def _role_of_title(name: str) -> str: + """标题 ⇒ 会话角色(`main`/`worker`/`waker`/`follow`/`""`)。 + + 🔴 **⛔ 与 `collabd.py::parse_session_name()` 必须逐条同款**(本文件此后是"两处同款"的第 2 处)。 + `selftest.py::看板与常驻程序:命名判据不许漂` 会**真的把两边的函数拉出来对账**,⛔ 不许一边改一边不改。 + + **四种形态**(判据只看标题:⛔ 不看 cwd 归属、⛔ 不看 status) + ① `[角色]-[类别]-<具体>`(合规) ② 一级前缀 `[协作]…` + ③ 🆕 **接续会话**:`[<类别>] 接续 · …` / `接续棒:…` ⇒ **worker** + ④ 🆕 **主控前缀**:`主控 · <类别> · …`(中点分隔、无方括号)⇒ **main** + + 🔴 为什么必须认 ③:接续会话是**会话自己建的新会话**,标题常**没有角色方括号**; + 旧判据只排 `[协作]` ⇒ 它会被当**主会话候选**(且标题里的 `[<类别>]` 又让它变成 + "该类别的主会话")⇒ 通知**投给它自己**(自指死结,2026-10-01 实测)。 + ⛔ 认不出 ⇒ 空串(⛔ 不猜、⛔ 不回落 cwd 推断 —— 架构 §2.3)。 + """ + nm = str(name or "").strip() + if not nm.startswith("["): + head = nm.split("·")[0].split(":")[0].split(":")[0].strip() + if head.startswith(MAIN_PREFIX): + return "main" + # 🔴🔴 2026-10-03 10:2x **接续棒与检查会话都判 worker**(⛔ 顺序有讲究:接续在前)。 + # 检查会话=**常驻程序**建、它自己派执行棒 ⇒ 是干活的那条。 + # ⚠️ 旧式光杆名(`结果检查-…`,10:1x 之前建的)会落到这里 ⇒ 之前返 `""` + # ⇒ 被 `_scan_ws_mains()` 收进**主会话候选** ⇒ 本文件 `_role_label()` 判据①命中 + # ⇒ 看板显示「主会话」(实测 `44b547d4`)⇒ 可能**投错窗口**。 + # ✅ 判据**直接复用 `collabd.py` 的实现**(`import` 同一个函数,不是抄一遍) + # —— 这两侧历史上已漂过三次 ⇒ 这次从结构上断掉漂移的可能。 + # 🔴 2026-10-03 11:1x `worker` ⇒ **`check`**(属常驻程序,⛔ 不是任务会话) + if _is_check_agent(nm): + return "check" + return "worker" if "接续" in nm else "" + _m0 = re.match(r"^\[([^\]]*)\]\s*(.*)$", nm) + r = _m0.group(1).strip() if _m0 else "" + # 🔴🔴 2026-10-02 23:4x **会话类别从四类收敛为两类**(用户:「按照新的逻辑整体修改」/ + # 「唤醒会话 跟进会话 和 上报程序 都去掉才对」/「上报机制也不需要了」)。 + # ⇒ 只认 `主` / `协作` 两键;`唤醒` / `跟进` **不再映射**。 + # ⛔ 历史 `sessions` 旧行(那些标题还在)**照旧显示在 `sessions` 列表里**, + # 但**不再是活类别** ⇒ 解析出 `""` ⇒ 不会被误收成主会话候选。 + # 🔴 **必须与 `collabd.py::parse_session_name()` 逐条同款** —— 见上文那行红线。 + # 🔴 2026-10-03 11:1x 新增 `检查` 键(⛔ 与 `collabd.py::parse_session_name()` 同款) + # 🔴🔴 2026-10-03 19:5x **「白名单」与「映射表」合并成一个 dict** —— + # 原来这里是 `if r in ("主","协作","检查"):` + 下一行一张映射表,**两处都要改**: + # 我只改了映射表 ⇒ 新前缀 `[任务会话]` 被白名单挡在门外 ⇒ 解析出 `""` + # ⇒ 看板认不出这条会话(**本轮实测当场发现**)。 + # 🔴 **判据纪律**:前缀表**只留一处**(dict 本身就是白名单),⛔ 不许再拆成"先判在不在、再查映射"。 + # ⚠️ 必须与 `collabd.py::parse_session_name()` 逐条同款(它用 `.get(r, "")` ⇒ 白名单即 dict 本身)。 + # 🔴🔴 2026-10-03 23:4x **「协作」→「执行」改名(用户定案:「**把协作会话 改为 执行会话,协作 改为 执行**」)** + # —— **沿用 19:2x 那次「显示层改名、机制层不动」的正确范式**,但**这次多留一个新前缀**: + # · 显示层:`worker` 标签 → **「执行会话」**;`main` → 「主会话」不变。 + # · 识别层:**新增 `执行` 前缀**(新排期用它),⛔ **`协作`/`任务会话` 两个旧前缀必须保留** + # (⚠️ 实测宿主库有 **53 条排期 + 45 条会话**标题带 `[协作]` ⇒ 删掉映射 + # ⇒ 它们全部解析成 `""` ⇒ **看板认不出、派活漏管**,且**不可逆**)。 + # 这正是 19:2x 注释里那条「改前缀会让现存历史会话认不出来」的教训。 + # · 机制内部标识 `worker` 一律不动(⛔ 它是代码里的角色 id,不是人看的词)。 + # 🔴🔴 2026-10-05 **用户定案:「协作 全部 改为执行」(连说两遍)** —— 新建前缀一律 `[执行]`。 + # ⛔⛔ 下表**只许增、不许删**:删任一条 ⇒ 存量会话/排期解析成 `role=""` + # ⇒ **看板画不出、派活漏管**,且**不可逆**。 + # ⚠️ 必须与 `collabd.py::parse_session_name()` 的 role 表**逐条同款** + # (`collabd.py:4863` 附近 + `selftest.py` 逐样本对账,⛔ 漂了就红)。 + _PFX = {"主": "main", + "执行": "worker", # 🆕 新前缀(当前口径) + "协作": "worker", # ⛔ 旧前缀,保留兼容(库里有 53+45 条在用) + "协作目标": "worker", # ⛔ 旧前缀,保留兼容(二代 10-03 短暂形态) + "任务会话": "worker", # ⛔ 旧前缀,保留兼容 + "检查": "check"} + if r in _PFX: + return _PFX[r] + # 🔴🔴 2026-10-01 修**第三处判据漂移**(实测扫出来的,不是推理): + # 原来只查 `_m0.group(2)`(方括号**后面**那段),于是 + # `[接续] 会话机制合并技能包(第 1 棒)` —— 方括号里是「接续」、后面那段里没有 + # ⇒ 本函数返回 **空**,而 `collabd.py::is_continuation()` 判据是 + # `"接续" in name`(**整串**)⇒ 返回 **worker**。 + # 后果(线上正发生):本函数返回空 ⇒ `_scan_ws_mains()` 的排除元组拦不住它 ⇒ + # 它被收进**主会话候选** ⇒ 看板的「主会话」位上坐着一条 **接续棒**(`3aa35bae`,355 分钟没动) + # —— 而它其实是**干活的棒**,正是 `is_continuation()` 注释里那个"自指死结"的同一族。 + # ⇒ 判据改成**整串**判:`"接续" in nm` —— + # 逐字同款 `collabd.py::is_continuation()`(⛔ 两处必须同款,`selftest.py` 有对账用例)。 + if "接续" in nm: + return "worker" + return "" + + +# 🔴 角色标签(**2026-10-02 两类口径**:① 主会话 ② 任务会话 —— 唤醒/跟进/上报整套退役)。 +# ⚠️ 与 `board.html` 的渲染判据**必须同款**(那边按角色挑格子)。 +# ⛔ `waker` / `follow` 两个键**刻意保留在表里**(⛔ 不删,删了旧会话渲成空标签更难读), +# 但**任何新会话都不会再被判成这两个角色**(见 `_role_of_title`)。 +# 🔴🔴 2026-10-03 11:1x **新增 `check`=「检查会话」**(用户纠正逐字: +# 「检查会话**不是协作会话**,**不应该出现在看板协作会话区域中**。 +# 检查会话属于**协作程序的会话**(本来也是协作程序创建),**可以放在协作程序框图中展示」)。 +# ⚠️ 之前 `collabd.py` 把它判成 `worker` ⇒ 混进任务会话那一排 ⇒ **正是用户指出的错误**。 +# ⛔ 不并入 `协作`(并进去前端就会画错地方)⇒ **独立一类**。 +_ROLE_LABEL = {"main": "主会话", + # 🔴🔴 2026-10-03 23:4x **显示层继续改名:「协作目标」→「执行会话」** + # (用户定案:「**把协作会话 改为 执行会话,协作 改为 执行**」; + # 19:2x 那次改的「协作目标」正是这条链上的中间态)。 + # ⚠️⚠️ **这个值同时是前端判据**(`board.html` 里 `role==='任务会话'` 那一排)⇒ + # **改它必须连前端一起改**,⛔ 只改一边 ⇒ 那一排会**整排空掉**(19:2x 已踩过)。 + # ⛔ **机制内部标识一律不动**:`worker` 角色 id、`[协作]`/`[执行]` 前缀的**识别映射** + # 全部保持 —— 前缀映射见上面 `_PFX`(旧名兼容不能删,否则存量失管)。 + "worker": "任务会话", + "check": "检查会话", + "waker": "唤醒会话(已退役)", "follow": "跟进会话(已退役)"} + + +def _role_label(sid: str, title: str, sc: dict) -> str: + """会话角色标签 —— **四类**(⛔ 不是"主会话 / 其它"两档)。 + + 🔴🔴 2026-10-01 修:原来这里是**二分**(`"主会话" if 登记/解析出的主会话 else "任务会话"`) + ⇒ **唤醒会话与第④类「队列上报的跟进会话」一律被标成「协作会话」** ⇒ + `board.html` 第三层(按 `role` 挑格子)会把它们**误画进"任务会话"那一排**: + · 唤醒会话会**画两遍**(一次在主会话左侧的触发源位、一次在这排里); + · 跟进会话则**顶着一个错名字**(读者以为它是任务会话 ⇒ 分不清"谁在干活")。 + ⚠️ 这个缺陷**一直存在但看不见** —— 直到真的建出这两类会话才会显形。 + + 判据优先级(⛔ 只看标题与主会话登记,⛔ 不回落 cwd —— 架构 §2.3): + ① 命中主会话登记/解析结果(`main_sid` / `main_sids`)⇒ **主会话**; + ② 否则按标题解析(`_role_of_title()`)⇒ 唤醒会话/跟进会话/任务会话; + ③ 解析不出(老式命名、接续棒)⇒ 回落**协作会话** —— 它们本来就是干活的棒,旧行为逐字不变。 + """ + if sid == sc.get("main_sid") or sid in (sc.get("main_sids") or []): + return "主会话" + # 🔴 2026-10-03 19:2x **回落值跟着角色表走**(⛔ 原来写死 `"任务会话"`;改名后它会与 + # `_ROLE_LABEL["worker"]` 不一致 ⇒ 同一个人被标成两个词)。 + return _ROLE_LABEL.get(_role_of_title(title), _ROLE_LABEL["worker"]) + + +def _scan_ws_mains(st: dict, topics=None, ws_root=None) -> dict: + """🔴 扫一遍宿主库,挑出**本工作区里所有"可能是主会话"的会话**(按最近活动倒序)。 + + 🔴🔴🔴 2026-10-05 **加 `ws_root` 形参**(用户报障逐字:「**peer 工作区的主会话还是找不到对应的吗**」)。 + + **根因**:本函数(及 `_resolve_main()`)**只认本看板的 `WS`**,而 peer 格(跨工作区 tab) + 渲染的是**别的**工作区 —— 它的会话 `cwd` 指向**对方**目录 + (实测:peer 主会话的 `cwd` = `<对方工作区根>`,而本看板 `WS` = `<本工作区根>`) + ⇒ `_same_ws()` 恒 `False` ⇒ `cand/named/by_topic` **全空** + ⇒ 前端每个类别 chip 都画成「⚠ 无主会话」,而**那条主会话明明活着**。 + + ⚠️ **这是"改一处漏一处"的又一次**(本包已复发多次):`_sessions()` 早就为 peer 修过 + (走 `bypass=True` + `sc["workspace"] = ws_root`,见 `_goal_block()` 注释), + **但同一条数据流上的 `_scan_ws_mains()/_resolve_main()` 没跟上** ⇒ 会话列表对了、主会话仍空。 + + ⛔ 不传 `ws_root` ⇒ 比对本看板 `WS`(**本区行为逐字不变**); + ✅ 传了 ⇒ 只把**"哪个工作区"**换成那个根,**其余判据逐条不动** + (角色排除/接续会话/退休角色/类别取自标题 —— 全部照旧)。 + + 🔴 与 `collabd.py::_scan_mains()` **同款判据**(⛔ **改一处必须改两处** —— 漂了就没人能发现主会话换了): + · 只看 `cwd == 目标工作区`(⛔ 不回落其他推断) + · ⛔ **排除全部"协作侧"命名**:`[协作]-…`/`[唤醒]-…`/🆕 `[跟进]-…`/**接续会话** + (`[<类别>] 接续 · …`)—— 四者都是**干活的棒**,⛔ 不是主会话候选 + (🔴 2026-10-01 两次扩:先只排 `[协作]` ⇒ 接续棒冒充主会话;再到**按角色排** + 补第④类 `follow`) + · 角色判据见 `_role_of_title()`(⛔ 与 `collabd.py::parse_session_name()` **逐条同款**) + · ⚠️ **不按 `status='working'` 筛**(主会话两轮之间是空闲 ⇒ 筛了就永远漏掉它) + · 类别取自**标题里出现的 `goal.topics` 项**(最长优先,见 `_topic_in_title()`) + + @returns `{"cand": [sid…], "named": [sid…], "by_topic": {类别: {"sid","source","explicit"}}}` + ⚠️ `by_topic` 的值是 **dict**(与 `collabd.py::_scan_mains()` **同款**)—— ⛔ 不是"类别→sid"的裸映射, + 否则调用方会把整个 dict 当成 sid 用(**实测踩到过**)。 + """ + out: dict = {"cand": [], "named": [], "by_topic": {}, "titles": {}} + db = _host_db() + if not db: + return out + # 🔴 `topics` 传入 ⇒ **按这个目标的类别认主会话**(多目标 tab 用); + # ⛔ 不传 ⇒ 回落活跃目标(`goal.json`)⇒ **单目标部署行为逐字不变**。 + tps = _as_list(topics) if topics is not None else _goal_topics() + try: + con = sqlite3.connect("file:%s?mode=ro" % str(db).replace("\\", "/"), uri=True, timeout=0.3) + try: + con.execute("pragma busy_timeout=300") + except Exception: + pass + rows = con.execute( + "select id,cwd,coalesce(custom_title,title,'') from sessions " + "where deleted_at is null order by last_activity_at desc limit 50").fetchall() + con.close() + for r in rows: + t = str(r[2] or "") + # 🔴 2026-10-01 改:从"只排 `[协作]`"改成"**排掉全部"协作侧"命名**"(接续会话 + 唤醒会话), + # 同日再补**第④类 `[跟进]`** —— ⛔ 与 `collabd.py::_scan_mains()` **逐条同款**, + # 理由见那边注释(接续棒/跟进棒冒充主会话=自指死结)。 + # 🔴 2026-10-02 两类改造:排除元组收到 `("worker",)` —— 同 `collabd.py` 那行。 + if not (_same_ws(str(r[1] or ""), ws_root) and _role_of_title(t) not in ("worker",) + and not _is_retired_role_title(t)): + continue + sid = str(r[0] or "") + out["titles"][sid] = t # 🔴 2026-10-05:顺手带回标题(供默认类别兜底判"属不属于别类") + out["cand"].append(sid) + ts = t.strip() + _ex = ts.startswith("主控") or ts.startswith("[主控]") + if _ex: + out["named"].append(sid) + _tp = _topic_in_title(t, tps) + if _tp: + _cur = out["by_topic"].get(_tp) + # 同一类别多条候选 ⇒ ① 显式「主控」优先 ② 否则取**最近活动**(首见即最近) + if _cur is None or (_ex and not _cur.get("explicit")): + out["by_topic"][_tp] = { + "sid": sid, "source": ("prefix:主控" if _ex else "topic"), "explicit": _ex} + except Exception: + pass + return out + + +def _resolve_main(st: dict, topics=None, ws_root=None) -> dict: + """🔴 主会话**解析**(⛔ 不只是读登记)—— **与 `collabd.py::resolve_main()` 必须同款**。 + + 2026-09-30 用户实测报障:「**还是没有把这个会话识别为主会话**」。 + 根因:board.py 当时只走**旧的静态判据** `_main_sid()`(① 登记为 main ② **退回"上次投给谁"**), + 而投递侧 `collabd.py` 早已改成 `resolve_main()` 的**动态解析** ⇒ **两边不同源**: + 主会话一换(或旧主会话停了),投递会跟随、**看板却继续指着旧会话**, + 并且因为 `in_project()` 的①号判据就是 `main_sid` ⇒ **正在用的那个会话连列都进不来**。 + ⇒ 本函数把「按**工作区**认」补上,判据与 collabd 逐条对齐: + ① 本工作区里**显式标了 `主控`** 的 ⇒ 认(用户自己标的,比"最近活动"可信) + ② 否则本工作区里**标题不带 `[协作]`** 且**最近活动**的那条 + ③ 都取不到 ⇒ 退回登记 `_main_sid()`;再没有 ⇒ `""`(⛔ 不猜) + ⚠️ 用户口径(2026-09-30):「以**一个工作区**为主会话的工作区」「下面**应该都可能是主会话**」。 + ⛔ 这与 §2.3「不回落 cwd 推断**项目归属**」不冲突 —— 那条管的是"会话属于哪个需求项目", + 本函数管的是"这条工作区里**哪条是主会话**",是**另一件事**。 + + 🔴🔴 2026-10-05 **加 `ws_root` 形参**(peer 格修复):⛔ 不传 ⇒ 走本看板 `WS`(**本区逐字不变**); + ✅ 传了 ⇒ 主会话候选换成**那个工作区**的会话(否则 peer 格永远是 0 条主会话,见 `_scan_ws_mains()`)。 + ⚠️ **注意 `reg`(登记值)的取向**:peer 格传入的 `st` 来自**对方的** + `collabd-state.json`(`_peer_state()`)⇒ 那份登记值本就是对方的 ⇒ 两边一致,⛔ 不会串台。 + """ + reg = _main_sid(st) + sc = _scan_ws_mains(st, topics, ws_root) + named, cand = sc["named"], sc["cand"] + pick = named[0] if named else (cand[0] if cand else "") + # 🔴🔴 2026-10-05 修 **「主会话在,看板却显示无主会话」**(用户报障逐字: + # 「**现在goal创建成功了 还是没有显示主会话**」)。 + # + # 真因=**同一个事实两套实现**(`collabd.py` 有、`board.py` 没有): + # `by_topic` 只在「**该会话标题里含类别名**」时才被填(`_topic_in_title()`)。 + # 而**建目标的那条会话**(=天然的主会话)标题往往是**口语式、不带类别** —— + # 实测本工作区:主会话标题「排查任务不执行的原因」,`topics=[会话协作自检, 机制排查与修复]` + # ⇒ `by_topic = {}` ⇒ 每个类别 chip 都画成「⚠️ 无主会话」, + # 而**同一个快照里 `main_sid8` 明明就是 `a80f300d`** ⇒ 自相矛盾。 + # + # 投递侧早就定过这条规则(`collabd.py::resolve_mains()` docstring ②,用户 2026-09-30): + # > 某类别没扫到 且 它就是**默认类别**(`topics[0]`)⇒ 回落 `default` + # > (这样单类别时的行为与旧版完全一致,向后兼容) + # > 不是默认类别 ⇒ **sid 留空**(⛔ 绝不回落到别类别 —— 那正是"投错窗口") + # ⇒ 看板侧**漏了同一段兜底** ⇒ 两边对同一份数据给出两套答案。本函数补齐。 + # 🔴 判据:**只兜底默认类别 `topics[0]`,且只在"该主会话确实不属于别的类别"时兜** + # —— ⛔ 不许把主会话安到它没标的**非默认**类别上(那会把看板变成"每个类别都有主会话"的假象)。 + _bt = dict(sc["by_topic"]) + _tps = list(_as_list(topics) if topics is not None else _goal_topics()) + if pick and _tps: + _dft_tp = _tps[0] + if not str((_bt.get(_dft_tp) or {}).get("sid") or ""): + # 该主会话标题若**明确标了别的类别** ⇒ 不兜(避免张冠李戴) + _own_tp = _topic_in_title(str(sc.get("titles", {}).get(pick) or ""), _tps) + if _own_tp in ("", _dft_tp): + _bt[_dft_tp] = {"sid": pick, "source": ("prefix:主控" if named else "workspace"), + "explicit": bool(named)} + # 🔴 `all_sids` = **解析出来的那些**(默认 + 登记 + 各任务类别),⛔ **不是**"本工作区所有会话" —— + # 只有解析出来的才算主会话,否则"本工作区随便开个无关会话"会被当成本项目(判据被无声放宽)。 + _all: list = [] + for _s in ([pick, reg] + [str((_v or {}).get("sid") or "") for _v in _bt.values()]): + if _s and _s not in _all: + _all.append(_s) + out: dict = {"all_sids": _all, "by_topic": _bt} + if pick: + out.update({"sid": pick, + "source": ("prefix:主控" if named else "workspace"), + "switched_from": (reg if (reg and reg != pick) else "")}) + return out + out.update({"sid": reg, "source": ("register" if reg else "no-register"), "switched_from": ""}) + return out + + +def project_scope(g: dict | None = None, ws_root=None) -> dict: + """🔴 **本需求目标项目的身份 + 归属判据**(用户 2026-09-30 明示两件事: + ① 「**要明确哪些会话是属于某个需求目标项目的**」 + ② 「**这个看板上应该明确显示是哪个需求目标项目**」)。 + + 🔴 **判据与 `collabd.py::_in_project()` 同款** ⇒ 看板列的"在跑会话" ≡ `--ready-next` 认的"在跑会话" + (⛔ 否则两边各说各话:看板显示没人跑、收尾确认却被拦)。 + ⛔ **刻意不用 cwd 推断** —— 架构 §2.3 明令「绝不回落到 cwd 推断」。 + + 🔴 2026-10-01 多目标:可选传入 `g`(某个目标)⇒ **按这个目标的类别算归属**(tab 用)。 + ⛔ 不传 ⇒ 回落活跃目标 `goal.json` ⇒ **单目标部署行为逐字不变**。 + + 🔴🔴 2026-10-05 加 `ws_root`(peer 格修复): + · `ws_root` 传空 ⇒ `st` 取**本区**收件箱、主会话按**本区** `WS` 解析(**本区逐字不变**); + · `ws_root` 传了 ⇒ `st` 取**对方**的 `collabd-state.json`、主会话按**对方**根解析 + (⛔ 否则 peer 格永远「无主会话」,而那条主会话明明活着 —— 用户报障逐字: + 「**peer 工作区的主会话还是找不到对应的吗**」)。 + ⚠️ **`workspace` 键仍必须回本区 `WS`**:它被 `in_project()` 当"本看板自己的归属依据"用, + 改它会连带把**会话归属**判错(那是另一条判据,⛔ 不许在本函数里顺手改)。 + """ + g = _goal() if g is None else g + if ws_root: + # peer 格:状态取对方的(⛔ 不是本区 —— 否则登记值串台) + st = _peer_state(str(ws_root)) or {} + else: + st = _j(INBOX / "collabd-state.json", {}) or {} + # 🔴 2026-09-30:主会话**解析**(⛔ 不再是"读登记/上次投给谁"那种静态值)—— + # 与 `collabd.py::resolve_main()` 同源,投递侧跟随谁、看板就显示谁。 + _rm = _resolve_main(st, _goal_topics(g), ws_root) + msid = str(_rm.get("sid") or "") + msids = list(_rm.get("all_sids") or []) # 🔴 多任务类别 ⇒ 本工作区可能有多条主会话 + short = str(g.get("short") or "") + topics = _goal_topics(g) # 🔴 任务类别清单(缺省回落 [short]) + gid = str(g.get("id") or "") + crit = [] # 给人读的判据清单(看板上原样展示) + if msid: + crit.append("主会话 %s(%s)" % (msid[:8], _rm.get("source") or "?")) + if len(msids) > 1: + crit.append("本工作区另有主会话 %d 条(共 %d)" % (len(msids) - 1, len(msids))) + for _t in topics: + crit.append("标题含 [%s]" % _t) + if gid: + crit.append("显式声明 --declare --goal %s" % gid) + return {"goal_id": gid, "short": short, "topics": topics, "title": str(g.get("title") or ""), + "topics_source": _goal_topics_source(g), # 🔴 类别来源(declared/fallback/none) + "main_sid": msid, "main_sids": msids, "workspace": str(WS), "criteria": crit, + # 🆕 每个**任务类别**各自的主会话(`{类别: {sid,source,explicit}}`)—— + # 看板"按类别"渲染要用它(用户:「通过协作会话名称前缀区分具体执行会话」) + "main_by_topic": dict(_rm.get("by_topic") or {})} + + +def in_project(sid: str, title: str, sc: dict) -> bool: + """🔴 **一个会话是否属于本项目**。⛔ **与 `collabd.py::_in_project()` 同款 —— 改一处必须改两处**。 + + 🔴 2026-09-30 **泛化**:归属判据的"标题含 `[<主题>]`"从**单个** `goal.short` 扩到 + **`goal.topics` 清单** —— 用户要求「**同一个工作区**多会话协作(主会话根据任务**自动梳理任务类别**: + **通过协作会话名称前缀的方式区分具体执行会话**)」⇒ 同一工作区里**多个任务类别**的会话 + 都必须被认成"本项目"(⛔ 否则除一个之外**静默漏管**:看板不列、收尾确认也不把它们算进来)。 + ⛔ `sc` 里没有 `topics` ⇒ **回落 `short`**(旧调用点/自测用例原样可用)。 + ⛔ `sc` 里没有 `main_sids` ⇒ 只看 `main_sid`。 + + 🔴 2026-10-01 **判据放宽为三条并列**(详见函数体内的长注释): + ① 主会话 sid 命中 + ② **角色可解析 + 标题出现任一类别**(子串,⛔ 不要求方括号)← 🆕 治「看板没显示协作会话」 + ③ 标题含 `[<任一类别>]`(老写法,向后兼容) + """ + if sid and (sid == sc.get("main_sid") or sid in (sc.get("main_sids") or [])): + return True + ti = str(title or "") + _tps = sc.get("topics") or ([sc["short"]] if sc.get("short") else []) + # 🔴🔴 2026-10-01 修(用户报「**看板没有正常显示协作会话**」)。 + # 真因:原来**只认带方括号**的写法(`"[%s]" % 类别 in 标题`),而**实际派活出来的棒名是** + # `[协作]-机制排查与修复-常驻投递容器`(第 2 级**没带方括号**) + # ⇒ 判 `False` ⇒ 它落到 `sessions_unrecognized` ⇒ `_sessions()` 的 `mine` 里没有它 + # ⇒ `board.html` 第三层(按 `role==='任务会话'` 挑格子)**画空框「暂无协作会话」**, + # 而那条会话**正在 working**。同族红线:**读到了却不说**(更糟:说反了)。 + # ⚠️ 而且这和 `_topic_in_title()` **自相矛盾** —— 那个函数注释白纸黑字写着 + # 「判据是**子串**(不要求 `[...]` 包裹)…**两种写法都要认**」, + # 于是**同一个标题,一个函数说命中、另一个说不命中**(判据打架,2026-10-01 实测)。 + # ⇒ 判据统一成**两条并列**(任一命中即算): + # ② **角色可解析**(`_role_of_title()` 非空)**且** 标题里出现任一类别(`_topic_in_title()` 子串) + # —— 这是**放宽**,⛔ 不是放弃精度:加这道"角色前缀闸"就是为了⛔ 不让 + # "正文里恰好提到类别名的无关会话"混进来(那种标题解析不出角色)。 + # ③ 老写法带方括号 `[<类别>]` **原样保留**(向后兼容:跨工作区时代与 `[<类别>] 接续 · …`)。 + # 🔴 **⛔ 与 `collabd.py::_in_project()` 必须逐条同款**(`selftest.py` 会真的把两边拉出来对账)。 + if _topic_in_title(ti, _tps) and _role_of_title(ti): + return True + # 🔴🔴 2026-10-03 10:2x **检查会话无条件归本项目**(用户报障连带查出的第二个缺口)。 + # 现象:`44b547d4`「结果检查-…」修完 role 后**从「主会话」列表移出了**, + # 却落进 `sessions_unrecognized` ⇒ 任务会话那一排**仍然画不出它**。 + # 根因:上面判据②要求「角色可解析 **且** 标题含 `goal.topics` 里的类别」, + # 而 `topics` 现网**未声明**(`None` ⇒ 回落 `short`=「本机协作」), + # `结果检查`/`目标检查` **不在里面** ⇒ 判 False。 + # 🔴 **为什么该无条件**:检查会话是**常驻程序自己建的**(`maybe_spawn_check_agent()`), + # 它**服务于本工作区的目标**,⛔ 不该因为"用户还没声明任务类别"而在看板上消失 + # —— 那正是 2026-10-01「创建了协作会话但看板没展示」同族。 + # ⚠️ 这是**第三条判据**(①②③ 之外),⛔ 不改①②③ ⇒ 老写法全部原样保留。 + # ⚠️ 与 `collabd.py::_in_project()` **必须同款**(改一处漏另一处已吃过三次)⇒ 已在对账用例里。 + if _is_check_agent(ti) and _same_ws_cwd_for_check(sc): + return True + for _t in _tps: + if _t and ("[%s]" % _t) in ti: + return True + return False + + +def _same_ws_cwd_for_check(sc: dict) -> bool: + """🔴 检查会话**是否属于本工作区**(只按 `sc["workspace"]` 这一条真痕迹)。 + + 🔴 为什么单独一个函数:`_in_project()` 的入参里**没有 cwd**(只有 `sid`/`title`/`sc`), + ⛔ 而"别的线也在建检查会话"是可能的(同一台机多工作区)⇒ **不能只凭标题就认领**。 + ⇒ 判据=`sc["workspace"]` 存在(它由 `board.py` 从**部署配置**读出,代表"本看板自己的工作区") + + 调用方已确认这条会话 `cwd` 命中本工作区(见 `_sessions()` 里的 `_ct == _wstail` 那道)。 + """ + return bool(str(sc.get("workspace") or "").strip()) + + +def _goal() -> dict: + return _j(INBOX / "goal.json", {}) or {} + + +# 🔴🔴 2026-10-01 立 · **会话退场**(治「接续棒越堆越多」)—— 看板侧。 +# 病因与设计见 `collabd.py` 那段 `RETIRED` 长注释(权威在那边,本处是**第 2 处消费者**)。 +# ⛔ **本文件只读不写**:写退场由 `collabd.py --retire self` 负责(一个状态只有一个写者)。 +def _retired() -> dict: + """读退场表(`/retired.json`)。读不到/坏了 ⇒ 空表(⛔ 不让它把看板搞挂)。""" + try: + d = _j(INBOX / "retired.json", {}) or {} + return d if isinstance(d, dict) else {} + except Exception: + return {} + + +def _retired_hit(sid: str, table: dict) -> bool: + """这条会话是否已退场。⚠️ 比对**前 8 位**(看板各处只存 8 位短 id)。""" + k = str(sid or "").strip()[:8].lower() + if not k: + return False + for s in table: + if str(s)[:8].lower() == k: + return True + return False + + +# 会话在版面上算不算"活的"(分钟):`completed` 且距上次活动超过它 ⇒ 视同退场。 +# 🔴 与 `collabd.py` 用**同一个配置键** `session_live_min`(⛔ 否则两个渲染器口径又会打架)。 +LIVE_MIN = float(C.get("session_live_min") or 90) + + +GOALS_DIR = INBOX / "goals" # 🔴 多目标:一个文件=一个目标(字段与 goal.json 同款) + + +def _peer_workspaces() -> list: + """🔴 要**并列查看**的其它工作区(只读),来自部署配置 `peer_workspaces`。 + + 🔴 2026-10-03 15:44x 用户报障:「tab 不能切换看另外两个工作区的目标」 + ⇒ 看板原本只认**一个** `INBOX`(=本工作区)⇒ 天生看不见别的目录。 + ⚠️ 配置写法:`"peer_workspaces": ["E:/ProgramData/AIProject/会话协作测试1", …]`(**正斜杠**)。 + 🔴 **严格只读** —— 这条边界必须守住: + · ⛔ 不写对方任何文件;⛔ 不起对方的常驻/看板;⛔ 不改对方的目标状态; + · 只把对方的 `goal.json` **读进来当作一个 tab**。 + ⚠️ 没有这个键/值非法 ⇒ 返回 `[]` ⇒ **行为与旧版逐字一致**(向后兼容)。 + """ + try: + v = (C or {}).get("peer_workspaces") or [] + except Exception: + return [] + if isinstance(v, str): + v = [v] + out = [] + for x in v or []: + s = str(x or "").strip().replace("\\", "/") + if not s or s == str(WS or "").replace("\\", "/"): + continue # ⛔ 本工作区不重复列(它已是 active 那格) + out.append(s) + return out + + +def goal_files() -> list: + """🔴 **所有目标文件**(活跃的排第一):`goal.json` + `goals/*.json`。 + + 用户 2026-10-01:「把协作实时看板改为 **tab 支持多个目标**执行协作状态展示」 + ⇒ 数据模型(我的决定,可推翻): + · `INBOX/goal.json` = **当前活跃目标** —— 唯一权威,`collabd.py` 只认它 ⇒ ⛔ 零改动; + · `INBOX/goals/.json` = **其余目标**(同一套字段:`id`/`title`/`short`/`topics`/ + `acceptance_state`/`run`); + · 看板把两者合成一个**有序列表** ⇒ **一个 tab 一个目标**。 + + ⛔ 没有 `goals/` 目录 ⇒ 列表里只有 1 项 ⇒ **与旧版逐字一致**(向后兼容)。 + ⚠️ 去重键=`id`(缺 `id` ⇒ 用文件名)—— 同一个目标两处都有 ⇒ **以 `goal.json` 为准**。 + + @returns `[{"path","goal","active","key"}]`(⛔ 读不到任何目标 ⇒ `[]`) + """ + out: list = [] + seen: set = set() + + def _add(p: Path, active: bool, ws_name: str = "", ws_root: str = "") -> None: + g = _j(p, {}) or {} + if not g: + return + k = str(g.get("id") or "").strip() or p.stem + if ws_name: + k = "%s/%s" % (ws_name, k) # ⛔ 去重键带工作区前缀 ⇒ 同名目标不互相顶掉 + if k in seen: + return + seen.add(k) + rec = {"path": str(p), "goal": g, "active": active, "key": k} + if ws_name: + # 🔴 标出"来自哪个工作区" ⇒ 前端能显示、也**不会被误当成本区活跃目标** + rec["peer"] = ws_name + rec["ws_root"] = ws_root + out.append(rec) + + _add(INBOX / "goal.json", True) + try: + if GOALS_DIR.is_dir(): + for p in sorted(GOALS_DIR.glob("*.json")): + _add(p, False) + except Exception: + pass + # 🔴🔴 2026-10-03 15:44x **新增:并列查看其它工作区的目标**(用户报障)。 + # ⚠️ **只读**:⛔ 不写对方文件、⛔ 不起对方进程、⛔ 不改对方状态。 + # ⇒ 活跃目标仍然只有本工作区的 `goal.json` ⇒ `collabd.py` 行为**零改动**。 + # 🔴🔴 2026-10-04 21:0x **新增:已经在会话列表里消失的工作区 ⇒ 不再占 tab**。 + # 用户报障逐字:「tab 要把已经不再 会话列表的工作区 目标移除,不然都放不下了」。 + # 🔴 **判据(唯一)**:宿主库 `sessions` 表里该 `cwd` 的**未删会话数**(`deleted_at is null or 0`) + # = 0 ⇒ 这个工作区在会话列表里**已经不存在了** ⇒ ⛔ 不列 tab。 + # 🔴 **为什么用"未删会话数"而不是"心跳新不新鲜"**:心跳只说明那个区的**常驻进程**在不在, + # 与"这个工作区还有没有人在用(会话列表里还有没有它)"**是两件事** —— + # 实测反例:会话协作测试2/3 的常驻挂在别的机器上、心跳早停,但其**未删会话为 0** + # (用户把会话全删了)⇒ 该工作区确实已从列表里退场;而 vibe-product 心跳 1.5 小时 + # 但未删会话 5 条 ⇒ **还在用** ⇒ 必须留。⇒ 判据只能是会话数,⛔ 不是心跳。 + # ⚠️ **严格只读、零删除**:只影响"要不要把这一格画出来", + # ⛔ 不动对方任何文件、⛔ 不从 `peer_workspaces` 里替用户删配置(那是用户的配置)。 + # ⚠️ 读不到库 ⇒ **保留**(fail-open:宁可多一格,⛔ 不因读数失败就把别人的格子吞掉)。 + for _pw in _peer_workspaces(): + try: + _root = Path(_pw).resolve() + if not _root.is_dir(): + continue + _g = _root / "tmp" / "supervise-inbox" / "goal.json" + if not _g.is_file(): + continue + try: + if not _peer_session_rows(str(_root)): + continue # 未删会话 0 条 ⇒ 该工作区已不在会话列表里 ⇒ 跳过(不占 tab) + except Exception: + pass # ⚠️ 读数失败 ⇒ 保留(fail-open) + _add(_g, False, _root.name, str(_root)) + except Exception: + continue + return out + + +# ── 🔴🔴 2026-10-03 16:1x **peer 格的取数**(⛔ 只用对方工作区自己的文件) ──────────── +# 背景(用户报障逐字):「**选择另一个工作区目标 tab 下面没有显示对应工作区目标和执行情况**」。 +# 🔴 实测真因(比"没显示"更糟 —— **在说假话**):`build()` 里的 `tasks`/`srows`/`st` +# **只取一次、所有格共用** ⇒ 切到 peer 格时,`labor`/`sessions`/`progress` 的 md5 +# **三格完全相同** ⇒ 显示的是**本工作区**的执行情况,却挂着对方工作区的名字。 +# ⇒ 修法:**peer 格一律只喂"那个工作区目录下的文件"**;读不到 ⇒ **空 + 如实说明**, +# ⛔ **绝不拿本工作区的数据补位**(同族红线:**读到了就要说**,⛔ 不许说假话)。 + +def _peer_inbox(ws_root: str) -> Path: + """对方工作区的 `INBOX`(同构约定 ⇒ `/tmp/supervise-inbox/`)。⛔ **只读**。""" + return Path(str(ws_root or "")) / "tmp" / "supervise-inbox" + + +def _peer_tasks(ws_root: str) -> dict: + """🔴 对方工作区的**台账**(只读它自己的 `tasks.json`)。读不到 ⇒ `{}`(⛔ 不拿本区的补)。""" + try: + return _j(_peer_inbox(ws_root) / "tasks.json", {}) or {} + except Exception: + return {} + + +def _peer_state(ws_root: str) -> dict: + """🔴 对方工作区常驻写的 `collabd-state.json`(只读)。读不到 ⇒ `{}`(⛔ 不拿本区的补)。""" + try: + return _j(_peer_inbox(ws_root) / "collabd-state.json", {}) or {} + except Exception: + return {} + + +def _peer_session_rows(ws_root: str) -> list: + """🔴 **单独补捞**对方工作区的会话(只读宿主库,⛔ 不写)。 + + ⚠️ 为什么不能只靠 `_session_rows()`:它取的是**全库最近 50 条**(按 `last_activity_at` 倒序) + ⇒ 对方工作区的会话**大概率一条都不在里面** ⇒ peer 格显示「0 条会话」=**假象** + (2026-10-03 实测:两个测试工作区的主会话明明 `working`,peer 格却 0 条)。 + ⛔ 也⛔ 不许为了捞它把全局 limit 放大 —— 那会让**本工作区**的 `others_running` 计数暴涨 + (`_sessions()` 拿全 rows 算"别的项目在跑几条"),等于为一个新功能改坏老行为。 + """ + db = _host_db() + if not db or not ws_root: + return [] + out = [] + try: + con = sqlite3.connect("file:%s?mode=ro" % str(db).replace("\\", "/"), uri=True, timeout=0.3) + con.row_factory = sqlite3.Row + try: + con.execute("pragma busy_timeout=300") + except Exception: + pass + # 🔴 库里 `cwd` 可能是反斜杠形态 ⇒ 用 `replace()` 归一成正斜杠再比(⛔ 别在 Python 侧比 ⇒ 要全表扫) + # 🔴 2026-10-03 17:0x **列必须与 `_session_rows()` 同款(7 列)** —— + # 少选 `status`/`custom_title`/`updated_at` ⇒ `_sessions()` 按键取值时抛 + # `No item with that key` ⇒ 被兜底成「库不可读」+空列表 + # ⇒ **peer 格永远 0 条**,而 `err` 里只有一句看不出来的"库不可读"(本轮实测踩到)。 + out = con.execute( + "select id,title,custom_title,status,cwd,updated_at,last_activity_at " + "from sessions where (deleted_at is null or deleted_at=0) " + "and replace(cwd,'\\','/')=? " + "order by updated_at desc limit 60", + (str(ws_root).replace("\\", "/").rstrip("/"),)).fetchall() + con.close() + except Exception: + return [] + return list(out or []) + + +def _peer_srows(srows, ws_root: str) -> list: + """🔴 对方工作区的会话行 = **已有 srows 里筛出的** + **单独补捞的**(按 `id` 去重)。 + + ⚠️ 用 `cwd` 判只在**这一处**合法:判的是「这条会话属于哪个**工作区**」(≡ `cwd` 的字面含义), + ⛔ **不是**架构 §2.3 禁止的那条「拿 `cwd` 推断会话属于哪个**需求项目**」。 + """ + b = str(ws_root or "").replace("\\", "/").rstrip("/").lower() + if not b: + return [] + seen: set = set() + out: list = [] + + def _push(r) -> None: + try: + _id = str(r["id"] or "") + except Exception: + return + if _id and _id in seen: + return + if _id: + seen.add(_id) + out.append(r) + + for r in srows or []: + try: + c = str(r["cwd"] or "") # `sqlite3.Row`(`_session_rows` 设了 row_factory) + except Exception: + continue + if c.replace("\\", "/").rstrip("/").lower() == b: + _push(r) + for r in _peer_session_rows(ws_root): + _push(r) + return out + + +def _peer_scope_note(ws_root: str) -> dict: + """🔴 peer 那格的**来源说明** —— 看板必须说得出「这格的数字哪来的、多旧」。 + + ⛔ 没有这份说明 ⇒ 读者会把"读不到"当成"对方没在跑",或把别的数字当成它的。 + """ + _ib = _peer_inbox(ws_root) + _hb = Path(str(ws_root or "")) / ".workbuddy" / "collab" / "logs" / "supervise-heartbeat.json" + _age = None + try: + _age = round((time.time() - _hb.stat().st_mtime) / 60.0, 1) + except Exception: + pass + return { + "read_only": True, + "tasks_present": (_ib / "tasks.json").is_file(), + "state_present": (_ib / "collabd-state.json").is_file(), + "heartbeat_age_min": _age, + } + + +def _peer_runtime_min(ws_root: str, n_check: int) -> dict: + """🔴 peer 格的**常驻程序实时状态**(只含架构图真正用到的那几个字段)。 + + ⚠️ **为什么不给 peer 直接复用 `_runtime()`**:那个函数内部**多处直接用本区 `INBOX`** + (`_tick.stamp`/`guard.log`/`guard.stop`…)⇒ 参数化要动一串路径,风险高; + 而这里**只需要"对方常驻在不在跑" + "对方有几次检查"** ⇒ 读**对方心跳**就够。 + ⚠️ 与 `_runtime()` 是**两份实现**(同族风险:同一条规则写两处必然漂)⇒ + **判据口径必须一致**:心跳 `ts` 距今 < 90 s = 在线(1.5 分钟),文案沿用 `_label_when_down()`。 + ⛔ 改 `_runtime()` 的在线判据时**必须**同步改这里。 + """ + hb = Path(str(ws_root or "")) / ".workbuddy" / "collab" / "logs" / "supervise-heartbeat.json" + age = pid = rnd = None + try: + d = _j(hb, {}) or {} + if d: + pid, rnd = d.get("pid"), d.get("round") + age = round((time.time() - float(d.get("ts") or 0)) / 60.0, 2) + except Exception: + pass + up = (age is not None) and (age <= 1.5) # 90 秒,与本区同口径 + return { + "prog": {"up": up, "age_min": age, "heartbeat_age_min": age, "heartbeat_pid": pid, + "by": (("常驻 --supervise(round %s)" % rnd) if rnd is not None else ""), + "label": ("在线" if up else _label_when_down(age, pid, age is None))}, + "check": {"n": n_check, + "label": ("无检查在跑" if not n_check else "%d 条检查在册" % n_check)}, + } + + +def _goal_topics(g: dict | None = None) -> list: + """🔴 **本需求下的"任务类别"清单**(`goal.json.topics`)。 + + 2026-09-30 用户要求:「要能支持**同一个工作区** 多会话协作(主会话根据任务**自动梳理任务类别**: + **通过协作会话名称前缀的方式区分具体执行会话**)」 + ⇒ **任务类别 ≡ 会话标题二级前缀 `[主题]` ≡ 台账条目的 `line` ≡ 分工板的一行**(同一个词,四处同义)。 + + ⛔ `topics` 缺省 ⇒ 回落 `[goal.short]` ⇒ **单类别时的行为与旧版逐字一致**(向后兼容)。 + 🔴 与 `collabd.py::_goal_topics()` **同款**(⛔ 改一处必须改两处)。 + """ + g = _goal() if g is None else g + out: list = [] + for _t in ((g or {}).get("topics") or []): + _t = str(_t or "").strip() + if _t and _t not in out: + out.append(_t) + if not out: + _s = str((g or {}).get("short") or "").strip() + if _s: + out.append(_s) + return out + + +def _goal_topics_source(g: dict | None = None) -> dict: + """🔴 **任务类别清单是"说明来的"还是"回落来的"** —— 看板必须说得出来源。 + + 用户 2026-10-01 订正:「**目标是 通过对话在调用 会话协作skill时说明的,不是固定的**」 + ⇒ `topics` 缺省回落 `short` 是**兼容**(单类别部署照旧),但**看板不许把"没声明过" + 显示成"就是这么定的"** —— 同族红线:**读到了却不说,或者没有却说有**。 + + 返回 `{"kind": "declared"|"fallback"|"none", "at": "...", "by": "...", "candidates": [...]}` + """ + g = _goal() if g is None else g + g = g or {} + raw = [str(x or "").strip() for x in (g.get("topics") or []) if str(x or "").strip()] + if raw: + kind = "declared" + elif str(g.get("short") or "").strip(): + kind = "fallback" + else: + kind = "none" + _cand = ((g.get("_topics候选") or {}) or {}).get("候选") if isinstance(g.get("_topics候选"), dict) else None + return {"kind": kind, + "at": str(g.get("topics_declared_at") or ""), + "by": str(g.get("topics_declared_by") or ""), + "candidates": _as_list(_cand), + # 🔴 目标本身**是不是**"在对话里说明过"(`declared_at` 由 `goalctl declare` 写) + "goal_declared_at": str(g.get("declared_at") or ""), + "goal_declared_by": str(g.get("declared_by") or "")} + + +def _as_list(v) -> list: + """把 `str | list | tuple | set` 归一成 `list[str]`(⛔ 空值 ⇒ `[]`)。""" + if v is None: + return [] + if isinstance(v, str): + return [v] if v.strip() else [] + try: + return [str(x) for x in v if str(x or "").strip()] + except Exception: + return [str(v)] + + +def _topic_in_title(title: str, topics=None) -> str: + """标题里出现的**任务类别**(🔴 **最长优先**);⛔ 都不命中 ⇒ 空串。 + + ⚠️ 判据是**子串**(不要求 `[...]` 包裹):主会话是口语式命名(「主控 · 唤醒机制」), + 执行棒是严格 `[执行]-<类别>-<具体>` —— 两种写法都要认。 + 🔴 与 `collabd.py::_topic_in_title()` **同款**(⛔ 改一处必须改两处)。 + """ + ti = str(title or "") + tps = _as_list(topics) if topics is not None else _goal_topics() + hit = "" + for _t in tps: + if _t and _t in ti and len(_t) > len(hit): + hit = _t + return hit + + +def _labor(tasks: dict, sess: list, goal: dict) -> list: + """🔴 **分工板**(用户 2026-09-30 两条明示: + ① 「协作会话**不是历史记录**,是**展示分工**的板块」 + ② 「每个分工板块可以**展示最近的协作任务**」)。 + + **分工位 = 线**(`goal.topics` ∪ `goal.lines` ∪ 台账里出现过的线)—— 线是本机制里真实存在的分工维度。 + 每格给四样:**最近的协作任务**/**当前承接会话**/**件汇总**/**状态色**。 + + 🔴 **三类"归属"别混(2026-09-30 修 · 用户报「桌面线有会话在跑、看板却说无协作任务」)**: + · **会话 → 本项目**:按 §2.3 的三级判据(主会话 sid / 标题含 `[任务类别]` / `--declare`), + ⛔ **绝不回落到 cwd**。这个判据在 `project_scope()` 里,本函数**收进来的 sess 已经过它筛**。 + · **会话 → 哪条线**(🆕 2026-09-30 多类别):**优先按任务类别归位** —— + 会话标题二级前缀 `[主题]` 命中的类别(`s["topic"]`)等于该线 ⇒ 归这条线。 + 🔴 这是"**同一个工作区**多会话协作"的正确判据:所有会话 cwd 相同,⛔ 按 cwd 分必然全塌成一行。 + · **会话 → 哪条线**(旧/兼容):**线也可能就是工作区名**(跨工作区时代的 `goal.lines`)⇒ + 用 `cwd_tail ∈ lines` 判线归属。⚠️ 两条判据**并列**(`topic == 线 ∨ cwd_tail == 线`), + 因为同一个部署里可能**同时**存在"按类别命名的新会话"和"按工作区命名的旧数据"。 + + 🔴 **修掉的两种误报**(旧版只看台账里的件 ⇒ 会漏): + ① 「(该线暂无协作任务)」:台账没件 **≠** 该线没活 —— 该线正在跑棒时这话是错的。 + ⇒ 台账无件时,**回落到"该线最近的一个会话"**当"最近的协作任务"。 + ② 「当前无会话在跑」:旧版只认"在跑会话标题里出现**台账件 id**" ⇒ + 棒在做台账里还没有的件(如 V6)时**匹配不到** ⇒ 明明在跑却说没人跑。 + ⇒ 承接判据改成:**topic == 线 ∨ cwd_tail == 线** ∨ 标题命中该线的台账件。 + 🔴 **状态色三值**:`busy`=该线有会话在跑|`gap`=**该线还有未完成的件却没人在跑**|`idle`=没活也没人跑。 + """ + lines: list = [] + # 🔴 2026-09-30 改:**任务类别(`goal.topics`)排在最前** —— 用户要求 + # 「**同一个工作区**多会话协作(主会话根据任务**自动梳理任务类别**:**通过协作会话名称前缀 + # 的方式区分具体执行会话**,所有主会话,协作会话,自动唤醒任务,都在**一个工作区**)」 + # ⇒ 分工板的"一 行"从**工作区**改成**任务类别**(⛔ 同工作区多类别时,按工作区分会全塌成一行)。 + # ⚠️ 旧的两路(`goal.lines` + 台账 `line`)**保留** ⇒ 跨工作区时代的既有部署照旧显示。 + for _tp in _goal_topics(goal): + if _tp and _tp not in lines: + lines.append(_tp) + for _ln in (goal.get("lines") or []): + _ln = str(_ln or "") + if _ln and _ln not in lines: + lines.append(_ln) + for _v in (tasks or {}).values(): + _ln = str((_v or {}).get("line") or "") + if _ln and _ln not in lines: + lines.append(_ln) + if not lines: + lines = ["(未标注线)"] + _tps = [t for t in _goal_topics(goal) if t] # 🔴 当前"任务类别"清单(`kind` 判据用它) + + _running = [s for s in (sess or []) if str(s.get("status")) == "working"] + + def _item_in_title(title: str) -> str: + for _tid in sorted((tasks or {}).keys(), key=len, reverse=True): + if _tid and re.search(r"(? _latest[0]: + _latest = (_ts, k, v) + # 承接会话:① 会话就在这条线的工作区里(cwd_tail == 线) ② 或标题命中该线的台账件 + holders = [] + for s in _running: + _ct = str(s.get("cwd_tail") or "") + _tp = str(s.get("topic") or "") + _tid = _item_in_title(s.get("title")) + # 🔴 2026-09-30:`_tp == ln` = **按任务类别归位**(新,同工作区多类别) + # `_ct == ln` = 按工作区归位(旧,向后兼容跨工作区时代的部署) + if _tp == ln or _ct == ln or (_tid and _tid in items): + holders.append({"id8": str(s.get("id8") or ""), + "what": (_tid or str(s.get("title") or ""))[:26]}) + + # 最近的协作任务:台账有件 ⇒ 用最新那件;**台账无件 ⇒ 回落到该线最近的会话** + # (⛔ 别因为"台账没件"就说"暂无协作任务" —— 该线可能正在跑一件还没上报的活) + latest = None + if _latest: + latest = {"item": _latest[1], "state": str(_latest[2].get("state") or "?"), + "by": str(_latest[2].get("by") or ""), + "age_min": round((_now - _latest[0]) / 60.0, 1)} + else: + # 🔴 2026-10-01 修:⛔ **别再排除 `role == "主会话"` 的会话**。 + # 原判据(`… and role != "主会话"`)本意是"最近在做的=承接会话,不含主会话", + # 但**该类只有主会话自己在跑**时会把候选整类判空 ⇒ 同一格里 ③ 说 + # 「(该类还没有记录,也无可归到它的会话)」、④ 却说「承接 <同一个会话>」 + # = **自相矛盾**(用户 2026-10-01 看到的正是这个画面,由此追问 + # 「唤醒机制跟主会话都一个 ID,难道是主会话?」)。 + # ⇒ 候选**不排除任何角色**,只把 `working` 排前 —— + # "正在跑的"就是"最近在做的",③④ 指向同一条会话时是**真相**,⛔ 不是重复。 + _cand = [s for s in (sess or []) + if (str(s.get("topic") or "") == ln or str(s.get("cwd_tail") or "") == ln)] + _cand.sort(key=lambda x: (0 if str(x.get("status")) == "working" else 1, + float(x.get("age_min") or 1e9))) + if _cand: + _s0 = _cand[0] + latest = {"item": str(_s0.get("title") or "")[:28], + "state": ("working" if str(_s0.get("status")) == "working" + else str(_s0.get("status") or "?")), + "by": str(_s0.get("id8") or ""), + "age_min": float(_s0.get("age_min") or 0), "from_session": True} + + out.append({ + "line": ln, + "name": str((C.get("lines") or {}).get(ln) or ln), + "items": {k: str(items[k].get("state") or "?") for k in sorted(items)}, + "done": len(done), "total": len(items), "open": n_open, + "latest": latest, + "running": holders, + "state": ("busy" if holders else ("gap" if n_open else "idle")), + # 🔴 2026-09-30:**这一行到底是不是"任务类别"**。 + # `kind="topic"` ⇒ 命中 `goal.topics`(=当前类别,架构图第三层的正式分工位); + # `kind="legacy"` ⇒ **只在台账/`goal.lines` 里出现过,不在当前类别清单**(跨工作区 + # 时代的旧线名,或已退役的类别)⇒ 它**不是**类别,⛔ 不许让工作区名冒充类别。 + # ⛔ 也**不许把它静默丢掉**(同族红线:读到了却不显示 = 少说一句话)⇒ + # 看板把它**折叠成一格「未归类」**画出来(见 build() 的 `orphan`)。 + "kind": ("topic" if ln in _tps else "legacy"), + }) + return out + + +def _session_rows() -> list: + """🔴 宿主库里的**原始会话行**(只读**一次** ⇒ 多目标共用,⛔ 不 N 个目标读 N 次)。 + + ⛔ 读不到 ⇒ **抛异常**(调用方升级成 `err`,⛔ 不静默伪装成"0 个会话" —— 那是假情报)。 + """ + db = _host_db() + if not db: + return [] + con = sqlite3.connect("file:%s?mode=ro" % str(db).replace("\\", "/"), uri=True, timeout=0.3) + con.row_factory = sqlite3.Row + try: + con.execute("pragma busy_timeout=300") + except Exception: + pass + # 🔴 2026-10-02 用户报障:「**刚才删除历史会话 也被当作有活动 显示在 协作会话中**」 + # ⇒ **真因**:本查询漏了 `deleted_at` 过滤 —— 宿主的"删除会话"是**软删除** + # (只写 `deleted_at` 时间戳,**行还在表里**;实测库里 `deleted_at` 非 0 的有 101 条)。 + # ⚠️ 同文件 `_deliverable` 那条(`:248`)**有**这个过滤,所以**只漏了这里** + # ⇒ 后果:已删会话照样被读进看板、出现在第三层「协作会话」里(用户看到的就是这个)。 + # ⚠️ 判据与 `board_ext.py:396` **统一**:`(deleted_at is null or deleted_at=0)`。 + rows = con.execute( + "select id,title,custom_title,status,cwd,updated_at,last_activity_at " + "from sessions where (deleted_at is null or deleted_at=0) " + "order by updated_at desc limit 60").fetchall() + con.close() + return rows + + +def _sessions(limit: int = 8, sc: dict | None = None, rows=None, bypass: bool = False) -> dict: + """返回 `{"mine": [...], "others_running": n, "unrecognized": [...], "hidden": {...}, "err": ""}` + —— **只把本项目的会话列进看板**。 + `mine` 里**主会话排最前**(看板第一眼要能看到"哪个是主会话")。 + + 🔴 2026-10-01 加 `hidden`:**已退场/超窗口**的会话,形状 + `{"retired": n, "stale": m, "live_min": X, "recent": [{id8,title,age_min,why}]}`。 + ⚠️ `recent` 只留最近 3 条明细(版面精简),**计数是全量**。 + + 🔴 **不许拖慢宿主**(用户 2026-09-30:「看板不能影响程序执行」): + · 只读连接(`mode=ro`)⇒ WAL 下**读者不阻塞写者**,宿主的写事务该多快还是多快 + · `busy_timeout=300` ⇒ 万一撞上写锁,**0.3 秒就放弃**,⛔ 不排队、⛔ 不长时间占着 + · 失败 ⇒ 返回 `err` 让界面显示「宿主库暂不可读」,⛔ **不静默伪装成"0 个会话"**(那是假情报) + + 🔴 2026-10-01 多目标:`sc` 传入 ⇒ 按该目标算归属;`rows` 传入 ⇒ 复用已读的行(⛔ 不重复读库)。 + """ + if sc is None: + sc = project_scope() + try: + if rows is None: + rows = _session_rows() + now = time.time() + # 🔴 2026-10-03 11:1x 加 `checks`(检查会话=常驻程序侧,⛔ 不进 `work`) + main, work, checks, others, unrec = [], [], [], 0, [] + _ret = _retired() + _hid = {"retired": 0, "stale": 0, "live_min": LIVE_MIN, "recent": []} + _wstail = str(sc.get("workspace") or "").replace("\\", "/").rstrip("/").split("/")[-1] + for r in rows: + title = str(r["custom_title"] or "") or str(r["title"] or "") + cwd = str(r["cwd"] or "") + st = str(r["status"] or "") + sid = str(r["id"] or "") + # 🔴 2026-10-03 17:0x **`bypass`**:peer 格专用(见函数头与 `_goal_block()` 调用点)。 + # 本分支恒为 False ⇒ **本工作区的判据逐字不变**(向后兼容)。 + if not (bypass or in_project(sid, title, sc)): + if st == "working": + others += 1 # ⚠️ 别的线在跑 ⇒ **只计数**,⛔ 不混进列表 + # 🔴 2026-10-01 加(用户报「创建了协作会话但看板没展示」):**同工作区、 + # 但按命名判据认不出来**的会话必须**说出口**(同族红线:读到了却不说)。 + # ⚠️⚠️ 这里**只用 cwd 决定"要不要提醒",⛔ 绝不用它决定"归不归本项目"** —— + # 归属判据仍是 §2.3 那三级(主会话 sid / 标题含 `[<类别>]` / `--declare`), + # ⛔ 不回落 cwd。这批会话**不进 `mine`、不进分工板、不算"本项目会话"**。 + # 为什么必须有:棒名写成 `[协作]-<昵称>-…`(漏方括号/用 short)时, + # `parse_session_name()` 与 `in_project()` **双双认不出** ⇒ 它会**静默消失**。 + _ct = cwd.replace("\\", "/").rstrip("/").split("/")[-1] + if _wstail and _ct == _wstail: + # ⚠️ 带 `age_min`:看板**只把"在跑的 / 近 3 小时的"摊在版面上**, + # 陈年历史只计一个数 —— 否则半个版面上全是早已完成的旧棒(用户反复要求"精简")。 + unrec.append({"id8": sid[:8], "title": title[:60], "status": st, + "age_min": round((now - float(r["updated_at"] or 0) / 1000.0) / 60.0, 1)}) + continue + rec = { + "id8": sid[:8], + "title": title[:60], + "status": st, + # 🔴 2026-09-30:多任务类别 ⇒ "主会话"是**一组**(`main_sids`),⛔ 不是"唯一那一条" + # 🔴🔴 2026-10-01 修:原来是**二分**(主会话/任务会话)⇒ 唤醒会话、第④类跟进会话 + # 一律被标成「协作会话」。详见 `_role_label()` 的注释。 + "role": _role_label(sid, title, sc), + # 🔴 任务类别(=标题二级前缀 `[主题]`)—— 分工板按它归位 + # (用户:「通过协作会话名称前缀的方式区分具体执行会话」⇒ 同一个词,四处同义) + "topic": _topic_in_title(title, sc.get("topics") or []), + "cwd_tail": cwd.replace("\\", "/").rstrip("/").split("/")[-1], + "age_min": round((now - float(r["updated_at"] or 0) / 1000.0) / 60.0, 1), + } + # 🔴🔴 2026-10-03 11:1x **三类分拣**(用户纠正:检查会话不进协作会话那一排): + # 主会话 / 任务会话 / **检查会话**(常驻程序侧)。 + # ⚠️ 改前是二分(`== 主会话` ⇒ main,否则 work)⇒ 检查会话被塞进 `work` + # ⇒ 前端 `role==="任务会话"` 那排就把它画成任务会话 ⇒ 用户看到的就是那个错。 + # ⛔ 三类**都显式列**:加第四类时若走 else 会静默混进某一类。 + # 🔴🔴 2026-10-03 19:2x **比较值跟着 `_ROLE_LABEL` 走** —— 原来写死 `"任务会话"` 字面量, + # 而 19:2x 把 worker 的**显示标签**改成了「协作目标」⇒ 两边对不上 + # ⇒ **worker 落进 `else` 分支被当成"检查会话"** ⇒ `work` 为空、`mine` 为空 + # ⇒ 自检「会话退场」四条假红(本轮实测踩到)。 + # 🔴 **判据纪律**:标签是**数据**(`_ROLE_LABEL`),⛔ **不许在比较处再写一遍字面量**。 + if rec["role"] == _ROLE_LABEL["main"]: + main.append(rec) + elif rec["role"] == _ROLE_LABEL["worker"]: + work.append(rec) + else: + checks.append(rec) # 检查会话(常驻程序侧) + # 🔴 2026-10-01:**退场筛**(治"接续棒越堆越多")—— + # ⚠️ **两条闸门都绕开 `working`**:在跑的棒一定要摊在版面上(⛔ 被"太旧了"吃掉最糟)。 + # ⚠️ 也绕开 **主会话**:它是版面上的**锚点**(第一眼要看到"哪个是主会话"), + # ⛔ 不许因为"它 4 小时没动"就把锚点抽掉 —— 那会让版面结构整块塌掉。 + # ⚠️ **收起 ≠ 删除**:宿主库里原样在;这里只影响呈现,并**必须报数**(同族红线)。 + _keep = [] + for rec in work: + if rec["status"] != "working": + if _retired_hit(rec["id8"], _ret): + _hid["retired"] += 1 + _hid["recent"].append({"id8": rec["id8"], "title": rec["title"], + "age_min": rec["age_min"], "why": "显式退场"}) + continue + if rec["status"] == "completed" and float(rec["age_min"] or 0) > LIVE_MIN: + _hid["stale"] += 1 + _hid["recent"].append({"id8": rec["id8"], "title": rec["title"], + "age_min": rec["age_min"], "why": "超 %d 分钟" % int(LIVE_MIN)}) + continue + _keep.append(rec) + work = _keep + # ⚠️ 只留最近 3 条明细(版面精简),但**计数是全量**(⛔ 不许因为"它很旧"就少说一句) + _hid["recent"] = sorted(_hid["recent"], key=lambda x: float(x.get("age_min") or 0))[:3] + # 🔴🔴 2026-10-01:**四类各保底一条**再截断 —— 看板上**每一类都有它自己的位置** + # (主会话独占一行 / 唤醒会话+跟进会话单独一行 / 任务会话那一排), + # ⇒ 哪一类被 `limit` 截掉,版面上就会出现**假话**: + # · 跟进会话被截掉 ⇒ 那一格渲染成「**(未登记跟进会话)**」,而它其实正在跑; + # · 唤醒会话被截掉 ⇒ `front.triggers` 照样画,但 `sessions` 里没有它(两处读数打架)。 + # ⚠️ **不能让"最近 8 条"这种排序决定版面上有没有某一类** —— 那是"读到了却不说"的同族。 + _all = main + work + _pin, _seen = [], set() + for _rec in _all: + if _rec["role"] not in _seen: + _seen.add(_rec["role"]); _pin.append(_rec) + for _rec in _all: + if _rec not in _pin: + _pin.append(_rec) + mine = _pin[:max(limit, len(_pin))] + # 🔴 2026-10-03 11:1x `checks` 一并回传(前端要画进常驻程序框) + return {"mine": mine, "checks": checks, "others_running": others, + "unrecognized": unrec, "hidden": _hid, "err": ""} + except Exception as e: + return {"mine": [], "others_running": 0, "unrecognized": [], "hidden": {}, + "err": "WorkBuddy 库暂不可读:%s" % e} + + +# ══════════════════════════════════════════════════════════════════════════════ +# 🔴 职责边界(用户 2026-09-30 定则): +# 「**技能就是技能 程序就是程序,谁用产生的文件 放在他自己那里**」 +# +「**禁止用抽象词,用 系统-模块-功能名**」 +# +# ⇒ **本文件(技能侧)⛔ 不含任何项目的路径、端口、真名、历史**。 +# 凡是"某个项目要看哪些组件、它们叫什么真名、端到端怎么算通过"这类**项目知识**, +# 一律由**使用方自己的扩展文件**提供,本文件只负责**把它读进来并渲染**: +# +# 配置项 `board_ext`(工作区相对路径,默认 `.workbuddy/collab/board_ext.py`) +# ⇒ 那个文件暴露 `build(ws: str) -> dict`(契约见其文件头) +# +# ⛔ 没配 / 读不到 / 抛异常 ⇒ **降级**(这一块不显示,看板照常)+ 记进 `warn`, +# ⛔ 绝不让使用方的脚本把看板搞挂。 +# ══════════════════════════════════════════════════════════════════════════════ + +EXT_CACHE: dict = {} # ⚠️ 缓存 **(mtime, size, 模块)**;⛔ 绝不缓存 `build()` 的**结果**(见下) + + +def _ext(ws: Path, warn: list) -> dict: + """🔴 读**使用方自己的**看板扩展(⛔ 技能侧不含任何项目知识)。 + + 契约:`build(ws) -> {"title","tag","chips":[{label,up,ok,bad,tip}],"paragraphs":[{text,tone}],"tip", + "triggers":[{name,detail,detail2,label,up,edge,tip}]}`。 + + ⚠️ `triggers` 为**可选**:表示**外部触发源(时间驱动)** ⇒ 画进架构图 R5 行右半。 + (用户 2026-09-30:「把心跳的节点也放到看板协作架构图中」。)空或缺 ⇒ 退回"只有钩子子进程"的老版面。 + 任何异常都吞掉并降级 —— 看板**不能因为使用方的脚本坏了就打不开**。 + + 🔴🔴 **⛔ 不许缓存 `build()` 的结果**(2026-09-30 我自己踩的坑): + 第一版把**结果 dict** 缓存了 ⇒ 扩展里的**探针只跑了一次** ⇒ 前置状态**永久冻结在服务启动那一刻**, + 而且**不报错**(表现为"看板说该端口未监听,而 netstat 明明确有 LISTENING")。 + ⇒ 只缓存**模块对象**(省一次 import),**每次快照都重新调用 `build(ws)`**。 + (⛔ 本文件的注释与 docstring 里也**不写任何项目的端口/路径** —— 有静态用例守着。) + """ + rel = str(C.get("board_ext") or ".workbuddy/collab/board_ext.py") + p = (ws / rel) + out: dict = {"title": "前置", "tag": "", "chips": [], "paragraphs": [], "tip": ""} + if not p.is_file(): + out["_missing"] = "未配看板扩展(board_ext):%s" % p + return out + try: + key = str(p) + try: + sig = (os.path.getmtime(p), os.path.getsize(p)) # 🔴 文件签名 + except OSError: + sig = (0.0, -1) + hit = EXT_CACHE.get(key) + if hit is None or hit[0] != sig: + # 🔴 2026-09-30 改:**按文件签名重载**。旧实现只按路径缓存模块 ⇒ 改了 `board_ext.py` + # 必须**重启服务**才生效,而"没生效"在界面上表现为"我改了但看板没变化" + # (用户当天连问两次)⇒ 改成 mtime+size 变了就重载,⛔ 不再需要重启。 + import importlib.util + spec = importlib.util.spec_from_file_location("_board_ext_%d" % abs(hash(key)), str(p)) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + EXT_CACHE[key] = (sig, mod) + mod = EXT_CACHE[key][1] + got = mod.build(str(ws)) # 🔴 每次快照都重跑探针(⛔ 结果不缓存) + if isinstance(got, dict): + out.update(got) + except Exception as e: + out["_err"] = str(e) + warn.append("看板扩展加载失败(已降级):%s" % e) + return out + + +def _queue(tasks: dict, st: dict, cur_fp: str = "") -> dict: + """🔴 **队列计数**(用户 2026-09-30:「协作程序 也要显示**当前待验收队列数量**」)。 + + 🔴 **队列 = 需求台账 `tasks.json`**(`architecture.md` §迭代记录明载),**四态**: + `pending 待执行` / `running 执行中` / `done 已完成` / `blocked 有阻碍`。 + ⚠️ **机制里没有「待验收」这个态**(⛔ 不臆造一个数字出来)⇒ 这里如实给**四态明细 + 未完结数**: + `open`(未完结)= 待执行 + 执行中 + 有阻碍 + 其它;`done` = 已完成。 + ⚠️ 另附 `notify_n`(= `queue_info.n`,**待主会话反馈**的通知条数)—— 它与台账是两码事,⛔ 别混。 + + ## 🔴🔴 2026-10-06 改:**按当前目标过滤**(用户逐字: + 「看板中 常驻程序 改为 目标检查,里面的队列 **应该是随着目标的不是目标累积的**」) + + **改前**:这一格报的是 `tasks.json` **全量累计**(`total=len(tasks)`)⇒ 换过目标之后 + 它把**历史目标的活**也一起数进来 ⇒ 读者**分不清"当前目标在跑多少"**。 + (实测本区台账里躺着跨时代的旧条目 —— 代码注释里也早写过这个现象。) + + **改后**(⛔ **不动数据**,纯显示层过滤;台账**仍然全留**,历史可回溯): + · 传进来的 `cur_fp` = 当前目标指纹(`collabd.goal_fp()`,= `sha1(title)[:6]`)。 + · 只把 `goal_fp == cur_fp` 的条目算进 `total`/`by`/`open`。 + · 🔴 **无归属的存量条目**(早期写的、没 `goal_fp` 字段)⇒ 算进 `legacy`,**⛔ 不混进当前目标**: + 把它们算进来=**伪造归属**(旧条目本属旧目标),会让"当前目标未完数"虚高 ⇒ 又回到改前的毛病。 + · ⚠️ `cur_fp` 为空(未声明目标)⇒ **如实把全部当 `legacy`**,⛔ 不假装"队列为空" + (那会把"没有目标"说成"没有活" —— 同族:降级不静默那条红线)。 + """ + by = {"pending": 0, "running": 0, "done": 0, "blocked": 0, "other": 0} + legacy = {"pending": 0, "running": 0, "done": 0, "blocked": 0, "other": 0} + n_legacy = 0 + for v in (tasks or {}).values(): + s = str((v or {}).get("state") or "").strip().lower() + k = s if s in by else "other" + _fp = str((v or {}).get("goal_fp") or "").strip() + # ⚠️ `cur_fp` 空 ⇒ 全部按 legacy 计(⛔ 不许因为"没有当前目标"就把它们当成当前目标的) + if cur_fp and _fp == cur_fp: + by[k] += 1 + else: + legacy[k] += 1 + n_legacy += 1 + qi = st.get("queue_info") or {} + try: + _nn = int(qi.get("n") or 0) + except Exception: + _nn = 0 + _cur_total = sum(by.values()) + return {"total": _cur_total, "by": by, + "open": by["pending"] + by["running"] + by["blocked"] + by["other"], + "notify_n": _nn, + # 🔴 新增:给渲染层"源数据有多宽"用 —— 看板那格据此决定要不要提"另有历史 N 件" + "all_total": len(tasks or {}), + "legacy": n_legacy, + "legacy_by": legacy, + "fp": cur_fp} + + +# ⛔ 「监督守护为什么停」是**某个项目的历史事实** ⇒ 归**使用方**(写在其 `board_ext.py` 里, +# 经 `ext["guard_stop_reason"]` 取回);技能侧⛔ 不落任何项目的止损史。 + + +def _age_min(p: Path): + """文件 mtime 距今多少分钟。取不到 ⇒ None(⛔ 不拿 0 冒充"刚更新")。""" + try: + return round((time.time() - p.stat().st_mtime) / 60.0, 1) + except Exception: + return None + + +def _label_when_down(prog_age, hb_pid, degraded: bool) -> str: + """🔴 **常驻不在线时的文案**(四态,⛔ 每态都说清"依据是什么")。 + + 🔴 2026-10-03 11:35x 用户报障后确立(原文:报「已 849.6 分钟没轮」而常驻明明活着): + 那条文案把**两件不同的事**混成一句 ⇒ 读者无法判断该做什么。 + 四态: + · **已停** = pid 不在了(心跳停 ⇒ 进程真死)⇒ ⛔ 别写成"没轮"(那是假绿)。 + · **没轮** = pid 还在但心跳旧(活着却卡住)⇒ 这是"该叫醒它"的场景。 + · **判据降级**= 心跳**读不到**(⛔ 不是"停")⇒ 只报读数,⛔ **不许提进程**(提了就是编)。 + · **状态未知**= 什么都读不到。 + """ + if degraded: + if prog_age is None: + return "心跳读不到 · 状态未知" + return "心跳读不到 · 判据已降级(旧戳 %d 分钟前)" % round(prog_age) + if prog_age is None: + return "心跳读不到 · 状态未知" + if hb_pid and not _pid_alive(hb_pid): + return "已停 · 进程不在(心跳停在 %d 秒前)" % round(prog_age * 60) + return "在线 · 已 %d 秒没轮(进程还在)" % round(prog_age * 60) + + +def _pid_alive(pid) -> bool: + """🔴 pid 是否活着 —— **直接复用 `collabd.py` 的实现**(⛔ 不重复写第二份)。 + + 🔴 2026-10-03 11:39~11:40x 两次踩坑后的决定(**两份同名实现=活例**): + ① 本文件第一版**没声明 `argtypes`/`restype`** ⇒ 64 位 `HANDLE` 被 ctypes 截断 + ⇒ 实测心跳正在更新、却 `OpenProcess` 返回 0 且 `GetLastError=87` + ⇒ **活进程被判死**(方向①假读数)。 + ② 第二版补了签名,但对 `ACCESS_DENIED` 返回"不在" + ⇒ **常驻活着却被说成「已停」**(方向②假读数)。 + ⇒ `collabd.py::_pid_alive` 是踩过坑的成熟版:`WinDLL` + 显式类型 + + **`ACCESS_DENIED` ⇒ 保守判「在」** + 判不出来 ⇒ 保守判「在」。 + ⚠️ 复用方式=`importlib` 按路径加载同目录 `collabd.py`(与 `_is_check_agent` 同款); + ⛔ 不写进 `sys.path`(污染宿主能 import 的名字空间)。 + ⚠️ 加载失败 ⇒ 回落成「保守判在」—— ⛔ 宁可不说"已停"(那会误报故障), + 也**不许**回落成"判不在"(那会误报停机)。 + """ + try: + return bool(_cb_shared._pid_alive(pid)) + except Exception: + return True # ⛔ 判不出来 ⇒ 保守判「在」(宁可漏报,⛔ 不误报停机) + + +def _supervise_heartbeat() -> tuple: + """🔴 读**常驻心跳**(`logs/supervise-heartbeat.json`)⇒ 返回 `(龄_分钟, pid, round)`。 + + 🔴 **这是"常驻程序在不在跑"的唯一真源**(用户 2026-10-03 11:35x 报障后确立): + 旧判据读 `_tick.stamp`/`collabd-once.stamp` —— 那是**已退役投递机制**的遗留戳, + 实测停在 2026-10-02 21:24 ⇒ 报"849.6 分钟没轮",而常驻明明 10 s/轮。 + ⚠️ **只判"心跳新鲜"还不够**:pid 可能已死而文件还在(宿主回收不删文件)⇒ + **pid 活 ∧ 心跳新鲜**才是完整判据(与 `collabd.py::supervise_alive()` 同款)。 + ⚠️ 读不到 ⇒ 返回 `(None, None, None)` ⇒ ⛔ **调用方不许直接判"没在跑"**(那是"读不到")。 + """ + import json as _j + hb = None + for cand in (INBOX.parent.parent / ".workbuddy" / "collab" / "logs" / "supervise-heartbeat.json", + WS / ".workbuddy" / "collab" / "logs" / "supervise-heartbeat.json"): + try: + if cand.exists(): + hb = cand + break + except Exception: + continue + if hb is None: + return (None, None, None) + try: + d = _j.loads(hb.read_text(encoding="utf-8")) + age = max(0.0, (time.time() - float(d.get("ts") or 0)) / 60.0) + return (age, d.get("pid"), d.get("round")) + except Exception: + return (None, None, None) + + +def _runtime() -> dict: + """🔴 **常驻程序 / 投递的实时状态**(用户 2026-09-30 要求架构图「**要能展示实时状态**」)。 + 取的全是**真痕迹**(状态戳 mtime),⛔ 不猜、⛔ 不按"配置里写着要常驻"就当它活着: + · **常驻程序**:`_tick.stamp`(宿主钩子唤起的一次性投递轮)/`collabd-once.stamp`(投影轮)取更新时间 + · **投递**(旧名「监督程序」):**不是一个该常驻的进程** —— 它就是**宿主钩子唤起的一次性 + `--tick`**。所以这里报的是「**这条链通不通**」(看 `--tick` 戳的新鲜度),⛔ 不是"启动没启动"。 + ⚠️ 教训仍在:图上必须能一眼看出**谁其实没在跑** —— 但"没在跑"得先说清**它本来该不该跑**。 + """ + # 🔴🔴 2026-10-03 11:35x **判据改读常驻心跳**(用户报障:「已 849.6 分钟没轮」, + # 而常驻明明 10 秒一轮)。真因:下面这两个戳是**已退役投递机制**的遗留, + # 实测都停在 **2026-10-02 21:24**(无人再写)⇒ 报"849 分钟没轮"=**假读数**。 + # ⇒ 主判据换成**心跳**(⛔ 与 `collabd.py::supervise_alive()` 同一口径)。 + # ⚠️ 旧戳**保留但降级**为附注(`legacy_*`),⛔ **不再参与判定** —— 它们仍记录着 + # "最后一条旧机制痕迹在哪",将来要查历史时用得上。 + tick = _age_min(INBOX / "_tick.stamp") + proj = _age_min(INBOX / "collabd-once.stamp") + glog = _age_min(INBOX / "guard.log") + stopped = (INBOX / "guard.stop").exists() + + # ① 主判据=常驻心跳(pid 活 ∧ ts 新鲜) + hb_age, hb_pid, hb_round = _supervise_heartbeat() + HEARTBEAT_FRESH = 90.0 # 秒。⛔ 与 `collabd.py::SRSM_FRESH` 同量级(那边是 900s, + # 但那是给"长工具调用"留的;⛔ 看板这里要"看起来在线", + # 90s 与常驻 10s/轮匹配 ⇒ 够灵敏又不误报) + if hb_age is not None: + prog_up = hb_age <= (HEARTBEAT_FRESH / 60.0) + prog_age = hb_age + prog_by = "常驻 --supervise(round %s)" % (hb_round if hb_round is not None else "?") + else: + # ② 心跳读不到 ⇒ **回落**旧戳,⛔ **不直接判"没跑"**(那会把"读不到"说成"没在跑")。 + # 🔴 2026-10-03 11:35x 变异 M3 抓到:回落时**不能沿用 pid 相关的文案** + # (实测报出「已 51210 秒没轮(进程还在)」—— pid 根本没读到,那句"进程还在"是**假的**)。 + # ⇒ 回落分支用**独立**的 `_hb_degraded` 标记,让 label 说"判据已降级"⛔ 不说进程。 + _a = [a for a in (tick, proj) if a is not None] + prog_up = bool(_a) and min(_a) < 15.0 + prog_age = min(_a) if _a else None + prog_by = ("旧机制戳(⚠️ 常驻心跳读不到,判据已降级)" if prog_up else "") + _hb_degraded = (hb_age is None) + _hb_note = "" if not _hb_degraded else "(⚠️ 常驻心跳读不到,本行判据已降级为旧机制戳)" + + guard_up = (not stopped) and (glog is not None and glog < 5.0) + return { + # 🔴 2026-09-30 用户:「**协作程序 应该一直运行,改成 在线**」⇒ 正常态就报两个字「在线」, + # ⛔ 不再堆"钩子 --tick · N 分钟前"那种细节长串(细节进 `how`,给界面做 tooltip 用)。 + # ⚠️ **但不放假绿**:真没轮动就说清"在线 · 已 N 分钟没轮"——机制还在,只是没被唤起; + # ⛔ 也不写「已停」——它不是一个"该常驻、可被停"的进程,那个词本身就会误导。 + "prog": {"up": prog_up, "age_min": prog_age, "by": prog_by, + # 🔴 心跳真源读数(前端 tooltip 可显示;⛔ 旧戳另存 `legacy_*`) + "heartbeat_age_min": hb_age, "heartbeat_pid": hb_pid, + "legacy_tick_age_min": tick, "legacy_proj_age_min": proj, + # ⚠️ 文案三态(2026-10-03 改):「在线」/「已 N 秒没轮」/「心跳读不到」 + # ⛔ 原来只有两态,且判据指错文件 ⇒ 报"849.6 分钟没轮"(假读数)。 + # ⚠️🔴 2026-10-03 11:35x **文案必须区分「死了」与「没被唤起」**: + # 实测踩到⇒常驻真死时,旧文案报「已 890 秒没轮」⇒ **把「进程死了」 + # 说成「没被唤起」=假绿**(读者会以为机制正常只是闲着)。 + # ⇒ pid 不在 ⇒ 明说「已停」;pid 在但心跳旧 ⇒ 才是「没轮」。 + # ⛔ 仍不写「已停」给**读不到心跳**的情形(那是读不到,⛔ 不是停)。 + "label": ("在线" if prog_up else + (_label_when_down(prog_age, hb_pid, _hb_degraded))), + "how": ("常驻程序**常驻一直运行**(collabd.py --supervise,10 s/轮);" + "判据=心跳文件 `logs/supervise-heartbeat.json` 的 ts" + "(pid 活 ∧ 心跳 <%d s),⛔ 与 `supervise_alive()` 同一口径。" + % int(HEARTBEAT_FRESH) + + ((";最近一轮=%s" % prog_by) if prog_by else "") + + _hb_note + + (";⚠️ 旧投递机制戳(_tick/--once)已**退役、不再写**," + "仅作历史痕迹保留(%s/%s 分钟前)。" + % (tick if tick is not None else "无", + proj if proj is not None else "无")))}, + # 🔴🔴 **「投递」这个词取代了旧的「监督程序」**(2026-09-30 改口径 · 用户连问四次 + # "投递为什么打不开 / 它一直停着能起什么作用 / 还需要保留吗 / 跟它有关系吗"): + # · 「监督程序」是**投递这条职责的旧名**; + # · 真正该报的是「**投递这条链通不通**」⇒ 判据用 `--tick` 戳的新鲜度(投递轮就是它)。 + # 🔴 2026-10-01 晚订正:常驻定案**回归**(用户「一直运行(常驻)」+「定时任务方案已废弃」) + # ⇒ 投递**应当一直运行** ⇒「长时间未触发」**是一个真读数**(常驻该起没起); + # ⛔ 不再是 09-30 那句「没有东西该在跑、别报已停」。`stopped`(`guard.stop`)随之恢复意义。 + # 🔴 **必须原样保留的**:投递方唯一性(只有 `--tick` 能推进队列)—— 22:53 事故的修法。 + "deliver": {"up": (tick is not None and tick < 15), "stopped": stopped, "age_min": tick, + # ⚠️ 这行窄(`PW-36` 还要给 12px 字号)⇒ 文案要短:机制那句在下面一行已经有了, + # 这里只报**新鲜度**,⛔ 别重复"常驻/钩子"(会溢出成"…前…"被截断)。 + "label": (("就绪 · %s 分钟前跑过" % tick) + if (tick is not None and tick < 15) else "长时间未触发(投递没在跑)"), + "reason": ""}, # ⛔ 不再给"停因"——那个问题已经不存在了 + # 🔴🔴 2026-10-03 10:1x **新增 `check`:检查会话在不在跑**(用户报障逐字: + # 「协作程序这个时候应该显示 **有检查程序在运行**」)。 + # ⚠️ 之前常驻程序那格只有**静态职责文案** + `prog.label`(在线/停), + # **完全看不到"它刚建的检查会话跑到哪一步了"** ⇒ 图上看着像"什么都没发生"。 + # 🔴 判据=**与会话明细同一个口径**(`status='working'` ⇒ 看板那格会亮): + # **⛔ 不另造判据**(同"统一一个标准"那条口径)⇒ 看板亮 ⇔ 这里说"在运行"。 + # ⚠️ 类别从**标题**解析(`[协作]-[结果检查]-…`/`[协作]-[目标检查]-…`) + # ⇒ ⚠️ **依赖检查会话按两级前缀命名**(2026-10-03 10:1x 才把旧名 `结果检查-…` + # 改成 `[协作]-[结果检查]-…`)⇒ 旧名会话 `topic` 为空 ⇒ 只报"检查中"不报种类。 + "check": _check_runtime(), + } + + +def _check_runtime() -> dict: + """🔴 **检查会话在不在跑**(常驻程序那格显示「有检查程序在运行」用)。 + + 🔴 **判据=`sessions.status='working'` + 标题带 `[结果检查]`/`[目标检查]`** —— + **与会话明细/看板亮灭同一个口径**(`board.html` 的 `mOn=!!(main&&main.status==='working')`), + ⛔ 不另造第二套"在执行"判据(那正是 2026-10-03 上午被统一掉的那件事)。 + ⚠️ 读库失败 ⇒ 返回 `{"n": 0, "unknown": True}` ⇒ 前端显示「状态读不到」, + ⛔ **不假装"没有检查在跑"**(那会把机制故障说成正常)。 + """ + out = {"n": 0, "kinds": [], "unknown": False, "label": "无检查在跑"} + try: + db = _host_db() + if not db: + out["unknown"] = True + out["label"] = "检查状态读不到" + return out + con = sqlite3.connect("file:%s?mode=ro" % str(db).replace("\\", "/"), uri=True, timeout=0.3) + try: + con.execute("pragma busy_timeout=300") + except Exception: + pass + # 🔴 `deleted_at` 过滤**必须带**(与 `_sessions()` 同款):宿主「删除会话」是**软删除** + # (行还在表里)⇒ 漏了它 ⇒ 已删的检查会话照样被算成"还在跑"(同 2026-10-02 那个坑)。 + # 🔴🔴 2026-10-03 10:1x **两种标题形态都认**(向后兼容,⛔ 不是妥协): + # · 新名(10:1x 起的合规名)`[协作]-[结果检查]-…` / `[协作]-[目标检查]-…`; + # · 旧名(10:1x 之前建的)`结果检查-…` / `目标检查-…`。 + # ⚠️ 为什么必须都认:改命名只对**之后新建**的会话生效 ⇒ 若只认新名, + # 那些**改之前就在跑**的检查会话在看板上会**凭空消失**(读数假"无检查在跑") + # ⇒ 又是"把机制故障/在跑说成没有"的同族假绿。 + # ⛔ 但**不因此放松命名规范**:新名仍是唯一规范(`collabd.py` 侧已改), + # 这里的双形态只是**读存量**的兼容层。 + rows = con.execute( + "select title from sessions where status='working' " + "and (deleted_at is null or deleted_at=0) " + "and (title like '%[结果检查]%' or title like '%[目标检查]%' " + " or title like '结果检查%' or title like '目标检查%')").fetchall() + con.close() + except Exception: + out["unknown"] = True + out["label"] = "检查状态读不到" + return out + kinds = [] + for (t,) in rows: + s = str(t or "") + kinds.append("目标检查" if "[目标检查]" in s else "结果检查") + out["n"] = len(kinds) + out["kinds"] = kinds + if out["n"]: + uniq = sorted(set(kinds)) + out["label"] = "有检查程序在运行" if len(uniq) > 1 else "有%s在运行" % uniq[0] + return out + + +# ⛔ 上一版这里还有 `_parse_relay_line` / `_overlay_node` / `_end_to_end_verified` 三个函数 —— +# 它们解析的是**某个项目的中继客户端日志**(glob 路径、字段名、`streams>0` 判据)。 +# 按「技能就是技能,谁用产生的文件放在他自己那里」,**已整体搬到使用方的 +# `.workbuddy/collab/board_ext.py`**;技能侧只保留「读扩展并渲染」这一层(见 `_ext()`)。 + + +def _orphan(lb: list) -> dict: + """把 `labor` 里 `kind != "topic"` 的行**汇总成一份可见的"未归类"**(⛔ 不许静默丢)。 + + 🔴 判据同族红线:**"读到了却没显示" = 少说一句话** —— 它不崩溃,所以最难发现。 + 场景:任务类别清单已迁移(唤醒机制/…),台账里仍有**跨工作区时代的旧线名** ⇒ 旧版把它们 + 也当"分工位"塞进 `labor`,而架构图只画前 N 格 ⇒ 那几格(连同里面的件)**整块消失**。 + ⇒ 现在:它们**不是类别**(`kind="legacy"`,⛔ 不占正式分工位),但看板必须**明确说出来**。 + """ + _lg = [x for x in (lb or []) if str(x.get("kind")) != "topic"] + if not _lg: + return {"lines": [], "n": 0, "total": 0, "done": 0, "open": 0} + return {"lines": [{"line": str(x.get("line") or ""), + "name": str(x.get("name") or x.get("line") or ""), + "total": int(x.get("total") or 0), + "done": int(x.get("done") or 0), + "open": int(x.get("open") or 0)} for x in _lg], + "n": len(_lg), + "total": sum(int(x.get("total") or 0) for x in _lg), + "done": sum(int(x.get("done") or 0) for x in _lg), + "open": sum(int(x.get("open") or 0) for x in _lg)} + + +# 🔴🔴🔴 **判定词白名单 —— 全库唯一事实源**(2026-10-04 立,用户报「看板还是 0 通过」)。 +# +# **为什么要立它**:这条规则**已经连修 3 次、每次只补一个词**,第 4 次又栽在同一个形状上: +# · 10-02 只认英文 `pass` ⇒ 真源写中文「过」⇒ 全判非 pass(补「过」) +# · 10-03 只取最后一段 ⇒ 「复核:过」被切掉(改逐段找) +# · 10-04 🔴 **真源写的是「达」**(`达(实测 7 个…)`)⇒ 三处判据**一个都不认** +# ⇒ vibe-product 15 条判据显示 **0 通过**(含 14 条已达标)——用户原话「为什么还是0通过」。 +# ⛔ 每次修完不立清单 ⇒ 下次换个词**必然再栽**。⇒ 白名单**只留这一处**,三处判据都读它。 +# +# ⚠️ **判定词只许在这里增**(改完必须同时改 `assets/board.html` 的 `ACC_PASS_WORDS`)。 +# 🔴 **fail-closed 不变**:`不过`/`未过`/`待重验`/`未达` **都不以这些词开头** ⇒ 自然判非过。 +ACC_PASS_WORDS = ("pass", "过", "通过", "达", "达标", "合格", "完成") + +# 🔴🔴🔴 **判定词前缀「副词」白名单**(2026-10-05 立 —— **第 5 次复发**,这次改成治形状、⛔ 不再补词)。 +# +# **病根**:判定词前会带一个**表已完成/确认的副词**,而 `startswith` 要求**从第一个字符**就命中 +# ⇒ 带了副词就**永远不命中** ⇒ 明明写着"已过"却判"非过": +# · 2026-10-05 实测(vibe-product 页签):9 条判据**全部写着 `已过|…`**,看板显示 **0 / 9 通过** +# —— 因为 `已过` 的第一个字是 `已`、⛔ 不是 `过`。用户原话:「完成情况 0 / 9 通过,但是 +# 我看到都显示已过呀」。 +# ⛔ 前四次(10-02「过」/10-03「逐段找」/10-04「达」)都在**补词**,每次换个措辞必然再栽 +# —— 这一次**换个方向**:**先把副词剥掉再比**,⛔ 不往词表里堆"已过/已通过/已完成"这种组合。 +# +# ✅ 剥离规则:判定词**只在前缀是下列副词时**才剥(⛔ 无脑 lstrip 会把 `未过` 剥成 `过` ⇒ **假绿**)。 +# ⚠️ 只收**表"完成/确认"**的副词;⛔ **绝不许收否定副词**(未/不/没/非/待)—— +# 它们一律**留给下面 fail-closed**(不剥 ⇒ 开头不是判定词 ⇒ 判非过)。 +ACC_ADV_PREFIX = ("已", "已经", "均", "都", "经复核", "复核后") + +# 🔴 与 `ACC_PASS_WORDS` 同理:`assets/board.html` 的 `ACC_ADV_PREFIX` 必须**逐字同款**。 + + + +def acc_is_pass(v) -> bool: + """🔴 一条验收判据「算不算过」—— 🔴 **全库唯一实现**。 + + 🔴🔴 **2026-10-04 立白名单**(见上文 `ACC_PASS_WORDS`):本函数**不再各自写死词表**, + 三处判据(`board.py` 本函数 / `collabd.py::_acc_is_pass` / `board.html::accIsPass`) + **必须同款** —— 漂了就会出现"看板说通过、常驻程序说不通过"(10-04 实测已发生: + 值 `过:1440 与 390 两视口` ⇒ `board.py` 判过、`collabd.py` 判不过)。 + + 🔴🔴 **2026-10-05 第 5 次复发 ⇒ 改治形状**:判定词前常带**表完成的副词**(`已过`/`均已通过`/ + `已完成`),而 `startswith` 要求**从第一个字符**就命中 ⇒ **带副词就永远不命中**。 + 实测(vibe-product 页签):9 条真值全写 `已过|…`,看板却显示 **0 / 9 通过**。 + ✅ **正解=先剥"完成副词"再比**(`ACC_ADV_PREFIX`),⛔ 不再往词表堆 `已过/已通过` 这种组合 + —— 前四次(10-02 `过`/10-03 逐段/10-04 `达`)都在补词,换个措辞必然再栽。 + ⚠️ 剥副词**只对白名单里的副词**;`未/不/没/非/待` **绝不在白名单** ⇒ `未过`仍判非过(fail-closed 不破)。 + + 沿革(⛔ 保留,因为"理由也会过时"): + 2026-10-02 实测修的**假红**:原判据是 `str(v).lower() != "pass"`,而**台账的真实写法是中文** + —— `goal.json::acceptance_state` 的值形如 `过(PID 3552 …)`/`待重验(…)` + ⇒ **6 条已过被判成「非 pass」** ⇒ 看板显示 **7 条全非 pass**,与真源**相反**。 + ⛔ 同族=**判据写死期望值**(`pitfalls.md` P0-13 / P0-20):看着有判据,实际**永远不命中真源**。 + 2026-10-02 残留假红(同族第 2 处):`**复核:过(形态已治本…)**` 判定词**不在开头**。 + 2026-10-03 18:5x 改「逐段找」:值里**有好几个冒号**(说明 + **时间里的**)⇒ 取最后一段切错。 + """ + import re as _re + s = str(v or "").strip() + if not s: + return False # 空 ⇒ 判不出来 ⇒ ⛔ 不算过(fail-closed) + head = _re.split(r"[((]", s, maxsplit=1)[0] # 丢掉括注说明 + # 🔴 **逐段找判定词**(⛔ 不再"只取最后一段")—— 见上沿革 10-03。 + for _seg in _re.split(r"[::]", head): + _t = _seg.strip().lstrip("*").strip().lower() + for _w in ACC_PASS_WORDS: + if _t.startswith(_w): + return True + # 🔴🔴 2026-10-05 第 5 次复发:**剥掉"完成副词"再比一次**(治形状,⛔ 不补词)。 + # ⚠️ 顺序:**先原样比、再剥副词比** —— 剥了更好命中,且⛔ 不影响原判定。 + # ⚠️ **可叠加**:实测真源写 `均已通过`/`都已达标` ⇒ 一个副词不够,要能连剥 + # (`已`+`均`/`都`)。⇒ **循环剥到剥不动为止**,每剥一轮都试一次判定词。 + # ⚠️ 只剥 `ACC_ADV_PREFIX` 里的(已/已经/均/都/…);`未/不/没/非/待` **不在**里面 + # ⇒ `未过`/`不过`/`待重验` **剥不动** ⇒ 仍判非过(fail-closed 不破)。 + # ⚠️ 上限 3 轮:防"怪值死循环"(正常最多两级:`均已`)—— ⛔ 不是省事,是**有界**。 + _t2 = _t + for _ in range(3): + _cut = "" + for _a in ACC_ADV_PREFIX: + 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 ACC_PASS_WORDS: + if _t2.startswith(_w): + return True + return False # 一段都没命中 ⇒ 判不出来 ⇒ ⛔ 不算过 + + +# 🔴 **兼容别名**(2026-10-04):本函数原名 `_acc_is_pass`,改名成公开名 `acc_is_pass` +# 是为了让 `collabd.py` 能 import 复用(`_` 前缀跨模块 import 不便,且语义上它确实是 +# 公开判据)。此处保留旧名别名 ⇒ 本文件内历史调用点(`_acc_summary` 等)与外部 +# 任何仍写 `board._acc_is_pass` 的地方都不会 NameError。⛔ 别名不许各写实现,只指同一对象。 +_acc_is_pass = acc_is_pass + + +def _acc_summary(acc: dict) -> str: + """🔴 验收状态一行话。⛔ 与 goalctl / collabd 同一条红线: + **一条有效判据都没有 ⇒ 判不出来**,⛔ 不许静默显示成「无」(那读起来像"全过")。""" + if not acc: + return "⚠️ 未声明验收判据(只有说明行)⇒ **判不出来**,⛔ 不因此判完成" + bad = [k for k, v in acc.items() if not _acc_is_pass(v)] + if not bad: + return "全部 pass(%d 条)" % len(acc) + n_pass = len(acc) - len(bad) + return "非 pass(%d/%d):%s" % (len(bad), len(acc), bad) if n_pass else "非 pass:%s" % bad + + +def _goal_block(g: dict, active: bool, tasks_all: dict, srows, st: dict, warn: list, + multi: bool = False, all_topics=None, + # 🔴 2026-10-03 15:45x 跨工作区 tab:`peer`=来源工作区名,`ws_root`=其绝对路径。 + # ⚠️ 这两个形参**必须存在**:调用点已按位置传了 10 个实参, + # 少了它们 ⇒ `TypeError` ⇒ 快照生不出 ⇒ 看板一直 `warming` + # (本轮实测就是这样:脚本报了 OK 但这一处没写进文件)。 + peer: str = "", ws_root: str = "") -> dict: + """🔴 **一个目标的完整快照块** —— 看板的一个 tab 就是它(用户 2026-10-01)。 + + 🔴 **台账怎么分到各目标**:**按任务类别**(类别清单 ≡ 台账条目的 `line`,同一个词,四处同义)。 + · **只有一个目标** ⇒ **原样全给**(⛔ 不做任何切分 ⇒ 与旧版逐字一致); + · **多个目标** ⇒ 按 `line ∈ 本目标 topics` 切;`line` 不属**任何**目标的那些件 + (=跨工作区时代留下的旧线名)**归到"活跃目标"名下并原样显示** —— + ⛔ 不静默丢弃(同族红线:**读到了就要说**,⛔ 别让它们整批不见)。 + """ + tps = _goal_topics(g) + tpset = set(tps) + tasks = dict(tasks_all) + if multi: + _at = set(all_topics or []) + tasks = {k: v for k, v in tasks_all.items() + if str((v or {}).get("line") or "") in tpset} + if active: + for k, v in tasks_all.items(): + if str((v or {}).get("line") or "") not in _at: + tasks.setdefault(k, v) + sc = project_scope(g, ws_root) if (peer and ws_root) else project_scope(g) + if peer and ws_root: + # 🔴 peer 格:把「哪个工作区」对准**对方** —— 否则 `_sessions()` 里那道 + # `_ct == _wstail`(`cwd` 末段 == 工作区名,`:971`)会把对方的会话**全部排除** + # ⇒ 界面显示「0 条会话」= **假象**(2026-10-03 实测:对方主会话明明 `working`)。 + # ⚠️ 只覆盖 `workspace` 这一个键:其余判据(主会话登记/任务类别)**仍按对方 goal.json 算**。 + sc = dict(sc or {}) + sc["workspace"] = ws_root + try: + # 🔴🔴 2026-10-03 17:0x **peer 格走 `bypass=True`** —— + # 对方会话的「项目归属」依据(主会话登记 + 任务类别)存在**对方**的收件箱里, + # 而 `in_project()` 读的是**本工作区** ⇒ 用它 ⇒ 对方会话全被判「不属于本项目」 + # ⇒ 格子永远 0 条(2026-10-03 实测:两个新区各 2 条会话,主会话与任务会话都看不见)。 + # ⚠️ 角色判据仍是**同一套**(`_role_of_title()` = 标题两级前缀,两侧共用)⇒ 两边说法一致。 + _ss = _sessions(sc=sc, rows=srows, bypass=bool(peer and ws_root)) + except Exception as e: # ⛔ 兜底:绝不让一个目标把整份快照搞挂 + _ss = {"mine": [], "others_running": 0, "err": "会话读取异常:%s" % e} + if _ss.get("err"): + _nm = str(g.get("short") or g.get("id") or "目标") + warn.append("%s:%s" % (_nm, _ss["err"])) + _lb = _labor(tasks, _ss["mine"], g) # 🔴 只算一次:分工板与"未归类"汇总都用它 + qi = st.get("queue_info") or {} + acc = {k: v for k, v in (g.get("acceptance_state") or {}).items() + if not str(k).startswith("_")} + # 🔴🔴 2026-10-06 **本块(=本目标)的指纹** —— 队列按目标过滤用。 + # 用户逐字:「队列 应该是随着目标的不是目标累积的」。 + # ⚠️ **必须从形参 `g` 算**(⛔ 不许读 `INBOX/goal.json`): + # peer 格渲染的是**对方工作区**的目标,读本区文件 ⇒ 用本区指纹过滤 ⇒ + # 对方格的队列**恒为 0**(跨工作区静默错)。 + # ⚠️ 口径与 `collabd.goal_fp()` / `goal_dir_name()` **逐字同一套**(`sha1(title)[:6]`)—— + # 只能有一处口径;这里是**渲染侧**的重算,⛔ 不是第二套标准。 + _fp_title = str((g or {}).get("title") or "").strip() + cur_fp = hashlib.sha1(_fp_title.encode("utf-8")).hexdigest()[:6] if _fp_title else "" + return { + "key": str(g.get("id") or g.get("short") or ""), + # 🔴🔴 2026-10-03 15:45x **透传 `peer`** —— ⛔ 上游 `goal_files()` 加了、下游这里不带 + # ⇒ 前端永远看不到 ⇒ 三个工作区目标标题相同 ⇒ **tab 等于白加**(读者分不清)。 + # ⚠️ 这是"改一处漏一处"的第 N 次复发 ⇒ 加字段必须**顺着数据流查到消费点**。 + "peer": peer, + "ws_root": ws_root, + # 🔴 2026-10-03 20:31x **每格都带真实工作区名**(用户:「都用真实名称」+「本工作区没用的东西不要显示」)。 + # ⛔ 原来前端对**本区**兜底成"本工作区"三个字 ⇒ 那不是名称、是占位词。 + # ⇒ 本区取**本工作区目录名**、对方区取快照给的 `peer`,两者都是**真实目录名**。 + "ws_name": (peer or Path(str(WS or "")).name or ""), + "active": bool(active), + # 🔴🔴 2026-10-03 18:5x **把「目标生命周期」透出到块里** —— + # 用户报障:「主会话说目标都已完成,⛔ 为什么看板上还是没完成」。 + # 真因:`collabd.py --set-life 已完成` 写的是 **`lifecycle`** 字段, + # 而看板原先**只读 `acceptance_state`/`run`** ⇒ ⛔ 目标早就标完成了,看板却永远看不到。 + # ⚠️ 这是"加字段必须**顺着数据流查到消费点**"的又一次(本包第 N 次):只加字段没人渲染 = 白加。 + "life": str(g.get("lifecycle") or ""), + "life_at": str(g.get("lifecycle_at") or ""), + "life_by": str(g.get("lifecycle_by") or ""), + "run": str(g.get("run") or "active"), + "paused": str(g.get("run") or "active").strip().lower() != "active", + "goal": {"title": g.get("title") or "", "short": g.get("short") or "", "acceptance": acc}, + # 🔴🔴 2026-10-06 **目标声明时间**(tab 排序用,用户「按创建时间顺序」)—— + # ⚠️ **必须在这里单独带出来**:上面 `goal` 只留 3 个字段(title/short/acceptance), + # `declared_at` 会被**丢掉** ⇒ 排序处若从 `_b["goal"]` 里取 ⇒ **恒为空** + # (实测栽过:三块 `tab_created` 全是 `'~'`,排序形同没做,而判据"升序"照样绿)。 + # ⚠️ 从**形参 `g`** 取(peer 块传的是**对方**的 goal → 取到对方时间,正确); + # ⛔ 不许回读 `INBOX/goal.json`(那样 peer 块会拿到**本区**时间 ⇒ 排序串区)。 + "goal_declared_at": str(g.get("declared_at") or ""), + "acc_summary": _acc_summary(acc), + # 🔴 项目身份:看板顶部「本项目」区用它 ⇒ 一眼看清"这个看板是哪个需求目标项目的" + # 🆕 2026-09-30:`topics` / `main_by_topic` —— 用户要求「**同一个工作区**多会话协作 + # (主会话根据任务**自动梳理任务类别**:**通过协作会话名称前缀**区分具体执行会话)」⇒ + # 看板必须把"**这个工作区有哪几个任务类别、每类的主会话是谁**"直接摆出来 + # (⛔ 否则用户只看到一堆会话,看不出"哪条管哪一类")。 + "project": {"id": sc["goal_id"], "short": sc["short"], "title": sc["title"], + "main_sid8": (sc["main_sid"] or "")[:8], "criteria": sc["criteria"], + "workspace": sc["workspace"], + "topics": list(sc.get("topics") or []), + # 🔴 2026-10-01:类别清单的**来源**(用户:「目标是**通过对话在调用 会话协作技能时说明的**, + # 不是固定的」)⇒ 看板要能回答"这批类别是**在对话里说明的**,还是**回落**来的"。 + "topics_source": dict(sc.get("topics_source") or {}), + "main_sids8": [str(s)[:8] for s in (sc.get("main_sids") or [])], + "main_by_topic": {str(_t): (str(_v.get("sid") or "")[:8] or "") + for _t, _v in ((sc.get("main_by_topic") or {}).items())}}, + "others_running": _ss["others_running"], + # 🔴 2026-10-01:**同工作区、但按命名判据没归入本项目**的会话(用户报「创建了协作会话 + # 但看板没展示」)—— 它们**不是**本项目会话(⛔ 不进 `sessions`、⛔ 不进分工板), + # 但**必须看得见**(同族红线:读到了却不说)⇒ 交给 `assets/board.html` 在图外说明里点名。 + # 典型成因:棒名写成 `[协作]-<昵称>-…`(漏方括号/用 `goal.short` 而非 `topics`)。 + # 🔴🔴 2026-10-03 11:1x **新增 **(用户纠正逐字): + # 「检查会话不是协作会话,不应该出现在看板协作会话区域中; + # 它属于协作程序的会话,**可以放在协作程序框图中展示**」 + # ⇒ 检查会话**从 里单列**(⛔ 不混进任务会话那一排), + # 前端在**常驻程序那格(R4)里**画它们。 + # ⚠️ 仍**保留**在 里(⛔ 不删)—— 会话明细表要看得见有这条会话。 + "sessions_checks": _ss.get("checks") or [], + "sessions_unrecognized": _ss.get("unrecognized") or [], + # 🔴 2026-10-01:**已退场/超窗口**的会话(治「接续棒越堆越多」)—— + # 它们**不进 `sessions`、不进分工板、不进第三层**,但**必须报数** + # (同族红线:读到了却不说 ⇒ 读者以为那些棒凭空消失了)。 + # ⚠️ **退场 ≠ 删除**:宿主库里原样在,⛔ 本文件也不写任何东西。 + "sessions_retired": _ss.get("hidden") or {}, + "tasks": {k: {"state": str((v or {}).get("state") or "?"), + "line": str((v or {}).get("line") or ""), + "by": str((v or {}).get("by") or ""), + "artifact": str((v or {}).get("artifact") or "")} for k, v in tasks.items()}, + "sessions": _ss["mine"], + # 🔴 分工板(架构图「协作会话」层用它渲染)—— ⛔ 不是历史会话列表 + "labor": _lb, + # 🔴🔴 2026-09-30 加:**"未归类"汇总** —— 台账里那些**不在当前任务类别清单**的线。 + # 为什么必须有:类别清单已换成任务类别(唤醒机制/…),而台账里还躺着**跨工作区时代 + # 的旧线名**(`ai1net-dsh-anywhere` / `ai1net-dsh-desktop`,各自 2 件且都已完成)。 + # 旧版把这类行也塞进 `labor` 当"分工位",架构图又只画前 4 格 ⇒ **4 件已完成的活整格不见**, + # 读者只看到 4 个"件 0 · 完成 0"。⇒ 两处修正:① 它们**不是类别**(`kind='legacy'`) + # ② 但**必须看得见**(图上折叠成一格 + 表里标注)。 + "orphan": _orphan(_lb), + # 🔴🔴 2026-10-06 队列**按当前目标过滤**(用户:「队列 应该是随着目标的不是目标累积的」)。 + # ⚠️ 指纹的**计算在本字典之外**(函数体上方 `cur_fp`,紧邻 `return {`)—— + # ⛔ 别把赋值语句插进字典字面量里(本轮踩过:`SyntaxError: ':' expected after + # dictionary key`,因为它被当成了 `"key": value` 的一部分)。 + "queue": _queue(tasks, st, cur_fp), # 🔴 队列计数(检查程序节点用) + "progress": {"last_progress_at": st.get("last_progress_at"), + "goals_open": bool(qi.get("probe", {}).get("goal_open")), + "main_busy": bool(qi.get("probe", {}).get("main_busy")), + "prog_age_min": qi.get("probe", {}).get("prog_age_min")}, + } + + +def build() -> dict: + """🔴 **生成一份只读快照**。保证:**永不抛异常**(任何一处读不到 ⇒ 记进 `warn` 并降级), + ⇒ 调用方永远拿得到一份可渲染的数据,⛔ 不会因为看板读不到某个文件而连累别人。 + + 🔴 2026-10-01 多目标(用户:「把协作实时看板改为 **tab 支持多个目标**执行协作状态展示」): + · 多出一个 `goals` 数组 —— **一格一个目标**(活跃的排最前,其余按文件名); + · 顶层的 `goal`/`project`/`tasks`/`sessions`/`labor`/`orphan`/`queue`/`progress` + =**活跃目标那一份** ⇒ ⛔ 老渲染器与老断言**逐字照旧可用**(向后兼容); + · `front`/`runtime`/`notify`/`wakeups`/`meta` **不随目标变**(它们是工作区级的)。 + """ + warn = [] + _gfs = goal_files() + if not _gfs: # ⛔ 连 goal.json 都没有 ⇒ 也要给出一份可渲染的 + _gfs = [{"path": str(INBOX / "goal.json"), "goal": {}, "active": True, "key": ""}] + tasks = _j(INBOX / "tasks.json", {}) or {} + st = _j(INBOX / "collabd-state.json", {}) or {} + if not _gfs[0]["goal"]: + warn.append("读不到 goal.json") + if not st: + warn.append("读不到 collabd-state.json") + try: + srows = _session_rows() # 🔴 只读一次库 ⇒ 所有目标共用 + # 🔴🔴 2026-10-03 17:0x **只留 `cwd` 命中本工作区的行** —— + # `_session_rows()` 取的是**全库**最近 N 条(按活动时间倒序)⇒ 别的工作区只要 + # 任务类别撞上就会混进来(实测:本区格子里出现过 `cwd_tail=会话协作测试2` 的任务会话 + # ⇒ 那是**别区数据混在本区名下**,与 P0-40 同族的假数据)。 + # ⚠️ 这里用 `cwd` 判的是「这条会话属于哪个**工作区**」(≡ `cwd` 的字面含义), + # ⛔ **不是**架构 §2.3 禁止的那条「拿 cwd 推断会话属于哪个**需求项目**」。 + _wtail = str(WS).replace("\\", "/").rstrip("/").split("/")[-1].lower() + _own_rows = [] + for _r in srows or []: + try: + _ct = str(_r["cwd"] or "").replace("\\", "/").rstrip("/").split("/")[-1].lower() + except Exception: + continue + if _ct == _wtail: + _own_rows.append(_r) + srows = _own_rows + except Exception as e: + srows = [] + warn.append("WorkBuddy 库暂不可读:%s" % e) + # 🔴 2026-10-03 16:1x:本工作区的**切分基准只按本工作区自己的目标**算 —— + # ⛔ 把 peer 目标的 topics 混进 `_all_tps` ⇒ 本区台账里 `line` 命中"对方类别"的那些件 + # **两边都不进**(不在本区 tpset、又因为命中 _at 而拿不到"归活跃目标"的兜底) + # ⇒ **静默丢失**(同族红线:读到了就要说,⛔ 不许让它整批不见)。 + _own_gfs = [it for it in _gfs if not it.get("peer")] + _multi = len(_own_gfs) > 1 + _all_tps = [t for it in _own_gfs for t in _goal_topics(it["goal"])] + blocks = [] + for it in _gfs: + _p = it.get("peer") or "" + _wr = it.get("ws_root") or "" + if _p: + # 🔴🔴 peer 格:**只喂那个工作区自己的数据**(⛔ 喂本区的 = 把本区执行情况标到别人名下) + _b = _goal_block(it["goal"], False, _peer_tasks(_wr), _peer_srows(srows, _wr), + _peer_state(_wr), warn, False, _goal_topics(it["goal"]), _p, _wr) + _b["peer_scope"] = _peer_scope_note(_wr) + # 🔴 2026-10-03 20:22x **peer 格补自己的协作程序状态**(用户报障「不同目标的协作程序框内容一样」): + # 框里那两行(常驻程序在线/检查状态)读的是 `runtime` ⇒ ⛔ 块里没有这个键时 + # 前端视图会**沿用顶层**(=本工作区那份)⇒ 三个 tab 显示一模一样。 + _b["runtime"] = _peer_runtime_min(_wr, len(_b.get("sessions_checks") or [])) + blocks.append(_b) + else: + blocks.append(_goal_block(it["goal"], it["active"], tasks, srows, st, warn, + _multi, _all_tps)) + # 🔴🔴 2026-10-06 用户定案:「**tab 的排列顺序应该按创建时间顺序**」—— + # ⛔ 推翻 2026-10-04 的「最新在运行的排前面」(按活跃度排)。 + # 🔴 **为什么要改(活跃度排序的真实毛病)**:`age_min` 读的是**最近一次会话活动**, + # 而会话活动**每次协作都会变** ⇒ tab **顺序会自己漂**: + # 用户看到的是「昨天 A 在前、今天 B 跑到前面」——而**用户脑子里认的是"第几个 tab 是什么"**, + # 位置一变就要**重新找** ⇒ 越用越别扭(且用户**无从判断**为什么顺序变了)。 + # ✅ **创建时间的语义**:目标**一旦建立就不动**(`declared_at` 由 `goalctl declare` 写、 + # 换目标才会变)⇒ tab 顺序**稳定**,用户能记住"第 1 个是我的老目标"。 + # ⚠️ **键取 `declared_at`(目标声明时间)**,⛔ 不取 `topics_declared_at`: + # 两者多数相同,但 `declared_at` 是**目标本身**的建立时间(语义正), + # `topics_declared_at` 是**任务类别清单**的确认时间(可能后补、也可能缺失)。 + # ⚠️ **缺 `declared_at` 的块**(老目标/未声明)⇒ 排在**最后**(`"~"` 大于任何 ISO 时间串), + # ⛔ **不许猜**一个时间塞进去(同族红线:读不到就说读不到)。 + # ⚠️ **同时间戳的块**保持原相对次序(Python sort 稳定 + key 里带原序号兜底)。 + for _i, _b in enumerate(blocks): + _b["tab_created"] = str(_b.get("goal_declared_at") or "") or "~" + _b["tab_i"] = _i # ⛔ 稳定排序的兜底键 + blocks.sort(key=lambda b: (b.get("tab_created", "~"), b.get("tab_i", 0))) + for _i, _b in enumerate(blocks): + _b["tab_i"] = _i # 排序后重编,⛔ 别让前端拿旧序号当"第几个 tab" + _b["tab_more"] = max(0, len(blocks) - 6) # 🔴 超出 6 个的部分 ⇒ 前端收进下拉 + _ext_d = _ext(WS, warn) # 🔴 使用方自己的扩展(⛔ 技能侧不含项目知识) + _rt = _runtime() # ⚠️ 「投递」不再需要"停因"(那个问题已随改名消失)⇒ 不回填 reason + _ext_d.pop("guard_stop_reason", None) # 使用方若还留着这个键 ⇒ 不渲染(避免又把旧问题带回来) + qi = st.get("queue_info") or {} + out = dict(blocks[0]) # ⇒ 顶层=**活跃目标那一份**(向后兼容) + out.update({ + "ts": time.strftime("%Y-%m-%d %H:%M:%S"), + "epoch": round(time.time(), 1), + "goals": blocks, # 🔴 tab 的数据源(一格一个目标) + "wakeups": [{"ts": w.get("ts"), "kind": w.get("kind") or "-", "http": w.get("http"), "ok": w.get("ok")} + for w in _tail_jsonl(INBOX / "wakeups.jsonl", 10)], + # 🔴 「前置」整块由**使用方**提供(见 `board_ext`)—— 技能侧只负责把它渲染出来。 + # ⛔ 技能里不许出现任何项目的端口/路径/真名/历史。 + "front": _ext_d, + "runtime": _rt, # 🔴 实时状态(架构图用) + "notify": {"awaiting": qi.get("awaiting") or "", "phase": qi.get("phase") or "", + "sent": qi.get("sent") or "", "notice": qi.get("notice") or ""}, + "warn": warn, # ⛔ 降级不静默:界面要显示"哪一块没读到" + "meta": {"supervise_interval": C.get("supervise_interval", 30), + "queue_idle_min": 5, "wake_min_gap": C.get("wake_min_gap", 300), + "workspace": str(WS), "inbox": str(INBOX), + # 🔴🔴 2026-10-03 **投递退役标记**(用户口径「没用了就删除」): + # 唤醒会话/跟进会话/队列上报整套退役 ⇒ 队列变化不再自动通知任何人。 + # ⛔ 这是**机读判据**:前端靠它把「队列通知」那张卡画成退役说明,而不是三个空格子 + # (⛔ 别改成"卡片直接删掉"—— 那会让"机制退出了"变成无声无息)。 + "deliver_retired": "2026-10-03", + # 🔴 多目标:看板据此知道"要不要画 tab 条"(1 个目标 ⇒ 不画,与旧版一致) + "goals_dir": str(GOALS_DIR), "goal_count": len(blocks)}, + }) + return out + + + +def _port_owner(port: int): + """监听 `127.0.0.1:` 的 PID(字符串);查不到 ⇒ None。只认 LISTENING。 + + ⚠️ 用 `netstat -ano` 而非 `pkill -f`(后者匹配 argv,会误伤)。返回 `None` 与"确无进程"**不区分** + —— 调用方一律按"没能定位到旧实例"处理(⛔ 不据此下"一定没有别人在跑"的结论)。 + """ + try: + import subprocess + out = subprocess.run(["netstat", "-ano"], capture_output=True, text=True, + timeout=10, errors="replace", + creationflags=0x08000000).stdout or "" # CREATE_NO_WINDOW:⛔ 闪窗 + except Exception: + return None + key = "127.0.0.1:%d " % int(port) + for ln in out.splitlines(): + if "LISTENING" in ln and key in ln: + p = ln.split() + if p: + return p[-1] + return None + + +def _live_board(port: int, timeout: float = 1.2) -> bool: + """同端口上是否已有**本看板**在跑:`/healthz` 能连上 **且** body 是我们的签名。 + + ⛔ 判据必须收紧:① ⛔ 不能只认「端口开着」(别人也可能占这个口) + ② ⛔ 不能只认 HTTP 200(2026-09-30 实测:本机有服务在 `/api/v1/health` 上返 200, + body 却是 `{"ret":1,"version":"3"}`)。✅ 签名 = 同时含 `"ok"` 与 `"snapshots"` + —— 本看板的 `/healthz` 是这两个键,别的服务不会同时给。 + """ + import urllib.request + try: + with urllib.request.urlopen("http://127.0.0.1:%d/healthz" % int(port), timeout=timeout) as x: + body = x.read(400).decode("utf-8", "replace") + except Exception: + return False + return ('"ok"' in body) and ("snapshots" in body) + + +def _stop_pid(pid: str) -> bool: + """按 PID 强停(只给 `--takeover` 用;⛔ 不在其它路径上调用)。返回是否成功。""" + try: + import subprocess + r = subprocess.run(["taskkill", "/F", "/PID", str(pid)], capture_output=True, + text=True, timeout=15, errors="replace", + # 🔴 `taskkill` 是**控制台程序** ⇒ 不加 CREATE_NO_WINDOW 每次必闪黑窗 + #(2026-10-02 实测补上:本文件最后一处漏网的显窗调用, + # AST 全量扫 `subprocess.run` 缺 creationflags 时它被捞出来) + creationflags=0x08000000) + return r.returncode == 0 + except Exception: + return False + + +def _find_other_boards(port: int) -> list: + """🔴🔴 **本机是不是已经有别的看板实例在跑**(⛔ 跨端口也认)—— 2026-10-05 加。 + + ## 为什么必须有它(用户 2026-10-05 逐字) + + > 「**看板全局也只能启动一个**」 + + 🔴 **病根**:原来的护栏 `_live_board(port)` **只按端口判** ⇒ + 8788 上有实例、你再去起 20099 ⇒ **照起不误** ⇒ 两个实例各答各的快照 + (实测撞到:用户书签 8788 拿到的是 09:44 的**陈旧静态快照**、目标显示为空; + 而真正在跑的实例在 20099 上,数据是新的)。 + ⚠️ 更坏的情况:两个实例**不同代码版本** ⇒ 同一个 URL 反复刷新拿到不同结论。 + + ✅ 正解=**全局判据**:扫进程表找**所有** `board.py --serve` 的 pythonw 实例 + (⛔ 不只是本端口)⇒ 已有别的 ⇒ 说清楚"它在哪个端口"并按调用方策略处置。 + + ⚠️ 判据取**进程命令行**(`board.py` + `--serve`)而**不是** glob/文件名 + —— 与「查常驻只许问程序自己」同族:⛔ 不猜、直接读系统给的事实。 + + @return `[{"pid","port"}...]`(⛔ 不含 `port` 自己那条) + """ + out = [] + try: + import subprocess + import json as _json + # 🔴 用 PowerShell 的 CIM(⛔ 不用 wmic —— 新版 Windows 已移除) + # + `-NoProfile`(⛔ 否则加载用户 profile 慢且可能报错) + ps = ("Get-CimInstance Win32_Process -Filter \"Name='pythonw.exe'\" | " + "Where-Object { $_.CommandLine -like '*board.py*' -and " + "$_.CommandLine -like '*--serve*' } | " + "ForEach-Object { \"$($_.ProcessId)`t$($_.CommandLine)\" }") + r = subprocess.run(["powershell", "-NoProfile", "-NonInteractive", "-Command", ps], + capture_output=True, text=True, timeout=15, errors="replace", + creationflags=0x08000000) + for ln in (r.stdout or "").splitlines(): + ln = ln.strip() + if not ln or "\t" not in ln: + continue + _p, _cl = ln.split("\t", 1) + try: + _pid = int(_p) + except Exception: + continue + if _pid == os.getpid(): + continue + _m = re.search(r"--serve\s+(\d+)", _cl) + _pt = int(_m.group(1)) if _m else 0 + if _pt and _pt == int(port): + continue # 同端口那条由 `_live_board` 管,⛔ 这里不重复 + out.append({"pid": _pid, "port": _pt}) + except Exception: + pass + return out + + +def serve(port: int = 8788, interval: float = 3.0, takeover: bool = False) -> int: + """🔴 本地看板服务(**只绑 127.0.0.1**)。⛔ 不引第三方、⛔ 不开对外端口、⛔ 不写任何账本。 + + 🔴🔴 **总则:看板不能影响程序执行**(用户 2026-09-30 明令:「看板不能影响程序执行,可以**异步**、 + 可以**延迟**」)。据此,请求路径与数据生产**彻底解耦**: + · **一个后台线程**每 `interval` 秒生成一次快照 → 存进**内存缓存** + · **请求线程只吐缓存字节** —— ⛔ 不碰 DB、⛔ 不读文件、⛔ 不做任何可能阻塞的事 + ⇒ 开 10 个标签页 = 10 次内存读;宿主库的查询频率**恒定**为 `1/interval`,与页面数无关 + · 刷新失败 ⇒ **保留上一份快照**(界面显示"延迟 N 秒")⇒ ⛔ 绝不 500、⛔ 绝不给空板 + · `--interval` 可取大(实时性换零负担);页面按同一 `interval` 自取,⛔ 不自行加频 + """ + import threading + from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + + html_p = HERE.parent / "assets" / "board.html" + cache = {"bytes": None, "at": 0.0, "err": "", "n": 0} + + def _refresh_loop(): + """唯一的生产者(后台守护线程)。⛔ 它出任何事都只影响"数据新不新",⛔ 不影响服务存活。""" + while True: + try: + d = build() + try: + _hsig = "%d.%d" % (os.path.getmtime(html_p), os.path.getsize(html_p)) + except OSError: + _hsig = "0" + # 🔴 `html_sig`:架构图的**布局与样式全在 board.html 里**,而页面只轮询本 JSON + # ⇒ 改了 HTML,不刷新页面就看不到(用户 2026-09-30 为此连问两次)。 + # 页面拿这个签名做"变了就自动重载",⛔ 不再需要用户手动 F5。 + d["board"] = {"refresh_interval": interval, "generated_at": round(time.time(), 1), + "readonly": True, "html_sig": _hsig} + cache["bytes"] = json.dumps(d, ensure_ascii=False, indent=1).encode("utf-8") + cache["at"] = time.time() + cache["err"] = "" + cache["n"] += 1 + except Exception as e: # ⛔ 不清旧快照:宁可给"旧的"也不给"空的" + cache["err"] = str(e) + time.sleep(max(0.5, float(interval))) + + threading.Thread(target=_refresh_loop, daemon=True).start() + + class H(BaseHTTPRequestHandler): + protocol_version = "HTTP/1.1" + + def _send(self, code, body: bytes, ctype: str): + try: + self.send_response(code) + self.send_header("Content-Type", ctype) + self.send_header("Content-Length", str(len(body))) + self.send_header("Cache-Control", "no-store") + self.end_headers() + self.wfile.write(body) + except Exception: # 客户端提前断开 ⇒ 静默(⛔ 不刷日志) + pass + + def do_GET(self): # noqa: N802 + path = self.path.split("?")[0] + if path in ("/", "/board.html", "/index.html"): + try: + self._send(200, html_p.read_bytes(), "text/html; charset=utf-8") + except Exception as e: + self._send(500, ("board.html 读不到:%s" % e).encode("utf-8"), + "text/plain; charset=utf-8") + elif path == "/board.json": + body = cache["bytes"] + if body is None: # 冷启动:第一份还没出来 + body = json.dumps({"warming": True, + "board": {"refresh_interval": interval}}, ensure_ascii=False).encode("utf-8") + self._send(200, body, "application/json; charset=utf-8") + elif path == "/healthz": + self._send(200, json.dumps({"ok": True, "snapshots": cache["n"], + "age": round(time.time() - cache["at"], 1), + "err": cache["err"]}, ensure_ascii=False).encode("utf-8"), + "application/json; charset=utf-8") + else: + self._send(404, b"not found", "text/plain; charset=utf-8") + + def log_message(self, *a): # ⛔ 静默(不刷屏、不唤醒宿主) + pass + + # 🔴🔴 单实例护栏(2026-09-30 加,治「多实例打架」)—— ⛔ 别删,原因见文件头。 + # · 默认:端口上已有本看板 ⇒ **拒绝启动**(避免"随机应答不同快照/不同代码版本") + # · `--takeover`:显式接管 —— 先停掉旧实例,再起本实例 + # 🔴🔴 2026-10-05 加强:**全局**判据(用户逐字:「**看板全局也只能启动一个**」)。 + # 病根:原来只按**端口**判 ⇒ 8788 有实例、去起 20099 **照起** ⇒ 两实例各答各的。 + # 实测后果:用户书签 8788 拿到 09:44 的**陈旧快照**(目标显示为空), + # 而真正在跑的实例在 20099(数据是新的)⇒ 用户以为"目标没了"。 + _others = _find_other_boards(port) + if _others: + if takeover: + for _o in _others: + _okx = _stop_pid(str(_o["pid"])) + print("%s 接管:已停**别端口**上的看板 PID %s(端口 %s)(%s)" + % ("✓" if _okx else "⚠️", _o["pid"], _o["port"] or "?", + "成功" if _okx else "失败")) + sys.stdout.flush() + time.sleep(1.0) + else: + _o0 = _others[0] + print("⛔ 本机已有看板在跑(127.0.0.1:%s,PID %s)⇒ **本次不启动**" + % (_o0["port"] or "?", _o0["pid"])) + print(" · 看板**全局只允许一个**(用户 2026-10-05 口径)—— 多实例会各答各的快照。") + print(" · 直接用它:http://127.0.0.1:%s/ ,⛔ 不用再起一个。" % (_o0["port"] or "?",)) + print(" · 要换到本端口/换新代码 ⇒ 加 `--takeover`(先停旧实例再接管)。") + sys.stdout.flush() + return 0 + if takeover: + _own = _port_owner(port) + if _own and _own != str(os.getpid()): + _ok = _stop_pid(_own) + print("%s 接管:已停旧看板 PID %s(%s)" % ("✓" if _ok else "⚠️", _own, + "成功" if _ok else "失败,见 taskkill 输出")) + sys.stdout.flush() + time.sleep(1.0) # 等端口真正释放 + elif _live_board(port): + _own = _port_owner(port) + print("⛔ 已有看板在跑(127.0.0.1:%d%s)⇒ **本次不启动**,避免多实例打架。" % + (int(port), (",PID %s" % _own) if _own else "")) + print(" · 只是想看板子 ⇒ 直接用 http://127.0.0.1:%d/ ,⛔ 不用再起一个。" % int(port)) + print(" · 要换上新代码 ⇒ 加 `--takeover`(先停旧实例再接管),或先停掉它。") + sys.stdout.flush() + return 0 + srv = ThreadingHTTPServer(("127.0.0.1", int(port)), H) + srv.daemon_threads = True + print("看板已起:http://127.0.0.1:%d/ (只绑回环 · 每 %ss 异步快照 · 请求零阻塞)" % (int(port), interval)) + sys.stdout.flush() + try: + srv.serve_forever() + except KeyboardInterrupt: + pass + finally: + srv.server_close() + return 0 + + +def main() -> int: + if "--serve" in sys.argv: + i = sys.argv.index("--serve") + p = 8788 + if i + 1 < len(sys.argv) and sys.argv[i + 1].isdigit(): + p = int(sys.argv[i + 1]) + iv = 3.0 + if "--interval" in sys.argv: # 想更省 ⇒ 调大(实时性换零负担) + j = sys.argv.index("--interval") + if j + 1 < len(sys.argv): + try: + iv = max(0.5, float(sys.argv[j + 1])) + except Exception: + iv = 3.0 + return serve(p, iv, takeover=("--takeover" in sys.argv)) + out = INBOX / "board.json" + if "--out" in sys.argv: + i = sys.argv.index("--out") + if i + 1 < len(sys.argv): + out = Path(sys.argv[i + 1]) + INBOX.mkdir(parents=True, exist_ok=True) + d = build() + out.write_text(json.dumps(d, ensure_ascii=False, indent=1), encoding="utf-8") + print("板快照已写:%s(%d 字节)" % (out, out.stat().st_size)) + print(" 目标:%s | %s" % (d["goal"]["title"][:40], _acc_summary(d["goal"]["acceptance"]))) + _f = d.get("front") or {} + _ch = _f.get("chips") or [] + print(" 前置(%s):%s" % ( + _f.get("title") or "—", + " | ".join("%s=%s" % (c.get("label"), "上线" if c.get("up") else "离线") for c in _ch) + or (_f.get("_missing") or "(使用方未配 board_ext)"))) + print(" 台账:%s" % {k: v["state"] for k, v in d["tasks"].items()}) + _r = d.get("runtime") or {} + print(" 实时:常驻程序=%s | 上报=%s" % ((_r.get("prog") or {}).get("label"), + (_r.get("deliver") or {}).get("label"))) + if d.get("warn"): + print(" ⚠️ 降级(不静默):%s" % ";".join(d["warn"])) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/session-mechanism/scripts/collabd.py b/session-mechanism/scripts/collabd.py index e2f8c3a..cb71434 100644 --- a/session-mechanism/scripts/collabd.py +++ b/session-mechanism/scripts/collabd.py @@ -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"`, - 在**技能目录**里没问题(`/scripts/collabd.py` ⇒ `.parent.parent` = ``), - 但**工作区副本**是 `.workbuddy/collab/collabd.py` ⇒ `.parent.parent` = - `/.workbuddy` ⇒ 去找 `/.workbuddy/assets/`(**不存在**) - ⇒ 每个工作区的「自我建立常驻」**必然失败**,而报错还写着"缺模板" - —— 看着像仓库少文件,其实只是**算错了包根**。 - (实测:vibe-product 副本返回「⛔ 缺模板」;而它旁边 - `.workbuddy/skills/session-mechanism/assets/` 里模板**好好在**。) - 认三处(按优先级): - ① 同目录的 `/assets/`(技能目录/包树里的 scripts/ 布局) - ② 从 `WS` 推的 **技能副本** `/.workbuddy/skills/session-mechanism/assets/` - —— 副本形态的正解(deploy 只分发 `collabd.py`,模板跟着技能副本走) - ③ 代码默认的技能目录 `/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() diff --git a/session-mechanism/scripts/goalctl.py b/session-mechanism/scripts/goalctl.py index ea94c71..1b9dd0f 100644 --- a/session-mechanism/scripts/goalctl.py +++ b/session-mechanism/scripts/goalctl.py @@ -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字>__.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 diff --git a/session-mechanism/scripts/hooks/decision-rules-hook.py b/session-mechanism/scripts/hooks/decision-rules-hook.py new file mode 100644 index 0000000..e740cac --- /dev/null +++ b/session-mechanism/scripts/hooks/decision-rules-hook.py @@ -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) diff --git a/session-mechanism/scripts/hooks/prompt-guards.py b/session-mechanism/scripts/hooks/prompt-guards.py new file mode 100644 index 0000000..a6b6983 --- /dev/null +++ b/session-mechanism/scripts/hooks/prompt-guards.py @@ -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:异常也不阻断提交 diff --git a/session-mechanism/scripts/hooks/skill-load-guard.py b/session-mechanism/scripts/hooks/skill-load-guard.py index 0c6bc48..2757c41 100644 --- a/session-mechanism/scripts/hooks/skill-load-guard.py +++ b/session-mechanism/scripts/hooks/skill-load-guard.py @@ -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' diff --git a/session-mechanism/scripts/hooks/stop-dialog-guard.py b/session-mechanism/scripts/hooks/stop-dialog-guard.py index 2fca05c..13dc1fc 100644 --- a/session-mechanism/scripts/hooks/stop-dialog-guard.py +++ b/session-mechanism/scripts/hooks/stop-dialog-guard.py @@ -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"(假绿)**; + 而实际失效的是**这一整条**:水位与收口 / 接续机制起点 / 预算告警 / 门禁自检 / 路径自检。 + + 证据:`/.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: diff --git a/session-mechanism/scripts/init_workspace.py b/session-mechanism/scripts/init_workspace.py index 6796b9f..4b0d205 100644 --- a/session-mechanism/scripts/init_workspace.py +++ b/session-mechanism/scripts/init_workspace.py @@ -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()` 按 `/.workbuddy/collab/collabd.config.json` 找配置; + # cwd 设成脚本目录 ⇒ 它去找 `/.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:用户「任何工作区都要能自己配好」) diff --git a/session-mechanism/scripts/selftest.py b/session-mechanism/scripts/selftest.py index 4aace41..92d30a0 100644 --- a/session-mechanism/scripts/selftest.py +++ b/session-mechanism/scripts/selftest.py @@ -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" / ...` - · 技能目录里对(`/scripts/collabd.py` ⇒ `.parent.parent` = ``); - · **工作区副本里错**(`.workbuddy/collab/collabd.py` ⇒ `.parent.parent` = - `/.workbuddy` ⇒ 去找 `/.workbuddy/assets/`,**不存在**) - ⇒ 每个工作区的自我供给**必然返回「⛔ 缺模板」**, - 而模板其实好好躺在 `/.workbuddy/skills/session-mechanism/assets/`。 + **为什么换掉旧的**:旧用例判「副本形态下找不找得到 `assets/start-supervise.ps1.tpl`」, + 而该模板与「旧 看守」这套形态**已整套废弃** —— + 任务动作从「壳脚本 -File」改成 **`pythonw.exe` + `supervise-launch.py`** + (GUI 子系统 ⇒ 零 conhost ⇒ **不闪黑窗**,用户 2026-10-05 报「又弹了窗口」的直接修复)。 + ⇒ 旧用例连**被测对象都不存在了** ⇒ 恒红、且无法修复 ⇒ **必须删,换成本条**。 - 判据**必须变异有效**(旧写法必须红、新写法必须绿): - 造一个「副本形态」的目录(`/collab/collabd.py` + - `/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" - # 新算法=真副本形态优先找 `/.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` 写在 `/inbox/` ⇒ 脚本读到「(无)」, + # 写又落到 `/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: diff --git a/session-mechanism/scripts/session-rules-check.py b/session-mechanism/scripts/session-rules-check.py index bd082c5..9490101 100644 --- a/session-mechanism/scripts/session-rules-check.py +++ b/session-mechanism/scripts/session-rules-check.py @@ -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` **只在** `/.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 `(它本来就是干这个的) + —— ⛔ **不在这里再抄一份"抽章节再比对"的逻辑**(抄一份 ⇒ 两处漂移 ⇒ 同族事故)。 + 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 /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"]