severally-mcp 2.4.0 → 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 (40) hide show
  1. package/README.ja.md +44 -19
  2. package/README.md +44 -18
  3. package/package.json +3 -1
  4. package/plugins/severally/.claude-plugin/plugin.json +1 -1
  5. package/plugins/severally/.codex-plugin/plugin.json +1 -1
  6. package/plugins/severally/dist/severally-mcp.mjs +585 -255
  7. package/plugins/severally/skills/_template/reference.md.tmpl +2 -2
  8. package/plugins/severally/skills/antigravity/severally/reference.md +2 -2
  9. package/plugins/severally/skills/claude/severally/reference.md +2 -2
  10. package/plugins/severally/skills/codex/severally/reference.md +2 -2
  11. package/plugins/severally/skills/opencode/severally/reference.md +2 -2
  12. package/scripts/build.mjs +57 -54
  13. package/scripts/doctor.mjs +15 -4
  14. package/scripts/install.mjs +73 -9
  15. package/scripts/live-check.mjs +11 -5
  16. package/scripts/live-mcp-check.mjs +10 -4
  17. package/src/adapters/antigravity-sandbox.mjs +24 -6
  18. package/src/adapters/antigravity.mjs +80 -17
  19. package/src/adapters/claude-code.mjs +49 -8
  20. package/src/adapters/codex.mjs +56 -6
  21. package/src/adapters/opencode-sandbox.mjs +20 -2
  22. package/src/adapters/opencode.mjs +115 -19
  23. package/src/brief.mjs +84 -7
  24. package/src/consultants.mjs +184 -25
  25. package/src/cross-spawn-escape.d.ts +7 -0
  26. package/src/export.mjs +50 -6
  27. package/src/expose-paths.mjs +149 -7
  28. package/src/failures.mjs +8 -1
  29. package/src/jobs.mjs +578 -132
  30. package/src/jsonc.mjs +11 -3
  31. package/src/parse-result.mjs +91 -14
  32. package/src/platform.mjs +52 -2
  33. package/src/policy.mjs +151 -28
  34. package/src/redact.mjs +22 -3
  35. package/src/result-schema.mjs +13 -0
  36. package/src/run.mjs +158 -12
  37. package/src/schema.mjs +84 -11
  38. package/src/server.mjs +49 -14
  39. package/src/status.mjs +16 -4
  40. package/src/store.mjs +287 -6
