severally-mcp 2.1.0 → 2.4.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 (52) hide show
  1. package/CODE_OF_CONDUCT.md +83 -0
  2. package/CONFIG.md +10 -6
  3. package/README.ja.md +146 -69
  4. package/README.md +150 -73
  5. package/bin/severally-mcp.mjs +17 -2
  6. package/config.example.json +26 -0
  7. package/docs/how-it-works.ja.md +4 -2
  8. package/docs/how-it-works.md +5 -3
  9. package/docs/install.ja.md +59 -24
  10. package/docs/install.md +60 -24
  11. package/package.json +16 -4
  12. package/plugins/severally/.claude-plugin/plugin.json +1 -1
  13. package/plugins/severally/.codex-plugin/plugin.json +1 -1
  14. package/plugins/severally/dist/severally-mcp.mjs +29220 -27764
  15. package/plugins/severally/skills/_template/SKILL.md.tmpl +16 -22
  16. package/plugins/severally/skills/_template/reference.md.tmpl +11 -5
  17. package/plugins/severally/skills/antigravity/severally/SKILL.md +16 -22
  18. package/plugins/severally/skills/antigravity/severally/reference.md +11 -5
  19. package/plugins/severally/skills/claude/severally/SKILL.md +16 -22
  20. package/plugins/severally/skills/claude/severally/reference.md +11 -5
  21. package/plugins/severally/skills/codex/severally/SKILL.md +16 -22
  22. package/plugins/severally/skills/codex/severally/reference.md +11 -5
  23. package/plugins/severally/skills/opencode/severally/SKILL.md +16 -22
  24. package/plugins/severally/skills/opencode/severally/reference.md +11 -5
  25. package/scripts/build.mjs +31 -26
  26. package/scripts/doctor.mjs +52 -17
  27. package/scripts/init-config.mjs +8 -6
  28. package/scripts/install.mjs +342 -84
  29. package/scripts/live-check.mjs +52 -19
  30. package/scripts/live-mcp-check.mjs +42 -12
  31. package/scripts/test.mjs +10 -4
  32. package/src/adapters/antigravity-sandbox.mjs +39 -23
  33. package/src/adapters/antigravity.mjs +34 -16
  34. package/src/adapters/claude-code.mjs +73 -21
  35. package/src/adapters/codex.mjs +69 -27
  36. package/src/adapters/opencode-sandbox.mjs +40 -21
  37. package/src/adapters/opencode.mjs +31 -13
  38. package/src/brief.mjs +65 -41
  39. package/src/consultants.mjs +99 -30
  40. package/src/export.mjs +49 -12
  41. package/src/expose-paths.mjs +162 -44
  42. package/src/failures.mjs +23 -13
  43. package/src/jobs.mjs +420 -140
  44. package/src/parse-result.mjs +85 -30
  45. package/src/platform.mjs +5 -7
  46. package/src/policy.mjs +139 -32
  47. package/src/redact.mjs +38 -14
  48. package/src/run.mjs +104 -22
  49. package/src/schema.mjs +76 -22
  50. package/src/server.mjs +176 -166
  51. package/src/status.mjs +132 -0
  52. package/src/store.mjs +164 -19
package/src/run.mjs CHANGED
@@ -5,10 +5,7 @@ import { spawnCommand as spawn } from './platform.mjs';
5
5
  import path from 'node:path';
6
6
  import { POLICY } from './policy.mjs';
7
7
 
8
- const DROP_EXACT = new Set([
9
- 'CLAUDECODE', 'CLAUDE_PID', 'CLAUDE_EFFORT', 'AI_AGENT',
10
- 'ANTHROPIC_LOG', 'NODE_OPTIONS',
11
- ]);
8
+ const DROP_EXACT = new Set(['CLAUDECODE', 'CLAUDE_PID', 'CLAUDE_EFFORT', 'AI_AGENT', 'ANTHROPIC_LOG', 'NODE_OPTIONS']);
12
9
  const DROP_PREFIX = ['CLAUDE_CODE_', 'SEVERALLY_', 'MCP_'];
13
10
 
14
11
  // Each consultant sees only its own vendor's credentials.
