pi-multi-viewers 0.1.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.
Files changed (35) hide show
  1. package/AGENTS.md +192 -0
  2. package/README.md +153 -0
  3. package/docs/design.md +288 -0
  4. package/docs/examples/first-experiment/README.md +42 -0
  5. package/docs/examples/first-experiment/work-a/AGENTS.md +10 -0
  6. package/docs/examples/first-experiment/work-a/a/0001.md +65 -0
  7. package/docs/examples/first-experiment/work-a/a/0002.md +70 -0
  8. package/docs/examples/first-experiment/work-a/perspective.md +5 -0
  9. package/docs/examples/first-experiment/work-b/AGENTS.md +10 -0
  10. package/docs/examples/first-experiment/work-b/b/0001.md +100 -0
  11. package/docs/examples/first-experiment/work-b/perspective.md +6 -0
  12. package/docs/reviews/2026-09-10-e2e10-fork-source-modes-review.md +366 -0
  13. package/docs/reviews/2026-09-10-e2e11-forkmode-guards-review.md +229 -0
  14. package/docs/reviews/2026-09-10-e2e12-code-review.md +346 -0
  15. package/docs/reviews/2026-09-11-e2e13-code-review.md +284 -0
  16. package/docs/reviews/2026-09-11-e2e14-observability-review.md +216 -0
  17. package/docs/reviews/README.md +36 -0
  18. package/docs/test-methodology.md +258 -0
  19. package/extensions/multi-viewers-say/index.ts +156 -0
  20. package/fake_agent.py +120 -0
  21. package/human_sayer.py +144 -0
  22. package/human_viewer.py +215 -0
  23. package/meeting_core.py +255 -0
  24. package/meeting_engine.py +733 -0
  25. package/meeting_fs.py +1066 -0
  26. package/meeting_loop.py +606 -0
  27. package/package.json +41 -0
  28. package/prompts/multi-viewers.md +94 -0
  29. package/scripts/check-residue.sh +190 -0
  30. package/scripts/mv.sh +325 -0
  31. package/start_discussion.py +1485 -0
  32. package/templates/AGENTS.md.tpl +100 -0
  33. package/templates/agent.md.tpl +9 -0
  34. package/templates/gitignore.tpl +7 -0
  35. package/templates/spec-readme.md.tpl +87 -0
