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
@@ -29,8 +29,8 @@ gets a vague answer.
29
29
  already in the question.
30
30
  2. **Write down what a useful answer looks like** (`success_criteria`) — e.g. "a concrete failure scenario with
31
31
  numbers, or a clear all-clear with its conditions".
32
- 3. **Supply the evidence**: `context.expose_paths` for the files (copied read-only under `./workspace`, and then
33
- the only part of your repository the consultant has), `context.facts` for the load-bearing facts,
32
+ 3. **Supply the evidence**: `context.expose_paths` for the files (copied read-only under `./workspace`; not a read
33
+ boundary -- apart from the protected credential paths, most consultants can still read other files on the disk), `context.facts` for the load-bearing facts,
34
34
  `context.artifacts` for what is not a file here. For a change you are making,
35
35
  `context.expose_diff: { repo, base? }` puts the diff in the brief and copies every file it touches, untracked
36
36
  ones included, under `./workspace/repo/` at their repository paths; add `expose_paths` for a caller or the
@@ -29,8 +29,8 @@ gets a vague answer.
29
29
  already in the question.
30
30
  2. **Write down what a useful answer looks like** (`success_criteria`) — e.g. "a concrete failure scenario with
31
31
  numbers, or a clear all-clear with its conditions".
32
- 3. **Supply the evidence**: `context.expose_paths` for the files (copied read-only under `./workspace`, and then
33
- the only part of your repository the consultant has), `context.facts` for the load-bearing facts,
32
+ 3. **Supply the evidence**: `context.expose_paths` for the files (copied read-only under `./workspace`; not a read
33
+ boundary -- apart from the protected credential paths, most consultants can still read other files on the disk), `context.facts` for the load-bearing facts,
34
34
  `context.artifacts` for what is not a file here. For a change you are making,
35
35
  `context.expose_diff: { repo, base? }` puts the diff in the brief and copies every file it touches, untracked
36
36
  ones included, under `./workspace/repo/` at their repository paths; add `expose_paths` for a caller or the
@@ -29,8 +29,8 @@ gets a vague answer.
29
29
  already in the question.
30
30
  2. **Write down what a useful answer looks like** (`success_criteria`) — e.g. "a concrete failure scenario with
31
31
  numbers, or a clear all-clear with its conditions".
32
- 3. **Supply the evidence**: `context.expose_paths` for the files (copied read-only under `./workspace`, and then
33
- the only part of your repository the consultant has), `context.facts` for the load-bearing facts,
32
+ 3. **Supply the evidence**: `context.expose_paths` for the files (copied read-only under `./workspace`; not a read
33
+ boundary -- apart from the protected credential paths, most consultants can still read other files on the disk), `context.facts` for the load-bearing facts,
34
34
  `context.artifacts` for what is not a file here. For a change you are making,
35
35
  `context.expose_diff: { repo, base? }` puts the diff in the brief and copies every file it touches, untracked
36
36
  ones included, under `./workspace/repo/` at their repository paths; add `expose_paths` for a caller or the
@@ -29,8 +29,8 @@ gets a vague answer.
29
29
  already in the question.
30
30
  2. **Write down what a useful answer looks like** (`success_criteria`) — e.g. "a concrete failure scenario with
31
31
  numbers, or a clear all-clear with its conditions".
32
- 3. **Supply the evidence**: `context.expose_paths` for the files (copied read-only under `./workspace`, and then
33
- the only part of your repository the consultant has), `context.facts` for the load-bearing facts,
32
+ 3. **Supply the evidence**: `context.expose_paths` for the files (copied read-only under `./workspace`; not a read
33
+ boundary -- apart from the protected credential paths, most consultants can still read other files on the disk), `context.facts` for the load-bearing facts,
34
34
  `context.artifacts` for what is not a file here. For a change you are making,
35
35
  `context.expose_diff: { repo, base? }` puts the diff in the brief and copies every file it touches, untracked
36
36
  ones included, under `./workspace/repo/` at their repository paths; add `expose_paths` for a caller or the
@@ -29,8 +29,8 @@ gets a vague answer.
29
29
  already in the question.
30
30
  2. **Write down what a useful answer looks like** (`success_criteria`) — e.g. "a concrete failure scenario with
31
31
  numbers, or a clear all-clear with its conditions".
