@maestrofrontier/frontier 1.4.4 → 1.5.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 (53) hide show
  1. package/.agents/plugins/marketplace.json +21 -0
  2. package/.codex-plugin/plugin.json +29 -0
  3. package/.cursorrules +197 -194
  4. package/AGENTS.md +214 -214
  5. package/CLAUDE.md +29 -29
  6. package/README.md +368 -278
  7. package/bin/maestro.cjs +75 -75
  8. package/commands/compress.md +36 -36
  9. package/commands/frontier.md +124 -124
  10. package/commands/terse.md +23 -23
  11. package/docs/codex.md +167 -98
  12. package/docs/orchestration.md +168 -168
  13. package/frontier/cli.cjs +279 -248
  14. package/frontier/config.cjs +468 -441
  15. package/frontier/dispatch.cjs +267 -255
  16. package/frontier/judge.cjs +92 -92
  17. package/frontier/run.cjs +201 -148
  18. package/frontier/schema.cjs +112 -112
  19. package/frontier/semaphore.cjs +49 -49
  20. package/frontier/synthesize.cjs +79 -79
  21. package/hooks/frontier-autorun.cjs +127 -124
  22. package/hooks/hooks.json +103 -103
  23. package/hooks/maestro-doctrine-guard.cjs +81 -81
  24. package/hooks/maestro-gate-reminder.cjs +22 -7
  25. package/hooks/maestro-gate-telemetry.cjs +79 -77
  26. package/hooks/maestro-phase-scope.cjs +118 -118
  27. package/hooks/maestro-statusline-sync.cjs +152 -152
  28. package/hooks/maestro-subagent-guard.cjs +148 -148
  29. package/hooks/maestro-terse-mode.cjs +189 -189
  30. package/hooks/maestro-toolbudget-advisory.cjs +127 -127
  31. package/integrations/README.md +111 -94
  32. package/integrations/cline/skills/frontier/SKILL.md +75 -75
  33. package/integrations/codex/prompts/frontier.md +70 -66
  34. package/integrations/codex/prompts/update.md +39 -36
  35. package/integrations/codex/skills/maestro-frontier/SKILL.md +122 -0
  36. package/integrations/codex/skills/{settings → maestro-settings}/SKILL.md +55 -46
  37. package/integrations/codex/skills/{terse → maestro-terse}/SKILL.md +58 -49
  38. package/integrations/codex/skills/maestro-update/SKILL.md +31 -0
  39. package/integrations/cursor/commands/frontier.md +63 -63
  40. package/integrations/cursor/commands/update.md +34 -34
  41. package/integrations/gemini/commands/frontier.toml +76 -76
  42. package/integrations/windsurf/workflows/frontier.md +70 -70
  43. package/package.json +58 -55
  44. package/scripts/install.cjs +1014 -605
  45. package/settings/cli.cjs +140 -140
  46. package/settings/config.cjs +309 -309
  47. package/skills/maestro-frontier/SKILL.md +122 -0
  48. package/skills/maestro-settings/SKILL.md +55 -0
  49. package/skills/maestro-terse/SKILL.md +58 -0
  50. package/skills/maestro-update/SKILL.md +31 -0
  51. package/skills/terse/SKILL.md +74 -0
  52. package/integrations/codex/skills/frontier/SKILL.md +0 -91
  53. package/integrations/codex/skills/update/SKILL.md +0 -29
