@alexeiled/pi-fusion 0.5.2 → 0.6.1

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 = {
@@ -30,45 +36,12 @@ export interface PanelSubagentTaskParams {
30
36
  model?: string;
31
37
  }
32
38
 
33
- export interface PanelChainTaskParams extends PanelSubagentTaskParams {
34
- as: string;
35
- label: string;
36
- phase: "Panel";
39
+ export interface PanelWorkflowTaskParams extends PanelSubagentTaskParams {
40
+ key: string;
37
41
  }
38
42
 
39
43
  export interface PanelSpawnParams {
40
- tasks: PanelSubagentTaskParams[];
41
- async: true;
42
- clarify: false;
43
- concurrency: number;
44
- context: "fresh" | "fork";
45
- output: true;
46
- outputMode: "inline";
47
- acceptance: FusionAcceptanceDisabled;
48
- timeoutMs?: number;
49
- }
50
-
51
- export interface FusionChainParallelStepParams {
52
- parallel: PanelChainTaskParams[];
53
- concurrency: number;
54
- failFast: false;
55
- }
56
-
57
- export interface FusionChainJudgeStepParams {
58
- agent: string;
59
- task: string;
60
- label: "Judge";
61
- phase: "Judge";
62
- output: true;
63
- outputMode: "inline";
64
- skill: false;
65
- acceptance: FusionAcceptanceDisabled;
66
- model?: string;
67
- }
68
-
69
- export interface FusionChainSpawnParams {
70
- chain: [FusionChainParallelStepParams, FusionChainJudgeStepParams];
71
- task: string;
44
+ workflowScript: string;
72
45
  async: true;
73
46
  clarify: false;
74
47
  context: "fresh" | "fork";
@@ -90,6 +63,8 @@ export interface JudgeSpawnParams {
90
63
  acceptance: FusionAcceptanceDisabled;
91
64
  model?: string;
92
65
  timeoutMs?: number;
66
+ /** Per-task cap on judge tool calls; see `FusionProfile.judgeToolBudget`. */
67
+ toolBudget?: ToolBudget;
93
68
  }
94
69
 
95
70
  export type { FailedPanelSummary, PanelOutput } from "./types.js";
@@ -99,6 +74,12 @@ export interface BuildJudgeSpawnParamsInput {
99
74
  prompt: string;
100
75
  panelOutputs: readonly PanelOutput[];
101
76
  failedPanelists: readonly FailedPanelSummary[];
77
+ /**
78
+ * Seeds the order panel answers are presented to the judge. Required rather
79
+ * than optional: a missing seed would silently restore the fixed index order
80
+ * and its position bias.
81
+ */
82
+ runId: string;
102
83
  }
103
84
 
104
85
  const PANEL_OUTPUT_CONTRACT = [
@@ -116,6 +97,7 @@ const JUDGE_OUTPUT_CONTRACT = [
116
97
  "## Agent Status",
117
98
  "## Consensus",
118
99
  "## Disagreements",
100
+ "## Contested Claims",
119
101
  "## Unique Insights",
120
102
  "## Blind Spots",
121
103
  "## Recommendation",
@@ -123,6 +105,36 @@ const JUDGE_OUTPUT_CONTRACT = [
123
105
  "## Next Step",
124
106
  ] as const;
125
107
 
108
+ const COMPOSER_OUTPUT_CONTRACT = [
109
+ "# Fusion Report",
110
+ "## Summary",
111
+ "## Coverage Map",
112
+ "## Combined Answer",
113
+ "## Gaps",
114
+ "## Conflicts At Seams",
115
+ "## Agent Status",
116
+ "## Risks",
117
+ "## Next Step",
118
+ ] as const;
119
+
120
+ const COMPOSER_INSTRUCTIONS = [
121
+ "You are the fusion composer.",
122
+ "Read-only synthesis only. Leave files, git state, and the workspace untouched. Do not ask other agents. Do not run subagents.",
123
+ "The panelists answered DIFFERENT facets of one task. Merge their answers; do not pick a winner.",
124
+ "- Do not rank panelists. They were not competing.",
125
+ "- Report a conflict only where facets genuinely overlap and disagree. Different subject matter is not disagreement.",
126
+ "- Name facets nobody covered, or covered only in passing.",
127
+ "- 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.",
128
+ ] as const;
129
+
130
+ const CONTESTED_CLAIMS_INSTRUCTIONS = [
131
+ "Contested claims:",
132
+ "- Where panelists state conflicting facts about this codebase, do not pick the more confident wording.",
133
+ "- Check the claim yourself with your read tools and cite file:line.",
134
+ "- Report each contested claim as: the claim, what you found, and which panelist was right.",
135
+ "- If you could not verify a claim, say so explicitly rather than choosing.",
136
+ ] as const;
137
+
126
138
  export function appendThinkingSuffix(
127
139
  model: string | undefined,
128
140
  thinking: ThinkingLevel | undefined,
@@ -136,57 +148,24 @@ export function buildPanelSpawnParams(
136
148
  profile: FusionProfile,
137
149
  prompt: string,
138
150
  ): PanelSpawnParams {
139
- return {
140
- tasks: profile.panel.map((member) =>
141
- buildPanelTaskParams(
151
+ const concurrency = profile.concurrency ?? profile.panel.length;
152
+ const tasks: PanelWorkflowTaskParams[] = profile.panel.map(
153
+ (member, index) => ({
154
+ key: `panel-${index + 1}`,
155
+ ...buildPanelTaskParams(
142
156
  member,
143
157
  prompt,
144
158
  profile.stopWhenPanelAgrees === true,
145
159
  ),
146
- ),
147
- async: true,
148
- clarify: false,
149
- concurrency: profile.concurrency ?? profile.panel.length,
150
- context: profile.context ?? "fresh",
151
- output: true,
152
- outputMode: "inline",
153
- acceptance: FUSION_ACCEPTANCE_DISABLED,
154
- ...(profile.timeoutMs !== undefined
155
- ? { timeoutMs: profile.timeoutMs }
156
- : {}),
157
- };
158
- }
159
-
160
- export function buildFusionChainSpawnParams(
161
- profile: FusionProfile,
162
- prompt: string,
163
- ): FusionChainSpawnParams {
164
- const model = appendThinkingSuffix(
165
- profile.judge.model,
166
- profile.judge.thinking,
160
+ }),
167
161
  );
162
+
168
163
  return {
169
- chain: [
170
- {
171
- parallel: profile.panel.map((member, index) =>
172
- buildPanelChainTaskParams(member, index),
173
- ),
174
- concurrency: profile.concurrency ?? profile.panel.length,
175
- failFast: false,
176
- },
177
- {
178
- agent: profile.judge.agent,
179
- task: buildChainJudgeTask(profile.panel),
180
- label: "Judge",
181
- phase: "Judge",
182
- output: true,
183
- outputMode: "inline",
184
- skill: false,
185
- acceptance: FUSION_ACCEPTANCE_DISABLED,
186
- ...(model ? { model } : {}),
187
- },
188
- ],
189
- task: prompt.trim(),
164
+ workflowScript: buildPanelWorkflowScript(
165
+ tasks,
166
+ concurrency,
167
+ profile.stopWhenPanelAgrees === true,
168
+ ),
190
169
  async: true,
191
170
  clarify: false,
192
171
  context: profile.context ?? "fresh",
@@ -207,7 +186,7 @@ export function buildJudgeSpawnParams(
207
186
  input.profile.judge.thinking,
208
187
  );
209
188
  return {
210
- agent: input.profile.judge.agent,
189
+ agent: resolveSynthesisAgent(input.profile),
211
190
  task: buildJudgeTask(input),
212
191
  async: true,
213
192
  clarify: false,
@@ -217,12 +196,60 @@ export function buildJudgeSpawnParams(
217
196
  skill: false,
218
197
  acceptance: FUSION_ACCEPTANCE_DISABLED,
219
198
  ...(model ? { model } : {}),
199
+ ...(input.profile.judgeToolBudget
200
+ ? { toolBudget: input.profile.judgeToolBudget }
201
+ : {}),
220
202
  ...(input.profile.timeoutMs !== undefined
221
203
  ? { timeoutMs: input.profile.timeoutMs }
222
204
  : {}),
223
205
  };
224
206
  }
225
207
 
208
+ function buildPanelWorkflowScript(
209
+ tasks: readonly PanelWorkflowTaskParams[],
210
+ concurrency: number,
211
+ stopWhenAgrees: boolean,
212
+ ): string {
213
+ const serializedTasks = JSON.stringify(tasks);
214
+ const effectiveConcurrency = stopWhenAgrees
215
+ ? Math.min(concurrency, 2)
216
+ : concurrency;
217
+ const stopLogic = stopWhenAgrees
218
+ ? [
219
+ "const decisions = results",
220
+ " .filter((result) => result && result.ok === true)",
221
+ " .map((result) => {",
222
+ " const text = typeof result.output === \"string\" ? result.output : \"\";",
223
+ " const match = text.match(/<fusion-panel-decision>([\\s\\S]*?)<\\/fusion-panel-decision>\\s*$/);",
224
+ " if (!match) return undefined;",
225
+ " try { return JSON.parse(match[1]); } catch { return undefined; }",
226
+ " })",
227
+ " .filter((decision) => decision && typeof decision.recommendation === \"string\" && decision.confidence === \"high\" && decision.needsMoreEvidence === false);",
228
+ "if (decisions.length >= 2 && results.length < tasks.length) {",
229
+ " const recommendation = decisions[0].recommendation.trim().toLocaleLowerCase().replace(/[^\\p{L}\\p{N}]+/gu, \" \" ).trim(),",
230
+ " agrees = recommendation && decisions.every((decision) => decision.recommendation.trim().toLocaleLowerCase().replace(/[^\\p{L}\\p{N}]+/gu, \" \" ).trim() === recommendation);",
231
+ " if (agrees) {",
232
+ " emit({ type: \"pi-fusion-panel-stop\", indices: tasks.slice(results.length).map((_, index) => results.length + index) });",
233
+ " return results;",
234
+ " }",
235
+ "}",
236
+ ].join("\n")
237
+ : "";
238
+
239
+ return [
240
+ `const tasks = ${serializedTasks};`,
241
+ `const concurrency = ${effectiveConcurrency};`,
242
+ "const results = [];",
243
+ "for (let index = 0; index < tasks.length; index += concurrency) {",
244
+ " results.push(...await runs.all(tasks.slice(index, index + concurrency)));",
245
+ stopWhenAgrees ? " " + stopLogic.replaceAll("\n", "\n ") : "",
246
+ "}",
247
+ "return results;",
248
+ ]
249
+ .filter(Boolean)
250
+ .join("\n");
251
+ }
252
+
226
253
  function buildPanelTaskParams(
227
254
  member: PanelMemberConfig,
228
255
  prompt: string,
@@ -241,26 +268,6 @@ function buildPanelTaskParams(
241
268
  };
242
269
  }
243
270
 
244
- function buildPanelChainTaskParams(
245
- member: PanelMemberConfig,
246
- index: number,
247
- ): PanelChainTaskParams {
248
- const model = appendThinkingSuffix(member.model, member.thinking);
249
- return {
250
- agent: member.agent,
251
- task: buildPanelTask(member, "{task}", false),
252
- as: chainOutputName(member, index),
253
- label: member.label,
254
- phase: "Panel",
255
- output: true,
256
- outputMode: "inline",
257
- progress: true,
258
- skill: false,
259
- acceptance: FUSION_ACCEPTANCE_DISABLED,
260
- ...(model ? { model } : {}),
261
- };
262
- }
263
-
264
271
  function buildPanelTask(
265
272
  member: PanelMemberConfig,
266
273
  prompt: string,
@@ -268,11 +275,10 @@ function buildPanelTask(
268
275
  ): string {
269
276
  const role = member.role?.trim() || "independent analysis and critique";
270
277
  return [
271
- `Panel member: ${member.label} (${member.id})`,
278
+ `Panel member: ${memberLabel(member)} (${member.id})`,
272
279
  `Role: ${role}`,
273
280
  "",
274
- "Original task:",
275
- prompt.trim(),
281
+ ...formatMemberTask(member, prompt),
276
282
  "",
277
283
  "Instructions:",
278
284
  "- Work independently from the other panelists.",
@@ -300,81 +306,156 @@ function buildPanelTask(
300
306
  ].join("\n");
301
307
  }
302
308
 
309
+ /**
310
+ * Merge mode swaps the synthesis agent, not the run slot. It still spawns into
311
+ * `judgeRunId`/`judgeAsyncDir` under phase `judge`, so `fusion:rpc:v1` consumers
312
+ * see no new phase value.
313
+ *
314
+ * An explicitly configured judge agent still wins: a user who names their own
315
+ * synthesis agent means it.
316
+ */
317
+ function resolveSynthesisAgent(profile: FusionProfile): string {
318
+ if (resolveSynthesisMode(profile) !== "merge") return profile.judge.agent;
319
+ return profile.judge.agent === JUDGE_AGENT
320
+ ? COMPOSER_AGENT
321
+ : profile.judge.agent;
322
+ }
323
+
324
+ const TASK_PLACEHOLDER = "{task}";
325
+
326
+ /**
327
+ * A member with a `question` answers that facet instead of the whole prompt.
328
+ * If the template omits `{task}` the original prompt is still appended: dropping
329
+ * it would silently strip the context the panelist needs.
330
+ */
331
+ function formatMemberTask(member: PanelMemberConfig, prompt: string): string[] {
332
+ const question = member.question?.trim();
333
+ const task = prompt.trim();
334
+ if (!question) return ["Original task:", task];
335
+
336
+ const facet = question.replaceAll(TASK_PLACEHOLDER, task);
337
+ if (question.includes(TASK_PLACEHOLDER)) {
338
+ return ["Your assigned facet of the task:", facet];
339
+ }
340
+ return [
341
+ "Your assigned facet of the task:",
342
+ facet,
343
+ "",
344
+ "Original task:",
345
+ task,
346
+ ];
347
+ }
348
+
303
349
  function buildJudgeTask(input: BuildJudgeSpawnParamsInput): string {
304
350
  const sortedOutputs = [...input.panelOutputs].sort(comparePanelItems);
305
351
  const sortedFailures = [...input.failedPanelists].sort(comparePanelItems);
352
+ // Status and failure lists stay in configuration order so the reader can map
353
+ // them to the profile. Only the answers the judge weighs are shuffled.
354
+ const presentedOutputs = shufflePanelItems(sortedOutputs, input.runId);
355
+ const blindLabels = input.profile.blindPanelLabels
356
+ ? buildBlindLabelMap([...sortedOutputs, ...sortedFailures])
357
+ : undefined;
358
+ const merging = resolveSynthesisMode(input.profile) === "merge";
306
359
  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.",
360
+ ...(merging
361
+ ? COMPOSER_INSTRUCTIONS
362
+ : [
363
+ "You are the fusion judge.",
364
+ "Read-only synthesis only. Leave files, git state, and the workspace untouched. Do not ask other agents. Do not run subagents.",
365
+ "Synthesize the panel results. Preserve disagreement instead of forcing consensus.",
366
+ ]),
310
367
  "",
311
368
  "Original task:",
312
369
  input.prompt.trim(),
313
370
  "",
314
371
  "Panel status:",
315
- ...formatPanelStatus(sortedOutputs, sortedFailures),
372
+ ...formatPanelStatus(sortedOutputs, sortedFailures, blindLabels),
316
373
  "",
374
+ ...(merging
375
+ ? [
376
+ "Facet assignments:",
377
+ ...formatFacetAssignments(input.profile, input.prompt, blindLabels),
378
+ "",
379
+ ]
380
+ : []),
317
381
  "Successful panel outputs:",
318
- ...formatPanelOutputs(sortedOutputs),
382
+ ...formatPanelOutputs(presentedOutputs, blindLabels),
319
383
  "",
320
384
  "Failed panelists:",
321
- ...formatFailedPanelists(sortedFailures),
385
+ ...formatFailedPanelists(sortedFailures, blindLabels),
322
386
  "",
387
+ // Judge-only. The composer has no Contested Claims section, so this block
388
+ // produces content the report silently drops, and its "which panelist was
389
+ // right" wording contradicts the composer's "do not rank panelists".
390
+ ...(merging ? [] : [...CONTESTED_CLAIMS_INSTRUCTIONS, ""]),
323
391
  "Output contract:",
324
- ...JUDGE_OUTPUT_CONTRACT,
392
+ ...(merging ? COMPOSER_OUTPUT_CONTRACT : JUDGE_OUTPUT_CONTRACT),
325
393
  ].join("\n");
326
394
  }
327
395
 
328
- function buildChainJudgeTask(panel: readonly PanelMemberConfig[]): string {
329
- return [
330
- "You are the fusion judge.",
331
- "Read-only synthesis only. Leave files, git state, and the workspace untouched. Do not ask other agents. Do not run subagents.",
332
- "Synthesize the panel results. Preserve disagreement instead of forcing consensus.",
333
- "",
334
- "Original task:",
335
- "{task}",
336
- "",
337
- "All listed panelists completed successfully.",
338
- "",
339
- "Panel outputs:",
340
- ...panel.flatMap((member, index) => [
341
- `## ${member.label} (${member.id})`,
342
- `Agent: ${member.agent}`,
343
- "",
344
- `{outputs.${chainOutputName(member, index)}}`,
345
- "",
346
- ]),
347
- "Output contract:",
348
- ...JUDGE_OUTPUT_CONTRACT,
349
- ].join("\n");
396
+ /**
397
+ * Lists what each member was asked to cover, so the composer can name gaps
398
+ * without reverse-engineering them from the answers it did receive.
399
+ */
400
+ function formatFacetAssignments(
401
+ profile: FusionProfile,
402
+ prompt: string,
403
+ blindLabels?: ReadonlyMap<number, string>,
404
+ ): string[] {
405
+ return profile.panel.map((member, index) => {
406
+ // Substituted the same way the panelist saw it; the raw template would put
407
+ // a literal "{task}" in front of the composer.
408
+ const question = member.question?.trim();
409
+ const facet = question
410
+ ? question.replaceAll(TASK_PLACEHOLDER, prompt.trim())
411
+ : (member.role?.trim() ?? "the whole task");
412
+ // The blind map only covers members that produced an output or a failure.
413
+ // A member stopped early by `stopWhenPanelAgrees` is in neither, and
414
+ // falling back to `member.label` would leak the name into the very prompt
415
+ // that is meant to hide it.
416
+ const name = blindLabels
417
+ ? (blindLabels.get(index) ?? "Candidate (did not report)")
418
+ : memberLabel(member);
419
+ return `- ${name}: ${facet}`;
420
+ });
350
421
  }
351
422
 
352
423
  function formatPanelStatus(
353
424
  outputs: readonly PanelOutput[],
354
425
  failures: readonly FailedPanelSummary[],
426
+ blindLabels?: ReadonlyMap<number, string>,
355
427
  ): string[] {
356
428
  const lines = [
357
429
  `- Successful panelists: ${outputs.length}`,
358
430
  `- Failed panelists: ${failures.length}`,
359
431
  ];
360
432
  for (const output of outputs) {
361
- lines.push(`- ${formatPanelName(output)}: succeeded`);
433
+ lines.push(`- ${formatPanelName(output, blindLabels)}: succeeded`);
362
434
  }
363
435
  for (const failure of failures) {
364
436
  lines.push(
365
- `- ${formatPanelName(failure)}: failed - ${firstLine(failure.summary)}`,
437
+ `- ${formatPanelName(failure, blindLabels)}: failed - ${firstLine(failure.summary)}`,
366
438
  );
367
439
  }
368
440
  return lines;
369
441
  }
370
442
 
371
- function formatPanelOutputs(outputs: readonly PanelOutput[]): string[] {
443
+ function formatPanelOutputs(
444
+ outputs: readonly PanelOutput[],
445
+ blindLabels?: ReadonlyMap<number, string>,
446
+ ): string[] {
372
447
  if (outputs.length === 0) return ["(none)"];
448
+ // Agent names and artifact paths carry the member id, so they are withheld
449
+ // when blinding. They are debugging aids for the reader, not judging inputs.
373
450
  return outputs.flatMap((output) => [
374
- `## ${formatPanelName(output)}`,
375
- `Agent: ${output.agent}`,
376
- ...(output.artifactPath ? [`Artifact: ${output.artifactPath}`] : []),
377
- ...(output.sessionPath ? [`Session: ${output.sessionPath}`] : []),
451
+ `## ${formatPanelName(output, blindLabels)}`,
452
+ ...(blindLabels
453
+ ? []
454
+ : [
455
+ `Agent: ${output.agent}`,
456
+ ...(output.artifactPath ? [`Artifact: ${output.artifactPath}`] : []),
457
+ ...(output.sessionPath ? [`Session: ${output.sessionPath}`] : []),
458
+ ]),
378
459
  "",
379
460
  output.output,
380
461
  "",
@@ -383,19 +464,56 @@ function formatPanelOutputs(outputs: readonly PanelOutput[]): string[] {
383
464
 
384
465
  function formatFailedPanelists(
385
466
  failures: readonly FailedPanelSummary[],
467
+ blindLabels?: ReadonlyMap<number, string>,
386
468
  ): string[] {
387
469
  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
- ]);
470
+ return failures.flatMap((failure) =>
471
+ blindLabels
472
+ ? [`- ${formatPanelName(failure, blindLabels)}: ${failure.summary}`]
473
+ : [
474
+ `- ${formatPanelName(failure)} (${failure.agent}): ${failure.summary}`,
475
+ ...(failure.artifactPath
476
+ ? [` Artifact: ${failure.artifactPath}`]
477
+ : []),
478
+ ...(failure.sessionPath ? [` Session: ${failure.sessionPath}`] : []),
479
+ ],
480
+ );
393
481
  }
394
482
 
395
483
  function formatPanelName(
396
484
  item: Pick<PanelOutput, "index" | "id" | "label">,
485
+ blindLabels?: ReadonlyMap<number, string>,
397
486
  ): string {
398
- return item.label ?? item.id ?? `Panelist ${item.index + 1}`;
487
+ return blindLabels?.get(item.index) ?? panelItemLabel(item);
488
+ }
489
+
490
+ /**
491
+ * Maps panel indices to neutral "Candidate X" names. Assigned by index rather
492
+ * than presentation order so the mapping is stable and the report can restore
493
+ * real names without persisting extra run state.
494
+ */
495
+ export function buildBlindLabelMap(
496
+ items: readonly Pick<PanelOutput, "index">[],
497
+ ): Map<number, string> {
498
+ const labels = new Map<number, string>();
499
+ const indices = [...new Set(items.map((item) => item.index))].sort(
500
+ (left, right) => left - right,
501
+ );
502
+ indices.forEach((index, position) => {
503
+ labels.set(index, `Candidate ${blindLabelFor(position)}`);
504
+ });
505
+ return labels;
506
+ }
507
+
508
+ function blindLabelFor(position: number): string {
509
+ // A..Z, then AA, AB, ... for panels larger than the alphabet.
510
+ let remaining = position;
511
+ let label = "";
512
+ do {
513
+ label = String.fromCharCode(65 + (remaining % 26)) + label;
514
+ remaining = Math.floor(remaining / 26) - 1;
515
+ } while (remaining >= 0);
516
+ return label;
399
517
  }
400
518
 
401
519
  function comparePanelItems(
@@ -405,14 +523,41 @@ function comparePanelItems(
405
523
  return left.index - right.index;
406
524
  }
407
525
 
408
- function firstLine(value: string): string {
409
- return value.split(/\r?\n/, 1)[0]?.trim() || "unknown failure";
526
+ /**
527
+ * LLM judges favour whichever candidate is presented first or last. A fixed
528
+ * order therefore advantages the same panel member on every run. Shuffling
529
+ * removes the bias; seeding it from the run id keeps a persisted run rendering
530
+ * identically when it is replayed through `fusion:rpc:v1` adopt.
531
+ */
532
+ export function shufflePanelItems<T>(items: readonly T[], seed: string): T[] {
533
+ const shuffled = [...items];
534
+ const nextRandom = createSeededRandom(seed);
535
+ for (let index = shuffled.length - 1; index > 0; index--) {
536
+ const swap = Math.floor(nextRandom() * (index + 1));
537
+ [shuffled[index], shuffled[swap]] = [shuffled[swap]!, shuffled[index]!];
538
+ }
539
+ return shuffled;
410
540
  }
411
541
 
412
- function chainOutputName(member: PanelMemberConfig, index: number): string {
413
- return /^[A-Za-z_][A-Za-z0-9_]*$/.test(member.id)
414
- ? member.id
415
- : `panel_${index + 1}`;
542
+ function createSeededRandom(seed: string): () => number {
543
+ // FNV-1a over the seed, then mulberry32. Small, dependency-free, and stable
544
+ // across Node versions - the reproducibility guarantee depends on that.
545
+ let hash = 0x811c9dc5;
546
+ for (let index = 0; index < seed.length; index++) {
547
+ hash ^= seed.charCodeAt(index);
548
+ hash = Math.imul(hash, 0x01000193);
549
+ }
550
+ let state = hash >>> 0;
551
+ return () => {
552
+ state = (state + 0x6d2b79f5) >>> 0;
553
+ let drawn = Math.imul(state ^ (state >>> 15), 1 | state);
554
+ drawn = (drawn + Math.imul(drawn ^ (drawn >>> 7), 61 | drawn)) ^ drawn;
555
+ return ((drawn ^ (drawn >>> 14)) >>> 0) / 4294967296;
556
+ };
557
+ }
558
+
559
+ function firstLine(value: string): string {
560
+ return value.split(/\r?\n/, 1)[0]?.trim() || "unknown failure";
416
561
  }
417
562
 
418
563
  function hasThinkingSuffix(model: string): boolean {
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;