@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,1014 +1,1014 @@
1
- #!/usr/bin/env node
2
- // Maestro installer — writes doctrine + engine + tool wrapper into a target
3
- // project. Append-only for AGENTS.md, no-clobber for wrapper files, safe to
4
- // re-run. Zero dependencies (Node stdlib only). CommonJS (.cjs).
5
- //
6
- // Usage (as module): const { run } = require('./install.cjs'); run(argv);
7
- // Usage (as script): node scripts/install.cjs [flags]
8
-
9
- 'use strict';
10
-
11
- const fs = require('fs');
12
- const os = require('os');
13
- const path = require('path');
14
- const crypto = require('crypto');
15
-
16
- // ---- constants ----
17
-
18
- const PKG_ROOT = path.join(__dirname, '..');
19
- const SENTINEL = '<!-- maestro:begin -->';
20
- const SENTINEL_END = '<!-- maestro:end -->';
21
-
22
- // Map target -> { templateSrc, projectDest, userDest }
23
- // templateSrc is relative to PKG_ROOT.
24
- // userDest null means no global install path for this target.
25
- const WRAPPER_MAP = {
26
- codex: {
27
- src: 'integrations/codex/prompts/frontier.md',
28
- proj: '.codex/prompts/frontier.md',
29
- user: () => path.join(homeDir(), '.codex', 'prompts', 'frontier.md'),
30
- },
31
- cursor: {
32
- src: 'integrations/cursor/commands/frontier.md',
33
- proj: '.cursor/commands/frontier.md',
34
- user: null, // no global path for cursor
35
- },
36
- gemini: {
37
- src: 'integrations/gemini/commands/frontier.toml',
38
- proj: '.gemini/commands/frontier.toml',
39
- user: () => path.join(homeDir(), '.gemini', 'commands', 'frontier.toml'),
40
- },
41
- cline: {
42
- src: 'integrations/cline/skills/frontier/SKILL.md',
43
- proj: '.cline/skills/frontier/SKILL.md',
44
- user: () => path.join(homeDir(), '.cline', 'skills', 'frontier', 'SKILL.md'),
45
- },
46
- windsurf: {
47
- src: 'integrations/windsurf/workflows/frontier.md',
48
- proj: '.windsurf/workflows/frontier.md',
49
- user: () => path.join(homeDir(), '.codeium', 'windsurf', 'global_workflows', 'frontier.md'),
50
- },
51
- };
52
-
53
- // Codex skill templates installed alongside the deprecated codex wrapper.
54
- // Codex loads skills from <project>/.agents/skills/<name>/SKILL.md (project)
55
- // or ~/.agents/skills/<name>/SKILL.md (global). Maestro-owned skills are
56
- // refreshed when still managed; user-edited copies are preserved.
57
- const CODEX_SKILLS = [
58
- { name: 'maestro-frontier', legacy: 'frontier' },
59
- { name: 'maestro-terse', legacy: 'terse' },
60
- { name: 'maestro-settings', legacy: 'settings' },
61
- { name: 'maestro-update', legacy: 'update' },
62
- ];
63
-
64
- const LEGACY_CODEX_SKILL_TEMPLATES = {
65
- frontier: `---
66
- name: frontier
67
- description: Maestro Frontier local multi-CLI fusion engine — switch mode, or run a prompt through the panel
68
- ---
69
-
70
- Drive the **Maestro Frontier** engine — a zero-dependency local multi-CLI fusion
71
- engine (a parallel panel of local CLIs → a judge model's analysis → a grounded
72
- synthesis). It is the same engine the Claude Code plugin ships; here it runs
73
- through the \`maestro\` CLI with \`--scope codex\`.
74
-
75
- **This is a typing shortcut, not a prompt hook.** Codex has no automatic
76
- prompt hook, so arming a mode does **not** auto-run the engine on later prompts —
77
- it only persists the mode. To actually fuse a prompt, invoke \`run\` explicitly
78
- (step 3).
79
-
80
- Map the user's request to one engine CLI call and run it from the repo root.
81
- Do not edit the engine's state file by hand.
82
-
83
- ## 1. Switch mode
84
-
85
- Persists to \`~/.config/maestro/frontier-state.codex.json\`; default \`off\`.
86
- \`--scope codex\` keeps Codex's armed mode independent from Claude Code, Cline,
87
- Cursor, and Gemini on the same machine:
88
-
89
- \`\`\`bash
90
- maestro frontier mode off --scope codex
91
- maestro frontier mode single --model <model> --scope codex
92
- maestro frontier mode fusion --preset <preset> --scope codex
93
- maestro frontier mode fusion --preset custom --models <a,b,c> --scope codex
94
- maestro frontier mode fusion --preset <preset> --judge <model> --synth <model> --scope codex
95
- \`\`\`
96
-
97
- Models: \`opus\` (Claude Opus 4.8, needs \`claude\`), \`gpt-5.5\` (needs \`codex\`),
98
- \`gemini\` (needs \`gemini\`). Presets: \`opus-duo\`, \`opus-gpt\`, \`gpt-duo\`,
99
- \`frontier-trio\`, \`custom\`. Judge + synth default to Opus; \`--judge\`/\`--synth\`
100
- override for any preset (e.g. \`--judge opus --synth gpt-5.5\`). \`gpt-duo\` runs
101
- judge + synth on GPT-5.5 — a Codex-only fusion that needs no \`claude\`.
102
-
103
- ## 2. Show current mode/preset
104
-
105
- \`\`\`bash
106
- maestro frontier status --scope codex
107
- \`\`\`
108
-
109
- ## 3. Run a prompt through the current mode
110
-
111
- This is the action that actually fuses, since nothing auto-runs here. Set a
112
- mode first (step 1), then:
113
-
114
- \`\`\`bash
115
- maestro frontier run "<prompt>" --scope codex
116
- \`\`\`
117
-
118
- - \`off\`: prints a notice, spawns nothing.
119
- - \`single\`: dispatches the one selected CLI, prints its answer.
120
- - \`fusion\`: runs the panel in parallel → judge → synthesizer; prints the final
121
- answer (a one-line run meta goes to stderr). Report stdout verbatim.
122
-
123
- On error the engine prints \`ERROR [<reason>]: <detail>\` to stderr and exits
124
- non-zero — relay the reason.
125
-
126
- ## Indicator instruction
127
-
128
- <!-- INDICATOR INSTRUCTION — apply every turn while this skill is active -->
129
-
130
- Before composing your first reply each turn, run:
131
-
132
- \`\`\`bash
133
- maestro frontier status --scope codex
134
- \`\`\`
135
-
136
- Parse the returned JSON \`mode\` field:
137
-
138
- - If \`mode\` is not \`off\`, make the **first line** of your reply exactly:
139
- \`Maestro Frontier ON (<label>)\`
140
- where \`<label>\` is formed as follows:
141
- - single mode → \`single · <model>\` (e.g. \`single · opus\`)
142
- - fusion mode → \`fusion · <preset>\` (e.g. \`fusion · frontier-trio\`);
143
- for a custom preset use \`fusion · custom (<model1>, <model2>, ...)\`
144
- - If \`mode\` is \`off\`, output no indicator line.
145
-
146
- <!-- END INDICATOR INSTRUCTION -->
147
-
148
- ## Notes
149
-
150
- - Real \`single\`/\`fusion\` runs spawn local CLIs and cost tokens; use small prompts.
151
- \`off\` is free.
152
- - Each model's CLI must be on \`PATH\`, or point at a specific build with
153
- \`MAESTRO_CLAUDE_BIN\` / \`MAESTRO_CODEX_BIN\` / \`MAESTRO_GEMINI_BIN\`.
154
- - Requires \`maestro\` on \`PATH\` (installed during Maestro setup). If it is
155
- missing, install Maestro first.
156
- `,
157
- terse: `---
158
- name: terse
159
- description: Toggle Maestro terse output level (lite, full, ultra, off) via the settings CLI
160
- ---
161
-
162
- Toggle the **Maestro terse** output level for this environment. Terse mode
163
- condenses agent replies; levels range from \`off\` (default verbosity) through
164
- \`lite\`, \`full\`, and \`ultra\` (most compressed).
165
-
166
- When the user invokes this skill, run the settings CLI to read or change the
167
- terse level. Do not edit settings files by hand.
168
-
169
- ## Check current terse level
170
-
171
- \`\`\`bash
172
- node settings/cli.cjs --help
173
- \`\`\`
174
-
175
- Consult the help output for the exact read subcommand, then run it. If
176
- \`settings/cli.cjs\` is not present, run \`maestro --help\` to discover the
177
- correct path.
178
-
179
- ## Set terse level
180
-
181
- \`\`\`bash
182
- node settings/cli.cjs terse <level>
183
- \`\`\`
184
-
185
- Valid levels: \`off\` | \`lite\` | \`full\` | \`ultra\`
186
-
187
- Examples:
188
-
189
- \`\`\`bash
190
- node settings/cli.cjs terse off
191
- node settings/cli.cjs terse lite
192
- node settings/cli.cjs terse full
193
- node settings/cli.cjs terse ultra
194
- \`\`\`
195
-
196
- If the CLI rejects an argument or the subcommand name differs, run
197
- \`node settings/cli.cjs --help\` first and follow the printed usage.
198
-
199
- ## Notes
200
-
201
- - The change persists in Maestro's settings store; it applies to subsequent
202
- agent turns in this project.
203
- - Requires \`node\` on \`PATH\` and Maestro installed in the project root. If
204
- \`settings/cli.cjs\` is missing, re-run the Maestro installer:
205
- \`npx github:mbanderas/maestro install --target codex\`
206
- `,
207
- settings: `---
208
- name: settings
209
- description: View and change Maestro toggles (terse, frontier, context-bar) via the settings CLI
210
- ---
211
-
212
- View or change **Maestro settings** for this project. The settings CLI manages
213
- the three primary toggles: \`terse\`, \`frontier\`, and \`context-bar\`.
214
-
215
- When the user invokes this skill, run the settings CLI from the repo root.
216
- Do not edit settings files by hand.
217
-
218
- ## Discover available commands
219
-
220
- \`\`\`bash
221
- node settings/cli.cjs --help
222
- \`\`\`
223
-
224
- If \`settings/cli.cjs\` is not present, run \`maestro --help\` to locate the
225
- correct entry point.
226
-
227
- ## Common operations
228
-
229
- List current settings:
230
-
231
- \`\`\`bash
232
- node settings/cli.cjs
233
- \`\`\`
234
-
235
- Set a toggle:
236
-
237
- \`\`\`bash
238
- node settings/cli.cjs terse <off|lite|full|ultra>
239
- node settings/cli.cjs frontier <off|single|fusion>
240
- node settings/cli.cjs context-bar <on|off>
241
- \`\`\`
242
-
243
- If a subcommand name or argument differs from the above, follow the usage
244
- printed by \`--help\` — do not guess flags.
245
-
246
- ## Notes
247
-
248
- - Changes persist in Maestro's settings store and apply to subsequent agent
249
- turns in this project.
250
- - Requires \`node\` on \`PATH\` and Maestro installed in the project root. If
251
- \`settings/cli.cjs\` is missing, re-run the installer:
252
- \`npx github:mbanderas/maestro install --target codex\`
253
- `,
254
- update: `---
255
- name: update
256
- description: Update Maestro to the latest version by re-running the installer for Codex
257
- ---
258
-
259
- Update **Maestro** to the latest marketplace code. This re-runs the installer,
260
- which pulls the current release and overwrites the local Maestro files in place.
261
-
262
- When the user invokes this skill, run the installer from the repo root:
263
-
264
- \`\`\`bash
265
- npx github:mbanderas/maestro install --target codex
266
- \`\`\`
267
-
268
- The installer is idempotent — it is safe to re-run against an existing
269
- installation. It will:
270
-
271
- - Pull the latest Maestro source from the repository.
272
- - Overwrite skills, hooks, and settings scaffolding with the new versions.
273
- - Leave project-local configuration (state files, secrets) untouched.
274
-
275
- ## Notes
276
-
277
- - Requires \`node\` and \`npx\` on \`PATH\`.
278
- - Run from the project root so the installer targets the correct directory.
279
- - After the installer completes, restart the Codex session (or reload the
280
- project) so updated skills and hooks take effect.
281
- - If \`npx\` is unavailable, clone \`https://github.com/mbanderas/maestro\`
282
- manually and follow the repository's install instructions.
283
- `,
284
- };
285
-
286
- // Runtime adapter per target. The adapter imports @AGENTS.md (Cursor has no
287
- // imports, so .cursorrules embeds the kernel). codex/cline/windsurf read
288
- // AGENTS.md directly and need no adapter.
289
- const ADAPTER_MAP = {
290
- claude: 'CLAUDE.md',
291
- gemini: 'GEMINI.md',
292
- cursor: '.cursorrules',
293
- };
294
-
295
- // Marker dirs used for auto-detection (scanned inside project root)
296
- const AUTO_MARKERS = [
297
- { dir: '.cursor', target: 'cursor' },
298
- { dir: '.gemini', target: 'gemini' },
299
- { dir: '.codex', target: 'codex' },
300
- { dir: '.cline', target: 'cline' },
301
- { dir: '.windsurf',target: 'windsurf' },
302
- { dir: '.claude', target: 'claude' },
303
- ];
304
-
305
- // ---- safety helpers ----
306
-
307
- /**
308
- * Returns true if p is a symlink (lstat-based). Never throws.
309
- * @param {string} p
310
- * @returns {boolean}
311
- */
312
- function isSymlink(p) {
313
- try {
314
- return fs.lstatSync(p).isSymbolicLink();
315
- } catch {
316
- return false;
317
- }
318
- }
319
-
320
- /**
321
- * Create directories for destPath. Refuses to create through a symlinked
322
- * ancestor directory. Returns true on success, false on refusal.
323
- * @param {string} destPath
324
- * @returns {boolean}
325
- */
326
- function safeMkdirp(destPath) {
327
- const dir = path.dirname(destPath);
328
- // Walk ancestors from PKG_ROOT outward — only check the leaf dir because
329
- // we cannot reliably validate every ancestor on all OSes; the write will
330
- // fail safely if anything is wrong.
331
- try {
332
- fs.mkdirSync(dir, { recursive: true });
333
- return true;
334
- } catch {
335
- return false;
336
- }
337
- }
338
-
339
- /**
340
- * Write buf to dest. Refuses if dest (or its parent dir) is a symlink.
341
- * Returns { ok: true } or { ok: false, reason: string }.
342
- * @param {string} dest
343
- * @param {string|Buffer} content
344
- * @returns {{ ok: boolean, reason?: string }}
345
- */
346
- function safeWrite(dest, content) {
347
- // Check parent dir
348
- const dir = path.dirname(dest);
349
- if (isSymlink(dir)) {
350
- return { ok: false, reason: `parent dir is a symlink: ${dir}` };
351
- }
352
- // Check destination itself
353
- if (isSymlink(dest)) {
354
- return { ok: false, reason: `destination is a symlink: ${dest}` };
355
- }
356
- try {
357
- fs.writeFileSync(dest, content, 'utf8');
358
- return { ok: true };
359
- } catch (err) {
360
- return { ok: false, reason: String(err.message || err) };
361
- }
362
- }
363
-
364
- function homeDir() {
365
- return process.platform === 'win32'
366
- ? (process.env.USERPROFILE || os.homedir())
367
- : (process.env.HOME || os.homedir());
368
- }
369
-
370
- function sha256(content) {
371
- return crypto.createHash('sha256').update(content, 'utf8').digest('hex');
372
- }
373
-
374
- function codexSkillManagedContent(name, srcContent) {
375
- const body = srcContent.trimEnd() + '\n';
376
- return `${body}\n<!-- maestro-managed:codex-skill name=${name} sha256=${sha256(body)} -->\n`;
377
- }
378
-
379
- function splitCodexSkillMarker(content) {
380
- const marker = /\n?<!-- maestro-managed:codex-skill name=([^\s]+) sha256=([a-f0-9]+|0000) -->\s*$/i.exec(content);
381
- if (!marker) return null;
382
- return {
383
- name: marker[1],
384
- hash: marker[2].toLowerCase(),
385
- body: content.slice(0, marker.index).trimEnd() + '\n',
386
- };
387
- }
388
-
389
- function isManagedCodexSkillContent(content, expectedName, managedBodies) {
390
- const marker = splitCodexSkillMarker(content);
391
- if (marker && marker.name === expectedName) {
392
- return marker.hash === '0000' || marker.hash === sha256(marker.body);
393
- }
394
- if (content.includes(`maestro-managed:codex-skill name=${expectedName} sha256=0000`)) {
395
- return true;
396
- }
397
- return managedBodies.some((body) => content.trimEnd() === body.trimEnd());
398
- }
399
-
400
- function legacyCodexSkillContent(legacyName, namespacedName) {
401
- const body = `---\nname: ${legacyName}\ndescription: Legacy Maestro compatibility skill for ${namespacedName}\n---\n\nThis legacy Maestro skill has moved to \`${namespacedName}\`.\n\nUse the \`${namespacedName}\` skill for current Maestro behavior. This compatibility skill is kept only for existing Codex installs that still reference \`${legacyName}\`.\n`;
402
- return codexSkillManagedContent(legacyName, body);
403
- }
404
-
405
- function legacyGenericCodexTemplate(srcContent, legacyName, namespacedName) {
406
- return srcContent.replace(
407
- new RegExp(`(^---\\r?\\nname: )${namespacedName}(\\r?\\n)`, 'm'),
408
- `$1${legacyName}$2`
409
- );
410
- }
411
-
412
- // ---- parse argv ----
413
-
414
- /**
415
- * @param {string[]} argv
416
- * @returns {{ target: string, project: string, user: boolean, dryRun: boolean, noHooks: boolean }}
417
- */
418
- function parseArgs(argv) {
419
- const opts = {
420
- target: 'auto',
421
- project: process.cwd(),
422
- user: false,
423
- dryRun: false,
424
- noHooks: false,
425
- };
426
-
427
- let i = 0;
428
- while (i < argv.length) {
429
- const a = argv[i];
430
- if (a === '--target' && i + 1 < argv.length) {
431
- opts.target = argv[++i];
432
- } else if (a === '--project' && i + 1 < argv.length) {
433
- opts.project = argv[++i];
434
- } else if (a === '--user') {
435
- opts.user = true;
436
- } else if (a === '--dry-run') {
437
- opts.dryRun = true;
438
- } else if (a === '--no-hooks') {
439
- opts.noHooks = true;
440
- }
441
- i++;
442
- }
443
-
444
- opts.project = path.resolve(opts.project);
445
- return opts;
446
- }
447
-
448
- // ---- auto-detect ----
449
-
450
- /**
451
- * Detect which tool is in use by looking for marker dirs.
452
- * @param {string} projectRoot
453
- * @returns {string} detected target or 'none'
454
- */
455
- function detectTarget(projectRoot) {
456
- for (const { dir, target } of AUTO_MARKERS) {
457
- try {
458
- const p = path.join(projectRoot, dir);
459
- const st = fs.lstatSync(p);
460
- if (st.isDirectory()) return target;
461
- } catch {
462
- // not found
463
- }
464
- }
465
- return 'none';
466
- }
467
-
468
- // ---- install actions ----
469
-
470
- /**
471
- * Read a file from the package root. Returns string, or null (logs) on error.
472
- * @param {string} rel
473
- * @param {(msg: string) => void} log
474
- * @returns {string|null}
475
- */
476
- function readPkgFile(rel, log) {
477
- try {
478
- return fs.readFileSync(path.join(PKG_ROOT, rel), 'utf8');
479
- } catch (err) {
480
- log(`ERROR: cannot read package ${rel}: ${err.message}`);
481
- return null;
482
- }
483
- }
484
-
485
- /**
486
- * Install a doctrine/adapter markdown file. Append-only, idempotent, never
487
- * clobbers user content above the maestro block; refuses symlinks.
488
- * @param {string} dest absolute destination path
489
- * @param {string} srcContent content to install
490
- * @param {string} label short name for logs (e.g. "AGENTS.md")
491
- * @param {boolean} dryRun
492
- * @param {(msg: string) => void} log
493
- * @returns {boolean} true = success (or no-op), false = error
494
- */
495
- function appendOnlyDoctrine(dest, srcContent, label, dryRun, log) {
496
- const block = `\n${SENTINEL}\n${srcContent}\n${SENTINEL_END}\n`;
497
-
498
- let existsStat;
499
- try { existsStat = fs.lstatSync(dest); } catch { existsStat = null; }
500
-
501
- if (existsStat) {
502
- if (existsStat.isSymbolicLink()) {
503
- log(`ERROR: ${label} is a symlink — refusing to write through it: ${dest}`);
504
- return false;
505
- }
506
-
507
- let existing;
508
- try { existing = fs.readFileSync(dest, 'utf8'); } catch (err) {
509
- log(`ERROR: cannot read existing ${label}: ${err.message}`);
510
- return false;
511
- }
512
-
513
- if (existing.includes(SENTINEL)) {
514
- log(`[doctrine] ${label} already contains sentinel — skipping`);
515
- return true;
516
- }
517
-
518
- if (dryRun) {
519
- log(`[dry-run] would append maestro doctrine to existing ${dest}`);
520
- return true;
521
- }
522
-
523
- const res = safeWrite(dest, existing + block);
524
- if (!res.ok) {
525
- log(`ERROR: failed to append to ${label}: ${res.reason}`);
526
- return false;
527
- }
528
- log(`[doctrine] appended maestro block to existing ${label}`);
529
- return true;
530
- }
531
-
532
- // Absent — write fresh, wrapped in the sentinel so re-runs detect it.
533
- if (dryRun) {
534
- log(`[dry-run] would create ${dest}`);
535
- return true;
536
- }
537
-
538
- if (!safeMkdirp(dest)) {
539
- log(`ERROR: could not create parent dir for ${dest}`);
540
- return false;
541
- }
542
-
543
- const freshContent = SENTINEL + '\n' + srcContent + '\n' + SENTINEL_END + '\n';
544
- const res = safeWrite(dest, freshContent);
545
- if (!res.ok) {
546
- log(`ERROR: failed to write ${label}: ${res.reason}`);
547
- return false;
548
- }
549
- log(`[doctrine] wrote ${label}`);
550
- return true;
551
- }
552
-
553
- /**
554
- * Install the portable doctrine core (AGENTS.md) into the project root.
555
- * @param {string} projectRoot
556
- * @param {boolean} dryRun
557
- * @param {(msg: string) => void} log
558
- * @returns {boolean}
559
- */
560
- function installDoctrine(projectRoot, dryRun, log) {
561
- const src = readPkgFile('AGENTS.md', log);
562
- if (src === null) return false;
563
- return appendOnlyDoctrine(path.join(projectRoot, 'AGENTS.md'), src, 'AGENTS.md', dryRun, log);
564
- }
565
-
566
- /**
567
- * Install the runtime adapter for a target (CLAUDE.md / GEMINI.md /
568
- * .cursorrules). codex/cline/windsurf read AGENTS.md directly -> no-op.
569
- * @param {string} target
570
- * @param {string} projectRoot
571
- * @param {boolean} dryRun
572
- * @param {(msg: string) => void} log
573
- * @returns {boolean}
574
- */
575
- function installAdapter(target, projectRoot, dryRun, log) {
576
- const rel = ADAPTER_MAP[target];
577
- if (!rel) return true; // no adapter for this target
578
- const src = readPkgFile(rel, log);
579
- if (src === null) return false;
580
- return appendOnlyDoctrine(path.join(projectRoot, rel), src, rel, dryRun, log);
581
- }
582
-
583
- /**
584
- * Recursively copy srcDir -> destDir, skipping *.test.cjs files.
585
- * @param {string} srcDir
586
- * @param {string} destDir
587
- * @param {boolean} dryRun
588
- * @param {(msg: string) => void} log
589
- * @returns {boolean}
590
- */
591
- function copyDirRecursive(srcDir, destDir, dryRun, log) {
592
- let entries;
593
- try {
594
- entries = fs.readdirSync(srcDir, { withFileTypes: true });
595
- } catch (err) {
596
- log(`ERROR: cannot read dir ${srcDir}: ${err.message}`);
597
- return false;
598
- }
599
-
600
- let ok = true;
601
- for (const entry of entries) {
602
- if (entry.isFile() && entry.name.endsWith('.test.cjs')) continue;
603
-
604
- const src = path.join(srcDir, entry.name);
605
- const dest = path.join(destDir, entry.name);
606
-
607
- if (entry.isDirectory()) {
608
- if (!copyDirRecursive(src, dest, dryRun, log)) ok = false;
609
- } else if (entry.isFile()) {
610
- if (dryRun) {
611
- log(`[dry-run] would write ${dest}`);
612
- continue;
613
- }
614
-
615
- if (isSymlink(dest)) {
616
- log(`ERROR: destination is a symlink — refusing: ${dest}`);
617
- ok = false;
618
- continue;
619
- }
620
- if (isSymlink(path.dirname(dest))) {
621
- log(`ERROR: destination parent is a symlink — refusing: ${dest}`);
622
- ok = false;
623
- continue;
624
- }
625
-
626
- try {
627
- fs.mkdirSync(destDir, { recursive: true });
628
- const content = fs.readFileSync(src);
629
- fs.writeFileSync(dest, content);
630
- log(`[engine] copied ${dest}`);
631
- } catch (err) {
632
- log(`ERROR: failed to copy ${src} -> ${dest}: ${err.message}`);
633
- ok = false;
634
- }
635
- }
636
- }
637
- return ok;
638
- }
639
-
640
- /**
641
- * Install engine files (frontier/ dir + settings/ dir + bin/maestro.cjs).
642
- * @param {string} projectRoot
643
- * @param {boolean} dryRun
644
- * @param {(msg: string) => void} log
645
- * @returns {boolean}
646
- */
647
- function installEngine(projectRoot, dryRun, log) {
648
- const srcFrontier = path.join(PKG_ROOT, 'frontier');
649
- const destFrontier = path.join(projectRoot, 'frontier');
650
- const srcSettings = path.join(PKG_ROOT, 'settings');
651
- const destSettings = path.join(projectRoot, 'settings');
652
- const srcBin = path.join(PKG_ROOT, 'bin', 'maestro.cjs');
653
- const destBin = path.join(projectRoot, 'bin', 'maestro.cjs');
654
-
655
- let ok = copyDirRecursive(srcFrontier, destFrontier, dryRun, log);
656
- if (!copyDirRecursive(srcSettings, destSettings, dryRun, log)) ok = false;
657
-
658
- // bin/maestro.cjs
659
- if (dryRun) {
660
- log(`[dry-run] would write ${destBin}`);
661
- } else {
662
- if (isSymlink(destBin)) {
663
- log(`ERROR: bin/maestro.cjs is a symlink — refusing: ${destBin}`);
664
- ok = false;
665
- } else {
666
- try {
667
- fs.mkdirSync(path.dirname(destBin), { recursive: true });
668
- fs.writeFileSync(destBin, fs.readFileSync(srcBin));
669
- log(`[engine] copied ${destBin}`);
670
- } catch (err) {
671
- log(`ERROR: failed to copy bin/maestro.cjs: ${err.message}`);
672
- ok = false;
673
- }
674
- }
675
- }
676
-
677
- // docs/orchestration.md — the on-demand S2-S6 multi-agent protocol the
678
- // kernel references. Maestro-owned reference file; copy (refuse symlinks).
679
- const srcDocs = path.join(PKG_ROOT, 'docs', 'orchestration.md');
680
- const destDocs = path.join(projectRoot, 'docs', 'orchestration.md');
681
- if (dryRun) {
682
- log(`[dry-run] would write ${destDocs}`);
683
- } else if (isSymlink(destDocs)) {
684
- log(`ERROR: docs/orchestration.md is a symlink — refusing: ${destDocs}`);
685
- ok = false;
686
- } else {
687
- try {
688
- fs.mkdirSync(path.dirname(destDocs), { recursive: true });
689
- fs.writeFileSync(destDocs, fs.readFileSync(srcDocs));
690
- log(`[doctrine] copied ${destDocs}`);
691
- } catch (err) {
692
- log(`ERROR: failed to copy docs/orchestration.md: ${err.message}`);
693
- ok = false;
694
- }
695
- }
696
-
697
- return ok;
698
- }
699
-
700
- /**
701
- * Copy a single package template file to dest, no-clobber. Skips when dest
702
- * already exists, refuses symlinks, honors dry-run. Reuses safeMkdirp +
703
- * safeWrite. Shared by wrapper and Codex-skill installs.
704
- * @param {string} src absolute source path (under PKG_ROOT)
705
- * @param {string} dest absolute destination path
706
- * @param {string} label short tag for logs (e.g. "wrapper", "codex-skill")
707
- * @param {boolean} dryRun
708
- * @param {(msg: string) => void} log
709
- * @returns {boolean} true = success (wrote, skipped, or planned), false = error
710
- */
711
- function installNoClobberFile(src, dest, label, dryRun, log) {
712
- // Check if dest exists already (no-clobber)
713
- let destStat;
714
- try { destStat = fs.lstatSync(dest); } catch { destStat = null; }
715
-
716
- if (destStat) {
717
- if (destStat.isSymbolicLink()) {
718
- log(`ERROR: ${label} dest is a symlink — refusing: ${dest}`);
719
- return false;
720
- }
721
- log(`[${label}] skipped (exists, not clobbered): ${dest}`);
722
- return true;
723
- }
724
-
725
- let srcContent;
726
- try {
727
- srcContent = fs.readFileSync(src, 'utf8');
728
- } catch (err) {
729
- log(`ERROR: cannot read template ${src}: ${err.message}`);
730
- return false;
731
- }
732
-
733
- if (dryRun) {
734
- log(`[dry-run] would create ${dest}`);
735
- return true;
736
- }
737
-
738
- if (!safeMkdirp(dest)) {
739
- log(`ERROR: could not create parent dir for ${dest}`);
740
- return false;
741
- }
742
-
743
- const res = safeWrite(dest, srcContent);
744
- if (!res.ok) {
745
- log(`ERROR: failed to write ${label} ${dest}: ${res.reason}`);
746
- return false;
747
- }
748
- log(`[${label}] wrote ${dest}`);
749
- return true;
750
- }
751
-
752
- /**
753
- * Install wrapper file (no-clobber).
754
- * @param {string} target
755
- * @param {string} projectRoot
756
- * @param {boolean} userGlobal
757
- * @param {boolean} dryRun
758
- * @param {(msg: string) => void} log
759
- * @returns {boolean}
760
- */
761
- function installWrapper(target, projectRoot, userGlobal, dryRun, log) {
762
- if (target === 'claude') {
763
- log('[claude] No wrapper file — plugin delivers the command.');
764
- log('[claude] To install the plugin: /plugin marketplace add mbanderas/maestro');
765
- log('[claude] Then: /plugin install maestro@maestro');
766
- return true;
767
- }
768
-
769
- const mapping = WRAPPER_MAP[target];
770
- if (!mapping) {
771
- log(`ERROR: unknown target: ${target}`);
772
- return false;
773
- }
774
-
775
- const src = path.join(PKG_ROOT, mapping.src);
776
-
777
- let dest;
778
- if (userGlobal) {
779
- if (!mapping.user) {
780
- log(`[wrapper] --user not supported for target ${target} — writing to project instead`);
781
- dest = path.join(projectRoot, mapping.proj);
782
- } else {
783
- dest = mapping.user();
784
- }
785
- } else {
786
- dest = path.join(projectRoot, mapping.proj);
787
- }
788
-
789
- return installNoClobberFile(src, dest, 'wrapper', dryRun, log);
790
- }
791
-
792
- /**
793
- * Install the Codex skill templates alongside the codex wrapper. Maestro-owned
794
- * skill files refresh in place; user-edited copies are preserved.
795
- * Project mode -> <project>/.agents/skills/<name>/SKILL.md; --user/global mode
796
- * -> ~/.agents/skills/<name>/SKILL.md (mirrors installWrapper's dest logic).
797
- * @param {string} projectRoot
798
- * @param {boolean} userGlobal
799
- * @param {boolean} dryRun
800
- * @param {(msg: string) => void} log
801
- * @returns {boolean}
802
- */
803
- function installManagedCodexSkill(src, dest, name, legacyName, dryRun, log) {
804
- let srcContent;
805
- try {
806
- srcContent = fs.readFileSync(src, 'utf8');
807
- } catch (err) {
808
- log(`ERROR: cannot read template ${src}: ${err.message}`);
809
- return false;
810
- }
811
-
812
- const managedContent = codexSkillManagedContent(name, srcContent);
813
- const managedBodies = [srcContent, managedContent];
814
-
815
- let destStat;
816
- try { destStat = fs.lstatSync(dest); } catch { destStat = null; }
817
-
818
- if (destStat) {
819
- if (destStat.isSymbolicLink()) {
820
- log(`ERROR: codex-skill dest is a symlink — refusing: ${dest}`);
821
- return false;
822
- }
823
-
824
- let existing;
825
- try { existing = fs.readFileSync(dest, 'utf8'); } catch (err) {
826
- log(`ERROR: cannot read existing Codex skill ${dest}: ${err.message}`);
827
- return false;
828
- }
829
-
830
- if (!isManagedCodexSkillContent(existing, name, managedBodies)) {
831
- log(`[codex-skill] preserved user-edited Codex skill: ${dest}`);
832
- log(`[codex-skill] next step: compare with integrations/codex/skills/${name}/SKILL.md and manually merge if desired`);
833
- return true;
834
- }
835
-
836
- if (existing === managedContent) {
837
- log(`[codex-skill] up to date: ${dest}`);
838
- return true;
839
- }
840
-
841
- if (dryRun) {
842
- log(`[dry-run] would refresh managed Codex skill ${dest}`);
843
- return true;
844
- }
845
-
846
- const res = safeWrite(dest, managedContent);
847
- if (!res.ok) {
848
- log(`ERROR: failed to refresh codex-skill ${dest}: ${res.reason}`);
849
- return false;
850
- }
851
- log(`[codex-skill] refreshed managed Codex skill: ${dest}`);
852
- return true;
853
- }
854
-
855
- if (dryRun) {
856
- log(`[dry-run] would create ${dest}`);
857
- return true;
858
- }
859
-
860
- if (!safeMkdirp(dest)) {
861
- log(`ERROR: could not create parent dir for ${dest}`);
862
- return false;
863
- }
864
-
865
- const res = safeWrite(dest, managedContent);
866
- if (!res.ok) {
867
- log(`ERROR: failed to write codex-skill ${dest}: ${res.reason}`);
868
- return false;
869
- }
870
- log(`[codex-skill] wrote ${dest}`);
871
- return true;
872
- }
873
-
874
- function migrateLegacyCodexSkill(dest, legacyName, namespacedName, knownTemplate, dryRun, log) {
875
- let destStat;
876
- try { destStat = fs.lstatSync(dest); } catch { return true; }
877
-
878
- if (destStat.isSymbolicLink()) {
879
- log(`ERROR: legacy codex-skill dest is a symlink — refusing: ${dest}`);
880
- return false;
881
- }
882
-
883
- let existing;
884
- try { existing = fs.readFileSync(dest, 'utf8'); } catch (err) {
885
- log(`ERROR: cannot read legacy Codex skill ${dest}: ${err.message}`);
886
- return false;
887
- }
888
-
889
- const shim = legacyCodexSkillContent(legacyName, namespacedName);
890
- const managedBodies = [
891
- knownTemplate,
892
- legacyGenericCodexTemplate(knownTemplate, legacyName, namespacedName),
893
- LEGACY_CODEX_SKILL_TEMPLATES[legacyName],
894
- shim,
895
- ].filter(Boolean);
896
- if (!isManagedCodexSkillContent(existing, legacyName, managedBodies)) {
897
- log(`[codex-skill] preserved user-edited legacy Codex skill: ${dest}`);
898
- log(`[codex-skill] next step: rename or merge it into .agents/skills/${namespacedName}/SKILL.md if you still need custom behavior`);
899
- return true;
900
- }
901
-
902
- if (existing === shim) {
903
- log(`[codex-skill] legacy compatibility up to date: ${dest}`);
904
- return true;
905
- }
906
-
907
- if (dryRun) {
908
- log(`[dry-run] would migrate legacy Codex skill ${dest}`);
909
- return true;
910
- }
911
-
912
- const res = safeWrite(dest, shim);
913
- if (!res.ok) {
914
- log(`ERROR: failed to migrate legacy codex-skill ${dest}: ${res.reason}`);
915
- return false;
916
- }
917
- log(`[codex-skill] migrated legacy Codex skill to compatibility shim: ${dest}`);
918
- return true;
919
- }
920
-
921
- function installCodexSkills(projectRoot, userGlobal, dryRun, log) {
922
- const skillsRoot = userGlobal
923
- ? path.join(homeDir(), '.agents', 'skills')
924
- : path.join(projectRoot, '.agents', 'skills');
925
-
926
- let ok = true;
927
- for (const skill of CODEX_SKILLS) {
928
- const src = path.join(PKG_ROOT, 'integrations', 'codex', 'skills', skill.name, 'SKILL.md');
929
- const dest = path.join(skillsRoot, skill.name, 'SKILL.md');
930
- if (!installManagedCodexSkill(src, dest, skill.name, skill.legacy, dryRun, log)) ok = false;
931
-
932
- let legacyTemplate = '';
933
- try { legacyTemplate = fs.readFileSync(src, 'utf8'); } catch {}
934
- const legacyDest = path.join(skillsRoot, skill.legacy, 'SKILL.md');
935
- if (!migrateLegacyCodexSkill(legacyDest, skill.legacy, skill.name, legacyTemplate, dryRun, log)) ok = false;
936
- }
937
- return ok;
938
- }
939
-
940
- // ---- main entry ----
941
-
942
- /**
943
- * Run the installer. Returns a numeric exit code (0 = success).
944
- * @param {string[]} argv
945
- * @returns {number}
946
- */
947
- function run(argv) {
948
- const opts = parseArgs(argv || []);
949
- const { target: rawTarget, project, user: userGlobal, dryRun } = opts;
950
-
951
- const lines = [];
952
- const log = (msg) => { lines.push(msg); process.stdout.write(msg + '\n'); };
953
-
954
- if (dryRun) log('[dry-run] planning only — no files will be written');
955
-
956
- // Resolve target
957
- let target = rawTarget;
958
- if (target === 'auto') {
959
- target = detectTarget(project);
960
- if (target === 'none') {
961
- log('[auto] no tool marker dir found — installing doctrine + engine only');
962
- log('[auto] pass --target <tool> to install a command wrapper');
963
- } else {
964
- log(`[auto] detected target: ${target}`);
965
- }
966
- }
967
-
968
- const VALID_TARGETS = ['auto', 'claude', 'codex', 'cursor', 'gemini', 'cline', 'windsurf'];
969
- if (!VALID_TARGETS.includes(rawTarget)) {
970
- log(`ERROR: unknown --target value: ${rawTarget}`);
971
- return 1;
972
- }
973
-
974
- let anyError = false;
975
-
976
- // 1. Doctrine — portable AGENTS.md kernel + this target's runtime adapter.
977
- if (!installDoctrine(project, dryRun, log)) anyError = true;
978
- if (!installAdapter(target, project, dryRun, log)) anyError = true;
979
-
980
- // 2. Engine — frontier/ + bin/maestro.cjs + docs/orchestration.md.
981
- if (!installEngine(project, dryRun, log)) anyError = true;
982
-
983
- // 3. Wrapper — this target's /frontier command (skip if no target detected).
984
- if (target !== 'none') {
985
- if (!installWrapper(target, project, userGlobal, dryRun, log)) anyError = true;
986
- }
987
-
988
- // 3b. Codex skills — the .agents/skills/<name>/SKILL.md set ships alongside
989
- // the deprecated codex prompt wrapper.
990
- if (target === 'codex') {
991
- if (!installCodexSkills(project, userGlobal, dryRun, log)) anyError = true;
992
- }
993
-
994
- if (anyError) {
995
- log('install completed with errors (see above)');
996
- return 1;
997
- }
998
- log('install complete');
999
- return 0;
1000
- }
1001
-
1002
- // ---- CLI entry ----
1003
-
1004
- if (require.main === module) {
1005
- const code = run(process.argv.slice(2));
1006
- process.exit(code);
1007
- }
1008
-
1009
- module.exports = {
1010
- run,
1011
- _test: {
1012
- LEGACY_CODEX_SKILL_TEMPLATES,
1013
- },
1014
- };
1
+ #!/usr/bin/env node
2
+ // Maestro installer — writes doctrine + engine + tool wrapper into a target
3
+ // project. Append-only for AGENTS.md, no-clobber for wrapper files, safe to
4
+ // re-run. Zero dependencies (Node stdlib only). CommonJS (.cjs).
5
+ //
6
+ // Usage (as module): const { run } = require('./install.cjs'); run(argv);
7
+ // Usage (as script): node scripts/install.cjs [flags]
8
+
9
+ 'use strict';
10
+
11
+ const fs = require('fs');
12
+ const os = require('os');
13
+ const path = require('path');
14
+ const crypto = require('crypto');
15
+
16
+ // ---- constants ----
17
+
18
+ const PKG_ROOT = path.join(__dirname, '..');
19
+ const SENTINEL = '<!-- maestro:begin -->';
20
+ const SENTINEL_END = '<!-- maestro:end -->';
21
+
22
+ // Map target -> { templateSrc, projectDest, userDest }
23
+ // templateSrc is relative to PKG_ROOT.
24
+ // userDest null means no global install path for this target.
25
+ const WRAPPER_MAP = {
26
+ codex: {
27
+ src: 'integrations/codex/prompts/frontier.md',
28
+ proj: '.codex/prompts/frontier.md',
29
+ user: () => path.join(homeDir(), '.codex', 'prompts', 'frontier.md'),
30
+ },
31
+ cursor: {
32
+ src: 'integrations/cursor/commands/frontier.md',
33
+ proj: '.cursor/commands/frontier.md',
34
+ user: null, // no global path for cursor
35
+ },
36
+ gemini: {
37
+ src: 'integrations/gemini/commands/frontier.toml',
38
+ proj: '.gemini/commands/frontier.toml',
39
+ user: () => path.join(homeDir(), '.gemini', 'commands', 'frontier.toml'),
40
+ },
41
+ cline: {
42
+ src: 'integrations/cline/skills/frontier/SKILL.md',
43
+ proj: '.cline/skills/frontier/SKILL.md',
44
+ user: () => path.join(homeDir(), '.cline', 'skills', 'frontier', 'SKILL.md'),
45
+ },
46
+ windsurf: {
47
+ src: 'integrations/windsurf/workflows/frontier.md',
48
+ proj: '.windsurf/workflows/frontier.md',
49
+ user: () => path.join(homeDir(), '.codeium', 'windsurf', 'global_workflows', 'frontier.md'),
50
+ },
51
+ };
52
+
53
+ // Codex skill templates installed alongside the deprecated codex wrapper.
54
+ // Codex loads skills from <project>/.agents/skills/<name>/SKILL.md (project)
55
+ // or ~/.agents/skills/<name>/SKILL.md (global). Maestro-owned skills are
56
+ // refreshed when still managed; user-edited copies are preserved.
57
+ const CODEX_SKILLS = [
58
+ { name: 'maestro-frontier', legacy: 'frontier' },
59
+ { name: 'maestro-terse', legacy: 'terse' },
60
+ { name: 'maestro-settings', legacy: 'settings' },
61
+ { name: 'maestro-update', legacy: 'update' },
62
+ ];
63
+
64
+ const LEGACY_CODEX_SKILL_TEMPLATES = {
65
+ frontier: `---
66
+ name: frontier
67
+ description: Maestro Frontier local multi-CLI fusion engine — switch mode, or run a prompt through the panel
68
+ ---
69
+
70
+ Drive the **Maestro Frontier** engine — a zero-dependency local multi-CLI fusion
71
+ engine (a parallel panel of local CLIs → a judge model's analysis → a grounded
72
+ synthesis). It is the same engine the Claude Code plugin ships; here it runs
73
+ through the \`maestro\` CLI with \`--scope codex\`.
74
+
75
+ **This is a typing shortcut, not a prompt hook.** Codex has no automatic
76
+ prompt hook, so arming a mode does **not** auto-run the engine on later prompts —
77
+ it only persists the mode. To actually fuse a prompt, invoke \`run\` explicitly
78
+ (step 3).
79
+
80
+ Map the user's request to one engine CLI call and run it from the repo root.
81
+ Do not edit the engine's state file by hand.
82
+
83
+ ## 1. Switch mode
84
+
85
+ Persists to \`~/.config/maestro/frontier-state.codex.json\`; default \`off\`.
86
+ \`--scope codex\` keeps Codex's armed mode independent from Claude Code, Cline,
87
+ Cursor, and Gemini on the same machine:
88
+
89
+ \`\`\`bash
90
+ maestro frontier mode off --scope codex
91
+ maestro frontier mode single --model <model> --scope codex
92
+ maestro frontier mode fusion --preset <preset> --scope codex
93
+ maestro frontier mode fusion --preset custom --models <a,b,c> --scope codex
94
+ maestro frontier mode fusion --preset <preset> --judge <model> --synth <model> --scope codex
95
+ \`\`\`
96
+
97
+ Models: \`opus\` (Claude Opus 4.8, needs \`claude\`), \`gpt-5.5\` (needs \`codex\`),
98
+ \`gemini\` (needs \`gemini\`). Presets: \`opus-duo\`, \`opus-gpt\`, \`gpt-duo\`,
99
+ \`frontier-trio\`, \`custom\`. Judge + synth default to Opus; \`--judge\`/\`--synth\`
100
+ override for any preset (e.g. \`--judge opus --synth gpt-5.5\`). \`gpt-duo\` runs
101
+ judge + synth on GPT-5.5 — a Codex-only fusion that needs no \`claude\`.
102
+
103
+ ## 2. Show current mode/preset
104
+
105
+ \`\`\`bash
106
+ maestro frontier status --scope codex
107
+ \`\`\`
108
+
109
+ ## 3. Run a prompt through the current mode
110
+
111
+ This is the action that actually fuses, since nothing auto-runs here. Set a
112
+ mode first (step 1), then:
113
+
114
+ \`\`\`bash
115
+ maestro frontier run "<prompt>" --scope codex
116
+ \`\`\`
117
+
118
+ - \`off\`: prints a notice, spawns nothing.
119
+ - \`single\`: dispatches the one selected CLI, prints its answer.
120
+ - \`fusion\`: runs the panel in parallel → judge → synthesizer; prints the final
121
+ answer (a one-line run meta goes to stderr). Report stdout verbatim.
122
+
123
+ On error the engine prints \`ERROR [<reason>]: <detail>\` to stderr and exits
124
+ non-zero — relay the reason.
125
+
126
+ ## Indicator instruction
127
+
128
+ <!-- INDICATOR INSTRUCTION — apply every turn while this skill is active -->
129
+
130
+ Before composing your first reply each turn, run:
131
+
132
+ \`\`\`bash
133
+ maestro frontier status --scope codex
134
+ \`\`\`
135
+
136
+ Parse the returned JSON \`mode\` field:
137
+
138
+ - If \`mode\` is not \`off\`, make the **first line** of your reply exactly:
139
+ \`Maestro Frontier ON (<label>)\`
140
+ where \`<label>\` is formed as follows:
141
+ - single mode → \`single · <model>\` (e.g. \`single · opus\`)
142
+ - fusion mode → \`fusion · <preset>\` (e.g. \`fusion · frontier-trio\`);
143
+ for a custom preset use \`fusion · custom (<model1>, <model2>, ...)\`
144
+ - If \`mode\` is \`off\`, output no indicator line.
145
+
146
+ <!-- END INDICATOR INSTRUCTION -->
147
+
148
+ ## Notes
149
+
150
+ - Real \`single\`/\`fusion\` runs spawn local CLIs and cost tokens; use small prompts.
151
+ \`off\` is free.
152
+ - Each model's CLI must be on \`PATH\`, or point at a specific build with
153
+ \`MAESTRO_CLAUDE_BIN\` / \`MAESTRO_CODEX_BIN\` / \`MAESTRO_GEMINI_BIN\`.
154
+ - Requires \`maestro\` on \`PATH\` (installed during Maestro setup). If it is
155
+ missing, install Maestro first.
156
+ `,
157
+ terse: `---
158
+ name: terse
159
+ description: Toggle Maestro terse output level (lite, full, ultra, off) via the settings CLI
160
+ ---
161
+
162
+ Toggle the **Maestro terse** output level for this environment. Terse mode
163
+ condenses agent replies; levels range from \`off\` (default verbosity) through
164
+ \`lite\`, \`full\`, and \`ultra\` (most compressed).
165
+
166
+ When the user invokes this skill, run the settings CLI to read or change the
167
+ terse level. Do not edit settings files by hand.
168
+
169
+ ## Check current terse level
170
+
171
+ \`\`\`bash
172
+ node settings/cli.cjs --help
173
+ \`\`\`
174
+
175
+ Consult the help output for the exact read subcommand, then run it. If
176
+ \`settings/cli.cjs\` is not present, run \`maestro --help\` to discover the
177
+ correct path.
178
+
179
+ ## Set terse level
180
+
181
+ \`\`\`bash
182
+ node settings/cli.cjs terse <level>
183
+ \`\`\`
184
+
185
+ Valid levels: \`off\` | \`lite\` | \`full\` | \`ultra\`
186
+
187
+ Examples:
188
+
189
+ \`\`\`bash
190
+ node settings/cli.cjs terse off
191
+ node settings/cli.cjs terse lite
192
+ node settings/cli.cjs terse full
193
+ node settings/cli.cjs terse ultra
194
+ \`\`\`
195
+
196
+ If the CLI rejects an argument or the subcommand name differs, run
197
+ \`node settings/cli.cjs --help\` first and follow the printed usage.
198
+
199
+ ## Notes
200
+
201
+ - The change persists in Maestro's settings store; it applies to subsequent
202
+ agent turns in this project.
203
+ - Requires \`node\` on \`PATH\` and Maestro installed in the project root. If
204
+ \`settings/cli.cjs\` is missing, re-run the Maestro installer:
205
+ \`npx github:mbanderas/maestro install --target codex\`
206
+ `,
207
+ settings: `---
208
+ name: settings
209
+ description: View and change Maestro toggles (terse, frontier, context-bar) via the settings CLI
210
+ ---
211
+
212
+ View or change **Maestro settings** for this project. The settings CLI manages
213
+ the three primary toggles: \`terse\`, \`frontier\`, and \`context-bar\`.
214
+
215
+ When the user invokes this skill, run the settings CLI from the repo root.
216
+ Do not edit settings files by hand.
217
+
218
+ ## Discover available commands
219
+
220
+ \`\`\`bash
221
+ node settings/cli.cjs --help
222
+ \`\`\`
223
+
224
+ If \`settings/cli.cjs\` is not present, run \`maestro --help\` to locate the
225
+ correct entry point.
226
+
227
+ ## Common operations
228
+
229
+ List current settings:
230
+
231
+ \`\`\`bash
232
+ node settings/cli.cjs
233
+ \`\`\`
234
+
235
+ Set a toggle:
236
+
237
+ \`\`\`bash
238
+ node settings/cli.cjs terse <off|lite|full|ultra>
239
+ node settings/cli.cjs frontier <off|single|fusion>
240
+ node settings/cli.cjs context-bar <on|off>
241
+ \`\`\`
242
+
243
+ If a subcommand name or argument differs from the above, follow the usage
244
+ printed by \`--help\` — do not guess flags.
245
+
246
+ ## Notes
247
+
248
+ - Changes persist in Maestro's settings store and apply to subsequent agent
249
+ turns in this project.
250
+ - Requires \`node\` on \`PATH\` and Maestro installed in the project root. If
251
+ \`settings/cli.cjs\` is missing, re-run the installer:
252
+ \`npx github:mbanderas/maestro install --target codex\`
253
+ `,
254
+ update: `---
255
+ name: update
256
+ description: Update Maestro to the latest version by re-running the installer for Codex
257
+ ---
258
+
259
+ Update **Maestro** to the latest marketplace code. This re-runs the installer,
260
+ which pulls the current release and overwrites the local Maestro files in place.
261
+
262
+ When the user invokes this skill, run the installer from the repo root:
263
+
264
+ \`\`\`bash
265
+ npx github:mbanderas/maestro install --target codex
266
+ \`\`\`
267
+
268
+ The installer is idempotent — it is safe to re-run against an existing
269
+ installation. It will:
270
+
271
+ - Pull the latest Maestro source from the repository.
272
+ - Overwrite skills, hooks, and settings scaffolding with the new versions.
273
+ - Leave project-local configuration (state files, secrets) untouched.
274
+
275
+ ## Notes
276
+
277
+ - Requires \`node\` and \`npx\` on \`PATH\`.
278
+ - Run from the project root so the installer targets the correct directory.
279
+ - After the installer completes, restart the Codex session (or reload the
280
+ project) so updated skills and hooks take effect.
281
+ - If \`npx\` is unavailable, clone \`https://github.com/mbanderas/maestro\`
282
+ manually and follow the repository's install instructions.
283
+ `,
284
+ };
285
+
286
+ // Runtime adapter per target. The adapter imports @AGENTS.md (Cursor has no
287
+ // imports, so .cursorrules embeds the kernel). codex/cline/windsurf read
288
+ // AGENTS.md directly and need no adapter.
289
+ const ADAPTER_MAP = {
290
+ claude: 'CLAUDE.md',
291
+ gemini: 'GEMINI.md',
292
+ cursor: '.cursorrules',
293
+ };
294
+
295
+ // Marker dirs used for auto-detection (scanned inside project root)
296
+ const AUTO_MARKERS = [
297
+ { dir: '.cursor', target: 'cursor' },
298
+ { dir: '.gemini', target: 'gemini' },
299
+ { dir: '.codex', target: 'codex' },
300
+ { dir: '.cline', target: 'cline' },
301
+ { dir: '.windsurf',target: 'windsurf' },
302
+ { dir: '.claude', target: 'claude' },
303
+ ];
304
+
305
+ // ---- safety helpers ----
306
+
307
+ /**
308
+ * Returns true if p is a symlink (lstat-based). Never throws.
309
+ * @param {string} p
310
+ * @returns {boolean}
311
+ */
312
+ function isSymlink(p) {
313
+ try {
314
+ return fs.lstatSync(p).isSymbolicLink();
315
+ } catch {
316
+ return false;
317
+ }
318
+ }
319
+
320
+ /**
321
+ * Create directories for destPath. Refuses to create through a symlinked
322
+ * ancestor directory. Returns true on success, false on refusal.
323
+ * @param {string} destPath
324
+ * @returns {boolean}
325
+ */
326
+ function safeMkdirp(destPath) {
327
+ const dir = path.dirname(destPath);
328
+ // Walk ancestors from PKG_ROOT outward — only check the leaf dir because
329
+ // we cannot reliably validate every ancestor on all OSes; the write will
330
+ // fail safely if anything is wrong.
331
+ try {
332
+ fs.mkdirSync(dir, { recursive: true });
333
+ return true;
334
+ } catch {
335
+ return false;
336
+ }
337
+ }
338
+
339
+ /**
340
+ * Write buf to dest. Refuses if dest (or its parent dir) is a symlink.
341
+ * Returns { ok: true } or { ok: false, reason: string }.
342
+ * @param {string} dest
343
+ * @param {string|Buffer} content
344
+ * @returns {{ ok: boolean, reason?: string }}
345
+ */
346
+ function safeWrite(dest, content) {
347
+ // Check parent dir
348
+ const dir = path.dirname(dest);
349
+ if (isSymlink(dir)) {
350
+ return { ok: false, reason: `parent dir is a symlink: ${dir}` };
351
+ }
352
+ // Check destination itself
353
+ if (isSymlink(dest)) {
354
+ return { ok: false, reason: `destination is a symlink: ${dest}` };
355
+ }
356
+ try {
357
+ fs.writeFileSync(dest, content, 'utf8');
358
+ return { ok: true };
359
+ } catch (err) {
360
+ return { ok: false, reason: String(err.message || err) };
361
+ }
362
+ }
363
+
364
+ function homeDir() {
365
+ return process.platform === 'win32'
366
+ ? (process.env.USERPROFILE || os.homedir())
367
+ : (process.env.HOME || os.homedir());
368
+ }
369
+
370
+ function sha256(content) {
371
+ return crypto.createHash('sha256').update(content, 'utf8').digest('hex');
372
+ }
373
+
374
+ function codexSkillManagedContent(name, srcContent) {
375
+ const body = srcContent.trimEnd() + '\n';
376
+ return `${body}\n<!-- maestro-managed:codex-skill name=${name} sha256=${sha256(body)} -->\n`;
377
+ }
378
+
379
+ function splitCodexSkillMarker(content) {
380
+ const marker = /\n?<!-- maestro-managed:codex-skill name=([^\s]+) sha256=([a-f0-9]+|0000) -->\s*$/i.exec(content);
381
+ if (!marker) return null;
382
+ return {
383
+ name: marker[1],
384
+ hash: marker[2].toLowerCase(),
385
+ body: content.slice(0, marker.index).trimEnd() + '\n',
386
+ };
387
+ }
388
+
389
+ function isManagedCodexSkillContent(content, expectedName, managedBodies) {
390
+ const marker = splitCodexSkillMarker(content);
391
+ if (marker && marker.name === expectedName) {
392
+ return marker.hash === '0000' || marker.hash === sha256(marker.body);
393
+ }
394
+ if (content.includes(`maestro-managed:codex-skill name=${expectedName} sha256=0000`)) {
395
+ return true;
396
+ }
397
+ return managedBodies.some((body) => content.trimEnd() === body.trimEnd());
398
+ }
399
+
400
+ function legacyCodexSkillContent(legacyName, namespacedName) {
401
+ const body = `---\nname: ${legacyName}\ndescription: Legacy Maestro compatibility skill for ${namespacedName}\n---\n\nThis legacy Maestro skill has moved to \`${namespacedName}\`.\n\nUse the \`${namespacedName}\` skill for current Maestro behavior. This compatibility skill is kept only for existing Codex installs that still reference \`${legacyName}\`.\n`;
402
+ return codexSkillManagedContent(legacyName, body);
403
+ }
404
+
405
+ function legacyGenericCodexTemplate(srcContent, legacyName, namespacedName) {
406
+ return srcContent.replace(
407
+ new RegExp(`(^---\\r?\\nname: )${namespacedName}(\\r?\\n)`, 'm'),
408
+ `$1${legacyName}$2`
409
+ );
410
+ }
411
+
412
+ // ---- parse argv ----
413
+
414
+ /**
415
+ * @param {string[]} argv
416
+ * @returns {{ target: string, project: string, user: boolean, dryRun: boolean, noHooks: boolean }}
417
+ */
418
+ function parseArgs(argv) {
419
+ const opts = {
420
+ target: 'auto',
421
+ project: process.cwd(),
422
+ user: false,
423
+ dryRun: false,
424
+ noHooks: false,
425
+ };
426
+
427
+ let i = 0;
428
+ while (i < argv.length) {
429
+ const a = argv[i];
430
+ if (a === '--target' && i + 1 < argv.length) {
431
+ opts.target = argv[++i];
432
+ } else if (a === '--project' && i + 1 < argv.length) {
433
+ opts.project = argv[++i];
434
+ } else if (a === '--user') {
435
+ opts.user = true;
436
+ } else if (a === '--dry-run') {
437
+ opts.dryRun = true;
438
+ } else if (a === '--no-hooks') {
439
+ opts.noHooks = true;
440
+ }
441
+ i++;
442
+ }
443
+
444
+ opts.project = path.resolve(opts.project);
445
+ return opts;
446
+ }
447
+
448
+ // ---- auto-detect ----
449
+
450
+ /**
451
+ * Detect which tool is in use by looking for marker dirs.
452
+ * @param {string} projectRoot
453
+ * @returns {string} detected target or 'none'
454
+ */
455
+ function detectTarget(projectRoot) {
456
+ for (const { dir, target } of AUTO_MARKERS) {
457
+ try {
458
+ const p = path.join(projectRoot, dir);
459
+ const st = fs.lstatSync(p);
460
+ if (st.isDirectory()) return target;
461
+ } catch {
462
+ // not found
463
+ }
464
+ }
465
+ return 'none';
466
+ }
467
+
468
+ // ---- install actions ----
469
+
470
+ /**
471
+ * Read a file from the package root. Returns string, or null (logs) on error.
472
+ * @param {string} rel
473
+ * @param {(msg: string) => void} log
474
+ * @returns {string|null}
475
+ */
476
+ function readPkgFile(rel, log) {
477
+ try {
478
+ return fs.readFileSync(path.join(PKG_ROOT, rel), 'utf8');
479
+ } catch (err) {
480
+ log(`ERROR: cannot read package ${rel}: ${err.message}`);
481
+ return null;
482
+ }
483
+ }
484
+
485
+ /**
486
+ * Install a doctrine/adapter markdown file. Append-only, idempotent, never
487
+ * clobbers user content above the maestro block; refuses symlinks.
488
+ * @param {string} dest absolute destination path
489
+ * @param {string} srcContent content to install
490
+ * @param {string} label short name for logs (e.g. "AGENTS.md")
491
+ * @param {boolean} dryRun
492
+ * @param {(msg: string) => void} log
493
+ * @returns {boolean} true = success (or no-op), false = error
494
+ */
495
+ function appendOnlyDoctrine(dest, srcContent, label, dryRun, log) {
496
+ const block = `\n${SENTINEL}\n${srcContent}\n${SENTINEL_END}\n`;
497
+
498
+ let existsStat;
499
+ try { existsStat = fs.lstatSync(dest); } catch { existsStat = null; }
500
+
501
+ if (existsStat) {
502
+ if (existsStat.isSymbolicLink()) {
503
+ log(`ERROR: ${label} is a symlink — refusing to write through it: ${dest}`);
504
+ return false;
505
+ }
506
+
507
+ let existing;
508
+ try { existing = fs.readFileSync(dest, 'utf8'); } catch (err) {
509
+ log(`ERROR: cannot read existing ${label}: ${err.message}`);
510
+ return false;
511
+ }
512
+
513
+ if (existing.includes(SENTINEL)) {
514
+ log(`[doctrine] ${label} already contains sentinel — skipping`);
515
+ return true;
516
+ }
517
+
518
+ if (dryRun) {
519
+ log(`[dry-run] would append maestro doctrine to existing ${dest}`);
520
+ return true;
521
+ }
522
+
523
+ const res = safeWrite(dest, existing + block);
524
+ if (!res.ok) {
525
+ log(`ERROR: failed to append to ${label}: ${res.reason}`);
526
+ return false;
527
+ }
528
+ log(`[doctrine] appended maestro block to existing ${label}`);
529
+ return true;
530
+ }
531
+
532
+ // Absent — write fresh, wrapped in the sentinel so re-runs detect it.
533
+ if (dryRun) {
534
+ log(`[dry-run] would create ${dest}`);
535
+ return true;
536
+ }
537
+
538
+ if (!safeMkdirp(dest)) {
539
+ log(`ERROR: could not create parent dir for ${dest}`);
540
+ return false;
541
+ }
542
+
543
+ const freshContent = SENTINEL + '\n' + srcContent + '\n' + SENTINEL_END + '\n';
544
+ const res = safeWrite(dest, freshContent);
545
+ if (!res.ok) {
546
+ log(`ERROR: failed to write ${label}: ${res.reason}`);
547
+ return false;
548
+ }
549
+ log(`[doctrine] wrote ${label}`);
550
+ return true;
551
+ }
552
+
553
+ /**
554
+ * Install the portable doctrine core (AGENTS.md) into the project root.
555
+ * @param {string} projectRoot
556
+ * @param {boolean} dryRun
557
+ * @param {(msg: string) => void} log
558
+ * @returns {boolean}
559
+ */
560
+ function installDoctrine(projectRoot, dryRun, log) {
561
+ const src = readPkgFile('AGENTS.md', log);
562
+ if (src === null) return false;
563
+ return appendOnlyDoctrine(path.join(projectRoot, 'AGENTS.md'), src, 'AGENTS.md', dryRun, log);
564
+ }
565
+
566
+ /**
567
+ * Install the runtime adapter for a target (CLAUDE.md / GEMINI.md /
568
+ * .cursorrules). codex/cline/windsurf read AGENTS.md directly -> no-op.
569
+ * @param {string} target
570
+ * @param {string} projectRoot
571
+ * @param {boolean} dryRun
572
+ * @param {(msg: string) => void} log
573
+ * @returns {boolean}
574
+ */
575
+ function installAdapter(target, projectRoot, dryRun, log) {
576
+ const rel = ADAPTER_MAP[target];
577
+ if (!rel) return true; // no adapter for this target
578
+ const src = readPkgFile(rel, log);
579
+ if (src === null) return false;
580
+ return appendOnlyDoctrine(path.join(projectRoot, rel), src, rel, dryRun, log);
581
+ }
582
+
583
+ /**
584
+ * Recursively copy srcDir -> destDir, skipping *.test.cjs files.
585
+ * @param {string} srcDir
586
+ * @param {string} destDir
587
+ * @param {boolean} dryRun
588
+ * @param {(msg: string) => void} log
589
+ * @returns {boolean}
590
+ */
591
+ function copyDirRecursive(srcDir, destDir, dryRun, log) {
592
+ let entries;
593
+ try {
594
+ entries = fs.readdirSync(srcDir, { withFileTypes: true });
595
+ } catch (err) {
596
+ log(`ERROR: cannot read dir ${srcDir}: ${err.message}`);
597
+ return false;
598
+ }
599
+
600
+ let ok = true;
601
+ for (const entry of entries) {
602
+ if (entry.isFile() && entry.name.endsWith('.test.cjs')) continue;
603
+
604
+ const src = path.join(srcDir, entry.name);
605
+ const dest = path.join(destDir, entry.name);
606
+
607
+ if (entry.isDirectory()) {
608
+ if (!copyDirRecursive(src, dest, dryRun, log)) ok = false;
609
+ } else if (entry.isFile()) {
610
+ if (dryRun) {
611
+ log(`[dry-run] would write ${dest}`);
612
+ continue;
613
+ }
614
+
615
+ if (isSymlink(dest)) {
616
+ log(`ERROR: destination is a symlink — refusing: ${dest}`);
617
+ ok = false;
618
+ continue;
619
+ }
620
+ if (isSymlink(path.dirname(dest))) {
621
+ log(`ERROR: destination parent is a symlink — refusing: ${dest}`);
622
+ ok = false;
623
+ continue;
624
+ }
625
+
626
+ try {
627
+ fs.mkdirSync(destDir, { recursive: true });
628
+ const content = fs.readFileSync(src);
629
+ fs.writeFileSync(dest, content);
630
+ log(`[engine] copied ${dest}`);
631
+ } catch (err) {
632
+ log(`ERROR: failed to copy ${src} -> ${dest}: ${err.message}`);
633
+ ok = false;
634
+ }
635
+ }
636
+ }
637
+ return ok;
638
+ }
639
+
640
+ /**
641
+ * Install engine files (frontier/ dir + settings/ dir + bin/maestro.cjs).
642
+ * @param {string} projectRoot
643
+ * @param {boolean} dryRun
644
+ * @param {(msg: string) => void} log
645
+ * @returns {boolean}
646
+ */
647
+ function installEngine(projectRoot, dryRun, log) {
648
+ const srcFrontier = path.join(PKG_ROOT, 'frontier');
649
+ const destFrontier = path.join(projectRoot, 'frontier');
650
+ const srcSettings = path.join(PKG_ROOT, 'settings');
651
+ const destSettings = path.join(projectRoot, 'settings');
652
+ const srcBin = path.join(PKG_ROOT, 'bin', 'maestro.cjs');
653
+ const destBin = path.join(projectRoot, 'bin', 'maestro.cjs');
654
+
655
+ let ok = copyDirRecursive(srcFrontier, destFrontier, dryRun, log);
656
+ if (!copyDirRecursive(srcSettings, destSettings, dryRun, log)) ok = false;
657
+
658
+ // bin/maestro.cjs
659
+ if (dryRun) {
660
+ log(`[dry-run] would write ${destBin}`);
661
+ } else {
662
+ if (isSymlink(destBin)) {
663
+ log(`ERROR: bin/maestro.cjs is a symlink — refusing: ${destBin}`);
664
+ ok = false;
665
+ } else {
666
+ try {
667
+ fs.mkdirSync(path.dirname(destBin), { recursive: true });
668
+ fs.writeFileSync(destBin, fs.readFileSync(srcBin));
669
+ log(`[engine] copied ${destBin}`);
670
+ } catch (err) {
671
+ log(`ERROR: failed to copy bin/maestro.cjs: ${err.message}`);
672
+ ok = false;
673
+ }
674
+ }
675
+ }
676
+
677
+ // docs/orchestration.md — the on-demand S2-S6 multi-agent protocol the
678
+ // kernel references. Maestro-owned reference file; copy (refuse symlinks).
679
+ const srcDocs = path.join(PKG_ROOT, 'docs', 'orchestration.md');
680
+ const destDocs = path.join(projectRoot, 'docs', 'orchestration.md');
681
+ if (dryRun) {
682
+ log(`[dry-run] would write ${destDocs}`);
683
+ } else if (isSymlink(destDocs)) {
684
+ log(`ERROR: docs/orchestration.md is a symlink — refusing: ${destDocs}`);
685
+ ok = false;
686
+ } else {
687
+ try {
688
+ fs.mkdirSync(path.dirname(destDocs), { recursive: true });
689
+ fs.writeFileSync(destDocs, fs.readFileSync(srcDocs));
690
+ log(`[doctrine] copied ${destDocs}`);
691
+ } catch (err) {
692
+ log(`ERROR: failed to copy docs/orchestration.md: ${err.message}`);
693
+ ok = false;
694
+ }
695
+ }
696
+
697
+ return ok;
698
+ }
699
+
700
+ /**
701
+ * Copy a single package template file to dest, no-clobber. Skips when dest
702
+ * already exists, refuses symlinks, honors dry-run. Reuses safeMkdirp +
703
+ * safeWrite. Shared by wrapper and Codex-skill installs.
704
+ * @param {string} src absolute source path (under PKG_ROOT)
705
+ * @param {string} dest absolute destination path
706
+ * @param {string} label short tag for logs (e.g. "wrapper", "codex-skill")
707
+ * @param {boolean} dryRun
708
+ * @param {(msg: string) => void} log
709
+ * @returns {boolean} true = success (wrote, skipped, or planned), false = error
710
+ */
711
+ function installNoClobberFile(src, dest, label, dryRun, log) {
712
+ // Check if dest exists already (no-clobber)
713
+ let destStat;
714
+ try { destStat = fs.lstatSync(dest); } catch { destStat = null; }
715
+
716
+ if (destStat) {
717
+ if (destStat.isSymbolicLink()) {
718
+ log(`ERROR: ${label} dest is a symlink — refusing: ${dest}`);
719
+ return false;
720
+ }
721
+ log(`[${label}] skipped (exists, not clobbered): ${dest}`);
722
+ return true;
723
+ }
724
+
725
+ let srcContent;
726
+ try {
727
+ srcContent = fs.readFileSync(src, 'utf8');
728
+ } catch (err) {
729
+ log(`ERROR: cannot read template ${src}: ${err.message}`);
730
+ return false;
731
+ }
732
+
733
+ if (dryRun) {
734
+ log(`[dry-run] would create ${dest}`);
735
+ return true;
736
+ }
737
+
738
+ if (!safeMkdirp(dest)) {
739
+ log(`ERROR: could not create parent dir for ${dest}`);
740
+ return false;
741
+ }
742
+
743
+ const res = safeWrite(dest, srcContent);
744
+ if (!res.ok) {
745
+ log(`ERROR: failed to write ${label} ${dest}: ${res.reason}`);
746
+ return false;
747
+ }
748
+ log(`[${label}] wrote ${dest}`);
749
+ return true;
750
+ }
751
+
752
+ /**
753
+ * Install wrapper file (no-clobber).
754
+ * @param {string} target
755
+ * @param {string} projectRoot
756
+ * @param {boolean} userGlobal
757
+ * @param {boolean} dryRun
758
+ * @param {(msg: string) => void} log
759
+ * @returns {boolean}
760
+ */
761
+ function installWrapper(target, projectRoot, userGlobal, dryRun, log) {
762
+ if (target === 'claude') {
763
+ log('[claude] No wrapper file — plugin delivers the command.');
764
+ log('[claude] To install the plugin: /plugin marketplace add mbanderas/maestro');
765
+ log('[claude] Then: /plugin install maestro@maestro');
766
+ return true;
767
+ }
768
+
769
+ const mapping = WRAPPER_MAP[target];
770
+ if (!mapping) {
771
+ log(`ERROR: unknown target: ${target}`);
772
+ return false;
773
+ }
774
+
775
+ const src = path.join(PKG_ROOT, mapping.src);
776
+
777
+ let dest;
778
+ if (userGlobal) {
779
+ if (!mapping.user) {
780
+ log(`[wrapper] --user not supported for target ${target} — writing to project instead`);
781
+ dest = path.join(projectRoot, mapping.proj);
782
+ } else {
783
+ dest = mapping.user();
784
+ }
785
+ } else {
786
+ dest = path.join(projectRoot, mapping.proj);
787
+ }
788
+
789
+ return installNoClobberFile(src, dest, 'wrapper', dryRun, log);
790
+ }
791
+
792
+ /**
793
+ * Install the Codex skill templates alongside the codex wrapper. Maestro-owned
794
+ * skill files refresh in place; user-edited copies are preserved.
795
+ * Project mode -> <project>/.agents/skills/<name>/SKILL.md; --user/global mode
796
+ * -> ~/.agents/skills/<name>/SKILL.md (mirrors installWrapper's dest logic).
797
+ * @param {string} projectRoot
798
+ * @param {boolean} userGlobal
799
+ * @param {boolean} dryRun
800
+ * @param {(msg: string) => void} log
801
+ * @returns {boolean}
802
+ */
803
+ function installManagedCodexSkill(src, dest, name, legacyName, dryRun, log) {
804
+ let srcContent;
805
+ try {
806
+ srcContent = fs.readFileSync(src, 'utf8');
807
+ } catch (err) {
808
+ log(`ERROR: cannot read template ${src}: ${err.message}`);
809
+ return false;
810
+ }
811
+
812
+ const managedContent = codexSkillManagedContent(name, srcContent);
813
+ const managedBodies = [srcContent, managedContent];
814
+
815
+ let destStat;
816
+ try { destStat = fs.lstatSync(dest); } catch { destStat = null; }
817
+
818
+ if (destStat) {
819
+ if (destStat.isSymbolicLink()) {
820
+ log(`ERROR: codex-skill dest is a symlink — refusing: ${dest}`);
821
+ return false;
822
+ }
823
+
824
+ let existing;
825
+ try { existing = fs.readFileSync(dest, 'utf8'); } catch (err) {
826
+ log(`ERROR: cannot read existing Codex skill ${dest}: ${err.message}`);
827
+ return false;
828
+ }
829
+
830
+ if (!isManagedCodexSkillContent(existing, name, managedBodies)) {
831
+ log(`[codex-skill] preserved user-edited Codex skill: ${dest}`);
832
+ log(`[codex-skill] next step: compare with integrations/codex/skills/${name}/SKILL.md and manually merge if desired`);
833
+ return true;
834
+ }
835
+
836
+ if (existing === managedContent) {
837
+ log(`[codex-skill] up to date: ${dest}`);
838
+ return true;
839
+ }
840
+
841
+ if (dryRun) {
842
+ log(`[dry-run] would refresh managed Codex skill ${dest}`);
843
+ return true;
844
+ }
845
+
846
+ const res = safeWrite(dest, managedContent);
847
+ if (!res.ok) {
848
+ log(`ERROR: failed to refresh codex-skill ${dest}: ${res.reason}`);
849
+ return false;
850
+ }
851
+ log(`[codex-skill] refreshed managed Codex skill: ${dest}`);
852
+ return true;
853
+ }
854
+
855
+ if (dryRun) {
856
+ log(`[dry-run] would create ${dest}`);
857
+ return true;
858
+ }
859
+
860
+ if (!safeMkdirp(dest)) {
861
+ log(`ERROR: could not create parent dir for ${dest}`);
862
+ return false;
863
+ }
864
+
865
+ const res = safeWrite(dest, managedContent);
866
+ if (!res.ok) {
867
+ log(`ERROR: failed to write codex-skill ${dest}: ${res.reason}`);
868
+ return false;
869
+ }
870
+ log(`[codex-skill] wrote ${dest}`);
871
+ return true;
872
+ }
873
+
874
+ function migrateLegacyCodexSkill(dest, legacyName, namespacedName, knownTemplate, dryRun, log) {
875
+ let destStat;
876
+ try { destStat = fs.lstatSync(dest); } catch { return true; }
877
+
878
+ if (destStat.isSymbolicLink()) {
879
+ log(`ERROR: legacy codex-skill dest is a symlink — refusing: ${dest}`);
880
+ return false;
881
+ }
882
+
883
+ let existing;
884
+ try { existing = fs.readFileSync(dest, 'utf8'); } catch (err) {
885
+ log(`ERROR: cannot read legacy Codex skill ${dest}: ${err.message}`);
886
+ return false;
887
+ }
888
+
889
+ const shim = legacyCodexSkillContent(legacyName, namespacedName);
890
+ const managedBodies = [
891
+ knownTemplate,
892
+ legacyGenericCodexTemplate(knownTemplate, legacyName, namespacedName),
893
+ LEGACY_CODEX_SKILL_TEMPLATES[legacyName],
894
+ shim,
895
+ ].filter(Boolean);
896
+ if (!isManagedCodexSkillContent(existing, legacyName, managedBodies)) {
897
+ log(`[codex-skill] preserved user-edited legacy Codex skill: ${dest}`);
898
+ log(`[codex-skill] next step: rename or merge it into .agents/skills/${namespacedName}/SKILL.md if you still need custom behavior`);
899
+ return true;
900
+ }
901
+
902
+ if (existing === shim) {
903
+ log(`[codex-skill] legacy compatibility up to date: ${dest}`);
904
+ return true;
905
+ }
906
+
907
+ if (dryRun) {
908
+ log(`[dry-run] would migrate legacy Codex skill ${dest}`);
909
+ return true;
910
+ }
911
+
912
+ const res = safeWrite(dest, shim);
913
+ if (!res.ok) {
914
+ log(`ERROR: failed to migrate legacy codex-skill ${dest}: ${res.reason}`);
915
+ return false;
916
+ }
917
+ log(`[codex-skill] migrated legacy Codex skill to compatibility shim: ${dest}`);
918
+ return true;
919
+ }
920
+
921
+ function installCodexSkills(projectRoot, userGlobal, dryRun, log) {
922
+ const skillsRoot = userGlobal
923
+ ? path.join(homeDir(), '.agents', 'skills')
924
+ : path.join(projectRoot, '.agents', 'skills');
925
+
926
+ let ok = true;
927
+ for (const skill of CODEX_SKILLS) {
928
+ const src = path.join(PKG_ROOT, 'integrations', 'codex', 'skills', skill.name, 'SKILL.md');
929
+ const dest = path.join(skillsRoot, skill.name, 'SKILL.md');
930
+ if (!installManagedCodexSkill(src, dest, skill.name, skill.legacy, dryRun, log)) ok = false;
931
+
932
+ let legacyTemplate = '';
933
+ try { legacyTemplate = fs.readFileSync(src, 'utf8'); } catch {}
934
+ const legacyDest = path.join(skillsRoot, skill.legacy, 'SKILL.md');
935
+ if (!migrateLegacyCodexSkill(legacyDest, skill.legacy, skill.name, legacyTemplate, dryRun, log)) ok = false;
936
+ }
937
+ return ok;
938
+ }
939
+
940
+ // ---- main entry ----
941
+
942
+ /**
943
+ * Run the installer. Returns a numeric exit code (0 = success).
944
+ * @param {string[]} argv
945
+ * @returns {number}
946
+ */
947
+ function run(argv) {
948
+ const opts = parseArgs(argv || []);
949
+ const { target: rawTarget, project, user: userGlobal, dryRun } = opts;
950
+
951
+ const lines = [];
952
+ const log = (msg) => { lines.push(msg); process.stdout.write(msg + '\n'); };
953
+
954
+ if (dryRun) log('[dry-run] planning only — no files will be written');
955
+
956
+ // Resolve target
957
+ let target = rawTarget;
958
+ if (target === 'auto') {
959
+ target = detectTarget(project);
960
+ if (target === 'none') {
961
+ log('[auto] no tool marker dir found — installing doctrine + engine only');
962
+ log('[auto] pass --target <tool> to install a command wrapper');
963
+ } else {
964
+ log(`[auto] detected target: ${target}`);
965
+ }
966
+ }
967
+
968
+ const VALID_TARGETS = ['auto', 'claude', 'codex', 'cursor', 'gemini', 'cline', 'windsurf'];
969
+ if (!VALID_TARGETS.includes(rawTarget)) {
970
+ log(`ERROR: unknown --target value: ${rawTarget}`);
971
+ return 1;
972
+ }
973
+
974
+ let anyError = false;
975
+
976
+ // 1. Doctrine — portable AGENTS.md kernel + this target's runtime adapter.
977
+ if (!installDoctrine(project, dryRun, log)) anyError = true;
978
+ if (!installAdapter(target, project, dryRun, log)) anyError = true;
979
+
980
+ // 2. Engine — frontier/ + bin/maestro.cjs + docs/orchestration.md.
981
+ if (!installEngine(project, dryRun, log)) anyError = true;
982
+
983
+ // 3. Wrapper — this target's /frontier command (skip if no target detected).
984
+ if (target !== 'none') {
985
+ if (!installWrapper(target, project, userGlobal, dryRun, log)) anyError = true;
986
+ }
987
+
988
+ // 3b. Codex skills — the .agents/skills/<name>/SKILL.md set ships alongside
989
+ // the deprecated codex prompt wrapper.
990
+ if (target === 'codex') {
991
+ if (!installCodexSkills(project, userGlobal, dryRun, log)) anyError = true;
992
+ }
993
+
994
+ if (anyError) {
995
+ log('install completed with errors (see above)');
996
+ return 1;
997
+ }
998
+ log('install complete');
999
+ return 0;
1000
+ }
1001
+
1002
+ // ---- CLI entry ----
1003
+
1004
+ if (require.main === module) {
1005
+ const code = run(process.argv.slice(2));
1006
+ process.exit(code);
1007
+ }
1008
+
1009
+ module.exports = {
1010
+ run,
1011
+ _test: {
1012
+ LEGACY_CODEX_SKILL_TEMPLATES,
1013
+ },
1014
+ };