pi-multi-viewers 0.1.0 → 0.2.1

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
@@ -18,7 +18,9 @@ meeting_fs.py git/文件层
18
18
  meeting_engine.py 【唯一状态机】+ 协议信号 + responder 注入
19
19
  meeting_loop.py Pi 薄壳:首唤生成 fork 源 + `--session` 打开 → 存 sid → `--session-id` 续接
20
20
  fake_agent.py 测试薄壳:responder = 随机决策
21
- start_discussion.py 环境生成/启动/清理(viewers 发现 / spec 解析 / fork 源)
21
+ start_discussion.py 组合层:CLI 分发 + 环境创建/启动/清理(_resolve_spec / setup_environment)
22
+ spec_gen.py spec 生成层(question/骨架/viewers 校验与快照/agent 定义 + pi 环境探测)
23
+ observability.py 观测层(check_status / --report / --wait / loop 存活检测)
22
24
  human_viewer.py 【human 通道】只读展示(增量/--follow/游标)
23
25
  human_sayer.py 【human 通道】插话命令(单次/stdin/交互 -i)
24
26
  scripts/mv.sh wrapper(prepare/start/status/wait/cleanup/view/say)
@@ -174,7 +176,7 @@ loop、状态从 git 共享事实推导、单一事实源 = protocol.json、无
174
176
  extension × 1(multi-viewers-say 插话:零 LLM,直接 spawn human_sayer.py;
175
177
  目录发现 = `<cwd>/mv-<sessionId>-*` 最新,兜底 `mv-*`(排除
176
178
  `mv-spec-*`)并警告)
177
- + wrapper。**npm 已发布 0.1.0(2026-09-11)**。
179
+ + wrapper。**npm 已发布 0.2.1(2026-09-11)**。
178
180
  - **开发机安装(两步,缺一不可;2026-09-10 实测)**:
179
181
  ① `pi install /root/pi-multi-viewers`——**注册包**(写
180
182
  `~/.pi/agent/settings.json` 的 `packages` 数组);pi 不是"扫 node_modules
package/README.md CHANGED
@@ -89,6 +89,8 @@ scripts/mv.sh --start <spec目录> # 启动(自动挂载主 sessi
89
89
 
90
90
  # 观看:--start 会输出可直接执行的 !! 流式观看命令(复制执行)
91
91
  scripts/mv.sh --view <dir> # 或一次性增量查看(主 pi 记录 HEAD 作下轮 --since)
92
+ # --follow 会打印【状态】(meeting/all-freezing/round-robin/concluded)
93
+ # 与【进度】(meeting 消耗/上限 | freezing 集合 | rr → 下一位)
92
94
 
93
95
  # 插话 / 状态 / 收尾
94
96
  scripts/mv.sh --say <dir> "<文本>" # 插话(命令行形态;pi 内用 /multi-viewers-say)
@@ -132,14 +134,19 @@ frontmatter 字段、写文件路径、独立参与者纪律。
132
134
  ## 架构
133
135
 
134
136
  ```
135
- meeting_core.py 纯判定(冻结级联/RR/聚合)
136
- meeting_fs.py git 层
137
- meeting_engine.py 唯一状态机(六分支)
138
- meeting_loop.py Pi 薄壳(fork 首唤 + --session-id 续接 + 视角注入)
139
- start_discussion.py 环境生成/启动/状态/清理
140
- human_viewer/sayer human 插话通道
137
+ meeting_core.py 纯判定(冻结级联/RR/聚合 + 状态机词汇常量)
138
+ meeting_fs.py git 层 + fork 源生成/裁剪 + 协议与产物常量
139
+ meeting_engine.py 唯一状态机(六分支)
140
+ meeting_loop.py Pi 薄壳(fork 首唤 + --session-id 续接 + 视角注入)
141
+ start_discussion.py 组合层:CLI 分发 + 环境创建/启动/清理
142
+ spec_gen.py spec 生成(question/骨架/viewers 快照/agent 定义 + pi 环境探测)
143
+ observability.py 观测(check_status / --report / --wait / loop 存活)
144
+ human_viewer/sayer human 插话通道
141
145
  ```
