orchestrator-workflow 0.28.0 → 0.30.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/init.d.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  import type { Harness } from "./detect.js";
2
2
  import type { ModelClass, Profile, Role } from "./models.js";
3
+ import type { HarnessRouting } from "./routing.js";
4
+ import type { OpencodeModelMaps } from "./routing-state.js";
3
5
  import type { Report } from "./writers.js";
4
6
  export interface InitOptions {
5
7
  targetDir: string;
@@ -15,11 +17,14 @@ export interface InitOptions {
15
17
  /**
16
18
  * Resolved fully-qualified opencode model ids per role, or `undefined` to
17
19
  * omit the `model:` frontmatter line (subagent inherits the session model).
18
- * When this field is absent the fallback is `opencodeModelValue(models[role])`,
19
- * which passes through a fully-qualified id and returns `undefined` for bare
20
- * aliases, producing the same inherit-session-model behaviour for bare inputs.
20
+ * Qualified values persist in routing; legacy bare IDs persist in the
21
+ * manifest compatibility map of the same name. A present map records
22
+ * inheritance for missing keys unless routing supplies that selection.
23
+ * When absent, prior
24
+ * selections survive; otherwise `opencodeModelValue(models[role])` passes
25
+ * through a qualified id or leaves bare aliases inheriting the session model.
21
26
  */
22
- opencodeModels?: Record<Role, string | undefined>;
27
+ opencodeModels?: Partial<Record<Role, string | undefined>>;
23
28
  /**
24
29
  * Renders additional per-role effort-tier subagent variants
25
30
  * (`<role>-<tier>.md`) alongside the default agent file. Defaults to
@@ -33,9 +38,29 @@ export interface InitOptions {
33
38
  * that variant. Only consulted when `tiers` is true and the `opencode`
34
39
  * harness is selected; mirrors `opencodeModels` but keyed by model class
35
40
  * instead of role, since a tier variant's model is chosen by class, not
36
- * by the role's own preselected model.
41
+ * by the role's own preselected model. Resolved ids are persisted per tier
42
+ * and retained on a later install that omits this field. Legacy bare IDs
43
+ * persist in the manifest compatibility map of the same name.
37
44
  */
38
- opencodeClassModels?: Record<ModelClass, string | undefined>;
45
+ opencodeClassModels?: Partial<Record<ModelClass, string | undefined>>;
46
+ /**
47
+ * Per-harness role/tier selections. This is an additive, partial map: the
48
+ * installer preserves previous leaves and fills remaining omissions from
49
+ * deterministic harness defaults. An explicit leaf wins over legacy inputs.
50
+ */
51
+ routing?: HarnessRouting;
52
+ /**
53
+ * "patch" (default) preserves previous routing leaves. "replace" accepts
54
+ * an authoritative resolved map and excludes all previous routing, as used
55
+ * by CLI resolution and apply --sync. Omitted leaves still use defaults.
56
+ */
57
+ routingMode?: "patch" | "replace";
58
+ /**
59
+ * Optional, explicitly supplied Codex capability catalog. The installer
60
+ * never contacts a live service; when this is present it is validated
61
+ * before any kit-owned file is written.
62
+ */
63
+ codexCatalog?: unknown;
39
64
  /**
40
65
  * Repo kit-version pin (distinct from the actually-installed `version`),
41
66
  * so a later `apply` command can gate on it. A `string` sets a new
@@ -47,7 +72,7 @@ export interface InitOptions {
47
72
  pin?: string | null;
48
73
  }
49
74
  export declare const MANIFEST_PATH: string;
50
- export interface Manifest {
75
+ export interface Manifest extends OpencodeModelMaps {
51
76
  kit: string;
52
77
  version: string;
53
78
  harnesses: Harness[];
@@ -56,6 +81,8 @@ export interface Manifest {
56
81
  profile: Profile;
57
82
  /** Whether per-role effort-tier subagent variants were rendered. */
58
83
  tiers: boolean;
84
+ /** Exact resolved harness/role/tier selections from the install. */
85
+ routing?: HarnessRouting;
59
86
  /**
60
87
  * sha256 of every kit-owned file as installed. This is how a re-run tells
61
88
  * "upstream changed, safe to update" apart from "user edited, conflict".
package/dist/init.js CHANGED
@@ -4,6 +4,9 @@ import { isAbsolute, join, normalize, sep } from "node:path";
4
4
  import { PACKAGE_VERSION, listTemplateNames, readAgentAsset, readAsset, } from "./assets.js";
5
5
  import { HARNESSES } from "./detect.js";
6
6
  import { CLASS_MODELS, DEFAULT_PROFILE, DEFAULT_TIER, READ_ONLY_ROLES, ROLES, ROLE_TIERS, TIER_DEFS, assertValidModelId, claudeModelValue, isProfile, opencodeModelValue, rolesForProfile, } from "./models.js";
7
+ import { composeCodexAgent } from "./codex.js";
8
+ import { parseRouting } from "./routing.js";
9
+ import { codexCatalogWarnings, normalizeRoutingState, legacyOpencodeFallbacks, parseOpencodeModelMaps, } from "./routing-state.js";
7
10
  import { emptyReport, ensureClaudeImport, installFile, upsertMarkerSection, } from "./writers.js";
8
11
  const SKILL_NAME = "orchestrator-workflow";
9
12
  export const MANIFEST_PATH = join(".ai", "workflow", "manifest.json"); // shared with doctor.ts/cli.ts (L9); see readInstalledManifest below
@@ -91,6 +94,14 @@ export function readInstalledManifest(targetDir) {
91
94
  // (the same per-field-degradation style as `profile` above) rather than
92
95
  // throwing on a legacy manifest.
93
96
  const tiers = typeof candidate.tiers === "boolean" ? candidate.tiers : false;
97
+ // Routing is intentionally stricter than the legacy per-role `models`
98
+ // map. A malformed routing record must stop a re-install instead of
99
+ // silently replacing the operator's explicit selections with new defaults.
100
+ const opencodeMaps = parseOpencodeModelMaps(candidate);
101
+ let routing;
102
+ if ("routing" in candidate) {
103
+ routing = parseRouting(candidate.routing);
104
+ }
94
105
  // A hand-written or damaged manifest may carry a non-string `pin`; that
95
106
  // degrades to "no recorded pin" here (the same per-field-degradation
96
107
  // style as `profile`/`tiers` above) rather than throwing. An empty or
@@ -105,6 +116,8 @@ export function readInstalledManifest(targetDir) {
105
116
  models: models,
106
117
  profile,
107
118
  tiers,
119
+ ...(routing !== undefined ? { routing } : {}),
120
+ ...opencodeMaps,
108
121
  files,
109
122
  installedAt: typeof candidate.installedAt === "string" ? candidate.installedAt : "",
110
123
  // The kit-version pin is deliberately free-form here: unlike the
@@ -118,6 +131,17 @@ export function readInstalledManifest(targetDir) {
118
131
  function yamlQuote(value) {
119
132
  return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
120
133
  }
134
+ function yamlModelScalar(value) {
135
+ // Keep ordinary legacy aliases and provider ids byte-compatible, but quote
136
+ // values YAML would otherwise coerce when they originate in routing JSON.
137
+ if (/^(?:true|false|null|yes|no|on|off|~)$/i.test(value) ||
138
+ /^[-+]?\d+(?:\.\d+)?$/.test(value) ||
139
+ value.includes(":") ||
140
+ value.includes("#")) {
141
+ return yamlQuote(value);
142
+ }
143
+ return value;
144
+ }
121
145
  /**
122
146
  * Composes the unsuffixed default agent file. It carries a pinned
123
147
  * `effort: <TIER_DEFS[DEFAULT_TIER[role]].effort>` line unconditionally
@@ -134,14 +158,14 @@ function yamlQuote(value) {
134
158
  * opencode's family-based `opencodeEffortLine` has, so there is no second,
135
159
  * potentially-diverging computation here to guard against.
136
160
  */
137
- function composeClaudeAgent(role, model) {
161
+ function composeClaudeAgent(role, model, selection) {
138
162
  const asset = readAgentAsset(role);
139
163
  const frontmatter = [
140
164
  "---",
141
165
  `name: ${asset.name}`,
142
166
  `description: ${yamlQuote(asset.description)}`,
143
- `model: ${claudeModelValue(model)}`,
144
- `effort: ${TIER_DEFS[DEFAULT_TIER[role]].effort}`,
167
+ `model: ${selection ? yamlModelScalar(selection.model) : claudeModelValue(model)}`,
168
+ `effort: ${selection?.effort ?? TIER_DEFS[DEFAULT_TIER[role]].effort}`,
145
169
  ];
146
170
  // Read-only roles keep every read/search tool but cannot mutate files.
147
171
  if (READ_ONLY_ROLES.has(role)) {
@@ -168,7 +192,7 @@ function composeOpencodeAgent(role, modelValue, effortLine) {
168
192
  // Only emit `model:` when a resolved, non-empty FQ id is available.
169
193
  // Omitting it lets the subagent inherit the session/default model.
170
194
  if (modelValue) {
171
- frontmatter.push(`model: ${modelValue}`);
195
+ frontmatter.push(`model: ${yamlModelScalar(modelValue)}`);
172
196
  }
173
197
  if (effortLine) {
174
198
  frontmatter.push(effortLine);
@@ -179,6 +203,9 @@ function composeOpencodeAgent(role, modelValue, effortLine) {
179
203
  frontmatter.push("---");
180
204
  return [...frontmatter, "", asset.body.trimEnd(), ""].join("\n");
181
205
  }
206
+ function routingSelection(routing, harness, role, tier) {
207
+ return routing[harness]?.[role]?.[tier];
208
+ }
182
209
  function tierDescriptionSuffix(asset, tier) {
183
210
  return `${asset.description} (Effort tier: ${tier}.)`;
184
211
  }
@@ -188,15 +215,15 @@ function tierDescriptionSuffix(asset, tier) {
188
215
  * `DEFAULT_TIER`), so `composeClaudeAgent`'s own output (pinned default
189
216
  * effort included) stays byte-identical whether or not tiers are on.
190
217
  */
191
- function composeClaudeAgentVariant(role, tier) {
218
+ function composeClaudeAgentVariant(role, tier, selection) {
192
219
  const asset = readAgentAsset(role);
193
220
  const def = TIER_DEFS[tier];
194
221
  const frontmatter = [
195
222
  "---",
196
223
  `name: ${asset.name}-${tier}`,
197
224
  `description: ${yamlQuote(tierDescriptionSuffix(asset, tier))}`,
198
- `model: ${claudeModelValue(CLASS_MODELS[def.modelClass])}`,
199
- `effort: ${def.effort}`,
225
+ `model: ${selection ? yamlModelScalar(selection.model) : claudeModelValue(CLASS_MODELS[def.modelClass])}`,
226
+ `effort: ${selection?.effort ?? def.effort}`,
200
227
  ];
201
228
  if (READ_ONLY_ROLES.has(role)) {
202
229
  frontmatter.push("disallowedTools: Edit, Write, NotebookEdit");
@@ -264,7 +291,7 @@ function composeOpencodeAgentVariant(role, tier, modelValue, effortLine) {
264
291
  "mode: subagent",
265
292
  ];
266
293
  if (modelValue) {
267
- frontmatter.push(`model: ${modelValue}`);
294
+ frontmatter.push(`model: ${yamlModelScalar(modelValue)}`);
268
295
  }
269
296
  if (effortLine) {
270
297
  frontmatter.push(effortLine);
@@ -288,6 +315,43 @@ export function runInit(options) {
288
315
  const tiers = options.tiers ?? false;
289
316
  const report = emptyReport();
290
317
  const previous = readInstalledManifest(targetDir);
318
+ const suppliedMaps = parseOpencodeModelMaps(options);
319
+ const previousMaps = options.routingMode === "replace"
320
+ ? {}
321
+ : parseOpencodeModelMaps(previous ?? {});
322
+ const opencodeModels = suppliedMaps.opencodeModels ??
323
+ (previousMaps.opencodeModels !== undefined
324
+ ? { ...previousMaps.opencodeModels }
325
+ : undefined);
326
+ if (opencodeModels && options.opencodeModels === undefined && previous) {
327
+ for (const role of ROLES) {
328
+ if (options.models[role] !== previous.models[role])
329
+ opencodeModels[role] = opencodeModelValue(options.models[role]);
330
+ }
331
+ }
332
+ const opencodeClassModels = suppliedMaps.opencodeClassModels ?? previousMaps.opencodeClassModels;
333
+ const compatibility = legacyOpencodeFallbacks({
334
+ opencodeModels,
335
+ opencodeClassModels,
336
+ });
337
+ const routing = normalizeRoutingState({
338
+ harnesses: options.harnesses,
339
+ models: options.models,
340
+ opencodeModels,
341
+ opencodeClassModels,
342
+ previousRouting: previous?.routing,
343
+ routing: options.routing,
344
+ routingMode: options.routingMode,
345
+ legacyOverrideRoles: previous
346
+ ? ROLES.filter((role) => options.models[role] !== previous.models[role])
347
+ : [],
348
+ updateOpencodeModels: options.opencodeModels !== undefined,
349
+ updateOpencodeClassModels: options.opencodeClassModels !== undefined,
350
+ });
351
+ // Check only rendered selections before the first mutation.
352
+ for (const warning of codexCatalogWarnings(routing, { harnesses: options.harnesses, profile, tiers }, options.codexCatalog)) {
353
+ report.notes.push(`Codex catalog: ${warning}`);
354
+ }
291
355
  // `null` clears an existing pin, a string sets a new one, and omitted
292
356
  // (`undefined`) carries the previous manifest's pin forward unchanged. An
293
357
  // empty or whitespace-only string is normalized to a clear as well: it can
@@ -315,7 +379,15 @@ export function runInit(options) {
315
379
  // sitting on disk and still becoming untracked either way. The ledger
316
380
  // (`previous.files`/`previous.harnesses`) is the only source of truth for
317
381
  // "what did the previous install actually put on disk."
318
- const previousHarnessDirs = (previous?.harnesses ?? []).filter((harness) => harness === "claude" || harness === "opencode");
382
+ const previousHarnessDirs = (previous?.harnesses ?? [])
383
+ .map((harness) => harness === "claude"
384
+ ? { dir: ".claude", extension: ".md" }
385
+ : harness === "opencode"
386
+ ? { dir: ".opencode", extension: ".md" }
387
+ : harness === "codex"
388
+ ? { dir: ".codex", extension: ".toml" }
389
+ : undefined)
390
+ .filter((entry) => entry !== undefined);
319
391
  // A full -> minimal downgrade drops explorer/task-slicer from the roles
320
392
  // installed, but (like dropping a harness from --harness) existing role
321
393
  // files are never deleted: they simply fall out of the manifest's file
@@ -323,9 +395,9 @@ export function runInit(options) {
323
395
  // left as an unexplained, untracked leftover on disk.
324
396
  if (previous && previous.profile === "full" && profile !== previous.profile) {
325
397
  const droppedRoles = rolesForProfile(previous.profile).filter((role) => !rolesForProfile(profile).includes(role));
326
- for (const harnessDir of previousHarnessDirs.map((harness) => harness === "claude" ? ".claude" : ".opencode")) {
398
+ for (const harnessDir of previousHarnessDirs) {
327
399
  for (const role of droppedRoles) {
328
- const relativePath = join(harnessDir, "agents", `${role}.md`);
400
+ const relativePath = join(harnessDir.dir, "agents", `${role}${harnessDir.extension}`);
329
401
  if (previous.files[relativePath] !== undefined) {
330
402
  report.notes.push(`${relativePath}: now untracked after the full -> ${profile} profile downgrade; run \`orchestrator-workflow uninstall\` first next time, or remove it by hand.`);
331
403
  }
@@ -338,7 +410,7 @@ export function runInit(options) {
338
410
  for (const tier of ROLE_TIERS[role]) {
339
411
  if (tier === DEFAULT_TIER[role])
340
412
  continue;
341
- const variantPath = join(harnessDir, "agents", `${role}-${tier}.md`);
413
+ const variantPath = join(harnessDir.dir, "agents", `${role}-${tier}${harnessDir.extension}`);
342
414
  if (previous.files[variantPath] !== undefined) {
343
415
  report.notes.push(`${variantPath}: now untracked after the full -> ${profile} profile downgrade; run \`orchestrator-workflow uninstall\` first next time, or remove it by hand.`);
344
416
  }
@@ -353,12 +425,12 @@ export function runInit(options) {
353
425
  // there is no overlap between the two loops). Surface it the same way:
354
426
  // a note per file instead of a silent, unexplained leftover.
355
427
  if (previous && previous.tiers && !tiers) {
356
- for (const harnessDir of previousHarnessDirs.map((harness) => harness === "claude" ? ".claude" : ".opencode")) {
428
+ for (const harnessDir of previousHarnessDirs) {
357
429
  for (const role of rolesForProfile(profile)) {
358
430
  for (const tier of ROLE_TIERS[role]) {
359
431
  if (tier === DEFAULT_TIER[role])
360
432
  continue;
361
- const relativePath = join(harnessDir, "agents", `${role}-${tier}.md`);
433
+ const relativePath = join(harnessDir.dir, "agents", `${role}-${tier}${harnessDir.extension}`);
362
434
  if (previous.files[relativePath] !== undefined) {
363
435
  report.notes.push(`${relativePath}: now untracked after tiers were turned off; run \`orchestrator-workflow uninstall\` first next time, or remove it by hand.`);
364
436
  }
@@ -390,16 +462,15 @@ export function runInit(options) {
390
462
  // either way, whether or not `previous.harnesses` ever named it.
391
463
  if (previous) {
392
464
  const harnessDirs = {
393
- claude: ".claude",
394
- codex: ".agents",
395
- opencode: ".opencode",
465
+ claude: [".claude"],
466
+ codex: [".agents", ".codex"],
467
+ opencode: [".opencode"],
396
468
  };
397
469
  for (const harness of HARNESSES) {
398
470
  if (options.harnesses.includes(harness))
399
471
  continue;
400
- const prefix = harnessDirs[harness] + sep;
401
472
  for (const relativePath of Object.keys(previous.files)) {
402
- if (relativePath.startsWith(prefix)) {
473
+ if (harnessDirs[harness].some((dir) => relativePath.startsWith(dir + sep))) {
403
474
  report.notes.push(`${relativePath}: now untracked after --harness dropped ${harness}; run \`orchestrator-workflow uninstall\` first next time, or remove it by hand.`);
404
475
  }
405
476
  }
@@ -415,7 +486,7 @@ export function runInit(options) {
415
486
  // `previous.files`), not on the sanitized `previous.harnesses`, for the
416
487
  // same reason as the loop above: a damaged manifest can filter a valid
417
488
  // harness out of `previous.harnesses` while its files remain recorded.
418
- const hadTrackedHarnessFiles = Object.keys(previous.files).some((relativePath) => HARNESSES.some((harness) => relativePath.startsWith(harnessDirs[harness] + sep)));
489
+ const hadTrackedHarnessFiles = Object.keys(previous.files).some((relativePath) => HARNESSES.some((harness) => harnessDirs[harness].some((dir) => relativePath.startsWith(dir + sep))));
419
490
  if (hadTrackedHarnessFiles && options.harnesses.length === 0) {
420
491
  for (const name of ["AGENTS.md", "CLAUDE.md"]) {
421
492
  if (existsSync(join(targetDir, name))) {
@@ -466,12 +537,12 @@ export function runInit(options) {
466
537
  if (options.harnesses.includes("claude")) {
467
538
  installKitFile(join(".claude", "skills", SKILL_NAME, "SKILL.md"), skill);
468
539
  for (const role of rolesForProfile(profile)) {
469
- installKitFile(join(".claude", "agents", `${role}.md`), composeClaudeAgent(role, options.models[role]));
540
+ installKitFile(join(".claude", "agents", `${role}.md`), composeClaudeAgent(role, options.models[role], routingSelection(routing, "claude", role, DEFAULT_TIER[role])));
470
541
  if (tiers) {
471
542
  for (const tier of ROLE_TIERS[role]) {
472
543
  if (tier === DEFAULT_TIER[role])
473
544
  continue;
474
- installKitFile(join(".claude", "agents", `${role}-${tier}.md`), composeClaudeAgentVariant(role, tier));
545
+ installKitFile(join(".claude", "agents", `${role}-${tier}.md`), composeClaudeAgentVariant(role, tier, routingSelection(routing, "claude", role, tier)));
475
546
  }
476
547
  }
477
548
  }
@@ -479,21 +550,46 @@ export function runInit(options) {
479
550
  }
480
551
  if (options.harnesses.includes("codex")) {
481
552
  installKitFile(join(".agents", "skills", SKILL_NAME, "SKILL.md"), skill);
553
+ for (const role of rolesForProfile(profile)) {
554
+ const defaultSelection = routingSelection(routing, "codex", role, DEFAULT_TIER[role]);
555
+ // `defaultCodexRouting` makes every default-tier selection complete;
556
+ // retain this guard so a hand-constructed partial routing object cannot
557
+ // produce an invalid native agent file through the public runInit API.
558
+ if (!defaultSelection) {
559
+ throw new Error(`Missing Codex routing for ${role}/${DEFAULT_TIER[role]}`);
560
+ }
561
+ installKitFile(join(".codex", "agents", `${role}.toml`), composeCodexAgent(role, defaultSelection));
562
+ if (tiers) {
563
+ for (const tier of ROLE_TIERS[role]) {
564
+ if (tier === DEFAULT_TIER[role])
565
+ continue;
566
+ const selection = routingSelection(routing, "codex", role, tier);
567
+ if (!selection) {
568
+ throw new Error(`Missing Codex routing for ${role}/${tier}`);
569
+ }
570
+ installKitFile(join(".codex", "agents", `${role}-${tier}.toml`), composeCodexAgent(role, selection, tier));
571
+ }
572
+ }
573
+ }
482
574
  }
483
575
  if (options.harnesses.includes("opencode")) {
484
576
  installKitFile(join(".opencode", "skills", SKILL_NAME, "SKILL.md"), skill);
485
577
  for (const role of rolesForProfile(profile)) {
486
- const modelValue = options.opencodeModels !== undefined
487
- ? options.opencodeModels[role]
488
- : opencodeModelValue(options.models[role]);
489
- const defaultEffortLine = opencodeEffortLine(DEFAULT_TIER[role], modelValue);
578
+ const modelValue = routingSelection(routing, "opencode", role, DEFAULT_TIER[role])
579
+ ?.model ??
580
+ (opencodeModels !== undefined
581
+ ? opencodeModels[role]
582
+ : opencodeModelValue(options.models[role]));
583
+ const defaultEffortLine = opencodeEffortLine(routingSelection(routing, "opencode", role, DEFAULT_TIER[role])
584
+ ?.effort ?? DEFAULT_TIER[role], modelValue);
490
585
  installKitFile(join(".opencode", "agents", `${role}.md`), composeOpencodeAgent(role, modelValue, defaultEffortLine));
491
586
  if (tiers) {
492
587
  for (const tier of ROLE_TIERS[role]) {
493
588
  if (tier === DEFAULT_TIER[role])
494
589
  continue;
590
+ const selection = routingSelection(routing, "opencode", role, tier);
495
591
  const modelClass = TIER_DEFS[tier].modelClass;
496
- const variantModelValue = options.opencodeClassModels?.[modelClass];
592
+ const variantModelValue = selection?.model ?? opencodeClassModels?.[modelClass];
497
593
  if (variantModelValue === undefined) {
498
594
  // No model resolved for this class: opencodeEffortLine always
499
595
  // returns undefined too when its modelValue argument is
@@ -509,7 +605,7 @@ export function runInit(options) {
509
605
  // rules in opencodeEffortLine above.
510
606
  continue;
511
607
  }
512
- const effortLine = opencodeEffortLine(tier, variantModelValue);
608
+ const effortLine = opencodeEffortLine(selection?.effort ?? tier, variantModelValue);
513
609
  installKitFile(join(".opencode", "agents", `${role}-${tier}.md`), composeOpencodeAgentVariant(role, tier, variantModelValue, effortLine));
514
610
  }
515
611
  }
@@ -524,6 +620,8 @@ export function runInit(options) {
524
620
  models: options.models,
525
621
  profile,
526
622
  tiers,
623
+ routing,
624
+ ...compatibility,
527
625
  files: installedFiles,
528
626
  ...(pin !== undefined ? { pin } : {}),
529
627
  };
@@ -536,6 +634,8 @@ export function runInit(options) {
536
634
  models: previous.models,
537
635
  profile: previous.profile,
538
636
  tiers: previous.tiers,
637
+ ...(previous.routing !== undefined ? { routing: previous.routing } : {}),
638
+ ...legacyOpencodeFallbacks(previous),
539
639
  files: previous.files,
540
640
  ...(previous.pin !== undefined ? { pin: previous.pin } : {}),
541
641
  }) === JSON.stringify(desired)) {
@@ -1,5 +1,7 @@
1
1
  import type { Harness } from "./detect.js";
2
2
  import type { Profile, Role } from "./models.js";
3
+ import type { OpencodeModelMaps } from "./routing-state.js";
4
+ import type { HarnessRouting } from "./routing.js";
3
5
  export declare const OPERATOR_HOME_DIRNAME = ".orchestrator-workflow";
4
6
  export declare const OPERATOR_HOME_ENV = "ORCHESTRATOR_WORKFLOW_HOME";
5
7
  export declare const OPERATOR_MANIFEST_FILENAME = "manifest.json";
@@ -11,11 +13,13 @@ export declare const OPERATOR_MANIFEST_FILENAME = "manifest.json";
11
13
  * some roles, and the rest should fall back to `DEFAULT_MODELS` at the call
12
14
  * site rather than forcing every role to be present here.
13
15
  */
14
- export interface OperatorManifestDefaults {
16
+ export interface OperatorManifestDefaults extends OpencodeModelMaps {
15
17
  harnesses: Harness[];
16
18
  profile: Profile;
17
19
  tiers: boolean;
18
20
  models: Partial<Record<Role, string>>;
21
+ /** Explicit or resolved per-harness selections, carried additively. */
22
+ routing?: HarnessRouting;
19
23
  }
20
24
  /** One target directory this operator has applied the kit to. */
21
25
  export interface OperatorTarget {
@@ -4,6 +4,8 @@ import { homedir } from "node:os";
4
4
  import { isAbsolute, join, resolve } from "node:path";
5
5
  import { HARNESSES } from "./detect.js";
6
6
  import { DEFAULT_PROFILE, ROLES, assertValidModelId, isProfile, } from "./models.js";
7
+ import { parseRouting } from "./routing.js";
8
+ import { parseOpencodeModelMaps } from "./routing-state.js";
7
9
  export const OPERATOR_HOME_DIRNAME = ".orchestrator-workflow";
8
10
  export const OPERATOR_HOME_ENV = "ORCHESTRATOR_WORKFLOW_HOME";
9
11
  export const OPERATOR_MANIFEST_FILENAME = "manifest.json";
@@ -88,6 +90,25 @@ export function readOperatorManifest(home) {
88
90
  ? rawDefaults.profile
89
91
  : DEFAULT_PROFILE;
90
92
  const tiers = typeof rawDefaults.tiers === "boolean" ? rawDefaults.tiers : false;
93
+ let opencodeMaps;
94
+ try {
95
+ opencodeMaps = parseOpencodeModelMaps(rawDefaults);
96
+ }
97
+ catch {
98
+ return undefined;
99
+ }
100
+ let routing;
101
+ if ("routing" in rawDefaults) {
102
+ try {
103
+ routing = parseRouting(rawDefaults.routing);
104
+ }
105
+ catch {
106
+ // Unlike legacy aliases, an invalid routing map could silently change
107
+ // native Codex agents on a future install. Treat the whole operator
108
+ // manifest as unreadable so setup/apply cannot overwrite it by guess.
109
+ return undefined;
110
+ }
111
+ }
91
112
  const targets = (Array.isArray(candidate.targets) ? candidate.targets : []).filter((value) => {
92
113
  if (typeof value !== "object" || value === null)
93
114
  return false;
@@ -100,7 +121,14 @@ export function readOperatorManifest(home) {
100
121
  return {
101
122
  kit: "orchestrator-workflow",
102
123
  schemaVersion: 1,
103
- defaults: { harnesses, profile, tiers, models },
124
+ defaults: {
125
+ harnesses,
126
+ profile,
127
+ tiers,
128
+ models,
129
+ ...(routing !== undefined ? { routing } : {}),
130
+ ...opencodeMaps,
131
+ },
104
132
  targets,
105
133
  createdAt: typeof candidate.createdAt === "string" ? candidate.createdAt : "",
106
134
  updatedAt: typeof candidate.updatedAt === "string" ? candidate.updatedAt : "",
@@ -135,6 +163,7 @@ function writeOperatorManifestUnlocked(home, manifest) {
135
163
  mkdirSync(home, { recursive: true });
136
164
  const path = join(home, OPERATOR_MANIFEST_FILENAME);
137
165
  const tmpPath = join(home, `${OPERATOR_MANIFEST_FILENAME}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`);
166
+ parseOpencodeModelMaps(manifest.defaults);
138
167
  writeFileSync(tmpPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
139
168
  renameSync(tmpPath, path);
140
169
  }
@@ -530,6 +559,9 @@ export function upsertOperatorTarget(manifest, targetPath, appliedVersion, appli
530
559
  defaults: {
531
560
  ...manifest.defaults,
532
561
  models: { ...manifest.defaults.models },
562
+ ...(manifest.defaults.routing !== undefined
563
+ ? { routing: manifest.defaults.routing }
564
+ : {}),
533
565
  },
534
566
  targets,
535
567
  },
@@ -0,0 +1,60 @@
1
+ import type { Harness } from "./detect.js";
2
+ import type { ModelClass, Profile, Role, Tier } from "./models.js";
3
+ import type { HarnessRouting } from "./routing.js";
4
+ /** Legacy API resolution state: absent is unknown; present missing keys inherit. */
5
+ export interface OpencodeModelMaps {
6
+ opencodeModels?: Partial<Record<Role, string | undefined>>;
7
+ opencodeClassModels?: Partial<Record<ModelClass, string | undefined>>;
8
+ }
9
+ /** Validate compatibility maps independently of strict explicit routing. */
10
+ export declare function parseOpencodeModelMaps(value: {
11
+ opencodeModels?: unknown;
12
+ opencodeClassModels?: unknown;
13
+ }): OpencodeModelMaps;
14
+ /** Persist only the compatibility values that cannot live in strict routing. */
15
+ export declare function legacyOpencodeFallbacks(value: OpencodeModelMaps): OpencodeModelMaps;
16
+ /** Strip compatibility-only leaves before saving or parsing strict routing. */
17
+ export declare function persistableRouting(routing: HarnessRouting): HarnessRouting;
18
+ export interface RoutingScope {
19
+ harnesses: Harness[];
20
+ profile: Profile;
21
+ tiers: boolean;
22
+ }
23
+ export declare function selectedTiers(role: Role, tiers: boolean): Tier[];
24
+ /** Selects only leaves that the requested adapters actually install. */
25
+ export declare function selectedRouting(routing: HarnessRouting, scope: RoutingScope): HarnessRouting;
26
+ /** One shared, offline catalog check for setup and installation. */
27
+ export declare function codexCatalogWarnings(routing: HarnessRouting, scope: RoutingScope, catalog?: unknown): string[];
28
+ export declare function legacyRouting(harnesses: Harness[], models: Record<Role, string>, opencodeModels: OpencodeModelMaps["opencodeModels"], opencodeClassModels: OpencodeModelMaps["opencodeClassModels"]): HarnessRouting;
29
+ export interface RoutingStateInput extends OpencodeModelMaps {
30
+ harnesses: Harness[];
31
+ models: Record<Role, string>;
32
+ previousRouting?: HarnessRouting;
33
+ routing?: HarnessRouting;
34
+ routingMode?: "patch" | "replace";
35
+ /** Explicit legacy default-role updates; unrelated selections remain sticky. */
36
+ legacyOverrideRoles?: Role[];
37
+ /** Resolved opencode inputs supplied directly to the installer are updates. */
38
+ updateOpencodeModels?: boolean;
39
+ updateOpencodeClassModels?: boolean;
40
+ }
41
+ /** Materializes legacy inputs and preserves leaves unless explicitly replaced. */
42
+ export declare function normalizeRoutingState(input: RoutingStateInput): HarnessRouting;
43
+ interface ComparableRoutingState extends OpencodeModelMaps {
44
+ models: Partial<Record<Role, string>>;
45
+ routing?: HarnessRouting;
46
+ }
47
+ /** Materialize each precedence layer before a higher layer overrides it. */
48
+ export declare function routingStateLayer(state: ComparableRoutingState & RoutingScope): HarnessRouting;
49
+ /** Whether a missing concrete leaf has an explicit legacy inheritance record. */
50
+ export declare function recordedOpencodeInheritance(maps: OpencodeModelMaps, role: Role, tier: Tier): boolean;
51
+ /** Merge recorded resolution state independently of the active render scope. */
52
+ export declare function mergeRoutingStateLayers(...states: (ComparableRoutingState & RoutingScope)[]): {
53
+ routing: HarnessRouting;
54
+ } & OpencodeModelMaps;
55
+ /** Compares installed scope without consulting a live opencode catalog. */
56
+ export declare function compareRoutingState(repo: ComparableRoutingState, operator: ComparableRoutingState, scope: RoutingScope): {
57
+ differs: boolean;
58
+ gaps: string[];
59
+ };
60
+ export {};