@kodrunhq/opencode-autopilot 0.1.3 → 1.0.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.
package/bin/cli.ts ADDED
@@ -0,0 +1,346 @@
1
+ #!/usr/bin/env bun
2
+
3
+ import { execFile as execFileCb } from "node:child_process";
4
+ import { randomBytes } from "node:crypto";
5
+ import { readFile, rename, writeFile } from "node:fs/promises";
6
+ import { join } from "node:path";
7
+ import { promisify } from "node:util";
8
+ import { CONFIG_PATH, createDefaultConfig, loadConfig, saveConfig } from "../src/config";
9
+ import { diagnose } from "../src/registry/doctor";
10
+ import { ALL_GROUP_IDS, DIVERSITY_RULES, GROUP_DEFINITIONS } from "../src/registry/model-groups";
11
+ import type { GroupId } from "../src/registry/types";
12
+ import { fileExists } from "../src/utils/fs-helpers";
13
+
14
+ const execFile = promisify(execFileCb);
15
+
16
+ // ── ANSI color helpers (zero dependencies) ──────────────────────────
17
+
18
+ const green = (s: string) => `\x1b[32m${s}\x1b[0m`;
19
+ const red = (s: string) => `\x1b[31m${s}\x1b[0m`;
20
+ const yellow = (s: string) => `\x1b[33m${s}\x1b[0m`;
21
+ const bold = (s: string) => `\x1b[1m${s}\x1b[0m`;
22
+
23
+ // ── Types ───────────────────────────────────────────────────────────
24
+
25
+ export interface CliOptions {
26
+ readonly cwd?: string;
27
+ readonly noTui?: boolean;
28
+ readonly configDir?: string;
29
+ }
30
+
31
+ // ── Constants ───────────────────────────────────────────────────────
32
+
33
+ const PLUGIN_NAME = "@kodrunhq/opencode-autopilot";
34
+ const OPENCODE_JSON = "opencode.json";
35
+
36
+ // ── Helpers ─────────────────────────────────────────────────────────
37
+
38
+ async function checkOpenCodeInstalled(): Promise<string | null> {
39
+ try {
40
+ const { stdout } = await execFile("opencode", ["--version"]);
41
+ return stdout.trim();
42
+ } catch (error: unknown) {
43
+ if (
44
+ error instanceof Error &&
45
+ "code" in error &&
46
+ (error as NodeJS.ErrnoException).code === "ENOENT"
47
+ ) {
48
+ return null;
49
+ }
50
+ throw error;
51
+ }
52
+ }
53
+
54
+ // ── runInstall ──────────────────────────────────────────────────────
55
+
56
+ export async function runInstall(options: CliOptions = {}): Promise<void> {
57
+ const cwd = options.cwd ?? process.cwd();
58
+ const configPath = options.configDir ?? CONFIG_PATH;
59
+
60
+ console.log("");
61
+ console.log(bold("opencode-autopilot install"));
62
+ console.log("─────────────────────────");
63
+ console.log("");
64
+
65
+ // 1. Check OpenCode installed (warn but don't fail)
66
+ const version = await checkOpenCodeInstalled();
67
+ if (version) {
68
+ console.log(` ${green("✓")} OpenCode installed: ${version}`);
69
+ } else {
70
+ console.log(` ${yellow("⚠")} OpenCode not found — install from https://opencode.ai`);
71
+ }
72
+
73
+ // 2. Locate or create opencode.json
74
+ const jsonPath = join(cwd, OPENCODE_JSON);
75
+ let opencodeJson: { plugin?: string[]; [key: string]: unknown };
76
+
77
+ if (await fileExists(jsonPath)) {
78
+ const raw = await readFile(jsonPath, "utf-8");
79
+ try {
80
+ opencodeJson = JSON.parse(raw) as typeof opencodeJson;
81
+ } catch {
82
+ console.error(
83
+ ` ${red("✗")} ${OPENCODE_JSON} contains invalid JSON. Please fix it and try again.`,
84
+ );
85
+ process.exit(1);
86
+ }
87
+ console.log(` ${green("✓")} Found ${OPENCODE_JSON}`);
88
+ } else {
89
+ opencodeJson = { plugin: [] };
90
+ console.log(` ${green("✓")} Created ${OPENCODE_JSON}`);
91
+ }
92
+
93
+ // 3. Register plugin (idempotent)
94
+ const existingPlugins: string[] = Array.isArray(opencodeJson.plugin) ? opencodeJson.plugin : [];
95
+
96
+ if (existingPlugins.includes(PLUGIN_NAME)) {
97
+ console.log(` ${green("✓")} Plugin already registered`);
98
+ } else {
99
+ opencodeJson = {
100
+ ...opencodeJson,
101
+ plugin: [...existingPlugins, PLUGIN_NAME],
102
+ };
103
+ const tmpJsonPath = `${jsonPath}.tmp.${randomBytes(8).toString("hex")}`;
104
+ await writeFile(tmpJsonPath, JSON.stringify(opencodeJson, null, 2), "utf-8");
105
+ await rename(tmpJsonPath, jsonPath);
106
+ console.log(` ${green("✓")} Plugin registered`);
107
+ }
108
+
109
+ // 4. Create starter config (skip if exists)
110
+ if (await fileExists(configPath)) {
111
+ const config = await loadConfig(configPath);
112
+ if (config?.configured) {
113
+ console.log(` ${green("✓")} Config already configured`);
114
+ } else {
115
+ console.log(` ${yellow("⚠")} Config exists, not yet configured`);
116
+ }
117
+ } else {
118
+ const defaultConfig = createDefaultConfig();
119
+ await saveConfig(defaultConfig, configPath);
120
+ console.log(` ${green("✓")} Created starter config`);
121
+ }
122
+
123
+ // 5. Print next steps
124
+ console.log("");
125
+ console.log(bold("Next steps:"));
126
+ console.log("");
127
+ console.log(" 1. Launch OpenCode");
128
+ console.log(" 2. Run /oc-configure to set up your model assignments");
129
+ console.log("");
130
+ console.log(" Or paste this into your AI session for guided setup:");
131
+ console.log("");
132
+ console.log(
133
+ " https://raw.githubusercontent.com/kodrunhq/opencode-autopilot/main/docs/guide/installation.md",
134
+ );
135
+ console.log("");
136
+ }
137
+
138
+ // ── runDoctor helpers ──────────────────────────────────────────────
139
+
140
+ async function printSystemChecks(
141
+ cwd: string,
142
+ configPath: string,
143
+ ): Promise<{ hasFailure: boolean; config: Awaited<ReturnType<typeof loadConfig>> }> {
144
+ let hasFailure = false;
145
+
146
+ console.log(bold("System"));
147
+
148
+ // 1. OpenCode installed
149
+ const version = await checkOpenCodeInstalled();
150
+ if (version) {
151
+ console.log(` OpenCode installed ${green("✓")} ${version}`);
152
+ } else {
153
+ console.log(
154
+ ` OpenCode installed ${red("✗")} not found — install from https://opencode.ai`,
155
+ );
156
+ hasFailure = true;
157
+ }
158
+
159
+ // 2. Plugin registered
160
+ const jsonPath = join(cwd, OPENCODE_JSON);
161
+ if (await fileExists(jsonPath)) {
162
+ try {
163
+ const raw = await readFile(jsonPath, "utf-8");
164
+ const parsed = JSON.parse(raw) as { plugin?: string[] };
165
+ if (Array.isArray(parsed.plugin) && parsed.plugin.includes(PLUGIN_NAME)) {
166
+ console.log(` Plugin registered ${green("✓")} ${OPENCODE_JSON}`);
167
+ } else {
168
+ console.log(` Plugin registered ${red("✗")} not in ${OPENCODE_JSON} — run install`);
169
+ hasFailure = true;
170
+ }
171
+ } catch (error: unknown) {
172
+ if (error instanceof SyntaxError) {
173
+ console.log(
174
+ ` Plugin registered ${red("✗")} invalid ${OPENCODE_JSON} — fix JSON syntax`,
175
+ );
176
+ } else {
177
+ console.log(
178
+ ` Plugin registered ${red("✗")} could not read ${OPENCODE_JSON}: ${error instanceof Error ? error.message : String(error)}`,
179
+ );
180
+ }
181
+ hasFailure = true;
182
+ }
183
+ } else {
184
+ console.log(` Plugin registered ${red("✗")} ${OPENCODE_JSON} not found — run install`);
185
+ hasFailure = true;
186
+ }
187
+
188
+ // 3. Config file exists + schema valid
189
+ const config = await loadConfig(configPath);
190
+ if (config) {
191
+ console.log(` Config file ${green("✓")} found`);
192
+ console.log(` Config schema ${green("✓")} v${config.version}`);
193
+ } else {
194
+ console.log(` Config file ${red("✗")} not found — run install`);
195
+ hasFailure = true;
196
+ }
197
+
198
+ // 4. Setup completed
199
+ if (config) {
200
+ if (config.configured) {
201
+ console.log(` Setup completed ${green("✓")} configured: true`);
202
+ } else {
203
+ console.log(
204
+ ` Setup completed ${red("✗")} configured: false — run /oc-configure in OpenCode`,
205
+ );
206
+ hasFailure = true;
207
+ }
208
+ }
209
+
210
+ return { hasFailure, config };
211
+ }
212
+
213
+ function printModelAssignments(result: ReturnType<typeof diagnose>): void {
214
+ console.log("");
215
+ console.log(bold("Model Assignments"));
216
+
217
+ if (result.configExists) {
218
+ for (const groupId of ALL_GROUP_IDS) {
219
+ const def = GROUP_DEFINITIONS[groupId];
220
+ const info = result.groupsAssigned[groupId];
221
+ const label = def.label.padEnd(20);
222
+
223
+ if (info?.assigned && info.primary) {
224
+ const fallbackStr = info.fallbacks.length > 0 ? ` -> ${info.fallbacks.join(", ")}` : "";
225
+ console.log(` ${label} ${info.primary}${fallbackStr}`);
226
+ } else {
227
+ console.log(` ${label} ${red("✗")} not assigned`);
228
+ }
229
+ }
230
+ } else {
231
+ console.log(` ${red("✗")} no config loaded`);
232
+ }
233
+ }
234
+
235
+ function printDiversityResults(
236
+ result: ReturnType<typeof diagnose>,
237
+ config: Awaited<ReturnType<typeof loadConfig>>,
238
+ ): void {
239
+ console.log("");
240
+ console.log(bold("Adversarial Diversity"));
241
+
242
+ if (config && Object.keys(config.groups).length > 0) {
243
+ // Derive display rules from DIVERSITY_RULES
244
+ const rules = DIVERSITY_RULES.map((rule) => {
245
+ const groupLabels = rule.groups.map((g) => GROUP_DEFINITIONS[g as GroupId].label);
246
+ const label =
247
+ groupLabels.length === 2
248
+ ? `${groupLabels[0]} <-> ${groupLabels[1]}`
249
+ : `${groupLabels[0]} <-> ${groupLabels.slice(1).join("+")}`;
250
+ return { label, groups: rule.groups };
251
+ });
252
+
253
+ for (const rule of rules) {
254
+ const key = [...rule.groups].sort().join(",");
255
+ const allAssigned = rule.groups.every((g) => config.groups[g]);
256
+
257
+ if (!allAssigned) {
258
+ console.log(` ${rule.label.padEnd(28)} ${yellow("⚠")} groups not fully assigned`);
259
+ continue;
260
+ }
261
+
262
+ const warning = result.diversityWarnings.find((w) => [...w.groups].sort().join(",") === key);
263
+
264
+ if (warning) {
265
+ console.log(
266
+ ` ${rule.label.padEnd(28)} ${yellow("⚠")} shared family: ${warning.sharedFamily} — consider different families`,
267
+ );
268
+ } else {
269
+ console.log(` ${rule.label.padEnd(28)} ${green("✓")} different families`);
270
+ }
271
+ }
272
+ } else {
273
+ console.log(` ${yellow("⚠")} no model assignments to check`);
274
+ }
275
+ }
276
+
277
+ // ── runDoctor ───────────────────────────────────────────────────────
278
+
279
+ export async function runDoctor(options: CliOptions = {}): Promise<void> {
280
+ const cwd = options.cwd ?? process.cwd();
281
+ const configPath = options.configDir ?? CONFIG_PATH;
282
+
283
+ console.log("");
284
+ console.log(bold("opencode-autopilot doctor"));
285
+ console.log("─────────────────────────");
286
+ console.log("");
287
+
288
+ const { hasFailure, config } = await printSystemChecks(cwd, configPath);
289
+
290
+ // Run shared diagnosis logic
291
+ const result = diagnose(config);
292
+
293
+ printModelAssignments(result);
294
+ printDiversityResults(result, config);
295
+
296
+ // ── Summary ────────────────────────────────────────────────
297
+
298
+ console.log("");
299
+ if (hasFailure) {
300
+ console.log(red("Some checks failed."));
301
+ process.exitCode = 1;
302
+ } else {
303
+ console.log(green("All checks passed."));
304
+ }
305
+ console.log("");
306
+ }
307
+
308
+ // ── Help ────────────────────────────────────────────────────────────
309
+
310
+ function printUsage(): void {
311
+ console.log("");
312
+ console.log(bold("Usage:") + " opencode-autopilot <command>");
313
+ console.log("");
314
+ console.log("Commands:");
315
+ console.log(" install Register the plugin and create starter config");
316
+ console.log(" doctor Check installation health and model assignments");
317
+ console.log("");
318
+ console.log("Options:");
319
+ console.log(" --help, -h Show this help message");
320
+ console.log("");
321
+ }
322
+
323
+ // ── CLI dispatch (only when run directly, not imported) ─────────────
324
+
325
+ if (import.meta.main) {
326
+ const args = process.argv.slice(2);
327
+ const command = args[0];
328
+
329
+ switch (command) {
330
+ case "install":
331
+ await runInstall({ noTui: args.includes("--no-tui") });
332
+ break;
333
+ case "doctor":
334
+ await runDoctor();
335
+ break;
336
+ case "--help":
337
+ case "-h":
338
+ case undefined:
339
+ printUsage();
340
+ break;
341
+ default:
342
+ console.error(`Unknown command: ${command}`);
343
+ printUsage();
344
+ process.exit(1);
345
+ }
346
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kodrunhq/opencode-autopilot",
3
- "version": "0.1.3",
3
+ "version": "1.0.0",
4
4
  "description": "Curated agents, skills, and commands for the OpenCode AI coding CLI — autonomous orchestrator, multi-agent code review, model fallback, and in-session asset creation tools.",
5
5
  "main": "src/index.ts",
6
6
  "keywords": [
@@ -35,9 +35,13 @@
35
35
  "peerDependencies": {
36
36
  "@opencode-ai/plugin": ">=1.3.0"
37
37
  },
38
+ "bin": {
39
+ "opencode-autopilot": "bin/cli.ts"
40
+ },
38
41
  "files": [
39
42
  "src/",
40
- "assets/"
43
+ "assets/",
44
+ "bin/"
41
45
  ],
42
46
  "scripts": {
43
47
  "test": "bun test",
@@ -1,4 +1,7 @@
1
1
  import type { Config } from "@opencode-ai/plugin";
2
+ import { loadConfig } from "../config";
3
+ import { resolveModelForAgent } from "../registry/resolver";
4
+ import type { AgentOverride, GroupModelAssignment } from "../registry/types";
2
5
  import { autopilotAgent } from "./autopilot";
3
6
  import { documenterAgent } from "./documenter";
4
7
  import { metaprompterAgent } from "./metaprompter";
@@ -6,6 +9,11 @@ import { pipelineAgents } from "./pipeline/index";
6
9
  import { prReviewerAgent } from "./pr-reviewer";
7
10
  import { researcherAgent } from "./researcher";
8
11
 
12
+ interface AgentConfig {
13
+ readonly [key: string]: unknown;
14
+ readonly permission?: Record<string, unknown>;
15
+ }
16
+
9
17
  const agents = {
10
18
  researcher: researcherAgent,
11
19
  metaprompter: metaprompterAgent,
@@ -14,32 +22,58 @@ const agents = {
14
22
  autopilot: autopilotAgent,
15
23
  } as const;
16
24
 
17
- export async function configHook(config: Config): Promise<void> {
18
- if (!config.agent) {
19
- config.agent = {};
20
- }
21
- for (const [name, agentConfig] of Object.entries(agents)) {
22
- // Only set if not already defined preserve user customizations (Pitfall 2)
23
- if (config.agent[name] === undefined) {
24
- // Mutation of config.agent is intentional: the OpenCode plugin configHook
25
- // API requires mutating the provided Config object to register agents.
26
- // We deep-copy agent config including nested permission to avoid shared references.
27
- config.agent[name] = {
25
+ /**
26
+ * Register a set of agents into the OpenCode config, resolving model
27
+ * assignments from groups/overrides. Skips agents already defined
28
+ * to preserve user customizations.
29
+ *
30
+ * Mutation of config.agent is intentional: the OpenCode plugin configHook
31
+ * API requires mutating the provided Config object to register agents.
32
+ */
33
+ function registerAgents(
34
+ agentMap: Readonly<Record<string, Readonly<AgentConfig>>>,
35
+ config: Config,
36
+ groups: Readonly<Record<string, GroupModelAssignment>>,
37
+ overrides: Readonly<Record<string, AgentOverride>>,
38
+ ): void {
39
+ for (const [name, agentConfig] of Object.entries(agentMap)) {
40
+ if (config.agent![name] === undefined) {
41
+ // Deep-copy agent config including nested permission to avoid shared references.
42
+ const resolved = resolveModelForAgent(name, groups, overrides);
43
+ config.agent![name] = {
28
44
  ...agentConfig,
29
45
  ...(agentConfig.permission && { permission: { ...agentConfig.permission } }),
46
+ ...(resolved && { model: resolved.primary }),
47
+ ...(resolved &&
48
+ resolved.fallbacks.length > 0 && {
49
+ fallback_models: [...resolved.fallbacks],
50
+ }),
30
51
  };
31
52
  }
32
53
  }
54
+ }
33
55
 
34
- // Register pipeline agents (v2 orchestrator subagents)
35
- for (const [name, agentConfig] of Object.entries(pipelineAgents)) {
36
- if (config.agent[name] === undefined) {
37
- config.agent[name] = {
38
- ...agentConfig,
39
- ...(agentConfig.permission && { permission: { ...agentConfig.permission } }),
40
- };
41
- }
56
+ export async function configHook(config: Config, configPath?: string): Promise<void> {
57
+ if (!config.agent) {
58
+ config.agent = {};
42
59
  }
60
+
61
+ // Load plugin config for model group resolution
62
+ let pluginConfig = null;
63
+ try {
64
+ pluginConfig = await loadConfig(configPath);
65
+ } catch (error: unknown) {
66
+ console.error(
67
+ "[opencode-autopilot] Failed to load plugin config:",
68
+ error instanceof Error ? error.message : String(error),
69
+ );
70
+ }
71
+ const groups: Readonly<Record<string, GroupModelAssignment>> = pluginConfig?.groups ?? {};
72
+ const overrides: Readonly<Record<string, AgentOverride>> = pluginConfig?.overrides ?? {};
73
+
74
+ // Register standard agents and pipeline agents (v2 orchestrator subagents)
75
+ registerAgents(agents, config, groups, overrides);
76
+ registerAgents(pipelineAgents, config, groups, overrides);
43
77
  }
44
78
 
45
79
  export { autopilotAgent } from "./autopilot";
package/src/config.ts CHANGED
@@ -1,7 +1,9 @@
1
+ import { randomBytes } from "node:crypto";
1
2
  import { readFile, rename, writeFile } from "node:fs/promises";
2
3
  import { dirname, join } from "node:path";
3
4
  import { z } from "zod";
4
5
  import { fallbackConfigSchema, fallbackDefaults } from "./orchestrator/fallback/fallback-config";
6
+ import { AGENT_REGISTRY, ALL_GROUP_IDS } from "./registry/model-groups";
5
7
  import { ensureDir, isEnoentError } from "./utils/fs-helpers";
6
8
  import { getGlobalConfigDir } from "./utils/paths";
7
9
 
@@ -60,7 +62,7 @@ const pluginConfigSchemaV2 = z.object({
60
62
 
61
63
  type PluginConfigV2 = z.infer<typeof pluginConfigSchemaV2>;
62
64
 
63
- // --- V3 schema ---
65
+ // --- V3 schema (internal, for migration) ---
64
66
 
65
67
  const pluginConfigSchemaV3 = z.object({
66
68
  version: z.literal(3),
@@ -72,10 +74,48 @@ const pluginConfigSchemaV3 = z.object({
72
74
  fallback_models: z.union([z.string(), z.array(z.string())]).optional(),
73
75
  });
74
76
 
75
- // Export pluginConfigSchema as alias for v3 (preserves import compatibility)
76
- export const pluginConfigSchema = pluginConfigSchemaV3;
77
+ type PluginConfigV3 = z.infer<typeof pluginConfigSchemaV3>;
77
78
 
78
- export type PluginConfig = z.infer<typeof pluginConfigSchemaV3>;
79
+ // --- V4 sub-schemas ---
80
+
81
+ const groupModelAssignmentSchema = z.object({
82
+ primary: z.string().min(1),
83
+ fallbacks: z.array(z.string().min(1)).default([]),
84
+ });
85
+
86
+ const agentOverrideSchema = z.object({
87
+ primary: z.string().min(1),
88
+ fallbacks: z.array(z.string().min(1)).optional(),
89
+ });
90
+
91
+ // --- V4 schema ---
92
+
93
+ const pluginConfigSchemaV4 = z
94
+ .object({
95
+ version: z.literal(4),
96
+ configured: z.boolean(),
97
+ groups: z.record(z.string(), groupModelAssignmentSchema).default({}),
98
+ overrides: z.record(z.string(), agentOverrideSchema).default({}),
99
+ orchestrator: orchestratorConfigSchema.default(orchestratorDefaults),
100
+ confidence: confidenceConfigSchema.default(confidenceDefaults),
101
+ fallback: fallbackConfigSchema.default(fallbackDefaults),
102
+ })
103
+ .superRefine((config, ctx) => {
104
+ for (const groupId of Object.keys(config.groups)) {
105
+ if (!ALL_GROUP_IDS.includes(groupId as (typeof ALL_GROUP_IDS)[number])) {
106
+ ctx.addIssue({
107
+ code: z.ZodIssueCode.custom,
108
+ path: ["groups", groupId],
109
+ message: `Unknown group id "${groupId}". Expected one of: ${ALL_GROUP_IDS.join(", ")}`,
110
+ });
111
+ }
112
+ }
113
+ });
114
+
115
+ // Export aliases updated to v4
116
+ export const pluginConfigSchema = pluginConfigSchemaV4;
117
+
118
+ export type PluginConfig = z.infer<typeof pluginConfigSchemaV4>;
79
119
 
80
120
  export const CONFIG_PATH = join(getGlobalConfigDir(), "opencode-autopilot.json");
81
121
 
@@ -91,7 +131,7 @@ function migrateV1toV2(v1Config: PluginConfigV1): PluginConfigV2 {
91
131
  };
92
132
  }
93
133
 
94
- function migrateV2toV3(v2Config: PluginConfigV2): PluginConfig {
134
+ function migrateV2toV3(v2Config: PluginConfigV2): PluginConfigV3 {
95
135
  return {
96
136
  version: 3 as const,
97
137
  configured: v2Config.configured,
@@ -102,6 +142,55 @@ function migrateV2toV3(v2Config: PluginConfigV2): PluginConfig {
102
142
  };
103
143
  }
104
144
 
145
+ function migrateV3toV4(v3Config: PluginConfigV3): PluginConfig {
146
+ const groups: Record<string, { primary: string; fallbacks: string[] }> = {};
147
+ const overrides: Record<string, { primary: string }> = {};
148
+
149
+ // Step 1: Reverse-map v3 flat models to groups
150
+ // v3.models is Record<string, string> where key is agent name, value is model id
151
+ for (const [agentName, modelId] of Object.entries(v3Config.models)) {
152
+ const entry = AGENT_REGISTRY[agentName];
153
+ if (!entry) {
154
+ // Agent not in registry — preserve as per-agent override
155
+ overrides[agentName] = { primary: modelId };
156
+ continue;
157
+ }
158
+
159
+ const groupId = entry.group;
160
+ if (!groups[groupId]) {
161
+ // First agent in this group sets the primary
162
+ groups[groupId] = { primary: modelId, fallbacks: [] };
163
+ } else if (groups[groupId].primary !== modelId) {
164
+ // Different model for same group — becomes an override
165
+ overrides[agentName] = { primary: modelId };
166
+ }
167
+ // Same model as group primary — no override needed
168
+ }
169
+
170
+ // Step 2: Migrate global fallback_models to per-group fallbacks
171
+ const globalFallbacks = v3Config.fallback_models
172
+ ? typeof v3Config.fallback_models === "string"
173
+ ? [v3Config.fallback_models]
174
+ : [...v3Config.fallback_models]
175
+ : [];
176
+
177
+ for (const group of Object.values(groups)) {
178
+ if (group.fallbacks.length === 0 && globalFallbacks.length > 0) {
179
+ group.fallbacks = [...globalFallbacks];
180
+ }
181
+ }
182
+
183
+ return {
184
+ version: 4 as const,
185
+ configured: v3Config.configured,
186
+ groups,
187
+ overrides,
188
+ orchestrator: v3Config.orchestrator,
189
+ confidence: v3Config.confidence,
190
+ fallback: v3Config.fallback,
191
+ };
192
+ }
193
+
105
194
  // --- Public API ---
106
195
 
107
196
  export async function loadConfig(configPath: string = CONFIG_PATH): Promise<PluginConfig | null> {
@@ -109,35 +198,40 @@ export async function loadConfig(configPath: string = CONFIG_PATH): Promise<Plug
109
198
  const raw = await readFile(configPath, "utf-8");
110
199
  const parsed = JSON.parse(raw);
111
200
 
112
- // Try v3 first
201
+ // Try v4 first
202
+ const v4Result = pluginConfigSchemaV4.safeParse(parsed);
203
+ if (v4Result.success) return v4Result.data;
204
+
205
+ // Try v3 and migrate to v4
113
206
  const v3Result = pluginConfigSchemaV3.safeParse(parsed);
114
207
  if (v3Result.success) {
115
- return v3Result.data;
208
+ const migrated = migrateV3toV4(v3Result.data);
209
+ await saveConfig(migrated, configPath);
210
+ return migrated;
116
211
  }
117
212
 
118
- // Try v2 and migrate to v3
213
+ // Try v2 v3 v4
119
214
  const v2Result = pluginConfigSchemaV2.safeParse(parsed);
120
215
  if (v2Result.success) {
121
- const migrated = migrateV2toV3(v2Result.data);
216
+ const v3 = migrateV2toV3(v2Result.data);
217
+ const migrated = migrateV3toV4(v3);
122
218
  await saveConfig(migrated, configPath);
123
219
  return migrated;
124
220
  }
125
221
 
126
- // Try v1 and double-migrate v1->v2->v3
222
+ // Try v1 v2 v3 → v4
127
223
  const v1Result = pluginConfigSchemaV1.safeParse(parsed);
128
224
  if (v1Result.success) {
129
225
  const v2 = migrateV1toV2(v1Result.data);
130
- const migrated = migrateV2toV3(v2);
226
+ const v3 = migrateV2toV3(v2);
227
+ const migrated = migrateV3toV4(v3);
131
228
  await saveConfig(migrated, configPath);
132
229
  return migrated;
133
230
  }
134
231
 
135
- // None matched -- force v3 parse to get proper error
136
- return pluginConfigSchemaV3.parse(parsed);
232
+ return pluginConfigSchemaV4.parse(parsed); // throw with proper error
137
233
  } catch (error: unknown) {
138
- if (isEnoentError(error)) {
139
- return null;
140
- }
234
+ if (isEnoentError(error)) return null;
141
235
  throw error;
142
236
  }
143
237
  }
@@ -147,7 +241,7 @@ export async function saveConfig(
147
241
  configPath: string = CONFIG_PATH,
148
242
  ): Promise<void> {
149
243
  await ensureDir(dirname(configPath));
150
- const tmpPath = `${configPath}.tmp.${Date.now()}`;
244
+ const tmpPath = `${configPath}.tmp.${randomBytes(8).toString("hex")}`;
151
245
  await writeFile(tmpPath, JSON.stringify(config, null, 2), "utf-8");
152
246
  await rename(tmpPath, configPath);
153
247
  }
@@ -158,9 +252,10 @@ export function isFirstLoad(config: PluginConfig | null): boolean {
158
252
 
159
253
  export function createDefaultConfig(): PluginConfig {
160
254
  return {
161
- version: 3 as const,
255
+ version: 4 as const,
162
256
  configured: false,
163
- models: {},
257
+ groups: {},
258
+ overrides: {},
164
259
  orchestrator: orchestratorDefaults,
165
260
  confidence: confidenceDefaults,
166
261
  fallback: fallbackDefaults,