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/AGENTS.md +4 -2
- package/README.md +13 -6
- package/docs/design.md +42 -1
- package/fake_agent.py +16 -5
- package/human_viewer.py +85 -16
- package/meeting_core.py +28 -9
- package/meeting_engine.py +53 -34
- package/meeting_fs.py +20 -0
- package/meeting_loop.py +1 -1
- package/observability.py +392 -0
- package/package.json +1 -1
- package/spec_gen.py +518 -0
- package/start_discussion.py +47 -828
package/start_discussion.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
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
|
-
|
|
563
|
-
|
|
564
|
-
|
|
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):删到只剩说明行 →
|
|
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
|
-
#
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
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="
|
|
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,
|
|
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)
|