amicus 2.2.0 → 3.1.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.
Files changed (49) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +80 -0
  3. package/README.md +13 -8
  4. package/bin/amicus.js +5 -0
  5. package/electron/close-guard.js +4 -4
  6. package/electron/fold.js +8 -8
  7. package/electron/ipc-guard.js +3 -3
  8. package/electron/main.js +31 -31
  9. package/electron/opencode-theme.js +3 -3
  10. package/electron/preload-content.js +1 -1
  11. package/electron/setup-ui.js +27 -3
  12. package/package.json +4 -3
  13. package/skills/second-opinion/SKILL.md +2 -0
  14. package/skills/sidecar/SKILL.md +66 -38
  15. package/src/cli-handlers-resume-continue.js +31 -4
  16. package/src/cli-handlers-run.js +15 -4
  17. package/src/cli.js +13 -0
  18. package/src/mcp-server.js +99 -12
  19. package/src/mcp-tools.js +26 -4
  20. package/src/opencode-client.js +18 -2
  21. package/src/sidecar/continue.js +10 -3
  22. package/src/sidecar/electron-install.js +9 -8
  23. package/src/sidecar/fanout-leg.js +26 -1
  24. package/src/sidecar/fanout-output.js +5 -0
  25. package/src/sidecar/fanout-validate.js +81 -0
  26. package/src/sidecar/fanout.js +65 -77
  27. package/src/sidecar/session-utils.js +6 -0
  28. package/src/sidecar/setup.js +2 -1
  29. package/src/utils/alias-resolver.js +6 -35
  30. package/src/utils/api-key-store.js +1 -9
  31. package/src/utils/auth-json.js +1 -1
  32. package/src/utils/config.js +98 -16
  33. package/src/utils/curated-models.js +33 -4
  34. package/src/utils/gateway-router.js +115 -0
  35. package/src/utils/input-validators.js +12 -42
  36. package/src/utils/model-classification.js +65 -0
  37. package/src/utils/model-descriptor.js +72 -0
  38. package/src/utils/model-fetcher.js +35 -9
  39. package/src/utils/model-input-default.js +32 -0
  40. package/src/utils/model-validator.js +68 -84
  41. package/src/utils/node-version-guard.js +16 -0
  42. package/src/utils/provider-registry.js +57 -0
  43. package/src/utils/quick-picks.js +11 -3
  44. package/src/utils/result-schema-rebuild.js +98 -0
  45. package/src/utils/result-schema.js +6 -76
  46. package/src/utils/route-error.js +137 -0
  47. package/src/utils/route-launch.js +179 -0
  48. package/src/utils/start-helpers.js +96 -43
  49. package/src/utils/validators.js +1 -8
