severally-mcp 2.0.0 → 2.3.1

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/CODE_OF_CONDUCT.md +83 -0
  2. package/CONFIG.md +13 -7
  3. package/README.ja.md +157 -77
  4. package/README.md +162 -81
  5. package/bin/severally-mcp.mjs +3 -0
  6. package/config.example.json +26 -0
  7. package/docs/how-it-works.ja.md +11 -3
  8. package/docs/how-it-works.md +12 -4
  9. package/docs/install.ja.md +25 -6
  10. package/docs/install.md +30 -7
  11. package/package.json +5 -3
  12. package/plugins/severally/.claude-plugin/plugin.json +1 -1
  13. package/plugins/severally/.codex-plugin/plugin.json +4 -2
  14. package/plugins/severally/.mcp.json +2 -1
  15. package/plugins/severally/dist/severally-mcp.mjs +29639 -28215
  16. package/plugins/severally/skills/_template/SKILL.md.tmpl +19 -23
  17. package/plugins/severally/skills/_template/reference.md.tmpl +22 -10
  18. package/plugins/severally/skills/antigravity/severally/SKILL.md +19 -23
  19. package/plugins/severally/skills/antigravity/severally/reference.md +22 -10
  20. package/plugins/severally/skills/claude/severally/SKILL.md +19 -23
  21. package/plugins/severally/skills/claude/severally/reference.md +22 -10
  22. package/plugins/severally/skills/codex/severally/SKILL.md +19 -23
  23. package/plugins/severally/skills/codex/severally/reference.md +22 -10
  24. package/plugins/severally/skills/opencode/severally/SKILL.md +19 -23
  25. package/plugins/severally/skills/opencode/severally/reference.md +22 -10
  26. package/scripts/init-config.mjs +1 -1
  27. package/scripts/install.mjs +124 -9
  28. package/src/adapters/antigravity-sandbox.mjs +5 -1
  29. package/src/adapters/codex.mjs +17 -6
  30. package/src/brief.mjs +38 -16
  31. package/src/consultants.mjs +31 -10
  32. package/src/expose-paths.mjs +68 -5
  33. package/src/jobs.mjs +294 -56
  34. package/src/policy.mjs +49 -11
  35. package/src/redact.mjs +20 -1
  36. package/src/run.mjs +38 -5
  37. package/src/schema.mjs +35 -12
  38. package/src/server.mjs +96 -120
  39. package/src/status.mjs +63 -0
  40. package/src/store.mjs +44 -1
package/src/policy.mjs CHANGED
@@ -35,21 +35,50 @@ const CONFIG = (() => {
35
35
  }
36
36
  })();
37
37
 
38
- // Every mistake in the file, not only a syntax error. A config with any of
39
- // them is not run on defaults: the server still starts, so the operator can
40
- // read why, but every consultation is refused (jobs.mjs) -- falling back would
41
- // quietly re-enable a CLI the operator turned off.
38
+ // A mistake that leaves the whole file untrustworthy (unreadable, an unknown
39
+ // top-level key, a section of the wrong shape) is not run on defaults: the
40
+ // server still starts, so the operator can read why, but every consultation is
41
+ // refused (jobs.mjs) -- falling back would quietly re-enable a CLI the
42
+ // operator turned off. A mistake inside one consultant or one CLI only takes
43
+ // that one out of use, with the reason.
42
44
  const BUILT = buildConsultants(CONFIG);
43
- if (!configError && BUILT.problems.length) configError = `${CONFIG_PATH}: ${BUILT.problems.join('; ')}`;
45
+ if (!configError && BUILT.global.length) configError = `${CONFIG_PATH}: ${BUILT.global.join('; ')}`;
46
+ // A value that is neither, from the environment, cannot be read as either: reading it as "on" would open the web.
47
+ if (!configError && ![undefined, '', 'on', 'off'].includes(process.env.SEVERALLY_NETWORK)) {
48
+ configError = `SEVERALLY_NETWORK must be "on" or "off", not ${JSON.stringify(process.env.SEVERALLY_NETWORK)}`;
49
+ }
44
50
  const FROM = configError ? buildConsultants({}) : BUILT;
51
+ const LOCAL = configError ? [] : BUILT.problems.filter((p) => !BUILT.global.includes(p));
45
52
 
