@alexeiled/pi-fusion 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.
package/src/report.ts ADDED
@@ -0,0 +1,478 @@
1
+ import type { FailedPanelSummary, PanelOutput } from "./run-builder.js";
2
+ import type { FusionRun } from "./types.js";
3
+
4
+ type ReportRun = Pick<
5
+ FusionRun,
6
+ "id" | "prompt" | "profileName" | "panelRunId" | "judgeRunId"
7
+ > &
8
+ Partial<Pick<FusionRun, "phase" | "createdAt" | "updatedAt">>;
9
+
10
+ export interface RenderPanelFailureReportInput {
11
+ run: ReportRun;
12
+ failures: readonly FailedPanelSummary[];
13
+ error?: string;
14
+ }
15
+
16
+ export interface RenderSinglePanelReportInput {
17
+ run: ReportRun;
18
+ output: PanelOutput;
19
+ failures: readonly FailedPanelSummary[];
20
+ }
21
+
22
+ export interface RenderJudgeReportInput {
23
+ run: ReportRun;
24
+ judgeOutput: string;
25
+ panelOutputs?: readonly PanelOutput[];
26
+ failures?: readonly FailedPanelSummary[];
27
+ }
28
+
29
+ export interface RenderFailureReportInput {
30
+ run: ReportRun;
31
+ error: string;
32
+ panelOutputs?: readonly PanelOutput[];
33
+ failures?: readonly FailedPanelSummary[];
34
+ }
35
+
36
+ export interface RenderCancelledReportInput {
37
+ run: ReportRun;
38
+ method: "stop" | "interrupt" | "local";
39
+ targetRunId?: string;
40
+ panelOutputs?: readonly PanelOutput[];
41
+ failures?: readonly FailedPanelSummary[];
42
+ }
43
+
44
+ type ReportSectionTitle =
45
+ | "Summary"
46
+ | "Agent Status"
47
+ | "Consensus"
48
+ | "Disagreements"
49
+ | "Unique Insights"
50
+ | "Blind Spots"
51
+ | "Recommendation"
52
+ | "Risks"
53
+ | "Next Step"
54
+ | "Run Metadata";
55
+
56
+ interface ReportSection {
57
+ title: ReportSectionTitle;
58
+ content: string | readonly string[];
59
+ }
60
+
61
+ interface AgentStatusOptions {
62
+ panelOutputs?: readonly PanelOutput[];
63
+ failures?: readonly FailedPanelSummary[];
64
+ judgeStatus: string;
65
+ extra?: readonly string[];
66
+ }
67
+
68
+ export function renderPanelFailureReport(
69
+ input: RenderPanelFailureReportInput,
70
+ ): string {
71
+ return renderReport([
72
+ {
73
+ title: "Summary",
74
+ content:
75
+ "No panelists completed successfully. The fusion run could not produce a recommendation.",
76
+ },
77
+ {
78
+ title: "Agent Status",
79
+ content: formatAgentStatus({
80
+ panelOutputs: [],
81
+ failures: input.failures,
82
+ judgeStatus: "not run - no successful panelists",
83
+ }),
84
+ },
85
+ {
86
+ title: "Consensus",
87
+ content: "No consensus was available because all panelists failed.",
88
+ },
89
+ {
90
+ title: "Disagreements",
91
+ content:
92
+ "No disagreements were synthesized because the judge did not run.",
93
+ },
94
+ {
95
+ title: "Unique Insights",
96
+ content: "No panel output was available to summarize.",
97
+ },
98
+ {
99
+ title: "Blind Spots",
100
+ content:
101
+ "All panelists failed, so the report may be missing every intended review perspective.",
102
+ },
103
+ { title: "Recommendation", content: "No recommendation is available." },
104
+ {
105
+ title: "Risks",
106
+ content: input.error
107
+ ? `All panelists failed. Root error: ${firstLine(input.error)}`
108
+ : "All panelists failed before producing usable output.",
109
+ },
110
+ {
111
+ title: "Next Step",
112
+ content:
113
+ "Inspect the failed subagent run IDs or artifacts, then retry /fusion after fixing the cause.",
114
+ },
115
+ { title: "Run Metadata", content: formatRunMetadata(input.run) },
116
+ ]);
117
+ }
118
+
119
+ export function renderSinglePanelReport(
120
+ input: RenderSinglePanelReportInput,
121
+ ): string {
122
+ const panelName = formatPanelName(input.output);
123
+ return renderReport([
124
+ {
125
+ title: "Summary",
126
+ content:
127
+ "Only one panelist completed successfully, so pi-fusion skipped the judge step.",
128
+ },
129
+ {
130
+ title: "Agent Status",
131
+ content: formatAgentStatus({
132
+ panelOutputs: [input.output],
133
+ failures: input.failures,
134
+ judgeStatus: "skipped - one successful panelist",
135
+ }),
136
+ },
137
+ {
138
+ title: "Consensus",
139
+ content:
140
+ "Only one panelist succeeded; no cross-panel consensus was available.",
141
+ },
142
+ {
143
+ title: "Disagreements",
144
+ content:
145
+ "No disagreements were synthesized because the judge did not run.",
146
+ },
147
+ {
148
+ title: "Unique Insights",
149
+ content: `Single successful panelist: ${panelName}.`,
150
+ },
151
+ {
152
+ title: "Blind Spots",
153
+ content:
154
+ "The result was not compared against another successful panelist or judge synthesis.",
155
+ },
156
+ {
157
+ title: "Recommendation",
158
+ content:
159
+ input.output.output.trim() || "Panelist completed without output.",
160
+ },
161
+ {
162
+ title: "Risks",
163
+ content:
164
+ "Single-panel results can miss disagreements, blind spots, and model-specific failure modes.",
165
+ },
166
+ {
167
+ title: "Next Step",
168
+ content:
169
+ "Use this single-panel result directly, or rerun /fusion if you need judge synthesis.",
170
+ },
171
+ { title: "Run Metadata", content: formatRunMetadata(input.run) },
172
+ ]);
173
+ }
174
+
175
+ export function renderJudgeReport(input: RenderJudgeReportInput): string {
176
+ const panelOutputs = input.panelOutputs ?? [];
177
+ const failures = input.failures ?? [];
178
+ const sections = parseMarkdownSections(input.judgeOutput);
179
+ const unsectionedOutput = stripReportTitle(input.judgeOutput);
180
+ const recommendationFallback =
181
+ sections.size === 0 && unsectionedOutput
182
+ ? unsectionedOutput
183
+ : "Judge completed without a recommendation.";
184
+
185
+ return renderReport([
186
+ {
187
+ title: "Summary",
188
+ content: sections.get("Summary") ?? judgeSummary(panelOutputs, failures),
189
+ },
190
+ {
191
+ title: "Agent Status",
192
+ content: formatAgentStatus({
193
+ panelOutputs,
194
+ failures,
195
+ judgeStatus: "succeeded",
196
+ }),
197
+ },
198
+ {
199
+ title: "Consensus",
200
+ content: sections.get("Consensus") ?? "Not specified by the judge.",
201
+ },
202
+ {
203
+ title: "Disagreements",
204
+ content: sections.get("Disagreements") ?? "Not specified by the judge.",
205
+ },
206
+ {
207
+ title: "Unique Insights",
208
+ content: sections.get("Unique Insights") ?? "Not specified by the judge.",
209
+ },
210
+ {
211
+ title: "Blind Spots",
212
+ content: sections.get("Blind Spots") ?? "Not specified by the judge.",
213
+ },
214
+ {
215
+ title: "Recommendation",
216
+ content: sections.get("Recommendation") ?? recommendationFallback,
217
+ },
218
+ {
219
+ title: "Risks",
220
+ content: sections.get("Risks") ?? "Not specified by the judge.",
221
+ },
222
+ {
223
+ title: "Next Step",
224
+ content:
225
+ sections.get("Next Step") ??
226
+ "Review the recommendation and decide whether to act on it.",
227
+ },
228
+ { title: "Run Metadata", content: formatRunMetadata(input.run) },
229
+ ]);
230
+ }
231
+
232
+ export function renderFailureReport(input: RenderFailureReportInput): string {
233
+ const phase = input.run.phase ?? "unknown";
234
+ return renderReport([
235
+ {
236
+ title: "Summary",
237
+ content: "Fusion failed before it could produce a final report.",
238
+ },
239
+ {
240
+ title: "Agent Status",
241
+ content: formatAgentStatus({
242
+ ...(input.panelOutputs !== undefined
243
+ ? { panelOutputs: input.panelOutputs }
244
+ : {}),
245
+ ...(input.failures !== undefined ? { failures: input.failures } : {}),
246
+ judgeStatus: `failed - ${firstLine(input.error)}`,
247
+ extra: [`- Phase: ${phase}`],
248
+ }),
249
+ },
250
+ {
251
+ title: "Consensus",
252
+ content: "No consensus was available because fusion failed.",
253
+ },
254
+ {
255
+ title: "Disagreements",
256
+ content: "No disagreements were synthesized because fusion failed.",
257
+ },
258
+ {
259
+ title: "Unique Insights",
260
+ content: "No unique insights were synthesized because fusion failed.",
261
+ },
262
+ {
263
+ title: "Blind Spots",
264
+ content:
265
+ "The failure may hide panel disagreements, missing evidence, or provider-specific errors.",
266
+ },
267
+ { title: "Recommendation", content: "No recommendation is available." },
268
+ {
269
+ title: "Risks",
270
+ content: `Fusion failed in phase ${phase}: ${input.error}`,
271
+ },
272
+ {
273
+ title: "Next Step",
274
+ content: "Fix the reported error and retry /fusion.",
275
+ },
276
+ { title: "Run Metadata", content: formatRunMetadata(input.run) },
277
+ ]);
278
+ }
279
+
280
+ export function renderCancelledReport(
281
+ input: RenderCancelledReportInput,
282
+ ): string {
283
+ const target = input.targetRunId ?? "not started";
284
+ return renderReport([
285
+ { title: "Summary", content: "Fusion cancellation was requested." },
286
+ {
287
+ title: "Agent Status",
288
+ content: formatAgentStatus({
289
+ ...(input.panelOutputs !== undefined
290
+ ? { panelOutputs: input.panelOutputs }
291
+ : {}),
292
+ ...(input.failures !== undefined ? { failures: input.failures } : {}),
293
+ judgeStatus: "cancelled or not completed",
294
+ extra: [
295
+ `- Phase: ${input.run.phase ?? "unknown"}`,
296
+ `- Cancellation method: ${input.method}`,
297
+ `- Target run: ${target}`,
298
+ ],
299
+ }),
300
+ },
301
+ {
302
+ title: "Consensus",
303
+ content: "No final consensus was available because fusion was cancelled.",
304
+ },
305
+ {
306
+ title: "Disagreements",
307
+ content:
308
+ "No final disagreements were synthesized because fusion was cancelled.",
309
+ },
310
+ {
311
+ title: "Unique Insights",
312
+ content:
313
+ "No final unique insights were synthesized because fusion was cancelled.",
314
+ },
315
+ {
316
+ title: "Blind Spots",
317
+ content:
318
+ "Cancellation may leave in-flight panel or judge output incomplete.",
319
+ },
320
+ { title: "Recommendation", content: "No recommendation is available." },
321
+ {
322
+ title: "Risks",
323
+ content: `The target subagent run (${target}) may still need inspection if it does not stop promptly.`,
324
+ },
325
+ {
326
+ title: "Next Step",
327
+ content: "Inspect the subagent run if it does not stop promptly.",
328
+ },
329
+ { title: "Run Metadata", content: formatRunMetadata(input.run) },
330
+ ]);
331
+ }
332
+
333
+ function renderReport(sections: readonly ReportSection[]): string {
334
+ return [
335
+ "# Fusion Report",
336
+ ...sections.flatMap((section) => [
337
+ "",
338
+ `## ${section.title}`,
339
+ formatSectionContent(section.content),
340
+ ]),
341
+ ].join("\n");
342
+ }
343
+
344
+ function formatSectionContent(content: string | readonly string[]): string {
345
+ const text = typeof content === "string" ? content : content.join("\n");
346
+ return text.trim() || "None.";
347
+ }
348
+
349
+ function formatAgentStatus(options: AgentStatusOptions): string[] {
350
+ const hasPanelStatus =
351
+ options.panelOutputs !== undefined || options.failures !== undefined;
352
+ const outputs = [...(options.panelOutputs ?? [])].sort(comparePanelItems);
353
+ const failures = [...(options.failures ?? [])].sort(comparePanelItems);
354
+ const lines: string[] = [];
355
+
356
+ if (hasPanelStatus) {
357
+ lines.push(`- Successful panelists: ${outputs.length}`);
358
+ lines.push(`- Failed panelists: ${failures.length}`);
359
+ for (const output of outputs) {
360
+ lines.push(`- ${formatPanelName(output)}: succeeded`);
361
+ lines.push(...formatPanelDetails(output));
362
+ }
363
+ for (const failure of failures) {
364
+ lines.push(
365
+ `- ${formatPanelName(failure)}: failed - ${firstLine(failure.summary)}`,
366
+ );
367
+ lines.push(...formatPanelDetails(failure));
368
+ }
369
+ } else {
370
+ lines.push("- Panel status: not available");
371
+ }
372
+
373
+ lines.push(`- Judge: ${options.judgeStatus}`);
374
+ if (options.extra) lines.push(...options.extra);
375
+ return lines;
376
+ }
377
+
378
+ function formatPanelDetails(
379
+ item: Pick<PanelOutput, "agent" | "artifactPath" | "sessionPath">,
380
+ ): string[] {
381
+ return [
382
+ ` Agent: ${item.agent}`,
383
+ ...(item.artifactPath ? [` Artifact: ${item.artifactPath}`] : []),
384
+ ...(item.sessionPath ? [` Session: ${item.sessionPath}`] : []),
385
+ ];
386
+ }
387
+
388
+ function formatRunMetadata(run: ReportRun): string[] {
389
+ return [
390
+ `- Fusion run: ${run.id}`,
391
+ `- Profile: ${run.profileName}`,
392
+ ...(run.phase ? [`- Phase: ${run.phase}`] : []),
393
+ `- Prompt: ${firstLine(run.prompt)}`,
394
+ ...(run.panelRunId ? [`- Panel run: ${run.panelRunId}`] : []),
395
+ ...(run.judgeRunId ? [`- Judge run: ${run.judgeRunId}`] : []),
396
+ ...(typeof run.createdAt === "number"
397
+ ? [`- Created: ${formatTimestamp(run.createdAt)}`]
398
+ : []),
399
+ ...(typeof run.updatedAt === "number"
400
+ ? [`- Updated: ${formatTimestamp(run.updatedAt)}`]
401
+ : []),
402
+ ];
403
+ }
404
+
405
+ function parseMarkdownSections(markdown: string): Map<string, string> {
406
+ const sections = new Map<string, string>();
407
+ let currentTitle: string | undefined;
408
+ let currentLines: string[] = [];
409
+
410
+ for (const line of markdown.split(/\r?\n/)) {
411
+ const heading = line.match(/^##\s+(.+?)\s*#*\s*$/);
412
+ if (heading) {
413
+ storeSection(sections, currentTitle, currentLines);
414
+ currentTitle = heading[1]?.trim();
415
+ currentLines = [];
416
+ continue;
417
+ }
418
+ if (currentTitle) currentLines.push(line);
419
+ }
420
+ storeSection(sections, currentTitle, currentLines);
421
+ return sections;
422
+ }
423
+
424
+ function storeSection(
425
+ sections: Map<string, string>,
426
+ title: string | undefined,
427
+ lines: readonly string[],
428
+ ): void {
429
+ if (!title) return;
430
+ const content = lines.join("\n").trim();
431
+ if (content) sections.set(title, content);
432
+ }
433
+
434
+ function stripReportTitle(markdown: string): string {
435
+ return markdown
436
+ .split(/\r?\n/)
437
+ .filter((line) => !/^#\s+Fusion Report\s*$/.test(line.trim()))
438
+ .join("\n")
439
+ .trim();
440
+ }
441
+
442
+ function judgeSummary(
443
+ outputs: readonly PanelOutput[],
444
+ failures: readonly FailedPanelSummary[],
445
+ ): string {
446
+ if (failures.length > 0) {
447
+ return `Fusion completed with ${outputs.length} successful ${plural(outputs.length, "panelist")} and ${failures.length} failed ${plural(failures.length, "panelist")}.`;
448
+ }
449
+ if (outputs.length > 0) {
450
+ return `Fusion completed with ${outputs.length} successful ${plural(outputs.length, "panelist")}.`;
451
+ }
452
+ return "Fusion judge completed.";
453
+ }
454
+
455
+ function plural(count: number, singular: string): string {
456
+ return count === 1 ? singular : `${singular}s`;
457
+ }
458
+
459
+ function formatPanelName(
460
+ item: Pick<PanelOutput, "index" | "id" | "label">,
461
+ ): string {
462
+ return item.label ?? item.id ?? `Panelist ${item.index + 1}`;
463
+ }
464
+
465
+ function comparePanelItems(
466
+ left: Pick<PanelOutput, "index">,
467
+ right: Pick<PanelOutput, "index">,
468
+ ): number {
469
+ return left.index - right.index;
470
+ }
471
+
472
+ function firstLine(value: string): string {
473
+ return value.split(/\r?\n/, 1)[0]?.trim() || "(empty)";
474
+ }
475
+
476
+ function formatTimestamp(value: number): string {
477
+ return new Date(value).toISOString();
478
+ }