142
146
 
147
+ 依赖方向单向:`core ← fs ← engine ← loop`;`start_discussion` 组合
148
+ `spec_gen` / `observability`(两者只依赖底层,互不依赖、不反向依赖主文件)。
149
+
143
150
  ## 开发
144
151
 
145
152
  ```bash
package/docs/design.md CHANGED
@@ -100,7 +100,18 @@ compaction 的 `firstKeptEntryId` 起 + 其后的条目"——窗口内含 compa
100
100
  | `status-<agent>.json` | loop | `{"sessionID": ...}` | 流程(崩溃恢复) | 是(恢复用) | O(1) |
101
101
  | `pi-sessions/fork-src-*.jsonl` | pi | 文档化 session schema | fork 构建 + `--report` | 否(报告用) | O(MB) 全量 → **禁轮询** |
102
102
  | `result.md`(固定位) | resultWriter loop | 结论文档 | 人 | 是(收尾判据) | — |
103
- | `--report`(视图) | start_discussion | 文本行 | 人/主 pi | **否**(不得升级为验收 gate) | 冷路径一次性 |
103
+ | `--report`(视图) | observability | 文本行 | 人(**三个出口**,见下) | **否**(不得升级为验收 gate) | 冷路径一次性 |
104
+
105
+ **报告的三个出口**(同一 `build_report`,同一份内容):
106
+ 1. **`--follow` 结束**——viewer 在 done 分支自动附报告(`human_viewer._print_report`)。
107
+ 这是**用户通道自带**:用户执行 `!!` 命令就在结束时直接看到,**零 LLM 参与**
108
+ (此前只靠 prompt 要求主 pi"记得转述"——那是 LLM 依赖,会漏;机制化后
109
+ 用户必然看到)。
110
+ 2. **`--cleanup`**——删目录前最后一次可读(结果与 1 重复出现是刻意的:
111
+ 不同时点各看一次,且清理后现场已不存在)。
112
+ 3. **`--report`**——独立入口(中途查看 / 脚本消费)。
113
+ `--view --since`(主 pi 增量轮询通道)**不附报告**——它面向 LLM,
114
+ 输出进 context,报告对模型无用且占 token。
104
115
 
105
116
  ### 本轮边界(`mv.analysis-start`)
106
117
 
@@ -112,6 +123,15 @@ BOUNDARY_TYPE`)——**显式登记"历史(fork 携带)/ 本轮"的分界*
112
123
  推断都会漂移(切换叙事改措辞、时钟精度)。2026-09-11 实测:不带边界时报告
113
124
  把 717 条 fork 历史算成"本轮 367 次响应 / input 1.2M / cacheRead 136.6M"。
114
125
 
126
+ **viewer 进度行(三样观测面)**:`--follow` 每轮在【状态】之外打印
127
+ 【进度】行——`meeting <消耗>/<上限> · … | freezing <已冻结>/<总数>(名单)
128
+ | rr → <下一位>`。**状态名一律用协议术语**(`meeting`/`freezing`/`rr`;
129
+ 译成中文会引入第二套命名,"冻结"到底指 freezing 还是 all-freezing 说不清)。
130
+ 判定与 `--report` 共用 core/engine 单一实现(`meeting_speak_count` /
131
+ `frozen_agents` / `rr_next_speaker`),且共用调用方已读的 `msgs`——
132
+ **零新增 bare 读取**(实测改动前后同为 6 次 run 调用 + 1 次 cat-file 批读;
133
+ `rr` 位置仅在 RR 阶段按需调权威实现)。进度行**只在变化时打印**(避免刷屏)。
134
+
115
135
  **报告的打印位置**:`--report`(手动,任意时刻)+ `--cleanup` 前(自动,
116
136
  删目录前最后一次可读——目录删后 `--report` 不可用)。cleanup 层对报告
117
137
  fail-open(报告失败不阻断清理,且**打印**失败原因不静默)。
@@ -224,7 +244,45 @@ commit 是溯源记录、本节是长期引用点——不并存两份权威值
224
244
  `mv-spec-*`)并**警告降级**
