@maestrofrontier/frontier 1.4.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/AGENTS.md +214 -0
- package/CLAUDE.md +29 -0
- package/LICENSE +21 -0
- package/README.md +521 -0
- package/bin/maestro.cjs +75 -0
- package/commands/compress.md +36 -0
- package/commands/context-bar.md +30 -0
- package/commands/frontier.md +124 -0
- package/commands/settings.md +101 -0
- package/commands/terse.md +23 -0
- package/commands/update.md +59 -0
- package/docs/orchestration.md +168 -0
- package/frontier/cli.cjs +248 -0
- package/frontier/config.cjs +441 -0
- package/frontier/dispatch.cjs +255 -0
- package/frontier/judge.cjs +92 -0
- package/frontier/run.cjs +148 -0
- package/frontier/schema.cjs +112 -0
- package/frontier/semaphore.cjs +49 -0
- package/frontier/synthesize.cjs +79 -0
- package/hooks/frontier-autorun.cjs +124 -0
- package/hooks/hooks.json +103 -0
- package/hooks/maestro-doctrine-guard.cjs +81 -0
- package/hooks/maestro-gate-reminder.cjs +58 -0
- package/hooks/maestro-gate-telemetry.cjs +77 -0
- package/hooks/maestro-loop-guard.cjs +76 -0
- package/hooks/maestro-phase-scope.cjs +118 -0
- package/hooks/maestro-statusline-sync.cjs +152 -0
- package/hooks/maestro-subagent-guard.cjs +148 -0
- package/hooks/maestro-terse-mode.cjs +189 -0
- package/hooks/maestro-toolbudget-advisory.cjs +127 -0
- package/integrations/README.md +87 -0
- package/integrations/cline/skills/frontier/SKILL.md +75 -0
- package/integrations/codex/prompts/frontier.md +66 -0
- package/integrations/codex/prompts/update.md +36 -0
- package/integrations/cursor/commands/frontier.md +63 -0
- package/integrations/cursor/commands/update.md +34 -0
- package/integrations/gemini/commands/frontier.toml +76 -0
- package/integrations/windsurf/workflows/frontier.md +70 -0
- package/package.json +52 -0
- package/scripts/install.cjs +490 -0
- package/settings/cli.cjs +140 -0
- package/settings/config.cjs +309 -0
package/frontier/cli.cjs
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Maestro Frontier — CLI entrypoint. Subcommands: mode, status, run.
|
|
3
|
+
|
|
4
|
+
'use strict';
|
|
5
|
+
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
const { DEFAULTS, loadState, saveState, resolveScope, validateMode, validatePreset, validateModel, adoptLegacyState } = require('./config.cjs');
|
|
8
|
+
const { runFrontier } = require('./run.cjs');
|
|
9
|
+
|
|
10
|
+
// ---------- arg helpers ----------
|
|
11
|
+
|
|
12
|
+
function getFlag(argv, flag) {
|
|
13
|
+
const i = argv.indexOf(flag);
|
|
14
|
+
return i !== -1 && i + 1 < argv.length ? argv[i + 1] : null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function hasFlag(argv, flag) {
|
|
18
|
+
return argv.indexOf(flag) !== -1;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Strip --scope <value> from an argv array so it never leaks into prompts.
|
|
23
|
+
* @param {string[]} argv
|
|
24
|
+
* @returns {string[]}
|
|
25
|
+
*/
|
|
26
|
+
function stripScopeFlag(argv) {
|
|
27
|
+
const out = [];
|
|
28
|
+
let i = 0;
|
|
29
|
+
while (i < argv.length) {
|
|
30
|
+
if (argv[i] === '--scope') {
|
|
31
|
+
i += 2; // skip flag and its value
|
|
32
|
+
} else {
|
|
33
|
+
out.push(argv[i]);
|
|
34
|
+
i++;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return out;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ---------- usage ----------
|
|
41
|
+
|
|
42
|
+
function usage() {
|
|
43
|
+
process.stderr.write(
|
|
44
|
+
'Usage:\n' +
|
|
45
|
+
' frontier mode <off|single|fusion> [--model X] [--preset Y] [--models a,b,c] [--scope <name>]\n' +
|
|
46
|
+
' frontier status [--scope <name>]\n' +
|
|
47
|
+
' frontier run [<prompt>|-] [--scope <name>]\n' +
|
|
48
|
+
' frontier adopt [--force] [--scope <name>]\n'
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ---------- subcommands ----------
|
|
53
|
+
|
|
54
|
+
function cmdMode(argv, scope) {
|
|
55
|
+
const newMode = argv[0];
|
|
56
|
+
if (!newMode || !validateMode(newMode)) {
|
|
57
|
+
process.stderr.write('ERROR: mode must be off, single, or fusion\n');
|
|
58
|
+
process.exit(2);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
let state;
|
|
62
|
+
|
|
63
|
+
if (newMode === 'off') {
|
|
64
|
+
state = { mode: 'off' };
|
|
65
|
+
} else if (newMode === 'single') {
|
|
66
|
+
const model = getFlag(argv, '--model');
|
|
67
|
+
if (!model) {
|
|
68
|
+
process.stderr.write('ERROR: --model required for single mode\n');
|
|
69
|
+
process.exit(2);
|
|
70
|
+
}
|
|
71
|
+
if (!validateModel(model)) {
|
|
72
|
+
process.stderr.write('ERROR: unknown model: ' + model + '\n');
|
|
73
|
+
process.exit(2);
|
|
74
|
+
}
|
|
75
|
+
state = { mode: 'single', model };
|
|
76
|
+
} else {
|
|
77
|
+
// fusion
|
|
78
|
+
const preset = getFlag(argv, '--preset');
|
|
79
|
+
if (!preset) {
|
|
80
|
+
process.stderr.write('ERROR: --preset required for fusion mode\n');
|
|
81
|
+
process.exit(2);
|
|
82
|
+
}
|
|
83
|
+
if (!validatePreset(preset)) {
|
|
84
|
+
process.stderr.write('ERROR: unknown preset: ' + preset + '\n');
|
|
85
|
+
process.exit(2);
|
|
86
|
+
}
|
|
87
|
+
if (preset === 'custom') {
|
|
88
|
+
const modelsRaw = getFlag(argv, '--models');
|
|
89
|
+
if (!modelsRaw) {
|
|
90
|
+
process.stderr.write('ERROR: --models required for custom preset\n');
|
|
91
|
+
process.exit(2);
|
|
92
|
+
}
|
|
93
|
+
const models = modelsRaw.split(',').map(m => m.trim()).filter(Boolean);
|
|
94
|
+
state = { mode: 'fusion', preset: 'custom', models };
|
|
95
|
+
} else {
|
|
96
|
+
state = { mode: 'fusion', preset };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Optional judge/synth model overrides — apply to any fusion preset so
|
|
100
|
+
// users can mix freely (e.g. --judge opus --synth gpt-5.5). Unset =
|
|
101
|
+
// the preset's own stage model (presetStages) or the global default.
|
|
102
|
+
const judge = getFlag(argv, '--judge');
|
|
103
|
+
if (judge !== null) {
|
|
104
|
+
if (!validateModel(judge)) {
|
|
105
|
+
process.stderr.write('ERROR: unknown judge model: ' + judge + '\n');
|
|
106
|
+
process.exit(2);
|
|
107
|
+
}
|
|
108
|
+
state.judgeModel = judge;
|
|
109
|
+
}
|
|
110
|
+
const synth = getFlag(argv, '--synth');
|
|
111
|
+
if (synth !== null) {
|
|
112
|
+
if (!validateModel(synth)) {
|
|
113
|
+
process.stderr.write('ERROR: unknown synth model: ' + synth + '\n');
|
|
114
|
+
process.exit(2);
|
|
115
|
+
}
|
|
116
|
+
state.synthModel = synth;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
saveState(state, scope);
|
|
121
|
+
process.stdout.write('frontier mode set: ' + JSON.stringify(state) + '\n');
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function cmdStatus(scope) {
|
|
125
|
+
const state = loadState(scope);
|
|
126
|
+
process.stdout.write(JSON.stringify(state) + '\n');
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async function cmdRun(argv, scope) {
|
|
130
|
+
// Strip --scope and its value before building the prompt so it never leaks.
|
|
131
|
+
const cleanArgv = stripScopeFlag(argv);
|
|
132
|
+
|
|
133
|
+
let prompt;
|
|
134
|
+
const rest = cleanArgv.join(' ').trim();
|
|
135
|
+
if (!rest || rest === '-') {
|
|
136
|
+
prompt = fs.readFileSync(0, 'utf8');
|
|
137
|
+
} else {
|
|
138
|
+
prompt = rest;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const state = loadState(scope);
|
|
142
|
+
|
|
143
|
+
if (state.mode === 'off') {
|
|
144
|
+
process.stdout.write('Frontier off — using normal Maestro (engine not invoked).\n');
|
|
145
|
+
process.exit(0);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const result = await runFrontier({ prompt, state });
|
|
149
|
+
|
|
150
|
+
if (result.status === 'error') {
|
|
151
|
+
process.stderr.write('ERROR [' + result.failure_reason + ']: ' + result.error + '\n');
|
|
152
|
+
process.exit(1);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
process.stdout.write(result.final + '\n');
|
|
156
|
+
|
|
157
|
+
if (result.mode === 'fusion') {
|
|
158
|
+
const models = (result.responses || []).map(r => r.model);
|
|
159
|
+
const failed = (result.failed_models || []).length;
|
|
160
|
+
const hasAnal = !!result.analysis;
|
|
161
|
+
process.stderr.write(
|
|
162
|
+
'meta: preset=' + result.preset +
|
|
163
|
+
' models=' + models.join(',') +
|
|
164
|
+
' analysis=' + hasAnal +
|
|
165
|
+
' failed=' + failed + '\n'
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
process.exit(0);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Adopt the legacy global frontier-state.json into the current Claude Code
|
|
173
|
+
// workspace scope (cc-*). Source is read-only; never overwrites an existing
|
|
174
|
+
// workspace state file unless --force. This is the explicit escape hatch the
|
|
175
|
+
// per-workspace isolation change requires: a workspace never inherits the old
|
|
176
|
+
// global armed mode automatically, so a user who wants it copies it once.
|
|
177
|
+
function cmdAdopt(argv, scope) {
|
|
178
|
+
const res = adoptLegacyState(scope, { force: hasFlag(argv, '--force') });
|
|
179
|
+
|
|
180
|
+
if (res.ok) {
|
|
181
|
+
process.stdout.write(
|
|
182
|
+
'frontier adopted legacy state into ' + res.scope + ': ' +
|
|
183
|
+
JSON.stringify(loadState(res.scope)) + '\n');
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
let msg;
|
|
188
|
+
switch (res.reason) {
|
|
189
|
+
case 'not-cc-scope':
|
|
190
|
+
msg = 'adopt only targets a Claude Code per-workspace scope (cc-*); current ' +
|
|
191
|
+
'scope is "' + res.scope + '". Run it inside a Claude Code workspace ' +
|
|
192
|
+
'(under a git project root), or arm Codex/Cursor with `mode --scope`.';
|
|
193
|
+
break;
|
|
194
|
+
case 'missing-legacy':
|
|
195
|
+
msg = 'no legacy global state to adopt (frontier-state.json not found). ' +
|
|
196
|
+
'Nothing to do — arm this workspace with `mode` instead.';
|
|
197
|
+
break;
|
|
198
|
+
case 'invalid-legacy':
|
|
199
|
+
msg = 'legacy state file is unreadable, a symlink, or invalid; refusing to adopt.';
|
|
200
|
+
break;
|
|
201
|
+
case 'exists':
|
|
202
|
+
msg = 'this workspace already has frontier state (' +
|
|
203
|
+
JSON.stringify(loadState(res.scope)) + '); pass --force to overwrite.';
|
|
204
|
+
break;
|
|
205
|
+
case 'unsafe-target':
|
|
206
|
+
msg = 'refusing to write workspace state (symlink or unsafe path): ' + res.path;
|
|
207
|
+
break;
|
|
208
|
+
case 'write-failed':
|
|
209
|
+
msg = 'failed to write workspace state file: ' + res.path;
|
|
210
|
+
break;
|
|
211
|
+
default:
|
|
212
|
+
msg = 'adopt failed (' + res.reason + ').';
|
|
213
|
+
}
|
|
214
|
+
process.stderr.write('ERROR [' + res.reason + ']: ' + msg + '\n');
|
|
215
|
+
process.exit(2);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// ---------- main ----------
|
|
219
|
+
|
|
220
|
+
async function main() {
|
|
221
|
+
const argv = process.argv.slice(2);
|
|
222
|
+
const cmd = argv[0];
|
|
223
|
+
|
|
224
|
+
// Resolve scope once from full argv; all subcommands receive it.
|
|
225
|
+
const scope = resolveScope(argv);
|
|
226
|
+
|
|
227
|
+
if (cmd === 'mode') {
|
|
228
|
+
cmdMode(argv.slice(1), scope);
|
|
229
|
+
} else if (cmd === 'status') {
|
|
230
|
+
cmdStatus(scope);
|
|
231
|
+
} else if (cmd === 'run') {
|
|
232
|
+
await cmdRun(argv.slice(1), scope);
|
|
233
|
+
} else if (cmd === 'adopt') {
|
|
234
|
+
cmdAdopt(argv.slice(1), scope);
|
|
235
|
+
} else {
|
|
236
|
+
usage();
|
|
237
|
+
process.exit(2);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (require.main === module) {
|
|
242
|
+
main().catch(err => {
|
|
243
|
+
process.stderr.write(String(err.stack || err) + '\n');
|
|
244
|
+
process.exit(1);
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
module.exports = { main, getFlag };
|
|
@@ -0,0 +1,441 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Maestro Frontier — config defaults + state persistence.
|
|
3
|
+
// Zero deps, CJS. configDir + safeWriteFlag patterns ported from
|
|
4
|
+
// hooks/maestro-terse-mode.cjs. tokenBudget=0 means budget abort
|
|
5
|
+
// DISABLED (opt-in feature; set to a positive integer to enable).
|
|
6
|
+
|
|
7
|
+
'use strict';
|
|
8
|
+
|
|
9
|
+
const crypto = require('crypto');
|
|
10
|
+
const fs = require('fs');
|
|
11
|
+
const os = require('os');
|
|
12
|
+
const path = require('path');
|
|
13
|
+
|
|
14
|
+
// ---------- configDir (ported from maestro-terse-mode.cjs) ----------
|
|
15
|
+
|
|
16
|
+
function configDir() {
|
|
17
|
+
if (process.env.XDG_CONFIG_HOME) return path.join(process.env.XDG_CONFIG_HOME, 'maestro');
|
|
18
|
+
if (process.platform === 'win32') {
|
|
19
|
+
return path.join(process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'), 'maestro');
|
|
20
|
+
}
|
|
21
|
+
return path.join(os.homedir(), '.config', 'maestro');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// ---------- arg helper (used by resolveScope) ----------
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @param {string[]} argv
|
|
28
|
+
* @param {string} flag
|
|
29
|
+
* @returns {string|null}
|
|
30
|
+
*/
|
|
31
|
+
function getFlag(argv, flag) {
|
|
32
|
+
const i = argv.indexOf(flag);
|
|
33
|
+
return i !== -1 && i + 1 < argv.length ? argv[i + 1] : null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ---------- workspace hash ----------
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Derive a stable 8-hex workspace identifier from cwd by walking up to the
|
|
40
|
+
* nearest .git root, normalizing the path, and hashing it.
|
|
41
|
+
* On win32 the path is lowercased (filesystem is case-insensitive).
|
|
42
|
+
* @param {string} cwd
|
|
43
|
+
* @returns {string} 8 hex characters
|
|
44
|
+
*/
|
|
45
|
+
function workspaceHash(cwd) {
|
|
46
|
+
let normalized = path.resolve(cwd);
|
|
47
|
+
let last = null;
|
|
48
|
+
while (normalized !== last && !fs.existsSync(path.join(normalized, '.git'))) {
|
|
49
|
+
last = normalized;
|
|
50
|
+
normalized = path.dirname(normalized);
|
|
51
|
+
}
|
|
52
|
+
if (process.platform === 'win32') {
|
|
53
|
+
normalized = normalized.replace(/\\/g, '/').toLowerCase().replace(/\/+$/g, '');
|
|
54
|
+
} else {
|
|
55
|
+
normalized = normalized.replace(/\\/g, '/').replace(/\/+$/g, '');
|
|
56
|
+
}
|
|
57
|
+
return crypto.createHash('sha256').update(normalized).digest('hex').slice(0, 8);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ---------- scope helpers ----------
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Sanitize a raw scope value: lowercase, strip non-[a-z0-9-] chars entirely,
|
|
64
|
+
* then return 'default' if the result is empty.
|
|
65
|
+
* Examples: 'Foo' -> 'foo', 'a b!c' -> 'abc', '' -> 'default'.
|
|
66
|
+
* @param {*} v
|
|
67
|
+
* @returns {string}
|
|
68
|
+
*/
|
|
69
|
+
function sanitizeScope(v) {
|
|
70
|
+
const s = String(v).toLowerCase().replace(/[^a-z0-9-]/g, '');
|
|
71
|
+
return s.length > 0 ? s : 'default';
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Resolve the active scope from argv + environment. Precedence:
|
|
76
|
+
* 1. --scope <value> flag in argv
|
|
77
|
+
* 2. process.env.MAESTRO_SCOPE
|
|
78
|
+
* 3. Autodetect: CLAUDE_PLUGIN_ROOT || CLAUDECODE truthy -> 'cc-<8hex>'
|
|
79
|
+
* where <8hex> is derived from the workspace root (opts.cwd,
|
|
80
|
+
* CLAUDE_PROJECT_DIR, or process.cwd()) via workspaceHash().
|
|
81
|
+
* 4. 'default'
|
|
82
|
+
* The chosen value for steps 1-2 is always passed through sanitizeScope.
|
|
83
|
+
* @param {string[]} argv
|
|
84
|
+
* @param {{ cwd?: string }} [opts]
|
|
85
|
+
* @returns {string}
|
|
86
|
+
*/
|
|
87
|
+
function resolveScope(argv, opts) {
|
|
88
|
+
const flagVal = getFlag(argv, '--scope');
|
|
89
|
+
if (flagVal !== null) return sanitizeScope(flagVal);
|
|
90
|
+
if (process.env.MAESTRO_SCOPE) return sanitizeScope(process.env.MAESTRO_SCOPE);
|
|
91
|
+
if (process.env.CLAUDE_PLUGIN_ROOT || process.env.CLAUDECODE) {
|
|
92
|
+
const cwd = (opts && opts.cwd) || process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
93
|
+
return 'cc-' + workspaceHash(cwd);
|
|
94
|
+
}
|
|
95
|
+
return 'default';
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ---------- path helpers ----------
|
|
99
|
+
|
|
100
|
+
/** Pre-scope global state file; migration source only, never written/deleted. */
|
|
101
|
+
function legacyStatePath() {
|
|
102
|
+
return path.join(configDir(), 'frontier-state.json');
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Scope-aware state path.
|
|
107
|
+
* scope === 'default' => frontier-state.json (legacy-compatible, no suffix).
|
|
108
|
+
* Any other scope => frontier-state.<scope>.json.
|
|
109
|
+
* @param {string} [scope] Omit to autodetect the runtime scope via resolveScope([]).
|
|
110
|
+
* @returns {string}
|
|
111
|
+
*/
|
|
112
|
+
function statePath(scope) {
|
|
113
|
+
if (scope === undefined) scope = resolveScope([]);
|
|
114
|
+
if (scope === 'default') return path.join(configDir(), 'frontier-state.json');
|
|
115
|
+
return path.join(configDir(), 'frontier-state.' + scope + '.json');
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ---------- state I/O ----------
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Read and validate a state file. Returns:
|
|
122
|
+
* - null if the file is absent (ENOENT) — caller decides the fallback.
|
|
123
|
+
* - {mode:'off'} on symlink, corrupt JSON, or invalid mode.
|
|
124
|
+
* - parsed object on success.
|
|
125
|
+
* @param {string} p
|
|
126
|
+
* @returns {object|null}
|
|
127
|
+
*/
|
|
128
|
+
function _readStateFile(p) {
|
|
129
|
+
let st;
|
|
130
|
+
try { st = fs.lstatSync(p); } catch { return null; }
|
|
131
|
+
if (st.isSymbolicLink()) return { mode: 'off' };
|
|
132
|
+
try {
|
|
133
|
+
const raw = fs.readFileSync(p, 'utf8');
|
|
134
|
+
const parsed = JSON.parse(raw);
|
|
135
|
+
if (!parsed || typeof parsed !== 'object') return { mode: 'off' };
|
|
136
|
+
if (!['off', 'single', 'fusion'].includes(parsed.mode)) return { mode: 'off' };
|
|
137
|
+
return parsed;
|
|
138
|
+
} catch {
|
|
139
|
+
return { mode: 'off' };
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Strict state read for explicit adoption paths. Unlike _readStateFile, this
|
|
145
|
+
* distinguishes invalid legacy content from a valid {mode:'off'} state.
|
|
146
|
+
* @param {string} p
|
|
147
|
+
* @returns {{ ok: true, state: object }|{ ok: false, reason: 'missing'|'invalid' }}
|
|
148
|
+
*/
|
|
149
|
+
function _readValidatedStateFile(p) {
|
|
150
|
+
let st;
|
|
151
|
+
try { st = fs.lstatSync(p); } catch { return { ok: false, reason: 'missing' }; }
|
|
152
|
+
if (st.isSymbolicLink()) return { ok: false, reason: 'invalid' };
|
|
153
|
+
try {
|
|
154
|
+
const raw = fs.readFileSync(p, 'utf8');
|
|
155
|
+
const parsed = JSON.parse(raw);
|
|
156
|
+
if (!parsed || typeof parsed !== 'object') return { ok: false, reason: 'invalid' };
|
|
157
|
+
if (!['off', 'single', 'fusion'].includes(parsed.mode)) return { ok: false, reason: 'invalid' };
|
|
158
|
+
return { ok: true, state: parsed };
|
|
159
|
+
} catch {
|
|
160
|
+
return { ok: false, reason: 'invalid' };
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Load frontier state for the given scope.
|
|
166
|
+
* D3 MIGRATION: if scope !== 'default' and scope does NOT match /^cc-/
|
|
167
|
+
* and the scoped file does NOT exist AND the legacy frontier-state.json
|
|
168
|
+
* DOES exist, seed from the legacy file (read-only — never write during
|
|
169
|
+
* load). Same symlink/parse guards apply. cc-* scopes are excluded from
|
|
170
|
+
* migration because they are per-workspace and must not inherit global
|
|
171
|
+
* legacy state. Named scopes (e.g. 'codex', 'cursor') still migrate.
|
|
172
|
+
* Falls back to {mode:'off'} on any failure.
|
|
173
|
+
* @param {string} [scope] Omit to autodetect the runtime scope via resolveScope([]).
|
|
174
|
+
* @returns {object}
|
|
175
|
+
*/
|
|
176
|
+
function loadState(scope) {
|
|
177
|
+
if (scope === undefined) scope = resolveScope([]);
|
|
178
|
+
try {
|
|
179
|
+
const p = statePath(scope);
|
|
180
|
+
const result = _readStateFile(p);
|
|
181
|
+
if (result !== null) return result;
|
|
182
|
+
|
|
183
|
+
// File absent. For non-default, non-cc-* scopes attempt migration from legacy file.
|
|
184
|
+
if (scope !== 'default' && !/^cc-/.test(scope)) {
|
|
185
|
+
const legacyResult = _readStateFile(legacyStatePath());
|
|
186
|
+
if (legacyResult !== null) return legacyResult;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return { mode: 'off' };
|
|
190
|
+
} catch {
|
|
191
|
+
return { mode: 'off' };
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Atomic temp+rename write, 0600, symlink-refusing.
|
|
197
|
+
* Ported from safeWriteFlag in hooks/maestro-terse-mode.cjs.
|
|
198
|
+
* @param {object} state
|
|
199
|
+
* @param {string} [scope] Omit to autodetect the runtime scope via resolveScope([]).
|
|
200
|
+
* @returns {boolean}
|
|
201
|
+
*/
|
|
202
|
+
function saveState(state, scope) {
|
|
203
|
+
if (scope === undefined) scope = resolveScope([]);
|
|
204
|
+
try {
|
|
205
|
+
const p = statePath(scope);
|
|
206
|
+
const dir = path.dirname(p);
|
|
207
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
208
|
+
try { if (fs.lstatSync(dir).isSymbolicLink()) return false; } catch { return false; }
|
|
209
|
+
try {
|
|
210
|
+
if (fs.lstatSync(p).isSymbolicLink()) return false;
|
|
211
|
+
} catch (e) {
|
|
212
|
+
if (e.code !== 'ENOENT') return false;
|
|
213
|
+
}
|
|
214
|
+
const tempPath = path.join(dir, `.frontier-state.${process.pid}.${Date.now()}.tmp`);
|
|
215
|
+
const O_NOFOLLOW = typeof fs.constants.O_NOFOLLOW === 'number' ? fs.constants.O_NOFOLLOW : 0;
|
|
216
|
+
const flags = fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | O_NOFOLLOW;
|
|
217
|
+
let fd;
|
|
218
|
+
try {
|
|
219
|
+
if (O_NOFOLLOW === 0) {
|
|
220
|
+
try { if (fs.lstatSync(tempPath).isSymbolicLink()) return false; } catch {}
|
|
221
|
+
}
|
|
222
|
+
fd = fs.openSync(tempPath, flags, 0o600);
|
|
223
|
+
fs.writeSync(fd, JSON.stringify(state));
|
|
224
|
+
try { fs.fchmodSync(fd, 0o600); } catch {}
|
|
225
|
+
} finally {
|
|
226
|
+
if (fd !== undefined) fs.closeSync(fd);
|
|
227
|
+
}
|
|
228
|
+
fs.renameSync(tempPath, p);
|
|
229
|
+
return true;
|
|
230
|
+
} catch {
|
|
231
|
+
return false;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Explicitly adopt the validated legacy frontier-state.json into a cc-* scope.
|
|
237
|
+
* This copies legacy content into frontier-state.<cc-scope>.json only, never
|
|
238
|
+
* deletes legacy, and refuses to overwrite an existing scoped file unless
|
|
239
|
+
* opts.force is true.
|
|
240
|
+
* @param {string} [scope] Omit to autodetect the runtime scope via resolveScope([]).
|
|
241
|
+
* @param {{ force?: boolean }} [opts]
|
|
242
|
+
* @returns {{ ok: true, scope: string, path: string }|{ ok: false, reason: string, scope: string, path?: string }}
|
|
243
|
+
*/
|
|
244
|
+
function adoptLegacyState(scope, opts) {
|
|
245
|
+
if (scope === undefined) scope = resolveScope([]);
|
|
246
|
+
const targetScope = sanitizeScope(scope);
|
|
247
|
+
const targetPath = statePath(targetScope);
|
|
248
|
+
if (!/^cc-/.test(targetScope)) {
|
|
249
|
+
return { ok: false, reason: 'not-cc-scope', scope: targetScope, path: targetPath };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const legacy = _readValidatedStateFile(legacyStatePath());
|
|
253
|
+
if (!legacy.ok) {
|
|
254
|
+
return {
|
|
255
|
+
ok: false,
|
|
256
|
+
reason: legacy.reason === 'missing' ? 'missing-legacy' : 'invalid-legacy',
|
|
257
|
+
scope: targetScope,
|
|
258
|
+
path: targetPath,
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
try {
|
|
263
|
+
const st = fs.lstatSync(targetPath);
|
|
264
|
+
if (st.isSymbolicLink()) return { ok: false, reason: 'unsafe-target', scope: targetScope, path: targetPath };
|
|
265
|
+
if (!(opts && opts.force)) return { ok: false, reason: 'exists', scope: targetScope, path: targetPath };
|
|
266
|
+
} catch (e) {
|
|
267
|
+
if (e.code !== 'ENOENT') return { ok: false, reason: 'unsafe-target', scope: targetScope, path: targetPath };
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
if (!saveState(legacy.state, targetScope)) {
|
|
271
|
+
return { ok: false, reason: 'write-failed', scope: targetScope, path: targetPath };
|
|
272
|
+
}
|
|
273
|
+
return { ok: true, scope: targetScope, path: targetPath };
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// ---------- DEFAULTS ----------
|
|
277
|
+
|
|
278
|
+
const DEFAULTS = {
|
|
279
|
+
adapters: {
|
|
280
|
+
opus: {
|
|
281
|
+
model: 'opus',
|
|
282
|
+
bin: process.env.MAESTRO_CLAUDE_BIN || 'claude',
|
|
283
|
+
baseArgs: ['-p', '--output-format', 'json', '--dangerously-skip-permissions'],
|
|
284
|
+
promptVia: 'stdin',
|
|
285
|
+
webTools: false,
|
|
286
|
+
output: 'stdout',
|
|
287
|
+
parse: 'claude-json',
|
|
288
|
+
},
|
|
289
|
+
'gpt-5.5': {
|
|
290
|
+
model: 'gpt-5.5',
|
|
291
|
+
bin: process.env.MAESTRO_CODEX_BIN || 'codex',
|
|
292
|
+
baseArgs: [
|
|
293
|
+
'exec',
|
|
294
|
+
'--skip-git-repo-check',
|
|
295
|
+
'--dangerously-bypass-approvals-and-sandbox',
|
|
296
|
+
'-m', 'gpt-5.5',
|
|
297
|
+
'--color', 'never',
|
|
298
|
+
],
|
|
299
|
+
promptVia: 'stdin',
|
|
300
|
+
webTools: true,
|
|
301
|
+
output: 'last-message-file',
|
|
302
|
+
parse: 'text',
|
|
303
|
+
},
|
|
304
|
+
gemini: {
|
|
305
|
+
model: 'gemini',
|
|
306
|
+
bin: process.env.MAESTRO_GEMINI_BIN || 'gemini',
|
|
307
|
+
baseArgs: [
|
|
308
|
+
'--output-format', 'json',
|
|
309
|
+
'--approval-mode', 'yolo',
|
|
310
|
+
'--model', 'gemini-3.1-pro-preview',
|
|
311
|
+
],
|
|
312
|
+
promptVia: 'arg',
|
|
313
|
+
promptFlag: '-p',
|
|
314
|
+
webTools: false,
|
|
315
|
+
output: 'stdout',
|
|
316
|
+
parse: 'gemini-json',
|
|
317
|
+
},
|
|
318
|
+
},
|
|
319
|
+
presets: {
|
|
320
|
+
'opus-duo': ['opus', 'opus'],
|
|
321
|
+
'opus-gpt': ['opus', 'gpt-5.5'],
|
|
322
|
+
'gpt-duo': ['gpt-5.5', 'gpt-5.5'],
|
|
323
|
+
'frontier-trio': ['opus', 'gpt-5.5', 'gemini'],
|
|
324
|
+
},
|
|
325
|
+
// Per-preset judge/synth model overrides. A preset listed here runs its
|
|
326
|
+
// judge + synthesizer on the named model instead of the global default
|
|
327
|
+
// below; this is what lets gpt-duo run end-to-end on Codex alone (no
|
|
328
|
+
// claude). Presets NOT listed use judgeModel/synthModel. An explicit
|
|
329
|
+
// --judge/--synth flag (state.judgeModel/synthModel) overrides both.
|
|
330
|
+
presetStages: {
|
|
331
|
+
'gpt-duo': { judge: 'gpt-5.5', synth: 'gpt-5.5' },
|
|
332
|
+
},
|
|
333
|
+
judgeModel: 'opus',
|
|
334
|
+
synthModel: 'opus',
|
|
335
|
+
concurrency: 4,
|
|
336
|
+
timeoutMs: 180000,
|
|
337
|
+
// tokenBudget=0 means budget abort DISABLED (opt-in).
|
|
338
|
+
// Set to a positive integer (e.g. 50000) to enable hard budget cutoff.
|
|
339
|
+
tokenBudget: 0,
|
|
340
|
+
excluded_domains: [],
|
|
341
|
+
};
|
|
342
|
+
|
|
343
|
+
// ---------- resolution helpers ----------
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* @param {object} state
|
|
347
|
+
* @param {typeof DEFAULTS} cfg
|
|
348
|
+
* @returns {string[]}
|
|
349
|
+
*/
|
|
350
|
+
function resolvePanel(state, cfg) {
|
|
351
|
+
if (state.preset === 'custom') {
|
|
352
|
+
const models = state.models;
|
|
353
|
+
if (!Array.isArray(models) || models.length === 0) {
|
|
354
|
+
throw new Error('resolvePanel: custom preset requires a non-empty models array');
|
|
355
|
+
}
|
|
356
|
+
if (models.length > 8) {
|
|
357
|
+
throw new Error('resolvePanel: custom preset exceeds 8-model limit');
|
|
358
|
+
}
|
|
359
|
+
const unknown = models.filter(m => !cfg.adapters[m]);
|
|
360
|
+
if (unknown.length > 0) {
|
|
361
|
+
throw new Error('resolvePanel: unknown model(s): ' + unknown.join(', '));
|
|
362
|
+
}
|
|
363
|
+
return models;
|
|
364
|
+
}
|
|
365
|
+
const resolved = cfg.presets[state.preset];
|
|
366
|
+
if (!resolved) {
|
|
367
|
+
throw new Error('resolvePanel: unknown preset: ' + state.preset);
|
|
368
|
+
}
|
|
369
|
+
return resolved;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Resolve the judge or synth model for a fusion state. Precedence:
|
|
374
|
+
* explicit flag (state.judgeModel/synthModel) -> per-preset override
|
|
375
|
+
* (cfg.presetStages) -> global default (cfg.judgeModel/synthModel).
|
|
376
|
+
* @param {'judge'|'synth'} stage
|
|
377
|
+
* @param {object} state
|
|
378
|
+
* @param {typeof DEFAULTS} cfg
|
|
379
|
+
* @returns {string}
|
|
380
|
+
*/
|
|
381
|
+
function resolveStageModel(stage, state, cfg) {
|
|
382
|
+
const explicit = stage === 'judge' ? state.judgeModel : state.synthModel;
|
|
383
|
+
if (explicit) return explicit;
|
|
384
|
+
const ps = cfg.presetStages && cfg.presetStages[state.preset];
|
|
385
|
+
if (ps && ps[stage]) return ps[stage];
|
|
386
|
+
return stage === 'judge' ? cfg.judgeModel : cfg.synthModel;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** @param {object} state @param {typeof DEFAULTS} cfg @returns {string} */
|
|
390
|
+
function resolveJudgeModel(state, cfg) {
|
|
391
|
+
return resolveStageModel('judge', state, cfg);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/** @param {object} state @param {typeof DEFAULTS} cfg @returns {string} */
|
|
395
|
+
function resolveSynthModel(state, cfg) {
|
|
396
|
+
return resolveStageModel('synth', state, cfg);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/** @param {string} m @returns {boolean} */
|
|
400
|
+
function validateMode(m) {
|
|
401
|
+
return ['off', 'single', 'fusion'].includes(m);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* @param {string} p
|
|
406
|
+
* @param {typeof DEFAULTS} [cfg]
|
|
407
|
+
* @returns {boolean}
|
|
408
|
+
*/
|
|
409
|
+
function validatePreset(p, cfg) {
|
|
410
|
+
const c = cfg || DEFAULTS;
|
|
411
|
+
return p === 'custom' || Object.prototype.hasOwnProperty.call(c.presets, p);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* @param {string} m
|
|
416
|
+
* @param {typeof DEFAULTS} [cfg]
|
|
417
|
+
* @returns {boolean}
|
|
418
|
+
*/
|
|
419
|
+
function validateModel(m, cfg) {
|
|
420
|
+
const c = cfg || DEFAULTS;
|
|
421
|
+
return Object.prototype.hasOwnProperty.call(c.adapters, m);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
module.exports = {
|
|
425
|
+
DEFAULTS,
|
|
426
|
+
configDir,
|
|
427
|
+
sanitizeScope,
|
|
428
|
+
workspaceHash,
|
|
429
|
+
resolveScope,
|
|
430
|
+
statePath,
|
|
431
|
+
legacyStatePath,
|
|
432
|
+
loadState,
|
|
433
|
+
saveState,
|
|
434
|
+
adoptLegacyState,
|
|
435
|
+
resolvePanel,
|
|
436
|
+
resolveJudgeModel,
|
|
437
|
+
resolveSynthModel,
|
|
438
|
+
validateMode,
|
|
439
|
+
validatePreset,
|
|
440
|
+
validateModel,
|
|
441
|
+
};
|