@@ -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,10 +46,15 @@ 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
@@ -49,7 +84,10 @@ const STEM_VENDORS = table({
49
84
  // Built-in "higher first" orders, each used only inside one model family: an
50
85
  // "ultra" Nemotron is not above a "flash" DeepSeek. `family` says which
51
86
  // candidates a list may compare; a family not listed here is never guessed at.
87
+ /** @param {readonly Consultant[]} cands */
52
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[]} */
53
91
  const TIERS = Object.freeze([
54
92
  { words: ['opus', 'sonnet', 'haiku'], family: (cands) => cands.every((c) => modelStem(c.model)[0] === 'claude') },
55
93
  { words: ['ultra', 'pro', 'flash', 'lite'], family: (cands) => cands.every((c) => modelStem(c.model)[0] === 'gemini') },
@@ -59,7 +97,12 @@ const TIERS = Object.freeze([
59
97
 
60
98
  export const RESERVED_NAMES = Object.freeze(['all']);
61
99
 
62
- /** 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
+ */
63
106
  export function normalizeName(raw) {
64
107
  if (typeof raw !== 'string') return raw;
65
108
  return raw
@@ -68,7 +111,11 @@ export function normalizeName(raw) {
68
111
  .replace(/[\s_]+/g, '-');
69
112
  }
70
113
 
71
- /** "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
+ */
72
119
  export function modelWords(model) {
73
120
  const s = String(model ?? '').toLowerCase();
74
121
  return s
@@ -77,26 +124,42 @@ export function modelWords(model) {
77
124
  .filter(Boolean);
78
125
  }
79
126
 
127
+ /** @param {string} w */
80
128
  const isDigitWord = (w) => /^v?\d+$/.test(w);
81
129
 
82
- /** 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
+ */
83
134
  export function modelStem(model) {
84
135
  const words = modelWords(model);
85
136
  const at = words.findIndex(isDigitWord);
86
137
  return at === -1 ? words : words.slice(0, at);
87
138
  }
88
139
 
89
- /** 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
+ */
90
145
  export function modelVersion(model) {
91
146
  const words = modelWords(model);
92
147
  const at = words.findIndex(isDigitWord);
93
148
  if (at === -1) return null;
94
149
  const digits = [];
95
- 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
+ }
96
154
  return digits;
97
155
  }
98
156
 
99
- /** 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
+ */
100
163
  export function vendorOf(model, explicit = null) {
101
164
  if (typeof explicit === 'string' && explicit.trim() !== '') return explicit.trim().toLowerCase();
102
165
  const first = modelStem(model)[0] ?? '';
@@ -104,13 +167,25 @@ export function vendorOf(model, explicit = null) {
104
167
  return STEM_VENDORS[first] ?? null;
105
168
  }
106
169
 
170
+ /**
171
+ * @param {string[]} hay
172
+ * @param {string[]} needle
173
+ */
107
174
  const containsRun = (hay, needle) => needle.length > 0 && hay.some((_, i) => needle.every((w, j) => hay[i + j] === w));
108
175
 
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. */
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
+ */
110
181
  export function matchesWords(model, wanted) {
111
182
  return containsRun(modelWords(model), modelWords(wanted));
112
183
  }
113
184
 
185
+ /**
186
+ * @param {number[]} a
187
+ * @param {number[]} b
188
+ */
114
189
  const compareVersions = (a, b) => {
115
190
  for (let i = 0; i < Math.max(a.length, b.length); i++) {
116
191
  const d = (a[i] ?? -1) - (b[i] ?? -1);
@@ -119,7 +194,12 @@ const compareVersions = (a, b) => {
119
194
  return 0;
120
195
  };
121
196
 
197
+ /**
198
+ * @param {readonly Consultant[]} cands
199
+ * @param {readonly string[]} ranking
200
+ */
122
201
  function byRanking(cands, ranking) {
202
+ /** @param {Consultant} c */
123
203
  const index = (c) => {
124
204
  const i = ranking.findIndex((r) => normalizeName(r) === normalizeName(c.name) || matchesWords(c.model, r));
125
205
  return i === -1 ? Infinity : i;
@@ -128,11 +208,18 @@ function byRanking(cands, ranking) {
128
208
  return best === Infinity ? cands : cands.filter((c) => index(c) === best);
129
209
  }
130
210
 
211
+ /**
212
+ * @param {readonly Consultant[]} cands
213
+ * @param {Tier} tier
214
+ */
131
215
  function byTier(cands, tier) {
132
216
  if (!tier.family(cands)) return cands;
217
+ /** @param {Consultant} c */
133
218
  const rank = (c) => {
134
219
  const words = modelWords(c.model);
135
- 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));
136
223
  };
137
224
  const ranks = cands.map(rank);
138
225
  if (ranks.includes(-1) || new Set(ranks).size === 1) return cands;
@@ -140,18 +227,29 @@ function byTier(cands, tier) {
140
227
  return cands.filter((_, i) => ranks[i] === best);
141
228
  }
142
229
 
230
+ /** @param {readonly Consultant[]} cands */
143
231
  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);
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);
149
244
  }
150
245
 
151
246
  /**
152
247
  * One of several candidates, by the operator's ranking, then the built-in
153
248
  * tiers, then the version within one stem -- or null when none of those
154
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}
155
253
  */
156
254
  export function rankCandidates(cands, ranking = []) {
157
255
  let left = byRanking(cands, ranking);
@@ -160,13 +258,19 @@ export function rankCandidates(cands, ranking = []) {
160
258
  left = byTier(left, tier);
161
259
  }
162
260
  if (left.length > 1) left = byVersion(left);
163
- return left.length === 1 ? left[0] : null;
261
+ return left.length === 1 ? (left[0] ?? null) : null;
164
262
  }
165
263
 
264
+ /** @typedef {{ none: true, consultant?: undefined, ambiguous?: undefined } | { none?: undefined, consultant: Consultant, ambiguous?: undefined } | { none?: undefined, consultant?: undefined, ambiguous: readonly Consultant[] }} Resolution */
265
+
166
266
  /**
167
267
  * What a requested name means: a consultant's own name, then a CLI name, then
168
268
  * a vendor word, then the words of a model id. Several candidates are ranked;
169
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}
170
274
  */
171
275
  export function resolveConsultant(input, consultants, ranking = []) {
172
276
  const key = normalizeName(input);
@@ -177,8 +281,9 @@ export function resolveConsultant(input, consultants, ranking = []) {
177
281
  if (CLI_ALIASES[key]) cands = consultants.filter((c) => c.cli === CLI_ALIASES[key]);
178
282
  else if (VENDOR_WORDS[key]) cands = consultants.filter((c) => c.vendor === VENDOR_WORDS[key]);
179
283
  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] };
284
+ const [only] = cands;
285
+ if (!only) return { none: true };
286
+ if (cands.length === 1) return { consultant: only };
182
287
  const winner = rankCandidates(cands, ranking);
183
288
  return winner ? { consultant: winner } : { ambiguous: cands };
184
289
  }
@@ -188,13 +293,23 @@ export function resolveConsultant(input, consultants, ranking = []) {
188
293
  * CLI is checked once, through its first available consultant -- and a CLI
189
294
  * name asks for exactly that one, never an ambiguity between its consultants.
190
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}
191
301
  */
192
302
  export function checkTargets(arg, available, all, ranking = []) {
303
+ /** @type {Map<string, Consultant>} */
193
304
  const first = new Map();
194
305
  for (const c of available) if (!first.has(c.cli)) first.set(c.cli, c);
195
306
  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;
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
+ }
198
313
  const r = resolveConsultant(arg, all, ranking);
199
314
  return r.consultant ? [r.consultant] : null;
200
315
  }
@@ -204,6 +319,9 @@ export function checkTargets(arg, available, all, ranking = []) {
204
319
  * the words agree exactly; one being a prefix-like part of the other
205
320
  * ("claude-opus-5" in "claude-opus-5-5") is "unknown", because saying either
206
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'}
207
325
  */
208
326
  export function sameModel(callerModel, consultantModel) {
209
327
  const a = modelWords(callerModel);
@@ -216,7 +334,16 @@ export function sameModel(callerModel, consultantModel) {
216
334
  const TOP_KEYS = ['server', 'clis', 'consultants', 'ranking'];
217
335
  const CLI_KEYS = ['enabled', 'note', 'bin', 'args', 'env', 'provider'];
218
336
  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
+ /**
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
+ */
220
347
  const clamp = (v, min, max) => Math.min(max, Math.max(min, v));
221
348
 
222
349
  /**
@@ -224,6 +351,8 @@ const clamp = (v, min, max) => Math.min(max, Math.max(min, v));
224
351
  * as a problem string. A config without `consultants` gets one consultant per
225
352
  * CLI, named after it, on that CLI's default model; whether it can run is the
226
353
  * CLI's availability, decided by policy.mjs.
354
+ * @param {unknown} config the parsed config file, before validation
355
+ * @returns {BuiltConsultants}
227
356
  */
228
357
  export function buildConsultants(config) {
229
358
  // `problems` is every mistake, for the operator to read; `global` the ones
@@ -231,10 +360,15 @@ export function buildConsultants(config) {
231
360
  // or one CLI is kept to it: that consultant cannot be used (`broken`), that
232
361
  // CLI is off (`brokenClis`) -- the closed side either way, so nothing the
233
362
  // operator turned off comes back on.
363
+ /** @type {string[]} */
234
364
  const problems = [];
365
+ /** @type {string[]} */
235
366
  const global = [];
367
+ /** @type {Record<string, string[]>} */
236
368
  const broken = {};
369
+ /** @type {Record<string, string[]>} */
237
370
  const brokenClis = {};
371
+ /** @param {string} msg */
238
372
  const fault = (msg) => {
239
373
  problems.push(msg);
240
374
  global.push(msg);
@@ -247,7 +381,9 @@ export function buildConsultants(config) {
247
381
 
248
382
  // The one server key that is not a number to clamp: a wrong value cannot
249
383
  // 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)) {
384
+ /** @type {readonly unknown[]} */
385
+ const networkModes = ['on', 'off'];
386
+ if (isObject(cfg.server) && cfg.server.network !== undefined && !networkModes.includes(cfg.server.network)) {
251
387
  fault('server.network must be "on" or "off"');
252
388
  }
253
389
 
@@ -258,11 +394,13 @@ export function buildConsultants(config) {
258
394
  else fault('ranking must be an array of non-empty strings');
259
395
  }
260
396
 
397
+ /** @type {readonly unknown[]} */
398
+ const cliIds = CLI_IDS;
261
399
  if (cfg.clis !== undefined) {
262
400
  if (!isObject(cfg.clis)) fault('clis must be an object keyed by CLI');
263
401
  else {
264
402
  for (const [id, def] of Object.entries(cfg.clis)) {
265
- if (!CLI_IDS.includes(id)) {
403
+ if (!cliIds.includes(id)) {
266
404
  problems.push(`clis.${id}: unknown CLI (known: ${CLI_IDS.join(', ')})`);
267
405
  continue;
268
406
  }
@@ -294,6 +432,7 @@ export function buildConsultants(config) {
294
432
  }
295
433
  }
296
434
 
435
+ /** @type {Consultant[]} */
297
436
  const consultants = [];
298
437
  if (cfg.consultants === undefined) {
299
438
  for (const cli of CLI_IDS) consultants.push(make(cli, { cli, model: DEFAULT_MODELS[cli] }));
@@ -319,7 +458,7 @@ export function buildConsultants(config) {
319
458
  }
320
459
  for (const k of Object.keys(def))
321
460
  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(', ')}`);
461
+ if (!cliIds.includes(def.cli)) problems.push(`${at}.cli: must be one of ${CLI_IDS.join(', ')}`);
323
462
  if (typeof def.model !== 'string' || def.model.trim() === '') problems.push(`${at}.model: required`);
324
463
  for (const k of ['effort', 'vendor', 'note']) {
325
464
  if (def[k] !== undefined && typeof def[k] !== 'string') problems.push(`${at}.${k}: must be a string`);
@@ -338,19 +477,39 @@ export function buildConsultants(config) {
338
477
  broken[name] = problems.slice(before);
339
478
  continue;
340
479
  }
341
- const pair = `${def.cli}\n${def.model.trim()}`;
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()}`;
342
483
  if (seen.has(pair)) {
343
484
  problems.push(`${at}: same cli and model as consultants.${seen.get(pair)}; asking one model twice adds no independence`);
344
485
  broken[name] = problems.slice(before);
345
486
  continue;
346
487
  }
347
488
  seen.set(pair, name);
348
- consultants.push(make(name, def));
489
+ consultants.push(make(name, valid));
349
490
  }
350
491
  }
351
492
  return { consultants: Object.freeze(consultants), ranking: Object.freeze(ranking), problems, global, broken, brokenClis };
352
493
  }
353
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
+ */
354
513
  function make(name, def) {
355
514
  const model = def.model.trim();
356
515
  return Object.freeze({
@@ -360,7 +519,7 @@ function make(name, def) {
360
519
  effort: typeof def.effort === 'string' && def.effort.trim() !== '' ? def.effort.trim() : null,
361
520
  timeoutMs: typeof def.timeout_ms === 'number' ? clamp(def.timeout_ms, 1_000, 7_200_000) : null,
362
521
  maxBudgetUsd: typeof def.max_budget_usd === 'number' ? clamp(def.max_budget_usd, 0.05, 20) : null,
363
- vendor: vendorOf(model, def.vendor ?? null),
522
+ vendor: vendorOf(model, def.vendor),
364
523
  enabled: def.enabled !== false,
365
524
  note: typeof def.note === 'string' && def.note.trim() !== '' ? def.note.trim() : null,
366
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
+ }
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) {