severally-mcp 2.0.0 → 2.1.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 (33) hide show
  1. package/CONFIG.md +4 -2
  2. package/README.ja.md +28 -15
  3. package/README.md +29 -15
  4. package/docs/how-it-works.ja.md +7 -1
  5. package/docs/how-it-works.md +7 -1
  6. package/docs/install.ja.md +13 -1
  7. package/docs/install.md +16 -2
  8. package/package.json +2 -2
  9. package/plugins/severally/.claude-plugin/plugin.json +1 -1
  10. package/plugins/severally/.codex-plugin/plugin.json +4 -2
  11. package/plugins/severally/.mcp.json +2 -1
  12. package/plugins/severally/dist/severally-mcp.mjs +327 -94
  13. package/plugins/severally/skills/_template/SKILL.md.tmpl +5 -3
  14. package/plugins/severally/skills/_template/reference.md.tmpl +12 -6
  15. package/plugins/severally/skills/antigravity/severally/SKILL.md +5 -3
  16. package/plugins/severally/skills/antigravity/severally/reference.md +12 -6
  17. package/plugins/severally/skills/claude/severally/SKILL.md +5 -3
  18. package/plugins/severally/skills/claude/severally/reference.md +12 -6
  19. package/plugins/severally/skills/codex/severally/SKILL.md +5 -3
  20. package/plugins/severally/skills/codex/severally/reference.md +12 -6
  21. package/plugins/severally/skills/opencode/severally/SKILL.md +5 -3
  22. package/plugins/severally/skills/opencode/severally/reference.md +12 -6
  23. package/scripts/init-config.mjs +1 -1
  24. package/scripts/install.mjs +35 -5
  25. package/src/adapters/antigravity-sandbox.mjs +5 -1
  26. package/src/adapters/codex.mjs +17 -6
  27. package/src/brief.mjs +38 -16
  28. package/src/expose-paths.mjs +68 -5
  29. package/src/jobs.mjs +171 -45
  30. package/src/policy.mjs +5 -4
  31. package/src/redact.mjs +20 -1
  32. package/src/schema.mjs +5 -4
  33. package/src/server.mjs +44 -16
@@ -105,6 +105,12 @@ function walkDir(root, dir, onFile, totals, skipped) {
105
105
  }
106
106
  for (const item of items) {
107
107
  const child = path.join(dir, item.name);
108
+ // Outside git there is no .gitignore to say what is not source: these two
109
+ // are never what a consultant should read, and would hit the caps alone.
110
+ if (item.name === '.git' || item.name === 'node_modules') {
111
+ skipped.push({ source: child, reason: 'ignored' });
112
+ continue;
113
+ }
108
114
  // Not followed: a link inside a directory the lead exposed can resolve
109
115
  // anywhere on the disk, which is exactly the scope this feature exists
110
116
  // to keep drawn. Recorded rather than dropped silently, so a consultant
@@ -130,6 +136,61 @@ function walkDir(root, dir, onFile, totals, skipped) {
130
136
  }
131
137
  }
132
138
 
