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.
package/spec_gen.py ADDED
@@ -0,0 +1,518 @@
1
+ """spec 生成层——question/骨架/agents 快照(从 start_discussion 拆出,S2)。
2
+
3
+ 职责:`mv.sh --prepare` 与 CLI `--spec-gen` 的全部产物生成——
4
+ question.md、models.md 骨架、viewers 校验与快照、agent 定义文件。
5
+ **单一职责**:只生成"分析开始前"的静态产物;环境创建(clone/bare)
6
+ 与运行期状态属于 start_discussion 主文件。
7
+
8
+ 依赖方向:只准 import meeting_core / meeting_fs(不准 import 主文件、
9
+ 不准互相 import——见 start_discussion 顶部分层注释)。
10
+ """
11
+
12
+ import json
13
+ import os
14
+ import re
15
+ import shutil
16
+ import sys
17
+
18
+ HERE = os.path.dirname(os.path.abspath(__file__))
19
+ TPL_DIR = os.path.join(HERE, "templates")
20
+ PI_AGENT_DIR = os.environ.get("PI_CODING_AGENT_DIR",
21
+ os.path.expanduser("~/.pi/agent"))
22
+ MAX_AGENT_NAME_LEN = 32
23
+
24
+ import meeting_fs
25
+ from meeting_fs import run_git, DEFAULT_STALL_TIMEOUT
26
+
27
+
28
+ def _join_model_ref(provider, model_id):
29
+ """按 pi 契约把 (provider, model_id) 拼成完整 model ref。
30
+
31
+ 契约(pi 源码 resolveSpawnContext):PI_PROVIDER=provider、
32
+ PI_MODEL=model id;session 的 model_change 同样分 provider/modelId
33
+ 两字段;settings 的 defaultModel/defaultProvider 同构。**model id 本身
34
+ 可含 '/'**(聚合类 provider 的命名空间 id,如 commandcode-goat 的
35
+ "deepseek/deepseek-v4-flash")——因此拼接是**无条件**的字段拼接,
36
+ 不得按"是否含斜杠"猜形状(形状启发式会把 provider 丢掉,解析到
37
+ 同名的另一个 provider,静默失真;2026-09-10 实测 fix)。
38
+
39
+ provider 缺失时只能原样返回(无法拼接)——这是调用方应保证的前置。
40
+ """
41
+ provider = (provider or "").strip()
42
+ model_id = (model_id or "").strip()
43
+ if not model_id:
44
+ return ""
45
+ if not provider:
46
+ return model_id
47
+ return f"{provider}/{model_id}"
48
+
49
+
50
+ def _default_model():
51
+ """本机默认模型(如 opencode-go/deepseek-v4-flash)。
52
+
53
+ 从 pi settings.json 读取 defaultProvider/defaultModel,按契约拼接
54
+ (_join_model_ref——defaultModel 同样可能是含 '/' 的 id)。
55
+ 无默认模型配置 → 返回 None(pi-agent.json 不写 model,回退 pi 默认)。
56
+ 获取失败(pi 不可用/无 settings)→ 返回 None。
57
+ """
58
+ try:
59
+ with open(os.path.join(PI_AGENT_DIR, "settings.json")) as f:
60
+ cfg = json.load(f)
61
+ provider = cfg.get("defaultProvider") or ""
62
+ model = cfg.get("defaultModel") or ""
63
+ if not model:
64
+ return None
65
+ return _join_model_ref(provider, model) or None
66
+ except (OSError, ValueError):
67
+ return None
68
+
69
+
70
+ def _detect_pi_model_thinking():
71
+ """探测主 pi 当前 model/thinking(spec models.md 预填,对齐 wrapper 旧语义)。
72
+
73
+ 顺序:PI_MODEL/PI_PROVIDER/PI_REASONING_LEVEL 环境变量(wrapper 由主 pi
74
+ bash 注入)→ session 文件最后 model_change/thinking_level_change 事件
75
+ (PI_SESSION_FILE 或 cwd 编码目录最新 jsonl)→ settings 默认(_default_model)。
76
+ 返回 (model, thinking)——缺失项为空串。
77
+ """
78
+ model = os.environ.get("PI_MODEL") or ""
79
+ provider = os.environ.get("PI_PROVIDER") or ""
80
+ thinking = os.environ.get("PI_REASONING_LEVEL") or ""
81
+ # 契约拼接(不按形状猜——id 可含 '/',见 _join_model_ref)
82
+ model = _join_model_ref(provider, model)
83
+ if model and thinking:
84
+ return model, thinking
85
+ # session 文件兜底(查找规则单点:current_session_file)
86
+ try:
87
+ sf = current_session_file()
88
+ if sf:
89
+ sm = st = sp = ""
90
+ with open(sf, encoding="utf-8") as f:
91
+ for line in f:
92
+ line = line.strip()
93
+ if not line:
94
+ continue
95
+ try:
96
+ ev = json.loads(line)
97
+ except ValueError:
98
+ continue
99
+ t = ev.get("type")
100
+ if t == "model_change":
101
+ sm = ev.get("modelId") or sm
102
+ sp = ev.get("provider") or sp
103
+ elif t == "thinking_level_change":
104
+ st = ev.get("thinkingLevel") or st
105
+ if not model and sm:
106
+ # model_change 是 provider + modelId 两字段——同样按契约拼接
107
+ model = _join_model_ref(sp, sm)
108
+ if not thinking and st:
109
+ thinking = st
110
+ except OSError:
111
+ pass
112
+ if not model:
113
+ model = _default_model() or ""
114
+ return model, thinking
115
+
116
+
117
+ def pi_sessions_dir(cwd):
118
+ """主 pi session 目录(编码约定单点,T7 收归 e2e7 评审)。
119
+
120
+ pi 的 session 目录编码 = "--" + 去首尾斜杠 + 内斜杠换 "-" + "--"
121
+ (/root/x → --root-x--;/tmp → --tmp--)。此前该约定在
122
+ _detect_pi_model_thinking 与 resolve_fork_source 两处字面量重复,
123
+ AGENTS.md 明言"编码错一根横线 = 静默解析不到"——风险点不应复制。
124
+ """
125
+ enc = "--" + cwd.strip("/").replace("/", "-") + "--"
126
+ return os.path.join(PI_AGENT_DIR, "sessions", enc)
127
+
128
+
129
+ def current_session_file():
130
+ """当前主 pi 的 session 文件路径(解析不到 → 空串)。
131
+
132
+ **查找规则的唯一实现**(此前两处各写一遍:resolve_fork_source 按
133
+ PI_SESSION_ID 匹配文件名、_detect_pi_model_thinking 兜底取目录内
134
+ **字典序最后**——同一概念两套规则,兜底可能选到别的 session)。
135
+ 规则:PI_SESSION_FILE(pi 直接给的路径,最精确)→ PI_SESSION_ID
136
+ 匹配文件名 → 目录内字典序最后(都无法确认时只能如此,调用方自决
137
+ 是否接受)。
138
+ """
139
+ sf = os.environ.get("PI_SESSION_FILE") or ""
140
+ if sf and os.path.isfile(sf):
141
+ return sf
142
+ sdir = pi_sessions_dir(os.getcwd())
143
+ try:
144
+ cands = sorted(f for f in os.listdir(sdir) if f.endswith(".jsonl"))
145
+ except OSError:
146
+ cands = []
147
+ if not cands:
148
+ return ""
149
+ sid = os.environ.get("PI_SESSION_ID") or ""
150
+ if sid:
151
+ hits = [f for f in cands if sid in f]
152
+ if hits:
153
+ return os.path.join(sdir, hits[-1])
154
+ return os.path.join(sdir, cands[-1])
155
+
156
+
157
+ def resolve_fork_source():
158
+ """从主 pi 环境(PI_SESSION_ID)解析当前 session 文件绝对路径
159
+ (fork-only 启动必需,2026-09-09 从 wrapper 收归——session 文件发现
160
+ 逻辑与 _detect_pi_model_thinking 同款路径约定)。
161
+
162
+ sessions 目录编码 = "--" + 去首尾斜杠内斜杠换 "-" + "--"。
163
+ 返回 (fork_source 或 None, error)——PI_SESSION_ID 未注入/文件缺失
164
+ 都是明确错误(fork-only 无静默退化)。
165
+ """
166
+ sid = os.environ.get("PI_SESSION_ID") or ""
167
+ if not sid:
168
+ return None, ("错误: PI_SESSION_ID 未注入——多视角分析必须在主 pi "
169
+ "session 内经 wrapper 启动。若确实在 session 内,"
170
+ "检查是否有扩展接管了 bash 工具(如 AFT 的 "
171
+ "`~/.config/cortexkit/aft.jsonc` 未设 \"bash\": false)"
172
+ "——接管后 pi 的环境变量不会传入 bash")
173
+ # 委托 current_session_file(查找规则单点);这里只管错误语义
174
+ path = current_session_file()
175
+ if not path or sid not in os.path.basename(path):
176
+ sdir = pi_sessions_dir(os.getcwd())
177
+ enc = os.path.basename(sdir)
178
+ return None, (f"错误: 未找到主 session 文件({enc}/*_{sid}.jsonl)"
179
+ "——fork-only 模式必须挂载主 session")
180
+ return path, None
181
+
182
+
183
+ def _spec_read(spec_dir, rel):
184
+ """读 spec 文件内容,永远跳过第一行(说明行,B 方案)。
185
+
186
+ 设计 16.4:第一行是骨架生成时的用途说明,不注入;正文从第二行起。
187
+ 文件不存在 → 返回 None(逐文件独立回退)。
188
+ """
189
+ fp = os.path.join(spec_dir, rel)
190
+ if not os.path.isfile(fp):
191
+ return None
192
+ with open(fp) as f:
193
+ lines = f.read().splitlines()
194
+ return "\n".join(lines[1:]).strip("\n")
195
+
196
+
197
+ def _strip_empty_sections(question):
198
+ """去掉 question.md 中未填的可选节(打磨项 2026-09-01 讨论结论)。
199
+
200
+ 模板生成的可选节(## 初始立场 / ## 待回答的问题)如果用户没编辑,
201
+ 节内只有占位符行("- X: 立场" / "- 问题")——注入前整节去掉,
202
+ 避免占位符混入讨论环境。判据精确:占位符是确定字符串,用户真实
203
+ 内容不会写成 "- X: 立场"(立场值就是"立场"二字)。
204
+ """
205
+ placeholder = re.compile(r"^-\s+(\w+:)?\s*立场$|^-\s+问题$")
206
+ out = []
207
+ pending_title = None # 当前节的标题(占位符节连标题一起删)
208
+ cur = [] # 当前节内容行
209
+ cur_is_placeholder = True
210
+
211
+ def flush():
212
+ if not cur_is_placeholder:
213
+ if pending_title is not None:
214
+ out.append(pending_title)
215
+ out.extend(cur)
216
+
217
+ for line in question.splitlines():
218
+ if line.startswith("## "):
219
+ flush()
220
+ pending_title = line
221
+ cur = []
222
+ cur_is_placeholder = True
223
+ elif line.strip() == "":
224
+ cur.append(line)
225
+ else:
226
+ cur.append(line)
227
+ if not placeholder.match(line):
228
+ cur_is_placeholder = False
229
+ flush()
230
+ return "\n".join(out)
231
+
232
+
233
+ def gen_spec_skeleton(spec_dir, participants, topic=None, background=None,
234
+ viewers_dir=None):
235
+ """生成 spec 骨架(--spec-gen,唯一实现):question.md + background.md +
236
+ models.md + agents/。
237
+
238
+ agents/ 三态:viewers_dir 给定 → _snapshot_viewers(校验+快照,失败
239
+ 返回 (None, err)、零产物);显式 participants → 占位骨架;两者皆无 →
240
+ 无 agents/(start 报错提示)。
241
+ topic/background: wrapper --prepare 传入(直接填进骨架);CLI 直用
242
+ 时缺省 = 占位文案。
243
+ models.md 预填主 pi 当前 model/thinking(_detect_pi_model_thinking,
244
+ 对齐旧 wrapper read_pi_model_thinking 语义——用户少改一个文件)。
245
+ 每个文件第一行 = 用途说明(不注入,设计 16.4)。
246
+ """
247
+ if viewers_dir:
248
+ participants, err = _snapshot_viewers(spec_dir, viewers_dir)
249
+ if err:
250
+ return None, err
251
+ # spec 目录本身无条件创建(2026-09-09 回归修复:agents 创建并入条件
252
+ # 分支后,viewers 骨架曾连 spec_dir 都不建 → README copy 崩)
253
+ os.makedirs(spec_dir, exist_ok=True)
254
+ # agents/ 占位骨架仅显式 --agents 时生成(viewers 快照路径已建好并
255
+ # 含内容——不能被占位覆盖;两者皆无 = viewers 发现留给启动时点)
256
+ if participants and not viewers_dir:
257
+ os.makedirs(os.path.join(spec_dir, "agents"), exist_ok=True)
258
+ # README.md:从模板复制(内容不变——模板化,用户 7909)
259
+ shutil.copyfile(os.path.join(TPL_DIR, "spec-readme.md.tpl"),
260
+ os.path.join(spec_dir, "README.md"))
261
+ # question.md:第一行说明 + 基本结构模板(用户 7713:提供基本结构)
262
+ q = [
263
+ "# question.md——分析起点(话题/立场/待答问题,自由 markdown)。本行是说明行,不会注入。",
264
+ "",
265
+ f"# 分析主题:{topic or "请填写"}",
266
+ "",
267
+ "## 初始立场(可选,每参与者一行)",
268
+ ]
269
+ q += [f"- {p}: 立场" for p in participants]
270
+ q += ["", "## 待回答的问题(可选)", "- 问题", ""]
271
+ with open(os.path.join(spec_dir, "question.md"), "w") as f:
272
+ f.write("\n".join(q))
273
+ # background.md(第一行说明 + 正文;用户 7707:文件可以是空的)
274
+ with open(os.path.join(spec_dir, "background.md"), "w") as f:
275
+ f.write("# background.md——显式边界与约定(注入每个 work 的 AGENTS.md 背景节)。"
276
+ "本行是说明行,不会注入。\n\n")
277
+ if background:
278
+ f.write(background + "\n")
279
+ # models.md(用户 8024/9204/9271:预列各 agent,每行 agent名: model,
280
+ # variant 默认 max 隐式——只有非 max 才写 `, variant`,日常更简洁;
281
+ # model/thinking 预填主 pi 当前值,用户少改一个文件)
282
+ pm, pt = _detect_pi_model_thinking()
283
+ with open(os.path.join(spec_dir, "models.md"), "w") as f:
284
+ lines = ["# models.md——模型配置(可选)。每行:agent名: model[, variant]。"
285
+ "model 默认 default,variant 默认 max(只有不用 max 才写 variant)。"
286
+ "本行是说明行,不会注入。"]
287
+ for p in participants:
288
+ if pm and pt:
289
+ lines.append(f"{p}: {pm}, {pt}")
290
+ elif pm:
291
+ lines.append(f"{p}: {pm}")
292
+ elif pt:
293
+ lines.append(f"{p}: default, {pt}")
294
+ else:
295
+ lines.append(f"{p}: default")
296
+ f.write("\n".join(lines) + "\n")
297
+ # agents/X.md 占位 + .order(仅显式 --agents 时;快照路径的 .order
298
+ # 由 _snapshot_viewers 写入,此处不得重写)
299
+ if participants and not viewers_dir:
300
+ for p in participants:
301
+ with open(os.path.join(spec_dir, "agents", f"{p}.md"), "w") as f:
302
+ f.write(f"# {p}.md——agent {p} 的分工/补充(追加到 agent {p} 定义正文)。"
303
+ f"本行是说明行,不会注入。\n\n")
304
+ # .order:固化 --agents 顺序(审核#6——sorted() 推断破坏顺序语义,
305
+ # starter/默认 resultWriter/RR 轮转链依赖 participants 顺序)
306
+ with open(os.path.join(spec_dir, "agents", ".order"), "w") as f:
307
+ f.write("\n".join(participants) + "\n")
308
+ return participants, None
309
+
310
+
311
+ def gen_agents_md(args, agent, participants, spec_background=None,
312
+ main_pi_cwd=None):
313
+ """meeting 协议 AGENTS.md(共享协议 + background;身份/立场在 agent
314
+ 定义/question.md)。
315
+
316
+ spec_background: spec 提供时优先(设计 16.6),否则 args.background,否则占位。
317
+ main_pi_cwd: 主 pi 工作目录(程序化注入,用户 2026-09-02)——直接进
318
+ AGENTS.md(注入 system prompt 的载体),不经 background.md 转接:
319
+ background.md 是人工编辑的讨论内容(用户审核 spec 时看),cwd 是
320
+ 环境事实(程序化写入),分开保持各自干净。None(手动场景)→ 节隐藏。
321
+ """
322
+ others = [p for p in participants if p != agent]
323
+ sample = others[0] if others else "x"
324
+ background = (spec_background if spec_background is not None
325
+ else (args.background or "(无)"))
326
+ with open(os.path.join(TPL_DIR, "AGENTS.md.tpl")) as f:
327
+ tpl = f.read()
328
+ # cwd 节:占位符填充(T5 修复,e2e7 评审)——原实现先 format 出
329
+ # "(未提供)"再整段字符串 replace 删除:模板文案/换行一改,隐藏
330
+ # 静默失效(模板与 python 双份文本耦合)。占位符方案:节文本单份
331
+ # 定义在此,模板位置显式可见;None → 空串(节消失)
332
+ if main_pi_cwd:
333
+ cwd_section = (
334
+ f"\n## 主 pi 工作目录\n\n分析环境的主 pi 在 `{main_pi_cwd}` "
335
+ f"目录运行。与该目录相关的信息\n(源码、文档、配置)可在其中"
336
+ f"查找:如有需要可查看相关文件以获取\n比本背景更详细的信息。\n")
337
+ else:
338
+ cwd_section = ""
339
+ out = tpl.format(
340
+ AGENT_NAME=agent,
341
+ N=str(len(participants)),
342
+ PARTICIPANTS_DISPLAY="、".join(participants),
343
+ SAMPLE_OTHER=sample,
344
+ BACKGROUND=background,
345
+ MAIN_PI_CWD_SECTION=cwd_section,
346
+ )
347
+ return out
348
+
349
+
350
+ def gen_question(topic, stances, background, questions):
351
+ """question.md(讨论起点:话题 + 可选立场 + 待回答问题)。
352
+
353
+ 分层(2026-08-09):background 移到 AGENTS.md(共享,system prompt);
354
+ 立场保持在此(非强制、可被说服,不进 system prompt)。
355
+ """
356
+ # 措辞与 spec 骨架(gen_spec_skeleton)、spec-readme 模板、prompt 统一为
357
+ # "# 分析主题:"——**单一措辞**,消费端只认它(此前生产路径产
358
+ # "# 讨论主题:" 而消费端写兼容循环兜两种,根因却是生产自己在产旧措辞)
359
+ lines = [f"# 分析主题:{topic}", ""]
360
+ if stances:
361
+ lines += ["## 初始立场", "每个参与者有自己的初始立场(可被论据说服):", ""]
362
+ for k, v in stances.items():
363
+ lines.append(f"- {k}: {v}")
364
+ lines += ["", "开场时请声明你的立场,然后参与讨论。"]
365
+ if questions:
366
+ lines += ["", "## 待回答的问题"] + [f"- {q}" for q in questions] + [""]
367
+ return "\n".join(lines)
368
+
369
+
370
+ def gen_protocol(topic, participants, max_meeting, max_rr, pure=False,
371
+ result_writer=None,
372
+ stall_timeout=DEFAULT_STALL_TIMEOUT,
373
+ fork_source=None, fork_cwd=None,
374
+ fork_mode=meeting_fs.DEFAULT_FORK_MODE):
375
+ """protocol.json(meeting 模式)。"""
376
+ rw = result_writer or participants[-1]
377
+ proto = {
378
+ "mode": "meeting",
379
+ "protocol_version": 2,
380
+ "topic": topic or "",
381
+ "participants": participants,
382
+ "resultWriter": rw,
383
+ "maxMeetingRounds": max_meeting,
384
+ "maxRRRounds": max_rr,
385
+ "stallTimeoutSeconds": stall_timeout,
386
+ "commitPolicy": "one-message-per-commit",
387
+ }
388
+ if pure:
389
+ proto["pure"] = True
390
+ if fork_source:
391
+ # fork 模式(多视角):首唤挂载主 session + cwd=主项目
392
+ # forkMode 取值域与默认值的定义在 meeting_fs(FORK_MODES /
393
+ # DEFAULT_FORK_MODE,单一事实源);此处只写入选定值
394
+ proto["forkSource"] = fork_source
395
+ proto["forkCwd"] = fork_cwd or os.getcwd()
396
+ proto["forkMode"] = fork_mode
397
+ return proto
398
+
399
+
400
+ def check_agent_name(name):
401
+ """单个 agent/视角名合法性(T4 收归,e2e7 评审):唯一实现。
402
+
403
+ 规则(viewers 文件名即 agent 名 → 同一套规则两处来源):
404
+ 非空 / 无路径分隔符与空白 / ≤32 字符 / 非 human 保留名。
405
+ 返回错误信息或 None。
406
+ """
407
+ if not name:
408
+ return "空名"
409
+ if re.search(r"[/\\\s]", name):
410
+ return "含路径分隔符或空白"
411
+ if len(name) > MAX_AGENT_NAME_LEN:
412
+ return f"超过 {MAX_AGENT_NAME_LEN} 字符"
413
+ if name == "human":
414
+ return "'human' 是保留名(human 插话通道),不可作为参与者"
415
+ return None
416
+
417
+
418
+ def list_agent_md(d):
419
+ """列出目录下的 agent 定义文件名(去 `.md`、**排除隐藏文件**、排序)。
420
+
421
+ **列举规则的唯一实现**——viewers/ 与 spec/agents/ 两条来源共用。
422
+ 为什么排除隐藏文件:`.draft.md` 之类会被当成参与者(名为 `.draft`,
423
+ 点号不在名字规则的禁止集内)静默进入讨论。此前只有 viewers 分支
424
+ 排除、spec/agents 的两处列举没排除(实测缺口)。
425
+ """
426
+ return sorted(f[:-3] for f in os.listdir(d)
427
+ if f.endswith(".md") and not f.startswith("."))
428
+
429
+
430
+ def validate_participants(participants):
431
+ """整组名字校验(**名字规则的唯一入口**)。返回错误或 None。
432
+
433
+ 覆盖:非空 / 无路径分隔符与空白 / ≤32 / 非 human 保留名。
434
+ CLI(--agents)与 spec/viewers(文件名)三条来源路径都经此。
435
+ """
436
+ for p in participants:
437
+ err = check_agent_name(p)
438
+ if err:
439
+ return f"错误: 非法 agent 名({err}):{p}"
440
+ return None
441
+
442
+
443
+ def viewer_set_error(names, empty, where="viewers/"):
444
+ """viewers 集合级校验(**唯一实现**):空正文视角 + 至少 2 个。
445
+
446
+ where: 报错时指路的目录前缀("viewers/" 或 "spec/agents/")。
447
+ 返回错误文本或 None。为什么单点:同一套规则曾在 prepare 快照路径与
448
+ spec 解析路径各写一遍,文案与检查项已经漂移(实测:非法名文案带不带
449
+ 文件名后缀不一致、≥2 检查一处列参与者一处不列)。
450
+ """
451
+ if empty:
452
+ # 空视角 = 没有 lenses 的 agent:行为由模型自由发挥,多视角退化成
453
+ # "同名随机视角"——静默退化,与无静默铁律相悖(占位文件忘写是常见成因)
454
+ detail = "、".join(f"{where}{n}.md({why})" for n, why in empty)
455
+ return (f"错误: {detail}——视角任务书不能为空"
456
+ f"(写清该视角用什么 lenses 看分析对象)")
457
+ if len(names) < 2:
458
+ return (f"错误: {where} 下仅发现 {len(names)} 个视角"
459
+ f"({', '.join(names)})——多视角分析至少需要 2 个")
460
+ return None
461
+
462
+
463
+ def _discover_viewers(viewers_dir):
464
+ """发现 viewers 目录(多视角产品约定):*.md 文件名即 agent 名。
465
+
466
+ 返回 (participants, briefs, errors)——participants 按文件名排序(决定
467
+ starter/RR 轮转与默认 resultWriter);briefs = {agent: 视角任务书正文};
468
+ errors = [(name, 原因)](空/纯空白视角——这类视角无 lenses,会让多视角
469
+ 退化成同名随机视角,属静默退化,必须报错而非放行)。
470
+ 目录不存在/无文件 → (None, None, [])(调用方决定报错或回退)。
471
+ """
472
+ if not os.path.isdir(viewers_dir):
473
+ return None, None, []
474
+ names = list_agent_md(viewers_dir)
475
+ if not names:
476
+ return None, None, []
477
+ briefs, errors = {}, []
478
+ for n in names:
479
+ with open(os.path.join(viewers_dir, f"{n}.md"), encoding="utf-8") as f:
480
+ brief = f.read().strip("\n")
481
+ if not brief.strip():
482
+ errors.append((n, "空视角任务书(没有任何视角内容)"))
483
+ continue
484
+ briefs[n] = brief
485
+ return names, briefs, errors
486
+
487
+
488
+ def _snapshot_viewers(spec_dir, viewers_dir):
489
+ """viewers 校验(prepare 时点)+ 快照进 spec/agents/(单一事实源:
490
+ 所有视角都源自 viewers/,spec agents/ = 本场快照,可按场修改,
491
+ 分析结束 spec 即删、资产永续;用户 2026-09-09 设计)。
492
+
493
+ 校验(任何违规 → 返回错误,不产生任何 spec 文件——用户:不合规
494
+ 根本不应该开始 spec-gen):目录存在 / ≥2 个合法 .md(排除隐藏)/
495
+ 无 human / 名字合法(空白/路径分隔符/≤32)。
496
+ 快照文件含说明行首行(spec 约定:_spec_read 跳过首行——裸拷贝会
497
+ 把正文首行当说明吃掉,实测缺口)。
498
+ 返回 (participants, error)。
499
+ """
500
+ names, _briefs, empty = _discover_viewers(viewers_dir)
501
+ if names is None:
502
+ return None, ("错误: 未找到 viewers/ 目录——多视角分析的视角资产"
503
+ "必须先建好(项目 cwd 下 viewers/<视角名>.md,至少 2 个)")
504
+ err = validate_participants(names) or viewer_set_error(names, empty)
505
+ if err:
506
+ return None, err
507
+ agents_dir = os.path.join(spec_dir, "agents")
508
+ os.makedirs(agents_dir, exist_ok=True)
509
+ for n in names:
510
+ with open(os.path.join(viewers_dir, f"{n}.md")) as f:
511
+ brief = f.read()
512
+ with open(os.path.join(agents_dir, f"{n}.md"), "w") as f:
513
+ f.write(f"# {n}.md——快照自 viewers/{n}.md(本行说明不注入;"
514
+ f"按场修改这里,不影响 viewers/ 资产)\n\n")
515
+ f.write(brief)
516
+ with open(os.path.join(agents_dir, ".order"), "w") as f:
517
+ f.write("\n".join(names) + "\n")
518
+ return names, None