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
package/src/run.mjs CHANGED
@@ -46,6 +46,7 @@ const DROP_PREFIX = ['CLAUDE_CODE_', 'SEVERALLY_', 'MCP_'];
46
46
  // The names came out of the binary with `strings`; only XDG_CONFIG_HOME was
47
47
  // probed behaviourally, so read the AGY_* line as "no evidence of a
48
48
  // config-dir override under that prefix", not as proof that none exists.
49
+ /** @type {Record<string, string[]>} */
49
50
  const TARGET_DROP_PREFIX = {
50
51
  codex: ['ANTHROPIC_', 'GEMINI_', 'GOOGLE_', 'AGY_', 'ANTIGRAVITY_', 'ZAI_'],
51
52
  'claude-code': ['OPENAI_', 'CODEX_', 'GEMINI_', 'GOOGLE_', 'AGY_', 'ANTIGRAVITY_', 'ZAI_'],
@@ -62,6 +63,7 @@ const TARGET_DROP_PREFIX = {
62
63
  // Exact names dropped for one target only (see the note above). Kept separate
63
64
  // from DROP_EXACT so the two other consultants, whose isolation has been
64
65
  // verified with these variables present, are not changed.
66
+ /** @type {Record<string, string[]>} */
65
67
  const TARGET_DROP_EXACT = {
66
68
  antigravity: ['XDG_CONFIG_HOME'],
67
69
  };
@@ -71,10 +73,15 @@ const TARGET_DROP_EXACT = {
71
73
  * sockets, session ids, entrypoint markers) is removed, as are the other two
72
74
  * vendors' credentials -- each consultant keeps only its own; and a recursion
73
75
  * marker is added.
76
+ * @param {string} target
77
+ * @param {Record<string, string>} [extra]
78
+ * @param {Record<string, string>} [operator]
79
+ * @returns {Record<string, string>}
74
80
  */
75
81
  export function childEnv(target, extra = {}, operator = {}) {
76
82
  const { dropPrefixes, dropExact } = dropRules(target);
77
83
  const keep = providerEnvNames(target);
84
+ /** @type {Record<string, string>} */
78
85
  const env = {};
79
86
  for (const [k, v] of Object.entries(process.env)) {
80
87
  if (v === undefined) continue;
@@ -101,6 +108,7 @@ export function childEnv(target, extra = {}, operator = {}) {
101
108
  const SECRET_PREFIX = ['AWS_', 'AZURE_', 'GITHUB_', 'GH_', 'GITLAB_', 'NPM_', 'DOCKER_', 'HF_', 'SLACK_', 'STRIPE_', 'OP_'];
102
109
  const SECRET_SUFFIX = /(TOKEN|SECRET|PASSWORD|PASSWD|PASSPHRASE|API_?KEY|ACCESS_KEY|PRIVATE_KEY|CREDENTIALS?)$/i;
103
110
  const SECRET_EXACT = new Set(['SSH_AUTH_SOCK', 'KUBECONFIG', 'DATABASE_URL']);
111
+ /** @type {Record<string, string[]>} */
104
112
  const OWN_PREFIX = {
105
113
  codex: ['OPENAI_', 'CODEX_'],
106
114
  'claude-code': ['ANTHROPIC_'],
@@ -110,17 +118,25 @@ const OWN_PREFIX = {
110
118
 
111
119
  // Names the operator's OpenCode provider definitions read as "{env:NAME}":
112
120
  // the consultant needs them, wherever they were exported.
121
+ /** @param {string} target */
113
122
  function providerEnvNames(target) {
114
- const provider = JSON.stringify(POLICY.clis?.[target]?.provider ?? {});
115
- return new Set([...provider.matchAll(/\{env:([A-Za-z_][A-Za-z0-9_]*)\}/g)].map((m) => m[1]));
123
+ // Only OpenCode's section carries provider definitions.
124
+ const provider = JSON.stringify(target === 'opencode' ? POLICY.clis.opencode.provider : {});
125
+ return new Set([...provider.matchAll(/\{env:([A-Za-z_][A-Za-z0-9_]*)\}/g)].flatMap(([, name]) => name ?? []));
116
126
  }
117
127
 
128
+ /**
129
+ * @param {string} target
130
+ * @param {string} key
131
+ * @param {Set<string>} keep
132
+ */
118
133
  function foreignSecret(target, key, keep) {
119
134
  if (keep.has(key) || (OWN_PREFIX[target] ?? []).some((p) => key.startsWith(p))) return false;
120
135
  const upper = key.toUpperCase();
121
136
  return SECRET_EXACT.has(upper) || SECRET_PREFIX.some((p) => upper.startsWith(p)) || SECRET_SUFFIX.test(key);
122
137
  }
123
138
 
139
+ /** @param {string} target */
124
140
  function dropRules(target) {
125
141
  return {
126
142
  dropPrefixes: [...DROP_PREFIX, ...(TARGET_DROP_PREFIX[target] ?? [])],
@@ -136,11 +152,15 @@ function dropRules(target) {
136
152
  * by config is refused rather than honoured; so is any name the per-job
137
153
  * sandbox sets (HOME and friends), which would otherwise be silently
138
154
  * overwritten.
155
+ * @param {string} target
156
+ * @param {unknown} env
157
+ * @param {string[]} [sandboxKeys]
158
+ * @returns {string | null}
139
159
  */
140
160
  export function operatorEnvProblem(target, env, sandboxKeys = []) {
141
161
  if (env === null || typeof env !== 'object' || Array.isArray(env)) return 'must be an object of string values';
142
162
  const { dropPrefixes, dropExact } = dropRules(target);
143
- const fold = (k) => (process.platform === 'win32' ? k.toUpperCase() : k);
163
+ const fold = (/** @type {string} */ k) => (process.platform === 'win32' ? k.toUpperCase() : k);
144
164
  const reserved = new Set(sandboxKeys.map(fold));
145
165
  for (const [k, v] of Object.entries(env)) {
146
166
  if (typeof v !== 'string') return `${k} must be a string`;
@@ -153,6 +173,10 @@ export function operatorEnvProblem(target, env, sandboxKeys = []) {
153
173
  }
154
174
 
155
175
  export class ProcHandle {
176
+ /**
177
+ * @param {import('node:child_process').ChildProcess} child
178
+ * @param {() => string} [readStdout]
179
+ */
156
180
  constructor(child, readStdout = () => '') {
157
181
  this.child = child;
158
182
  this.killed = false;
@@ -165,6 +189,7 @@ export class ProcHandle {
165
189
  * Kill the child and every descendant it spawned -- also after the child
166
190
  * itself has exited: a descendant still holding its pipes keeps the
167
191
  * consultation from closing, and on POSIX it is still in the group.
192
+ * @param {NodeJS.Signals} [signal]
168
193
  */
169
194
  stop(signal = 'SIGTERM') {
170
195
  if (this.killed || !this.child) return;
@@ -225,13 +250,131 @@ export class ProcHandle {
225
250
  }
226
251
 
227
252
  /**
228
- * @returns {{handle: ProcHandle, done: Promise<{code, signal, stdout, stderr, timedOut, cancelled, truncated}>}}
253
+ * @typedef {object} ChildResult
254
+ * @property {number | null} code
255
+ * @property {NodeJS.Signals | null} signal
256
+ * @property {string} stdout
257
+ * @property {string} stderr
258
+ * @property {boolean} truncated
259
+ * @property {boolean} timedOut
260
+ * @property {boolean} cancelled
261
+ * @property {boolean} stalled
262
+ * @property {boolean} pipesForced
263
+ * @property {string} [spawnError]
264
+ */
265
+
266
+ /**
267
+ * What an adapter's buildInvocation returns.
268
+ * @typedef {object} Invocation
269
+ * @property {string} command
270
+ * @property {string[]} args
271
+ * @property {string} model
272
+ * @property {string} [workdir]
273
+ * @property {string} [lastMessagePath]
274
+ */
275
+
276
+ /**
277
+ * The synthesised HOME of one consultation, as an adapter's prepareSandbox
278
+ * returns it; what the adapters share of it.
279
+ * @typedef {object} Sandbox
280
+ * @property {string} root
281
+ * @property {string} credentials 'symlink', 'copy' or 'missing'
282
+ * @property {string} credentialsSource
283
+ * @property {readonly string[]} tools
284
+ * @property {Record<string, string>} env
285
+ * @property {() => void} cleanup
286
+ * @property {(provider: string) => boolean} [seed] insert the provider's credential into the session database (OpenCode only)
287
+ */
288
+
289
+ /**
290
+ * What a consultant was launched with, read off the invocation: the boundary
291
+ * record kept beside each answer. Facts about the launch, not a claim about
292
+ * what the consultant did.
293
+ * @typedef {object} Boundary
294
+ * @property {string} reads
295
+ * @property {string} shell
296
+ * @property {boolean} web
297
+ * @property {boolean} writes
298
+ * @property {boolean} mcp
299
+ * @property {string} protected_paths
300
+ * @property {string[] | null} tools
229
301
  */
302
+
303
+ /**
304
+ * What a consultation used, as far as its CLI says; null where it does not say.
305
+ * @typedef {object} UsageRecord
306
+ * @property {number | null} input_tokens
307
+ * @property {number | null} cached_input_tokens
308
+ * @property {number | null} output_tokens
309
+ * @property {number | null} total_tokens
310
+ * @property {number | null} cost_usd
311
+ * @property {number | null} web_search_requests
312
+ * @property {number | null} [turns]
313
+ * @property {number | null} [thinking_tokens]
314
+ * @property {number | null} [reasoning_tokens]
315
+ * @property {string[] | null} [denied_actions]
316
+ */
317
+
318
+ /**
319
+ * What jobs.mjs hands buildInvocation; each adapter reads the fields it needs.
320
+ * @typedef {object} BuildRequest
321
+ * @property {string} workdir
322
+ * @property {string} schemaPath
323
+ * @property {string} guardrails
324
+ * @property {Sandbox | null} sandbox the adapter's own prepareSandbox result, if it has one
325
+ * @property {import('./consultants.mjs').Consultant} consultant
326
+ * @property {boolean} [hasExposedPaths]
327
+ * @property {'on' | 'off' | undefined} [network]
328
+ */
329
+
330
+ /**
331
+ * What an adapter's interpret makes of a finished child: an answer (text is a
332
+ * JSON object or the raw text to mine one out of), or a classified failure.
333
+ * @typedef {{ ok: true, text: string | Record<string, unknown>, usageRaw: unknown, payload?: unknown, events?: number, denials?: unknown[], recoveredError?: { failureKind: string, message: string } | null, earlierTextParts?: string[] }} InterpretedOk
334
+ * @typedef {{ ok: false, failureKind: string, message: string, usageRaw: unknown, payload?: unknown, events?: number }} InterpretedFail
335
+ * @typedef {InterpretedOk | InterpretedFail} Interpreted
336
+ */
337
+
338
+ /**
339
+ * The shape every adapter module (codex, claude-code, antigravity, opencode)
340
+ * exports; the optional members are the ones only some CLIs have.
341
+ * @typedef {object} Adapter
342
+ * @property {readonly string[]} FORBIDDEN_FLAGS
343
+ * @property {readonly string[]} RESERVED_FLAGS
344
+ * @property {(req: BuildRequest) => Invocation} buildInvocation
345
+ * @property {(run: Partial<ChildResult> & { lastMessageText?: string }) => Interpreted} interpret
346
+ * @property {(raw: unknown) => UsageRecord | null} usageRecord
347
+ * @property {(args: { args: string[], sandbox: Sandbox | null, network?: 'on' | 'off' | undefined, hasExposedPaths?: boolean }) => Boundary} [boundary]
348
+ * @property {(run: { stdout: string }) => string | null} [progress]
349
+ * @property {(run: { stdout: string }) => Record<string, number> | null} [toolCalls]
350
+ * @property {readonly string[]} [FILE_READ_TOOLS] the names toolCalls reports for reading a file
351
+ * @property {readonly string[]} [ALLOWED_TOOLS] the only tools the consultant may call; any other is refused
352
+ * @property {() => Promise<void>} [prepare]
353
+ * @property {(args: { workdir: string, network?: 'on' | 'off' | undefined }) => Sandbox} [prepareSandbox]
354
+ * @property {(args: { run: ChildResult, sandbox: Sandbox }) => string} [diagnostics]
355
+ * @property {(args: { interpreted: Interpreted, sandbox: Sandbox, model: string }) => boolean} [recoverAuthFailure]
356
+ * @property {(line: string) => string} [compactLine]
357
+ * @property {(line: string) => number | null | undefined} [settleAfter]
358
+ * @property {NodeJS.Signals} [stopSignal]
359
+ */
360
+
230
361
  /**
231
362
  * `pipesForced` in the result: the child exited but something it started kept
232
363
  * stdout or stderr open past the kill grace, so the group was stopped and the
233
364
  * pipes closed from this side. A descendant outside the group (setsid, or on
234
365
  * Windows once the parent is gone) may still be running.
366
+ * @param {object} options
367
+ * @param {string} options.command
368
+ * @param {readonly string[]} options.args
369
+ * @param {string} [options.cwd]
370
+ * @param {NodeJS.ProcessEnv} [options.env]
371
+ * @param {string | null} [options.input]
372
+ * @param {number} options.timeoutMs
373
+ * @param {(cancel: () => void) => void} [options.onCancelSignal]
374
+ * @param {((line: string) => string) | null} [options.compactLine]
375
+ * @param {((line: string) => number | null | undefined) | null} [options.settleAfter]
376
+ * @param {NodeJS.Signals} [options.stopSignal]
377
+ * @returns {{handle: ProcHandle, done: Promise<ChildResult>, pid: number | undefined}}
235
378
  */
236
379
  export function runChild({
237
380
  command,
@@ -267,10 +410,11 @@ export function runChild({
267
410
  // says it is done; past that it is stopped and `stalled` set. A later line
268
411
  // can shorten the wait, never lengthen it.
269
412
  let stalled = false;
270
- let settleTimer = null;
413
+ /** @type {NodeJS.Timeout | undefined} */
414
+ let settleTimer;
271
415
  let settleAt = Infinity;
272
- const watch = (line) => {
273
- const ms = settleAfter(line);
416
+ const watch = (/** @type {string} */ line) => {
417
+ const ms = settleAfter?.(line);
274
418
  if (ms == null || Date.now() + ms >= settleAt) return;
275
419
  settleAt = Date.now() + ms;
276
420
  clearTimeout(settleTimer);
@@ -280,7 +424,7 @@ export function runChild({
280
424
  }, ms);
281
425
  };
282
426
 
283
- const append = (which, chunk) => {
427
+ const append = (/** @type {'out' | 'err'} */ which, /** @type {string | Buffer} */ chunk) => {
284
428
  const text = chunk.toString('utf8');
285
429
  if (which === 'out') {
286
430
  if (stdout.length >= cap) {
@@ -304,10 +448,10 @@ export function runChild({
304
448
  // partial line is bounded by the cap too; past it, the line is kept as is.
305
449
  let pending = '';
306
450
  const byLine = Boolean(compactLine || settleAfter);
307
- const compact = compactLine ?? ((l) => l);
451
+ const compact = compactLine ?? ((/** @type {string} */ l) => l);
308
452
  const flushLines = () => {
309
453
  const lines = pending.split('\n');
310
- pending = lines.pop();
454
+ pending = lines.pop() ?? '';
311
455
  for (const l of lines) {
312
456
  if (settleAfter) watch(l);
313
457
  append('out', `${compact(l)}\n`);
@@ -340,11 +484,13 @@ export function runChild({
340
484
  });
341
485
  }
342
486
 
487
+ /** @type {Promise<ChildResult>} */
343
488
  const done = new Promise((resolve) => {
344
489
  let settled = false;
345
490
  let pipesForced = false;
346
- let orphanTimer = null;
347
- const finish = (payload) => {
491
+ /** @type {NodeJS.Timeout | undefined} */
492
+ let orphanTimer;
493
+ const finish = (/** @type {{ code: number | null, signal: NodeJS.Signals | null, spawnError?: string }} */ payload) => {
348
494
  if (settled) return;
349
495
  settled = true;
350
496
  clearTimeout(timer);
package/src/schema.mjs CHANGED
@@ -18,7 +18,14 @@ import {
18
18
  } from './policy.mjs';
19
19
  import { normalizeName, resolveConsultant } from './consultants.mjs';
20
20
 
21
+ /** @typedef {import('./consultants.mjs').Consultant} Consultant */
22
+ /** @typedef {import('./consultants.mjs').CliId} CliId */
23
+
21
24
  const L = POLICY.input;
25
+ /**
26
+ * @param {number} max
27
+ * @param {string} label
28
+ */
22
29
  const trimmed = (max, label) =>
23
30
  z.string().trim().min(1, `${label} must not be empty`).max(max, `${label} exceeds ${max} characters`);
24
31
 
@@ -144,10 +151,59 @@ export const requestSchema = z
144
151
  })
145
152
  .strict();
146
153
 
154
+ /**
155
+ * What charCount reads of a request, once parseRequest has filled in its defaults.
156
+ * @typedef {{
157
+ * question: string,
158
+ * objective?: string | null | undefined,
159
+ * constraints?: string[] | undefined,
160
+ * success_criteria?: string[] | undefined,
161
+ * context: { facts: string[], counterpoints: string[], proposal?: string | null | undefined, artifacts: { name: string, excerpt: string }[] },
162
+ * }} CountedRequest
163
+ */
164
+
165
+ /** @typedef {'explore' | 'review' | 'debate'} Mode */
166
+ /** @typedef {z.output<typeof requestSchema>} ValidRequest */
167
+ /** @typedef {z.output<typeof contextSchema>} ValidContext */
168
+
169
+ /**
170
+ * A request as parseRequest returns it: validated, defaults filled in, the
171
+ * consultant(s) resolved. `target` is the first of `targets`.
172
+ * @typedef {Omit<
173
+ * ValidRequest,
174
+ * 'target' | 'targets' | 'caller' | 'caller_model' | 'initiator' | 'followup_to' | 'mode' | 'network' | 'success_criteria' | 'constraints' | 'context'
175
+ * > & {
176
+ * target: string,
177
+ * targets: string[],
178
+ * fanout: boolean,
179
+ * caller: CliId | null,
180
+ * caller_model: string | null,
181
+ * initiator: string | null,
182
+ * followup_to: string | null,
183
+ * mode: Mode,
184
+ * network: 'on' | 'off',
185
+ * success_criteria: string[],
186
+ * constraints: string[],
187
+ * context: Omit<ValidContext, 'constraints' | 'success_criteria' | 'facts' | 'counterpoints' | 'artifacts' | 'proposal'> & {
188
+ * facts: string[],
189
+ * counterpoints: string[],
190
+ * artifacts: ValidContext['artifacts'] & {},
191
+ * proposal: string | null,
192
+ * },
193
+ * }} ParsedRequest
194
+ */
195
+
196
+ /**
197
+ * parseRequest's working copy: the request as it is until the defaults are filled in.
198
+ * `constraints` and `success_criteria` may still sit inside the context at first.
199
+ * @typedef {ParsedRequest & { context: { constraints?: string[] | undefined, success_criteria?: string[] | undefined } }} DraftRequest
200
+ */
201
+
202
+ /** @param {CountedRequest} req */
147
203
  export function charCount(req) {
148
204
  let n = req.question.length + (req.objective?.length ?? 0);
149
- for (const c of req.constraints) n += c.length;
150
- for (const c of req.success_criteria) n += c.length;
205
+ for (const c of req.constraints ?? []) n += c.length;
206
+ for (const c of req.success_criteria ?? []) n += c.length;
151
207
  for (const f of req.context.facts) n += f.length;
152
208
  for (const c of req.context.counterpoints) n += c.length;
153
209
  if (req.context.proposal) n += req.context.proposal.length;
@@ -156,6 +212,11 @@ export function charCount(req) {
156
212
  }
157
213
 
158
214
  export class RequestError extends Error {
215
+ /**
216
+ * @param {string} message
217
+ * @param {string} [code]
218
+ * @param {unknown[]} [details]
219
+ */
159
220
  constructor(message, code = 'invalid_request', details = undefined) {
160
221
  super(message);
161
222
  this.name = 'RequestError';
@@ -168,6 +229,9 @@ export class RequestError extends Error {
168
229
  * Validate + normalise a consult request.
169
230
  * `isFollowup` is decided by the caller (the job store resolves followup_to first);
170
231
  * `groupFollowup` when followup_to named a fan-out, whose members are the targets.
232
+ * @param {unknown} raw
233
+ * @param {{ isFollowup?: boolean, parentMode?: Mode | null, groupFollowup?: boolean }} [options]
234
+ * @returns {ParsedRequest}
171
235
  */
172
236
  export function parseRequest(raw, { isFollowup = false, parentMode = null, groupFollowup = false } = {}) {
173
237
  const parsed = requestSchema.safeParse(raw);
@@ -175,24 +239,31 @@ export function parseRequest(raw, { isFollowup = false, parentMode = null, group
175
239
  const issues = parsed.error.issues.map((i) => `${i.path.join('.') || '(root)'}: ${i.message}`);
176
240
  throw new RequestError(`request failed validation: ${issues.join('; ')}`, 'invalid_request', issues);
177
241
  }
178
- // Filled in below with what the request resolves to (targets, caller, ...).
179
- /** @type {Record<string, any>} */
180
- const req = parsed.data;
242
+ // Filled in below with what the request resolves to (targets, caller, ...), so
243
+ // until it returns it is only partly a ParsedRequest: the validated data is
244
+ // typed as the finished shape and every field the shape adds is set before the return.
245
+ const req = /** @type {DraftRequest} */ (parsed.data);
181
246
  // Left without a default in the schema, so that a follow-up can tell a list
182
247
  // it left out (inherited from the round before, jobs.mjs) from one it
183
248
  // emptied on purpose: the tool layer applies the schema's defaults first.
184
- for (const k of ['constraints', 'success_criteria']) {
185
- if (req.context[k] === undefined) continue;
249
+ /** @param {'constraints' | 'success_criteria'} k */
250
+ const hoist = (k) => {
251
+ const inner = req.context[k];
252
+ if (inner === undefined) return;
186
253
  if (req[k] !== undefined) {
187
254
  throw new RequestError(`pass ${k} once: at the top of the request or inside context, not both`, 'invalid_request');
188
255
  }
189
- req[k] = req.context[k];
256
+ req[k] = inner;
190
257
  delete req.context[k];
191
- }
258
+ };
259
+ hoist('constraints');
260
+ hoist('success_criteria');
192
261
  req.network ??= 'on';
193
262
  req.success_criteria ??= [];
194
263
  req.constraints ??= [];
195
- for (const k of ['facts', 'counterpoints', 'artifacts']) req.context[k] ??= [];
264
+ req.context.facts ??= [];
265
+ req.context.counterpoints ??= [];
266
+ req.context.artifacts ??= [];
196
267
  const hasSingle = req.target !== undefined && req.target !== null;
197
268
  const hasMany = Array.isArray(req.targets) && req.targets.length > 0;
198
269
  if (hasSingle && hasMany) {
@@ -208,12 +279,14 @@ export function parseRequest(raw, { isFollowup = false, parentMode = null, group
208
279
  'target_required',
209
280
  );
210
281
  }
282
+ /** @type {string[]} */
211
283
  const named = hasSingle ? [req.target] : req.targets;
212
284
  const isAll = named.map((n) => normalizeName(n) === 'all');
213
285
  if (isAll.some(Boolean) && named.length > 1) {
214
286
  throw new RequestError('"all" already means every available consultant; pass it alone', 'all_mixed');
215
287
  }
216
288
 
289
+ /** @type {Consultant[]} */
217
290
  let chosen;
218
291
  if (isAll[0]) {
219
292
  chosen = availableConsultants();
@@ -275,7 +348,7 @@ export function parseRequest(raw, { isFollowup = false, parentMode = null, group
275
348
  );
276
349
  }
277
350
  req.targets = unique;
278
- req.target = unique[0]; // single-target consumers (brief, history, adapters) keep working
351
+ req.target = /** @type {string} */ (unique[0]); // non-empty: every name in `named` resolved to a consultant; single-target consumers (brief, history, adapters) keep working
279
352
  req.fanout = unique.length > 1;
280
353
  req.caller = resolveCaller(req.caller ?? '') ?? null;
281
354
  req.caller_model = req.caller_model ?? null;
package/src/server.mjs CHANGED
@@ -9,7 +9,9 @@ import { requestSchema, RequestError } from './schema.mjs';
9
9
  import { JobManager, pointsOf } from './jobs.mjs';
10
10
  import { exportChain, ExportError } from './export.mjs';
11
11
 
12
- export const SEVERALLY_VERSION = '2.4.0';
12
+ /** @typedef {import('@modelcontextprotocol/server').CallToolResult} CallToolResult */
13
+
14
+ export const SEVERALLY_VERSION = '2.5.0';
13
15
 
14
16
  // consult_get waits this long when no wait_ms is given: a call that forgot it
15
17
  // would otherwise return at once and be repeated. Up to 45 s, under the 60 s
@@ -21,10 +23,19 @@ const DEFAULT_WAIT_MS = process.env.SEVERALLY_MAX_WAIT_MS ? POLICY.maxWaitMs : M
21
23
  // counts silence against the call keeps it open.
22
24
  const PROGRESS_EVERY_MS = 15_000;
23
25
 
26
+ /** @param {string} v */
24
27
  const semver = (v) => String(v).split('.').map(Number);
28
+ /**
29
+ * @param {string} a
30
+ * @param {string} b
31
+ */
25
32
  const older = (a, b) => {
26
33
  const [x, y] = [semver(a), semver(b)];
27
- for (let i = 0; i < 3; i++) if (x[i] !== y[i]) return x[i] < y[i];
34
+ for (let i = 0; i < 3; i++) {
35
+ const [p, q] = [x[i], y[i]];
36
+ if (p === undefined || q === undefined) return false;
37
+ if (p !== q) return p < q;
38
+ }
28
39
  return false;
29
40
  };
30
41
 
@@ -33,8 +44,11 @@ const older = (a, b) => {
33
44
  * from this server: a fix reaches a client only through the copy it runs, and
34
45
  * an operator who updated one copy cannot see that another is behind. Only
35
46
  * version strings are compared. A copy that is missing is not reported.
47
+ * @param {string} [home]
48
+ * @param {string} [severallyHome]
36
49
  */
37
50
  export function staleInstall(home = os.homedir(), severallyHome = POLICY.home) {
51
+ /** @param {string} file */
38
52
  const read = (file) => {
39
53
  try {
40
54
  return fs.readFileSync(file, 'utf8');
@@ -50,6 +64,7 @@ export function staleInstall(home = os.homedir(), severallyHome = POLICY.home) {
50
64
  // This release names it SEVERALLY_VERSION; older ones only had it in the server info.
51
65
  runtime: bundle?.match(/(?:SEVERALLY_VERSION = |name: ["']severally["'], version: )["'](\d+\.\d+\.\d+)["']/)?.[1],
52
66
  claude_skills_dir: (() => {
67
+ if (plugin === null) return undefined;
53
68
  try {
54
69
  return JSON.parse(plugin).version;
55
70
  } catch {
@@ -57,15 +72,18 @@ export function staleInstall(home = os.homedir(), severallyHome = POLICY.home) {
57
72
  }
58
73
  })(),
59
74
  };
75
+ /** @type {Record<string, string>} */
60
76
  const copies = { this_server: SEVERALLY_VERSION };
61
77
  // Only plain x.y.z is compared; anything else is left out rather than guessed at.
62
- for (const [name, v] of Object.entries(found)) if (/^\d+\.\d+\.\d+$/.test(v ?? '')) copies[name] = v;
78
+ for (const [name, v] of Object.entries(found)) if (v !== undefined && /^\d+\.\d+\.\d+$/.test(v)) copies[name] = v;
63
79
  const versions = Object.values(copies);
64
80
  if (versions.every((v) => v === SEVERALLY_VERSION)) return null;
65
81
  const newest = versions.reduce((a, b) => (older(a, b) ? b : a));
66
82
  return {
67
83
  copies,
68
- behind: Object.keys(copies).filter((k) => older(copies[k], newest)),
84
+ behind: Object.entries(copies)
85
+ .filter(([, v]) => older(v, newest))
86
+ .map(([k]) => k),
69
87
  fix:
70
88
  `bring every copy to ${newest}: run \`npx -y severally-mcp@${newest} install\` (or \`node scripts/install.mjs\` from a checkout of that version) ` +
71
89
  '(it updates the runtime and the ~/.claude/skills copy), run `/plugin marketplace update severally` for a ' +
@@ -149,15 +167,27 @@ const recordShape = {
149
167
  .describe('written after reading the answer'),
150
168
  };
151
169
 
170
+ /** @param {unknown} err */
171
+ const errorMessage = (err) =>
172
+ String((typeof err === 'object' && err !== null && 'message' in err ? err.message : undefined) ?? err);
173
+
152
174
  export function createServer(manager = new JobManager()) {
153
175
  // Read once: the copies on disk change only when someone reinstalls, and then
154
176
  // this server is the one to restart.
155
177
  const stale = staleInstall();
156
178
  const server = new McpServer({ name: 'severally', version: SEVERALLY_VERSION }, { instructions: SERVER_INSTRUCTIONS });
157
179
 
158
- const ok = (payload) => ({ content: [{ type: /** @type {const} */ ('text'), text: JSON.stringify(payload, null, 2) }] });
180
+ /**
181
+ * @param {unknown} payload
182
+ * @returns {CallToolResult}
183
+ */
184
+ const ok = (payload) => ({ content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }] });
185
+ /**
186
+ * @param {unknown} payload
187
+ * @returns {CallToolResult}
188
+ */
159
189
  const fail = (payload) => ({
160
- content: [{ type: /** @type {const} */ ('text'), text: JSON.stringify(payload, null, 2) }],
190
+ content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }],
161
191
  isError: true,
162
192
  });
163
193
 
@@ -177,7 +207,7 @@ export function createServer(manager = new JobManager()) {
177
207
  if (err instanceof RequestError) {
178
208
  return fail({ error: err.code, message: err.message, details: err.details ?? null, limits: limitsSummary() });
179
209
  }
180
- return fail({ error: 'internal_error', message: String(err?.message ?? err) });
210
+ return fail({ error: 'internal_error', message: errorMessage(err) });
181
211
  }
182
212
  },
183
213
  );
@@ -241,12 +271,13 @@ export function createServer(manager = new JobManager()) {
241
271
  }, PROGRESS_EVERY_MS);
242
272
  let view;
243
273
  try {
244
- // A request the client cancels stops the wait with it.
274
+ // A request the client cancels stops the wait with it. Without group_id,
275
+ // job_id is set: exactly one of the two was checked above.
245
276
  view = group_id
246
277
  ? await manager.waitGroup(group_id, wait_ms ?? DEFAULT_WAIT_MS, { detail }, req?.signal)
247
- : await manager.wait(job_id, wait_ms ?? DEFAULT_WAIT_MS, { detail }, req?.signal);
278
+ : await manager.wait(/** @type {string} */ (job_id), wait_ms ?? DEFAULT_WAIT_MS, { detail }, req?.signal);
248
279
  } finally {
249
- clearInterval(timer);
280
+ clearInterval(timer ?? undefined);
250
281
  }
251
282
  if (!view) {
252
283
  return fail({
@@ -257,7 +288,8 @@ export function createServer(manager = new JobManager()) {
257
288
  // Still running: the poll answer as it is; ids applies once there is an answer.
258
289
  if (!ids || ['running', 'queued', 'cancelling'].includes(view.status)) return ok(view);
259
290
  try {
260
- return ok(pointsOf(view, ids));
291
+ // ids comes with job_id alone (checked above), so this is one job's view.
292
+ return ok(pointsOf(/** @type {import('./jobs.mjs').JobView} */ (view), ids));
261
293
  } catch (err) {
262
294
  if (err instanceof RequestError) return fail({ error: err.code, message: err.message });
263
295
  throw err;
@@ -283,7 +315,8 @@ export function createServer(manager = new JobManager()) {
283
315
  if (Boolean(job_id) === Boolean(group_id)) {
284
316
  return fail({ error: 'invalid_request', message: 'pass exactly one of job_id or group_id' });
285
317
  }
286
- const view = group_id ? manager.cancelGroup(group_id) : manager.cancel(job_id);
318
+ // Without group_id, job_id is set: exactly one of the two was checked above.
319
+ const view = group_id ? manager.cancelGroup(group_id) : manager.cancel(/** @type {string} */ (job_id));
287
320
  if (!view) {
288
321
  return fail({
289
322
  error: 'unknown_job',
@@ -324,6 +357,7 @@ export function createServer(manager = new JobManager()) {
324
357
  async ({ job_id, entries, reflection, records }) => {
325
358
  try {
326
359
  if (records !== undefined) {
360
+ /** @type {Record<string, unknown>} */
327
361
  const out = manager.record(
328
362
  Object.fromEntries(Object.entries({ job_id, entries, reflection, records }).filter(([, v]) => v !== undefined)),
329
363
  );
@@ -342,7 +376,7 @@ export function createServer(manager = new JobManager()) {
342
376
  if (err instanceof RequestError) {
343
377
  return fail({ error: err.code, message: err.message, details: err.details ?? null });
344
378
  }
345
- return fail({ error: 'internal_error', message: String(err?.message ?? err) });
379
+ return fail({ error: 'internal_error', message: errorMessage(err) });
346
380
  }
347
381
  },
348
382
  );
@@ -367,7 +401,7 @@ export function createServer(manager = new JobManager()) {
367
401
  return ok(exportChain({ chain_id, group_id }));
368
402
  } catch (err) {
369
403
  if (err instanceof ExportError) return fail({ error: err.code, message: err.message });
370
- return fail({ error: 'internal_error', message: String(err?.message ?? err) });
404
+ return fail({ error: 'internal_error', message: errorMessage(err) });
371
405
  }
372
406
  },
373
407
  );
@@ -420,6 +454,7 @@ export async function main() {
420
454
  manager.sweepInterrupted();
421
455
 
422
456
  let closing = false;
457
+ /** @param {string} signal */
423
458
  const bye = async (signal) => {
424
459
  if (closing) return;
425
460
  closing = true;