severally-mcp 2.3.1 → 2.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/README.ja.md +52 -17
  2. package/README.md +52 -16
  3. package/bin/severally-mcp.mjs +14 -2
  4. package/docs/install.ja.md +48 -20
  5. package/docs/install.md +48 -21
  6. package/package.json +15 -3
  7. package/plugins/severally/.claude-plugin/plugin.json +1 -1
  8. package/plugins/severally/.codex-plugin/plugin.json +1 -1
  9. package/plugins/severally/dist/severally-mcp.mjs +6573 -5978
  10. package/plugins/severally/skills/_template/reference.md.tmpl +2 -2
  11. package/plugins/severally/skills/antigravity/severally/reference.md +2 -2
  12. package/plugins/severally/skills/claude/severally/reference.md +2 -2
  13. package/plugins/severally/skills/codex/severally/reference.md +2 -2
  14. package/plugins/severally/skills/opencode/severally/reference.md +2 -2
  15. package/scripts/build.mjs +76 -68
  16. package/scripts/doctor.mjs +66 -20
  17. package/scripts/init-config.mjs +8 -6
  18. package/scripts/install.mjs +327 -90
  19. package/scripts/live-check.mjs +62 -23
  20. package/scripts/live-mcp-check.mjs +52 -16
  21. package/scripts/test.mjs +10 -4
  22. package/src/adapters/antigravity-sandbox.mjs +62 -28
  23. package/src/adapters/antigravity.mjs +111 -30
  24. package/src/adapters/claude-code.mjs +121 -28
  25. package/src/adapters/codex.mjs +125 -33
  26. package/src/adapters/opencode-sandbox.mjs +59 -22
  27. package/src/adapters/opencode.mjs +144 -30
  28. package/src/brief.mjs +148 -47
  29. package/src/consultants.mjs +255 -48
  30. package/src/cross-spawn-escape.d.ts +7 -0
  31. package/src/export.mjs +99 -18
  32. package/src/expose-paths.mjs +310 -50
  33. package/src/failures.mjs +31 -14
  34. package/src/jobs.mjs +863 -249
  35. package/src/jsonc.mjs +11 -3
  36. package/src/parse-result.mjs +172 -40
  37. package/src/platform.mjs +56 -8
  38. package/src/policy.mjs +245 -52
  39. package/src/redact.mjs +59 -16
  40. package/src/result-schema.mjs +13 -0
  41. package/src/run.mjs +225 -30
  42. package/src/schema.mjs +132 -27
  43. package/src/server.mjs +178 -81
  44. package/src/status.mjs +94 -13
  45. package/src/store.mjs +418 -35
package/src/jsonc.mjs CHANGED
@@ -11,8 +11,12 @@
11
11
  // byte offsets -- and therefore JSON.parse's error positions -- still point at
12
12
  // the right place in the original text.
13
13
 
