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
@@ -24336,7 +24336,7 @@ var LADDER_ERROR_HTTP_STATUS = {
24336
24336
  [ProtocolErrorCode.MissingRequiredClientCapability]: 400,
24337
24337
  [HEADER_MISMATCH_ERROR_CODE]: 400
24338
24338
  };
24339
- function rejection(rung, cell, httpStatus, error62, settled) {
24339
+ function rejection(rung, cell, httpStatus, error62, settled2) {
24340
24340
  return {
24341
24341
  kind: "reject",
24342
24342
  rung,
@@ -24345,7 +24345,7 @@ function rejection(rung, cell, httpStatus, error62, settled) {
24345
24345
  code: error62.code,
24346
24346
  message: error62.message,
24347
24347
  ...error62.data !== void 0 && { data: error62.data },
24348
- settled
24348
+ settled: settled2
24349
24349
  };
24350
24350
  }
24351
24351
  function carriesValidModernEnvelopeClaim(params) {
@@ -34560,28 +34560,28 @@ var StdioServerTransport = class {
34560
34560
  if (this._closed) return Promise.reject(/* @__PURE__ */ new Error("StdioServerTransport is closed"));
34561
34561
  return new Promise((resolve, reject) => {
34562
34562
  const json2 = serializeMessage(message);
34563
- let settled = false;
34563
+ let settled2 = false;
34564
34564
  const onError = (error62) => {
34565
- if (settled) return;
34566
- settled = true;
34565
+ if (settled2) return;
34566
+ settled2 = true;
34567
34567
  this._stdout.off("error", onError);
34568
34568
  this._stdout.off("drain", onDrain);
34569
34569
  reject(error62);
34570
34570
  };
34571
34571
  const onDrain = () => {
34572
- if (settled) return;
34573
- settled = true;
34572
+ if (settled2) return;
34573
+ settled2 = true;
34574
34574
  this._stdout.off("error", onError);
34575
34575
  this._stdout.off("drain", onDrain);
34576
34576
  resolve();
34577
34577
  };
34578
34578
  this._stdout.once("error", onError);
34579
34579
  if (this._stdout.write(json2)) {
34580
- if (settled) return;
34581
- settled = true;
34580
+ if (settled2) return;
34581
+ settled2 = true;
34582
34582
  this._stdout.off("error", onError);
34583
34583
  resolve();
34584
- } else if (!settled) this._stdout.once("drain", onDrain);
34584
+ } else if (!settled2) this._stdout.once("drain", onDrain);
34585
34585
  });
34586
34586
  }
34587
34587
  };
@@ -35484,9 +35484,10 @@ var POLICY = Object.freeze({
35484
35484
  // going beyond that is an operator decision with quota to match.
35485
35485
  maxConcurrent: limit("SEVERALLY_MAX_CONCURRENT", cfgServer.max_concurrent, 9, 1, 9),
35486
35486
  maxJobsRetained: limit("SEVERALLY_MAX_JOBS_RETAINED", cfgServer.max_jobs_retained, 200, 20, 2e3),
35487
- // Upper bound for consult_get(wait_ms). Deliberately under the 60s default
35488
- // request timeout that MCP clients apply, so a long wait does not blow up as
35489
- // a client-side timeout while the consultation is still healthy.
35487
+ // Upper bound for consult_get(wait_ms). 45 s stays under the 60 s some MCP
35488
+ // clients allow a tool call, so a long wait does not blow up as a client-side
35489
+ // timeout while the consultation is still healthy; a client registration
35490
+ // that is known to wait longer raises it (scripts/install.mjs).
35490
35491
  maxWaitMs: limit("SEVERALLY_MAX_WAIT_MS", cfgServer.max_wait_ms, 45e3, 0, 6e5),
35491
35492
  input: Object.freeze({
35492
35493
  questionMax: 4e3,
@@ -35520,7 +35521,7 @@ var POLICY = Object.freeze({
35520
35521
  })
35521
35522
  });
35522
35523
  function timeoutMs(consultant) {
35523
- return consultant?.timeoutMs ?? limit("SEVERALLY_TIMEOUT_MS", cfgServer.timeout_ms, 36e5, 1e3, 72e5);
35524
+ return consultant?.timeoutMs ?? limit("SEVERALLY_TIMEOUT_MS", cfgServer.timeout_ms, 18e5, 1e3, 72e5);
35524
35525
  }
35525
35526
  function agyExitGraceMs() {
35526
35527
  return {
@@ -35747,7 +35748,7 @@ var RequestError = class extends Error {
35747
35748
  this.details = details;
35748
35749
  }
35749
35750
  };
35750
- function parseRequest(raw, { isFollowup = false, parentMode = null } = {}) {
35751
+ function parseRequest(raw, { isFollowup = false, parentMode = null, groupFollowup = false } = {}) {
35751
35752
  const parsed = requestSchema.safeParse(raw);
35752
35753
  if (!parsed.success) {
35753
35754
  const issues = parsed.error.issues.map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`);
@@ -35808,9 +35809,9 @@ function parseRequest(raw, { isFollowup = false, parentMode = null } = {}) {
35808
35809
  "duplicate_targets"
35809
35810
  );
35810
35811
  }
35811
- if (isFollowup && unique.length > 1) {
35812
+ if (isFollowup && !groupFollowup && unique.length > 1) {
35812
35813
  throw new RequestError(
35813
- "a follow-up continues the exchange with one consultant; name exactly one target",
35814
+ "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",
35814
35815
  "followup_fanout_not_allowed"
35815
35816
  );
35816
35817
  }
@@ -35955,6 +35956,12 @@ var CONSULT_RESULT_SCHEMA = obj(
35955
35956
  var RESULT_KEYS = Object.keys(CONSULT_RESULT_SCHEMA.properties);
35956
35957
 
35957
35958
  // src/redact.mjs
35959
+ var CODE_WORDS = /* @__PURE__ */ new Set(["true", "false", "null", "none", "undefined", "string", "str", "number", "boolean", "bool", "int", "bytes"]);
35960
+ function looksLikeCode(name, value) {
35961
+ if (CODE_WORDS.has(value.toLowerCase()) || value.startsWith("${") || /^\$[A-Z_][A-Z0-9_]*$/.test(value) || /^(?:process\.env\.|os\.environ\b)/.test(value)) return true;
35962
+ if (value.length >= 8 && /^PASSW(?:OR)?D$/i.test(name.split("_").at(-1))) return false;
35963
+ return /^[A-Za-z_][\w$]*(?:\.[A-Za-z_$][\w$]*)+$/.test(value) || /^[A-Za-z_][\w$.]*\(/.test(value) || value.startsWith("&");
35964
+ }
35958
35965
  var PATTERNS = [
35959
35966
  // Provider API keys
35960
35967
  [/\bsk-ant-[A-Za-z0-9_\-]{12,}/g, "[REDACTED:anthropic-key]"],
@@ -35963,6 +35970,7 @@ var PATTERNS = [
35963
35970
  [/\bgh[pousr]_[A-Za-z0-9]{16,}/g, "[REDACTED:github-token]"],
35964
35971
  [/\bgithub_pat_[A-Za-z0-9_]{20,}/g, "[REDACTED:github-token]"],
35965
35972
  [/\bxox[baprs]-[A-Za-z0-9-]{10,}/g, "[REDACTED:slack-token]"],
35973
+ [/https:\/\/hooks\.slack\.com\/services\/T[A-Za-z0-9]+\/B[A-Za-z0-9]+\/[A-Za-z0-9]+/g, "[REDACTED:slack-webhook]"],
35966
35974
  [/\bAKIA[0-9A-Z]{16}\b/g, "[REDACTED:aws-access-key-id]"],
35967
35975
  [/\bASIA[0-9A-Z]{16}\b/g, "[REDACTED:aws-access-key-id]"],
35968
35976
  [/\bAIza[0-9A-Za-z_\-]{30,}/g, "[REDACTED:google-api-key]"],
@@ -35980,9 +35988,16 @@ var PATTERNS = [
35980
35988
  [/(:\/\/[^\s:/@]*:)[^\s@/]+(@)/g, "$1[REDACTED]$2"],
35981
35989
  // KEY=value / "token": "value" style assignments
35982
35990
  [
35983
- /\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,
35991
+ /\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,
35984
35992
  (_m, k, sep, _v) => `${k}${sep}[REDACTED]`
35985
35993
  ],
35994
+ // Password-type names, with values from 4 characters; PASS and PWD too (PWD
35995
+ // only after `_` or a letter: bare PWD is the working directory). What reads
35996
+ // as code rather than a password is left alone (looksLikeCode).
35997
+ [
35998
+ /\b((?:[A-Za-z0-9_]*_)?(?:PASSWORD|PASSWD|PASS|(?<=[A-Za-z_])PWD))(["']?\s*[:=]\s*["']?)([^\s"',;)]{4,})/gi,
35999
+ (m, k, sep, v) => looksLikeCode(k, v) ? m : `${k}${sep}[REDACTED]`
36000
+ ],
35986
36001
  // Any environment variable named *_KEY. Upper case only: in code, primary_key,
35987
36002
  // sort_key and idempotency_key name things, not secrets.
35988
36003
  [/\b([A-Z0-9_]+_KEY)(\s*=\s*["']?)([^\s"',;)]{8,})/g, (_m, k, sep, _v) => `${k}${sep}[REDACTED]`]
@@ -36067,6 +36082,10 @@ function walkDir(root, dir, onFile, totals, skipped) {
36067
36082
  }
36068
36083
  for (const item of items) {
36069
36084
  const child = path4.join(dir, item.name);
36085
+ if (item.name === ".git" || item.name === "node_modules") {
36086
+ skipped.push({ source: child, reason: "ignored" });
36087
+ continue;
36088
+ }
36070
36089
  if (item.isSymbolicLink()) {
36071
36090
  skipped.push({ source: child, reason: "symlink" });
36072
36091
  continue;
@@ -36083,12 +36102,73 @@ function walkDir(root, dir, onFile, totals, skipped) {
36083
36102
  }
36084
36103
  }
36085
36104
  }
36105
+ function byPathParts(a, b) {
36106
+ const x = a.split("/");
36107
+ const y = b.split("/");
36108
+ for (let i = 0; i < Math.min(x.length, y.length); i++) if (x[i] !== y[i]) return x[i] < y[i] ? -1 : 1;
36109
+ return x.length - y.length;
36110
+ }
36111
+ function walkGit(dir, exposedAs, onFile, totals, skipped) {
36112
+ let listed;
36113
+ let ignored;
36114
+ try {
36115
+ listed = [...new Set(nulSplit(git(dir, ["ls-files", "-z", "--cached", "--others", "--exclude-standard"])))];
36116
+ ignored = nulSplit(git(dir, ["ls-files", "-z", "--others", "--ignored", "--exclude-standard", "--directory"]));
36117
+ } catch {
36118
+ return false;
36119
+ }
36120
+ if (listed.length === 0) return false;
36121
+ for (const p of ignored) skipped.push({ source: path4.join(dir, p), reason: "ignored" });
36122
+ if (fs3.existsSync(path4.join(dir, ".git"))) skipped.push({ source: path4.join(dir, ".git"), reason: "ignored" });
36123
+ const realDir = fs3.realpathSync(dir);
36124
+ const protectedDirs = /* @__PURE__ */ new Set();
36125
+ for (const rel of listed.sort(byPathParts)) {
36126
+ const abs = path4.join(dir, rel);
36127
+ const parts = rel.split("/");
36128
+ const guarded = parts.slice(0, -1).map((_, i) => path4.join(dir, ...parts.slice(0, i + 1))).find(isProtectedPath);
36129
+ if (guarded) {
36130
+ if (!protectedDirs.has(guarded)) skipped.push({ source: guarded, reason: "protected" });
36131
+ protectedDirs.add(guarded);
36132
+ continue;
36133
+ }
36134
+ if (isProtectedPath(abs)) {
36135
+ skipped.push({ source: abs, reason: "protected" });
36136
+ continue;
36137
+ }
36138
+ let stat;
36139
+ try {
36140
+ stat = fs3.lstatSync(abs);
36141
+ } catch {
36142
+ continue;
36143
+ }
36144
+ if (stat.isDirectory()) {
36145
+ skipped.push({ source: abs, reason: "submodule" });
36146
+ continue;
36147
+ }
36148
+ const inside = (() => {
36149
+ try {
36150
+ return fs3.realpathSync(abs).startsWith(realDir + path4.sep);
36151
+ } catch {
36152
+ return false;
36153
+ }
36154
+ })();
36155
+ if (stat.isSymbolicLink() || !inside) {
36156
+ skipped.push({ source: abs, reason: "symlink" });
36157
+ continue;
36158
+ }
36159
+ if (!stat.isFile()) continue;
36160
+ onFile(abs, path4.normalize(rel));
36161
+ checkBudget(totals);
36162
+ }
36163
+ return true;
36164
+ }
36086
36165
  function walkEntries(exposePaths, onFile) {
36087
36166
  const totals = { files: 0, bytes: 0 };
36088
36167
  const skipped = [];
36089
36168
  const entries = exposePaths.map((p, i) => {
36090
36169
  const { source, index } = typeof p === "string" ? { source: p, index: i } : p;
36091
36170
  const stat = statEntry(source);
36171
+ const exposedAs = `workspace/${index}-${path4.basename(source)}`;
36092
36172
  const before = { files: totals.files, bytes: totals.bytes };
36093
36173
  const paths = [];
36094
36174
  if (isProtectedPath(source)) {
@@ -36097,18 +36177,17 @@ function walkEntries(exposePaths, onFile) {
36097
36177
  onFile(source, "", index, source, totals);
36098
36178
  checkBudget(totals);
36099
36179
  } else {
36100
- walkDir(source, source, (abs, rel) => {
36180
+ const add = (abs, rel) => {
36101
36181
  paths.push(rel.split(path4.sep).join("/"));
36102
36182
  onFile(abs, rel, index, source, totals);
36103
- }, totals, skipped);
36183
+ };
36184
+ walkGit(source, exposedAs, add, totals, skipped) || walkDir(source, source, add, totals, skipped);
36104
36185
  }
36105
36186
  return {
36106
36187
  index,
36107
36188
  source,
36108
36189
  kind: stat.isDirectory() ? "directory" : "file",
36109
- // Indexed, so two entries whose basenames collide (two different
36110
- // util.mjs) stay apart, and the brief can name each one exactly.
36111
- exposedAs: `workspace/${index}-${path4.basename(source)}`,
36190
+ exposedAs,
36112
36191
  files: totals.files - before.files,
36113
36192
  bytes: totals.bytes - before.bytes,
36114
36193
  paths: paths.sort()
@@ -36501,6 +36580,8 @@ function renderExposeManifest(manifest) {
36501
36580
  const skipped = [
36502
36581
  count("symlink") ? `${count("symlink")} symlink(s), or path(s) through one, were skipped, not followed.` : null,
36503
36582
  count("protected") ? `${count("protected")} credential or history path(s) were left out on purpose.` : null,
36583
+ count("ignored") ? `${count("ignored")} path(s) (.git, node_modules, whatever .gitignore excludes) were left out as not source.` : null,
36584
+ count("submodule") ? `${count("submodule")} submodule(s) or nested repositor(ies) were left out; their files are not copied.` : null,
36504
36585
  count("gone") ? `No longer there, so not copied again: ${named("gone")}.` : null,
36505
36586
  count("masked") ? `Not copied again, because the record of the earlier round masked the path: ${named("masked")}.` : null,
36506
36587
  count("replaced") ? `The earlier round's change is replaced by the diff given in this round: ${named("replaced")}.` : null
@@ -36517,6 +36598,25 @@ function renderExposeManifest(manifest) {
36517
36598
  ].join("\n");
36518
36599
  }
36519
36600
  var clip = (s2, max = 400) => s2.length > max ? `${s2.slice(0, max)}...` : s2;
36601
+ var HISTORY_CHARS = 4e4;
36602
+ function renderRound(p) {
36603
+ const tag = (id2) => `r${p.round}.${id2}`;
36604
+ const verdict = (x) => x.verdict ? ` -- lead's verdict: ${x.verdict}${x.effect ? ` (${clip(x.effect)})` : ""}` : "";
36605
+ const list = (title, items, line) => items?.length ? [title, ...items.map(line)].join("\n") : null;
36606
+ return [
36607
+ `### Round ${p.round} (${p.mode})`,
36608
+ `Question asked: ${p.question}`,
36609
+ `Your answer, summarised: ${p.summary}`,
36610
+ list(
36611
+ "Points you raised:",
36612
+ p.findings,
36613
+ (f) => `- ${tag(f.id)}: ${clip(f.point)}${verdict(f)}${f.grounds ? `
36614
+ grounds: ${clip(f.grounds)}` : ""}`
36615
+ ),
36616
+ list("Unknowns you named:", p.unknowns, (u) => `- ${tag(u.id)}: ${clip(u.point)}${verdict(u)}`),
36617
+ list("Checks you proposed:", p.next_checks, (c) => `- ${tag(c.id)}: ${clip(c.point)}${verdict(c)}`)
36618
+ ].filter(Boolean).join("\n");
36619
+ }
36520
36620
  function renderBrief(req, chain = { round: 1, priorRounds: [] }, exposeManifest = null) {
36521
36621
  const parts = [];
36522
36622
  parts.push(renderGuardrails(Boolean(exposeManifest), req.network));
@@ -36545,19 +36645,19 @@ function renderBrief(req, chain = { round: 1, priorRounds: [] }, exposeManifest
36545
36645
  if (chain.priorRounds && chain.priorRounds.length) {
36546
36646
  parts.push("");
36547
36647
  parts.push("## Earlier rounds of this same consultation");
36548
- for (const p of chain.priorRounds) {
36549
- parts.push(
36550
- [
36551
- `### Round ${p.round} (${p.mode})`,
36552
- `Question asked: ${p.question}`,
36553
- `Your answer, summarised: ${p.summary}`,
36554
- p.findings && p.findings.length ? [
36555
- "Points you raised, with what the lead's own check found -- confirmed: it held; not_applicable: checked, and it does not apply here; unverifiable: could not be checked; no verdict: not checked yet:",
36556
- ...p.findings.map((f) => `- ${f.id}: ${clip(f.point)}${f.verdict ? ` -- lead's verdict: ${f.verdict}${f.effect ? ` (${clip(f.effect)})` : ""}` : ""}`)
36557
- ].join("\n") : null
36558
- ].filter(Boolean).join("\n")
36559
- );
36560
- }
36648
+ parts.push(
36649
+ "Each point carries the lead's own check where there is one -- confirmed: it held; not_applicable: checked, and it does not apply here; unverifiable: could not be checked; unverified, or no verdict: not checked yet."
36650
+ );
36651
+ const blocks = chain.priorRounds.map(renderRound);
36652
+ let kept = 0;
36653
+ let used = 0;
36654
+ for (let i = blocks.length - 1; i >= 0 && (kept === 0 || used + blocks[i].length <= HISTORY_CHARS); i--) {
36655
+ used += blocks[i].length;
36656
+ kept += 1;
36657
+ }
36658
+ const left = chain.priorRounds.slice(0, blocks.length - kept).map((p) => p.round);
36659
+ if (left.length) parts.push(`(Round${left.length > 1 ? "s" : ""} ${left.join(", ")} left out to keep this brief within its size.)`);
36660
+ parts.push(...blocks.slice(blocks.length - kept));
36561
36661
  parts.push("");
36562
36662
  parts.push(
36563
36663
  "This round is a follow-up. The lead has narrowed the exchange to the divergences above. Do not restate your earlier answer; address what is new, and say plainly if the new information changes or does not change your position."
@@ -36981,10 +37081,10 @@ function runChild({
36981
37081
  });
36982
37082
  }
36983
37083
  const done = new Promise((resolve) => {
36984
- let settled = false;
37084
+ let settled2 = false;
36985
37085
  const finish = (payload) => {
36986
- if (settled) return;
36987
- settled = true;
37086
+ if (settled2) return;
37087
+ settled2 = true;
36988
37088
  clearTimeout(timer);
36989
37089
  clearTimeout(settleTimer);
36990
37090
  resolve({ stdout, stderr, truncated, timedOut, cancelled, stalled, ...payload });
@@ -37371,9 +37471,10 @@ function interpret({ stdout, stderr, code, lastMessageText }) {
37371
37471
  if (typeof candidate === "string" && candidate.trim()) text2 = candidate.trim();
37372
37472
  }
37373
37473
  }
37374
- if (errorEvents.length) {
37375
- const msg = errorEvents.map((e) => e.message ?? e.error?.message ?? JSON.stringify(e)).join(" | ");
37376
- return { ok: false, failureKind: classifyMessage(msg), message: msg, usageRaw: usage, events: events3.length };
37474
+ const errorMsg = errorEvents.map((e) => e.message ?? e.error?.message ?? JSON.stringify(e)).join(" | ");
37475
+ const recovered = errorEvents.length > 0 && Boolean(text2) && events3.at(-1)?.type === "turn.completed";
37476
+ if (errorEvents.length && !recovered) {
37477
+ return { ok: false, failureKind: classifyMessage(errorMsg), message: errorMsg, usageRaw: usage, events: events3.length };
37377
37478
  }
37378
37479
  if (!text2) {
37379
37480
  const msg = (stderr || "").trim() || `codex exited with code ${code} and produced no final message`;
@@ -37385,7 +37486,13 @@ function interpret({ stdout, stderr, code, lastMessageText }) {
37385
37486
  events: events3.length
37386
37487
  };
37387
37488
  }
37388
- return { ok: true, text: text2, usageRaw: usage, events: events3.length };
37489
+ return {
37490
+ ok: true,
37491
+ text: text2,
37492
+ usageRaw: usage,
37493
+ events: events3.length,
37494
+ recoveredError: recovered ? { failureKind: classifyMessage(errorMsg), message: errorMsg } : null
37495
+ };
37389
37496
  }
37390
37497
  function progress({ stdout }) {
37391
37498
  const items = [];
@@ -38185,11 +38292,11 @@ async function prepare() {
38185
38292
  if (standaloneSupport !== null) return;
38186
38293
  if (!probePromise) {
38187
38294
  probePromise = new Promise((resolve) => {
38188
- let settled = false;
38295
+ let settled2 = false;
38189
38296
  let timer = null;
38190
38297
  const finishProbe = (supported, cache) => {
38191
- if (settled) return;
38192
- settled = true;
38298
+ if (settled2) return;
38299
+ settled2 = true;
38193
38300
  if (timer) clearTimeout(timer);
38194
38301
  if (cache) standaloneSupport = Boolean(supported);
38195
38302
  else probePromise = null;
@@ -38630,7 +38737,17 @@ var JobManager = class {
38630
38737
  if (configProblem()) throw new RequestError(`the severally config file needs fixing first: ${configProblem()}`, "config_invalid");
38631
38738
  const followupTo = typeof rawRequest?.followup_to === "string" && rawRequest.followup_to.trim() ? rawRequest.followup_to.trim() : null;
38632
38739
  let parent = null;
38633
- if (followupTo) {
38740
+ let fromGroup = null;
38741
+ if (followupTo?.startsWith("group_")) {
38742
+ if ((rawRequest.target ?? null) !== null || rawRequest.targets?.length) {
38743
+ throw new RequestError(
38744
+ "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",
38745
+ "followup_group_targets"
38746
+ );
38747
+ }
38748
+ fromGroup = this.#groupParents(followupTo);
38749
+ rawRequest = { ...rawRequest, targets: fromGroup.links.map((l) => l.parent.target) };
38750
+ } else if (followupTo) {
38634
38751
  parent = this.jobs.get(followupTo) ?? loadRound(followupTo);
38635
38752
  if (!parent) throw new RequestError(`followup_to references unknown job "${followupTo}"`, "unknown_job");
38636
38753
  if (parent.status !== "completed") {
@@ -38649,7 +38766,17 @@ var JobManager = class {
38649
38766
  }
38650
38767
  parent = { ...parent, nextRound: Math.max(...rounds.map((j) => j.round)) + 1 };
38651
38768
  }
38652
- const req = parseRequest(rawRequest, { isFollowup: Boolean(parent), parentMode: parent?.mode ?? null });
38769
+ const req = parseRequest(rawRequest, {
38770
+ isFollowup: Boolean(parent || fromGroup),
38771
+ parentMode: parent?.mode ?? fromGroup?.group.mode ?? null,
38772
+ groupFollowup: Boolean(fromGroup)
38773
+ });
38774
+ if (fromGroup && fromGroup.links.some((l, i) => l.parent.target !== req.targets[i])) {
38775
+ throw new RequestError(
38776
+ `the members of group "${followupTo}" no longer resolve to the consultants that answered them; follow up each member by its job_id`,
38777
+ "followup_target_mismatch"
38778
+ );
38779
+ }
38653
38780
  if (parent && parent.target !== req.target) {
38654
38781
  throw new RequestError(
38655
38782
  `follow-up must go to the same consultant as job "${parent.job_id}" (${parent.target})`,
@@ -38657,7 +38784,7 @@ var JobManager = class {
38657
38784
  );
38658
38785
  }
38659
38786
  const round = parent ? parent.nextRound : 1;
38660
- if (round > POLICY.maxRounds) {
38787
+ if (!fromGroup && round > POLICY.maxRounds) {
38661
38788
  throw new RequestError(
38662
38789
  `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`,
38663
38790
  "round_limit"
@@ -38680,13 +38807,46 @@ var JobManager = class {
38680
38807
  );
38681
38808
  }
38682
38809
  try {
38683
- return this.#admitted(req, parent, round, seats);
38810
+ const links = fromGroup?.links ?? req.targets.map(() => ({ parent, round }));
38811
+ return this.#admitted(req, links, seats, fromGroup);
38684
38812
  } catch (err) {
38685
38813
  for (const seat of seats) if (!this.jobs.has(seat.job_id)) releaseSeat(seat.job_id);
38686
38814
  throw err;
38687
38815
  }
38688
38816
  }
38689
- #admitted(req, parent, round, seats) {
38817
+ // A follow-up to a fan-out: each member goes on from the newest answered
38818
+ // round of its own chain. A member that is still running, never answered or
38819
+ // has used its rounds is skipped and named; if that is every member, the
38820
+ // request is refused.
38821
+ #groupParents(groupId) {
38822
+ const group = this.groups.get(groupId) ?? loadGroup(groupId);
38823
+ if (!group) throw new RequestError(`followup_to references unknown group "${groupId}"`, "unknown_group");
38824
+ const links = [];
38825
+ const skipped = [];
38826
+ for (const jid of group.job_ids) {
38827
+ const member = this.jobs.get(jid) ?? loadRound(jid);
38828
+ if (!member) {
38829
+ skipped.push({ job_id: jid, target: null, reason: "not found: aged out of the history, or still running in another severally process" });
38830
+ continue;
38831
+ }
38832
+ const rounds = this.#chainRounds(member.chain_id);
38833
+ const latest = rounds.filter((j) => !["failed", "cancelled"].includes(j.status)).sort((a, b) => b.round - a.round)[0];
38834
+ const nextRound = Math.max(...rounds.map((j) => j.round)) + 1;
38835
+ const consultant = consultantByName(member.target);
38836
+ const reason = !latest ? `${member.status}, so there is no answer to follow up` : latest.status !== "completed" ? `round ${latest.round} is still ${latest.status}` : nextRound > POLICY.maxRounds ? `has used its ${POLICY.maxRounds} rounds` : !consultant || !consultantAvailable(consultant) ? "this consultant is no longer configured or available here" : null;
38837
+ if (reason) skipped.push({ job_id: (latest ?? member).job_id, target: member.target, reason });
38838
+ else links.push({ parent: { ...latest, nextRound }, round: nextRound, groupMember: member });
38839
+ }
38840
+ if (!links.length) {
38841
+ throw new RequestError(
38842
+ `no member of group "${groupId}" can take a follow-up: ${skipped.map((x) => `${x.target ?? x.job_id} (${x.reason})`).join(", ")}`,
38843
+ "followup_nothing_to_continue"
38844
+ );
38845
+ }
38846
+ return { group, links, skipped };
38847
+ }
38848
+ #admitted(req, links, seats, fromGroup = null) {
38849
+ const parent = links[0].groupMember ?? links[0].parent;
38690
38850
  const groupId = id("group");
38691
38851
  let exposeManifest = null;
38692
38852
  const parentEntries = req.context.expose_from_parent ? parent.brief?.expose_paths?.entries ?? [] : [];
@@ -38737,13 +38897,14 @@ var JobManager = class {
38737
38897
  req._snapshot = path11.join(snapshot, "workspace");
38738
38898
  }
38739
38899
  const members2 = req.targets.map((target, i) => {
38740
- const chainId = parent ? parent.chain_id : id("chain");
38900
+ const { parent: from, round: round2 } = links[i];
38901
+ const chainId = from ? from.chain_id : id("chain");
38741
38902
  const jobId = seats[i].job_id;
38742
38903
  const job = {
38743
38904
  job_id: jobId,
38744
38905
  chain_id: chainId,
38745
38906
  group_id: groupId,
38746
- round,
38907
+ round: round2,
38747
38908
  target,
38748
38909
  mode: req.mode,
38749
38910
  // Redacted here, at the one point the request text becomes state:
@@ -38769,7 +38930,7 @@ var JobManager = class {
38769
38930
  retries: [],
38770
38931
  partial_output: null,
38771
38932
  tool_calls: null,
38772
- followup_to: parent?.job_id ?? null,
38933
+ followup_to: from?.job_id ?? null,
38773
38934
  status: "queued",
38774
38935
  // What this consultant is: the CLI it runs on, the model and its
38775
38936
  // lineage, as the operator defined it.
@@ -38809,19 +38970,21 @@ var JobManager = class {
38809
38970
  persistGroup(group);
38810
38971
  } catch {
38811
38972
  }
38812
- const chain = { round, priorRounds: this.#chainHistory(members2[0].chain_id) };
38813
- const brief = renderBrief(req, chain, exposeManifest);
38814
38973
  for (const job of members2) {
38974
+ const chain = { round: job.round, priorRounds: this.#chainHistory(job.chain_id) };
38975
+ const brief = renderBrief(req, chain, exposeManifest);
38815
38976
  job.promise = this.#execute(job, { ...req, target: job.target }, chain, brief).catch((err) => {
38816
38977
  this.#fail(job, "cli_error", err?.message ?? String(err));
38817
38978
  });
38818
38979
  }
38819
38980
  if (req._snapshot) cleanupJobDir(groupId);
38981
+ const { round } = members2[0];
38982
+ const sameRound = members2.every((m) => m.round === round);
38820
38983
  const common = {
38821
38984
  group_id: groupId,
38822
- round,
38985
+ ...sameRound ? { round, rounds_remaining: POLICY.maxRounds - round } : {},
38986
+ ...fromGroup ? { followup_to_group: fromGroup.group.group_id, skipped: fromGroup.skipped } : {},
38823
38987
  ...parentEntries.length ? { exposed_from_parent: inheritedSummary(exposeManifest) } : {},
38824
- rounds_remaining: POLICY.maxRounds - round,
38825
38988
  mode: req.mode,
38826
38989
  // Counted, not refused: a brief of facts alone is a legitimate
38827
38990
  // consultation, but one about code that hands over no file is one the
@@ -38850,16 +39013,22 @@ var JobManager = class {
38850
39013
  ...common,
38851
39014
  question: group.question,
38852
39015
  // the redacted text every member was actually sent
38853
- jobs: members2.map((m) => ({ job_id: m.job_id, chain_id: m.chain_id, target: m.target, cli: m.cli, model: m.model })),
39016
+ jobs: members2.map((m) => ({
39017
+ job_id: m.job_id,
39018
+ chain_id: m.chain_id,
39019
+ target: m.target,
39020
+ cli: m.cli,
39021
+ model: m.model,
39022
+ ...sameRound ? {} : { round: m.round, rounds_remaining: POLICY.maxRounds - m.round }
39023
+ })),
38854
39024
  poll_with: `consult_get({ group_id: "${groupId}" })`,
38855
39025
  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."
38856
39026
  };
38857
39027
  }
38858
39028
  // The rounds before a follow-up, from the history on disk and from memory
38859
39029
  // (memory wins: it can hold a verdict written since the round was saved).
38860
- // Each finding goes with the lead's verdict on it, so the consultant answers
38861
- // knowing which of its points were checked and how they came out. Every
38862
- // finding that carries a verdict is passed; of the rest, the first five.
39030
+ // Each point goes with the lead's verdict on it, so the consultant answers
39031
+ // knowing which of its points were checked and how they came out.
38863
39032
  #chainRounds(chainId) {
38864
39033
  const byRound = new Map(loadChain(chainId).map((j) => [j.round, j]));
38865
39034
  for (const j of this.jobs.values()) if (j.chain_id === chainId) byRound.set(j.round, j);
@@ -38868,9 +39037,22 @@ var JobManager = class {
38868
39037
  #chainHistory(chainId) {
38869
39038
  return this.#chainRounds(chainId).filter((j) => j.status === "completed" && j.result).sort((a, b) => a.round - b.round).map((j) => {
38870
39039
  const verdicts = new Map((j.record?.entries ?? []).map((e) => [e.id, e]));
38871
- let unchecked = 0;
38872
- const findings = j.result.findings.filter((f) => verdicts.has(f.id) || unchecked++ < 5).map((f) => ({ id: f.id, point: f.point, verdict: verdicts.get(f.id)?.verdict ?? null, effect: verdicts.get(f.id)?.effect ?? null }));
38873
- return { round: j.round, mode: j.mode, question: j.question, summary: j.result.summary, findings };
39040
+ const ordered = (items, shape) => [
39041
+ ...items.filter((x) => verdicts.has(x.id)),
39042
+ ...items.filter((x) => !verdicts.has(x.id))
39043
+ ].map((x) => ({ id: x.id, ...shape(x), verdict: verdicts.get(x.id)?.verdict ?? null, effect: verdicts.get(x.id)?.effect ?? null }));
39044
+ const checked = (x) => x.verdict !== null;
39045
+ const findings = ordered(j.result.findings, (f) => ({ point: f.point, grounds: f.grounds ?? null }));
39046
+ return {
39047
+ round: j.round,
39048
+ mode: j.mode,
39049
+ question: j.question,
39050
+ summary: j.result.summary,
39051
+ // Every checked finding; of the rest, the first five.
39052
+ findings: [...findings.filter(checked), ...findings.filter((f) => !checked(f)).slice(0, 5)],
39053
+ unknowns: ordered(j.result.unknowns, (u) => ({ point: u.item })).slice(0, 5),
39054
+ next_checks: ordered(j.result.next_checks, (c) => ({ point: c.check })).slice(0, 5)
39055
+ };
38874
39056
  });
38875
39057
  }
38876
39058
  #evict() {
@@ -38996,6 +39178,10 @@ var JobManager = class {
38996
39178
  }
38997
39179
  const lastMessageText = invocation.lastMessagePath ? readIfExists(invocation.lastMessagePath) : "";
38998
39180
  interpreted = adapter.interpret({ ...run, lastMessageText });
39181
+ const { recoveredError } = interpreted;
39182
+ if (interpreted.ok && recoveredError && !parses(interpreted.text)) {
39183
+ interpreted = { ok: false, ...recoveredError, usageRaw: interpreted.usageRaw };
39184
+ }
38999
39185
  if (interpreted.ok) break;
39000
39186
  if (run.stalled && !interpreted.usageRaw) {
39001
39187
  return this.#fail(
@@ -39059,7 +39245,11 @@ var JobManager = class {
39059
39245
  }
39060
39246
  }
39061
39247
  job.result = normalized.result;
39062
- const notes = [adviceCaveat(normalized), sameVendorCaveat(job.caller, job.caller_model, consultant)].filter(Boolean);
39248
+ const notes = [
39249
+ adviceCaveat(normalized),
39250
+ sameVendorCaveat(job.caller, job.caller_model, consultant),
39251
+ interpreted.recoveredError && `the consultant's CLI reported an error mid-run and then finished its answer: ${redact(interpreted.recoveredError.message).slice(0, 300)}`
39252
+ ].filter(Boolean);
39063
39253
  job.quality = {
39064
39254
  ...normalized.quality,
39065
39255
  evidence_basis: normalized.result.evidence_basis,
@@ -39248,32 +39438,32 @@ var JobManager = class {
39248
39438
  message: `the verdict for "${job.job_id}" could not be written to the history (${e.message}); record it again`
39249
39439
  };
39250
39440
  }
39441
+ const summary = job.record && recordSummary(job);
39251
39442
  return {
39252
39443
  job_id: job.job_id,
39253
39444
  chain_id: job.chain_id,
39254
39445
  round: job.round,
39255
- record: job.record ? recordSummary(job) : null,
39256
- prediction: job.prediction ?? null,
39257
- reflection: job.reflection ?? null
39446
+ record: summary ? { updated_at: summary.updated_at, verdicts: summary.verdicts, coverage: summary.coverage } : null,
39447
+ reflection_saved: job.reflection != null
39258
39448
  };
39259
39449
  }
39260
- async wait(jobId, waitMs, opts) {
39450
+ async wait(jobId, waitMs, opts, signal) {
39261
39451
  const job = this.jobs.get(jobId);
39262
- if (!job) return this.#waitOnDisk(() => this.view(jobId, opts), waitMs);
39452
+ if (!job) return this.#waitOnDisk(() => this.view(jobId, opts), waitMs, signal);
39263
39453
  const budget = Math.min(Math.max(0, waitMs ?? 0), POLICY.maxWaitMs);
39264
39454
  if (budget === 0 || job.status === "completed" || job.status === "failed" || job.status === "cancelled") {
39265
39455
  return this.view(jobId, opts);
39266
39456
  }
39267
- await Promise.race([job.promise, new Promise((r) => setTimeout(r, budget).unref?.())]);
39457
+ await settled(job.promise, budget, signal);
39268
39458
  return this.view(jobId, opts);
39269
39459
  }
39270
39460
  // A consultation another process runs has no promise here, so its history
39271
39461
  // is re-read once a second until it is no longer running or the wait is up.
39272
- async #waitOnDisk(read, waitMs) {
39462
+ async #waitOnDisk(read, waitMs, signal) {
39273
39463
  const until = Date.now() + Math.min(Math.max(0, waitMs ?? 0), POLICY.maxWaitMs);
39274
39464
  let view = read();
39275
- while (view && ["running", "queued"].includes(view.status) && Date.now() < until) {
39276
- await new Promise((r) => setTimeout(r, Math.min(1e3, until - Date.now())));
39465
+ while (view && ["running", "queued"].includes(view.status) && Date.now() < until && !signal?.aborted) {
39466
+ await settled(null, Math.min(1e3, until - Date.now()), signal);
39277
39467
  view = read();
39278
39468
  }
39279
39469
  return view;
@@ -39347,7 +39537,8 @@ var JobManager = class {
39347
39537
  // the number of consultants configured rather than asked.
39348
39538
  limits: live || !full ? null : limitsSummary()
39349
39539
  };
39350
- view.comparison = comparison(members2, full ? Infinity : GROUP_CHARS - printed(view));
39540
+ const anyFinished = members2.some((m) => ["completed", "failed", "cancelled"].includes(m.status));
39541
+ if (anyFinished) view.comparison = comparison(members2, full ? Infinity : GROUP_CHARS - printed(view));
39351
39542
  return view;
39352
39543
  }
39353
39544
  groupView(groupId, opts) {
@@ -39355,16 +39546,13 @@ var JobManager = class {
39355
39546
  if (!group) return null;
39356
39547
  return this.#shapeGroup(group, group.job_ids.map((jid) => this.view(jid, opts)).filter(Boolean), opts);
39357
39548
  }
39358
- async waitGroup(groupId, waitMs, opts) {
39549
+ async waitGroup(groupId, waitMs, opts, signal) {
39359
39550
  const group = this.groups.get(groupId);
39360
- if (!group) return this.#waitOnDisk(() => this.groupView(groupId, opts), waitMs);
39551
+ if (!group) return this.#waitOnDisk(() => this.groupView(groupId, opts), waitMs, signal);
39361
39552
  const budget = Math.min(Math.max(0, waitMs ?? 0), POLICY.maxWaitMs);
39362
39553
  if (budget > 0) {
39363
39554
  const promises = group.job_ids.map((jid) => this.jobs.get(jid)?.promise).filter(Boolean);
39364
- await Promise.race([
39365
- Promise.all(promises),
39366
- new Promise((r) => setTimeout(r, budget).unref?.())
39367
- ]);
39555
+ await settled(Promise.all(promises), budget, signal);
39368
39556
  }
39369
39557
  return this.groupView(groupId, opts);
39370
39558
  }
@@ -39403,11 +39591,11 @@ var JobManager = class {
39403
39591
  job._handle.stop();
39404
39592
  }
39405
39593
  }
39406
- const settled = Promise.all(live.map((j) => j.promise).filter(Boolean));
39407
- await Promise.race([settled, new Promise((r) => setTimeout(r, graceMs).unref?.())]);
39594
+ const settled2 = Promise.all(live.map((j) => j.promise).filter(Boolean));
39595
+ await Promise.race([settled2, new Promise((r) => setTimeout(r, graceMs).unref?.())]);
39408
39596
  for (const job of live) if (job._handle) handles.push(job._handle);
39409
39597
  for (const h of new Set(handles)) h.forceKill();
39410
- await Promise.race([settled, new Promise((r) => setTimeout(r, 1e3).unref?.())]);
39598
+ await Promise.race([settled2, new Promise((r) => setTimeout(r, 1e3).unref?.())]);
39411
39599
  }
39412
39600
  /** The synchronous last resort for process exit: no waiting is possible there. */
39413
39601
  killNow() {
@@ -39449,6 +39637,31 @@ function partialOutput(run) {
39449
39637
  note: "raw output stream of a consultant that was stopped at its time budget; unparsed and unverified, not an answer"
39450
39638
  };
39451
39639
  }
39640
+ async function settled(promise2, ms, signal) {
39641
+ let timer;
39642
+ let stop;
39643
+ const until = new Promise((resolve) => {
39644
+ stop = resolve;
39645
+ timer = setTimeout(resolve, ms);
39646
+ if (signal?.aborted) resolve();
39647
+ else signal?.addEventListener("abort", resolve, { once: true });
39648
+ });
39649
+ try {
39650
+ await (promise2 ? Promise.race([promise2, until]) : until);
39651
+ } finally {
39652
+ clearTimeout(timer);
39653
+ signal?.removeEventListener("abort", stop);
39654
+ }
39655
+ }
39656
+ function parses(text2) {
39657
+ try {
39658
+ normalizeResult(text2);
39659
+ return true;
39660
+ } catch (err) {
39661
+ if (err instanceof OutputError) return false;
39662
+ throw err;
39663
+ }
39664
+ }
39452
39665
  function recordableIds(result) {
39453
39666
  return [
39454
39667
  ...result.findings.map((f) => f.id),
@@ -39990,8 +40203,9 @@ function exportChain({ chain_id: chainId, group_id: groupId } = {}) {
39990
40203
  }
39991
40204
 
39992
40205
  // src/server.mjs
39993
- var SEVERALLY_VERSION = "2.0.0";
39994
- var DEFAULT_WAIT_MS = Math.min(POLICY.maxWaitMs, 45e3);
40206
+ var SEVERALLY_VERSION = "2.1.0";
40207
+ var DEFAULT_WAIT_MS = process.env.SEVERALLY_MAX_WAIT_MS ? POLICY.maxWaitMs : Math.min(POLICY.maxWaitMs, 45e3);
40208
+ var PROGRESS_EVERY_MS = 15e3;
39995
40209
  var semver = (v) => String(v).split(".").map(Number);
39996
40210
  var older = (a, b) => {
39997
40211
  const [x, y] = [semver(a), semver(b)];
@@ -40057,8 +40271,12 @@ ${availableConsultants().map((c) => ` ${c.name}: ${c.cli}, ${c.model}`)
40057
40271
  drift -- and the result is marked as same lineage.
40058
40272
  targets: ask several consultants the same question at once (mutually exclusive with target, no duplicates).
40059
40273
  targets: ["all"] asks every consultant listed above; "all" cannot be mixed with names.
40060
- Every member gets the byte-identical brief and one group_id; poll it with consult_get({ group_id }).
40061
- A follow-up (followup_to) always names one consultant -- fan-out is never available on a follow-up.
40274
+ Every member gets the byte-identical brief (on a follow-up, plus its own earlier rounds) and one
40275
+ group_id; poll it with consult_get({ group_id }). Each member takes one slot of the concurrency cap,
40276
+ and a fan-out that would go over it is refused before any member starts.
40277
+ A follow-up (followup_to) to a job names that one consultant. followup_to a group_id, with no target or
40278
+ targets, asks every member of that fan-out the same new question, each on its own chain from its latest
40279
+ round; a member that is still running, never answered or has used its rounds is skipped and listed.
40062
40280
  caller: optional -- the CLI you are running in ("codex" / "claude-code" / "antigravity" / "opencode"), so the
40063
40281
  server can annotate a same-vendor consultation.
40064
40282
  caller_model: optional -- the model you are running on (e.g. "claude-opus-5"), self-declared and never checked.
@@ -40082,7 +40300,9 @@ code only if you hand it over in this call. When the question is about code in a
40082
40300
  name the files it is about by absolute path in context.expose_paths: those files and directories are copied
40083
40301
  read-only into its working directory (under ./workspace) and are then the only part of your repository it has.
40084
40302
  At most 20 entries, 500 files and 5 MB in total; symlinks and protected files are skipped rather than copied,
40085
- and text files are credential-masked exactly as the brief is. A follow-up starts in a fresh working directory
40303
+ and text files are credential-masked exactly as the brief is. A directory inside a git work tree copies only the
40304
+ files git lists (tracked, or untracked and not ignored), so a repository root fits; outside git, .git and
40305
+ node_modules are skipped. A follow-up starts in a fresh working directory
40086
40306
  with no files: pass context.expose_from_parent: true to copy again, as they are now, the paths the round it follows
40087
40307
  exposed (a changed or vanished entry is marked; an earlier expose_diff brings back the files it touched, not the
40088
40308
  diff), and add expose_paths for anything new. It is never implied: leave it off when the follow-up does not need them. Put the facts it needs into context.facts, and keep
@@ -40149,7 +40369,7 @@ function createServer(manager = new JobManager()) {
40149
40369
  {
40150
40370
  title: "Get consultation status or result",
40151
40371
  annotations: { readOnlyHint: true, openWorldHint: false },
40152
- description: `Fetch the state of a consultation. It waits up to ${DEFAULT_WAIT_MS} ms for it to finish before answering, which stays under the request timeout MCP clients apply; wait_ms sets another wait, and wait_ms: 0 answers at once. A typical consultation takes one to five minutes, so expect to call this several times, and do something else in between. A completed job carries the structured answer; a failed one carries failure.kind (timeout, auth, usage_limit, rate_limited, model_unavailable, invalid_output, cli_error, spawn_error, interrupted) \u2014 that is "no advice was obtained", which is different from advice that arrived with thin evidence (see quality.evidence_basis). Pass group_id instead of job_id to fetch a fan-out; the wait then covers every consultant in it. A fan-out comes back sized to read in one go: each member is its status, failure and job_id, and \`comparison.by_target\` holds every consultant's stance, summary and findings (with their ids) side by side, cut to fixed lengths where \`truncated\` says so. consult_get({ job_id, ids: ["f2", "u1"] }) on a member returns just those points, each whole with its grounds and your verdict (\`recorded\`): fetch every point you mean to adopt, that decides your call, that rests on thin grounds, or that every consultant assumes -- not only where they diverge. consult_get({ job_id }) returns the whole answer. A finished consultation names its brief (size, sha256, round file) rather than repeating it; detail: "full" returns the brief, and for a fan-out every member's full answer.`,
40372
+ description: `Fetch the state of a consultation. It waits up to ${DEFAULT_WAIT_MS} ms for it to finish before answering, a limit set to stay under this client's tool-call timeout; wait_ms sets another wait, and wait_ms: 0 answers at once. While it waits it sends a progress notification every 15 s if you asked for them, and a call you cancel stops waiting. A typical consultation takes one to ten minutes; call this again while it runs, and pass a shorter wait_ms when you have independent work to do in between. A completed job carries the structured answer; a failed one carries failure.kind (timeout, auth, usage_limit, rate_limited, model_unavailable, invalid_output, cli_error, spawn_error, interrupted) \u2014 that is "no advice was obtained", which is different from advice that arrived with thin evidence (see quality.evidence_basis). Pass group_id instead of job_id to fetch a fan-out; the wait then covers every consultant in it. A fan-out comes back sized to read in one go: each member is its status, failure and job_id, and \`comparison.by_target\` holds every consultant's stance, summary and findings (with their ids) side by side, cut to fixed lengths where \`truncated\` says so. consult_get({ job_id, ids: ["f2", "u1"] }) on a member returns just those points, each whole with its grounds and your verdict (\`recorded\`): fetch every point you mean to adopt, that decides your call, that rests on thin grounds, or that every consultant assumes -- not only where they diverge. consult_get({ job_id }) returns the whole answer. A finished consultation names its brief (size, sha256, round file) rather than repeating it; detail: "full" returns the brief, and for a fan-out every member's full answer.`,
40153
40373
  inputSchema: external_exports.object({
40154
40374
  job_id: external_exports.string().min(1).optional(),
40155
40375
  group_id: external_exports.string().min(1).optional(),
@@ -40158,14 +40378,27 @@ function createServer(manager = new JobManager()) {
40158
40378
  ids: external_exports.array(external_exports.string().min(1)).min(1).max(100).optional().describe("with job_id: return only these points of the finished answer (f1, u1, c1 ...), each whole with its grounds and, under `recorded`, the verdict you wrote")
40159
40379
  })
40160
40380
  },
40161
- async ({ job_id, group_id, wait_ms, detail, ids }) => {
40381
+ async ({ job_id, group_id, wait_ms, detail, ids }, ctx) => {
40162
40382
  if (Boolean(job_id) === Boolean(group_id)) {
40163
40383
  return fail({ error: "invalid_request", message: "pass exactly one of job_id or group_id" });
40164
40384
  }
40165
40385
  if (ids && (group_id || detail)) {
40166
40386
  return fail({ error: "invalid_request", message: "ids goes with job_id alone: a point id belongs to one consultant's answer" });
40167
40387
  }
40168
- const view = group_id ? await manager.waitGroup(group_id, wait_ms ?? DEFAULT_WAIT_MS, { detail }) : await manager.wait(job_id, wait_ms ?? DEFAULT_WAIT_MS, { detail });
40388
+ const req = ctx?.mcpReq;
40389
+ const token = req?._meta?.progressToken;
40390
+ let beats = 0;
40391
+ const timer = token === void 0 ? null : setInterval(() => {
40392
+ beats += 1;
40393
+ req.notify({ method: "notifications/progress", params: { progressToken: token, progress: beats, message: "the consultation is still running" } }).catch(() => {
40394
+ });
40395
+ }, PROGRESS_EVERY_MS);
40396
+ let view;
40397
+ try {
40398
+ view = group_id ? await manager.waitGroup(group_id, wait_ms ?? DEFAULT_WAIT_MS, { detail }, req?.signal) : await manager.wait(job_id, wait_ms ?? DEFAULT_WAIT_MS, { detail }, req?.signal);
40399
+ } finally {
40400
+ clearInterval(timer);
40401
+ }
40169
40402
  if (!view) {
40170
40403
  return fail({ error: "unknown_job", message: `no consultation with ${group_id ? "group_id" : "job_id"} "${group_id ?? job_id}"` });
40171
40404
  }
@@ -40205,7 +40438,7 @@ function createServer(manager = new JobManager()) {
40205
40438
  {
40206
40439
  title: "Record what checking a point showed",
40207
40440
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
40208
- description: 'Write your own verdict against one or more points of an answer, after you have checked them in the repository. Ids come from the result: findings are f1, f2 ..., unknowns u1 ..., next_checks c1 ... . verdict says what checking showed -- "confirmed" (it holds here), "not_applicable" (true in general, not for this codebase), "unverifiable" (cannot be settled with what you can reach), "unverified" (not checked yet, and say in effect why not). It does not say whether you adopted the point. effect is what it changed about your decision; note is the evidence you used. A follow-up to this job shows the consultant each finding\'s verdict and effect (credential-masked, like the brief), never the note. Recording the same id again replaces that entry. This server stores what you write and counts the verdicts; it never infers one, and never decides a consultation was worth it. The entry is saved beside the answer and the brief in ~/.severally/history, which is what makes the decision readable a month from now; the job is read back from that history, so a consultation from an earlier session can still be recorded against. Pass `reflection` to record what the answer added over what you already expected, when the consultation was started with a `prediction`. A prediction itself cannot be written here: it goes in consult_start, before the consultant runs, which is the only thing that makes it a prediction. For a fan-out, record every member in one call: records: [{ job_id, entries, reflection? }, ...] in place of job_id/entries/reflection. Every record is checked first; if any job or id is wrong, nothing is written and each bad one is named. A job whose write fails is listed under results with its error, not counted as saved.',
40441
+ description: 'Write your own verdict against one or more points of an answer, after you have checked them in the repository. Ids come from the result: findings are f1, f2 ..., unknowns u1 ..., next_checks c1 ... . verdict says what checking showed -- "confirmed" (it holds here), "not_applicable" (true in general, not for this codebase), "unverifiable" (cannot be settled with what you can reach), "unverified" (not checked yet, and say in effect why not). It does not say whether you adopted the point. effect is what it changed about your decision; note is the evidence you used. A follow-up to this job shows the consultant each point\'s verdict and effect (credential-masked, like the brief), never the note. Recording the same id again replaces that entry. This server stores what you write and counts the verdicts; it never infers one, and never decides a consultation was worth it. The entry is saved beside the answer and the brief in ~/.severally/history, which is what makes the decision readable a month from now; the job is read back from that history, so a consultation from an earlier session can still be recorded against. It answers with counts (the verdicts tallied, and how many points now carry one); consult_get reads the entries back. Pass `reflection` to record what the answer added over what you already expected, when the consultation was started with a `prediction`. A prediction itself cannot be written here: it goes in consult_start, before the consultant runs, which is the only thing that makes it a prediction. For a fan-out, record every member in one call: records: [{ job_id, entries, reflection? }, ...] in place of job_id/entries/reflection. Every record is checked first; if any job or id is wrong, nothing is written and each bad one is named. A job whose write fails is listed under results with its error, not counted as saved.',
40209
40442
  inputSchema: external_exports.object({
40210
40443
  ...recordShape,
40211
40444
  job_id: recordShape.job_id.optional(),