225
245
  (宁可提示也不静默插错分析)。观看仍用 `!!` 流式(命令 API 无原生流式
226
246
  通道,bash 流式是平台原生能力)。
227
- 10. **git 守卫范围 = 从讨论 workdir 发起的操作**(`GIT_CEILING_DIRECTORIES`
247
+ 10. **question.md 的主题行措辞 = `# 分析主题:`(唯一)**:生成端
248
+ (`gen_question` / `gen_spec_skeleton`)、模板(`spec-readme`)、prompt、
249
+ 消费端(`setup_environment` 提取 `protocol.topic`)四处同一措辞;
250
+ 消费端**只认它**,旧措辞 spec → fail-fast(明确报错,不静默退化)。
251
+ 曾出现双轨(生成产 `# 讨论主题:`、消费端写兼容循环兜两种)——那
252
+ 正是"补丁掩盖设计缺陷"的形态(不改生产、只兜消费端)。
253
+ 11. **stall 接管 = 心跳式软仲裁(非互斥)**:非 rw 在无进展超时时接管收尾,
254
+ 先 pull 重检共享事实(concluded / result.md)→ 写接管声明 commit
255
+ (**只为推进 HEAD**,使对方 `_stall_elapsed` 归零而退出该分支)→
256
+ finalize。毫秒级同轮窗口存在(双方都可能 finalize),但**不是死锁**:
257
+ 靠 push 容错 + 下轮 concluded 退出兜底,产物始终唯一。声明只降低并发
258
+ 概率,**不构成互斥保证**(注释勿写"天然唯一")。实测(2026-09-11,
259
+ 381 测试中的 `TestStallTakeover`):a 进入接管 0.11s 内完成声明→收尾→
260
+ concluded,b 晚 1.4s 只见 concluded 即退出——正常时序下软仲裁生效。
261
+ 12. **`result.md` 的文件名与有效性阈值 = `meeting_fs.RESULT_MD` /
262
+ `RESULT_MD_MIN_BYTES`**:产品级核心产物,此前 8 处字面量分散在
263
+ engine/loop/viewer/start_discussion/fake_agent(`repo.git` 早已收归
264
+ fs 层,产物名却没有家)。文件叫什么、多大算有效——同一概念的两个
265
+ 数字住在一起。
266
+ 13. **分层 = core ← fs ← engine ← loop,`start_discussion` 是组合层**
267
+ (S2 拆分后):三项职责按"谁消费"拆到两个同层模块——
268
+ `spec_gen.py`(分析前的静态产物生成 + 为其服务的 pi 环境探测)、
269
+ `observability.py`(运行期只读观测);主文件保留 CLI 分发、环境创建、
270
+ 启动、清理与编排。**判据是职责而非行数**:spec 生成与观测各自 350–520
271
+ 行、概念内聚,再拆会制造碎片(import 网变复杂而收益递减)。
272
+ 两个子模块只依赖底层(core/fs/engine),互不依赖、不反向依赖主文件;
273
+ 主文件 re-export 子模块公开符号,保证 `from start_discussion import X`
274
+ 的既有调用方(tests/wrapper)零改动。
275
+ **拆分暴露的纪律**:`mock.patch` 必须打在**符号定义处**(拆前
276
+ `start_discussion.check_status` 与定义处同址,拆后 mock re-export
277
+ 不生效——本轮 4 处测试因此假绿/失败,已改到 `spec_gen` /
278
+ `observability`)。
279
+ 14. **报告附在 `--follow` 输出末尾(机制化,不依赖 LLM)**:`--follow` 是
280
+ 用户直接执行的通道(`!!`),done 时自动打印报告——用户零操作看到运行
281
+ 事实。**为什么不能只靠 prompt**:让主 pi"记得跑 `--report` 并转述"是
282
+ 流程依赖 LLM(会漏、不可验收),正是本项目一贯要消除的形态;报告既然
283
+ 是给用户的,就该长在用户直接看的通道上。`--view --since` 不附(主 pi
284
+ 通道,进 context 且对模型无用)。
285
+ 15. **git 守卫范围 = 从讨论 workdir 发起的操作**(`GIT_CEILING_DIRECTORIES`
228
286
  注入于 spawn);主项目仓库不在守卫范围(agent 的 cwd 就是主项目,其
229
287
  约束归指令层 + 主项目 `.gitignore`)。要拦主仓库需换机制类(沙箱/钩子),
230
288
  经评估收益不支撑扩面。
@@ -256,3 +256,26 @@ python 时被"顺手改写"为含斜杠则不拼——**移植不是重写**:
256
256
  3. 删除函数区间用"下一个函数名"作终点前,先 `grep` 确认区间内没有别的定义;
257
257
  4. 提交前跑**真实调用路径**:`python3 -c "import <mod>"`(抓未定义/未导入名)
258
258
  + 相关测试。两次失误都是真实路径抓到的——静态阅读与"看起来对"都不够。
259
+
260
+ ### 19. 验证分层:测试挂在"代码改动"上,不挂在"发版"上(2026-09-11 发版冗余)
261
+
262
+ **背景**:发版时顺手跑全量测试(382 测试 / 5 分钟)看起来"更稳妥",实为冗余
263
+ ——发版动作本身只改 `package.json` 的版本号与文档文案,**零代码改动**;而区间
264
+ 内的代码改动在其**实现提交时**已经跑过全量测试。
265
+
266
+ **判据**:「这段区间内有没有**未被验证**的代码改动?」——有则跑,没有则不跑。
267
+ 不是「是否在发版」「是否要推送」「是否重要」。
268
+
269
+ **分层**:
270
+
271
+ | 时点 | 验证内容 |
272
+ |---|---|
273
+ | 实现提交 | **全量测试**(代码改了;这是唯一的测试时机) |
274
+ | 发版 | **打包验证**:`npm pack --dry-run` 核清单 + 真实安装后跑 CLI 冒烟 |
275
+ | 推送 | 无需测试(提交时已验)——只核 `git status` 干净 + 本地/远程一致 |
276
+
277
+ **例外**(唯一合理场景):发版区间累积了多个提交、且距上次全量测试较久
278
+ (如跨天),此时补跑一次合理——因为"区间内改动已被验证"这个前提可能不成立。
279
+
280
+ **成本视角**:冗余测试不只是耗时——它稀释了"全绿"的信号价值(每次发版都全绿
281
+ 时,人不再区分"这次真的验了代码"与"这次只 bump 了版本号")。
package/fake_agent.py CHANGED
@@ -21,6 +21,8 @@ import time
21
21
 
22
22
  sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
23
23
 
24
+ import meeting_core
25
+ import meeting_fs
24
26
  from meeting_fs import next_msg_id, write_message
25
27
  from meeting_engine import agent_loop
26
28
 
@@ -65,7 +67,7 @@ def make_responder(min_sleep, max_sleep, crash_rate):
65
67
  # --- 内容决定(LLM 的判断)---
66
68
  if finalizing:
67
69
  # 收尾指令:resultWriter 写 result.md(双面约束:此时才允许写)
68
- result_path = os.path.join(workdir, "result.md")
70
+ result_path = os.path.join(workdir, meeting_fs.RESULT_MD)
69
71
  # 内容 >50 字节(_result_md_valid 阈值,审核#3)——否则恒走
70
72
  # loop 兜底代写,正常收尾路径零验证(fake 与真实 responder 语义不一致)
71
73
  # 文案三分支(review4 L12):stall 不误标"配额耗尽"
@@ -81,13 +83,14 @@ def make_responder(min_sleep, max_sleep, crash_rate):
81
83
  return True
82
84
  if retry:
83
85
  # 被重试(上次没产出):无静默铁律,强制表态
84
- decision = "freezing" if not rr_turn else "pass"
86
+ decision = meeting_core.T_FREEZING if not rr_turn else meeting_core.T_PASS
85
87
  elif rr_turn:
86
88
  # RR 阶段:单向流,只写 pass(无异议回退,留待以后)
87
- decision = "pass"
89
+ decision = meeting_core.T_PASS
88
90
  else:
89
91
  # meeting 阶段:有内容 → message;无话可说 → freezing
90
- decision = "message" if random.random() < 0.65 else "freezing"
92
+ decision = meeting_core.T_MESSAGE if random.random() < 0.65 \
93
+ else meeting_core.T_FREEZING
91
94
 
92
95
  time.sleep(random.uniform(min_sleep, max_sleep))
93
96
  if random.random() < crash_rate:
@@ -116,5 +119,13 @@ if __name__ == "__main__":
116
119
  workdir, agent = sys.argv[1], sys.argv[2]
117
120
  responder = make_responder(float(sys.argv[3]), float(sys.argv[4]),
118
121
  float(sys.argv[5]))
122
+ # stall 超时:与生产(meeting_loop.__main__)同款——protocol 优先
123
+ # (装置对齐生产:此前 fake_agent 恒用默认 600s,stall 路径在测试中
124
+ # 永不可达——P2 的接管分支零覆盖正是这个原因)
125
+ stall_timeout = meeting_fs.DEFAULT_STALL_TIMEOUT
126
+ proto = meeting_fs.read_protocol(meeting_fs.bare_of_workdir(workdir))
127
+ if proto.get("stallTimeoutSeconds"):
128
+ stall_timeout = proto["stallTimeoutSeconds"]
119
129
  agent_loop(workdir, agent, responder,
120
- max_meeting=int(sys.argv[6]), max_rr=int(sys.argv[7]))
130
+ max_meeting=int(sys.argv[6]), max_rr=int(sys.argv[7]),
131
+ stall_timeout=stall_timeout)
package/human_viewer.py CHANGED
@@ -28,10 +28,17 @@ import os
28
28
  import sys
