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
@@ -22,7 +22,58 @@ import { spawnSync } from 'node:child_process';
22
22
  import { POLICY, isProtectedPath } from './policy.mjs';
23
23
  import { redact } from './redact.mjs';
24
24
 
25
+ /** @typedef {{ files: number, bytes: number }} ExposeTotals */
26
+
27
+ /**
28
+ * A path left out of the copy, with why: 'ignored', 'symlink', 'protected',
29
+ * 'submodule', 'gone', 'masked' or 'replaced'. `exposed_as` names it where a
30
+ * follow-up carries over an earlier round's entry.
31
+ * @typedef {{ source: string, reason: string, exposed_as?: string | undefined }} SkippedPath
32
+ */
33
+
34
+ /**
35
+ * One exposed entry as the walk and the copy report it. `kind` is 'file',
36
+ * 'directory', 'diff' or 'change_files'.
37
+ * @typedef {object} ExposeEntry
38
+ * @property {number} index
39
+ * @property {string} source
40
+ * @property {string} kind
41
+ * @property {string} exposedAs
42
+ * @property {number} files
43
+ * @property {number} bytes
44
+ * @property {string[]} paths
45
+ * @property {string | null} [sha256]
46
+ * @property {string | undefined} [base]
47
+ * @property {string[]} [changes]
48
+ * @property {boolean} [changed] a follow-up's copy of a parent path: whether its bytes changed since
49
+ * @property {boolean} [inherited] a follow-up copied it again from the parent round
50
+ */
51
+
52
+ /**
53
+ * What an expose_paths request comes to: the entries, their totals, and what was left out.
54
+ * @typedef {{ entries: ExposeEntry[], totals: ExposeTotals, skipped: SkippedPath[] }} ExposeManifest
55
+ */
56
+
57
+ /** A path as the lead names it, or as an earlier round numbered it. @typedef {string | { source: string, index: number }} ExposePathSpec */
58
+
59
+ /**
60
+ * An entry of an earlier round's brief record (briefRecord's `expose_paths.entries`).
61
+ * @typedef {object} ExposedEntryRecord
62
+ * @property {number} index
63
+ * @property {string} source
64
+ * @property {string | undefined} [exposed_as]
65
+ * @property {string | undefined} [kind]
66
+ * @property {string | undefined} [base]
67
+ * @property {string[] | undefined} [changes]
68
+ */
69
+
70
+ /** @typedef {{ path: string, status: string }} RepoChange */
71
+
25
72
  export class ExposeError extends Error {
73
+ /**
74
+ * @param {string} message
75
+ * @param {string} code
76
+ */
26
77
  constructor(message, code) {
27
78
  super(message);
28
79
  this.name = 'ExposeError';
@@ -30,7 +81,13 @@ export class ExposeError extends Error {
30
81
  }
31
82
  }
32
83
 
33
- /** A size a reader can judge at a glance; the brief is read, not parsed. */
84
+ /** @param {unknown} err */
85
+ const messageOf = (err) => (err instanceof Error ? err.message : String(err));
86
+
87
+ /**
88
+ * A size a reader can judge at a glance; the brief is read, not parsed.
89
+ * @param {number} bytes
90
+ */
34
91
  export function humanSize(bytes) {
35
92
  if (bytes < 1024) return `${bytes} B`;
36
93
  const kb = bytes / 1024;
@@ -42,6 +99,7 @@ export function humanSize(bytes) {
42
99
  // Getting it wrong is survivable in one direction only -- a binary treated as
43
100
  // text would be corrupted by redact()'s utf8 round-trip, so the test errs
44
101
  // toward calling things binary.
102
+ /** @param {Uint8Array} buf */
45
103
  function isBinary(buf) {
46
104
  const len = Math.min(buf.length, 8_000);
47
105
  for (let i = 0; i < len; i++) if (buf[i] === 0) return true;
@@ -50,10 +108,15 @@ function isBinary(buf) {
50
108
 
51
109
  // Before a file is read: the caps are a few megabytes, and a 1 GB file must
52
110
  // be refused by its size, not after it has been read into memory and written.
111
+ /**
112
+ * @param {ExposeTotals} totals
113
+ * @param {number} size
114
+ */
53
115
  function ensureRoom(totals, size) {
54
116
  checkBudget({ files: totals.files + 1, bytes: totals.bytes + size });
55
117
  }
56
118
 
119
+ /** @param {ExposeTotals} totals */
57
120
  function checkBudget(totals) {
58
121
  if (totals.files > POLICY.input.exposeFilesMax) {
59
122
  throw new ExposeError(
@@ -74,12 +137,13 @@ function checkBudget(totals) {
74
137
  // A path the lead named, as opposed to one found while walking: it is refused
75
138
  // rather than skipped, because a lead who typed a symlink meant something by
76
139
  // it and should hear which entry could not be used.
140
+ /** @param {string} source */
77
141
  function statEntry(source) {
78
142
  let stat;
79
143
  try {
80
144
  stat = fs.lstatSync(source);
81
145
  } catch (err) {
82
- throw new ExposeError(`expose_paths entry "${source}" cannot be read: ${err.message}`, 'not_found');
146
+ throw new ExposeError(`expose_paths entry "${source}" cannot be read: ${messageOf(err)}`, 'not_found');
83
147
  }
84
148
  if (stat.isSymbolicLink()) {
85
149
  throw new ExposeError(`expose_paths entry "${source}" is a symlink; name the path it points at instead`, 'invalid_type');
@@ -94,6 +158,13 @@ function statEntry(source) {
94
158
  // accounts for the file (it owns the totals), and the budget is checked after
95
159
  // every single one, so an oversized tree stops at the file that crossed the
96
160
  // line rather than after the whole thing has been walked or copied.
161
+ /**
162
+ * @param {string} root
163
+ * @param {string} dir
164
+ * @param {(abs: string, rel: string) => void} onFile
165
+ * @param {ExposeTotals} totals
166
+ * @param {SkippedPath[]} skipped
167
+ */
97
168
  function walkDir(root, dir, onFile, totals, skipped) {
98
169
  let items;
99
170
  try {
@@ -101,7 +172,7 @@ function walkDir(root, dir, onFile, totals, skipped) {
101
172
  // entries were created.
102
173
  items = fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
103
174
  } catch (err) {
104
- throw new ExposeError(`expose_paths entry "${dir}" cannot be read: ${err.message}`, 'not_found');
175
+ throw new ExposeError(`expose_paths entry "${dir}" cannot be read: ${messageOf(err)}`, 'not_found');
105
176
  }
106
177
  for (const item of items) {
107
178
  const child = path.join(dir, item.name);
@@ -138,10 +209,18 @@ function walkDir(root, dir, onFile, totals, skipped) {
138
209
 
139
210
  // walkDir's order: name by name at every level, not the whole path as one
140
211
  // string ("a/x" comes before "a-b" there, after it as a string).
212
+ /**
213
+ * @param {string} a
214
+ * @param {string} b
215
+ */
141
216
  function byPathParts(a, b) {
142
217
  const x = a.split('/');
143
218
  const y = b.split('/');
144
- for (let i = 0; i < Math.min(x.length, y.length); i++) if (x[i] !== y[i]) return x[i] < y[i] ? -1 : 1;
219
+ for (const [i, part] of x.entries()) {
220
+ const other = y[i];
221
+ if (other === undefined) break;
222
+ if (part !== other) return part < other ? -1 : 1;
223
+ }
145
224
  return x.length - y.length;
146
225
  }
147
226
 
@@ -152,6 +231,14 @@ function byPathParts(a, b) {
152
231
  // submodule, or reach outside the directory. False when this is not a work
153
232
  // tree, git is missing or lists nothing (an ignored directory the lead named
154
233
  // on purpose): the caller walks it instead.
234
+ /**
235
+ * @param {string} dir
236
+ * @param {string} exposedAs
237
+ * @param {(abs: string, rel: string) => void} onFile
238
+ * @param {ExposeTotals} totals
239
+ * @param {SkippedPath[]} skipped
240
+ * @returns {boolean}
241
+ */
155
242
  function walkGit(dir, exposedAs, onFile, totals, skipped) {
156
243
  let listed;
157
244
  let ignored;
@@ -217,8 +304,14 @@ function walkGit(dir, exposedAs, onFile, totals, skipped) {
217
304
 
218
305
  // Each path is a string, or { source, index } to keep the name an earlier
219
306
  // round gave it.
307
+ /**
308
+ * @param {ExposePathSpec[]} exposePaths
309
+ * @param {(abs: string, rel: string, index: number, source: string, totals: ExposeTotals) => void} onFile
310
+ * @returns {ExposeManifest}
311
+ */
220
312
  function walkEntries(exposePaths, onFile) {
221
313
  const totals = { files: 0, bytes: 0 };
314
+ /** @type {SkippedPath[]} */
222
315
  const skipped = [];
223
316
  const entries = exposePaths.map((p, i) => {
224
317
  const { source, index } = typeof p === 'string' ? { source: p, index: i } : p;
@@ -229,6 +322,7 @@ function walkEntries(exposePaths, onFile) {
229
322
  const before = { files: totals.files, bytes: totals.bytes };
230
323
  // What a directory holds, for the brief: not every consultant has a tool
231
324
  // that lists a directory, and a name it cannot see is a file it never reads.
325
+ /** @type {string[]} */
232
326
  const paths = [];
233
327
  if (isProtectedPath(source)) {
234
328
  skipped.push({ source, reason: 'protected' });
@@ -236,6 +330,7 @@ function walkEntries(exposePaths, onFile) {
236
330
  onFile(source, '', index, source, totals);
237
331
  checkBudget(totals);
238
332
  } else {
333
+ /** @type {(abs: string, rel: string) => void} */
239
334
  const add = (abs, rel) => {
240
335
  paths.push(rel.split(path.sep).join('/'));
241
336
  onFile(abs, rel, index, source, totals);
@@ -261,6 +356,8 @@ function walkEntries(exposePaths, onFile) {
261
356
  * real copy may write slightly fewer bytes once a text file's
262
357
  * credential-shaped strings are masked, which only ever shrinks what was
263
358
  * already reported.
359
+ * @param {ExposePathSpec[] | null | undefined} exposePaths
360
+ * @returns {ExposeManifest | null}
264
361
  */
265
362
  export function previewExposePaths(exposePaths) {
266
363
  if (!exposePaths || exposePaths.length === 0) return null;
@@ -277,11 +374,15 @@ export function previewExposePaths(exposePaths) {
277
374
  * are copied unchanged, since a utf8 round-trip would corrupt them. Walks
278
375
  * from scratch and re-checks the caps, so a tree that grew or vanished since
279
376
  * the preview fails here instead of being copied in part.
377
+ * @param {string} workdir
378
+ * @param {ExposePathSpec[] | null | undefined} exposePaths
379
+ * @returns {ExposeManifest | null}
280
380
  */
281
381
  export function materializeExposedPaths(workdir, exposePaths) {
282
382
  if (!exposePaths || exposePaths.length === 0) return null;
283
383
  const root = path.join(workdir, 'workspace');
284
384
  fs.mkdirSync(root, { recursive: true, mode: 0o700 });
385
+ /** @type {import('node:crypto').Hash[]} */
285
386
  const hashes = [];
286
387
  const manifest = walkEntries(exposePaths, (abs, rel, index, source, totals) => {
287
388
  ensureRoom(totals, fs.statSync(abs).size);
@@ -297,6 +398,10 @@ export function materializeExposedPaths(workdir, exposePaths) {
297
398
 
298
399
  // The bytes a consultant gets for one file, written to dest: valid-utf8 text
299
400
  // through redact(), anything else unchanged.
401
+ /**
402
+ * @param {string} abs
403
+ * @param {string} dest
404
+ */
300
405
  function copyMasked(abs, dest) {
301
406
  const buf = fs.readFileSync(abs);
302
407
  let out = buf;
@@ -319,12 +424,21 @@ function copyMasked(abs, dest) {
319
424
  // sha256 covers the copies in path order, so the same files give the same
320
425
  // hash however they were found. `gone` is where a path that no longer exists
321
426
  // goes; by default it is dropped, as a change listing a vanished path is.
427
+ /**
428
+ * @param {string} workdir
429
+ * @param {string} top
430
+ * @param {RepoChange[]} changes
431
+ * @param {ExposeManifest} manifest
432
+ * @param {(c: RepoChange) => void} shown
433
+ * @param {((c: RepoChange) => void) | null} [gone]
434
+ */
322
435
  function copyRepoFiles(workdir, top, changes, manifest, shown, gone = null) {
323
436
  const root = path.join(workdir, 'workspace', 'repo');
324
437
  // Made even when nothing is copied (a change that only deletes): each
325
438
  // member's workspace is copied from here.
326
439
  fs.mkdirSync(root, { recursive: true, mode: 0o700 });
327
440
  const realTop = fs.realpathSync(top);
441
+ /** @type {{ path: string, size: number }[]} */
328
442
  const copied = [];
329
443
  for (const c of changes) {
330
444
  const abs = path.join(top, c.path);
@@ -388,11 +502,17 @@ function copyRepoFiles(workdir, top, changes, manifest, shown, gone = null) {
388
502
  * ('masked'). The parent's diff is not taken again: the files it touched are
389
503
  * re-copied as they are now (`diffs`, for reexposeChange), because a diff
390
504
  * against a base that has since moved would be a different change.
505
+ * @param {ExposedEntryRecord[]} parentEntries
506
+ * @param {string[]} exposePaths
391
507
  */
392
508
  export function planInheritance(parentEntries, exposePaths) {
509
+ /** @type {{ source: string, index: number }[]} */
393
510
  const paths = [];
511
+ /** @type {SkippedPath[]} */
394
512
  const skipped = [];
513
+ /** @type {ExposedEntryRecord[]} */
395
514
  const diffs = [];
515
+ /** @param {string} p */
396
516
  const exists = (p) => {
397
517
  try {
398
518
  fs.lstatSync(p);
@@ -433,12 +553,17 @@ export function planInheritance(parentEntries, exposePaths) {
433
553
  * The files an earlier round's change touched, copied again under
434
554
  * workspace/repo/ as they are now, added to `manifest` as a 'change_files'
435
555
  * entry. Read from that round's record of the change, not from git.
556
+ * @param {string} workdir
557
+ * @param {ExposedEntryRecord} entry
558
+ * @param {ExposeManifest} manifest
559
+ * @returns {boolean}
436
560
  */
437
561
  export function reexposeChange(workdir, entry, manifest) {
562
+ /** @type {RepoChange[]} */
438
563
  const changes = [];
439
564
  for (const c of entry.changes ?? []) {
440
565
  const m = /^(.*) \(([^()]+)\)$/.exec(c);
441
- const p = m ? m[1] : c;
566
+ const p = m?.[1] ?? c;
442
567
  if (p.includes('[REDACTED')) {
443
568
  manifest.skipped.push({ source: path.join(entry.source, p), reason: 'masked', exposed_as: `workspace/repo/${p}` });
444
569
  continue;
@@ -456,6 +581,7 @@ export function reexposeChange(workdir, entry, manifest) {
456
581
  changes.push({ path: p, status: deleted ? (back ? 'restored' : 'D') : (m?.[2] ?? 'M') });
457
582
  }
458
583
  const before = { ...manifest.totals };
584
+ /** @type {RepoChange[]} */
459
585
  const shown = [];
460
586
  const { copied, sha256 } = copyRepoFiles(
461
587
  workdir,
@@ -496,6 +622,10 @@ export function reexposeChange(workdir, entry, manifest) {
496
622
  // switched off, pathspecs are literal, and no shell is involved.
497
623
  // ponytail: a clean filter (filter.<driver>.clean in .git/config) still runs
498
624
  // on `git diff` of the working tree; .git/config is local, not cloned.
625
+ /**
626
+ * @param {string} cwd
627
+ * @param {string[]} args
628
+ */
499
629
  function git(cwd, args) {
500
630
  // Without the server's own GIT_* (GIT_DIR, GIT_INDEX_FILE ... would point
501
631
  // at another repository), and without taking the lead's index lock.
@@ -508,7 +638,7 @@ function git(cwd, args) {
508
638
  });
509
639
  if (r.error || r.status !== 0) {
510
640
  const why =
511
- /** @type {NodeJS.ErrnoException} */ (r.error)?.code === 'ENOENT'
641
+ r.error && 'code' in r.error && r.error.code === 'ENOENT'
512
642
  ? 'git is not on PATH'
513
643
  : String(r.stderr ?? r.error?.message ?? '').trim();
514
644
  throw new ExposeError(`context.expose_diff: git ${args[0]} failed in "${cwd}": ${why}`, 'not_found');
@@ -516,6 +646,7 @@ function git(cwd, args) {
516
646
  return r.stdout;
517
647
  }
518
648
 
649
+ /** @param {Buffer} buf */
519
650
  const nulSplit = (buf) => buf.toString('utf8').split('\0').filter(Boolean);
520
651
 
521
652
  /**
@@ -525,6 +656,11 @@ const nulSplit = (buf) => buf.toString('utf8').split('\0').filter(Boolean);
525
656
  * name the copies; deleted files are listed, not copied. Protected paths are
526
657
  * left out of the copy and of the diff. Returns the diff text, masked and cut
527
658
  * to maxChars, for the brief.
659
+ * @param {string} workdir
660
+ * @param {{ repo: string, base?: string | undefined }} source
661
+ * @param {ExposeManifest} manifest
662
+ * @param {number} maxChars
663
+ * @returns {string}
528
664
  */
529
665
  export function exposeDiff(workdir, { repo, base = 'HEAD' }, manifest, maxChars) {
530
666
  const top = git(repo, ['rev-parse', '--show-toplevel']).toString('utf8').trim();
@@ -532,12 +668,18 @@ export function exposeDiff(workdir, { repo, base = 'HEAD' }, manifest, maxChars)
532
668
  const tokens = nulSplit(
533
669
  git(top, ['diff', '--name-status', '-z', '--no-renames', '--no-relative', '--no-ext-diff', '--no-textconv', base, '--']),
534
670
  );
671
+ /** @type {RepoChange[]} */
535
672
  const changes = [];
536
- for (let i = 0; i + 1 < tokens.length; i += 2) changes.push({ status: tokens[i], path: tokens[i + 1] });
673
+ for (let i = 0; i + 1 < tokens.length; i += 2) {
674
+ const status = tokens[i];
675
+ const file = tokens[i + 1];
676
+ if (status !== undefined && file !== undefined) changes.push({ status, path: file });
677
+ }
537
678
  for (const p of nulSplit(git(top, ['ls-files', '--others', '--exclude-standard', '-z'])))
538
679
  changes.push({ status: 'untracked', path: p });
539
680
 
540
681
  const before = { ...manifest.totals };
682
+ /** @type {RepoChange[]} */
541
683
  const shown = [];
542
684
  const { copied, sha256 } = copyRepoFiles(workdir, top, changes, manifest, (c) => shown.push(c));
543
685
  if (shown.length === 0) {
package/src/failures.mjs CHANGED
@@ -36,13 +36,20 @@ const RULES = [
36
36
  ],
37
37
  ];
38
38
 
39
+ /**
40
+ * @param {string | null | undefined} text
41
+ * @param {string} [fallback]
42
+ */
39
43
  export function classifyMessage(text, fallback = 'cli_error') {
40
44
  if (!text) return fallback;
41
45
  for (const [re, kind] of RULES) if (re.test(text)) return kind;
42
46
  return fallback;
43
47
  }
44
48
 
45
- /** Whether the lead may reasonably try again (with a longer budget, or later). */
49
+ /**
50
+ * Whether the lead may reasonably try again (with a longer budget, or later).
51
+ * @param {string} kind
52
+ */
46
53
  export function isRetriable(kind) {
47
54
  return kind === 'timeout' || kind === 'cli_error' || kind === 'rate_limited' || kind === 'interrupted';
48
55
  }