@alexeiled/pi-fusion 0.2.2 → 0.3.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.
@@ -110,7 +110,8 @@ Panel member:
110
110
  - `id`: stable machine name
111
111
  - `label`: human-readable report label
112
112
  - `agent`: subagent name
113
- - `model`: optional model override; often the main source of panel diversity
113
+ - `model`: optional model override; often the main source of panel diversity. Supports normal Pi model ids, and if `pi-claude-alias` is configured, Claude alias shorthand like `claude-work/opus-4.8`
114
+ - Claude alias handles must be unique across global and project alias files; duplicate handles are rejected.
114
115
  - `thinking`: optional `off`, `minimal`, `low`, `medium`, `high`, or `xhigh`
115
116
  - `role`: optional perspective hint layered on top of the model
116
117
 
@@ -162,7 +163,7 @@ Deliberate review:
162
163
  "id": "architect",
163
164
  "label": "Architect",
164
165
  "agent": "pi-fusion.fusion-panelist",
165
- "model": "anthropic/claude-sonnet-4",
166
+ "model": "claude-work/sonnet-4.6",
166
167
  "thinking": "high",
167
168
  "role": "architecture and failure modes"
168
169
  },
@@ -177,7 +178,7 @@ Deliberate review:
177
178
  ],
178
179
  "judge": {
179
180
  "agent": "pi-fusion.fusion-judge",
180
- "model": "anthropic/claude-sonnet-4",
181
+ "model": "claude-work/sonnet-4.6",
181
182
  "thinking": "high"
182
183
  },
183
184
  "concurrency": 2,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alexeiled/pi-fusion",
3
- "version": "0.2.2",
3
+ "version": "0.3.0",
4
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",
@@ -21,6 +21,7 @@
21
21
  ],
22
22
  "files": [
23
23
  "agents/*.md",
24
+ "skills/**/*.md",
24
25
  "src/*.ts",
25
26
  "docs/user-guide.md"
26
27
  ],
@@ -39,6 +40,9 @@
39
40
  "extensions": [
40
41
  "./src/index.ts"
41
42
  ],