29
29
  import time
30
30
 
31
+ import meeting_core
31
32
  import meeting_fs
32
33
  from meeting_fs import (run_git, git_show, git_head, is_message_file,
33
34
  parse_log_nameonly, extract_body, parse_frontmatter)
34
- from meeting_engine import aggregate_mode
35
+ from meeting_core import (
36
+ aggregate_mode as core_aggregate_mode,
37
+ frozen_agents,
38
+ meeting_speak_count,
39
+ )
40
+ from meeting_engine import (each_agent_last, each_agent_messages,
41
+ rr_next_speaker)
35
42
 
36
43
 
37
44
  def participants_from_bare(bare):
@@ -94,23 +101,32 @@ def is_finished(bare, agents, mode=None):
94
101
  为什么落 viewer:它是唯一增量实现的持有者,也是观察端判据的家。
95
102
  """
96
103
  if mode is None:
97
- mode = aggregate_mode(bare, agents)
98
- if mode != "concluded":
104
+ # engine.each_agent_last(它把最后一条规范化成 {type, mode, next}
105
+ # ——缺字段补 None);直接传原始 frontmatter 会在 core 里 KeyError
106
+ # (core 用 v["mode"] 按契约取值,不猜缺字段)
107
+ mode = core_aggregate_mode(each_agent_last(bare, agents))
108
+ if mode != meeting_core.M_CONCLUDED:
99
109
  return False
100
- content = git_show(bare, "HEAD", "result.md")
101
- return bool(content) and len(content) > 50
110
+ content = git_show(bare, "HEAD", meeting_fs.RESULT_MD)
111
+ return bool(content) and len(content) > meeting_fs.RESULT_MD_MIN_BYTES
102
112
 
103
113
 
104
- def incremental(bare, agents, since):
114
+ def incremental(bare, agents, since, max_meeting=None):
105
115
  """单次增量读取。
