pi-multi-viewers 0.8.1 → 0.8.3

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
@@ -28,13 +28,15 @@ human_sayer.py 【human 通道】插话命令(单次/stdin/交互 -i)
28
28
  scripts/mv.sh 稳定入口 shim(exec mv_cli.py;路径被 prompt/README 引用)
29
29
  scripts/pi-probe.sh LLM 探针(跑 pi + 登记新 session → 残留检查器可追溯)
30
30
  scripts/check-residue.sh 残留检查(session/进程/目录三类;增删 scripts/ 时同步本节)
31
- mv_cli.py 命令行实现(prepare/start/status/report/wait/cleanup/view/say/viewers)
31
+ scripts/archive-result.sh 归档 result.md(机械部分:逐字复制+校验/存档头骨架/索引行/删源副本)
32
+ mv_cli.py 命令行实现(prepare/start/status/report/wait/cleanup/view/say/viewers/set-viewer)
32
33
  extensions/multi-viewers/ 【单一扩展单元】index.ts = 三命令 + shared.ts = 助手
33
34
  /multi-viewers 分析入口(prepare→暂停点弹窗→start→预填+打印观看命令)
34
35
  /multi-viewers-finish 收尾(status→确认→cleanup)
35
36
  /multi-viewers-say 插话(零 LLM,直接 spawn human_sayer.py)
36
37
  ⚠ extensions/ 平级禁放 .ts 助手(加载器会把平级文件当独立扩展)
37
- prompts/multi-viewers-setup.md /multi-viewers-setup 建视角入口(建议→你定→落盘→给审)
38
+ prompts/multi-viewers-setup.md /multi-viewers-setup 建视角入口(建议→你定→**mv.sh --set-viewer** 落盘→给审)
39
+ # 那四条本来写进 prompt 的纪律(命名/不覆盖/非空/校验)改由命令保证
38
40
  docs/design.md 设计文档(fork 源模式与规模口径 + 决策记录)
39
41
  package.json npm 包 pi-multi-viewers(pi.prompts 注册;**版本号唯一事实源**)
40
42
  templates/ AGENTS.md.tpl / agent.md.tpl / gitignore.tpl / spec-readme.md.tpl
@@ -259,3 +261,7 @@ loop、状态从 git 共享事实推导、单一事实源 = protocol.json、无
259
261
  - **核验法**(照上游约定,不用命令行长度判断):
260
262
  `readlink -f ~/.pi/agent/npm/node_modules/pi-multi-viewers` 指向仓库根,
261
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
@@ -112,15 +112,16 @@ scripts/mv.sh --view # 一次性增量查看(主 pi
112
112
  scripts/mv.sh --say "<文本>" # 插话(命令行形态;pi 内用 /multi-viewers-say)
113
113
  scripts/mv.sh --status # 状态 + 路径(取值与含义以该命令输出为准)
114
114
  scripts/mv.sh --report # 只读报告(流程/配额/进程/LLM/档位对照;冷路径,不持久化)
115
- scripts/mv.sh --cleanup # 收尾(result.md 自动留存到 <dir>-result.md)
115
+ scripts/mv.sh --cleanup # 收尾(result.md + 报告都留存到 <dir>-*.md/.txt)
116
116
  scripts/mv.sh --viewers # 列出+校验当前项目 viewers/(只读;建视角时用)