14
- /** Remove // and /* *\/ comments and trailing commas, preserving offsets. */
14
+ /**
15
+ * Remove // and /* *\/ comments and trailing commas, preserving offsets.
16
+ * @param {string} text
17
+ */
15
18
  export function stripJsonc(text) {
19
+ /** @type {string[]} */
16
20
  const out = [];
17
21
  let inString = false;
18
22
  let i = 0;
@@ -28,7 +32,7 @@ export function stripJsonc(text) {
28
32
  };
29
33
 
30
34
  while (i < text.length) {
31
- const c = text[i];
35
+ const c = text.charAt(i);
32
36
  const next = text[i + 1];
33
37
 
34
38
  if (inString) {
@@ -78,7 +82,11 @@ export function stripJsonc(text) {
78
82
  return out.join('');
79
83
  }
80
84
 
81
- /** JSON.parse, tolerating comments and trailing commas. */
85
+ /**
86
+ * JSON.parse, tolerating comments and trailing commas.
87
+ * @param {string} text
88
+ * @returns {unknown}
89
+ */
82
90
  export function parseJsonc(text) {
83
91
  return JSON.parse(stripJsonc(text));
84
92
  }
@@ -8,6 +8,10 @@ import { RESULT_KEYS } from './result-schema.mjs';
8
8
  import { redact } from './redact.mjs';
9
9
 
10
10
  export class OutputError extends Error {
11
+ /**
12
+ * @param {string} message
13
+ * @param {string} [detail]
14
+ */
11
15
  constructor(message, detail) {
12
16
  super(message);
13
17
  this.name = 'OutputError';
@@ -17,6 +21,38 @@ export class OutputError extends Error {
17
21
 
18
22
  const O = POLICY.output;
19
23
 
24
+ /** @typedef {'high' | 'medium' | 'low'} Level */
25
+ /** @typedef {'sufficient' | 'thin' | 'insufficient'} EvidenceBasis */
26
+ /** @typedef {(typeof STANCES)[number]} Stance */
27
+ /** @typedef {{ id: string, point: string, grounds: string | null, impact: string | null, severity: Level | null, confidence: Level | null }} Finding */
28
+ /** @typedef {{ option: string, tradeoffs: string | null, when_preferred: string | null }} Alternative */
29
+ /** @typedef {{ id: string, item: string, why_it_matters: string | null, how_to_obtain: string | null }} Unknown */
30
+ /** @typedef {{ condition: string, changes_to: string | null }} DecisionChanger */
31
+ /** @typedef {{ id: string, check: string, method: string | null, expected_signal: string | null }} NextCheck */
32
+ /** @typedef {{ topic: string, your_position: string | null, why_unresolved: string | null }} RemainingDisagreement */
33
+ /** @typedef {{ title: string | null, url: string | null, relevance: string | null }} Reference */
34
+
35
+ /**
36
+ * The validated consult result, in the shape of CONSULT_RESULT_SCHEMA.
37
+ * @typedef {object} ConsultResult
38
+ * @property {string} summary
39
+ * @property {Level | null} confidence
40
+ * @property {EvidenceBasis | null} evidence_basis
41
+ * @property {Stance | null} stance
42
+ * @property {Finding[]} findings
43
+ * @property {Alternative[]} alternatives
44
+ * @property {Unknown[]} unknowns
45
+ * @property {DecisionChanger[]} decision_changers
46
+ * @property {NextCheck[]} next_checks
47
+ * @property {RemainingDisagreement[]} remaining_disagreements
48
+ * @property {Reference[]} references
49
+ */
50
+
51
+ /**
52
+ * @param {unknown} v
53
+ * @param {number} max
54
+ * @returns {string | null}
55
+ */
20
56
  function clampText(v, max) {
21
57
  if (typeof v !== 'string') return null;
22
58
  const t = v.trim();
@@ -24,34 +60,61 @@ function clampText(v, max) {
24
60
  return t.length > max ? `${t.slice(0, max)}\n…[truncated by severally]` : t;
25
61
  }
26
62
 
63
+ /**
64
+ * Narrows a string to one of the listed values.
65
+ * @template {string} T
66
+ * @param {readonly T[]} list
67
+ * @param {string} t
68
+ * @returns {t is T}
69
+ */
70
+ const oneOf = (list, t) => list.some((x) => x === t);
71
+
72
+ /** @type {Level[]} */
27
73
  const LEVELS = ['high', 'medium', 'low'];
74
+ /** @type {EvidenceBasis[]} */
28
75
  const BASIS = ['sufficient', 'thin', 'insufficient'];
29
76
 
77
+ /**
78
+ * @param {unknown} v
79
+ * @param {Level | null} [fallback]
80
+ * @returns {Level | null}
81
+ */
30
82
  function level(v, fallback = null) {
31
83
  const t = typeof v === 'string' ? v.trim().toLowerCase() : '';
32
- return LEVELS.includes(t) ? t : fallback;
84
+ return oneOf(LEVELS, t) ? t : fallback;
33
85
  }
34
86
 
87
+ /**
88
+ * @param {unknown} v
89
+ * @returns {EvidenceBasis | null}
90
+ */
35
91
  function basis(v) {
36
92
  const t = typeof v === 'string' ? v.trim().toLowerCase() : '';
37
- return BASIS.includes(t) ? t : null;
93
+ return oneOf(BASIS, t) ? t : null;
38
94
  }
39
95
 
96
+ /**
97
+ * @param {unknown} v
98
+ * @returns {Stance | null}
99
+ */
40
100
  function stance(v) {
41
101
  const t = typeof v === 'string' ? v.trim().toLowerCase() : '';
42
- return STANCES.includes(t) ? t : null;
102
+ return oneOf(STANCES, t) ? t : null;
43
103
  }
44
104
 
45
- /** Extract a JSON object from raw model text (fenced block, or first balanced object). */
105
+ /**
106
+ * Extract a JSON object from raw model text (fenced block, or first balanced object).
107
+ * @param {unknown} text
108
+ * @returns {Record<string, unknown> | null}
109
+ */
46
110
  export function extractJson(text) {
47
111
  if (typeof text !== 'string') return null;
48
112
  const trimmed = text.trim();
49
113
  if (!trimmed) return null;
114
+ /** @type {string[]} */
50
115
  const candidates = [];
51
116
  if (trimmed.startsWith('{')) candidates.push(trimmed);
52
- const fence = /```(?:json)?\s*([\s\S]*?)```/gi;
53
- let m;
54
- while ((m = fence.exec(trimmed)) !== null) candidates.push(m[1].trim());
117
+ for (const [, body = ''] of trimmed.matchAll(/```(?:json)?\s*([\s\S]*?)```/gi)) candidates.push(body.trim());
55
118
  // Longest balanced {...} run as a last resort.
56
119
  const start = trimmed.indexOf('{');
57
120
  if (start !== -1) {
@@ -60,14 +123,26 @@ export function extractJson(text) {
60
123
  let esc = false;
61
124
  for (let i = start; i < trimmed.length; i++) {
62
125
  const ch = trimmed[i];
63
- if (esc) { esc = false; continue; }
64
- if (ch === '\\') { esc = true; continue; }
65
- if (ch === '"') { inStr = !inStr; continue; }
126
+ if (esc) {
127
+ esc = false;
128
+ continue;
129
+ }
130
+ if (ch === '\\') {
131
+ esc = true;
132
+ continue;
133
+ }
134
+ if (ch === '"') {
135
+ inStr = !inStr;
136
+ continue;
137
+ }
66
138
  if (inStr) continue;
67
139
  if (ch === '{') depth++;
68
140
  else if (ch === '}') {
69
141
  depth--;
70
- if (depth === 0) { candidates.push(trimmed.slice(start, i + 1)); break; }
142
+ if (depth === 0) {
143
+ candidates.push(trimmed.slice(start, i + 1));
144
+ break;
145
+ }
71
146
  }
72
147
  }
73
148
  }
@@ -75,7 +150,9 @@ export function extractJson(text) {
75
150
  try {
76
151
  const parsed = JSON.parse(c);
77
152
  if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) return parsed;
78
- } catch { /* try the next candidate */ }
153
+ } catch {
154
+ /* try the next candidate */
155
+ }
79
156
  }
80
157
  return null;
81
158
  }
@@ -84,24 +161,34 @@ export function extractJson(text) {
84
161
  // verdict against one point later. The id is ours, assigned at parse time and
85
162
  // never read from the answer: a consultant that numbered its own findings
86
163
  // cannot make one of them impersonate another.
164
+ /**
165
+ * @template T
166
+ * @param {unknown} raw
167
+ * @param {Record<string, 'text' | 'level'>} fields
168
+ * @param {{ requiredField: string, idPrefix?: string | null }} options
169
+ * @returns {T[]}
170
+ */
87
171
  function mapList(raw, fields, { requiredField, idPrefix = null }) {
88
172
  if (!Array.isArray(raw)) return [];
173
+ /** @type {T[]} */
89
174
  const out = [];
90
175
  for (const entry of raw.slice(0, O.listMax)) {
91
176
  if (!entry || typeof entry !== 'object') continue;
177
+ /** @type {Record<string, unknown>} */
92
178
  const item = idPrefix ? { id: `${idPrefix}${out.length + 1}` } : {};
93
179
  for (const [key, kind] of Object.entries(fields)) {
94
180
  item[key] = kind === 'level' ? level(entry[key]) : clampText(entry[key], O.itemTextMax);
95
181
  }
96
182
  if (!item[requiredField]) continue;
97
- out.push(item);
183
+ // Every key of `fields` is a key of T, and the required one is set.
184
+ out.push(/** @type {T} */ (item));
98
185
  }
99
186
  return out;
100
187
  }
101
188
 
102
189
  /**
103
- * @param {object|string} raw parsed JSON object, or raw text to mine one out of
104
- * @returns {{result: object, quality: {structured_output: boolean, missing_sections: string[]}}}
190
+ * @param {Record<string, unknown> | string} raw parsed JSON object, or raw text to mine one out of
191
+ * @returns {{result: ConsultResult, quality: {missing_sections: string[], findings_without_grounds: number, unsourced: boolean, list_truncated?: Record<string, number>}}}
105
192
  */
106
193
  export function normalizeResult(raw) {
107
194
  const objIn = typeof raw === 'string' ? extractJson(raw) : raw;
@@ -115,45 +202,90 @@ export function normalizeResult(raw) {
115
202
  throw new OutputError('consultant response has no usable "summary" field', JSON.stringify(src).slice(0, 2000));
116
203
  }
117
204
 
205
+ /** @type {ConsultResult} */
118
206
  const result = {
119
207
  summary,
120
208
  confidence: level(src.confidence),
121
209
  evidence_basis: basis(src.evidence_basis),
122
210
  stance: stance(src.stance),
123
- findings: mapList(src.findings, {
124
- point: 'text', grounds: 'text', impact: 'text', severity: 'level', confidence: 'level',
125
- }, { requiredField: 'point', idPrefix: 'f' }),
126
- alternatives: mapList(src.alternatives, {
127
- option: 'text', tradeoffs: 'text', when_preferred: 'text',
128
- }, { requiredField: 'option' }),
129
- unknowns: mapList(src.unknowns, {
130
- item: 'text', why_it_matters: 'text', how_to_obtain: 'text',
131
- }, { requiredField: 'item', idPrefix: 'u' }),
132
- decision_changers: mapList(src.decision_changers, {
133
- condition: 'text', changes_to: 'text',
134
- }, { requiredField: 'condition' }),
135
- next_checks: mapList(src.next_checks, {
136
- check: 'text', method: 'text', expected_signal: 'text',
137
- }, { requiredField: 'check', idPrefix: 'c' }),
138
- remaining_disagreements: mapList(src.remaining_disagreements, {
139
- topic: 'text', your_position: 'text', why_unresolved: 'text',
140
- }, { requiredField: 'topic' }),
211
+ findings: mapList(
212
+ src.findings,
213
+ {
214
+ point: 'text',
215
+ grounds: 'text',
216
+ impact: 'text',
217
+ severity: 'level',
218
+ confidence: 'level',
219
+ },
220
+ { requiredField: 'point', idPrefix: 'f' },
221
+ ),
222
+ alternatives: mapList(
223
+ src.alternatives,
224
+ {
225
+ option: 'text',
226
+ tradeoffs: 'text',
227
+ when_preferred: 'text',
228
+ },
229
+ { requiredField: 'option' },
230
+ ),
231
+ unknowns: mapList(
232
+ src.unknowns,
233
+ {
234
+ item: 'text',
235
+ why_it_matters: 'text',
236
+ how_to_obtain: 'text',
237
+ },
238
+ { requiredField: 'item', idPrefix: 'u' },
239
+ ),
240
+ decision_changers: mapList(
241
+ src.decision_changers,
242
+ {
243
+ condition: 'text',
244
+ changes_to: 'text',
245
+ },
246
+ { requiredField: 'condition' },
247
+ ),
248
+ next_checks: mapList(
249
+ src.next_checks,
250
+ {
251
+ check: 'text',
252
+ method: 'text',
253
+ expected_signal: 'text',
254
+ },
255
+ { requiredField: 'check', idPrefix: 'c' },
256
+ ),
257
+ remaining_disagreements: mapList(
258
+ src.remaining_disagreements,
259
+ {
260
+ topic: 'text',
261
+ your_position: 'text',
262
+ why_unresolved: 'text',
263
+ },
264
+ { requiredField: 'topic' },
265
+ ),
141
266
  references: (Array.isArray(src.references) ? src.references : [])
142
267
  .slice(0, O.referencesMax)
143
- .map((r) => (r && typeof r === 'object'
144
- ? { title: clampText(r.title, 300), url: clampText(r.url, 1000), relevance: clampText(r.relevance, 600) }
145
- : { title: clampText(r, 300), url: null, relevance: null }))
268
+ .map((r) =>
269
+ r && typeof r === 'object'
270
+ ? { title: clampText(r.title, 300), url: clampText(r.url, 1000), relevance: clampText(r.relevance, 600) }
271
+ : { title: clampText(r, 300), url: null, relevance: null },
272
+ )
146
273
  .filter((r) => r.title || r.url),
147
274
  };
148
275
 
149
276
  // The lists are cut to O.listMax before anything else looks at them; say how
150
277
  // long the ones that were cut had been, so the cut is not silent.
151
- const listTruncated = Object.fromEntries(['findings', 'alternatives', 'unknowns', 'decision_changers', 'next_checks', 'remaining_disagreements']
152
- .filter((k) => Array.isArray(src[k]) && src[k].length > O.listMax)
153
- .map((k) => [k, src[k].length]));
278
+ const listTruncated = Object.fromEntries(
279
+ ['findings', 'alternatives', 'unknowns', 'decision_changers', 'next_checks', 'remaining_disagreements'].flatMap((k) => {
280
+ const v = src[k];
281
+ return Array.isArray(v) && v.length > O.listMax ? [[k, v.length]] : [];
282
+ }),
283
+ );
154
284
 
285
+ /** @type {Record<string, unknown>} */
286
+ const fields = result;
155
287
  const missing = RESULT_KEYS.filter((k) => {
156
- const v = result[k];
288
+ const v = fields[k];
157
289
  return v === null || v === undefined || (Array.isArray(v) && v.length === 0);
158
290
  });
159
291
 
package/src/platform.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ /// <reference path="./cross-spawn-escape.d.ts" />
1
2
  import fs from 'node:fs';
2
3
  import path from 'node:path';
3
4
  import which from 'which';
@@ -5,12 +6,24 @@ import spawn from 'cross-spawn';
5
6
  import escape from 'cross-spawn/lib/util/escape.js';
6
7
  import { spawn as nativeSpawn, spawnSync as nativeSpawnSync } from 'node:child_process';
7
8
 
9
+ /**
10
+ * @param {string} source
11
+ * @param {string} destination
12
+ */
8
13
  export function copyTree(source, destination) {
9
14
  // Node 22.23.2's native cpSync fast path corrupts non-ASCII Windows paths.
10
15
  // A filter selects its JS traversal, preserving Japanese directory names.
11
16
  fs.cpSync(source, destination, { recursive: true, filter: () => true });
12
17
  }
13
18
 
19
+ /** @typedef {import('node:child_process').SpawnOptions & { cwd?: string | undefined, env?: NodeJS.ProcessEnv | undefined }} SpawnOpts */
20
+
21
+ /**
22
+ * @template {SpawnOpts} O
23
+ * @param {string} command
24
+ * @param {readonly string[]} args
25
+ * @param {O} options
26
+ */
14
27
  function batchInvocation(command, args, options) {
15
28
  if (process.platform !== 'win32') return null;
16
29
  const resolved = findCommand(command, options);
@@ -26,23 +39,46 @@ function batchInvocation(command, args, options) {
26
39
  };
27
40
  }
28
41
 
42
+ /**
43
+ * @overload
44
+ * @param {string} command
45
+ * @param {readonly string[]} args
46
+ * @param {SpawnOpts & { stdio: ['pipe', 'pipe', 'pipe'] }} options
47
+ * @returns {import('node:child_process').ChildProcessWithoutNullStreams}
48
+ */
49
+ /**
50
+ * @overload
51
+ * @param {string} command
52
+ * @param {readonly string[]} args
53
+ * @param {SpawnOpts} [options]
54
+ * @returns {import('node:child_process').ChildProcess}
55
+ */
56
+ /**
57
+ * @param {string} command
58
+ * @param {readonly string[]} args
59
+ * @param {SpawnOpts} [options]
60
+ */
29
61
  export function spawnCommand(command, args, options = {}) {
30
62
  const batch = batchInvocation(command, args, options);
31
63
  return batch ? nativeSpawn(batch.command, batch.args, batch.options) : spawn(command, args, options);
32
64
  }
33
65
 
34
66
  // Explicit script paths are supported by cross-spawn's shebang handling too.
67
+ /**
68
+ * @param {string} command
69
+ * @param {{ cwd?: string | undefined, env?: NodeJS.ProcessEnv | undefined }} [options]
70
+ * @returns {string | null}
71
+ */
35
72
  export function findCommand(command, { cwd = process.cwd(), env = process.env } = {}) {
36
73
  if (typeof command !== 'string' || !command) return null;
37
74
  cwd = path.resolve(cwd);
38
75
  const windows = process.platform === 'win32';
39
- const value = (name) => env[windows
40
- ? Object.keys(env).find((key) => key.toUpperCase() === name) : name];
76
+ const value = (/** @type {string} */ name) => {
77
+ const key = windows ? Object.keys(env).find((k) => k.toUpperCase() === name) : name;
78
+ return key === undefined ? undefined : env[key];
79
+ };
41
80
  const explicit = command.includes('/') || (windows && command.includes('\\'));
42
- const dirs = explicit ? [cwd] : [
43
- ...(windows ? [cwd] : []),
44
- ...(value('PATH') ?? '').split(path.delimiter).filter(Boolean),
45
- ];
81
+ const dirs = explicit ? [cwd] : [...(windows ? [cwd] : []), ...(value('PATH') ?? '').split(path.delimiter).filter(Boolean)];
46
82
  for (const dir of dirs) {
47
83
  // Absolute candidates prevent which's own cwd lookup from resolving a
48
84
  // wrapper in the server directory instead of the child's directory.
@@ -56,13 +92,25 @@ export function findCommand(command, { cwd = process.cwd(), env = process.env }
56
92
  if (!fs.statSync(candidate).isFile()) return null;
57
93
  fs.accessSync(candidate, fs.constants.X_OK);
58
94
  return candidate;
59
- } catch { /* not installed */ }
95
+ } catch {
96
+ /* not installed */
97
+ }
60
98
  }
61
99
  return null;
62
100
  }
63
101
 
102
+ /** @typedef {Omit<import('node:child_process').SpawnSyncOptionsWithStringEncoding, 'cwd' | 'encoding'> & { cwd?: string, encoding?: BufferEncoding }} ExecOptions */
103
+
104
+ /**
105
+ * @param {string} command
106
+ * @param {readonly string[]} args
107
+ * @param {ExecOptions} [options]
108
+ * @returns {string}
109
+ */
64
110
  export function execCommandSync(command, args, options = {}) {
65
- const opts = { windowsHide: true, encoding: 'utf8', ...options };
111
+ /** @type {BufferEncoding} */
112
+ const encoding = 'utf8';
113
+ const opts = { windowsHide: true, encoding, ...options };
66
114
  const batch = batchInvocation(command, args, opts);
67
115
  const result = batch ? nativeSpawnSync(batch.command, batch.args, batch.options) : spawn.sync(command, args, opts);
68
116
  if (result.error) throw result.error;