32
- 3. **Supply the evidence**: `context.expose_paths` for the files (copied read-only under `./workspace`, and then
33
- the only part of your repository the consultant has), `context.facts` for the load-bearing facts,
32
+ 3. **Supply the evidence**: `context.expose_paths` for the files (copied read-only under `./workspace`; not a read
33
+ boundary -- apart from the protected credential paths, most consultants can still read other files on the disk), `context.facts` for the load-bearing facts,
34
34
  `context.artifacts` for what is not a file here. For a change you are making,
35
35
  `context.expose_diff: { repo, base? }` puts the diff in the brief and copies every file it touches, untracked
36
36
  ones included, under `./workspace/repo/` at their repository paths; add `expose_paths` for a caller or the
package/scripts/build.mjs CHANGED
@@ -13,6 +13,7 @@ const skillsDir = path.join(root, 'plugins', 'severally', 'skills');
13
13
  // `en`/`jp` are the everyday name a user would actually type when asking for
14
14
  // this peer by alias ("ask GPT", "gptと相談して") -- English keeps the
15
15
  // acronym capitalised, casual Japanese usually doesn't.
16
+ /** @type {Record<string, { label: string, short: string, reach: string, en: string, jp: string, jpVerb: string }>} */
16
17
  const PEERS = {
17
18
  codex: {
18
19
  label: 'Codex CLI',
@@ -70,8 +71,8 @@ const EVERYONE_TRIGGERS = [
70
71
 
71
72
  // All four everyday-alias shapes for one peer: "ask X", "get X to review
72
73
  // this", the natural Japanese "XにVERBして" form, and "Xにレビューしてもらって".
73
- function triggersFor(peerId) {
74
- const p = PEERS[peerId];
74
+ /** @param {{ en: string, jp: string, jpVerb: string }} p */
75
+ function triggersFor(p) {
75
76
  return [`"ask ${p.en}"`, `"get ${p.en} to review this"`, `"${p.jp}${p.jpVerb}"`, `"${p.jp}にレビューしてもらって"`];
76
77
  }
77
78
 
@@ -83,6 +84,7 @@ function triggersFor(peerId) {
83
84
  // one turn and the Skill itself then needs approving once (checked
84
85
  // 2026-10-18 with `claude -p --permission-mode default`).
85
86
  const QUIET_TOOLS = ['consult_get', 'consult_list', 'consult_record', 'consult_export', 'consult_cancel'];
87
+ /** @param {string} text */
86
88
  function withAllowedTools(text) {
87
89
  const names = QUIET_TOOLS.flatMap((t) => [`mcp__plugin_severally_severally__${t}`, `mcp__severally__${t}`]);
88
90
  // A one-line description is assumed; a folded one would swallow the new key.
@@ -96,65 +98,63 @@ function withAllowedTools(text) {
96
98
  // then file name. Writes nothing, so it is safe to call from a test without
97
99
  // mutating the working tree.
98
100
  export function renderSkills() {
99
- const rendered = {};
100
- for (const host of HOSTS) rendered[host.dir] = {};
101
- for (const name of SKILL_FILES) {
102
- const texts = renderTemplate(`${name}.tmpl`);
103
- for (const host of HOSTS) rendered[host.dir][name] = texts[host.dir];
104
- }
105
- return rendered;
101
+ return Object.fromEntries(
102
+ HOSTS.map((host) => [host.dir, Object.fromEntries(SKILL_FILES.map((name) => [name, renderTemplate(`${name}.tmpl`, host)]))]),
103
+ );
106
104
  }
107
105
 
108
106
  const SKILL_FILES = ['SKILL.md', 'reference.md'];
109
107
 
110
- function renderTemplate(file) {
108
+ /**
109
+ * @param {string} file
110
+ * @param {{ dir: string, self: string }} host
111
+ */
112
+ function renderTemplate(file, host) {
111
113
  const tmpl = fs.readFileSync(path.join(skillsDir, '_template', file), 'utf8');
112
- const rendered = {};
113
- for (const host of HOSTS) {
114
- const peers = Object.keys(PEERS).filter((id) => id !== host.self);
115
- const table = [
116
- '| target | Consultant | Reach for it when |',
117
- '|---|---|---|',
118
- ...peers.map((id) => `| \`${id}\` | ${PEERS[id].label} | ${PEERS[id].reach} |`),
119
- ].join('\n');
120
- // Every consultant can read files; what keeps the answer brief-only is
121
- // that the child starts in an empty working directory and is never told
122
- // where the repository is. Codex additionally keeps a read-only shell, so
123
- // "they cannot run commands" would be false about it.
124
- const codexCaveat = peers.includes('codex')
125
- ? ' The Codex consultant additionally has a read-only shell, so it can run commands that only read.'
126
- : '';
127
- const executionCaveat =
128
- '\n\nOne caveat on that list: a consultant can read local files. Its writes are blocked,' +
129
- '\nits network reaches only web search and browsing (none with `network: "off"`), credential and history' +
130
- '\npaths are refused where its CLI allows, and by default it starts in an empty working directory without being' +
131
- '\ntold where your repository is, so it answers from the brief and from the files you hand it:' +
132
- '\n`context.expose_paths` (absolute paths) or `context.expose_diff` (a change in progress), which are' +
133
- '\ncopied read-only into its working directory.' +
134
- codexCaveat;
135
- const text = tmpl
136
- .replaceAll('{{EXECUTION_CAVEAT}}', executionCaveat)
137
- .replaceAll('{{DESCRIPTION_PEERS}}', peers.map((id) => PEERS[id].short).join(' or '))
138
- .replaceAll('{{TRIGGERS}}', [...peers.flatMap(triggersFor), ...EVERYONE_TRIGGERS].join(', '))
139
- .replaceAll('{{PEER_TABLE}}', table)
140
- .replaceAll('{{DEFAULT_TARGET}}', peers[0])
141
- // The second peer, for the fan-out example of two named consultants.
142
- .replaceAll('{{SECOND_TARGET}}', peers[1])
143
- .replaceAll('{{SELF_TARGET}}', host.self);
144
- rendered[host.dir] = host.self === 'claude-code' && file === 'SKILL.md.tmpl' ? withAllowedTools(text) : text;
145
- }
146
- return rendered;
114
+ const peers = Object.entries(PEERS).filter(([id]) => id !== host.self);
115
+ const [first, second] = peers;
116
+ // Every host has three peers, so both exist.
117
+ if (!first || !second) throw new Error(`${host.self}: expected at least two peers to consult`);
118
+ const table = [
119
+ '| target | Consultant | Reach for it when |',
120
+ '|---|---|---|',
121
+ ...peers.map(([id, p]) => `| \`${id}\` | ${p.label} | ${p.reach} |`),
122
+ ].join('\n');
123
+ // Every consultant can read files; what keeps the answer brief-only is
124
+ // that the child starts in an empty working directory and is never told
125
+ // where the repository is. Codex additionally keeps a read-only shell, so
126
+ // "they cannot run commands" would be false about it.
127
+ const codexCaveat = peers.some(([id]) => id === 'codex')
128
+ ? ' The Codex consultant additionally has a read-only shell, so it can run commands that only read.'
129
+ : '';
130
+ const executionCaveat =
131
+ '\n\nOne caveat on that list: a consultant can read local files. Its writes are blocked,' +
132
+ '\nits network reaches only web search and browsing (none with `network: "off"`), credential and history' +
133
+ '\npaths are refused where its CLI allows, and by default it starts in an empty working directory without being' +
134
+ '\ntold where your repository is, so it answers from the brief and from the files you hand it:' +
135
+ '\n`context.expose_paths` (absolute paths) or `context.expose_diff` (a change in progress), which are' +
136
+ '\ncopied read-only into its working directory.' +
137
+ codexCaveat;
138
+ const text = tmpl
139
+ .replaceAll('{{EXECUTION_CAVEAT}}', executionCaveat)
140
+ .replaceAll('{{DESCRIPTION_PEERS}}', peers.map(([, p]) => p.short).join(' or '))
141
+ .replaceAll('{{TRIGGERS}}', [...peers.flatMap(([, p]) => triggersFor(p)), ...EVERYONE_TRIGGERS].join(', '))
142
+ .replaceAll('{{PEER_TABLE}}', table)
143
+ .replaceAll('{{DEFAULT_TARGET}}', first[0])
144
+ // The second peer, for the fan-out example of two named consultants.
145
+ .replaceAll('{{SECOND_TARGET}}', second[0])
146
+ .replaceAll('{{SELF_TARGET}}', host.self);
147
+ return host.self === 'claude-code' && file === 'SKILL.md.tmpl' ? withAllowedTools(text) : text;
147
148
  }
148
149
 
149
150
  // Thin writer: renders and writes each host's SKILL.md. Stays silent --
150
151
  // logging belongs to the CLI entry point below, not to code that may be
151
152
  // imported (e.g. by a test).
152
153
  export function generateSkills() {
153
- const rendered = renderSkills();
154
- for (const host of HOSTS) {
155
- const out = path.join(skillsDir, host.dir, 'severally');
154
+ for (const [dir, files] of Object.entries(renderSkills())) {
155
+ const out = path.join(skillsDir, dir, 'severally');
156
156
  fs.mkdirSync(out, { recursive: true });
157
- for (const name of SKILL_FILES) fs.writeFileSync(path.join(out, name), rendered[host.dir][name]);
157
+ for (const [name, text] of Object.entries(files)) fs.writeFileSync(path.join(out, name), text);
158
158
  }
159
159
  }
160
160
 
@@ -184,15 +184,18 @@ export async function bundle() {
184
184
  * THIRD_PARTY_NOTICES beside the bundle: each package the bundle actually
185
185
  * contains, with its LICENSE file verbatim. esbuild's legalComments cannot do
186
186
  * this -- these packages state their licence only in the LICENSE file.
187
+ * @param {string[]} inputs
188
+ * @param {string} outDir
187
189
  */
188
190
  function writeNotices(inputs, outDir) {
189
191
  const pkgDirs = new Set();
190
192
  for (const input of inputs) {
191
- const m = input
192
- .split('/')
193
- .join(path.sep)
194
- .match(/^(.*node_modules[\\/](?:@[^\\/]+[\\/])?[^\\/]+)/);
195
- if (m) pkgDirs.add(path.resolve(process.cwd(), m[1]));
193
+ const [, pkgPath] =
194
+ input
195
+ .split('/')
196
+ .join(path.sep)
197
+ .match(/^(.*node_modules[\\/](?:@[^\\/]+[\\/])?[^\\/]+)/) ?? [];
198
+ if (pkgPath) pkgDirs.add(path.resolve(process.cwd(), pkgPath));
196
199
  }
197
200
  const sections = [...pkgDirs]
198
201
  .map((dir) => {
@@ -27,6 +27,7 @@ import fs from 'node:fs';
27
27
  import os from 'node:os';
28
28
  import path from 'node:path';
29
29
  import { JobManager } from '../src/jobs.mjs';
30
+ /** @typedef {import('../src/jobs.mjs').JobView} JobView */
30
31
  import { spawnSync } from 'node:child_process';
31
32
  import { availableConsultants, POLICY } from '../src/policy.mjs';
32
33
  import { checkTargets } from '../src/consultants.mjs';
@@ -40,6 +41,11 @@ if (!consultants) {
40
41
  process.exit(2);
41
42
  }
42
43
 
44
+ /**
45
+ * @param {string} dir
46
+ * @param {string} secret
47
+ * @param {string} rootCanary
48
+ */
43
49
  function probe(dir, secret, rootCanary) {
44
50
  const written = path.join(dir, 'written-by-consultant.txt');
45
51
  const touched = path.join(dir, 'touched-by-command.txt');
@@ -63,6 +69,10 @@ function probe(dir, secret, rootCanary) {
63
69
  // A consultant that was told a path is denied may simply not try it, so an
64
70
  // unreturned canary does not show the sandbox stopped anything. For Codex the
65
71
  // sandbox can be asked directly, with no model and no quota.
72
+ /**
73
+ * @param {string} file
74
+ * @param {string} word
75
+ */
66
76
  function codexSandboxRead(file, word) {
67
77
  const r = spawnSync(
68
78
  POLICY.clis.codex.cli,
@@ -87,6 +97,7 @@ function codexSandboxRead(file, word) {
87
97
 
88
98
  const SUB_AGENT_TOOL = /subagent|spawn_agent|collab_tool_call|^task(:|$)|^agent(:|$)/i;
89
99
 
100
+ /** @param {JobView} view */
90
101
  function subAgentRow(view) {
91
102
  if (!view.tool_calls) return `not observable (no tool-call stream); tools offered: ${view.boundary?.tools ?? 'unknown'}`;
92
103
  if (Object.keys(view.tool_calls).length === 0) return 'inconclusive: the stream shows no tool calls at all';
@@ -119,17 +130,17 @@ for (const { name: target, cli } of consultants) {
119
130
  const { _secret, ...request } = probe(dir, secret, rootCanary);
120
131
 
121
132
  const mgr = new JobManager();
122
- /** @type {Record<string, any>} */
123
133
  const started = mgr.start({ target, ...request });
124
- if (!started.job_id) {
134
+ if (!('job_id' in started)) {
125
135
  rows.push({ target, error: JSON.stringify(started) });
126
136
  failed = true;
127
137
  for (const d of [dir, rootDir]) fs.rmSync(d, { recursive: true, force: true });
128
138
  continue;
129
139
  }
130
140
  process.stderr.write(`${target}: running ${started.job_id}\n`);
131
- await mgr.jobs.get(started.job_id).promise;
132
- const view = mgr.view(started.job_id);
141
+ await mgr.jobs.get(started.job_id)?.promise;
142
+ // The job was started above in this process, so it exists.
143
+ const view = /** @type {JobView} */ (mgr.view(started.job_id));
133
144
 
134
145
  if (view.status !== 'completed') {
135
146
  rows.push({ target, error: `${view.status}: ${view.failure?.kind} ${view.failure?.message ?? ''}`.trim() });
@@ -80,9 +80,16 @@ const backupDir = path.join(home, '.severally', 'backups', stamp);
80
80
  let keptRegistrations = false;
81
81
  let lastListOutput = '';
82
82
 
83
+ /** @param {...unknown} a */
83
84
  const log = (...a) => console.log(...a);
85
+ /** @param {string} s */
84
86
  const step = (s) => log(`\n== ${s}`);
85
87
 
88
+ /**
89
+ * @param {string} cmd
90
+ * @param {string[]} args
91
+ * @param {import('../src/platform.mjs').ExecOptions & { real?: boolean }} [opts]
92
+ */
86
93
  function run(cmd, args, opts = {}) {
87
94
  const { real, ...spawnOpts } = opts;
88
95
  if (dryRun && !real) {
@@ -92,16 +99,25 @@ function run(cmd, args, opts = {}) {
92
99
  return execCommandSync(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'], ...spawnOpts });
93
100
  }
94
101
 
102
+ /**
103
+ * @param {string} cmd
104
+ * @param {string[]} args
105
+ * @param {import('../src/platform.mjs').ExecOptions & { real?: boolean }} [opts]
106
+ * @returns {RunResult}
107
+ */
95
108
  function tryRun(cmd, args, opts) {
96
109
  try {
97
110
  return { ok: true, out: run(cmd, args, opts) };
98
- } catch (err) {
111
+ } catch (e) {
112
+ // execFileSync failures carry the child's stdout and stderr.
113
+ const err = /** @type {{ stdout?: unknown, stderr?: unknown, message?: unknown }} */ (e);
99
114
  return { ok: false, out: `${err.stdout ?? ''}${err.stderr ?? ''}`.trim() || String(err.message) };
100
115
  }
101
116
  }
102
117
 
103
118
  // Backup names are derived from the whole path: two different clients both
104
119
  // hold a "severally" directory, and one must not overwrite the other.
120
+ /** @param {string} target */
105
121
  function backupName(target) {
106
122
  return path
107
123
  .relative(home, target)
@@ -110,6 +126,7 @@ function backupName(target) {
110
126
  .replace(/^\./, '');
111
127
  }
112
128
 
129
+ /** @param {string} file */
113
130
  function backup(file) {
114
131
  if (!fs.existsSync(file)) return null;
115
132
  if (dryRun) {
@@ -125,6 +142,7 @@ function backup(file) {
125
142
  return dest;
126
143
  }
127
144
 
145
+ /** @param {string} dir */
128
146
  function backupTree(dir) {
129
147
  if (!fs.existsSync(dir)) return null;
130
148
  if (dryRun) {
@@ -138,6 +156,7 @@ function backupTree(dir) {
138
156
  }
139
157
 
140
158
  // Read-only probes run even under --dry-run, so the plan it prints is accurate.
159
+ /** @param {string} bin */
141
160
  function which(bin) {
142
161
  return findCommand(bin);
143
162
  }
@@ -198,6 +217,7 @@ let globalRoot = null;
198
217
  if (mode === 'plugin' && !skipGlobal) {
199
218
  const npmRoot = tryRun('npm', ['root', '-g'], { real: true });
200
219
  globalRoot = npmRoot.ok ? npmRoot.out.trim() || null : null;
220
+ /** @param {string} dir */
201
221
  const inside = (dir) => {
202
222
  try {
203
223
  return fs.realpathSync(root).startsWith(fs.realpathSync(dir) + path.sep);
@@ -214,6 +234,7 @@ if (mode === 'plugin' && !skipGlobal) {
214
234
  }
215
235
 
216
236
  step(`Installing the standalone MCP server (mode: ${mode})`);
237
+ /** @type {[string, string]} */
217
238
  const serverCommand = [process.execPath, installRuntime()];
218
239
  const runtimeDir = path.dirname(serverCommand[1]);
219
240
  const marketplaceRoot = path.join(home, '.severally', 'marketplace');
@@ -322,17 +343,20 @@ const agyConfig = path.join(home, '.gemini', 'config', 'mcp_config.json');
322
343
  const CODEX_TIMEOUT_SEC = 660;
323
344
  const codexConfig = path.join(codexHome, 'config.toml');
324
345
  // The user's own tool_timeout_sec, read before --force removes the table that holds it.
346
+ /** @type {number | undefined} */
325
347
  let priorCodexTimeout;
326
348
 
327
349
  // Where [mcp_servers.severally] sits in config.toml, and what it holds.
328
350
  // A minimal text read, not a TOML parser: the table runs to the next header
329
351
  // that is not one of its own sub-tables (codex may put env in one).
352
+ /** @returns {{ reason: string, lines?: undefined, start?: undefined, timeout?: undefined, wait?: undefined } | { reason?: undefined, lines: string[], start: number, timeout: number | undefined, wait: boolean }} */
330
353
  function codexTable() {
354
+ /** @type {string[]} */
331
355
  let lines;
332
356
  try {
333
357
  lines = fs.readFileSync(codexConfig, 'utf8').split('\n');
334
358
  } catch (err) {
335
- return { reason: `cannot read ${codexConfig}: ${err.message}` };
359
+ return { reason: `cannot read ${codexConfig}: ${/** @type {Error} */ (err).message}` };
336
360
  }
337
361
  const start = lines.findIndex((l) => /^\s*\[\s*mcp_servers\.severally\s*\]/.test(l));
338
362
  if (start < 0) return { reason: `${codexConfig} has no [mcp_servers.severally] table` };
@@ -357,15 +381,16 @@ function codexPlan() {
357
381
 
358
382
  // Inserts tool_timeout_sec right after the table header. Returns why it could
359
383
  // not, or null.
384
+ /** @param {number} sec */
360
385
  function writeCodexTimeout(sec) {
361
386
  const t = codexTable();
362
- if (t.reason) return t.reason;
387
+ if (t.reason !== undefined) return t.reason;
363
388
  if (t.timeout !== undefined) return null; // never overwrite the user's value
364
389
  t.lines.splice(t.start + 1, 0, `tool_timeout_sec = ${sec}`);
365
390
  try {
366
391
  fs.writeFileSync(codexConfig, t.lines.join('\n'));
367
392
  } catch (err) {
368
- return `cannot write ${codexConfig}: ${err.message}`;
393
+ return `cannot write ${codexConfig}: ${/** @type {Error} */ (err).message}`;
369
394
  }
370
395
  return null;
371
396
  }
@@ -373,12 +398,28 @@ function writeCodexTimeout(sec) {
373
398
  const claudeSkillsDir = path.join(home, '.claude', 'skills');
374
399
 
375
400
  // ---------------------------------------------------------------- clients
401
+ /**
402
+ * @typedef {{ ok: boolean, out: string }} RunResult
403
+ * @typedef {object} Client
404
+ * @property {string} bin
405
+ * @property {string} label
406
+ * @property {string[]} backup
407
+ * @property {() => boolean} isRegistered
408
+ * @property {() => RunResult} remove
409
+ * @property {(bin: string[]) => RunResult} add
410
+ * @property {() => void} [keptNote]
411
+ * @property {boolean} [verifyAdd]
412
+ * @property {string[]} [registrationConfig]
413
+ * @property {string} skillFrom
414
+ * @property {string} skillTo
415
+ */
376
416
  // Each row fully describes how to register a client's MCP server directly
377
417
  // (through the client's own `mcp add`) and place its skill. This is the one
378
418
  // place that knows how severally is installed outside of a bundled
379
419
  // plugin: --manual mode installs every row this way, and the default plugin
380
420
  // mode falls back to the Antigravity and OpenCode rows alone, since those
381
421
  // two have no verified plugin-install path.
422
+ /** @type {Client[]} */
382
423
  const CLIENTS = [
383
424
  {
384
425
  bin: 'claude',
@@ -509,14 +550,24 @@ const CLIENTS = [
509
550
  skillTo: path.join(xdgConfig, 'opencode', 'skills', 'severally'),
510
551
  },
511
552
  ];
512
- const codexClient = CLIENTS.find((c) => c.bin === 'codex');
513
- const antigravityClient = CLIENTS.find((c) => c.bin === 'agy');
514
- const opencodeClient = CLIENTS.find((c) => c.bin === 'opencode');
553
+ /** @param {string} bin */
554
+ function clientFor(bin) {
555
+ const client = CLIENTS.find((c) => c.bin === bin);
556
+ if (!client) throw new Error(`no client row for ${bin}`);
557
+ return client;
558
+ }
559
+ const codexClient = clientFor('codex');
560
+ const antigravityClient = clientFor('agy');
561
+ const opencodeClient = clientFor('opencode');
515
562
 
516
563
  // opencode re-adds in place and has no `mcp remove`, so --force over an
517
564
  // existing registration can silently keep the OLD command while the
518
565
  // name-only list check passes. The global config is where the CLI records
519
566
  // the server command; confirm it matches what this install registers.
567
+ /**
568
+ * @param {Client} client
569
+ * @param {string[]} bin
570
+ */
520
571
  function registrationCommandMatches(client, bin) {
521
572
  for (const file of client.registrationConfig ?? []) {
522
573
  let cfg;
@@ -534,6 +585,7 @@ function registrationCommandMatches(client, bin) {
534
585
 
535
586
  // The list right after an add can be briefly empty, so it is asked up to
536
587
  // three times, 1.5 s apart, before the add is judged.
588
+ /** @param {Client} client */
537
589
  function listedAfterAdd(client) {
538
590
  for (let i = 0; i < 3; i++) {
539
591
  if (i) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 1500);
@@ -545,6 +597,10 @@ function listedAfterAdd(client) {
545
597
  // Registers one client's MCP server directly (through its own `mcp add`) and
546
598
  // copies its skill, backing up everything it touches first. Shared by plugin
547
599
  // mode (Antigravity only) and --manual mode (all three clients).
600
+ /**
601
+ * @param {Client} client
602
+ * @param {string[]} bin
603
+ */
548
604
  function installClientDirectly(client, bin) {
549
605
  step(`Registering with ${client.label}`);
550
606
  if (!which(client.bin)) {
@@ -609,7 +665,12 @@ function claudeHasMarketplacePlugin() {
609
665
  if (!which('claude')) return false;
610
666
  const r = tryRun('claude', ['plugin', 'list', '--json'], { real: true });
611
667
  try {
612
- return (r.ok && JSON.parse(r.out).find((p) => /^severally@/.test(p.id) && p.id !== 'severally@skills-dir')?.id) || false;
668
+ return (
669
+ (r.ok &&
670
+ JSON.parse(r.out).find((/** @type {{ id: string }} */ p) => /^severally@/.test(p.id) && p.id !== 'severally@skills-dir')
671
+ ?.id) ||
672
+ false
673
+ );
613
674
  } catch {
614
675
  return false;
615
676
  }
@@ -751,10 +812,13 @@ async function allowLocalTools() {
751
812
  if (!which('claude')) return;
752
813
  const prefix = mode === 'plugin' || claudeMarketplace ? 'mcp__plugin_severally_severally__' : 'mcp__severally__';
753
814
  const settingsFile = path.join(home, '.claude', 'settings.json');
815
+ /** @type {{ permissions?: { allow?: string[] } & Record<string, unknown> } & Record<string, unknown>} */
754
816
  let settings = {};
755
817
  try {
756
818
  settings = JSON.parse(fs.readFileSync(settingsFile, 'utf8'));
757
- } catch (err) {
819
+ } catch (e) {
820
+ // readFileSync and JSON.parse throw Errors; only fs ones carry a code.
821
+ const err = /** @type {NodeJS.ErrnoException} */ (e);
758
822
  if (err.code !== 'ENOENT') {
759
823
  log(` could not read ${settingsFile} (${err.message}); left as it is`);
760
824
  return;
@@ -18,10 +18,15 @@ import fs from 'node:fs';
18
18
  import os from 'node:os';
19
19
  import path from 'node:path';
20
20
  import { JobManager } from '../src/jobs.mjs';
21
+ /** @typedef {import('../src/jobs.mjs').JobView} JobView */
21
22
  import { limitsSummary } from '../src/policy.mjs';
22
23
  import * as store from '../src/store.mjs';
23
24
 
24
25
  const argv = process.argv.slice(2);
26
+ /**
27
+ * @param {string} name
28
+ * @param {string} dflt
29
+ */
25
30
  const arg = (name, dflt) => {
26
31
  const i = argv.indexOf(`--${name}`);
27
32
  return i === -1 ? dflt : argv[i + 1];
@@ -30,6 +35,7 @@ const arg = (name, dflt) => {
30
35
  const target = arg('target', 'antigravity');
31
36
  const mode = arg('mode', 'review');
32
37
 
38
+ /** @type {Record<string, Record<string, unknown>>} */
33
39
  const REQUESTS = {
34
40
  review: {
35
41
  mode: 'review',
@@ -101,13 +107,12 @@ REQUESTS.sandbox = {
101
107
  },
102
108
  };
103
109
 
104
- const request = { target, ...REQUESTS[mode] };
110
+ const request = { target, ...(mode === undefined ? {} : REQUESTS[mode]) };
105
111
 
106
112
  console.log('limits:', JSON.stringify(limitsSummary(), null, 2));
107
113
  const mgr = new JobManager();
108
- /** @type {Record<string, any>} */
109
114
  const started = mgr.start(request);
110
- if (!started.job_id) {
115
+ if (!('job_id' in started)) {
111
116
  console.error('live-check runs one consultant at a time: pass a single --target, not "all"');
112
117
  process.exit(2);
113
118
  }
@@ -130,10 +135,11 @@ try {
130
135
  } catch {
131
136
  /* no working directory: the job failed before it started */
132
137
  }
133
- await mgr.jobs.get(started.job_id).promise;
138
+ await mgr.jobs.get(started.job_id)?.promise;
134
139
  clearInterval(watch);
135
140
  watcher?.close();
136
- const view = mgr.view(started.job_id);
141
+ // The job was started above in this process, so it exists.
142
+ const view = /** @type {JobView} */ (mgr.view(started.job_id));
137
143
  console.log(`\n=== finished in ${((Date.now() - t0) / 1000).toFixed(1)}s: ${view.status} ===`);
138
144
  console.log(JSON.stringify(view, null, 2));
139
145
  if (mode !== 'sandbox') process.exit(view.status === 'completed' ? 0 : 1);
@@ -11,10 +11,15 @@ import { execFileSync } from 'node:child_process';
11
11
 
12
12
  // The globally installed server, unless another path is given.
13
13
  const BIN = process.argv[2] ?? 'severally-mcp';
14
- const env = { ...process.env };
14
+ // process.env holds only strings at runtime; its type also admits undefined.
15
+ const env = /** @type {Record<string, string>} */ ({ ...process.env });
15
16
  const client = new Client({ name: 'live-mcp', version: '1' });
16
17
  await client.connect(new StdioClientTransport({ command: BIN, args: [], env }));
17
- const P = (r) => JSON.parse(r.content[0].text);
18
+ /** @typedef {import('@modelcontextprotocol/client').CallToolResult} CallToolResult */
19
+ /** @typedef {import('@modelcontextprotocol/client').TextContent} TextContent */
20
+ // The server answers every tool call with one text item, so content[0] is TextContent.
21
+ /** @param {CallToolResult} r A tool result whose first content item is JSON text. */
22
+ const P = (r) => JSON.parse(/** @type {TextContent} */ (r.content[0]).text);
18
23
 
19
24
  const request = {
20
25
  target: 'claude-code',
@@ -86,7 +91,7 @@ const before = countClaudeChildren();
86
91
  const cancelled = P(await client.callTool({ name: 'consult_cancel', arguments: { job_id: fu.job_id } }));
87
92
  console.log('cancel ->', cancelled.status, '|', cancelled.cancel_effect);
88
93
  let after = before;
89
- for (let i = 0; i < 40 && after >= before && before > 0; i++) {
94
+ for (let i = 0; i < 40 && before !== null && before > 0 && after !== null && after >= before; i++) {
90
95
  await new Promise((r) => setTimeout(r, 250));
91
96
  after = countClaudeChildren();
92
97
  }
@@ -115,7 +120,8 @@ function countClaudeChildren() {
115
120
  : execFileSync('ps', ['-eo', 'args='], { encoding: 'utf8' });
116
121
  return out.split('\n').filter((l) => l.includes('--no-session-persistence') && l.includes('--restricted')).length;
117
122
  } catch (error) {
118
- console.warn(`Could not inspect consultant processes: ${error.message}`);
123
+ // execFileSync throws an Error.
124
+ console.warn(`Could not inspect consultant processes: ${/** @type {Error} */ (error).message}`);
119
125
  return null;
120
126
  }
121
127
  }