@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.
@@ -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
  };