117
+ scripts/mv.sh --set-viewer <名字> # 新建视角文件(正文从 stdin 读;只新建不覆盖)
117
118
  ```
118
119
 
119
120
  ## 视角文件写什么(`viewers/<视角名>.md`)
120
121
 
121
122
  建视角**推荐**走 `/multi-viewers-setup`(交互式:先给候选建议 → 你定建哪几个 →
122
- 落盘 → 展示给你审);也可以手写。写完用 `scripts/mv.sh --viewers` 自查(列出并
123
- 用代码判据校验名字与空正文)。
123
+ `--set-viewer` 落盘 → 展示给你审);也可以手写,写完用 `scripts/mv.sh --viewers`
124
+ 自查(列出并用代码判据校验名字与空正文)。
124
125
 
125
126
  一个视角文件 = **一份视角说明**,纯内容、无格式要求(无 frontmatter、
126
127
  无需标题,**文件名就是全部元数据**)。三个要点(措辞经实验验证):
package/docs/design.md CHANGED
@@ -103,7 +103,7 @@ compaction 的 `firstKeptEntryId` 起 + 其后的条目"——窗口内含 compa
103
103
  | `status-<agent>.json` | loop | `{"sessionID": ...}` | 流程(崩溃恢复) | 是(恢复用) | O(1) |
104
104
  | `pi-sessions/fork-src-*.jsonl` | pi | 文档化 session schema(`usage`/`stopReason`/`timestamp`/`thinkingLevel`) | fork 构建 + `--report` | 否(报告用) | O(MB) 全量 → **禁轮询** |
105
105
  | `result.md`(固定位) | resultWriter loop | 结论文档 | 人 | 是(收尾判据) | — |
106
- | `--report`(视图) | observability | 文本行 | 人(**三个出口**,见下) | **否**(不得升级为验收 gate) | 冷路径一次性 —— **O(session 大小)**:每 agent 读整个 fork-src jsonl(实测 3 × 789KB ≈ 2.4MB/次、50–150ms/次,×3 出口 <0.3s/次分析),**不得进入任何轮询路径**(e2e16 评审量化) |
106
+ | `--report`(视图) | observability | 文本行(`--cleanup` 另落盘 `<base>-report.txt`) | 人(**三个出口**,见下) | **否**(不得升级为验收 gate) | 冷路径一次性 —— **O(session 大小)**:每 agent 读整个 fork-src jsonl(实测 3 × 789KB ≈ 2.4MB/次、50–150ms/次,×3 出口 <0.3s/次分析),**不得进入任何轮询路径**(e2e16 评审量化) |
107
107
 
108
108
  **报告的字段集**(e2e17 评审后定稿,后续增补不计数——字段行以本表为准)——
109
109
  **谓词分组 + 对照 + 事实行**,
@@ -168,7 +168,7 @@ BOUNDARY_TYPE`)——**显式登记"历史(fork 携带)/ 本轮"的分界*
168
168
 
169
169
  **报告的打印位置**:`--report`(手动,任意时刻)+ `--cleanup` 前(自动,
170
170
  删目录前最后一次可读——目录删后 `--report` 不可用)。cleanup 层对报告
171
- fail-open(报告失败不阻断清理,且**打印**失败原因不静默)。
171
+ fail-open(报告失败不阻断清理,且**打印**失败原因不静默)。报告随 `--cleanup` **落盘**一份到 `<base>-report.txt`(与 `-result.md` 同级)——原先只在终端出现一次,目录删掉后无法复查(复盘时长口径时踩到,用户 2026-09-25 定)。
172
172
 
173
173
  消费规则:`meeting_fs.iter_after_boundary` 只产出边界之后的条目;**未找到
174
174
  边界(老产物/手工 session)→ 返回空、按 n/a 处理,不得退回全文扫描**
@@ -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
 
@@ -575,17 +575,31 @@ commit 是溯源记录、本节是长期引用点——不并存两份权威值
575
575
  resume 新命令面、env 回退配置、`runCli` 超时、启动路径继续优化(已在 1–2s 地板)、
576
576
  给 stalled 加第三种动作。**契约例外**:扩展作为包内第一方消费者**直连**
577
577
  `mv_cli.py`(实测 shim 61ms vs 直连 58–66ms,性能上零差异;按契约一致性记例外一行)。
578
+ **建视角流程(`/multi-viewers-setup`,仍是 prompt)**:机械部分下移到
579
+ `mv.sh --set-viewer <名字>`(正文从 stdin 读)——**命名规则 / 不覆盖已有 / 空正文拒绝 /
580
+ 写完校验并回显**四条由命令保证(此前是 prompt 里给 LLM 的纪律,会漏);prompt 只负责
581
+ **看项目给候选 + 内容撰写 + 与用户来回**(那才是 LLM 该做的)。
582
+ **有意的 LLM 义务残留**:prompt 第 4 步要求「改完再跑 `--viewers` 复核」——它不新增
583
+ 命令面,且有硬 gate 兜底(prepare/start 的集合校验不过就拒绝启动),故保留。
578
584
  **UI 通道按 mode 分级(2026-09-25 实测)**:dialog(`select`/`confirm`/`input`/`editor`)