@@ -164,23 +161,48 @@ export class ProcHandle {
164
161
  this.stdoutSoFar = readStdout;
165
162
  }
166
163
 
167
- /** Kill the child and every descendant it spawned. */
164
+ /**
165
+ * Kill the child and every descendant it spawned -- also after the child
166
+ * itself has exited: a descendant still holding its pipes keeps the
167
+ * consultation from closing, and on POSIX it is still in the group.
168
+ */
168
169
  stop(signal = 'SIGTERM') {
169
- if (this.killed || !this.child || this.child.exitCode !== null) return;
170
+ if (this.killed || !this.child) return;
170
171
  this.killed = true;
171
172
  const pid = this.child.pid;
172
173
  if (!pid) return;
173
174
  if (process.platform === 'win32') {
174
175
  // Negative PIDs/process groups are POSIX-only. Kill the Windows tree
175
- // before its parent exits and its descendants become unreachable.
176
- const killer = spawn(path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'taskkill.exe'),
177
- ['/pid', String(pid), '/T', '/F'], { windowsHide: true, stdio: 'ignore' });
178
- const fallback = () => { try { this.child.kill('SIGKILL'); } catch { /* gone */ } };
176
+ // before its parent exits and its descendants become unreachable. Once
177
+ // it has exited, its pid may already be another process's: leave it.
178
+ if (this.child.exitCode !== null || this.child.signalCode !== null) return;
179
+ const killer = spawn(
180
+ path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'taskkill.exe'),
181
+ ['/pid', String(pid), '/T', '/F'],
182
+ { windowsHide: true, stdio: 'ignore' },
183
+ );
184
+ const fallback = () => {
185
+ try {
186
+ this.child.kill('SIGKILL');
187
+ } catch {
188
+ /* gone */
189
+ }
190
+ };
179
191
  killer.on('error', fallback);
180
- killer.on('exit', (code) => { if (code !== 0) fallback(); });
192
+ killer.on('exit', (code) => {
193
+ if (code !== 0) fallback();
194
+ });
181
195
  return;
182
196
  }
183
- try { process.kill(-pid, signal); } catch { try { this.child.kill(signal); } catch { /* gone */ } }
197
+ try {
198
+ process.kill(-pid, signal);
199
+ } catch {
200
+ try {
201
+ this.child.kill(signal);
202
+ } catch {
203
+ /* gone */
204
+ }
205
+ }
184
206
  setTimeout(() => this.forceKill(), POLICY.killGraceMs).unref();
185
207
  }
186
208
 
@@ -194,15 +216,35 @@ export class ProcHandle {
194
216
  forceKill() {
195
217
  const pid = this.child?.pid;
196
218
  if (!pid || process.platform === 'win32') return;
197
- try { process.kill(-pid, 'SIGKILL'); } catch { /* gone */ }
219
+ try {
220
+ process.kill(-pid, 'SIGKILL');
221
+ } catch {
222
+ /* gone */
223
+ }
198
224
  }
199
225
  }
200
226
 
201
227
  /**
202
228
  * @returns {{handle: ProcHandle, done: Promise<{code, signal, stdout, stderr, timedOut, cancelled, truncated}>}}
203
229
  */