46
- /** What is wrong with the config file, or null. Reported at startup; while set, nothing can be started. */
53
+ /** What is wrong with the config file as a whole, or null. Reported at startup; while set, nothing can be started. */
47
54
  export function configProblem() {
48
55
  return configError;
49
56
  }
50
57
 
58
+ /** The mistakes kept to one consultant or one CLI, each of which is out of use until fixed. */
59
+ export function configWarnings() {
60
+ return LOCAL.map((p) => `${CONFIG_PATH}: ${p}`);
61
+ }
62
+
63
+ /** The names of the consultants out of use because of a mistake in their own config section. */
64
+ export function brokenConsultantNames() {
65
+ return configError ? [] : Object.keys(BUILT.broken);
66
+ }
67
+
68
+ /** Why the consultant of this name is out of use because of a config mistake, or null. */
69
+ export function brokenConsultant(name) {
70
+ const p = !configError && Object.hasOwn(BUILT.broken, name) ? BUILT.broken[name] : null;
71
+ return p ? `the config file has a mistake in it: ${p.join('; ')}` : null;
72
+ }
73
+
51
74
  const usable = configError ? {} : CONFIG;
52
- const cfgCli = (id) => (usable.clis && typeof usable.clis === 'object' ? usable.clis[id] ?? {} : {});
75
+ // A CLI whose own section has a mistake is off, whatever else the section says.
76
+ const cfgCli = (id) => {
77
+ if (Object.hasOwn(BUILT.brokenClis, id) && !configError) {
78
+ return { enabled: false, note: `turned off: the config file has a mistake in clis.${id}: ${BUILT.brokenClis[id].join('; ')}` };
79
+ }
80
+ return usable.clis && typeof usable.clis === 'object' ? usable.clis[id] ?? {} : {};
81
+ };
53
82
  const cfgServer = usable.server && typeof usable.server === 'object' ? usable.server : {};
54
83
 
55
84
  /**
@@ -214,9 +243,14 @@ export const POLICY = Object.freeze({
214
243
  // going beyond that is an operator decision with quota to match.
215
244
  maxConcurrent: limit('SEVERALLY_MAX_CONCURRENT', cfgServer.max_concurrent, 9, 1, 9),
216
245
  maxJobsRetained: limit('SEVERALLY_MAX_JOBS_RETAINED', cfgServer.max_jobs_retained, 200, 20, 2000),
217
- // Upper bound for consult_get(wait_ms). Deliberately under the 60s default
218
- // request timeout that MCP clients apply, so a long wait does not blow up as
219
- // a client-side timeout while the consultation is still healthy.
246
+ // Upper bound for consult_get(wait_ms). 45 s stays under the 60 s some MCP
247
+ // clients allow a tool call, so a long wait does not blow up as a client-side
248
+ // timeout while the consultation is still healthy; a client registration
249
+ // that is known to wait longer raises it (scripts/install.mjs).
250
+ // "off" takes the web away from every consultation, whatever the request
251
+ // asks; "on" leaves it to each request's own network.
252
+ // Either source can close it; neither can reopen what the other closed.
253
+ network: [process.env.SEVERALLY_NETWORK, cfgServer.network].includes('off') ? 'off' : 'on',
220
254
  maxWaitMs: limit('SEVERALLY_MAX_WAIT_MS', cfgServer.max_wait_ms, 45_000, 0, 600_000),
221
255
 
222
256
  input: Object.freeze({
@@ -261,7 +295,7 @@ export const POLICY = Object.freeze({
261
295
  * different request, not a slower one.
262
296
  */
263
297
  export function timeoutMs(consultant) {
264
- return consultant?.timeoutMs ?? limit('SEVERALLY_TIMEOUT_MS', cfgServer.timeout_ms, 3_600_000, 1_000, 7_200_000);
298
+ return consultant?.timeoutMs ?? limit('SEVERALLY_TIMEOUT_MS', cfgServer.timeout_ms, 1_800_000, 1_000, 7_200_000);
265
299
  }
266
300
 