@@ -0,0 +1,1485 @@
1
+ #!/usr/bin/env python3
2
+ """start_discussion.py —— Meeting 模式讨论环境生成 + 启动(独立于 agents-rr-discuss)。
3
+
4
+ 用法:
5
+ python3 start_discussion.py --dir mymeet --topic "主题" --agents a,b \
6
+ [--stances '{"a": "立场1", "b": "立场2"}'] [--start] [--pure] \
7
+ [--models '{"a": "provider/model"}'] [--max-meeting 10] [--max-rr 7]
8
+
9
+ 复杂内容用 spec 规格目录(设计 16,与 CLI 内容参数互斥):
10
+ 1. 生成骨架: python3 start_discussion.py --spec-gen myspec --agents a,b,c
11
+ 2. 编辑内容: vim myspec/question.md myspec/background.md myspec/models.md myspec/agents/*.md
12
+ 3. 创建讨论: python3 start_discussion.py --dir mymeet --spec myspec/ --result-writer c
13
+
14
+ 生命周期:
15
+ 创建(--dir)→ 启动(--start,可选)→ 观察(--status/--wait)→ 清理(--cleanup)
16
+ """
17
+
18
+ import argparse
19
+ import glob
20
+ import json
21
+ import os
22
+ import meeting_fs
23
+ import human_viewer
24
+ import meeting_core
25
+ import meeting_engine
26
+ import re
27
+ import shutil
28
+ import subprocess
29
+ import sys
30
+ import time
31
+
32
+ HERE = os.path.dirname(os.path.abspath(__file__))
33
+ TPL_DIR = os.path.join(HERE, "templates")
34
+ PI_AGENT_DIR = os.environ.get("PI_CODING_AGENT_DIR",
35
+ os.path.expanduser("~/.pi/agent"))
36
+ GIT_USER = "meeting-bot"
37
+ GIT_EMAIL = "meeting-bot@local"
38
+
39
+
40
+ def run_cmd(cmd, cwd=None, check=True):
41
+ """执行一次性环境命令(git init/clone/config/push 等)。
42
+
43
+ **读路径不走这里**:读取仓库内容的 git 命令一律用 `meeting_fs.run_git`
44
+ (带 core.quotepath=false 加固——中文视角名下 quotepath 转义会让路径
45
+ 解析失效;两个入口并存时加固只覆盖一半,是同类问题的潜在根因)。
46
+ """
47
+ r = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True)
48
+ if check and r.returncode != 0:
49
+ raise RuntimeError(f"cmd {cmd} 失败: {r.stderr.strip()}")
50
+ return r
51
+
52
+
53
+ def _spec_read(spec_dir, rel):
54
+ """读 spec 文件内容,永远跳过第一行(说明行,B 方案)。
55
+
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
154
+
155
+
156
+ def _spec_models(spec_dir, participants):
157
+ """解析 models.md(容错,用户 8024/9204 定):返回 {agent: (model, variant)}。
158
+
159
+ 每行格式:`agent名: model, variant`(model 与 variant 逗号分隔)。
160
+ - model 缺省/'default' → None(创建时填本机默认模型)
161
+ - variant 缺省/'default'/'max' → 'max'(默认档,专业用户才改)
162
+ 容错:空行/无 ':'/agent 不在 participants → 跳过;单字段行只有 model。
163
+ 规则:第一行说明跳过(_spec_read)。
164
+ """
165
+ if spec_dir is None:
166
+ return {}
167
+ content = _spec_read(spec_dir, "models.md")
168
+ if content is None:
169
+ return {}
170
+ out = {}
171
+ for line in content.splitlines():
172
+ line = line.strip()
173
+ if not line or ":" not in line:
174
+ continue
175
+ agent, _, rest = line.partition(":")
176
+ agent = agent.strip()
177
+ if agent not in participants:
178
+ continue
179
+ # 逗号分隔:model, variant(variant 可缺省)
180
+ parts = [p.strip() for p in rest.split(",")]
181
+ model = parts[0] if parts and parts[0] else ""
182
+ variant = parts[1] if len(parts) > 1 else ""
183
+ m = None if (not model or model == "default") else model
184
+ v = "max" if (not variant or variant == "default") else variant
185
+ out[agent] = (m, v)
186
+ return out
187
+
188
+
189
+ def _strip_empty_sections(question):
190
+ """去掉 question.md 中未填的可选节(打磨项 2026-09-01 讨论结论)。
191
+
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
+
302
+
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
+
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
+
341
+
342
+ def gen_agent_def(agent, participants, models=None, stances=None, extra=None):
343
+ """agent prompt 文件(Pi 适配:纯 markdown,无 opencode frontmatter)。
344
+
345
+ Pi 没有 opencode agent 定义机制;每个 agent 的身份/分工通过
346
+ --append-system-prompt 注入。模型与 thinking 写进 pi-agent.json,
347
+ 由 meeting_loop 启动时以 --model/--thinking 传入。
348
+ 分层:**身份由本函数生成**(agent 名 = 文件名,单一来源);视角内容
349
+ (lenses/边界/交锋义务)来自 spec/agents/X.md 或 viewers/X.md。
350
+ 共享协议/背景在 AGENTS.md;话题/立场/问题在 question.md。
351
+ extra: 视角任务书正文——追加在"你的视角任务书"标题之后。
352
+ (thinking/variant 由 pi-agent.json 承载,不在本函数定义中)
353
+ """
354
+ model_body = ""
355
+ if models and agent in models:
356
+ model_body = f"你使用模型 {models[agent]} 参与讨论。\n"
357
+ stance_ref = ("你的立场和观点见 question.md 与讨论中的发言。\n"
358
+ if stances and agent in stances else "")
359
+ with open(os.path.join(TPL_DIR, "agent.md.tpl")) as f:
360
+ tpl = f.read()
361
+ result = (tpl
362
+ .replace("{AGENT_NAME}", agent)
363
+ .replace("{N}", str(len(participants)))
364
+ .replace("{PARTICIPANTS_DISPLAY}", "、".join(participants))
365
+ .replace("{MODEL_BODY}", model_body)
366
+ .replace("{STANCE_REF}", stance_ref))
367
+ if extra:
368
+ # 视角正文接在"## 你的视角任务书"标题下(本函数只管拼接,
369
+ # 身份与任务书的分界由模板定义)
370
+ result = result.rstrip() + "\n\n" + extra.strip() + "\n"
371
+ return result
372
+
373
+
374
+ def gen_question(topic, stances, background, questions):
375
+ """question.md(讨论起点:话题 + 可选立场 + 待回答问题)。
376
+
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
418
+
419
+
420
+ def _resolve_path(p):
421
+ """路径解析(review4 L10 抽取):含路径符(/~.)→ 绝对路径;否则 cwd 下拼接。"""
422
+ if any(ch in p for ch in "/~."):
423
+ return os.path.abspath(os.path.expanduser(p))
424
+ return os.path.join(os.getcwd(), p)
425
+
426
+ MAX_AGENT_NAME_LEN = 32
427
+
428
+
429
+ def pi_sessions_dir(cwd):
430
+ """主 pi session 目录(编码约定单点,T7 收归 e2e7 评审)。
431
+
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
+
440
+
441
+ def current_session_file():
442
+ """当前主 pi 的 session 文件路径(解析不到 → 空串)。
443
+
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
+
498
+
499
+ def validate_participants(participants):
500
+ """整组名字校验(**名字规则的唯一入口**)。返回错误或 None。
501
+
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
+
511
+
512
+ def viewer_set_error(names, empty, where="viewers/"):
513
+ """viewers 集合级校验(**唯一实现**):空正文视角 + 至少 2 个。
514
+
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
+
556
+
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
+
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
614
+
615
+
616
+ def _resolve_spec(spec, agents, topic, background, stances, questions, models,
617
+ viewers_dir=None):
618
+ """--spec 模式解析(审核#5 抽成可测函数):互斥校验 + spec 目录 +
619
+ participants 推断 + question.md 必填。
620
+
621
+ participants 来源优先级:spec/agents/(显式自定义,含 .order)>
622
+ viewers_dir 发现(项目稳定视角,*.md 文件名即 agent 名)> 错误。
623
+ 返回 (spec_dir, participants, viewer_briefs, error)——error 非 None 时
624
+ 前两者为 None;viewer_briefs = {agent: 视角正文}(viewers 模式非空)。
625
+ """
626
+ if not spec:
627
+ return None, None, None, None
628
+ # 明确互斥(用户 7782):内容/参与者参数二选一,不留"忽略/优先"中间态
629
+ # (审核#18→review4 M2):--agents 默认 None——显式传(任何值,含 a,b)
630
+ # 一律报互斥,消除默认值字符串比较的漏报/误报
631
+ conflicting = []
632
+ if agents is not None:
633
+ conflicting.append("--agents")
634
+ if topic:
635
+ conflicting.append("--topic")
636
+ if background:
637
+ conflicting.append("--background")
638
+ if stances:
639
+ conflicting.append("--stances")
640
+ if questions:
641
+ conflicting.append("--questions")
642
+ if models:
643
+ conflicting.append("--models")
644
+ if conflicting:
645
+ return None, None, None, (
646
+ f"错误: --spec 与 {', '.join(conflicting)} 互斥——"
647
+ f"内容要么全在 spec,要么全在命令行")
648
+ # spec 目录解析(相对 → cwd 下,L10 抽取)
649
+ spec_dir = _resolve_path(spec)
650
+ if not os.path.isdir(spec_dir):
651
+ return None, None, None, f"错误: spec 目录不存在 {spec_dir}"
652
+ agents_dir = os.path.join(spec_dir, "agents")
653
+ viewer_briefs = {}
654
+ if os.path.isdir(agents_dir):
655
+ # participants 从 spec/agents/ 推断(无 --agents,避免冲突)
656
+ # 顺序:agents/.order 固化 --agents 顺序(审核#6),未列出的 .md 按
657
+ # 字母序追加(用户增删 agent 自然处理);无 .order 回退 sorted
658
+ order_file = os.path.join(agents_dir, ".order")
659
+ if os.path.isfile(order_file):
660
+ with open(order_file) as f:
661
+ order = [l.strip() for l in f.read().splitlines() if l.strip()]
662
+ listed = [p for p in order
663
+ if os.path.isfile(os.path.join(agents_dir, f"{p}.md"))]
664
+ listed_set = set(listed)
665
+ extra = sorted(n for n in list_agent_md(agents_dir)
666
+ if n not in listed_set)
667
+ participants = listed + extra
668
+ else:
669
+ participants = list_agent_md(agents_dir)
670
+ if not participants:
671
+ return None, None, None, "错误: spec/agents/ 下没有 agent 定义文件"
672
+ else:
673
+ # viewers 发现(多视角产品约定):cwd/viewers/*.md,文件名即 agent 名
674
+ participants, viewer_briefs, empty = _discover_viewers(viewers_dir)
675
+ if participants is None:
676
+ return None, None, None, (
677
+ "错误: spec 缺少 agents/ 且未找到 viewers/ 目录"
678
+ "(项目 cwd 下建 viewers/<视角名>.md,或 --spec-gen --agents 生成)")
679
+ # 集合级校验(空正文 / ≥2)——名字规则由 main 的中心闸门统一把
680
+ # (同一实现;此处只管 viewers 特有的集合级规则)
681
+ err = viewer_set_error(participants, empty)
682
+ if err:
683
+ return None, None, None, err
684
+ # spec 必须有 question.md(讨论起点不可缺)
685
+ if not os.path.isfile(os.path.join(spec_dir, "question.md")):
686
+ return None, None, None, "错误: spec 缺少 question.md(讨论起点,先 --spec-gen 生成)"
687
+ # 空正文校验(审核#19):删到只剩说明行 → 无讨论主题(CLI 路径有
688
+ # --topic 必填对等约束)
689
+ if not (_spec_read(spec_dir, "question.md") or "").strip():
690
+ return None, None, None, "错误: spec 的 question.md 正文为空(讨论起点不可缺)"
691
+ return spec_dir, participants, viewer_briefs, None
692
+
693
+
694
+ def _clone_work(base, p):
695
+ """clone work-<p> + 配置 git 身份 + 建本地目录(T6 后唯一 clone 入口)。
696
+
697
+ git 身份在此统一配置(调用方不再重复 config——e2e7 评审 T6)。
698
+ """
699
+ workdir = os.path.join(base, f"work-{p}")
700
+ run_cmd(["git", "clone", meeting_fs.bare_of_base(base), workdir])
701
+ run_cmd(["git", "config", "user.name", GIT_USER], cwd=workdir)
702
+ run_cmd(["git", "config", "user.email", GIT_EMAIL], cwd=workdir)
703
+ for sub in [".pi/agent", p]: # L9:只建自己的目录(读走 bare,写有 makedirs 兜底)
704
+ os.makedirs(os.path.join(workdir, sub), exist_ok=True)
705
+ return workdir
706
+
707
+
708
+ def setup_environment(args, participants, base, spec_dir=None,
709
+ viewer_briefs=None):
710
+ """生成讨论环境(bare + clones + 配置 + setup commit + 重建)。
711
+
712
+ spec_dir: 讨论规格目录(设计 16)——内容优先:question.md →
713
+ question.md、background.md → AGENTS.md 背景节、agents/X.md → agent 定义
714
+ 正文。逐文件独立回退(缺哪个走 CLI/占位)。
715
+ viewer_briefs: viewers 发现的视角任务书 {agent: 正文}(2026-09-09)——
716
+ 优先级低于 spec/agents/(显式自定义胜出),作为 agent 定义正文。
717
+ """
718
+ # spec 内容预读(跳过首行说明)
719
+ spec_question = _spec_read(spec_dir, "question.md") if spec_dir else None
720
+ if spec_question is not None:
721
+ # 未填的可选节(占位符)去掉,避免注入混入模板内容(打磨项 2026-09-01)
722
+ spec_question = _strip_empty_sections(spec_question)
723
+ # topic 固化(e2e7 评审 W——此前 spec 主路径 protocol.json.topic
724
+ # 恒空串:gen_protocol(args.topic=None) 碰巧工作因 AGENTS.md 不
725
+ # 消费 topic,但 protocol 是单一事实源,空串是撒谎)。提取
726
+ # question.md 的 "# 分析主题:" 行;无可辨识主题行 → fail-fast
727
+ # (与 _snapshot_viewers 的"不合规零产物"同哲学)
728
+ spec_topic = None
729
+ if spec_question is not None:
730
+ 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:
737
+ break
738
+ if spec_dir and not spec_topic:
739
+ raise ValueError(
740
+ f"错误: spec 的 question.md 缺少 '# 分析主题:' 行(无法固化 "
741
+ f"protocol.topic)——补主题行后重试")
742
+ spec_background = _spec_read(spec_dir, "background.md") if spec_dir else None
743
+ spec_agents = {}
744
+ if spec_dir:
745
+ for p in participants:
746
+ c = _spec_read(spec_dir, f"agents/{p}.md")
747
+ if c is not None:
748
+ spec_agents[p] = c
749
+ # viewers briefs:spec/agents/ 优先(显式自定义胜出),否则 viewers 正文
750
+ agent_extra = {p: (spec_agents.get(p) or (viewer_briefs or {}).get(p))
751
+ for p in participants}
752
+ agent_extra = {k: v for k, v in agent_extra.items() if v}
753
+ # models:spec 模式从 models.md 读(自包含,{agent: (model, variant)}),
754
+ # CLI --models 已互斥({agent: model} 旧格式——variant 用默认 max)
755
+ if spec_dir:
756
+ models = _spec_models(spec_dir, participants)
757
+ else:
758
+ models = {p: (m, "max") for p, m in (args.models or {}).items()}
759
+ # default 模型 → 创建时实时获取 Pi 默认模型填入:
760
+ # pi-agent.json 带 model 后,meeting_loop 才会传 --model;
761
+ # 骨架期 models.md 仍写 default(--spec-gen 不获取),创建时(--spec)
762
+ # 才解析。运行期固化不变(环境自包含)。
763
+ if spec_dir:
764
+ dm = _default_model()
765
+ if dm:
766
+ for p in participants:
767
+ if p not in models or models[p][0] is None:
768
+ models[p] = (dm, models.get(p, (None, "max"))[1])
769
+ # stance_ref(agent 定义"立场见 question.md"提示):spec 模式一律保留
770
+ # (设计 16.6:无法程序判断 question.md 有无立场节 → 一律提示;
771
+ # 互斥下 CLI stances 必为 None,传占位 dict 触发生成)
772
+ stances_arg = (args.stances if not spec_dir
773
+ else {p: "" for p in participants})
774
+
775
+ os.makedirs(base, exist_ok=True)
776
+ run_cmd(["git", "init", "--bare", meeting_fs.bare_of_base(base)])
777
+
778
+ # T6 重构(e2e7 评审):原流程 = 全部 clone → 写共享 → commit → 再
779
+ # rmtree+clone 重建 others + 回写本地文件(2N-1 次 clone,~40% 冗余;
780
+ # "保存→删→克隆→回写"是为绕开未跟踪文件冲突的补丁)。新流程:
781
+ # 先 clone work-a 提交共享配置,再 clone others(一次拿到 setup
782
+ # commit)——clone 恰 N 次,无重建、无回写、无重复 git config。
783
+ wa = os.path.join(base, f"work-{participants[0]}")
784
+ if os.path.exists(wa):
785
+ shutil.rmtree(wa)
786
+ _clone_work(base, participants[0]) # clone + git 身份 + 建目录
787
+
788
+ # 共享配置(work-a 提交,setup commit 进 bare)
789
+ with open(os.path.join(wa, "protocol.json"), "w") as f:
790
+ json.dump(gen_protocol(spec_topic or args.topic, participants, args.max_meeting,
791
+ args.max_rr, args.pure, args.result_writer,
792
+ args.stall_timeout,
793
+ fork_source=getattr(args, "fork_source", None),
794
+ fork_cwd=os.getcwd(),
795
+ fork_mode=getattr(args, "fork_mode", meeting_fs.DEFAULT_FORK_MODE)),
796
+ f, indent=2, ensure_ascii=False)
797
+ with open(os.path.join(wa, "question.md"), "w") as f:
798
+ if spec_question is not None:
799
+ # spec 提供 → 整文件(跳过首行)作为 question.md(设计 16.6)
800
+ f.write(spec_question + "\n")
801
+ else:
802
+ f.write(gen_question(args.topic, args.stances, args.background,
803
+ args.questions))
804
+ with open(os.path.join(TPL_DIR, "gitignore.tpl")) as gtf:
805
+ gitignore = gtf.read()
806
+ with open(os.path.join(wa, ".gitignore"), "w") as f:
807
+ f.write(gitignore)
808
+ run_cmd(["git", "add", "-A"], cwd=wa)
809
+ run_cmd(["git", "-c", f"user.name={GIT_USER}", "-c", f"user.email={GIT_EMAIL}",
810
+ "commit", "-m", "discuss: setup"], cwd=wa)
811
+ # push 当前分支(不用硬编码 master——用户可能配置了
812
+ # init.defaultBranch=main,硬编码会导致 bare 双分支、clone 检出空
813
+ # 分支 → 环境损坏。审核 C2。)
814
+ branch = run_cmd(["git", "branch", "--show-current"], cwd=wa,
815
+ check=False).stdout.strip()
816
+ run_cmd(["git", "push", meeting_fs.bare_of_base(base),
817
+ branch or "master"], cwd=wa)
818
+
819
+ # others clone(直接拿到 setup commit;work-a 已在上方创建)
820
+ for p in participants[1:]:
821
+ workdir = os.path.join(base, f"work-{p}")
822
+ if os.path.exists(workdir):
823
+ shutil.rmtree(workdir)
824
+ _clone_work(base, p) # clone + git 身份 + 建目录
825
+
826
+ # 本地配置(每个 work 各自;.gitignore 已随 setup commit 分发)
827
+ for p in participants:
828
+ workdir = os.path.join(base, f"work-{p}")
829
+ with open(os.path.join(workdir, "AGENTS.md"), "w") as f:
830
+ f.write(gen_agents_md(args, p, participants, spec_background,
831
+ main_pi_cwd=os.getcwd()))
832
+ mv = models.get(p, (None, "max"))
833
+ with open(os.path.join(workdir, ".pi/agent", f"{p}.md"), "w") as f:
834
+ f.write(gen_agent_def(p, participants, {p: mv[0]} if mv[0] else None,
835
+ stances_arg, agent_extra.get(p)))
836
+ with open(os.path.join(workdir, "pi-agent.json"), "w") as f:
837
+ json.dump({
838
+ "model": mv[0] or "",
839
+ "thinking": mv[1] if mv[1] else "max",
840
+ "prompt_file": f".pi/agent/{p}.md",
841
+ }, f, indent=2, ensure_ascii=False)
842
+
843
+ # work-human:human 插话的提交通道(helper 设计 §5.4)——
844
+ # 固定存在、不占参与者名额、无 agent 定义/pi-agent.json/AGENTS.md
845
+ # (human 无 LLM 身份),不启动 loop 进程。
846
+ # **必须在重建循环之外独立创建**(循环内会每迭代 clone 一次 →
847
+ # 第二个参与者起 already exists,实测暴露);rmtree 守卫幂等。
848
+ wh = os.path.join(base, "work-human")
849
+ if os.path.exists(wh):
850
+ shutil.rmtree(wh)
851
+ _clone_work(base, "human")
852
+
853
+ # 复制 meeting_loop.py + 依赖模块(脚本同目录,自包含)
854
+ for mod in ["meeting_loop.py", "meeting_fs.py", "meeting_core.py",
855
+ "meeting_engine.py"]:
856
+ shutil.copy(os.path.join(HERE, mod), os.path.join(base, mod))
857
+ rw = args.result_writer or participants[-1]
858
+ print(f"[setup] 环境就绪: {base}({len(participants)} agents: {', '.join(participants)})")
859
+ print(f"[setup] resultWriter={rw}, maxMeeting={args.max_meeting}, maxRR={args.max_rr}, "
860
+ f"立场={'有' if (args.stances or spec_dir) else '无'}, pure={args.pure}")
861
+
862
+
863
+ def _preserve_result_md(base):
864
+ """清理前保存 result.md(薄包装 → meeting_fs.preserve_result_md,
865
+ T2 合并:与 loop 退出路径共享同一实现)。"""
866
+ dest = meeting_fs.preserve_result_md(base)
867
+ if dest:
868
+ print(f"[cleanup] 已保存 result.md → {dest}")
869
+
870
+
871
+ def cleanup_discussion(base):
872
+ """清理一次讨论:保存 result.md(若存在)→ 删目录。
873
+
874
+ result.md 是讨论唯一产物(审核报告等)——清理前先从 bare git 历史
875
+ 复制到父级目录(<base名>-result.md),避免清理丢产物(用户建议)。
876
+ Pi 的 session 文件存放在 <base>/pi-sessions,随目录一起删除,无需
877
+ 额外清理全局 DB。
878
+ 不负责终止 loop 进程(职责边界,用户 2026-08-31 定)——loop 每轮
879
+ 检测到 repo.git 消失即自行退出(meeting_engine.agent_loop)。
880
+ """
881
+ if not os.path.isdir(base):
882
+ print(f"[cleanup] 目录不存在: {base}")
883
+ return
884
+ _preserve_result_md(base)
885
+ # 报告(**删目录前最后一次可读**——目录删后 --report 不可用)。
886
+ # 报告是附加信息、清理是主职责:报告生成失败**不阻断**清理
887
+ # (fail-open 只在这一层兜底——build_report 内部各段已各自 fail-open)。
888
+ print("[cleanup] —— 本次分析报告(删除目录前最后一次可读)——")
889
+ try:
890
+ for line in build_report(base):
891
+ print(line)
892
+ except Exception as e: # noqa: BLE001(兜底不吞:打印)
893
+ print(f"[cleanup] 报告生成失败(不影响清理): {e!r}")
894
+ shutil.rmtree(base)
895
+ print(f"[cleanup] 已删除目录 {base}(含 pi-sessions)")
896
+
897
+
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
+
926
+ def _loops_alive(base):
927
+ """讨论的 loop 进程是否存活(argv 精确匹配,见 _loop_pids)。"""
928
+ return bool(_loop_pids(base))
929
+
930
+
931
+ def check_status(base):
932
+ """讨论状态(单值;状态全集显式于此,T3/#7 修复 e2e7 评审):
933
+
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
+
970
+
971
+ def _parse_agents(agents_arg):
972
+ """--agents 解析(唯一实现,2026-09-09 从 wrapper 收归):
973
+ None → [];纯数字 n → a..(第 n 个字母);逗号分隔 → 名称列表
974
+ (去空白、滤空段)。数字下限 2(meeting 至少两个 LLM agents)。
975
+ 返回 (participants, error)。
976
+ """
977
+ if agents_arg is None:
978
+ return [], None
979
+ if agents_arg.isdigit():
980
+ n = int(agents_arg)
981
+ if n < 2:
982
+ return None, "错误: agents 数量至少为 2(meeting 至少两个 LLM agents)"
983
+ if n > 26:
984
+ return None, "错误: agents 数量最多 26(a..z)"
985
+ return [chr(97 + i) for i in range(n)], None
986
+ participants = [a.strip() for a in agents_arg.split(",") if a.strip()]
987
+ # 完整名字规则(非空/无空白与路径分隔符/非 human/≤32)——单实现。
988
+ # 此前只查 human,非法名(如 "a/b")会一路走到 spec-gen 才炸:
989
+ # FileNotFoundError traceback + spec 半成品落盘(实测)
990
+ err = validate_participants(participants)
991
+ if err:
992
+ return None, err
993
+ return participants, None
994
+
995
+
996
+ def wait_for_completion(base):
997
+ """`--wait`:阻塞展示进展直到收尾或终态。返回退出码(0 完成 / 1 终止)。
998
+
999
+ 从 main 内联抽出(main 里最大单块;项目方法论把 main/CLI 分发
1000
+ 列为独立测试盲区)。**纯结构变换**:调用序列与 sleep 序列逐字
1001
+ 不变——helper 只做机械动作(状态判定 → 打印 → 增量展示 → sleep),
1002
+ 不吸收"何时进入分支"的阶段判断。
1003
+
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
+
1195
+
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
+
1205
+
1206
+ def _hhmm(ts):
1207
+ return time.strftime("%H:%M:%S", time.localtime(ts))
1208
+
1209
+
1210
+ def _report_wake_fields(base):
1211
+ """解析 loop-*.log 的登记字段(`elapsed_ms` / `rc`)。
1212
+
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
+
1238
+
1239
+ def _report_session_usage(base, agent):
1240
+ """从该 agent 的 session 文件取 usage(**单一适配器** + 流式预过滤)。
1241
+
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
+
1282
+
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
+
1291
+
1292
+ def main():
1293
+ parser = argparse.ArgumentParser(description="Meeting 模式讨论环境")
1294
+ parser.add_argument("--dir",
1295
+ help="讨论运行目录(创建/启动/清理/状态/等待用;--spec-gen 不需要)")
1296
+ parser.add_argument("--agents", default=None,
1297
+ help="参与者(逗号分隔,默认 a,b;--spec 时不可传)")
1298
+ parser.add_argument("--topic", default=None, help="讨论主题(--skip-setup 时不需要)")
1299
+ parser.add_argument("--stances", default=None, help='JSON: {"a": "立场"}')
1300
+ parser.add_argument("--background", default=None, help="背景说明")
1301
+ parser.add_argument("--questions", default=None, help="待回答问题(|分隔,对齐 RR)")
1302
+ parser.add_argument("--models", default=None, help='JSON: {"a": "provider/model"}')
1303
+ parser.add_argument("--result-writer", default=None, help="resultWriter(默认最后一位参与者)")
1304
+ parser.add_argument("--max-meeting", type=int, default=10, help="meeting 阶段发言配额(每 agent)")
1305
+ parser.add_argument("--max-rr", type=int, default=7, help="RR 阶段轮次配额(starter)")
1306
+ parser.add_argument("--stall-timeout", type=int, default=600,
1307
+ help="无进展超时兜底(秒,默认 600;防 provider API 慢)")
1308
+ parser.add_argument("--spec-gen", metavar="DIR", default=None,
1309
+ help="生成 spec 骨架到 DIR(如 --spec-gen myspec/;不需 --dir)")
1310
+ parser.add_argument("--spec", default=None,
1311
+ help="讨论规格目录(内容源:question/background/agents,优先于 CLI 内容参数)")
1312
+ parser.add_argument("--fork-mode", default=meeting_fs.DEFAULT_FORK_MODE,
1313
+ choices=list(meeting_fs.FORK_MODES),
1314
+ help="fork 裁剪策略:budget=预算+折叠(默认,长会话可行);"
1315
+ "compaction=按 compaction 边界(中小会话零损失);"
1316
+ "full=全量(小会话/验证)")
1317
+ parser.add_argument("--fork-source", default=None,
1318
+ help="主 session 文件绝对路径(fork-only):写入 "
1319
+ "protocol.json,各 agent 首唤由本地生成 fork "
1320
+ "源挂载主上下文;不传 = 从主 pi 环境自动解析"
1321
+ "(PI_SESSION_ID;解析失败明确报错)")
1322
+ parser.add_argument("--pure", action="store_true", help="--pure 模式(禁外部插件)")
1323
+ parser.add_argument("--start", action="store_true", help="创建后启动讨论")
1324
+ parser.add_argument("--skip-setup", action="store_true",
1325
+ help="跳过环境生成,只启动已有环境(需 --dir)")
1326
+ parser.add_argument("--cleanup", action="store_true", help="清理讨论(目录,含 pi-sessions)")
1327
+ parser.add_argument("--status", action="store_true", help="检查讨论状态")
1328
+ parser.add_argument("--report", action="store_true",
1329
+ help="只读报告(观测面聚合:流程/配额/进程/LLM;"
1330
+ "冷路径一次性,不持久化)")
1331
+ parser.add_argument("--wait", action="store_true", help="阻塞直到讨论完成")
1332
+ args = parser.parse_args()
1333
+
1334
+ participants, agents_err = _parse_agents(args.agents)
1335
+ if agents_err:
1336
+ print(agents_err)
1337
+ sys.exit(1)
1338
+ try:
1339
+ args.stances = json.loads(args.stances) if args.stances else None
1340
+ args.models = json.loads(args.models) if args.models else None
1341
+ except ValueError as e:
1342
+ print(f"错误: JSON 参数解析失败: {e}(--stances/--models 需合法 JSON)")
1343
+ sys.exit(1)
1344
+ args.questions = args.questions.split("|") if args.questions else None
1345
+
1346
+ # --spec-gen 直接带目录位置参数(--spec-gen myspec/,不需 --dir/--spec)
1347
+ if args.spec_gen:
1348
+ # agents/ 三态(2026-09-09):显式 --agents = 占位骨架(覆盖路径);
1349
+ # 缺省 = viewers 快照(校验前移——不合规零产物);python 单一事实源,
1350
+ # bash --prepare 与 CLI 直用产出一致
1351
+ spec_dir = _resolve_path(args.spec_gen) # L10
1352
+ viewers_dir = (os.path.join(os.getcwd(), "viewers")
1353
+ if not participants else None)
1354
+ participants, err = gen_spec_skeleton(
1355
+ spec_dir, participants, topic=args.topic,
1356
+ background=args.background, viewers_dir=viewers_dir)
1357
+ if err:
1358
+ print(err)
1359
+ sys.exit(1)
1360
+ print(f"[spec-gen] 已生成骨架: {spec_dir}")
1361
+ return
1362
+
1363
+ # 其他模式必须 --dir(讨论运行目录)
1364
+ if not args.dir:
1365
+ print("错误: 需要 --dir(讨论运行目录;--spec-gen 不需要)")
1366
+ sys.exit(1)
1367
+ # --dir 语义分场景(2026-09-03 修正):
1368
+ # 创建模式(--dir 裸名 + 非消费标志):快捷命名 → cwd/discussion-<name>
1369
+ # 消费模式(--cleanup/--status/--wait/--skip-setup,操作已存在目录):
1370
+ # 裸名按字面解释(cwd 下同名目录),存在就用不存在报错——前缀快捷
1371
+ # 只属于创建;操作时套用会找错目录(实测 cleanup 裸名潜伏 bug)
1372
+ consuming = (args.cleanup or args.status or args.wait or args.skip_setup
1373
+ or args.report)
1374
+ if any(ch in args.dir for ch in "/~."):
1375
+ base = os.path.abspath(os.path.expanduser(args.dir))
1376
+ elif consuming:
1377
+ base = os.path.join(os.getcwd(), args.dir)
1378
+ else:
1379
+ base = os.path.join(os.getcwd(), f"discussion-{args.dir}")
1380
+
1381
+ if args.cleanup:
1382
+ cleanup_discussion(base)
1383
+ return
1384
+ if args.status:
1385
+ print(f"[status] {check_status(base)}")
1386
+ return
1387
+ if args.report:
1388
+ for line in build_report(base):
1389
+ print(line)
1390
+ return
1391
+ if args.wait:
1392
+ return wait_for_completion(base)
1393
+ # 创建(--start 总是 setup;--skip-setup = 跳过创建,只启动已有环境)
1394
+ if args.skip_setup:
1395
+ if not os.path.exists(base):
1396
+ print(f"错误: 环境不存在 {base}")
1397
+ sys.exit(1)
1398
+ print(f"[start] 跳过环境生成——只启动已有环境")
1399
+ # 参与者从已有环境的 protocol.json 读(单一事实源 = bare HEAD,
1400
+ # 不依赖 CLI;读不到 → 明确报错,不静默)
1401
+ participants = meeting_fs.read_protocol(
1402
+ meeting_fs.bare_of_base(base)).get("participants", [])
1403
+ if not participants:
1404
+ print("[error] 无法读取已有环境 protocol.json")
1405
+ sys.exit(1)
1406
+ else:
1407
+ # review5 A8:创建前检测 base 已存在——git init --bare 幂等不删
1408
+ # 旧对象,重复 --dir 会复用旧 bare(旧 concluded 污染新讨论)。
1409
+ # 提示先 --cleanup(或手动删目录)。
1410
+ if os.path.exists(base):
1411
+ print(f"错误: 讨论目录已存在 {base}(请先 --cleanup 或删除,"
1412
+ f"避免旧 bare 污染)")
1413
+ sys.exit(1)
1414
+ # 创建分支(--spec 提供内容源时 spec 优先,设计 16.5)
1415
+ spec_dir = None
1416
+ viewer_briefs = {}
1417
+ if args.spec:
1418
+ # 互斥校验 + spec 目录解析 + participants 推断 + question.md 必填
1419
+ # (审核#5:抽成 _resolve_spec 可测函数)
1420
+ spec_dir, parts, viewer_briefs, err = _resolve_spec(
1421
+ args.spec, args.agents, args.topic, args.background,
1422
+ args.stances, args.questions, args.models,
1423
+ viewers_dir=os.path.join(os.getcwd(), "viewers"))
1424
+ if err:
1425
+ print(err)
1426
+ sys.exit(1)
1427
+ participants = parts
1428
+ # 非 spec:--agents 未传 → 默认 a,b(M2:argparse 默认 None)
1429
+ if not args.spec and not participants:
1430
+ participants = ["a", "b"]
1431
+ # agent 名校验 + 非空校验(审核#20:--agents "," 全空)
1432
+ # 2026-09-09 放宽:viewers 模式下文件名即 agent 名(中文人物名/
1433
+ # 视角名合法)——非法 = 空名/路径分隔符/空白/human 保留名/>32 字符
1434
+ if not participants:
1435
+ print("错误: 参与者为空(--agents 或 spec/agents/ 无有效 agent)")
1436
+ sys.exit(1)
1437
+ # 名字合法性(T4 收归:唯一实现 check_agent_name/validate_participants,
1438
+ # 含 human 保留名)
1439
+ err = validate_participants(participants)
1440
+ if err:
1441
+ print(err)
1442
+ sys.exit(1)
1443
+ # resultWriter 必须 ∈ participants(spec 推断或 CLI 的 participants)
1444
+ if args.result_writer and args.result_writer not in participants:
1445
+ print(f"错误: resultWriter {args.result_writer} 不在参与者 {participants} 中")
1446
+ sys.exit(1)
1447
+ # fork-only(2026-09-09 定):创建必须携带主 session 文件——无
1448
+ # fork 上下文的多视角分析违背产品本质,明确报错而非静默退化。
1449
+ # --fork-source 未显式传 → 从主 pi 环境(PI_SESSION_ID)自动解析
1450
+ # (收归 python:与 models 探测同款路径约定)
1451
+ if not args.fork_source:
1452
+ args.fork_source, fork_err = resolve_fork_source()
1453
+ if fork_err:
1454
+ print(fork_err)
1455
+ sys.exit(1)
1456
+ # 无 spec 时创建必须给 --topic(否则是无效的 --start 单独用)
1457
+ if not args.spec and not args.topic:
1458
+ print("错误: 需要 --topic(或使用 --skip-setup 启动已有环境)")
1459
+ sys.exit(1)
1460
+ setup_environment(args, participants, base, spec_dir,
1461
+ viewer_briefs=viewer_briefs)
1462
+ if args.start:
1463
+ # 启动每个 agent 的 meeting_loop(独立进程,git 触发)
1464
+ procs = []
1465
+ for p in participants:
1466
+ workdir = os.path.join(base, f"work-{p}")
1467
+ cmd = [sys.executable, os.path.join(base, "meeting_loop.py"),
1468
+ workdir, p]
1469
+ if args.pure:
1470
+ cmd.append("--pure")
1471
+ # 配额(max-meeting/max-rr/stall-timeout)是环境属性:创建时
1472
+ # 固化在 protocol.json,启动继承(loop 读 protocol 优先)。
1473
+ # 不传 CLI —— 避免无条件覆盖 protocol.json 的固化值
1474
+ # (审核 C1:配额单一事实源;与 pure 处理一致)
1475
+ with open(os.path.join(base, f"loop-{p}.log"), "w") as f:
1476
+ procs.append(subprocess.Popen(cmd, stdout=f,
1477
+ stderr=subprocess.STDOUT,
1478
+ start_new_session=True))
1479
+ print(f"[start] 已拉起 {len(procs)} 个进程"
1480
+ f"(存活未校验;loop 状态见 status-*.json 与 {base}/loop-*.log)")
1481
+
1482
+
1483
+ if __name__ == "__main__":
1484
+ # 传递 main 返回码(--wait 超时返回 1——此前被丢弃,wrapper 判据失效)
1485
+ sys.exit(main() or 0)