orchestrator-workflow 0.25.0 → 0.27.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.
@@ -0,0 +1,29 @@
1
+ import type { ResolveInitInputsParams } from "./cli-inputs.js";
2
+ import type { Harness } from "./detect.js";
3
+ import type { Manifest } from "./init.js";
4
+ /** The subset of `apply`'s commander options that feed input resolution. */
5
+ export interface ApplyResolutionOptions {
6
+ harness?: string;
7
+ models?: string;
8
+ profile?: string;
9
+ opencodeProvider?: string;
10
+ tiers?: boolean;
11
+ }
12
+ /**
13
+ * Builds `apply`'s own `resolveInitInputs` params, pinning the sticky-branch
14
+ * wiring so a future edit to the CLI action's call site cannot silently
15
+ * widen a deliberately templates-only target: `stickyPreChecked` is always
16
+ * a hardcoded `[]` here, never `chosenHarnesses` or `detected` (see
17
+ * `ResolveInitInputsParams.stickyPreChecked`'s doc comment for why).
18
+ * Kept in its own side-effect-free module (rather than inline in `cli.ts`,
19
+ * which runs `program.parseAsync(process.argv)` on import) so it can be
20
+ * unit-tested directly (`test/cli-apply.test.ts`) instead of only
21
+ * indirectly exercised through a spawned CLI process, and so a reversion
22
+ * here fails a targeted test instead of only the much larger
23
+ * interactive-prompt suite (agent-tasks fe834823, fix round 3, review
24
+ * finding 1). `stickyAnnotateDetected` is a fresh `detectHarnesses(targetDir)`
25
+ * call, independent of `chosenHarnesses`: it only feeds the checkbox's
26
+ * " (detected)" label (`ResolveInitInputsParams.stickyAnnotateDetected`),
27
+ * never the pre-check itself.
28
+ */
29
+ export declare function buildApplyInitInputs(targetDir: string, chosenHarnesses: Harness[], previous: Manifest, interactive: boolean, opts: ApplyResolutionOptions, previousIsRecordedManifest: boolean): ResolveInitInputsParams;
@@ -0,0 +1,29 @@
1
+ import { detectHarnesses } from "./detect.js";
2
+ /**
3
+ * Builds `apply`'s own `resolveInitInputs` params, pinning the sticky-branch
4
+ * wiring so a future edit to the CLI action's call site cannot silently
5
+ * widen a deliberately templates-only target: `stickyPreChecked` is always
6
+ * a hardcoded `[]` here, never `chosenHarnesses` or `detected` (see
7
+ * `ResolveInitInputsParams.stickyPreChecked`'s doc comment for why).
8
+ * Kept in its own side-effect-free module (rather than inline in `cli.ts`,
9
+ * which runs `program.parseAsync(process.argv)` on import) so it can be
10
+ * unit-tested directly (`test/cli-apply.test.ts`) instead of only
11
+ * indirectly exercised through a spawned CLI process, and so a reversion
12
+ * here fails a targeted test instead of only the much larger
13
+ * interactive-prompt suite (agent-tasks fe834823, fix round 3, review
14
+ * finding 1). `stickyAnnotateDetected` is a fresh `detectHarnesses(targetDir)`
15
+ * call, independent of `chosenHarnesses`: it only feeds the checkbox's
16
+ * " (detected)" label (`ResolveInitInputsParams.stickyAnnotateDetected`),
17
+ * never the pre-check itself.
18
+ */
19
+ export function buildApplyInitInputs(targetDir, chosenHarnesses, previous, interactive, opts, previousIsRecordedManifest) {
20
+ return {
21
+ detected: chosenHarnesses,
22
+ stickyPreChecked: [],
23
+ stickyAnnotateDetected: detectHarnesses(targetDir),
24
+ interactive,
25
+ previous,
26
+ opts,
27
+ previousIsRecordedManifest,
28
+ };
29
+ }
@@ -0,0 +1,122 @@
1
+ import type { Harness } from "./detect.js";
2
+ import type { Manifest } from "./init.js";
3
+ import type { ModelClass, Profile, Role } from "./models.js";
4
+ export declare function promptHarnesses(detected: Harness[], installed: Harness[], fallbackToClaude?: boolean, annotateDetected?: Harness[]): Promise<Harness[]>;
5
+ export declare function promptProfile(base: Profile): Promise<Profile>;
6
+ export declare function promptModels(base: Record<Role, string>, roles: Role[]): Promise<Record<Role, string>>;
7
+ /** The subset of `init`'s commander options that feed input resolution. */
8
+ export interface InitResolutionOptions {
9
+ harness?: string;
10
+ models?: string;
11
+ profile?: string;
12
+ opencodeProvider?: string;
13
+ tiers?: boolean;
14
+ }
15
+ export interface ResolveInitInputsParams {
16
+ /** Result of `detectHarnesses(targetDir)`; passed in so the caller can
17
+ * print it before resolution starts, matching `init`'s existing output
18
+ * order, without this function reading the filesystem a second time. */
19
+ detected: Harness[];
20
+ interactive: boolean;
21
+ /** The previously installed manifest, if any (`readInstalledManifest`). */
22
+ previous: Manifest | undefined;
23
+ opts: InitResolutionOptions;
24
+ /**
25
+ * True when `previous` is backed by the target's own actually-recorded
26
+ * manifest, as opposed to a wholly synthetic object with no repo
27
+ * manifest behind it at all. `init` sets this whenever it has a
28
+ * `previous` (`readInstalledManifest(targetDir)` returned one). `apply`
29
+ * always hands `resolveInitInputs` a non-`undefined` `previous` (its
30
+ * `buildApplyPrevious` synthesizes one even for a target with no
31
+ * manifest of its own, to carry the operator-defaults floor), so it sets
32
+ * this flag from whether the target actually has a repo manifest
33
+ * (`Boolean(repoManifest)`), not from whether `previous` itself is
34
+ * defined. Only consulted for the harnesses-stickiness rule below,
35
+ * together with `previous.harnessesRecordedEmpty` (which `apply`'s
36
+ * `buildApplyPrevious` carries straight through from that repo
37
+ * manifest): a real recorded `harnesses: []` means a deliberate
38
+ * `--harness none` install, and a plain re-run (init or apply, no
39
+ * `--harness` flag) must not silently widen it via detection or the
40
+ * operator manifest's default harnesses. This flag alone does not
41
+ * distinguish a deliberate `harnesses: []` from a damaged/legacy
42
+ * manifest whose raw `harnesses` field was missing, malformed, or an
43
+ * array whose every entry failed the known-harness filter (all of which
44
+ * also sanitize to `harnesses: []`) -- that distinction is
45
+ * `harnessesRecordedEmpty`'s job; both must hold for the stickiness gate
46
+ * to fire, so a target with no repo manifest at all, or one with a
47
+ * missing/malformed `harnesses` field, still falls through to the
48
+ * fallback chain below unchanged.
49
+ */
50
+ previousIsRecordedManifest?: boolean;
51
+ /**
52
+ * The entries pre-checked in the interactive prompt when the target
53
+ * recorded `harnesses: []` (the harnesses-stickiness gate's branch,
54
+ * gated on `previousIsRecordedManifest && previous.
55
+ * harnessesRecordedEmpty`). Defaults to `detected` when omitted, which
56
+ * is `init`'s own call site's behaviour (it does not pass this field at
57
+ * all): a fresh interactive re-run on a templates-only `init` target
58
+ * still pre-checks whatever `detectHarnesses(targetDir)` finds on disk,
59
+ * unchanged from before this field existed. `apply`'s call site passes
60
+ * `[]` instead: the operator's recorded `harnesses: []` is the intent
61
+ * that matters, not a `.claude/`-style directory the harness itself
62
+ * left on disk, which is a weak signal and must not re-widen a
63
+ * deliberate `--harness none` install just because a bare Enter is
64
+ * pressed (agent-tasks fe834823). Only the sticky branch reads this
65
+ * field; the normal (non-recorded-empty) branch still prompts from
66
+ * `detected` unchanged, matching `apply`'s existing pre-check behaviour
67
+ * on a normal target.
68
+ */
69
+ stickyPreChecked?: Harness[];
70
+ /**
71
+ * The sticky branch's own `promptHarnesses` " (detected)" label source,
72
+ * independent of `stickyPreChecked` (which drives what is actually
73
+ * pre-checked, not what is merely labelled). Defaults to
74
+ * `stickyPreChecked ?? detected` when omitted, matching `promptHarnesses`'
75
+ * own default and `init`'s call site (which omits both fields, so its
76
+ * sticky prompt still labels from real on-disk detection, unchanged).
77
+ * `apply`'s call site passes `[]` for `stickyPreChecked` (nothing is
78
+ * pre-checked; see that field's doc comment) but still wants the
79
+ * operator to see which harness is actually on disk, so it passes a
80
+ * fresh `detectHarnesses(targetDir)` call here instead: labelling is a
81
+ * hint, not an intent signal, so it is safe to annotate what the
82
+ * pre-check itself must not read (agent-tasks fe834823, fix round 3).
83
+ */
84
+ stickyAnnotateDetected?: Harness[];
85
+ }
86
+ export interface ResolvedInitInputs {
87
+ harnesses: Harness[];
88
+ profile: Profile;
89
+ models: Record<Role, string>;
90
+ tiers: boolean;
91
+ opencodeModels?: Record<Role, string | undefined>;
92
+ opencodeClassModels?: Record<ModelClass, string | undefined>;
93
+ /**
94
+ * Warning lines to print, in order, exactly as `init` printed them to
95
+ * stderr before this extraction (each written as `${line}\n`). Returned
96
+ * as data rather than printed here so the caller decides where/whether to
97
+ * print them.
98
+ */
99
+ warnings: string[];
100
+ }
101
+ /**
102
+ * Resolves everything `runInit` needs (harnesses, profile, models, tiers,
103
+ * the opencode model resolutions) from the CLI-parsed options, the target
104
+ * directory, whether the session is interactive, and the previously
105
+ * installed manifest. Used by `init`'s action today, and reusable by a
106
+ * later `apply --target` command without duplicating this logic.
107
+ *
108
+ * Every override-vs-persist rule below matches `init`'s pre-extraction
109
+ * behaviour: an explicit flag always overrides; a plain re-run (flag
110
+ * omitted) keeps the previously installed value; a fresh install with no
111
+ * prior manifest falls back to the shipped default.
112
+ *
113
+ * `params.detected` doubles as the fallback-chain input the non-sticky
114
+ * "else" branch below prompts and falls back from, and (for `init`'s call
115
+ * site only, since it omits `stickyPreChecked`) the harnesses-stickiness
116
+ * branch's own pre-check. `apply`'s call site's `detected` is
117
+ * `resolveApplyHarnesses`'s fallback-chain result (never empty), which is
118
+ * not what the sticky branch should pre-check (see
119
+ * `ResolveInitInputsParams.stickyPreChecked`'s doc comment), so it passes
120
+ * that field separately (`[]`) for the sticky branch to read instead.
121
+ */
122
+ export declare function resolveInitInputs(params: ResolveInitInputsParams): Promise<ResolvedInitInputs>;
@@ -0,0 +1,301 @@
1
+ import inquirer from "inquirer";
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";
4
+ import { detectProvider, loadOpencodeCatalog, resolveAlias, resolveOpencodeModels, } from "./opencode.js";
5
+ export async function promptHarnesses(detected, installed, fallbackToClaude = true,
6
+ // 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 every call site that omits this parameter keeps annotating exactly
9
+ // what it pre-checks from, unchanged. `apply`'s sticky-branch call site
10
+ // is the one caller that passes a different value here: it pre-checks
11
+ // nothing (`stickyPreChecked ?? detected` is `[]`) but still wants the
12
+ // operator to see which harness is actually on disk, so it annotates
13
+ // from a fresh `detectHarnesses(targetDir)` call instead
14
+ // (agent-tasks fe834823, fix round 3).
15
+ annotateDetected = detected) {
16
+ const known = [...new Set([...detected, ...installed])];
17
+ // Nothing detected and nothing previously installed: the plain-first-run
18
+ // case pre-checks `claude` as a sane default (`fallbackToClaude`'s default
19
+ // `true`). The templates-only re-run branch below opts OUT of that
20
+ // (`fallbackToClaude: false`): a repo the operator explicitly recorded as
21
+ // `harnesses: []` has no harness files by construction, so `detected` is
22
+ // always empty there too, and pre-checking `claude` on Enter would
23
+ // silently re-widen an explicit `--harness none` install -- contradicting
24
+ // README.md's and this function's own "nothing forced pre-selected" claim
25
+ // (see CHANGELOG). That "`detected` is always empty there too" premise
26
+ // holds for `init`'s own call site, where `detected` is
27
+ // `detectHarnesses(targetDir)` on a target with no harness files by
28
+ // construction. `apply`'s call site (`resolveInitInputs`'s interactive
29
+ // branch just below) does not call this function with its own
30
+ // `resolveApplyHarnesses` result at all for this branch: that result is
31
+ // never empty (it falls back through the operator default, then
32
+ // detection, then `["claude"]`), so it passes `stickyPreChecked: []`
33
+ // here instead, regardless of what is actually on disk -- the operator's
34
+ // recorded `harnesses: []` is the intent that matters, not a harness
35
+ // config a harness itself left behind (agent-tasks fe834823; the
36
+ // residual noted in docs/okf/log.md's 2026-08-31 entry is closed).
37
+ const preselected = known.length > 0 ? known : fallbackToClaude ? ["claude"] : [];
38
+ const { harnesses } = await inquirer.prompt([
39
+ {
40
+ type: "checkbox",
41
+ name: "harnesses",
42
+ message: "Install adapters for which harnesses? (deselect all for templates only, no harness)",
43
+ choices: HARNESSES.map((harness) => ({
44
+ name: harness + (annotateDetected.includes(harness) ? " (detected)" : ""),
45
+ value: harness,
46
+ checked: preselected.includes(harness),
47
+ })),
48
+ // An empty selection is a supported state
49
+ // (`--harness none`, templates-only mode): it used to be rejected
50
+ // here because every install always wrote at least one harness
51
+ // adapter; there is no longer a reason to require one.
52
+ },
53
+ ]);
54
+ return harnesses;
55
+ }
56
+ export async function promptProfile(base) {
57
+ // Labels are derived from rolesForProfile so a future role addition (like
58
+ // the advisor role) shows up here automatically instead of silently
59
+ // falling out of sync with the roles the profile actually installs.
60
+ const fullRoles = rolesForProfile("full").join(", ");
61
+ const minimalRoles = rolesForProfile("minimal").join(", ");
62
+ const { profile } = await inquirer.prompt([
63
+ {
64
+ type: "list",
65
+ name: "profile",
66
+ message: "Which subagent roles should be installed?",
67
+ default: base,
68
+ choices: [
69
+ {
70
+ name: `full — ${fullRoles} (default)`,
71
+ value: "full",
72
+ },
73
+ {
74
+ name: `minimal — ${minimalRoles} only (reviewer is never optional)`,
75
+ value: "minimal",
76
+ },
77
+ ],
78
+ },
79
+ ]);
80
+ return profile;
81
+ }
82
+ export async function promptModels(base, roles) {
83
+ const models = { ...base };
84
+ for (const role of roles) {
85
+ const { choice } = await inquirer.prompt([
86
+ {
87
+ type: "list",
88
+ name: "choice",
89
+ message: `Model for the ${role} subagent:`,
90
+ default: models[role],
91
+ choices: [
92
+ ...MODEL_ALIASES.map((alias) => ({
93
+ name: alias === DEFAULT_MODELS[role] ? `${alias} (default)` : alias,
94
+ value: alias,
95
+ })),
96
+ { name: "custom model id", value: "__custom__" },
97
+ ],
98
+ },
99
+ ]);
100
+ if (choice === "__custom__") {
101
+ const { custom } = await inquirer.prompt([
102
+ {
103
+ type: "input",
104
+ name: "custom",
105
+ message: `Custom model id for ${role}:`,
106
+ validate: (value) => {
107
+ try {
108
+ assertValidModelId(value.trim());
109
+ return true;
110
+ }
111
+ catch (error) {
112
+ return error instanceof Error ? error.message : String(error);
113
+ }
114
+ },
115
+ },
116
+ ]);
117
+ models[role] = custom.trim();
118
+ }
119
+ else {
120
+ models[role] = choice;
121
+ }
122
+ }
123
+ return models;
124
+ }
125
+ /**
126
+ * Resolves everything `runInit` needs (harnesses, profile, models, tiers,
127
+ * the opencode model resolutions) from the CLI-parsed options, the target
128
+ * directory, whether the session is interactive, and the previously
129
+ * installed manifest. Used by `init`'s action today, and reusable by a
130
+ * later `apply --target` command without duplicating this logic.
131
+ *
132
+ * Every override-vs-persist rule below matches `init`'s pre-extraction
133
+ * behaviour: an explicit flag always overrides; a plain re-run (flag
134
+ * omitted) keeps the previously installed value; a fresh install with no
135
+ * prior manifest falls back to the shipped default.
136
+ *
137
+ * `params.detected` doubles as the fallback-chain input the non-sticky
138
+ * "else" branch below prompts and falls back from, and (for `init`'s call
139
+ * site only, since it omits `stickyPreChecked`) the harnesses-stickiness
140
+ * branch's own pre-check. `apply`'s call site's `detected` is
141
+ * `resolveApplyHarnesses`'s fallback-chain result (never empty), which is
142
+ * not what the sticky branch should pre-check (see
143
+ * `ResolveInitInputsParams.stickyPreChecked`'s doc comment), so it passes
144
+ * that field separately (`[]`) for the sticky branch to read instead.
145
+ */
146
+ export async function resolveInitInputs(params) {
147
+ const { detected, interactive, previous, opts, previousIsRecordedManifest, stickyPreChecked, stickyAnnotateDetected, } = params;
148
+ let harnesses;
149
+ if (opts.harness) {
150
+ harnesses = parseHarnessOption(opts.harness);
151
+ }
152
+ else if (previousIsRecordedManifest &&
153
+ previous &&
154
+ previous.harnessesRecordedEmpty) {
155
+ // A recorded previous manifest with harnesses: [] was an explicit
156
+ // --harness none (templates-only) install. A plain non-interactive
157
+ // re-run (no --harness flag) must stay templates-only rather than
158
+ // falling back to filesystem detection and silently installing a
159
+ // harness (e.g. claude) the operator never asked for; adding one back
160
+ // requires an explicit --harness on this run, the same
161
+ // override-vs-persist rule --profile/--models/--tiers already use,
162
+ // just applied to the "no harnesses" case specifically.
163
+ // `harnessesRecordedEmpty` gates this on the raw JSON's `harnesses`
164
+ // field having actually been an empty array: a missing/malformed field,
165
+ // or an array whose every entry failed the known-harness filter (e.g.
166
+ // ["cursor"], all-unknown names), also sanitizes to
167
+ // `harnesses.length === 0` (readInstalledManifest in init.ts) but must
168
+ // fall through to detection below instead, the same as any other
169
+ // damaged manifest (see CHANGELOG).
170
+ //
171
+ // An interactive re-run is different: stickiness only protects a
172
+ // non-interactive call (`--yes`, or any other flow with no prompt) from
173
+ // silently widening an explicit "none" back out; an interactive session
174
+ // can already ask and let the operator decide, so it still prompts here
175
+ // instead of skipping straight to templates-only. `installed` is passed
176
+ // as `[]` (not the recorded `previous.harnesses`) so nothing is
177
+ // pre-checked from the previous install, unlike the "else" branch
178
+ // below's normal re-run prompt.
179
+ // `stickyPreChecked ?? detected` is used here rather than plain
180
+ // `detected`: `init` does not pass `stickyPreChecked` at all, so its
181
+ // own call site keeps pre-checking whatever `detectHarnesses(targetDir)`
182
+ // finds on disk, unchanged from before this field existed. `apply`
183
+ // passes `stickyPreChecked: []`: the operator's recorded
184
+ // `harnesses: []` is the intent that matters here, not a harness
185
+ // config left on disk (a weak signal `apply`'s own `detected` --
186
+ // `resolveApplyHarnesses`'s fallback-chain result, never empty --
187
+ // cannot represent either), so the interactive prompt on a templates-
188
+ // only `apply` target starts with nothing pre-checked at all
189
+ // (agent-tasks fe834823). `fallbackToClaude: false` closes the same
190
+ // gap for the case where nothing is pre-checked either: without it,
191
+ // `promptHarnesses` would pre-check `claude` on its own "nothing
192
+ // known" fallback, re-widening the install on a bare Enter.
193
+ // `stickyAnnotateDetected` is passed through as the fourth argument so
194
+ // the checkbox's " (detected)" label can still point at what is
195
+ // actually on disk even though nothing is pre-checked from it; when
196
+ // omitted (as `init`'s call site does), `promptHarnesses` defaults it
197
+ // to its own first argument, i.e. `stickyPreChecked ?? detected`,
198
+ // matching pre-round-3 labelling behaviour exactly.
199
+ harnesses = interactive
200
+ ? await promptHarnesses(stickyPreChecked ?? detected, [], false, stickyAnnotateDetected)
201
+ : [];
202
+ }
203
+ else {
204
+ const installed = previous?.harnesses ?? [];
205
+ const fallback = [...new Set([...detected, ...installed])];
206
+ harnesses = interactive
207
+ ? await promptHarnesses(detected, installed)
208
+ : fallback.length > 0
209
+ ? fallback
210
+ : ["claude"];
211
+ }
212
+ // Explicit --profile always overrides; a plain re-run keeps the
213
+ // profile from the previous install (same override-vs-persist rule as
214
+ // --harness/--models above); a fresh install with no prior manifest
215
+ // defaults to full.
216
+ let profile;
217
+ if (opts.profile) {
218
+ profile = parseProfile(opts.profile);
219
+ }
220
+ else {
221
+ profile = previous?.profile ?? DEFAULT_PROFILE;
222
+ if (interactive)
223
+ profile = await promptProfile(profile);
224
+ }
225
+ let models = {
226
+ ...DEFAULT_MODELS,
227
+ ...(previous?.models ?? {}),
228
+ };
229
+ if (opts.models)
230
+ models = parseModelsSpec(opts.models, models);
231
+ if (interactive && !opts.models)
232
+ models = await promptModels(models, rolesForProfile(profile));
233
+ // Explicit --tiers/--no-tiers always override; a plain re-run (neither
234
+ // flag passed) keeps whatever the previous install had (default false
235
+ // for a fresh install), same override-vs-persist rule as
236
+ // --profile/--models above. commander's negatable-option pairing
237
+ // (--tiers / --no-tiers declared under the same "tiers" option name)
238
+ // resolves opts.tiers to `true` when --tiers is passed, `false` when
239
+ // --no-tiers is passed, and `undefined` when neither is passed; the
240
+ // CLI re-run test verifies this against the installed commander
241
+ // version rather than assuming it. No interactive prompt: tiers is
242
+ // opt-in/off via the flags only.
243
+ const tiers = opts.tiers ?? previous?.tiers ?? false;
244
+ // Resolve opencode model aliases against the live catalog when the opencode
245
+ // harness is selected. The shell-out stays reachable only from this
246
+ // resolution step, keeping runInit pure.
247
+ let opencodeModels;
248
+ let opencodeClassModels;
249
+ const warnings = [];
250
+ if (harnesses.includes("opencode")) {
251
+ const catalog = loadOpencodeCatalog();
252
+ const { resolved, warnings: modelWarnings } = resolveOpencodeModels(models, {
253
+ catalog,
254
+ explicitProvider: opts.opencodeProvider,
255
+ });
256
+ opencodeModels = resolved;
257
+ for (const w of modelWarnings) {
258
+ warnings.push(`Warning: ${w}`);
259
+ }
260
+ if (tiers) {
261
+ const providerResult = detectProvider({
262
+ catalog,
263
+ explicit: opts.opencodeProvider,
264
+ });
265
+ opencodeClassModels = {};
266
+ for (const modelClass of MODEL_CLASSES) {
267
+ const alias = CLASS_MODELS[modelClass];
268
+ const resolvedModel = providerResult.provider
269
+ ? resolveAlias(providerResult.provider, alias, catalog)
270
+ : undefined;
271
+ opencodeClassModels[modelClass] = resolvedModel;
272
+ if (resolvedModel !== undefined)
273
+ continue;
274
+ // One warning per unresolved model class: without it, every
275
+ // effort-tier variant keyed to this class is silently skipped
276
+ // (init.ts skips the variant write entirely when the class
277
+ // model is unresolved), with nothing on stderr saying why.
278
+ const reason = providerResult.provider
279
+ ? `provider "${providerResult.provider}" has no "${alias}" model in the catalog`
280
+ : providerResult.ambiguous
281
+ ? `multiple providers offer Claude models in the live catalog; cannot auto-detect`
282
+ : `no provider offering Claude models found in the live catalog`;
283
+ // States the real effect (no variant file at all, not just a
284
+ // missing model: line, since init.ts skips the write entirely
285
+ // when the class never resolves) and the real scope (opencode
286
+ // only: Claude Code variants resolve model: from a plain alias
287
+ // and need no live catalog lookup, so they are unaffected).
288
+ warnings.push(`Warning: Tier model class "${modelClass}" (alias "${alias}") could not be resolved to an opencode model id (${reason}); no opencode effort-tier variant files will be rendered for this class (Claude Code variants are unaffected).`);
289
+ }
290
+ }
291
+ }
292
+ return {
293
+ harnesses,
294
+ profile,
295
+ models,
296
+ tiers,
297
+ opencodeModels,
298
+ opencodeClassModels,
299
+ warnings,
300
+ };
301
+ }