@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.
@@ -1,34 +1,85 @@
1
1
  ---
2
2
  name: fusion-review
3
3
  description: >
4
- Trigger a pi-fusion multi-model panel review implicitly. Use when the user asks to
5
- "invoke fusion", "run a fusion panel", "discuss this through fusion", "get multi-model
6
- opinions", "panel review", or any phrasing that implies running a parallel model review.
7
- Call the start_fusion_review tool with the user's topic or prompt.
4
+ Run a pi-fusion review: several models answer in parallel, then one synthesis
5
+ step returns a single report. Use when the user says "invoke fusion", "run a
6
+ fusion panel", "discuss this through fusion", "get multi-model opinions",
7
+ "panel review", "compare <model> and <model> on this", or asks to cover
8
+ something from several angles ("what did we miss", "audit this release").
9
+ Call the start_fusion_review tool with the topic. NOT for routine edits,
10
+ formatting, or one-step fixes. NOT for arguing one claim from both sides (use
11
+ dialectic) and NOT for open-ended idea generation (use brainstorming) —
12
+ Fusion panelists work independently and never talk to each other.
8
13
  ---
9
14
 
10
15
  # Fusion Review
11
16
 
12
- Use the `start_fusion_review` tool to launch a fusion panel without the user needing to type `/fusion`.
17
+ Call `start_fusion_review` so the user does not have to type `/fusion`.
13
18
 
14
19
  ## When to use
15
20
 
16
- Use Fusion for hard decisions, design tradeoffs, risk review, tricky debugging, or questions where independent model perspectives are useful.
21
+ Use Fusion when independent perspectives are worth the extra latency:
17
22
 
18
- Do not use Fusion for routine edits, formatting, obvious one-step fixes, or simple questions. Keep those on the normal Pi path.
23
+ - a hard decision or a design tradeoff
24
+ - risk or release review
25
+ - tricky debugging
26
+ - a research-heavy question
27
+ - a breadth sweep, such as an audit or "what did we miss"
19
28
 
20
- - "invoke fusion panel to discuss this"
21
- - "run fusion on this"
22
- - "get a panel review of …"
23
- - "use multi-model review for …"
24
- - "discuss this through fusion"
25
- - Any prompt that implies parallel model review or multi-perspective analysis
29
+ Do not use Fusion for routine edits, formatting, obvious one-step fixes, or
30
+ simple questions. Keep those on the normal Pi path.
26
31
 
27
- ## How to use
32
+ ## Not this skill
28
33
 
29
- Call `start_fusion_review` with:
34
+ - The user wants one claim argued for and against. That is `dialectic`.
35
+ - The user wants options generated, or a draft plan stress-tested. That is
36
+ `brainstorming-ideas`.
30
37
 
31
- - `prompt` the topic, question, or code excerpt to review (required)
32
- - `profile` a named fusion profile (optional; omit to use the default)
38
+ Fusion differs from both. Panelists run in isolation and never see the answers of
39
+ the others. The value is uncorrelated errors, not debate.
33
40
 
