pi-multi-viewers 0.1.0 → 0.2.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.
@@ -13,26 +13,33 @@
13
13
 
14
14
  生命周期:
15
15
  创建(--dir)→ 启动(--start,可选)→ 观察(--status/--wait)→ 清理(--cleanup)
16
+
17
+ 本文件 = **组合层**(CLI 分发 + 环境创建/启动/清理 + 编排)。同层拆出的模块:
18
+ spec_gen.py spec 生成(question/骨架/viewers 快照/agent 定义 + pi 环境探测)
19
+ observability.py 观测(check_status / --report / --wait / loop 存活)
20
+
21
+ 依赖方向:主文件 → {spec_gen, observability, meeting_fs, meeting_engine,
22
+ human_viewer};两个子模块**只**依赖底层(core/fs/engine),不互相依赖、
23
+ 不反向依赖主文件。为向后兼容(tests/wrapper 的 `from start_discussion
24
+ import X`),主文件 re-export 了子模块的公开符号(见文件尾部)。
16
25
  """
17
26
 
18
27
  import argparse
19
- import glob
20
28
  import json
21
29
  import os
22
- import meeting_fs
23
- import human_viewer
24
- import meeting_core
25
- import meeting_engine
26
30
  import re
27
31
  import shutil
28
32
  import subprocess
29
33
  import sys
30
34
  import time
31
35
 
36
+ import meeting_fs
37
+ import observability
38
+ import spec_gen
39
+
32
40
  HERE = os.path.dirname(os.path.abspath(__file__))
33
41
  TPL_DIR = os.path.join(HERE, "templates")
34
- PI_AGENT_DIR = os.environ.get("PI_CODING_AGENT_DIR",
35
- os.path.expanduser("~/.pi/agent"))
42
+
36
43
  GIT_USER = "meeting-bot"
37
44
  GIT_EMAIL = "meeting-bot@local"
38
45
 
@@ -50,107 +57,12 @@ def run_cmd(cmd, cwd=None, check=True):
50
57
  return r
51
58
 
52
59
 
53
- def _spec_read(spec_dir, rel):
54
- """读 spec 文件内容,永远跳过第一行(说明行,B 方案)。
55
60
 
56
- 设计 16.4:第一行是骨架生成时的用途说明,不注入;正文从第二行起。
57
- 文件不存在 → 返回 None(逐文件独立回退)。
58
- """
59
- fp = os.path.join(spec_dir, rel)
60
- if not os.path.isfile(fp):
61
- return None
62
- with open(fp) as f:
63
- lines = f.read().splitlines()
64
- return "\n".join(lines[1:]).strip("\n")
65
-
66
-
67
- def _join_model_ref(provider, model_id):
68
- """按 pi 契约把 (provider, model_id) 拼成完整 model ref。
69
-
70
- 契约(pi 源码 resolveSpawnContext):PI_PROVIDER=provider、
71
- PI_MODEL=model id;session 的 model_change 同样分 provider/modelId
72
- 两字段;settings 的 defaultModel/defaultProvider 同构。**model id 本身
73
- 可含 '/'**(聚合类 provider 的命名空间 id,如 commandcode-goat 的
74
- "deepseek/deepseek-v4-flash")——因此拼接是**无条件**的字段拼接,
75
- 不得按"是否含斜杠"猜形状(形状启发式会把 provider 丢掉,解析到
76
- 同名的另一个 provider,静默失真;2026-09-10 实测 fix)。
77
-
78
- provider 缺失时只能原样返回(无法拼接)——这是调用方应保证的前置。
79
- """
80
- provider = (provider or "").strip()
81
- model_id = (model_id or "").strip()
82
- if not model_id:
83
- return ""
84
- if not provider:
85
- return model_id
86
- return f"{provider}/{model_id}"
87
-
88
-
89
- def _default_model():
90
- """本机默认模型(如 opencode-go/deepseek-v4-flash)。
91
-
92
- 从 pi settings.json 读取 defaultProvider/defaultModel,按契约拼接
93
- (_join_model_ref——defaultModel 同样可能是含 '/' 的 id)。
94
- 无默认模型配置 → 返回 None(pi-agent.json 不写 model,回退 pi 默认)。
95
- 获取失败(pi 不可用/无 settings)→ 返回 None。
96
- """
97
- try:
98
- with open(os.path.join(PI_AGENT_DIR, "settings.json")) as f:
99
- cfg = json.load(f)
100
- provider = cfg.get("defaultProvider") or ""
101
- model = cfg.get("defaultModel") or ""
102
- if not model:
103
- return None
104
- return _join_model_ref(provider, model) or None
105
- except (OSError, ValueError):
106
- return None
107
-
108
-
109
- def _detect_pi_model_thinking():
110
- """探测主 pi 当前 model/thinking(spec models.md 预填,对齐 wrapper 旧语义)。
111
-
112
- 顺序:PI_MODEL/PI_PROVIDER/PI_REASONING_LEVEL 环境变量(wrapper 由主 pi
113
- bash 注入)→ session 文件最后 model_change/thinking_level_change 事件
114
- (PI_SESSION_FILE 或 cwd 编码目录最新 jsonl)→ settings 默认(_default_model)。
115
- 返回 (model, thinking)——缺失项为空串。
116
- """
117
- model = os.environ.get("PI_MODEL") or ""
118
- provider = os.environ.get("PI_PROVIDER") or ""
119
- thinking = os.environ.get("PI_REASONING_LEVEL") or ""
120
- # 契约拼接(不按形状猜——id 可含 '/',见 _join_model_ref)
121
- model = _join_model_ref(provider, model)
122
- if model and thinking:
123
- return model, thinking
124
- # session 文件兜底(查找规则单点:current_session_file)
125
- try:
126
- sf = current_session_file()
127
- if sf:
128
- sm = st = sp = ""
129
- with open(sf, encoding="utf-8") as f:
130
- for line in f:
131
- line = line.strip()
132
- if not line:
133
- continue
134
- try:
135
- ev = json.loads(line)
136
- except ValueError:
137
- continue
138
- t = ev.get("type")
139
- if t == "model_change":
140
- sm = ev.get("modelId") or sm
141
- sp = ev.get("provider") or sp
142
- elif t == "thinking_level_change":
143
- st = ev.get("thinkingLevel") or st
144
- if not model and sm:
145
- # model_change 是 provider + modelId 两字段——同样按契约拼接
146
- model = _join_model_ref(sp, sm)
147
- if not thinking and st:
148
- thinking = st
149
- except OSError:
150
- pass
151
- if not model:
152
- model = _default_model() or ""
153
- return model, thinking
61
+
62
+
63
+
64
+
65
+
154
66
 
155
67
 
156
68
  def _spec_models(spec_dir, participants):
@@ -186,157 +98,10 @@ def _spec_models(spec_dir, participants):
186
98
  return out
187
99
 
188
100
 
189
- def _strip_empty_sections(question):
190
- """去掉 question.md 中未填的可选节(打磨项 2026-09-01 讨论结论)。
191
101
 