204
- export function runChild({ command, args, cwd, env, input, timeoutMs, onCancelSignal, compactLine = null,
205
- settleAfter = null, stopSignal = 'SIGTERM' }) {
230
+ /**
231
+ * `pipesForced` in the result: the child exited but something it started kept
232
+ * stdout or stderr open past the kill grace, so the group was stopped and the
233
+ * pipes closed from this side. A descendant outside the group (setsid, or on
234
+ * Windows once the parent is gone) may still be running.
235
+ */
236
+ export function runChild({
237
+ command,
238
+ args,
239
+ cwd,
240
+ env,
241
+ input,
242
+ timeoutMs,
243
+ onCancelSignal,
244
+ compactLine = null,
245
+ settleAfter = null,
246
+ stopSignal = 'SIGTERM',
247
+ }) {
206
248
  const child = spawn(command, args, {
207
249
  cwd,
208
250
  // PWD is the parent's otherwise, and opencode takes its project root from
@@ -232,17 +274,26 @@ export function runChild({ command, args, cwd, env, input, timeoutMs, onCancelSi
232
274
  if (ms == null || Date.now() + ms >= settleAt) return;
233
275
  settleAt = Date.now() + ms;
234
276
  clearTimeout(settleTimer);
235
- settleTimer = setTimeout(() => { stalled = true; handle.stop(stopSignal); }, ms);
277
+ settleTimer = setTimeout(() => {
278
+ stalled = true;
279
+ handle.stop(stopSignal);
280
+ }, ms);
236
281
  };
237
282
 
238
283
  const append = (which, chunk) => {
239
284
  const text = chunk.toString('utf8');
240
285
  if (which === 'out') {
241
- if (stdout.length >= cap) { truncated = true; return; }
286
+ if (stdout.length >= cap) {
287
+ truncated = true;
288
+ return;
289
+ }
242
290
  stdout += text.slice(0, cap - stdout.length);
243
291
  if (stdout.length >= cap) truncated = true;
244
292
  } else {
245
- if (stderr.length >= cap) { truncated = true; return; }
293
+ if (stderr.length >= cap) {
294
+ truncated = true;
295
+ return;
296
+ }
246
297
  stderr += text.slice(0, cap - stderr.length);
247
298
  if (stderr.length >= cap) truncated = true;
248
299
  }
@@ -261,7 +312,10 @@ export function runChild({ command, args, cwd, env, input, timeoutMs, onCancelSi
261
312
  if (settleAfter) watch(l);
262
313
  append('out', `${compact(l)}\n`);
263
314
  }
264
- if (pending.length > cap) { append('out', pending); pending = ''; }
315
+ if (pending.length > cap) {
316
+ append('out', pending);
317
+ pending = '';
318
+ }
265
319
  };
266
320
  // Decode as a stream: a multibyte character split across two chunks would
267
321
  // otherwise turn into replacement characters inside the answer text.
@@ -288,22 +342,50 @@ export function runChild({ command, args, cwd, env, input, timeoutMs, onCancelSi
288
342
 
289
343
  const done = new Promise((resolve) => {
290
344
  let settled = false;
345
+ let pipesForced = false;
346
+ let orphanTimer = null;
291
347
  const finish = (payload) => {
292
348
  if (settled) return;
293
349
  settled = true;
294
350
  clearTimeout(timer);
295
351
  clearTimeout(settleTimer);
296
- resolve({ stdout, stderr, truncated, timedOut, cancelled, stalled, ...payload });
352
+ clearTimeout(orphanTimer);
353
+ resolve({ stdout, stderr, truncated, timedOut, cancelled, stalled, pipesForced, ...payload });
354
+ };
355
+ const flushPending = () => {
356
+ if (byLine && pending) {
357
+ append('out', compact(pending));
358
+ pending = '';
359
+ }
297
360
  };
298
361
  child.on('error', (err) => finish({ code: null, signal: null, spawnError: err.message }));
362
+ // 'close' waits for the pipes as well as the child. Once the child has
363
+ // exited, a grace later the rest of its group is stopped, and a grace
364
+ // after that the pipes are closed from here, whoever still holds them.
365
+ child.on('exit', (code, signal) => {
366
+ const grace = POLICY.killGraceMs;
367
+ orphanTimer = setTimeout(() => {
368
+ pipesForced = true;
369
+ handle.stop(stopSignal);
370
+ orphanTimer = setTimeout(() => {
371
+ handle.forceKill();
372
+ flushPending();
373
+ child.stdout.destroy();
374
+ child.stderr.destroy();
375
+ finish({ code, signal });
376
+ }, grace);
377
+ }, grace);
378
+ });
299
379
  child.on('close', (code, signal) => {
300
- if (byLine && pending) { append('out', compact(pending)); pending = ''; }
380
+ flushPending();
301
381
  finish({ code, signal });
302
382
  });
303
383
  });
304
384
 
305
385
  if (input !== undefined && input !== null) {
306
- child.stdin.on('error', () => { /* child may exit before draining stdin */ });
386
+ child.stdin.on('error', () => {
387
+ /* child may exit before draining stdin */
388
+ });
307
389
  child.stdin.end(input);
308
390
  } else {
309
391
  child.stdin.end();
package/src/schema.mjs CHANGED
@@ -5,8 +5,16 @@
5
5
  import { z } from 'zod';
6
6
  import path from 'node:path';
7
7
  import {
8
- POLICY, CALLER_INPUTS, MODES, STANCES, artifactKinds,
9
- resolveCaller, availableConsultants, consultantAvailable, unavailableReason,
8
+ POLICY,
9
+ CALLER_INPUTS,
10
+ MODES,
11
+ STANCES,
12
+ artifactKinds,
13
+ resolveCaller,
14
+ availableConsultants,
15
+ consultantAvailable,
16
+ unavailableReason,
17
+ brokenConsultant,
10
18
  } from './policy.mjs';
11
19
  import { normalizeName, resolveConsultant } from './consultants.mjs';
12
20
 
@@ -36,16 +44,20 @@ const exposedPathSchema = z
36
44
  .refine((p) => path.isAbsolute(p), { message: 'context.expose_paths[] must be an absolute path' });
37
45
 
38
46
  // A revision for `git diff`: a ref, a commit, or HEAD~n -- never an option.
39
- const gitRevision = z.string().trim().min(1).max(200)
47
+ const gitRevision = z
48
+ .string()
49
+ .trim()
50
+ .min(1)
51
+ .max(200)
40
52
  .regex(/^[A-Za-z0-9._/~^@{}-]+$/, 'context.expose_diff.base must be a git revision (a branch, tag, commit or HEAD~n)')
41
53
  .refine((r) => !r.startsWith('-'), { message: 'context.expose_diff.base must not start with "-"' });
42
54
 
43
55
  export const contextSchema = z
44
56
  .object({
45
- facts: z.array(trimmed(L.factMax, 'context.facts[]')).max(L.factsMax).default([]),
57
+ facts: z.array(trimmed(L.factMax, 'context.facts[]')).max(L.factsMax).optional(),
46
58
  proposal: z.string().trim().max(L.proposalMax).nullish(),
47
- counterpoints: z.array(trimmed(L.counterpointMax, 'context.counterpoints[]')).max(L.counterpointsMax).default([]),
48
- artifacts: z.array(artifactSchema).max(L.artifactsMax).default([]),
59
+ counterpoints: z.array(trimmed(L.counterpointMax, 'context.counterpoints[]')).max(L.counterpointsMax).optional(),
60
+ artifacts: z.array(artifactSchema).max(L.artifactsMax).optional(),
49
61
  expose_paths: z.array(exposedPathSchema).max(L.exposePathsMax).default([]),
50
62
  // The lead's uncommitted (or since-`base`) change: its diff goes in the
51
63
  // brief and the files it touches are copied, under workspace/repo/ at
@@ -55,6 +67,9 @@ export const contextSchema = z
55
67
  // round exposed. Never implied -- what reaches another vendor's model is
56
68
  // what the lead named in this call.
57
69
  expose_from_parent: z.boolean().optional(),
70
+ // Read as the request's own: a lead often nests them beside the facts.
71
+ constraints: z.array(trimmed(L.constraintMax, 'context.constraints[]')).max(L.constraintsMax).optional(),
72
+ success_criteria: z.array(trimmed(L.constraintMax, 'context.success_criteria[]')).max(L.constraintsMax).optional(),
58
73
  })
59
74
  .strict();
60
75
 
@@ -62,7 +77,11 @@ export const contextSchema = z
62
77
  // for it, its CLI, its vendor, or its model ("glm", "claude opus"). Which
63
78
  // consultant that is depends on the operator's config, so it is resolved in
64
79
  // parseRequest, not by an enum here.
65
- const targetSpec = z.string().trim().min(1, 'target must not be empty').max(120, 'target exceeds 120 characters')
80
+ const targetSpec = z
81
+ .string()
82
+ .trim()
83
+ .min(1, 'target must not be empty')
84
+ .max(120, 'target exceeds 120 characters')
66
85
  .regex(/^[^\p{Cc}\p{Cf}]+$/u, 'target must be a single line of printable characters');
67
86
 
68
87
  // Who asked for a consultation, as the lead reports it.
@@ -100,13 +119,13 @@ export const requestSchema = z
100
119
  // "off" takes web search and browsing away from every consultant in this
101
120
  // round, for a brief that must not leave the machine through a URL. The
102
121
  // consultant still reads the brief and the disk; it reaches nothing else.
103
- network: z.enum(NETWORK_MODES).default('on'),
122
+ network: z.enum(NETWORK_MODES).optional(),
104
123
  question: trimmed(L.questionMax, 'question'),
105
124
  // Optional: most consultations state what they are after in the question
106
125
  // itself, and a second field that repeats it is friction, not information.
107
126
  objective: trimmed(L.objectiveMax, 'objective').nullish(),
108
- success_criteria: z.array(trimmed(L.constraintMax, 'success_criteria[]')).max(L.constraintsMax).default([]),
109
- constraints: z.array(trimmed(L.constraintMax, 'constraints[]')).max(L.constraintsMax).default([]),
127
+ success_criteria: z.array(trimmed(L.constraintMax, 'success_criteria[]')).max(L.constraintsMax).optional(),
128
+ constraints: z.array(trimmed(L.constraintMax, 'constraints[]')).max(L.constraintsMax).optional(),
110
129
  // prefault, not default: zod 4 returns a default as is, skipping the inner defaults (expose_paths among them).
111
130
  context: contextSchema.prefault({}),
112
131
  // Written by the lead before the consultant is launched, kept on this
@@ -156,14 +175,33 @@ export function parseRequest(raw, { isFollowup = false, parentMode = null, group
156
175
  const issues = parsed.error.issues.map((i) => `${i.path.join('.') || '(root)'}: ${i.message}`);
157
176
  throw new RequestError(`request failed validation: ${issues.join('; ')}`, 'invalid_request', issues);
158
177
  }
178
+ // Filled in below with what the request resolves to (targets, caller, ...).
179
+ /** @type {Record<string, any>} */
159
180
  const req = parsed.data;
181
+ // Left without a default in the schema, so that a follow-up can tell a list
182
+ // it left out (inherited from the round before, jobs.mjs) from one it
183
+ // 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;
186
+ if (req[k] !== undefined) {
187
+ throw new RequestError(`pass ${k} once: at the top of the request or inside context, not both`, 'invalid_request');
188
+ }
189
+ req[k] = req.context[k];
190
+ delete req.context[k];
191
+ }
192
+ req.network ??= 'on';
193
+ req.success_criteria ??= [];
194
+ req.constraints ??= [];
195
+ for (const k of ['facts', 'counterpoints', 'artifacts']) req.context[k] ??= [];
160
196
  const hasSingle = req.target !== undefined && req.target !== null;
161
197
  const hasMany = Array.isArray(req.targets) && req.targets.length > 0;
162
198
  if (hasSingle && hasMany) {
163
199
  throw new RequestError('pass either target (one consultant) or targets (several), not both', 'invalid_request');
164
200
  }
165
- const listed = () => availableConsultants().map((c) => `${c.name} (${c.cli}, ${c.model})`).join(', ')
166
- || 'none -- no consultant CLI is available here';
201
+ const listed = () =>
202
+ availableConsultants()
203
+ .map((c) => `${c.name} (${c.cli}, ${c.model})`)
204
+ .join(', ') || 'none -- no consultant CLI is available here';
167
205
  if (!hasSingle && !hasMany) {
168
206
  throw new RequestError(
169
207
  `name the consultant: target: a consultant, its CLI or its model (available: ${listed()}), or targets: [...] to ask several the same question, targets: ["all"] to ask everyone`,
@@ -180,7 +218,10 @@ export function parseRequest(raw, { isFollowup = false, parentMode = null, group
180
218
  if (isAll[0]) {
181
219
  chosen = availableConsultants();
182
220
  if (!chosen.length) {
183
- throw new RequestError('no consultant is available on this machine -- install one of the CLIs, or check the config file', 'target_unavailable');
221
+ throw new RequestError(
222
+ 'no consultant is available on this machine -- install one of the CLIs, or check the config file',
223
+ 'target_unavailable',
224
+ );
184
225
  }
185
226
  } else {
186
227
  // Resolved among the reachable consultants first, so one that is off does
@@ -192,13 +233,19 @@ export function parseRequest(raw, { isFollowup = false, parentMode = null, group
192
233
  chosen = named.map((name) => {
193
234
  let r = resolveConsultant(name, reachable, POLICY.ranking);
194
235
  if (r.none) r = resolveConsultant(name, POLICY.consultants, POLICY.ranking);
236
+ if (r.none && brokenConsultant(name)) {
237
+ throw new RequestError(
238
+ `consultant ${JSON.stringify(name)} is not available: ${brokenConsultant(name)}; available: ${listed()}`,
239
+ 'target_unavailable',
240
+ );
241
+ }
195
242
  if (r.none) {
196
243
  throw new RequestError(`no consultant answers to ${JSON.stringify(name)}; available: ${listed()}`, 'unknown_target');
197
244
  }
198
245
  if (r.ambiguous) {
199
246
  throw new RequestError(
200
- `${JSON.stringify(name)} fits more than one consultant and no ranking rule decides between `
201
- + `${r.ambiguous.map((c) => `${c.name} (${c.cli}, ${c.model})`).join(', ')}; name one of them`,
247
+ `${JSON.stringify(name)} fits more than one consultant and no ranking rule decides between ` +
248
+ `${r.ambiguous.map((c) => `${c.name} (${c.cli}, ${c.model})`).join(', ')}; name one of them`,
202
249
  'target_ambiguous',
203
250
  );
204
251
  }
@@ -207,8 +254,8 @@ export function parseRequest(raw, { isFollowup = false, parentMode = null, group
207
254
  const off = [...new Set(chosen.filter((c) => !consultantAvailable(c)))];
208
255
  if (off.length) {
209
256
  throw new RequestError(
210
- `consultant ${off.map((c) => `${JSON.stringify(c.name)} (${unavailableReason(c)})`).join(', ')} `
211
- + `is not available on this machine; available: ${listed()}`,
257
+ `consultant ${off.map((c) => `${JSON.stringify(c.name)} (${unavailableReason(c)})`).join(', ')} ` +
258
+ `is not available on this machine; available: ${listed()}`,
212
259
  'target_unavailable',
213
260
  );
214
261
  }
@@ -240,8 +287,7 @@ export function parseRequest(raw, { isFollowup = false, parentMode = null, group
240
287
  // one its context fits -- no proposal is explore, a proposal is review, a
241
288
  // proposal with the other side's claims is debate. Checked below like a
242
289
  // mode that was given.
243
- req.mode ??= parentMode
244
- ?? (!req.context.proposal ? 'explore' : req.context.counterpoints.length ? 'debate' : 'review');
290
+ req.mode ??= parentMode ?? (!req.context.proposal ? 'explore' : req.context.counterpoints.length ? 'debate' : 'review');
245
291
 
246
292
  const total = charCount(req);
247
293
  if (total > L.totalCharsMax) {
@@ -275,10 +321,18 @@ export function parseRequest(raw, { isFollowup = false, parentMode = null, group
275
321
  'expose_from_parent_needs_followup',
276
322
  );
277
323
  }
278
- if (req.context.facts.length === 0 && req.context.artifacts.length === 0 && req.context.expose_paths.length === 0
279
- && !req.context.expose_diff && !req.context.expose_from_parent) {
324
+ if (
325
+ req.context.facts.length === 0 &&
326
+ req.context.artifacts.length === 0 &&
327
+ req.context.expose_paths.length === 0 &&
328
+ !req.context.expose_diff &&
329
+ !req.context.expose_from_parent
330
+ ) {
280
331
  throw new RequestError(
281
- 'provide at least one entry in context.facts, context.artifacts or context.expose_paths: the consultant starts in an empty working directory and is not told where your repository is, so every fact it needs must be in the request',
332
+ 'provide at least one entry in context.facts, context.artifacts or context.expose_paths: the consultant starts in an empty working directory and is not told where your repository is, so every fact it needs must be in the request' +
333
+ (isFollowup
334
+ ? '; on a follow-up, context.expose_from_parent: true copies again the files the earlier round was shown'
335
+ : ''),
282
336
  'context_required',
283
337
  );
284
338
  }