106
116
 
107
- 返回: (mode, lines: list[str], head, done)
117
+ 返回: (mode, lines: list[str], head, done, progress)
108
118
  - mode: 当前聚合 mode(meeting/all-freezing/round-robin/concluded)
109
119
  - lines: 新消息展示行(旧→新)
110
120
  - head: 当前 HEAD
111
- - done: 分析是否已收尾(mode == concluded)
121
+ - done: 分析是否已收尾(concluded result.md 有效)
122
+ - progress: 进度行文本(三样观测面,见 progress_text)
123
+
124
+ **单次读取**:一次 each_agent_messages → 派生 mode 与全部三样观测面
125
+ (mode 判定、freezing 集合、meeting 配额),不重复读 bare。
112
126
  """
113
- mode = aggregate_mode(bare, agents)
127
+ msgs = each_agent_messages(bare, agents)
128
+ lasts = {a: (msgs[a][-1] if msgs[a] else None) for a in agents}
129
+ mode = core_aggregate_mode(lasts)
114
130
  lines = []
115
131
  for commit, path in new_messages(bare, since):
116
132
  content = git_show(bare, commit, path)
@@ -120,7 +136,43 @@ def incremental(bare, agents, since):
120
136
  if s:
121
137
  lines.append(s)
122
138
  head = git_head(bare)
123
- return mode, lines, head, is_finished(bare, agents, mode)
139
+ return (mode, lines, head, is_finished(bare, agents, mode),
140
+ progress_text(bare, agents, msgs, lasts, mode, max_meeting))
141
+
142
+
143
+ def progress_text(bare, agents, msgs, lasts, mode, max_meeting=None):
144
+ """进度行:三样观测面(**状态名一律用协议术语**,不翻译)。
145
+
146
+ 形态:`meeting 7/10 · 6/10 | freezing 2/3(性能、简单)| rr → 简单`
147
+
148
+ - `meeting`:配额消耗/上限(`meeting_core.meeting_speak_count` 口径
149
+ ——"mode:meeting 且 type:message"的发言轮;上限来自 protocol,
150
+ 调用方传入;未传则只显示消耗数)。
151
+ - `freezing`:已冻结数/总数(`meeting_core.frozen_agents`)。
152
+ - `rr`:RR 阶段轮到谁(**权威实现** `rr_next_speaker`;仅
153
+ round-robin 阶段出现)。
154
+
155
+ **为什么用协议术语而非中文**(用户 2026-09-11):状态名是协议的
156
+ 一等概念(`meeting`/`freezing`/`round-robin`),翻译成中文会引入
157
+ 第二套命名("冻结"到底指 freezing 还是 all-freezing?)——协议名
158
+ 在代码、消息 frontmatter、`--report`、viewer 里只有一个说法。
159
+
160
+ 判定全部复用 core/engine 的单一实现(与 `--report` 同一份),
161
+ 且共用调用方已读的 msgs——**零新增 bare 读取**;仅 rr 位置在
162
+ RR 阶段调权威实现(需要读 HEAD 的文件列表,属必要成本)。
163
+ """
164
+ types = {a: (lasts[a].get("type") if lasts[a] else None) for a in agents}
165
+ parts = []
166
+ counts = " · ".join(f"{meeting_speak_count(msgs, a)}"
167
+ + (f"/{max_meeting}" if max_meeting else "")
168
+ for a in agents)
169
+ parts.append(f"meeting {counts}")
170
+ frozen = frozen_agents(agents, types)
171
+ parts.append(f"freezing {len(frozen)}/{len(agents)}"
172
+ + (f"({'、'.join(frozen)})" if frozen else ""))
173
+ if mode == meeting_core.M_ROUND_ROBIN:
174
+ parts.append(f"rr → {rr_next_speaker(bare, agents) or '(未定)'}")
175
+ return " | ".join(parts)
124
176
 
125
177
 
126
178
  def _cursor_path(base):
@@ -150,15 +202,27 @@ def _write_cursor(base, ref):
150
202
  OBSERVER_POLL_INTERVAL = 2.0
151
203
 
152
204
 
153
- def follow(base, bare, agents, poll_interval=OBSERVER_POLL_INTERVAL):
154
- """--follow:循环展示(tail -f 式)直到分析结束。"""
205
+ def follow(base, bare, agents, max_meeting=None,
206
+ poll_interval=OBSERVER_POLL_INTERVAL):
207
+ """--follow:循环展示(tail -f 式)直到分析结束。
208
+
209
+ 状态名用协议术语(`【状态】meeting` / `all-freezing` / `round-robin`
210
+ / `concluded`);进度行见 progress_text。
211
+ """
155
212
  since = _read_cursor(base)
156
213
  last_mode = None
214
+ last_progress = None
157
215
  while True:
158
- mode, lines, head, done = incremental(bare, agents, since)
216
+ mode, lines, head, done, progress = incremental(
217
+ bare, agents, since, max_meeting)
159
218
  if mode != last_mode:
160
219
  print(f"【状态】{mode}", flush=True)
161
220
  last_mode = mode
221
+ # 进度行只在**变化时**打印(每轮都打会刷屏;冻结/配额/RR 位
222
+ # 置在两次消息之间本就不变)
223
+ if progress != last_progress:
224
+ print(f"【进度】{progress}", flush=True)
225
+ last_progress = progress
162
226
  for s in lines:
163
227
  print(s, flush=True)
164
228
  if head != since:
@@ -167,10 +231,37 @@ def follow(base, bare, agents, poll_interval=OBSERVER_POLL_INTERVAL):
167
231
  if done:
168
232
  print(f"【分析已结束】result.md: {result_path(base)}",
169
233
  flush=True)
234
+ _print_report(base)
170
235
  return
171
236
  time.sleep(poll_interval)
172
237
 
173
238
 
239
+ def _print_report(base):
240
+ """分析结束时打印观测报告(**用户通道自带**,不依赖任何 LLM 动作)。
241
+
242
+ 为什么在这里:`--follow` 是用户直接执行的通道(`!!` 命令),结束时
243
+ 自动附报告 = 用户零操作看到运行事实(提交/墙钟/配额/进程/LLM 用量),
244
+ 而不是指望主 pi 记得去跑 `--report` 再转述(LLM 依赖,可能漏)。
245
+ `--report` 独立入口与 `--cleanup` 的打印保持不变(不同场景各看一次)。
246
+
247
+ **延迟 import observability**:该模块顶层 import 本模块
248
+ (wait_for_completion 用 incremental),顶层反向 import 会成环。本函数
249
+ 只在 done 分支执行一次(冷路径),函数内 import 是标准解法。
250
+
251
+ fail-open:报告是附加信息,生成失败绝不阻断观看退出(契约同
252
+ observability.build_report——任何一段读不出显示 n/a)。
253
+ """
254
+ try:
255
+ from observability import build_report
256
+ print("【分析报告】", flush=True)
257
+ for line in build_report(base):
258
+ print(line, flush=True)
259
+ except Exception as e: # noqa: BLE001
260
+ # 宽捕获是刻意的:报告在观看主循环的退出路径上,任何异常
261
+ # (含未预期)都不该让用户失去"分析已结束"这个关键信息
262
+ print(f"【分析报告】生成失败(不影响观看):{e}", flush=True)
263
+
264
+
174
265
  def main():
175
266
  parser = argparse.ArgumentParser(description="human 分析展示(只读)")
176
267
  parser.add_argument("base", help="分析目录(含 repo.git)")
@@ -184,17 +275,22 @@ def main():
184
275
  print(f"错误: 分析不存在: {base}", file=sys.stderr)
185
276
  return 1
186
277
 
187
- agents = participants_from_bare(bare)
278
+ proto = meeting_fs.read_protocol(bare) # 一次读取:参与者 + 配额上限
279
+ agents = proto.get("participants") or None
188
280
  if not agents:
189
281
  print(f"错误: 无法读取 protocol.json(分析未初始化?): {base}",
190
282
  file=sys.stderr)
191
283
  return 1
284
+ max_meeting = proto.get("maxMeetingRounds")
192
285
 
193
286
  sys.stdout.reconfigure(line_buffering=True)
194
287
  if args.follow:
195
- follow(base, bare, agents)
288
+ follow(base, bare, agents, max_meeting=max_meeting)
196
289
  else:
197
- mode, lines, _, done = incremental(bare, agents, args.since)
290
+ mode, lines, _, done, progress = incremental(
291
+ bare, agents, args.since, max_meeting)
292
+ print(f"【状态】{mode}", flush=True)
293
+ print(f"【进度】{progress}", flush=True)
198
294
  print(f"【状态】{mode}", flush=True)
199
295
  for s in lines:
200
296
  print(s, flush=True)
package/meeting_core.py CHANGED
@@ -16,6 +16,25 @@
16
16
  MEETING_TYPES = {"message", "freezing", "all-freezing", "pass", "concluded"}
17
17
  """meeting 模式合法 type 集合(校验器白名单)"""
18
18
 
19
+ # ---------------------------------------------------------------
20
+ # 状态机词汇(**字面量的家**)——消息 type / 全局 mode 的合法值。
21
+ # 谁消费:core(全部判定)、engine(信号写点 + 分支)、loop(fatal 文案)、
22
+ # viewer(展示)、fake_agent(responder 决策池)。
23
+ # 为什么建家:这些字符串是**状态机的词表**,任何一处拼写漂移 = 判定静默
24
+ # 失效。曾是 46 处字面量散在 6 个文件(e2e15 自审 S3)。
25
+ # 注意:**不要**为了用常量而替换注释/日志文案里的词(那是给人读的)——
26
+ # 只替换"参与判定的值"。
27
+ # ---------------------------------------------------------------
28
+ T_MESSAGE = "message"
29
+ T_FREEZING = "freezing"
30
+ T_ALL_FREEZING = "all-freezing"
31
+ T_PASS = "pass"
32
+ T_CONCLUDED = "concluded"
33
+ M_MEETING = "meeting"
34
+ M_ROUND_ROBIN = "round-robin"
35
+ M_ALL_FREEZING = T_ALL_FREEZING # mode 值与 type 值同形(af 复用)
36
+ M_CONCLUDED = T_CONCLUDED
37
+
19
38
  # ---------------------------------------------------------------
20
39
  # 1. 冻结判定(阶段 1.1)
21
40
  # ---------------------------------------------------------------
@@ -187,7 +206,7 @@ def meeting_speak_count(messages, agent):
187
206
  "meeting 6/2" 的超限假象——6 是消息总数、2 是配额上限)。
188
207
  """
