severally-mcp 2.3.1 → 2.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/README.ja.md +52 -17
  2. package/README.md +52 -16
  3. package/bin/severally-mcp.mjs +14 -2
  4. package/docs/install.ja.md +48 -20
  5. package/docs/install.md +48 -21
  6. package/package.json +15 -3
  7. package/plugins/severally/.claude-plugin/plugin.json +1 -1
  8. package/plugins/severally/.codex-plugin/plugin.json +1 -1
  9. package/plugins/severally/dist/severally-mcp.mjs +6573 -5978
  10. package/plugins/severally/skills/_template/reference.md.tmpl +2 -2
  11. package/plugins/severally/skills/antigravity/severally/reference.md +2 -2
  12. package/plugins/severally/skills/claude/severally/reference.md +2 -2
  13. package/plugins/severally/skills/codex/severally/reference.md +2 -2
  14. package/plugins/severally/skills/opencode/severally/reference.md +2 -2
  15. package/scripts/build.mjs +76 -68
  16. package/scripts/doctor.mjs +66 -20
  17. package/scripts/init-config.mjs +8 -6
  18. package/scripts/install.mjs +327 -90
  19. package/scripts/live-check.mjs +62 -23
  20. package/scripts/live-mcp-check.mjs +52 -16
  21. package/scripts/test.mjs +10 -4
  22. package/src/adapters/antigravity-sandbox.mjs +62 -28
  23. package/src/adapters/antigravity.mjs +111 -30
  24. package/src/adapters/claude-code.mjs +121 -28
  25. package/src/adapters/codex.mjs +125 -33
  26. package/src/adapters/opencode-sandbox.mjs +59 -22
  27. package/src/adapters/opencode.mjs +144 -30
  28. package/src/brief.mjs +148 -47
  29. package/src/consultants.mjs +255 -48
  30. package/src/cross-spawn-escape.d.ts +7 -0
  31. package/src/export.mjs +99 -18
  32. package/src/expose-paths.mjs +310 -50
  33. package/src/failures.mjs +31 -14
  34. package/src/jobs.mjs +863 -249
  35. package/src/jsonc.mjs +11 -3
  36. package/src/parse-result.mjs +172 -40
  37. package/src/platform.mjs +56 -8
  38. package/src/policy.mjs +245 -52
  39. package/src/redact.mjs +59 -16
  40. package/src/result-schema.mjs +13 -0
  41. package/src/run.mjs +225 -30
  42. package/src/schema.mjs +132 -27
  43. package/src/server.mjs +178 -81
  44. package/src/status.mjs +94 -13
  45. package/src/store.mjs +418 -35
@@ -4,9 +4,39 @@
4
4
  // schema.mjs and jobs.mjs ask it questions -- so the rules can be tested
5
5
  // without a server.
6
6
 
7
+ /** @typedef {'codex' | 'claude-code' | 'antigravity' | 'opencode'} CliId */
8
+
9
+ /**
10
+ * One operator-named pairing of a CLI and a model, as built from the config.
11
+ * @typedef {object} Consultant
12
+ * @property {string} name
13
+ * @property {CliId} cli
14
+ * @property {string} model
15
+ * @property {string | null} effort
16
+ * @property {number | null} timeoutMs
17
+ * @property {number | null} maxBudgetUsd
18
+ * @property {string | null} vendor
19
+ * @property {boolean} enabled
20
+ * @property {string | null} note
21
+ */
22
+
23
+ /**
24
+ * What buildConsultants makes of a config file: the consultants and ranking,
25
+ * and every mistake found (see buildConsultants for the problem groups).
26
+ * @typedef {object} BuiltConsultants
27
+ * @property {readonly Consultant[]} consultants
28
+ * @property {readonly string[]} ranking
29
+ * @property {string[]} problems
30
+ * @property {string[]} global
31
+ * @property {Record<string, string[]>} broken
32
+ * @property {Record<string, string[]>} brokenClis
33
+ */
34
+
35
+ /** @type {readonly CliId[]} */
7
36
  export const CLI_IDS = Object.freeze(['codex', 'claude-code', 'antigravity', 'opencode']);
8
37
 
9
38
  // The model each CLI's consultant runs when the operator defines none.
