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.
@@ -1,35 +1,16 @@
1
1
  import inquirer from "inquirer";
2
2
  import { HARNESSES, parseHarnessOption } from "./detect.js";
3
- import { CLASS_MODELS, DEFAULT_MODELS, DEFAULT_PROFILE, MODEL_ALIASES, MODEL_CLASSES, assertValidModelId, parseModelsSpec, parseProfile, rolesForProfile, } from "./models.js";
3
+ import { CLASS_MODELS, DEFAULT_TIER, DEFAULT_MODELS, DEFAULT_PROFILE, MODEL_ALIASES, MODEL_CLASSES, assertValidModelId, parseModelsSpec, parseProfile, rolesForProfile, } from "./models.js";
4
+ import { mergeRouting, parseRouting } from "./routing.js";
5
+ import { legacyRouting, normalizeRoutingState, selectedTiers, parseOpencodeModelMaps, recordedOpencodeInheritance, } from "./routing-state.js";
4
6
  import { detectProvider, loadOpencodeCatalog, resolveAlias, resolveOpencodeModels, } from "./opencode.js";
5
7
  export async function promptHarnesses(detected, installed, fallbackToClaude = true,
6
8
  // Drives only the checkbox's " (detected)" label suffix, independent of
7
- // `detected`'s own role in pre-checking a choice: defaults to `detected`
8
- // so a call site that omits this parameter keeps annotating exactly what
9
- // it pre-checks from, unchanged. `resolveInitInputs`'s harnesses-
10
- // stickiness branch (shared by `init` and `apply`, D-002, agent-dx
11
- // 7669907c) always passes an explicit value here (`stickyAnnotateDetected
12
- // ?? detected`) instead of relying on this default: that branch pre-checks
13
- // nothing but still wants the operator to see which harness is actually
14
- // on disk, so labelling and pre-checking are resolved separately
15
- // (agent-tasks fe834823, fix round 3).
9
+ // `detected`'s role in pre-checking; see `stickyAnnotateDetected` below.
16
10
  annotateDetected = detected) {
17
11
  const known = [...new Set([...detected, ...installed])];
18
- // Nothing detected and nothing previously installed: the plain-first-run
19
- // case pre-checks `claude` as a sane default (`fallbackToClaude`'s default
20
- // `true`). The templates-only re-run branch below opts OUT of that
21
- // (`fallbackToClaude: false`): a repo the operator explicitly recorded as
22
- // `harnesses: []` has no harness files by construction, and pre-checking
23
- // `claude` on Enter would silently re-widen an explicit `--harness none`
24
- // install -- contradicting README.md's and this function's own "nothing
25
- // forced pre-selected" claim (see CHANGELOG). `resolveInitInputs`'s
26
- // harnesses-stickiness branch (shared by `init` and `apply`, D-002,
27
- // agent-dx 7669907c) always calls this function with `stickyPreChecked ??
28
- // []` regardless of what is actually on disk or previously chosen --
29
- // the operator's recorded `harnesses: []` is the intent that matters,
30
- // not a harness config a harness itself left behind (agent-tasks
31
- // fe834823; the residual noted in docs/okf/log.md's 2026-08-31 entry is
32
- // closed).
12
+ // The templates-only branch disables this first-run fallback; see
13
+ // `ResolveInitInputsParams.stickyPreChecked` for the sticky rule.
33
14
  const preselected = known.length > 0 ? known : fallbackToClaude ? ["claude"] : [];
34
15
  const { harnesses } = await inquirer.prompt([
35
16
  {
@@ -118,6 +99,16 @@ export async function promptModels(base, roles) {
118
99
  }
119
100
  return models;
120
101
  }
102
+ function modelOverrideRoles(spec) {
103
+ const roles = [];
104
+ for (const item of spec.split(",")) {
105
+ const role = item.trim().split("=", 1)[0]?.trim();
106
+ if (role && rolesForProfile("full").includes(role)) {
107
+ roles.push(role);
108
+ }
109
+ }
110
+ return [...new Set(roles)];
111
+ }
121
112
  /**
122
113
  * Resolves everything `runInit` needs (harnesses, profile, models, tiers,
123
114
  * the opencode model resolutions) from the CLI-parsed options, the target
@@ -130,16 +121,15 @@ export async function promptModels(base, roles) {
130
121
  * omitted) keeps the previously installed value; a fresh install with no
131
122
  * prior manifest falls back to the shipped default.
132
123
  *
133
- * `params.detected` is the fallback-chain input the non-sticky "else"
134
- * branch below prompts and falls back from. It plays no role in the
135
- * harnesses-stickiness branch's own pre-check any more: that branch
136
- * always pre-checks `stickyPreChecked ?? []` (D-002, agent-dx 7669907c),
137
- * never `detected`, so `init` and `apply` share one semantics there. It
138
- * still feeds that branch's " (detected)" label by default (see
139
- * `ResolveInitInputsParams.stickyAnnotateDetected`'s doc comment).
124
+ * `params.detected` is the non-sticky fallback-chain input; see
125
+ * `ResolveInitInputsParams.stickyPreChecked` for the sticky rule.
140
126
  */
141
127
  export async function resolveInitInputs(params) {
142
128
  const { detected, interactive, previous, opts, previousIsRecordedManifest, stickyPreChecked, stickyAnnotateDetected, } = params;
129
+ const routingPatch = opts.routing === undefined ? undefined : parseRouting(opts.routing);
130
+ const previousRouting = previous?.routing === undefined
131
+ ? undefined
132
+ : parseRouting(previous.routing);
143
133
  let harnesses;
144
134
  if (opts.harness) {
145
135
  harnesses = parseHarnessOption(opts.harness);
@@ -171,19 +161,7 @@ export async function resolveInitInputs(params) {
171
161
  // as `[]` (not the recorded `previous.harnesses`) so nothing is
172
162
  // pre-checked from the previous install, unlike the "else" branch
173
163
  // below's normal re-run prompt.
174
- // `stickyPreChecked ?? []` pre-checks nothing by default: `init` and
175
- // `apply` share this semantics (D-002, agent-dx 7669907c; see
176
- // CHANGELOG). Neither call site needs to pass `stickyPreChecked`
177
- // itself for this default to apply (`apply` still does, as defence in
178
- // depth; see `buildApplyInitInputs`'s doc comment); a bare Enter on a
179
- // target recorded as `harnesses: []` must not re-widen a deliberate
180
- // `--harness none` install just because a harness config happens to
181
- // still be on disk, which is a weak signal, not the recorded intent
182
- // (agent-tasks fe834823; the same argument applies identically to
183
- // `init`). `fallbackToClaude: false` closes the same gap for the case
184
- // where nothing is pre-checked either: without it, `promptHarnesses`
185
- // would pre-check `claude` on its own "nothing known" fallback,
186
- // re-widening the install on a bare Enter.
164
+ // See `ResolveInitInputsParams.stickyPreChecked` for the shared sticky rule.
187
165
  // `stickyAnnotateDetected ?? detected` is passed through as the fourth
188
166
  // argument so the checkbox's " (detected)" label still points at what
189
167
  // is actually on disk even though nothing is pre-checked from it: the
@@ -226,8 +204,16 @@ export async function resolveInitInputs(params) {
226
204
  };
227
205
  if (opts.models)
228
206
  models = parseModelsSpec(opts.models, models);
229
- if (interactive && !opts.models)
207
+ const promptLegacyModels = interactive &&
208
+ !opts.models &&
209
+ harnesses.some((harness) => harness === "claude" || harness === "opencode");
210
+ if (promptLegacyModels)
230
211
  models = await promptModels(models, rolesForProfile(profile));
212
+ const legacyOverrideRoles = opts.models
213
+ ? modelOverrideRoles(opts.models)
214
+ : promptLegacyModels
215
+ ? rolesForProfile(profile)
216
+ : [];
231
217
  // Explicit --tiers/--no-tiers always override; a plain re-run (neither
232
218
  // flag passed) keeps whatever the previous install had (default false
233
219
  // for a fresh install), same override-vs-persist rule as
@@ -242,16 +228,28 @@ export async function resolveInitInputs(params) {
242
228
  // Resolve opencode model aliases against the live catalog when the opencode
243
229
  // harness is selected. The shell-out stays reachable only from this
244
230
  // resolution step, keeping runInit pure.
245
- let opencodeModels;
246
- let opencodeClassModels;
231
+ const previousMaps = parseOpencodeModelMaps(previous ?? {});
232
+ let opencodeModels = previousMaps.opencodeModels;
233
+ let opencodeClassModels = previousMaps.opencodeClassModels;
247
234
  const warnings = [];
248
- if (harnesses.includes("opencode")) {
235
+ const availableRouting = mergeRouting(legacyRouting(harnesses, models, opencodeModels, opencodeClassModels), previousRouting, routingPatch);
236
+ const needsOpencodeResolution = harnesses.includes("opencode") &&
237
+ (opts.opencodeProvider !== undefined ||
238
+ legacyOverrideRoles.some((role) => !routingPatch?.opencode?.[role]?.[DEFAULT_TIER[role]]) ||
239
+ rolesForProfile(profile).some((role) => selectedTiers(role, tiers).some((tier) => !availableRouting.opencode?.[role]?.[tier] &&
240
+ !recordedOpencodeInheritance(previousMaps, role, tier))));
241
+ if (needsOpencodeResolution) {
249
242
  const catalog = loadOpencodeCatalog();
250
243
  const { resolved, warnings: modelWarnings } = resolveOpencodeModels(models, {
251
244
  catalog,
252
245
  explicitProvider: opts.opencodeProvider,
253
246
  });
254
- opencodeModels = resolved;
247
+ opencodeModels =
248
+ previousMaps.opencodeModels !== undefined && !opts.opencodeProvider
249
+ ? { ...previousMaps.opencodeModels }
250
+ : resolved;
251
+ for (const role of legacyOverrideRoles)
252
+ opencodeModels[role] = resolved[role];
255
253
  for (const w of modelWarnings) {
256
254
  warnings.push(`Warning: ${w}`);
257
255
  }
@@ -263,11 +261,15 @@ export async function resolveInitInputs(params) {
263
261
  opencodeClassModels = {};
264
262
  for (const modelClass of MODEL_CLASSES) {
265
263
  const alias = CLASS_MODELS[modelClass];
266
- const resolvedModel = providerResult.provider
267
- ? resolveAlias(providerResult.provider, alias, catalog)
268
- : undefined;
264
+ const recordedClass = previousMaps.opencodeClassModels !== undefined &&
265
+ !opts.opencodeProvider;
266
+ const resolvedModel = recordedClass
267
+ ? previousMaps.opencodeClassModels?.[modelClass]
268
+ : providerResult.provider
269
+ ? resolveAlias(providerResult.provider, alias, catalog)
270
+ : undefined;
269
271
  opencodeClassModels[modelClass] = resolvedModel;
270
- if (resolvedModel !== undefined)
272
+ if (resolvedModel !== undefined || recordedClass)
271
273
  continue;
272
274
  // One warning per unresolved model class: without it, every
273
275
  // effort-tier variant keyed to this class is silently skipped
@@ -287,6 +289,18 @@ export async function resolveInitInputs(params) {
287
289
  }
288
290
  }
289
291
  }
292
+ const routing = normalizeRoutingState({
293
+ harnesses,
294
+ models,
295
+ opencodeModels,
296
+ opencodeClassModels,
297
+ previousRouting,
298
+ routing: routingPatch,
299
+ legacyOverrideRoles,
300
+ // Changing provider is an explicit request to resolve all opencode leaves.
301
+ updateOpencodeModels: opts.opencodeProvider !== undefined,
302
+ updateOpencodeClassModels: opts.opencodeProvider !== undefined,
303
+ });
290
304
  return {
291
305
  harnesses,
292
306
  profile,
@@ -294,6 +308,7 @@ export async function resolveInitInputs(params) {
294
308
  tiers,
295
309
  opencodeModels,
296
310
  opencodeClassModels,
311
+ routing,
297
312
  warnings,
298
313
  };
299
314
  }
package/dist/cli.js CHANGED
@@ -10,6 +10,8 @@ import { resolveInitInputs } from "./cli-inputs.js";
10
10
  import { HARNESSES, detectHarnesses } from "./detect.js";
11
11
  import { DEFAULT_MODELS, PROFILES } from "./models.js";
12
12
  import { MANIFEST_PATH, readInstalledManifest, runInit } from "./init.js";
13
+ import { parseRouting } from "./routing.js";
14
+ import { codexCatalogWarnings, legacyOpencodeFallbacks, parseOpencodeModelMaps, mergeRoutingStateLayers, } from "./routing-state.js";
13
15
  import { OPERATOR_MANIFEST_FILENAME, OperatorManifestLockTimeoutError, applyRegistrationFailureMessage, createOperatorManifest, operatorManifestState, readOperatorManifest, resolveOperatorHome, safeRealpath, updateOperatorManifest, upsertOperatorTarget, } from "./operator-manifest.js";
14
16
  import { runUninstall } from "./uninstall.js";
15
17
  import { adoptExitCodeForStatus, adoptJsonExtras, inspectTarget, runDoctor, statOrClassify, suppressSuccessLine, targetReportToJson, } from "./doctor.js";
@@ -43,6 +45,20 @@ function requireDirectory(dir) {
43
45
  }
44
46
  return targetDir;
45
47
  }
48
+ function readJsonOption(path, option) {
49
+ try {
50
+ return JSON.parse(readFileSync(resolve(path), "utf8"));
51
+ }
52
+ catch (error) {
53
+ const reason = error instanceof Error ? error.message : String(error);
54
+ throw new Error(`Could not read ${option} JSON file ${path}: ${reason}`);
55
+ }
56
+ }
57
+ function routingOption(path) {
58
+ return path === undefined
59
+ ? undefined
60
+ : parseRouting(readJsonOption(path, "--routing"));
61
+ }
46
62
  /**
47
63
  * `resolveInitInputs` was extracted from `init` and typed against `init`'s
48
64
  * per-repo `Manifest` (kit/version/files/installedAt included), since that
@@ -64,6 +80,8 @@ function defaultsAsManifest(defaults) {
64
80
  models: { ...DEFAULT_MODELS, ...defaults.models },
65
81
  profile: defaults.profile,
66
82
  tiers: defaults.tiers,
83
+ routing: defaults.routing,
84
+ ...parseOpencodeModelMaps(defaults),
67
85
  files: {},
68
86
  installedAt: "",
69
87
  };
@@ -80,6 +98,8 @@ function defaultsEqual(a, b) {
80
98
  profile: d.profile,
81
99
  tiers: d.tiers,
82
100
  models: Object.fromEntries(Object.entries(d.models).sort(([x], [y]) => x.localeCompare(y))),
101
+ routing: d.routing ?? {},
102
+ ...parseOpencodeModelMaps(d),
83
103
  });
84
104
  return normalize(a) === normalize(b);
85
105
  }
@@ -96,6 +116,8 @@ program
96
116
  .option("-f, --force", "overwrite kit-owned files that have local edits")
97
117
  .option("--harness <list>", `comma-separated harnesses (${HARNESSES.join(", ")}), or "none" alone for templates-only mode (.ai/workflow/** and .ai/runs/.gitkeep only, no AGENTS.md/CLAUDE.md/harness files); default: detected`)
98
118
  .option("--models <spec>", 'per-role model overrides, e.g. "implementer=sonnet,reviewer=opus"')
119
+ .option("--routing <json-file>", "harness/role/tier routing patch JSON")
120
+ .option("--codex-catalog <json-file>", "optional offline Codex capability catalog JSON to validate before writing")
99
121
  .option("--profile <profile>", `subagent role profile (${PROFILES.join(", ")}); default: full, or the previously installed profile on a re-run`)
100
122
  .option("--opencode-provider <id>", "opencode provider id for alias resolution (e.g. github-copilot); auto-detected when omitted")
101
123
  .option("--tiers", "also render per-role effort-tier subagent variants (<role>-<tier>.md); default: off, or the previously installed value on a re-run")
@@ -105,6 +127,19 @@ program
105
127
  if (!targetDir)
106
128
  return;
107
129
  const interactive = !opts.yes && isInteractive();
130
+ let routing;
131
+ let codexCatalog;
132
+ try {
133
+ routing = routingOption(opts.routing);
134
+ codexCatalog = opts.codexCatalog
135
+ ? readJsonOption(opts.codexCatalog, "--codex-catalog")
136
+ : undefined;
137
+ }
138
+ catch (error) {
139
+ console.error(error instanceof Error ? error.message : error);
140
+ process.exitCode = 2;
141
+ return;
142
+ }
108
143
  // Say where files will land BEFORE anything is written; an accidental
109
144
  // cwd (e.g. $HOME) is the most likely operator mistake.
110
145
  console.log(`Installing into ${targetDir}`);
@@ -125,13 +160,16 @@ program
125
160
  : "none recorded";
126
161
  console.log(`Found existing install (${version.startsWith("unknown") ? version : `v${version}`}, harnesses: ${installedFor}, profile: ${previous.profile}, tiers: ${previous.tiers})`);
127
162
  }
128
- const { harnesses, profile, models, tiers, opencodeModels, opencodeClassModels, warnings, } = await resolveInitInputs(
163
+ const { harnesses, profile, models, tiers, opencodeModels, opencodeClassModels, routing: resolvedRouting, warnings, } = await resolveInitInputs(
129
164
  // The sticky-branch wiring itself (neither `stickyPreChecked` nor
130
165
  // `stickyAnnotateDetected` overridden, so `resolveInitInputs`'s own
131
166
  // `?? []` / `?? detected` defaults apply) is pinned inside
132
167
  // `buildInitInitInputs` and covered by a dedicated test
133
168
  // (`test/cli-init.test.ts`), not by this call site.
134
- buildInitInitInputs(detected, previous, interactive, opts));
169
+ buildInitInitInputs(detected, previous, interactive, {
170
+ ...opts,
171
+ routing,
172
+ }));
135
173
  for (const w of warnings) {
136
174
  process.stderr.write(`${w}\n`);
137
175
  }
@@ -144,6 +182,9 @@ program
144
182
  opencodeModels,
145
183
  tiers,
146
184
  opencodeClassModels,
185
+ routing: resolvedRouting,
186
+ routingMode: "replace",
187
+ codexCatalog,
147
188
  });
148
189
  showPaths("Created", report.written);
149
190
  showPaths("Updated", report.updated);
@@ -159,6 +200,8 @@ program
159
200
  .option("-y, --yes", "accept all defaults and skip prompts")
160
201
  .option("--harness <list>", `comma-separated harnesses (${HARNESSES.join(", ")}); default: previously stored, or claude`)
161
202
  .option("--models <spec>", 'per-role model overrides, e.g. "implementer=sonnet,reviewer=opus"')
203
+ .option("--routing <json-file>", "harness/role/tier routing patch JSON")
204
+ .option("--codex-catalog <json-file>", "optional offline Codex capability catalog JSON to validate before saving")
162
205
  .option("--profile <profile>", `subagent role profile (${PROFILES.join(", ")}); default: full, or the previously stored profile on a re-run`)
163
206
  .option("--opencode-provider <id>", "opencode provider id for alias resolution (e.g. github-copilot); auto-detected when omitted")
164
207
  .option("--tiers", "select per-role effort-tier subagent variants by default; default: off, or the previously stored value on a re-run")
@@ -166,6 +209,19 @@ program
166
209
  .action(async (opts) => {
167
210
  const interactive = !opts.yes && isInteractive();
168
211
  const home = resolveOperatorHome();
212
+ let routingPatch;
213
+ let codexCatalog;
214
+ try {
215
+ routingPatch = routingOption(opts.routing);
216
+ codexCatalog = opts.codexCatalog
217
+ ? readJsonOption(opts.codexCatalog, "--codex-catalog")
218
+ : undefined;
219
+ }
220
+ catch (error) {
221
+ console.error(error instanceof Error ? error.message : error);
222
+ process.exitCode = 2;
223
+ return;
224
+ }
169
225
  console.log(`Operator home: ${home}`);
170
226
  const existing = readOperatorManifest(home);
171
227
  if (existing) {
@@ -177,7 +233,7 @@ program
177
233
  // No target repository exists to detect harnesses against; "claude"
178
234
  // is the same shipped fallback `init` uses for a fresh install with
179
235
  // nothing detected and nothing previously recorded.
180
- const { harnesses, profile, models, tiers, warnings } = await resolveInitInputs({
236
+ const { harnesses, profile, models, tiers, routing, warnings, opencodeModels, opencodeClassModels, } = await resolveInitInputs({
181
237
  // No target directory exists to detect against: the stored
182
238
  // harnesses are the baseline, and only a first-ever setup falls
183
239
  // back to claude, so a codex-only default is not widened silently.
@@ -186,16 +242,30 @@ program
186
242
  : ["claude"],
187
243
  interactive,
188
244
  previous: defaultsAsManifest(existing?.defaults),
189
- opts,
245
+ opts: { ...opts, routing: routingPatch },
190
246
  });
191
247
  for (const w of warnings) {
192
248
  process.stderr.write(`${w}\n`);
193
249
  }
250
+ {
251
+ try {
252
+ for (const warning of codexCatalogWarnings(routing, { harnesses, profile, tiers }, codexCatalog)) {
253
+ process.stderr.write(`Warning: ${warning}\n`);
254
+ }
255
+ }
256
+ catch (error) {
257
+ console.error(error instanceof Error ? error.message : error);
258
+ process.exitCode = 2;
259
+ return;
260
+ }
261
+ }
194
262
  const newDefaults = {
195
263
  harnesses,
196
264
  profile,
197
265
  tiers,
198
266
  models,
267
+ routing,
268
+ ...legacyOpencodeFallbacks({ opencodeModels, opencodeClassModels }),
199
269
  };
200
270
  // The prompts and resolution above ran against `existing`, an
201
271
  // unlocked read taken before this point; another process (an
@@ -410,6 +480,7 @@ function buildApplyPrevious(repoManifest, operatorDefaults, sync) {
410
480
  const tiers = sync
411
481
  ? operatorDefaults.tiers
412
482
  : (repoManifest?.tiers ?? operatorDefaults.tiers);
483
+ const { routing, ...compatibility } = mergeRoutingStateLayers(operatorDefaults, ...(!sync && repoManifest ? [repoManifest] : []));
413
484
  return {
414
485
  kit: "orchestrator-workflow",
415
486
  version: PACKAGE_VERSION,
@@ -424,6 +495,8 @@ function buildApplyPrevious(repoManifest, operatorDefaults, sync) {
424
495
  models,
425
496
  profile,
426
497
  tiers,
498
+ ...(routing !== undefined ? { routing } : {}),
499
+ ...compatibility,
427
500
  files: {},
428
501
  installedAt: "",
429
502
  };
@@ -436,6 +509,8 @@ program
436
509
  .option("-f, --force", "overwrite kit-owned files that have local edits")
437
510
  .option("--harness <list>", `comma-separated harnesses (${HARNESSES.join(", ")}); default: the target's recorded harnesses, else the operator defaults, else detected`)
438
511
  .option("--models <spec>", 'per-role model overrides, e.g. "implementer=sonnet,reviewer=opus"')
512
+ .option("--routing <json-file>", "harness/role/tier routing patch JSON")
513
+ .option("--codex-catalog <json-file>", "optional offline Codex capability catalog JSON to validate before writing")
439
514
  .option("--profile <profile>", `subagent role profile (${PROFILES.join(", ")}); default: the target's recorded profile, else the operator default`)
440
515
  .option("--opencode-provider <id>", "opencode provider id for alias resolution (e.g. github-copilot); auto-detected when omitted")
441
516
  .option("--tiers", "also render per-role effort-tier subagent variants (<role>-<tier>.md); default: the target's recorded value, else the operator default")
@@ -445,6 +520,19 @@ program
445
520
  .option("--pin <version>", "set or replace the target's recorded kit-version pin and apply this operator install regardless of any existing pin")
446
521
  .option("--unpin", "clear the target's recorded kit-version pin and apply this operator install")
447
522
  .action(async (opts) => {
523
+ let routingPatch;
524
+ let codexCatalog;
525
+ try {
526
+ routingPatch = routingOption(opts.routing);
527
+ codexCatalog = opts.codexCatalog
528
+ ? readJsonOption(opts.codexCatalog, "--codex-catalog")
529
+ : undefined;
530
+ }
531
+ catch (error) {
532
+ console.error(error instanceof Error ? error.message : error);
533
+ process.exitCode = 2;
534
+ return;
535
+ }
448
536
  // --pin and --unpin express opposite intents (set a pin vs clear it);
449
537
  // accepting both silently would make the effective pin depend on
450
538
  // internal option-resolution order, so this is a usage error rather
@@ -536,7 +624,7 @@ program
536
624
  }
537
625
  const chosenHarnesses = resolveApplyHarnesses(targetDir, repoManifest, operatorManifest.defaults);
538
626
  const previous = buildApplyPrevious(repoManifest, operatorManifest.defaults, Boolean(opts.sync));
539
- const { harnesses, profile, models, tiers, opencodeModels, opencodeClassModels, warnings, } = await resolveInitInputs(
627
+ const { harnesses, profile, models, tiers, opencodeModels, opencodeClassModels, routing: resolvedRouting, warnings, } = await resolveInitInputs(
540
628
  // `previous` is always defined here (`buildApplyPrevious` returns a
541
629
  // synthetic object even for a target with no manifest of its own),
542
630
  // so `previousIsRecordedManifest` cannot be `Boolean(previous)`; it
@@ -557,7 +645,10 @@ program
557
645
  // `stickyAnnotateDetected`) is pinned inside `buildApplyInitInputs`
558
646
  // rather than inlined here (agent-tasks fe834823, fix round 3,
559
647
  // review finding 1).
560
- buildApplyInitInputs(targetDir, chosenHarnesses, previous, interactive, opts, Boolean(repoManifest)));
648
+ buildApplyInitInputs(targetDir, chosenHarnesses, previous, interactive,
649
+ // Explicit routing is parsed before the first target write and is
650
+ // the final merge layer after repo/operator sticky resolution.
651
+ { ...opts, routing: routingPatch }, Boolean(repoManifest)));
561
652
  for (const w of warnings) {
562
653
  process.stderr.write(`${w}\n`);
563
654
  }
@@ -588,6 +679,9 @@ program
588
679
  opencodeModels,
589
680
  tiers,
590
681
  opencodeClassModels,
682
+ routing: resolvedRouting,
683
+ routingMode: "replace",
684
+ codexCatalog,
591
685
  pin,
592
686
  });
593
687
  showPaths("Created", report.written);
@@ -786,6 +880,12 @@ function printTargetDetail(target, operatorVersion) {
786
880
  if (target.divergence.models) {
787
881
  console.log(` models: ${target.divergentModelRoles.join(", ")}`);
788
882
  }
883
+ if (target.divergence.routing) {
884
+ console.log(" routing: repo and operator selections differ");
885
+ }
886
+ }
887
+ for (const gap of target.routingComparisonGaps ?? []) {
888
+ console.log(` Routing comparison incomplete: ${gap}`);
789
889
  }
790
890
  const showsVersionLagDetail = (target.status === "version-lag" ||
791
891
  ((target.status === "divergent" || target.status === "drift") &&
@@ -823,6 +923,10 @@ function operatorDefaultsFromRepoManifest(repoManifest) {
823
923
  profile: repoManifest.profile,
824
924
  tiers: repoManifest.tiers,
825
925
  models: { ...repoManifest.models },
926
+ ...parseOpencodeModelMaps(repoManifest),
927
+ ...(repoManifest.routing !== undefined
928
+ ? { routing: repoManifest.routing }
929
+ : {}),
826
930
  };
827
931
  }
828
932
  /**
@@ -0,0 +1,8 @@
1
+ import type { Role, Tier } from "./models.js";
2
+ import type { ModelSelection } from "./routing.js";
3
+ /**
4
+ * Produces a standalone Codex subagent definition. The reviewer intentionally
5
+ * inherits its parent's sandbox: its canonical prompt prohibits source edits,
6
+ * while inherited access lets it create test/build output in temporary paths.
7
+ */
8
+ export declare function composeCodexAgent(role: Role, selection: ModelSelection, tier?: Tier): string;
package/dist/codex.js ADDED
@@ -0,0 +1,52 @@
1
+ import { readAgentAsset } from "./assets.js";
2
+ function tomlString(value) {
3
+ let escaped = "";
4
+ for (let index = 0; index < value.length; index += 1) {
5
+ const code = value.charCodeAt(index);
6
+ if (code === 0x22)
7
+ escaped += '\\"';
8
+ else if (code === 0x5c)
9
+ escaped += "\\\\";
10
+ else if (code === 0x08)
11
+ escaped += "\\b";
12
+ else if (code === 0x09)
13
+ escaped += "\\t";
14
+ else if (code === 0x0a)
15
+ escaped += "\\n";
16
+ else if (code === 0x0c)
17
+ escaped += "\\f";
18
+ else if (code === 0x0d)
19
+ escaped += "\\r";
20
+ else if (code < 0x20 || code === 0x7f) {
21
+ escaped += `\\u${code.toString(16).padStart(4, "0")}`;
22
+ }
23
+ else {
24
+ escaped += value[index];
25
+ }
26
+ }
27
+ return `"${escaped}"`;
28
+ }
29
+ function descriptionForTier(description, tier) {
30
+ return tier === undefined
31
+ ? description
32
+ : `${description} (Effort tier: ${tier}.)`;
33
+ }
34
+ /**
35
+ * Produces a standalone Codex subagent definition. The reviewer intentionally
36
+ * inherits its parent's sandbox: its canonical prompt prohibits source edits,
37
+ * while inherited access lets it create test/build output in temporary paths.
38
+ */
39
+ export function composeCodexAgent(role, selection, tier) {
40
+ const asset = readAgentAsset(role);
41
+ const lines = [
42
+ `name = ${tomlString(tier === undefined ? asset.name : `${asset.name}-${tier}`)}`,
43
+ `description = ${tomlString(descriptionForTier(asset.description, tier))}`,
44
+ `model = ${tomlString(selection.model)}`,
45
+ `model_reasoning_effort = ${tomlString(selection.effort)}`,
46
+ ];
47
+ if (role === "explorer" || role === "advisor") {
48
+ lines.push('sandbox_mode = "read-only"');
49
+ }
50
+ lines.push(`developer_instructions = ${tomlString(asset.body.trimEnd())}`);
51
+ return `${lines.join("\n")}\n`;
52
+ }
package/dist/doctor.d.ts CHANGED
@@ -24,6 +24,8 @@ export interface TargetDivergence {
24
24
  profile: boolean;
25
25
  tiers: boolean;
26
26
  models: boolean;
27
+ /** Present only when explicit effective routing differs. */
28
+ routing?: boolean;
27
29
  }
28
30
  /**
29
31
  * Per-target report. Only `path`, `status`, `installedVersion`, `pin`,
@@ -40,6 +42,8 @@ export interface TargetReport {
40
42
  pin: string | null;
41
43
  divergence: TargetDivergence | null;
42
44
  driftFiles: string[] | null;
45
+ /** Selected legacy opencode leaves that cannot be compared offline. */
46
+ routingComparisonGaps?: string[];
43
47
  /** Human-output-only: the repo's own profile, or null when unknown. */
44
48
  repoProfile: Profile | null;
45
49
  /** Human-output-only: the operator default profile, for the comparison line. */
@@ -81,6 +85,8 @@ export interface TargetReportJson {
81
85
  pin: string | null;
82
86
  divergence: TargetDivergence | null;
83
87
  driftFiles: string[] | null;
88
+ /** Selected legacy opencode leaves that cannot be compared offline. */
89
+ routingComparisonGaps?: string[];
84
90
  versionLag: boolean;
85
91
  reason: string | null;
86
92
  }
package/dist/doctor.js CHANGED
@@ -3,7 +3,8 @@ import { existsSync, readFileSync, statSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { PACKAGE_VERSION } from "./assets.js";
5
5
  import { MANIFEST_PATH, readInstalledManifest } from "./init.js";
6
- import { DEFAULT_MODELS, ROLES } from "./models.js";
6
+ import { DEFAULT_MODELS, rolesForProfile } from "./models.js";
7
+ import { compareRoutingState } from "./routing-state.js";
7
8
  import { OPERATOR_MANIFEST_FILENAME, operatorManifestState, updateOperatorManifest, } from "./operator-manifest.js";
8
9
  export function targetReportToJson(report) {
9
10
  return {
@@ -13,6 +14,9 @@ export function targetReportToJson(report) {
13
14
  pin: report.pin,
14
15
  divergence: report.divergence,
15
16
  driftFiles: report.driftFiles,
17
+ ...(report.routingComparisonGaps?.length
18
+ ? { routingComparisonGaps: report.routingComparisonGaps }
19
+ : {}),
16
20
  versionLag: report.versionLag,
17
21
  reason: report.reason,
18
22
  };
@@ -167,17 +171,31 @@ export function inspectTarget(target, operator, kitVersion) {
167
171
  if (manifestStat.kind === "error") {
168
172
  return baseReport(target, operator, "unverifiable", "directory not accessible");
169
173
  }
170
- const manifest = readInstalledManifest(target.path);
174
+ let manifest;
175
+ try {
176
+ manifest = readInstalledManifest(target.path);
177
+ }
178
+ catch {
179
+ // Strict reinstall validation must not abort inspection of other targets.
180
+ return baseReport(target, operator, "unverifiable", "manifest unreadable");
181
+ }
171
182
  if (!manifest) {
172
183
  return baseReport(target, operator, "unverifiable", "manifest unreadable");
173
184
  }
174
185
  const driftFiles = computeDriftFiles(target.path, manifest);
175
- const divergentModelRoles = ROLES.filter((role) => resolvedModel(manifest.models, role) !==
176
- resolvedModel(operator.defaults.models, role));
186
+ const divergentModelRoles = rolesForProfile(manifest.profile).filter((role) => manifest.harnesses.some((harness) => harness === "claude" || harness === "opencode") &&
187
+ resolvedModel(manifest.models, role) !==
188
+ resolvedModel(operator.defaults.models, role));
189
+ const routingComparison = compareRoutingState(manifest, operator.defaults, {
190
+ harnesses: manifest.harnesses,
191
+ profile: manifest.profile,
192
+ tiers: manifest.tiers,
193
+ });
177
194
  const divergence = {
178
195
  profile: manifest.profile !== operator.defaults.profile,
179
196
  tiers: manifest.tiers !== operator.defaults.tiers,
180
197
  models: divergentModelRoles.length > 0,
198
+ ...(routingComparison.differs ? { routing: true } : {}),
181
199
  };
182
200
  // A recorded pin suppresses version-lag only when the pin equals the
183
201
  // repo's own installed version: that is the expected, deliberate-stay
@@ -196,7 +214,10 @@ export function inspectTarget(target, operator, kitVersion) {
196
214
  if (driftFiles.length > 0) {
197
215
  status = "drift";
198
216
  }
199
- else if (divergence.profile || divergence.tiers || divergence.models) {
217
+ else if (divergence.profile ||
218
+ divergence.tiers ||
219
+ divergence.models ||
220
+ divergence.routing) {
200
221
  status = "divergent";
201
222
  }
202
223
  else if (versionLag) {
@@ -212,6 +233,9 @@ export function inspectTarget(target, operator, kitVersion) {
212
233
  pin: hasPin ? manifest.pin : null,
213
234
  divergence,
214
235
  driftFiles: driftFiles.length > 0 ? driftFiles : null,
236
+ ...(routingComparison.gaps.length > 0
237
+ ? { routingComparisonGaps: routingComparison.gaps }
238
+ : {}),
215
239
  repoProfile: manifest.profile,
216
240
  operatorProfile: operator.defaults.profile,
217
241
  repoTiers: manifest.tiers,
package/dist/index.d.ts CHANGED
@@ -8,3 +8,6 @@ export { CLASS_MODELS, DEFAULT_MODELS, DEFAULT_PROFILE, DEFAULT_TIER, MODEL_ALIA
8
8
  export type { ModelAlias, ModelClass, Profile, Role, Tier } from "./models.js";
9
9
  export type { Report } from "./writers.js";
10
10
  export { PACKAGE_VERSION } from "./assets.js";
11
+ export { defaultCodexRouting, mergeRouting, parseRouting, validateCodexCatalog, } from "./routing.js";
12
+ export type { HarnessRouting, ModelSelection } from "./routing.js";
13
+ export { composeCodexAgent } from "./codex.js";
package/dist/index.js CHANGED
@@ -3,3 +3,5 @@ export { runUninstall } from "./uninstall.js";
3
3
  export { detectHarnesses, parseHarnessList, parseHarnessOption, HARNESSES, } from "./detect.js";
4
4
  export { CLASS_MODELS, DEFAULT_MODELS, DEFAULT_PROFILE, DEFAULT_TIER, MODEL_ALIASES, MODEL_CLASSES, PROFILES, ROLES, ROLE_TIERS, TIER_DEFS, claudeModelValue, isProfile, opencodeModelValue, parseModelsSpec, parseProfile, rolesForProfile, } from "./models.js";
5
5
  export { PACKAGE_VERSION } from "./assets.js";
6
+ export { defaultCodexRouting, mergeRouting, parseRouting, validateCodexCatalog, } from "./routing.js";
7
+ export { composeCodexAgent } from "./codex.js";