pi-multi-viewers 0.8.2 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -68,7 +68,7 @@ tests/ 测试(unittest discover tests)
68
68
 
69
69
  | 档 | 唤醒命令 | 用途 |
70
70
  |---|---|---|
71
- | **mc-tools**(默认) | 四个 `--no-*` + `-e <MC 的 subagent-entry.js>` | 给 agents **按需检索项目背景**(`ctx_search`)——背景蒸馏机制已移除,这是其补充通道。**允许而非要求 MC**:找不到 MC → 降级为零扩展 + 一行可见说明(`ctx_search` 本次不可用)|
71
+ | **mc-tools**(默认) | 四个 `--no-*` + `-e <MC 的 subagent-entry.js>` + `-e <pi-mcp-adapter 入口>` | 给 agents **按需检索项目背景**(`ctx_search`)——背景蒸馏机制已移除,这是其补充通道。**允许而非要求 MC**:找不到 MC → 降级为零扩展 + 一行可见说明(`ctx_search` 本次不可用)|
72
72
  | **none** | 四个 `--no-*` | 零扩展、**零依赖**(无 MC 的机器/CI 用这档)|
73
73
  | **all** | 不加任何 `--no-*`(pi 默认发现)| A/B 实验与显式 opt-in |
74
74
 
@@ -85,7 +85,7 @@ tests/ 测试(unittest discover tests)
85
85
  生产基线:本场 strict=1、n=19,唤醒启动段中位 **0.68s**、收尾中位 0.04s ✓)。
86
86
  **入口解析 fail-fast**(`meeting_fs.resolve_mc_tools_entry`:从 pi 的 packages 找 MC 包 →
87
87
  读它声明的扩展入口 → 取同目录的 subagent-entry.js);缺 MC 时**可见降级**为零扩展。
88
- **依赖边界**:mc-tools 档**允许而非要求** MC——缺 MC 时降级为零扩展,且**可见**
88
+ **依赖边界**:mc-tools 档的**两份入口各自独立**(MC 的 ctx_search、MCP adapter 的 web_search 等)——**允许而非要求**,缺谁少谁、都**可见**
89
89
  (打印一行"本次按零扩展运行:ctx_search 不可用";无静默铁律);`none` 档零依赖
90
90
  (无 MC 的机器/CI 显式选它)。**测试/探针保真**:设 `MV_MC_TOOLS_STRICT=1` →
91
91
  缺 MC 即报错退出(否则测试可能在"没装 MC"下通过而 ctx_search 从未生效)。
@@ -261,3 +261,7 @@ loop、状态从 git 共享事实推导、单一事实源 = protocol.json、无
261
261
  - **核验法**(照上游约定,不用命令行长度判断):
262
262
  `readlink -f ~/.pi/agent/npm/node_modules/pi-multi-viewers` 指向仓库根,
263
263
  且该路径下 `scripts/mv.sh` 存在。
264
+ - **registry 核验别只看 `/latest`**(实测踩两次):npm 的 abbreviated 元数据
265
+ (`registry.npmjs.org/<pkg>/latest`)有缓存,发布后可能持续返回旧版本;
266
+ **权威判据 = 完整文档的 `dist-tags`**:
267
+ `curl -s https://registry.npmjs.org/pi-multi-viewers | python3 -c "import json,sys;print(json.load(sys.stdin)['dist-tags'])"`
package/README.md CHANGED
@@ -1,12 +1,30 @@
1
1
  # pi-multi-viewers
2
2
 
3
- 多视角协同分析(Pi 插件):把主 pi session **fork** 成 N 个视角 agent,
4
- 各带一份视角任务书(效率/简单/铁律/……),在 meeting 协议下交锋、
5
- 修正、收敛,产出一份共识结果。
3
+ **多视角协同分析(Pi 插件)**:把当前 pi 会话 fork 成 N 个视角 agent,让它们带着你的真实上下文互相交锋,产出共识与分歧。
6
4
 
7
- 与 [pi-agents-helper](https://github.com/maxdai/pi-agents-helper)(多方
8
- 讨论达成共识,agent 无主上下文)平行演化;共享 meeting 协议核心
9
- (core/fs/engine),差异在初始化层。
5
+ ## 它想解决什么问题
6
+
7
+ 需要考虑多种因素或者准则时,LLM 容易出现**逐渐忽略其中一部分因素**的问题。
8
+
9
+ 对策是**给每个因素一条独立的会话**:一个视角 agent 只关注一个因素(效率 / 简单 / 铁律 / …),
10
+ 各带一份视角任务书与独立发言配额,并且必须对其它视角的观点表态(认同或反驳,都要用自己的论据)。
11
+ 这样**只要这些会话存在,对应因素就不会被忽略**——关注不靠提醒模型"别忘了 X",
12
+ 而是让每个 X 有一个独立的载体。
13
+
14
+ 讨论由代码驱动的 meeting 协议推进(发言配额 → 冻结 → 轮转表态 → 共识收束),
15
+ 最终产出 `result.md`:**共识结论 + 明确否决项(含理由与重估触发条件)+ 各自保留的分歧**。
16
+
17
+ ## 什么时候值得跑
18
+
19
+ **当一个问题需要长思考、并且需要在长思考过程中保持若干个视角的关注时**,可以尝试使用。
20
+
21
+ (反过来说:查一个事实、跑一条命令、一两分钟能自己确认的问题,直接问 pi 更快。)
22
+
23
+ ## 代价
24
+
25
+ 一次分析通常 **12–35 分钟**(3 视角、20–50 次唤醒,正常情况)。这个区间**不含**异常外溢:
26
+ provider 连续失败或扩展异常时可能显著更久(历史场次里出现过 55 分钟与 73 分钟)。
27
+ 它换来的不是"更快",而是"多几个独立立场 + 一份可复查的分歧记录"。
10
28
 
11
29
  ## 核心机制(2026-09-09/10 实测验证,见 docs/examples/first-experiment)
12
30
 
@@ -67,16 +85,20 @@ ls ~/.pi/agent/npm/node_modules/pi-multi-viewers/scripts/mv.sh
67
85
 
68
86
  ## 用法
69
87
 
70
- ```
71
- /multi-viewers-setup # ① 建视角(首次使用先跑这个;prompt:先建议 → 你定 → 落盘 → 给你审)
72
- /multi-viewers "<主题>" # ② 分析(extension:生成 spec → 弹窗门禁 → 启动 → 预填观看命令)
73
- /multi-viewers-say "<文本>" # ③ 插话(分析进行中;extension:零 LLM 直接写入 human 消息)
74
- /multi-viewers-finish # ④ 收尾(extension:查状态 → 确认 → 清理,报告随清理打印)
75
- ```
88
+ **接口总表**(pi 内 1 个 prompt + 3 个命令;终端侧另有等价 CLI):
76
89
 
77
- 四个 pi 命令入口,按使用顺序排列。**②③④ 是 extension**(流程完全由代码执行、
78
- 零 LLM:跑命令、门禁弹窗、观看命令预填、状态判据都走退出码/机器标记行,
79
- 不靠 LLM 转述);**① 是 prompt**——写视角是内容工作,本就需要 LLM 参与。
90
+ | 入口 | 形态 | 作用 |
91
+ |---|---|---|
92
+ | `/multi-viewers-setup` | prompt | 建视角(建议 → 你定 → `--set-viewer` 落盘 → 给你审) |
93
+ | `/multi-viewers "<主题>"` | extension | 分析:prepare → **暂停点弹窗** → start → 交付观看命令 |
94
+ | `/multi-viewers-finish` | extension | 收尾:status → 确认 → cleanup(报告随清理打印并落盘) |
95
+ | `/multi-viewers-say "<文本>"` | extension | 插话(human 消息,各视角可见可回应) |
96
+ | `scripts/mv.sh <子命令>` | CLI | 终端侧等价入口(`--prepare` / `--start` / `--status` / `--view` / `--say` / `--report` / `--wait` / `--cleanup` / `--viewers` / `--set-viewer`)——pi 内命令内部也走它 |
97
+
98
+ 按使用顺序:先 `/multi-viewers-setup` 建视角(一次就够),之后 `/multi-viewers "<主题>"` 跑分析,
99
+ 分析进行中用 `/multi-viewers-say` 插话,结束后 `/multi-viewers-finish` 收尾。
100
+ **后三个是 extension**(流程完全由代码执行、零 LLM:跑命令、门禁弹窗、观看命令交付、状态判据
101
+ 都走退出码/机器标记行,不靠 LLM 转述);**`setup` 是 prompt**——写视角是内容工作,本就需要 LLM 参与。
80
102
 
81
103
  **目录可以省略**:`--view`/`--say`/`--status`/`--report`/`--wait`/`--cleanup`