@@ -0,0 +1,32 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * @module model-input-default
5
+ * Shared "no --model given" default-lookup, extracted so it lives in exactly
6
+ * one place (#61 Task 6.2 review follow-up): both the CLI's resolveLaunchModel
7
+ * (start-helpers.js) and the MCP amicus_start handler (mcp-server.js) need to
8
+ * fall back to the configured default before handing a model off to
9
+ * resolveRouteForLaunch — without it, an omitted model reaches
10
+ * resolveRouteForLaunch as undefined -> parseDescriptor(undefined) -> an
11
+ * `invalid` descriptor, breaking the common "no --model" launch case.
12
+ * A leaf module (no other src/ module requires/mocks it), so introducing it
13
+ * doesn't reshape any existing jest.doMock('../src/utils/route-launch', ...)
14
+ * or jest.doMock('../src/utils/config', ...) call shape.
15
+ */
16
+
17
+ /**
18
+ * Resolve the raw model input for a launch: an explicit value (including one
19
+ * a caller may have already normalized) passes through unchanged; otherwise
20
+ * falls back to the configured default.
21
+ * @param {string|null|undefined} inputModel
22
+ * @returns {string|undefined} inputModel as-is, the configured default, or
23
+ * undefined if neither exists (the caller decides how to report that).
24
+ */
25
+ function resolveModelInputOrDefault(inputModel) {
26
+ if (inputModel !== undefined && inputModel !== null) { return inputModel; }
27
+ const { loadConfig } = require('./config');
28
+ const cfg = loadConfig();
29
+ return (cfg && cfg.default) ? cfg.default : undefined;
30
+ }
31
+
32
+ module.exports = { resolveModelInputOrDefault };
@@ -7,10 +7,7 @@
7
7
  */
8
8
 
9
9
  const readline = require('readline');
10
- const { fetchModelsFromProvider } = require('./model-fetcher');
11
- const { readApiKeyValues } = require('./api-key-store');
12
10
  const { loadConfig, saveConfig, getConfigPath } = require('./config');
13
- const { logger } = require('./logger');
14
11
 
15
12
  /**
16
13
  * Normalize a model ID to include the provider prefix.
@@ -33,54 +30,6 @@ const ALIAS_SEARCH_TERMS = {
33
30
  'deepseek': 'deepseek',
34
31
  };
35
32
 
36
- /**
37
- * Validate a direct-API fallback model exists on the provider.
38
- * Returns silently if valid. On failure: prompts (interactive) or throws (headless).
39
- *
40
- * @param {string} resolvedModel - e.g. 'google/gemini-3.1-flash-lite-preview'
41
- * @param {string} alias - Original alias name (e.g. 'gemini')
42
- * @param {object} [options]
43
- * @param {boolean} [options.headless] - If true, throw instead of prompting
44
- * @returns {Promise<string>} Confirmed model string
45
- */
46
- async function validateDirectModel(resolvedModel, alias, options = {}) {
47
- const parts = resolvedModel.split('/');
48
- if (parts.length < 2) { return resolvedModel; }
49
-
50
- const provider = parts[0];
51
- const modelId = parts.slice(1).join('/');
52
-
53
- const keys = readApiKeyValues();
54
- const providerKey = keys[provider];
55
- if (!providerKey) { return resolvedModel; }
56
-
57
- let models;
58
- try {
59
- models = await fetchModelsFromProvider(provider, providerKey);
60
- } catch (err) {
61
- logger.debug({ msg: 'Model fetch failed, skipping validation', error: err.message });
62
- return resolvedModel;
63
- }
64
-
65
- if (!models || models.length === 0) { return resolvedModel; }
66
-
67
- const found = models.some(m => m.id === resolvedModel || m.id === modelId);
68
- if (found) { return resolvedModel; }
69
-
70
- const relevant = filterRelevantModels(models, alias);
71
-
72
- if (options.headless || !process.stdin.isTTY) {
73
- const list = relevant.slice(0, 10).map(m => ` ${normalizeModelId(provider, m.id)}`).join('\n');
74
- throw new Error(
75
- `Model '${modelId}' not found on ${provider} API.\n` +
76
- `Available models:\n${list}\n` +
77
- `Fix with: amicus setup --add-alias ${alias}=${relevant[0] ? normalizeModelId(provider, relevant[0].id) : 'provider/model'}`
78
- );
79
- }
80
-
81
- return promptModelSelection(relevant, alias, provider, modelId);
82
- }
83
-
84
33
  /**
85
34
  * Filter models to those relevant to the alias
86
35
  * @param {Array<{id: string, name: string}>} models
@@ -101,54 +50,83 @@ function filterRelevantModels(models, alias) {
101
50
  return filtered.slice(0, 15);
102
51
  }
103
52
 
104
- /** Interactive prompt — ask user to pick from available models */
105
- async function promptModelSelection(models, alias, provider, failedModelId) {
106
- process.stderr.write(`\n Model '${failedModelId}' not found on ${provider} API.\n`);
107
- process.stderr.write(' Available models:\n');
108
- models.forEach((m, i) => {
109
- const label = (m.name && m.name !== m.id) ? `${m.name} (${m.id})` : m.id;
110
- process.stderr.write(` ${i + 1}. ${label}\n`);
53
+ /**
54
+ * Interactive alternatives picker for a direct-model miss (#61 Task 6.3, spec
55
+ * Decision 10). Presents `selectionResult.suggestions` (built upstream by
56
+ * route-launch.js's buildSuggestions) as a labeled numbered menu and lets the
57
+ * user pick one, or cancel. Uses the same readline + persist pattern (same
58
+ * save-with-malformed-config guard) as other model pickers in this module,
59
+ * adapted to the `{model, gateway, note}` suggestion shape instead of
60
+ * provider `{id, name}` rows.
61
+ *
62
+ * Never auto-selects — even a single suggestion still requires an explicit
63
+ * pick. Cancellation (empty input, an out-of-range number, or no suggestions
64
+ * to offer) always throws; the caller (resolveLaunchModel) is expected to
65
+ * catch and translate that into a "cancelled" stderr message + exit(1).
66
+ *
67
+ * @param {{requested: string, suggestions: Array<{model:string, gateway:string, note?:string}>}} selectionResult
68
+ * @param {string|undefined} alias - alias to persist the choice under, if any
69
+ * @returns {Promise<{model: string, gateway: string}>}
70
+ */
71
+ async function promptRouteSelection(selectionResult, alias) {
72
+ const suggestions = (selectionResult && Array.isArray(selectionResult.suggestions))
73
+ ? selectionResult.suggestions : [];
74
+ const requested = selectionResult && selectionResult.requested;
75
+
76
+ process.stderr.write(`\n Model '${requested}' isn't available on the direct API.\n`);
77
+
78
+ if (suggestions.length === 0) {
79
+ process.stderr.write(' No alternatives available.\n\n');
80
+ throw new Error('Model selection cancelled.');
81
+ }
82
+
83
+ process.stderr.write(' Alternatives:\n');
84
+ suggestions.forEach((s, i) => {
85
+ const note = s && s.note ? ` — ${s.note}` : '';
86
+ process.stderr.write(` ${i + 1}. ${s && s.model} [${s && s.gateway}]${note}\n`);
111
87
  });
112
88
  process.stderr.write('\n');
113
89
 
114
90
  const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
115
91
 
116
92
  const answer = await new Promise(resolve => {
117
- rl.question(` Select a model (1-${models.length}) or press Enter to cancel: `, resolve);
93
+ rl.question(` Select (1-${suggestions.length}) or Enter to cancel: `, resolve);
118
94
  });
119
95
  rl.close();
120
96
 
121
97
  const idx = parseInt(answer, 10) - 1;
122
- if (isNaN(idx) || idx < 0 || idx >= models.length) {
98
+ if (isNaN(idx) || idx < 0 || idx >= suggestions.length) {
123
99
  throw new Error('Model selection cancelled.');
124
100
  }
125
101
 
126
- const selected = models[idx];
127
- const newModel = normalizeModelId(provider, selected.id);
128
-
129
- let config = loadConfig();
130
- if (!config) {
131
- const fs = require('fs');
132
- const configPath = getConfigPath();
133
- if (fs.existsSync(configPath)) {
134
- throw new Error(
135
- `Cannot save model selection: config file at ${configPath} is malformed. ` +
136
- 'Fix it manually or run \'amicus setup\'.'
137
- );
102
+ const chosen = suggestions[idx];
103
+ const newModel = chosen.model;
104
+
105
+ if (alias) {
106
+ let config = loadConfig();
107
+ if (!config) {
108
+ const fs = require('fs');
109
+ const configPath = getConfigPath();
110
+ if (fs.existsSync(configPath)) {
111
+ throw new Error(
112
+ `Cannot save model selection: config file at ${configPath} is malformed. ` +
113
+ 'Fix it manually or run \'amicus setup\'.'
114
+ );
115
+ }
116
+ config = {};
117
+ }
118
+ if (!config.aliases) { config.aliases = {}; }
119
+ config.aliases[alias] = newModel;
120
+ try {
121
+ saveConfig(config);
122
+ process.stderr.write(` Saved: ${alias} -> ${newModel}\n`);
123
+ } catch (err) {
124
+ process.stderr.write(` Warning: Could not save selection (${err.message}). Using for this session only.\n`);
138
125
  }
139
- config = {};
140
- }
141
- if (!config.aliases) { config.aliases = {}; }
142
- config.aliases[alias] = newModel;
143
- try {
144
- saveConfig(config);
145
- process.stderr.write(` Saved: ${alias} → ${newModel}\n`);
146
- } catch (err) {
147
- process.stderr.write(` Warning: Could not save selection (${err.message}). Using for this session only.\n`);
148
126
  }
149
- process.stderr.write(` (To change later: amicus setup --add-alias ${alias}=...)\n\n`);
127
+ process.stderr.write('\n');
150
128
 
151
- return newModel;
129
+ return { model: newModel, gateway: chosen.gateway };
152
130
  }
153
131
 
154
132
  /**
@@ -204,4 +182,10 @@ async function warnIfNotInCatalog(model) {
204
182
  }
205
183
  }
206
184
 
207
- module.exports = { validateDirectModel, filterRelevantModels, normalizeModelId, validateAgainstCatalog, warnIfNotInCatalog };
185
+ module.exports = {
186
+ filterRelevantModels,
187
+ normalizeModelId,
188
+ validateAgainstCatalog,
189
+ warnIfNotInCatalog,
190
+ promptRouteSelection,
191
+ };
@@ -0,0 +1,16 @@
1
+ 'use strict';
2
+ const MIN_NODE = '22.12.0';
3
+
4
+ /** @param {string} current @param {string} min @returns {{ok:boolean,message:string|null}} */
5
+ function checkNodeVersion(current, min = MIN_NODE) {
6
+ const c = current.replace(/^v/, '').split('.').map(Number);
7
+ const m = min.split('.').map(Number);
8
+ for (let i = 0; i < 3; i++) {
9
+ if ((c[i] || 0) > (m[i] || 0)) { return { ok: true, message: null }; }
10
+ if ((c[i] || 0) < (m[i] || 0)) {
11
+ return { ok: false, message: `Amicus 3.0 requires Node >=${min}; you are on ${current.replace(/^v/, '')}. Upgrade Node and retry.` };
12
+ }
13
+ }
14
+ return { ok: true, message: null };
15
+ }
16
+ module.exports = { checkNodeVersion, MIN_NODE };
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Provider-capability registry — the single source of truth for provider
3
+ * identity, credentials, direct-vs-gateway role, and display names.
4
+ * The historical maps (PROVIDER_ENV_MAP, PROVIDER_KEY_MAP, KNOWN_PROVIDERS,
5
+ * PROVIDER_FAMILY_NAMES) are DERIVED from PROVIDERS below so they can never
6
+ * drift apart again. Leaf module: requires nothing internal (no circular deps).
7
+ */
8
+ 'use strict';
9
+
10
+ /**
11
+ * @typedef {Object} ProviderDescriptor
12
+ * @property {string} id provider id (namespace)
13
+ * @property {string} envVar env var holding the key
14
+ * @property {string} keyDisplayName human name used in missing-key errors
15
+ * @property {string} familyName short name used for optgroup grouping
16
+ * @property {boolean} direct can be a DIRECT route target (false for the gateway)
17
+ * @property {boolean} gateway is the OpenRouter gateway itself
18
+ * @property {boolean} hasLiveFetch has a live GET /models endpoint
19
+ * @property {string} authJsonKey key used in OpenCode auth.json
20
+ */
21
+
22
+ /** @type {ProviderDescriptor[]} */
23
+ const PROVIDERS = [
24
+ { id: 'openrouter', envVar: 'OPENROUTER_API_KEY', keyDisplayName: 'OpenRouter', familyName: 'OpenRouter', direct: false, gateway: true, hasLiveFetch: true, authJsonKey: 'openrouter' },
25
+ { id: 'google', envVar: 'GOOGLE_GENERATIVE_AI_API_KEY', keyDisplayName: 'Google Gemini', familyName: 'Google', direct: true, gateway: false, hasLiveFetch: true, authJsonKey: 'google' },
26
+ { id: 'openai', envVar: 'OPENAI_API_KEY', keyDisplayName: 'OpenAI', familyName: 'OpenAI', direct: true, gateway: false, hasLiveFetch: true, authJsonKey: 'openai' },
27
+ { id: 'anthropic', envVar: 'ANTHROPIC_API_KEY', keyDisplayName: 'Anthropic', familyName: 'Anthropic', direct: true, gateway: false, hasLiveFetch: true, authJsonKey: 'anthropic' },
28
+ { id: 'deepseek', envVar: 'DEEPSEEK_API_KEY', keyDisplayName: 'DeepSeek', familyName: 'DeepSeek', direct: true, gateway: false, hasLiveFetch: true, authJsonKey: 'deepseek' },
29
+ ];
30
+
31
+ const _byId = new Map(PROVIDERS.map(p => [p.id, p]));
32
+
33
+ /** @param {string} id @returns {ProviderDescriptor|undefined} */
34
+ function getProvider(id) { return _byId.get(id); }
35
+
36
+ /** @param {string} id @returns {boolean} true only for direct-route vendors (never the gateway) */
37
+ function isDirectProvider(id) { const p = _byId.get(id); return !!p && p.direct; }
38
+
39
+ /** @returns {string[]} ids of direct-route vendors (excludes openrouter) */
40
+ function listDirectProviders() { return PROVIDERS.filter(p => p.direct).map(p => p.id); }
41
+
42
+ // --- Derived compatibility maps (do not hand-edit; edit PROVIDERS above) ---
43
+ const PROVIDER_ENV_MAP = Object.fromEntries(PROVIDERS.map(p => [p.id, p.envVar]));
44
+ const PROVIDER_KEY_MAP = Object.fromEntries(PROVIDERS.map(p => [p.id, { key: p.envVar, name: p.keyDisplayName }]));
45
+ const KNOWN_PROVIDERS = PROVIDERS.map(p => p.id);
46
+ const PROVIDER_FAMILY_NAMES = Object.fromEntries(PROVIDERS.map(p => [p.id, p.familyName]));
47
+
48
+ module.exports = {
49
+ PROVIDERS,
50
+ getProvider,
51
+ isDirectProvider,
52
+ listDirectProviders,
53
+ PROVIDER_ENV_MAP,
54
+ PROVIDER_KEY_MAP,
55
+ KNOWN_PROVIDERS,
56
+ PROVIDER_FAMILY_NAMES,
57
+ };
@@ -10,7 +10,7 @@
10
10
 
11
11
  'use strict';
12
12
 
13
- const { getFamilies, toDefaultAliases } = require('./curated-models');
13
+ const { getFamilies, toDefaultAliases, toCanonicalDefault } = require('./curated-models');
14
14
 
15
15
  const MARKER_RE = /(-preview|-exp|-beta|-latest|:free)+$/;
16
16
 
@@ -67,13 +67,21 @@ function resolveQuickPicks(catalog) {
67
67
 
68
68
  /**
69
69
  * Seed map for fresh configs: static defaults overlaid with live family
70
- * openrouter routes (cardless aliases stay pinned).
70
+ * routes (cardless aliases stay pinned). The overlaid route is run through
71
+ * `toCanonicalDefault` so a direct-capable vendor (e.g. google, openai)
72
+ * lands as bare `vendor/model` (direct-first via the gateway router)
73
+ * instead of the raw `openrouter/<vendor>/<rest>` pick — otherwise a fresh
74
+ * `amicus setup` with a live catalog would silently defeat the direct-first
75
+ * default `toDefaultAliases()` establishes. Gateway-only vendors are
76
+ * returned unchanged by `toCanonicalDefault`.
71
77
  * @returns {Object<string,string>}
72
78
  */
73
79
  function toLiveSeedAliases(catalog) {
74
80
  const seeds = toDefaultAliases();
75
81
  for (const r of resolveQuickPicks(catalog || [])) {
76
- if (r.source === 'live' && r.routes.openrouter) { seeds[r.alias] = r.routes.openrouter; }
82
+ if (r.source === 'live' && r.routes.openrouter) {
83
+ seeds[r.alias] = toCanonicalDefault(r.routes.openrouter);
84
+ }
77
85
  }
78
86
  return seeds;
79
87
  }
@@ -0,0 +1,98 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * @module result-schema-rebuild
5
+ * Rebuild run/wave documents from persisted session directories (as opposed to
6
+ * `result-schema.js`'s builders, which assemble a document from in-memory
7
+ * state right after a run/wave finishes). Split out of result-schema.js to
8
+ * stay under the 300-line size gate (#61 whole-branch review housekeeping) —
9
+ * re-exported from result-schema.js so every existing caller's import path is
10
+ * unaffected.
11
+ *
12
+ * `buildRunResult`/`buildWaveResult` are required LAZILY inside each function
13
+ * body (not at module load time) so this file can depend on result-schema.js
14
+ * without a circular-require ordering hazard: result-schema.js requires this
15
+ * module too (to re-export these two functions), and a top-level require here
16
+ * would see result-schema.js's exports mid-assembly (see result-schema.js's
17
+ * module doc for why abort-result.js already avoids the same trap).
18
+ */
19
+
20
+ /**
21
+ * Rebuild a run document from a persisted session directory.
22
+ * @param {string} project - Project dir
23
+ * @param {string} taskId
24
+ * @returns {object} run document
25
+ * @throws {Error} if the session does not exist or metadata.json is missing/corrupt
26
+ */
27
+ function buildRunResultFromSession(project, taskId) {
28
+ const fs = require('fs');
29
+ const path = require('path');
30
+ const { resolveExistingSessionDir } = require('../session-manager');
31
+ const { buildRunResult } = require('./result-schema');
32
+ const sessionDir = resolveExistingSessionDir(project, taskId);
33
+ const metaPath = path.join(sessionDir, 'metadata.json');
34
+ if (!fs.existsSync(metaPath)) {
35
+ throw new Error(`Session ${taskId} not found`);
36
+ }
37
+ let metadata;
38
+ try {
39
+ metadata = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
40
+ } catch (err) {
41
+ throw new Error(`Session ${taskId}: metadata is corrupt (${err.message})`);
42
+ }
43
+ const summaryPath = path.join(sessionDir, 'summary.md');
44
+ const summary = fs.existsSync(summaryPath) ? fs.readFileSync(summaryPath, 'utf-8') : null;
45
+ return buildRunResult({ taskId, metadata, summary, sessionDir });
46
+ }
47
+
48
+ /**
49
+ * Rebuild a wave document. Prefers the stored wave.json (written atomically at
50
+ * fanout exit); falls back to a live rebuild from leg sessions (e.g. after a
51
+ * hard kill of the fanout process).
52
+ * @param {string} project
53
+ * @param {string} waveId
54
+ * @returns {object} wave document
55
+ * @throws {Error} if the wave session does not exist or metadata.json is missing/corrupt
56
+ */
57
+ function buildWaveResultFromSession(project, waveId) {
58
+ const fs = require('fs');
59
+ const path = require('path');
60
+ const { resolveExistingSessionDir } = require('../session-manager');
61
+ const { buildRunResult, buildWaveResult } = require('./result-schema');
62
+ const waveDir = resolveExistingSessionDir(project, waveId);
63
+ const wavePath = path.join(waveDir, 'wave.json');
64
+ if (fs.existsSync(wavePath)) {
65
+ try {
66
+ return JSON.parse(fs.readFileSync(wavePath, 'utf-8'));
67
+ } catch {
68
+ // Corrupt wave.json (e.g. hard-kill mid-write) — fall through to live rebuild
69
+ }
70
+ }
71
+ const metaPath = path.join(waveDir, 'metadata.json');
72
+ if (!fs.existsSync(metaPath)) {
73
+ throw new Error(`Wave ${waveId} not found`);
74
+ }
75
+ let meta;
76
+ try {
77
+ meta = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
78
+ } catch (err) {
79
+ throw new Error(`Wave ${waveId}: metadata is corrupt (${err.message})`);
80
+ }
81
+ const legs = (meta.legs || []).map((legId) => {
82
+ try { return buildRunResultFromSession(project, legId); }
83
+ catch (err) {
84
+ const { logger } = require('./logger');
85
+ logger.warn('Failed to rebuild leg session; using unknown stub', { legId, error: err.message });
86
+ return buildRunResult({ taskId: legId, metadata: { status: 'unknown', parentWave: waveId } });
87
+ }
88
+ });
89
+ return buildWaveResult({
90
+ waveId,
91
+ legs,
92
+ promptMeta: meta.promptMeta || null,
93
+ createdAt: meta.createdAt || null,
94
+ completedAt: meta.completedAt || null,
95
+ });
96
+ }
97
+
98
+ module.exports = { buildRunResultFromSession, buildWaveResultFromSession };
@@ -121,9 +121,10 @@ function waveExitCode(waveStatus) {
121
121
  * @param {string|null} [opts.createdAt]
122
122
  * @param {string|null} [opts.completedAt]
123
123
  * @param {string|null} [opts.status] - Override (e.g. 'aborted' on signal); default aggregates legs
124
+ * @param {string[]} [opts.notices] - Advisory per-leg migration notices (#61 FIX 2); never affects status/exitCode.
124
125
  * @returns {object} wave document
125
126
  */
126
- function buildWaveResult({ waveId, legs = [], promptMeta = null, createdAt = null, completedAt = null, status = null }) {
127
+ function buildWaveResult({ waveId, legs = [], promptMeta = null, createdAt = null, completedAt = null, status = null, notices = [] }) {
127
128
  const { sumWaveUsage } = require('./pricing');
128
129
  // Named buckets only (see "COUNTS REMAINDER RULE" above). 'crashed' and
129
130
  // 'idle-timeout' legs are intentionally NOT bucketed — they land in `total`
@@ -149,84 +150,13 @@ function buildWaveResult({ waveId, legs = [], promptMeta = null, createdAt = nul
149
150
  completedAt,
150
151
  durationMs,
151
152
  usage: sumWaveUsage(legs),
153
+ notices: Array.isArray(notices) ? notices.filter(Boolean) : [],
152
154
  };
153
155
  }
154
156
 
155
- /**
156
- * Rebuild a run document from a persisted session directory.
157
- * @param {string} project - Project dir
158
- * @param {string} taskId
159
- * @returns {object} run document
160
- * @throws {Error} if the session does not exist or metadata.json is missing/corrupt
161
- */
162
- function buildRunResultFromSession(project, taskId) {
163
- const fs = require('fs');
164
- const path = require('path');
165
- const { resolveExistingSessionDir } = require('../session-manager');
166
- const sessionDir = resolveExistingSessionDir(project, taskId);
167
- const metaPath = path.join(sessionDir, 'metadata.json');
168
- if (!fs.existsSync(metaPath)) {
169
- throw new Error(`Session ${taskId} not found`);
170
- }
171
- let metadata;
172
- try {
173
- metadata = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
174
- } catch (err) {
175
- throw new Error(`Session ${taskId}: metadata is corrupt (${err.message})`);
176
- }
177
- const summaryPath = path.join(sessionDir, 'summary.md');
178
- const summary = fs.existsSync(summaryPath) ? fs.readFileSync(summaryPath, 'utf-8') : null;
179
- return buildRunResult({ taskId, metadata, summary, sessionDir });
180
- }
181
-
182
- /**
183
- * Rebuild a wave document. Prefers the stored wave.json (written atomically at
184
- * fanout exit); falls back to a live rebuild from leg sessions (e.g. after a
185
- * hard kill of the fanout process).
186
- * @param {string} project
187
- * @param {string} waveId
188
- * @returns {object} wave document
189
- * @throws {Error} if the wave session does not exist or metadata.json is missing/corrupt
190
- */
191
- function buildWaveResultFromSession(project, waveId) {
192
- const fs = require('fs');
193
- const path = require('path');
194
- const { resolveExistingSessionDir } = require('../session-manager');
195
- const waveDir = resolveExistingSessionDir(project, waveId);
196
- const wavePath = path.join(waveDir, 'wave.json');
197
- if (fs.existsSync(wavePath)) {
198
- try {
199
- return JSON.parse(fs.readFileSync(wavePath, 'utf-8'));
200
- } catch {
201
- // Corrupt wave.json (e.g. hard-kill mid-write) — fall through to live rebuild
202
- }
203
- }
204
- const metaPath = path.join(waveDir, 'metadata.json');
205
- if (!fs.existsSync(metaPath)) {
206
- throw new Error(`Wave ${waveId} not found`);
207
- }
208
- let meta;
209
- try {
210
- meta = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
211
- } catch (err) {
212
- throw new Error(`Wave ${waveId}: metadata is corrupt (${err.message})`);
213
- }
214
- const legs = (meta.legs || []).map((legId) => {
215
- try { return buildRunResultFromSession(project, legId); }
216
- catch (err) {
217
- const { logger } = require('./logger');
218
- logger.warn('Failed to rebuild leg session; using unknown stub', { legId, error: err.message });
219
- return buildRunResult({ taskId: legId, metadata: { status: 'unknown', parentWave: waveId } });
220
- }
221
- });
222
- return buildWaveResult({
223
- waveId,
224
- legs,
225
- promptMeta: meta.promptMeta || null,
226
- createdAt: meta.createdAt || null,
227
- completedAt: meta.completedAt || null,
228
- });
229
- }
157
+ // buildRunResultFromSession/buildWaveResultFromSession live in
158
+ // ./result-schema-rebuild.js (size-gate split); re-exported below.
159
+ const { buildRunResultFromSession, buildWaveResultFromSession } = require('./result-schema-rebuild');
230
160
 
231
161
  /**
232
162
  * Build a model-catalog document (`models [--search] [--refresh] --json`).
@@ -0,0 +1,137 @@
1
+ /**
2
+ * @module route-error
3
+ * Shared renderer (#61 Task 6.1): turns a router RouteResult — an error or a
4
+ * selection_required — into the two surfaces that need to explain it:
5
+ * - `toStructuredError` -> the MCP-facing structured object
6
+ * - `toCliMessage` -> a human stderr string for the CLI
7
+ *
8
+ * Pure module: no I/O, no requires of launch modules (cli.js/headless.js/
9
+ * mcp-server.js/etc). Additive only — not imported by any launch path yet;
10
+ * wiring is a later task in the #61 Integration plan.
11
+ *
12
+ * Router error shape (src/utils/model-descriptor.js `routeError()`):
13
+ * {kind:'error', type:'model_route_error', field, requested, reason,
14
+ * preferredGateway, suggestions}
15
+ * Selection shape (`selectionRequired()`):
16
+ * {kind:'selection_required', requested, suggestions}
17
+ *
18
+ * The router's error `reason` is a closed set of 7 values (ROUTE_ERROR_REASONS
19
+ * below). A `selection_required` result has no `reason` of its own — it is
20
+ * synthesized here as SELECTION_REQUIRED_REASON, kept in the same documented
21
+ * REASON_TEXT map rather than invented ad hoc, so callers can treat every
22
+ * rendered structured error the same way regardless of which RouteResult
23
+ * produced it.
24
+ */
25
+ 'use strict';
26
+
27
+ /** The closed set of reasons a router `error` result can carry. */
28
+ const ROUTE_ERROR_REASONS = Object.freeze([
29
+ 'gateway_conflict',
30
+ 'no_openrouter_key',
31
+ 'no_direct_integration',
32
+ 'no_direct_key',
33
+ 'no_key_for_vendor',
34
+ 'model_not_found',
35
+ 'invalid_descriptor',
36
+ ]);
37
+
38
+ /**
39
+ * Synthesized reason for a `selection_required` RouteResult. Deliberately
40
+ * distinct from 'model_not_found': the model wasn't missing, it was ambiguous
41
+ * (multiple catalog candidates) and the router is asking the caller to pick.
42
+ */
43
+ const SELECTION_REQUIRED_REASON = 'selection_required';
44
+
45
+ /** One-line, non-technical explanation of what went wrong, keyed by reason. */
46
+ const REASON_TEXT = Object.freeze({
47
+ gateway_conflict: 'This model must go through OpenRouter, but --gateway direct was forced.',
48
+ no_openrouter_key: 'No OpenRouter API key is configured.',
49
+ no_direct_integration: 'This vendor has no direct API integration.',
50
+ no_direct_key: "No API key is configured for this vendor's direct API.",
51
+ no_key_for_vendor: 'No API key was found for this vendor via any gateway.',
52
+ model_not_found: 'The requested model was not found in the catalog.',
53
+ invalid_descriptor: 'The model identifier could not be parsed.',
54
+ [SELECTION_REQUIRED_REASON]: 'Multiple models match your request; a specific one must be selected.',
55
+ });
56
+
57
+ /** Copy-paste fix guidance appended after the REASON_TEXT sentence. */
58
+ const FIX_HINTS = Object.freeze({
59
+ gateway_conflict: "An openrouter/... model can't be forced with --gateway direct.",
60
+ no_openrouter_key: 'Set OPENROUTER_API_KEY, or use --gateway direct.',
61
+ no_direct_integration: 'This vendor has no direct integration; drop --gateway direct.',
62
+ no_direct_key: 'Add a key with `amicus key <vendor> <key>`, or use --gateway openrouter.',
63
+ no_key_for_vendor: 'Add a provider key or an OpenRouter key.',
64
+ model_not_found: 'Run `amicus models --refresh`, or pass --no-validate-model.',
65
+ invalid_descriptor: 'Use a vendor/model id or a configured alias.',
66
+ [SELECTION_REQUIRED_REASON]: 'Pick one of the suggestions below, or narrow the model id.',
67
+ });
68
+
69
+ /** @returns {Array} suggestions normalized to an array. */
70
+ function normalizeSuggestions(suggestions) {
71
+ return Array.isArray(suggestions) ? suggestions : [];
72
+ }
73
+
74
+ /**
75
+ * Render a router RouteResult (error or selection_required) into the
76
+ * MCP-facing structured object. Pass-through/normalize for an `error` result;
77
+ * synthesized for a `selection_required` result.
78
+ * @param {object} result a RouteResult with kind 'error' or 'selection_required'
79
+ * @returns {{type:'model_route_error', field:string, requested:*, reason:string,
80
+ * preferredGateway:(string|null), suggestions:Array}}
81
+ */
82
+ function toStructuredError(result) {
83
+ const r = result || {};
84
+ if (r.kind === 'selection_required') {
85
+ return {
86
+ type: 'model_route_error',
87
+ field: 'model',
88
+ requested: r.requested,
89
+ reason: SELECTION_REQUIRED_REASON,
90
+ preferredGateway: null,
91
+ suggestions: normalizeSuggestions(r.suggestions),
92
+ };
93
+ }
94
+ // Router `error` result (kind:'error'): pass through/normalize.
95
+ return {
96
+ type: 'model_route_error',
97
+ field: r.field || 'model',
98
+ requested: r.requested,
99
+ reason: r.reason,
100
+ preferredGateway: r.preferredGateway || null,
101
+ suggestions: normalizeSuggestions(r.suggestions),
102
+ };
103
+ }
104
+
105
+ /**
106
+ * Render a router RouteResult into a human stderr message: the reason's
107
+ * one-line explanation, an optional "Did you mean" suggestion list, and a
108
+ * fix hint.
109
+ * @param {object} result a RouteResult with kind 'error' or 'selection_required'
110
+ * @returns {string}
111
+ */
112
+ function toCliMessage(result) {
113
+ const err = toStructuredError(result);
114
+ const sentence = REASON_TEXT[err.reason] || `Model routing error (${err.reason}).`;
115
+ const lines = [err.requested ? `${sentence} (requested "${err.requested}")` : sentence];
116
+
117
+ if (err.suggestions.length > 0) {
118
+ lines.push('Did you mean:');
119
+ for (const s of err.suggestions) {
120
+ const note = s && s.note ? ` — ${s.note}` : '';
121
+ lines.push(` - ${s && s.model} (${s && s.gateway})${note}`);
122
+ }
123
+ }
124
+
125
+ const hint = FIX_HINTS[err.reason];
126
+ if (hint) { lines.push(hint); }
127
+
128
+ return lines.join('\n');
129
+ }
130
+
131
+ module.exports = {
132
+ toStructuredError,
133
+ toCliMessage,
134
+ REASON_TEXT,
135
+ ROUTE_ERROR_REASONS,
136
+ SELECTION_REQUIRED_REASON,
137
+ };