@maestrofrontier/frontier 1.4.5 → 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 (50) hide show
  1. package/.agents/plugins/marketplace.json +21 -21
  2. package/.codex-plugin/plugin.json +29 -29
  3. package/.cursorrules +197 -194
  4. package/AGENTS.md +3 -3
  5. package/README.md +368 -368
  6. package/bin/maestro.cjs +75 -75
  7. package/commands/compress.md +36 -36
  8. package/commands/frontier.md +124 -124
  9. package/commands/terse.md +23 -23
  10. package/docs/codex.md +167 -167
  11. package/docs/orchestration.md +168 -168
  12. package/frontier/cli.cjs +279 -252
  13. package/frontier/config.cjs +468 -468
  14. package/frontier/dispatch.cjs +267 -255
  15. package/frontier/judge.cjs +92 -92
  16. package/frontier/run.cjs +201 -180
  17. package/frontier/schema.cjs +112 -112
  18. package/frontier/semaphore.cjs +49 -49
  19. package/frontier/synthesize.cjs +79 -79
  20. package/hooks/frontier-autorun.cjs +127 -120
  21. package/hooks/hooks.json +103 -103
  22. package/hooks/maestro-doctrine-guard.cjs +81 -81
  23. package/hooks/maestro-gate-reminder.cjs +22 -7
  24. package/hooks/maestro-gate-telemetry.cjs +79 -77
  25. package/hooks/maestro-phase-scope.cjs +118 -118
  26. package/hooks/maestro-statusline-sync.cjs +152 -152
  27. package/hooks/maestro-subagent-guard.cjs +148 -148
  28. package/hooks/maestro-terse-mode.cjs +189 -189
  29. package/hooks/maestro-toolbudget-advisory.cjs +127 -127
  30. package/integrations/README.md +111 -111
  31. package/integrations/cline/skills/frontier/SKILL.md +75 -75
  32. package/integrations/codex/prompts/frontier.md +70 -70
  33. package/integrations/codex/prompts/update.md +39 -39
  34. package/integrations/codex/skills/maestro-frontier/SKILL.md +122 -122
  35. package/integrations/codex/skills/maestro-settings/SKILL.md +55 -55
  36. package/integrations/codex/skills/maestro-terse/SKILL.md +58 -58
  37. package/integrations/codex/skills/maestro-update/SKILL.md +31 -31
  38. package/integrations/cursor/commands/frontier.md +63 -63
  39. package/integrations/cursor/commands/update.md +34 -34
  40. package/integrations/gemini/commands/frontier.toml +76 -76
  41. package/integrations/windsurf/workflows/frontier.md +70 -70
  42. package/package.json +58 -58
  43. package/scripts/install.cjs +1014 -1014
  44. package/settings/cli.cjs +140 -140
  45. package/settings/config.cjs +309 -309
  46. package/skills/maestro-frontier/SKILL.md +122 -122
  47. package/skills/maestro-settings/SKILL.md +55 -55
  48. package/skills/maestro-terse/SKILL.md +58 -58
  49. package/skills/maestro-update/SKILL.md +31 -31
  50. package/skills/terse/SKILL.md +74 -74
@@ -1,468 +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
- 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
- };
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
+ };