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.
- package/README.ja.md +52 -17
- package/README.md +52 -16
- package/bin/severally-mcp.mjs +14 -2
- package/docs/install.ja.md +48 -20
- package/docs/install.md +48 -21
- package/package.json +15 -3
- package/plugins/severally/.claude-plugin/plugin.json +1 -1
- package/plugins/severally/.codex-plugin/plugin.json +1 -1
- package/plugins/severally/dist/severally-mcp.mjs +6573 -5978
- package/plugins/severally/skills/_template/reference.md.tmpl +2 -2
- package/plugins/severally/skills/antigravity/severally/reference.md +2 -2
- package/plugins/severally/skills/claude/severally/reference.md +2 -2
- package/plugins/severally/skills/codex/severally/reference.md +2 -2
- package/plugins/severally/skills/opencode/severally/reference.md +2 -2
- package/scripts/build.mjs +76 -68
- package/scripts/doctor.mjs +66 -20
- package/scripts/init-config.mjs +8 -6
- package/scripts/install.mjs +327 -90
- package/scripts/live-check.mjs +62 -23
- package/scripts/live-mcp-check.mjs +52 -16
- package/scripts/test.mjs +10 -4
- package/src/adapters/antigravity-sandbox.mjs +62 -28
- package/src/adapters/antigravity.mjs +111 -30
- package/src/adapters/claude-code.mjs +121 -28
- package/src/adapters/codex.mjs +125 -33
- package/src/adapters/opencode-sandbox.mjs +59 -22
- package/src/adapters/opencode.mjs +144 -30
- package/src/brief.mjs +148 -47
- package/src/consultants.mjs +255 -48
- package/src/cross-spawn-escape.d.ts +7 -0
- package/src/export.mjs +99 -18
- package/src/expose-paths.mjs +310 -50
- package/src/failures.mjs +31 -14
- package/src/jobs.mjs +863 -249
- package/src/jsonc.mjs +11 -3
- package/src/parse-result.mjs +172 -40
- package/src/platform.mjs +56 -8
- package/src/policy.mjs +245 -52
- package/src/redact.mjs +59 -16
- package/src/result-schema.mjs +13 -0
- package/src/run.mjs +225 -30
- package/src/schema.mjs +132 -27
- package/src/server.mjs +178 -81
- package/src/status.mjs +94 -13
- package/src/store.mjs +418 -35
package/src/consultants.mjs
CHANGED
|
@@ -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',
|
|
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',
|
|
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
|
-
/**
|
|
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
|
|
108
|
+
return raw
|
|
109
|
+
.trim()
|
|
110
|
+
.toLowerCase()
|
|
111
|
+
.replace(/[\s_]+/g, '-');
|
|
57
112
|
}
|
|
58
113
|
|
|
59
|
-
/**
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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 (
|
|
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
|
-
/**
|
|
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
|
-
|
|
93
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
|
|
167
|
-
if (
|
|
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
|
|
183
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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() !== ''))
|
|
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 (!
|
|
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)) {
|
|
250
|
-
|
|
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')
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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)))
|
|
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)) {
|
|
276
|
-
|
|
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)) {
|
|
279
|
-
|
|
280
|
-
|
|
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])))
|
|
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')
|
|
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')
|
|
292
|
-
|
|
293
|
-
|
|
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,
|
|
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
|
|
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
|
+
}
|