@alexeiled/pi-fusion 0.5.2 → 0.6.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.
@@ -3,12 +3,18 @@ import {
3
3
  PANEL_DECISION_OPEN,
4
4
  } from "./run-observations.js";
5
5
  import {
6
+ COMPOSER_AGENT,
7
+ JUDGE_AGENT,
8
+ memberLabel,
9
+ panelItemLabel,
10
+ resolveSynthesisMode,
6
11
  THINKING_LEVELS,
7
12
  type FailedPanelSummary,
8
13
  type FusionProfile,
9
14
  type PanelMemberConfig,
10
15
  type PanelOutput,
11
16
  type ThinkingLevel,
17
+ type ToolBudget,
12
18
  } from "./types.js";
13
19
 
14
20
  export const FUSION_ACCEPTANCE_DISABLED = {
@@ -90,6 +96,8 @@ export interface JudgeSpawnParams {
90
96
  acceptance: FusionAcceptanceDisabled;
91
97
  model?: string;
92
98
  timeoutMs?: number;
99
+ /** Per-task cap on judge tool calls; see `FusionProfile.judgeToolBudget`. */
100
+ toolBudget?: ToolBudget;
93
101
  }
94
102
 
95
103
  export type { FailedPanelSummary, PanelOutput } from "./types.js";
@@ -99,6 +107,12 @@ export interface BuildJudgeSpawnParamsInput {
99
107
  prompt: string;
100
108
  panelOutputs: readonly PanelOutput[];
101
109
  failedPanelists: readonly FailedPanelSummary[];
110
+ /**
111
+ * Seeds the order panel answers are presented to the judge. Required rather
112
+ * than optional: a missing seed would silently restore the fixed index order
113
+ * and its position bias.
114
+ */
115
+ runId: string;
102
116
  }
103
117
 
104
118
  const PANEL_OUTPUT_CONTRACT = [
@@ -116,6 +130,7 @@ const JUDGE_OUTPUT_CONTRACT = [
116
130
  "## Agent Status",
117
131
  "## Consensus",
118
132
  "## Disagreements",
133
+ "## Contested Claims",
119
134
  "## Unique Insights",
120
135
  "## Blind Spots",
121
136
  "## Recommendation",
@@ -123,6 +138,36 @@ const JUDGE_OUTPUT_CONTRACT = [
123
138
  "## Next Step",
124
139
  ] as const;
125
140
 
141
+ const COMPOSER_OUTPUT_CONTRACT = [
142
+ "# Fusion Report",
143
+ "## Summary",
144
+ "## Coverage Map",
145
+ "## Combined Answer",
146
+ "## Gaps",
147
+ "## Conflicts At Seams",
148
+ "## Agent Status",
149
+ "## Risks",
150
+ "## Next Step",
151
+ ] as const;
152
+
153
+ const COMPOSER_INSTRUCTIONS = [
154
+ "You are the fusion composer.",
155
+ "Read-only synthesis only. Leave files, git state, and the workspace untouched. Do not ask other agents. Do not run subagents.",
156
+ "The panelists answered DIFFERENT facets of one task. Merge their answers; do not pick a winner.",
157
+ "- Do not rank panelists. They were not competing.",
158
+ "- Report a conflict only where facets genuinely overlap and disagree. Different subject matter is not disagreement.",
159
+ "- Name facets nobody covered, or covered only in passing.",
160
+ "- Where facets overlap and state conflicting facts about this codebase, check the claim with your read tools and cite file:line under Conflicts At Seams. Do not settle it by whose wording sounds more confident.",
161
+ ] as const;
162
+
163
+ const CONTESTED_CLAIMS_INSTRUCTIONS = [
164
+ "Contested claims:",
165
+ "- Where panelists state conflicting facts about this codebase, do not pick the more confident wording.",
166
+ "- Check the claim yourself with your read tools and cite file:line.",
167
+ "- Report each contested claim as: the claim, what you found, and which panelist was right.",
168
+ "- If you could not verify a claim, say so explicitly rather than choosing.",
169
+ ] as const;
170
+
126
171
  export function appendThinkingSuffix(
127
172
  model: string | undefined,
128
173
  thinking: ThinkingLevel | undefined,
@@ -207,7 +252,7 @@ export function buildJudgeSpawnParams(
207
252
  input.profile.judge.thinking,
208
253
  );
209
254
  return {
210
- agent: input.profile.judge.agent,
255
+ agent: resolveSynthesisAgent(input.profile),
211
256
  task: buildJudgeTask(input),
212
257
  async: true,
213
258
  clarify: false,
@@ -217,6 +262,9 @@ export function buildJudgeSpawnParams(
217
262
  skill: false,
218
263
  acceptance: FUSION_ACCEPTANCE_DISABLED,
219
264
  ...(model ? { model } : {}),
265
+ ...(input.profile.judgeToolBudget
266
+ ? { toolBudget: input.profile.judgeToolBudget }
267
+ : {}),
220
268
  ...(input.profile.timeoutMs !== undefined
221
269
  ? { timeoutMs: input.profile.timeoutMs }
222
270
  : {}),
@@ -250,7 +298,7 @@ function buildPanelChainTaskParams(
250
298
  agent: member.agent,
251
299
  task: buildPanelTask(member, "{task}", false),
252
300
  as: chainOutputName(member, index),
253
- label: member.label,
301
+ label: memberLabel(member),
254
302
  phase: "Panel",
255
303
  output: true,
256
304
  outputMode: "inline",
@@ -268,11 +316,10 @@ function buildPanelTask(
268
316
  ): string {
269
317
  const role = member.role?.trim() || "independent analysis and critique";
270
318
  return [
271
- `Panel member: ${member.label} (${member.id})`,
319
+ `Panel member: ${memberLabel(member)} (${member.id})`,
272
320
  `Role: ${role}`,
273
321
  "",
274
- "Original task:",
275
- prompt.trim(),
322
+ ...formatMemberTask(member, prompt),
276
323
  "",
277
324
  "Instructions:",
278
325
  "- Work independently from the other panelists.",
@@ -300,31 +347,120 @@ function buildPanelTask(
300
347
  ].join("\n");
301
348
  }
302
349
 
350
+ /**
351
+ * Merge mode swaps the synthesis agent, not the run slot. It still spawns into
352
+ * `judgeRunId`/`judgeAsyncDir` under phase `judge`, so `fusion:rpc:v1` consumers
353
+ * see no new phase value.
354
+ *
355
+ * An explicitly configured judge agent still wins: a user who names their own
356
+ * synthesis agent means it.
357
+ */
358
+ function resolveSynthesisAgent(profile: FusionProfile): string {
359
+ if (resolveSynthesisMode(profile) !== "merge") return profile.judge.agent;
360
+ return profile.judge.agent === JUDGE_AGENT
361
+ ? COMPOSER_AGENT
362
+ : profile.judge.agent;
363
+ }
364
+
365
+ const TASK_PLACEHOLDER = "{task}";
366
+
367
+ /**
368
+ * A member with a `question` answers that facet instead of the whole prompt.
369
+ * If the template omits `{task}` the original prompt is still appended: dropping
370
+ * it would silently strip the context the panelist needs.
371
+ */
372
+ function formatMemberTask(member: PanelMemberConfig, prompt: string): string[] {
373
+ const question = member.question?.trim();
374
+ const task = prompt.trim();
375
+ if (!question) return ["Original task:", task];
376
+
377
+ const facet = question.replaceAll(TASK_PLACEHOLDER, task);
378
+ if (question.includes(TASK_PLACEHOLDER)) {
379
+ return ["Your assigned facet of the task:", facet];
380
+ }
381
+ return [
382
+ "Your assigned facet of the task:",
383
+ facet,
384
+ "",
385
+ "Original task:",
386
+ task,
387
+ ];
388
+ }
389
+
303
390
  function buildJudgeTask(input: BuildJudgeSpawnParamsInput): string {
304
391
  const sortedOutputs = [...input.panelOutputs].sort(comparePanelItems);
305
392
  const sortedFailures = [...input.failedPanelists].sort(comparePanelItems);
393
+ // Status and failure lists stay in configuration order so the reader can map
394
+ // them to the profile. Only the answers the judge weighs are shuffled.
395
+ const presentedOutputs = shufflePanelItems(sortedOutputs, input.runId);
396
+ const blindLabels = input.profile.blindPanelLabels
397
+ ? buildBlindLabelMap([...sortedOutputs, ...sortedFailures])
398
+ : undefined;
399
+ const merging = resolveSynthesisMode(input.profile) === "merge";
306
400
  return [
307
- "You are the fusion judge.",
308
- "Read-only synthesis only. Leave files, git state, and the workspace untouched. Do not ask other agents. Do not run subagents.",
309
- "Synthesize the panel results. Preserve disagreement instead of forcing consensus.",
401
+ ...(merging
402
+ ? COMPOSER_INSTRUCTIONS
403
+ : [
404
+ "You are the fusion judge.",
405
+ "Read-only synthesis only. Leave files, git state, and the workspace untouched. Do not ask other agents. Do not run subagents.",
406
+ "Synthesize the panel results. Preserve disagreement instead of forcing consensus.",
407
+ ]),
310
408
  "",
311
409
  "Original task:",
312
410
  input.prompt.trim(),
313
411
  "",
314
412
  "Panel status:",
315
- ...formatPanelStatus(sortedOutputs, sortedFailures),
413
+ ...formatPanelStatus(sortedOutputs, sortedFailures, blindLabels),
316
414
  "",
415
+ ...(merging
416
+ ? [
417
+ "Facet assignments:",
418
+ ...formatFacetAssignments(input.profile, input.prompt, blindLabels),
419
+ "",
420
+ ]
421
+ : []),
317
422
  "Successful panel outputs:",
318
- ...formatPanelOutputs(sortedOutputs),
423
+ ...formatPanelOutputs(presentedOutputs, blindLabels),
319
424
  "",
320
425
  "Failed panelists:",
321
- ...formatFailedPanelists(sortedFailures),
426
+ ...formatFailedPanelists(sortedFailures, blindLabels),
322
427
  "",
428
+ // Judge-only. The composer has no Contested Claims section, so this block
429
+ // produces content the report silently drops, and its "which panelist was
430
+ // right" wording contradicts the composer's "do not rank panelists".
431
+ ...(merging ? [] : [...CONTESTED_CLAIMS_INSTRUCTIONS, ""]),
323
432
  "Output contract:",
324
- ...JUDGE_OUTPUT_CONTRACT,
433
+ ...(merging ? COMPOSER_OUTPUT_CONTRACT : JUDGE_OUTPUT_CONTRACT),
325
434
  ].join("\n");
326
435
  }
327
436
 
437
+ /**
438
+ * Lists what each member was asked to cover, so the composer can name gaps
439
+ * without reverse-engineering them from the answers it did receive.
440
+ */
441
+ function formatFacetAssignments(
442
+ profile: FusionProfile,
443
+ prompt: string,
444
+ blindLabels?: ReadonlyMap<number, string>,
445
+ ): string[] {
446
+ return profile.panel.map((member, index) => {
447
+ // Substituted the same way the panelist saw it; the raw template would put
448
+ // a literal "{task}" in front of the composer.
449
+ const question = member.question?.trim();
450
+ const facet = question
451
+ ? question.replaceAll(TASK_PLACEHOLDER, prompt.trim())
452
+ : (member.role?.trim() ?? "the whole task");
453
+ // The blind map only covers members that produced an output or a failure.
454
+ // A member stopped early by `stopWhenPanelAgrees` is in neither, and
455
+ // falling back to `member.label` would leak the name into the very prompt
456
+ // that is meant to hide it.
457
+ const name = blindLabels
458
+ ? (blindLabels.get(index) ?? "Candidate (did not report)")
459
+ : memberLabel(member);
460
+ return `- ${name}: ${facet}`;
461
+ });
462
+ }
463
+
328
464
  function buildChainJudgeTask(panel: readonly PanelMemberConfig[]): string {
329
465
  return [
330
466
  "You are the fusion judge.",
@@ -338,7 +474,7 @@ function buildChainJudgeTask(panel: readonly PanelMemberConfig[]): string {
338
474
  "",
339
475
  "Panel outputs:",
340
476
  ...panel.flatMap((member, index) => [
341
- `## ${member.label} (${member.id})`,
477
+ `## ${memberLabel(member)} (${member.id})`,
342
478
  `Agent: ${member.agent}`,
343
479
  "",
344
480
  `{outputs.${chainOutputName(member, index)}}`,
@@ -352,29 +488,39 @@ function buildChainJudgeTask(panel: readonly PanelMemberConfig[]): string {
352
488
  function formatPanelStatus(
353
489
  outputs: readonly PanelOutput[],
354
490
  failures: readonly FailedPanelSummary[],
491
+ blindLabels?: ReadonlyMap<number, string>,
355
492
  ): string[] {
356
493
  const lines = [
357
494
  `- Successful panelists: ${outputs.length}`,
358
495
  `- Failed panelists: ${failures.length}`,
359
496
  ];
360
497
  for (const output of outputs) {
361
- lines.push(`- ${formatPanelName(output)}: succeeded`);
498
+ lines.push(`- ${formatPanelName(output, blindLabels)}: succeeded`);
362
499
  }
363
500
  for (const failure of failures) {
364
501
  lines.push(
365
- `- ${formatPanelName(failure)}: failed - ${firstLine(failure.summary)}`,
502
+ `- ${formatPanelName(failure, blindLabels)}: failed - ${firstLine(failure.summary)}`,
366
503
  );
367
504
  }
368
505
  return lines;
369
506
  }
370
507
 
371
- function formatPanelOutputs(outputs: readonly PanelOutput[]): string[] {
508
+ function formatPanelOutputs(
509
+ outputs: readonly PanelOutput[],
510
+ blindLabels?: ReadonlyMap<number, string>,
511
+ ): string[] {
372
512
  if (outputs.length === 0) return ["(none)"];
513
+ // Agent names and artifact paths carry the member id, so they are withheld
514
+ // when blinding. They are debugging aids for the reader, not judging inputs.
373
515
  return outputs.flatMap((output) => [
374
- `## ${formatPanelName(output)}`,
375
- `Agent: ${output.agent}`,
376
- ...(output.artifactPath ? [`Artifact: ${output.artifactPath}`] : []),
377
- ...(output.sessionPath ? [`Session: ${output.sessionPath}`] : []),
516
+ `## ${formatPanelName(output, blindLabels)}`,
517
+ ...(blindLabels
518
+ ? []
519
+ : [
520
+ `Agent: ${output.agent}`,
521
+ ...(output.artifactPath ? [`Artifact: ${output.artifactPath}`] : []),
522
+ ...(output.sessionPath ? [`Session: ${output.sessionPath}`] : []),
523
+ ]),
378
524
  "",
379
525
  output.output,
380
526
  "",
@@ -383,19 +529,56 @@ function formatPanelOutputs(outputs: readonly PanelOutput[]): string[] {
383
529
 
384
530
  function formatFailedPanelists(
385
531
  failures: readonly FailedPanelSummary[],
532
+ blindLabels?: ReadonlyMap<number, string>,
386
533
  ): string[] {
387
534
  if (failures.length === 0) return ["(none)"];
388
- return failures.flatMap((failure) => [
389
- `- ${formatPanelName(failure)} (${failure.agent}): ${failure.summary}`,
390
- ...(failure.artifactPath ? [` Artifact: ${failure.artifactPath}`] : []),
391
- ...(failure.sessionPath ? [` Session: ${failure.sessionPath}`] : []),
392
- ]);
535
+ return failures.flatMap((failure) =>
536
+ blindLabels
537
+ ? [`- ${formatPanelName(failure, blindLabels)}: ${failure.summary}`]
538
+ : [
539
+ `- ${formatPanelName(failure)} (${failure.agent}): ${failure.summary}`,
540
+ ...(failure.artifactPath
541
+ ? [` Artifact: ${failure.artifactPath}`]
542
+ : []),
543
+ ...(failure.sessionPath ? [` Session: ${failure.sessionPath}`] : []),
544
+ ],
545
+ );
393
546
  }
394
547
 
395
548
  function formatPanelName(
396
549
  item: Pick<PanelOutput, "index" | "id" | "label">,
550
+ blindLabels?: ReadonlyMap<number, string>,
397
551
  ): string {
398
- return item.label ?? item.id ?? `Panelist ${item.index + 1}`;
552
+ return blindLabels?.get(item.index) ?? panelItemLabel(item);
553
+ }
554
+
555
+ /**
556
+ * Maps panel indices to neutral "Candidate X" names. Assigned by index rather
557
+ * than presentation order so the mapping is stable and the report can restore
558
+ * real names without persisting extra run state.
559
+ */
560
+ export function buildBlindLabelMap(
561
+ items: readonly Pick<PanelOutput, "index">[],
562
+ ): Map<number, string> {
563
+ const labels = new Map<number, string>();
564
+ const indices = [...new Set(items.map((item) => item.index))].sort(
565
+ (left, right) => left - right,
566
+ );
567
+ indices.forEach((index, position) => {
568
+ labels.set(index, `Candidate ${blindLabelFor(position)}`);
569
+ });
570
+ return labels;
571
+ }
572
+
573
+ function blindLabelFor(position: number): string {
574
+ // A..Z, then AA, AB, ... for panels larger than the alphabet.
575
+ let remaining = position;
576
+ let label = "";
577
+ do {
578
+ label = String.fromCharCode(65 + (remaining % 26)) + label;
579
+ remaining = Math.floor(remaining / 26) - 1;
580
+ } while (remaining >= 0);
581
+ return label;
399
582
  }
400
583
 
401
584
  function comparePanelItems(
@@ -405,6 +588,39 @@ function comparePanelItems(
405
588
  return left.index - right.index;
406
589
  }
407
590
 
591
+ /**
592
+ * LLM judges favour whichever candidate is presented first or last. A fixed
593
+ * order therefore advantages the same panel member on every run. Shuffling
594
+ * removes the bias; seeding it from the run id keeps a persisted run rendering
595
+ * identically when it is replayed through `fusion:rpc:v1` adopt.
596
+ */
597
+ export function shufflePanelItems<T>(items: readonly T[], seed: string): T[] {
598
+ const shuffled = [...items];
599
+ const nextRandom = createSeededRandom(seed);
600
+ for (let index = shuffled.length - 1; index > 0; index--) {
601
+ const swap = Math.floor(nextRandom() * (index + 1));
602
+ [shuffled[index], shuffled[swap]] = [shuffled[swap]!, shuffled[index]!];
603
+ }
604
+ return shuffled;
605
+ }
606
+
607
+ function createSeededRandom(seed: string): () => number {
608
+ // FNV-1a over the seed, then mulberry32. Small, dependency-free, and stable
609
+ // across Node versions - the reproducibility guarantee depends on that.
610
+ let hash = 0x811c9dc5;
611
+ for (let index = 0; index < seed.length; index++) {
612
+ hash ^= seed.charCodeAt(index);
613
+ hash = Math.imul(hash, 0x01000193);
614
+ }
615
+ let state = hash >>> 0;
616
+ return () => {
617
+ state = (state + 0x6d2b79f5) >>> 0;
618
+ let drawn = Math.imul(state ^ (state >>> 15), 1 | state);
619
+ drawn = (drawn + Math.imul(drawn ^ (drawn >>> 7), 61 | drawn)) ^ drawn;
620
+ return ((drawn ^ (drawn >>> 14)) >>> 0) / 4294967296;
621
+ };
622
+ }
623
+
408
624
  function firstLine(value: string): string {
409
625
  return value.split(/\r?\n/, 1)[0]?.trim() || "unknown failure";
410
626
  }
package/src/run-store.ts CHANGED
@@ -40,6 +40,8 @@ export interface FusionRunStartInput {
40
40
  id?: string;
41
41
  prompt: string;
42
42
  profileName: string;
43
+ inlinePanel?: string[];
44
+ baseProfileName?: string;
43
45
  operationId?: string;
44
46
  phase?: Exclude<FusionPhase, FusionTerminalPhase>;
45
47
  createdAt?: number;
@@ -149,6 +151,14 @@ export class FusionRunStore {
149
151
  id: input.id ?? this.idFactory(),
150
152
  prompt: input.prompt,
151
153
  profileName: input.profileName,
154
+ ...(input.inlinePanel?.length
155
+ ? {
156
+ inlinePanel: input.inlinePanel,
157
+ ...(input.baseProfileName
158
+ ? { baseProfileName: input.baseProfileName }
159
+ : {}),
160
+ }
161
+ : {}),
152
162
  ...(input.operationId !== undefined
153
163
  ? { operationId: input.operationId }
154
164
  : {}),
@@ -385,6 +395,14 @@ function cloneRun(run: FusionRun): FusionRun {
385
395
  id: run.id,
386
396
  prompt: run.prompt,
387
397
  profileName: run.profileName,
398
+ // cloneRun is a strict field allowlist: a new FusionRun field is dropped on
399
+ // both write and read until it is listed here.
400
+ ...(run.inlinePanel !== undefined
401
+ ? { inlinePanel: [...run.inlinePanel] }
402
+ : {}),
403
+ ...(run.baseProfileName !== undefined
404
+ ? { baseProfileName: run.baseProfileName }
405
+ : {}),
388
406
  ...(run.operationId !== undefined ? { operationId: run.operationId } : {}),
389
407
  phase: run.phase,
390
408
  createdAt: run.createdAt,
@@ -444,6 +462,19 @@ function isFusionRunState(value: unknown): value is FusionRun {
444
462
  if (value.operationId !== undefined && !isNonEmptyString(value.operationId)) {
445
463
  return false;
446
464
  }
465
+ if (
466
+ value.inlinePanel !== undefined &&
467
+ (!Array.isArray(value.inlinePanel) ||
468
+ !value.inlinePanel.every(isNonEmptyString))
469
+ ) {
470
+ return false;
471
+ }
472
+ if (
473
+ value.baseProfileName !== undefined &&
474
+ !isNonEmptyString(value.baseProfileName)
475
+ ) {
476
+ return false;
477
+ }
447
478
  if (!isFusionPhase(value.phase)) return false;
448
479
  if (!isFiniteNumber(value.createdAt)) return false;
449
480
  if (!isFiniteNumber(value.updatedAt)) return false;
package/src/types.ts CHANGED
@@ -8,15 +8,34 @@ export const THINKING_LEVELS = [
8
8
  ] as const;
9
9
 
10
10
  export type ThinkingLevel = (typeof THINKING_LEVELS)[number];
11
+ export const PANEL_AGENT = "pi-fusion.fusion-panelist";
12
+ export const PANEL_AGENT_WEB = "pi-fusion.fusion-panelist-web";
13
+ export const PANEL_AGENT_FULL = "pi-fusion.fusion-panelist-full";
14
+ export const JUDGE_AGENT = "pi-fusion.fusion-judge";
15
+ export const COMPOSER_AGENT = "pi-fusion.fusion-composer";
16
+
11
17
  export type FusionContextMode = "fresh" | "fork";
12
18
 
19
+ /**
20
+ * How panel answers become one report.
21
+ * `select` — panelists answered the same question; the judge picks or reconciles.
22
+ * `merge` — panelists answered different facets; the composer unions them.
23
+ */
24
+ export type FusionSynthesisMode = "select" | "merge";
25
+
13
26
  export interface PanelMemberConfig {
14
27
  id: string;
15
- label: string;
28
+ /** Report label. Defaults to `id` — set it only when they should differ. */
29
+ label?: string;
16
30
  agent: string;
17
31
  model?: string;
18
32
  thinking?: ThinkingLevel;
19
33
  role?: string;
34
+ /**
35
+ * Facet prompt sent instead of the raw task. `{task}` is substituted with the
36
+ * original prompt. Turns a redundant panel into one that divides the work.
37
+ */
38
+ question?: string;
20
39
  }
21
40
 
22
41
  export interface JudgeConfig {
@@ -32,6 +51,65 @@ export interface FusionProfile {
32
51
  timeoutMs?: number;
33
52
  context?: FusionContextMode;
34
53
  stopWhenPanelAgrees?: boolean;
54
+ /**
55
+ * Present panel answers to the judge as "Candidate A/B/C" instead of the
56
+ * configured labels. Role names read as authority cues before a word of
57
+ * content is compared. The report always restores the real labels.
58
+ */
59
+ blindPanelLabels?: boolean;
60
+ /**
61
+ * Caps the tool calls the judge may spend verifying contested claims.
62
+ * `soft` nudges, `hard` blocks further tool use so the judge still finalises.
63
+ */
64
+ judgeToolBudget?: ToolBudget;
65
+ /**
66
+ * Usually omit this. It is inferred: a panel where any member has a
67
+ * `question` is answering facets, so it merges; otherwise it selects.
68
+ * Set it only to override that — most often `"select"` on a faceted panel
69
+ * when you deliberately want the judge to pick rather than union.
70
+ *
71
+ * Under `merge` the synthesis spawn uses the composer agent and contract
72
+ * instead of the judge's. It reuses the judge run slot, so no new
73
+ * `FusionPhase` is introduced and `fusion:rpc:v1` stays compatible.
74
+ */
75
+ synthesis?: FusionSynthesisMode;
76
+ }
77
+
78
+ /**
79
+ * Facets are the thing that actually decides how answers combine, so the mode
80
+ * follows them by default. Requiring `synthesis` and `question` to agree made
81
+ * two ways to get a silently wrong report: facets judged for consensus they
82
+ * cannot have, or a composer told to merge facets that do not exist.
83
+ */
84
+ /**
85
+ * Report label for a panel item. `label` is optional; it falls back to `id`, and
86
+ * then to the position. One definition so report and prompt never disagree.
87
+ */
88
+ export function panelItemLabel(
89
+ item: Pick<PanelOutput, "index" | "id" | "label">,
90
+ ): string {
91
+ return item.label?.trim() || item.id?.trim() || `Panelist ${item.index + 1}`;
92
+ }
93
+
94
+ /** Report label for a member; `label` is optional and falls back to `id`. */
95
+ export function memberLabel(
96
+ member: Pick<PanelMemberConfig, "id" | "label">,
97
+ ): string {
98
+ return member.label?.trim() || member.id;
99
+ }
100
+
101
+ export function resolveSynthesisMode(
102
+ profile: Pick<FusionProfile, "panel" | "synthesis">,
103
+ ): FusionSynthesisMode {
104
+ if (profile.synthesis) return profile.synthesis;
105
+ return profile.panel.some((member) => member.question?.trim())
106
+ ? "merge"
107
+ : "select";
108
+ }
109
+
110
+ export interface ToolBudget {
111
+ soft?: number;
112
+ hard?: number;
35
113
  }
36
114
 
37
115
  export type PanelConfidence = "low" | "medium" | "high";
@@ -79,6 +157,8 @@ export interface ParsedFusionArgs {
79
157
  prompt: string;
80
158
  profile?: string;
81
159
  operationId?: string;
160
+ /** Inline panel entries from `--panel`: `<model>` or `<agent>:<model>`. */
161
+ panel?: string[];
82
162
  }
83
163
 
84
164
  export interface PanelOutput {
@@ -121,6 +201,14 @@ export interface FusionRun {
121
201
  id: string;
122
202
  prompt: string;
123
203
  profileName: string;
204
+ /**
205
+ * Inline `--panel` entries, when the run used them. `profileName` then carries
206
+ * a display name that no config defines, so restore rebuilds the profile from
207
+ * `baseProfileName` plus these entries instead of looking the display name up.
208
+ */
209
+ inlinePanel?: string[];
210
+ /** Name of the config profile the inline panel was layered onto. */
211
+ baseProfileName?: string;
124
212
  operationId?: string;
125
213
  phase: FusionPhase;
126
214
  createdAt: number;