139
+ // walkDir's order: name by name at every level, not the whole path as one
140
+ // string ("a/x" comes before "a-b" there, after it as a string).
141
+ function byPathParts(a, b) {
142
+ const x = a.split('/');
143
+ 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;
145
+ return x.length - y.length;
146
+ }
147
+
148
+ // A directory inside a git work tree: the candidates are what git lists
149
+ // (tracked, plus untracked that .gitignore does not exclude), so `.git` and
150
+ // ignored trees drop out. Every candidate gets walkDir's checks again -- a
151
+ // committed .env is still protected, a listed file may be gone, a link, a
152
+ // submodule, or reach outside the directory. False when this is not a work
153
+ // tree, git is missing or lists nothing (an ignored directory the lead named
154
+ // on purpose): the caller walks it instead.
155
+ function walkGit(dir, exposedAs, onFile, totals, skipped) {
156
+ let listed;
157
+ let ignored;
158
+ try {
159
+ listed = [...new Set(nulSplit(git(dir, ['ls-files', '-z', '--cached', '--others', '--exclude-standard'])))];
160
+ ignored = nulSplit(git(dir, ['ls-files', '-z', '--others', '--ignored', '--exclude-standard', '--directory']));
161
+ } catch { return false; }
162
+ if (listed.length === 0) return false;
163
+ for (const p of ignored) skipped.push({ source: path.join(dir, p), reason: 'ignored' });
164
+ if (fs.existsSync(path.join(dir, '.git'))) skipped.push({ source: path.join(dir, '.git'), reason: 'ignored' });
165
+ const realDir = fs.realpathSync(dir);
166
+ const protectedDirs = new Set();
167
+ for (const rel of listed.sort(byPathParts)) {
168
+ const abs = path.join(dir, rel);
169
+ // walkDir never enters a protected directory (.env.d/, .ssh/ ...); a
170
+ // listed file is checked with each directory above it for the same reason.
171
+ const parts = rel.split('/');
172
+ const guarded = parts.slice(0, -1).map((_, i) => path.join(dir, ...parts.slice(0, i + 1))).find(isProtectedPath);
173
+ if (guarded) {
174
+ if (!protectedDirs.has(guarded)) skipped.push({ source: guarded, reason: 'protected' });
175
+ protectedDirs.add(guarded);
176
+ continue;
177
+ }
178
+ if (isProtectedPath(abs)) { skipped.push({ source: abs, reason: 'protected' }); continue; }
179
+ let stat;
180
+ // Deleted in the work tree but still in the index: not part of the tree
181
+ // as it is now, so not part of the copy either.
182
+ try { stat = fs.lstatSync(abs); } catch { continue; }
183
+ // A directory here is a submodule (or a nested repository): its own history, not this one's files.
184
+ if (stat.isDirectory()) { skipped.push({ source: abs, reason: 'submodule' }); continue; }
185
+ const inside = (() => { try { return fs.realpathSync(abs).startsWith(realDir + path.sep); } catch { return false; } })();
186
+ if (stat.isSymbolicLink() || !inside) { skipped.push({ source: abs, reason: 'symlink' }); continue; }
187
+ if (!stat.isFile()) continue;
188
+ onFile(abs, path.normalize(rel));
189
+ checkBudget(totals);
190
+ }
191
+ return true;
192
+ }
193
+
133
194
  // Each path is a string, or { source, index } to keep the name an earlier
134
195
  // round gave it.