39
+ /** @type {Readonly<Record<CliId, string>>} */
10
40
  export const DEFAULT_MODELS = Object.freeze({
11
41
  codex: 'gpt-6-astra',
12
42
  'claude-code': 'claude-opus-5-5',
@@ -16,17 +46,26 @@ export const DEFAULT_MODELS = Object.freeze({
16
46
 
17
47
  // The lineage a CLI's own host model belongs to, for a caller that declared no
18
48
  // model. OpenCode runs anyone's models, so it has none.
49
+ /** @type {Readonly<Record<CliId, string | null>>} */
19
50
  export const CLI_VENDORS = Object.freeze({ codex: 'openai', 'claude-code': 'anthropic', antigravity: 'google', opencode: null });
20
51
 
21
52
  // Null-prototype tables, so a request cannot reach Object.prototype through
22
53
  // them: 'constructor' must be no match, not a function.
54
+ /**
55
+ * @param {Record<string, string>} entries
56
+ * @returns {Readonly<Record<string, string>>}
57
+ */
23
58
  const table = (entries) => Object.freeze(Object.assign(Object.create(null), entries));
24
59
 
25
60
  // Names that mean "the consultants on this CLI". gpt/claude/gemini/glm are
26
61
  // deliberately absent: they name model families, and a Claude model hosted on
27
62
  // OpenCode should answer to "claude" too.
28
63
  export const CLI_ALIASES = table({
29
- codex: 'codex', 'claude-code': 'claude-code', antigravity: 'antigravity', agy: 'antigravity', opencode: 'opencode',
64
+ codex: 'codex',
65
+ 'claude-code': 'claude-code',
66
+ antigravity: 'antigravity',
67
+ agy: 'antigravity',
68
+ opencode: 'opencode',
30
69
  });
31
70
 
32
71
  // Names that mean "that vendor's consultants, on any CLI".
@@ -34,13 +73,21 @@ export const VENDOR_WORDS = table({ openai: 'openai', chatgpt: 'openai', anthrop
34
73
 
35
74
  // The lineage of a model, read off the first word of its stem.
36
75
  const STEM_VENDORS = table({
37
- gpt: 'openai', claude: 'anthropic', gemini: 'google', glm: 'zai', deepseek: 'deepseek', nemotron: 'nvidia',
76
+ gpt: 'openai',
77
+ claude: 'anthropic',
78
+ gemini: 'google',
79
+ glm: 'zai',
80
+ deepseek: 'deepseek',
81
+ nemotron: 'nvidia',
38
82
  });
39
83
 
40
84
  // Built-in "higher first" orders, each used only inside one model family: an
41
85
  // "ultra" Nemotron is not above a "flash" DeepSeek. `family` says which
42
86
  // candidates a list may compare; a family not listed here is never guessed at.
87
+ /** @param {readonly Consultant[]} cands */
43
88
  const sameStem = (cands) => new Set(cands.map((c) => modelStem(c.model).join('-'))).size === 1;
89
+ /** @typedef {{ words: string[], family: (cands: readonly Consultant[]) => boolean, last?: boolean }} Tier */
90
+ /** @type {readonly Tier[]} */
44
91
  const TIERS = Object.freeze([
45
92
  { words: ['opus', 'sonnet', 'haiku'], family: (cands) => cands.every((c) => modelStem(c.model)[0] === 'claude') },
46
93
  { words: ['ultra', 'pro', 'flash', 'lite'], family: (cands) => cands.every((c) => modelStem(c.model)[0] === 'gemini') },
@@ -50,38 +97,69 @@ const TIERS = Object.freeze([
50
97
 
51
98
  export const RESERVED_NAMES = Object.freeze(['all']);
52
99
 
53
- /** Case, runs of spaces and underscores folded away: "Claude Code" -> "claude-code". */
100
+ /**
101
+ * Case, runs of spaces and underscores folded away: "Claude Code" -> "claude-code".
102
+ * @template T
103
+ * @param {T} raw
104
+ * @returns {T | string}
105
+ */
54
106
  export function normalizeName(raw) {
55
107
  if (typeof raw !== 'string') return raw;
56
- return raw.trim().toLowerCase().replace(/[\s_]+/g, '-');
108
+ return raw
109
+ .trim()
110
+ .toLowerCase()
111
+ .replace(/[\s_]+/g, '-');
57
112
  }
58
113
 
59
- /** "zai-coding-plan/glm-5.3" -> ["glm", "5", "3"]: the provider prefix is not part of the model. */
114
+ /**
115
+ * "zai-coding-plan/glm-5.3" -> ["glm", "5", "3"]: the provider prefix is not part of the model.
116
+ * @param {unknown} model
117
+ * @returns {string[]}
118
+ */
60
119
  export function modelWords(model) {
61
120
  const s = String(model ?? '').toLowerCase();
62
- return s.slice(s.lastIndexOf('/') + 1).split(/[-._#:\s]+/).filter(Boolean);
121
+ return s
122
+ .slice(s.lastIndexOf('/') + 1)
123
+ .split(/[-._#:\s]+/)
124
+ .filter(Boolean);
63
125
  }
64
126
 
127
+ /** @param {string} w */
65
128
  const isDigitWord = (w) => /^v?\d+$/.test(w);
66
129
 
67
- /** The words before the version: "claude-opus-5-5" -> ["claude", "opus"]. */
130
+ /**
131
+ * The words before the version: "claude-opus-5-5" -> ["claude", "opus"].
132
+ * @param {unknown} model
133
+ */
68
134
  export function modelStem(model) {
69
135
  const words = modelWords(model);
70
136
  const at = words.findIndex(isDigitWord);
71
137
  return at === -1 ? words : words.slice(0, at);
72
138
  }
73
139
 
74
- /** The version right after the stem, as numbers: "deepseek-v4.1-flash" -> [4, 1]; null when there is none. */
140
+ /**
141
+ * The version right after the stem, as numbers: "deepseek-v4.1-flash" -> [4, 1]; null when there is none.
142
+ * @param {unknown} model
143
+ * @returns {number[] | null}
144
+ */
75
145
  export function modelVersion(model) {
76
146
  const words = modelWords(model);
77
147
  const at = words.findIndex(isDigitWord);
78
148
  if (at === -1) return null;
79
149
  const digits = [];
80
- for (let i = at; i < words.length && isDigitWord(words[i]); i++) digits.push(Number(words[i].replace(/^v/, '')));
150
+ for (const w of words.slice(at)) {
151
+ if (!isDigitWord(w)) break;
152
+ digits.push(Number(w.replace(/^v/, '')));
153
+ }
81
154
  return digits;
82
155
  }
83
156
 
84
- /** The operator's word wins; otherwise the stem decides, and an unknown stem is null rather than a guess. */
157
+ /**
158
+ * The operator's word wins; otherwise the stem decides, and an unknown stem is null rather than a guess.
159
+ * @param {unknown} model
160
+ * @param {unknown} [explicit]
161
+ * @returns {string | null}
162
+ */
85
163
  export function vendorOf(model, explicit = null) {
86
164
  if (typeof explicit === 'string' && explicit.trim() !== '') return explicit.trim().toLowerCase();
87
165
  const first = modelStem(model)[0] ?? '';
@@ -89,14 +167,25 @@ export function vendorOf(model, explicit = null) {
89
167
  return STEM_VENDORS[first] ?? null;
90
168
  }
91
169
 
92
- const containsRun = (hay, needle) => needle.length > 0
93
- && hay.some((_, i) => needle.every((w, j) => hay[i + j] === w));
170
+ /**
171
+ * @param {string[]} hay
172
+ * @param {string[]} needle
173
+ */
174
+ const containsRun = (hay, needle) => needle.length > 0 && hay.some((_, i) => needle.every((w, j) => hay[i + j] === w));
94
175
 
95
- /** Does the name appear in the model id as whole words? "claude opus" in "claude-opus-5-5": yes; "g" in "gpt-6": no. */
176
+ /**
177
+ * Does the name appear in the model id as whole words? "claude opus" in "claude-opus-5-5": yes; "g" in "gpt-6": no.
178
+ * @param {unknown} model
179
+ * @param {unknown} wanted
180
+ */
96
181
  export function matchesWords(model, wanted) {
97
182
  return containsRun(modelWords(model), modelWords(wanted));
98
183
  }
99
184
 
185
+ /**
186
+ * @param {number[]} a
187
+ * @param {number[]} b
188
+ */
100
189
  const compareVersions = (a, b) => {
101
190
  for (let i = 0; i < Math.max(a.length, b.length); i++) {
102
191
  const d = (a[i] ?? -1) - (b[i] ?? -1);
@@ -105,7 +194,12 @@ const compareVersions = (a, b) => {
105
194
  return 0;
106
195
  };
107
196
 
197
+ /**
198
+ * @param {readonly Consultant[]} cands
199
+ * @param {readonly string[]} ranking
200
+ */
108
201
  function byRanking(cands, ranking) {
202
+ /** @param {Consultant} c */
109
203
  const index = (c) => {
110
204
  const i = ranking.findIndex((r) => normalizeName(r) === normalizeName(c.name) || matchesWords(c.model, r));
111
205
  return i === -1 ? Infinity : i;
@@ -114,11 +208,18 @@ function byRanking(cands, ranking) {
114
208
  return best === Infinity ? cands : cands.filter((c) => index(c) === best);
115
209
  }
116
210
 
211
+ /**
212
+ * @param {readonly Consultant[]} cands
213
+ * @param {Tier} tier
214
+ */
117
215
  function byTier(cands, tier) {
118
216
  if (!tier.family(cands)) return cands;
217
+ /** @param {Consultant} c */
119
218
  const rank = (c) => {
120
219
  const words = modelWords(c.model);
121
- return tier.last ? tier.words.indexOf(words.at(-1)) : tier.words.findIndex((w) => words.includes(w));
220
+ const last = words.at(-1);
221
+ if (tier.last) return last === undefined ? -1 : tier.words.indexOf(last);
222
+ return tier.words.findIndex((w) => words.includes(w));
122
223
  };
123
224
  const ranks = cands.map(rank);
124
225
  if (ranks.includes(-1) || new Set(ranks).size === 1) return cands;
@@ -126,18 +227,29 @@ function byTier(cands, tier) {
126
227
  return cands.filter((_, i) => ranks[i] === best);
127
228
  }
128
229
 
230
+ /** @param {readonly Consultant[]} cands */
129
231
  function byVersion(cands) {
130
- const stem = modelStem(cands[0].model).join('-');
131
- const versions = cands.map((c) => modelVersion(c.model));
132
- if (versions.includes(null) || cands.some((c) => modelStem(c.model).join('-') !== stem)) return cands;
133
- const best = versions.reduce((a, b) => (compareVersions(a, b) >= 0 ? a : b));
134
- return cands.filter((_, i) => compareVersions(versions[i], best) === 0);
232
+ const [first] = cands;
233
+ if (!first) return cands;
234
+ const stem = modelStem(first.model).join('-');
235
+ if (cands.some((c) => modelStem(c.model).join('-') !== stem)) return cands;
236
+ const versioned = [];
237
+ for (const c of cands) {
238
+ const version = modelVersion(c.model);
239
+ if (version === null) return cands;
240
+ versioned.push({ c, version });
241
+ }
242
+ const best = versioned.map((x) => x.version).reduce((a, b) => (compareVersions(a, b) >= 0 ? a : b));
243
+ return versioned.filter((x) => compareVersions(x.version, best) === 0).map((x) => x.c);
135
244
  }
136
245
 
137
246
  /**
138
247
  * One of several candidates, by the operator's ranking, then the built-in
139
248
  * tiers, then the version within one stem -- or null when none of those
140
249
  * separates them. Never a guess: two names nothing orders stay two.
250
+ * @param {readonly Consultant[]} cands
251
+ * @param {readonly string[]} [ranking]
252
+ * @returns {Consultant | null}
141
253
  */
142
254
  export function rankCandidates(cands, ranking = []) {
143
255
  let left = byRanking(cands, ranking);
@@ -146,13 +258,19 @@ export function rankCandidates(cands, ranking = []) {
146
258
  left = byTier(left, tier);
147
259
  }
148
260
  if (left.length > 1) left = byVersion(left);
149
- return left.length === 1 ? left[0] : null;
261
+ return left.length === 1 ? (left[0] ?? null) : null;
150
262
  }
151
263
 
264
+ /** @typedef {{ none: true, consultant?: undefined, ambiguous?: undefined } | { none?: undefined, consultant: Consultant, ambiguous?: undefined } | { none?: undefined, consultant?: undefined, ambiguous: readonly Consultant[] }} Resolution */
265
+
152
266
  /**
153
267
  * What a requested name means: a consultant's own name, then a CLI name, then
154
268
  * a vendor word, then the words of a model id. Several candidates are ranked;
155
269
  * ones that cannot be ranked come back as `ambiguous`, never as a pick.
270
+ * @param {unknown} input
271
+ * @param {readonly Consultant[]} consultants
272
+ * @param {readonly string[]} [ranking]
273
+ * @returns {Resolution}
156
274
  */
157
275
  export function resolveConsultant(input, consultants, ranking = []) {
158
276
  const key = normalizeName(input);
@@ -163,8 +281,9 @@ export function resolveConsultant(input, consultants, ranking = []) {
163
281
  if (CLI_ALIASES[key]) cands = consultants.filter((c) => c.cli === CLI_ALIASES[key]);
164
282
  else if (VENDOR_WORDS[key]) cands = consultants.filter((c) => c.vendor === VENDOR_WORDS[key]);
165
283
  else cands = consultants.filter((c) => matchesWords(c.model, input));
166
- if (cands.length === 0) return { none: true };
167
- if (cands.length === 1) return { consultant: cands[0] };
284
+ const [only] = cands;
285
+ if (!only) return { none: true };
286
+ if (cands.length === 1) return { consultant: only };
168
287
  const winner = rankCandidates(cands, ranking);
169
288
  return winner ? { consultant: winner } : { ambiguous: cands };
170
289
  }
@@ -174,13 +293,23 @@ export function resolveConsultant(input, consultants, ranking = []) {
174
293
  * CLI is checked once, through its first available consultant -- and a CLI
175
294
  * name asks for exactly that one, never an ambiguity between its consultants.
176
295
  * Any other name resolves like a request. null when nothing answers to it.
296
+ * @param {unknown} arg
297
+ * @param {readonly Consultant[]} available
298
+ * @param {readonly Consultant[]} all
299
+ * @param {readonly string[]} [ranking]
300
+ * @returns {Consultant[] | null}
177
301
  */
178
302
  export function checkTargets(arg, available, all, ranking = []) {
303
+ /** @type {Map<string, Consultant>} */
179
304
  const first = new Map();
180
305
  for (const c of available) if (!first.has(c.cli)) first.set(c.cli, c);
181
306
  if (arg === null || arg === undefined) return [...first.values()];
182
- const cli = CLI_ALIASES[normalizeName(arg)];
183
- if (cli) return first.has(cli) ? [first.get(cli)] : null;
307
+ const key = normalizeName(arg);
308
+ const cli = typeof key === 'string' ? CLI_ALIASES[key] : undefined;
309
+ if (cli) {
310
+ const hit = first.get(cli);
311
+ return hit ? [hit] : null;
312
+ }
184
313
  const r = resolveConsultant(arg, all, ranking);
185
314
  return r.consultant ? [r.consultant] : null;
186
315
  }
@@ -190,6 +319,9 @@ export function checkTargets(arg, available, all, ranking = []) {
190
319
  * the words agree exactly; one being a prefix-like part of the other
191
320
  * ("claude-opus-5" in "claude-opus-5-5") is "unknown", because saying either
192
321
  * "same" or "different" there would be a claim the server cannot back.
322
+ * @param {unknown} callerModel
323
+ * @param {unknown} consultantModel
324
+ * @returns {'same' | 'unknown' | 'different'}
193
325
  */
194
326
  export function sameModel(callerModel, consultantModel) {
195
327
  const a = modelWords(callerModel);
@@ -202,7 +334,16 @@ export function sameModel(callerModel, consultantModel) {
202
334
  const TOP_KEYS = ['server', 'clis', 'consultants', 'ranking'];
203
335
  const CLI_KEYS = ['enabled', 'note', 'bin', 'args', 'env', 'provider'];
204
336
  const CONSULTANT_KEYS = ['cli', 'model', 'effort', 'timeout_ms', 'max_budget_usd', 'vendor', 'enabled', 'note'];
205
- const isObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
337
+ /**
338
+ * @param {unknown} v
339
+ * @returns {v is Record<string, unknown>}
340
+ */
341
+ export const isObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
342
+ /**
343
+ * @param {number} v
344
+ * @param {number} min
345
+ * @param {number} max
346
+ */
206
347
  const clamp = (v, min, max) => Math.min(max, Math.max(min, v));
207
348
 
208
349
  /**
@@ -210,6 +351,8 @@ const clamp = (v, min, max) => Math.min(max, Math.max(min, v));
210
351
  * as a problem string. A config without `consultants` gets one consultant per
211
352
  * CLI, named after it, on that CLI's default model; whether it can run is the
212
353
  * CLI's availability, decided by policy.mjs.
354
+ * @param {unknown} config the parsed config file, before validation
355
+ * @returns {BuiltConsultants}
213
356
  */
214
357
  export function buildConsultants(config) {
215
358
  // `problems` is every mistake, for the operator to read; `global` the ones
@@ -217,11 +360,19 @@ export function buildConsultants(config) {
217
360
  // or one CLI is kept to it: that consultant cannot be used (`broken`), that
218
361
  // CLI is off (`brokenClis`) -- the closed side either way, so nothing the
219
362
  // operator turned off comes back on.
363
+ /** @type {string[]} */
220
364
  const problems = [];
365
+ /** @type {string[]} */
221
366
  const global = [];
367
+ /** @type {Record<string, string[]>} */
222
368
  const broken = {};
369
+ /** @type {Record<string, string[]>} */
223
370
  const brokenClis = {};
224
- const fault = (msg) => { problems.push(msg); global.push(msg); };
371
+ /** @param {string} msg */
372
+ const fault = (msg) => {
373
+ problems.push(msg);
374
+ global.push(msg);
375
+ };
225
376
  const cfg = isObject(config) ? config : {};
226
377
  if (!isObject(config)) fault('the config file must hold a JSON object');
227
378
  for (const key of Object.keys(cfg)) {
@@ -230,39 +381,58 @@ export function buildConsultants(config) {
230
381
 
231
382
  // The one server key that is not a number to clamp: a wrong value cannot
232
383
  // be read as either setting, and reading it as "on" would open the web.
233
- if (isObject(cfg.server) && cfg.server.network !== undefined && !['on', 'off'].includes(cfg.server.network)) {
384
+ /** @type {readonly unknown[]} */
385
+ const networkModes = ['on', 'off'];
386
+ if (isObject(cfg.server) && cfg.server.network !== undefined && !networkModes.includes(cfg.server.network)) {
234
387
  fault('server.network must be "on" or "off"');
235
388
  }
236
389
 
237
390
  let ranking = [];
238
391
  if (cfg.ranking !== undefined) {
239
- if (Array.isArray(cfg.ranking) && cfg.ranking.every((r) => typeof r === 'string' && r.trim() !== '')) ranking = cfg.ranking.map((r) => r.trim());
392
+ if (Array.isArray(cfg.ranking) && cfg.ranking.every((r) => typeof r === 'string' && r.trim() !== ''))
393
+ ranking = cfg.ranking.map((r) => r.trim());
240
394
  else fault('ranking must be an array of non-empty strings');
241
395
  }
242
396
 
397
+ /** @type {readonly unknown[]} */
398
+ const cliIds = CLI_IDS;
243
399
  if (cfg.clis !== undefined) {
244
400
  if (!isObject(cfg.clis)) fault('clis must be an object keyed by CLI');
245
401
  else {
246
402
  for (const [id, def] of Object.entries(cfg.clis)) {
247
- if (!CLI_IDS.includes(id)) { problems.push(`clis.${id}: unknown CLI (known: ${CLI_IDS.join(', ')})`); continue; }
403
+ if (!cliIds.includes(id)) {
404
+ problems.push(`clis.${id}: unknown CLI (known: ${CLI_IDS.join(', ')})`);
405
+ continue;
406
+ }
248
407
  const before = problems.length;
249
- if (!isObject(def)) { problems.push(`clis.${id} must be an object`); brokenClis[id] = problems.slice(before); continue; }
250
- for (const k of Object.keys(def)) if (!CLI_KEYS.includes(k)) problems.push(`clis.${id}.${k}: unknown key (allowed: ${CLI_KEYS.join(', ')})`);
408
+ if (!isObject(def)) {
409
+ problems.push(`clis.${id} must be an object`);
410
+ brokenClis[id] = problems.slice(before);
411
+ continue;
412
+ }
413
+ for (const k of Object.keys(def))
414
+ if (!CLI_KEYS.includes(k)) problems.push(`clis.${id}.${k}: unknown key (allowed: ${CLI_KEYS.join(', ')})`);
251
415
  // A wrong type must not fall back to a default: enabled: "false"
252
416
  // read as "unset" would turn on the CLI the operator meant to stop.
253
- if (def.enabled !== undefined && typeof def.enabled !== 'boolean') problems.push(`clis.${id}.enabled: must be true or false`);
254
- for (const k of ['bin', 'note']) if (def[k] !== undefined && typeof def[k] !== 'string') problems.push(`clis.${id}.${k}: must be a string`);
255
- if (def.args !== undefined && !(Array.isArray(def.args) && def.args.every((a) => typeof a === 'string'))) problems.push(`clis.${id}.args: must be an array of strings`);
256
- if (def.env !== undefined && !(isObject(def.env) && Object.values(def.env).every((v) => typeof v === 'string'))) problems.push(`clis.${id}.env: must be an object of strings`);
417
+ if (def.enabled !== undefined && typeof def.enabled !== 'boolean')
418
+ problems.push(`clis.${id}.enabled: must be true or false`);
419
+ for (const k of ['bin', 'note'])
420
+ if (def[k] !== undefined && typeof def[k] !== 'string') problems.push(`clis.${id}.${k}: must be a string`);
421
+ if (def.args !== undefined && !(Array.isArray(def.args) && def.args.every((a) => typeof a === 'string')))
422
+ problems.push(`clis.${id}.args: must be an array of strings`);
423
+ if (def.env !== undefined && !(isObject(def.env) && Object.values(def.env).every((v) => typeof v === 'string')))
424
+ problems.push(`clis.${id}.env: must be an object of strings`);
257
425
  if (def.provider !== undefined) {
258
426
  if (id !== 'opencode') problems.push(`clis.${id}.provider: only OpenCode takes provider definitions`);
259
- else if (!(isObject(def.provider) && Object.values(def.provider).every(isObject))) problems.push(`clis.${id}.provider: must be an object of provider objects, as in opencode.json`);
427
+ else if (!(isObject(def.provider) && Object.values(def.provider).every(isObject)))
428
+ problems.push(`clis.${id}.provider: must be an object of provider objects, as in opencode.json`);
260
429
  }
261
430
  if (problems.length > before) brokenClis[id] = problems.slice(before);
262
431
  }
263
432
  }
264
433
  }
265
434
 
435
+ /** @type {Consultant[]} */
266
436
  const consultants = [];
267
437
  if (cfg.consultants === undefined) {
268
438
  for (const cli of CLI_IDS) consultants.push(make(cli, { cli, model: DEFAULT_MODELS[cli] }));
@@ -272,37 +442,74 @@ export function buildConsultants(config) {
272
442
  const seen = new Map();
273
443
  for (const [name, def] of Object.entries(cfg.consultants)) {
274
444
  const at = `consultants.${name}`;
275
- if (!/^[a-z0-9][a-z0-9.-]{0,63}$/.test(name)) { problems.push(`${at}: a name is lower-case letters, digits, "." and "-", at most 64 characters`); continue; }
276
- if (RESERVED_NAMES.includes(name)) { problems.push(`${at}: "${name}" is reserved`); continue; }
445
+ if (!/^[a-z0-9][a-z0-9.-]{0,63}$/.test(name)) {
446
+ problems.push(`${at}: a name is lower-case letters, digits, "." and "-", at most 64 characters`);
447
+ continue;
448
+ }
449
+ if (RESERVED_NAMES.includes(name)) {
450
+ problems.push(`${at}: "${name}" is reserved`);
451
+ continue;
452
+ }
277
453
  const before = problems.length;
278
- if (!isObject(def)) { problems.push(`${at} must be an object`); broken[name] = problems.slice(before); continue; }
279
- for (const k of Object.keys(def)) if (!CONSULTANT_KEYS.includes(k)) problems.push(`${at}.${k}: unknown key (allowed: ${CONSULTANT_KEYS.join(', ')})`);
280
- if (!CLI_IDS.includes(def.cli)) problems.push(`${at}.cli: must be one of ${CLI_IDS.join(', ')}`);
454
+ if (!isObject(def)) {
455
+ problems.push(`${at} must be an object`);
456
+ broken[name] = problems.slice(before);
457
+ continue;
458
+ }
459
+ for (const k of Object.keys(def))
460
+ if (!CONSULTANT_KEYS.includes(k)) problems.push(`${at}.${k}: unknown key (allowed: ${CONSULTANT_KEYS.join(', ')})`);
461
+ if (!cliIds.includes(def.cli)) problems.push(`${at}.cli: must be one of ${CLI_IDS.join(', ')}`);
281
462
  if (typeof def.model !== 'string' || def.model.trim() === '') problems.push(`${at}.model: required`);
282
463
  for (const k of ['effort', 'vendor', 'note']) {
283
464
  if (def[k] !== undefined && typeof def[k] !== 'string') problems.push(`${at}.${k}: must be a string`);
284
465
  }
285
466
  if (def.enabled !== undefined && typeof def.enabled !== 'boolean') problems.push(`${at}.enabled: must be true or false`);
286
467
  for (const k of ['timeout_ms', 'max_budget_usd']) {
287
- if (def[k] !== undefined && !(typeof def[k] === 'number' && Number.isFinite(def[k]))) problems.push(`${at}.${k}: must be a number`);
468
+ if (def[k] !== undefined && !(typeof def[k] === 'number' && Number.isFinite(def[k])))
469
+ problems.push(`${at}.${k}: must be a number`);
288
470
  }
289
- if (def.max_budget_usd !== undefined && def.cli !== 'claude-code') problems.push(`${at}.max_budget_usd: only a claude-code consultant has a budget`);
471
+ if (def.max_budget_usd !== undefined && def.cli !== 'claude-code')
472
+ problems.push(`${at}.max_budget_usd: only a claude-code consultant has a budget`);
290
473
  // agy takes the effort from the model name and rejects --effort.
291
- if (def.effort !== undefined && def.cli === 'antigravity') problems.push(`${at}.effort: Antigravity takes the effort from the model name (e.g. gemini-3.8-flash-low)`);
292
- if (problems.length > before) { broken[name] = problems.slice(before); continue; }
293
- const pair = `${def.cli}\n${def.model.trim()}`;
474
+ if (def.effort !== undefined && def.cli === 'antigravity')
475
+ problems.push(`${at}.effort: Antigravity takes the effort from the model name (e.g. gemini-3.8-flash-low)`);
476
+ if (problems.length > before) {
477
+ broken[name] = problems.slice(before);
478
+ continue;
479
+ }
480
+ // The checks above have all passed: cli is a known id and model a non-empty string.
481
+ const valid = /** @type {ConsultantDef} */ (def);
482
+ const pair = `${valid.cli}\n${valid.model.trim()}`;
294
483
  if (seen.has(pair)) {
295
484
  problems.push(`${at}: same cli and model as consultants.${seen.get(pair)}; asking one model twice adds no independence`);
296
485
  broken[name] = problems.slice(before);
297
486
  continue;
298
487
  }
299
488
  seen.set(pair, name);
300
- consultants.push(make(name, def));
489
+ consultants.push(make(name, valid));
301
490
  }
302
491
  }
303
492
  return { consultants: Object.freeze(consultants), ranking: Object.freeze(ranking), problems, global, broken, brokenClis };
304
493
  }
305
494
 
495
+ /**
496
+ * A consultant's definition in the config file, once it has passed validation.
497
+ * @typedef {object} ConsultantDef
498
+ * @property {CliId} cli
499
+ * @property {string} model
500
+ * @property {unknown} [effort]
501
+ * @property {unknown} [timeout_ms]
502
+ * @property {unknown} [max_budget_usd]
503
+ * @property {unknown} [vendor]
504
+ * @property {unknown} [enabled]
505
+ * @property {unknown} [note]
506
+ */
507
+
508
+ /**
509
+ * @param {string} name
510
+ * @param {ConsultantDef} def a definition that has passed validation
511
+ * @returns {Consultant}
512
+ */
306
513
  function make(name, def) {
307
514
  const model = def.model.trim();
308
515
  return Object.freeze({
@@ -312,7 +519,7 @@ function make(name, def) {
312
519
  effort: typeof def.effort === 'string' && def.effort.trim() !== '' ? def.effort.trim() : null,
313
520
  timeoutMs: typeof def.timeout_ms === 'number' ? clamp(def.timeout_ms, 1_000, 7_200_000) : null,
314
521
  maxBudgetUsd: typeof def.max_budget_usd === 'number' ? clamp(def.max_budget_usd, 0.05, 20) : null,
315
- vendor: vendorOf(model, def.vendor ?? null),
522
+ vendor: vendorOf(model, def.vendor),
316
523
  enabled: def.enabled !== false,
317
524
  note: typeof def.note === 'string' && def.note.trim() !== '' ? def.note.trim() : null,
318
525
  });
@@ -0,0 +1,7 @@
1
+ // cross-spawn ships no types for its internal escaping helpers.
2
+ declare module 'cross-spawn/lib/util/escape.js' {
3
+ export function command(arg: string): string;
4
+ export function argument(arg: string, doubleEscape?: boolean): string;
5
+ const escape: { command: typeof command; argument: typeof argument };
6
+ export default escape;
7
+ }