ultimate-pi 0.13.1 → 0.14.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 (31) hide show
  1. package/.agents/skills/harness-debate-plan/SKILL.md +61 -21
  2. package/.agents/skills/harness-orchestration/SKILL.md +1 -1
  3. package/.pi/agents/harness/planning/plan-adversary.md +2 -2
  4. package/.pi/agents/harness/planning/plan-evaluator.md +3 -1
  5. package/.pi/agents/harness/planning/review-integrator.md +4 -2
  6. package/.pi/extensions/debate-orchestrator.ts +39 -435
  7. package/.pi/extensions/harness-debate-tools.ts +519 -0
  8. package/.pi/extensions/harness-plan-approval.ts +41 -17
  9. package/.pi/extensions/harness-run-context.ts +18 -0
  10. package/.pi/extensions/lib/debate-bus-core.ts +434 -0
  11. package/.pi/extensions/lib/debate-bus-state.ts +58 -0
  12. package/.pi/extensions/lib/harness-spawn-budget.ts +5 -25
  13. package/.pi/extensions/lib/plan-approval/dialog.ts +33 -272
  14. package/.pi/extensions/lib/plan-approval/format-plan.ts +12 -85
  15. package/.pi/extensions/lib/plan-approval/plan-review.ts +6 -6
  16. package/.pi/extensions/lib/plan-approval/render.ts +6 -0
  17. package/.pi/extensions/lib/plan-approval/validate.ts +1 -1
  18. package/.pi/extensions/lib/plan-debate-envelope.ts +2 -0
  19. package/.pi/extensions/lib/plan-debate-gate.ts +155 -0
  20. package/.pi/extensions/lib/plan-debate-id.ts +39 -0
  21. package/.pi/extensions/lib/plan-debate-lane.ts +220 -0
  22. package/.pi/extensions/lib/plan-debate-round-status.ts +94 -0
  23. package/.pi/extensions/lib/plan-debate-write-guard.ts +20 -0
  24. package/.pi/extensions/lib/plan-messenger.ts +276 -0
  25. package/.pi/extensions/lib/plan-review-integrator-rules.ts +119 -0
  26. package/.pi/extensions/lib/plan-scope-guard.ts +89 -0
  27. package/.pi/harness/agents.manifest.json +7 -7
  28. package/.pi/prompts/harness-plan.md +22 -12
  29. package/CHANGELOG.md +12 -0
  30. package/package.json +3 -3
  31. package/.pi/extensions/lib/plan-approval/fallback.ts +0 -50
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Canonical plan-phase debate identifiers (ADR-0035).
3
+ */
4
+
5
+ export function planDebateIdForRun(runId: string): string {
6
+ const trimmed = runId.trim();
7
+ if (!trimmed) throw new Error("run_id is required for plan debate");
8
+ return `plan-${trimmed}`;
9
+ }
10
+
11
+ /** Accept plan-<run_id> only; rewrite plan-<plan_id> when run_id is known. */
12
+ export function normalizePlanDebateId(
13
+ rawDebateId: string,
14
+ runId: string,
15
+ ): { debateId: string; corrected: boolean; warning?: string } {
16
+ const trimmed = rawDebateId.trim();
17
+ const canonical = planDebateIdForRun(runId);
18
+ if (!trimmed) {
19
+ return { debateId: canonical, corrected: true, warning: "empty debate id" };
20
+ }
21
+ if (trimmed === canonical) {
22
+ return { debateId: canonical, corrected: false };
23
+ }
24
+ if (trimmed.startsWith("plan-") && trimmed !== canonical) {
25
+ return {
26
+ debateId: canonical,
27
+ corrected: true,
28
+ warning: `debate id must be plan-<run_id>; got ${trimmed}, using ${canonical}`,
29
+ };
30
+ }
31
+ if (!trimmed.startsWith("plan-")) {
32
+ return {
33
+ debateId: trimmed,
34
+ corrected: false,
35
+ warning: "non-plan debate id (post-execute profile)",
36
+ };
37
+ }
38
+ return { debateId: trimmed, corrected: false };
39
+ }
@@ -0,0 +1,220 @@
1
+ /**
2
+ * Persist plan debate lane YAML + pi-messenger side effects from subagent output.
3
+ */
4
+
5
+ import { constants } from "node:fs";
6
+ import { access, mkdir } from "node:fs/promises";
7
+ import { dirname, join } from "node:path";
8
+ import {
9
+ parseStructuredDocument,
10
+ writeYamlFile,
11
+ } from "../../lib/harness-yaml.js";
12
+ import { postMessengerMessage } from "./plan-messenger.js";
13
+
14
+ export type DebateLaneKind =
15
+ | "hypothesis-validation"
16
+ | "validation-turn"
17
+ | "adversary-brief"
18
+ | "sprint-audit";
19
+
20
+ const AGENT_LANE: Record<string, DebateLaneKind> = {
21
+ "harness/planning/hypothesis-validator": "hypothesis-validation",
22
+ "harness/planning/plan-evaluator": "validation-turn",
23
+ "harness/planning/plan-adversary": "adversary-brief",
24
+ "harness/planning/sprint-contract-auditor": "sprint-audit",
25
+ };
26
+
27
+ export function debateLaneForAgent(agent: string): DebateLaneKind | null {
28
+ const normalized = agent.replace(/^\.pi\/agents\//, "").trim();
29
+ return AGENT_LANE[normalized] ?? null;
30
+ }
31
+
32
+ export function laneArtifactPath(
33
+ lane: DebateLaneKind,
34
+ roundIndex: number,
35
+ ): string {
36
+ switch (lane) {
37
+ case "hypothesis-validation":
38
+ return `artifacts/hypothesis-validation-r${roundIndex}.yaml`;
39
+ case "validation-turn":
40
+ return `artifacts/validation-turn-r${roundIndex}.yaml`;
41
+ case "adversary-brief":
42
+ return `artifacts/adversary-brief-r${roundIndex}.yaml`;
43
+ case "sprint-audit":
44
+ return `artifacts/sprint-audit-r${roundIndex}.yaml`;
45
+ }
46
+ }
47
+
48
+ export function extractClaimIds(doc: Record<string, unknown>): string[] {
49
+ const explicit = doc.messenger_claim_ids;
50
+ if (Array.isArray(explicit)) {
51
+ return explicit.filter(
52
+ (x): x is string => typeof x === "string" && x.length > 0,
53
+ );
54
+ }
55
+ const checks = doc.checks;
56
+ if (!Array.isArray(checks)) return [];
57
+ return checks
58
+ .map((c) => (c as { id?: string }).id)
59
+ .filter((id): id is string => typeof id === "string" && id.length > 0);
60
+ }
61
+
62
+ async function fileExists(path: string): Promise<boolean> {
63
+ try {
64
+ await access(path, constants.R_OK);
65
+ return true;
66
+ } catch {
67
+ return false;
68
+ }
69
+ }
70
+
71
+ export interface ApplyDebateLaneResult {
72
+ ok: boolean;
73
+ lane: DebateLaneKind;
74
+ round_index: number;
75
+ artifact_path: string;
76
+ messenger_posted: boolean;
77
+ errors: string[];
78
+ next_step?: string;
79
+ }
80
+
81
+ export async function applyDebateLane(opts: {
82
+ runDir: string;
83
+ lane: DebateLaneKind;
84
+ content: string;
85
+ roundIndex?: number;
86
+ }): Promise<ApplyDebateLaneResult> {
87
+ const errors: string[] = [];
88
+ let doc: Record<string, unknown>;
89
+ try {
90
+ doc = parseStructuredDocument(opts.content, opts.lane) as Record<
91
+ string,
92
+ unknown
93
+ >;
94
+ } catch (err) {
95
+ const msg = err instanceof Error ? err.message : String(err);
96
+ return {
97
+ ok: false,
98
+ lane: opts.lane,
99
+ round_index: opts.roundIndex ?? 0,
100
+ artifact_path: "",
101
+ messenger_posted: false,
102
+ errors: [msg],
103
+ };
104
+ }
105
+
106
+ const roundIndex =
107
+ typeof doc.round_index === "number"
108
+ ? doc.round_index
109
+ : (opts.roundIndex ?? 1);
110
+ const relPath = laneArtifactPath(opts.lane, roundIndex);
111
+ const absPath = join(opts.runDir, relPath);
112
+ await mkdir(dirname(absPath), { recursive: true });
113
+ await writeYamlFile(absPath, doc);
114
+
115
+ let messengerPosted = false;
116
+ let nextStep: string | undefined;
117
+
118
+ if (opts.lane === "validation-turn") {
119
+ const claimIds = extractClaimIds(doc);
120
+ const body =
121
+ (typeof doc.human_summary === "string" && doc.human_summary.trim()) ||
122
+ claimIds.map((id) => `Check ${id}`).join("; ") ||
123
+ "Plan evaluator claims for this round.";
124
+ if (claimIds.length === 0) {
125
+ errors.push(
126
+ "validation-turn has no claim ids (checks[].id or messenger_claim_ids)",
127
+ );
128
+ } else {
129
+ await postMessengerMessage(opts.runDir, {
130
+ from: "PlanEvaluatorAgent",
131
+ kind: "claim",
132
+ round_index: roundIndex,
133
+ to: ["broadcast"],
134
+ body,
135
+ claim_ids: claimIds,
136
+ in_reply_to: [],
137
+ evidence_refs: [relPath],
138
+ artifact_path: relPath,
139
+ });
140
+ messengerPosted = true;
141
+ nextStep = `Spawn plan-adversary with harness_messenger_read_round({ round_index: ${roundIndex} }) transcript, then harness_debate_apply_lane for adversary output.`;
142
+ }
143
+ }
144
+
145
+ if (opts.lane === "adversary-brief") {
146
+ const turnPath = join(
147
+ opts.runDir,
148
+ laneArtifactPath("validation-turn", roundIndex),
149
+ );
150
+ let inReplyTo: string[] = [];
151
+ if (await fileExists(turnPath)) {
152
+ const { readFile } = await import("node:fs/promises");
153
+ const { parse: parseYaml } = await import("yaml");
154
+ const turn = parseYaml(await readFile(turnPath, "utf-8")) as Record<
155
+ string,
156
+ unknown
157
+ >;
158
+ inReplyTo = extractClaimIds(turn);
159
+ }
160
+ if (inReplyTo.length === 0) {
161
+ errors.push(
162
+ "no claim ids to rebut — validation-turn-rN must exist before adversary",
163
+ );
164
+ } else {
165
+ const body =
166
+ (typeof doc.human_summary === "string" && doc.human_summary.trim()) ||
167
+ (Array.isArray(doc.failure_modes) && doc.failure_modes[0]) ||
168
+ "Adversary rebuttal for evaluator claims.";
169
+ await postMessengerMessage(opts.runDir, {
170
+ from: "PlanAdversaryAgent",
171
+ kind: "rebuttal",
172
+ round_index: roundIndex,
173
+ to: ["broadcast"],
174
+ body: String(body),
175
+ claim_ids: [],
176
+ in_reply_to: inReplyTo,
177
+ evidence_refs: [relPath],
178
+ artifact_path: relPath,
179
+ });
180
+ messengerPosted = true;
181
+ nextStep = `Spawn review-integrator with harness_messenger_read_round({ round_index: ${roundIndex} }) + lane artifacts, then harness_debate_submit_round.`;
182
+ }
183
+ }
184
+
185
+ return {
186
+ ok: errors.length === 0,
187
+ lane: opts.lane,
188
+ round_index: roundIndex,
189
+ artifact_path: relPath,
190
+ messenger_posted: messengerPosted,
191
+ errors,
192
+ next_step: nextStep,
193
+ };
194
+ }
195
+
196
+ export function formatApplyLaneMessage(result: ApplyDebateLaneResult): string {
197
+ if (!result.ok) {
198
+ return `Lane ${result.lane} failed:\n- ${result.errors.join("\n- ")}`;
199
+ }
200
+ const parts = [
201
+ `Wrote ${result.artifact_path}`,
202
+ result.messenger_posted
203
+ ? "messenger updated"
204
+ : "no messenger post for this lane",
205
+ ];
206
+ if (result.next_step) parts.push(`Next: ${result.next_step}`);
207
+ return parts.join("\n");
208
+ }
209
+
210
+ export const DEBATE_LANE_AGENT_ORDER: Array<{
211
+ lane: DebateLaneKind;
212
+ agent: string;
213
+ }> = [
214
+ {
215
+ lane: "hypothesis-validation",
216
+ agent: "harness/planning/hypothesis-validator",
217
+ },
218
+ { lane: "validation-turn", agent: "harness/planning/plan-evaluator" },
219
+ { lane: "adversary-brief", agent: "harness/planning/plan-adversary" },
220
+ ];
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Round-level debate readiness for parent orchestration.
3
+ */
4
+
5
+ import { constants } from "node:fs";
6
+ import { access } from "node:fs/promises";
7
+ import { join } from "node:path";
8
+ import { type DebateLaneKind, laneArtifactPath } from "./plan-debate-lane.js";
9
+ import {
10
+ getMessengerRoundState,
11
+ messengerRoundDebateReady,
12
+ } from "./plan-messenger.js";
13
+
14
+ async function exists(path: string): Promise<boolean> {
15
+ try {
16
+ await access(path, constants.R_OK);
17
+ return true;
18
+ } catch {
19
+ return false;
20
+ }
21
+ }
22
+
23
+ function lanesForRound(roundIndex: number): DebateLaneKind[] {
24
+ const lanes: DebateLaneKind[] = ["validation-turn", "adversary-brief"];
25
+ if (roundIndex === 1) lanes.unshift("hypothesis-validation");
26
+ if (roundIndex === 4) lanes.push("sprint-audit");
27
+ return lanes;
28
+ }
29
+
30
+ export interface RoundStatusResult {
31
+ round_index: number;
32
+ /** Lane YAML + messenger thread complete; spawn integrator next. */
33
+ ready_for_integrator: boolean;
34
+ /** review-round-rN.yaml on disk (call harness_debate_submit_round if bus not updated). */
35
+ review_round_on_disk: boolean;
36
+ missing: string[];
37
+ next_tool?: string;
38
+ messenger: { ok: boolean; errors: string[] };
39
+ }
40
+
41
+ export async function getPlanDebateRoundStatus(
42
+ runDir: string,
43
+ roundIndex: number,
44
+ ): Promise<RoundStatusResult> {
45
+ const missing: string[] = [];
46
+ for (const lane of lanesForRound(roundIndex)) {
47
+ const rel = laneArtifactPath(lane, roundIndex);
48
+ if (!(await exists(join(runDir, rel)))) {
49
+ missing.push(rel);
50
+ }
51
+ }
52
+ const roundState = await getMessengerRoundState(runDir, roundIndex);
53
+ const messenger = messengerRoundDebateReady(roundState, roundIndex === 4);
54
+ if (!messenger.ok) {
55
+ missing.push(...messenger.errors.map((e) => `messenger: ${e}`));
56
+ }
57
+ const reviewRound = `artifacts/review-round-r${roundIndex}.yaml`;
58
+ const reviewRoundOnDisk = await exists(join(runDir, reviewRound));
59
+
60
+ let next_tool: string | undefined;
61
+ if (missing.some((m) => m.includes("hypothesis-validation"))) {
62
+ next_tool = "subagent harness/planning/hypothesis-validator";
63
+ } else if (missing.some((m) => m.includes("validation-turn"))) {
64
+ next_tool = "subagent harness/planning/plan-evaluator";
65
+ } else if (missing.some((m) => m.includes("adversary-brief"))) {
66
+ next_tool =
67
+ "harness_messenger_read_round then subagent harness/planning/plan-adversary";
68
+ } else if (missing.some((m) => m.includes("sprint-audit"))) {
69
+ next_tool = "subagent harness/planning/sprint-contract-auditor";
70
+ } else if (!messenger.ok) {
71
+ next_tool =
72
+ "harness_debate_apply_lane (evaluator/adversary) or re-spawn lane agent";
73
+ } else if (!reviewRoundOnDisk) {
74
+ next_tool =
75
+ "subagent harness/planning/review-integrator then harness_debate_submit_round";
76
+ } else {
77
+ next_tool =
78
+ "harness_debate_submit_round with integrator draft from review-round file";
79
+ }
80
+
81
+ const readyForIntegrator =
82
+ messenger.ok &&
83
+ missing.filter((m) => !m.startsWith("messenger")).length === 0 &&
84
+ !reviewRoundOnDisk;
85
+
86
+ return {
87
+ round_index: roundIndex,
88
+ ready_for_integrator: readyForIntegrator,
89
+ review_round_on_disk: reviewRoundOnDisk,
90
+ missing,
91
+ next_tool,
92
+ messenger,
93
+ };
94
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * P0 — only harness_debate_submit_round may write review-round-r*.yaml via write_harness_yaml.
3
+ */
4
+
5
+ let reviewRoundWriteDepth = 0;
6
+
7
+ export function isReviewRoundYamlWriteAllowed(): boolean {
8
+ return reviewRoundWriteDepth > 0;
9
+ }
10
+
11
+ export async function withReviewRoundYamlWrite<T>(
12
+ fn: () => Promise<T>,
13
+ ): Promise<T> {
14
+ reviewRoundWriteDepth += 1;
15
+ try {
16
+ return await fn();
17
+ } finally {
18
+ reviewRoundWriteDepth -= 1;
19
+ }
20
+ }
@@ -0,0 +1,276 @@
1
+ /**
2
+ * pi-messenger-style plan debate transport — per-agent inboxes + round threads.
3
+ *
4
+ * Layout under `.pi/harness/runs/<run_id>/debate-messenger/`:
5
+ * inbox/<AgentLabel>/<seq>-<kind>.json
6
+ * threads/round-<N>/transcript.jsonl
7
+ * state.json
8
+ */
9
+
10
+ import { randomUUID } from "node:crypto";
11
+ import {
12
+ appendFile,
13
+ mkdir,
14
+ readdir,
15
+ readFile,
16
+ writeFile,
17
+ } from "node:fs/promises";
18
+ import { join } from "node:path";
19
+ import type { DebateParticipant } from "../../lib/debate-orchestrator-types.js";
20
+
21
+ export type MessengerMessageKind =
22
+ | "system"
23
+ | "claim"
24
+ | "rebuttal"
25
+ | "integrate"
26
+ | "audit";
27
+
28
+ export interface MessengerMessage {
29
+ schema_version: "1.0.0";
30
+ id: string;
31
+ ts: string;
32
+ from: DebateParticipant | "system";
33
+ to: Array<DebateParticipant | "broadcast">;
34
+ kind: MessengerMessageKind;
35
+ round_index: number;
36
+ in_reply_to: string[];
37
+ body: string;
38
+ claim_ids: string[];
39
+ evidence_refs: string[];
40
+ artifact_path?: string;
41
+ }
42
+
43
+ export interface MessengerRoundState {
44
+ round_index: number;
45
+ evaluator_posted: boolean;
46
+ adversary_posted: boolean;
47
+ integrator_posted: boolean;
48
+ claim_count: number;
49
+ rebuttal_count: number;
50
+ }
51
+
52
+ export interface MessengerState {
53
+ schema_version: "1.0.0";
54
+ run_id: string;
55
+ debate_id: string;
56
+ opened_at: string;
57
+ rounds: Record<string, MessengerRoundState>;
58
+ }
59
+
60
+ function messengerRoot(runDir: string): string {
61
+ return join(runDir, "debate-messenger");
62
+ }
63
+
64
+ function nowIso(): string {
65
+ return new Date().toISOString();
66
+ }
67
+
68
+ function roundKey(roundIndex: number): string {
69
+ return String(roundIndex);
70
+ }
71
+
72
+ export async function initPlanMessenger(
73
+ runDir: string,
74
+ opts: { runId: string; debateId: string },
75
+ ): Promise<string> {
76
+ const root = messengerRoot(runDir);
77
+ await mkdir(join(root, "inbox"), { recursive: true });
78
+ await mkdir(join(root, "threads"), { recursive: true });
79
+ const state: MessengerState = {
80
+ schema_version: "1.0.0",
81
+ run_id: opts.runId,
82
+ debate_id: opts.debateId,
83
+ opened_at: nowIso(),
84
+ rounds: {},
85
+ };
86
+ await writeFile(
87
+ join(root, "state.json"),
88
+ `${JSON.stringify(state, null, 2)}\n`,
89
+ "utf-8",
90
+ );
91
+ return root;
92
+ }
93
+
94
+ export async function loadMessengerState(
95
+ runDir: string,
96
+ ): Promise<MessengerState | null> {
97
+ const path = join(messengerRoot(runDir), "state.json");
98
+ try {
99
+ const raw = await readFile(path, "utf-8");
100
+ return JSON.parse(raw) as MessengerState;
101
+ } catch {
102
+ return null;
103
+ }
104
+ }
105
+
106
+ async function saveMessengerState(
107
+ runDir: string,
108
+ state: MessengerState,
109
+ ): Promise<void> {
110
+ await writeFile(
111
+ join(messengerRoot(runDir), "state.json"),
112
+ `${JSON.stringify(state, null, 2)}\n`,
113
+ "utf-8",
114
+ );
115
+ }
116
+
117
+ function defaultRoundState(roundIndex: number): MessengerRoundState {
118
+ return {
119
+ round_index: roundIndex,
120
+ evaluator_posted: false,
121
+ adversary_posted: false,
122
+ integrator_posted: false,
123
+ claim_count: 0,
124
+ rebuttal_count: 0,
125
+ };
126
+ }
127
+
128
+ export async function postMessengerMessage(
129
+ runDir: string,
130
+ msg: Omit<MessengerMessage, "schema_version" | "id" | "ts"> & {
131
+ id?: string;
132
+ ts?: string;
133
+ },
134
+ ): Promise<MessengerMessage> {
135
+ const root = messengerRoot(runDir);
136
+ const full: MessengerMessage = {
137
+ schema_version: "1.0.0",
138
+ id: msg.id ?? randomUUID(),
139
+ ts: msg.ts ?? nowIso(),
140
+ from: msg.from,
141
+ to: msg.to,
142
+ kind: msg.kind,
143
+ round_index: msg.round_index,
144
+ in_reply_to: msg.in_reply_to ?? [],
145
+ body: msg.body,
146
+ claim_ids: msg.claim_ids ?? [],
147
+ evidence_refs: msg.evidence_refs ?? [],
148
+ artifact_path: msg.artifact_path,
149
+ };
150
+
151
+ const inboxDir = join(root, "inbox", full.from);
152
+ await mkdir(inboxDir, { recursive: true });
153
+ const inboxName = `${full.round_index.toString().padStart(2, "0")}-${full.kind}-${full.id.slice(0, 8)}.json`;
154
+ await writeFile(
155
+ join(inboxDir, inboxName),
156
+ `${JSON.stringify(full, null, 2)}\n`,
157
+ );
158
+
159
+ const threadDir = join(root, "threads", `round-${full.round_index}`);
160
+ await mkdir(threadDir, { recursive: true });
161
+ await appendFile(
162
+ join(threadDir, "transcript.jsonl"),
163
+ `${JSON.stringify(full)}\n`,
164
+ "utf-8",
165
+ );
166
+
167
+ const state = (await loadMessengerState(runDir)) ?? {
168
+ schema_version: "1.0.0",
169
+ run_id: "",
170
+ debate_id: "",
171
+ opened_at: nowIso(),
172
+ rounds: {},
173
+ };
174
+ const key = roundKey(full.round_index);
175
+ const round = state.rounds[key] ?? defaultRoundState(full.round_index);
176
+ if (full.from === "PlanEvaluatorAgent" && full.kind === "claim") {
177
+ round.evaluator_posted = true;
178
+ round.claim_count += full.claim_ids.length || 1;
179
+ }
180
+ if (full.from === "PlanAdversaryAgent" && full.kind === "rebuttal") {
181
+ round.adversary_posted = true;
182
+ round.rebuttal_count += full.in_reply_to.length || 1;
183
+ }
184
+ if (full.from === "ReviewIntegratorAgent" && full.kind === "integrate") {
185
+ round.integrator_posted = true;
186
+ }
187
+ state.rounds[key] = round;
188
+ await saveMessengerState(runDir, state);
189
+ return full;
190
+ }
191
+
192
+ export async function readRoundTranscript(
193
+ runDir: string,
194
+ roundIndex: number,
195
+ ): Promise<MessengerMessage[]> {
196
+ const path = join(
197
+ messengerRoot(runDir),
198
+ "threads",
199
+ `round-${roundIndex}`,
200
+ "transcript.jsonl",
201
+ );
202
+ try {
203
+ const raw = await readFile(path, "utf-8");
204
+ return raw
205
+ .split("\n")
206
+ .filter((line) => line.trim())
207
+ .map((line) => JSON.parse(line) as MessengerMessage);
208
+ } catch {
209
+ return [];
210
+ }
211
+ }
212
+
213
+ export function formatTranscriptForSpawn(
214
+ messages: MessengerMessage[],
215
+ maxChars = 12000,
216
+ ): string {
217
+ const lines = messages.map((m) => {
218
+ const reply =
219
+ m.in_reply_to.length > 0 ? ` (re: ${m.in_reply_to.join(", ")})` : "";
220
+ const claims = m.claim_ids.length > 0 ? ` [${m.claim_ids.join(", ")}]` : "";
221
+ return `[${m.from}/${m.kind}${claims}${reply}] ${m.body}`;
222
+ });
223
+ let text = lines.join("\n\n");
224
+ if (text.length > maxChars) {
225
+ text = `${text.slice(0, maxChars)}\n\n…(transcript truncated)`;
226
+ }
227
+ return text || "(empty thread — post evaluator claims before adversary)";
228
+ }
229
+
230
+ export async function getMessengerRoundState(
231
+ runDir: string,
232
+ roundIndex: number,
233
+ ): Promise<MessengerRoundState | null> {
234
+ const state = await loadMessengerState(runDir);
235
+ if (!state) return null;
236
+ return state.rounds[roundKey(roundIndex)] ?? null;
237
+ }
238
+
239
+ export function messengerRoundDebateReady(
240
+ round: MessengerRoundState | null,
241
+ _requireSprintAudit: boolean,
242
+ ): { ok: boolean; errors: string[] } {
243
+ const errors: string[] = [];
244
+ if (!round) {
245
+ errors.push("no messenger activity for this round");
246
+ return { ok: false, errors };
247
+ }
248
+ if (!round.evaluator_posted) {
249
+ errors.push("PlanEvaluatorAgent has not posted claims to the thread");
250
+ }
251
+ if (!round.adversary_posted) {
252
+ errors.push("PlanAdversaryAgent has not posted rebuttals to the thread");
253
+ }
254
+ if (round.claim_count < 1) {
255
+ errors.push("round thread has no claim_ids");
256
+ }
257
+ if (round.rebuttal_count < 1) {
258
+ errors.push("adversary must rebut at least one claim (in_reply_to)");
259
+ }
260
+ if (!round.integrator_posted) {
261
+ errors.push(
262
+ "ReviewIntegratorAgent must post integrate message before bus submit",
263
+ );
264
+ }
265
+ return { ok: errors.length === 0, errors };
266
+ }
267
+
268
+ export async function listInboxAgents(runDir: string): Promise<string[]> {
269
+ const inbox = join(messengerRoot(runDir), "inbox");
270
+ try {
271
+ const entries = await readdir(inbox, { withFileTypes: true });
272
+ return entries.filter((e) => e.isDirectory()).map((e) => e.name);
273
+ } catch {
274
+ return [];
275
+ }
276
+ }