579
585
  全模式可用(RPC/web 走请求-响应子协议、阻塞等用户;不带 `timeout` 即不倒计时,
580
- 暂停点成立);`notify` 全模式可用(TUI = showStatus 行;pi-web = **追加进聊天流的
581
- 常驻行**,非瞬时提示);**`setEditorText` 仅 TUI**——pi-web 忽略(`pi-web/static/app.js`
582
- 注释「set_editor_text … ignored」+ SDK `ui-context.ts` 里是空实现)。⇒ **交付观看命令
583
- 必须有 notify 兜底**(预填只是增强,不能当唯一出口);需要分级时用 `ctx.mode`。
584
- 但 pi-web 实测**关掉 notify 弹窗即消失** ⇒ 观看命令还写一条 `pi.sendMessage`
585
- (`customType: multi-viewers`、`display: true`)进消息流:持久可回滚复制,
586
- 代价 = 参与 LLM 上下文的一行(用户 2026-09-25 要求「message 流中也能显示」)。
587
- 被否决:A(handler 里 `sendUserMessage` 触发 LLM 回合改 spec——时序不可控)、
588
- D(拆两条命令——把门禁成本转嫁用户;B1 变体/第三种即现形态)。
586
+ 暂停点成立);`notify` 全模式可用(TUI = showStatus 行;pi-web 会关闭即消失);
587
+ **`setEditorText` 仅 TUI**——pi-web 忽略(`pi-web/static/app.js` 注释
588
+ 「set_editor_text … ignored」+ SDK `ui-context.ts` 里是空实现)。⇒ 交付观看命令
589
+ 不能只靠一个通道,**四个通道各司其职**:
590
+ | 通道 | 作用域 | 上下文成本 | 角色 |
591
+ |---|---|---|---|
592
+ | `setEditorText` 预填 | 仅 TUI | 0 | TUI 便利(能直接回车跑) |
593
+ | `notify` | 全模式 | 0 | 即时反馈(pi-web 关掉弹窗即消失) |
594
+ | `pi.sendMessage`(custom_message) | 全模式 | ~百 token(主 session)+ 随 fork 进每场分析 | 持久留痕(pi-web 渲染为折叠块) |
595
+ | `ctx.ui.setWidget` | 全模式 | 0(纯 UI) | 运行期常驻可见(一眼看到、不需点击) |
596
+ 三条注记:① `sendMessage` 的 custom_message **会随 fork 进每场分析各视角的上下文**
597
+ (fork 源在首唤由主 session 条目构建,不做类型过滤)——~2 行/场,有界;不为它加
598
+ 过滤(那会让构造层获得扩展类型知识,跨层耦合换几行噪音,不配)。② widget 是
599
+ **运行期**状态(fire-and-forget UI,非会话条目;reload/重启后不恢复——持久记录靠
600
+ custom_message)。③ 零上下文留痕档确实存在(`pi.appendEntry` + `registerEntryRenderer`,
601
+ 明确不进 LLM 上下文),但其渲染器是 TUI 组件、pi-web 无渲染路径 ⇒
602
+ **可见 ∩ 零上下文 = 空集**,跨模式成本不可归零,接受现值。需要分级时用 `ctx.mode`。
589
603
 
590
604
  ### 被否决方案(含重估触发条件)
591
605
 
@@ -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` 相关代码),运行时零变化。**
@@ -11,6 +11,9 @@
11
11
  真实缺陷(见下)。
12
12
  2. **决策溯源**:修复批次(commit)常引用报告中的编号(如"P0/P1/P2"),
13
13
  保留原文才能核对当时的事实与推理。
14
+
15
+ 归档动作的**机械部分**由 `scripts/archive-result.sh` 做(命名、正文逐字复制+字节校验、
16
+ 存档头骨架、索引行、删仓库根松散副本)——判断部分(主题/问题/commit)仍由人写。
14
17
  3. **方法论的样本**:报告本身演示了"观察句 vs 机制句"的证据强度要求
15
18
  (`docs/test-methodology.md` 方法 16)——其中也有几处机制句在讨论中被
16
19
  交叉复核推翻,是活教材。
@@ -34,6 +37,7 @@
34
37
  | `2026-09-14-e2e25-doc-drift-review.md` | 文档 vs 代码一致性(文档漂移)+ ctx_search 的**自然使用**观察 | **三处文档仍写"兜底 mv-* 并警告"而代码是无兜底、未匹配报错**(行为语义相反);README 缺 `--extension-policy`;报告字段列表过期;4 条缺失项;**根因 = 对实现的复述**(治本:引事实源不复制);自然使用观察:`ctx_search` **0 次**、historian 0 次 | `ba204ef` |
35
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 进仓库) |
36
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` |
37
41
 
