@fusionkit/cli 0.1.6 → 0.1.8

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.
Files changed (47) hide show
  1. package/README.md +8 -6
  2. package/dist/commands/doctor.js +7 -3
  3. package/dist/commands/fusion.js +16 -8
  4. package/dist/fusion/env.d.ts +4 -1
  5. package/dist/fusion/env.js +1 -1
  6. package/dist/fusion/stack.d.ts +4 -0
  7. package/dist/fusion/stack.js +21 -1
  8. package/dist/fusion-config.d.ts +58 -6
  9. package/dist/fusion-config.js +152 -22
  10. package/dist/fusion-init.d.ts +1 -0
  11. package/dist/fusion-init.js +48 -4
  12. package/dist/fusion-quickstart.js +1 -0
  13. package/dist/test/cli.test.js +3 -3
  14. package/dist/test/fusion-config.test.js +64 -4
  15. package/package.json +14 -14
  16. package/scope/.next/BUILD_ID +1 -1
  17. package/scope/.next/app-build-manifest.json +14 -14
  18. package/scope/.next/app-path-routes-manifest.json +4 -4
  19. package/scope/.next/build-manifest.json +2 -2
  20. package/scope/.next/prerender-manifest.json +3 -3
  21. package/scope/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  22. package/scope/.next/server/app/_not-found.html +1 -1
  23. package/scope/.next/server/app/_not-found.rsc +1 -1
  24. package/scope/.next/server/app/api/environments/route_client-reference-manifest.js +1 -1
  25. package/scope/.next/server/app/api/ingest/route_client-reference-manifest.js +1 -1
  26. package/scope/.next/server/app/api/models/route_client-reference-manifest.js +1 -1
  27. package/scope/.next/server/app/api/replay/route_client-reference-manifest.js +1 -1
  28. package/scope/.next/server/app/api/sessions/[traceId]/route_client-reference-manifest.js +1 -1
  29. package/scope/.next/server/app/api/sessions/route_client-reference-manifest.js +1 -1
  30. package/scope/.next/server/app/api/stream/route_client-reference-manifest.js +1 -1
  31. package/scope/.next/server/app/environments/page_client-reference-manifest.js +1 -1
  32. package/scope/.next/server/app/environments.html +1 -1
  33. package/scope/.next/server/app/environments.rsc +1 -1
  34. package/scope/.next/server/app/index.html +1 -1
  35. package/scope/.next/server/app/index.rsc +1 -1
  36. package/scope/.next/server/app/models/page_client-reference-manifest.js +1 -1
  37. package/scope/.next/server/app/models.html +1 -1
  38. package/scope/.next/server/app/models.rsc +1 -1
  39. package/scope/.next/server/app/page_client-reference-manifest.js +1 -1
  40. package/scope/.next/server/app/sessions/[traceId]/page_client-reference-manifest.js +1 -1
  41. package/scope/.next/server/app-paths-manifest.json +4 -4
  42. package/scope/.next/server/functions-config-manifest.json +1 -1
  43. package/scope/.next/server/pages/404.html +1 -1
  44. package/scope/.next/server/pages/500.html +1 -1
  45. package/scope/.next/server/server-reference-manifest.json +1 -1
  46. /package/scope/.next/static/{x7wPUCpgS31-5ZHJkcKsU → DyybY5vozMobja2-GQ4Tu}/_buildManifest.js +0 -0
  47. /package/scope/.next/static/{x7wPUCpgS31-5ZHJkcKsU → DyybY5vozMobja2-GQ4Tu}/_ssgManifest.js +0 -0
package/README.md CHANGED
@@ -44,23 +44,25 @@ cloud panel (skip with `--yes`). Use `--local` for the on-device MLX panel, or
44
44
 
45
45
  ## Per-repo config
46
46
 
47
- Tired of long flag lines? Scaffold a committed `fusionkit.json`:
47
+ Tired of long flag lines? Scaffold a committed `.fusionkit/` folder:
48
48
 
49
49
  ```bash
50
50
  fusionkit init
51
51
  ```
52
52
 
53
- It records the panel, judge, default tool, and run defaults so the whole team
54
- can just run `fusionkit codex`. Only env-var *names* for keys are stored, never
55
- secrets. Explicit CLI flags always override the file. Inspect the effective
56
- config and a dry-run preview with `fusionkit status`.
53
+ It writes `.fusionkit/fusion.json` (the panel, judge, default tool, and run
54
+ defaults) plus editable system-prompt overrides in `.fusionkit/prompts/*.md`, so
55
+ the whole team can just run `fusionkit codex`. Only env-var *names* for keys are
56
+ stored, never secrets. Explicit CLI flags always override the folder. A legacy
57
+ `fusionkit.json` is auto-migrated on first run. Inspect the effective config and
58
+ a dry-run preview with `fusionkit status`.
57
59
 
58
60
  ## Commands
59
61
 
60
62
  - `fusionkit codex | claude | cursor` — launch that agent backed by the panel.
61
63
  - `fusionkit serve` — just run the gateway and print setup snippets for any tool.
62
64
  - `fusionkit fusion [tool]` — the generic launcher (interactive picker on a TTY).