@@ -1,92 +1,92 @@
1
- #!/usr/bin/env node
2
- // Maestro Frontier — judge stage: build prompt + invoke Opus to produce Analysis.
3
-
4
- 'use strict';
5
-
6
- const { parseAnalysis } = require('./schema.cjs');
7
- const dispatch = require('./dispatch.cjs');
8
-
9
- /**
10
- * Build the judge prompt for Opus.
11
- * @param {string} userPrompt
12
- * @param {import('./schema.cjs').PanelResponse[]} responses
13
- * @param {object} cfg
14
- * @returns {string}
15
- */
16
- function buildJudgePrompt(userPrompt, responses, cfg) {
17
- const sections = responses.map(
18
- r => `### Response from ${r.model}\n${r.content}`
19
- ).join('\n\n');
20
-
21
- const domainNote = (cfg.excluded_domains && cfg.excluded_domains.length > 0)
22
- ? `\nDisregard any claims sourced only from the following domains: ${cfg.excluded_domains.join(', ')}.\n`
23
- : '';
24
-
25
- return `You are a neutral JUDGE evaluating multiple AI panel responses to a user question.
26
-
27
- USER QUESTION:
28
- ${userPrompt}
29
-
30
- PANEL RESPONSES:
31
- ${sections}
32
- ${domainNote}
33
- INSTRUCTIONS:
34
- COMPARE the panel responses — do NOT merge or summarize them. Perform a structured analysis:
35
- - consensus: points that all or most models agree on
36
- - contradictions: topics where models disagree, with each model's specific stance
37
- - partial_coverage: points that only some models covered (with which models)
38
- - unique_insights: insights raised by exactly one model (with which model)
39
- - blind_spots: important points that NO model addressed
40
-
41
- OUTPUT IS ONLY a single JSON object with EXACTLY these keys:
42
- consensus (string[])
43
- contradictions (array of {topic: string, stances: [{model: string, stance: string}]})
44
- partial_coverage (array of {models: string[], point: string})
45
- unique_insights (array of {model: string, insight: string})
46
- blind_spots (string[])
47
-
48
- No prose before or after. No markdown fence. No extra keys. Output the raw JSON object only.`;
49
- }
50
-
51
- /**
52
- * Run the judge stage. Returns Analysis or undefined on any failure (degrades gracefully).
53
- * @param {string} userPrompt
54
- * @param {import('./schema.cjs').PanelResponse[]} responses
55
- * @param {object} cfg
56
- * @param {{ spawn?: Function }} [deps]
57
- * @returns {Promise<import('./schema.cjs').Analysis | undefined>}
58
- */
59
- async function runJudge(userPrompt, responses, cfg, deps) {
60
- const spawn = (deps && deps.spawn) || dispatch.spawnOne;
61
- let r;
62
- try {
63
- r = await spawn(
64
- buildJudgePrompt(userPrompt, responses, cfg),
65
- cfg.adapters[cfg.judgeModel],
66
- { timeoutMs: cfg.timeoutMs, fusionDepth: 1 }
67
- );
68
- } catch {
69
- return undefined;
70
- }
71
-
72
- if (!r || !r.ok || !r.content) return undefined;
73
-
74
- // Primary parse
75
- try {
76
- return parseAnalysis(r.content);
77
- } catch {
78
- // substring recovery: find outermost { ... }
79
- const first = r.content.indexOf('{');
80
- const last = r.content.lastIndexOf('}');
81
- if (first !== -1 && last > first) {
82
- try {
83
- return parseAnalysis(r.content.slice(first, last + 1));
84
- } catch {
85
- return undefined;
86
- }
87
- }
88
- return undefined;
89
- }
90
- }
91
-
92
- module.exports = { buildJudgePrompt, runJudge };
1
+ #!/usr/bin/env node
2
+ // Maestro Frontier — judge stage: build prompt + invoke Opus to produce Analysis.
3
+
4
+ 'use strict';
5
+
6
+ const { parseAnalysis } = require('./schema.cjs');
7
+ const dispatch = require('./dispatch.cjs');
8
+
9
+ /**
10
+ * Build the judge prompt for Opus.
11
+ * @param {string} userPrompt
12
+ * @param {import('./schema.cjs').PanelResponse[]} responses
13
+ * @param {object} cfg
14
+ * @returns {string}
15
+ */
16
+ function buildJudgePrompt(userPrompt, responses, cfg) {
17
+ const sections = responses.map(
18
+ r => `### Response from ${r.model}\n${r.content}`
19
+ ).join('\n\n');
20
+
21
+ const domainNote = (cfg.excluded_domains && cfg.excluded_domains.length > 0)
22
+ ? `\nDisregard any claims sourced only from the following domains: ${cfg.excluded_domains.join(', ')}.\n`
23
+ : '';
24
+
25
+ return `You are a neutral JUDGE evaluating multiple AI panel responses to a user question.
26
+
27
+ USER QUESTION:
28
+ ${userPrompt}
29
+
30
+ PANEL RESPONSES:
31
+ ${sections}
32
+ ${domainNote}
33
+ INSTRUCTIONS:
34
+ COMPARE the panel responses — do NOT merge or summarize them. Perform a structured analysis:
35
+ - consensus: points that all or most models agree on
36
+ - contradictions: topics where models disagree, with each model's specific stance
37
+ - partial_coverage: points that only some models covered (with which models)
38
+ - unique_insights: insights raised by exactly one model (with which model)
39
+ - blind_spots: important points that NO model addressed
40
+
41
+ OUTPUT IS ONLY a single JSON object with EXACTLY these keys:
42
+ consensus (string[])
43
+ contradictions (array of {topic: string, stances: [{model: string, stance: string}]})
44
+ partial_coverage (array of {models: string[], point: string})
45
+ unique_insights (array of {model: string, insight: string})
46
+ blind_spots (string[])
47
+
48
+ No prose before or after. No markdown fence. No extra keys. Output the raw JSON object only.`;
49
+ }
50
+
51
+ /**
52
+ * Run the judge stage. Returns Analysis or undefined on any failure (degrades gracefully).
53
+ * @param {string} userPrompt
54
+ * @param {import('./schema.cjs').PanelResponse[]} responses
55
+ * @param {object} cfg
56
+ * @param {{ spawn?: Function }} [deps]
57
+ * @returns {Promise<import('./schema.cjs').Analysis | undefined>}
58
+ */
59
+ async function runJudge(userPrompt, responses, cfg, deps) {
60
+ const spawn = (deps && deps.spawn) || dispatch.spawnOne;
61
+ let r;
62
+ try {
63
+ r = await spawn(
64
+ buildJudgePrompt(userPrompt, responses, cfg),
65
+ cfg.adapters[cfg.judgeModel],
66
+ { timeoutMs: cfg.timeoutMs, fusionDepth: 1 }
67
+ );
68
+ } catch {
69
+ return undefined;
70
+ }
71
+
72
+ if (!r || !r.ok || !r.content) return undefined;
73
+
74
+ // Primary parse
75
+ try {
76
+ return parseAnalysis(r.content);
77
+ } catch {
78
+ // substring recovery: find outermost { ... }
79
+ const first = r.content.indexOf('{');
80
+ const last = r.content.lastIndexOf('}');
81
+ if (first !== -1 && last > first) {
82
+ try {
83
+ return parseAnalysis(r.content.slice(first, last + 1));
84
+ } catch {
85
+ return undefined;
86
+ }
87
+ }
88
+ return undefined;
89
+ }
90
+ }
91
+
92
+ module.exports = { buildJudgePrompt, runJudge };
package/frontier/run.cjs CHANGED
@@ -1,148 +1,201 @@
1
- #!/usr/bin/env node
2
- // Maestro Frontier — mode router (off / single / fusion).
3
- // runFrontier({prompt, state, cfg, deps}) -> Promise<FusionResult>.
4
- //
5
- // Re-grounding note (Fable T2): the engine fans a prompt to model CLIs
6
- // that do NOT share this session's file/context. A member or synthesis
7
- // output asserting "I can't see X" / "no such file" reflects that
8
- // subprocess's blank context, not ground truth — re-ground such claims
9
- // against live context before relaying them to the user (AGENTS.md S7.7).
10
-
11
- 'use strict';
12
-
13
- const { DEFAULTS } = require('./config.cjs');
14
- const { resolvePanel, resolveJudgeModel, resolveSynthModel } = require('./config.cjs');
15
- const { classify, toFailedModel } = require('./schema.cjs');
16
- const dispatch = require('./dispatch.cjs');
17
- const judge = require('./judge.cjs');
18
- const synthesize = require('./synthesize.cjs');
19
-
20
- /**
21
- * @param {{ prompt:string, state:object, cfg?:object, deps?:object }} opts
22
- * @returns {Promise<object>}
23
- */
24
- async function runFrontier({ prompt, state, cfg, deps }) {
25
- cfg = cfg || DEFAULTS;
26
- deps = deps || {};
27
-
28
- const spawnOne = deps.spawnOne || dispatch.spawnOne;
29
- const fanOut = deps.fanOut || dispatch.fanOut;
30
- const runJudge = deps.runJudge || judge.runJudge;
31
- const runSynth = deps.runSynth || synthesize.runSynth;
32
-
33
- const rawDepth = parseInt(process.env.FUSION_DEPTH || '0', 10);
34
- const depth = isNaN(rawDepth) ? 0 : rawDepth;
35
-
36
- // ---- OFF ----
37
- if (state.mode === 'off') {
38
- return { status: 'off', mode: 'off', final: null };
39
- }
40
-
41
- // ---- RECURSION GUARD (single + fusion) ----
42
- if (depth >= 1) {
43
- const base = {
44
- status: 'error',
45
- mode: state.mode,
46
- error: 'fusion depth exceeded (one-level cap)',
47
- failure_reason: 'fusion_invocation_capped',
48
- };
49
- if (state.mode === 'fusion') return { ...base, preset: state.preset };
50
- if (state.mode === 'single') return { ...base, model: state.model };
51
- return base;
52
- }
53
-
54
- // ---- SINGLE ----
55
- if (state.mode === 'single') {
56
- const adapter = cfg.adapters && cfg.adapters[state.model];
57
- if (!adapter) {
58
- return {
59
- status: 'error',
60
- mode: 'single',
61
- model: state.model,
62
- error: 'unknown model: ' + state.model,
63
- failure_reason: 'unexpected_error',
64
- };
65
- }
66
- const resp = await spawnOne(prompt, adapter, { timeoutMs: cfg.timeoutMs, fusionDepth: depth + 1 });
67
- if (!resp.ok) {
68
- return {
69
- status: 'error',
70
- mode: 'single',
71
- model: state.model,
72
- error: resp.error,
73
- failure_reason: classify([toFailedModel(resp)]),
74
- };
75
- }
76
- return { status: 'ok', mode: 'single', model: state.model, final: resp.content, response: resp };
77
- }
78
-
79
- // ---- FUSION ----
80
- if (state.mode === 'fusion') {
81
- // resolve panel
82
- let panelIds;
83
- try {
84
- panelIds = resolvePanel(state, cfg);
85
- } catch (e) {
86
- return {
87
- status: 'error',
88
- mode: 'fusion',
89
- preset: state.preset,
90
- error: e.message,
91
- failure_reason: 'unexpected_error',
92
- };
93
- }
94
-
95
- // BUDGET (opt-in)
96
- if (cfg.tokenBudget && cfg.tokenBudget > 0) {
97
- const projected = Math.ceil(prompt.length / 4) * panelIds.length;
98
- if (projected > cfg.tokenBudget) {
99
- return {
100
- status: 'error',
101
- mode: 'fusion',
102
- preset: state.preset,
103
- error: 'projected token budget exceeded (' + projected + '>' + cfg.tokenBudget + ')',
104
- failure_reason: 'unexpected_error',
105
- };
106
- }
107
- }
108
-
109
- const panel = await fanOut(prompt, panelIds, cfg, { fusionDepth: depth + 1 });
110
- const ok = panel.filter(p => p.ok);
111
- const failed = panel.filter(p => !p.ok);
112
-
113
- if (ok.length === 0) {
114
- return {
115
- status: 'error',
116
- mode: 'fusion',
117
- preset: state.preset,
118
- error: 'all panels failed',
119
- failure_reason: classify(failed.map(toFailedModel)),
120
- };
121
- }
122
-
123
- // Resolve judge/synth model (explicit flag -> preset override -> default)
124
- // and hand the judge/synth stages a cfg pinned to those models, so a
125
- // preset like gpt-duo runs entirely on its own provider.
126
- const stageCfg = {
127
- ...cfg,
128
- judgeModel: resolveJudgeModel(state, cfg),
129
- synthModel: resolveSynthModel(state, cfg),
130
- };
131
- const analysis = await runJudge(prompt, ok, stageCfg);
132
- let final = await runSynth(prompt, { analysis, responses: ok }, stageCfg);
133
- if (!final) {
134
- // synth-fail fallback: longest ok response
135
- final = ok.reduce((a, b) => b.content.length > a.content.length ? b : a).content;
136
- }
137
-
138
- const result = { status: 'ok', mode: 'fusion', preset: state.preset, final, responses: ok };
139
- if (analysis !== undefined) result.analysis = analysis;
140
- if (failed.length) result.failed_models = failed.map(toFailedModel);
141
- return result;
142
- }
143
-
144
- // ---- UNKNOWN MODE ----
145
- return { status: 'error', mode: state.mode, error: 'unknown mode', failure_reason: 'unexpected_error' };
146
- }
147
-
148
- module.exports = { runFrontier };
1
+ #!/usr/bin/env node
2
+ // Maestro Frontier — mode router (off / single / fusion).
3
+ // runFrontier({prompt, state, cfg, deps}) -> Promise<FusionResult>.
4
+ //
5
+ // Re-grounding note (Fable T2): the engine fans a prompt to model CLIs
6
+ // that do NOT share this session's file/context. A member or synthesis
7
+ // output asserting "I can't see X" / "no such file" reflects that
8
+ // subprocess's blank context, not ground truth — re-ground such claims
9
+ // against live context before relaying them to the user (AGENTS.md S7.7).
10
+
11
+ 'use strict';
12
+
13
+ const { DEFAULTS } = require('./config.cjs');
14
+ const { resolvePanel, resolveJudgeModel, resolveSynthModel } = require('./config.cjs');
15
+ const { classify, toFailedModel } = require('./schema.cjs');
16
+ const dispatch = require('./dispatch.cjs');
17
+ const judge = require('./judge.cjs');
18
+ const synthesize = require('./synthesize.cjs');
19
+
20
+ const MODEL_ALIASES = {
21
+ chatgpt: 'gpt-5.5',
22
+ };
23
+
24
+ const PRESET_ALIASES = {
25
+ 'chatgpt-duo': 'gpt-duo',
26
+ };
27
+
28
+ /** @param {string} model @returns {string} */
29
+ function canonicalModelId(model) {
30
+ return MODEL_ALIASES[model] || model;
31
+ }
32
+
33
+ /** @param {string} preset @returns {string} */
34
+ function canonicalPresetId(preset) {
35
+ return PRESET_ALIASES[preset] || preset;
36
+ }
37
+
38
+ /** @param {object} state @returns {object} */
39
+ function normalizeStateAliases(state) {
40
+ const normalized = { ...state };
41
+ if (normalized.model) normalized.model = canonicalModelId(normalized.model);
42
+ if (normalized.preset) normalized.preset = canonicalPresetId(normalized.preset);
43
+ if (Array.isArray(normalized.models)) {
44
+ normalized.models = normalized.models.map(canonicalModelId);
45
+ }
46
+ if (normalized.judgeModel) normalized.judgeModel = canonicalModelId(normalized.judgeModel);
47
+ if (normalized.synthModel) normalized.synthModel = canonicalModelId(normalized.synthModel);
48
+ return normalized;
49
+ }
50
+
51
+ /**
52
+ * @param {{ prompt:string, state:object, cfg?:object, deps?:object }} opts
53
+ * @returns {Promise<object>}
54
+ */
55
+ async function runFrontier({ prompt, state, cfg, deps }) {
56
+ cfg = cfg || DEFAULTS;
57
+ deps = deps || {};
58
+ state = normalizeStateAliases(state || {});
59
+
60
+ const spawnOne = deps.spawnOne || dispatch.spawnOne;
61
+ const fanOut = deps.fanOut || dispatch.fanOut;
62
+ const runJudge = deps.runJudge || judge.runJudge;
63
+ const runSynth = deps.runSynth || synthesize.runSynth;
64
+ const onProgress = (deps && typeof deps.onProgress === 'function') ? deps.onProgress : null;
65
+
66
+ const startMs = Date.now();
67
+
68
+ /** Emit a progress event; swallows any error thrown by the callback. */
69
+ function emit(eventObj) {
70
+ if (!onProgress) return;
71
+ try { onProgress(eventObj); } catch (_) {}
72
+ }
73
+
74
+ const rawDepth = parseInt(process.env.FUSION_DEPTH || '0', 10);
75
+ const depth = isNaN(rawDepth) ? 0 : rawDepth;
76
+
77
+ // ---- OFF ----
78
+ if (state.mode === 'off') {
79
+ return { status: 'off', mode: 'off', final: null };
80
+ }
81
+
82
+ // ---- RECURSION GUARD (single + fusion) ----
83
+ if (depth >= 1) {
84
+ const base = {
85
+ status: 'error',
86
+ mode: state.mode,
87
+ error: 'fusion depth exceeded (one-level cap)',
88
+ failure_reason: 'fusion_invocation_capped',
89
+ };
90
+ if (state.mode === 'fusion') return { ...base, preset: state.preset };
91
+ if (state.mode === 'single') return { ...base, model: state.model };
92
+ return base;
93
+ }
94
+
95
+ // ---- SINGLE ----
96
+ if (state.mode === 'single') {
97
+ const adapter = cfg.adapters && cfg.adapters[state.model];
98
+ if (!adapter) {
99
+ return {
100
+ status: 'error',
101
+ mode: 'single',
102
+ model: state.model,
103
+ error: 'unknown model: ' + state.model,
104
+ failure_reason: 'unexpected_error',
105
+ };
106
+ }
107
+ emit({ phase: 'single-start', model: state.model });
108
+ const resp = await spawnOne(prompt, adapter, { timeoutMs: cfg.timeoutMs, fusionDepth: depth + 1 });
109
+ if (!resp.ok) {
110
+ return {
111
+ status: 'error',
112
+ mode: 'single',
113
+ model: state.model,
114
+ error: resp.error,
115
+ failure_reason: classify([toFailedModel(resp)]),
116
+ };
117
+ }
118
+ emit({ phase: 'done', models: 1, ms: Date.now() - startMs });
119
+ return { status: 'ok', mode: 'single', model: state.model, final: resp.content, response: resp };
120
+ }
121
+
122
+ // ---- FUSION ----
123
+ if (state.mode === 'fusion') {
124
+ // resolve panel
125
+ let panelIds;
126
+ try {
127
+ panelIds = resolvePanel(state, cfg);
128
+ } catch (e) {
129
+ return {
130
+ status: 'error',
131
+ mode: 'fusion',
132
+ preset: state.preset,
133
+ error: e.message,
134
+ failure_reason: 'unexpected_error',
135
+ };
136
+ }
137
+
138
+ // BUDGET (opt-in)
139
+ if (cfg.tokenBudget && cfg.tokenBudget > 0) {
140
+ const projected = Math.ceil(prompt.length / 4) * panelIds.length;
141
+ if (projected > cfg.tokenBudget) {
142
+ return {
143
+ status: 'error',
144
+ mode: 'fusion',
145
+ preset: state.preset,
146
+ error: 'projected token budget exceeded (' + projected + '>' + cfg.tokenBudget + ')',
147
+ failure_reason: 'unexpected_error',
148
+ };
149
+ }
150
+ }
151
+
152
+ emit({ phase: 'panel-start', models: panelIds });
153
+ const panel = await fanOut(prompt, panelIds, cfg, { fusionDepth: depth + 1, onProgress });
154
+ const ok = panel.filter(p => p.ok);
155
+ const failed = panel.filter(p => !p.ok);
156
+
157
+ emit({ phase: 'panel-done', ok: ok.length, failed: failed.length });
158
+
159
+ if (ok.length === 0) {
160
+ return {
161
+ status: 'error',
162
+ mode: 'fusion',
163
+ preset: state.preset,
164
+ error: 'all panels failed',
165
+ failure_reason: classify(failed.map(toFailedModel)),
166
+ };
167
+ }
168
+
169
+ // Resolve judge/synth model (explicit flag -> preset override -> default)
170
+ // and hand the judge/synth stages a cfg pinned to those models, so a
171
+ // preset like gpt-duo runs entirely on its own provider.
172
+ const stageCfg = {
173
+ ...cfg,
174
+ judgeModel: resolveJudgeModel(state, cfg),
175
+ synthModel: resolveSynthModel(state, cfg),
176
+ };
177
+ emit({ phase: 'judge-start', model: stageCfg.judgeModel });
178
+ const analysis = await runJudge(prompt, ok, stageCfg);
179
+ emit({ phase: 'synth-start', model: stageCfg.synthModel });
180
+ let final = await runSynth(prompt, { analysis, responses: ok }, stageCfg);
181
+ if (!final) {
182
+ // synth-fail fallback: longest ok response
183
+ final = ok.reduce((a, b) => b.content.length > a.content.length ? b : a).content;
184
+ }
185
+
186
+ if (failed.length > 0 && ok.length > 0) {
187
+ emit({ phase: 'degraded', failed: failed.length });
188
+ }
189
+ emit({ phase: 'done', models: ok.length, ms: Date.now() - startMs });
190
+
191
+ const result = { status: 'ok', mode: 'fusion', preset: state.preset, final, responses: ok };
192
+ if (analysis !== undefined) result.analysis = analysis;
193
+ if (failed.length) result.failed_models = failed.map(toFailedModel);
194
+ return result;
195
+ }
196
+
197
+ // ---- UNKNOWN MODE ----
198
+ return { status: 'error', mode: state.mode, error: 'unknown mode', failure_reason: 'unexpected_error' };
199
+ }
200
+
201
+ module.exports = { runFrontier, canonicalModelId, canonicalPresetId, normalizeStateAliases };