38
42
  ## 环境口径(读报告时的背景)
39
43
 
@@ -16,11 +16,12 @@
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)`,且随 fork 进入每场分析上下文
24
+ * ④ `ctx.ui.setWidget`——**运行期常驻可见**(一眼看到、不需点击;MC 待办用的同一通道)
24
25
  */
25
26
 
26
27
  import {
@@ -91,7 +92,8 @@ export default function register(pi: any) {
91
92
  return;
92
93
  }
93
94
 
94
- // ④ 观看命令:三个出口(见文件头)——预填(仅 TUI)+ notify(即时)+ 消息流(持久)
95
+ // ④ 观看命令:四个通道各司其职(见文件头与决策 22)——预填(TUI)/ notify(即时)/
96
+ // custom_message(留痕)/ widget(常驻可见)
95
97
  ctx.ui.setEditorText(watch);
96
98
  // 消息流里留一条持久记录:用户实测 pi-web 的 notify 会随弹窗关闭而消失,
97
99
  // 关了窗口就再也找不到这行命令。custom_message 进会话(**参与 LLM 上下文**,
@@ -102,6 +104,15 @@ export default function register(pi: any) {
102
104
  content: `多视角分析已启动:${dir}\n观看命令(复制执行,不进 LLM):\n${watch}`,
103
105
  display: true,
104
106
  });
107
+ // 常驻面板:pi-web 实测把 custom_message 渲染成折叠的 `multi-viewers (click to expand)`,
108
+ // 且历史条目未必实时刷新 → 观看命令还需要一条**一眼可见、不需点击**的常驻出口。
109
+ // setWidget 正是这个语义(pi-web 注释:"Persistent widget panel … Not a popup";
110
+ // 主 pi 的 magic-context 待办面板用的就是它)。
111
+ ctx.ui.setWidget("multi-viewers", [
112
+ `多视角分析进行中:${dir}`,
113
+ `观看(复制执行,不进 LLM):${watch}`,
114
+ `插话 /multi-viewers-say <文本> 收尾 /multi-viewers-finish`,
115
+ ]);
105
116
  ctx.ui.notify(
106
117
  `分析已启动:${dir}\n` +
107
118
  "观看(复制执行;TUI 下已预填进输入框):\n" +
@@ -167,7 +178,7 @@ export default function register(pi: any) {
167
178
  const ok = await ctx.ui.confirm(
168
179
  "确认收尾?",
169
180
  "将清理分析目录;结果会保存到 `<分析目录>-result.md`," +
170
- "清理时还会打印一次分析报告。",
181
+ "清理时还会打印并落盘一份报告(<分析目录>-report.txt)。",
171
182
  );
172
183
  if (!ok) {
173
184
  ctx.ui.notify("已取消收尾(分析目录保留)。", "info");
@@ -178,6 +189,8 @@ export default function register(pi: any) {
178
189
  ctx.ui.notify(`收尾失败:\n${clean.output}`, "error");
179
190
  return;
180
191
  }
192
+ // 收尾成功 → 清掉常驻面板(否则留下一行指向已删除目录的观看命令)。
193
+ ctx.ui.setWidget("multi-viewers", undefined);
181
194
  ctx.ui.notify(
182
195
  `${clean.output}\n\n要摘要就在对话里说一声(主 pi 读该 result.md 即可)。`,
183
196
  "success",
package/meeting_fs.py CHANGED
@@ -47,6 +47,17 @@ def result_path(base):
47
47
  """
48
48
  return f"{base}-{RESULT_MD}"
49
49
 
50
+
51
+ def report_path(base):
52
+ """分析报告的固定落盘位(`<分析目录>-report.txt`,与 result.md 同级)。
53
+
54
+ 报告原本只在 `--cleanup` 时往终端打一次,目录一删就再也拿不到——复盘
55
+ (时长口径、配额、provider 失败这类)时无据可查(用户 2026-09-25 复盘
56
+ 回算不出实际时长,正是此缺口)。观测数字应当可复查,故随清理落盘一份。
57
+ `--report` 命令本身仍不持久化(冷路径视图,三个出口共享同一实现)。
58
+ """
59
+ return f"{base}-report.txt"
60
+
50
61
  # 协议参数默认值(gen_protocol 固化进 protocol.json)——**唯一声明点**:
51
62
  # CLI default、engine 签名默认、observability 的兜底读取都引用这里
52
63
  # (此前 10/7 在三处各写一遍,改一处不改另一处就会漂移)。
package/mv_cli.py CHANGED
@@ -54,6 +54,7 @@ USAGE = f"""用法:
54
54
  {PROG} --view [dir] [--since <ref>]
55
55
  {PROG} --say [dir] "<文本>"
56
56
  {PROG} --viewers # 列出并校验当前项目的 viewers/(只读;建视角时用)
57
+ {PROG} --set-viewer <名字> # 新建一个视角文件(正文从 stdin 读;只新建不覆盖)
57
58
 
58
59
  消费命令的 <dir> 可省略(自动发现本 session 当前分析——按 cwd 下
59
60
  mv-<PI_SESSION_ID>-* 最新;无匹配则报错要求显式传目录)
@@ -153,9 +154,6 @@ def cmd_viewers(args):
153
154
  目录**(消费命令的目录自动发现对此不适用——它找的是 mv-<sid>-*)。判据全部
154
155
  复用 `spec_gen` 的单一实现(列举 `list_agent_md` / 名字 `check_agent_name` /
155
156
  集合 `viewer_set_error`),这一层不另写一套规则。
156
-
157
- 数量不足(<2)在这里是**提示**不是错误:建 1 个是合法的中间状态,只有启动
158
- 一次分析时才要求 ≥2(那条判据仍由 `viewer_set_error` 独占)。
159
157
  """
160
158
  if args:
161
159
  fail(f"未知参数: {' '.join(args)}(--viewers 不接受参数——只检查项目 cwd 的 viewers/)")
@@ -163,6 +161,17 @@ def cmd_viewers(args):
163
161
  if not os.path.isdir(vdir):
164
162
  fail(f"未找到 {vdir}——视角文件放在项目 cwd 的 viewers/<视角名>.md"
165
163
  f"(文件名即视角名;可跑 /multi-viewers-setup 交互式建立)")
164
+ return _validate_and_print_viewers(vdir)
165
+
166
+
167
+ def _validate_and_print_viewers(vdir):
168
+ """列出 + 校验 + 打印(`--viewers` 与 `--set-viewer` 的**同一实现**)。
169
+
170
+ 判据全部复用 `spec_gen` 的单一实现(列举 `list_agent_md` / 名字
171
+ `check_agent_name` / 集合 `viewer_set_error`)——这一层不另写规则。
172
+ 数量不足(<2)是**提示**不是错误:建 1 个是合法中间状态(只有启动一次
173
+ 分析才要求 ≥2,那条判据由 `viewer_set_error` 独占)。
174
+ """
166
175
  names, _briefs, empty = spec_gen._discover_viewers(vdir)
167
176
  if not names:
168
177
  fail(f"{vdir} 下没有 *.md——文件名即视角名(如 viewers/效率.md)")
@@ -177,11 +186,9 @@ def cmd_viewers(args):
177
186
  notes.append("空:没有视角内容")
178
187
  suffix = f"({';'.join(notes)})" if notes else ""
179
188
  print(f" {n}.md{suffix}")
180
- err = spec_gen.validate_participants(names)
189
+ err = spec_gen._viewer_set_errors(names, empty)
181
190
  if err:
182
191
  fail_verbatim(err)
183
- if empty:
184
- fail_verbatim(spec_gen.viewer_set_error(names, empty))
185
192
  gap = spec_gen.viewers_count_gap(names)
186
193
  if gap:
187
194
  print(f" 校验:{gap}(建 1 个是合法的中间状态)")
@@ -190,6 +197,49 @@ def cmd_viewers(args):
190
197
  return 0
191
198
 
192
199
 
200
+ def cmd_set_viewer(args):
201
+ """新建一个视角文件:`--set-viewer <名字>`,正文**从 stdin 读**。
202
+
203
+ 为什么是命令而不是"让 LLM 直接写文件":文件名即视角名,于是**命名规则 /
204
+ 不覆盖已有 / 空正文拒绝 / 写完校验并回显**这四条本来只能写在 prompt 里当
205
+ 纪律(LLM 会漏),现在由机制保证(判据复用 `spec_gen` 单一实现)。LLM/人
206
+ 只负责**内容**——那是它该做的部分。不提供 `--force`:本命令语义 = 只新建,
207
+ 改已有视角请直接编辑文件。
208
+ """
209
+ if len(args) != 1:
210
+ fail("用法: --set-viewer <名字>(正文从 stdin 读;如 "
211
+ "`mv.sh --set-viewer 效率 <<'EOF' … EOF`)")
212
+ name = args[0]
213
+ err = spec_gen.check_agent_name(name)
214
+ if err:
215
+ fail(f"非法视角名({err}):{name}")
216
+ vdir = os.path.join(os.getcwd(), "viewers")
217
+ target = os.path.join(vdir, f"{name}.md")
218
+ # lexists(不是 exists):悬空符号链接在 exists 下为假 → 会被"覆盖"写入,
219
+ # 违背"绝不覆盖"的承诺(悬空链接是这条承诺目前的唯一破口)。
220
+ if os.path.lexists(target):
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/ 后再建新视角——本次未写入任何文件)")
231
+ body = sys.stdin.read().strip()
232
+ if not body:
233
+ fail(f"视角内容为空({name})——正文从 stdin 传入;空视角没有 lenses,"
234
+ f"分析会退化成同名随机视角")
235
+ os.makedirs(vdir, exist_ok=True)
236
+ with open(target, "w", encoding="utf-8") as f:
237
+ f.write(body + "\n")
238
+ print(f"[set-viewer] 已写入 {target}\n")
239
+ print(body + "\n")
240
+ return _validate_and_print_viewers(vdir)
241
+
242
+
193
243
  def cmd_status(args):
194
244
  d = _dir_only("--status", args)
195
245
  return _call([PYTHON, START_DISCUSSION, "--dir", d, "--status"])
@@ -418,6 +468,8 @@ def main(argv=None):
418
468
  return cmd_start(rest[0], rest[1:])
419
469
  if cmd == "--viewers":
420
470
  return cmd_viewers(rest)
471
+ if cmd == "--set-viewer":
472
+ return cmd_set_viewer(rest)
421
473
  if cmd == "--status":
422
474
  return cmd_status(rest)
423
475
  if cmd == "--report":
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.1",
3
+ "version": "0.8.3",
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,
@@ -15,7 +15,6 @@ description: 建立多视角分析的 viewers/ 视角文件(交互式:先建
15
15
  1. <名字> —— <一行镜头>(与其它候选怎样互补/对立)
16
16
 
17
17
  - 这一步**只提议**:不建文件、不改任何东西
18
- - 名字:中文、短(≤32 字符)、不含空白与路径分隔符、**不要用 `human`**(保留名)
19
18
  - 建几个**由用户决定**(只建 1 个也行)
20
19
 
21
20
  **然后结束本回合,等用户输入。**
@@ -25,31 +24,27 @@ description: 建立多视角分析的 viewers/ 视角文件(交互式:先建
25
24
  用户会告诉你建哪几个(可能只有一个、也可能点名不在候选里的)。**以用户输入为准**;
26
25
  若某个视角该用什么镜头没说清 → **问一句**,不要替他定。
27
26
 
28
- ## 第 3 步:按用户输入建文件(只新建)
27
+ ## 第 3 步:用命令建文件(内容经 stdin 传给命令,不要直接写文件)
29
28
 
30
- 写之前**先读 `README.md` 的「视角文件写什么」节**(唯一事实源:三个要点 + 正误对照 +
31
- 命名规则)。要点:
29
+ 先读 `README.md` 的「视角文件写什么」节——那是唯一事实源(三个要点、正误对照、命名规范)。
32
30
 
33
- - 纯内容、无 frontmatter、无标题——**文件名就是全部元数据**
34
- - 必含三条:① **单一 lenses**(所有观点必须从该视角出发)② **不越界**(其它视角由
35
- 别的参与者负责;**不要**列举是哪几个——参与者会变,列举会过期)③ **交锋义务**
36
- (对其它视角的观点可认同或反驳,但要用本视角的论据)
37
- - **只写视角本身**:身份("你是 X")、参与者名单、消息格式、独立参与者纪律都由脚本
38
- 生成,**不要写进文件**
39
-
40
- 写完跑一次只读检查(会列出视角并用代码判据校验名字与空正文):
31
+ 每个视角调用一次(正文经 stdin;**命名合法性、不覆盖已有、空正文拒绝、写完校验并
32
+ 回显**都由命令保证,不需要你另外检查):
41
33
 
42
34
  ```bash