267
301
  /**
@@ -388,11 +422,15 @@ export function limitsSummary() {
388
422
  const by = (f) => Object.fromEntries(list.map((c) => [c.name, f(c)]));
389
423
  return {
390
424
  available_targets: list.map((c) => c.name),
425
+ // A consultant or CLI out of use because of a mistake in its own section.
426
+ ...(LOCAL.length ? { config_mistakes: configWarnings() } : {}),
391
427
  clis: by((c) => c.cli),
392
428
  models: by((c) => c.model),
393
429
  // Per consultant, because they no longer share one budget.
394
430
  timeout_ms: by(timeoutMs),
395
431
  max_rounds_per_chain: POLICY.maxRounds,
432
+ // "off": the operator has taken the web away from every consultation.
433
+ network: POLICY.network,
396
434
  max_concurrent_jobs: POLICY.maxConcurrent,
397
435
  max_wait_ms: POLICY.maxWaitMs,
398
436
  input_char_budget: POLICY.input.totalCharsMax,
package/src/redact.mjs CHANGED
@@ -1,6 +1,19 @@
1
1
  // Credential scrubbing. Applied on the way out (brief sent to the consultant),
2
2
  // on the way in (consultant output), and to everything persisted to disk.
3
3
 
4
+ const CODE_WORDS = new Set(['true', 'false', 'null', 'none', 'undefined', 'string', 'str', 'number', 'boolean', 'bool', 'int', 'bytes']);
5
+
6
+ // A password-type value that is code, not a secret: a flag or a type, a
7
+ // variable reference (${X}, $X, process.env.X, os.environ[...]), and -- unless
8
+ // it is a long value under a full PASSWORD/PASSWD name, which stays masked
9
+ // whatever it looks like -- a dotted name, a call or a borrow (&str).
10
+ function looksLikeCode(name, value) {
11
+ if (CODE_WORDS.has(value.toLowerCase()) || value.startsWith('${') || /^\$[A-Z_][A-Z0-9_]*$/.test(value)
12
+ || /^(?:process\.env\.|os\.environ\b)/.test(value)) return true;
13
+ if (value.length >= 8 && /^PASSW(?:OR)?D$/i.test(name.split('_').at(-1))) return false;
14
+ return /^[A-Za-z_][\w$]*(?:\.[A-Za-z_$][\w$]*)+$/.test(value) || /^[A-Za-z_][\w$.]*\(/.test(value) || value.startsWith('&');
15
+ }
16
+
4
17
  const PATTERNS = [
5
18
  // Provider API keys
6
19
  [/\bsk-ant-[A-Za-z0-9_\-]{12,}/g, '[REDACTED:anthropic-key]'],
@@ -9,6 +22,7 @@ const PATTERNS = [
9
22
  [/\bgh[pousr]_[A-Za-z0-9]{16,}/g, '[REDACTED:github-token]'],
10
23
  [/\bgithub_pat_[A-Za-z0-9_]{20,}/g, '[REDACTED:github-token]'],
11
24
  [/\bxox[baprs]-[A-Za-z0-9-]{10,}/g, '[REDACTED:slack-token]'],
25
+ [/https:\/\/hooks\.slack\.com\/services\/T[A-Za-z0-9]+\/B[A-Za-z0-9]+\/[A-Za-z0-9]+/g, '[REDACTED:slack-webhook]'],
12
26
  [/\bAKIA[0-9A-Z]{16}\b/g, '[REDACTED:aws-access-key-id]'],
13
27
  [/\bASIA[0-9A-Z]{16}\b/g, '[REDACTED:aws-access-key-id]'],
14
28
  [/\bAIza[0-9A-Za-z_\-]{30,}/g, '[REDACTED:google-api-key]'],
@@ -25,8 +39,13 @@ const PATTERNS = [
25
39
  // The password in a URL's userinfo, also with an empty user (redis://:pass@host)
26
40
  [/(:\/\/[^\s:/@]*:)[^\s@/]+(@)/g, '$1[REDACTED]$2'],
27
41
  // KEY=value / "token": "value" style assignments
28
- [/\b((?:[A-Za-z0-9_]*_)?(?:API_KEY|APIKEY|ACCESS_KEY|PRIVATE_KEY|ACCESS_TOKEN|REFRESH_TOKEN|ID_TOKEN|SECRET_KEY|CLIENT_SECRET|AUTH_TOKEN|CREDENTIALS|PASSWORD|PASSWD|SECRET|TOKEN))(["']?\s*[:=]\s*["']?)([^\s"',;)]{8,})/gi,
42
+ [/\b((?:[A-Za-z0-9_]*_)?(?:API_KEY|APIKEY|ACCESS_KEY|PRIVATE_KEY|ACCESS_TOKEN|REFRESH_TOKEN|ID_TOKEN|SECRET_KEY|CLIENT_SECRET|AUTH_TOKEN|CREDENTIALS|ACCOUNTKEY|SHAREDACCESSKEY|SECRET|TOKEN))(["']?\s*[:=]\s*["']?)([^\s"',;)]{8,})/gi,
29
43
  (_m, k, sep, _v) => `${k}${sep}[REDACTED]`],
44
+ // Password-type names, with values from 4 characters; PASS and PWD too (PWD
45
+ // only after `_` or a letter: bare PWD is the working directory). What reads
46
+ // as code rather than a password is left alone (looksLikeCode).
47
+ [/\b((?:[A-Za-z0-9_]*_)?(?:PASSWORD|PASSWD|PASS|(?<=[A-Za-z_])PWD))(["']?\s*[:=]\s*["']?)([^\s"',;)]{4,})/gi,
48
+ (m, k, sep, v) => (looksLikeCode(k, v) ? m : `${k}${sep}[REDACTED]`)],
30
49
  // Any environment variable named *_KEY. Upper case only: in code, primary_key,
31
50
  // sort_key and idempotency_key name things, not secrets.
32
51
  [/\b([A-Z0-9_]+_KEY)(\s*=\s*["']?)([^\s"',;)]{8,})/g, (_m, k, sep, _v) => `${k}${sep}[REDACTED]`],
package/src/run.mjs CHANGED
@@ -164,15 +164,21 @@ export class ProcHandle {
164
164
  this.stdoutSoFar = readStdout;
165
165
  }
166
166
 
167
- /** Kill the child and every descendant it spawned. */
167
+ /**
168
+ * Kill the child and every descendant it spawned -- also after the child
169
+ * itself has exited: a descendant still holding its pipes keeps the
170
+ * consultation from closing, and on POSIX it is still in the group.
171
+ */
168
172
  stop(signal = 'SIGTERM') {
169
- if (this.killed || !this.child || this.child.exitCode !== null) return;
173
+ if (this.killed || !this.child) return;
170
174
  this.killed = true;
171
175
  const pid = this.child.pid;
172
176
  if (!pid) return;
173
177
  if (process.platform === 'win32') {
174
178
  // Negative PIDs/process groups are POSIX-only. Kill the Windows tree
175
- // before its parent exits and its descendants become unreachable.
179
+ // before its parent exits and its descendants become unreachable. Once
180
+ // it has exited, its pid may already be another process's: leave it.
181
+ if (this.child.exitCode !== null || this.child.signalCode !== null) return;
176
182
  const killer = spawn(path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'taskkill.exe'),
177
183
  ['/pid', String(pid), '/T', '/F'], { windowsHide: true, stdio: 'ignore' });
178
184
  const fallback = () => { try { this.child.kill('SIGKILL'); } catch { /* gone */ } };
@@ -201,6 +207,12 @@ export class ProcHandle {
201
207
  /**
202
208
  * @returns {{handle: ProcHandle, done: Promise<{code, signal, stdout, stderr, timedOut, cancelled, truncated}>}}
203
209
  */
210
+ /**
211
+ * `pipesForced` in the result: the child exited but something it started kept
212
+ * stdout or stderr open past the kill grace, so the group was stopped and the
213
+ * pipes closed from this side. A descendant outside the group (setsid, or on
214
+ * Windows once the parent is gone) may still be running.
215
+ */
204
216
  export function runChild({ command, args, cwd, env, input, timeoutMs, onCancelSignal, compactLine = null,
205
217
  settleAfter = null, stopSignal = 'SIGTERM' }) {
206
218
  const child = spawn(command, args, {
@@ -288,16 +300,37 @@ export function runChild({ command, args, cwd, env, input, timeoutMs, onCancelSi
288
300
 
289
301
  const done = new Promise((resolve) => {
290
302
  let settled = false;
303
+ let pipesForced = false;
304
+ let orphanTimer = null;
291
305
  const finish = (payload) => {
292
306
  if (settled) return;
293
307
  settled = true;
294
308
  clearTimeout(timer);
295
309
  clearTimeout(settleTimer);
296
- resolve({ stdout, stderr, truncated, timedOut, cancelled, stalled, ...payload });
310
+ clearTimeout(orphanTimer);
311
+ resolve({ stdout, stderr, truncated, timedOut, cancelled, stalled, pipesForced, ...payload });
297
312
  };
313
+ const flushPending = () => { if (byLine && pending) { append('out', compact(pending)); pending = ''; } };
298
314
  child.on('error', (err) => finish({ code: null, signal: null, spawnError: err.message }));
315
+ // 'close' waits for the pipes as well as the child. Once the child has
316
+ // exited, a grace later the rest of its group is stopped, and a grace
317
+ // after that the pipes are closed from here, whoever still holds them.
318
+ child.on('exit', (code, signal) => {
319
+ const grace = POLICY.killGraceMs;
320
+ orphanTimer = setTimeout(() => {
321
+ pipesForced = true;
322
+ handle.stop(stopSignal);
323
+ orphanTimer = setTimeout(() => {
324
+ handle.forceKill();
325
+ flushPending();
326
+ child.stdout.destroy();
327
+ child.stderr.destroy();
328
+ finish({ code, signal });
329
+ }, grace);
330
+ }, grace);
331
+ });
299
332
  child.on('close', (code, signal) => {
300
- if (byLine && pending) { append('out', compact(pending)); pending = ''; }
333
+ flushPending();
301
334
  finish({ code, signal });
302
335
  });
303
336
  });
package/src/schema.mjs CHANGED
@@ -6,7 +6,7 @@ import { z } from 'zod';
6
6
  import path from 'node:path';
7
7
  import {
8
8
  POLICY, CALLER_INPUTS, MODES, STANCES, artifactKinds,
9
- resolveCaller, availableConsultants, consultantAvailable, unavailableReason,
9
+ resolveCaller, availableConsultants, consultantAvailable, unavailableReason, brokenConsultant,
10
10
  } from './policy.mjs';
11
11
  import { normalizeName, resolveConsultant } from './consultants.mjs';
12
12
 
@@ -42,10 +42,10 @@ const gitRevision = z.string().trim().min(1).max(200)
42
42
 
43
43
  export const contextSchema = z
44
44
  .object({
45
- facts: z.array(trimmed(L.factMax, 'context.facts[]')).max(L.factsMax).default([]),
45
+ facts: z.array(trimmed(L.factMax, 'context.facts[]')).max(L.factsMax).optional(),
46
46
  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([]),
47
+ counterpoints: z.array(trimmed(L.counterpointMax, 'context.counterpoints[]')).max(L.counterpointsMax).optional(),
48
+ artifacts: z.array(artifactSchema).max(L.artifactsMax).optional(),
49
49
  expose_paths: z.array(exposedPathSchema).max(L.exposePathsMax).default([]),
50
50
  // The lead's uncommitted (or since-`base`) change: its diff goes in the
51
51
  // brief and the files it touches are copied, under workspace/repo/ at
@@ -55,6 +55,9 @@ export const contextSchema = z
55
55
  // round exposed. Never implied -- what reaches another vendor's model is
56
56
  // what the lead named in this call.
57
57
  expose_from_parent: z.boolean().optional(),
58
+ // Read as the request's own: a lead often nests them beside the facts.
59
+ constraints: z.array(trimmed(L.constraintMax, 'context.constraints[]')).max(L.constraintsMax).optional(),
60
+ success_criteria: z.array(trimmed(L.constraintMax, 'context.success_criteria[]')).max(L.constraintsMax).optional(),
58
61
  })
59
62
  .strict();
60
63
 
@@ -100,13 +103,13 @@ export const requestSchema = z
100
103
  // "off" takes web search and browsing away from every consultant in this
101
104
  // round, for a brief that must not leave the machine through a URL. The
102
105
  // consultant still reads the brief and the disk; it reaches nothing else.
103
- network: z.enum(NETWORK_MODES).default('on'),
106
+ network: z.enum(NETWORK_MODES).optional(),
104
107
  question: trimmed(L.questionMax, 'question'),
105
108
  // Optional: most consultations state what they are after in the question
106
109
  // itself, and a second field that repeats it is friction, not information.
107
110
  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([]),
111
+ success_criteria: z.array(trimmed(L.constraintMax, 'success_criteria[]')).max(L.constraintsMax).optional(),
112
+ constraints: z.array(trimmed(L.constraintMax, 'constraints[]')).max(L.constraintsMax).optional(),
110
113
  // prefault, not default: zod 4 returns a default as is, skipping the inner defaults (expose_paths among them).
111
114
  context: contextSchema.prefault({}),
112
115
  // Written by the lead before the consultant is launched, kept on this
@@ -147,15 +150,31 @@ export class RequestError extends Error {
147
150
 
148
151
  /**
149
152
  * Validate + normalise a consult request.
150
- * `isFollowup` is decided by the caller (the job store resolves followup_to first).
153
+ * `isFollowup` is decided by the caller (the job store resolves followup_to first);
154
+ * `groupFollowup` when followup_to named a fan-out, whose members are the targets.
151
155
  */
152
- export function parseRequest(raw, { isFollowup = false, parentMode = null } = {}) {
156
+ export function parseRequest(raw, { isFollowup = false, parentMode = null, groupFollowup = false } = {}) {
153
157
  const parsed = requestSchema.safeParse(raw);
154
158
  if (!parsed.success) {
155
159
  const issues = parsed.error.issues.map((i) => `${i.path.join('.') || '(root)'}: ${i.message}`);
156
160
  throw new RequestError(`request failed validation: ${issues.join('; ')}`, 'invalid_request', issues);
157
161
  }
158
162
  const req = parsed.data;
163
+ // Left without a default in the schema, so that a follow-up can tell a list
164
+ // it left out (inherited from the round before, jobs.mjs) from one it
165
+ // emptied on purpose: the tool layer applies the schema's defaults first.
166
+ for (const k of ['constraints', 'success_criteria']) {
167
+ if (req.context[k] === undefined) continue;
168
+ if (req[k] !== undefined) {
169
+ throw new RequestError(`pass ${k} once: at the top of the request or inside context, not both`, 'invalid_request');
170
+ }
171
+ req[k] = req.context[k];
172
+ delete req.context[k];
173
+ }
174
+ req.network ??= 'on';
175
+ req.success_criteria ??= [];
176
+ req.constraints ??= [];
177
+ for (const k of ['facts', 'counterpoints', 'artifacts']) req.context[k] ??= [];
159
178
  const hasSingle = req.target !== undefined && req.target !== null;
160
179
  const hasMany = Array.isArray(req.targets) && req.targets.length > 0;
161
180
  if (hasSingle && hasMany) {
@@ -191,6 +210,9 @@ export function parseRequest(raw, { isFollowup = false, parentMode = null } = {}
191
210
  chosen = named.map((name) => {
192
211
  let r = resolveConsultant(name, reachable, POLICY.ranking);
193
212
  if (r.none) r = resolveConsultant(name, POLICY.consultants, POLICY.ranking);
213
+ if (r.none && brokenConsultant(name)) {
214
+ throw new RequestError(`consultant ${JSON.stringify(name)} is not available: ${brokenConsultant(name)}; available: ${listed()}`, 'target_unavailable');
215
+ }
194
216
  if (r.none) {
195
217
  throw new RequestError(`no consultant answers to ${JSON.stringify(name)}; available: ${listed()}`, 'unknown_target');
196
218
  }
@@ -220,9 +242,9 @@ export function parseRequest(raw, { isFollowup = false, parentMode = null } = {}
220
242
  'duplicate_targets',
221
243
  );
222
244
  }
223
- if (isFollowup && unique.length > 1) {
245
+ if (isFollowup && !groupFollowup && unique.length > 1) {
224
246
  throw new RequestError(
225
- 'a follow-up continues the exchange with one consultant; name exactly one target',
247
+ 'a follow-up to a job continues the exchange with one consultant; name exactly one target, or pass followup_to a group_id to ask every member of that fan-out again',
226
248
  'followup_fanout_not_allowed',
227
249
  );
228
250
  }
@@ -277,7 +299,8 @@ export function parseRequest(raw, { isFollowup = false, parentMode = null } = {}
277
299
  if (req.context.facts.length === 0 && req.context.artifacts.length === 0 && req.context.expose_paths.length === 0
278
300
  && !req.context.expose_diff && !req.context.expose_from_parent) {
279
301
  throw new RequestError(
280
- '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',
302
+ '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'
303
+ + (isFollowup ? '; on a follow-up, context.expose_from_parent: true copies again the files the earlier round was shown' : ''),
281
304
  'context_required',
282
305
  );
283
306
  }