192
- 模板生成的可选节(## 初始立场 / ## 待回答的问题)如果用户没编辑,
193
- 节内只有占位符行("- X: 立场" / "- 问题")——注入前整节去掉,
194
- 避免占位符混入讨论环境。判据精确:占位符是确定字符串,用户真实
195
- 内容不会写成 "- X: 立场"(立场值就是"立场"二字)。
196
- """
197
- placeholder = re.compile(r"^-\s+(\w+:)?\s*立场$|^-\s+问题$")
198
- out = []
199
- pending_title = None # 当前节的标题(占位符节连标题一起删)
200
- cur = [] # 当前节内容行
201
- cur_is_placeholder = True
202
-
203
- def flush():
204
- if not cur_is_placeholder:
205
- if pending_title is not None:
206
- out.append(pending_title)
207
- out.extend(cur)
208
-
209
- for line in question.splitlines():
210
- if line.startswith("## "):
211
- flush()
212
- pending_title = line
213
- cur = []
214
- cur_is_placeholder = True
215
- elif line.strip() == "":
216
- cur.append(line)
217
- else:
218
- cur.append(line)
219
- if not placeholder.match(line):
220
- cur_is_placeholder = False
221
- flush()
222
- return "\n".join(out)
223
-
224
-
225
- def gen_spec_skeleton(spec_dir, participants, topic=None, background=None,
226
- viewers_dir=None):
227
- """生成 spec 骨架(--spec-gen,唯一实现):question.md + background.md +
228
- models.md + agents/。
229
-
230
- agents/ 三态:viewers_dir 给定 → _snapshot_viewers(校验+快照,失败
231
- 返回 (None, err)、零产物);显式 participants → 占位骨架;两者皆无 →
232
- 无 agents/(start 报错提示)。
233
- topic/background: wrapper --prepare 传入(直接填进骨架);CLI 直用
234
- 时缺省 = 占位文案。
235
- models.md 预填主 pi 当前 model/thinking(_detect_pi_model_thinking,
236
- 对齐旧 wrapper read_pi_model_thinking 语义——用户少改一个文件)。
237
- 每个文件第一行 = 用途说明(不注入,设计 16.4)。
238
- """
239
- if viewers_dir:
240
- participants, err = _snapshot_viewers(spec_dir, viewers_dir)
241
- if err:
242
- return None, err
243
- # spec 目录本身无条件创建(2026-09-09 回归修复:agents 创建并入条件
244
- # 分支后,viewers 骨架曾连 spec_dir 都不建 → README copy 崩)
245
- os.makedirs(spec_dir, exist_ok=True)
246
- # agents/ 占位骨架仅显式 --agents 时生成(viewers 快照路径已建好并
247
- # 含内容——不能被占位覆盖;两者皆无 = viewers 发现留给启动时点)
248
- if participants and not viewers_dir:
249
- os.makedirs(os.path.join(spec_dir, "agents"), exist_ok=True)
250
- # README.md:从模板复制(内容不变——模板化,用户 7909)
251
- shutil.copyfile(os.path.join(TPL_DIR, "spec-readme.md.tpl"),
252
- os.path.join(spec_dir, "README.md"))
253
- # question.md:第一行说明 + 基本结构模板(用户 7713:提供基本结构)
254
- q = [
255
- "# question.md——分析起点(话题/立场/待答问题,自由 markdown)。本行是说明行,不会注入。",
256
- "",
257
- f"# 分析主题:{topic or "请填写"}",
258
- "",
259
- "## 初始立场(可选,每参与者一行)",
260
- ]
261
- q += [f"- {p}: 立场" for p in participants]
262
- q += ["", "## 待回答的问题(可选)", "- 问题", ""]
263
- with open(os.path.join(spec_dir, "question.md"), "w") as f:
264
- f.write("\n".join(q))
265
- # background.md(第一行说明 + 正文;用户 7707:文件可以是空的)
266
- with open(os.path.join(spec_dir, "background.md"), "w") as f:
267
- f.write("# background.md——显式边界与约定(注入每个 work 的 AGENTS.md 背景节)。"
268
- "本行是说明行,不会注入。\n\n")
269
- if background:
270
- f.write(background + "\n")
271
- # models.md(用户 8024/9204/9271:预列各 agent,每行 agent名: model,
272
- # variant 默认 max 隐式——只有非 max 才写 `, variant`,日常更简洁;
273
- # model/thinking 预填主 pi 当前值,用户少改一个文件)
274
- pm, pt = _detect_pi_model_thinking()
275
- with open(os.path.join(spec_dir, "models.md"), "w") as f:
276
- lines = ["# models.md——模型配置(可选)。每行:agent名: model[, variant]。"
277
- "model 默认 default,variant 默认 max(只有不用 max 才写 variant)。"
278
- "本行是说明行,不会注入。"]
279
- for p in participants:
280
- if pm and pt:
281
- lines.append(f"{p}: {pm}, {pt}")
282
- elif pm:
283
- lines.append(f"{p}: {pm}")
284
- elif pt:
285
- lines.append(f"{p}: default, {pt}")
286
- else:
287
- lines.append(f"{p}: default")
288
- f.write("\n".join(lines) + "\n")
289
- # agents/X.md 占位 + .order(仅显式 --agents 时;快照路径的 .order
290
- # 由 _snapshot_viewers 写入,此处不得重写)
291
- if participants and not viewers_dir:
292
- for p in participants:
293
- with open(os.path.join(spec_dir, "agents", f"{p}.md"), "w") as f:
294
- f.write(f"# {p}.md——agent {p} 的分工/补充(追加到 agent {p} 定义正文)。"
295
- f"本行是说明行,不会注入。\n\n")
296
- # .order:固化 --agents 顺序(审核#6——sorted() 推断破坏顺序语义,
297
- # starter/默认 resultWriter/RR 轮转链依赖 participants 顺序)
298
- with open(os.path.join(spec_dir, "agents", ".order"), "w") as f:
299
- f.write("\n".join(participants) + "\n")
300
- return participants, None
301
102
 
302
103
 
303
- def gen_agents_md(args, agent, participants, spec_background=None,
304
- main_pi_cwd=None):
305
- """meeting 协议 AGENTS.md(共享协议 + background;身份/立场在 agent
306
- 定义/question.md)。
307
104
 
308
- spec_background: spec 提供时优先(设计 16.6),否则 args.background,否则占位。
309
- main_pi_cwd: 主 pi 工作目录(程序化注入,用户 2026-09-02)——直接进
310
- AGENTS.md(注入 system prompt 的载体),不经 background.md 转接:
311
- background.md 是人工编辑的讨论内容(用户审核 spec 时看),cwd 是
312
- 环境事实(程序化写入),分开保持各自干净。None(手动场景)→ 节隐藏。
313
- """
314
- others = [p for p in participants if p != agent]
315
- sample = others[0] if others else "x"
316
- background = (spec_background if spec_background is not None
317
- else (args.background or "(无)"))
318
- with open(os.path.join(TPL_DIR, "AGENTS.md.tpl")) as f:
319
- tpl = f.read()
320
- # cwd 节:占位符填充(T5 修复,e2e7 评审)——原实现先 format 出
321
- # "(未提供)"再整段字符串 replace 删除:模板文案/换行一改,隐藏
322
- # 静默失效(模板与 python 双份文本耦合)。占位符方案:节文本单份
323
- # 定义在此,模板位置显式可见;None → 空串(节消失)
324
- if main_pi_cwd:
325
- cwd_section = (
326
- f"\n## 主 pi 工作目录\n\n分析环境的主 pi 在 `{main_pi_cwd}` "
327
- f"目录运行。与该目录相关的信息\n(源码、文档、配置)可在其中"
328
- f"查找:如有需要可查看相关文件以获取\n比本背景更详细的信息。\n")
329
- else:
330
- cwd_section = ""
331
- out = tpl.format(
332
- AGENT_NAME=agent,
333
- N=str(len(participants)),
334
- PARTICIPANTS_DISPLAY="、".join(participants),
335
- SAMPLE_OTHER=sample,
336
- BACKGROUND=background,
337
- MAIN_PI_CWD_SECTION=cwd_section,
338
- )
339
- return out
340
105
 
341
106
 
342
107
  def gen_agent_def(agent, participants, models=None, stances=None, extra=None):
@@ -371,50 +136,8 @@ def gen_agent_def(agent, participants, models=None, stances=None, extra=None):
371
136
  return result
372
137
 
373
138
 
374
- def gen_question(topic, stances, background, questions):
375
- """question.md(讨论起点:话题 + 可选立场 + 待回答问题)。
376
139
 
377
- 分层(2026-08-09):background 移到 AGENTS.md(共享,system prompt);
378
- 立场保持在此(非强制、可被说服,不进 system prompt)。
379
- """
380
- lines = [f"# 讨论主题:{topic}", ""]
381
- if stances:
382
- lines += ["## 初始立场", "每个参与者有自己的初始立场(可被论据说服):", ""]
383
- for k, v in stances.items():
384
- lines.append(f"- {k}: {v}")
385
- lines += ["", "开场时请声明你的立场,然后参与讨论。"]
386
- if questions:
387
- lines += ["", "## 待回答的问题"] + [f"- {q}" for q in questions] + [""]
388
- return "\n".join(lines)
389
-
390
-
391
- def gen_protocol(topic, participants, max_meeting, max_rr, pure=False,
392
- result_writer=None, stall_timeout=600,
393
- fork_source=None, fork_cwd=None,
394
- fork_mode=meeting_fs.DEFAULT_FORK_MODE):
395
- """protocol.json(meeting 模式)。"""
396
- rw = result_writer or participants[-1]
397
- proto = {
398
- "mode": "meeting",
399
- "protocol_version": 2,
400
- "topic": topic or "",
401
- "participants": participants,
402
- "resultWriter": rw,
403
- "maxMeetingRounds": max_meeting,
404
- "maxRRRounds": max_rr,
405
- "stallTimeoutSeconds": stall_timeout,
406
- "commitPolicy": "one-message-per-commit",
407
- }
408
- if pure:
409
- proto["pure"] = True
410
- if fork_source:
411
- # fork 模式(多视角):首唤挂载主 session + cwd=主项目
412
- # forkMode 取值域与默认值的定义在 meeting_fs(FORK_MODES /
413
- # DEFAULT_FORK_MODE,单一事实源);此处只写入选定值
414
- proto["forkSource"] = fork_source
415
- proto["forkCwd"] = fork_cwd or os.getcwd()
416
- proto["forkMode"] = fork_mode
417
- return proto
140
+
418
141
 
419
142
 
420
143
  def _resolve_path(p):
@@ -423,194 +146,24 @@ def _resolve_path(p):
423
146
  return os.path.abspath(os.path.expanduser(p))
424
147
  return os.path.join(os.getcwd(), p)
425
148
 
426
- MAX_AGENT_NAME_LEN = 32
427
149
 
428
150
 
429
- def pi_sessions_dir(cwd):
430
- """主 pi session 目录(编码约定单点,T7 收归 e2e7 评审)。
431
151
 
432
- pi 的 session 目录编码 = "--" + 去首尾斜杠 + 内斜杠换 "-" + "--"
433
- (/root/x → --root-x--;/tmp → --tmp--)。此前该约定在
434
- _detect_pi_model_thinking 与 resolve_fork_source 两处字面量重复,
435
- AGENTS.md 明言"编码错一根横线 = 静默解析不到"——风险点不应复制。
436
- """
437
- enc = "--" + cwd.strip("/").replace("/", "-") + "--"
438
- return os.path.join(PI_AGENT_DIR, "sessions", enc)
439
152
 
440
153
 
441
- def current_session_file():
442
- """当前主 pi 的 session 文件路径(解析不到 → 空串)。
443
154
 
444
- **查找规则的唯一实现**(此前两处各写一遍:resolve_fork_source 按
445
- PI_SESSION_ID 匹配文件名、_detect_pi_model_thinking 兜底取目录内
446
- **字典序最后**——同一概念两套规则,兜底可能选到别的 session)。
447
- 规则:PI_SESSION_FILE(pi 直接给的路径,最精确)→ PI_SESSION_ID
448
- 匹配文件名 → 目录内字典序最后(都无法确认时只能如此,调用方自决
449
- 是否接受)。
450
- """
451
- sf = os.environ.get("PI_SESSION_FILE") or ""
452
- if sf and os.path.isfile(sf):
453
- return sf
454
- sdir = pi_sessions_dir(os.getcwd())
455
- try:
456
- cands = sorted(f for f in os.listdir(sdir) if f.endswith(".jsonl"))
457
- except OSError:
458
- cands = []
459
- if not cands:
460
- return ""
461
- sid = os.environ.get("PI_SESSION_ID") or ""
462
- if sid:
463
- hits = [f for f in cands if sid in f]
464
- if hits:
465
- return os.path.join(sdir, hits[-1])
466
- return os.path.join(sdir, cands[-1])
467
-
468
-
469
- def check_agent_name(name):
470
- """单个 agent/视角名合法性(T4 收归,e2e7 评审):唯一实现。
471
-
472
- 规则(viewers 文件名即 agent 名 → 同一套规则两处来源):
473
- 非空 / 无路径分隔符与空白 / ≤32 字符 / 非 human 保留名。
474
- 返回错误信息或 None。
475
- """
476
- if not name:
477
- return "空名"
478
- if re.search(r"[/\\\s]", name):
479
- return "含路径分隔符或空白"
480
- if len(name) > MAX_AGENT_NAME_LEN:
481
- return f"超过 {MAX_AGENT_NAME_LEN} 字符"
482
- if name == "human":
483
- return "'human' 是保留名(human 插话通道),不可作为参与者"
484
- return None
485
-
486
-
487
- def list_agent_md(d):
488
- """列出目录下的 agent 定义文件名(去 `.md`、**排除隐藏文件**、排序)。
489
-
490
- **列举规则的唯一实现**——viewers/ 与 spec/agents/ 两条来源共用。
491
- 为什么排除隐藏文件:`.draft.md` 之类会被当成参与者(名为 `.draft`,
492
- 点号不在名字规则的禁止集内)静默进入讨论。此前只有 viewers 分支
493
- 排除、spec/agents 的两处列举没排除(实测缺口)。
494
- """
495
- return sorted(f[:-3] for f in os.listdir(d)
496
- if f.endswith(".md") and not f.startswith("."))
497
155
 
498
156
 
499
- def validate_participants(participants):
500
- """整组名字校验(**名字规则的唯一入口**)。返回错误或 None。
501
157
 
502
- 覆盖:非空 / 无路径分隔符与空白 / ≤32 / 非 human 保留名。
503
- CLI(--agents)与 spec/viewers(文件名)三条来源路径都经此。
504
- """
505
- for p in participants:
506
- err = check_agent_name(p)
507
- if err:
508
- return f"错误: 非法 agent 名({err}):{p}"
509
- return None
510
158
 
511
159
 
512
- def viewer_set_error(names, empty, where="viewers/"):
513
- """viewers 集合级校验(**唯一实现**):空正文视角 + 至少 2 个。
514
160
 
515
- where: 报错时指路的目录前缀("viewers/" 或 "spec/agents/")。
516
- 返回错误文本或 None。为什么单点:同一套规则曾在 prepare 快照路径与
517
- spec 解析路径各写一遍,文案与检查项已经漂移(实测:非法名文案带不带
518
- 文件名后缀不一致、≥2 检查一处列参与者一处不列)。
519
- """
520
- if empty:
521
- # 空视角 = 没有 lenses 的 agent:行为由模型自由发挥,多视角退化成
522
- # "同名随机视角"——静默退化,与无静默铁律相悖(占位文件忘写是常见成因)
523
- detail = "、".join(f"{where}{n}.md({why})" for n, why in empty)
524
- return (f"错误: {detail}——视角任务书不能为空"
525
- f"(写清该视角用什么 lenses 看分析对象)")
526
- if len(names) < 2:
527
- return (f"错误: {where} 下仅发现 {len(names)} 个视角"
528
- f"({', '.join(names)})——多视角分析至少需要 2 个")
529
- return None
530
-
531
-
532
- def _discover_viewers(viewers_dir):
533
- """发现 viewers 目录(多视角产品约定):*.md 文件名即 agent 名。
534
-
535
- 返回 (participants, briefs, errors)——participants 按文件名排序(决定
536
- starter/RR 轮转与默认 resultWriter);briefs = {agent: 视角任务书正文};
537
- errors = [(name, 原因)](空/纯空白视角——这类视角无 lenses,会让多视角
538
- 退化成同名随机视角,属静默退化,必须报错而非放行)。
539
- 目录不存在/无文件 → (None, None, [])(调用方决定报错或回退)。
540
- """
541
- if not os.path.isdir(viewers_dir):
542
- return None, None, []
543
- names = list_agent_md(viewers_dir)
544
- if not names:
545
- return None, None, []
546
- briefs, errors = {}, []
547
- for n in names:
548
- with open(os.path.join(viewers_dir, f"{n}.md"), encoding="utf-8") as f:
549
- brief = f.read().strip("\n")
550
- if not brief.strip():
551
- errors.append((n, "空视角任务书(没有任何视角内容)"))
552
- continue
553
- briefs[n] = brief
554
- return names, briefs, errors
555
161
 
556
162
 
557
- def resolve_fork_source():
558
- """从主 pi 环境(PI_SESSION_ID)解析当前 session 文件绝对路径
559
- (fork-only 启动必需,2026-09-09 从 wrapper 收归——session 文件发现
560
- 逻辑与 _detect_pi_model_thinking 同款路径约定)。
561
163
 
562
- sessions 目录编码 = "--" + 去首尾斜杠内斜杠换 "-" + "--"。
563
- 返回 (fork_source 或 None, error)——PI_SESSION_ID 未注入/文件缺失
564
- 都是明确错误(fork-only 无静默退化)。
565
- """
566
- sid = os.environ.get("PI_SESSION_ID") or ""
567
- if not sid:
568
- return None, ("错误: PI_SESSION_ID 未注入——多视角分析必须在主 pi "
569
- "session 内经 wrapper 启动。若确实在 session 内,"
570
- "检查是否有扩展接管了 bash 工具(如 AFT 的 "
571
- "`~/.config/cortexkit/aft.jsonc` 未设 \"bash\": false)"
572
- "——接管后 pi 的环境变量不会传入 bash")
573
- # 委托 current_session_file(查找规则单点);这里只管错误语义
574
- path = current_session_file()
575
- if not path or sid not in os.path.basename(path):
576
- sdir = pi_sessions_dir(os.getcwd())
577
- enc = os.path.basename(sdir)
578
- return None, (f"错误: 未找到主 session 文件({enc}/*_{sid}.jsonl)"
579
- "——fork-only 模式必须挂载主 session")
580
- return path, None
581
-
582
-
583
- def _snapshot_viewers(spec_dir, viewers_dir):
584
- """viewers 校验(prepare 时点)+ 快照进 spec/agents/(单一事实源:
585
- 所有视角都源自 viewers/,spec agents/ = 本场快照,可按场修改,
586
- 分析结束 spec 即删、资产永续;用户 2026-09-09 设计)。
587
-
588
- 校验(任何违规 → 返回错误,不产生任何 spec 文件——用户:不合规
589
- 根本不应该开始 spec-gen):目录存在 / ≥2 个合法 .md(排除隐藏)/
590
- 无 human / 名字合法(空白/路径分隔符/≤32)。
591
- 快照文件含说明行首行(spec 约定:_spec_read 跳过首行——裸拷贝会
592
- 把正文首行当说明吃掉,实测缺口)。
593
- 返回 (participants, error)。
594
- """
595
- names, _briefs, empty = _discover_viewers(viewers_dir)
596
- if names is None:
597
- return None, ("错误: 未找到 viewers/ 目录——多视角分析的视角资产"
598
- "必须先建好(项目 cwd 下 viewers/<视角名>.md,至少 2 个)")
599
- err = validate_participants(names) or viewer_set_error(names, empty)
600
- if err:
601
- return None, err
602
- agents_dir = os.path.join(spec_dir, "agents")
603
- os.makedirs(agents_dir, exist_ok=True)
604
- for n in names:
605
- with open(os.path.join(viewers_dir, f"{n}.md")) as f:
606
- brief = f.read()
607
- with open(os.path.join(agents_dir, f"{n}.md"), "w") as f:
608
- f.write(f"# {n}.md——快照自 viewers/{n}.md(本行说明不注入;"
609
- f"按场修改这里,不影响 viewers/ 资产)\n\n")
610
- f.write(brief)
611
- with open(os.path.join(agents_dir, ".order"), "w") as f:
612
- f.write("\n".join(names) + "\n")
613
- return names, None
164
+
165
+
166
+
614
167
 
615
168
 
616
169
  def _resolve_spec(spec, agents, topic, background, stances, questions, models,
@@ -684,7 +237,7 @@ def _resolve_spec(spec, agents, topic, background, stances, questions, models,
684
237
  # spec 必须有 question.md(讨论起点不可缺)
685
238
  if not os.path.isfile(os.path.join(spec_dir, "question.md")):
686
239
  return None, None, None, "错误: spec 缺少 question.md(讨论起点,先 --spec-gen 生成)"
687
- # 空正文校验(审核#19):删到只剩说明行 → 无讨论主题(CLI 路径有
240
+ # 空正文校验(审核#19):删到只剩说明行 → 无分析主题(CLI 路径有
688
241
  # --topic 必填对等约束)
689
242
  if not (_spec_read(spec_dir, "question.md") or "").strip():
690
243
  return None, None, None, "错误: spec 的 question.md 正文为空(讨论起点不可缺)"
@@ -728,12 +281,10 @@ def setup_environment(args, participants, base, spec_dir=None,
728
281
  spec_topic = None
729
282
  if spec_question is not None:
730
283
  for line in spec_question.splitlines():
731
- # 兼容两种措辞(make_spec fixture 用旧版"讨论主题")
732
- for prefix in ("# 分析主题:", "# 讨论主题:"):
733
- if line.startswith(prefix):
734
- spec_topic = line.replace(prefix, "").strip()
735
- break
736
- if spec_topic:
284
+ # **只认一种措辞**("# 分析主题:")——生成端与消费端同一约定;
285
+ # spec 用别的措辞 落到下方 fail-fast(明确报错,不静默)
286
+ if line.startswith("# 分析主题:"):
287
+ spec_topic = line.replace("# 分析主题:", "").strip()
737
288
  break
738
289
  if spec_dir and not spec_topic:
739
290
  raise ValueError(
@@ -895,77 +446,10 @@ def cleanup_discussion(base):
895
446
  print(f"[cleanup] 已删除目录 {base}(含 pi-sessions)")
896
447
 
897
448
 
898
- def _loop_pids(base):
899
- """本讨论存活的 loop PID 列表。
900
-
901
- **判据 = argv 精确相等,不是命令行文本正则**(2026-09-10 评审 A2):
902
- `pgrep -f <正则>` 会匹配到**任何**命令行里含该文本的进程——从 shell
903
- 包装调用时(`bash -c "...pgrep -f 'meeting_loop.py.*<base>'..."`)会命中
904
- 调用者自身,误判"有 loop 存活"。项目已固化该教训(docs/test-methodology.md
905
- 方法 2:方括号技巧或精确 PID),此处用 /proc 的 argv 逐项比较根治:
906
- 只看 argv 里是否有**恰好等于** `os.path.join(base, "meeting_loop.py")`
907
- 的元素——与启动方(Popen cmd 的第一个参数)同一构造。
908
- 附带:/proc 扫描 ≈1.1ms vs pgrep ≈5.9ms(不构成选型理由,理由是判据精度)。
909
-
910
- 读不到 /proc(非 Linux/权限)→ 返回空列表(fail-open:与"无 loop"同义,
911
- 只影响状态显示,不影响流程——loop 自身不依赖此函数)。
912
- """
913
- target = os.path.join(base, "meeting_loop.py")
914
- pids = []
915
- for entry in glob.glob("/proc/[0-9]*/cmdline"):
916
- try:
917
- with open(entry, "rb") as f:
918
- argv = f.read().decode("utf-8", "replace").split("\0")
919
- except OSError:
920
- continue
921
- if target in argv:
922
- pids.append(entry.split("/")[2])
923
- return pids
924
-
925
449
 
926
- def _loops_alive(base):
927
- """讨论的 loop 进程是否存活(argv 精确匹配,见 _loop_pids)。"""
928
- return bool(_loop_pids(base))
929
450
 
930
451
 
931
- def check_status(base):
932
- """讨论状态(单值;状态全集显式于此,T3/#7 修复 e2e7 评审):
933
452
 
934
- not-exists 无 bare(目录不存在/未创建)
935
- done result.md + concluded(权威收尾完成)
936
- running 有 loop 存活(讨论中 / 收尾中——收尾中细分见下)
937
- stalled 有 result.md 无 concluded 且 **loop 均不存活**
938
- (收尾中断:rw 崩溃在 result.md 之后、concluded 之前)
939
- stopped 无 result.md 且 loop 不存活(未启动/中断)
940
-
941
- 修复动因(e2e7 评审 T3):原实现"有 result.md 无 concluded"恒返回
942
- running 且不看存活 → "收尾进行中"与"收尾间隙崩溃"不可区分,--wait
943
- 无限轮询(无终止上界)。现 stalled 使 --wait 有界退出。
944
- 移除恒 None 第二返回值(#7 装饰性契约)——信息由状态本身表达。
945
- """
946
- bare = meeting_fs.bare_of_base(base)
947
- if not os.path.isdir(bare):
948
- return "not-exists"
949
- # 读路径统一走 fs.run_git(quotepath 加固单点;run_cmd 只做一次性
950
- # 环境命令——init/clone/config/push)
951
- # 读路径统一走 fs.run_git(quotepath 加固单点;run_cmd 只做一次性
952
- # 环境命令——init/clone/config/push)
953
- agents = meeting_fs.read_protocol(bare).get("participants", [])
954
- # "收尾完成"判据**单源** = human_viewer.is_finished(concluded 且
955
- # HEAD:result.md 有效)——与 viewer 的 done 同一判据(§3.5-P5:此前
956
- # viewer 认 concluded、这里还额外认 result.md 存在,分叉会让 viewer
957
- # 在产物落盘前先报"已结束"并打印尚不存在的路径)。
958
- # 不用 git grep 全文:行文本匹配会被 result.md/消息正文里的
959
- # `type: concluded` 误触发(实测);human 消息天然排除(aggregate_mode
960
- # 只按 participants 取末条)。
961
- if human_viewer.is_finished(bare, agents):
962
- return "done"
963
- # 未完成:有 result.md 但未收尾 → 看 loop 存活区分收尾中/收尾中断
964
- r = meeting_fs.run_git(bare, "log", "--all", "--format=%H", "--",
965
- "result.md", check=False)
966
- if r.stdout.strip():
967
- return "running" if _loops_alive(base) else "stalled"
968
- return "running" if _loops_alive(base) else "stopped"
969
453
 
970
454
 
971
455
  def _parse_agents(agents_arg):
@@ -993,301 +477,35 @@ def _parse_agents(agents_arg):
993
477
  return participants, None
994
478
 
995
479
 
996
- def wait_for_completion(base):
997
- """`--wait`:阻塞展示进展直到收尾或终态。返回退出码(0 完成 / 1 终止)。
998
-
999
- 从 main 内联抽出(main 里最大单块;项目方法论把 main/CLI 分发
1000
- 列为独立测试盲区)。**纯结构变换**:调用序列与 sleep 序列逐字
1001
- 不变——helper 只做机械动作(状态判定 → 打印 → 增量展示 → sleep),
1002
- 不吸收"何时进入分支"的阶段判断。
1003
480
 
1004
- 终止语义(四种终态,各有明确文案):stalled / not-exists /
1005
- stopped(按 loop-*.log 分叉成因)/ done(含固定位 result.md 提示)。
1006
- """
1007
- # T1 收归(e2e7 评审):进展展示复用 human_viewer.incremental
1008
- # (原内联 65 行自行 git log 全量 + 手工解析 frontmatter——与
1009
- # viewer 两套输出格式、非增量、概念丢失)。incremental 走
1010
- # since..HEAD 增量 + 统一 format_message。
1011
- import human_viewer
1012
- sys.stdout.reconfigure(line_buffering=True)
1013
- print(f"[wait] 等待讨论完成: {base}")
1014
- bare = meeting_fs.bare_of_base(base)
1015
- agents = human_viewer.participants_from_bare(bare) or []
1016
- since = "" # 首次全量(--wait 一次性观察,无游标持久需求)
1017
- first = True
1018
- while True:
1019
- state = check_status(base)
1020
- if state == "stalled":
1021
- print("[wait] 收尾中断(result.md 已提交、concluded 缺失、"
1022
- "无 loop 存活)——停止等待;可读 result.md 或 --cleanup")
1023
- return 1
1024
- if state == "not-exists":
1025
- print(f"[wait] 讨论不存在: {base}")
1026
- return 1
1027
- if state == "stopped":
1028
- # 终态:无 result.md 且无 loop 存活。
1029
- #
1030
- # **不用 loop-*.log 存在性分叉成因**(§3.4-P4):那是拿日志当
1031
- # 判定输入(唯一实例,与"日志零判定输入"不变量冲突),且两个
1032
- # 分支给出的动作此前都不可执行(`--start` 对已存在目录报"请先
1033
- # --cleanup";裸 `--start <base>` 又因缺 question.md 失败)。
1034
- # 合并为一条动作完整的提示——真正可执行的是 `--skip-setup
1035
- # --start`(环境不完整则先 --cleanup 重建)。
1036
- print(f"[wait] 未在运行且未收尾({base}:无 loop 存活、无 "
1037
- "result.md)——查 loop-*.log / status-*.json 判断原因;"
1038
- "重跑:--skip-setup --start(protocol 缺失则先 --cleanup "
1039
- "后重建)")
1040
- return 1
1041
- _mode, lines, head, done = human_viewer.incremental(
1042
- bare, agents, since)
1043
- if done:
1044
- for line in lines:
1045
- print(line)
1046
- print()
1047
- print("[wait] 讨论完成 ✅")
1048
- # 固定位(与 prompt 收尾指引一致):resultWriter 的 loop
1049
- # 退出时保存、cleanup 兜底再存一次——调用方无需推 rw 是谁
1050
- print(f"[wait] result.md: {base}-result.md")
1051
- return 0
1052
- for line in lines:
1053
- print(f"[wait] {time.strftime('%H:%M:%S')} 新进展:")
1054
- print(line)
1055
- print()
1056
- since = head or since
1057
- if first:
1058
- first = False
1059
- # 观察刷新节奏(消费端常量——与 loop 的空闲重试节奏有意独立,
1060
- # 见 human_viewer.OBSERVER_POLL_INTERVAL 注释)。原硬编码 10s
1061
- # 会让结束观察延迟最长 10s(e2e13 时间流分析:唯一 >10s 的
1062
- # 非必要等待点)。
1063
- time.sleep(human_viewer.OBSERVER_POLL_INTERVAL)
1064
-
1065
-
1066
- def build_report(base):
1067
- """只读报告(`--report`)——观测面的**唯一机器消费出口**。
1068
-
1069
- 契约(design.md 观测面契约节):
1070
- - **冷路径一次性**:不常驻、不被轮询;调用方(人/主 pi)按需触发。
1071
- - **不持久化**:视图不占"数字的家"——数字的家是 bare(判定域)、
1072
- loop log 的登记字段、pi session 的文档化字段;报告只是它们的一次投影。
1073
- - **fail-open**:任何一段读不出(缺目录/缺文件/格式变)→ 该段显示 n/a,
1074
- 不报错、不改判定、不阻塞。
1075
- - **跨度分标**:进程跨度(elapsed_ms)≠ per-response 跨度(session
1076
- 时间戳差)≠ 墙钟跨度(commit 时间差)——各自标名,不混算。
1077
- - **不得升级为验收 gate**:有效期判断留给人 + result.md(本轮 §4 明确
1078
- 不做运行期评分)。
1079
-
1080
- 返回输出行列表(调用方 print)。
1081
- """
1082
- out = []
1083
- out.append(f"[报告] {base}")
1084
- bare = meeting_fs.bare_of_base(base)
1085
- if not os.path.isdir(bare):
1086
- out.append(" 分析目录不存在(已 cleanup?)——n/a")
1087
- return out
1088
- agents = meeting_fs.read_protocol(bare).get("participants", [])
1089
- if not agents:
1090
- out.append(" 协议不可读(participants 空)——n/a")
1091
- return out
1092
- proto = meeting_fs.read_protocol(bare)
1093
-
1094
- # ---- 流程时间线(bare = 判定域,现场派生) ----
1095
- r = meeting_fs.run_git(bare, "log", "--reverse", "--format=%ct%x09%s",
1096
- "HEAD", check=False)
1097
- rows = []
1098
- for line in r.stdout.splitlines():
1099
- if "\t" not in line:
1100
- continue
1101
- ts, subj = line.split("\t", 1)
1102
- rows.append((int(ts), subj))
1103
- per_agent = {a: 0 for a in agents}
1104
- human_n = 0
1105
- for _, subj in rows:
1106
- m = re.match(r"discuss:\s*(.+?)/(\d+)$", subj)
1107
- if not m:
1108
- continue
1109
- who = m.group(1)
1110
- if who == "human" or who not in per_agent:
1111
- human_n += 1
1112
- else:
1113
- per_agent[who] += 1
1114
- if rows:
1115
- span = rows[-1][0] - rows[0][0]
1116
- out.append(f"流程:{len(agents)} agents | 提交 "
1117
- f"{sum(per_agent.values())}(含流程信号;"
1118
- + " / ".join(f"{a} {n}" for a, n in per_agent.items())
1119
- + f")| 墙钟跨度 {_dur(span)}(首末 commit 差)")
1120
- # 最长无进展间隔(相邻 commit 间隔的最大值)
1121
- gaps = [(rows[i + 1][0] - rows[i][0], rows[i][0], rows[i + 1][0])
1122
- for i in range(len(rows) - 1)]
1123
- if gaps:
1124
- g, t1, t2 = max(gaps)
1125
- out.append(f"节奏:最长无进展 interval {_dur(g)}"
1126
- f"({_hhmm(t1)} → {_hhmm(t2)},commit 间隔)")
1127
-
1128
- # ---- 配额与 human 插话(bare 派生,无状态) ----
1129
- # **配额消耗从 frontmatter 统计**(mode==meeting 且 type==message),
1130
- # 不是"该 agent 的消息总数"——上限约束的是 meeting 发言轮次,而一个
1131
- # agent 的消息里还有 freezing/all-freezing/pass/concluded 等流程信号。
1132
- # 两者混算会出现"meeting 6/2"这种超限假象(口径错误,2026-09-11 实测)。
1133
- msgs = meeting_engine.each_agent_messages(bare, agents)
1134
- lasts = {a: (msgs[a][-1] if msgs[a] else None) for a in agents}
1135
- types = {a: (lasts[a].get("type") if lasts[a] else None) for a in agents}
1136
- quota_meeting = proto.get("maxMeetingRounds", 10)
1137
- quota_rr = proto.get("maxRRRounds", 7)
1138
- out.append("配额:meeting " + "、".join(
1139
- f"{a} {meeting_core.meeting_speak_count(msgs, a)}/{quota_meeting}"
1140
- for a in agents)
1141
- + f"(消耗/上限,口径 = mode:meeting 且 type:message)"
1142
- f"| RR 上限 {quota_rr}/agent | human 插话 {human_n} 条"
1143
- "(不占配额;各 agent 上限 +human 条数)")
1144
- frozen = meeting_core.frozen_agents(agents, types)
1145
- not_frozen = [a for a in agents if a not in frozen]
1146
- out.append(f"冻结:{len(frozen)}/{len(agents)} 已冻结"
1147
- + (f"({'、'.join(frozen)})" if frozen else "")
1148
- + (f";未冻结 {'、'.join(not_frozen)}" if not_frozen else ""))
1149
- # aggregate_mode 期望 {agent: {type, mode}}(core 判定入口形态)——
1150
- # 用 `.get` 规范化:消息缺字段(老产物/手工 fixture)时按 None 处理,
1151
- # 不得 KeyError(报告契约:读不出 → 降级,不崩)
1152
- mode_now = meeting_core.aggregate_mode(
1153
- {a: ({"type": fm.get("type"), "mode": fm.get("mode")} if fm else None)
1154
- for a, fm in lasts.items()})
1155
- if mode_now == "round-robin":
1156
- out.append(f"RR:轮到 "
1157
- f"{meeting_engine.rr_next_speaker(bare, agents) or '(未定)'}")
1158
- else:
1159
- out.append(f"阶段:{mode_now}")
1160
- # 标题与口径:一次读取派生的三样观测面(配额进度 / 冻结集合 / RR 位置)
1161
-
1162
- # ---- 进程事实(登记字段;日志的唯一机器消费点) ----
1163
- proc = _report_wake_fields(base)
1164
- out.append("进程(loop log 登记字段):")
1165
- if not proc:
1166
- out.append(" n/a(无完成行——尚未唤醒或日志缺失)")
1167
- for a in agents:
1168
- d = proc.get(a)
1169
- if not d:
1170
- out.append(f" {a}: n/a")
1171
- continue
1172
- out.append(f" {a}: 唤醒 {d['wakes']} 次 | 进程跨度 总 "
1173
- f"{_dur(d['total_ms'] // 1000)} / 最大 "
1174
- f"{_dur(d['max_ms'] // 1000)} | rc≠0 {d['fails']} 次")
1175
-
1176
- # ---- LLM 运行事实(session 文档化字段;流式预过滤,不整文件解析) ----
1177
- out.append("LLM(session 文档化字段):")
1178
- any_usage = False
1179
- for a in agents:
1180
- u = _report_session_usage(base, a)
1181
- if not u:
1182
- out.append(f" {a}: n/a")
1183
- continue
1184
- any_usage = True
1185
- out.append(f" {a}: input {u['input']} | cacheRead "
1186
- f"{u['cache_read']} | output {u['output']} | 响应 "
1187
- f"{u['responses']} 次 | error {u['errors']} 次")
1188
- if not any_usage:
1189
- out.append(" n/a(session 缺失,或无本轮数据——边界条目自 2026-09-11 "
1190
- "起写入,此前的老分析不适用)」")
1191
- out.append("(口径:进程跨度=pi 进程生命周期;输出=prompt 分段合计;"
1192
- "墙钟=commit 时间差——三者不可互替)")
1193
- return out
1194
481
 
1195
482
 
1196
- def _dur(sec):
1197
- """人类可读时长(口径由调用方在同一行标注——进程跨度/墙钟/间隔)。"""
1198
- sec = int(sec)
1199
- if sec < 60:
1200
- return f"{sec}s"
1201
- if sec < 3600:
1202
- return f"{sec // 60}m{sec % 60:02d}s"
1203
- return f"{sec // 3600}h{(sec % 3600) // 60:02d}m"
1204
483
 
1205
484
 
1206
- def _hhmm(ts):
1207
- return time.strftime("%H:%M:%S", time.localtime(ts))
1208
485
 
1209
486
 
1210
- def _report_wake_fields(base):
1211
- """解析 loop-*.log 的登记字段(`elapsed_ms` / `rc`)。
1212
487
 
1213
- **日志的唯一机器消费点**(观测面契约:日志零判定输入,报告只读已登记
1214
- 字段——不解析自由文本、不做启发式猜测)。fail-open:读不到 → 跳过。
1215
- """
1216
- out = {}
1217
- for f in sorted(glob.glob(os.path.join(base, "loop-*.log"))):
1218
- agent = os.path.basename(f)[len("loop-"):-len(".log")]
1219
- d = {"wakes": 0, "total_ms": 0, "max_ms": 0, "fails": 0}
1220
- try:
1221
- with open(f, encoding="utf-8", errors="replace") as fh:
1222
- for line in fh:
1223
- m = re.search(r"elapsed_ms=(\d+) rc=(-?\d+)", line)
1224
- if not m:
1225
- continue
1226
- d["wakes"] += 1
1227
- ms, rc = int(m.group(1)), int(m.group(2))
1228
- d["total_ms"] += ms
1229
- d["max_ms"] = max(d["max_ms"], ms)
1230
- if rc != 0:
1231
- d["fails"] += 1
1232
- except OSError:
1233
- continue
1234
- if d["wakes"]:
1235
- out[agent] = d
1236
- return out
1237
488
 
1238
489
 
1239
- def _report_session_usage(base, agent):
1240
- """从该 agent 的 session 文件取 usage(**单一适配器** + 流式预过滤)。
1241
490
 
1242
- 字段来源 = pi 的**文档化** session schema(`docs/session-format.md`:
1243
- `usage` / `stopReason`)。行级预过滤(`"usage" in line` 才 json.loads)
1244
- ——避免对 MB 级文件整解析(实测 json.loads 3MB ≈27ms,预过滤可省大部分)。
1245
- fail-open:文件缺失/字段变 → 返回 {}。
1246
- """
1247
- try:
1248
- with open(os.path.join(base, f"status-{agent}.json")) as f:
1249
- sid = json.load(f).get("sessionID") or ""
1250
- except (OSError, ValueError):
1251
- sid = ""
1252
- if not sid:
1253
- return {}
1254
- fp = os.path.join(base, "pi-sessions", f"fork-src-{sid}.jsonl")
1255
- if not os.path.isfile(fp):
1256
- return {}
1257
- # **只统计边界之后的条目**(本轮运行事实)——fork 携带的历史条目里也
1258
- # 有大量 assistant+usage,全文件统计会把主 pi 的历史算成本次分析的
1259
- # 消耗(2026-09-11 实测:717 条 fork 历史被算成"本轮 367 次响应 /
1260
- # input 1.2M")。边界由 append_handoff_turns 写入(显式登记,非推断)。
1261
- u = {"input": 0, "cache_read": 0, "output": 0, "responses": 0, "errors": 0}
1262
- for ev in meeting_fs.iter_after_boundary(fp):
1263
- m = ev.get("message") or {}
1264
- if m.get("role") != "assistant":
1265
- continue
1266
- u["responses"] += 1
1267
- if m.get("stopReason") == "error":
1268
- u["errors"] += 1
1269
- usage = m.get("usage") or {}
1270
- for k, key in (("input", "input"), ("cacheRead", "cache_read"),
1271
- ("output", "output")):
1272
- v = usage.get(k)
1273
- if isinstance(v, int):
1274
- u[key] += v
1275
- if not u["responses"]:
1276
- return {}
1277
- # 数字格式化(人读):千分位缩写
1278
- for k in ("input", "cache_read", "output"):
1279
- u[k] = _num(u[k])
1280
- return u
1281
491
 
1282
492
 
1283
- def _num(n):
1284
- """人可读数字(k/M 缩写;原值精度对人读报告无意义)。"""
1285
- if n >= 1_000_000:
1286
- return f"{n / 1_000_000:.1f}M"
1287
- if n >= 1_000:
1288
- return f"{n / 1_000:.1f}k"
1289
- return str(n)
1290
493
 
494
+ # ---- 从 spec_gen re-export(S2 拆分;向后兼容:tests/wrapper/extension
495
+ # 里的 `from start_discussion import X` 与 CLI 分发继续工作)----
496
+ from spec_gen import ( # noqa: F401
497
+ check_agent_name, validate_participants, gen_question,
498
+ _strip_empty_sections, gen_spec_skeleton, _spec_read,
499
+ _snapshot_viewers, _discover_viewers, list_agent_md,
500
+ viewer_set_error, gen_agents_md, gen_protocol,
501
+ pi_sessions_dir, current_session_file, _join_model_ref,
502
+ _default_model, _detect_pi_model_thinking, resolve_fork_source,
503
+ PI_AGENT_DIR, MAX_AGENT_NAME_LEN,
504
+ )
505
+ from observability import ( # noqa: F401
506
+ _loop_pids, _loops_alive, check_status, build_report,
507
+ wait_for_completion,
508
+ )
1291
509
 
1292
510
  def main():
1293
511
  parser = argparse.ArgumentParser(description="Meeting 模式讨论环境")
@@ -1295,7 +513,7 @@ def main():
1295
513
  help="讨论运行目录(创建/启动/清理/状态/等待用;--spec-gen 不需要)")
1296
514
  parser.add_argument("--agents", default=None,
1297
515
  help="参与者(逗号分隔,默认 a,b;--spec 时不可传)")
1298
- parser.add_argument("--topic", default=None, help="讨论主题(--skip-setup 时不需要)")
516
+ parser.add_argument("--topic", default=None, help="分析主题(--skip-setup 时不需要)")
1299
517
  parser.add_argument("--stances", default=None, help='JSON: {"a": "立场"}')
1300
518
  parser.add_argument("--background", default=None, help="背景说明")
1301
519
  parser.add_argument("--questions", default=None, help="待回答问题(|分隔,对齐 RR)")
@@ -1303,7 +521,8 @@ def main():
1303
521
  parser.add_argument("--result-writer", default=None, help="resultWriter(默认最后一位参与者)")
1304
522
  parser.add_argument("--max-meeting", type=int, default=10, help="meeting 阶段发言配额(每 agent)")
1305
523
  parser.add_argument("--max-rr", type=int, default=7, help="RR 阶段轮次配额(starter)")
1306
- parser.add_argument("--stall-timeout", type=int, default=600,
524
+ parser.add_argument("--stall-timeout", type=int,
525
+ default=meeting_fs.DEFAULT_STALL_TIMEOUT,
1307
526
  help="无进展超时兜底(秒,默认 600;防 provider API 慢)")
1308
527
  parser.add_argument("--spec-gen", metavar="DIR", default=None,
1309
528
  help="生成 spec 骨架到 DIR(如 --spec-gen myspec/;不需 --dir)")
@@ -1482,4 +701,4 @@ def main():
1482
701
 
1483
702
  if __name__ == "__main__":
1484
703
  # 传递 main 返回码(--wait 超时返回 1——此前被丢弃,wrapper 判据失效)
1485
- sys.exit(main() or 0)
704
+ sys.exit(main() or 0)