43
- ~/.pi/agent/npm/node_modules/pi-multi-viewers/scripts/mv.sh --viewers
35
+ ~/.pi/agent/npm/node_modules/pi-multi-viewers/scripts/mv.sh --set-viewer <视角名> <<'EOF'
36
+ <视角正文>
37
+ EOF
44
38
  ```
45
39
 
46
- **已有同名文件 → 不覆盖**:告诉用户"该视角已存在",停下等他决定(改名,或他明确
47
- 要求改动)。
40
+ 命令会回显**写入路径 + 正文全文 + 校验结果**——那就是第 4 步要展示的东西。
41
+ 若命令报"视角已存在":告诉用户,停下等他决定(改名,或他明确要求改动)。
48
42
 
49
43
  ## 第 4 步:展示,等审阅
50
44
 
51
- 把每个新建文件的**路径 + 全文**展示给用户,请他提修改意见或确认:
45
+ 把命令的回显(路径 + 全文 + 校验)给用户看,请他提修改意见或确认:
52
46
 
53
- - 有意见 → 改 → **再展示**(反复直到他确认)
47
+ - 有意见 → **直接编辑该文件**(`viewers/<名字>.md`)→ 跑一次
48
+ `~/.pi/agent/npm/node_modules/pi-multi-viewers/scripts/mv.sh --viewers` 复核 → 再展示(反复直到他确认)
54
49
  - 确认后一行交接:视角已建好(启动一次分析至少需要 2 个视角),接下来
55
50
  `/multi-viewers "<主题>"` 即可
@@ -0,0 +1,101 @@
1
+ #!/usr/bin/env bash
2
+ # archive-result.sh —— 把一场分析的 result.md 归档进 docs/reviews/
3
+ #
4
+ # 为什么是脚本:每场分析后都要做同一串机械动作,靠人/LLM 记(实测会漏——
5
+ # 最容易漏的是索引行)。这里把**机械部分**固化成命令:
6
+ # · 命名(<日期>-<slug>.md)
7
+ # · 正文**逐字复制**并做字节校验(存档不改原文)
8
+ # · 存档头骨架(TODO 标记留在文件里,判断内容仍由人写)
9
+ # · 索引表加一行(插在最后一行存档之后)
10
+ # · 删掉仓库根的松散副本
11
+ # **判断部分不代劳**:写什么主题/抓到什么问题/落地哪个 commit,是人的判断。
12
+ #
13
+ # 用法:
14
+ # scripts/archive-result.sh <result.md> --slug <slug> [--date YYYY-MM-DD] [--dry-run]
15
+ # 例:
16
+ # scripts/archive-result.sh mv-mv-main-20260925-105614-result.md \
17
+ # --slug multi-viewers-postfix-review
18
+ #
19
+ # 退出码:0 成功(或 dry-run);1 参数/环境错;2 目标已存在(不覆盖)
20
+
21
+ set -u
22
+ HERE="$(cd "$(dirname "$0")/.." && pwd)"
23
+ REVIEWS="$HERE/docs/reviews"
24
+ INDEX="$REVIEWS/README.md"
25
+
26
+ SRC=""; SLUG=""; DATE="$(date +%F)"; DRY=0
27
+ while [ $# -gt 0 ]; do
28
+ case "$1" in
29
+ --slug) SLUG="${2:-}"; shift 2 ;;
30
+ --date) DATE="${2:-}"; shift 2 ;;
31
+ --dry-run) DRY=1; shift ;;
32
+ -h|--help) sed -n '2,20p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
33
+ -*) echo "错误: 未知参数 $1" >&2; exit 1 ;;
34
+ *) SRC="$1"; shift ;;
35
+ esac
36
+ done
37
+
38
+ [ -n "$SRC" ] || { echo "错误: 缺少 result.md 路径(--help 看用法)" >&2; exit 1; }
39
+ [ -n "$SLUG" ] || { echo "错误: 缺少 --slug <slug>" >&2; exit 1; }
40
+ [ -f "$SRC" ] || { echo "错误: 文件不存在: $SRC" >&2; exit 1; }
41
+ case "$SLUG" in *[!a-zA-Z0-9._-]*) echo "错误: slug 只允许字母/数字/._-(当前: $SLUG)" >&2; exit 1 ;; esac
42
+ [ -f "$INDEX" ] || { echo "错误: 找不到索引 $INDEX" >&2; exit 1; }
43
+
44
+ DEST="$REVIEWS/$DATE-$SLUG.md"
45
+ if [ -e "$DEST" ]; then
46
+ echo "错误: 目标已存在(不覆盖): $DEST" >&2; exit 2
47
+ fi
48
+
49
+ if [ "$DRY" -eq 1 ]; then
50
+ echo "[dry-run] 将归档: $SRC → $DEST"
51
+ echo "[dry-run] 正文 $(wc -c < "$SRC") 字节,逐字复制"
52
+ echo "[dry-run] 将写存档头骨架(含 TODO)+ 在 $INDEX 加一行 + 删 $SRC"
53
+ exit 0
54
+ fi
55
+
56
+ # 1) 存档头骨架 + 正文(逐字)
57
+ {
58
+ cat <<EOF
59
+ <!-- 存档:docs/reviews/$DATE-$SLUG.md
60
+ 来源:一次真实多视角分析的 result.md 原文(未删改,仅加本头与下方说明)。
61
+ 分析场次目录已随 cleanup 删除;文中消息编号不可再核验,仅作溯源线索
62
+ (与代码注释引用约定一致:行为以自描述为准)。 -->
63
+
64
+ # 存档说明
65
+
66
+ - **主题**:TODO(一句话)
67
+ - **场次**:\`TODO(分析目录名)\`(视角 / 档位 / 收敛方式)
68
+ - **判定/发现**:TODO
69
+ - **落地**:TODO(commit 或"未落地")
70
+ - **备注**:TODO(可不填则删掉本行)
71
+
72
+ EOF
73
+ cat "$SRC"
74
+ } > "$DEST"
75
+
76
+ # 2) 逐字校验(正文必须与源**逐字相同**——存档不改原文)
77
+ if ! cmp -s <(tail -c "$(wc -c < "$SRC")" "$DEST") "$SRC"; then
78
+ echo "错误: 正文校验失败(存档与源不一致)——已保留 $DEST 供排查" >&2
79
+ exit 1
80
+ fi
81
+ echo "[archive] 正文逐字校验通过($(wc -c < "$SRC") 字节)"
82
+
83
+ # 3) 索引加一行(紧跟最后一行存档;TODO 留给写索引的人)
84
+ python3 - "$INDEX" "$DATE-$SLUG.md" <<'PY'
85
+ import sys
86
+ idx_path, fname = sys.argv[1], sys.argv[2]
87
+ lines = open(idx_path, encoding="utf-8").read().splitlines(True)
88
+ hits = [i for i, l in enumerate(lines) if l.startswith("| `2026-")]
89
+ if not hits:
90
+ print("错误: 索引里找不到存档行(| `2026-…)", file=sys.stderr)
91
+ sys.exit(1)
92
+ row = f"| `{fname}` | TODO 主题 | TODO 抓到的问题 | TODO 落地 commit |\n"
93
+ lines.insert(hits[-1] + 1, row)
94
+ open(idx_path, "w", encoding="utf-8").write("".join(lines))
95
+ print(f"[archive] 索引已加一行({idx_path};TODO 待补)")
96
+ PY
97
+ [ $? -eq 0 ] || exit 1
98
+
99
+ # 4) 删仓库根松散副本(**只删源文件**,且必须在写成功后)
100
+ rm -f "$SRC"
101
+ echo "[archive] 已写 $DEST;源副本已删;下一步:补 $DEST 与索引行里的 TODO"
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,39 +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
480
  try:
454
- for line in build_report(base):
455
- print(line)
456
- except Exception as e: # noqa: BLE001(兜底不吞:打印)
457
- print(f"[cleanup] 报告生成失败(不影响清理): {e!r}")
458
- shutil.rmtree(base)
459
- print(f"[cleanup] 已删除目录 {base}(含 pi-sessions)")
481
+ # 报告(**删目录前最后一次可读**——目录删后 --report 不可用)。
482
+ # 报告是附加信息、清理是主职责:报告生成失败**不阻断**清理
483
+ # (fail-open 只在这一层兜底——build_report 内部各段已各自 fail-open)。
484
+ _print_best_effort("[cleanup] —— 本次分析报告(删除目录前最后一次可读)——")
485
+ lines = None
486
+ try:
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)")
460
506
 
461
507
 
462
508