@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,441 +1,468 @@
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
- };
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
+ function workspaceCwd(opts) {
75
+ return (opts && opts.cwd) || process.env.CLAUDE_PROJECT_DIR || process.env.CODEX_PROJECT_DIR || process.cwd();
76
+ }
77
+
78
+ function resolveScopeAlias(scope, opts) {
79
+ const clean = sanitizeScope(scope);
80
+ if (['codex-project', 'codex-workspace', 'codex-repo'].includes(clean)) {
81
+ return 'codex-' + workspaceHash(workspaceCwd(opts));
82
+ }
83
+ if (['claude-project', 'claude-workspace', 'cc-project', 'cc-workspace'].includes(clean)) {
84
+ return 'cc-' + workspaceHash(workspaceCwd(opts));
85
+ }
86
+ return clean;
87
+ }
88
+
89
+ /**
90
+ * Resolve the active scope from argv + environment. Precedence:
91
+ * 1. --scope <value> flag in argv
92
+ * 2. process.env.MAESTRO_SCOPE
93
+ * 3. Autodetect: PLUGIN_ROOT truthy -> 'codex-<8hex>'
94
+ * 4. Autodetect: CLAUDE_PLUGIN_ROOT || CLAUDECODE truthy -> 'cc-<8hex>'
95
+ * 5. Autodetect: PLUGIN_DATA truthy -> 'codex-<8hex>'
96
+ * where <8hex> is derived from the workspace root (opts.cwd,
97
+ * CLAUDE_PROJECT_DIR, CODEX_PROJECT_DIR, or process.cwd()) via
98
+ * workspaceHash().
99
+ * 6. 'default'
100
+ * The chosen value for steps 1-2 is sanitized, and project aliases such as
101
+ * codex-project/codex-workspace are expanded to per-workspace scopes.
102
+ * @param {string[]} argv
103
+ * @param {{ cwd?: string }} [opts]
104
+ * @returns {string}
105
+ */
106
+ function resolveScope(argv, opts) {
107
+ const flagVal = getFlag(argv, '--scope');
108
+ if (flagVal !== null) return resolveScopeAlias(flagVal, opts);
109
+ if (process.env.MAESTRO_SCOPE) return resolveScopeAlias(process.env.MAESTRO_SCOPE, opts);
110
+ if (process.env.PLUGIN_ROOT) {
111
+ return 'codex-' + workspaceHash(workspaceCwd(opts));
112
+ }
113
+ if (process.env.CLAUDE_PLUGIN_ROOT || process.env.CLAUDECODE) {
114
+ return 'cc-' + workspaceHash(workspaceCwd(opts));
115
+ }
116
+ if (process.env.PLUGIN_DATA) {
117
+ return 'codex-' + workspaceHash(workspaceCwd(opts));
118
+ }
119
+ return 'default';
120
+ }
121
+
122
+ // ---------- path helpers ----------
123
+
124
+ /** Pre-scope global state file; migration source only, never written/deleted. */
125
+ function legacyStatePath() {
126
+ return path.join(configDir(), 'frontier-state.json');
127
+ }
128
+
129
+ /**
130
+ * Scope-aware state path.
131
+ * scope === 'default' => frontier-state.json (legacy-compatible, no suffix).
132
+ * Any other scope => frontier-state.<scope>.json.
133
+ * @param {string} [scope] Omit to autodetect the runtime scope via resolveScope([]).
134
+ * @returns {string}
135
+ */
136
+ function statePath(scope) {
137
+ if (scope === undefined) scope = resolveScope([]);
138
+ else scope = resolveScopeAlias(scope);
139
+ if (scope === 'default') return path.join(configDir(), 'frontier-state.json');
140
+ return path.join(configDir(), 'frontier-state.' + scope + '.json');
141
+ }
142
+
143
+ // ---------- state I/O ----------
144
+
145
+ /**
146
+ * Read and validate a state file. Returns:
147
+ * - null if the file is absent (ENOENT) caller decides the fallback.
148
+ * - {mode:'off'} on symlink, corrupt JSON, or invalid mode.
149
+ * - parsed object on success.
150
+ * @param {string} p
151
+ * @returns {object|null}
152
+ */
153
+ function _readStateFile(p) {
154
+ let st;
155
+ try { st = fs.lstatSync(p); } catch { return null; }
156
+ if (st.isSymbolicLink()) return { mode: 'off' };
157
+ try {
158
+ const raw = fs.readFileSync(p, 'utf8');
159
+ const parsed = JSON.parse(raw);
160
+ if (!parsed || typeof parsed !== 'object') return { mode: 'off' };
161
+ if (!['off', 'single', 'fusion'].includes(parsed.mode)) return { mode: 'off' };
162
+ return parsed;
163
+ } catch {
164
+ return { mode: 'off' };
165
+ }
166
+ }
167
+
168
+ /**
169
+ * Strict state read for explicit adoption paths. Unlike _readStateFile, this
170
+ * distinguishes invalid legacy content from a valid {mode:'off'} state.
171
+ * @param {string} p
172
+ * @returns {{ ok: true, state: object }|{ ok: false, reason: 'missing'|'invalid' }}
173
+ */
174
+ function _readValidatedStateFile(p) {
175
+ let st;
176
+ try { st = fs.lstatSync(p); } catch { return { ok: false, reason: 'missing' }; }
177
+ if (st.isSymbolicLink()) return { ok: false, reason: 'invalid' };
178
+ try {
179
+ const raw = fs.readFileSync(p, 'utf8');
180
+ const parsed = JSON.parse(raw);
181
+ if (!parsed || typeof parsed !== 'object') return { ok: false, reason: 'invalid' };
182
+ if (!['off', 'single', 'fusion'].includes(parsed.mode)) return { ok: false, reason: 'invalid' };
183
+ return { ok: true, state: parsed };
184
+ } catch {
185
+ return { ok: false, reason: 'invalid' };
186
+ }
187
+ }
188
+
189
+ /**
190
+ * Load frontier state for the given scope.
191
+ * D3 MIGRATION: if scope !== 'default' and scope does NOT match /^(cc|codex)-/
192
+ * and the scoped file does NOT exist AND the legacy frontier-state.json
193
+ * DOES exist, seed from the legacy file (read-only — never write during
194
+ * load). Same symlink/parse guards apply. cc-* and codex-* scopes are
195
+ * excluded from migration because they are per-workspace and must not inherit
196
+ * global legacy state. Named scopes (e.g. 'codex', 'cursor') still migrate.
197
+ * Falls back to {mode:'off'} on any failure.
198
+ * @param {string} [scope] Omit to autodetect the runtime scope via resolveScope([]).
199
+ * @returns {object}
200
+ */
201
+ function loadState(scope) {
202
+ if (scope === undefined) scope = resolveScope([]);
203
+ else scope = resolveScopeAlias(scope);
204
+ try {
205
+ const p = statePath(scope);
206
+ const result = _readStateFile(p);
207
+ if (result !== null) return result;
208
+
209
+ // File absent. For non-default, non-workspace scopes attempt migration from legacy file.
210
+ if (scope !== 'default' && !/^(cc|codex)-/.test(scope)) {
211
+ const legacyResult = _readStateFile(legacyStatePath());
212
+ if (legacyResult !== null) return legacyResult;
213
+ }
214
+
215
+ return { mode: 'off' };
216
+ } catch {
217
+ return { mode: 'off' };
218
+ }
219
+ }
220
+
221
+ /**
222
+ * Atomic temp+rename write, 0600, symlink-refusing.
223
+ * Ported from safeWriteFlag in hooks/maestro-terse-mode.cjs.
224
+ * @param {object} state
225
+ * @param {string} [scope] Omit to autodetect the runtime scope via resolveScope([]).
226
+ * @returns {boolean}
227
+ */
228
+ function saveState(state, scope) {
229
+ if (scope === undefined) scope = resolveScope([]);
230
+ else scope = resolveScopeAlias(scope);
231
+ try {
232
+ const p = statePath(scope);
233
+ const dir = path.dirname(p);
234
+ fs.mkdirSync(dir, { recursive: true });
235
+ try { if (fs.lstatSync(dir).isSymbolicLink()) return false; } catch { return false; }
236
+ try {
237
+ if (fs.lstatSync(p).isSymbolicLink()) return false;
238
+ } catch (e) {
239
+ if (e.code !== 'ENOENT') return false;
240
+ }
241
+ const tempPath = path.join(dir, `.frontier-state.${process.pid}.${Date.now()}.tmp`);
242
+ const O_NOFOLLOW = typeof fs.constants.O_NOFOLLOW === 'number' ? fs.constants.O_NOFOLLOW : 0;
243
+ const flags = fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | O_NOFOLLOW;
244
+ let fd;
245
+ try {
246
+ if (O_NOFOLLOW === 0) {
247
+ try { if (fs.lstatSync(tempPath).isSymbolicLink()) return false; } catch {}
248
+ }
249
+ fd = fs.openSync(tempPath, flags, 0o600);
250
+ fs.writeSync(fd, JSON.stringify(state));
251
+ try { fs.fchmodSync(fd, 0o600); } catch {}
252
+ } finally {
253
+ if (fd !== undefined) fs.closeSync(fd);
254
+ }
255
+ fs.renameSync(tempPath, p);
256
+ return true;
257
+ } catch {
258
+ return false;
259
+ }
260
+ }
261
+
262
+ /**
263
+ * Explicitly adopt the validated legacy frontier-state.json into a cc-* scope.
264
+ * This copies legacy content into frontier-state.<cc-scope>.json only, never
265
+ * deletes legacy, and refuses to overwrite an existing scoped file unless
266
+ * opts.force is true.
267
+ * @param {string} [scope] Omit to autodetect the runtime scope via resolveScope([]).
268
+ * @param {{ force?: boolean }} [opts]
269
+ * @returns {{ ok: true, scope: string, path: string }|{ ok: false, reason: string, scope: string, path?: string }}
270
+ */
271
+ function adoptLegacyState(scope, opts) {
272
+ if (scope === undefined) scope = resolveScope([]);
273
+ const targetScope = sanitizeScope(scope);
274
+ const targetPath = statePath(targetScope);
275
+ if (!/^cc-/.test(targetScope)) {
276
+ return { ok: false, reason: 'not-cc-scope', scope: targetScope, path: targetPath };
277
+ }
278
+
279
+ const legacy = _readValidatedStateFile(legacyStatePath());
280
+ if (!legacy.ok) {
281
+ return {
282
+ ok: false,
283
+ reason: legacy.reason === 'missing' ? 'missing-legacy' : 'invalid-legacy',
284
+ scope: targetScope,
285
+ path: targetPath,
286
+ };
287
+ }
288
+
289
+ try {
290
+ const st = fs.lstatSync(targetPath);
291
+ if (st.isSymbolicLink()) return { ok: false, reason: 'unsafe-target', scope: targetScope, path: targetPath };
292
+ if (!(opts && opts.force)) return { ok: false, reason: 'exists', scope: targetScope, path: targetPath };
293
+ } catch (e) {
294
+ if (e.code !== 'ENOENT') return { ok: false, reason: 'unsafe-target', scope: targetScope, path: targetPath };
295
+ }
296
+
297
+ if (!saveState(legacy.state, targetScope)) {
298
+ return { ok: false, reason: 'write-failed', scope: targetScope, path: targetPath };
299
+ }
300
+ return { ok: true, scope: targetScope, path: targetPath };
301
+ }
302
+
303
+ // ---------- DEFAULTS ----------
304
+
305
+ const DEFAULTS = {
306
+ adapters: {
307
+ opus: {
308
+ model: 'opus',
309
+ bin: process.env.MAESTRO_CLAUDE_BIN || 'claude',
310
+ baseArgs: ['-p', '--output-format', 'json', '--dangerously-skip-permissions'],
311
+ promptVia: 'stdin',
312
+ webTools: false,
313
+ output: 'stdout',
314
+ parse: 'claude-json',
315
+ },
316
+ 'gpt-5.5': {
317
+ model: 'gpt-5.5',
318
+ bin: process.env.MAESTRO_CODEX_BIN || 'codex',
319
+ baseArgs: [
320
+ 'exec',
321
+ '--skip-git-repo-check',
322
+ '--dangerously-bypass-approvals-and-sandbox',
323
+ '-m', 'gpt-5.5',
324
+ '--color', 'never',
325
+ ],
326
+ promptVia: 'stdin',
327
+ webTools: true,
328
+ output: 'last-message-file',
329
+ parse: 'text',
330
+ },
331
+ gemini: {
332
+ model: 'gemini',
333
+ bin: process.env.MAESTRO_GEMINI_BIN || 'gemini',
334
+ baseArgs: [
335
+ '--output-format', 'json',
336
+ '--approval-mode', 'yolo',
337
+ '--model', 'gemini-3.1-pro-preview',
338
+ ],
339
+ promptVia: 'arg',
340
+ promptFlag: '-p',
341
+ webTools: false,
342
+ output: 'stdout',
343
+ parse: 'gemini-json',
344
+ },
345
+ },
346
+ presets: {
347
+ 'opus-duo': ['opus', 'opus'],
348
+ 'opus-gpt': ['opus', 'gpt-5.5'],
349
+ 'gpt-duo': ['gpt-5.5', 'gpt-5.5'],
350
+ 'frontier-trio': ['opus', 'gpt-5.5', 'gemini'],
351
+ },
352
+ // Per-preset judge/synth model overrides. A preset listed here runs its
353
+ // judge + synthesizer on the named model instead of the global default
354
+ // below; this is what lets gpt-duo run end-to-end on Codex alone (no
355
+ // claude). Presets NOT listed use judgeModel/synthModel. An explicit
356
+ // --judge/--synth flag (state.judgeModel/synthModel) overrides both.
357
+ presetStages: {
358
+ 'gpt-duo': { judge: 'gpt-5.5', synth: 'gpt-5.5' },
359
+ },
360
+ judgeModel: 'opus',
361
+ synthModel: 'opus',
362
+ concurrency: 4,
363
+ timeoutMs: 180000,
364
+ // tokenBudget=0 means budget abort DISABLED (opt-in).
365
+ // Set to a positive integer (e.g. 50000) to enable hard budget cutoff.
366
+ tokenBudget: 0,
367
+ excluded_domains: [],
368
+ };
369
+
370
+ // ---------- resolution helpers ----------
371
+
372
+ /**
373
+ * @param {object} state
374
+ * @param {typeof DEFAULTS} cfg
375
+ * @returns {string[]}
376
+ */
377
+ function resolvePanel(state, cfg) {
378
+ if (state.preset === 'custom') {
379
+ const models = state.models;
380
+ if (!Array.isArray(models) || models.length === 0) {
381
+ throw new Error('resolvePanel: custom preset requires a non-empty models array');
382
+ }
383
+ if (models.length > 8) {
384
+ throw new Error('resolvePanel: custom preset exceeds 8-model limit');
385
+ }
386
+ const unknown = models.filter(m => !cfg.adapters[m]);
387
+ if (unknown.length > 0) {
388
+ throw new Error('resolvePanel: unknown model(s): ' + unknown.join(', '));
389
+ }
390
+ return models;
391
+ }
392
+ const resolved = cfg.presets[state.preset];
393
+ if (!resolved) {
394
+ throw new Error('resolvePanel: unknown preset: ' + state.preset);
395
+ }
396
+ return resolved;
397
+ }
398
+
399
+ /**
400
+ * Resolve the judge or synth model for a fusion state. Precedence:
401
+ * explicit flag (state.judgeModel/synthModel) -> per-preset override
402
+ * (cfg.presetStages) -> global default (cfg.judgeModel/synthModel).
403
+ * @param {'judge'|'synth'} stage
404
+ * @param {object} state
405
+ * @param {typeof DEFAULTS} cfg
406
+ * @returns {string}
407
+ */
408
+ function resolveStageModel(stage, state, cfg) {
409
+ const explicit = stage === 'judge' ? state.judgeModel : state.synthModel;
410
+ if (explicit) return explicit;
411
+ const ps = cfg.presetStages && cfg.presetStages[state.preset];
412
+ if (ps && ps[stage]) return ps[stage];
413
+ return stage === 'judge' ? cfg.judgeModel : cfg.synthModel;
414
+ }
415
+
416
+ /** @param {object} state @param {typeof DEFAULTS} cfg @returns {string} */
417
+ function resolveJudgeModel(state, cfg) {
418
+ return resolveStageModel('judge', state, cfg);
419
+ }
420
+
421
+ /** @param {object} state @param {typeof DEFAULTS} cfg @returns {string} */
422
+ function resolveSynthModel(state, cfg) {
423
+ return resolveStageModel('synth', state, cfg);
424
+ }
425
+
426
+ /** @param {string} m @returns {boolean} */
427
+ function validateMode(m) {
428
+ return ['off', 'single', 'fusion'].includes(m);
429
+ }
430
+
431
+ /**
432
+ * @param {string} p
433
+ * @param {typeof DEFAULTS} [cfg]
434
+ * @returns {boolean}
435
+ */
436
+ function validatePreset(p, cfg) {
437
+ const c = cfg || DEFAULTS;
438
+ return p === 'custom' || Object.prototype.hasOwnProperty.call(c.presets, p);
439
+ }
440
+
441
+ /**
442
+ * @param {string} m
443
+ * @param {typeof DEFAULTS} [cfg]
444
+ * @returns {boolean}
445
+ */
446
+ function validateModel(m, cfg) {
447
+ const c = cfg || DEFAULTS;
448
+ return Object.prototype.hasOwnProperty.call(c.adapters, m);
449
+ }
450
+
451
+ module.exports = {
452
+ DEFAULTS,
453
+ configDir,
454
+ sanitizeScope,
455
+ workspaceHash,
456
+ resolveScope,
457
+ statePath,
458
+ legacyStatePath,
459
+ loadState,
460
+ saveState,
461
+ adoptLegacyState,
462
+ resolvePanel,
463
+ resolveJudgeModel,
464
+ resolveSynthModel,
465
+ validateMode,
466
+ validatePreset,
467
+ validateModel,
468
+ };