34
- The tool queues a `/fusion` command as a follow-up, so the panel starts immediately after the current turn.
41
+ ## How to call it
42
+
43
+ | Parameter | Required | Use |
44
+ | --------- | -------- | ------------------------------------------------------------- |
45
+ | `prompt` | yes | The topic, question, or code excerpt |
46
+ | `profile` | no | A named profile that the user already has. Omit it otherwise. |
47
+ | `panel` | no | Models for this run only. Use it when the user names models. |
48
+
49
+ Each `panel` entry is `<model>` or `<agent>:<model>`. For example: `opus`, or
50
+ `pi-fusion.fusion-panelist-web:gpt-5.5`.
51
+
52
+ Pass `panel` only when the user names the models. If the user asks for a kind of
53
+ review but names no model, omit `panel` and let the profile decide.
54
+
55
+ Do not pass a `profile` name that you have not seen in the config of the user.
56
+ An unknown name fails the run.
57
+
58
+ ## Two shapes of report
59
+
60
+ The shape follows the profile. You do not select it, and there is no flag.
61
+
62
+ **Select** is the default. Every panelist answers the whole question. The judge
63
+ compares the answers and reports consensus, disagreements, contested claims, and
64
+ blind spots. This fits a decision question, where the failure mode is the bad
65
+ reasoning path of one model.
66
+
67
+ **Merge** happens when the profile gives its members a `question` field. Each
68
+ panelist then answers a different facet. A composer unions the answers into a
69
+ coverage map, a combined answer, and a list of gaps. This fits a breadth
70
+ question, where the failure mode is incomplete coverage.
71
+
72
+ If the user asks for a multi-angle audit and has no faceted profile, run the
73
+ default panel. Then tell them that a profile with `question` fields covers this
74
+ better.
75
+
76
+ ## After the call
77
+
78
+ The tool returns at once. The panel and the synthesis step run in the background,
79
+ and Fusion posts the report when they finish.
80
+
81
+ - Do not call the tool again while a run is active. It returns a conflict with
82
+ the id of the active run.
83
+ - Do not summarize or predict the report. Wait for it.
84
+ - To show progress or stop a run, tell the user to type `/fusion status` or
85
+ `/fusion stop`. No tool does this.
package/src/commands.ts CHANGED
@@ -16,6 +16,7 @@ const FUSION_HELP = [
16
16
  "Fusion commands",
17
17
  "/fusion <prompt>",
18
18
  "/fusion --profile <name> <prompt>",
19
+ "/fusion --panel <models> <prompt>",
19
20
  "/fusion status",
20
21
  "/fusion stop",
21
22
  "/fusion init",
package/src/config.ts CHANGED
@@ -4,6 +4,8 @@ import { dirname, join } from "node:path";
4
4
  import { applyClaudeAliasShorthand } from "./claude-aliases.js";
5
5
  import { FusionConfigError } from "./errors.js";
6
6
  import {
7
+ JUDGE_AGENT,
8
+ PANEL_AGENT,
7
9
  THINKING_LEVELS,
8
10
  type FusionConfig,
9
11
  type FusionContextMode,
@@ -11,6 +13,7 @@ import {
11
13
  type JudgeConfig,
12
14
  type PanelMemberConfig,
13
15
  type ThinkingLevel,
16
+ type ToolBudget,
14
17
  } from "./types.js";
15
18
  import {
16
19
  isNodeErrorCode,
@@ -21,8 +24,43 @@ import {
21
24
 
22
25
  export const FUSION_CONFIG_FILE = "fusion.json";
23
26
  export const DEFAULT_PROFILE_NAME = "quality";
24
- export const PANEL_AGENT = "pi-fusion.fusion-panelist";
25
- export const JUDGE_AGENT = "pi-fusion.fusion-judge";
27
+ export {
28
+ COMPOSER_AGENT,
29
+ JUDGE_AGENT,
30
+ PANEL_AGENT,
31
+ PANEL_AGENT_FULL,
32
+ PANEL_AGENT_WEB,
33
+ } from "./types.js";
34
+
35
+ /**
36
+ * Tool names Fusion's bundled agents may declare: Pi core child tools plus the
37
+ * tools contributed by `pi-web-providers`. A name outside this set resolves to
38
+ * nothing at runtime, so a typo is only discovered after a panel run is spent.
39
+ * Single source of truth for `test/unit/agents.test.ts`.
40
+ */
41
+ export const KNOWN_TOOL_NAMES: readonly string[] = [
42
+ "read",
43
+ "bash",
44
+ "edit",
45
+ "write",
46
+ "grep",
47
+ "find",
48
+ "ls",
49
+ "web_search",
50
+ "web_contents",
51
+ "web_answer",
52
+ "web_research",
53
+ ];
54
+
55
+ /**
56
+ * Agent references are dot-separated (`<package>.<name>`, or a bare local name).
57
+ * This deliberately does not enforce the lowercase `IDENTIFIER_PATTERN` that
58
+ * pi-subagents applies to package names: that pattern does not cover the
59
+ * frontmatter `name`, and rejecting a config that would actually run is worse
60
+ * than accepting a malformed one. It catches the real typo classes only —
61
+ * embedded whitespace, empty segments, leading or trailing dots.
62
+ */
63
+ const AGENT_REFERENCE_PATTERN = /^[^\s.]+(?:\.[^\s.]+)*$/;
26
64
 
27
65
  export interface FusionConfigLoadContext {
28
66
  cwd: string;
@@ -138,6 +176,89 @@ export function resolveProfile(
138
176
  return { name, profile };
139
177
  }
140
178
 
179
+ /**
180
+ * Splits an inline `--panel` entry into agent and model.
181
+ *
182
+ * Two shapes collide on `:` — an agent-qualified entry
183
+ * (`pi-fusion.fusion-panelist-web:gpt-5.5`) and a model with a thinking suffix
184
+ * (`gpt-4.1:high`). A dot in the prefix is not enough to tell them apart:
185
+ * dotted model versions like `gpt-4.1`, `claude-3.5-haiku`, and
186
+ * `gemini-2.5-pro` are common. The suffix decides — if it is a thinking level,
187
+ * the whole entry is a model.
188
+ */
189
+ export function splitInlinePanelEntry(entry: string): {
190
+ agent: string;
191
+ model: string;
192
+ } {
193
+ const trimmed = entry.trim();
194
+ const separator = trimmed.indexOf(":");
195
+ if (separator > 0) {
196
+ const prefix = trimmed.slice(0, separator);
197
+ const rest = trimmed.slice(separator + 1).trim();
198
+ if (
199
+ prefix.includes(".") &&
200
+ !prefix.includes("/") &&
201
+ rest &&
202
+ !isThinkingLevel(rest)
203
+ ) {
204
+ return { agent: prefix, model: rest };
205
+ }
206
+ }
207
+ return { agent: PANEL_AGENT, model: trimmed };
208
+ }
209
+
210
+ /**
211
+ * Builds an ephemeral profile from `--panel`. The resolved profile still
212
+ * supplies the judge and every other setting; only the panel is replaced.
213
+ */
214
+ export function buildInlinePanelProfile(
215
+ base: FusionProfile,
216
+ entries: readonly string[],
217
+ ): FusionProfile {
218
+ const usedIds = new Set<string>();
219
+ const panel = entries.map((entry, index) => {
220
+ const { agent, model } = splitInlinePanelEntry(entry);
221
+ if (!model) {
222
+ throw new FusionConfigError(
223
+ `Inline panel entry "${entry}" has no model.`,
224
+ );
225
+ }
226
+ if (!isAgentReference(agent)) {
227
+ throw new FusionConfigError(
228
+ `Inline panel entry "${entry}" has a malformed agent reference.`,
229
+ );
230
+ }
231
+ return {
232
+ id: uniqueInlineId(model, index, usedIds),
233
+ label: model,
234
+ agent,
235
+ model,
236
+ };
237
+ });
238
+ // Inline members have no `question`, so they all answer the whole task.
239
+ // Carrying `synthesis: "merge"` over from the base profile would tell the
240
+ // composer to merge facets that do not exist.
241
+ const { synthesis: _dropped, ...rest } = base;
242
+ return { ...rest, panel };
243
+ }
244
+
245
+ function uniqueInlineId(
246
+ model: string,
247
+ index: number,
248
+ used: Set<string>,
249
+ ): string {
250
+ const base =
251
+ model
252
+ .replace(/[^A-Za-z0-9]+/g, "_")
253
+ .replace(/^_+|_+$/g, "")
254
+ .toLowerCase() || `panel_${index + 1}`;
255
+ let candidate = base;
256
+ let suffix = 2;
257
+ while (used.has(candidate)) candidate = `${base}_${suffix++}`;
258
+ used.add(candidate);
259
+ return candidate;
260
+ }
261
+
141
262
  export async function writeProjectFusionConfigTemplate(
142
263
  cwd: string,
143
264
  deps: FileWriteDeps = {},
@@ -209,24 +330,65 @@ function isFusionProfile(value: unknown): value is FusionProfile {
209
330
  ) {
210
331
  return false;
211
332
  }
333
+ if (
334
+ value.blindPanelLabels !== undefined &&
335
+ typeof value.blindPanelLabels !== "boolean"
336
+ ) {
337
+ return false;
338
+ }
339
+ if (
340
+ value.judgeToolBudget !== undefined &&
341
+ !isToolBudget(value.judgeToolBudget)
342
+ ) {
343
+ return false;
344
+ }
345
+ if (
346
+ value.synthesis !== undefined &&
347
+ value.synthesis !== "select" &&
348
+ value.synthesis !== "merge"
349
+ ) {
350
+ return false;
351
+ }
352
+ return true;
353
+ }
354
+
355
+ function isToolBudget(value: unknown): value is ToolBudget {
356
+ if (!isRecord(value)) return false;
357
+ if (value.soft !== undefined && !isPositiveInteger(value.soft)) return false;
358
+ if (value.hard !== undefined && !isPositiveInteger(value.hard)) return false;
359
+ if (value.soft === undefined && value.hard === undefined) return false;
360
+ if (
361
+ isPositiveInteger(value.soft) &&
362
+ isPositiveInteger(value.hard) &&
363
+ value.soft > value.hard
364
+ ) {
365
+ return false;
366
+ }
212
367
  return true;
213
368
  }
214
369
 
370
+ export function isAgentReference(value: unknown): value is string {
371
+ return isNonEmptyString(value) && AGENT_REFERENCE_PATTERN.test(value.trim());
372
+ }
373
+
215
374
  function isPanelMemberConfig(value: unknown): value is PanelMemberConfig {
216
375
  if (!isRecord(value)) return false;
217
376
  if (!isNonEmptyString(value.id)) return false;
218
- if (!isNonEmptyString(value.label)) return false;
219
- if (!isNonEmptyString(value.agent)) return false;
377
+ if (value.label !== undefined && !isNonEmptyString(value.label)) return false;
378
+ if (!isAgentReference(value.agent)) return false;
220
379
  if (value.model !== undefined && !isNonEmptyString(value.model)) return false;
221
380
  if (value.thinking !== undefined && !isThinkingLevel(value.thinking))
222
381
  return false;
223
382
  if (value.role !== undefined && typeof value.role !== "string") return false;
383
+ if (value.question !== undefined && !isNonEmptyString(value.question)) {
384
+ return false;
385
+ }
224
386
  return true;
225
387
  }
226
388
 
227
389
  function isJudgeConfig(value: unknown): value is JudgeConfig {
228
390
  if (!isRecord(value)) return false;
229
- if (!isNonEmptyString(value.agent)) return false;
391
+ if (!isAgentReference(value.agent)) return false;
230
392
  if (value.model !== undefined && !isNonEmptyString(value.model)) return false;
231
393
  if (value.thinking !== undefined && !isThinkingLevel(value.thinking))
232
394
  return false;
@@ -2,7 +2,7 @@ import { FusionArgsError } from "./errors.js";
2
2
  import type { ParsedFusionArgs } from "./types.js";
3
3
 
4
4
  const FUSION_USAGE =
5
- "Usage: /fusion <prompt> | /fusion --profile <name> <prompt> | /fusion status | /fusion stop | /fusion init.";
5
+ "Usage: /fusion <prompt> | /fusion --profile <name> <prompt> | /fusion --panel <models> <prompt> | /fusion status | /fusion stop | /fusion init.";
6
6
 
7
7
  export type FusionInlineCommand = "init" | "status" | "stop";
8
8
 
@@ -27,12 +27,31 @@ export function parseFusionArgs(
27
27
  if (tokens[0] === "/fusion" || tokens[0] === "fusion") tokens.shift();
28
28
 
29
29
  let profile: string | undefined;
30
+ let panel: string[] | undefined;
30
31
  const promptTokens: string[] = [];
31
32
 
32
33
  for (let index = 0; index < tokens.length; index++) {
33
34
  const token = tokens[index];
34
35
  if (!token) continue;
35
36
 
37
+ if (promptTokens.length === 0 && token === "--panel") {
38
+ const value = tokens[index + 1];
39
+ if (!value || value.startsWith("-")) {
40
+ throw new FusionArgsError(`Missing value for --panel. ${FUSION_USAGE}`);
41
+ }
42
+ if (panel) throw new FusionArgsError("Panel can only be provided once.");
43
+ panel = parsePanelEntries(value);
44
+ index++;
45
+ continue;
46
+ }
47
+
48
+ if (promptTokens.length === 0 && token.startsWith("--panel=")) {
49
+ const value = token.slice("--panel=".length);
50
+ if (panel) throw new FusionArgsError("Panel can only be provided once.");
51
+ panel = parsePanelEntries(value);
52
+ continue;
53
+ }
54
+
36
55
  if (
37
56
  promptTokens.length === 0 &&
38
57
  (token === "--profile" || token === "-p")
@@ -72,7 +91,22 @@ export function parseFusionArgs(
72
91
 
73
92
  const prompt = promptTokens.join(" ").trim();
74
93
  if (!prompt) throw new FusionArgsError(FUSION_USAGE);
75
- return profile ? { prompt, profile } : { prompt };
94
+ return {
95
+ prompt,
96
+ ...(profile ? { profile } : {}),
97
+ ...(panel ? { panel } : {}),
98
+ };
99
+ }
100
+
101
+ function parsePanelEntries(value: string): string[] {
102
+ const entries = value
103
+ .split(",")
104
+ .map((entry) => entry.trim())
105
+ .filter(Boolean);
106
+ if (entries.length === 0) {
107
+ throw new FusionArgsError(`Missing value for --panel. ${FUSION_USAGE}`);
108
+ }
109
+ return entries;
76
110
  }
77
111
 
78
112
  export function tokenizeCommandArgs(input: string): string[] {
package/src/index.ts CHANGED
@@ -18,22 +18,30 @@ function registerFusionTool(
18
18
  name: "start_fusion_review",
19
19
  label: "Fusion Review",
20
20
  description:
21
- "Start a pi-fusion multi-model panel review for a hard decision, design tradeoff, risk review, tricky debugging question, or research-heavy topic. Do not use for routine edits, formatting, or obvious one-step fixes.",
21
+ "Start a pi-fusion review. Several models answer in parallel, then one synthesis step returns a single report. Use it for a hard decision, a design tradeoff, a risk or release review, tricky debugging, a research-heavy question, or a breadth sweep such as an audit or 'what did we miss'. Do not use it for routine edits, formatting, or obvious one-step fixes.",
22
22
  promptSnippet: "Start a fusion panel review for a topic or code",
23
23
  promptGuidelines: [
24
- "Use start_fusion_review only for hard decisions, design tradeoffs, risk review, tricky debugging, or research-heavy questions. Do not use it for routine edits, formatting, or obvious one-step fixes.",
24
+ "Use start_fusion_review only for hard decisions, design tradeoffs, risk review, tricky debugging, research-heavy questions, or breadth sweeps such as audits. Do not use it for routine edits, formatting, or obvious one-step fixes.",
25
+ "Pass panel only when the user names the models to compare. Otherwise omit it and let the profile decide.",
25
26
  ],
26
27
  parameters: Type.Object({
27
28
  prompt: Type.String({ description: "What to review or discuss" }),
28
29
  profile: Type.Optional(
29
30
  Type.String({ description: "Fusion profile name (optional)" }),
30
31
  ),
32
+ panel: Type.Optional(
33
+ Type.Array(Type.String(), {
34
+ description:
35
+ "Models to use for this run, overriding the profile panel. Each entry is <model> or <agent>:<model>. Use only when the user names specific models.",
36
+ }),
37
+ ),
31
38
  }),
32
39
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
33
40
  const result = await orchestrator.startRun(
34
41
  {
35
42
  prompt: params.prompt,
36
43
  ...(params.profile ? { profile: params.profile } : {}),
44
+ ...(params.panel?.length ? { panel: params.panel } : {}),
37
45
  },
38
46
  ctx,
39
47
  );
@@ -48,6 +56,7 @@ function registerFusionTool(
48
56
  details: {
49
57
  prompt: params.prompt,
50
58
  profile: params.profile,
59
+ panel: params.panel,
51
60
  status: result.status,
52
61
  },
53
62
  };
@@ -1,4 +1,6 @@
1
+ import { applyClaudeAliasShorthand } from "./claude-aliases.js";
1
2
  import {
3
+ buildInlinePanelProfile,
2
4
  loadFusionConfig,
3
5
  resolveProfile as resolveFusionProfile,
4
6
  type ResolvedFusionProfile,
@@ -30,12 +32,14 @@ import {
30
32
  readSubagentResultArtifact,
31
33
  readSubagentStatusArtifact,
32
34
  } from "./subagent-artifacts.js";
33
- import type {
34
- FailedPanelSummary,
35
- FusionProfile,
36
- FusionRun,
37
- PanelOutput,
38
- ParsedFusionArgs,
35
+ import {
36
+ memberLabel,
37
+ resolveSynthesisMode,
38
+ type FailedPanelSummary,
39
+ type FusionProfile,
40
+ type FusionRun,
41
+ type PanelOutput,
42
+ type ParsedFusionArgs,
39
43
  } from "./types.js";
40
44
  import {
41
45
  extractRunObservation,
@@ -150,9 +154,30 @@ export class FusionOrchestrator {
150
154
  }
151
155
 
152
156
  let resolved: ResolvedFusionProfile;
157
+ let baseProfileName: string | undefined;
153
158
  try {
154
159
  const config = await this.loadConfig(ctx);
155
160
  resolved = this.resolveProfile(config, args.profile);
161
+ baseProfileName = resolved.name;
162
+ if (args.panel?.length) {
163
+ // The named profile still supplies the judge and every other setting;
164
+ // only the panel is replaced. Inline models skip the alias pass that
165
+ // runs at config load, so re-run it over the assembled profile.
166
+ const inlineName = `${resolved.name} (inline panel)`;
167
+ const aliased = await applyClaudeAliasShorthand(
168
+ {
169
+ defaultProfile: inlineName,
170
+ profiles: {
171
+ [inlineName]: buildInlinePanelProfile(
172
+ resolved.profile,
173
+ args.panel,
174
+ ),
175
+ },
176
+ },
177
+ ctx,
178
+ );
179
+ resolved = this.resolveProfile(aliased, inlineName);
180
+ }
156
181
  this.configWarning = undefined;
157
182
  } catch (error: unknown) {
158
183
  const message = errorMessage(error);
@@ -166,6 +191,9 @@ export class FusionOrchestrator {
166
191
  run = this.runStore.startRun({
167
192
  prompt: args.prompt,
168
193
  profileName: resolved.name,
194
+ ...(args.panel?.length
195
+ ? { inlinePanel: args.panel, baseProfileName: baseProfileName }
196
+ : {}),
169
197
  ...(args.operationId !== undefined
170
198
  ? { operationId: args.operationId }
171
199
  : {}),
@@ -350,6 +378,12 @@ export class FusionOrchestrator {
350
378
  ? configuredJudgeModel(this.activeProfile)
351
379
  : undefined,
352
380
  ),
381
+ ...(this.activeProfile
382
+ ? {
383
+ synthesis: resolveSynthesisMode(this.activeProfile),
384
+ panel: this.activeProfile.panel,
385
+ }
386
+ : {}),
353
387
  });
354
388
  const cancelled = this.runStore.cancelRun(active.id, {
355
389
  ...(active.chainRunId ? { chainRunId: active.chainRunId } : {}),
@@ -381,10 +415,19 @@ export class FusionOrchestrator {
381
415
 
382
416
  try {
383
417
  const config = await this.loadConfig(ctx);
384
- this.activeProfile = this.resolveProfile(
418
+ // An inline run's profileName is a display name that no config defines,
419
+ // so rebuild it from the base profile plus the persisted entries. Looking
420
+ // the display name up throws, which used to leave activeProfile undefined
421
+ // and fail the run the moment its panel completed.
422
+ const base = this.resolveProfile(
385
423
  config,
386
- active.profileName,
424
+ active.inlinePanel?.length
425
+ ? active.baseProfileName
426
+ : active.profileName,
387
427
  ).profile;
428
+ this.activeProfile = active.inlinePanel?.length
429
+ ? buildInlinePanelProfile(base, active.inlinePanel)
430
+ : base;
388
431
  this.configWarning = undefined;
389
432
  } catch (error: unknown) {
390
433
  const message = `Could not restore fusion profile "${active.profileName}": ${errorMessage(error)}`;
@@ -576,6 +619,8 @@ export class FusionOrchestrator {
576
619
  failures: storedPanelFailures(observed),
577
620
  ...withJudgeModel(configuredJudgeModel(profile)),
578
621
  judgeObservation,
622
+ ...(profile.blindPanelLabels ? { blindPanelLabels: true } : {}),
623
+ synthesis: resolveSynthesisMode(profile),
579
624
  });
580
625
  return this.completeActiveRun(report);
581
626
  }
@@ -814,9 +859,17 @@ export class FusionOrchestrator {
814
859
  const output = extractJudgeOutput(lifecyclePayload);
815
860
  if (!output.ok) return this.failActiveRun(output.error);
816
861
 
817
- const judgeModel = this.activeProfile
818
- ? configuredJudgeModel(this.activeProfile)
819
- : undefined;
862
+ // The panel and chain handlers already treat a missing profile as fatal.
863
+ // Without the same guard here the run "succeeds" with a degraded report:
864
+ // blind labels are never restored, and the judge model is dropped.
865
+ const profile = this.activeProfile;
866
+ if (!profile) {
867
+ return this.failActiveRun(
868
+ "Fusion judge completed, but the active profile was not available.",
869
+ );
870
+ }
871
+
872
+ const judgeModel = configuredJudgeModel(profile);
820
873
  const judgeObservation = mergeRunObservations(
821
874
  extractRunObservation(
822
875
  findStepsArray(snapshot.statusPayload)[0] ?? snapshot.statusPayload,
@@ -837,6 +890,8 @@ export class FusionOrchestrator {
837
890
  failures: storedPanelFailures(observed),
838
891
  ...withJudgeModel(judgeModel),
839
892
  judgeObservation,
893
+ ...(profile.blindPanelLabels ? { blindPanelLabels: true } : {}),
894
+ synthesis: resolveSynthesisMode(profile),
840
895
  });
841
896
  return this.completeActiveRun(report);
842
897
  }
@@ -989,6 +1044,12 @@ export class FusionOrchestrator {
989
1044
  ? configuredJudgeModel(this.activeProfile)
990
1045
  : undefined,
991
1046
  ),
1047
+ ...(this.activeProfile
1048
+ ? {
1049
+ synthesis: resolveSynthesisMode(this.activeProfile),
1050
+ panel: this.activeProfile.panel,
1051
+ }
1052
+ : {}),
992
1053
  });
993
1054
  }
994
1055
 
@@ -1269,7 +1330,7 @@ function buildFusionStatusDetails(
1269
1330
  const activity = describeStepActivity(step);
1270
1331
  const metrics = describeStepMetrics(step);
1271
1332
  return {
1272
- label: member.label,
1333
+ label: memberLabel(member),
1273
1334
  ...(member.role ? { role: member.role } : {}),
1274
1335
  ...(model ? { model } : {}),
1275
1336
  status: describePanelStatus(step),
@@ -1334,7 +1395,7 @@ function buildCompletedPanelStatusLines(
1334
1395
  );
1335
1396
  if (output) {
1336
1397
  return {
1337
- label: member.label,
1398
+ label: memberLabel(member),
1338
1399
  ...(member.role ? { role: member.role } : {}),
1339
1400
  ...(model ? { model } : {}),
1340
1401
  status: "completed",
@@ -1344,7 +1405,7 @@ function buildCompletedPanelStatusLines(
1344
1405
  (item) => item.id === member.id || item.index === index,
1345
1406
  );
1346
1407
  return {
1347
- label: member.label,
1408
+ label: memberLabel(member),
1348
1409
  ...(member.role ? { role: member.role } : {}),
1349
1410
  ...(model ? { model } : {}),
1350
1411
  status: failure ? "failed" : "unknown",
@@ -4,11 +4,12 @@ import {
4
4
  buildJudgeSpawnParams,
5
5
  type JudgeSpawnParams,
6
6
  } from "./run-builder.js";
7
- import type {
8
- FailedPanelSummary,
9
- FusionProfile,
10
- FusionRun,
11
- PanelOutput,
7
+ import {
8
+ resolveSynthesisMode,
9
+ type FailedPanelSummary,
10
+ type FusionProfile,
11
+ type FusionRun,
12
+ type PanelOutput,
12
13
  } from "./types.js";
13
14
 
14
15
  export type PanelCompletionDecision =
@@ -39,6 +40,8 @@ export function decidePanelCompletion(
39
40
  run: input.run,
40
41
  failures: input.panelFailures,
41
42
  ...withJudgeModel(judgeModel),
43
+ synthesis: resolveSynthesisMode(input.profile),
44
+ panel: input.profile.panel,
42
45
  });
43
46
  return {
44
47
  kind: "fail",
@@ -47,7 +50,14 @@ export function decidePanelCompletion(
47
50
  };
48
51
  }
49
52
 
50
- if (input.panelOutputs.length === 1) {
53
+ // Under `select` every panelist answered the whole question, so a lone
54
+ // survivor is a complete if thin answer. Under `merge` it answered ONE facet:
55
+ // returning it as the answer would be wrong, not thin. Run the composer so
56
+ // the report names the facets nobody covered.
57
+ if (
58
+ input.panelOutputs.length === 1 &&
59
+ resolveSynthesisMode(input.profile) !== "merge"
60
+ ) {
51
61
  const report = renderSinglePanelReport({
52
62
  run: input.run,
53
63
  output: input.panelOutputs[0]!,
@@ -64,6 +74,7 @@ export function decidePanelCompletion(
64
74
  prompt: input.run.prompt,
65
75
  panelOutputs: input.panelOutputs,
66
76
  failedPanelists: input.panelFailures,
77
+ runId: input.run.id,
67
78
  }),
68
79
  missingRunIdError: input.fallbackJudge
69
80
  ? "pi-subagents spawn did not return a fallback judge run ID."