189
208
  return sum(1 for fm in messages.get(agent, [])
190
- if fm.get("mode") == "meeting" and fm.get("type") == "message")
209
+ if fm.get("mode") == M_MEETING and fm.get("type") == T_MESSAGE)
191
210
 
192
211
 
193
212
  def frozen_agents(agents, all_last_types):
@@ -229,7 +248,7 @@ def can_start_rr(all_last_types):
229
248
  return False
230
249
  if any(t is None for t in all_last_types.values()):
231
250
  return False
232
- return all(t == "all-freezing" for t in all_last_types.values())
251
+ return all(t == T_ALL_FREEZING for t in all_last_types.values())
233
252
 
234
253
 
235
254
  def aggregate_mode(all_last):
@@ -243,13 +262,13 @@ def aggregate_mode(all_last):
243
262
  4. 否则 → meeting
244
263
  """
245
264
  if not all_last:
246
- return "meeting" # 空 → 无任何 agent → 无冻结无 RR(review5 F3)
265
+ return M_MEETING # 空 → 无任何 agent → 无冻结无 RR(review5 F3)
247
266
  types = {a: (v["type"] if v else None) for a, v in all_last.items()}
248
267
  modes = {a: (v["mode"] if v else None) for a, v in all_last.items()}
249
- if any(t == "concluded" for t in types.values()):
250
- return "concluded"
251
- if any(m == "round-robin" for m in modes.values()):
252
- return "round-robin"
253
- if all(t in ("freezing", "all-freezing") for t in types.values()):
254
- return "all-freezing"
268
+ if any(t == T_CONCLUDED for t in types.values()):
269
+ return T_CONCLUDED
270
+ if any(m == M_ROUND_ROBIN for m in modes.values()):
271
+ return M_ROUND_ROBIN
272
+ if all(t in (T_FREEZING, T_ALL_FREEZING) for t in types.values()):
273
+ return T_ALL_FREEZING
255
274
  return "meeting"