severally-mcp 2.4.0 → 2.5.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.
Files changed (50) hide show
  1. package/CONFIG.md +1 -0
  2. package/README.ja.md +44 -19
  3. package/README.md +44 -18
  4. package/config.example.json +1 -1
  5. package/docs/how-it-works.ja.md +2 -0
  6. package/docs/how-it-works.md +2 -0
  7. package/package.json +3 -1
  8. package/plugins/severally/.claude-plugin/plugin.json +1 -1
  9. package/plugins/severally/.codex-plugin/plugin.json +1 -1
  10. package/plugins/severally/dist/severally-mcp.mjs +630 -271
  11. package/plugins/severally/skills/_template/SKILL.md.tmpl +2 -1
  12. package/plugins/severally/skills/_template/reference.md.tmpl +2 -2
  13. package/plugins/severally/skills/antigravity/severally/SKILL.md +2 -1
  14. package/plugins/severally/skills/antigravity/severally/reference.md +2 -2
  15. package/plugins/severally/skills/claude/severally/SKILL.md +2 -1
  16. package/plugins/severally/skills/claude/severally/reference.md +2 -2
  17. package/plugins/severally/skills/codex/severally/SKILL.md +2 -1
  18. package/plugins/severally/skills/codex/severally/reference.md +2 -2
  19. package/plugins/severally/skills/opencode/severally/SKILL.md +2 -1
  20. package/plugins/severally/skills/opencode/severally/reference.md +2 -2
  21. package/scripts/build.mjs +57 -54
  22. package/scripts/doctor.mjs +15 -4
  23. package/scripts/init-config.mjs +2 -1
  24. package/scripts/install.mjs +73 -9
  25. package/scripts/live-check.mjs +11 -5
  26. package/scripts/live-mcp-check.mjs +10 -4
  27. package/src/adapters/antigravity-sandbox.mjs +24 -6
  28. package/src/adapters/antigravity.mjs +80 -17
  29. package/src/adapters/claude-code.mjs +49 -8
  30. package/src/adapters/codex.mjs +56 -6
  31. package/src/adapters/opencode-sandbox.mjs +20 -2
  32. package/src/adapters/opencode.mjs +115 -19
  33. package/src/brief.mjs +84 -7
  34. package/src/consultants.mjs +193 -27
  35. package/src/cross-spawn-escape.d.ts +7 -0
  36. package/src/export.mjs +50 -6
  37. package/src/expose-paths.mjs +149 -7
  38. package/src/failures.mjs +8 -1
  39. package/src/jobs.mjs +632 -152
  40. package/src/jsonc.mjs +11 -3
  41. package/src/parse-result.mjs +91 -14
  42. package/src/platform.mjs +52 -2
  43. package/src/policy.mjs +151 -28
  44. package/src/redact.mjs +22 -3
  45. package/src/result-schema.mjs +13 -0
  46. package/src/run.mjs +158 -12
  47. package/src/schema.mjs +84 -11
  48. package/src/server.mjs +49 -14
  49. package/src/status.mjs +16 -4
  50. package/src/store.mjs +287 -6
@@ -4,9 +4,40 @@
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} fallbackModel
19
+ * @property {string | null} vendor
20
+ * @property {boolean} enabled
21
+ * @property {string | null} note
22
+ */
23
+
24
+ /**
25
+ * What buildConsultants makes of a config file: the consultants and ranking,
26
+ * and every mistake found (see buildConsultants for the problem groups).
27
+ * @typedef {object} BuiltConsultants
28
+ * @property {readonly Consultant[]} consultants
29
+ * @property {readonly string[]} ranking
30
+ * @property {string[]} problems
31
+ * @property {string[]} global
32
+ * @property {Record<string, string[]>} broken
33
+ * @property {Record<string, string[]>} brokenClis
34
+ */
35
+
36
+ /** @type {readonly CliId[]} */
7
37
  export const CLI_IDS = Object.freeze(['codex', 'claude-code', 'antigravity', 'opencode']);
8
38
 
9
39
  // The model each CLI's consultant runs when the operator defines none.