135
196
  function walkEntries(exposePaths, onFile) {
@@ -138,6 +199,9 @@ function walkEntries(exposePaths, onFile) {
138
199
  const entries = exposePaths.map((p, i) => {
139
200
  const { source, index } = typeof p === 'string' ? { source: p, index: i } : p;
140
201
  const stat = statEntry(source);
202
+ // Indexed, so two entries whose basenames collide (two different
203
+ // util.mjs) stay apart, and the brief can name each one exactly.
204
+ const exposedAs = `workspace/${index}-${path.basename(source)}`;
141
205
  const before = { files: totals.files, bytes: totals.bytes };
142
206
  // What a directory holds, for the brief: not every consultant has a tool
143
207
  // that lists a directory, and a name it cannot see is a file it never reads.
@@ -148,18 +212,17 @@ function walkEntries(exposePaths, onFile) {
148
212
  onFile(source, '', index, source, totals);
149
213
  checkBudget(totals);
150
214
  } else {
151
- walkDir(source, source, (abs, rel) => {
215
+ const add = (abs, rel) => {
152
216
  paths.push(rel.split(path.sep).join('/'));
153
217
  onFile(abs, rel, index, source, totals);
154
- }, totals, skipped);
218
+ };
219
+ walkGit(source, exposedAs, add, totals, skipped) || walkDir(source, source, add, totals, skipped);
155
220
  }
156
221
  return {
157
222
  index,
158
223
  source,
159
224
  kind: stat.isDirectory() ? 'directory' : 'file',
160
- // Indexed, so two entries whose basenames collide (two different
161
- // util.mjs) stay apart, and the brief can name each one exactly.
162
- exposedAs: `workspace/${index}-${path.basename(source)}`,
225
+ exposedAs,
163
226
  files: totals.files - before.files,
164
227
  bytes: totals.bytes - before.bytes,
165
228
  paths: paths.sort(),
package/src/jobs.mjs CHANGED
@@ -4,7 +4,7 @@
4
4
  import crypto from 'node:crypto';
5
5
  import fs from 'node:fs';
6
6
  import path from 'node:path';
7
- import { POLICY, VERDICTS, limitsSummary, timeoutMs, detectCaller, configProblem, consultantByName } from './policy.mjs';
7
+ import { POLICY, VERDICTS, limitsSummary, timeoutMs, detectCaller, configProblem, consultantByName, consultantAvailable } from './policy.mjs';
8
8
  import { vendorOf, sameModel, CLI_VENDORS } from './consultants.mjs';
9
9
  import { parseRequest, RequestError, charCount } from './schema.mjs';
10
10
  import { renderBrief, renderGuardrails, briefRecord } from './brief.mjs';
@@ -296,7 +296,19 @@ export class JobManager {
296
296
  ? rawRequest.followup_to.trim()
297
297
  : null;
298
298
  let parent = null;
299
- if (followupTo) {
299
+ let fromGroup = null;
300
+ if (followupTo?.startsWith('group_')) {
301
+ // A follow-up to a fan-out goes to every member that can take one; the
302
+ // members are the targets, so the request names none.
303
+ if ((rawRequest.target ?? null) !== null || rawRequest.targets?.length) {
304
+ throw new RequestError(
305
+ 'a follow-up to a group goes to every member that can take one, so leave target and targets out; to follow up one member, pass its job_id',
306
+ 'followup_group_targets',
307
+ );
308
+ }
309
+ fromGroup = this.#groupParents(followupTo);
310
+ rawRequest = { ...rawRequest, targets: fromGroup.links.map((l) => l.parent.target) };
311
+ } else if (followupTo) {
300
312
  // A job from an earlier server session is read back from the history,
301
313
  // so a restart does not end a consultation the lead is still checking.
302
314
  parent = this.jobs.get(followupTo) ?? store.loadRound(followupTo);
@@ -322,8 +334,18 @@ export class JobManager {
322
334
  parent = { ...parent, nextRound: Math.max(...rounds.map((j) => j.round)) + 1 };
323
335
  }
324
336
 
325
- const req = parseRequest(rawRequest, { isFollowup: Boolean(parent), parentMode: parent?.mode ?? null });
337
+ const req = parseRequest(rawRequest, {
338
+ isFollowup: Boolean(parent || fromGroup),
339
+ parentMode: parent?.mode ?? fromGroup?.group.mode ?? null,
340
+ groupFollowup: Boolean(fromGroup),
341
+ });
326
342
 
343
+ if (fromGroup && fromGroup.links.some((l, i) => l.parent.target !== req.targets[i])) {
344
+ throw new RequestError(
345
+ `the members of group "${followupTo}" no longer resolve to the consultants that answered them; follow up each member by its job_id`,
346
+ 'followup_target_mismatch',
347
+ );
348
+ }
327
349
  if (parent && parent.target !== req.target) {
328
350
  throw new RequestError(
329
351
  `follow-up must go to the same consultant as job "${parent.job_id}" (${parent.target})`,
@@ -331,7 +353,7 @@ export class JobManager {
331
353
  );
332
354
  }
333
355
  const round = parent ? parent.nextRound : 1;
334
- if (round > POLICY.maxRounds) {
356
+ if (!fromGroup && round > POLICY.maxRounds) {
335
357
  throw new RequestError(
336
358
  `this consultation has used its ${POLICY.maxRounds} rounds (1 initial + ${POLICY.maxRounds - 1} follow-ups); decide with what you have, or start a new consultation with a materially different question`,
337
359
  'round_limit',
@@ -361,14 +383,57 @@ export class JobManager {
361
383
  }
362
384
  // A request refused from here on gives its seats back.
363
385
  try {
364
- return this.#admitted(req, parent, round, seats);
386
+ // Each target's parent round and the round it opens, in req.targets order.
387
+ const links = fromGroup?.links ?? req.targets.map(() => ({ parent, round }));
388
+ return this.#admitted(req, links, seats, fromGroup);
365
389
  } catch (err) {
366
390
  for (const seat of seats) if (!this.jobs.has(seat.job_id)) store.releaseSeat(seat.job_id);
367
391
  throw err;
368
392
  }
369
393
  }
370
394
 
371
- #admitted(req, parent, round, seats) {
395
+ // A follow-up to a fan-out: each member goes on from the newest answered
396
+ // round of its own chain. A member that is still running, never answered or
397
+ // has used its rounds is skipped and named; if that is every member, the
398
+ // request is refused.
399
+ #groupParents(groupId) {
400
+ const group = this.groups.get(groupId) ?? store.loadGroup(groupId);
401
+ if (!group) throw new RequestError(`followup_to references unknown group "${groupId}"`, 'unknown_group');
402
+ const links = [];
403
+ const skipped = [];
404
+ for (const jid of group.job_ids) {
405
+ const member = this.jobs.get(jid) ?? store.loadRound(jid);
406
+ if (!member) {
407
+ skipped.push({ job_id: jid, target: null, reason: 'not found: aged out of the history, or still running in another severally process' });
408
+ continue;
409
+ }
410
+ const rounds = this.#chainRounds(member.chain_id);
411
+ const latest = rounds.filter((j) => !['failed', 'cancelled'].includes(j.status)).sort((a, b) => b.round - a.round)[0];
412
+ const nextRound = Math.max(...rounds.map((j) => j.round)) + 1;
413
+ // The name is kept, not re-resolved: a consultant renamed or turned off
414
+ // since is skipped, never continued by whichever one answers to it now.
415
+ const consultant = consultantByName(member.target);
416
+ const reason = !latest ? `${member.status}, so there is no answer to follow up`
417
+ : latest.status !== 'completed' ? `round ${latest.round} is still ${latest.status}`
418
+ : nextRound > POLICY.maxRounds ? `has used its ${POLICY.maxRounds} rounds`
419
+ : !consultant || !consultantAvailable(consultant) ? 'this consultant is no longer configured or available here'
420
+ : null;
421
+ if (reason) skipped.push({ job_id: (latest ?? member).job_id, target: member.target, reason });
422
+ else links.push({ parent: { ...latest, nextRound }, round: nextRound, groupMember: member });
423
+ }
424
+ if (!links.length) {
425
+ throw new RequestError(
426
+ `no member of group "${groupId}" can take a follow-up: ${skipped.map((x) => `${x.target ?? x.job_id} (${x.reason})`).join(', ')}`,
427
+ 'followup_nothing_to_continue',
428
+ );
429
+ }
430
+ return { group, links, skipped };
431
+ }
432
+
433
+ #admitted(req, links, seats, fromGroup = null) {
434
+ // What expose_from_parent copies again: for a group, what that fan-out
435
+ // exposed (one manifest, shared by its members); otherwise the parent's.
436
+ const parent = links[0].groupMember ?? links[0].parent;
372
437
  const groupId = id('group');
373
438
  // Copied once for the whole request, before a round is counted or a job
374
439
  // exists, into the group's own directory: every member is then handed the
@@ -428,7 +493,8 @@ export class JobManager {
428
493
  req._snapshot = path.join(snapshot, 'workspace');
429
494
  }
430
495
  const members = req.targets.map((target, i) => {
431
- const chainId = parent ? parent.chain_id : id('chain');
496
+ const { parent: from, round } = links[i];
497
+ const chainId = from ? from.chain_id : id('chain');
432
498
  const jobId = seats[i].job_id;
433
499
  const job = {
434
500
  job_id: jobId,
@@ -462,7 +528,7 @@ export class JobManager {
462
528
  retries: [],
463
529
  partial_output: null,
464
530
  tool_calls: null,
465
- followup_to: parent?.job_id ?? null,
531
+ followup_to: from?.job_id ?? null,
466
532
  status: 'queued',
467
533
  // What this consultant is: the CLI it runs on, the model and its
468
534
  // lineage, as the operator defined it.
@@ -500,14 +566,13 @@ export class JobManager {
500
566
  this.groups.set(groupId, group);
501
567
  try { store.persistGroup(group); } catch { /* history is best effort */ }
502
568
 
503
- // One brief, rendered once for the whole group: a fan-out is only comparable
504
- // if every consultant answered the same question in the same words. Every
505
- // member shares one chain history by construction -- a fan-out is never a
506
- // follow-up (parseRequest refuses that), so each member's fresh chain is
507
- // empty, and a follow-up is always a group of one on the parent's chain.
508
- const chain = { round, priorRounds: this.#chainHistory(members[0].chain_id) };
509
- const brief = renderBrief(req, chain, exposeManifest);
569
+ // A fan-out is only comparable if every consultant answered the same
570
+ // question in the same words: every member's brief is the same request,
571
+ // rendered the same way. Only the earlier rounds differ -- each member's
572
+ // own, on a follow-up to a group; none, on a first round.
510
573
  for (const job of members) {
574
+ const chain = { round: job.round, priorRounds: this.#chainHistory(job.chain_id) };
575
+ const brief = renderBrief(req, chain, exposeManifest);
511
576
  job.promise = this.#execute(job, { ...req, target: job.target }, chain, brief).catch((err) => {
512
577
  this.#fail(job, 'cli_error', err?.message ?? String(err));
513
578
  });
@@ -517,11 +582,14 @@ export class JobManager {
517
582
  // if the process dies mid-consultation. Keep that copy ahead of any await.
518
583
  if (req._snapshot) store.cleanupJobDir(groupId);
519
584
 
585
+ // The members of a follow-up to a group can be at different rounds.
586
+ const { round } = members[0];
587
+ const sameRound = members.every((m) => m.round === round);
520
588
  const common = {
521
589
  group_id: groupId,
522
- round,
590
+ ...(sameRound ? { round, rounds_remaining: POLICY.maxRounds - round } : {}),
591
+ ...(fromGroup ? { followup_to_group: fromGroup.group.group_id, skipped: fromGroup.skipped } : {}),
523
592
  ...(parentEntries.length ? { exposed_from_parent: inheritedSummary(exposeManifest) } : {}),
524
- rounds_remaining: POLICY.maxRounds - round,
525
593
  mode: req.mode,
526
594
  // Counted, not refused: a brief of facts alone is a legitimate
527
595
  // consultation, but one about code that hands over no file is one the
@@ -550,7 +618,10 @@ export class JobManager {
550
618
  return {
551
619
  ...common,
552
620
  question: group.question, // the redacted text every member was actually sent
553
- jobs: members.map((m) => ({ job_id: m.job_id, chain_id: m.chain_id, target: m.target, cli: m.cli, model: m.model })),
621
+ jobs: members.map((m) => ({
622
+ job_id: m.job_id, chain_id: m.chain_id, target: m.target, cli: m.cli, model: m.model,
623
+ ...(sameRound ? {} : { round: m.round, rounds_remaining: POLICY.maxRounds - m.round }),
624
+ })),
554
625
  poll_with: `consult_get({ group_id: "${groupId}" })`,
555
626
  note: 'Every consultant got the identical brief. When they come back, compare the grounds behind the points that differ; matching summaries are not evidence of agreement.',
556
627
  };
@@ -558,9 +629,8 @@ export class JobManager {
558
629
 
559
630
  // The rounds before a follow-up, from the history on disk and from memory
560
631
  // (memory wins: it can hold a verdict written since the round was saved).
561
- // Each finding goes with the lead's verdict on it, so the consultant answers
562
- // knowing which of its points were checked and how they came out. Every
563
- // finding that carries a verdict is passed; of the rest, the first five.
632
+ // Each point goes with the lead's verdict on it, so the consultant answers
633
+ // knowing which of its points were checked and how they came out.
564
634
  #chainRounds(chainId) {
565
635
  const byRound = new Map(store.loadChain(chainId).map((j) => [j.round, j]));
566
636
  for (const j of this.jobs.values()) if (j.chain_id === chainId) byRound.set(j.round, j);
@@ -573,11 +643,24 @@ export class JobManager {
573
643
  .sort((a, b) => a.round - b.round)
574
644
  .map((j) => {
575
645
  const verdicts = new Map((j.record?.entries ?? []).map((e) => [e.id, e]));
576
- let unchecked = 0;
577
- const findings = j.result.findings
578
- .filter((f) => verdicts.has(f.id) || unchecked++ < 5)
579
- .map((f) => ({ id: f.id, point: f.point, verdict: verdicts.get(f.id)?.verdict ?? null, effect: verdicts.get(f.id)?.effect ?? null }));
580
- return { round: j.round, mode: j.mode, question: j.question, summary: j.result.summary, findings };
646
+ // The checked points first, then the unchecked ones, each with the
647
+ // lead's verdict and effect when there is one.
648
+ const ordered = (items, shape) => [
649
+ ...items.filter((x) => verdicts.has(x.id)),
650
+ ...items.filter((x) => !verdicts.has(x.id)),
651
+ ].map((x) => ({ id: x.id, ...shape(x), verdict: verdicts.get(x.id)?.verdict ?? null, effect: verdicts.get(x.id)?.effect ?? null }));
652
+ const checked = (x) => x.verdict !== null;
653
+ const findings = ordered(j.result.findings, (f) => ({ point: f.point, grounds: f.grounds ?? null }));
654
+ return {
655
+ round: j.round,
656
+ mode: j.mode,
657
+ question: j.question,
658
+ summary: j.result.summary,
659
+ // Every checked finding; of the rest, the first five.
660
+ findings: [...findings.filter(checked), ...findings.filter((f) => !checked(f)).slice(0, 5)],
661
+ unknowns: ordered(j.result.unknowns, (u) => ({ point: u.item })).slice(0, 5),
662
+ next_checks: ordered(j.result.next_checks, (c) => ({ point: c.check })).slice(0, 5),
663
+ };
581
664
  });
582
665
  }
583
666
 
@@ -621,8 +704,9 @@ export class JobManager {
621
704
  return this.#fail(job, 'spawn_error', `could not copy the exposed files: ${err.message}`);
622
705
  }
623
706
  }
624
- // start() renders the brief once per chain and hands it in, so every member
625
- // of a fan-out is sent byte-identical text.
707
+ // start() renders the brief and hands it in: every member of a fan-out is
708
+ // sent the same request in the same words, with only its own earlier rounds
709
+ // added on a follow-up to the group.
626
710
  const text = brief ?? renderBrief(req, chain);
627
711
  const schemaPath = store.writeJobArtifact(job.job_id, 'response-schema.json', JSON.stringify(CONSULT_RESULT_SCHEMA, null, 2));
628
712
  const sandbox = adapter.prepareSandbox ? adapter.prepareSandbox({ workdir, network: req.network }) : null;
@@ -766,6 +850,12 @@ export class JobManager {
766
850
  }
767
851
  const lastMessageText = invocation.lastMessagePath ? store.readIfExists(invocation.lastMessagePath) : '';
768
852
  interpreted = adapter.interpret({ ...run, lastMessageText });
853
+ // An answer after an error the CLI reported is kept only if it parses;
854
+ // otherwise it is that error, re-run like any other.
855
+ const { recoveredError } = interpreted;
856
+ if (interpreted.ok && recoveredError && !parses(interpreted.text)) {
857
+ interpreted = { ok: false, ...recoveredError, usageRaw: interpreted.usageRaw };
858
+ }
769
859
  if (interpreted.ok) break;
770
860
  // No envelope at all: what stderr holds is the dump from stopping it,
771
861
  // not a message to classify. Not re-run, as it may have spent its quota.
@@ -834,7 +924,11 @@ export class JobManager {
834
924
  }
835
925
 
836
926
  job.result = normalized.result;
837
- const notes = [adviceCaveat(normalized), sameVendorCaveat(job.caller, job.caller_model, consultant)].filter(Boolean);
927
+ const notes = [
928
+ adviceCaveat(normalized),
929
+ sameVendorCaveat(job.caller, job.caller_model, consultant),
930
+ interpreted.recoveredError && `the consultant's CLI reported an error mid-run and then finished its answer: ${redact(interpreted.recoveredError.message).slice(0, 300)}`,
931
+ ].filter(Boolean);
838
932
  // No `advice_usable` verdict here. It was always true, so it carried no
839
933
  // information -- and worse, it read as the server vouching for advice it
840
934
  // cannot judge. What this server may assert is what it can derive from
@@ -1054,34 +1148,36 @@ export class JobManager {
1054
1148
  message: `the verdict for "${job.job_id}" could not be written to the history (${e.message}); record it again`,
1055
1149
  };
1056
1150
  }
1151
+ // Counts only: the entries are what the lead just wrote, and
1152
+ // consult_get({ job_id, ids }) reads any of them back with its verdict.
1153
+ const summary = job.record && recordSummary(job);
1057
1154
  return {
1058
1155
  job_id: job.job_id,
1059
1156
  chain_id: job.chain_id,
1060
1157
  round: job.round,
1061
- record: job.record ? recordSummary(job) : null,
1062
- prediction: job.prediction ?? null,
1063
- reflection: job.reflection ?? null,
1158
+ record: summary ? { updated_at: summary.updated_at, verdicts: summary.verdicts, coverage: summary.coverage } : null,
1159
+ reflection_saved: job.reflection != null,
1064
1160
  };
1065
1161
  }
1066
1162
 
1067
- async wait(jobId, waitMs, opts) {
1163
+ async wait(jobId, waitMs, opts, signal) {
1068
1164
  const job = this.jobs.get(jobId);
1069
- if (!job) return this.#waitOnDisk(() => this.view(jobId, opts), waitMs);
1165
+ if (!job) return this.#waitOnDisk(() => this.view(jobId, opts), waitMs, signal);
1070
1166
  const budget = Math.min(Math.max(0, waitMs ?? 0), POLICY.maxWaitMs);
1071
1167
  if (budget === 0 || job.status === 'completed' || job.status === 'failed' || job.status === 'cancelled') {
1072
1168
  return this.view(jobId, opts);
1073
1169
  }
1074
- await Promise.race([job.promise, new Promise((r) => setTimeout(r, budget).unref?.())]);
1170
+ await settled(job.promise, budget, signal);
1075
1171
  return this.view(jobId, opts);
1076
1172
  }
1077
1173
 
1078
1174
  // A consultation another process runs has no promise here, so its history
1079
1175
  // is re-read once a second until it is no longer running or the wait is up.
1080
- async #waitOnDisk(read, waitMs) {
1176
+ async #waitOnDisk(read, waitMs, signal) {
1081
1177
  const until = Date.now() + Math.min(Math.max(0, waitMs ?? 0), POLICY.maxWaitMs);
1082
1178
  let view = read();
1083
- while (view && ['running', 'queued'].includes(view.status) && Date.now() < until) {
1084
- await new Promise((r) => setTimeout(r, Math.min(1000, until - Date.now())));
1179
+ while (view && ['running', 'queued'].includes(view.status) && Date.now() < until && !signal?.aborted) {
1180
+ await settled(null, Math.min(1000, until - Date.now()), signal);
1085
1181
  view = read();
1086
1182
  }
1087
1183
  return view;
@@ -1182,7 +1278,10 @@ export class JobManager {
1182
1278
  limits: live || !full ? null : limitsSummary(),
1183
1279
  };
1184
1280
  // The side-by-side gets what the rest of the response leaves of GROUP_CHARS.
1185
- view.comparison = comparison(members, full ? Infinity : GROUP_CHARS - printed(view));
1281
+ // While no one has finished there is nothing to set side by side, and an
1282
+ // empty column per consultant is only context spent on every poll.
1283
+ const anyFinished = members.some((m) => ['completed', 'failed', 'cancelled'].includes(m.status));
1284
+ if (anyFinished) view.comparison = comparison(members, full ? Infinity : GROUP_CHARS - printed(view));
1186
1285
  return view;
1187
1286
  }
1188
1287
 
@@ -1192,18 +1291,15 @@ export class JobManager {
1192
1291
  return this.#shapeGroup(group, group.job_ids.map((jid) => this.view(jid, opts)).filter(Boolean), opts);
1193
1292
  }
1194
1293
 
1195
- async waitGroup(groupId, waitMs, opts) {
1294
+ async waitGroup(groupId, waitMs, opts, signal) {
1196
1295
  const group = this.groups.get(groupId);
1197
1296
  // A fan-out from another process or an earlier session: nothing here to
1198
1297
  // wait on, so report what the history holds.
1199
- if (!group) return this.#waitOnDisk(() => this.groupView(groupId, opts), waitMs);
1298
+ if (!group) return this.#waitOnDisk(() => this.groupView(groupId, opts), waitMs, signal);
1200
1299
  const budget = Math.min(Math.max(0, waitMs ?? 0), POLICY.maxWaitMs);
1201
1300
  if (budget > 0) {
1202
1301
  const promises = group.job_ids.map((jid) => this.jobs.get(jid)?.promise).filter(Boolean);
1203
- await Promise.race([
1204
- Promise.all(promises),
1205
- new Promise((r) => setTimeout(r, budget).unref?.()),
1206
- ]);
1302
+ await settled(Promise.all(promises), budget, signal);
1207
1303
  }
1208
1304
  return this.groupView(groupId, opts);
1209
1305
  }
@@ -1321,6 +1417,36 @@ function partialOutput(run) {
1321
1417
  };
1322
1418
  }
1323
1419
 
1420
+ // Waits for `promise` (null: for nothing), at most `ms`, and no longer than
1421
+ // `signal` stays unaborted. The timer goes with the wait: a ten-minute wait
1422
+ // cut short leaves nothing behind.
1423
+ async function settled(promise, ms, signal) {
1424
+ let timer;
1425
+ let stop;
1426
+ const until = new Promise((resolve) => {
1427
+ stop = resolve;
1428
+ timer = setTimeout(resolve, ms);
1429
+ if (signal?.aborted) resolve();
1430
+ else signal?.addEventListener('abort', resolve, { once: true });
1431
+ });
1432
+ try {
1433
+ await (promise ? Promise.race([promise, until]) : until);
1434
+ } finally {
1435
+ clearTimeout(timer);
1436
+ signal?.removeEventListener('abort', stop);
1437
+ }
1438
+ }
1439
+
1440
+ function parses(text) {
1441
+ try {
1442
+ normalizeResult(text);
1443
+ return true;
1444
+ } catch (err) {
1445
+ if (err instanceof OutputError) return false;
1446
+ throw err;
1447
+ }
1448
+ }
1449
+
1324
1450
  function recordableIds(result) {
1325
1451
  return [
1326
1452
  ...result.findings.map((f) => f.id),
package/src/policy.mjs CHANGED
@@ -214,9 +214,10 @@ export const POLICY = Object.freeze({
214
214
  // going beyond that is an operator decision with quota to match.
215
215
  maxConcurrent: limit('SEVERALLY_MAX_CONCURRENT', cfgServer.max_concurrent, 9, 1, 9),
216
216
  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.
217
+ // Upper bound for consult_get(wait_ms). 45 s stays under the 60 s some MCP
218
+ // clients allow a tool call, so a long wait does not blow up as a client-side
219
+ // timeout while the consultation is still healthy; a client registration
220
+ // that is known to wait longer raises it (scripts/install.mjs).
220
221
  maxWaitMs: limit('SEVERALLY_MAX_WAIT_MS', cfgServer.max_wait_ms, 45_000, 0, 600_000),
221
222
 
222
223
  input: Object.freeze({
@@ -261,7 +262,7 @@ export const POLICY = Object.freeze({
261
262
  * different request, not a slower one.
262
263
  */
263
264
  export function timeoutMs(consultant) {
264
- return consultant?.timeoutMs ?? limit('SEVERALLY_TIMEOUT_MS', cfgServer.timeout_ms, 3_600_000, 1_000, 7_200_000);
265
+ return consultant?.timeoutMs ?? limit('SEVERALLY_TIMEOUT_MS', cfgServer.timeout_ms, 1_800_000, 1_000, 7_200_000);
265
266
  }
266
267
 
267
268
  /**
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/schema.mjs CHANGED
@@ -147,9 +147,10 @@ export class RequestError extends Error {
147
147
 
148
148
  /**
149
149
  * Validate + normalise a consult request.
150
- * `isFollowup` is decided by the caller (the job store resolves followup_to first).
150
+ * `isFollowup` is decided by the caller (the job store resolves followup_to first);
151
+ * `groupFollowup` when followup_to named a fan-out, whose members are the targets.
151
152
  */
152
- export function parseRequest(raw, { isFollowup = false, parentMode = null } = {}) {
153
+ export function parseRequest(raw, { isFollowup = false, parentMode = null, groupFollowup = false } = {}) {
153
154
  const parsed = requestSchema.safeParse(raw);
154
155
  if (!parsed.success) {
155
156
  const issues = parsed.error.issues.map((i) => `${i.path.join('.') || '(root)'}: ${i.message}`);
@@ -220,9 +221,9 @@ export function parseRequest(raw, { isFollowup = false, parentMode = null } = {}
220
221
  'duplicate_targets',
221
222
  );
222
223
  }
223
- if (isFollowup && unique.length > 1) {
224
+ if (isFollowup && !groupFollowup && unique.length > 1) {
224
225
  throw new RequestError(
225
- 'a follow-up continues the exchange with one consultant; name exactly one target',
226
+ '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
227
  'followup_fanout_not_allowed',
227
228
  );
228
229
  }