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,156 @@
1
+ /**
2
+ * multi-viewers-say —— 多视角分析插话命令(零 LLM 参与)
3
+ *
4
+ * 用户输入 `/multi-viewers-say <文本>` 时立即执行 human_sayer:
5
+ * 把文本作为 human 消息注入当前分析(各视角 agent 可见、可回应)。
6
+ * 不经过 LLM——命令 handler 直接 spawn human_sayer.py(一次调用一次返回),
7
+ * 结果用 ctx.ui.notify 反馈。
8
+ *
9
+ * 讨论目录发现(零状态文件):
10
+ * wrapper --start 的目录名 = mv-<PI_SESSION_ID>-<时间戳>
11
+ * (aft 不再替换 bash 后 PI_SESSION_ID 注入可用);
12
+ * handler 用 ctx.sessionManager.getSessionId() 取本 session id,
13
+ * glob ctx.cwd/mv-<sid>-* 取最新目录——session 隔离(同目录多
14
+ * session 并发分析也互不干扰),无状态文件、无 cleanup 比对。
15
+ * 兜底:无 sid 目录(PI 环境变量未注入时 wrapper 拿不到 sid)→ 项目下
16
+ * 最新的 mv-*,并警告降级(宁可提示也不要静默插错分析)。
17
+ *
18
+ * 前缀 mv- 与 pi-agents-helper 的 discuss-* 命名空间隔离(两个系统的
19
+ * 插话命令都按"同 sid 最新目录"发现目标,共用前缀会互相插错)。
20
+ *
21
+ * 观看分析仍用 `!!` bash 流式(human_viewer --follow)——命令 API 无原生
22
+ * 流式通道(handler 返回 Promise<void>),且 bash 流式是平台原生能力。
23
+ */
24
+
25
+ import { spawn } from "node:child_process";
26
+ import * as fs from "node:fs";
27
+ import * as path from "node:path";
28
+ import { fileURLToPath } from "node:url";
29
+
30
+ // ---- 自定位:import.meta.url 向上找包根(package.json name = 包名)----
31
+ // 扩展从包内加载时(pi install / npm 安装 + symlink)零硬编码——
32
+ // 包移到哪都能工作;复制安装(拆散包结构)时找不到包根 → 回退开发机路径。
33
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
34
+ const PKG_NAME = "pi-multi-viewers";
35
+
36
+ function findPackageRoot(start: string, pkgName: string): string | null {
37
+ let dir = start;
38
+ for (let i = 0; i < 8; i++) {
39
+ try {
40
+ const pkg = JSON.parse(
41
+ fs.readFileSync(path.join(dir, "package.json"), "utf8"),
42
+ );
43
+ if (pkg.name === pkgName) return dir;
44
+ } catch {
45
+ // 继续向上
46
+ }
47
+ const parent = path.dirname(dir);
48
+ if (parent === dir) break;
49
+ dir = parent;
50
+ }
51
+ return null;
52
+ }
53
+
54
+ const PACKAGE_ROOT = findPackageRoot(__dirname, PKG_NAME);
55
+ const SAYER = PACKAGE_ROOT
56
+ ? path.join(PACKAGE_ROOT, "human_sayer.py")
57
+ : "/root/pi-multi-viewers/human_sayer.py"; // 复制安装退化(开发机)
58
+
59
+ /** 按 (cwd, sessionId) 推导当前分析目录:优先 mv-<sid>-<stamp>
60
+ * (session 隔离),找不到回退 mv-<stamp>(无 sid 目录——PI 环境
61
+ * 变量缺失时 wrapper 拿不到 sid,降级为项目下最新分析,警告提示)。 */
62
+ function findCurrentDir(
63
+ cwd: string,
64
+ sid: string,
65
+ ): { dir: string | null; degraded: boolean } {
66
+ try {
67
+ const names = fs.readdirSync(cwd, { withFileTypes: true });
68
+ const bySid = names
69
+ .filter((e) => e.isDirectory() && e.name.startsWith(`mv-${sid}-`))
70
+ .map((e) => e.name)
71
+ .sort();
72
+ if (bySid.length > 0) {
73
+ const dir = path.join(cwd, bySid[bySid.length - 1]);
74
+ return { dir: fs.existsSync(dir) ? dir : null, degraded: false };
75
+ }
76
+ const any = names
77
+ .filter(
78
+ (e) =>
79
+ e.isDirectory() &&
80
+ e.name.startsWith("mv-") &&
81
+ // 排除 mv-spec-*:那是尚未被 --start 消费的 spec 目录,不是分析
82
+ // 目录(否则会选中它并报出误导性的"分析不存在")
83
+ !e.name.startsWith("mv-spec-"),
84
+ )
85
+ .map((e) => e.name)
86
+ .sort();
87
+ if (any.length > 0) {
88
+ const dir = path.join(cwd, any[any.length - 1]);
89
+ return { dir: fs.existsSync(dir) ? dir : null, degraded: true };
90
+ }
91
+ return { dir: null, degraded: false };
92
+ } catch {
93
+ return { dir: null, degraded: false };
94
+ }
95
+ }
96
+
97
+ /** 执行 human_sayer.py 一次插话。返回 { ok, output }。 */
98
+ function runSayer(
99
+ dir: string,
100
+ text: string,
101
+ ): Promise<{ ok: boolean; output: string }> {
102
+ return new Promise((resolve) => {
103
+ const proc = spawn("python3", [SAYER, dir, text], {
104
+ stdio: ["ignore", "pipe", "pipe"],
105
+ });
106
+ let out = "";
107
+ proc.stdout.on("data", (d) => (out += d.toString()));
108
+ proc.stderr.on("data", (d) => (out += d.toString()));
109
+ proc.on("close", (code) => resolve({ ok: code === 0, output: out.trim() }));
110
+ proc.on("error", (e) => resolve({ ok: false, output: String(e) }));
111
+ });
112
+ }
113
+
114
+ export default function register(pi: any) {
115
+ pi.registerCommand("multi-viewers-say", {
116
+ description: "向正在进行的多视角分析插话(human 消息,各视角可见可回应)",
117
+ argumentHint: "<插话内容>",
118
+ getArgumentCompletions: () => null,
119
+ handler: async (args: string, ctx: any) => {
120
+ const text = args.trim();
121
+ if (!text) {
122
+ ctx.ui.notify(
123
+ "插话内容为空——用法: /multi-viewers-say <文本>",
124
+ "warning",
125
+ );
126
+ return;
127
+ }
128
+ const sid = ctx.sessionManager.getSessionId();
129
+ const found = findCurrentDir(ctx.cwd, sid);
130
+ const dir = found.dir;
131
+ if (!dir) {
132
+ ctx.ui.notify(
133
+ "没有正在进行的多视角分析(cwd 下无 mv-* 目录)。" +
134
+ "先用 /multi-viewers 启动分析。",
135
+ "error",
136
+ );
137
+ return;
138
+ }
139
+ if (found.degraded) {
140
+ ctx.ui.notify(
141
+ `未找到本 session 的分析目录(目录名不含 session id——` +
142
+ `PI 环境变量可能未注入)——插话指向项目下最新分析: ${dir}`,
143
+ "warning",
144
+ );
145
+ }
146
+ const { ok, output } = await runSayer(dir, text);
147
+ if (ok && output) {
148
+ ctx.ui.notify(output, "success");
149
+ } else if (ok) {
150
+ ctx.ui.notify("插话已发送", "success");
151
+ } else {
152
+ ctx.ui.notify(`插话失败: ${output || "未知错误"}`, "error");
153
+ }
154
+ },
155
+ });
156
+ }
package/fake_agent.py ADDED
@@ -0,0 +1,120 @@
1
+ #!/usr/bin/env python3
2
+ """fake_agent.py —— FakeAgent(模拟"内容 LLM" + 引擎流程接管)。
3
+
4
+ 职责边界(设计确认 2026-08-09):
5
+ - responder(模拟 LLM)= 只做**内容决定**:写 type + 正文,
6
+ 不写 next/mode/seen_at/from(真实 LLM 不知道这些协议字段)
7
+ - 流程(补全字段/commit/push/触发/级联/收尾)= 全部由引擎(loop)接管
8
+
9
+ 之前的错误:FakeAgent 替 LLM 写 next/mode → 模拟的是"假想完美流程 LLM",
10
+ 掩盖了真实路径缺陷(LLM 漏 next → 卡死)。现在与真实 LLM 职责一致。
11
+
12
+ 用法:python3 fake_agent.py <workdir> <agent> <min_sleep> <max_sleep> <crash_rate>
13
+ <max_meeting_rounds> <max_rr_rounds>
14
+ """
15
+
16
+ import os
17
+ import json
18
+ import random
19
+ import sys
20
+ import time
21
+
22
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
23
+
24
+ from meeting_fs import next_msg_id, write_message
25
+ from meeting_engine import agent_loop
26
+
27
+
28
+ # 测试装置:记录每个 agent 处理过(收到 prompt 中)的消息文件。
29
+ # 仅 FakeAgent(模拟 LLM)记录——正常流程不记录(用户 9988:这只是测试,
30
+ # 验证 agent 完整处理了所有消息、无遗漏)。多进程子进程写文件,测试进程读。
31
+ PROCESSED_DIR = "processed" # 讨论根下的目录名
32
+
33
+
34
+ def record_processed(workdir, agent, meta):
35
+ """记录本 agent 收到(prompt 包含)的消息文件路径,去重后落盘。
36
+
37
+ workdir: work-<agent>;记录写到讨论根 processed/<agent>.json。
38
+ meta: 新消息列表(含 path 字段)。
39
+ """
40
+ if not meta:
41
+ return
42
+ base = os.path.dirname(workdir)
43
+ pdir = os.path.join(base, PROCESSED_DIR)
44
+ os.makedirs(pdir, exist_ok=True)
45
+ pf = os.path.join(pdir, f"{agent}.json")
46
+ seen = set()
47
+ if os.path.exists(pf):
48
+ try:
49
+ seen = set(json.load(open(pf)))
50
+ except (OSError, ValueError):
51
+ seen = set()
52
+ seen.update(m["path"] for m in meta)
53
+ with open(pf, "w") as f:
54
+ json.dump(sorted(seen), f)
55
+
56
+
57
+ def make_responder(min_sleep, max_sleep, crash_rate):
58
+ """构造"内容 LLM" responder:只写 type + 正文,不写协议字段。"""
59
+ def responder(workdir, agent, head, meta, is_first, rr_turn, retry,
60
+ finalizing=False, finalize_reason="consensus"):
61
+ # --- 测试装置:记录收到(prompt 包含)的消息文件(用户 9960)---
62
+ # 模拟 LLM 的"感知":meta = 本次唤醒读点之后的新消息。
63
+ # 正常流程不记录(仅 FakeAgent 测试用)。
64
+ record_processed(workdir, agent, meta)
65
+ # --- 内容决定(LLM 的判断)---
66
+ if finalizing:
67
+ # 收尾指令:resultWriter 写 result.md(双面约束:此时才允许写)
68
+ result_path = os.path.join(workdir, "result.md")
69
+ # 内容 >50 字节(_result_md_valid 阈值,审核#3)——否则恒走
70
+ # loop 兜底代写,正常收尾路径零验证(fake 与真实 responder 语义不一致)
71
+ # 文案三分支(review4 L12):stall 不误标"配额耗尽"
72
+ if finalize_reason == "consensus":
73
+ rtxt = "全员 pass,共识达成"
74
+ elif finalize_reason == "quota":
75
+ rtxt = "配额耗尽,未完全共识"
76
+ else:
77
+ rtxt = "无进展超时(stall),未完全共识"
78
+ with open(result_path, "w") as f:
79
+ f.write(f"# 讨论结果\n\n{rtxt}。\n"
80
+ f"三方结论一致,无遗留分歧。详细结论见讨论消息。\n")
81
+ return True
82
+ if retry:
83
+ # 被重试(上次没产出):无静默铁律,强制表态
84
+ decision = "freezing" if not rr_turn else "pass"
85
+ elif rr_turn:
86
+ # RR 阶段:单向流,只写 pass(无异议回退,留待以后)
87
+ decision = "pass"
88
+ else:
89
+ # meeting 阶段:有内容 → message;无话可说 → freezing
90
+ decision = "message" if random.random() < 0.65 else "freezing"
91
+
92
+ time.sleep(random.uniform(min_sleep, max_sleep))
93
+ if random.random() < crash_rate:
94
+ print(f"[{agent}] 模拟崩溃(未写文件)", flush=True)
95
+ return False
96
+
97
+ # --- 只写内容文件(最小 frontmatter:只有 type,其他字段留给 loop 补全)---
98
+ msg_id = next_msg_id(workdir, agent)
99
+ os.makedirs(os.path.join(workdir, agent), exist_ok=True)
100
+ fm = {
101
+ "type": decision,
102
+ }
103
+ path = f"{agent}/{msg_id}.md"
104
+ write_message(workdir, path, fm, f"{decision} by {agent} (sim content)")
105
+ # 流程(补全/commit)由引擎的 respond_with_fallback 接管——
106
+ # responder 只负责写内容文件
107
+ return True
108
+ return responder
109
+
110
+
111
+ if __name__ == "__main__":
112
+ if len(sys.argv) < 8:
113
+ print("用法: fake_agent.py <workdir> <agent> <min_sleep> <max_sleep> "
114
+ "<crash_rate> <max_meeting_rounds> <max_rr_rounds>")
115
+ sys.exit(1)
116
+ workdir, agent = sys.argv[1], sys.argv[2]
117
+ responder = make_responder(float(sys.argv[3]), float(sys.argv[4]),
118
+ float(sys.argv[5]))
119
+ agent_loop(workdir, agent, responder,
120
+ max_meeting=int(sys.argv[6]), max_rr=int(sys.argv[7]))
package/human_sayer.py ADDED
@@ -0,0 +1,144 @@
1
+ #!/usr/bin/env python3
2
+ """human_sayer.py —— human 插话命令(pi-agents-helper 阶段 2)。
3
+
4
+ human 通道的写入进程(docs/pi-helper-design.md §5.3):写一条 human
5
+ 消息到 bare(经 work-human 提交通道),agents 下次轮询即可看到并可响应。
6
+
7
+ 用法:
8
+ python3 human_sayer.py <base> <文本> # 文本参数(可多行)
9
+ echo "多行文本" | python3 human_sayer.py <base> # 无文本参数时读 stdin
10
+
11
+ frontmatter 由本命令**确定性补全**(人只提供正文,设计 §2.2):
12
+ from: human / type: message(强制——人不写流程信号)/
13
+ mode: 写入时当前聚合 mode / seen_at: 写入时 HEAD / to: all /
14
+ summary: 正文首行截取(可选)
15
+
16
+ 并发容错(设计 §5.3):
17
+ - flock work-human/.human.lock:两个 sayer 串行(主 pi + 手动插话)
18
+ - 写前 pull + git_push 容错重试:与 agents 并发写(push 非快进 →
19
+ pull --rebase → 重推)
20
+ """
21
+
22
+ import argparse
23
+ import fcntl
24
+ import os
25
+ import sys
26
+
27
+ from meeting_fs import (
28
+ bare_of_base, bare_of_workdir, git_head, git_pull, git_commit, git_push,
29
+ next_msg_id, write_message, commit_message)
30
+ from meeting_engine import aggregate_mode, participants
31
+
32
+ SUMMARY_MAX = 60 # summary 截取长度(frontmatter 值单行化后展示用)
33
+ HUMAN = "human" # 固定名(保留字,start_discussion 校验)
34
+
35
+
36
+ def _summary(body):
37
+ """正文首行截取为 summary(frontmatter 值单行化前的摘要)。"""
38
+ first = next((l.strip() for l in body.splitlines() if l.strip()), "")
39
+ return first[:SUMMARY_MAX] if first else ""
40
+
41
+
42
+ def say(workdir, body):
43
+ """写一条 human 消息(flock + pull + frontmatter + commit + push)。
44
+
45
+ workdir: work-human 路径
46
+ body: 正文(多行)
47
+ 返回: (path, summary)——消息文件路径与摘要
48
+ """
49
+ bare = bare_of_workdir(workdir)
50
+ lock_path = os.path.join(workdir, ".human.lock")
51
+ with open(lock_path, "w") as lf:
52
+ fcntl.flock(lf, fcntl.LOCK_EX)
53
+ # 写前同步(对齐生产 commit_new_files:pull 后再写,防序号/HEAD 落后)
54
+ git_pull(workdir)
55
+ head = git_head(bare)
56
+ agents = participants(bare)
57
+ mode = aggregate_mode(bare, agents) if agents else "meeting"
58
+ mid = next_msg_id(workdir, HUMAN)
59
+ path = f"{HUMAN}/{mid}.md"
60
+ fm = {
61
+ "from": HUMAN,
62
+ "type": "message", # 强制——人不写流程信号
63
+ "mode": mode, # 写入时当前聚合 mode(不参与判定,仅信息)
64
+ "seen_at": head, # 人看到了当前全部状态
65
+ "to": "all",
66
+ }
67
+ summ = _summary(body)
68
+ if summ:
69
+ fm["summary"] = summ
70
+ write_message(workdir, path, fm, body)
71
+ git_commit(workdir, [path], commit_message(HUMAN, mid))
72
+ git_push(workdir) # 容错重试(与 agents 并发写)
73
+ fcntl.flock(lf, fcntl.LOCK_UN)
74
+ return path, summ
75
+
76
+
77
+ def interactive(workdir):
78
+ """交互模式:逐行累积,空行提交(粘贴多行/打字统一语义)。
79
+
80
+ 终端交互插话用法(实测 2026-08-30 用户反馈:shell 敲命令不直观):
81
+ python3 human_sayer.py <base> -i
82
+ 然后直接输入插话内容:
83
+ > 第一行
84
+ > 第二行(继续累积)
85
+ > (空行 Enter)→ 提交整块,已发送 human/NNNN.md
86
+ 粘贴多行文本:粘贴的换行逐行进入累积,最后空行提交;
87
+ 空行且无累积 → 忽略;Ctrl-D 退出。
88
+ """
89
+ print("输入插话内容(逐行累积,空行 Enter 提交;Ctrl-D 退出)",
90
+ flush=True)
91
+ lines = []
92
+ while True:
93
+ try:
94
+ line = input("> ")
95
+ except EOFError:
96
+ break
97
+ if line.strip():
98
+ lines.append(line)
99
+ elif lines:
100
+ body = "\n".join(lines)
101
+ lines = []
102
+ path, summ = say(workdir, body)
103
+ print(f"已发送 {path}" + (f"(摘要: {summ})" if summ else ""),
104
+ flush=True)
105
+
106
+
107
+ def main():
108
+ parser = argparse.ArgumentParser(description="human 插话(写一条消息)")
109
+ parser.add_argument("base", help="分析目录(含 repo.git 与 work-human)")
110
+ parser.add_argument("text", nargs="?", default=None,
111
+ help="插话文本(可多行;不传则读 stdin)")
112
+ parser.add_argument("-i", "--interactive", action="store_true",
113
+ help="交互模式:逐行输入,空行提交(终端手动插话)")
114
+ args = parser.parse_args()
115
+
116
+ base = os.path.abspath(os.path.expanduser(args.base))
117
+ workdir = os.path.join(base, "work-human")
118
+ bare = bare_of_base(base)
119
+ if not os.path.isdir(bare):
120
+ print(f"错误: 分析不存在: {base}", file=sys.stderr)
121
+ return 1
122
+ if not os.path.isdir(workdir):
123
+ print(f"错误: work-human 不存在: {workdir}(分析创建于 human 功能之前?)",
124
+ file=sys.stderr)
125
+ return 1
126
+
127
+ if args.interactive:
128
+ interactive(workdir)
129
+ return 0
130
+
131
+ body = args.text if args.text is not None else sys.stdin.read()
132
+ body = body.strip()
133
+ if not body:
134
+ print("错误: 插话内容为空(传 <文本> 参数或经 stdin 输入)",
135
+ file=sys.stderr)
136
+ return 1
137
+
138
+ path, summ = say(workdir, body)
139
+ print(f"已发送 {path}" + (f"(摘要: {summ})" if summ else ""))
140
+ return 0
141
+
142
+
143
+ if __name__ == "__main__":
144
+ sys.exit(main())
@@ -0,0 +1,215 @@
1
+ #!/usr/bin/env python3
2
+ """human_viewer.py —— 只读展示分析进展。
3
+
4
+ human 通道的展示进程(docs/pi-helper-design.md §5.2):纯 bare 只读,
5
+ 无写路径。增量输出 `--since <ref>` 之后的新消息 + 状态变化(mode 切换)。
6
+
7
+ 用法:
8
+ python3 human_viewer.py <base> # 当前状态 + 全部消息
9
+ python3 human_viewer.py <base> --since <ref> # 增量(ref 之后)
10
+ python3 human_viewer.py <base> --follow # 循环展示直到分析结束
11
+
12
+ 输出契约(稳定文本,供壳/主 pi 消费):
13
+ 【状态】<mode>
14
+ [<path>] <from> (<type>): <summary 若有>
15
+ <正文>
16
+ ---
17
+
18
+ --follow 模式:游标持久化 <base>/.viewer-cursor(记录的 ref),重启不丢;
19
+ 分析 done(mode == concluded)→ 打印 result.md 路径后退出。
20
+
21
+ 复用边界:frontmatter 解析/正文提取/消息文件判定/git log 输出解析全部
22
+ 来自 meeting_fs(单一实现);本模块只做 bare 只读组装与展示格式。
23
+ """
24
+
25
+ import argparse
26
+ import json
27
+ import os
28
+ import sys
29
+ import time
30
+
31
+ import meeting_fs
32
+ from meeting_fs import (run_git, git_show, git_head, is_message_file,
33
+ parse_log_nameonly, extract_body, parse_frontmatter)
34
+ from meeting_engine import aggregate_mode
35
+
36
+
37
+ def participants_from_bare(bare):
38
+ """参与者列表(单一事实源 = bare HEAD 的 protocol.json)。"""
39
+ return meeting_fs.read_protocol(bare).get("participants") or None
40
+
41
+
42
+ def result_path(base):
43
+ """result.md 的固定位(`<分析目录>-result.md`,与 --wait / prompt 一致)。
44
+
45
+ resultWriter 的 loop 退出(concluded)时保存到该位置,cleanup 兜底再存
46
+ 一次;权威单一事实源是 bare 的 `HEAD:result.md`。调用方**无需**推
47
+ resultWriter 是谁、也不必进 work 子目录——分析目录删除后该文件仍在。
48
+ """
49
+ return f"{base}-result.md"
50
+
51
+
52
+ def new_messages(bare, since):
53
+ """since 之后的新消息文件(commit 拓扑序,旧→新)。
54
+
55
+ 返回: list[(commit, path)]——只含消息文件(作者/NNNN.md,含 human/)。
56
+ since: git ref(""/None = 全部)
57
+ """
58
+ if since:
59
+ r = run_git(bare, "log", f"{since}..HEAD", "--name-only",
60
+ "--format=%H", "--reverse", check=False)
61
+ else:
62
+ r = run_git(bare, "log", "HEAD", "--name-only",
63
+ "--format=%H", "--reverse", check=False)
64
+ result = []
65
+ for commit, fls in parse_log_nameonly(r.stdout):
66
+ for f in fls:
67
+ if is_message_file(f):
68
+ result.append((commit, f))
69
+ return result
70
+
71
+
72
+ def format_message(path, content):
73
+ """消息 → 展示行。返回 str | None(frontmatter 不可用)。"""
74
+ fm = parse_frontmatter(content)
75
+ if not fm:
76
+ return None
77
+ body = extract_body(content) or ""
78
+ header = f"[{path}] {fm.get('from', '?')} ({fm.get('type', '?')})"
79
+ if fm.get("summary"):
80
+ header += f": {fm['summary']}"
81
+ if body:
82
+ return f"{header}\n{body}\n---"
83
+ return f"{header}\n---"
84
+
85
+
86
+ def is_finished(bare, agents, mode=None):
87
+ """分析是否**收尾完成**(`--wait`/viewer/`--status` 共用的唯一判据)。
88
+
89
+ 定义 = `concluded`(状态机聚合)**且** result.md 已进 bare 且有效。
90
+
91
+ 为什么两条件:`concluded` 是协议信号、result.md 是产物——只认 concluded
92
+ 会在产物落盘前先报"已结束"并打印尚不存在的路径(§3.5-P5 实测分叉);
93
+ 只认 result.md 则无法区分"收尾进行中"与"收尾中断(stalled)"。
94
+ 为什么落 viewer:它是唯一增量实现的持有者,也是观察端判据的家。
95
+ """
96
+ if mode is None:
97
+ mode = aggregate_mode(bare, agents)
98
+ if mode != "concluded":
99
+ return False
100
+ content = git_show(bare, "HEAD", "result.md")
101
+ return bool(content) and len(content) > 50
102
+
103
+
104
+ def incremental(bare, agents, since):
105
+ """单次增量读取。
106
+
107
+ 返回: (mode, lines: list[str], head, done)
108
+ - mode: 当前聚合 mode(meeting/all-freezing/round-robin/concluded)
109
+ - lines: 新消息展示行(旧→新)
110
+ - head: 当前 HEAD
111
+ - done: 分析是否已收尾(mode == concluded)
112
+ """
113
+ mode = aggregate_mode(bare, agents)
114
+ lines = []
115
+ for commit, path in new_messages(bare, since):
116
+ content = git_show(bare, commit, path)
117
+ if content is None:
118
+ continue
119
+ s = format_message(path, content)
120
+ if s:
121
+ lines.append(s)
122
+ head = git_head(bare)
123
+ return mode, lines, head, is_finished(bare, agents, mode)
124
+
125
+
126
+ def _cursor_path(base):
127
+ return os.path.join(base, ".viewer-cursor")
128
+
129
+
130
+ def _read_cursor(base):
131
+ try:
132
+ with open(_cursor_path(base)) as f:
133
+ return f.read().strip() or None
134
+ except OSError:
135
+ return None
136
+
137
+
138
+ def _write_cursor(base, ref):
139
+ with open(_cursor_path(base), "w") as f:
140
+ f.write(ref + "\n")
141
+
142
+
143
+ # 观察端刷新节奏(**有意独立于状态机的空闲重试节奏**):
144
+ # meeting_engine.POLL_INTERVAL 是 loop 的空闲轮询(与 API/CPU 成本相关),
145
+ # 这里是"观察者多久看一眼"(与 UX 延迟相关)——共享值 ≠ 共享概念。
146
+ # 若直接绑定 engine 的常量,将来调 loop 节奏会**静默改变观察契约**
147
+ # (动作-远距离耦合)。两者当前同为 2.0s 只是巧合,改一个不影响另一个。
148
+ # 下界 ≥1s:每次刷新 ≈3 个 git 子进程 + /proc 扫描;调到 0.1s 会变成
149
+ # ~30% 单核的无谓开销。
150
+ OBSERVER_POLL_INTERVAL = 2.0
151
+
152
+
153
+ def follow(base, bare, agents, poll_interval=OBSERVER_POLL_INTERVAL):
154
+ """--follow:循环展示(tail -f 式)直到分析结束。"""
155
+ since = _read_cursor(base)
156
+ last_mode = None
157
+ while True:
158
+ mode, lines, head, done = incremental(bare, agents, since)
159
+ if mode != last_mode:
160
+ print(f"【状态】{mode}", flush=True)
161
+ last_mode = mode
162
+ for s in lines:
163
+ print(s, flush=True)
164
+ if head != since:
165
+ _write_cursor(base, head)
166
+ since = head
167
+ if done:
168
+ print(f"【分析已结束】result.md: {result_path(base)}",
169
+ flush=True)
170
+ return
171
+ time.sleep(poll_interval)
172
+
173
+
174
+ def main():
175
+ parser = argparse.ArgumentParser(description="human 分析展示(只读)")
176
+ parser.add_argument("base", help="分析目录(含 repo.git)")
177
+ parser.add_argument("--since", default=None, help="增量起点 ref(git ref)")
178
+ parser.add_argument("--follow", action="store_true", help="循环展示直到结束")
179
+ args = parser.parse_args()
180
+
181
+ base = os.path.abspath(os.path.expanduser(args.base))
182
+ bare = meeting_fs.bare_of_base(base)
183
+ if not os.path.isdir(bare):
184
+ print(f"错误: 分析不存在: {base}", file=sys.stderr)
185
+ return 1
186
+
187
+ agents = participants_from_bare(bare)
188
+ if not agents:
189
+ print(f"错误: 无法读取 protocol.json(分析未初始化?): {base}",
190
+ file=sys.stderr)
191
+ return 1
192
+
193
+ sys.stdout.reconfigure(line_buffering=True)
194
+ if args.follow:
195
+ follow(base, bare, agents)
196
+ else:
197
+ mode, lines, _, done = incremental(bare, agents, args.since)
198
+ print(f"【状态】{mode}", flush=True)
199
+ for s in lines:
200
+ print(s, flush=True)
201
+ if done:
202
+ print(f"【分析已结束】result.md: {result_path(base)}",
203
+ flush=True)
204
+ return 0
205
+
206
+
207
+ if __name__ == "__main__":
208
+ try:
209
+ sys.exit(main())
210
+ except BrokenPipeError:
211
+ # 管道消费者提前关闭(如 `| head` 截断):静默退出,不打印 traceback
212
+ # ——viewer 是工具,被主 pi/wrapper 管道消费时输出截断是正常场景。
213
+ devnull = os.open(os.devnull, os.O_WRONLY)
214
+ os.dup2(devnull, sys.stdout.fileno())
215
+ sys.exit(0)