43
+ "skills": [
44
+ "./skills"
45
+ ],
42
46
  "subagents": {
43
47
  "agents": [
44
48
  "./agents"
@@ -0,0 +1,30 @@
1
+ ---
2
+ name: fusion-review
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.
8
+ ---
9
+
10
+ # Fusion Review
11
+
12
+ Use the `start_fusion_review` tool to launch a fusion panel without the user needing to type `/fusion`.
13
+
14
+ ## When to use
15
+
16
+ - "invoke fusion panel to discuss this"
17
+ - "run fusion on this"
18
+ - "get a panel review of …"
19
+ - "use multi-model review for …"
20
+ - "discuss this through fusion"
21
+ - Any prompt that implies parallel model review or multi-perspective analysis
22
+
23
+ ## How to use
24
+
25
+ Call `start_fusion_review` with:
26
+
27
+ - `prompt` — the topic, question, or code excerpt to review (required)
28
+ - `profile` — a named fusion profile (optional; omit to use the default)
29
+
30
+ The tool queues a `/fusion` command as a follow-up, so the panel starts immediately after the current turn.
@@ -0,0 +1,255 @@
1
+ import { CONFIG_DIR_NAME, getAgentDir } from "@earendil-works/pi-coding-agent";
2
+ import { readFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import type { FusionConfig } from "./types.js";
5
+ import { FusionConfigError } from "./errors.js";
6
+ import { isNodeErrorCode, isNonEmptyString, isRecord } from "./utils.js";
7
+ import type { FusionConfigLoadContext } from "./config.js";
8
+
9
+ const CLAUDE_ALIAS_CONFIG_FILE = "claude-alias.json";
10
+
11
+ interface ClaudeAliasDefinition {
12
+ slug: string;
13
+ providerId: string;
14
+ handle: string;
15
+ }
16
+
17
+ interface AliasFileDeps {
18
+ readTextFile?: (path: string) => Promise<string>;
19
+ agentDir?: string;
20
+ }
21
+
22
+ interface RawAliasConfig {
23
+ aliases?: unknown;
24
+ }
25
+
26
+ interface RawAliasEntry {
27
+ slug?: unknown;
28
+ handle?: unknown;
29
+ }
30
+
31
+ export async function applyClaudeAliasShorthand(
32
+ config: FusionConfig,
33
+ ctx: FusionConfigLoadContext,
34
+ deps: AliasFileDeps = {},
35
+ ): Promise<FusionConfig> {
36
+ const aliases = await loadClaudeAliases(ctx, deps);
37
+ if (aliases.length === 0) return config;
38
+
39
+ return {
40
+ ...config,
41
+ profiles: Object.fromEntries(
42
+ Object.entries(config.profiles).map(([name, profile]) => [
43
+ name,
44
+ {
45
+ ...profile,
46
+ panel: profile.panel.map((member) => ({
47
+ ...member,
48
+ ...(member.model
49
+ ? { model: resolveClaudeAliasModelSpec(member.model, aliases) }
50
+ : {}),
51
+ })),
52
+ judge: {
53
+ ...profile.judge,
54
+ ...(profile.judge.model
55
+ ? { model: resolveClaudeAliasModelSpec(profile.judge.model, aliases) }
56
+ : {}),
57
+ },
58
+ },
59
+ ]),
60
+ ),
61
+ };
62
+ }
63
+
64
+ export function resolveClaudeAliasModelSpec(
65
+ model: string,
66
+ aliases: readonly ClaudeAliasDefinition[],
67
+ ): string {
68
+ const trimmed = model.trim();
69
+ const slashIndex = trimmed.indexOf("/");
70
+ if (slashIndex <= 0 || slashIndex === trimmed.length - 1) return trimmed;
71
+
72
+ const handle = trimmed.slice(0, slashIndex).trim().toLowerCase();
73
+ const modelRef = trimmed.slice(slashIndex + 1).trim();
74
+ const alias = aliases.find((item) => item.handle === handle);
75
+ if (!alias) return trimmed;
76
+
77
+ return `${alias.providerId}/${normalizeAnthropicModelRef(modelRef)}`;
78
+ }
79
+
80
+ export function normalizeAnthropicModelRef(modelRef: string): string {
81
+ const normalized = modelRef
82
+ .trim()
83
+ .toLowerCase()
84
+ .replace(/[._\s]+/g, "-")
85
+ .replace(/-+/g, "-")
86
+ .replace(/^-+|-+$/g, "");
87
+
88
+ if (!normalized) return modelRef.trim();
89
+ return normalized.startsWith("claude-") ? normalized : `claude-${normalized}`;
90
+ }
91
+
92
+ async function loadClaudeAliases(
93
+ ctx: FusionConfigLoadContext,
94
+ deps: AliasFileDeps,
95
+ ): Promise<ClaudeAliasDefinition[]> {
96
+ const readTextFile = deps.readTextFile ?? readUtf8File;
97
+ const global = await readOptionalAliasFile(
98
+ getGlobalClaudeAliasConfigPath(deps.agentDir),
99
+ readTextFile,
100
+ );
101
+ const project = ctx.isProjectTrusted()
102
+ ? await readOptionalAliasFile(
103
+ getProjectClaudeAliasConfigPath(ctx.cwd),
104
+ readTextFile,
105
+ )
106
+ : undefined;
107
+
108
+ const merged = new Map<string, ClaudeAliasDefinition>();
109
+ for (const alias of global ?? []) {
110
+ merged.set(alias.slug, alias);
111
+ }
112
+ for (const alias of project ?? []) {
113
+ merged.set(alias.slug, alias);
114
+ }
115
+
116
+ const aliases = [...merged.values()];
117
+ validateUniqueHandles(aliases);
118
+ return aliases;
119
+ }
120
+
121
+ async function readOptionalAliasFile(
122
+ path: string,
123
+ readTextFile: (path: string) => Promise<string>,
124
+ ): Promise<ClaudeAliasDefinition[] | undefined> {
125
+ let raw: string;
126
+ try {
127
+ raw = await readTextFile(path);
128
+ } catch (error: unknown) {
129
+ if (isNodeErrorCode(error, "ENOENT")) return undefined;
130
+ const message = error instanceof Error ? error.message : String(error);
131
+ throw new FusionConfigError(
132
+ `Could not read Claude alias config at ${path}: ${message}`,
133
+ );
134
+ }
135
+
136
+ return parseAliasFile(raw, path);
137
+ }
138
+
139
+ function parseAliasFile(raw: string, source: string): ClaudeAliasDefinition[] {
140
+ let value: unknown;
141
+ try {
142
+ value = JSON.parse(raw);
143
+ } catch (error: unknown) {
144
+ const message = error instanceof Error ? error.message : String(error);
145
+ throw new FusionConfigError(
146
+ `Invalid JSON in Claude alias config at ${source}: ${message}`,
147
+ );
148
+ }
149
+
150
+ if (!isRecord(value) || !Array.isArray((value as RawAliasConfig).aliases)) {
151
+ throw new FusionConfigError(
152
+ `Invalid Claude alias config at ${source}. Expected aliases array.`,
153
+ );
154
+ }
155
+
156
+ const aliasValues = (value as { aliases: unknown[] }).aliases;
157
+ const aliases: ClaudeAliasDefinition[] = [];
158
+ for (const [index, entry] of aliasValues.entries()) {
159
+ const parsed = parseAliasEntry(entry);
160
+ if (!parsed) {
161
+ throw new FusionConfigError(
162
+ `Invalid Claude alias entry at ${source} aliases[${index}].`,
163
+ );
164
+ }
165
+ aliases.push(parsed);
166
+ }
167
+
168
+ return dedupeAliases(aliases, source);
169
+ }
170
+
171
+ function parseAliasEntry(value: unknown): ClaudeAliasDefinition | undefined {
172
+ if (!isRecord(value)) return undefined;
173
+ const entry = value as RawAliasEntry;
174
+
175
+ const slug = normalizeSlug(entry.slug);
176
+ if (!slug) return undefined;
177
+
178
+ const handle = normalizeHandle(entry.handle) ?? `claude-${slug}`;
179
+ if (!handle) return undefined;
180
+
181
+ return {
182
+ slug,
183
+ providerId: `anthropic-${slug}`,
184
+ handle,
185
+ };
186
+ }
187
+
188
+ function dedupeAliases(
189
+ aliases: readonly ClaudeAliasDefinition[],
190
+ source: string,
191
+ ): ClaudeAliasDefinition[] {
192
+ const byHandle = new Set<string>();
193
+ const deduped: ClaudeAliasDefinition[] = [];
194
+
195
+ for (const alias of aliases) {
196
+ if (byHandle.has(alias.handle)) {
197
+ throw new FusionConfigError(
198
+ `Duplicate Claude alias handle "${alias.handle}" in ${source}.`,
199
+ );
200
+ }
201
+ byHandle.add(alias.handle);
202
+ deduped.push(alias);
203
+ }
204
+
205
+ return deduped;
206
+ }
207
+
208
+ function validateUniqueHandles(
209
+ aliases: readonly ClaudeAliasDefinition[],
210
+ ): void {
211
+ const seen = new Map<string, string>();
212
+
213
+ for (const alias of aliases) {
214
+ const existingSlug = seen.get(alias.handle);
215
+ if (existingSlug && existingSlug !== alias.slug) {
216
+ throw new FusionConfigError(
217
+ `Duplicate Claude alias handle "${alias.handle}" across merged config. Use a unique handle for each alias.`,
218
+ );
219
+ }
220
+ seen.set(alias.handle, alias.slug);
221
+ }
222
+ }
223
+
224
+ function normalizeSlug(value: unknown): string | undefined {
225
+ if (!isNonEmptyString(value)) return undefined;
226
+ const slug = value
227
+ .trim()
228
+ .toLowerCase()
229
+ .replace(/[^a-z0-9]+/g, "-")
230
+ .replace(/^-+|-+$/g, "");
231
+ return slug || undefined;
232
+ }
233
+
234
+ function normalizeHandle(value: unknown): string | undefined {
235
+ if (value === undefined) return undefined;
236
+ if (!isNonEmptyString(value)) return undefined;
237
+ const handle = value
238
+ .trim()
239
+ .toLowerCase()
240
+ .replace(/[^a-z0-9]+/g, "-")
241
+ .replace(/^-+|-+$/g, "");
242
+ return handle || undefined;
243
+ }
244
+
245
+ function getGlobalClaudeAliasConfigPath(agentDir = getAgentDir()): string {
246
+ return join(agentDir, CLAUDE_ALIAS_CONFIG_FILE);
247
+ }
248
+
249
+ function getProjectClaudeAliasConfigPath(cwd: string): string {
250
+ return join(cwd, CONFIG_DIR_NAME, CLAUDE_ALIAS_CONFIG_FILE);
251
+ }
252
+
253
+ async function readUtf8File(path: string): Promise<string> {
254
+ return readFile(path, "utf8");
255
+ }
package/src/config.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { CONFIG_DIR_NAME, getAgentDir } from "@earendil-works/pi-coding-agent";
2
2
  import { mkdir, readFile, writeFile } from "node:fs/promises";
3
3
  import { dirname, join } from "node:path";
4
+ import { applyClaudeAliasShorthand } from "./claude-aliases.js";
4
5
  import { FusionConfigError } from "./errors.js";
5
6
  import {
6
7
  THINKING_LEVELS,
@@ -104,12 +105,15 @@ export async function loadFusionConfig(
104
105
  if (ctx.isProjectTrusted()) {
105
106
  const projectPath = getProjectFusionConfigPath(ctx.cwd);
106
107
  const projectConfig = await readOptionalConfig(projectPath, readTextFile);
107
- if (projectConfig) return projectConfig;
108
+ if (projectConfig) {
109
+ return applyClaudeAliasShorthand(projectConfig, ctx, deps);
110
+ }
108
111
  }
109
112
 
110
113
  const globalPath = getGlobalFusionConfigPath(deps.agentDir);
111
114
  const globalConfig = await readOptionalConfig(globalPath, readTextFile);
112
- return globalConfig ?? createDefaultFusionConfig();
115
+ const config = globalConfig ?? createDefaultFusionConfig();
116
+ return applyClaudeAliasShorthand(config, ctx, deps);
113
117
  }
114
118
 
115
119
  export function resolveProfile(
package/src/index.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { Type } from "typebox";
2
3
  import { registerFusionCommands } from "./commands.js";
3
4
  import {
4
5
  FusionOrchestrator,
@@ -7,6 +8,35 @@ import {
7
8
  import { FusionRunStore } from "./run-store.js";
8
9
  import { SubagentsRpcClient } from "./subagents-rpc.js";
9
10
 
11
+ function registerFusionTool(pi: ExtensionAPI): void {
12
+ pi.registerTool({
13
+ name: "start_fusion_review",
14
+ label: "Fusion Review",
15
+ description:
16
+ "Start a pi-fusion multi-model panel review. Use when the user asks to invoke fusion, run a panel review, get multi-model opinions, or discuss something through the fusion panel.",
17
+ promptSnippet: "Start a fusion panel review for a topic or code",
18
+ promptGuidelines: [
19
+ "Use start_fusion_review when the user says 'invoke fusion', 'run fusion', 'fusion panel', 'multi-model review', 'panel review', or similar.",
20
+ ],
21
+ parameters: Type.Object({
22
+ prompt: Type.String({ description: "What to review or discuss" }),
23
+ profile: Type.Optional(
24
+ Type.String({ description: "Fusion profile name (optional)" }),
25
+ ),
26
+ }),
27
+ execute(_toolCallId, params) {
28
+ const cmd = params.profile
29
+ ? `/fusion --profile ${params.profile} ${params.prompt}`
30
+ : `/fusion ${params.prompt}`;
31
+ pi.sendUserMessage(cmd, { deliverAs: "followUp" });
32
+ return Promise.resolve({
33
+ content: [{ type: "text", text: "Starting fusion panel review…" }],
34
+ details: { prompt: params.prompt, profile: params.profile },
35
+ });
36
+ },
37
+ });
38
+ }
39
+
10
40
  export default function fusionExtension(pi: ExtensionAPI): void {
11
41
  const orchestrator = new FusionOrchestrator({
12
42
  rpc: new SubagentsRpcClient({ events: pi.events }),
@@ -15,6 +45,7 @@ export default function fusionExtension(pi: ExtensionAPI): void {
15
45
  });
16
46
 
17
47
  registerFusionCommands(pi, orchestrator);
48
+ registerFusionTool(pi);
18
49
 
19
50
  const unsubscribeComplete = pi.events.on(
20
51
  SUBAGENT_ASYNC_COMPLETE_EVENT,