40
+ /** @type {Readonly<Record<CliId, string>>} */
10
41
  export const DEFAULT_MODELS = Object.freeze({
11
42
  codex: 'gpt-6-astra',
12
43
  'claude-code': 'claude-opus-5-5',
@@ -16,10 +47,15 @@ export const DEFAULT_MODELS = Object.freeze({
16
47
 
17
48
  // The lineage a CLI's own host model belongs to, for a caller that declared no
18
49
  // model. OpenCode runs anyone's models, so it has none.
50
+ /** @type {Readonly<Record<CliId, string | null>>} */
19
51
  export const CLI_VENDORS = Object.freeze({ codex: 'openai', 'claude-code': 'anthropic', antigravity: 'google', opencode: null });
20
52
 
21
53
  // Null-prototype tables, so a request cannot reach Object.prototype through
22
54
  // them: 'constructor' must be no match, not a function.
55
+ /**
56
+ * @param {Record<string, string>} entries
57
+ * @returns {Readonly<Record<string, string>>}
58
+ */
23
59
  const table = (entries) => Object.freeze(Object.assign(Object.create(null), entries));
24
60
 
25
61
  // Names that mean "the consultants on this CLI". gpt/claude/gemini/glm are
@@ -49,7 +85,10 @@ const STEM_VENDORS = table({
49
85
  // Built-in "higher first" orders, each used only inside one model family: an
50
86
  // "ultra" Nemotron is not above a "flash" DeepSeek. `family` says which
51
87
  // candidates a list may compare; a family not listed here is never guessed at.
88
+ /** @param {readonly Consultant[]} cands */
52
89
  const sameStem = (cands) => new Set(cands.map((c) => modelStem(c.model).join('-'))).size === 1;
90
+ /** @typedef {{ words: string[], family: (cands: readonly Consultant[]) => boolean, last?: boolean }} Tier */
91
+ /** @type {readonly Tier[]} */
53
92
  const TIERS = Object.freeze([
54
93
  { words: ['opus', 'sonnet', 'haiku'], family: (cands) => cands.every((c) => modelStem(c.model)[0] === 'claude') },
55
94
  { words: ['ultra', 'pro', 'flash', 'lite'], family: (cands) => cands.every((c) => modelStem(c.model)[0] === 'gemini') },
@@ -59,7 +98,12 @@ const TIERS = Object.freeze([
59
98
 
60
99
  export const RESERVED_NAMES = Object.freeze(['all']);
61
100
 
62
- /** Case, runs of spaces and underscores folded away: "Claude Code" -> "claude-code". */
101
+ /**
102
+ * Case, runs of spaces and underscores folded away: "Claude Code" -> "claude-code".
103
+ * @template T
104
+ * @param {T} raw
105
+ * @returns {T | string}
106
+ */
63
107
  export function normalizeName(raw) {
64
108
  if (typeof raw !== 'string') return raw;
65
109
  return raw
@@ -68,7 +112,11 @@ export function normalizeName(raw) {
68
112
  .replace(/[\s_]+/g, '-');
69
113
  }
70
114
 
71
- /** "zai-coding-plan/glm-5.3" -> ["glm", "5", "3"]: the provider prefix is not part of the model. */
115
+ /**
116
+ * "zai-coding-plan/glm-5.3" -> ["glm", "5", "3"]: the provider prefix is not part of the model.
117
+ * @param {unknown} model
118
+ * @returns {string[]}
119
+ */
72
120
  export function modelWords(model) {
73
121
  const s = String(model ?? '').toLowerCase();
74
122
  return s
@@ -77,26 +125,42 @@ export function modelWords(model) {
77
125
  .filter(Boolean);
78
126
  }
79
127
 
128
+ /** @param {string} w */
80
129
  const isDigitWord = (w) => /^v?\d+$/.test(w);
81
130
 
82
- /** The words before the version: "claude-opus-5-5" -> ["claude", "opus"]. */
131
+ /**
132
+ * The words before the version: "claude-opus-5-5" -> ["claude", "opus"].
133
+ * @param {unknown} model
134
+ */
83
135
  export function modelStem(model) {
84
136
  const words = modelWords(model);
85
137
  const at = words.findIndex(isDigitWord);
86
138
  return at === -1 ? words : words.slice(0, at);
87
139
  }
88
140
 
89
- /** The version right after the stem, as numbers: "deepseek-v4.1-flash" -> [4, 1]; null when there is none. */
141
+ /**
142
+ * The version right after the stem, as numbers: "deepseek-v4.1-flash" -> [4, 1]; null when there is none.
143
+ * @param {unknown} model
144
+ * @returns {number[] | null}
145
+ */
90
146
  export function modelVersion(model) {
91
147
  const words = modelWords(model);
92
148
  const at = words.findIndex(isDigitWord);
93
149
  if (at === -1) return null;
94
150
  const digits = [];
95
- for (let i = at; i < words.length && isDigitWord(words[i]); i++) digits.push(Number(words[i].replace(/^v/, '')));
151
+ for (const w of words.slice(at)) {
152
+ if (!isDigitWord(w)) break;
153
+ digits.push(Number(w.replace(/^v/, '')));
154
+ }
96
155
  return digits;
97
156
  }
98
157
 
99
- /** The operator's word wins; otherwise the stem decides, and an unknown stem is null rather than a guess. */
158
+ /**
159
+ * The operator's word wins; otherwise the stem decides, and an unknown stem is null rather than a guess.
160
+ * @param {unknown} model
161
+ * @param {unknown} [explicit]
162
+ * @returns {string | null}
163
+ */
100
164
  export function vendorOf(model, explicit = null) {
101
165
  if (typeof explicit === 'string' && explicit.trim() !== '') return explicit.trim().toLowerCase();
102
166
  const first = modelStem(model)[0] ?? '';
@@ -104,13 +168,25 @@ export function vendorOf(model, explicit = null) {
104
168
  return STEM_VENDORS[first] ?? null;
105
169
  }
106
170
 
171
+ /**
172
+ * @param {string[]} hay
173
+ * @param {string[]} needle
174
+ */
107
175
  const containsRun = (hay, needle) => needle.length > 0 && hay.some((_, i) => needle.every((w, j) => hay[i + j] === w));
108
176
 
109
- /** Does the name appear in the model id as whole words? "claude opus" in "claude-opus-5-5": yes; "g" in "gpt-6": no. */
177
+ /**
178
+ * Does the name appear in the model id as whole words? "claude opus" in "claude-opus-5-5": yes; "g" in "gpt-6": no.
179
+ * @param {unknown} model
180
+ * @param {unknown} wanted
181
+ */
110
182
  export function matchesWords(model, wanted) {
111
183
  return containsRun(modelWords(model), modelWords(wanted));
112
184
  }
113
185
 
186
+ /**
187
+ * @param {number[]} a
188
+ * @param {number[]} b
189
+ */
114
190
  const compareVersions = (a, b) => {
115
191
  for (let i = 0; i < Math.max(a.length, b.length); i++) {
116
192
  const d = (a[i] ?? -1) - (b[i] ?? -1);
@@ -119,7 +195,12 @@ const compareVersions = (a, b) => {
119
195
  return 0;
120
196
  };
121
197
 
198
+ /**
199
+ * @param {readonly Consultant[]} cands
200
+ * @param {readonly string[]} ranking
201
+ */
122
202
  function byRanking(cands, ranking) {
203
+ /** @param {Consultant} c */
123
204
  const index = (c) => {
124
205
  const i = ranking.findIndex((r) => normalizeName(r) === normalizeName(c.name) || matchesWords(c.model, r));
125
206
  return i === -1 ? Infinity : i;
@@ -128,11 +209,18 @@ function byRanking(cands, ranking) {
128
209
  return best === Infinity ? cands : cands.filter((c) => index(c) === best);
129
210
  }
130
211
 
212
+ /**
213
+ * @param {readonly Consultant[]} cands
214
+ * @param {Tier} tier
215
+ */
131
216
  function byTier(cands, tier) {
132
217
  if (!tier.family(cands)) return cands;
218
+ /** @param {Consultant} c */
133
219
  const rank = (c) => {
134
220
  const words = modelWords(c.model);
135
- return tier.last ? tier.words.indexOf(words.at(-1)) : tier.words.findIndex((w) => words.includes(w));
221
+ const last = words.at(-1);
222
+ if (tier.last) return last === undefined ? -1 : tier.words.indexOf(last);
223
+ return tier.words.findIndex((w) => words.includes(w));
136
224
  };
137
225
  const ranks = cands.map(rank);
138
226
  if (ranks.includes(-1) || new Set(ranks).size === 1) return cands;
@@ -140,18 +228,29 @@ function byTier(cands, tier) {
140
228
  return cands.filter((_, i) => ranks[i] === best);
141
229
  }
142
230
 
231
+ /** @param {readonly Consultant[]} cands */
143
232
  function byVersion(cands) {
144
- const stem = modelStem(cands[0].model).join('-');
145
- const versions = cands.map((c) => modelVersion(c.model));
146
- if (versions.includes(null) || cands.some((c) => modelStem(c.model).join('-') !== stem)) return cands;
147
- const best = versions.reduce((a, b) => (compareVersions(a, b) >= 0 ? a : b));
148
- return cands.filter((_, i) => compareVersions(versions[i], best) === 0);
233
+ const [first] = cands;
234
+ if (!first) return cands;
235
+ const stem = modelStem(first.model).join('-');
236
+ if (cands.some((c) => modelStem(c.model).join('-') !== stem)) return cands;
237
+ const versioned = [];
238
+ for (const c of cands) {
239
+ const version = modelVersion(c.model);
240
+ if (version === null) return cands;
241
+ versioned.push({ c, version });
242
+ }
243
+ const best = versioned.map((x) => x.version).reduce((a, b) => (compareVersions(a, b) >= 0 ? a : b));
244
+ return versioned.filter((x) => compareVersions(x.version, best) === 0).map((x) => x.c);
149
245
  }
150
246
 
151
247
  /**
152
248
  * One of several candidates, by the operator's ranking, then the built-in
153
249
  * tiers, then the version within one stem -- or null when none of those
154
250
  * separates them. Never a guess: two names nothing orders stay two.
251
+ * @param {readonly Consultant[]} cands
252
+ * @param {readonly string[]} [ranking]
253
+ * @returns {Consultant | null}
155
254
  */
156
255
  export function rankCandidates(cands, ranking = []) {
157
256
  let left = byRanking(cands, ranking);
@@ -160,13 +259,19 @@ export function rankCandidates(cands, ranking = []) {
160
259
  left = byTier(left, tier);
161
260
  }
162
261
  if (left.length > 1) left = byVersion(left);
163
- return left.length === 1 ? left[0] : null;
262
+ return left.length === 1 ? (left[0] ?? null) : null;
164
263
  }
165
264
 
265
+ /** @typedef {{ none: true, consultant?: undefined, ambiguous?: undefined } | { none?: undefined, consultant: Consultant, ambiguous?: undefined } | { none?: undefined, consultant?: undefined, ambiguous: readonly Consultant[] }} Resolution */
266
+
166
267
  /**
167
268
  * What a requested name means: a consultant's own name, then a CLI name, then
168
269
  * a vendor word, then the words of a model id. Several candidates are ranked;
169
270
  * ones that cannot be ranked come back as `ambiguous`, never as a pick.
271
+ * @param {unknown} input
272
+ * @param {readonly Consultant[]} consultants
273
+ * @param {readonly string[]} [ranking]
274
+ * @returns {Resolution}
170
275
  */
171
276
  export function resolveConsultant(input, consultants, ranking = []) {
172
277
  const key = normalizeName(input);
@@ -177,8 +282,9 @@ export function resolveConsultant(input, consultants, ranking = []) {
177
282
  if (CLI_ALIASES[key]) cands = consultants.filter((c) => c.cli === CLI_ALIASES[key]);
178
283
  else if (VENDOR_WORDS[key]) cands = consultants.filter((c) => c.vendor === VENDOR_WORDS[key]);
179
284
  else cands = consultants.filter((c) => matchesWords(c.model, input));
180
- if (cands.length === 0) return { none: true };
181
- if (cands.length === 1) return { consultant: cands[0] };
285
+ const [only] = cands;
286
+ if (!only) return { none: true };
287
+ if (cands.length === 1) return { consultant: only };
182
288
  const winner = rankCandidates(cands, ranking);
183
289
  return winner ? { consultant: winner } : { ambiguous: cands };
184
290
  }
@@ -188,13 +294,23 @@ export function resolveConsultant(input, consultants, ranking = []) {
188
294
  * CLI is checked once, through its first available consultant -- and a CLI
189
295
  * name asks for exactly that one, never an ambiguity between its consultants.
190
296
  * Any other name resolves like a request. null when nothing answers to it.
297
+ * @param {unknown} arg
298
+ * @param {readonly Consultant[]} available
299
+ * @param {readonly Consultant[]} all
300
+ * @param {readonly string[]} [ranking]
301
+ * @returns {Consultant[] | null}
191
302
  */
192
303
  export function checkTargets(arg, available, all, ranking = []) {
304
+ /** @type {Map<string, Consultant>} */
193
305
  const first = new Map();
194
306
  for (const c of available) if (!first.has(c.cli)) first.set(c.cli, c);
195
307
  if (arg === null || arg === undefined) return [...first.values()];
196
- const cli = CLI_ALIASES[normalizeName(arg)];
197
- if (cli) return first.has(cli) ? [first.get(cli)] : null;
308
+ const key = normalizeName(arg);
309
+ const cli = typeof key === 'string' ? CLI_ALIASES[key] : undefined;
310
+ if (cli) {
311
+ const hit = first.get(cli);
312
+ return hit ? [hit] : null;
313
+ }
198
314
  const r = resolveConsultant(arg, all, ranking);
199
315
  return r.consultant ? [r.consultant] : null;
200
316
  }
@@ -204,6 +320,9 @@ export function checkTargets(arg, available, all, ranking = []) {
204
320
  * the words agree exactly; one being a prefix-like part of the other
205
321
  * ("claude-opus-5" in "claude-opus-5-5") is "unknown", because saying either
206
322
  * "same" or "different" there would be a claim the server cannot back.
323
+ * @param {unknown} callerModel
324
+ * @param {unknown} consultantModel
325
+ * @returns {'same' | 'unknown' | 'different'}
207
326
  */
208
327
  export function sameModel(callerModel, consultantModel) {
209
328
  const a = modelWords(callerModel);
@@ -215,8 +334,17 @@ export function sameModel(callerModel, consultantModel) {
215
334
 
216
335
  const TOP_KEYS = ['server', 'clis', 'consultants', 'ranking'];
217
336
  const CLI_KEYS = ['enabled', 'note', 'bin', 'args', 'env', 'provider'];
218
- const CONSULTANT_KEYS = ['cli', 'model', 'effort', 'timeout_ms', 'max_budget_usd', 'vendor', 'enabled', 'note'];
219
- const isObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
337
+ const CONSULTANT_KEYS = ['cli', 'model', 'effort', 'timeout_ms', 'max_budget_usd', 'fallback_model', 'vendor', 'enabled', 'note'];
338
+ /**
339
+ * @param {unknown} v
340
+ * @returns {v is Record<string, unknown>}
341
+ */
342
+ export const isObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
343
+ /**
344
+ * @param {number} v
345
+ * @param {number} min
346
+ * @param {number} max
347
+ */
220
348
  const clamp = (v, min, max) => Math.min(max, Math.max(min, v));
221
349
 
222
350
  /**
@@ -224,6 +352,8 @@ const clamp = (v, min, max) => Math.min(max, Math.max(min, v));
224
352
  * as a problem string. A config without `consultants` gets one consultant per
225
353
  * CLI, named after it, on that CLI's default model; whether it can run is the
226
354
  * CLI's availability, decided by policy.mjs.
355
+ * @param {unknown} config the parsed config file, before validation
356
+ * @returns {BuiltConsultants}
227
357
  */
228
358
  export function buildConsultants(config) {
229
359
  // `problems` is every mistake, for the operator to read; `global` the ones
@@ -231,10 +361,15 @@ export function buildConsultants(config) {
231
361
  // or one CLI is kept to it: that consultant cannot be used (`broken`), that
232
362
  // CLI is off (`brokenClis`) -- the closed side either way, so nothing the
233
363
  // operator turned off comes back on.
364
+ /** @type {string[]} */
234
365
  const problems = [];
366
+ /** @type {string[]} */
235
367
  const global = [];
368
+ /** @type {Record<string, string[]>} */
236
369
  const broken = {};
370
+ /** @type {Record<string, string[]>} */
237
371
  const brokenClis = {};
372
+ /** @param {string} msg */
238
373
  const fault = (msg) => {
239
374
  problems.push(msg);
240
375
  global.push(msg);
@@ -247,7 +382,9 @@ export function buildConsultants(config) {
247
382
 
248
383
  // The one server key that is not a number to clamp: a wrong value cannot
249
384
  // be read as either setting, and reading it as "on" would open the web.
250
- if (isObject(cfg.server) && cfg.server.network !== undefined && !['on', 'off'].includes(cfg.server.network)) {
385
+ /** @type {readonly unknown[]} */
386
+ const networkModes = ['on', 'off'];
387
+ if (isObject(cfg.server) && cfg.server.network !== undefined && !networkModes.includes(cfg.server.network)) {
251
388
  fault('server.network must be "on" or "off"');
252
389
  }
253
390
 
@@ -258,11 +395,13 @@ export function buildConsultants(config) {
258
395
  else fault('ranking must be an array of non-empty strings');
259
396
  }
260
397
 
398
+ /** @type {readonly unknown[]} */
399
+ const cliIds = CLI_IDS;
261
400
  if (cfg.clis !== undefined) {
262
401
  if (!isObject(cfg.clis)) fault('clis must be an object keyed by CLI');
263
402
  else {
264
403
  for (const [id, def] of Object.entries(cfg.clis)) {
265
- if (!CLI_IDS.includes(id)) {
404
+ if (!cliIds.includes(id)) {
266
405
  problems.push(`clis.${id}: unknown CLI (known: ${CLI_IDS.join(', ')})`);
267
406
  continue;
268
407
  }
@@ -294,6 +433,7 @@ export function buildConsultants(config) {
294
433
  }
295
434
  }
296
435
 
436
+ /** @type {Consultant[]} */
297
437
  const consultants = [];
298
438
  if (cfg.consultants === undefined) {
299
439
  for (const cli of CLI_IDS) consultants.push(make(cli, { cli, model: DEFAULT_MODELS[cli] }));
@@ -319,9 +459,9 @@ export function buildConsultants(config) {
319
459
  }
320
460
  for (const k of Object.keys(def))
321
461
  if (!CONSULTANT_KEYS.includes(k)) problems.push(`${at}.${k}: unknown key (allowed: ${CONSULTANT_KEYS.join(', ')})`);
322
- if (!CLI_IDS.includes(def.cli)) problems.push(`${at}.cli: must be one of ${CLI_IDS.join(', ')}`);
462
+ if (!cliIds.includes(def.cli)) problems.push(`${at}.cli: must be one of ${CLI_IDS.join(', ')}`);
323
463
  if (typeof def.model !== 'string' || def.model.trim() === '') problems.push(`${at}.model: required`);
324
- for (const k of ['effort', 'vendor', 'note']) {
464
+ for (const k of ['effort', 'fallback_model', 'vendor', 'note']) {
325
465
  if (def[k] !== undefined && typeof def[k] !== 'string') problems.push(`${at}.${k}: must be a string`);
326
466
  }
327
467
  if (def.enabled !== undefined && typeof def.enabled !== 'boolean') problems.push(`${at}.enabled: must be true or false`);
@@ -329,6 +469,10 @@ export function buildConsultants(config) {
329
469
  if (def[k] !== undefined && !(typeof def[k] === 'number' && Number.isFinite(def[k])))
330
470
  problems.push(`${at}.${k}: must be a number`);
331
471
  }
472
+ if (typeof def.fallback_model === 'string' && def.fallback_model.trim() === '')
473
+ problems.push(`${at}.fallback_model: must not be empty`);
474
+ else if (typeof def.fallback_model === 'string' && def.fallback_model.trim() === String(def.model).trim())
475
+ problems.push(`${at}.fallback_model: must differ from model; the fallback is for when the model's quota is used up`);
332
476
  if (def.max_budget_usd !== undefined && def.cli !== 'claude-code')
333
477
  problems.push(`${at}.max_budget_usd: only a claude-code consultant has a budget`);
334
478
  // agy takes the effort from the model name and rejects --effort.
@@ -338,19 +482,40 @@ export function buildConsultants(config) {
338
482
  broken[name] = problems.slice(before);
339
483
  continue;
340
484
  }
341
- const pair = `${def.cli}\n${def.model.trim()}`;
485
+ // The checks above have all passed: cli is a known id and model a non-empty string.
486
+ const valid = /** @type {ConsultantDef} */ (def);
487
+ const pair = `${valid.cli}\n${valid.model.trim()}`;
342
488
  if (seen.has(pair)) {
343
489
  problems.push(`${at}: same cli and model as consultants.${seen.get(pair)}; asking one model twice adds no independence`);
344
490
  broken[name] = problems.slice(before);
345
491
  continue;
346
492
  }
347
493
  seen.set(pair, name);
348
- consultants.push(make(name, def));
494
+ consultants.push(make(name, valid));
349
495
  }
350
496
  }
351
497
  return { consultants: Object.freeze(consultants), ranking: Object.freeze(ranking), problems, global, broken, brokenClis };
352
498
  }
353
499
 
500
+ /**
501
+ * A consultant's definition in the config file, once it has passed validation.
502
+ * @typedef {object} ConsultantDef
503
+ * @property {CliId} cli
504
+ * @property {string} model
505
+ * @property {unknown} [effort]
506
+ * @property {unknown} [timeout_ms]
507
+ * @property {unknown} [max_budget_usd]
508
+ * @property {unknown} [fallback_model]
509
+ * @property {unknown} [vendor]
510
+ * @property {unknown} [enabled]
511
+ * @property {unknown} [note]
512
+ */
513
+
514
+ /**
515
+ * @param {string} name
516
+ * @param {ConsultantDef} def a definition that has passed validation
517
+ * @returns {Consultant}
518
+ */
354
519
  function make(name, def) {
355
520
  const model = def.model.trim();
356
521
  return Object.freeze({
@@ -360,7 +525,8 @@ function make(name, def) {
360
525
  effort: typeof def.effort === 'string' && def.effort.trim() !== '' ? def.effort.trim() : null,
361
526
  timeoutMs: typeof def.timeout_ms === 'number' ? clamp(def.timeout_ms, 1_000, 7_200_000) : null,
362
527
  maxBudgetUsd: typeof def.max_budget_usd === 'number' ? clamp(def.max_budget_usd, 0.05, 20) : null,
363
- vendor: vendorOf(model, def.vendor ?? null),
528
+ fallbackModel: typeof def.fallback_model === 'string' && def.fallback_model.trim() !== '' ? def.fallback_model.trim() : null,
529
+ vendor: vendorOf(model, def.vendor),
364
530
  enabled: def.enabled !== false,
365
531
  note: typeof def.note === 'string' && def.note.trim() !== '' ? def.note.trim() : null,
366
532
  });
@@ -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
+ }
package/src/export.mjs CHANGED
@@ -13,7 +13,15 @@ import path from 'node:path';
13
13
  import { POLICY } from './policy.mjs';
14
14
  import { humanSize } from './expose-paths.mjs';
15
15
 
16
+ /** @typedef {import('./store.mjs').Round} Round A round parsed from the history files. */
17
+ /** @typedef {import('./store.mjs').BriefRecord} BriefRecord */
18
+ /** @typedef {import('./store.mjs').JobRecord} JobRecord */
19
+
16
20
  export class ExportError extends Error {
21
+ /**
22
+ * @param {string} message
23
+ * @param {string} code
24
+ */
17
25
  constructor(message, code) {
18
26
  super(message);
19
27
  this.name = 'ExportError';
@@ -23,6 +31,7 @@ export class ExportError extends Error {
23
31
 
24
32
  const historyDir = () => path.join(POLICY.home, 'history');
25
33
 
34
+ /** @param {string} p */
26
35
  function readJson(p) {
27
36
  try {
28
37
  return JSON.parse(fs.readFileSync(p, 'utf8'));
@@ -31,8 +40,13 @@ function readJson(p) {
31
40
  }
32
41
  }
33
42
 
43
+ /**
44
+ * @param {string} chainId
45
+ * @returns {Round[]}
46
+ */
34
47
  function roundsOf(chainId) {
35
48
  const dir = path.join(historyDir(), chainId);
49
+ /** @type {string[]} */
36
50
  let names = [];
37
51
  try {
38
52
  names = fs.readdirSync(dir);
@@ -49,6 +63,10 @@ function roundsOf(chainId) {
49
63
  // A fan-out is one question sent to several consultants, and each consultant
50
64
  // answers on its own chain. Nothing in the group file records which chain that
51
65
  // was, so the index -- one line per consultation -- is what maps them back.
66
+ /**
67
+ * @param {string} groupId
68
+ * @returns {string[]}
69
+ */
52
70
  function chainsOfGroup(groupId) {
53
71
  const group = readJson(path.join(historyDir(), 'groups', `${groupId}.json`));
54
72
  if (!group) throw new ExportError(`no fan-out with group_id "${groupId}" in ~/.severally/history`, 'unknown_group');
@@ -62,6 +80,7 @@ function chainsOfGroup(groupId) {
62
80
  } catch {
63
81
  /* an unreadable index leaves the group unresolvable, handled below */
64
82
  }
83
+ /** @type {string[]} */
65
84
  const chains = [];
66
85
  for (const jobId of group.job_ids ?? []) {
67
86
  const chainId = byJob.get(jobId);
@@ -73,16 +92,30 @@ function chainsOfGroup(groupId) {
73
92
  return chains;
74
93
  }
75
94
 
95
+ /**
96
+ * @param {string[] | null | undefined} items
97
+ * @param {(item: string) => string} render
98
+ */
76
99
  const bullets = (items, render) => (items ?? []).map(render);
77
100
 
101
+ /**
102
+ * @param {string} text
103
+ * @param {string | null | undefined} language
104
+ */
78
105
  function fence(text, language) {
79
106
  const marker = String(text).includes('```') ? '~~~' : '```';
80
107
  return [`${marker}${language ?? ''}`, text, marker];
81
108
  }
82
109
 
110
+ /** @param {BriefRecord | null | undefined} brief */
83
111
  function briefSection(brief) {
84
112
  if (!brief) return ['*(the brief was not recorded for this round)*', ''];
113
+ /** @type {string[]} */
85
114
  const out = [];
115
+ /**
116
+ * @param {string} title
117
+ * @param {string | string[] | null | undefined} body
118
+ */
86
119
  const block = (title, body) => {
87
120
  if (!body || (Array.isArray(body) && body.length === 0)) return;
88
121
  out.push(`**${title}**`, '');
@@ -128,6 +161,7 @@ function briefSection(brief) {
128
161
  : ', unchanged since the earlier round';
129
162
  out.push(`- ${e.exposed_as} (${e.kind}, ${e.files} file(s), ${humanSize(e.bytes)}${since}) -- from ${e.source}`);
130
163
  }
164
+ /** @param {string} reason */
131
165
  const count = (reason) => (exposed.skipped ?? []).filter((x) => x.reason === reason).length;
132
166
  if (count('symlink')) out.push(`- ${count('symlink')} symlink(s) inside those directories were skipped, not followed`);
133
167
  if (count('protected')) out.push(`- ${count('protected')} credential or history path(s) were left out on purpose`);
@@ -143,6 +177,10 @@ function briefSection(brief) {
143
177
  // The lead's own line, printed under the point it belongs to. An id with no
144
178
  // entry says so out loud: "nobody looked at this" is the thing a record of a
145
179
  // decision most easily loses.
180
+ /**
181
+ * @param {JobRecord | null | undefined} record
182
+ * @param {string} id
183
+ */
146
184
  function verdictLine(record, id) {
147
185
  const entry = (record?.entries ?? []).find((e) => e.id === id);
148
186
  if (!entry) return ' - checked: no verdict recorded';
@@ -150,6 +188,7 @@ function verdictLine(record, id) {
150
188
  return ` - checked: ${entry.verdict}${tail ? ` -- ${tail}` : ''}`;
151
189
  }
152
190
 
191
+ /** @param {Round} round */
153
192
  function answerSection(round) {
154
193
  const r = round.result;
155
194
  if (!r) {
@@ -160,6 +199,7 @@ function answerSection(round) {
160
199
  '',
161
200
  ];
162
201
  }
202
+ /** @type {string[]} */
163
203
  const out = [];
164
204
  const record = round.record;
165
205
  out.push('**Summary**', '', r.summary, '');
@@ -227,6 +267,10 @@ function answerSection(round) {
227
267
  // A fan-out sends the byte-identical brief to every consultant, so printing it
228
268
  // once per consultant buries the answers in repetition. Compared, not judged:
229
269
  // identical text is named as identical, anything else is printed in full.
270
+ /**
271
+ * @param {Round} round
272
+ * @param {BriefRecord | null} previousBrief
273
+ */
230
274
  function roundSection(round, previousBrief) {
231
275
  const secs = round.duration_ms == null ? null : (round.duration_ms / 1000).toFixed(1);
232
276
  const head = [
@@ -269,21 +313,20 @@ function roundSection(round, previousBrief) {
269
313
  }
270
314
 
271
315
  /**
272
- * @param {{chain_id?: string, group_id?: string}} target
316
+ * @param {{chain_id?: string | undefined, group_id?: string | undefined}} target
273
317
  * @returns {{markdown: string, rounds: number, chain_ids: string[]}}
274
318
  */
275
319
  export function exportChain({ chain_id: chainId, group_id: groupId } = {}) {
276
320
  if (Boolean(chainId) === Boolean(groupId)) {
277
321
  throw new ExportError('pass exactly one of chain_id or group_id', 'export_target_required');
278
322
  }
279
- const chainIds = groupId ? chainsOfGroup(groupId) : [chainId];
323
+ const chainIds = groupId ? chainsOfGroup(groupId) : chainId ? [chainId] : [];
280
324
  const perChain = chainIds.map((id) => ({ chain_id: id, rounds: roundsOf(id) }));
281
- const total = perChain.reduce((n, c) => n + c.rounds.length, 0);
282
- if (total === 0) {
325
+ const first = perChain.find((c) => c.rounds.length)?.rounds[0];
326
+ if (!first) {
283
327
  throw new ExportError(`no consultation recorded under "${chainId ?? groupId}" in ~/.severally/history`, 'unknown_chain');
284
328
  }
285
-
286
- const first = perChain.find((c) => c.rounds.length)?.rounds[0];
329
+ const total = perChain.reduce((n, c) => n + c.rounds.length, 0);
287
330
  const lines = [
288
331
  `# Consultation record: ${first.question}`,
289
332
  '',
@@ -296,6 +339,7 @@ export function exportChain({ chain_id: chainId, group_id: groupId } = {}) {
296
339
  'ways, both are printed and the difference is left standing.',
297
340
  '',
298
341
  ];
342
+ /** @type {BriefRecord | null} */
299
343
  let previousBrief = null;
300
344
  for (const chain of perChain) {
301
345
  for (const round of chain.rounds) {