@alexeiled/pi-fusion 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,75 +1,121 @@
1
1
  # pi-fusion
2
2
 
3
- [![npm](https://img.shields.io/badge/npm-%40alexeiled%2Fpi--fusion-cb3837?style=flat-square&logo=npm&logoColor=white)](https://www.npmjs.com/package/@alexeiled/pi-fusion)
3
+ [![npm version](https://img.shields.io/npm/v/%40alexeiled%2Fpi-fusion?style=flat-square&logo=npm&logoColor=white)](https://www.npmjs.com/package/@alexeiled/pi-fusion)
4
+ [![CI](https://img.shields.io/github/actions/workflow/status/alexei-led/pi-fusion/test.yml?branch=master&style=flat-square&label=ci)](https://github.com/alexei-led/pi-fusion/actions/workflows/test.yml?query=branch%3Amaster)
4
5
  [![node](https://img.shields.io/badge/node-%3E%3D22.19.0-5fa04e?style=flat-square&logo=node.js&logoColor=white)](https://nodejs.org/)
5
6
  [![license](https://img.shields.io/badge/license-MIT-blue?style=flat-square)](./LICENSE)
6
7
 
7
- > Parallel panel. One judge. One report.
8
+ > Parallel models. One judge. Better answers.
8
9
 
9
- `pi-fusion` is a Pi extension for questions that need deliberation, not a guess.
10
- It runs a small panel of read-only subagents in parallel, then asks a judge agent
11
- to synthesize one final Markdown report.
10
+ `pi-fusion` is a Pi extension for hard technical questions.
11
+ It uses `pi-subagents` to send the same prompt through a small parallel model panel,
12
+ then asks a judge agent to compare the outputs and return the best realistic answer.
12
13
 
13
- ## Fusion model
14
+ CI covers lint, typecheck, unit tests, integration tests, package smoke tests,
15
+ and `npm pack --dry-run`.
14
16
 
15
- My approach is simple:
17
+ ![pi-fusion flow](https://raw.githubusercontent.com/alexei-led/pi-fusion/master/docs/assets/fusion-flow.png)
16
18
 
17
- - one hard question becomes a short review panel
18
- - panelists work independently in parallel
19
- - the judge reconciles evidence, not votes
20
- - if the judge result is missing but the panel still produced enough signal,
21
- Fusion retries only the judge step
19
+ ## Why Fusion exists
22
20
 
23
- This is evidence-first, not majority vote.
24
- The judge is a synthesizer, not a tie-breaker by headcount.
21
+ Hard questions are often bottlenecked by one model's search path.
22
+ `pi-fusion` trades latency for diversity:
23
+
24
+ - the same prompt fans out to several model runs in parallel
25
+ - each model explores the problem from a different training prior and reasoning path
26
+ - overlap raises confidence
27
+ - disagreement exposes risk
28
+ - the judge keeps the strongest parts and drops weak, partial, or conflicting ones
29
+
30
+ This is evidence selection, not majority vote.
25
31
 
26
32
  ```mermaid
33
+ %%{init: {"theme": "base", "flowchart": {"curve": "basis", "nodeSpacing": 28, "rankSpacing": 48}, "themeVariables": {"background": "#050816", "fontFamily": "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace", "primaryTextColor": "#E5F0FF", "lineColor": "#38bdf8", "tertiaryColor": "#0b1220"}}}%%
27
34
  flowchart LR
28
- classDef input fill:#f8fafc,stroke:#94a3b8,color:#0f172a,stroke-width:1px;
29
- classDef panel fill:#e0f2fe,stroke:#38bdf8,color:#0f172a,stroke-width:1px;
30
- classDef judge fill:#fef3c7,stroke:#f59e0b,color:#0f172a,stroke-width:1px;
31
- classDef report fill:#dcfce7,stroke:#22c55e,color:#0f172a,stroke-width:1px;
32
- classDef fallback fill:#fae8ff,stroke:#c084fc,color:#0f172a,stroke-width:1px,stroke-dasharray:4 3;
33
-
34
- U[You in Pi]:::input --> Q[/fusion prompt/]:::input
35
-
36
- subgraph P[Parallel panel]
37
- direction LR
38
- A[Architect]:::panel
39
- I[Implementer]:::panel
40
- T[Tester]:::panel
35
+ classDef prompt fill:#071321,stroke:#38bdf8,color:#dbeafe,stroke-width:2px;
36
+ classDef modelA fill:#081223,stroke:#38bdf8,color:#dbeafe,stroke-width:2px;
37
+ classDef modelB fill:#120826,stroke:#a855f7,color:#f3e8ff,stroke-width:2px;
38
+ classDef modelC fill:#08180f,stroke:#22c55e,color:#dcfce7,stroke-width:2px;
39
+ classDef modelD fill:#241307,stroke:#f59e0b,color:#fef3c7,stroke-width:2px;
40
+ classDef modelE fill:#260712,stroke:#ff4d8d,color:#ffd1e7,stroke-width:2px;
41
+ classDef judge fill:#2b1905,stroke:#f59e0b,color:#fef3c7,stroke-width:3px;
42
+ classDef answer fill:#062814,stroke:#22c55e,color:#dcfce7,stroke-width:3px;
43
+ classDef note fill:#0b1220,stroke:#475569,color:#cbd5e1,stroke-width:1px;
44
+
45
+ P["input prompt<br/>same question to every model"]:::prompt
46
+
47
+ subgraph PANEL[parallel model panel]
48
+ direction TB
49
+ A["model A<br/>strong baseline candidate"]:::modelA
50
+ B["model B<br/>finds contradiction"]:::modelB
51
+ C["model C<br/>adds unique insight"]:::modelC
52
+ D["model D<br/>fast practical path"]:::modelD
53
+ E["model E<br/>catches edge case"]:::modelE
41
54
  end
42
55
 
43
- Q --> A
44
- Q --> I
45
- Q --> T
46
- A --> J{{Judge}}:::judge
47
- I --> J
48
- T --> J
49
- J --> R[Final Markdown report]:::report
50
- J -. missing judge result .-> F[Fallback judge retry]:::fallback
51
- F --> R
56
+ J["judge<br/>consensus contradictions • blind spots"]:::judge
57
+ R["best answer<br/>selected or synthesized"]:::answer
58
+ N["not majority vote<br/>best evidence wins"]:::note
59
+
60
+ P --> A
61
+ P --> B
62
+ P --> C
63
+ P --> D
64
+ P --> E
65
+
66
+ A --> J
67
+ B --> J
68
+ C --> J
69
+ D --> J
70
+ E --> J
71
+
72
+ J --> R
73
+ J -.-> N
74
+
75
+ linkStyle 0,5 stroke:#38bdf8,stroke-width:3px;
76
+ linkStyle 1,6 stroke:#a855f7,stroke-width:3px;
77
+ linkStyle 2,7 stroke:#22c55e,stroke-width:3px;
78
+ linkStyle 3,8 stroke:#f59e0b,stroke-width:3px;
79
+ linkStyle 4,9 stroke:#ff4d8d,stroke-width:3px;
80
+ linkStyle 10 stroke:#22c55e,stroke-width:4px;
81
+ linkStyle 11 stroke:#94a3b8,stroke-width:2px,stroke-dasharray: 5 5;
52
82
  ```
53
83
 
54
- ## How the judge works
84
+ ## Why a panel can beat one model
85
+
86
+ Single-model answers are brittle on hard tasks. They are limited by one model's
87
+ priors, one reasoning path, and one failure mode.
88
+
89
+ A panel helps because:
90
+
91
+ - different models are trained differently and make different bets
92
+ - errors are less correlated, so blind spots do not line up perfectly
93
+ - consensus is a useful confidence signal without pretending certainty
94
+ - disagreement tells you where the answer is fragile
95
+ - a judge can select or synthesize the best realistic answer from the set
96
+
97
+ The result is slower, but usually better for design choices, risk review,
98
+ tricky debugging, and research-heavy questions.
99
+
100
+ ## What the judge actually does
55
101
 
56
102
  The judge gets:
57
103
 
58
104
  - the original prompt
59
- - the panel outputs
60
- - the panel failures and blind spots
105
+ - every panel output
106
+ - panel failures and blind spots
61
107
  - the configured judge model
62
108
 
63
- It produces one report that highlights:
109
+ It then:
64
110
 
65
- - consensus
66
- - disagreements
67
- - risks
68
- - missing evidence
69
- - next step
111
+ - finds consensus
112
+ - preserves real disagreements
113
+ - spots weak or incomplete answers
114
+ - pulls forward unique insights worth keeping
115
+ - returns one clear recommendation and next step
70
116
 
71
- It does not edit files or spawn more subagents. It does one job: turn
72
- competing notes into one clear recommendation.
117
+ It does not edit files or spawn more subagents. It does one job: choose or
118
+ synthesize the best realistic answer.
73
119
 
74
120
  ## Good fit
75
121
 
@@ -89,6 +135,7 @@ Do not use it for trivial edits, formatting, or obvious one-step fixes.
89
135
  /fusion
90
136
  /fusion <prompt>
91
137
  /fusion --profile <name> <prompt>
138
+ /fusion -p <name> <prompt>
92
139
  /fusion status
93
140
  /fusion stop
94
141
  /fusion init
@@ -113,7 +160,7 @@ Then reload Pi:
113
160
  /reload
114
161
  ```
115
162
 
116
- For full config examples and profile details, see [`docs/user-guide.md`](./docs/user-guide.md).
163
+ For commands, config, and troubleshooting details, see [`docs/user-guide.md`](./docs/user-guide.md).
117
164
 
118
165
  ## Notes
119
166
 
@@ -1,10 +1,10 @@
1
1
  # pi-fusion user guide
2
2
 
3
- Use this when the README is not enough.
3
+ README covers the why. This guide covers commands, config, and troubleshooting.
4
4
 
5
5
  ## Mental model
6
6
 
7
- `pi-fusion` turns one hard question into a small review panel:
7
+ `pi-fusion` turns one hard question into a small parallel panel:
8
8
 
9
9
  ```text
10
10
  prompt → parallel panel → judge synthesis → final report
@@ -12,6 +12,8 @@ prompt → parallel panel → judge synthesis → final report
12
12
 
13
13
  Normal execution is a single `pi-subagents` async chain. If that chain completes without a judge result but at least two panelists still produced usable answers, `pi-fusion` runs one fallback judge pass instead of losing the review.
14
14
 
15
+ Panel diversity can come from different model choices, different perspective prompts, or both. In practice, mixing models is usually the main lever.
16
+
15
17
  The base Pi session stays in control. Fusion is a tool for decisions, not a replacement for normal coding.
16
18
 
17
19
  ## Commands
@@ -108,9 +110,9 @@ Panel member:
108
110
  - `id`: stable machine name
109
111
  - `label`: human-readable report label
110
112
  - `agent`: subagent name
111
- - `model`: optional model override
113
+ - `model`: optional model override; often the main source of panel diversity
112
114
  - `thinking`: optional `off`, `minimal`, `low`, `medium`, `high`, or `xhigh`
113
- - `role`: short perspective instruction
115
+ - `role`: optional perspective hint layered on top of the model
114
116
 
115
117
  Judge:
116
118
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@alexeiled/pi-fusion",
3
- "version": "0.2.0",
4
- "description": "Subagent-native multi-model deliberation for Pi",
3
+ "version": "0.2.2",
4
+ "description": "Stronger answers for hard Pi questions via a parallel model panel + judge, built on pi-subagents",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
7
7
  "license": "MIT",
@@ -22,11 +22,7 @@
22
22
  "files": [
23
23
  "agents/*.md",
24
24
  "src/*.ts",
25
- "README.md",
26
- "LICENSE",
27
- "AGENTS.md",
28
- "docs/user-guide.md",
29
- "tsconfig.json"
25
+ "docs/user-guide.md"
30
26
  ],
31
27
  "scripts": {
32
28
  "check": "tsc --noEmit",
@@ -47,7 +43,8 @@
47
43
  "agents": [
48
44
  "./agents"
49
45
  ]
50
- }
46
+ },
47
+ "image": "https://raw.githubusercontent.com/alexei-led/pi-fusion/master/docs/assets/fusion-icon.png"
51
48
  },
52
49
  "peerDependencies": {
53
50
  "@earendil-works/pi-coding-agent": "*",
package/src/commands.ts CHANGED
@@ -7,10 +7,11 @@ import {
7
7
  getProjectFusionConfigPath,
8
8
  writeProjectFusionConfigTemplate,
9
9
  } from "./config.js";
10
- import { FusionArgsError, FusionConfigError } from "./errors.js";
10
+ import { FusionConfigError } from "./errors.js";
11
+ import { parseFusionInlineCommand } from "./fusion-args.js";
12
+ import type { ParsedFusionArgs } from "./types.js";
13
+ import { isNodeErrorCode } from "./utils.js";
11
14
 
12
- const FUSION_USAGE =
13
- "Usage: /fusion <prompt> | /fusion --profile <name> <prompt> | /fusion status | /fusion stop | /fusion init.";
14
15
  const FUSION_HELP = [
15
16
  "Fusion commands",
16
17
  "/fusion <prompt>",
@@ -20,39 +21,11 @@ const FUSION_HELP = [
20
21
  "/fusion init",
21
22
  ].join("\n");
22
23
 
23
- export type FusionInlineCommand = "init" | "status" | "stop";
24
-
25
- export interface ParsedFusionArgs {
26
- prompt: string;
27
- profile?: string;
28
- }
29
-
30
- interface FusionInitContext {
31
- cwd: string;
32
- hasUI: boolean;
33
- isProjectTrusted(): boolean;
34
- ui: {
35
- confirm(title: string, message: string): Promise<boolean>;
36
- notify(message: string, type?: "info" | "warning" | "error"): void;
37
- };
38
- }
39
-
40
- export interface FusionInitDeps {
41
- readTextFile?: (path: string) => Promise<string>;
42
- writeTextFile?: (path: string, content: string) => Promise<void>;
43
- ensureDir?: (path: string) => Promise<void>;
44
- }
45
-
46
- export type FusionInitResult =
47
- | { status: "written"; path: string }
48
- | {
49
- status: "skipped";
50
- reason: "untrusted" | "exists" | "cancelled";
51
- path?: string;
52
- };
53
-
54
24
  export interface FusionRuntimeCommandHandler {
55
- startRun(args: string, ctx: ExtensionCommandContext): Promise<unknown>;
25
+ startRun(
26
+ args: string | ParsedFusionArgs,
27
+ ctx: ExtensionCommandContext,
28
+ ): Promise<unknown>;
56
29
  showStatus(ctx: ExtensionCommandContext): Promise<unknown>;
57
30
  cancelActiveRun(ctx: ExtensionCommandContext): Promise<unknown>;
58
31
  }
@@ -124,124 +97,29 @@ export async function runFusionInit(
124
97
  return { status: "written", path: writtenPath };
125
98
  }
126
99
 
127
- export function parseFusionInlineCommand(
128
- input: string | readonly string[],
129
- ): FusionInlineCommand | undefined {
130
- const tokens =
131
- typeof input === "string" ? tokenizeCommandArgs(input) : [...input];
132
- if (tokens.length !== 1) return undefined;
133
- const command = tokens[0];
134
- if (command === "init" || command === "status" || command === "stop") {
135
- return command;
136
- }
137
- return undefined;
100
+ interface FusionInitContext {
101
+ cwd: string;
102
+ hasUI: boolean;
103
+ isProjectTrusted(): boolean;
104
+ ui: {
105
+ confirm(title: string, message: string): Promise<boolean>;
106
+ notify(message: string, type?: "info" | "warning" | "error"): void;
107
+ };
138
108
  }
139
109
 
140
- export function parseFusionArgs(
141
- input: string | readonly string[],
142
- ): ParsedFusionArgs {
143
- const tokens =
144
- typeof input === "string" ? tokenizeCommandArgs(input) : [...input];
145
- if (tokens[0] === "/fusion" || tokens[0] === "fusion") tokens.shift();
146
-
147
- let profile: string | undefined;
148
- const promptTokens: string[] = [];
149
-
150
- for (let index = 0; index < tokens.length; index++) {
151
- const token = tokens[index];
152
- if (!token) continue;
153
-
154
- if (
155
- promptTokens.length === 0 &&
156
- (token === "--profile" || token === "-p")
157
- ) {
158
- const value = tokens[index + 1];
159
- if (!value || value.startsWith("-")) {
160
- throw new FusionArgsError(
161
- `Missing value for ${token}. ${FUSION_USAGE}`,
162
- );
163
- }
164
- if (profile)
165
- throw new FusionArgsError("Profile can only be provided once.");
166
- profile = value;
167
- index++;
168
- continue;
169
- }
170
-
171
- if (promptTokens.length === 0 && token.startsWith("--profile=")) {
172
- const value = token.slice("--profile=".length).trim();
173
- if (!value)
174
- throw new FusionArgsError(
175
- `Missing value for --profile. ${FUSION_USAGE}`,
176
- );
177
- if (profile)
178
- throw new FusionArgsError("Profile can only be provided once.");
179
- profile = value;
180
- continue;
181
- }
182
-
183
- if (promptTokens.length === 0 && token.startsWith("-")) {
184
- throw new FusionArgsError(`Unknown option ${token}. ${FUSION_USAGE}`);
185
- }
186
-
187
- promptTokens.push(token, ...tokens.slice(index + 1));
188
- break;
189
- }
190
-
191
- const prompt = promptTokens.join(" ").trim();
192
- if (!prompt) throw new FusionArgsError(FUSION_USAGE);
193
- return profile ? { prompt, profile } : { prompt };
110
+ export interface FusionInitDeps {
111
+ readTextFile?: (path: string) => Promise<string>;
112
+ writeTextFile?: (path: string, content: string) => Promise<void>;
113
+ ensureDir?: (path: string) => Promise<void>;
194
114
  }
195
115
 
196
- export function tokenizeCommandArgs(input: string): string[] {
197
- const tokens: string[] = [];
198
- let current = "";
199
- let quote: "'" | '"' | undefined;
200
- let escaping = false;
201
-
202
- for (const char of input.trim()) {
203
- if (escaping) {
204
- current += char;
205
- escaping = false;
206
- continue;
207
- }
208
-
209
- if (char === "\\") {
210
- escaping = true;
211
- continue;
212
- }
213
-
214
- if (quote) {
215
- if (char === quote) {
216
- quote = undefined;
217
- } else {
218
- current += char;
219
- }
220
- continue;
221
- }
222
-
223
- if (char === "'" || char === '"') {
224
- quote = char;
225
- continue;
226
- }
227
-
228
- if (/\s/.test(char)) {
229
- if (current) {
230
- tokens.push(current);
231
- current = "";
232
- }
233
- continue;
234
- }
235
-
236
- current += char;
237
- }
238
-
239
- if (escaping) current += "\\";
240
- if (quote)
241
- throw new FusionArgsError(`Unclosed ${quote} quote in /fusion arguments.`);
242
- if (current) tokens.push(current);
243
- return tokens;
244
- }
116
+ export type FusionInitResult =
117
+ | { status: "written"; path: string }
118
+ | {
119
+ status: "skipped";
120
+ reason: "untrusted" | "exists" | "cancelled";
121
+ path?: string;
122
+ };
245
123
 
246
124
  async function fileExists(
247
125
  path: string,
@@ -259,15 +137,6 @@ async function fileExists(
259
137
  }
260
138
  }
261
139
 
262
- function isNodeErrorCode(error: unknown, code: string): boolean {
263
- return (
264
- typeof error === "object" &&
265
- error !== null &&
266
- "code" in error &&
267
- error.code === code
268
- );
269
- }
270
-
271
140
  async function readUtf8File(path: string): Promise<string> {
272
141
  return readFile(path, "utf8");
273
142
  }
package/src/config.ts CHANGED
@@ -11,6 +11,12 @@ import {
11
11
  type PanelMemberConfig,
12
12
  type ThinkingLevel,
13
13
  } from "./types.js";
14
+ import {
15
+ isNodeErrorCode,
16
+ isNonEmptyString,
17
+ isPositiveInteger,
18
+ isRecord,
19
+ } from "./utils.js";
14
20
 
15
21
  export const FUSION_CONFIG_FILE = "fusion.json";
16
22
  export const DEFAULT_PROFILE_NAME = "quality";
@@ -227,22 +233,6 @@ function isFusionContextMode(value: unknown): value is FusionContextMode {
227
233
  return value === "fresh" || value === "fork";
228
234
  }
229
235
 
230
- function isRecord(value: unknown): value is Record<string, unknown> {
231
- return typeof value === "object" && value !== null && !Array.isArray(value);
232
- }
233
-
234
- function isNonEmptyString(value: unknown): value is string {
235
- return typeof value === "string" && value.trim().length > 0;
236
- }
237
-
238
- function isPositiveInteger(value: unknown): value is number {
239
- return typeof value === "number" && Number.isInteger(value) && value > 0;
240
- }
241
-
242
- function isNodeErrorCode(error: unknown, code: string): boolean {
243
- return isRecord(error) && error.code === code;
244
- }
245
-
246
236
  async function readUtf8File(path: string): Promise<string> {
247
237
  return readFile(path, "utf8");
248
238
  }
@@ -0,0 +1,126 @@
1
+ import { FusionArgsError } from "./errors.js";
2
+ import type { ParsedFusionArgs } from "./types.js";
3
+
4
+ const FUSION_USAGE =
5
+ "Usage: /fusion <prompt> | /fusion --profile <name> <prompt> | /fusion status | /fusion stop | /fusion init.";
6
+
7
+ export type FusionInlineCommand = "init" | "status" | "stop";
8
+
9
+ export function parseFusionInlineCommand(
10
+ input: string | readonly string[],
11
+ ): FusionInlineCommand | undefined {
12
+ const tokens =
13
+ typeof input === "string" ? tokenizeCommandArgs(input) : [...input];
14
+ if (tokens.length !== 1) return undefined;
15
+ const command = tokens[0];
16
+ if (command === "init" || command === "status" || command === "stop") {
17
+ return command;
18
+ }
19
+ return undefined;
20
+ }
21
+
22
+ export function parseFusionArgs(
23
+ input: string | readonly string[],
24
+ ): ParsedFusionArgs {
25
+ const tokens =
26
+ typeof input === "string" ? tokenizeCommandArgs(input) : [...input];
27
+ if (tokens[0] === "/fusion" || tokens[0] === "fusion") tokens.shift();
28
+
29
+ let profile: string | undefined;
30
+ const promptTokens: string[] = [];
31
+
32
+ for (let index = 0; index < tokens.length; index++) {
33
+ const token = tokens[index];
34
+ if (!token) continue;
35
+
36
+ if (
37
+ promptTokens.length === 0 &&
38
+ (token === "--profile" || token === "-p")
39
+ ) {
40
+ const value = tokens[index + 1];
41
+ if (!value || value.startsWith("-")) {
42
+ throw new FusionArgsError(
43
+ `Missing value for ${token}. ${FUSION_USAGE}`,
44
+ );
45
+ }
46
+ if (profile)
47
+ throw new FusionArgsError("Profile can only be provided once.");
48
+ profile = value;
49
+ index++;
50
+ continue;
51
+ }
52
+
53
+ if (promptTokens.length === 0 && token.startsWith("--profile=")) {
54
+ const value = token.slice("--profile=".length).trim();
55
+ if (!value)
56
+ throw new FusionArgsError(
57
+ `Missing value for --profile. ${FUSION_USAGE}`,
58
+ );
59
+ if (profile)
60
+ throw new FusionArgsError("Profile can only be provided once.");
61
+ profile = value;
62
+ continue;
63
+ }
64
+
65
+ if (promptTokens.length === 0 && token.startsWith("-")) {
66
+ throw new FusionArgsError(`Unknown option ${token}. ${FUSION_USAGE}`);
67
+ }
68
+
69
+ promptTokens.push(token, ...tokens.slice(index + 1));
70
+ break;
71
+ }
72
+
73
+ const prompt = promptTokens.join(" ").trim();
74
+ if (!prompt) throw new FusionArgsError(FUSION_USAGE);
75
+ return profile ? { prompt, profile } : { prompt };
76
+ }
77
+
78
+ export function tokenizeCommandArgs(input: string): string[] {
79
+ const tokens: string[] = [];
80
+ let current = "";
81
+ let quote: "'" | '"' | undefined;
82
+ let escaping = false;
83
+
84
+ for (const char of input.trim()) {
85
+ if (escaping) {
86
+ current += char;
87
+ escaping = false;
88
+ continue;
89
+ }
90
+
91
+ if (char === "\\") {
92
+ escaping = true;
93
+ continue;
94
+ }
95
+
96
+ if (quote) {
97
+ if (char === quote) {
98
+ quote = undefined;
99
+ } else {
100
+ current += char;
101
+ }
102
+ continue;
103
+ }
104
+
105
+ if (char === "'" || char === '"') {
106
+ quote = char;
107
+ continue;
108
+ }
109
+
110
+ if (/\s/.test(char)) {
111
+ if (current) {
112
+ tokens.push(current);
113
+ current = "";
114
+ }
115
+ continue;
116
+ }
117
+
118
+ current += char;
119
+ }
120
+
121
+ if (escaping) current += "\\";
122
+ if (quote)
123
+ throw new FusionArgsError(`Unclosed ${quote} quote in /fusion arguments.`);
124
+ if (current) tokens.push(current);
125
+ return tokens;
126
+ }
@@ -4,18 +4,17 @@ import {
4
4
  type ResolvedFusionProfile,
5
5
  } from "./config.js";
6
6
  import { FusionArgsError } from "./errors.js";
7
+ import { parseFusionArgs } from "./fusion-args.js";
8
+ import { decidePanelCompletion } from "./panel-completion.js";
7
9
  import {
8
10
  renderCancelledReport,
9
11
  renderFailureReport,
10
12
  renderJudgeReport,
11
- renderPanelFailureReport,
12
- renderSinglePanelReport,
13
13
  } from "./report.js";
14
14
  import { extractPanelResults } from "./result-extract.js";
15
15
  import {
16
16
  appendThinkingSuffix,
17
17
  buildFusionChainSpawnParams,
18
- buildJudgeSpawnParams,
19
18
  } from "./run-builder.js";
20
19
  import { FusionRunStore, FusionRunStoreError } from "./run-store.js";
21
20
  import {
@@ -35,8 +34,8 @@ import type {
35
34
  FusionProfile,
36
35
  FusionRun,
37
36
  PanelOutput,
37
+ ParsedFusionArgs,
38
38
  } from "./types.js";
39
- import { parseFusionArgs, type ParsedFusionArgs } from "./commands.js";
40
39
  import type { SubagentsTargetParams } from "./subagents-rpc.js";
41
40
 
42
41
  export const SUBAGENT_ASYNC_COMPLETE_EVENT = "subagent:async-complete";
@@ -124,15 +123,7 @@ export class FusionOrchestrator {
124
123
  ): Promise<FusionCommandResult> {
125
124
  this.context = ctx;
126
125
 
127
- let args: ParsedFusionArgs;
128
- try {
129
- args = typeof input === "string" ? parseFusionArgs(input) : input;
130
- } catch (error: unknown) {
131
- const message = errorMessage(error);
132
- this.notify(ctx, message, "error");
133
- return { status: "failed", error: message };
134
- }
135
-
126
+ const args = typeof input === "string" ? parseFusionArgs(input) : input;
136
127
  const existing = this.runStore.getActiveRun();
137
128
  if (existing) {
138
129
  const message = `Fusion run ${existing.id} is already active.`;
@@ -475,61 +466,13 @@ export class FusionOrchestrator {
475
466
  return this.completeActiveRun(report);
476
467
  }
477
468
 
478
- if (extracted.outputs.length === 0) {
479
- const report = renderPanelFailureReport({
480
- run: updated,
481
- failures: extracted.failures,
482
- ...withJudgeModel(configuredJudgeModel(profile)),
483
- });
484
- return this.failActiveRun(
485
- "No fusion panelists completed successfully.",
486
- report,
487
- );
488
- }
489
-
490
- if (extracted.outputs.length === 1) {
491
- const report = renderSinglePanelReport({
492
- run: updated,
493
- output: extracted.outputs[0]!,
494
- failures: extracted.failures,
495
- ...withJudgeModel(configuredJudgeModel(profile)),
496
- });
497
- return this.completeActiveRun(report);
498
- }
499
-
500
- try {
501
- const spawnResult = await this.rpc.spawn(
502
- buildJudgeSpawnParams({
503
- profile,
504
- prompt: active.prompt,
505
- panelOutputs: extracted.outputs,
506
- failedPanelists: extracted.failures,
507
- }),
508
- );
509
- const judgeRunId = extractSubagentRunId(spawnResult);
510
- if (!judgeRunId) {
511
- throw new FusionArgsError(
512
- "pi-subagents spawn did not return a fallback judge run ID.",
513
- );
514
- }
515
- const judgeAsyncDir = extractSubagentAsyncDir(spawnResult);
516
- const nextRun = this.runStore.updateRun(active.id, {
517
- phase: "judge",
518
- judgeRunId,
519
- ...(judgeAsyncDir ? { judgeAsyncDir } : {}),
520
- panelOutputs: extracted.outputs,
521
- panelFailures: extracted.failures,
522
- });
523
- publishFusionStatus(this.context, nextRun);
524
- this.notify(
525
- this.context,
526
- `Fusion fallback judge started: ${judgeRunId}`,
527
- "info",
528
- );
529
- return { status: "started", run: nextRun };
530
- } catch (error: unknown) {
531
- return this.failActiveRun(errorMessage(error));
532
- }
469
+ return this.finishPanelCompletion(
470
+ updated,
471
+ profile,
472
+ extracted.outputs,
473
+ extracted.failures,
474
+ { fallbackJudge: true },
475
+ );
533
476
  }
534
477
 
535
478
  private async handleLegacyPanelComplete(
@@ -576,53 +519,57 @@ export class FusionOrchestrator {
576
519
  extracted.failures,
577
520
  );
578
521
 
579
- if (extracted.outputs.length === 0) {
580
- const report = renderPanelFailureReport({
581
- run: updated,
582
- failures: extracted.failures,
583
- ...withJudgeModel(configuredJudgeModel(profile)),
584
- });
585
- return this.failActiveRun(
586
- "No fusion panelists completed successfully.",
587
- report,
588
- );
589
- }
522
+ return this.finishPanelCompletion(
523
+ updated,
524
+ profile,
525
+ extracted.outputs,
526
+ extracted.failures,
527
+ { fallbackJudge: false },
528
+ );
529
+ }
590
530
 
591
- if (extracted.outputs.length === 1) {
592
- const report = renderSinglePanelReport({
593
- run: updated,
594
- output: extracted.outputs[0]!,
595
- failures: extracted.failures,
596
- ...withJudgeModel(configuredJudgeModel(profile)),
597
- });
598
- return this.completeActiveRun(report);
531
+ private async finishPanelCompletion(
532
+ run: FusionRun,
533
+ profile: FusionProfile,
534
+ panelOutputs: readonly PanelOutput[],
535
+ panelFailures: readonly FailedPanelSummary[],
536
+ options: { fallbackJudge: boolean },
537
+ ): Promise<FusionCommandResult> {
538
+ const decision = decidePanelCompletion({
539
+ run,
540
+ profile,
541
+ panelOutputs,
542
+ panelFailures,
543
+ fallbackJudge: options.fallbackJudge,
544
+ });
545
+
546
+ if (decision.kind === "fail") {
547
+ return this.failActiveRun(decision.error, decision.report);
548
+ }
549
+ if (decision.kind === "complete") {
550
+ return this.completeActiveRun(decision.report);
599
551
  }
600
552
 
601
553
  try {
602
- const spawnResult = await this.rpc.spawn(
603
- buildJudgeSpawnParams({
604
- profile,
605
- prompt: active.prompt,
606
- panelOutputs: extracted.outputs,
607
- failedPanelists: extracted.failures,
608
- }),
609
- );
554
+ const spawnResult = await this.rpc.spawn(decision.params);
610
555
  const judgeRunId = extractSubagentRunId(spawnResult);
611
556
  if (!judgeRunId) {
612
- throw new FusionArgsError(
613
- "pi-subagents spawn did not return a judge run ID.",
614
- );
557
+ throw new FusionArgsError(decision.missingRunIdError);
615
558
  }
616
559
  const judgeAsyncDir = extractSubagentAsyncDir(spawnResult);
617
- const nextRun = this.runStore.updateRun(active.id, {
560
+ const nextRun = this.runStore.updateRun(run.id, {
618
561
  phase: "judge",
619
562
  judgeRunId,
620
563
  ...(judgeAsyncDir ? { judgeAsyncDir } : {}),
621
- panelOutputs: extracted.outputs,
622
- panelFailures: extracted.failures,
564
+ panelOutputs: [...panelOutputs],
565
+ panelFailures: [...panelFailures],
623
566
  });
624
567
  publishFusionStatus(this.context, nextRun);
625
- this.notify(this.context, `Fusion judge started: ${judgeRunId}`, "info");
568
+ this.notify(
569
+ this.context,
570
+ `${decision.notification}: ${judgeRunId}`,
571
+ "info",
572
+ );
626
573
  return { status: "started", run: nextRun };
627
574
  } catch (error: unknown) {
628
575
  return this.failActiveRun(errorMessage(error));
@@ -0,0 +1,85 @@
1
+ import { renderPanelFailureReport, renderSinglePanelReport } from "./report.js";
2
+ import {
3
+ appendThinkingSuffix,
4
+ buildJudgeSpawnParams,
5
+ type JudgeSpawnParams,
6
+ } from "./run-builder.js";
7
+ import type {
8
+ FailedPanelSummary,
9
+ FusionProfile,
10
+ FusionRun,
11
+ PanelOutput,
12
+ } from "./types.js";
13
+
14
+ export type PanelCompletionDecision =
15
+ | { kind: "fail"; error: string; report: string }
16
+ | { kind: "complete"; report: string }
17
+ | {
18
+ kind: "judge";
19
+ params: JudgeSpawnParams;
20
+ missingRunIdError: string;
21
+ notification: string;
22
+ };
23
+
24
+ export interface DecidePanelCompletionInput {
25
+ run: FusionRun;
26
+ profile: FusionProfile;
27
+ panelOutputs: readonly PanelOutput[];
28
+ panelFailures: readonly FailedPanelSummary[];
29
+ fallbackJudge?: boolean;
30
+ }
31
+
32
+ export function decidePanelCompletion(
33
+ input: DecidePanelCompletionInput,
34
+ ): PanelCompletionDecision {
35
+ const judgeModel = configuredJudgeModel(input.profile);
36
+
37
+ if (input.panelOutputs.length === 0) {
38
+ const report = renderPanelFailureReport({
39
+ run: input.run,
40
+ failures: input.panelFailures,
41
+ ...withJudgeModel(judgeModel),
42
+ });
43
+ return {
44
+ kind: "fail",
45
+ error: "No fusion panelists completed successfully.",
46
+ report,
47
+ };
48
+ }
49
+
50
+ if (input.panelOutputs.length === 1) {
51
+ const report = renderSinglePanelReport({
52
+ run: input.run,
53
+ output: input.panelOutputs[0]!,
54
+ failures: input.panelFailures,
55
+ ...withJudgeModel(judgeModel),
56
+ });
57
+ return { kind: "complete", report };
58
+ }
59
+
60
+ return {
61
+ kind: "judge",
62
+ params: buildJudgeSpawnParams({
63
+ profile: input.profile,
64
+ prompt: input.run.prompt,
65
+ panelOutputs: input.panelOutputs,
66
+ failedPanelists: input.panelFailures,
67
+ }),
68
+ missingRunIdError: input.fallbackJudge
69
+ ? "pi-subagents spawn did not return a fallback judge run ID."
70
+ : "pi-subagents spawn did not return a judge run ID.",
71
+ notification: input.fallbackJudge
72
+ ? "Fusion fallback judge started"
73
+ : "Fusion judge started",
74
+ };
75
+ }
76
+
77
+ function configuredJudgeModel(profile: FusionProfile): string | undefined {
78
+ return appendThinkingSuffix(profile.judge.model, profile.judge.thinking);
79
+ }
80
+
81
+ function withJudgeModel(
82
+ judgeModel: string | undefined,
83
+ ): { judgeModel: string } | Record<string, never> {
84
+ return judgeModel ? { judgeModel } : {};
85
+ }
package/src/run-store.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import type { FusionPhase, FusionRun } from "./types.js";
3
+ import { isFiniteNumber, isNonEmptyString, isRecord } from "./utils.js";
3
4
 
4
5
  export const FUSION_RUN_ENTRY_TYPE = "fusion-run";
5
6
 
@@ -423,18 +424,6 @@ function isTerminalPhase(value: unknown): value is FusionTerminalPhase {
423
424
  return value === "done" || value === "failed" || value === "cancelled";
424
425
  }
425
426
 
426
- function isRecord(value: unknown): value is Record<string, unknown> {
427
- return typeof value === "object" && value !== null && !Array.isArray(value);
428
- }
429
-
430
- function isNonEmptyString(value: unknown): value is string {
431
- return typeof value === "string" && value.trim().length > 0;
432
- }
433
-
434
- function isFiniteNumber(value: unknown): value is number {
435
- return typeof value === "number" && Number.isFinite(value);
436
- }
437
-
438
427
  function isPanelOutputArray(
439
428
  value: unknown,
440
429
  ): value is NonNullable<FusionRun["panelOutputs"]> {
package/src/types.ts CHANGED
@@ -38,6 +38,11 @@ export interface FusionConfig {
38
38
  profiles: Record<string, FusionProfile>;
39
39
  }
40
40
 
41
+ export interface ParsedFusionArgs {
42
+ prompt: string;
43
+ profile?: string;
44
+ }
45
+
41
46
  export interface PanelOutput {
42
47
  index: number;
43
48
  agent: string;
package/src/utils.ts CHANGED
@@ -0,0 +1,19 @@
1
+ export function isRecord(value: unknown): value is Record<string, unknown> {
2
+ return typeof value === "object" && value !== null && !Array.isArray(value);
3
+ }
4
+
5
+ export function isNonEmptyString(value: unknown): value is string {
6
+ return typeof value === "string" && value.trim().length > 0;
7
+ }
8
+
9
+ export function isPositiveInteger(value: unknown): value is number {
10
+ return typeof value === "number" && Number.isInteger(value) && value > 0;
11
+ }
12
+
13
+ export function isFiniteNumber(value: unknown): value is number {
14
+ return typeof value === "number" && Number.isFinite(value);
15
+ }
16
+
17
+ export function isNodeErrorCode(error: unknown, code: string): boolean {
18
+ return isRecord(error) && error.code === code;
19
+ }
package/AGENTS.md DELETED
@@ -1,7 +0,0 @@
1
- # Contributor notes
2
-
3
- - Keep the extension small, typed, and testable.
4
- - Do not own or replace the Pi footer. Publish only the `fusion` status key.
5
- - Use `pi-subagents` RPC for panel and judge execution. Do not import its internals.
6
- - Keep bundled panel agents read-only by default.
7
- - Do not add runtime dependencies unless the task needs them.
package/tsconfig.json DELETED
@@ -1,19 +0,0 @@
1
- {
2
- "compilerOptions": {
3
- "target": "ES2023",
4
- "module": "NodeNext",
5
- "moduleResolution": "NodeNext",
6
- "strict": true,
7
- "noUncheckedIndexedAccess": true,
8
- "exactOptionalPropertyTypes": true,
9
- "useUnknownInCatchVariables": true,
10
- "noImplicitOverride": true,
11
- "noImplicitReturns": true,
12
- "noFallthroughCasesInSwitch": true,
13
- "skipLibCheck": true
14
- },
15
- "include": [
16
- "src/**/*.ts",
17
- "test/**/*.ts"
18
- ]
19
- }