orchestrator-workflow 0.25.0 → 0.26.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,253 @@
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
+ const known = [...new Set([...detected, ...installed])];
7
+ // Nothing detected and nothing previously installed: the plain-first-run
8
+ // case pre-checks `claude` as a sane default (`fallbackToClaude`'s default
9
+ // `true`). The templates-only re-run branch below opts OUT of that
10
+ // (`fallbackToClaude: false`): a repo the operator explicitly recorded as
11
+ // `harnesses: []` has no harness files by construction, so `detected` is
12
+ // always empty there too, and pre-checking `claude` on Enter would
13
+ // silently re-widen an explicit `--harness none` install -- contradicting
14
+ // README.md's and this function's own "nothing forced pre-selected" claim
15
+ // (see CHANGELOG).
16
+ const preselected = known.length > 0 ? known : fallbackToClaude ? ["claude"] : [];
17
+ const { harnesses } = await inquirer.prompt([
18
+ {
19
+ type: "checkbox",
20
+ name: "harnesses",
21
+ message: "Install adapters for which harnesses? (deselect all for templates only, no harness)",
22
+ choices: HARNESSES.map((harness) => ({
23
+ name: harness + (detected.includes(harness) ? " (detected)" : ""),
24
+ value: harness,
25
+ checked: preselected.includes(harness),
26
+ })),
27
+ // An empty selection is a supported state
28
+ // (`--harness none`, templates-only mode): it used to be rejected
29
+ // here because every install always wrote at least one harness
30
+ // adapter; there is no longer a reason to require one.
31
+ },
32
+ ]);
33
+ return harnesses;
34
+ }
35
+ export async function promptProfile(base) {
36
+ // Labels are derived from rolesForProfile so a future role addition (like
37
+ // the advisor role) shows up here automatically instead of silently
38
+ // falling out of sync with the roles the profile actually installs.
39
+ const fullRoles = rolesForProfile("full").join(", ");
40
+ const minimalRoles = rolesForProfile("minimal").join(", ");
41
+ const { profile } = await inquirer.prompt([
42
+ {
43
+ type: "list",
44
+ name: "profile",
45
+ message: "Which subagent roles should be installed?",
46
+ default: base,
47
+ choices: [
48
+ {
49
+ name: `full — ${fullRoles} (default)`,
50
+ value: "full",
51
+ },
52
+ {
53
+ name: `minimal — ${minimalRoles} only (reviewer is never optional)`,
54
+ value: "minimal",
55
+ },
56
+ ],
57
+ },
58
+ ]);
59
+ return profile;
60
+ }
61
+ export async function promptModels(base, roles) {
62
+ const models = { ...base };
63
+ for (const role of roles) {
64
+ const { choice } = await inquirer.prompt([
65
+ {
66
+ type: "list",
67
+ name: "choice",
68
+ message: `Model for the ${role} subagent:`,
69
+ default: models[role],
70
+ choices: [
71
+ ...MODEL_ALIASES.map((alias) => ({
72
+ name: alias === DEFAULT_MODELS[role] ? `${alias} (default)` : alias,
73
+ value: alias,
74
+ })),
75
+ { name: "custom model id", value: "__custom__" },
76
+ ],
77
+ },
78
+ ]);
79
+ if (choice === "__custom__") {
80
+ const { custom } = await inquirer.prompt([
81
+ {
82
+ type: "input",
83
+ name: "custom",
84
+ message: `Custom model id for ${role}:`,
85
+ validate: (value) => {
86
+ try {
87
+ assertValidModelId(value.trim());
88
+ return true;
89
+ }
90
+ catch (error) {
91
+ return error instanceof Error ? error.message : String(error);
92
+ }
93
+ },
94
+ },
95
+ ]);
96
+ models[role] = custom.trim();
97
+ }
98
+ else {
99
+ models[role] = choice;
100
+ }
101
+ }
102
+ return models;
103
+ }
104
+ /**
105
+ * Resolves everything `runInit` needs (harnesses, profile, models, tiers,
106
+ * the opencode model resolutions) from the CLI-parsed options, the target
107
+ * directory, whether the session is interactive, and the previously
108
+ * installed manifest. Used by `init`'s action today, and reusable by a
109
+ * later `apply --target` command without duplicating this logic.
110
+ *
111
+ * Every override-vs-persist rule below matches `init`'s pre-extraction
112
+ * behaviour: an explicit flag always overrides; a plain re-run (flag
113
+ * omitted) keeps the previously installed value; a fresh install with no
114
+ * prior manifest falls back to the shipped default.
115
+ */
116
+ export async function resolveInitInputs(params) {
117
+ const { detected, interactive, previous, opts, previousIsRecordedManifest } = params;
118
+ let harnesses;
119
+ if (opts.harness) {
120
+ harnesses = parseHarnessOption(opts.harness);
121
+ }
122
+ else if (previousIsRecordedManifest &&
123
+ previous &&
124
+ previous.harnessesRecordedEmpty) {
125
+ // A recorded previous manifest with harnesses: [] was an explicit
126
+ // --harness none (templates-only) install. A plain non-interactive
127
+ // re-run (no --harness flag) must stay templates-only rather than
128
+ // falling back to filesystem detection and silently installing a
129
+ // harness (e.g. claude) the operator never asked for; adding one back
130
+ // requires an explicit --harness on this run, the same
131
+ // override-vs-persist rule --profile/--models/--tiers already use,
132
+ // just applied to the "no harnesses" case specifically.
133
+ // `harnessesRecordedEmpty` gates this on the raw JSON's `harnesses`
134
+ // field having actually been an empty array: a missing/malformed field,
135
+ // or an array whose every entry failed the known-harness filter (e.g.
136
+ // ["cursor"], all-unknown names), also sanitizes to
137
+ // `harnesses.length === 0` (readInstalledManifest in init.ts) but must
138
+ // fall through to detection below instead, the same as any other
139
+ // damaged manifest (see CHANGELOG).
140
+ //
141
+ // An interactive re-run is different: stickiness only protects a
142
+ // non-interactive call (`--yes`, or any other flow with no prompt) from
143
+ // silently widening an explicit "none" back out; an interactive session
144
+ // can already ask and let the operator decide, so it still prompts here
145
+ // instead of skipping straight to templates-only. `installed` is passed
146
+ // as `[]` (not the recorded `previous.harnesses`) so nothing is
147
+ // pre-checked, unlike the "else" branch below's normal re-run prompt --
148
+ // the previous run explicitly asked for none, so the checkbox starts
149
+ // from that state, only `detected` entries pre-checked. `fallbackToClaude:
150
+ // false` closes the same gap for the case where nothing is detected
151
+ // either: without it, `promptHarnesses` would pre-check `claude` on its
152
+ // own "nothing known" fallback, re-widening the install on a bare Enter.
153
+ harnesses = interactive ? await promptHarnesses(detected, [], false) : [];
154
+ }
155
+ else {
156
+ const installed = previous?.harnesses ?? [];
157
+ const fallback = [...new Set([...detected, ...installed])];
158
+ harnesses = interactive
159
+ ? await promptHarnesses(detected, installed)
160
+ : fallback.length > 0
161
+ ? fallback
162
+ : ["claude"];
163
+ }
164
+ // Explicit --profile always overrides; a plain re-run keeps the
165
+ // profile from the previous install (same override-vs-persist rule as
166
+ // --harness/--models above); a fresh install with no prior manifest
167
+ // defaults to full.
168
+ let profile;
169
+ if (opts.profile) {
170
+ profile = parseProfile(opts.profile);
171
+ }
172
+ else {
173
+ profile = previous?.profile ?? DEFAULT_PROFILE;
174
+ if (interactive)
175
+ profile = await promptProfile(profile);
176
+ }
177
+ let models = {
178
+ ...DEFAULT_MODELS,
179
+ ...(previous?.models ?? {}),
180
+ };
181
+ if (opts.models)
182
+ models = parseModelsSpec(opts.models, models);
183
+ if (interactive && !opts.models)
184
+ models = await promptModels(models, rolesForProfile(profile));
185
+ // Explicit --tiers/--no-tiers always override; a plain re-run (neither
186
+ // flag passed) keeps whatever the previous install had (default false
187
+ // for a fresh install), same override-vs-persist rule as
188
+ // --profile/--models above. commander's negatable-option pairing
189
+ // (--tiers / --no-tiers declared under the same "tiers" option name)
190
+ // resolves opts.tiers to `true` when --tiers is passed, `false` when
191
+ // --no-tiers is passed, and `undefined` when neither is passed; the
192
+ // CLI re-run test verifies this against the installed commander
193
+ // version rather than assuming it. No interactive prompt: tiers is
194
+ // opt-in/off via the flags only.
195
+ const tiers = opts.tiers ?? previous?.tiers ?? false;
196
+ // Resolve opencode model aliases against the live catalog when the opencode
197
+ // harness is selected. The shell-out stays reachable only from this
198
+ // resolution step, keeping runInit pure.
199
+ let opencodeModels;
200
+ let opencodeClassModels;
201
+ const warnings = [];
202
+ if (harnesses.includes("opencode")) {
203
+ const catalog = loadOpencodeCatalog();
204
+ const { resolved, warnings: modelWarnings } = resolveOpencodeModels(models, {
205
+ catalog,
206
+ explicitProvider: opts.opencodeProvider,
207
+ });
208
+ opencodeModels = resolved;
209
+ for (const w of modelWarnings) {
210
+ warnings.push(`Warning: ${w}`);
211
+ }
212
+ if (tiers) {
213
+ const providerResult = detectProvider({
214
+ catalog,
215
+ explicit: opts.opencodeProvider,
216
+ });
217
+ opencodeClassModels = {};
218
+ for (const modelClass of MODEL_CLASSES) {
219
+ const alias = CLASS_MODELS[modelClass];
220
+ const resolvedModel = providerResult.provider
221
+ ? resolveAlias(providerResult.provider, alias, catalog)
222
+ : undefined;
223
+ opencodeClassModels[modelClass] = resolvedModel;
224
+ if (resolvedModel !== undefined)
225
+ continue;
226
+ // One warning per unresolved model class: without it, every
227
+ // effort-tier variant keyed to this class is silently skipped
228
+ // (init.ts skips the variant write entirely when the class
229
+ // model is unresolved), with nothing on stderr saying why.
230
+ const reason = providerResult.provider
231
+ ? `provider "${providerResult.provider}" has no "${alias}" model in the catalog`
232
+ : providerResult.ambiguous
233
+ ? `multiple providers offer Claude models in the live catalog; cannot auto-detect`
234
+ : `no provider offering Claude models found in the live catalog`;
235
+ // States the real effect (no variant file at all, not just a
236
+ // missing model: line, since init.ts skips the write entirely
237
+ // when the class never resolves) and the real scope (opencode
238
+ // only: Claude Code variants resolve model: from a plain alias
239
+ // and need no live catalog lookup, so they are unaffected).
240
+ 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).`);
241
+ }
242
+ }
243
+ }
244
+ return {
245
+ harnesses,
246
+ profile,
247
+ models,
248
+ tiers,
249
+ opencodeModels,
250
+ opencodeClassModels,
251
+ warnings,
252
+ };
253
+ }