82
104
  不带目录时自动定位"本 session 当前分析"(只匹配 `mv-<sessionId>-*` 最新;**未匹配
@@ -98,8 +120,9 @@ scripts/mv.sh --start <spec目录> # 启动(自动挂载主 sessi
98
120
  # 可选:--fork-mode compaction|budget|full(见上表;一般不调)
99
121
  # 可选:--max-meeting 15 --max-rr 7 --stall-timeout 600
100
122
  # (配额:建环境时固化进 protocol.json,之后不可改;meeting 配额是"每 agent")
101
- # 可选:--extension-policy mc-tools|none|all(默认 mc-tools = agents 带 MC 的只读检索工具
102
- # ctx_search;none = 零扩展、零依赖;all = 走 pi 默认发现。缺 MC 时 mc-tools 可见降级)
123
+ # 可选:--extension-policy mc-tools|none|all(默认 mc-tools = 零扩展 + 两份只读工具入口:
124
+ # MC 的 ctx_search 与 MCP adapter 的 web 检索等;none = 零扩展、零依赖;
125
+ # all = 走 pi 默认发现。两份入口各自独立,缺谁少谁且可见降级)
103
126
  # 高级:--agents "a,b" 起一次性视角(不建 viewers/ 时用;prompt 入口不传它)
104
127
 
105
128
  # 观看:--start 会输出可直接执行的 !! 流式观看命令(复制执行)
@@ -156,6 +179,9 @@ frontmatter 字段、写文件路径、独立参与者纪律。
156
179
  | 完全一次性(项目还没建 viewers/) | `mv.sh --prepare "<主题>" --agents "a,b"`(wrapper 高级用法) |
157
180
 
158
181
  ## 架构
182
+ 与 [pi-agents-helper](https://github.com/maxdai/pi-agents-helper)(多方
183
+ 讨论达成共识,agent 无主上下文)平行演化;共享 meeting 协议核心
184
+ (core/fs/engine),差异在初始化层。
159
185
 
160
186
  ```
161
187
  meeting_core.py 纯判定(冻结级联/RR/聚合 + 状态机词汇常量)
package/docs/design.md CHANGED
@@ -201,7 +201,7 @@ fail-open(报告失败不阻断清理,且**打印**失败原因不静默)
201
201
  轮询路径消费 MB 面、不做运行期 LLM 评分(有效性判断留给人 + result.md)、
202
202
  不做目录内 retention(cleanup 是唯一清理点)、message frontmatter 不加
203
203
  时间戳(第二事实源 + 该字段由 LLM 写,不可信;权威时间 = commit 时间)、
204
- 日志不 JSON 化(主消费者是人)、报告不自动落固定位(视图不占"家")。
204
+ 日志不 JSON 化(主消费者是人)、报告不自动落固定位(视图不占“家”;**例外**:cleanup 删目录前打印并落盘一份快照 `<base>-report.txt`,见观测面契约)。
205
205
 
206
206
  ## 数字的归宿(一个数字只留一个"家")
207
207
 
@@ -495,7 +495,7 @@ commit 是溯源记录、本节是长期引用点——不并存两份权威值
495
495
 
496
496
  | 档 | 唤醒命令 | 语义 |
497
497
  |---|---|---|
498
- | `mc-tools`(默认) | 四个 `--no-*` + `-e <MC subagent-entry.js>`(找不到 MC 时退化为四个 `--no-*`)| 只要 MC 的**只读检索工具**(`ctx_search`);**允许而非要求** MC |
498
+ | `mc-tools`(默认) | 四个 `--no-*` + `-e <MC subagent-entry.js>` + `-e <pi-mcp-adapter 入口>`(任一份入口缺失即**部分降级**:缺谁少谁、都可见;全缺 = 等价 none)
499
499
  | `none` | 四个 `--no-*` | 零扩展:最快、**零依赖** |
500
500
  | `all` | 不加任何 `--no-*` | pi 默认发现(A/B 与显式 opt-in)|
501
501
 
@@ -517,6 +517,7 @@ commit 是溯源记录、本节是长期引用点——不并存两份权威值
517
517
  spawn cwd 全截断("无 MC 的机器"上产出残缺命令)。**登记行**:首唤打
518
518
  `扩展策略: 声明=X 生效=Y strict=0|1 [降级原因=…]`——报告据此给"声明 vs 生效"
519
519
  (E2;与"档位"同型),否则降级只在 loop log 里、产品面看不见。
520
+ **两份入口(2026-09-25 用户裁决 B)**:`mc-tools` 除 MC 的只读检索工具外,再显式 `-e` 加载 **pi-mcp-adapter**(web_search / web_reader / zread 等 MCP 工具)——`--no-extensions` 关的是**扩展发现**,显式路径照常生效(pi `--help` 原文),不加载就等于 agents 完全失去联网检索能力。两份入口**各自独立降级**(缺谁少谁、都可见),不新增档位(保持简单)。
520
521
 
521
522
  **依赖边界(用户 2026-09-14 定)**:mc-tools **允许而非要求** MC——缺 MC 时
522
523
  **降级为零扩展并按 none 运行**,但**必须可见**(打印一行"mc-tools 档未生效
@@ -579,17 +580,27 @@ commit 是溯源记录、本节是长期引用点——不并存两份权威值
579
580
  `mv.sh --set-viewer <名字>`(正文从 stdin 读)——**命名规则 / 不覆盖已有 / 空正文拒绝 /
580
581
  写完校验并回显**四条由命令保证(此前是 prompt 里给 LLM 的纪律,会漏);prompt 只负责
581
582
  **看项目给候选 + 内容撰写 + 与用户来回**(那才是 LLM 该做的)。
583
+ **有意的 LLM 义务残留**:prompt 第 4 步要求「改完再跑 `--viewers` 复核」——它不新增
584
+ 命令面,且有硬 gate 兜底(prepare/start 的集合校验不过就拒绝启动),故保留。
582
585
  **UI 通道按 mode 分级(2026-09-25 实测)**:dialog(`select`/`confirm`/`input`/`editor`)
583
586
  全模式可用(RPC/web 走请求-响应子协议、阻塞等用户;不带 `timeout` 即不倒计时,
584
- 暂停点成立);`notify` 全模式可用(TUI = showStatus 行;pi-web = **追加进聊天流的
585
- 常驻行**,非瞬时提示);**`setEditorText` 仅 TUI**——pi-web 忽略(`pi-web/static/app.js`
586
- 注释「set_editor_text … ignored」+ SDK `ui-context.ts` 里是空实现)。⇒ **交付观看命令
587
- 必须有 notify 兜底**(预填只是增强,不能当唯一出口);需要分级时用 `ctx.mode`。
588
- 但 pi-web 实测**关掉 notify 弹窗即消失** ⇒ 观看命令还写一条 `pi.sendMessage`
589
- (`customType: multi-viewers`、`display: true`)进消息流:持久可回滚复制,
590
- 代价 = 参与 LLM 上下文的一行(用户 2026-09-25 要求「message 流中也能显示」)。
591
- 被否决:A(handler 里 `sendUserMessage` 触发 LLM 回合改 spec——时序不可控)、
592
- D(拆两条命令——把门禁成本转嫁用户;B1 变体/第三种即现形态)。
587
+ 暂停点成立);`notify` 全模式可用(TUI = showStatus 行;pi-web 会关闭即消失);
588
+ **`setEditorText` 仅 TUI**——pi-web 忽略(`pi-web/static/app.js` 注释
589
+ 「set_editor_text … ignored」+ SDK `ui-context.ts` 里是空实现)。⇒ 交付观看命令
590
+ 不能只靠一个通道,**四个通道各司其职**:
591
+ | 通道 | 作用域 | 上下文成本 | 角色 |
592
+ |---|---|---|---|
593
+ | `setEditorText` 预填 | 仅 TUI | 0 | TUI 便利(能直接回车跑) |
594
+ | `notify` | 全模式 | 0 | 即时反馈(pi-web 关掉弹窗即消失) |
595
+ | `pi.sendMessage`(custom_message) | 全模式 | ~百 token(主 session)+ 随 fork 进每场分析 | 持久留痕(pi-web 渲染为折叠块、需点击;起 0.5.19 实时出现) |
596
+ | `ctx.ui.setWidget` | 全模式 | 0(纯 UI) | 运行期常驻可见(一眼看到、不需点击) |
597
+ 三条注记:① `sendMessage` 的 custom_message **会随 fork 进每场分析各视角的上下文**
598
+ (fork 源在首唤由主 session 条目构建,不做类型过滤)——~2 行/场,有界;不为它加
599
+ 过滤(那会让构造层获得扩展类型知识,跨层耦合换几行噪音,不配)。② widget 是
600
+ **运行期**状态(fire-and-forget UI,非会话条目;reload/重启后不恢复——持久记录靠
601
+ custom_message)。③ 零上下文留痕档确实存在(`pi.appendEntry` + `registerEntryRenderer`,
602
+ 明确不进 LLM 上下文),但其渲染器是 TUI 组件、pi-web 无渲染路径 ⇒
603
+ **可见 ∩ 零上下文 = 空集**,跨模式成本不可归零,接受现值。需要分级时用 `ctx.mode`。
593
604
 
594
605
  ### 被否决方案(含重估触发条件)
595
606
 
@@ -0,0 +1,182 @@
1
+ <!-- 存档:docs/reviews/2026-09-25-extension-mechanisms-review.md
2
+ 来源:一次真实多视角分析的 result.md 原文(未删改,仅加本头与下方说明)。
3
+ 分析场次目录已随 cleanup 删除;文中消息编号不可再核验,仅作溯源线索
4
+ (与代码注释引用约定一致:行为以自描述为准)。 -->
5
+
6
+ # 存档说明
7
+
8
+ - **主题**:复验 0.8.2 新上线的三处机制(① `--cleanup` 落盘报告 ② `mv.sh --set-viewer`
9
+ ③ 观看命令交付通道)+ 这三处与扩展 harness 的测试覆盖、断言强度
10
+ - **场次**:`mv-mv-main-20260925-153113`(3 视角:效率 / 简单 / 铁律;真实 pi 讨论;
11
+ `forkMode=budget`、扩展策略 `mc-tools`(声明=生效)、`maxMeeting=15`;共识收敛,
12
+ 墙钟 28m42s / 46 次唤醒 / provider error 38 次)
13
+ - **判定**:三处机制**可以收下**(效率账:① <0.3s/场换掉 20–40 分钟重跑取数;
14
+ ② 一次命令调用省掉 LLM 的校验/重试回合;③ ~百 token/场换掉分钟级往返)
15
+ - **本场抓到(多数在最新那批代码里,均属防静默失效)**:
16
+ - **①(c) 显示层失败会跳过清理**(最重要):`cleanup_discussion` 的 print 在管道关闭时抛
17
+ `BrokenPipeError` → 逃逸 → **rmtree 被跳过**、目录残留、rc≠0。三处逃逸点:
18
+ banner print 在 try 外 / except 处理器自身再 print / 写盘失败处理器又 print。
19
+ 修法 = `_print_best_effort`(全部 stdout 走它,函数内无裸 `print(`)+ `rmtree` 进
20
+ `finally` ⇒ **rmtree 必达**;唯一例外 = 产物留存真失败(result.md 权威位在待删目录内)
21
+ 修后由 `51a4535` 落地,本仓复现脚本前后对照(逃逸+残留 → 无逃逸+已删+报告仍落盘)
22
+ - **② `--set-viewer` 半成功**:集合级校验在**写之后** → viewers/ 里预置坏文件时
23
+ "rc≠0 但新文件已写入"。修法 **B′**(校验前移到读 stdin/写之前,单一组合点
24
+ `spec_gen._viewer_set_errors`);另 `exists` → `lexists`
25
+ - **③ 通道模型修正**:从"三个出口(缺一不可)"收敛为**四通道角色模型**;并指出
26
+ `custom_message` **随 fork 进入每场分析上下文**(~2 行/场,有界)——此前只说"主 session"
27
+ - 文档描述簇(三处"不持久化"复述只同步了一处)、断言偏弱("落盘≡打印"未锁)、
28
+ prompt 里 README 三要点的第二处复述、fail-open 宽窄不对称(Exception vs OSError)
29
+ - **落地**:`51a4535`(475 python 测试 + 48 harness 断言全绿;净增 ≈11 行生产代码、
30
+ 零运行时行为变化)。报告另记录"明确不做"(fork 过滤 / `ctx.mode` 分支 / 符号链接测试矩阵)
31
+ 与已知限(widget 仅运行期常驻)
32
+
33
+ # 0.8.2 复验 · 三方共识结果
34
+
35
+ 参与者:效率 / 简单 / 铁律(3 视角,真实 pi 讨论;`forkMode=budget`、`mc-tools`、`maxMeeting=15`)。
36
+ 主题:复验 0.8.2 新上线的三处机制(① `--cleanup` 落盘报告 ② `mv.sh --set-viewer` ③ 观看命令交付通道)+ 测试覆盖与断言强度。**只提意见,不改代码、不跑测试**。
37
+
38
+ ## 0. 结论摘要
39
+
40
+ 1. **三处机制方向正确、净收益为正**(效率账:① 用 <0.3s/场换掉 20–40 分钟重跑取数;② 用一次命令调用省掉 LLM 的校验/重试回合;③ 用 ~百 token/场换掉分钟级往返)。
41
+ 2. ① ② 的实现基本符合设计,但有**一处文档描述缺口簇、一处测试断言弱、一处职责边界含糊**;③ 在讨论**进行中**因真实观察新增了第四个交付通道(`setWidget`,HEAD `0b28256`),审查基准随之推进,结论按版本分段。
42
+ 3. **本批必须落地**(三方一致,零运行时变化):
43
+ - ① **显示层失败可跳过清理**的修补(`_print_best_effort` + `rmtree` 必达;唯一例外=产物留存真失败)——本轮最重要行为修复;
44
+ - ① 落盘内容 ≡ 打印内容 的等值断言 + 写失败 fail-open 分支用例 + 三处"不持久化"复述改引用 + 两处新能力文案;
45
+ - ② **B′**:集合校验前移到写之前(单点 `_viewer_set_errors` + `if names:` 守卫),消掉"写成功但 rc≠0"的半成功;
46
+ - ② prompt 删去 README 三要点复述与命名括注;
47
+ - ③ `index.ts:19`/`:96` 的"三个出口"改四通道角色表述;决策 22 收敛为角色表 + 三注记(fork 复制事实、widget「(运行期)」、why-not-appendEntry);
48
+ - harness **单一日志**(sendMessage 入 `calls`、删 `sent`、失败路径精确 kinds)+ ① 三断言用例——**净删代码、覆盖变强**。
49
+ 4. **一句话**:三处机制可以收下;本批做的是"让清理必定发生、让校验先于副作用、让文档按实际写"——全部是防静默失效类的修复,生产代码净增 ≈11 行(显示层 ~6 + B′ ~5),其余为断言与文档。
50
+
51
+ ---
52
+
53
+ ## 1. 审查基准与版本口径(一个动态事实,先钉死)
54
+
55
+ | 项 | 事实 |
56
+ |---|---|
57
+ | 0.8.2 | tag `v0.8.2` = `81c8820`(内容:`e06bceb` 报告落盘 + `--set-viewer` + 归档脚本;`81c8820` 发版) |
58
+ | ③ 的历史 | `sendMessage` 第三出口 ∈ **0.8.1**(`e92eacc`,见 `v0.8.0..v0.8.1`);④ `setWidget` ∈ **`0b28256`**(讨论进行中落地,未发版) |
59
+ | 本场 HEAD | `0b28256`("观看命令加常驻面板出口")——③ 按**四通道**审,① ② 与 v0.8.2 一致 |
60
+ | 引用口径 | 复验结论**按版本分段**:①② 对 v0.8.2;③ 对 0.8.1(sendMessage)/ `0b28256`(widget)——避免被读成"0.8.2 已复验"而重复检查 |
61
+ | 数字校准 | harness **48** 条断言(0b28256 后);口径见 `docs/design.md:106`(`build_report` 实测 50–150ms/次、×3 出口 <0.3s/场,单点数字非承诺) |
62
+ | 行号校准 | setup prompt:命名括注 `:19`、三要点复述 `:33-34`、复核义务 `:55-56`;README 三要点 `:129-133` |
63
+
64
+ 讨论中审查对象发生变化(`0b28256` 在 15:34 落地),**结论绑定 revision**——执行时以当时 HEAD 为准。
65
+
66
+ ## 2. ① `--cleanup` 落盘报告(`meeting_fs.report_path` + fail-open)
67
+
68
+ ### 2.1 复验判定
69
+
70
+ - **实现正确**:`cleanup_discussion` 只调一次 `build_report`,同一份 `lines` 既打印又落盘(无二次遍历);落盘在 `shutil.rmtree` 之前;路径声明 `meeting_fs.report_path` 与 `result_path` 同家(各一行,未过度参数化)。`<base>-report.txt` 已在 `.gitignore:8`(`check-ignore` 实测命中)。
71
+ - **职责边界**:落盘点选在 cleanup("cleanup 是唯一清理点",design.md:202)正确;观测数字从"只在终端出现一次"变为"可复查"。
72
+
73
+ ### 2.2 发现(按类别)
74
+
75
+ **(a) 文档描述簇(两个方向)**:
76
+ - 旧话未删:`observability.py:5`(模块头)与 `:217`(`build_report` docstring)仍写"**不持久化**……视图不占'数字的家'",`docs/design.md:204` 仍写"报告不自动落固定位(视图不占'家')"——同一提交只更新了 design.md:167-171(观测面契约),留下三处相反复述。
77
+ - 新话未加:`start_discussion.py:436`(cleanup docstring)与扩展收尾弹窗 `index.ts:180-181`("清理时还会打印一次分析报告")都未提报告已**落盘**——用户事后只找 result,不知道有 report.txt,① 的"可复查"收益在**发现性**上打折。
78
+ - 改法(按仓库纪律"删复述引权威 > 最短准确陈述"):`build_report` 处改为"函数自身不写文件;唯一落盘点在 `cleanup_discussion`";design.md:204 补"(cleanup 删除前例外)";弹窗/docstring 加一句"报告落盘到 `<分析目录>-report.txt`"。
79
+
80
+ **(b) fail-open 宽窄不对称**:生成段 `except Exception`(:454-459)与写盘段 `except OSError`(:465-470)——统一为两段同捕 `Exception`(对称、免推理;`join` 抛非 `OSError` 会阻断 rmtree 的自述矛盾一并消失)。
81
+
82
+ **(c) 显示层失败可跳过清理(本场最重要的行为发现,3/3 决定本批修)**:
83
+ - 现状三处逃逸点:banner print 在 `try` 之外(:452);`try` 内 handler 自身 `print`(:459)→ 再次 flush 时重抛;写盘段把 BrokenPipe 当 `OSError` 捕获后**也再 print**(:469-470)。此外 `_preserve_result_md` 的 print(:432)在报告段之前,也在保护之外。
84
+ - 结果**随缓冲而定**:整份输出留在缓冲区(管道 ≈8KB)→ 异常只在解释器退出时出现、清理已完成;报告超过缓冲或未缓冲 → 循环中途炸、**rmtree 被跳过**(目录残留 + rc≠0)。
85
+ - **修补形态(冻结)**:
86
+ - 新增 `_print_best_effort`;契约 = **`cleanup_discussion` 的全部 stdout 输出都走它**(含早退 `:446` 与 `_preserve_result_md` 的 `:432`)——按"出口"定义、可 `grep` 校验(函数内无裸 `print(`);
87
+ - 不变量:**`rmtree` 必达,唯一例外 = 产物留存真失败(I/O)**;显示层失败永不算留存失败;
88
+ - `_preserve_result_md(base)` 调用**留在 `try` 之外**——它真失败(`meeting_fs.py:1255` 的 `open(dest,"w")` 不吞)时冒泡、**不删目录**(result.md 权威位置 `<base>/repo.git` 在待删目录内,删了就永久丢失);
89
+ - `try/finally`(`rmtree` + 末行提示)只从**报告段**起;rmtree 自身失败仍冒泡;清理成功 **rc 0**。
90
+
91
+ **(d) 测试缺口**:
92
+ - 落盘内容 ≡ 打印内容**未断言**(现只 `assertIn("配额:meeting", report)`,tests/test_main_paths.py:319-323;落盘只写前 3 行也能过)——提交信息所称"内容与打印一致"未被锁;
93
+ - **写失败 fail-open 分支零覆盖**(`[cleanup] 报告保存失败(不影响清理)`,tests/ 无命中)。
94
+
95
+ ### 2.3 冻结清单(①)
96
+
97
+ - [ ] `start_discussion.py`:`_print_best_effort` 助手 + 全部 stdout 走它 + `cleanup_discussion` 重排(`try/finally` 只包报告段、`rmtree` 在 `finally`;`_preserve_result_md` 在 try 外;统一捕 `Exception`;清理成功 rc 0)
98
+ - [ ] `observability.py:5`/`:217`、`design.md:204`:改为引用落盘点,不复述"不持久化"
99
+ - [ ] `start_discussion.py:436` docstring + `extensions/multi-viewers/index.ts:180-181` 弹窗:补"报告落盘 `<分析目录>-report.txt`"
100
+ - [ ] 测试:`assertEqual(落盘, 打印)`(print 已 mock,可直接重建)+ 写失败分支用例 + **三断言新用例**(目录已删 + rc 0 + `<base>-report.txt` 存在)
101
+
102
+ ## 3. ② `mv.sh --set-viewer`
103
+
104
+ ### 3.1 复验判定
105
+
106
+ - **四条保证确由机制保证**(逐条核对,判据复用 `spec_gen` 单一实现):命名合法(`check_agent_name`,写前);不覆盖(`os.path.exists` 写前拒绝 + 测试断言原文件未动);空正文拒绝(且不建目录);写完校验并回显(`_validate_and_print_viewers`)。
107
+ - **prompt 义务已下移**:第 3 步明确"四条由命令保证,不需要你另外检查";内容经 stdin 传命令(不直接写文件)。LLM 只负责内容——职责分工正确。
108
+ - 复杂度:`--viewers` 与 `--set-viewer` 共用同一校验实现(净简化,无第二套规则);守卫扁平、无 `--force`。
109
+
110
+ ### 3.2 发现与裁定
111
+
112
+ - **半成功(职责边界含糊)**:`cmd_set_viewer` 先写文件、后调集合级校验;若 viewers/ 里**预先**有坏文件(空文件/非法名),命令 rc≠0 **但新文件已写入**。可复现:`viewers/` 放一个空 `甲.md` → `--set-viewer 乙` → `乙.md` 已写、rc=1。
113
+ **裁定(3/3):采纳 B′**(最小形态):
114
+ - 抽 `_viewer_set_errors(names, empty) = validate_participants(names) or (viewer_set_error(names, empty) if empty else None)`——**唯一组合点**(`if empty` 保护:否则建第 2 个视角会被 ≥2 判据误判为错误);
115
+ - `_validate_and_print_viewers` 改调它(判据组合不再有第二份);
116
+ - 前置检查放 `exists` 检查后、**读 stdin 前**(错误路径不消费输入),**只留 `if names:` 守卫**(去掉冗余 `os.path.isdir`——`_discover_viewers` 对目录缺失/空/无 md 一律返回 `(None,None,[])`;不守卫则 `validate_participants(None)` 抛 TypeError,**首次建视角即命中**);
117
+ - 组合用例:预置坏视角 → 断言 **rc≠0 且新文件不存在**;「已写入 `<path>`」仅出现在成功路径。
118
+ - 回退 A(零改动):保留半成功,但契约行必须写**双义**("rc≠0 = 未写入,或已写入但目录整体不合规——以「已写入」行区分"),且用一条用例钉住;A/B′ 的断言不可共用。
119
+ - **prompt 残留(3/3 同意删)**:
120
+ - `prompts/multi-viewers-setup.md:33-34`:三要点是 README:129-133 的**第二处完整复述**(该 prompt `:30` 已声明 README 为唯一事实源)→ 删复述、留指针(删后零额外读取);
121
+ - `:19` 命名括注是 `check_agent_name` 的部分抄写(漏 ≤32/human)→ 删;
122
+ - `:55-56` "编辑后跑 `--viewers` 复核"是 LLM 义务(有 prepare/start 硬 gate 兜底)→ 在决策 22 记为**有 backstop 的有意残留**,不新增编辑命令。
123
+ - `os.path.exists` → `os.path.lexists`(一字收紧"绝不覆盖",悬空符号链接是目前唯一破口);**不**铺符号链接测试矩阵。
124
+
125
+ ## 4. ③ 观看命令交付通道(0.8.1 的 sendMessage + `0b28256` 的 widget)
126
+
127
+ ### 4.1 事实核验
128
+
129
+ - 投递已实证:`sendMessage` 写入会话成功;**本场三个 fork 源各含恰好 1 条** `customType=multi-viewers` 的 `custom_message`(逐文件解析:481/550/555 条目中各 1)——**说明它进入每场分析各视角的上下文**(fork 源在**首唤**构建,`meeting_loop._prepare_fork_session`),不是"只在主 session"。
130
+ - 显示端:pi-web 把 `custom_message` 渲染为**折叠块**(需点击/重载)→ 用户实测"看不见";`notify` 弹窗关闭即消失;`setWidget` 面板为**一眼可见、不需点击**(pi-web 注释 "Persistent widget panel … Not a popup")。
131
+
132
+ ### 4.2 角色模型(替代"缺一不可/三个出口"的说法)
133
+
134
+ | 通道 | 作用域 | 上下文成本 | 必需性/角色 |
135
+ |---|---|---|---|
136
+ | `setEditorText` 预填 | 仅 TUI | 0 | TUI 便利(能直接跑) |
137
+ | `notify` | 全模式 | 0 | 即时反馈(会消失) |
138
+ | `pi.sendMessage` | 全模式 | ~100–200 token/回合(主 session)+ 每场 fork 3×~100–200(一次性、有界) | **持久留痕**(折叠;跨重启仍在) |
139
+ | `ctx.ui.setWidget` | 全模式(pi-web 已验证) | 0(纯 UI) | **运行期常驻可见** |
140
+
141
+ ### 4.3 冻结清单(③)
142
+
143
+ - [ ] `extensions/multi-viewers/index.ts:19` 与 **`:96`**(第二处,枚举漏 widget)→ 改四通道角色表述(与决策 22 一致);
144
+ - [ ] 决策 22 的 UI 通道段**收敛为 4 行角色表 + 三注记**:① fork 复制事实(`design.md:589` 现只写"参与 LLM 上下文的一行",须补"随 fork 进每场分析");② widget「**(运行期)**」(fire-and-forget UI 状态、非会话条目,跨重启不恢复);③ why-not-appendEntry(零上下文留痕档存在但渲染 TUI-only,pi-web 不可见);
145
+ - [ ] 文档可如实写"显示层失败也不阻断清理"(①(c) 修完后);
146
+ - **不做**:fork 过滤(`build_fork_source` 不按 customType 过滤;新增黑名单=构造层获得扩展类型知识,跨层耦合换 ~2 行噪音,不配);`ctx.mode` 分支(把客户端差异搬进扩展);"瘦身" sendMessage(收益 <50 token/回合,不配 churn)。
147
+
148
+ ### 4.4 display-only 口径(写准,防未来翻案)
149
+
150
+ `sendMessage` **无** excludeFromContext 选项(options 仅 `{triggerTurn, deliverAs}`;`excludeFromContext` 属 bashExecution);**零上下文留痕档存在**——`pi.appendEntry` + `registerEntryRenderer`("do NOT participate in LLM context"),但渲染器是 TUI 组件、pi-web 无渲染路径 ⇒ **可见 ∩ 零上下文 = 空集**,跨模式成本不可归零、接受现值。widget 的零上下文已覆盖"面板可见"需求。
151
+
152
+ ## 5. 测试与断言(冻结清单)
153
+
154
+ - **harness 单一日志(第一优先简化)**:mock 的 `sendMessage` 也 push 进 `calls`(kind="sendMessage");删 `sent` 数组与两段特判;成功后精确 `eq(kinds(calls), [...])` 一次覆盖四通道;取消/失败路径精确 `eq(kinds(calls), ["notify:error"])`(现失败路径只 `includes("notify:error") && !includes("setEditorText")`,四通道下只挡一个);content 用**语义包含**(`includes(dir)`/`includes(watch)`),不锁排版;D2 负断言改**正向**("含 TUI 限定词")。
155
+ - ①:等值断言 + 写失败分支 + 显示层三断言用例(目录已删 + rc 0 + report.txt 存在)。
156
+ - ②:组合用例(rc≠0 且文件不存在);两条 None 路径已由现有首建用例覆盖(`test_creates_and_validates` / `test_creates_viewers_dir_when_missing`),无需新装置。
157
+ - 数字:harness 现 **48** 条;本批预计 +4~6 条断言、删 `sent` 相关代码——**净减代码**。
158
+
159
+ ## 6. 文档同步清单
160
+
161
+ 1. **报告出口描述**:`design.md:139` 第 2 项(`--cleanup`)补"打印 + **落盘快照**",**计数保持三**(比加第四出口少一个概念;`design.md:106`、`meeting_fs.py:57` 不动)。
162
+ 2. **术语拆分**:观看命令 = 「**四个交付通道**」;报告保留「出口」(全仓"三个出口"5 处属两个机制,改 `:19`/`:96` 时勿误伤报告三处)。
163
+ 3. 决策 22:角色表 + 三注记(见 4.3);观测面契约处(:167-171)已更新,保持。
164
+ 4. `AGENTS.md`:如无引用冲突无需改(版本号唯一事实源 = package.json;本批若发版按"修复+文档"升第三位)。
165
+ 5. 版本引用按 §1 分段。
166
+
167
+ ## 7. 明确不做 / 已知限 / 回退口径
168
+
169
+ - **不做**:fork 过滤、`ctx.mode` 分支、符号链接测试矩阵、② 的 C 方案(改为"创建成功即 rc0、集合问题仅提示"——属产品行为变更)、为 prompt 的"复核义务"新增编辑命令。
170
+ - **回退口径**:② 若取 A,契约行写双义(§3.2);①(c) 若本批不修(不推荐,已 3/3 支持修),则须双落点——`cleanup_discussion` 代码注释(主防线)+ 结论一行,且**不得**声称"fail-open 覆盖显示层"。
171
+ - **已知限**(记录在案):widget 仅运行期常驻(pi 重启/reload 后不恢复,持久记录靠折叠的 custom_message);手动 `mv.sh --cleanup` 或外部删除后,面板会短暂指向已删目录(下次运行同 key 覆盖);`sendMessage` 内容参与每场 fork 上下文(~2 行/场,预算窗口内,有界)。
172
+
173
+ ## 8. 收敛过程(消息索引)
174
+
175
+ - **开题(效率/0001、简单/0001、铁律/0001)**:效率给运行开销账(三处净收益为正)+ 两问(report.txt 是否 ignore、display-only 档);简单给简洁性复验(① 哨兵可省/两文案未同步、② 最干净、③ 头注释三出口矛盾)+ harness 失败路径断言弱于取消路径;铁律按三铁律逐条核(① 三处"不持久化"只同步一处、断言强度、fail-open 宽窄;② 校验在写之后 + prompt 复述;③"缺一不可"强于设计 + fork 下游效应 + 版本归属修正)。
176
+ - **交错与澄清**:挂账两项由简单/铁律独立结清(`.gitignore:8` 命中;零上下文档存在但 TUI-only);效率两次撤回("viewer 看不到"被 fork 源证据证伪;"零成本前置"被组合复制论证撤回);简单撤回哨兵建议(BrokenPipe 反例成立);铁律更正自身(fork 在首唤构建,本场即已进入;0009 的"受保护块从留存开始"按字面会丢产物,由简单/0007 修正、铁律/0015 确认)。
177
+ - **关键分歧裁定**:① 显示层项——效率先降级 → 铁律/0007 代码验证(三逃逸点、缓冲而定)→ 效率/0008 收回、支持本批修 → 简单/0006 站修 → **3/3 修**;② 半成功——效率 A→B′、简单 A→B′、铁律 B′,**最终三方一致 B′**(A 回退写双义);③ 通道——从"三个出口(缺一不可)"收敛为四通道角色模型(保留、不加过滤)。
178
+ - **RR 表态**:效率/0015、简单/0011、铁律/0015 均 `pass`,无异议——共识闭合。
179
+
180
+ ## 9. 一句话结论
181
+
182
+ **三处机制可以收下;本批修复的全部是"防静默失效"类问题——清理必达(唯一例外=产物留存真失败)、校验先于副作用、文档按实际写;生产代码净增 ≈11 行、断言净增 ≈4~6 条(并删 `sent` 相关代码),运行时零变化。**
@@ -37,6 +37,7 @@
37
37
  | `2026-09-14-e2e25-doc-drift-review.md` | 文档 vs 代码一致性(文档漂移)+ ctx_search 的**自然使用**观察 | **三处文档仍写"兜底 mv-* 并警告"而代码是无兜底、未匹配报错**(行为语义相反);README 缺 `--extension-policy`;报告字段列表过期;4 条缺失项;**根因 = 对实现的复述**(治本:引事实源不复制);自然使用观察:`ctx_search` **0 次**、historian 0 次 | `ba204ef` |
38
38
  | `2026-09-25-multi-viewers-extension-review.md` | 把 `/multi-viewers` 改成 extension 这次的实现(extension 代码 / CLI 机器标记行契约 / 文档同步) | **P1:`stalled` 被当成 running → 让用户等一个永不到来的收尾**(唯一行为错误);P2 `[result]` 只在 done 打印;P3 头注释与暂停点自相矛盾;P4 `/root/pi-multi-viewers` 单机死回退;P5 两扩展逐字重复 ≈35 行且已漂移(→ 合并为一单元三命令);P6 取消提示缺 sid 提醒 + **扩展消费端零仓库内测试**;首用另暴露:主题带引号、观看命令只有预填一个出口 | 本批(合并 + P1–P6 + 首用两项 + harness 进仓库) |
39
39
  | `2026-09-25-multi-viewers-postfix-review.md` | 复验 0.8.0 的 extension 合并与 P1–P6(含 harness 覆盖审查) | **P1–P6 逐条到位、合并净简化**;新抓 **漏 A:`run_tests.sh --reuse` 的错误成功信号**(harness 失败仍算绿 → 命中旧绿 + exit 0,修法 ②′ 清指纹 + rc==0 才写回);D2 通知里的不实断言(pi-web 忽略 `setEditorText`);B1/B2 契约前缀与不可执行出路;7 类现存分支零覆盖 + sid 注入与 percent-encoding 两装置缺口;D1/D3 文档漂移;S1–S3 简化 | `69a415a`(+ `e92eacc` 第三交付出口) |
40
+ | `2026-09-25-extension-mechanisms-review.md` | 复验 0.8.2 三处机制(报告落盘 / `--set-viewer` / 观看命令通道)+ 测试覆盖与断言强度 | **① 显示层失败会跳过清理**(BrokenPipe 逃逸 → rmtree 被跳过、目录残留;修法 `_print_best_effort` + rmtree 进 finally ⇒ 清理必达);**② `--set-viewer` 半成功**(校验在写之后 → rc≠0 但文件已写入;改 B′ 校验前移);③ 通道模型由「三出口」收敛为四通道角色表,并证实 custom_message 随 fork 进每场上下文;文档三处「不持久化」复述、断言偏弱、prompt 复述、fail-open 宽窄不对称 | `51a4535` |
40
41
 
41
42
  ## 环境口径(读报告时的背景)
42
43
 
@@ -16,11 +16,13 @@
16
16
  *
17
17
  * 与 CLI 的契约(标记行/退出码)与全部原语见 ./shared.ts。
18
18
  *
19
- * 观看命令交付 = **三个出口**(缺一不可,都是实测暴露的):
20
- * ① `ctx.ui.setEditorText` 预填输入框(按 Enter 即执行;**仅 TUI**,pi-web 忽略)
21
- * ② `notify` 带一份(即时可见;但 pi-web 上关掉弹窗即消失)
22
- * ③ `pi.sendMessage` 写一条 custom_message 进消息流(**持久可回滚复制**;
23
- * 代价 = 参与 LLM 上下文的一行)
19
+ * 观看命令交付 = **四个通道,各司其职**(每一个都是实测逼出来的,见 docs/design.md 决策 22):
20
+ * ① `ctx.ui.setEditorText` 预填输入框——TUI 便利(能直接回车跑);pi-web 忽略
21
+ * ② `notify`——即时反馈;pi-web 上关掉弹窗即消失
22
+ * ③ `pi.sendMessage`(custom_message)——**持久留痕**,跨重启仍在;但 pi-web 渲染为
23
+ * **折叠的** `multi-viewers (click to expand)`,需点击展开(pi-web 0.5.19 起**实时出现**,
24
+ * 此前只在重载后可见);且随 fork 进入每场分析上下文
25
+ * ④ `ctx.ui.setWidget`——**运行期常驻可见**(一眼看到、不需点击;MC 待办用的同一通道)
24
26
  */
25
27
 
26
28
  import {
@@ -91,7 +93,8 @@ export default function register(pi: any) {
91
93
  return;
92
94
  }
93
95
 
94
- // ④ 观看命令:三个出口(见文件头)——预填(仅 TUI)+ notify(即时)+ 消息流(持久)
96
+ // ④ 观看命令:四个通道各司其职(见文件头与决策 22)——预填(TUI)/ notify(即时)/
97
+ // custom_message(留痕)/ widget(常驻可见)
95
98
  ctx.ui.setEditorText(watch);
96
99
  // 消息流里留一条持久记录:用户实测 pi-web 的 notify 会随弹窗关闭而消失,
97
100
  // 关了窗口就再也找不到这行命令。custom_message 进会话(**参与 LLM 上下文**,
@@ -102,6 +105,15 @@ export default function register(pi: any) {
102
105
  content: `多视角分析已启动:${dir}\n观看命令(复制执行,不进 LLM):\n${watch}`,
103
106
  display: true,
104
107
  });
108
+ // 常驻面板:custom_message 在 pi-web 是**折叠块**(要点击才展开;0.5.19 起实时出现)
109
+ // → 观看命令还需要一条**一眼可见、不需点击**的常驻出口。
110
+ // setWidget 正是这个语义(pi-web 注释:"Persistent widget panel … Not a popup";
111
+ // 主 pi 的 magic-context 待办面板用的就是它)。
112
+ ctx.ui.setWidget("multi-viewers", [
113
+ `多视角分析进行中:${dir}`,
114
+ `观看(复制执行,不进 LLM):${watch}`,
115
+ `插话 /multi-viewers-say <文本> 收尾 /multi-viewers-finish`,
116
+ ]);
105
117
  ctx.ui.notify(
106
118
  `分析已启动:${dir}\n` +
107
119
  "观看(复制执行;TUI 下已预填进输入框):\n" +
@@ -167,7 +179,7 @@ export default function register(pi: any) {
167
179
  const ok = await ctx.ui.confirm(
168
180
  "确认收尾?",
169
181
  "将清理分析目录;结果会保存到 `<分析目录>-result.md`," +
170
- "清理时还会打印一次分析报告。",
182
+ "清理时还会打印并落盘一份报告(<分析目录>-report.txt)。",
171
183
  );
172
184
  if (!ok) {
173
185
  ctx.ui.notify("已取消收尾(分析目录保留)。", "info");
@@ -178,6 +190,8 @@ export default function register(pi: any) {
178
190
  ctx.ui.notify(`收尾失败:\n${clean.output}`, "error");
179
191
  return;
180
192
  }
193
+ // 收尾成功 → 清掉常驻面板(否则留下一行指向已删除目录的观看命令)。
194
+ ctx.ui.setWidget("multi-viewers", undefined);
181
195
  ctx.ui.notify(
182
196
  `${clean.output}\n\n要摘要就在对话里说一声(主 pi 读该 result.md 即可)。`,
183
197
  "success",
package/meeting_fs.py CHANGED
@@ -126,32 +126,24 @@ def _package_dir(source, agent_dir):
126
126
  return cand if os.path.isdir(cand) else None
127
127
 
128
128
 
129
- def resolve_mc_tools_entry(agent_dir=None):
130
- """解析 MC 的**只读工具入口**(`dist/subagent-entry.js`)——"mc-tools" 档用。
131
-
132
- 为什么这样解析而不硬编码路径:MC 自己就是用"主入口的**兄弟文件**"
133
- (其源码 `resolveSiblingEntryPath("subagent-entry.js")`)定位它。我们的
134
- 等价做法 = 从 pi 的注册表(settings.json.packages)找到 MC 包目录 → 读它
135
- package.json 声明的扩展入口(`pi.extensions[0]`)→ 取同目录下的
136
- subagent-entry.js。
129
+ def _packages_of(pkg_name, agent_dir=None):
130
+ """在 pi 的 packages 里找声明了 `pkg_name` 的包:返回 (candidates, err)。
137
131
 
138
- **失败语义**:任一步缺失返回 `(None, 原因)`——**由调用方按策略决定**:
139
- loop 在生产态做**可见降级**(打印一行说明后按零扩展运行 ✓ 无静默),
140
- 在严格态(`MV_MC_TOOLS_STRICT=1`,测试/探针保真)直接报错。
132
+ candidates = [(source, pkg_dir)],按注册顺序(可能有多个候选——包名相同但
133
+ 来源不同);err = 读注册表失败的原因(此时 candidates 为空)。
141
134
 
142
- 依赖边界(决策 20):mc-tools **允许而非要求** MC——缺 MC 即降级为零扩展;
143
- `none` 档零依赖(无 MC 的机器/CI 显式选它)。
135
+ **匹配按裸包名精确相等**(F5):不能用子串——`in` 会把
136
+ `@cortexkit/pi-magic-context-legacy` 也命中。npm 源直接比裸名;路径源读
137
+ 它的 package.json.name。**遍历全部候选**(F6:首个匹配不可解析时继续看
138
+ 后面的候选,否则"装着也报没装",文案误导)。
144
139
  """
145
140
  agent_dir = agent_dir or pi_agent_dir()
146
141
  try:
147
142
  with open(os.path.join(agent_dir, "settings.json"), encoding="utf-8") as f:
148
143
  pkgs = json.load(f).get("packages") or []
149
144
  except (OSError, ValueError) as e:
150
- return None, f"读不到 pi 的 packages({agent_dir}/settings.json): {e}"
151
- # F5:按**裸包名精确相等**识别(npm 源直接比;路径源读其 package.json.name);
152
- # F6:遍历**全部**候选、取首个可解析(此前首个匹配失败即停,装着 MC 也会
153
- # 报"没装",文案误导)
154
- seen = [] # 候选(source 字符串)——用于错误文案,说明"试过哪些"
145
+ return [], f"读不到 pi 的 packages({agent_dir}/settings.json): {e}"
146
+ out = []
155
147
  for entry in pkgs:
156
148
  source = _entry_source(entry)
157
149
  if not source:
@@ -167,32 +159,80 @@ def resolve_mc_tools_entry(agent_dir=None):
167
159
  name = json.load(f).get("name") or ""
168
160
  except (OSError, ValueError):
169
161
  name = ""
170
- if name != MC_PACKAGE:
171
- continue
172
- seen.append(source)
162
+ if name == pkg_name:
163
+ out.append((source, pkg_dir))
164
+ return out, ""
165
+
166
+
167
+ def _declared_extensions(pkg_name, agent_dir=None):
168
+ """包名 → (pkg_dir, exts, err):包目录 + 它自己声明的扩展入口(已归一为列表)。
169
+
170
+ exts 来自包 package.json 的 `pi.extensions`(判据**由上游声明**,不硬编码布局);
171
+ 字符串形态归一为单元素列表(F7:直接取首字符会解析出错误路径)。
172
+ """
173
+ cands, err = _packages_of(pkg_name, agent_dir)
174
+ if err:
175
+ return None, [], err
176
+ if not cands:
177
+ return None, [], f"packages 里没有 {pkg_name}(pi install npm:{pkg_name})"
178
+ last = ""
179
+ for source, pkg_dir in cands:
173
180
  try:
174
181
  with open(os.path.join(pkg_dir, "package.json"), encoding="utf-8") as f:
175
182
  man = json.load(f)
176
183
  except (OSError, ValueError) as e:
177
- return None, f"读不到 {MC_PACKAGE} 的 package.json: {e}"
184
+ last = f"读不到 {pkg_name} 的 package.json: {e}"
185
+ continue
178
186
  exts = (man.get("pi") or {}).get("extensions")
179
- if isinstance(exts, str): # F7:字符串形态(取首字符会解析错路径)
187
+ if isinstance(exts, str):
180
188
  exts = [exts]
181
189
  if not isinstance(exts, list) or not exts or not all(
182
190
  isinstance(x, str) and x for x in exts):
183
- return None, f"{MC_PACKAGE} 的 pi.extensions 形态不可用(版本不兼容?)"
184
- cand = os.path.normpath(
185
- os.path.join(pkg_dir, os.path.dirname(exts[0]), "subagent-entry.js"))
186
- if os.path.isfile(cand):
187
- return cand, ""
188
- # 该候选不可解析 → 继续看后面的候选(F6)
189
- last_missing = os.path.relpath(cand, pkg_dir)
190
- if seen:
191
- return None, (f"{MC_PACKAGE} 的只读工具入口不存在({last_missing})"
192
- f"——上游版本可能改了布局")
193
- return None, (f"packages 里没有可解析的 {MC_PACKAGE}——装它"
194
- f"(pi install npm:{MC_PACKAGE}),或改用零扩展档:"
195
- f"--extension-policy none")
191
+ last = f"{pkg_name} 的 pi.extensions 形态不可用(版本不兼容?)"
192
+ continue
193
+ return pkg_dir, exts, ""
194
+ return None, [], (last or f"{pkg_name} 的扩展入口不可用")
195
+
196
+
197
+ def resolve_mc_tools_entry(agent_dir=None):
198
+ """解析 MC 的**只读工具入口**(`dist/subagent-entry.js`)——"mc-tools" 档用。
199
+
200
+ 路径推理:MC 自己就是用"主入口的**兄弟文件**"(其源码
201
+ `resolveSiblingEntryPath("subagent-entry.js")`)定位它;我们等价地读它声明的
202
+ 扩展入口(`pi.extensions[0]`),再取同目录下的 subagent-entry.js。
203
+
204
+ 失败语义(与 resolve_mcp_adapter_entry 同):任一步缺失返回 `(None, 原因)`
205
+ ——**由调用方按策略决定**:loop 生产态做**可见降级**,严格态
206
+ (`MV_MC_TOOLS_STRICT=1`)直接报错。
207
+ """
208
+ pkg_dir, exts, err = _declared_extensions(MC_PACKAGE, agent_dir)
209
+ if err:
210
+ return None, err
211
+ cand = os.path.normpath(
212
+ os.path.join(pkg_dir, os.path.dirname(exts[0]), "subagent-entry.js"))
213
+ if os.path.isfile(cand):
214
+ return cand, ""
215
+ return None, (f"{MC_PACKAGE} 的只读工具入口不存在"
216
+ f"({os.path.relpath(cand, pkg_dir)})——上游版本可能改了布局")
217
+
218
+
219
+ def resolve_mcp_adapter_entry(agent_dir=None):
220
+ """解析 MCP adapter 的扩展入口(它声明的 `pi.extensions[0]`)——mc-tools 第二份。
221
+
222
+ 为什么需要:MCP 工具(web_search / web_reader / zread…)由 pi-mcp-adapter
223
+ 提供,而 `--no-extensions` 关掉的是**扩展发现**——显式 `-e` 路径照常生效
224
+ (pi --help 原文)。不显式加载 = agents 完全没有联网检索能力。
225
+
226
+ 与 MC 的差别:这里要的**就是主入口本身**(它注册 MCP 工具),不取兄弟文件。
227
+ """
228
+ pkg_dir, exts, err = _declared_extensions(MCP_ADAPTER_PACKAGE, agent_dir)
229
+ if err:
230
+ return None, err
231
+ cand = os.path.normpath(os.path.join(pkg_dir, exts[0]))
232
+ if os.path.isfile(cand):
233
+ return cand, ""
234
+ return None, (f"{MCP_ADAPTER_PACKAGE} 声明的入口不存在"
235
+ f"({os.path.relpath(cand, pkg_dir)})")
196
236
 
197
237
 
198
238
  def pi_agent_dir():
@@ -778,6 +818,10 @@ def mc_tools_strict():
778
818
  # 入口是它的内部文件,路径解析见 resolve_mc_tools_entry 的 docstring)
779
819
  MC_PACKAGE = "@cortexkit/pi-magic-context"
780
820
 
821
+ # MCP 工具(web_search / web_reader / zread…)的提供者——mc-tools 档的第二份入口。
822
+ # 名字**由 pi 的注册表给**(settings.json.packages),这里只做精确匹配用。
823
+ MCP_ADAPTER_PACKAGE = "pi-mcp-adapter"
824
+
781
825
  FORK_MODES = ("budget", "compaction", "full")
782
826
  DEFAULT_FORK_MODE = "budget"
783
827
  #
package/meeting_loop.py CHANGED
@@ -330,19 +330,36 @@ def _build_wake_cmd(workdir, agent, sid, cfg, fork_source, fork_cwd,
330
330
  if extension_policy == "none":
331
331
  cmd += no_ext
332
332
  elif extension_policy == "mc-tools":
333
- entry, err = meeting_fs.resolve_mc_tools_entry()
334
- if entry:
335
- cmd += no_ext + ["-e", entry]
336
- elif meeting_fs.mc_tools_strict():
337
- # 严格模式(测试/探针保真):缺 MC 即响,不降级——否则测试可能在
338
- # "没装 MC"的环境里通过,而 ctx_search 从未生效
339
- log(agent, f"[fatal] mc-tools 档入口解析失败(严格模式):{err}")
340
- raise RuntimeError(f"mc-tools 档不可用: {err}")
341
- else:
342
- # mc-tools **允许**而非要求 MC:缺 MC → 降级零扩展,但**可见**
343
- effective_policy = "none"
344
- downgrade_reason = err
345
- cmd += no_ext
333
+ # mc-tools = 零扩展 + 显式加载**两份只读工具入口**(2026-09-25 用户裁定 B:
334
+ # 并入默认档,不再新增档位——保持简单):
335
+ # ① MC 的 subagent-entry(只注册工具、不装 hook)→ ctx_search
336
+ # ② MCP adapter(web_search / web_reader / zread 等 MCP 工具)
337
+ # 为什么必须显式 -e:`--no-extensions` 关的是**发现**,显式路径照常生效
338
+ # (pi --help 原文);MCP 工具此前因发现被关而对 agents 完全不可用。
339
+ # 两份入口**各自独立**降级(允许而非要求)——缺哪个就少哪个,都**可见**。
340
+ resolved = [] # [(label, entry)]
341
+ missing = [] # [(label, err)]
342
+ for label, resolver in (
343
+ ("ctx_search(MC 只读检索)", meeting_fs.resolve_mc_tools_entry),
344
+ ("MCP 工具(web_search 等)", meeting_fs.resolve_mcp_adapter_entry)):
345
+ entry, err = resolver()
346
+ if entry:
347
+ resolved.append((label, entry))
348
+ else:
349
+ missing.append((label, err))
350
+ cmd += no_ext
351
+ for _label, entry in resolved:
352
+ cmd += ["-e", entry]
353
+ if missing:
354
+ reason = ";".join(f"{label} 不可用({err})" for label, err in missing)
355
+ if meeting_fs.mc_tools_strict():
356
+ # 严格模式(测试/探针保真):缺入口即响,不降级——否则测试可能在
357
+ # "没装某入口"的环境里通过,而该工具从未生效
358
+ log(agent, f"[fatal] mc-tools 档入口解析失败(严格模式):{reason}")
359
+ raise RuntimeError(f"mc-tools 档不可用: {reason}")
360
+ # 允许而非要求:缺入口 → 少一份 -e,但**可见**
361
+ downgrade_reason = reason
362
+ effective_policy = ("mc-tools" if resolved else "none")
346
363
  # "all":不加任何 --no-*(走 pi 默认发现)
347
364
  if first_wake:
348
365
  # 登记行(观测面的稳定字段;报告据此给"声明 vs 生效")。只在首唤打:
@@ -351,8 +368,10 @@ def _build_wake_cmd(workdir, agent, sid, cfg, fork_source, fork_cwd,
351
368
  f" strict={int(meeting_fs.mc_tools_strict())}"
352
369
  + (f" 降级原因={downgrade_reason}" if downgrade_reason else ""))
353
370
  if downgrade_reason:
354
- log(agent, f"mc-tools 档未生效({downgrade_reason})——本次按零扩展"
355
- f"运行:ctx_search 不可用")
371
+ log(agent, f"mc-tools 档{'部分' if resolved else '完全'}未生效"
372
+ f"({downgrade_reason})——本次按"
373
+ f"{'已解析的入口' if resolved else '零扩展'}运行:"
374
+ f"缺失的工具在本次分析中不可用")
356
375
  model = cfg.get("model") or ""
357
376
  if model:
358
377
  cmd += ["--model", model]
package/mv_cli.py CHANGED
@@ -186,11 +186,9 @@ def _validate_and_print_viewers(vdir):
186
186
  notes.append("空:没有视角内容")
187
187
  suffix = f"({';'.join(notes)})" if notes else ""
188
188
  print(f" {n}.md{suffix}")
189
- err = spec_gen.validate_participants(names)
189
+ err = spec_gen._viewer_set_errors(names, empty)
190
190
  if err:
191
191
  fail_verbatim(err)
192
- if empty:
193
- fail_verbatim(spec_gen.viewer_set_error(names, empty))
194
192
  gap = spec_gen.viewers_count_gap(names)
195
193
  if gap:
196
194
  print(f" 校验:{gap}(建 1 个是合法的中间状态)")
@@ -217,8 +215,19 @@ def cmd_set_viewer(args):
217
215
  fail(f"非法视角名({err}):{name}")
218
216
  vdir = os.path.join(os.getcwd(), "viewers")
219
217
  target = os.path.join(vdir, f"{name}.md")
220
- if os.path.exists(target):
218
+ # lexists(不是 exists):悬空符号链接在 exists 下为假 → 会被"覆盖"写入,
219
+ # 违背"绝不覆盖"的承诺(悬空链接是这条承诺目前的唯一破口)。
220
+ if os.path.lexists(target):
221
221
  fail(f"视角已存在,不覆盖: {target}(改名,或直接编辑该文件)")
222
+ # **写前**做集合级校验(评审 ② B′):否则 viewers/ 里已有坏文件时,本命令
223
+ # 会"先写成功、再以 rc≠0 退出"——副作用已发生却报失败(半成功)。
224
+ # 用 if names 守卫:names 为 None 表示目录还不存在/还没有视角,那是合法起点
225
+ # (不守卫会让 validate_participants(None) 抛 TypeError——首次建视角即命中)。
226
+ names, _briefs, empty = spec_gen._discover_viewers(vdir)
227
+ if names:
228
+ err = spec_gen._viewer_set_errors(names, empty)
229
+ if err:
230
+ fail_verbatim(f"{err}\n(修正 viewers/ 后再建新视角——本次未写入任何文件)")
222
231
  body = sys.stdin.read().strip()
223
232
  if not body:
224
233
  fail(f"视角内容为空({name})——正文从 stdin 传入;空视角没有 lenses,"
package/observability.py CHANGED
@@ -1,8 +1,9 @@
1
1
  """观测层——状态判定 / --report / --wait(从 start_discussion 拆出,S2)。
2
2
 
3
3
  职责:**运行期只读观测**——check_status 状态机、--report 观测面聚合、
4
- --wait 阻塞观察、loop 进程存活检测。不写任何产物(报告不落盘——
5
- 观测面契约:冷路径一次性,不持久化)。
4
+ --wait 阻塞观察、loop 进程存活检测。本层**不写任何文件**:报告的唯一落点
5
+ 在 `start_discussion.cleanup_discussion`(删目录前打印并落盘一份
6
+ `<base>-report.txt`,观测数字可复查)——本模块只生成行、不负责落地。
6
7
 
7
8
  依赖方向:只准 import meeting_core / meeting_fs / meeting_engine /
8
9
  human_viewer(observability 是"读"侧,human_viewer.incremental 是它
@@ -214,8 +215,9 @@ def build_report(base):
214
215
 
215
216
  契约(design.md 观测面契约节):
216
217
  - **冷路径一次性**:不常驻、不被轮询;调用方(人/主 pi)按需触发。
217
- - **不持久化**:视图不占"数字的家"——数字的家是 bare(判定域)、
218
+ - **本函数不落盘**:视图不占"数字的家"——数字的家是 bare(判定域)、
218
219
  loop log 的登记字段、pi session 的文档化字段;报告只是它们的一次投影。
220
+ 唯一的落盘点是 `cleanup_discussion`(删目录前写 `<base>-report.txt`)。
219
221
  - **fail-open**:任何一段读不出(缺目录/缺文件/格式变)→ 该段显示 n/a,
220
222
  不报错、不改判定、不阻塞。
221
223
  - **跨度分标**:进程跨度(elapsed_ms)≠ per-response 跨度(session
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-multi-viewers",
3
- "version": "0.8.2",
3
+ "version": "0.9.0",
4
4
  "description": "Multi-perspective analysis for Pi: fork the main session into N perspective agents over the meeting protocol.",
5
5
  "type": "module",
6
6
  "private": false,
@@ -16,7 +16,6 @@ description: 建立多视角分析的 viewers/ 视角文件(交互式:先建
16
16
 
17
17
  - 这一步**只提议**:不建文件、不改任何东西
18
18
  - 建几个**由用户决定**(只建 1 个也行)
19
- - 名字规则由第 3 步的命令强制(顺带一提:中文、短、不含空白与路径分隔符)
20
19
 
21
20
  **然后结束本回合,等用户输入。**
22
21
 
@@ -27,14 +26,7 @@ description: 建立多视角分析的 viewers/ 视角文件(交互式:先建
27
26
 
28
27
  ## 第 3 步:用命令建文件(内容经 stdin 传给命令,不要直接写文件)
29
28
 
30
- 先读 `README.md` 的「视角文件写什么」节(唯一事实源:三个要点 + 正误对照)。要点:
31
-
32
- - 纯内容、无 frontmatter、无标题——**文件名就是全部元数据**
33
- - 必含三条:① **单一 lenses**(所有观点必须从该视角出发)② **不越界**(其它视角由
34
- 别的参与者负责;**不要**列举是哪几个——参与者会变,列举会过期)③ **交锋义务**
35
- (对其它视角的观点可认同或反驳,但要用本视角的论据)
36
- - **只写视角本身**:身份("你是 X")、参与者名单、消息格式、独立参与者纪律都由脚本
37
- 生成,**不要写进文件**
29
+ 先读 `README.md` 的「视角文件写什么」节——那是唯一事实源(三个要点、正误对照、命名规范)。
38
30
 
39
31
  每个视角调用一次(正文经 stdin;**命名合法性、不覆盖已有、空正文拒绝、写完校验并
40
32
  回显**都由命令保证,不需要你另外检查):
package/spec_gen.py CHANGED
@@ -478,6 +478,21 @@ def viewer_set_error(names, empty, where="viewers/"):
478
478
  return f"错误: {gap}" if gap else None
479
479
 
480
480
 
481
+ def _viewer_set_errors(names, empty, where="viewers/"):
482
+ """集合级校验的**唯一组合点**:整组名字 + 空正文 + 数量 ≥2。
483
+
484
+ 为什么单独存在:`--viewers`(只读检查)与 `--set-viewer`(写前校验)必须
485
+ 用**同一套**判据——同一套规则曾在两处漂移过(文案与检查项不一致)。
486
+ `if empty` 是必要保护:否则 viewers/ 里只有一个**合法**视角时,
487
+ `viewer_set_error` 会因为数量不足而报错,把"建第 2 个视角"判成非法。
488
+ `names` 为空(目录缺失/无 .md)返回 None——那是"还没有视角",不是错误。
489
+ """
490
+ if not names:
491
+ return None
492
+ return (validate_participants(names)
493
+ or (viewer_set_error(names, empty, where) if empty else None))
494
+
495
+
481
496
  def _discover_viewers(viewers_dir):
482
497
  """发现 viewers 目录(多视角产品约定):*.md 文件名即 agent 名。
483
498
 
@@ -424,52 +424,85 @@ def setup_environment(args, participants, base, spec_dir=None,
424
424
  f"extensionPolicy={args.extension_policy}")
425
425
 
426
426
 
427
+ def _print_best_effort(*args, **kwargs):
428
+ """打印,但**绝不让显示层失败影响主职责**(清理 / 产物留存)。
429
+
430
+ 为什么需要:`cleanup_discussion` 的输出可能在管道关闭时抛
431
+ `BrokenPipeError`(`| head`、终端断开、CI 截断)——实测复现:异常从 print
432
+ 逃逸 → **`rmtree` 被跳过**,目录残留且 rc≠0,即"该清理的没清理"
433
+ (2026-09-25 评审批 ①(c))。显示层从来不是主职责,失败只能被忽略。
434
+
435
+ 契约:`cleanup_discussion` 的**全部 stdout 都走本函数**(该函数内不得出现
436
+ 裸 `print(`,可 grep 校验);于是不变量成立——**rmtree 必达**,唯一例外
437
+ 是产物留存真失败(那在 `_preserve_result_md` 里冒泡,见其注释)。
438
+ """
439
+ try:
440
+ print(*args, **kwargs)
441
+ except Exception: # noqa: BLE001(显示层失败永不上抛)
442
+ pass
443
+
444
+
427
445
  def _preserve_result_md(base):
428
446
  """清理前保存 result.md(薄包装 → meeting_fs.preserve_result_md,
429
- T2 合并:与 loop 退出路径共享同一实现)。"""
447
+ T2 合并:与 loop 退出路径共享同一实现)。
448
+
449
+ 这里的失败**必须冒泡**(不吞):result.md 的权威位置在 `<base>/repo.git`,
450
+ 即**待删目录之内**——留存失败还继续删 = 永久丢失产物。所以它不包在
451
+ 报告段的 try/finally 里(评审批 ①(c):唯一允许阻断清理的失败)。
452
+ """
430
453
  dest = meeting_fs.preserve_result_md(base)
431
454
  if dest:
432
- print(f"[cleanup] 已保存 result.md → {dest}")
455
+ _print_best_effort(f"[cleanup] 已保存 result.md → {dest}")
433
456
 
434
457
 
435
458
  def cleanup_discussion(base):
436
- """清理一次讨论:保存 result.md(若存在)→ 删目录。
459
+ """清理一次讨论:保存 result.md(若存在)→ 打印并落盘报告 → 删目录。
437
460
 
438
461
  result.md 是讨论唯一产物(审核报告等)——清理前先从 bare git 历史
439
462
  复制到父级目录(<base名>-result.md),避免清理丢产物(用户建议)。
463
+ 报告在本步**打印并落盘**到 `<base>-report.txt`(原先只在终端出现一次,
464
+ 目录删掉后无法复查——复盘时长口径时踩到):观测数字应当可复查。
440
465
  Pi 的 session 文件存放在 <base>/pi-sessions,随目录一起删除,无需
441
466
  额外清理全局 DB。
442
467
  不负责终止 loop 进程(职责边界,用户 2026-08-31 定)——loop 每轮
443
468
  检测到 repo.git 消失即自行退出(meeting_engine.agent_loop)。
469
+
470
+ 失败语义(评审批 ①(c) 冻结):**rmtree 必达**;报告生成 / 打印 / 落盘
471
+ 失败都不阻断清理(fail-open,捕 `Exception` 而非只捕 `OSError`——两段
472
+ 对称,不依赖"这些代码只可能抛 OSError"的脆弱推理);唯一例外是
473
+ `_preserve_result_md` 真失败(I/O)→ 冒泡且**不删目录**。
474
+ 清理成功返回 0(显示层失败不算失败)。
444
475
  """
445
476
  if not os.path.isdir(base):
446
- print(f"[cleanup] 目录不存在: {base}")
477
+ _print_best_effort(f"[cleanup] 目录不存在: {base}")
447
478
  return
448
479
  _preserve_result_md(base)
449
- # 报告(**删目录前最后一次可读**——目录删后 --report 不可用)。
450
- # 报告是附加信息、清理是主职责:报告生成失败**不阻断**清理
451
- # (fail-open 只在这一层兜底——build_report 内部各段已各自 fail-open)。
452
- print("[cleanup] —— 本次分析报告(删除目录前最后一次可读)——")
453
- lines = None
454
480
  try:
455
- lines = list(build_report(base))
456
- for line in lines:
457
- print(line)
458
- except Exception as e: # noqa: BLE001(兜底不吞:打印)
459
- print(f"[cleanup] 报告生成失败(不影响清理): {e!r}")
460
- if lines is not None:
461
- # 落盘一份(与 <base>-result.md 同级):报告本来只在终端出现一次,
462
- # 目录删掉后 --report 也不可用 → 观测数字不可复查(复盘时长口径时
463
- # 踩过)。与 result.md 同样的 fail-open:写不动不阻断清理。
464
- rp = meeting_fs.report_path(base)
481
+ # 报告(**删目录前最后一次可读**——目录删后 --report 不可用)。
482
+ # 报告是附加信息、清理是主职责:报告生成失败**不阻断**清理
483
+ # (fail-open 只在这一层兜底——build_report 内部各段已各自 fail-open)。
484
+ _print_best_effort("[cleanup] —— 本次分析报告(删除目录前最后一次可读)——")
485
+ lines = None
465
486
  try:
466
- with open(rp, "w", encoding="utf-8") as f:
467
- f.write("\n".join(lines) + "\n")
468
- print(f"[cleanup] 报告已保存 → {rp}")
469
- except OSError as e:
470
- print(f"[cleanup] 报告保存失败(不影响清理): {e!r}")
471
- shutil.rmtree(base)
472
- print(f"[cleanup] 已删除目录 {base}(含 pi-sessions)")
487
+ lines = list(build_report(base))
488
+ for line in lines:
489
+ _print_best_effort(line)
490
+ except Exception as e: # noqa: BLE001(兜底不吞:打印)
491
+ _print_best_effort(f"[cleanup] 报告生成失败(不影响清理): {e!r}")
492
+ if lines is not None:
493
+ # 落盘一份(与 <base>-result.md 同级):报告本来只在终端出现一次,
494
+ # 目录删掉后 --report 也不可用 → 观测数字不可复查(复盘时长口径时
495
+ # 踩过)。与 result.md 同样的 fail-open:写不动不阻断清理。
496
+ rp = meeting_fs.report_path(base)
497
+ try:
498
+ with open(rp, "w", encoding="utf-8") as f:
499
+ f.write("\n".join(lines) + "\n")
500
+ _print_best_effort(f"[cleanup] 报告已保存 → {rp}")
501
+ except Exception as e: # noqa: BLE001(同生成段:对称)
502
+ _print_best_effort(f"[cleanup] 报告保存失败(不影响清理): {e!r}")
503
+ finally:
504
+ shutil.rmtree(base) # 必达(唯一例外见 docstring)
505
+ _print_best_effort(f"[cleanup] 已删除目录 {base}(含 pi-sessions)")
473
506
 
474
507
 
475
508