63
- - `fusionkit init` — scaffold `fusionkit.json` for this repo.
65
+ - `fusionkit init` — scaffold the committed `.fusionkit/` folder for this repo.
64
66
  - `fusionkit doctor` — check prerequisites with fix hints.
65
67
  - `fusionkit status` — show the effective config and what a run will do.
66
68
 
@@ -56,13 +56,15 @@ function runDoctor() {
56
56
  console.log("");
57
57
  console.log(bold("repo config"));
58
58
  try {
59
- const config = loadFusionConfig(repoRoot);
59
+ const config = loadFusionConfig(repoRoot, (message) => console.log(` ${gray(glyph.bullet())} ${dim(message)}`));
60
60
  if (config === undefined) {
61
- console.log(` ${gray(glyph.bullet())} no ${cyan("fusionkit.json")} yet — run ${bold("fusionkit fusion init")}`);
61
+ console.log(` ${gray(glyph.bullet())} no ${cyan(".fusionkit/")} yet — run ${bold("fusionkit fusion init")}`);
62
62
  }
63
63
  else {
64
+ const overrides = Object.keys(config.prompts ?? {});
64
65
  console.log(` ${green(glyph.tick())} ${cyan(fusionConfigPath(repoRoot))}`);
65
66
  console.log(` ${dim(`tool: ${config.tool ?? "(unset)"} panel: ${(config.panel ?? []).map((s) => s.id).join(", ") || "(unset)"}`)}`);
67
+ console.log(` ${dim(`prompt overrides: ${overrides.length > 0 ? overrides.join(", ") : "(none — built-in defaults)"}`)}`);
66
68
  }
67
69
  }
68
70
  catch (error) {
@@ -94,7 +96,7 @@ function runStatus() {
94
96
  }
95
97
  let config;
96
98
  try {
97
- config = loadFusionConfig(repoRoot);
99
+ config = loadFusionConfig(repoRoot, (message) => console.log(dim(message)));
98
100
  }
99
101
  catch (error) {
100
102
  console.log(`${red("config error:")} ${error instanceof Error ? error.message : String(error)}`);
@@ -110,6 +112,8 @@ function runStatus() {
110
112
  console.log(`${dim("tool:")} ${bold(tool)}`);
111
113
  console.log(`${dim("judge:")} ${judge}`);
112
114
  console.log(`${dim("observe:")} ${config?.observe === true ? "on" : "off"}`);
115
+ const overrides = Object.keys(config?.prompts ?? {});
116
+ console.log(`${dim("prompts:")} ${overrides.length > 0 ? overrides.join(", ") : dim("(built-in defaults)")}`);
113
117
  console.log(bold("\npanel"));
114
118
  for (const spec of panel)
115
119
  console.log(` ${glyph.bullet()} ${panelLabel(spec)}`);
@@ -17,9 +17,9 @@ function applyFusionOptions(command) {
17
17
  .option("--fusionkit-dir <dir>", "local FusionKit checkout (dev override for the uvx synthesizer)")
18
18
  .option("--repo <dir>", "coding workspace the panel fuses over")
19
19
  .option("--local", "use the local MLX panel trio instead of the default cloud panel")
20
- .option("--no-local", "override a fusionkit.json default of local=true")
20
+ .option("--no-local", "override a .fusionkit default of local=true")
21
21
  .option("--observe", "boot the local scope dashboard and stream live trace events")
22
- .option("--no-observe", "override a fusionkit.json default of observe=true")
22
+ .option("--no-observe", "override a .fusionkit default of observe=true")
23
23
  .option("--yes", "skip the interactive cloud-panel cost confirmation")
24
24
  .option("--auth-token <token>", "require a bearer token on the gateway")
25
25
  .option("--port <n>", "gateway port (default: ephemeral)")
@@ -98,6 +98,8 @@ function mergeConfig(options, config) {
98
98
  options.portless = config.portless;
99
99
  if (options.port === undefined && config.port != null)
100
100
  options.port = config.port;
101
+ if (options.prompts === undefined && config.prompts !== undefined)
102
+ options.prompts = config.prompts;
101
103
  }
102
104
  /** The repo root used for config lookup: --repo if given, else the cwd's git root. */
103
105
  function configRepoRoot(options) {
@@ -124,15 +126,21 @@ function resolveContext(opts) {
124
126
  return { options, ...(config?.tool !== undefined ? { configTool: config.tool } : {}) };
125
127
  }
126
128
  export function registerFusion(program) {
127
- // Top-level `init` — scaffold a committed fusionkit.json for this repo.
129
+ // Top-level `init` — scaffold a committed .fusionkit/ folder for this repo.
128
130
  program
129
131
  .command("init")
130
- .description("scaffold a committed fusionkit.json for this repo")
132
+ .description("scaffold a committed .fusionkit/ folder for this repo")
131
133
  .option("--repo <dir>", "coding workspace the panel fuses over")
132
- .option("--force", "overwrite an existing fusionkit.json")
134
+ .option("--fusionkit-dir <dir>", "local FusionKit checkout (dev override for default prompts)")
135
+ .option("--force", "overwrite an existing .fusionkit/ config and prompts")
133
136
  .action(async (opts) => {
134
- const repoRoot = configRepoRoot(resolveOptions(opts));
135
- const code = await runFusionInit({ repoRoot, force: opts.force === true });
137
+ const options = resolveOptions(opts);
138
+ const repoRoot = configRepoRoot(options);
139
+ const code = await runFusionInit({
140
+ repoRoot,
141
+ force: opts.force === true,
142
+ ...(options.fusionkitDir !== undefined ? { fusionkitDir: options.fusionkitDir } : {})
143
+ });
136
144
  process.exit(code);
137
145
  });
138
146
  // Generic `fusion [tool]` — keeps the original surface and interactive pick.
@@ -143,7 +151,7 @@ export function registerFusion(program) {
143
151
  .argument("[args...]", "arguments forwarded to the tool")
144
152
  .option("--tool <tool>", `coding agent to launch (${FUSION_TOOLS.join(" | ")})`))
145
153
  .addHelpText("after", "\nfusionkit's own flags must precede the tool name; everything after the tool is forwarded to it." +
146
- "\nRun `fusionkit init` to scaffold a committed fusionkit.json for this repo." +
154
+ "\nRun `fusionkit init` to scaffold a committed .fusionkit/ folder for this repo." +
147
155
  "\nRun `fusionkit fusion stop` to reap portless singleton services (router, dashboard, ...).")
148
156
  .action(async (positionalTool, args, opts) => {
149
157
  // `fusion stop` reaps persistent portless singletons left running by prior
@@ -1,3 +1,4 @@
1
+ import type { PromptOverrides } from "../fusion-config.js";
1
2
  /** A launchable tool id from the registry, or the `serve` pseudo-tool. */
2
3
  export type FusionTool = string;
3
4
  export type PanelProvider = "mlx" | "openai" | "anthropic" | "google" | "openai-compatible";
@@ -32,6 +33,8 @@ export type RunFusionOptions = {
32
33
  yes?: boolean;
33
34
  /** Route services through portless (stable named URLs + singletons). Default on. */
34
35
  portless?: boolean;
36
+ /** System-prompt overrides forwarded to the synthesizer's router config. */
37
+ prompts?: PromptOverrides;
35
38
  log?: (line: string) => void;
36
39
  };
37
40
  /**
@@ -77,7 +80,7 @@ export type StackReporter = (event: StackEvent) => void;
77
80
  * synthesizer (`fusionkit serve`) and the single-model OpenAI shim
78
81
  * (`fusionkit serve-endpoint`). Pinned so `uvx` resolves a reproducible build.
79
82
  */
80
- export declare const FUSIONKIT_PYPI_VERSION = "0.2.0";
83
+ export declare const FUSIONKIT_PYPI_VERSION = "0.3.1";
81
84
  /**
82
85
  * Default cloud panel — works cross-platform with only `OPENAI_API_KEY` and
83
86
  * `ANTHROPIC_API_KEY` set. The judge defaults to the first entry.
@@ -10,7 +10,7 @@ import { existsSync, readFileSync } from "node:fs";
10
10
  * synthesizer (`fusionkit serve`) and the single-model OpenAI shim
11
11
  * (`fusionkit serve-endpoint`). Pinned so `uvx` resolves a reproducible build.
12
12
  */
13
- export const FUSIONKIT_PYPI_VERSION = "0.2.0";
13
+ export const FUSIONKIT_PYPI_VERSION = "0.3.1";
14
14
  /**
15
15
  * Default cloud panel — works cross-platform with only `OPENAI_API_KEY` and
16
16
  * `ANTHROPIC_API_KEY` set. The judge defaults to the first entry.
@@ -1,5 +1,6 @@
1
1
  import type { EnsembleModel } from "@fusionkit/ensemble";
2
2
  import type { PortlessSession } from "../shared/portless.js";
3
+ import type { PromptOverrides } from "../fusion-config.js";
3
4
  import type { PanelModelSpec, StackReporter } from "./env.js";
4
5
  /**
5
6
  * The single `fusionkit serve` router: one process that fronts every panel
@@ -31,6 +32,7 @@ export declare function startRouter(options: {
31
32
  specs: PanelModelSpec[];
32
33
  judgeModel?: string;
33
34
  fusionkitDir?: string;
35
+ prompts?: PromptOverrides;
34
36
  logsDir?: string;
35
37
  report?: StackReporter;
36
38
  log: (line: string) => void;
@@ -46,6 +48,8 @@ export type StartFusionStackOptions = {
46
48
  models: PanelModelSpec[];
47
49
  endpoints?: Record<string, string>;
48
50
  fusionkitDir?: string;
51
+ /** System-prompt overrides emitted into the router's synthesizer config. */
52
+ prompts?: PromptOverrides;
49
53
  judgeModel?: string;
50
54
  /** Pre-running fusionkit serve URL for trajectory synthesis (skips spawn). */
51
55
  synthesisUrl?: string;
@@ -10,6 +10,7 @@ import { MlxBackend, startGateway } from "@fusionkit/model-gateway";
10
10
  import { startFusionStepGateway } from "../gateway.js";
11
11
  import { createPortlessSession } from "../shared/portless.js";
12
12
  import { freePort, spawnLogged, terminate, waitForHttp } from "../shared/proc.js";
13
+ import { PROMPT_CONFIG_KEY, PROMPT_IDS } from "../fusion-config.js";
13
14
  import { defaultKeyEnv, fusionkitPyCommand, loadEnvFileInto } from "./env.js";
14
15
  /**
15
16
  * Heuristic: does the captured output indicate a permanent failure (bad key,
@@ -85,6 +86,19 @@ function routerConfigYaml(input) {
85
86
  // Generous budget: reasoning models (gpt-5.x) spend tokens on reasoning before
86
87
  // producing content, so a small cap can yield an empty answer.
87
88
  lines.push("sampling: {temperature: 0.2, top_p: 0.9, max_tokens: 8192}");
89
+ // Committed `.fusionkit/prompts/*.md` overrides flow into the synthesizer's
90
+ // PromptOverrides here. JSON.stringify yields a valid YAML double-quoted
91
+ // scalar, so multi-line prompts are escaped safely.
92
+ const promptEntries = PROMPT_IDS.flatMap((id) => {
93
+ const value = input.prompts?.[id];
94
+ return value !== undefined ? [[PROMPT_CONFIG_KEY[id], value]] : [];
95
+ });
96
+ if (promptEntries.length > 0) {
97
+ lines.push("prompts:");
98
+ for (const [key, value] of promptEntries) {
99
+ lines.push(` ${key}: ${JSON.stringify(value)}`);
100
+ }
101
+ }
88
102
  lines.push("");
89
103
  return lines.join("\n");
90
104
  }
@@ -142,7 +156,12 @@ export async function startRouter(options) {
142
156
  gateways.push(gateway);
143
157
  mlxUrls[spec.id] = gateway.url();
144
158
  }
145
- const config = routerConfigYaml({ specs, mlxUrls, judgeId: judgeSpec.id });
159
+ const config = routerConfigYaml({
160
+ specs,
161
+ mlxUrls,
162
+ judgeId: judgeSpec.id,
163
+ ...(options.prompts !== undefined ? { prompts: options.prompts } : {})
164
+ });
146
165
  const configDir = mkdtempSync(join(tmpdir(), "fusion-router-"));
147
166
  const configPath = join(configDir, "router.yaml");
148
167
  writeFileSync(configPath, config);
@@ -235,6 +254,7 @@ export async function startFusionStack(options) {
235
254
  specs: options.models,
236
255
  ...(options.judgeModel !== undefined ? { judgeModel: options.judgeModel } : {}),
237
256
  ...(options.fusionkitDir !== undefined ? { fusionkitDir: options.fusionkitDir } : {}),
257
+ ...(options.prompts !== undefined ? { prompts: options.prompts } : {}),
238
258
  ...(options.logsDir !== undefined ? { logsDir: options.logsDir } : {}),
239
259
  ...(report !== undefined ? { report } : {}),
240
260
  log: options.log
@@ -1,6 +1,20 @@
1
1
  import type { FusionTool, PanelModelSpec } from "./fusion-quickstart.js";
2
+ export declare const FUSION_CONFIG_DIRNAME = ".fusionkit";
3
+ export declare const FUSION_CONFIG_BASENAME = "fusion.json";
4
+ export declare const FUSION_PROMPTS_DIRNAME = "prompts";
5
+ /** Legacy single-file config at the repo root (pre-`.fusionkit/`). */
2
6
  export declare const FUSION_CONFIG_FILENAME = "fusionkit.json";
3
- export declare const FUSION_CONFIG_VERSION = "fusionkit.fusion.v1";
7
+ export declare const FUSION_CONFIG_VERSION = "fusionkit.fusion.v2";
8
+ /**
9
+ * The committable system-prompt override ids. Each maps to a
10
+ * `.fusionkit/prompts/<id>.md` file and to a `FusionConfig.prompts` key in the
11
+ * Python synthesizer (see {@link PROMPT_CONFIG_KEY}).
12
+ */
13
+ export declare const PROMPT_IDS: readonly ["judge", "synthesizer", "trajectory-synthesizer", "trajectory-step", "verifier", "panel"];
14
+ export type PromptId = (typeof PROMPT_IDS)[number];
15
+ /** Map each prompt override id to the `prompts:` key fusionkit's config expects. */
16
+ export declare const PROMPT_CONFIG_KEY: Record<PromptId, string>;
17
+ export type PromptOverrides = Partial<Record<PromptId, string>>;
4
18
  export type FusionConfig = {
5
19
  version: typeof FUSION_CONFIG_VERSION;
6
20
  tool?: FusionTool;
@@ -10,19 +24,57 @@ export type FusionConfig = {
10
24
  observe?: boolean;
11
25
  portless?: boolean;
12
26
  port?: number | null;
27
+ /**
28
+ * System-prompt overrides, loaded from `.fusionkit/prompts/*.md`. Not stored
29
+ * inline in `config.json` — it is hydrated from the prompt files on load.
30
+ */
31
+ prompts?: PromptOverrides;
13
32
  };
14
33
  export declare class FusionConfigError extends Error {
15
34
  constructor(message: string);
16
35
  }
36
+ /** The `.fusionkit/` directory at the repo root. */
37
+ export declare function fusionConfigDir(repoRoot: string): string;
38
+ /** The `.fusionkit/fusion.json` settings file. */
17
39
  export declare function fusionConfigPath(repoRoot: string): string;
18
- /** Validate a parsed object as a {@link FusionConfig}, throwing on any problem. */
40
+ /** The legacy `fusionkit.json` at the repo root (pre-`.fusionkit/`). */
41
+ export declare function legacyFusionConfigPath(repoRoot: string): string;
42
+ /** The `.fusionkit/prompts/` directory holding the override files. */
43
+ export declare function fusionPromptsDir(repoRoot: string): string;
44
+ /** The `.fusionkit/prompts/<id>.md` file for a single prompt override. */
45
+ export declare function fusionPromptPath(repoRoot: string, id: PromptId): string;
46
+ /**
47
+ * Validate a parsed settings object as a {@link FusionConfig}, throwing on any
48
+ * problem. Prompt overrides are loaded separately from `.fusionkit/prompts/`,
49
+ * not from this object. A `v1` version is accepted and upgraded to `v2`.
50
+ */
19
51
  export declare function parseFusionConfig(raw: unknown, source: string): FusionConfig;
20
52
  /**
21
- * Load `<repoRoot>/fusionkit.json` if present. Returns `undefined` when the file
22
- * does not exist; throws {@link FusionConfigError} on malformed content.
53
+ * Read the committed prompt overrides from `.fusionkit/prompts/*.md`. Only files
54
+ * that exist and are non-empty (after trimming) become overrides.
55
+ */
56
+ export declare function readFusionPrompts(repoRoot: string): PromptOverrides;
57
+ /**
58
+ * Load the per-repo config. Prefers `.fusionkit/config.json`; if it is absent
59
+ * but a legacy `fusionkit.json` exists, auto-migrates it into the folder (the
60
+ * original is left intact) and loads from there. Returns `undefined` when no
61
+ * config exists; throws {@link FusionConfigError} on malformed content.
62
+ *
63
+ * `onNotice` receives a one-line message when a migration happens.
64
+ */
65
+ export declare function loadFusionConfig(repoRoot: string, onNotice?: (message: string) => void): FusionConfig | undefined;
66
+ /**
67
+ * Write `.fusionkit/config.json` (creating the folder), refusing to clobber
68
+ * unless `force`. Prompt overrides are stored as files, not inline, so any
69
+ * `prompts` on the config object is omitted here.
23
70
  */
24
- export declare function loadFusionConfig(repoRoot: string): FusionConfig | undefined;
25
- /** Write `fusionkit.json` at the repo root, refusing to clobber unless `force`. */
26
71
  export declare function writeFusionConfig(repoRoot: string, config: FusionConfig, options?: {
27
72
  force?: boolean;
28
73
  }): string;
74
+ /**
75
+ * Write prompt override files into `.fusionkit/prompts/`. Existing files are
76
+ * left untouched unless `force`. Returns the paths actually written.
77
+ */
78
+ export declare function writeFusionPrompts(repoRoot: string, prompts: PromptOverrides, options?: {
79
+ force?: boolean;
80
+ }): string[];
@@ -1,29 +1,85 @@
1
1
  /**
2
- * Per-repo fusion configuration (`fusionkit.json`, committed at the repo root).
2
+ * Per-repo fusion configuration, stored in a committed `.fusionkit/` folder at
3
+ * the repo root:
3
4
  *
4
- * Captures the panel, judge, default tool, and run defaults so a contributor can
5
- * just run `fusionkit codex` instead of retyping a long flag line. The file is
6
- * safe to commit: it stores only the env-var *names* that hold API keys
7
- * (`keyEnv`), never the secret values.
5
+ * .fusionkit/
6
+ * fusion.json - all settings (panel, judge, default tool, run defaults)
7
+ * prompts/<id>.md - optional system-prompt overrides (one file per prompt)
8
8
  *
9
- * Precedence at run time is: explicit CLI flags > fusionkit.json > built-in
10
- * defaults. CLI flags always win, so the file is a default layer, not a lock.
9
+ * The folder is safe to commit: it stores only the env-var *names* that hold API
10
+ * keys (`keyEnv`), never the secret values. A prompt file that exists and is
11
+ * non-empty overrides the matching built-in synthesizer prompt; absent/empty
12
+ * falls back to the built-in default.
13
+ *
14
+ * Precedence at run time is: explicit CLI flags > .fusionkit > built-in
15
+ * defaults. CLI flags always win, so the folder is a default layer, not a lock.
16
+ *
17
+ * Legacy `fusionkit.json` files at the repo root are auto-migrated into
18
+ * `.fusionkit/fusion.json` on first load (the original is left intact as a
19
+ * back-compat fallback).
11
20
  */
12
- import { existsSync, readFileSync, writeFileSync } from "node:fs";
21
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
13
22
  import { join } from "node:path";
14
23
  import { FUSION_TOOLS } from "./fusion-quickstart.js";
15
24
  import { PANEL_PROVIDERS } from "./shared/options.js";
25
+ export const FUSION_CONFIG_DIRNAME = ".fusionkit";
26
+ // `fusion.json` (not `config.json`) so the fusion settings never collide with
27
+ // the plane home's `.fusionkit/config.json` (`warrant.config.v2`).
28
+ export const FUSION_CONFIG_BASENAME = "fusion.json";
29
+ export const FUSION_PROMPTS_DIRNAME = "prompts";
30
+ /** Legacy single-file config at the repo root (pre-`.fusionkit/`). */
16
31
  export const FUSION_CONFIG_FILENAME = "fusionkit.json";
17
- export const FUSION_CONFIG_VERSION = "fusionkit.fusion.v1";
32
+ export const FUSION_CONFIG_VERSION = "fusionkit.fusion.v2";
33
+ /** Versions `parseFusionConfig` will load; `v1` is upgraded to `v2` in memory. */
34
+ const SUPPORTED_CONFIG_VERSIONS = ["fusionkit.fusion.v1", "fusionkit.fusion.v2"];
35
+ /**
36
+ * The committable system-prompt override ids. Each maps to a
37
+ * `.fusionkit/prompts/<id>.md` file and to a `FusionConfig.prompts` key in the
38
+ * Python synthesizer (see {@link PROMPT_CONFIG_KEY}).
39
+ */
40
+ export const PROMPT_IDS = [
41
+ "judge",
42
+ "synthesizer",
43
+ "trajectory-synthesizer",
44
+ "trajectory-step",
45
+ "verifier",
46
+ "panel"
47
+ ];
48
+ /** Map each prompt override id to the `prompts:` key fusionkit's config expects. */
49
+ export const PROMPT_CONFIG_KEY = {
50
+ judge: "judge_system",
51
+ synthesizer: "synthesizer_system",
52
+ "trajectory-synthesizer": "trajectory_synthesizer_system",
53
+ "trajectory-step": "trajectory_step_system",
54
+ verifier: "verifier_system",
55
+ panel: "panel_system"
56
+ };
18
57
  export class FusionConfigError extends Error {
19
58
  constructor(message) {
20
59
  super(message);
21
60
  this.name = "FusionConfigError";
22
61
  }
23
62
  }
63
+ /** The `.fusionkit/` directory at the repo root. */
64
+ export function fusionConfigDir(repoRoot) {
65
+ return join(repoRoot, FUSION_CONFIG_DIRNAME);
66
+ }
67
+ /** The `.fusionkit/fusion.json` settings file. */
24
68
  export function fusionConfigPath(repoRoot) {
69
+ return join(fusionConfigDir(repoRoot), FUSION_CONFIG_BASENAME);
70
+ }
71
+ /** The legacy `fusionkit.json` at the repo root (pre-`.fusionkit/`). */
72
+ export function legacyFusionConfigPath(repoRoot) {
25
73
  return join(repoRoot, FUSION_CONFIG_FILENAME);
26
74
  }
75
+ /** The `.fusionkit/prompts/` directory holding the override files. */
76
+ export function fusionPromptsDir(repoRoot) {
77
+ return join(fusionConfigDir(repoRoot), FUSION_PROMPTS_DIRNAME);
78
+ }
79
+ /** The `.fusionkit/prompts/<id>.md` file for a single prompt override. */
80
+ export function fusionPromptPath(repoRoot, id) {
81
+ return join(fusionPromptsDir(repoRoot), `${id}.md`);
82
+ }
27
83
  function isRecord(value) {
28
84
  return typeof value === "object" && value !== null && !Array.isArray(value);
29
85
  }
@@ -57,12 +113,16 @@ function validatePanelEntry(entry, index) {
57
113
  }
58
114
  return spec;
59
115
  }
60
- /** Validate a parsed object as a {@link FusionConfig}, throwing on any problem. */
116
+ /**
117
+ * Validate a parsed settings object as a {@link FusionConfig}, throwing on any
118
+ * problem. Prompt overrides are loaded separately from `.fusionkit/prompts/`,
119
+ * not from this object. A `v1` version is accepted and upgraded to `v2`.
120
+ */
61
121
  export function parseFusionConfig(raw, source) {
62
122
  if (!isRecord(raw))
63
123
  throw new FusionConfigError(`${source}: must be a JSON object`);
64
- if (raw.version !== FUSION_CONFIG_VERSION) {
65
- throw new FusionConfigError(`${source}: unsupported version ${JSON.stringify(raw.version)} (expected "${FUSION_CONFIG_VERSION}")`);
124
+ if (typeof raw.version !== "string" || !SUPPORTED_CONFIG_VERSIONS.includes(raw.version)) {
125
+ throw new FusionConfigError(`${source}: unsupported version ${JSON.stringify(raw.version)} (expected one of ${SUPPORTED_CONFIG_VERSIONS.join(", ")})`);
66
126
  }
67
127
  const config = { version: FUSION_CONFIG_VERSION };
68
128
  if (raw.tool !== undefined) {
@@ -104,14 +164,7 @@ export function parseFusionConfig(raw, source) {
104
164
  }
105
165
  return config;
106
166
  }
107
- /**
108
- * Load `<repoRoot>/fusionkit.json` if present. Returns `undefined` when the file
109
- * does not exist; throws {@link FusionConfigError} on malformed content.
110
- */
111
- export function loadFusionConfig(repoRoot) {
112
- const path = fusionConfigPath(repoRoot);
113
- if (!existsSync(path))
114
- return undefined;
167
+ function readAndParse(path) {
115
168
  let raw;
116
169
  try {
117
170
  raw = JSON.parse(readFileSync(path, "utf8"));
@@ -121,12 +174,89 @@ export function loadFusionConfig(repoRoot) {
121
174
  }
122
175
  return parseFusionConfig(raw, path);
123
176
  }
124
- /** Write `fusionkit.json` at the repo root, refusing to clobber unless `force`. */
177
+ /**
178
+ * Read the committed prompt overrides from `.fusionkit/prompts/*.md`. Only files
179
+ * that exist and are non-empty (after trimming) become overrides.
180
+ */
181
+ export function readFusionPrompts(repoRoot) {
182
+ const dir = fusionPromptsDir(repoRoot);
183
+ const prompts = {};
184
+ if (!existsSync(dir))
185
+ return prompts;
186
+ for (const id of PROMPT_IDS) {
187
+ const path = fusionPromptPath(repoRoot, id);
188
+ if (!existsSync(path))
189
+ continue;
190
+ const text = readFileSync(path, "utf8").trim();
191
+ if (text.length > 0)
192
+ prompts[id] = text;
193
+ }
194
+ return prompts;
195
+ }
196
+ function withPrompts(repoRoot, config) {
197
+ const prompts = readFusionPrompts(repoRoot);
198
+ if (Object.keys(prompts).length === 0)
199
+ return config;
200
+ return { ...config, prompts };
201
+ }
202
+ /**
203
+ * Load the per-repo config. Prefers `.fusionkit/config.json`; if it is absent
204
+ * but a legacy `fusionkit.json` exists, auto-migrates it into the folder (the
205
+ * original is left intact) and loads from there. Returns `undefined` when no
206
+ * config exists; throws {@link FusionConfigError} on malformed content.
207
+ *
208
+ * `onNotice` receives a one-line message when a migration happens.
209
+ */
210
+ export function loadFusionConfig(repoRoot, onNotice) {
211
+ const newPath = fusionConfigPath(repoRoot);
212
+ if (existsSync(newPath)) {
213
+ return withPrompts(repoRoot, readAndParse(newPath));
214
+ }
215
+ const legacyPath = legacyFusionConfigPath(repoRoot);
216
+ if (!existsSync(legacyPath))
217
+ return undefined;
218
+ const config = readAndParse(legacyPath);
219
+ try {
220
+ writeFusionConfig(repoRoot, config);
221
+ onNotice?.(`migrated ${legacyPath} into ${newPath}`);
222
+ }
223
+ catch {
224
+ // Could not write the migrated copy (e.g. read-only FS); use the legacy
225
+ // file in place for this run rather than failing.
226
+ }
227
+ return withPrompts(repoRoot, config);
228
+ }
229
+ /**
230
+ * Write `.fusionkit/config.json` (creating the folder), refusing to clobber
231
+ * unless `force`. Prompt overrides are stored as files, not inline, so any
232
+ * `prompts` on the config object is omitted here.
233
+ */
125
234
  export function writeFusionConfig(repoRoot, config, options = {}) {
126
235
  const path = fusionConfigPath(repoRoot);
127
236
  if (existsSync(path) && options.force !== true) {
128
237
  throw new FusionConfigError(`${path} already exists (pass --force to overwrite)`);
129
238
  }
130
- writeFileSync(path, JSON.stringify(config, null, 2) + "\n");
239
+ mkdirSync(fusionConfigDir(repoRoot), { recursive: true });
240
+ const { prompts: _prompts, ...persisted } = config;
241
+ writeFileSync(path, JSON.stringify(persisted, null, 2) + "\n");
131
242
  return path;
132
243
  }
244
+ /**
245
+ * Write prompt override files into `.fusionkit/prompts/`. Existing files are
246
+ * left untouched unless `force`. Returns the paths actually written.
247
+ */
248
+ export function writeFusionPrompts(repoRoot, prompts, options = {}) {
249
+ mkdirSync(fusionPromptsDir(repoRoot), { recursive: true });
250
+ const written = [];
251
+ for (const id of PROMPT_IDS) {
252
+ const text = prompts[id];
253
+ if (text === undefined)
254
+ continue;
255
+ const path = fusionPromptPath(repoRoot, id);
256
+ if (existsSync(path) && options.force !== true)
257
+ continue;
258
+ writeFileSync(path, text.endsWith("\n") ? text : `${text}\n`);
259
+ written.push(path);
260
+ }
261
+ return written;
262
+ }
@@ -1,4 +1,5 @@
1
1
  export declare function runFusionInit(input: {
2
2
  repoRoot?: string;
3
3
  force?: boolean;
4
+ fusionkitDir?: string;
4
5
  }): Promise<number>;
@@ -3,8 +3,9 @@
3
3
  * per-repo `fusionkit.json`. On a non-interactive stdin the prompts fall back to
4
4
  * their defaults, so `fusion init` still produces a sensible config in CI.
5
5
  */
6
- import { DEFAULT_CLOUD_PANEL, DEFAULT_TRIO, defaultKeyEnv } from "./fusion-quickstart.js";
7
- import { FUSION_CONFIG_VERSION, FusionConfigError, fusionConfigPath, writeFusionConfig } from "./fusion-config.js";
6
+ import { execFileSync } from "node:child_process";
7
+ import { DEFAULT_CLOUD_PANEL, DEFAULT_TRIO, defaultKeyEnv, fusionkitPyCommand } from "./fusion-quickstart.js";
8
+ import { FUSION_CONFIG_VERSION, FusionConfigError, fusionConfigPath, fusionPromptsDir, PROMPT_IDS, writeFusionConfig, writeFusionPrompts } from "./fusion-config.js";
8
9
  import { parsePanelModelSpec } from "./shared/options.js";
9
10
  import { confirm, done, note, select, text } from "./ui/prompt.js";
10
11
  import { uiStream } from "./ui/runtime.js";
@@ -18,6 +19,35 @@ function withKeyEnv(spec) {
18
19
  const keyEnv = defaultKeyEnv(provider);
19
20
  return keyEnv !== undefined ? { ...spec, keyEnv } : { ...spec };
20
21
  }
22
+ /**
23
+ * Pull the built-in default prompts from the Python `fusionkit` CLI
24
+ * (`fusionkit prompts dump`) so the scaffolded `.fusionkit/prompts/*.md` files
25
+ * match the synthesizer's source of truth. Returns `undefined` if the CLI is
26
+ * unreachable (e.g. offline) — callers fall back to leaving prompts unset, in
27
+ * which case the built-in defaults are used at run time.
28
+ */
29
+ function fetchDefaultPrompts(fusionkitDir) {
30
+ const runner = fusionkitPyCommand(fusionkitDir);
31
+ try {
32
+ const stdout = execFileSync(runner.command, [...runner.prefix, "prompts", "dump"], {
33
+ encoding: "utf8",
34
+ stdio: ["ignore", "pipe", "ignore"],
35
+ timeout: 120_000,
36
+ ...(runner.cwd !== undefined ? { cwd: runner.cwd } : {})
37
+ });
38
+ const parsed = JSON.parse(stdout);
39
+ const prompts = {};
40
+ for (const id of PROMPT_IDS) {
41
+ const value = parsed[id];
42
+ if (typeof value === "string" && value.length > 0)
43
+ prompts[id] = value;
44
+ }
45
+ return Object.keys(prompts).length > 0 ? prompts : undefined;
46
+ }
47
+ catch {
48
+ return undefined;
49
+ }
50
+ }
21
51
  async function buildCustomPanel() {
22
52
  out.write(dim("Add panel models as ID=PROVIDER:MODEL (e.g. gpt=openai:gpt-5.5). Blank line to finish.\n"));
23
53
  const specs = [];
@@ -42,7 +72,7 @@ async function buildCustomPanel() {
42
72
  export async function runFusionInit(input) {
43
73
  if (input.repoRoot === undefined) {
44
74
  out.write(`${red("error:")} not inside a git repository.\n` +
45
- " cd into your project (or run from a repo) so fusionkit.json lands at the repo root.\n");
75
+ " cd into your project (or run from a repo) so .fusionkit/ lands at the repo root.\n");
46
76
  return 1;
47
77
  }
48
78
  out.write(`\n${brandHeader("let's set up model fusion for this repo")}\n\n`);
@@ -103,8 +133,22 @@ export async function runFusionInit(input) {
103
133
  }
104
134
  throw error;
105
135
  }
136
+ // Scaffold editable prompt overrides from the synthesizer's built-in defaults.
137
+ // If the Python CLI is unreachable, skip silently — unset prompts use the
138
+ // built-in defaults at run time, and the user can eject them later with
139
+ // `fusionkit prompts dump --dir .fusionkit/prompts`.
140
+ const defaultPrompts = fetchDefaultPrompts(input.fusionkitDir);
141
+ const wrotePrompts = defaultPrompts !== undefined
142
+ ? writeFusionPrompts(input.repoRoot, defaultPrompts, { force: input.force === true })
143
+ : [];
106
144
  out.write("\n");
107
145
  done(`wrote ${cyan(fusionConfigPath(input.repoRoot))}`);
108
- note(`commit it, then just run: ${bold(`fusionkit ${tool === "serve" ? "serve" : tool}`)}`);
146
+ if (wrotePrompts.length > 0) {
147
+ note(`editable prompts in ${cyan(fusionPromptsDir(input.repoRoot))} (empty file = built-in default)`);
148
+ }
149
+ else if (defaultPrompts === undefined) {
150
+ note(`prompts use built-in defaults; run ${bold("fusionkit prompts dump --dir .fusionkit/prompts")} to customize`);
151
+ }
152
+ note(`commit ${cyan(".fusionkit/")}, then just run: ${bold(`fusionkit ${tool === "serve" ? "serve" : tool}`)}`);
109
153
  return 0;
110
154
  }
@@ -201,6 +201,7 @@ export async function runFusion(tool, toolArgs, options = {}) {
201
201
  ...(report !== undefined ? { report } : {}),
202
202
  ...(options.endpoints !== undefined ? { endpoints: options.endpoints } : {}),
203
203
  ...(options.fusionkitDir !== undefined ? { fusionkitDir: options.fusionkitDir } : {}),
204
+ ...(options.prompts !== undefined ? { prompts: options.prompts } : {}),
204
205
  ...(options.judgeModel !== undefined ? { judgeModel: options.judgeModel } : {}),
205
206
  ...(options.synthesisUrl !== undefined ? { synthesisUrl: options.synthesisUrl } : {}),
206
207
  ...(options.authToken !== undefined ? { authToken: options.authToken } : {}),